# -*- coding: utf-8 -*- # # Copyright 2016 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """High-level client for interacting with the Cloud Build API.""" from __future__ import absolute_import from __future__ import division from __future__ import unicode_literals import json import time from apitools.base.py import encoding from googlecloudsdk.api_lib.cloudbuild import cloudbuild_util from googlecloudsdk.api_lib.cloudbuild import logs as cloudbuild_logs from googlecloudsdk.api_lib.util import requests from googlecloudsdk.core import exceptions from googlecloudsdk.core import log from googlecloudsdk.core import properties from six.moves import range # pylint: disable=redefined-builtin _ERROR_FORMAT_STRING = ('Error Response:{status_code? [{?}]}' '{status_message? {?}}{url?\n{?}}' '{details?\n\nDetails:\n{?}}') def GetBuildProp(build_op, prop_key, required=False): """Extract the value of a build's prop_key from a build operation. Args: build_op: A Google Cloud Builder build operation. prop_key: str, The property name. required: If True, raise an OperationError if prop_key isn't present. Returns: The corresponding build operation value indexed by prop_key. Raises: OperationError: The required prop_key was not found. """ if build_op.metadata is not None: for prop in build_op.metadata.additionalProperties: if prop.key == 'build': for build_prop in prop.value.object_value.properties: if build_prop.key == prop_key: string_value = build_prop.value.string_value return string_value or build_prop.value if required: raise OperationError('Build operation does not contain required ' 'property [{}]'.format(prop_key)) def _GetStatusFromOp(op): """Get the Cloud Build Status from an Operation object. The op.response field is supposed to have a copy of the build object; however, the wire JSON from the server doesn't get deserialized into an actual build object. Instead, it is stored as a generic ResponseValue object, so we have to root around a bit. Args: op: the Operation object from a CloudBuild build request. Returns: string status, likely "SUCCESS" or "ERROR". """ if op.response and op.response.additionalProperties: for prop in op.response.additionalProperties: if prop.key == 'status': return prop.value.string_value return 'UNKNOWN' class BuildFailedError(exceptions.Error): """Raised when a Google Cloud Builder build fails.""" class OperationTimeoutError(exceptions.Error): """Raised when an operation times out.""" pass class OperationError(exceptions.Error): """Raised when an operation contains an error.""" pass class CloudBuildClient(object): """High-level client for interacting with the Cloud Build API.""" _RETRY_INTERVAL = 1 _MAX_RETRIES = 60 * 60 CLOUDBUILD_SUCCESS = 'SUCCESS' CLOUDBUILD_LOGFILE_FMT_STRING = 'log-{build_id}.txt' def __init__(self, client=None, messages=None): self.client = client or cloudbuild_util.GetClientInstance() self.messages = messages or cloudbuild_util.GetMessagesModule() def ExecuteCloudBuildAsync(self, build, project=None): """Execute a call to CloudBuild service and return the build operation. Args: build: Build object. The Build to execute. project: The project to execute, or None to use the current project property. Raises: BuildFailedError: when the build fails. Returns: build_op, an in-progress build operation. """ if project is None: project = properties.VALUES.core.project.Get(required=True) build_op = self.client.projects_builds.Create( self.messages.CloudbuildProjectsBuildsCreateRequest( projectId=project, build=build,)) return build_op def ExecuteCloudBuild(self, build, project=None): """Execute a call to CloudBuild service and wait for it to finish. Args: build: Build object. The Build to execute. project: The project to execute, or None to use the current project property. Raises: BuildFailedError: when the build fails. """ build_op = self.ExecuteCloudBuildAsync(build, project) self.WaitAndStreamLogs(build_op) def WaitAndStreamLogs(self, build_op): """Wait for a Cloud Build to finish, streaming logs if possible.""" build_id = GetBuildProp(build_op, 'id', required=True) logs_uri = GetBuildProp(build_op, 'logUrl') logs_bucket = GetBuildProp(build_op, 'logsBucket') log.status.Print( 'Started cloud build [{build_id}].'.format(build_id=build_id)) log_loc = 'in the Cloud Console.' log_tailer = None if logs_bucket: log_object = self.CLOUDBUILD_LOGFILE_FMT_STRING.format(build_id=build_id) log_tailer = cloudbuild_logs.GCSLogTailer( bucket=logs_bucket, obj=log_object) if logs_uri: log.status.Print('To see logs in the Cloud Console: ' + logs_uri) log_loc = 'at ' + logs_uri else: log.status.Print('Logs can be found in the Cloud Console.') callback = None if log_tailer: callback = log_tailer.Poll try: op = self.WaitForOperation(operation=build_op, retry_callback=callback) except OperationTimeoutError: log.debug('', exc_info=True) raise BuildFailedError('Cloud build timed out. Check logs ' + log_loc) # Poll the logs one final time to ensure we have everything. We know this # final poll will get the full log contents because GCS is strongly # consistent and Cloud Build waits for logs to finish pushing before # marking the build complete. if log_tailer: log_tailer.Poll(is_last=True) final_status = _GetStatusFromOp(op) if final_status != self.CLOUDBUILD_SUCCESS: message = requests.ExtractErrorMessage( encoding.MessageToPyValue(op.error)) raise BuildFailedError('Cloud build failed. Check logs ' + log_loc + ' Failure status: ' + final_status + ': ' + message) def WaitForOperation(self, operation, retry_callback=None): """Wait until the operation is complete or times out. This does not use the core api_lib.util.waiter because the cloud build logs serve as a progress tracker. Args: operation: The operation resource to wait on retry_callback: A callback to be executed before each retry, if desired. Returns: The operation resource when it has completed Raises: OperationTimeoutError: when the operation polling times out """ completed_operation = self._PollUntilDone(operation, retry_callback) if not completed_operation: raise OperationTimeoutError(('Operation [{0}] timed out. This operation ' 'may still be underway.').format( operation.name)) return completed_operation def _PollUntilDone(self, operation, retry_callback): """Polls the operation resource until it is complete or times out.""" if operation.done: return operation request_type = self.client.operations.GetRequestType('Get') request = request_type(name=operation.name) for _ in range(self._MAX_RETRIES): operation = self.client.operations.Get(request) if operation.done: log.debug('Operation [{0}] complete. Result: {1}'.format( operation.name, json.dumps(encoding.MessageToDict(operation), indent=4))) return operation log.debug('Operation [{0}] not complete. Waiting {1}s.'.format( operation.name, self._RETRY_INTERVAL)) time.sleep(self._RETRY_INTERVAL) if retry_callback is not None: retry_callback() return None