1# 2# Copyright (C) 2017 The Android Open Source Project 3# 4# Licensed under the Apache License, Version 2.0 (the "License"); 5# you may not use this file except in compliance with the License. 6# You may obtain a copy of the License at 7# 8# http://www.apache.org/licenses/LICENSE-2.0 9# 10# Unless required by applicable law or agreed to in writing, software 11# distributed under the License is distributed on an "AS IS" BASIS, 12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13# See the License for the specific language governing permissions and 14# limitations under the License. 15# 16"""Module to fetch artifacts from Partner Android Build server.""" 17 18import argparse 19import getpass 20import httplib2 21import json 22import logging 23import os 24import requests 25import urlparse 26from posixpath import join as path_urljoin 27 28from oauth2client.client import flow_from_clientsecrets 29from oauth2client.file import Storage 30from oauth2client.tools import argparser 31from oauth2client.tools import run_flow 32 33from selenium import webdriver 34from selenium.webdriver.common.by import By 35from selenium.common.exceptions import TimeoutException 36from selenium.webdriver.support import expected_conditions as EC 37from selenium.webdriver.common.keys import Keys 38from selenium.webdriver.chrome.options import Options 39from selenium.webdriver.support.ui import WebDriverWait 40 41from host_controller.build import build_provider 42 43# constants for GET and POST endpoints 44GET = 'GET' 45POST = 'POST' 46 47# timeout seconds for requests 48REQUESTS_TIMEOUT_SECONDS = 60 49 50 51class BuildProviderPAB(build_provider.BuildProvider): 52 """Client that manages Partner Android Build downloading. 53 54 Attributes: 55 BAD_XSRF_CODE: int, error code for bad XSRF token error 56 BASE_URL: string, path to PAB entry point 57 BUILDARTIFACT_NAME_KEY: string, index in artifact containing name 58 BUILD_BUILDID_KEY: string, index in build containing build_id 59 BUILD_COMPLETED_STATUS: int, value of 'complete' build 60 BUILD_STATUS_KEY: string, index in build object containing status. 61 CHROME_DRIVER_LOCATION: string, path to chromedriver 62 CHROME_LOCATION: string, path to Chrome browser 63 CLIENT_STORAGE: string, path to store credentials. 64 DEFAULT_CHUNK_SIZE: int, number of bytes to download at a time. 65 DOWNLOAD_URL_KEY: string, index in downloadBuildArtifact containing url 66 EMAIL: string, email constant for userinfo JSON 67 EXPIRED_XSRF_CODE: int, error code for expired XSRF token error 68 GETBUILD_ARTIFACTS_KEY, string, index in build obj containing artifacts 69 GMS_DOWNLOAD_URL: string, base url for downloading artifacts. 70 LISTBUILD_BUILD_KEY: string, index in listBuild containing builds 71 PAB_URL: string, redirect url from Google sign-in to PAB 72 PASSWORD: string, password constant for userinfo JSON 73 SCOPE: string, URL for which to request access via oauth2. 74 SVC_URL: string, path to buildsvc RPC 75 XSRF_STORE: string, path to store xsrf token 76 _credentials : oauth2client credentials object 77 _userinfo_file: location of file containing email and password 78 _xsrf : string, XSRF token from PAB website. expires after 7 days. 79 """ 80 _credentials = None 81 _userinfo_file = None 82 _xsrf = None 83 BAD_XSRF_CODE = -32000 84 BASE_URL = 'https://partner.android.com' 85 BUILDARTIFACT_NAME_KEY = '1' 86 BUILD_BUILDID_KEY = '1' 87 BUILD_COMPLETED_STATUS = 7 88 BUILD_STATUS_KEY = '7' 89 CHROME_DRIVER_LOCATION = '/usr/local/bin/chromedriver' 90 CHROME_LOCATION = '/usr/bin/google-chrome' 91 CLIENT_SECRETS = os.path.join( 92 os.path.dirname(__file__), 'client_secrets.json') 93 CLIENT_STORAGE = os.path.join(os.path.dirname(__file__), 'credentials') 94 DEFAULT_CHUNK_SIZE = 1024 95 DOWNLOAD_URL_KEY = '1' 96 EMAIL = 'email' 97 EXPIRED_XSRF_CODE = -32001 98 GETBUILD_ARTIFACTS_KEY = '2' 99 GMS_DOWNLOAD_URL = 'https://partnerdash.google.com/build/gmsdownload' 100 LISTBUILD_BUILD_KEY = '1' 101 PAB_URL = ('https://www.google.com/accounts/Login?&continue=' 102 'https://partner.android.com/build/') 103 PASSWORD = 'password' 104 # need both of these scopes to access PAB downloader 105 scopes = ('https://www.googleapis.com/auth/partnerdash', 106 'https://www.googleapis.com/auth/alkali-base') 107 SCOPE = ' '.join(scopes) 108 SVC_URL = urlparse.urljoin(BASE_URL, 'build/u/0/_gwt/_rpc/buildsvc') 109 XSRF_STORE = os.path.join(os.path.dirname(__file__), 'xsrf') 110 111 def __init__(self): 112 """Creates a temp dir.""" 113 super(BuildProviderPAB, self).__init__() 114 115 def Authenticate(self, userinfo_file=None, noauth_local_webserver=False, 116 scopes=SCOPE): 117 """Authenticate using OAuth2. 118 119 Args: 120 userinfo_file: (optional) the path of a JSON file which has 121 "email" and "password" string fields. 122 noauth_local_webserver: boolean, True if do not (or can not) use 123 a local web server. 124 scopes: string or iterable of strings, the scopes to request. 125 """ 126 # this should be a JSON file with "email" and "password" string fields 127 self._userinfo_file = userinfo_file 128 logging.info('Parsing flags, use --noauth_local_webserver' 129 ' if running on remote machine') 130 131 parser = argparse.ArgumentParser(parents=[argparser]) 132 flags, unknown = parser.parse_known_args() 133 flags.noauth_local_webserver = noauth_local_webserver 134 logging.info('Preparing OAuth token') 135 flow = flow_from_clientsecrets(self.CLIENT_SECRETS, scope=scopes) 136 storage = Storage(self.CLIENT_STORAGE) 137 if self._credentials is None: 138 self._credentials = storage.get() 139 if self._credentials is None or self._credentials.invalid: 140 logging.info('Credentials not found, authenticating.') 141 self._credentials = run_flow(flow, storage, flags) 142 143 if self._credentials.access_token_expired: 144 logging.info('Access token expired, refreshing.') 145 self._credentials.refresh(http=httplib2.Http()) 146 147 if self.XSRF_STORE is not None and os.path.isfile(self.XSRF_STORE): 148 with open(self.XSRF_STORE, 'r') as handle: 149 self._xsrf = handle.read() 150 151 def GetXSRFToken(self, email=None, password=None): 152 """Get XSRF token. Prompt if email/password not provided. 153 154 Args: 155 email: string, optional. Gmail account of user logging in 156 password: string, optional. Password of user logging in 157 158 Returns: 159 boolean, whether the token was accessed and stored 160 161 Raises: 162 ValueError if login fails or userinfo file is malformed. 163 """ 164 if self._userinfo_file is not None: 165 with open(self._userinfo_file, 'r') as handle: 166 userinfo = json.load(handle) 167 168 if self.EMAIL not in userinfo or self.PASSWORD not in userinfo: 169 raise ValueError( 170 'Malformed userinfo file: needs email and password') 171 172 email = userinfo[self.EMAIL] 173 password = userinfo[self.PASSWORD] 174 175 chrome_options = Options() 176 chrome_options.add_argument("--headless") 177 178 driver = webdriver.Chrome( 179 chrome_options=chrome_options) 180 181 driver.set_window_size(1080, 800) 182 wait = WebDriverWait(driver, 10) 183 184 driver.get(self.PAB_URL) 185 186 query = driver.find_element_by_id("identifierId") 187 if email is None: 188 email = raw_input("Email: ") 189 query.send_keys(email) 190 driver.find_element_by_id("identifierNext").click() 191 192 pw = wait.until(EC.element_to_be_clickable((By.NAME, "password"))) 193 pw.clear() 194 195 if password is None: 196 pw.send_keys(getpass.getpass("Password: ")) 197 else: 198 pw.send_keys(password) 199 200 driver.find_element_by_id("passwordNext").click() 201 202 try: 203 wait.until(EC.title_contains("Partner Android Build")) 204 except TimeoutException as e: 205 logging.exception(e) 206 raise ValueError('Wrong password or non-standard login flow') 207 208 self._xsrf = driver.execute_script("return clientConfig.XSRF_TOKEN;") 209 with open(self.XSRF_STORE, 'w') as handle: 210 handle.write(self._xsrf) 211 212 return True 213 214 def CallBuildsvc(self, method, params, account_id): 215 """Call the buildsvc RPC with given parameters. 216 217 Args: 218 method: string, name of method to be called in buildsvc 219 params: dict, parameters to RPC call 220 account_id: int, ID associated with the PAB account. 221 222 Returns: 223 dict, result from RPC call 224 225 Raises: 226 ValueError if RPC call returns an error or an unknown response. 227 """ 228 if self._xsrf is None: 229 self.GetXSRFToken() 230 params = json.dumps(params) 231 232 data = {"method": method, "params": params, "xsrf": self._xsrf} 233 data = json.dumps(data) 234 headers = {} 235 self._credentials.apply(headers) 236 headers['Content-Type'] = 'application/json' 237 headers['x-alkali-account'] = account_id 238 239 try: 240 response = requests.post(self.SVC_URL, data=data, headers=headers, 241 timeout=REQUESTS_TIMEOUT_SECONDS) 242 except requests.exceptions.Timeout as e: 243 logging.exception(e) 244 raise ValueError("Request timeout.") 245 246 responseJSON = {} 247 248 try: 249 responseJSON = response.json() 250 except ValueError: 251 raise ValueError("Backend error -- check your account ID") 252 253 if 'result' in responseJSON: 254 return responseJSON['result'] 255 256 if 'error' in responseJSON and 'code' in responseJSON['error']: 257 if responseJSON['error']['code'] == self.BAD_XSRF_CODE: 258 raise ValueError( 259 "Bad XSRF token -- must be for the same account as your credentials") 260 if responseJSON['error']['code'] == self.EXPIRED_XSRF_CODE: 261 raise ValueError("Expired XSRF token -- please refresh") 262 263 raise ValueError("Unknown response from server -- %s" % 264 json.dumps(responseJSON)) 265 266 def GetBuildList(self, 267 account_id, 268 branch, 269 target, 270 page_token="", 271 max_results=10, 272 internal=True, 273 method=GET, 274 verify_signed=False): 275 """Get the list of builds for a given account, branch and target 276 Args: 277 account_id: int, ID associated with the PAB account. 278 branch: string, branch to pull resource from. 279 target: string, "latest" or a specific version. 280 page_token: string, token used for pagination 281 max_results: maximum build results the build list contains, e.g. 25 282 internal: bool, whether to query internal build 283 method: 'GET' or 'POST', which endpoint to query 284 verify_signed: bool, whether to verify signed build. 285 286 Returns: 287 list of dicts representing the builds, descending in time 288 289 Raises: 290 ValueError if build request returns an error or builds not found. 291 """ 292 if method == POST: 293 params = { 294 "1": branch, 295 "2": target, 296 "3": page_token, 297 "4": max_results, 298 "7": int(internal) 299 } 300 301 result = self.CallBuildsvc("listBuild", params, account_id) 302 # in listBuild response, index '1' contains builds 303 if self.LISTBUILD_BUILD_KEY in result: 304 return result[self.LISTBUILD_BUILD_KEY] 305 306 if verify_signed: 307 logging.error("verify_signed does not support POST method.") 308 309 raise ValueError("Build list not found -- %s" % params) 310 elif method == GET: 311 headers = {} 312 self._credentials.apply(headers) 313 314 action = 'list-internal' if internal else 'list' 315 # PAB URL format expects something (anything) to be given as buildid 316 # and resource, even for action list 317 dummy = 'DUMMY' 318 url = path_urljoin(self.BASE_URL, 'build', 'builds', action, 319 branch, target, dummy, 320 dummy) + '?a=' + str(account_id) 321 try: 322 response = requests.get(url, headers=headers, 323 timeout=REQUESTS_TIMEOUT_SECONDS) 324 responseJSON = response.json() 325 builds = responseJSON['build'] 326 except requests.exceptions.Timeout as e: 327 logging.exception(e) 328 raise ValueError("Request timeout.") 329 except ValueError as e: 330 logging.exception(e) 331 raise ValueError("Backend error -- check your account ID") 332 333 if verify_signed: 334 for build in builds: 335 artifact_name = "signed%2Fsigned-{}-img-{}.zip".format( 336 target.split("-")[0], build["build_id"]) 337 logging.debug("Checking whether the build is signed for " 338 "build_target {} and build_id {}".format( 339 target, build["build_id"])) 340 signed_build_url = self.GetArtifactURL( 341 account_id=account_id, 342 build_id=build["build_id"], 343 target=target, 344 artifact_name=artifact_name, 345 branch=branch, 346 internal=False, 347 method=method) 348 try: 349 self.GetResponseWithURL(signed_build_url) 350 logging.debug("The build is signed.") 351 build["signed"] = True 352 except requests.HTTPError: 353 logging.debug("The build is not signed.") 354 build["signed"] = False 355 except requests.exceptions.Timeout as e: 356 logging.debug("Server is not responding.") 357 logging.exception(e) 358 build["signed"] = False 359 return builds 360 361 def GetLatestBuildId(self, account_id, branch, target, method=GET): 362 """Get the most recent build_id for a given account, branch and target 363 Args: 364 account_id: int, ID associated with the PAB account. 365 branch: string, branch to pull resource from. 366 target: string, "latest" or a specific version. 367 method: 'GET' or 'POST', which endpoint to query 368 369 Returns: 370 string, most recent build id 371 372 Raises: 373 ValueError if complete builds are not found. 374 """ 375 # TODO: support pagination, maybe? 376 build_list = self.GetBuildList(account_id=account_id, 377 branch=branch, 378 target=target, 379 method=method) 380 if len(build_list) == 0: 381 raise ValueError( 382 'No builds found for account_id=%s, branch=%s, target=%s' % 383 (account_id, branch, target)) 384 for build in build_list: 385 if method == POST: 386 # get build status: 7 = completed build 387 if build.get(self.BUILD_STATUS_KEY, 388 None) == self.BUILD_COMPLETED_STATUS: 389 # return build id (index '1') 390 return build[self.BUILD_BUILDID_KEY] 391 elif method == GET: 392 if build['build_attempt_status'] == "COMPLETE" and build[ 393 "successful"]: 394 return build['build_id'] 395 raise ValueError( 396 'No complete builds found: %s failed or incomplete builds found' % 397 len(build_list)) 398 399 def GetBuildArtifacts( 400 self, account_id, build_id, branch, target, method=POST): 401 """Get the list of build artifacts. 402 403 For an account, build, target, branch. 404 405 Args: 406 account_id: int, ID associated with the PAB account. 407 build_id: string, ID of the build 408 branch: string, branch to pull resource from. 409 target: string, "latest" or a specific version. 410 method: 'GET' or 'POST', which endpoint to query 411 412 Returns: 413 list of build artifact objects 414 415 Raises: 416 NotImplementedError if method is 'GET', which is not supported yet. 417 ValueError if build artifacts are not found. 418 """ 419 if method == GET: 420 raise NotImplementedError( 421 "GetBuildArtifacts not supported with GET") 422 params = {"1": build_id, "2": target, "3": branch} 423 424 result = self.CallBuildsvc("getBuild", params, account_id) 425 # in getBuild response, index '2' contains the artifacts 426 if self.GETBUILD_ARTIFACTS_KEY in result: 427 return result[self.GETBUILD_ARTIFACTS_KEY] 428 if len(result) == 0: 429 raise ValueError("Build artifacts not found -- %s" % params) 430 431 def GetArtifactURL(self, 432 account_id, 433 build_id, 434 target, 435 artifact_name, 436 branch, 437 internal, 438 method=GET): 439 """Get the URL for an artifact on the PAB server, using buildsvc. 440 441 Args: 442 account_id: int, ID associated with the PAB account. 443 build_id: string/int, id of the build. 444 target: string, "latest" or a specific version. 445 artifact_name: string, simple file name (no parent dir or path). 446 branch: string, branch to pull resource from. 447 internal: int, whether the request is for an internal build artifact 448 method: 'GET' or 'POST', which endpoint to query 449 450 Returns: 451 string, The URL for the resource specified by the parameters 452 453 Raises: 454 ValueError if given parameters are incorrect or resource not found. 455 """ 456 if method == POST: 457 params = { 458 "1": str(build_id), 459 "2": target, 460 "3": artifact_name, 461 "4": branch, 462 "5": "", # release_candidate_name 463 "6": internal 464 } 465 466 result = self.CallBuildsvc(method='downloadBuildArtifact', 467 params=params, 468 account_id=account_id) 469 470 # in downloadBuildArtifact response, index '1' contains the url 471 if self.DOWNLOAD_URL_KEY in result: 472 return result[self.DOWNLOAD_URL_KEY] 473 if len(result) == 0: 474 raise ValueError("Resource not found -- %s" % params) 475 elif method == GET: 476 headers = {} 477 self._credentials.apply(headers) 478 479 action = 'get-internal' if internal else 'get' 480 get_url = path_urljoin(self.BASE_URL, 'build', 'builds', action, 481 branch, target, build_id, 482 artifact_name) + '?a=' + str(account_id) 483 484 try: 485 response = requests.get(get_url, headers=headers, 486 timeout=REQUESTS_TIMEOUT_SECONDS) 487 responseJSON = response.json() 488 return responseJSON['url'] 489 except requests.exceptions.Timeout as e: 490 logging.exception(e) 491 raise ValueError("Request timeout.") 492 except ValueError: 493 raise ValueError("Backend error -- check your account ID") 494 495 def DownloadArtifact(self, download_url, filename): 496 """Get artifact from Partner Android Build server. 497 498 Args: 499 download_url: location of resource that we want to download 500 filename: where the artifact gets downloaded locally. 501 502 Returns: 503 boolean, whether the file was successfully downloaded 504 """ 505 try: 506 response = self.GetResponseWithURL(download_url) 507 except (requests.HTTPError, requests.exceptions.Timeout) as error: 508 logging.exception(error) 509 return False 510 logging.info('%s now downloading...', download_url) 511 with open(filename, 'wb') as handle: 512 for block in response.iter_content(self.DEFAULT_CHUNK_SIZE): 513 handle.write(block) 514 return True 515 516 def GetArtifact(self, 517 account_id, 518 branch, 519 target, 520 artifact_name, 521 build_id='latest', 522 method=GET, 523 full_device_images=False): 524 """Get an artifact for an account, branch, target and name and build id. 525 526 If build_id not given, get latest. 527 528 Args: 529 account_id: int, ID associated with the PAB account. 530 branch: string, branch to pull resource from. 531 target: string, "latest" or a specific version. 532 artifact_name: name of artifact, e.g. aosp_arm64_ab-img-4353141.zip 533 ({id} will automatically get replaced with build ID) 534 build_id: string, build ID of an artifact to fetch (or 'latest'). 535 method: 'GET' or 'POST', which endpoint to query. 536 537 Returns: 538 a dict containing the device image info. 539 a dict containing the test suite package info. 540 a dict containing the artifact info. 541 a dict containing the global config info. 542 543 Raises: 544 ValueError if artifacts are not found. 545 """ 546 artifact_info = {} 547 if build_id == 'latest': 548 build_id = self.GetLatestBuildId(account_id=account_id, 549 branch=branch, 550 target=target, 551 method=method) 552 logging.info("latest build ID = %s", build_id) 553 artifact_info["build_id"] = build_id 554 555 if "build_id" in artifact_name: 556 artifact_name = artifact_name.format(build_id=build_id) 557 558 if method == POST: 559 artifacts = self.GetBuildArtifacts(account_id=account_id, 560 build_id=build_id, 561 branch=branch, 562 target=target, 563 method=method) 564 565 if len(artifacts) == 0: 566 raise ValueError( 567 "No artifacts found for build_id=%s, branch=%s, target=%s" 568 % (build_id, branch, target)) 569 570 # in build artifact response, index '1' contains the name 571 artifact_names = [ 572 artifact[self.BUILDARTIFACT_NAME_KEY] for artifact in artifacts 573 ] 574 if artifact_name not in artifact_names: 575 raise ValueError("%s not found in artifact list" % 576 artifact_name) 577 578 url = self.GetArtifactURL(account_id=account_id, 579 build_id=build_id, 580 target=target, 581 artifact_name=artifact_name, 582 branch=branch, 583 internal=False, 584 method=method) 585 586 if self.tmp_dirpath: 587 artifact_path = os.path.join(self.tmp_dirpath, artifact_name) 588 else: 589 artifact_path = artifact_name 590 self.DownloadArtifact(url, artifact_path) 591 592 self.SetFetchedFile( 593 artifact_path, full_device_images=full_device_images) 594 595 return (self.GetDeviceImage(), self.GetTestSuitePackage(), 596 artifact_info, self.GetConfigPackage()) 597 598 def GetSignedBuildArtifact(self, 599 account_id, 600 branch, 601 target, 602 artifact_name, 603 build_id='latest', 604 method=GET, 605 full_device_images=False): 606 """Get an signed build artifact from the PAB bulid list. 607 608 Args: 609 account_id: int, ID associated with the PAB account. 610 branch: string, branch to pull resource from. 611 target: string, "latest" or a specific version. 612 artifact_name: name of artifact, e.g. aosp_arm64_ab-img-4353141.zip 613 ({id} will automatically get replaced with build ID) 614 build_id: string, build ID of an artifact to fetch (or 'latest'). 615 method: 'GET' or 'POST', which endpoint to query. 616 617 Returns: 618 a dict containing the device image info. 619 a dict containing the test suite package info. 620 a dict containing the artifact info. 621 a dict containing the global config info. 622 """ 623 artifact_info = {} 624 build_ids = [] 625 artifact_path = "" 626 if build_id == 'latest': 627 try: 628 build_list = self.GetBuildList( 629 account_id=account_id, 630 branch=branch, 631 target=target, 632 method=method) 633 for build in build_list: 634 build_ids.append(build["build_id"]) 635 except ValueError as e: 636 logging.exception(e) 637 else: 638 build_ids.append(build_id) 639 640 for build_id in build_ids: 641 _artifact_name = artifact_name 642 if "build_id" in _artifact_name: 643 _artifact_name = _artifact_name.format(build_id=build_id) 644 _artifact_name = "signed%2Fsigned-" + _artifact_name 645 try: 646 url = self.GetArtifactURL( 647 account_id=account_id, 648 build_id=build_id, 649 target=target, 650 artifact_name=_artifact_name, 651 branch=branch, 652 internal=False, 653 method=method) 654 except ValueError as e: 655 logging.exception(e) 656 continue 657 658 if self.tmp_dirpath: 659 artifact_path = os.path.join(self.tmp_dirpath, _artifact_name) 660 else: 661 artifact_path = _artifact_name 662 ret = self.DownloadArtifact(url, artifact_path) 663 664 if ret: 665 artifact_info["build_id"] = build_id 666 break 667 668 self.SetFetchedFile( 669 artifact_path, full_device_images=full_device_images) 670 671 return (self.GetDeviceImage(), self.GetTestSuitePackage(), 672 artifact_info, self.GetConfigPackage()) 673 674 def GetResponseWithURL(self, url): 675 """Gets the response content from the server connected with the url. 676 677 Args: 678 url: A string representing the server url. 679 680 Returns: 681 A Response object received from the server. 682 683 Raises: 684 requests.HTTPError if response.status_code is not 200. 685 requests.exceptions.Timeout if the server does not respond. 686 """ 687 headers = {} 688 self._credentials.apply(headers) 689 response = requests.get(url, headers=headers, stream=True, 690 timeout=REQUESTS_TIMEOUT_SECONDS) 691 response.raise_for_status() 692 693 return response 694 695 def FetchLatestBuiltHCPackage(self, account_id, branch, target): 696 """Fetchs the latest <artifact_name> file and return the path. 697 698 Args: 699 account_id: string, Partner Android Build account_id to use. 700 branch: string, branch to grab the artifact from. 701 targets: string, a comma-separate list of build target product(s). 702 703 Returns: 704 path to the fetched file. None if the fetching has failed. 705 """ 706 try: 707 listed_builds = self.GetBuildList( 708 account_id=account_id, 709 branch=branch, 710 target=target, 711 page_token="", 712 max_results=1, 713 method="GET") 714 715 if listed_builds and len(listed_builds) > 0: 716 for listed_build in listed_builds: 717 if listed_build["successful"]: 718 self.GetArtifact( 719 account_id=account_id, 720 branch=branch, 721 target=target, 722 artifact_name="android-vtslab.zip", 723 build_id=listed_build["build_id"], 724 method="GET") 725 726 return self.GetHostControllerPackage("vtslab") 727 except ValueError as e: 728 logging.exception(e) 729