# -*- 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. """Manage and stream build logs from Cloud Builds.""" from __future__ import absolute_import from __future__ import division from __future__ import print_function from __future__ import unicode_literals import collections import re import threading import time from apitools.base.py import exceptions as api_exceptions from googlecloudsdk.api_lib.cloudbuild import cloudbuild_util from googlecloudsdk.api_lib.logging import common from googlecloudsdk.core import exceptions from googlecloudsdk.core import log from googlecloudsdk.core import properties from googlecloudsdk.core import resources from googlecloudsdk.core.console import console_attr_os from googlecloudsdk.core.credentials import requests as creds_requests from googlecloudsdk.core.util import encoding import requests LOG_STREAM_HELP_TEXT = """ To live stream log output for this build, please ensure the grpc module is installed. Run: pip install grpcio and set: export CLOUDSDK_PYTHON_SITEPACKAGES=1 """ DEFAULT_LOGS_BUCKET_IS_OUTSIDE_SECURITY_PERIMETER_TEXT = """ The build is running, and logs are being written to the default logs bucket. This tool can only stream logs if you are Viewer/Owner of the project and, if applicable, allowed by your VPC-SC security policy. The default logs bucket is always outside any VPC-SC security perimeter. If you want your logs saved inside your VPC-SC perimeter, use your own bucket. See https://cloud.google.com/build/docs/securing-builds/store-manage-build-logs. """ class NoLogsBucketException(exceptions.Error): def __init__(self): msg = 'Build does not specify logsBucket, unable to stream logs' super(NoLogsBucketException, self).__init__(msg) class DefaultLogsBucketIsOutsideSecurityPerimeterException(exceptions.Error): def __init__(self): super(DefaultLogsBucketIsOutsideSecurityPerimeterException, self).__init__(DEFAULT_LOGS_BUCKET_IS_OUTSIDE_SECURITY_PERIMETER_TEXT) Response = collections.namedtuple('Response', ['status', 'headers', 'body']) class RequestsLogTailer(object): """LogTailer transport to make HTTP requests using requests.""" def __init__(self): self.session = creds_requests.GetSession() def Request(self, url, cursor): try: response = self.session.request( 'GET', url, headers={'Range': 'bytes={0}-'.format(cursor)}) return Response(response.status_code, response.headers, response.content) except requests.exceptions.RequestException as e: raise api_exceptions.CommunicationError('Failed to connect: %s' % e) def GetGCLLogTailer(): """Return a GCL LogTailer.""" try: # pylint: disable=g-import-not-at-top from googlecloudsdk.api_lib.logging import tailing # pylint: enable=g-import-not-at-top except ImportError: log.out.Print(LOG_STREAM_HELP_TEXT) return None return tailing.LogTailer() def IsCB4A(build): """Separate CB4A requests to print different logs.""" if build.options: if build.options.cluster: return bool(build.options.cluster.name) elif build.options.anthosCluster: return bool(build.options.anthosCluster.membership) return False class TailerBase(object): """Base class for log tailer classes.""" LOG_OUTPUT_BEGIN = ' REMOTE BUILD OUTPUT ' OUTPUT_LINE_CHAR = '-' def _ValidateScreenReader(self, text): """Modify output for better screen reader experience.""" screen_reader = properties.VALUES.accessibility.screen_reader.GetBool() if screen_reader: return re.sub('---> ', '', text) return text def _PrintLogLine(self, text): """Testing Hook: This method enables better verification of output.""" if self.out and text: self.out.Print(text.rstrip()) def _PrintFirstLine(self, msg=LOG_OUTPUT_BEGIN): """Print a pretty starting line to identify start of build output logs.""" width, _ = console_attr_os.GetTermSize() self._PrintLogLine(msg.center(width, self.OUTPUT_LINE_CHAR)) def _PrintLastLine(self, msg=''): """Print a pretty ending line to identify end of build output logs.""" width, _ = console_attr_os.GetTermSize() # We print an extra blank visually separating the log from other output. self._PrintLogLine(msg.center(width, self.OUTPUT_LINE_CHAR) + '\n') class GCLLogTailer(TailerBase): """Helper class to tail logs from GCL, printing content as available.""" def __init__(self, buildId, projectId, timestamp, logUrl=None, out=log.status, is_cb4a=False): self.tailer = GetGCLLogTailer() self.build_id = buildId self.project_id = projectId self.timestamp = timestamp self.out = out self.buffer_window_seconds = 2 self.log_url = logUrl self.stop = False self.is_cb4a = is_cb4a @classmethod def FromBuild(cls, build, out=log.out): """Build a GCLLogTailer from a build resource. Args: build: Build resource, The build whose logs shall be streamed. out: The output stream to write the logs to. Returns: GCLLogTailer, the tailer of this build's logs. """ return cls( buildId=build.id, projectId=build.projectId, timestamp=build.createTime, logUrl=build.logUrl, out=out, is_cb4a=IsCB4A(build)) def Tail(self): """Tail the GCL logs and print any new bytes to the console.""" if not self.tailer: return if self.stop: return parent = 'projects/{project_id}'.format(project_id=self.project_id) log_filter = ('logName="projects/{project_id}/logs/cloudbuild" AND ' 'resource.type="build" AND ' 'resource.labels.build_id="{build_id}"').format( project_id=self.project_id, build_id=self.build_id) if self.is_cb4a: # The labels starting with 'k8s-pod/' in the log entries from GKE-on-GCP # clusters are different from other labels. The dots '.' in the labels are # converted to '_'. For example, 'k8s-pod/tekton.dev/taskRun' is # converted to 'k8s-pod/tekton_dev/taskRun'. log_filter = ('labels."k8s-pod/tekton.dev/taskRun"="{build_id}" OR ' 'labels."k8s-pod/tekton_dev/taskRun"="{build_id}"').format( build_id=self.build_id) output_logs = self.tailer.TailLogs( [parent], log_filter, buffer_window_seconds=self.buffer_window_seconds) self._PrintFirstLine() for output in output_logs: text = self._ValidateScreenReader(output.text_payload) self._PrintLogLine(text) self._PrintLastLine(' BUILD FINISHED; TRUNCATING OUTPUT LOGS ') if self.log_url: self._PrintLogLine( 'Logs are available at [{log_url}].'.format(log_url=self.log_url)) return def Stop(self): """Stop log tailing.""" self.stop = True # Sleep to allow the Tailing API to send the last logs it buffered up time.sleep(self.buffer_window_seconds) if self.tailer: self.tailer.Stop() def Print(self): """Print GCL logs to the console.""" parent = 'projects/{project_id}'.format(project_id=self.project_id) log_filter = ( 'logName="projects/{project_id}/logs/cloudbuild" AND ' 'resource.type="build" AND ' # timestamp needed for faster querying in GCL 'timestamp>="{timestamp}" AND ' 'resource.labels.build_id="{build_id}"').format( project_id=self.project_id, timestamp=self.timestamp, build_id=self.build_id) if self.is_cb4a: # The labels starting with 'k8s-pod/' in the log entries from GKE-on-GCP # clusters are different from other labels. The dots '.' in the labels are # converted to '_'. For example, 'k8s-pod/tekton.dev/taskRun' is # converted to 'k8s-pod/tekton_dev/taskRun'. log_filter = ('(labels."k8s-pod/tekton.dev/taskRun"="{build_id}" OR ' 'labels."k8s-pod/tekton_dev/taskRun"="{build_id}") AND ' 'timestamp>="{timestamp}"').format( build_id=self.build_id, timestamp=self.timestamp) output_logs = common.FetchLogs( log_filter=log_filter, order_by='asc', parent=parent) self._PrintFirstLine() for output in output_logs: text = self._ValidateScreenReader(output.textPayload) self._PrintLogLine(text) self._PrintLastLine() class GCSLogTailer(TailerBase): """Helper class to tail a GCS logfile, printing content as available.""" LOG_OUTPUT_INCOMPLETE = ' (possibly incomplete) ' def __init__(self, bucket, obj, out=log.status): self.transport = RequestsLogTailer() self.url = self._StorageUrl(bucket, obj) log.debug('GCS logfile url is ' + self.url) # position in the file being read self.cursor = 0 self.out = out self.stop = False def _StorageUrl(self, bucket, obj): universe_domain = properties.VALUES.core.universe_domain.Get() url_pattern = 'https://storage.{universe_domain}/{bucket}/{obj}' if properties.VALUES.context_aware.use_client_certificate.GetBool(): # mTLS is enabled. url_pattern = 'https://storage.mtls.{universe_domain}/{bucket}/{obj}' return url_pattern.format( universe_domain=universe_domain, bucket=bucket, obj=obj) @classmethod def FromBuild(cls, build, out=log.out): """Build a GCSLogTailer from a build resource. Args: build: Build resource, The build whose logs shall be streamed. out: The output stream to write the logs to. Raises: NoLogsBucketException: If the build does not specify a logsBucket. Returns: GCSLogTailer, the tailer of this build's logs. """ if not build.logsBucket: raise NoLogsBucketException() # remove gs:// prefix from bucket log_stripped = build.logsBucket gcs_prefix = 'gs://' if log_stripped.startswith(gcs_prefix): log_stripped = log_stripped[len(gcs_prefix):] if '/' not in log_stripped: log_bucket = log_stripped log_object_dir = '' else: [log_bucket, log_object_dir] = log_stripped.split('/', 1) log_object_dir += '/' log_object = '{object}log-{id}.txt'.format( object=log_object_dir, id=build.id, ) return cls( bucket=log_bucket, obj=log_object, out=out) def Poll(self, is_last=False): """Poll the GCS object and print any new bytes to the console. Args: is_last: True if this is the final poll operation. Raises: api_exceptions.HttpError: if there is trouble connecting to GCS. api_exceptions.CommunicationError: if there is trouble reaching the server and is_last=True. """ try: res = self.transport.Request(self.url, self.cursor) except api_exceptions.CommunicationError: # Sometimes this request fails due to read timeouts (b/121307719). When # this happens we should just proceed and rely on the next poll to pick # up any missed logs. If this is the last request, there won't be another # request, and we can just fail. if is_last: raise return if res.status == 404: # Not Found # Logfile hasn't been written yet (ie, build hasn't started). log.debug('Reading GCS logfile: 404 (no log yet; keep polling)') return if res.status == 416: # Requested Range Not Satisfiable # We have consumed all available data. We'll get this a lot as we poll. log.debug('Reading GCS logfile: 416 (no new content; keep polling)') if is_last: self._PrintLastLine() return if res.status == 206 or res.status == 200: # Partial Content # New content available. Print it! log.debug('Reading GCS logfile: {code} (read {count} bytes)'.format( code=res.status, count=len(res.body))) if self.cursor == 0: self._PrintFirstLine() self.cursor += len(res.body) decoded = encoding.Decode(res.body) if decoded is not None: decoded = self._ValidateScreenReader(decoded) self._PrintLogLine(decoded.rstrip('\n')) if is_last: self._PrintLastLine() return # For 429/503, there isn't much to do other than retry on the next poll. # If we get a 429 after the build has completed, the user may get incomplete # logs. This is expected to be rare enough to not justify building a complex # exponential retry system. if res.status == 429: # Too Many Requests log.warning('Reading GCS logfile: 429 (server is throttling us)') if is_last: self._PrintLastLine(self.LOG_OUTPUT_INCOMPLETE) return if res.status >= 500 and res.status < 600: # Server Error log.warning('Reading GCS logfile: got {0}, retrying'.format(res.status)) if is_last: self._PrintLastLine(self.LOG_OUTPUT_INCOMPLETE) return # Default: any other codes are treated as errors. headers = dict(res.headers) headers['status'] = res.status raise api_exceptions.HttpError(headers, res.body, self.url) def Tail(self): """Tail the GCS object and print any new bytes to the console.""" while not self.stop: self.Poll() time.sleep(1) # 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. self.Poll(is_last=True) def Stop(self): """Stop log tailing.""" self.stop = True def Print(self): """Print GCS logs to the console.""" self.Poll(is_last=True) class ThreadInterceptor(threading.Thread): """Wrapper to intercept thread exceptions.""" def __init__(self, target): super(ThreadInterceptor, self).__init__() self.target = target self.exception = None def run(self): try: self.target() except api_exceptions.HttpError as e: if e.status_code == 403: # The only way to successfully create a build and then be unable to read # the logs bucket is if you are using the default logs bucket and # VPC-SC. self.exception = DefaultLogsBucketIsOutsideSecurityPerimeterException() else: self.exception = e except api_exceptions.CommunicationError as e: self.exception = e class CloudBuildClient(object): """Client for interacting with the Cloud Build API (and Cloud Build logs).""" def __init__(self, client=None, messages=None, support_gcl=False, polling_interval=1): self.client = client or cloudbuild_util.GetClientInstance() self.messages = messages or cloudbuild_util.GetMessagesModule() self.support_gcl = support_gcl self.polling_interval = polling_interval def GetBuild(self, build_ref): """Get a Build message. Args: build_ref: Build reference. Expects a cloudbuild.projects.locations.builds but also supports cloudbuild.projects.builds. Returns: Build resource """ # Legacy build_refs (for cloudbuild.projects.builds) don't have a location # attached. Convert to the expected type and add the default location. if build_ref.Collection() == 'cloudbuild.projects.builds': build_ref = resources.REGISTRY.Create( collection='cloudbuild.projects.locations.builds', projectsId=build_ref.projectId, locationsId=cloudbuild_util.DEFAULT_REGION, buildsId=build_ref.id) return self.client.projects_locations_builds.Get( self.messages.CloudbuildProjectsLocationsBuildsGetRequest( name=build_ref.RelativeName())) def ShouldStopTailer(self, build, build_ref, log_tailer, working_statuses): """Checks whether a log tailer should be stopped. Args: build: Build object, containing build status build_ref: Build reference, The build whose logs shall be streamed. log_tailer: Specific log tailer object working_statuses: Valid working statuses that define we should continue tailing Returns: Build message, the completed or terminated build. """ log.status.Print('Waiting for build to complete. Polling interval: ' + str(self.polling_interval) + ' second(s).') while build.status in working_statuses: build = self.GetBuild(build_ref) time.sleep(self.polling_interval) if log_tailer: log_tailer.Stop() return build def Stream(self, build_ref, out=log.out): """Streams the logs for a build if available. Regardless of whether logs are available for streaming, awaits build completion before returning. Args: build_ref: Build reference, The build whose logs shall be streamed. out: The output stream to write the logs to. Raises: NoLogsBucketException: If the build is expected to specify a logsBucket but does not. Returns: Build message, the completed or terminated build. """ build = self.GetBuild(build_ref) if not build.options or build.options.logging not in [ self.messages.BuildOptions.LoggingValueValuesEnum.NONE, self.messages.BuildOptions.LoggingValueValuesEnum.STACKDRIVER_ONLY, self.messages.BuildOptions.LoggingValueValuesEnum.CLOUD_LOGGING_ONLY, ]: log_tailer = GCSLogTailer.FromBuild(build, out=out) elif build.options.logging in [ self.messages.BuildOptions.LoggingValueValuesEnum.STACKDRIVER_ONLY, self.messages.BuildOptions.LoggingValueValuesEnum.CLOUD_LOGGING_ONLY, ] and self.support_gcl: log.info('Streaming logs from GCL: requested logging mode is {0}.'.format( build.options.logging)) log_tailer = GCLLogTailer.FromBuild(build, out=out) else: log.info('Not streaming logs: requested logging mode is {0}.'.format( build.options.logging)) log_tailer = None statuses = self.messages.Build.StatusValueValuesEnum working_statuses = [ statuses.QUEUED, statuses.WORKING, ] t = None if log_tailer: t = ThreadInterceptor(target=log_tailer.Tail) t.start() build = self.ShouldStopTailer(build, build_ref, log_tailer, working_statuses) if t: t.join() if t.exception is not None: raise t.exception return build def PrintLog(self, build_ref): """Print the logs for a build. Args: build_ref: Build reference, The build whose logs shall be streamed. Raises: NoLogsBucketException: If the build does not specify a logsBucket. """ build = self.GetBuild(build_ref) if not build.options or build.options.logging not in [ self.messages.BuildOptions.LoggingValueValuesEnum.NONE, self.messages.BuildOptions.LoggingValueValuesEnum.STACKDRIVER_ONLY, self.messages.BuildOptions.LoggingValueValuesEnum.CLOUD_LOGGING_ONLY, ]: log_tailer = GCSLogTailer.FromBuild(build) elif build.options.logging in [ self.messages.BuildOptions.LoggingValueValuesEnum.STACKDRIVER_ONLY, self.messages.BuildOptions.LoggingValueValuesEnum.CLOUD_LOGGING_ONLY, ]: log.info('Printing logs from GCL: requested logging mode is {0}.'.format( build.options.logging)) log_tailer = GCLLogTailer.FromBuild(build) else: log.info('Logs not available: build logging mode is {0}.'.format( build.options.logging)) log_tailer = None if log_tailer: log_tailer.Print()