blob: 4002db31b5b85995482fcf3ebffdbbcac7fa1eef [file] [log] [blame]
Keith Haddow58f36d12020-10-28 16:16:391#!/usr/bin/env python3
Luis Hector Chavezdca9dd72018-06-12 19:56:302# -*- coding: utf-8 -*-
Mike Frysinger8b0fc372022-09-08 07:24:243# Copyright 2009-2012 The ChromiumOS Authors
[email protected]ded22402009-10-26 22:36:214# Use of this source code is governed by a BSD-style license that can be
5# found in the LICENSE file.
6
Amin Hassani2aa34282020-11-18 01:18:197"""Chromium OS development server that can be used for all forms of update.
Chris Sosa3ae4dc12013-03-29 18:47:008
Amin Hassani2aa34282020-11-18 01:18:199This devserver can be used to perform system-wide autoupdate and update
10of specific portage packages on devices running Chromium OS derived operating
11systems.
12
13The devserver is configured to stage and
14serve artifacts from Google Storage using the credentials provided to it before
15it is run. The easiest way to understand this is that the devserver is
16functioning as a local cache for artifacts produced and uploaded by build
17servers. Users of this form of devserver can either download the artifacts
18from the devservers static directory OR use the update RPC to perform a
19system-wide autoupdate. Archive mode is always active.
20
21For autoupdates, there are many more advanced options that can help specify
22how to update and which payload to give to a requester.
Chris Sosa3ae4dc12013-03-29 18:47:0023"""
24
Gabe Black3b567202015-09-23 21:07:5925from __future__ import print_function
Chris Sosa7c931362010-10-12 02:49:0126
Gilad Arnold55a2a372012-10-02 16:46:3227import json
Chris McDonald771446e2021-08-05 20:23:0828import logging
Jack Rosenthal8de609d2023-02-09 20:20:3529from logging import handlers
David Riley2fcb0122017-11-02 18:25:3930import optparse # pylint: disable=deprecated-module
[email protected]ded22402009-10-26 22:36:2131import os
Scott Zawalski4647ce62012-01-03 22:17:2832import re
Simran Basi4baad082013-02-14 21:39:1833import shutil
Mandeep Singh Baines38dcdda2012-12-08 01:55:3334import socket
Chris Masone816e38c2012-05-02 19:22:3635import subprocess
J. Richard Barnette3d977b82013-04-23 18:05:1936import sys
Chris Masone816e38c2012-05-02 19:22:3637import tempfile
Dan Shi59ae7092013-06-04 21:37:2738import threading
Gilad Arnoldd5ebaaa2012-10-02 18:52:3839import types
J. Richard Barnette3d977b82013-04-23 18:05:1940
Jack Rosenthal8de609d2023-02-09 20:20:3541import autoupdate
Amin Hassanid4e35392019-10-03 18:02:4442
43# pylint: disable=no-name-in-module, import-error
J. Richard Barnette3d977b82013-04-23 18:05:1944import cherrypy
Chris Sosa855b8932013-08-21 20:24:5545from cherrypy import _cplogging as cplogging
Amin Hassanid4e35392019-10-03 18:02:4446from cherrypy.process import plugins
Achuith Bhandarkar662fb722019-10-31 23:12:4947import cherrypy_ext
Achuith Bhandarkar662fb722019-10-31 23:12:4948import health_checker
49
Richard Barnettedf35c322017-08-19 00:02:1350# This must happen before any local modules get a chance to import
51# anything from chromite. Otherwise, really bad things will happen, and
52# you will _not_ understand why.
Congbin Guo3afae6c2019-08-13 23:29:4253import setup_chromite # pylint: disable=unused-import
Jack Rosenthal8de609d2023-02-09 20:20:3554from six.moves import http_client
55
Eliot Courtneyf39420b2020-10-27 09:34:0456from chromite.lib import cros_build_lib
Achuith Bhandarkar662fb722019-10-31 23:12:4957from chromite.lib.xbuddy import android_build
58from chromite.lib.xbuddy import artifact_info
59from chromite.lib.xbuddy import build_artifact
Achuith Bhandarkar662fb722019-10-31 23:12:4960from chromite.lib.xbuddy import common_util
61from chromite.lib.xbuddy import devserver_constants
62from chromite.lib.xbuddy import downloader
63from chromite.lib.xbuddy import xbuddy
Gilad Arnoldc65330c2012-09-20 22:17:4864
Amin Hassani3587fb32021-04-28 17:10:0165
Jack Rosenthal8de609d2023-02-09 20:20:3566# pylint: enable=no-name-in-module, import-error
67
68
Gilad Arnoldc65330c2012-09-20 22:17:4869# Module-local log function.
Chris Sosa6a3697f2013-01-30 00:44:4370def _Log(message, *args):
Jack Rosenthal8de609d2023-02-09 20:20:3571 return logging.info(message, *args)
72
Frank Farzan40160872011-12-13 02:39:1873
Chris Sosa417e55d2011-01-26 00:40:4874CACHED_ENTRIES = 12
Don Garrettf90edf02010-11-17 01:36:1475
Jack Rosenthal8de609d2023-02-09 20:20:3576TELEMETRY_FOLDER = "telemetry_src"
77TELEMETRY_DEPS = [
78 "dep-telemetry_dep.tar.bz2",
79 "dep-page_cycler_dep.tar.bz2",
80 "dep-chrome_test.tar.bz2",
81 "dep-perf_data_dep.tar.bz2",
82]
Simran Basi4baad082013-02-14 21:39:1883
Amin Hassani2aa34282020-11-18 01:18:1984# Sets up global to share between classes.
85updater = None
86
xixuan3d48bff2017-01-31 03:00:0987# Log rotation parameters. These settings correspond to twice a day once
88# devserver is started, with about two weeks (28 backup files) of old logs
89# kept for backup.
J. Richard Barnette3d977b82013-04-23 18:05:1990#
xixuan3d48bff2017-01-31 03:00:0991# For more, see the documentation in standard python library for
J. Richard Barnette3d977b82013-04-23 18:05:1992# logging.handlers.TimedRotatingFileHandler
Jack Rosenthal8de609d2023-02-09 20:20:3593_LOG_ROTATION_TIME = "H"
Congbin Guo3afae6c2019-08-13 23:29:4294_LOG_ROTATION_INTERVAL = 12 # hours
95_LOG_ROTATION_BACKUP = 28 # backup counts
Frank Farzan40160872011-12-13 02:39:1896
Sanika Kulkarnid4496fd2020-02-05 01:26:2597# Error msg for deprecated RPC usage.
Jack Rosenthal8de609d2023-02-09 20:20:3598DEPRECATED_RPC_ERROR_MSG = (
99 "The %s RPC has been deprecated. Usage of this "
100 "RPC is discouraged. Please go to "
101 "go/devserver-deprecation for more information."
102)
Sanika Kulkarnid4496fd2020-02-05 01:26:25103
xixuan52c2fba2016-05-21 00:02:48104
Amin Hassanid4e35392019-10-03 18:02:44105class DevServerError(Exception):
Jack Rosenthal8de609d2023-02-09 20:20:35106 """Exception class used by DevServer."""
Amin Hassanid4e35392019-10-03 18:02:44107
108
Sanika Kulkarnid4496fd2020-02-05 01:26:25109class DeprecatedRPCError(DevServerError):
Jack Rosenthal8de609d2023-02-09 20:20:35110 """Exception class used when an RPC is deprecated but is still being used."""
Sanika Kulkarnid4496fd2020-02-05 01:26:25111
Jack Rosenthal8de609d2023-02-09 20:20:35112 def __init__(self, rpc_name):
113 """Constructor for DeprecatedRPCError class.
Sanika Kulkarnid4496fd2020-02-05 01:26:25114
Jack Rosenthal8de609d2023-02-09 20:20:35115 :param rpc_name: (str) name of the RPC that has been deprecated.
116 """
117 super(DeprecatedRPCError, self).__init__(
118 DEPRECATED_RPC_ERROR_MSG % rpc_name
119 )
120 self.rpc_name = rpc_name
Sanika Kulkarnid4496fd2020-02-05 01:26:25121
122
Amin Hassani722e0962019-11-15 23:45:31123class DevServerHTTPError(cherrypy.HTTPError):
Jack Rosenthal8de609d2023-02-09 20:20:35124 """Exception class to log the HTTPResponse before routing it to cherrypy."""
Amin Hassani722e0962019-11-15 23:45:31125
Jack Rosenthal8de609d2023-02-09 20:20:35126 def __init__(self, status, message):
127 """CherryPy error with logging.
128
129 Args:
130 status: HTTPResponse status.
131 message: Message associated with the response.
132 """
133 cherrypy.HTTPError.__init__(self, status, message)
134 _Log("HTTPError status: %s message: %s", status, message)
Amin Hassani722e0962019-11-15 23:45:31135
136
Gabe Black3b567202015-09-23 21:07:59137def _canonicalize_archive_url(archive_url):
Jack Rosenthal8de609d2023-02-09 20:20:35138 """Canonicalizes archive_url strings.
Gabe Black3b567202015-09-23 21:07:59139
Jack Rosenthal8de609d2023-02-09 20:20:35140 Raises:
141 DevserverError: if archive_url is not set.
142 """
143 if archive_url:
144 if not archive_url.startswith("gs://"):
145 raise DevServerError(
146 "Archive URL isn't from Google Storage (%s) ." % archive_url
147 )
Gabe Black3b567202015-09-23 21:07:59148
Jack Rosenthal8de609d2023-02-09 20:20:35149 return archive_url.rstrip("/")
150 else:
151 raise DevServerError("Must specify an archive_url in the request")
Gabe Black3b567202015-09-23 21:07:59152
153
Amin Hassani2aa34282020-11-18 01:18:19154def _canonicalize_local_path(local_path):
Jack Rosenthal8de609d2023-02-09 20:20:35155 """Canonicalizes |local_path| strings.
Gabe Black3b567202015-09-23 21:07:59156
Jack Rosenthal8de609d2023-02-09 20:20:35157 Raises:
158 DevserverError: if |local_path| is not set.
159 """
160 # Restrict staging of local content to only files within the static
161 # directory.
162 local_path = os.path.abspath(local_path)
163 if not local_path.startswith(updater.static_dir):
164 raise DevServerError(
165 "Local path %s must be a subdirectory of the static"
166 " directory: %s" % (local_path, updater.static_dir)
167 )
Gabe Black3b567202015-09-23 21:07:59168
Jack Rosenthal8de609d2023-02-09 20:20:35169 return local_path.rstrip("/")
Gabe Black3b567202015-09-23 21:07:59170
171
172def _get_artifacts(kwargs):
Jack Rosenthal8de609d2023-02-09 20:20:35173 """Returns a tuple of named and file artifacts given the stage rpc kwargs.
Gabe Black3b567202015-09-23 21:07:59174
Jack Rosenthal8de609d2023-02-09 20:20:35175 Raises:
176 DevserverError if no artifacts would be returned.
177 """
178 artifacts = kwargs.get("artifacts")
179 files = kwargs.get("files")
180 if not artifacts and not files:
181 raise DevServerError("No artifacts specified.")
Gabe Black3b567202015-09-23 21:07:59182
Jack Rosenthal8de609d2023-02-09 20:20:35183 # Note we NEED to coerce files to a string as we get raw unicode from
184 # cherrypy and we treat files as strings elsewhere in the code.
185 return (
186 str(artifacts).split(",") if artifacts else [],
187 str(files).split(",") if files else [],
188 )
Gabe Black3b567202015-09-23 21:07:59189
190
Dan Shi61305df2015-10-26 23:52:35191def _is_android_build_request(kwargs):
Jack Rosenthal8de609d2023-02-09 20:20:35192 """Check if a devserver call is for Android build, based on the arguments.
Dan Shi61305df2015-10-26 23:52:35193
Jack Rosenthal8de609d2023-02-09 20:20:35194 This method exams the request's arguments (os_type) to determine if the
195 request is for Android build. If os_type is set to `android`, returns True.
196 If os_type is not set or has other values, returns False.
Dan Shi61305df2015-10-26 23:52:35197
Jack Rosenthal8de609d2023-02-09 20:20:35198 Args:
199 kwargs: Keyword arguments for the request.
Dan Shi61305df2015-10-26 23:52:35200
Jack Rosenthal8de609d2023-02-09 20:20:35201 Returns:
202 True if the request is for Android build. False otherwise.
203 """
204 os_type = kwargs.get("os_type", None)
205 return os_type == "android"
Dan Shi61305df2015-10-26 23:52:35206
207
Amin Hassani2aa34282020-11-18 01:18:19208def _get_downloader(kwargs):
Jack Rosenthal8de609d2023-02-09 20:20:35209 """Returns the downloader based on passed in arguments.
Gabe Black3b567202015-09-23 21:07:59210
Jack Rosenthal8de609d2023-02-09 20:20:35211 Args:
212 kwargs: Keyword arguments for the request.
213 """
214 local_path = kwargs.get("local_path")
215 if local_path:
216 local_path = _canonicalize_local_path(local_path)
Gabe Black3b567202015-09-23 21:07:59217
Jack Rosenthal8de609d2023-02-09 20:20:35218 dl = None
219 if local_path:
220 delete_source = _parse_boolean_arg(kwargs, "delete_source")
221 dl = downloader.LocalDownloader(
222 updater.static_dir, local_path, delete_source=delete_source
223 )
Gabe Black3b567202015-09-23 21:07:59224
Jack Rosenthal8de609d2023-02-09 20:20:35225 if not _is_android_build_request(kwargs):
226 archive_url = kwargs.get("archive_url")
227 if not archive_url and not local_path:
228 raise DevServerError(
229 "Requires archive_url or local_path to be specified."
230 )
231 if archive_url and local_path:
232 raise DevServerError(
233 "archive_url and local_path can not both be specified."
234 )
235 if not dl:
236 archive_url = _canonicalize_archive_url(archive_url)
237 dl = downloader.GoogleStorageDownloader(
238 updater.static_dir,
239 archive_url,
240 downloader.GoogleStorageDownloader.GetBuildIdFromArchiveURL(
241 archive_url
242 ),
243 )
244 elif not dl:
245 target = kwargs.get("target", None)
246 branch = kwargs.get("branch", None)
247 build_id = kwargs.get("build_id", None)
248 if not target or not branch or not build_id:
249 raise DevServerError(
250 "target, branch, build ID must all be specified for "
251 "downloading Android build."
252 )
253 dl = downloader.AndroidBuildDownloader(
254 updater.static_dir, branch, build_id, target
255 )
Gabe Black3b567202015-09-23 21:07:59256
Jack Rosenthal8de609d2023-02-09 20:20:35257 return dl
Gabe Black3b567202015-09-23 21:07:59258
259
Amin Hassani2aa34282020-11-18 01:18:19260def _get_downloader_and_factory(kwargs):
Jack Rosenthal8de609d2023-02-09 20:20:35261 """Returns the downloader and artifact factory based on passed in arguments.
Gabe Black3b567202015-09-23 21:07:59262
Jack Rosenthal8de609d2023-02-09 20:20:35263 Args:
264 kwargs: Keyword arguments for the request.
265 """
266 artifacts, files = _get_artifacts(kwargs)
267 dl = _get_downloader(kwargs)
Gabe Black3b567202015-09-23 21:07:59268
Jack Rosenthal8de609d2023-02-09 20:20:35269 if isinstance(
270 dl, (downloader.GoogleStorageDownloader, downloader.LocalDownloader)
271 ):
272 factory_class = build_artifact.ChromeOSArtifactFactory
273 elif isinstance(dl, downloader.AndroidBuildDownloader):
274 factory_class = build_artifact.AndroidArtifactFactory
275 else:
276 raise DevServerError(
277 "Unrecognized value for downloader type: %s" % type(dl)
278 )
Gabe Black3b567202015-09-23 21:07:59279
Jack Rosenthal8de609d2023-02-09 20:20:35280 factory = factory_class(dl.GetBuildDir(), artifacts, files, dl.GetBuild())
Gabe Black3b567202015-09-23 21:07:59281
Jack Rosenthal8de609d2023-02-09 20:20:35282 return dl, factory
Gabe Black3b567202015-09-23 21:07:59283
284
Scott Zawalski4647ce62012-01-03 22:17:28285def _LeadingWhiteSpaceCount(string):
Jack Rosenthal8de609d2023-02-09 20:20:35286 """Count the amount of leading whitespace in a string.
Scott Zawalski4647ce62012-01-03 22:17:28287
Jack Rosenthal8de609d2023-02-09 20:20:35288 Args:
289 string: The string to count leading whitespace in.
Don Garrettf84631a2014-01-08 02:21:26290
Jack Rosenthal8de609d2023-02-09 20:20:35291 Returns:
292 number of white space chars before characters start.
293 """
294 matched = re.match(r"^\s+", string)
295 if matched:
296 return len(matched.group())
Scott Zawalski4647ce62012-01-03 22:17:28297
Jack Rosenthal8de609d2023-02-09 20:20:35298 return 0
Scott Zawalski4647ce62012-01-03 22:17:28299
300
301def _PrintDocStringAsHTML(func):
Jack Rosenthal8de609d2023-02-09 20:20:35302 """Make a functions docstring somewhat HTML style.
Scott Zawalski4647ce62012-01-03 22:17:28303
Jack Rosenthal8de609d2023-02-09 20:20:35304 Args:
305 func: The function to return the docstring from.
Don Garrettf84631a2014-01-08 02:21:26306
Jack Rosenthal8de609d2023-02-09 20:20:35307 Returns:
308 A string that is somewhat formated for a web browser.
309 """
310 # TODO(scottz): Make this parse Args/Returns in a prettier way.
311 # Arguments could be bolded and indented etc.
312 html_doc = []
313 for line in func.__doc__.splitlines():
314 leading_space = _LeadingWhiteSpaceCount(line)
315 if leading_space > 0:
316 line = " " * leading_space + line
Scott Zawalski4647ce62012-01-03 22:17:28317
Jack Rosenthal8de609d2023-02-09 20:20:35318 html_doc.append("<BR>%s" % line)
Scott Zawalski4647ce62012-01-03 22:17:28319
Jack Rosenthal8de609d2023-02-09 20:20:35320 return "\n".join(html_doc)
Scott Zawalski4647ce62012-01-03 22:17:28321
322
Simran Basief83d6a2014-08-28 21:32:01323def _GetUpdateTimestampHandler(static_dir):
Jack Rosenthal8de609d2023-02-09 20:20:35324 """Returns a handler to update directory staged.timestamp.
Simran Basief83d6a2014-08-28 21:32:01325
Jack Rosenthal8de609d2023-02-09 20:20:35326 This handler resets the stage.timestamp whenever static content is accessed.
Simran Basief83d6a2014-08-28 21:32:01327
Jack Rosenthal8de609d2023-02-09 20:20:35328 Args:
329 static_dir: Directory from which static content is being staged.
Simran Basief83d6a2014-08-28 21:32:01330
Jack Rosenthal8de609d2023-02-09 20:20:35331 Returns:
332 A cherrypy handler to update the timestamp of accessed content.
333 """
334
335 def UpdateTimestampHandler():
336 if not "404" in cherrypy.response.status:
337 build_match = re.match(
338 devserver_constants.STAGED_BUILD_REGEX,
339 cherrypy.request.path_info,
340 )
341 if build_match:
342 build_dir = os.path.join(static_dir, build_match.group("build"))
343 downloader.Downloader.TouchTimestampForStaged(build_dir)
344
345 return UpdateTimestampHandler
Simran Basief83d6a2014-08-28 21:32:01346
347
Chris Sosa7c931362010-10-12 02:49:01348def _GetConfig(options):
Jack Rosenthal8de609d2023-02-09 20:20:35349 """Returns the configuration for the devserver."""
Mandeep Singh Baines38dcdda2012-12-08 01:55:33350
Jack Rosenthal8de609d2023-02-09 20:20:35351 socket_host = "::"
352 # Fall back to IPv4 when python is not configured with IPv6.
353 if not socket.has_ipv6:
354 socket_host = "0.0.0.0"
Mandeep Singh Baines38dcdda2012-12-08 01:55:33355
Jack Rosenthal8de609d2023-02-09 20:20:35356 # Adds the UpdateTimestampHandler to cherrypy's tools. This tools executes
357 # on the on_end_resource hook. This hook is called once processing is
358 # complete and the response is ready to be returned.
359 cherrypy.tools.update_timestamp = cherrypy.Tool(
360 "on_end_resource", _GetUpdateTimestampHandler(options.static_dir)
361 )
Simran Basief83d6a2014-08-28 21:32:01362
Jack Rosenthal8de609d2023-02-09 20:20:35363 base_config = {
364 "global": {
365 "server.log_request_headers": True,
366 "server.protocol_version": "HTTP/1.1",
367 "server.socket_host": socket_host,
368 "server.socket_port": int(options.port),
369 "response.timeout": 6000,
370 "request.show_tracebacks": True,
371 "server.socket_timeout": 60,
372 "server.thread_pool": 2,
373 "engine.autoreload.on": False,
374 },
375 "/build": {
376 "response.timeout": 100000,
377 },
378 "/update": {
379 # Gets rid of cherrypy parsing post file for args.
380 "request.process_request_body": False,
381 "response.timeout": 10000,
382 },
383 # Sets up the static dir for file hosting.
384 "/static": {
385 "tools.staticdir.dir": options.static_dir,
386 "tools.staticdir.on": True,
387 "response.timeout": 10000,
388 "tools.update_timestamp.on": True,
389 },
390 }
391 if options.production:
392 base_config["global"].update({"server.thread_pool": 150})
Scott Zawalski1c5e7cd2012-02-27 18:12:52393
Jack Rosenthal8de609d2023-02-09 20:20:35394 return base_config
[email protected]64244662009-11-12 00:52:08395
Darin Petkove17164a2010-08-11 20:24:41396
Gilad Arnoldd5ebaaa2012-10-02 18:52:38397def _GetRecursiveMemberObject(root, member_list):
Jack Rosenthal8de609d2023-02-09 20:20:35398 """Returns an object corresponding to a nested member list.
Gilad Arnoldd5ebaaa2012-10-02 18:52:38399
Jack Rosenthal8de609d2023-02-09 20:20:35400 Args:
401 root: the root object to search
402 member_list: list of nested members to search
Don Garrettf84631a2014-01-08 02:21:26403
Jack Rosenthal8de609d2023-02-09 20:20:35404 Returns:
405 An object corresponding to the member name list; None otherwise.
406 """
407 for member in member_list:
408 next_root = root.__class__.__dict__.get(member)
409 if not next_root:
410 return None
411 root = next_root
412 return root
Gilad Arnoldd5ebaaa2012-10-02 18:52:38413
414
415def _IsExposed(name):
Jack Rosenthal8de609d2023-02-09 20:20:35416 """Returns True iff |name| has an `exposed' attribute and it is set."""
417 return hasattr(name, "exposed") and name.exposed
Gilad Arnoldd5ebaaa2012-10-02 18:52:38418
419
Congbin Guo6bc32182019-08-21 00:54:30420def _GetExposedMethod(nested_member):
Jack Rosenthal8de609d2023-02-09 20:20:35421 """Returns a CherryPy-exposed method, if such exists.
Gilad Arnoldd5ebaaa2012-10-02 18:52:38422
Jack Rosenthal8de609d2023-02-09 20:20:35423 Args:
424 nested_member: a slash-joined path to the nested member
Don Garrettf84631a2014-01-08 02:21:26425
Jack Rosenthal8de609d2023-02-09 20:20:35426 Returns:
427 A function object corresponding to the path defined by |nested_member| from
428 the app root object registered, if the function is exposed; None otherwise.
429 """
430 for app in cherrypy.tree.apps.values():
431 # Use the 'index' function doc as the doc of the app.
432 if nested_member == app.script_name.lstrip("/"):
433 nested_member = "index"
Congbin Guo6bc32182019-08-21 00:54:30434
Jack Rosenthal8de609d2023-02-09 20:20:35435 method = _GetRecursiveMemberObject(app.root, nested_member.split("/"))
436 if (
437 method
438 and isinstance(method, types.FunctionType)
439 and _IsExposed(method)
440 ):
441 return method
Gilad Arnoldd5ebaaa2012-10-02 18:52:38442
443
Gilad Arnold748c8322012-10-12 16:51:35444def _FindExposedMethods(root, prefix, unlisted=None):
Jack Rosenthal8de609d2023-02-09 20:20:35445 """Finds exposed CherryPy methods.
Gilad Arnoldd5ebaaa2012-10-02 18:52:38446
Jack Rosenthal8de609d2023-02-09 20:20:35447 Args:
448 root: the root object for searching
449 prefix: slash-joined chain of members leading to current object
450 unlisted: URLs to be excluded regardless of their exposed status
Don Garrettf84631a2014-01-08 02:21:26451
Jack Rosenthal8de609d2023-02-09 20:20:35452 Returns:
453 List of exposed URLs that are not unlisted.
454 """
455 method_list = []
456 for member in root.__class__.__dict__.keys():
457 prefixed_member = prefix + "/" + member if prefix else member
458 if unlisted and prefixed_member in unlisted:
459 continue
460 member_obj = root.__class__.__dict__[member]
461 if _IsExposed(member_obj):
462 if isinstance(member_obj, types.FunctionType):
463 # Regard the app name as exposed "method" name if it exposed 'index'
464 # function.
465 if prefix and member == "index":
466 method_list.append(prefix)
467 else:
468 method_list.append(prefixed_member)
469 else:
470 method_list += _FindExposedMethods(
471 member_obj, prefixed_member, unlisted
472 )
473 return method_list
Gilad Arnoldd5ebaaa2012-10-02 18:52:38474
475
xixuan52c2fba2016-05-21 00:02:48476def _parse_boolean_arg(kwargs, key):
Jack Rosenthal8de609d2023-02-09 20:20:35477 """Parse boolean arg from kwargs.
xixuanac89ce82016-12-01 00:48:20478
Jack Rosenthal8de609d2023-02-09 20:20:35479 Args:
480 kwargs: the parameters to be checked.
481 key: the key to be parsed.
xixuanac89ce82016-12-01 00:48:20482
Jack Rosenthal8de609d2023-02-09 20:20:35483 Returns:
484 The boolean value of kwargs[key], or False if key doesn't exist in kwargs.
xixuanac89ce82016-12-01 00:48:20485
Jack Rosenthal8de609d2023-02-09 20:20:35486 Raises:
487 DevServerHTTPError if kwargs[key] is not a boolean variable.
488 """
489 if key in kwargs:
490 if kwargs[key] == "True":
491 return True
492 elif kwargs[key] == "False":
493 return False
494 else:
495 raise DevServerHTTPError(
496 http_client.INTERNAL_SERVER_ERROR,
497 "The value for key %s is not boolean." % key,
498 )
xixuan52c2fba2016-05-21 00:02:48499 else:
Jack Rosenthal8de609d2023-02-09 20:20:35500 return False
xixuan52c2fba2016-05-21 00:02:48501
xixuan447ad9d2017-02-28 22:46:20502
xixuanac89ce82016-12-01 00:48:20503def _parse_string_arg(kwargs, key):
Jack Rosenthal8de609d2023-02-09 20:20:35504 """Parse string arg from kwargs.
xixuanac89ce82016-12-01 00:48:20505
Jack Rosenthal8de609d2023-02-09 20:20:35506 Args:
507 kwargs: the parameters to be checked.
508 key: the key to be parsed.
xixuanac89ce82016-12-01 00:48:20509
Jack Rosenthal8de609d2023-02-09 20:20:35510 Returns:
511 The string value of kwargs[key], or None if key doesn't exist in kwargs.
512 """
513 if key in kwargs:
514 return kwargs[key]
515 else:
516 return None
xixuanac89ce82016-12-01 00:48:20517
xixuan447ad9d2017-02-28 22:46:20518
xixuanac89ce82016-12-01 00:48:20519def _build_uri_from_build_name(build_name):
Jack Rosenthal8de609d2023-02-09 20:20:35520 """Get build url from a given build name.
xixuanac89ce82016-12-01 00:48:20521
Jack Rosenthal8de609d2023-02-09 20:20:35522 Args:
523 build_name: the build name to be parsed, whose format is
524 'board/release_version'.
xixuanac89ce82016-12-01 00:48:20525
Jack Rosenthal8de609d2023-02-09 20:20:35526 Returns:
527 The release_archive_url on Google Storage for this build name.
528 """
529 # TODO(ahassani): This function doesn't seem to be used anywhere since its
530 # previous use of lib.paygen.gspath was broken and it doesn't seem to be
531 # causing any runtime issues. So deprecate this in the future.
532 tokens = build_name.split("/")
533 return "gs://chromeos-releases/stable-channel/%s/%s" % (
534 tokens[0],
535 tokens[1],
536 )
xixuan52c2fba2016-05-21 00:02:48537
xixuan447ad9d2017-02-28 22:46:20538
Sanika Kulkarnid4496fd2020-02-05 01:26:25539def is_deprecated_server():
Jack Rosenthal8de609d2023-02-09 20:20:35540 """Gets whether the devserver has deprecated RPCs."""
541 return cherrypy.config.get("infra_removal", False)
Sanika Kulkarnid4496fd2020-02-05 01:26:25542
543
David Rochberg7c79a812011-01-19 19:24:45544class DevServerRoot(object):
Jack Rosenthal8de609d2023-02-09 20:20:35545 """The Root Class for the Dev Server.
Chris Sosa7c931362010-10-12 02:49:01546
Jack Rosenthal8de609d2023-02-09 20:20:35547 CherryPy works as follows:
548 For each method in this class, cherrpy interprets root/path
549 as a call to an instance of DevServerRoot->method_name. For example,
550 a call to http://myhost/build will call build. CherryPy automatically
551 parses http args and places them as keyword arguments in each method.
552 For paths http://myhost/update/dir1/dir2, you can use *args so that
553 cherrypy uses the update method and puts the extra paths in args.
Dan Shif8eb0d12013-08-02 00:52:06554 """
Dan Shi59ae7092013-06-04 21:37:27555
Jack Rosenthal8de609d2023-02-09 20:20:35556 # Method names that should not be listed on the index page.
557 _UNLISTED_METHODS = ["index", "doc"]
Prashanth Ba06d2d22014-03-07 23:35:19558
Jack Rosenthal8de609d2023-02-09 20:20:35559 # Number of threads that devserver is staging images.
560 _staging_thread_count = 0
561 # Lock used to lock increasing/decreasing count.
562 _staging_thread_count_lock = threading.Lock()
Prashanth Ba06d2d22014-03-07 23:35:19563
Jack Rosenthal8de609d2023-02-09 20:20:35564 def __init__(self, _xbuddy):
565 self._builder = None
566 self._telemetry_lock_dict = common_util.LockDict()
567 self._xbuddy = _xbuddy
Congbin Guo3afae6c2019-08-13 23:29:42568
Jack Rosenthal8de609d2023-02-09 20:20:35569 @property
570 def staging_thread_count(self):
571 """Get the staging thread count."""
572 return self._staging_thread_count
Prashanth Ba06d2d22014-03-07 23:35:19573
Jack Rosenthal8de609d2023-02-09 20:20:35574 @cherrypy.expose
575 def build(self, board, pkg, **kwargs):
576 """Builds the package specified."""
577 if is_deprecated_server():
578 raise DeprecatedRPCError("build")
Chris Sosa76e44b92013-01-31 20:11:38579
Jack Rosenthal8de609d2023-02-09 20:20:35580 import builder
Chris Sosa76e44b92013-01-31 20:11:38581
Jack Rosenthal8de609d2023-02-09 20:20:35582 if self._builder is None:
583 self._builder = builder.Builder()
584 return self._builder.Build(board, pkg, kwargs)
Chris Sosa76e44b92013-01-31 20:11:38585
Jack Rosenthal8de609d2023-02-09 20:20:35586 @cherrypy.expose
587 def is_staged(self, **kwargs):
588 """Check if artifacts have been downloaded.
Chris Sosa76e44b92013-01-31 20:11:38589
Jack Rosenthal8de609d2023-02-09 20:20:35590 Examples:
591 To check if autotest and test_suites are staged:
592 http://devserver_url:<port>/is_staged?archive_url=gs://your_url/path&
593 artifacts=autotest,test_suites
Chris Sosa76e44b92013-01-31 20:11:38594
Jack Rosenthal8de609d2023-02-09 20:20:35595 Args:
596 async: True to return without waiting for download to complete.
597 artifacts: Comma separated list of named artifacts to download.
598 These are defined in artifact_info and have their implementation
599 in build_artifact.py.
600 files: Comma separated list of file artifacts to stage. These
601 will be available as is in the corresponding static directory with no
602 custom post-processing.
Chris Sosa76e44b92013-01-31 20:11:38603
Jack Rosenthal8de609d2023-02-09 20:20:35604 Returns:
605 True of all artifacts are staged.
606 """
607 dl, factory = _get_downloader_and_factory(kwargs)
608 response = str(dl.IsStaged(factory))
609 _Log("Responding to is_staged %s request with %r", kwargs, response)
610 return response
Chris Sosa76e44b92013-01-31 20:11:38611
Jack Rosenthal8de609d2023-02-09 20:20:35612 @cherrypy.expose
613 def list_image_dir(self, **kwargs):
614 """Take an archive url and list the contents in its staged directory.
Chris Sosa76e44b92013-01-31 20:11:38615
Jack Rosenthal8de609d2023-02-09 20:20:35616 Examples:
617 To list the contents of where this devserver should have staged
618 gs://image-archive/<board>-release/<build> call:
619 http://devserver_url:<port>/list_image_dir?archive_url=<gs://..>
Congbin Guo3afae6c2019-08-13 23:29:42620
Jack Rosenthal8de609d2023-02-09 20:20:35621 Args:
622 archive_url: Google Storage URL for the build.
Gabe Black3b567202015-09-23 21:07:59623
Jack Rosenthal8de609d2023-02-09 20:20:35624 Returns:
625 A string with information about the contents of the image directory.
626 """
627 dl = _get_downloader(kwargs)
Simran Basi4baad082013-02-14 21:39:18628 try:
Jack Rosenthal8de609d2023-02-09 20:20:35629 image_dir_contents = dl.ListBuildDir()
630 except build_artifact.ArtifactDownloadError as e:
631 return "Cannot list the contents of staged artifacts. %s" % e
632 if not image_dir_contents:
633 return (
634 "%s has not been staged on this devserver."
635 % dl.DescribeSource()
636 )
637 return image_dir_contents
Simran Basi4baad082013-02-14 21:39:18638
Jack Rosenthal8de609d2023-02-09 20:20:35639 @cherrypy.expose
640 def stage(self, **kwargs):
641 """Downloads and caches build artifacts.
642
643 Downloads and caches build artifacts, possibly from a Google Storage URL,
644 or from Android's build server. Returns once these have been downloaded
645 on the devserver. A call to this will attempt to cache non-specified
646 artifacts in the background for the given from the given URL following
647 the principle of spatial locality. Spatial locality of different
648 artifacts is explicitly defined in the build_artifact module.
649
650 These artifacts will then be available from the static/ sub-directory of
651 the devserver.
652
653 Examples:
654 To download the autotest and test suites tarballs:
655 http://devserver_url:<port>/stage?archive_url=gs://your_url/path&
656 artifacts=autotest,test_suites
657 To download the full update payload:
658 http://devserver_url:<port>/stage?archive_url=gs://your_url/path&
659 artifacts=full_payload
660 To download just a file called blah.bin:
661 http://devserver_url:<port>/stage?archive_url=gs://your_url/path&
662 files=blah.bin
663
664 For both these examples, one could find these artifacts at:
665 http://devserver_url:<port>/static/<relative_path>*
666
667 Note for this example, relative path is the archive_url stripped of its
668 basename i.e. path/ in the examples above. Specific example:
669
670 gs://chromeos-image-archive/x86-mario-release/R26-3920.0.0
671
672 Will get staged to:
673
674 http://devserver_url:<port>/static/x86-mario-release/R26-3920.0.0
675
676 Args:
677 archive_url: Google Storage URL for the build.
678 local_path: Local path for the build.
679 delete_source: Only meaningful with local_path. bool to indicate if the
680 source files should be deleted. This is especially useful when staging
681 a file locally in resource constrained environments as it allows us to
682 move the relevant files locally instead of copying them.
683 async: True to return without waiting for download to complete.
684 artifacts: Comma separated list of named artifacts to download.
685 These are defined in artifact_info and have their implementation
686 in build_artifact.py.
687 files: Comma separated list of files to stage. These
688 will be available as is in the corresponding static directory with no
689 custom post-processing.
690 clean: True to remove any previously staged artifacts first.
691 """
692 dl, factory = _get_downloader_and_factory(kwargs)
693
694 with DevServerRoot._staging_thread_count_lock:
695 DevServerRoot._staging_thread_count += 1
696 try:
697 boolean_string = kwargs.get("clean")
698 clean = xbuddy.XBuddy.ParseBoolean(boolean_string)
699 if clean and os.path.exists(dl.GetBuildDir()):
700 _Log("Removing %s" % dl.GetBuildDir())
701 shutil.rmtree(dl.GetBuildDir())
702 dl.Download(factory)
703 finally:
704 with DevServerRoot._staging_thread_count_lock:
705 DevServerRoot._staging_thread_count -= 1
706 return "Success"
707
708 @cherrypy.expose
709 def locate_file(self, **kwargs):
710 """Get the path to the given file name.
711
712 This method looks up the given file name inside specified build artifacts.
713 One use case is to help caller to locate an apk file inside a build
714 artifact. The location of the apk file could be different based on the
715 branch and target.
716
717 Args:
718 file_name: Name of the file to look for.
719 artifacts: A list of artifact names to search for the file.
720
721 Returns:
722 Path to the file with the given name. It's relative to the folder for the
723 build, e.g., DATA/priv-app/sl4a/sl4a.apk
724 """
725 if is_deprecated_server():
726 raise DeprecatedRPCError("locate_file")
727
728 dl, _ = _get_downloader_and_factory(kwargs)
729 try:
730 file_name = kwargs["file_name"]
731 artifacts = kwargs["artifacts"]
732 except KeyError:
733 raise DevServerError(
734 "`file_name` and `artifacts` are required to search "
735 "for a file in build artifacts."
736 )
737 build_path = dl.GetBuildDir()
738 for artifact in artifacts:
739 # Get the unzipped folder of the artifact. If it's not defined in
740 # ARTIFACT_UNZIP_FOLDER_MAP, assume the files are unzipped to the build
741 # directory directly.
742 folder = artifact_info.ARTIFACT_UNZIP_FOLDER_MAP.get(artifact, "")
743 artifact_path = os.path.join(build_path, folder)
744 for root, _, filenames in os.walk(artifact_path):
745 if file_name in set([f for f in filenames]):
746 return os.path.relpath(
747 os.path.join(root, file_name), build_path
748 )
Amin Hassanid4e35392019-10-03 18:02:44749 raise DevServerError(
Jack Rosenthal8de609d2023-02-09 20:20:35750 "File `%s` can not be found in artifacts: %s"
751 % (file_name, artifacts)
752 )
Simran Basi4baad082013-02-14 21:39:18753
Jack Rosenthal8de609d2023-02-09 20:20:35754 @cherrypy.expose
755 def setup_telemetry(self, **kwargs):
756 """Extracts and sets up telemetry
Simran Basi4baad082013-02-14 21:39:18757
Jack Rosenthal8de609d2023-02-09 20:20:35758 This method goes through the telemetry deps packages, and stages them on
759 the devserver to be used by the drones and the telemetry tests.
Chris Masone816e38c2012-05-02 19:22:36760
Jack Rosenthal8de609d2023-02-09 20:20:35761 Args:
762 archive_url: Google Storage URL for the build.
Chris Masone816e38c2012-05-02 19:22:36763
Jack Rosenthal8de609d2023-02-09 20:20:35764 Returns:
765 Path to the source folder for the telemetry codebase once it is staged.
766 """
767 dl = _get_downloader(kwargs)
Sanika Kulkarnid4496fd2020-02-05 01:26:25768
Jack Rosenthal8de609d2023-02-09 20:20:35769 build_path = dl.GetBuildDir()
770 deps_path = os.path.join(build_path, "autotest/packages")
771 telemetry_path = os.path.join(build_path, TELEMETRY_FOLDER)
772 src_folder = os.path.join(telemetry_path, "src")
Dan Shif08fe492016-10-04 21:39:25773
Jack Rosenthal8de609d2023-02-09 20:20:35774 with self._telemetry_lock_dict.lock(telemetry_path):
775 if os.path.exists(src_folder):
776 # Telemetry is already fully stage return
777 return src_folder
Chris Sosa76e44b92013-01-31 20:11:38778
Jack Rosenthal8de609d2023-02-09 20:20:35779 common_util.MkDirP(telemetry_path)
Chris Sosa76e44b92013-01-31 20:11:38780
Jack Rosenthal8de609d2023-02-09 20:20:35781 # Copy over the required deps tar balls to the telemetry directory.
782 for dep in TELEMETRY_DEPS:
783 dep_path = os.path.join(deps_path, dep)
784 if not os.path.exists(dep_path):
785 # This dep does not exist (could be new), do not extract it.
786 continue
787 try:
788 cros_build_lib.ExtractTarball(dep_path, telemetry_path)
789 except cros_build_lib.TarballError as e:
790 shutil.rmtree(telemetry_path)
791 raise DevServerError(str(e))
Chris Sosa76e44b92013-01-31 20:11:38792
Jack Rosenthal8de609d2023-02-09 20:20:35793 # By default all the tarballs extract to test_src but some parts of
794 # the telemetry code specifically hardcoded to exist inside of 'src'.
795 test_src = os.path.join(telemetry_path, "test_src")
796 try:
797 shutil.move(test_src, src_folder)
798 except shutil.Error:
799 # This can occur if src_folder already exists. Remove and retry move.
800 shutil.rmtree(src_folder)
801 raise DevServerError(
802 "Failure in telemetry setup for build %s. Appears that the "
803 "test_src to src move failed." % dl.GetBuild()
804 )
Chris Sosa76e44b92013-01-31 20:11:38805
Jack Rosenthal8de609d2023-02-09 20:20:35806 return src_folder
Chris Sosa76e44b92013-01-31 20:11:38807
Jack Rosenthal8de609d2023-02-09 20:20:35808 @cherrypy.expose
809 def symbolicate_dump(self, minidump, **kwargs):
810 """Symbolicates a minidump using pre-downloaded symbols, returns it.
Chris Masone816e38c2012-05-02 19:22:36811
Jack Rosenthal8de609d2023-02-09 20:20:35812 Callers will need to POST to this URL with a body of MIME-type
813 "multipart/form-data".
814 The body should include a single argument, 'minidump', containing the
815 binary-formatted minidump to symbolicate.
Chris Masone816e38c2012-05-02 19:22:36816
Jack Rosenthal8de609d2023-02-09 20:20:35817 Args:
818 archive_url: Google Storage URL for the build.
819 minidump: The binary minidump file to symbolicate.
820 """
821 if is_deprecated_server():
822 raise DeprecatedRPCError("symbolicate_dump")
Scott Zawalski16954532012-03-20 19:31:36823
Jack Rosenthal8de609d2023-02-09 20:20:35824 # Ensure the symbols have been staged.
825 # Try debug.tar.xz first, then debug.tgz
826 for artifact in (artifact_info.SYMBOLS_ONLY, artifact_info.SYMBOLS):
827 kwargs["artifacts"] = artifact
828 dl = _get_downloader(kwargs)
Don Garrettf84631a2014-01-08 02:21:26829
Jack Rosenthal8de609d2023-02-09 20:20:35830 try:
831 if self.stage(**kwargs) == "Success":
832 break
833 except build_artifact.ArtifactDownloadError:
834 continue
835 else:
836 raise DevServerError(
837 "Failed to stage symbols for %s" % dl.DescribeSource()
838 )
Sanika Kulkarni07d47ed2020-08-06 21:56:46839
Jack Rosenthal8de609d2023-02-09 20:20:35840 to_return = ""
841 with tempfile.NamedTemporaryFile() as local:
842 while True:
843 data = minidump.file.read(8192)
844 if not data:
845 break
846 local.write(data)
Scott Zawalski16954532012-03-20 19:31:36847
Jack Rosenthal8de609d2023-02-09 20:20:35848 local.flush()
Dan Shi61305df2015-10-26 23:52:35849
Jack Rosenthal8de609d2023-02-09 20:20:35850 symbols_directory = os.path.join(
851 dl.GetBuildDir(), "debug", "breakpad"
852 )
Dan Shi61305df2015-10-26 23:52:35853
Jack Rosenthal8de609d2023-02-09 20:20:35854 # The location of minidump_stackwalk is defined in chromeos-admin.
855 stackwalk = subprocess.Popen(
856 [
857 "/usr/local/bin/minidump_stackwalk",
858 local.name,
859 symbols_directory,
860 ],
861 stdout=subprocess.PIPE,
862 stderr=subprocess.PIPE,
863 )
Scott Zawalski16954532012-03-20 19:31:36864
Jack Rosenthal8de609d2023-02-09 20:20:35865 to_return, error_text = stackwalk.communicate()
866 if stackwalk.returncode != 0:
867 raise DevServerError(
868 "Can't generate stack trace: %s (rc=%d)"
869 % (error_text, stackwalk.returncode)
870 )
xixuan7efd0002016-04-14 22:34:01871
Jack Rosenthal8de609d2023-02-09 20:20:35872 return to_return
xixuan7efd0002016-04-14 22:34:01873
Jack Rosenthal8de609d2023-02-09 20:20:35874 @cherrypy.expose
875 def latestbuild(self, **kwargs):
876 """Return a string representing the latest build for a given target.
xixuan7efd0002016-04-14 22:34:01877
Jack Rosenthal8de609d2023-02-09 20:20:35878 Args:
879 target: The build target, typically a combination of the board and the
880 type of build e.g. x86-mario-release.
881 milestone: The milestone to filter builds on. E.g. R16. Optional, if not
882 provided the latest RXX build will be returned.
Sanika Kulkarnid4496fd2020-02-05 01:26:25883
Jack Rosenthal8de609d2023-02-09 20:20:35884 Returns:
885 A string representation of the latest build if one exists, i.e.
886 R19-1993.0.0-a1-b1480.
887 An empty string if no latest could be found.
888 """
889 if is_deprecated_server():
890 raise DeprecatedRPCError("latestbuild")
xixuan7efd0002016-04-14 22:34:01891
Jack Rosenthal8de609d2023-02-09 20:20:35892 if not kwargs:
893 return _PrintDocStringAsHTML(self.latestbuild)
xixuan7efd0002016-04-14 22:34:01894
Jack Rosenthal8de609d2023-02-09 20:20:35895 if "target" not in kwargs:
896 raise DevServerHTTPError(
897 http_client.INTERNAL_SERVER_ERROR, "Error: target= is required!"
898 )
xixuan7efd0002016-04-14 22:34:01899
Jack Rosenthal8de609d2023-02-09 20:20:35900 if _is_android_build_request(kwargs):
901 branch = kwargs.get("branch", None)
902 target = kwargs.get("target", None)
903 if not target or not branch:
904 raise DevServerError(
905 "Both target and branch must be specified to query"
906 " for the latest Android build."
907 )
908 return android_build.BuildAccessor.GetLatestBuildID(target, branch)
xixuan7efd0002016-04-14 22:34:01909
Jack Rosenthal8de609d2023-02-09 20:20:35910 try:
911 return common_util.GetLatestBuildVersion(
912 updater.static_dir,
913 kwargs["target"],
914 milestone=kwargs.get("milestone"),
915 )
916 except common_util.CommonUtilError as errmsg:
917 raise DevServerHTTPError(
918 http_client.INTERNAL_SERVER_ERROR, str(errmsg)
919 )
xixuan7efd0002016-04-14 22:34:01920
Jack Rosenthal8de609d2023-02-09 20:20:35921 @cherrypy.expose
922 def list_suite_controls(self, **kwargs):
923 """Return a list of contents of all known control files.
xixuan7efd0002016-04-14 22:34:01924
Jack Rosenthal8de609d2023-02-09 20:20:35925 Example URL:
926 To List all control files' content:
927 http://dev-server/list_suite_controls?suite_name=bvt&
928 build=daisy_spring-release/R29-4279.0.0
Scott Zawalski4647ce62012-01-03 22:17:28929
Jack Rosenthal8de609d2023-02-09 20:20:35930 Args:
931 build: The build i.e. x86-alex-release/R18-1514.0.0-a1-b1450.
932 suite_name: List the control files belonging to that suite.
Scott Zawalski4647ce62012-01-03 22:17:28933
Jack Rosenthal8de609d2023-02-09 20:20:35934 Returns:
935 A dictionary of all control files's path to its content for given suite.
936 """
937 if is_deprecated_server():
938 raise DeprecatedRPCError("list_suite_controls")
Don Garrettf84631a2014-01-08 02:21:26939
Jack Rosenthal8de609d2023-02-09 20:20:35940 if not kwargs:
941 return _PrintDocStringAsHTML(self.controlfiles)
Sanika Kulkarnid4496fd2020-02-05 01:26:25942
Jack Rosenthal8de609d2023-02-09 20:20:35943 if "build" not in kwargs:
944 raise DevServerHTTPError(
945 http_client.INTERNAL_SERVER_ERROR, "Error: build= is required!"
946 )
947
948 if "suite_name" not in kwargs:
949 raise DevServerHTTPError(
950 http_client.INTERNAL_SERVER_ERROR,
951 "Error: suite_name= is required!",
952 )
953
954 control_file_list = [
955 line.rstrip()
956 for line in common_util.GetControlFileListForSuite(
957 updater.static_dir, kwargs["build"], kwargs["suite_name"]
958 ).splitlines()
959 ]
960
961 control_file_content_dict = {}
962 for control_path in control_file_list:
963 control_file_content_dict[
964 control_path
965 ] = common_util.GetControlFile(
966 updater.static_dir, kwargs["build"], control_path
967 )
968
969 return json.dumps(control_file_content_dict)
970
971 @cherrypy.expose
972 def controlfiles(self, **kwargs):
973 """Return a control file or a list of all known control files.
974
975 Example URL:
976 To List all control files:
977 http://dev-server/controlfiles?suite_name=&build=daisy_spring-release/R29-4279.0.0
978 To List all control files for, say, the bvt suite:
979 http://dev-server/controlfiles?suite_name=bvt&build=daisy_spring-release/R29-4279.0.0
980 To return the contents of a path:
981 http://dev-server/controlfiles?board=x86-alex-release&build=R18-1514.0.0&control_path=client/sleeptest/control
982
983 Args:
984 build: The build i.e. x86-alex-release/R18-1514.0.0-a1-b1450.
985 control_path: If you want the contents of a control file set this
986 to the path. E.g. client/site_tests/sleeptest/control
987 Optional, if not provided return a list of control files is returned.
988 suite_name: If control_path is not specified but a suite_name is
989 specified, list the control files belonging to that suite instead of
990 all control files. The empty string for suite_name will list all control
991 files for the build.
992
993 Returns:
994 Contents of a control file if control_path is provided.
995 A list of control files if no control_path is provided.
996 """
997 if is_deprecated_server():
998 raise DeprecatedRPCError("controlfiles")
999
1000 if not kwargs:
1001 return _PrintDocStringAsHTML(self.controlfiles)
Scott Zawalski4647ce62012-01-03 22:17:281002
Jack Rosenthal8de609d2023-02-09 20:20:351003 if "build" not in kwargs:
1004 raise DevServerHTTPError(
1005 http_client.INTERNAL_SERVER_ERROR, "Error: build= is required!"
1006 )
Scott Zawalski4647ce62012-01-03 22:17:281007
Jack Rosenthal8de609d2023-02-09 20:20:351008 if "control_path" not in kwargs:
1009 if "suite_name" in kwargs and kwargs["suite_name"]:
1010 return common_util.GetControlFileListForSuite(
1011 updater.static_dir, kwargs["build"], kwargs["suite_name"]
1012 )
1013 else:
1014 return common_util.GetControlFileList(
1015 updater.static_dir, kwargs["build"]
1016 )
1017 else:
1018 return common_util.GetControlFile(
1019 updater.static_dir, kwargs["build"], kwargs["control_path"]
1020 )
Frank Farzan40160872011-12-13 02:39:181021
Jack Rosenthal8de609d2023-02-09 20:20:351022 @cherrypy.expose
1023 def xbuddy_translate(self, *args, **kwargs):
1024 """Translates an xBuddy path to a real path to artifact if it exists.
Yu-Ju Hong1bdb7a92014-04-10 23:02:111025
Jack Rosenthal8de609d2023-02-09 20:20:351026 Args:
1027 args: An xbuddy path in the form of {local|remote}/build_id/artifact.
1028 Local searches the devserver's static directory. Remote searches a
1029 Google Storage image archive.
Simran Basi99e63c02014-05-20 17:39:521030
Jack Rosenthal8de609d2023-02-09 20:20:351031 Kwargs:
1032 image_dir: Google Storage image archive to search in if requesting a
1033 remote artifact. If none uses the default bucket.
Yu-Ju Hong1bdb7a92014-04-10 23:02:111034
Jack Rosenthal8de609d2023-02-09 20:20:351035 Returns:
1036 String in the format of build_id/artifact as stored on the local server
1037 or in Google Storage.
1038 """
1039 if is_deprecated_server():
1040 raise DeprecatedRPCError("xbuddy_translate")
Sanika Kulkarnid4496fd2020-02-05 01:26:251041
Jack Rosenthal8de609d2023-02-09 20:20:351042 build_id, filename = self._xbuddy.Translate(
1043 args, image_dir=kwargs.get("image_dir")
1044 )
1045 response = os.path.join(build_id, filename)
1046 _Log("Path translation requested, returning: %s", response)
1047 return response
Yu-Ju Hong1bdb7a92014-04-10 23:02:111048
Jack Rosenthal8de609d2023-02-09 20:20:351049 @cherrypy.expose
1050 def xbuddy(self, *args, **kwargs):
1051 """The full xBuddy call, returns resource specified by path_parts.
joychen3cb228e2013-06-12 19:13:131052
Jack Rosenthal8de609d2023-02-09 20:20:351053 Args:
1054 path_parts: the path following xbuddy/ in the call url is split into the
1055 components of the path. The path can be understood as
1056 "{local|remote}/build_id/artifact" where build_id is composed of
1057 "board/version."
joycheneaf4cfc2013-07-02 15:38:571058
Jack Rosenthal8de609d2023-02-09 20:20:351059 The first path element is optional, and can be "remote" or "local"
1060 If local (the default), devserver will not attempt to access Google
1061 Storage, and will only search the static directory for the files.
1062 If remote, devserver will try to obtain the artifact off GS if it's
1063 not found locally.
1064 The board is the familiar board name, optionally suffixed.
1065 The version can be the google storage version number, and may also be
1066 any of a number of xBuddy defined version aliases that will be
1067 translated into the latest built image that fits the description.
1068 Defaults to latest.
1069 The artifact is one of a number of image or artifact aliases used by
1070 xbuddy, defined in xbuddy:ALIASES. Defaults to test.
joycheneaf4cfc2013-07-02 15:38:571071
Jack Rosenthal8de609d2023-02-09 20:20:351072 Kwargs:
1073 return_dir: {true|false}
1074 if set to true, returns the url to the update.gz
1075 relative_path: {true|false}
1076 if set to true, returns the relative path to the payload
1077 directory from static_dir.
1078 Example URL:
1079 http://host:port/xbuddy/x86-generic/R26-4000.0.0/test
1080 or
1081 http://host:port/xbuddy/x86-generic/R26-4000.0.0/test?return_dir=true
joychen3cb228e2013-06-12 19:13:131082
Jack Rosenthal8de609d2023-02-09 20:20:351083 Returns:
1084 If |return_dir|, return a uri to the folder where the artifact is. E.g.,
1085 http://host:port/static/x86-generic-release/R26-4000.0.0/
1086 If |relative_path| is true, return a relative path the folder where the
1087 payloads are. E.g.,
1088 archive/x86-generic-release/R26-4000.0.0
1089 """
1090 if is_deprecated_server():
1091 raise DeprecatedRPCError("xbuddy")
Sanika Kulkarnid4496fd2020-02-05 01:26:251092
Jack Rosenthal8de609d2023-02-09 20:20:351093 boolean_string = kwargs.get("return_dir")
1094 return_dir = xbuddy.XBuddy.ParseBoolean(boolean_string)
1095 boolean_string = kwargs.get("relative_path")
1096 relative_path = xbuddy.XBuddy.ParseBoolean(boolean_string)
joychen121fc9b2013-08-02 21:30:301097
Jack Rosenthal8de609d2023-02-09 20:20:351098 if return_dir and relative_path:
1099 raise DevServerHTTPError(
1100 http_client.INTERNAL_SERVER_ERROR,
1101 "Cannot specify both return_dir and relative_path",
1102 )
Chris Sosa75490802013-10-01 00:21:451103
Jack Rosenthal8de609d2023-02-09 20:20:351104 build_id, file_name = self._xbuddy.Get(args)
Yu-Ju Hong51495eb2013-12-13 01:08:431105
Jack Rosenthal8de609d2023-02-09 20:20:351106 response = None
1107 if return_dir:
1108 response = os.path.join(cherrypy.request.base, "static", build_id)
1109 _Log("Directory requested, returning: %s", response)
1110 elif relative_path:
1111 response = build_id
1112 _Log("Relative path requested, returning: %s", response)
1113 else:
1114 # Redirect to download the payload if no kwargs are set.
1115 build_id = "/" + os.path.join("static", build_id, file_name)
1116 _Log("Payload requested, returning: %s", build_id)
1117 raise cherrypy.HTTPRedirect(build_id, 302)
joychen3cb228e2013-06-12 19:13:131118
Jack Rosenthal8de609d2023-02-09 20:20:351119 return response
Yu-Ju Hong51495eb2013-12-13 01:08:431120
Jack Rosenthal8de609d2023-02-09 20:20:351121 @cherrypy.expose
1122 def xbuddy_capacity(self):
1123 """Returns the number of images cached by xBuddy."""
1124 if is_deprecated_server():
1125 raise DeprecatedRPCError("xbuddy_capacity")
Sanika Kulkarnid4496fd2020-02-05 01:26:251126
Jack Rosenthal8de609d2023-02-09 20:20:351127 return self._xbuddy.Capacity()
joychen3cb228e2013-06-12 19:13:131128
Jack Rosenthal8de609d2023-02-09 20:20:351129 @cherrypy.expose
1130 def index(self):
1131 """Presents a welcome message and documentation links."""
1132 if is_deprecated_server():
1133 raise DeprecatedRPCError("index")
Sanika Kulkarnid4496fd2020-02-05 01:26:251134
Jack Rosenthal8de609d2023-02-09 20:20:351135 html_template = (
1136 "Welcome to the Dev Server!<br>\n"
1137 "<br>\n"
1138 "Here are the available methods, click for documentation:<br>\n"
1139 "<br>\n"
1140 "%s"
1141 )
Congbin Guo6bc32182019-08-21 00:54:301142
Jack Rosenthal8de609d2023-02-09 20:20:351143 exposed_methods = []
1144 for app in cherrypy.tree.apps.values():
1145 exposed_methods += _FindExposedMethods(
1146 app.root,
1147 app.script_name.lstrip("/"),
1148 unlisted=self._UNLISTED_METHODS,
1149 )
Congbin Guo6bc32182019-08-21 00:54:301150
Jack Rosenthal8de609d2023-02-09 20:20:351151 return html_template % "<br>\n".join(
1152 [
1153 "<a href=doc/%s>%s</a>" % (name, name)
1154 for name in sorted(exposed_methods)
1155 ]
1156 )
Gilad Arnoldf8f769f2012-09-24 15:43:011157
Jack Rosenthal8de609d2023-02-09 20:20:351158 @cherrypy.expose
1159 def doc(self, *args):
1160 """Shows the documentation for available methods / URLs.
Gilad Arnoldf8f769f2012-09-24 15:43:011161
Jack Rosenthal8de609d2023-02-09 20:20:351162 Examples:
1163 http://myhost/doc/update
1164 """
1165 if is_deprecated_server():
1166 raise DeprecatedRPCError("doc")
Sanika Kulkarnid4496fd2020-02-05 01:26:251167
Jack Rosenthal8de609d2023-02-09 20:20:351168 name = "/".join(args)
1169 method = _GetExposedMethod(name)
1170 if not method:
1171 raise DevServerError("No exposed method named `%s'" % name)
1172 if not method.__doc__:
1173 raise DevServerError(
1174 "No documentation for exposed method `%s'" % name
1175 )
1176 return "<pre>\n%s</pre>" % method.__doc__
Chris Sosa7c931362010-10-12 02:49:011177
Jack Rosenthal8de609d2023-02-09 20:20:351178 @cherrypy.expose
1179 def update(self, *args, **kwargs):
1180 """Handles an update check from a Chrome OS client.
Amin Hassani2aa34282020-11-18 01:18:191181
Jack Rosenthal8de609d2023-02-09 20:20:351182 The HTTP request should contain the standard Omaha-style XML blob. The URL
1183 line may contain an additional intermediate path to the update payload.
Amin Hassani2aa34282020-11-18 01:18:191184
Jack Rosenthal8de609d2023-02-09 20:20:351185 This request can be handled in one of 4 ways, depending on the devsever
1186 settings and intermediate path.
Amin Hassani2aa34282020-11-18 01:18:191187
Jack Rosenthal8de609d2023-02-09 20:20:351188 1. No intermediate path. DEPRECATED
Amin Hassani2aa34282020-11-18 01:18:191189
Jack Rosenthal8de609d2023-02-09 20:20:351190 2. Path explicitly invokes XBuddy
1191 If there is a path given, it can explicitly invoke xbuddy by prefixing it
1192 with 'xbuddy'. This path is then used to acquire an image binary for the
1193 devserver to generate an update payload from. Devserver then serves this
1194 payload.
Amin Hassani2aa34282020-11-18 01:18:191195
Jack Rosenthal8de609d2023-02-09 20:20:351196 3. Path is left for the devserver to interpret.
1197 If the path given doesn't explicitly invoke xbuddy, devserver will attempt
1198 to generate a payload from the test image in that directory and serve it.
Amin Hassani2aa34282020-11-18 01:18:191199
Jack Rosenthal8de609d2023-02-09 20:20:351200 Examples:
1201 2. Explicitly invoke xbuddy
1202 update_engine_client --omaha_url=
1203 http://myhost/update/xbuddy/remote/board/version/dev
1204 This would go to GS to download the dev image for the board, from which
1205 the devserver would generate a payload to serve.
Amin Hassani2aa34282020-11-18 01:18:191206
Jack Rosenthal8de609d2023-02-09 20:20:351207 3. Give a path for devserver to interpret
1208 update_engine_client --omaha_url=http://myhost/update/some/random/path
1209 This would attempt, in order to:
1210 a) Generate an update from a test image binary if found in
1211 static_dir/some/random/path.
1212 b) Serve an update payload found in static_dir/some/random/path.
1213 c) Hope that some/random/path takes the form "board/version" and
1214 and attempt to download an update payload for that board/version
1215 from GS.
1216 """
1217 label = "/".join(args)
1218 body_length = int(cherrypy.request.headers.get("Content-Length", 0))
1219 data = cherrypy.request.rfile.read(body_length)
Amin Hassani2aa34282020-11-18 01:18:191220
Jack Rosenthal8de609d2023-02-09 20:20:351221 return updater.HandleUpdatePing(data, label, **kwargs)
Amin Hassani2aa34282020-11-18 01:18:191222
Dan Shif5ce2de2013-04-25 23:06:321223
Chris Sosadbc20082012-12-10 21:39:111224def _CleanCache(cache_dir, wipe):
Jack Rosenthal8de609d2023-02-09 20:20:351225 """Wipes any excess cached items in the cache_dir.
Chris Sosadbc20082012-12-10 21:39:111226
Jack Rosenthal8de609d2023-02-09 20:20:351227 Args:
1228 cache_dir: the directory we are wiping from.
1229 wipe: If True, wipe all the contents -- not just the excess.
1230 """
1231 if wipe:
1232 # Clear the cache and exit on error.
1233 cmd = "rm -rf %s/*" % cache_dir
1234 if os.system(cmd) != 0:
1235 _Log("Failed to clear the cache with %s" % cmd)
1236 sys.exit(1)
1237 else:
1238 # Clear all but the last N cached updates
1239 cmd = "cd %s; ls -tr | head --lines=-%d | xargs rm -rf" % (
1240 cache_dir,
1241 CACHED_ENTRIES,
1242 )
1243 if os.system(cmd) != 0:
1244 _Log("Failed to clean up old delta cache files with %s" % cmd)
1245 sys.exit(1)
Chris Sosadbc20082012-12-10 21:39:111246
1247
Chris Sosa3ae4dc12013-03-29 18:47:001248def _AddTestingOptions(parser):
Jack Rosenthal8de609d2023-02-09 20:20:351249 group = optparse.OptionGroup(
1250 parser,
1251 "Advanced Testing Options",
1252 "These are used by test scripts and "
1253 "developers writing integration tests utilizing the devserver. They are "
1254 "not intended to be really used outside the scope of someone "
1255 "knowledgable about the test.",
1256 )
1257 group.add_option(
1258 "--exit",
1259 action="store_true",
1260 help="do not start the server (yet clear cache)",
1261 )
1262 parser.add_option_group(group)
Chris Sosa3ae4dc12013-03-29 18:47:001263
1264
Chris Sosa3ae4dc12013-03-29 18:47:001265def _AddProductionOptions(parser):
Jack Rosenthal8de609d2023-02-09 20:20:351266 group = optparse.OptionGroup(
1267 parser,
1268 "Advanced Server Options",
1269 "These options can be used to changed " "for advanced server behavior.",
1270 )
1271 group.add_option(
1272 "--clear_cache",
1273 action="store_true",
1274 default=False,
1275 help="At startup, removes all cached entries from the"
1276 "devserver's cache.",
1277 )
1278 group.add_option(
1279 "--logfile",
1280 metavar="PATH",
1281 help="log output to this file instead of stdout",
1282 )
1283 group.add_option(
1284 "--pidfile",
1285 metavar="PATH",
1286 help="path to output a pid file for the server.",
1287 )
1288 group.add_option(
1289 "--portfile",
1290 metavar="PATH",
1291 help="path to output the port number being served on.",
1292 )
1293 group.add_option(
1294 "--production",
1295 action="store_true",
1296 default=False,
1297 help="have the devserver use production values when "
1298 "starting up. This includes using more threads and "
1299 "performing less logging.",
1300 )
1301 parser.add_option_group(group)
Chris Sosa3ae4dc12013-03-29 18:47:001302
1303
Paul Hobbsef4e0702016-06-28 00:01:421304def MakeLogHandler(logfile):
Jack Rosenthal8de609d2023-02-09 20:20:351305 """Create a LogHandler instance used to log all messages."""
1306 hdlr_cls = handlers.TimedRotatingFileHandler
1307 hdlr = hdlr_cls(
1308 logfile,
1309 when=_LOG_ROTATION_TIME,
1310 interval=_LOG_ROTATION_INTERVAL,
1311 backupCount=_LOG_ROTATION_BACKUP,
1312 )
1313 hdlr.setFormatter(cplogging.logfmt)
1314 return hdlr
J. Richard Barnette3d977b82013-04-23 18:05:191315
1316
Chris Sosacde6bf42012-06-01 01:36:391317def main():
Jack Rosenthal8de609d2023-02-09 20:20:351318 usage = "\n\n".join(["usage: %prog [options]", __doc__])
1319 parser = optparse.OptionParser(usage=usage)
joychened64b222013-06-21 23:39:341320
Jack Rosenthal8de609d2023-02-09 20:20:351321 # get directory that the devserver is run from
1322 devserver_dir = os.path.dirname(os.path.abspath(sys.argv[0]))
1323 default_static_dir = "%s/static" % devserver_dir
1324 parser.add_option(
1325 "--static_dir",
1326 metavar="PATH",
1327 default=default_static_dir,
1328 help="writable static directory",
1329 )
1330 parser.add_option(
1331 "--port",
1332 default=8080,
1333 type="int",
1334 help=(
1335 "port for the dev server to use; if zero, binds to "
1336 "an arbitrary available port (default: 8080)"
1337 ),
1338 )
1339 parser.add_option(
1340 "-t", "--test_image", action="store_true", help="Deprecated."
1341 )
1342 parser.add_option(
1343 "-x",
1344 "--xbuddy_manage_builds",
1345 action="store_true",
1346 default=False,
1347 help="If set, allow xbuddy to manage images in" "build/images.",
1348 )
1349 parser.add_option(
1350 "-a",
1351 "--android_build_credential",
1352 default=None,
1353 help="Path to a json file which contains the credential "
1354 "needed to access Android builds.",
1355 )
1356 parser.add_option(
1357 "--infra_removal",
1358 action="store_true",
1359 default=False,
1360 help="If option is present, some RPCs will be disabled to "
1361 "help with infra removal efforts. See "
1362 "go/devserver-deprecation",
1363 )
1364 _AddProductionOptions(parser)
1365 _AddTestingOptions(parser)
1366 (options, _) = parser.parse_args()
[email protected]21a5ca32009-11-04 18:23:231367
Jack Rosenthal8de609d2023-02-09 20:20:351368 # Handle options that must be set globally in cherrypy. Do this
1369 # work up front, because calls to _Log() below depend on this
1370 # initialization.
1371 if options.production:
1372 cherrypy.config.update({"environment": "production"})
1373 cherrypy.config.update({"infra_removal": options.infra_removal})
1374 if not options.logfile:
1375 cherrypy.config.update({"log.screen": True})
1376 else:
1377 cherrypy.config.update({"log.error_file": "", "log.access_file": ""})
1378 hdlr = MakeLogHandler(options.logfile)
1379 # Pylint can't seem to process these two calls properly
1380 # pylint: disable=E1101
1381 cherrypy.log.access_log.addHandler(hdlr)
1382 cherrypy.log.error_log.addHandler(hdlr)
1383 # pylint: enable=E1101
J. Richard Barnette3d977b82013-04-23 18:05:191384
Jack Rosenthal8de609d2023-02-09 20:20:351385 # set static_dir, from which everything will be served
1386 options.static_dir = os.path.realpath(options.static_dir)
Chris Sosa0356d3b2010-09-16 22:46:221387
Jack Rosenthal8de609d2023-02-09 20:20:351388 cache_dir = os.path.join(options.static_dir, "cache")
1389 # If our devserver is only supposed to serve payloads, we shouldn't be
1390 # mucking with the cache at all. If the devserver hadn't previously
1391 # generated a cache and is expected, the caller is using it wrong.
1392 if os.path.exists(cache_dir):
1393 _CleanCache(cache_dir, options.clear_cache)
1394 else:
1395 os.makedirs(cache_dir)
Don Garrettf90edf02010-11-17 01:36:141396
Jack Rosenthal8de609d2023-02-09 20:20:351397 pkgroot_dir = os.path.join(options.static_dir, "pkgroot")
1398 common_util.SymlinkFile("/build", pkgroot_dir)
Amin Hassanief523622020-07-06 19:09:231399
Jack Rosenthal8de609d2023-02-09 20:20:351400 _Log("Using cache directory %s" % cache_dir)
1401 _Log("Serving from %s" % options.static_dir)
[email protected]21a5ca32009-11-04 18:23:231402
Jack Rosenthal8de609d2023-02-09 20:20:351403 _xbuddy = xbuddy.XBuddy(
1404 manage_builds=options.xbuddy_manage_builds,
1405 static_dir=options.static_dir,
1406 )
1407 if options.clear_cache and options.xbuddy_manage_builds:
1408 _xbuddy.CleanCache()
joychen121fc9b2013-08-02 21:30:301409
Jack Rosenthal8de609d2023-02-09 20:20:351410 # We allow global use here to share with cherrypy classes.
1411 # pylint: disable=W0603
1412 global updater
1413 updater = autoupdate.Autoupdate(_xbuddy, static_dir=options.static_dir)
Amin Hassani2aa34282020-11-18 01:18:191414
Jack Rosenthal8de609d2023-02-09 20:20:351415 if options.exit:
1416 return
Chris Sosa2f1c41e2012-07-10 21:32:331417
Jack Rosenthal8de609d2023-02-09 20:20:351418 dev_server = DevServerRoot(_xbuddy)
1419 health_checker_app = health_checker.Root(dev_server, options.static_dir)
joychen3cb228e2013-06-12 19:13:131420
Jack Rosenthal8de609d2023-02-09 20:20:351421 if options.pidfile:
1422 plugins.PIDFile(cherrypy.engine, options.pidfile).subscribe()
Chris Sosa855b8932013-08-21 20:24:551423
Jack Rosenthal8de609d2023-02-09 20:20:351424 if options.portfile:
1425 cherrypy_ext.PortFile(cherrypy.engine, options.portfile).subscribe()
Gilad Arnold11fbef42014-02-10 19:04:131426
Jack Rosenthal8de609d2023-02-09 20:20:351427 if options.android_build_credential and os.path.exists(
1428 options.android_build_credential
1429 ):
1430 try:
1431 with open(options.android_build_credential) as f:
1432 android_build.BuildAccessor.credential_info = json.load(f)
1433 except ValueError as e:
1434 _Log(
1435 "Failed to load the android build credential: %s. Error: %s."
1436 % (options.android_build_credential, e)
1437 )
Congbin Guo3afae6c2019-08-13 23:29:421438
Jack Rosenthal8de609d2023-02-09 20:20:351439 cherrypy.tree.mount(
1440 health_checker_app, "/check_health", config=health_checker.get_config()
1441 )
1442 cherrypy.quickstart(dev_server, config=_GetConfig(options))
Chris Sosacde6bf42012-06-01 01:36:391443
1444
Jack Rosenthal8de609d2023-02-09 20:20:351445if __name__ == "__main__":
1446 main()