1098 lines
41 KiB
Python
1098 lines
41 KiB
Python
# -*- 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.
|
|
|
|
"""Debug apis layer."""
|
|
|
|
from __future__ import absolute_import
|
|
from __future__ import division
|
|
from __future__ import unicode_literals
|
|
|
|
import re
|
|
import threading
|
|
|
|
from apitools.base.py import exceptions as apitools_exceptions
|
|
|
|
from googlecloudsdk.api_lib.debug import errors
|
|
from googlecloudsdk.api_lib.util import apis
|
|
from googlecloudsdk.core import config
|
|
from googlecloudsdk.core import log
|
|
from googlecloudsdk.core import resources
|
|
from googlecloudsdk.core.util import retry
|
|
|
|
import six
|
|
from six.moves import urllib
|
|
|
|
# Names for default module and version. In App Engine, the default module and
|
|
# version don't report explicit names to the debugger, so use these strings
|
|
# instead when displaying the target name. Note that this code assumes there
|
|
# will not be a non-default version or module explicitly named 'default', since
|
|
# that would result in a naming conflict between the actual default and the
|
|
# one named 'default'.
|
|
DEFAULT_MODULE = 'default'
|
|
DEFAULT_VERSION = 'default'
|
|
|
|
|
|
def SplitLogExpressions(format_string):
|
|
"""Extracts {expression} substrings into a separate array.
|
|
|
|
Each substring of the form {expression} will be extracted into an array, and
|
|
each {expression} substring will be replaced with $N, where N is the index
|
|
of the extraced expression in the array. Any '$' sequence outside an
|
|
expression will be escaped with '$$'.
|
|
|
|
For example, given the input:
|
|
'a={a}, b={b}'
|
|
The return value would be:
|
|
('a=$0, b=$1', ['a', 'b'])
|
|
|
|
Args:
|
|
format_string: The string to process.
|
|
Returns:
|
|
string, [string] - The new format string and the array of expressions.
|
|
Raises:
|
|
InvalidLogFormatException: if the string has unbalanced braces.
|
|
"""
|
|
expressions = []
|
|
log_format = ''
|
|
current_expression = ''
|
|
brace_count = 0
|
|
need_separator = False
|
|
for c in format_string:
|
|
if need_separator and c.isdigit():
|
|
log_format += ' '
|
|
need_separator = False
|
|
if c == '{':
|
|
if brace_count:
|
|
# Nested braces
|
|
current_expression += c
|
|
else:
|
|
# New expression
|
|
current_expression = ''
|
|
brace_count += 1
|
|
elif not brace_count:
|
|
if c == '}':
|
|
# Unbalanced left brace.
|
|
raise errors.InvalidLogFormatException(
|
|
'There are too many "}" characters in the log format string')
|
|
elif c == '$':
|
|
# Escape '$'
|
|
log_format += '$$'
|
|
else:
|
|
# Not in or starting an expression.
|
|
log_format += c
|
|
else:
|
|
# Currently reading an expression.
|
|
if c != '}':
|
|
current_expression += c
|
|
continue
|
|
brace_count -= 1
|
|
if brace_count == 0:
|
|
# Finish processing the expression
|
|
if current_expression in expressions:
|
|
i = expressions.index(current_expression)
|
|
else:
|
|
i = len(expressions)
|
|
expressions.append(current_expression)
|
|
log_format += '${0}'.format(i)
|
|
# If the next character is a digit, we need an extra space to prevent
|
|
# the agent from combining the positional argument with the subsequent
|
|
# digits.
|
|
need_separator = True
|
|
else:
|
|
# Closing a nested brace
|
|
current_expression += c
|
|
|
|
if brace_count:
|
|
# Unbalanced left brace.
|
|
raise errors.InvalidLogFormatException(
|
|
'There are too many "{" characters in the log format string')
|
|
return log_format, expressions
|
|
|
|
|
|
def MergeLogExpressions(log_format, expressions):
|
|
"""Replaces each $N substring with the corresponding {expression}.
|
|
|
|
This function is intended for reconstructing an input expression string that
|
|
has been split using SplitLogExpressions. It is not intended for substituting
|
|
the expression results at log time.
|
|
|
|
Args:
|
|
log_format: A string containing 0 or more $N substrings, where N is any
|
|
valid index into the expressions array. Each such substring will be
|
|
replaced by '{expression}', where "expression" is expressions[N].
|
|
expressions: The expressions to substitute into the format string.
|
|
Returns:
|
|
The combined string.
|
|
"""
|
|
def GetExpression(m):
|
|
try:
|
|
return '{{{0}}}'.format(expressions[int(m.group(0)[1:])])
|
|
except IndexError:
|
|
return m.group(0)
|
|
|
|
parts = log_format.split('$$')
|
|
return '$'.join(re.sub(r'\$\d+', GetExpression, part) for part in parts)
|
|
|
|
|
|
def DebugViewUrl(breakpoint):
|
|
"""Returns a URL to view a breakpoint in the browser.
|
|
|
|
Given a breakpoint, this transform will return a URL which will open the
|
|
snapshot's location in a debug view pointing at the snapshot.
|
|
|
|
Args:
|
|
breakpoint: A breakpoint object with added information on project and
|
|
debug target.
|
|
Returns:
|
|
The URL for the breakpoint.
|
|
"""
|
|
debug_view_url = 'https://console.cloud.google.com/debug/fromgcloud?'
|
|
data = [
|
|
('project', breakpoint.project),
|
|
('dbgee', breakpoint.target_id),
|
|
('bp', breakpoint.id)
|
|
]
|
|
return debug_view_url + urllib.parse.urlencode(data)
|
|
|
|
|
|
def LogQueryV2String(breakpoint, separator=' '):
|
|
"""Returns an advanced log query string for use with gcloud logging read.
|
|
|
|
Args:
|
|
breakpoint: A breakpoint object with added information on project, service,
|
|
and debug target.
|
|
separator: A string to append between conditions
|
|
Returns:
|
|
A log query suitable for use with gcloud logging read.
|
|
Raises:
|
|
InvalidLogFormatException if the breakpoint has an invalid log expression.
|
|
"""
|
|
query = (
|
|
'resource.type=gae_app{sep}'
|
|
'logName:request_log{sep}'
|
|
'resource.labels.module_id="{service}"{sep}'
|
|
'resource.labels.version_id="{version}"{sep}'
|
|
'severity={logLevel}').format(
|
|
service=breakpoint.service, version=breakpoint.version,
|
|
logLevel=breakpoint.logLevel or 'INFO', sep=separator)
|
|
if breakpoint.logMessageFormat:
|
|
# Search for all of the non-expression components of the message.
|
|
# The re.sub converts the format to a series of quoted strings.
|
|
query += '{sep}"{text}"'.format(
|
|
text=re.sub(r'\$([0-9]+)', r'" "',
|
|
SplitLogExpressions(breakpoint.logMessageFormat)[0]),
|
|
sep=separator)
|
|
return query
|
|
|
|
|
|
def LogViewUrl(breakpoint):
|
|
"""Returns a URL to view the output for a logpoint.
|
|
|
|
Given a breakpoint in an appengine service, this transform will return a URL
|
|
which will open the log viewer to the request log for the service.
|
|
|
|
Args:
|
|
breakpoint: A breakpoint object with added information on project, service,
|
|
debug target, and logQuery.
|
|
Returns:
|
|
The URL for the appropriate logs.
|
|
"""
|
|
debug_view_url = 'https://console.cloud.google.com/logs?'
|
|
data = [
|
|
('project', breakpoint.project),
|
|
('advancedFilter', LogQueryV2String(breakpoint, separator='\n') + '\n')
|
|
]
|
|
return debug_view_url + urllib.parse.urlencode(data)
|
|
|
|
|
|
class DebugObject(object):
|
|
"""Base class for debug api wrappers."""
|
|
|
|
# Lock for remote calls in routines which might be multithreaded. Client
|
|
# connections are not thread-safe. Currently, only WaitForBreakpoint can
|
|
# be called from multiple threads.
|
|
_client_lock = threading.Lock()
|
|
|
|
# Breakpoint type name constants
|
|
SNAPSHOT_TYPE = 'SNAPSHOT'
|
|
LOGPOINT_TYPE = 'LOGPOINT'
|
|
|
|
def BreakpointAction(self, type_name):
|
|
if type_name == self.SNAPSHOT_TYPE:
|
|
return self._debug_messages.Breakpoint.ActionValueValuesEnum.CAPTURE
|
|
if type_name == self.LOGPOINT_TYPE:
|
|
return self._debug_messages.Breakpoint.ActionValueValuesEnum.LOG
|
|
raise errors.InvalidBreakpointTypeError(type_name)
|
|
|
|
CLIENT_VERSION = 'google.com/gcloud/{0}'.format(config.CLOUD_SDK_VERSION)
|
|
|
|
def __init__(self, debug_client=None, debug_messages=None,
|
|
resource_client=None, resource_messages=None):
|
|
"""Sets up class with instantiated api client."""
|
|
self._debug_client = (
|
|
debug_client or apis.GetClientInstance('clouddebugger', 'v2'))
|
|
self._debug_messages = (
|
|
debug_messages or apis.GetMessagesModule('clouddebugger', 'v2'))
|
|
self._resource_client = (
|
|
resource_client or
|
|
apis.GetClientInstance('cloudresourcemanager', 'v1beta1'))
|
|
self._resource_messages = (
|
|
resource_messages or
|
|
apis.GetMessagesModule('cloudresourcemanager', 'v1beta1'))
|
|
self._resource_parser = resources.REGISTRY.Clone()
|
|
self._resource_parser.RegisterApiByName('clouddebugger', 'v2')
|
|
|
|
|
|
class Debugger(DebugObject):
|
|
"""Abstracts Cloud Debugger service for a project."""
|
|
|
|
def __init__(self, project, debug_client=None, debug_messages=None,
|
|
resource_client=None, resource_messages=None):
|
|
super(Debugger, self).__init__(
|
|
debug_client=debug_client, debug_messages=debug_messages,
|
|
resource_client=resource_client, resource_messages=resource_messages)
|
|
self._project = project
|
|
|
|
def ListDebuggees(self, include_inactive=False, include_stale=False):
|
|
"""Lists all debug targets registered with the debug service.
|
|
|
|
Args:
|
|
include_inactive: If true, also include debuggees that are not currently
|
|
running.
|
|
include_stale: If false, filter out any debuggees that refer to
|
|
stale minor versions. A debugge represents a stale minor version if it
|
|
meets the following criteria:
|
|
1. It has a minorversion label.
|
|
2. All other debuggees with the same name (i.e., all debuggees with
|
|
the same module and version, in the case of app engine) have a
|
|
minorversion label.
|
|
3. The minorversion value for the debuggee is less than the
|
|
minorversion value for at least one other debuggee with the same
|
|
name.
|
|
Returns:
|
|
[Debuggee] A list of debuggees.
|
|
"""
|
|
request = self._debug_messages.ClouddebuggerDebuggerDebuggeesListRequest(
|
|
project=self._project, includeInactive=include_inactive,
|
|
clientVersion=self.CLIENT_VERSION)
|
|
try:
|
|
response = self._debug_client.debugger_debuggees.List(request)
|
|
except apitools_exceptions.HttpError as error:
|
|
raise errors.UnknownHttpError(error)
|
|
|
|
result = [Debuggee(debuggee) for debuggee in response.debuggees]
|
|
|
|
if not include_stale:
|
|
return _FilterStaleMinorVersions(result)
|
|
|
|
return result
|
|
|
|
def DefaultDebuggee(self):
|
|
"""Find the default debuggee.
|
|
|
|
Returns:
|
|
The default debug target, which is either the only target available
|
|
or the latest minor version of the application, if all targets have the
|
|
same module and version.
|
|
Raises:
|
|
errors.NoDebuggeeError if no debuggee was found.
|
|
errors.MultipleDebuggeesError if there is not a unique default.
|
|
"""
|
|
debuggees = self.ListDebuggees()
|
|
if len(debuggees) == 1:
|
|
# Just one possible target
|
|
return debuggees[0]
|
|
|
|
if not debuggees:
|
|
raise errors.NoDebuggeeError()
|
|
|
|
# More than one module or version. Can't determine the default target.
|
|
raise errors.MultipleDebuggeesError(None, debuggees)
|
|
|
|
def FindDebuggee(self, pattern=None):
|
|
"""Find the unique debuggee matching the given pattern.
|
|
|
|
Args:
|
|
pattern: A string containing a debuggee ID or a regular expression that
|
|
matches a single debuggee's name or description. If it matches any
|
|
debuggee name, the description will not be inspected.
|
|
Returns:
|
|
The matching Debuggee.
|
|
Raises:
|
|
errors.MultipleDebuggeesError if the pattern matches multiple debuggees.
|
|
errors.NoDebuggeeError if the pattern matches no debuggees.
|
|
"""
|
|
if not pattern:
|
|
debuggee = self.DefaultDebuggee()
|
|
log.status.write(
|
|
'Debug target not specified. Using default target: {0}\n'.format(
|
|
debuggee.name))
|
|
return debuggee
|
|
|
|
try:
|
|
# Look for active debuggees first, since there are usually very
|
|
# few of them compared to inactive debuggees.
|
|
all_debuggees = self.ListDebuggees()
|
|
return self._FilterDebuggeeList(all_debuggees, pattern)
|
|
except errors.NoDebuggeeError:
|
|
# Try looking at inactive debuggees
|
|
pass
|
|
all_debuggees = self.ListDebuggees(include_inactive=True,
|
|
include_stale=True)
|
|
return self._FilterDebuggeeList(all_debuggees, pattern)
|
|
|
|
def _FilterDebuggeeList(self, all_debuggees, pattern):
|
|
"""Finds the debuggee which matches the given pattern.
|
|
|
|
Args:
|
|
all_debuggees: A list of debuggees to search.
|
|
pattern: A string containing a debuggee ID or a regular expression that
|
|
matches a single debuggee's name or description. If it matches any
|
|
debuggee name, the description will not be inspected.
|
|
Returns:
|
|
The matching Debuggee.
|
|
Raises:
|
|
errors.MultipleDebuggeesError if the pattern matches multiple debuggees.
|
|
errors.NoDebuggeeError if the pattern matches no debuggees.
|
|
"""
|
|
if not all_debuggees:
|
|
raise errors.NoDebuggeeError()
|
|
|
|
latest_debuggees = _FilterStaleMinorVersions(all_debuggees)
|
|
|
|
# Find all debuggees specified by ID, plus all debuggees which are the
|
|
# latest minor version when specified by name.
|
|
debuggees = ([d for d in all_debuggees if d.target_id == pattern] +
|
|
[d for d in latest_debuggees if pattern == d.name])
|
|
if not debuggees:
|
|
# Try matching as an RE on name or description. Name and description
|
|
# share common substrings, so filter out duplicates.
|
|
match_re = re.compile(pattern)
|
|
debuggees = (
|
|
[d for d in latest_debuggees if match_re.search(d.name)] +
|
|
[d for d in latest_debuggees
|
|
if d.description and match_re.search(d.description)])
|
|
|
|
if not debuggees:
|
|
raise errors.NoDebuggeeError(pattern, debuggees=all_debuggees)
|
|
|
|
debuggee_ids = set(d.target_id for d in debuggees)
|
|
if len(debuggee_ids) > 1:
|
|
raise errors.MultipleDebuggeesError(pattern, debuggees)
|
|
|
|
# Just one possible target
|
|
return debuggees[0]
|
|
|
|
def RegisterDebuggee(self, description, uniquifier, agent_version=None):
|
|
"""Register a debuggee with the Cloud Debugger.
|
|
|
|
This method is primarily intended to simplify testing, since it registering
|
|
a debuggee is only a small part of the functionality of a debug agent, and
|
|
the rest of the API is not supported here.
|
|
Args:
|
|
description: A concise description of the debuggee.
|
|
uniquifier: A string uniquely identifying the debug target. Note that the
|
|
uniquifier distinguishes between different deployments of a service,
|
|
not between different replicas of a single deployment. I.e., all
|
|
replicas of a single deployment should report the same uniquifier.
|
|
agent_version: A string describing the program registering the debuggee.
|
|
Defaults to "google.com/gcloud/NNN" where NNN is the gcloud version.
|
|
Returns:
|
|
The registered Debuggee.
|
|
"""
|
|
if not agent_version:
|
|
agent_version = self.CLIENT_VERSION
|
|
request = self._debug_messages.RegisterDebuggeeRequest(
|
|
debuggee=self._debug_messages.Debuggee(
|
|
project=self._project, description=description,
|
|
uniquifier=uniquifier, agentVersion=agent_version))
|
|
try:
|
|
response = self._debug_client.controller_debuggees.Register(request)
|
|
except apitools_exceptions.HttpError as error:
|
|
raise errors.UnknownHttpError(error)
|
|
return Debuggee(response.debuggee)
|
|
|
|
|
|
class Debuggee(DebugObject):
|
|
"""Represents a single debuggee."""
|
|
|
|
def __init__(self, message, debug_client=None, debug_messages=None,
|
|
resource_client=None, resource_messages=None):
|
|
super(Debuggee, self).__init__(
|
|
debug_client=debug_client, debug_messages=debug_messages,
|
|
resource_client=resource_client, resource_messages=resource_messages)
|
|
self.project = message.project
|
|
self.agent_version = message.agentVersion
|
|
self.description = message.description
|
|
self.ext_source_contexts = message.extSourceContexts
|
|
self.target_id = message.id
|
|
self.is_disabled = message.isDisabled
|
|
self.is_inactive = message.isInactive
|
|
self.source_contexts = message.sourceContexts
|
|
self.status = message.status
|
|
self.target_uniquifier = message.uniquifier
|
|
self.labels = {}
|
|
if message.labels:
|
|
for l in message.labels.additionalProperties:
|
|
self.labels[l.key] = l.value
|
|
|
|
def __eq__(self, other):
|
|
return (isinstance(other, self.__class__) and
|
|
self.target_id == other.target_id)
|
|
|
|
def __ne__(self, other):
|
|
return not self.__eq__(other)
|
|
|
|
def __repr__(self):
|
|
return '<id={0}, name={1}{2}>'.format(
|
|
self.target_id, self.name, ', description={0}'.format(self.description)
|
|
if self.description else '')
|
|
|
|
@property
|
|
def service(self):
|
|
return self.labels.get('module', None)
|
|
|
|
@property
|
|
def version(self):
|
|
return self.labels.get('version', None)
|
|
|
|
@property
|
|
def minorversion(self):
|
|
return self.labels.get('minorversion', None)
|
|
|
|
@property
|
|
def name(self):
|
|
service = self.service
|
|
version = self.version
|
|
if service or version:
|
|
return (service or DEFAULT_MODULE) + '-' + (version or DEFAULT_VERSION)
|
|
return self.description
|
|
|
|
def _BreakpointDescription(self, restrict_to_type):
|
|
if not restrict_to_type:
|
|
return 'breakpoint'
|
|
elif restrict_to_type == self.SNAPSHOT_TYPE:
|
|
return 'snapshot'
|
|
else:
|
|
return 'logpoint'
|
|
|
|
def GetBreakpoint(self, breakpoint_id):
|
|
"""Gets the details for a breakpoint.
|
|
|
|
Args:
|
|
breakpoint_id: A breakpoint ID.
|
|
Returns:
|
|
The full Breakpoint message for the ID.
|
|
"""
|
|
request = (self._debug_messages.
|
|
ClouddebuggerDebuggerDebuggeesBreakpointsGetRequest(
|
|
breakpointId=breakpoint_id, debuggeeId=self.target_id,
|
|
clientVersion=self.CLIENT_VERSION))
|
|
try:
|
|
response = self._debug_client.debugger_debuggees_breakpoints.Get(request)
|
|
except apitools_exceptions.HttpError as error:
|
|
raise errors.UnknownHttpError(error)
|
|
return self.AddTargetInfo(response.breakpoint)
|
|
|
|
def DeleteBreakpoint(self, breakpoint_id):
|
|
"""Deletes a breakpoint.
|
|
|
|
Args:
|
|
breakpoint_id: A breakpoint ID.
|
|
"""
|
|
request = (self._debug_messages.
|
|
ClouddebuggerDebuggerDebuggeesBreakpointsDeleteRequest(
|
|
breakpointId=breakpoint_id, debuggeeId=self.target_id,
|
|
clientVersion=self.CLIENT_VERSION))
|
|
try:
|
|
self._debug_client.debugger_debuggees_breakpoints.Delete(request)
|
|
except apitools_exceptions.HttpError as error:
|
|
raise errors.UnknownHttpError(error)
|
|
|
|
def ListBreakpoints(self, location_regexp=None, resource_ids=None,
|
|
include_all_users=False, include_inactive=False,
|
|
restrict_to_type=None, full_details=False):
|
|
"""Returns all breakpoints matching the given IDs or patterns.
|
|
|
|
Lists all breakpoints for this debuggee, and returns every breakpoint
|
|
where the location field contains the given pattern or the ID is exactly
|
|
equal to the pattern (there can be at most one breakpoint matching by ID).
|
|
|
|
Args:
|
|
location_regexp: A list of regular expressions to compare against the
|
|
location ('path:line') of the breakpoints. If both location_regexp and
|
|
resource_ids are empty or None, all breakpoints will be returned.
|
|
resource_ids: Zero or more resource IDs in the form expected by the
|
|
resource parser. These breakpoints will be retrieved regardless
|
|
of the include_all_users or include_inactive flags
|
|
include_all_users: If true, search breakpoints created by all users.
|
|
include_inactive: If true, search breakpoints that are in the final state.
|
|
This option controls whether regular expressions can match inactive
|
|
breakpoints. If an object is specified by ID, it will be returned
|
|
whether or not this flag is set.
|
|
restrict_to_type: An optional breakpoint type (LOGPOINT_TYPE or
|
|
SNAPSHOT_TYPE)
|
|
full_details: If true, issue a GetBreakpoint request for every result to
|
|
get full details including the call stack and variable table.
|
|
Returns:
|
|
A list of all matching breakpoints.
|
|
Raises:
|
|
InvalidLocationException if a regular expression is not valid.
|
|
"""
|
|
resource_ids = resource_ids or []
|
|
location_regexp = location_regexp or []
|
|
ids = set(
|
|
[self._resource_parser.Parse(
|
|
r, params={'debuggeeId': self.target_id},
|
|
collection='clouddebugger.debugger.debuggees.breakpoints').Name()
|
|
for r in resource_ids])
|
|
patterns = []
|
|
for r in location_regexp:
|
|
try:
|
|
patterns.append(re.compile(r'^(.*/)?(' + r + ')$'))
|
|
except re.error as e:
|
|
raise errors.InvalidLocationException(
|
|
'The location pattern "{0}" is not a valid Python regular '
|
|
'expression: {1}'.format(r, e))
|
|
|
|
request = (self._debug_messages.
|
|
ClouddebuggerDebuggerDebuggeesBreakpointsListRequest(
|
|
debuggeeId=self.target_id,
|
|
includeAllUsers=include_all_users,
|
|
includeInactive=include_inactive or bool(ids),
|
|
clientVersion=self.CLIENT_VERSION))
|
|
try:
|
|
response = self._debug_client.debugger_debuggees_breakpoints.List(request)
|
|
except apitools_exceptions.HttpError as error:
|
|
raise errors.UnknownHttpError(error)
|
|
if not patterns and not ids:
|
|
return self._FilteredDictListWithInfo(response.breakpoints,
|
|
restrict_to_type)
|
|
|
|
if include_inactive:
|
|
# Match everything (including inactive breakpoints) against all ids and
|
|
# patterns.
|
|
result = [bp for bp in response.breakpoints
|
|
if _BreakpointMatchesIdOrRegexp(bp, ids, patterns)]
|
|
else:
|
|
# Return everything that is listed by ID, plus every breakpoint that
|
|
# is not inactive (i.e. isFinalState is false) which matches any pattern.
|
|
# Breakpoints that are inactive should not be matched against the
|
|
# patterns.
|
|
result = [bp for bp in response.breakpoints
|
|
if _BreakpointMatchesIdOrRegexp(
|
|
bp, ids, [] if bp.isFinalState else patterns)]
|
|
# Check if any ids were missing, and fetch them individually. This can
|
|
# happen if an ID for another user's breakpoint was specified, but the
|
|
# all_users flag was false. This code will also raise an error for any
|
|
# missing IDs.
|
|
missing_ids = ids - set([bp.id for bp in result])
|
|
if missing_ids:
|
|
raise errors.BreakpointNotFoundError(
|
|
missing_ids, self._BreakpointDescription(restrict_to_type))
|
|
|
|
# Verify that all patterns matched at least one breakpoint.
|
|
for p in patterns:
|
|
if not [bp for bp in result
|
|
if _BreakpointMatchesIdOrRegexp(bp, [], [p])]:
|
|
raise errors.NoMatchError(self._BreakpointDescription(restrict_to_type),
|
|
p.pattern)
|
|
result = self._FilteredDictListWithInfo(result, restrict_to_type)
|
|
if full_details:
|
|
def IsCompletedSnapshot(bp):
|
|
return ((not bp.action or
|
|
bp.action == self.BreakpointAction(self.SNAPSHOT_TYPE)) and
|
|
bp.isFinalState and not (bp.status and bp.status.isError))
|
|
result = [
|
|
self.GetBreakpoint(bp.id) if IsCompletedSnapshot(bp) else bp
|
|
for bp in result
|
|
]
|
|
return result
|
|
|
|
def CreateSnapshot(self, location, condition=None, expressions=None,
|
|
user_email=None, labels=None):
|
|
"""Creates a "snapshot" breakpoint.
|
|
|
|
Args:
|
|
location: The breakpoint source location, which will be interpreted by
|
|
the debug agents on the machines running the Debuggee. Usually of the
|
|
form file:line-number
|
|
condition: An optional conditional expression in the target's programming
|
|
language. The snapshot will be taken when the expression is true.
|
|
expressions: A list of expressions to evaluate when the snapshot is
|
|
taken.
|
|
user_email: The email of the user who created the snapshot.
|
|
labels: A dictionary containing key-value pairs which will be stored
|
|
with the snapshot definition and reported when the snapshot is queried.
|
|
Returns:
|
|
The created Breakpoint message.
|
|
"""
|
|
labels_value = None
|
|
if labels:
|
|
labels_value = self._debug_messages.Breakpoint.LabelsValue(
|
|
additionalProperties=[
|
|
self._debug_messages.Breakpoint.LabelsValue.AdditionalProperty(
|
|
key=key, value=value)
|
|
for key, value in six.iteritems(labels)])
|
|
location = self._LocationFromString(location)
|
|
if not expressions:
|
|
expressions = []
|
|
request = (
|
|
self._debug_messages.
|
|
ClouddebuggerDebuggerDebuggeesBreakpointsSetRequest(
|
|
debuggeeId=self.target_id,
|
|
breakpoint=self._debug_messages.Breakpoint(
|
|
location=location, condition=condition, expressions=expressions,
|
|
labels=labels_value, userEmail=user_email,
|
|
action=(self._debug_messages.Breakpoint.
|
|
ActionValueValuesEnum.CAPTURE)),
|
|
clientVersion=self.CLIENT_VERSION))
|
|
try:
|
|
response = self._debug_client.debugger_debuggees_breakpoints.Set(request)
|
|
except apitools_exceptions.HttpError as error:
|
|
raise errors.UnknownHttpError(error)
|
|
return self.AddTargetInfo(response.breakpoint)
|
|
|
|
def CreateLogpoint(self, location, log_format_string, log_level=None,
|
|
condition=None, user_email=None, labels=None):
|
|
"""Creates a logpoint in the debuggee.
|
|
|
|
Args:
|
|
location: The breakpoint source location, which will be interpreted by
|
|
the debug agents on the machines running the Debuggee. Usually of the
|
|
form file:line-number
|
|
log_format_string: The message to log, optionally containin {expression}-
|
|
style formatting.
|
|
log_level: String (case-insensitive), one of 'info', 'warning', or
|
|
'error', indicating the log level that should be used for logging.
|
|
condition: An optional conditional expression in the target's programming
|
|
language. The snapshot will be taken when the expression is true.
|
|
user_email: The email of the user who created the snapshot.
|
|
labels: A dictionary containing key-value pairs which will be stored
|
|
with the snapshot definition and reported when the snapshot is queried.
|
|
Returns:
|
|
The created Breakpoint message.
|
|
Raises:
|
|
InvalidLocationException: if location is empty or malformed.
|
|
InvalidLogFormatException: if log_format is empty or malformed.
|
|
"""
|
|
if not location:
|
|
raise errors.InvalidLocationException(
|
|
'The location must not be empty.')
|
|
if not log_format_string:
|
|
raise errors.InvalidLogFormatException(
|
|
'The log format string must not be empty.')
|
|
labels_value = None
|
|
if labels:
|
|
labels_value = self._debug_messages.Breakpoint.LabelsValue(
|
|
additionalProperties=[
|
|
self._debug_messages.Breakpoint.LabelsValue.AdditionalProperty(
|
|
key=key, value=value)
|
|
for key, value in six.iteritems(labels)])
|
|
location = self._LocationFromString(location)
|
|
if log_level:
|
|
log_level = (
|
|
self._debug_messages.Breakpoint.LogLevelValueValuesEnum(
|
|
log_level.upper()))
|
|
log_message_format, expressions = SplitLogExpressions(log_format_string)
|
|
request = (
|
|
self._debug_messages.
|
|
ClouddebuggerDebuggerDebuggeesBreakpointsSetRequest(
|
|
debuggeeId=self.target_id,
|
|
breakpoint=self._debug_messages.Breakpoint(
|
|
location=location, condition=condition, logLevel=log_level,
|
|
logMessageFormat=log_message_format, expressions=expressions,
|
|
labels=labels_value, userEmail=user_email,
|
|
action=(self._debug_messages.Breakpoint.
|
|
ActionValueValuesEnum.LOG)),
|
|
clientVersion=self.CLIENT_VERSION))
|
|
try:
|
|
response = self._debug_client.debugger_debuggees_breakpoints.Set(request)
|
|
except apitools_exceptions.HttpError as error:
|
|
raise errors.UnknownHttpError(error)
|
|
return self.AddTargetInfo(response.breakpoint)
|
|
|
|
def _CallGet(self, request):
|
|
with self._client_lock:
|
|
return self._debug_client.debugger_debuggees_breakpoints.Get(request)
|
|
|
|
def WaitForBreakpointSet(self, breakpoint_id, original_location, timeout=None,
|
|
retry_ms=500):
|
|
"""Waits for a breakpoint to be set by at least one agent.
|
|
|
|
Breakpoint set can be detected in two ways: it can be completed, or the
|
|
location may change if the breakpoint could not be set at the specified
|
|
location. A breakpoint may also be set without any change being reported
|
|
to the server, in which case this function will wait until the timeout
|
|
is reached.
|
|
Args:
|
|
breakpoint_id: A breakpoint ID.
|
|
original_location: string, the user-specified breakpoint location. If a
|
|
response has a different location, the function will return immediately.
|
|
timeout: The number of seconds to wait for completion.
|
|
retry_ms: Milliseconds to wait betweeen retries.
|
|
Returns:
|
|
The Breakpoint message, or None if the breakpoint did not get set before
|
|
the timeout.
|
|
"""
|
|
def MovedOrFinal(r):
|
|
return (
|
|
r.breakpoint.isFinalState or
|
|
(original_location and
|
|
original_location != _FormatLocation(r.breakpoint.location)))
|
|
try:
|
|
return self.WaitForBreakpoint(
|
|
breakpoint_id=breakpoint_id, timeout=timeout, retry_ms=retry_ms,
|
|
completion_test=MovedOrFinal)
|
|
except apitools_exceptions.HttpError as error:
|
|
raise errors.UnknownHttpError(error)
|
|
|
|
def WaitForBreakpoint(self, breakpoint_id, timeout=None, retry_ms=500,
|
|
completion_test=None):
|
|
"""Waits for a breakpoint to be completed.
|
|
|
|
Args:
|
|
breakpoint_id: A breakpoint ID.
|
|
timeout: The number of seconds to wait for completion.
|
|
retry_ms: Milliseconds to wait betweeen retries.
|
|
completion_test: A function that accepts a Breakpoint message and
|
|
returns True if the breakpoint wait is not finished. If not specified,
|
|
defaults to a function which just checks the isFinalState flag.
|
|
Returns:
|
|
The Breakpoint message, or None if the breakpoint did not complete before
|
|
the timeout,
|
|
"""
|
|
if not completion_test:
|
|
completion_test = lambda r: r.breakpoint.isFinalState
|
|
retry_if = lambda r, _: not completion_test(r)
|
|
retryer = retry.Retryer(
|
|
max_wait_ms=1000*timeout if timeout is not None else None,
|
|
wait_ceiling_ms=1000)
|
|
request = (self._debug_messages.
|
|
ClouddebuggerDebuggerDebuggeesBreakpointsGetRequest(
|
|
breakpointId=breakpoint_id, debuggeeId=self.target_id,
|
|
clientVersion=self.CLIENT_VERSION))
|
|
try:
|
|
result = retryer.RetryOnResult(self._CallGet, [request],
|
|
should_retry_if=retry_if,
|
|
sleep_ms=retry_ms)
|
|
except retry.RetryException:
|
|
# Timeout before the beakpoint was finalized.
|
|
return None
|
|
except apitools_exceptions.HttpError as error:
|
|
raise errors.UnknownHttpError(error)
|
|
if not completion_test(result):
|
|
# Termination condition was not met
|
|
return None
|
|
return self.AddTargetInfo(result.breakpoint)
|
|
|
|
def WaitForMultipleBreakpoints(self, ids, wait_all=False, timeout=None):
|
|
"""Waits for one or more breakpoints to complete.
|
|
|
|
Args:
|
|
ids: A list of breakpoint IDs.
|
|
wait_all: If True, wait for all breakpoints to complete. Otherwise, wait
|
|
for any breakpoint to complete.
|
|
timeout: The number of seconds to wait for completion.
|
|
Returns:
|
|
The completed Breakpoint messages, in the order requested. If wait_all was
|
|
specified and the timeout was reached, the result will still comprise the
|
|
completed Breakpoints.
|
|
"""
|
|
waiter = _BreakpointWaiter(wait_all, timeout)
|
|
for i in ids:
|
|
waiter.AddTarget(self, i)
|
|
results = waiter.Wait()
|
|
return [results[i] for i in ids if i in results]
|
|
|
|
def AddTargetInfo(self, message):
|
|
"""Converts a message into an object with added debuggee information.
|
|
|
|
Args:
|
|
message: A message returned from a debug API call.
|
|
Returns:
|
|
An object including the fields of the original object plus the following
|
|
fields: project, target_uniquifier, and target_id.
|
|
"""
|
|
result = _MessageDict(message, hidden_fields={
|
|
'project': self.project,
|
|
'target_uniquifier': self.target_uniquifier,
|
|
'target_id': self.target_id,
|
|
'service': self.service,
|
|
'version': self.version})
|
|
# Restore some default values if they were stripped
|
|
if (message.action ==
|
|
self._debug_messages.Breakpoint.ActionValueValuesEnum.LOG and
|
|
not message.logLevel):
|
|
result['logLevel'] = (
|
|
self._debug_messages.Breakpoint.LogLevelValueValuesEnum.INFO)
|
|
|
|
if message.isFinalState is None:
|
|
result['isFinalState'] = False
|
|
|
|
# Reformat a few fields for readability
|
|
if message.location:
|
|
result['location'] = _FormatLocation(message.location)
|
|
if message.logMessageFormat:
|
|
result['logMessageFormat'] = MergeLogExpressions(message.logMessageFormat,
|
|
message.expressions)
|
|
result.HideExistingField('expressions')
|
|
|
|
if not message.status or not message.status.isError:
|
|
if message.action == self.BreakpointAction(self.LOGPOINT_TYPE):
|
|
# We can only generate view URLs for GAE, since there's not a standard
|
|
# way to view them in GCE. Use the presence of minorversion as an
|
|
# indicator that it's GAE.
|
|
if self.minorversion:
|
|
result['logQuery'] = LogQueryV2String(result)
|
|
result['logViewUrl'] = LogViewUrl(result)
|
|
else:
|
|
result['consoleViewUrl'] = DebugViewUrl(result)
|
|
|
|
return result
|
|
|
|
def _LocationFromString(self, location):
|
|
"""Converts a file:line location string into a SourceLocation.
|
|
|
|
Args:
|
|
location: A string of the form file:line.
|
|
Returns:
|
|
The corresponding SourceLocation message.
|
|
Raises:
|
|
InvalidLocationException: if the line is not of the form path:line
|
|
"""
|
|
components = location.split(':')
|
|
if len(components) != 2:
|
|
raise errors.InvalidLocationException(
|
|
'Location must be of the form "path:line"')
|
|
try:
|
|
return self._debug_messages.SourceLocation(path=components[0],
|
|
line=int(components[1]))
|
|
except ValueError:
|
|
raise errors.InvalidLocationException(
|
|
'Location must be of the form "path:line", where "line" must be an '
|
|
'integer.')
|
|
|
|
def _FilteredDictListWithInfo(self, result, restrict_to_type):
|
|
"""Filters a result list to contain only breakpoints of the given type.
|
|
|
|
Args:
|
|
result: A list of breakpoint messages, to be filtered.
|
|
restrict_to_type: An optional breakpoint type. If None, no filtering
|
|
will be done.
|
|
Returns:
|
|
The filtered result, converted to equivalent dicts with debug info fields
|
|
added.
|
|
"""
|
|
return [self.AddTargetInfo(r) for r in result
|
|
if not restrict_to_type
|
|
or r.action == self.BreakpointAction(restrict_to_type)
|
|
or (not r.action and restrict_to_type == self.SNAPSHOT_TYPE)]
|
|
|
|
|
|
class _BreakpointWaiter(object):
|
|
"""Waits for multiple breakpoints.
|
|
|
|
Attributes:
|
|
_result_lock: Lock for modifications to all fields
|
|
_done: Flag to indicate that the wait condition is satisfied and wait
|
|
should stop even if some threads are not finished.
|
|
_threads: The list of active threads
|
|
_results: The set of completed breakpoints.
|
|
_failures: All exceptions which caused any thread to stop waiting.
|
|
_wait_all: If true, wait for all breakpoints to complete, else wait for
|
|
any breakpoint to complete. Controls whether to set _done after any
|
|
breakpoint completes.
|
|
_timeout: Mazimum time (in ms) to wait for breakpoints to complete.
|
|
"""
|
|
|
|
def __init__(self, wait_all, timeout):
|
|
self._result_lock = threading.Lock()
|
|
self._done = False
|
|
self._threads = []
|
|
self._results = {}
|
|
self._failures = []
|
|
self._wait_all = wait_all
|
|
self._timeout = timeout
|
|
|
|
def _IsComplete(self, response):
|
|
if response.breakpoint.isFinalState:
|
|
return True
|
|
with self._result_lock:
|
|
return self._done
|
|
|
|
def _WaitForOne(self, debuggee, breakpoint_id):
|
|
try:
|
|
breakpoint = debuggee.WaitForBreakpoint(
|
|
breakpoint_id, timeout=self._timeout,
|
|
completion_test=self._IsComplete)
|
|
if not breakpoint:
|
|
# Breakpoint never completed (i.e. timeout)
|
|
with self._result_lock:
|
|
if not self._wait_all:
|
|
self._done = True
|
|
return
|
|
if breakpoint.isFinalState:
|
|
with self._result_lock:
|
|
self._results[breakpoint_id] = breakpoint
|
|
if not self._wait_all:
|
|
self._done = True
|
|
except errors.DebugError as e:
|
|
with self._result_lock:
|
|
self._failures.append(e)
|
|
self._done = True
|
|
|
|
def AddTarget(self, debuggee, breakpoint_id):
|
|
self._threads.append(
|
|
threading.Thread(target=self._WaitForOne,
|
|
args=(debuggee, breakpoint_id)))
|
|
|
|
def Wait(self):
|
|
for t in self._threads:
|
|
t.start()
|
|
for t in self._threads:
|
|
t.join()
|
|
if self._failures:
|
|
# Just raise the first exception we handled
|
|
raise self._failures[0]
|
|
return self._results
|
|
|
|
|
|
def _FormatLocation(location):
|
|
if not location:
|
|
return None
|
|
return '{0}:{1}'.format(location.path, location.line)
|
|
|
|
|
|
def _BreakpointMatchesIdOrRegexp(breakpoint, ids, patterns):
|
|
"""Check if a breakpoint matches any of the given IDs or regexps.
|
|
|
|
Args:
|
|
breakpoint: Any _debug_messages.Breakpoint message object.
|
|
ids: A set of strings to search for exact matches on breakpoint ID.
|
|
patterns: A list of regular expressions to match against the file:line
|
|
location of the breakpoint.
|
|
Returns:
|
|
True if the breakpoint matches any ID or pattern.
|
|
"""
|
|
if breakpoint.id in ids:
|
|
return True
|
|
if not breakpoint.location:
|
|
return False
|
|
location = _FormatLocation(breakpoint.location)
|
|
for p in patterns:
|
|
if p.match(location):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _FilterStaleMinorVersions(debuggees):
|
|
"""Filter out any debugees referring to a stale minor version.
|
|
|
|
Args:
|
|
debuggees: A list of Debuggee objects.
|
|
Returns:
|
|
A filtered list containing only the debuggees denoting the most recent
|
|
minor version with the given name. If any debuggee with a given name does
|
|
not have a 'minorversion' label, the resulting list will contain all
|
|
debuggees with that name.
|
|
"""
|
|
# First group by name
|
|
byname = {}
|
|
for debuggee in debuggees:
|
|
if debuggee.name in byname:
|
|
byname[debuggee.name].append(debuggee)
|
|
else:
|
|
byname[debuggee.name] = [debuggee]
|
|
# Now look at each list for a given name, choosing only the latest
|
|
# version.
|
|
result = []
|
|
for name_list in byname.values():
|
|
latest = _FindLatestMinorVersion(name_list)
|
|
if latest:
|
|
result.append(latest)
|
|
else:
|
|
result.extend(name_list)
|
|
return result
|
|
|
|
|
|
def _FindLatestMinorVersion(debuggees):
|
|
"""Given a list of debuggees, find the one with the highest minor version.
|
|
|
|
Args:
|
|
debuggees: A list of Debuggee objects.
|
|
Returns:
|
|
If all debuggees have the same name, return the one with the highest
|
|
integer value in its 'minorversion' label. If any member of the list does
|
|
not have a minor version, or if elements of the list have different
|
|
names, returns None.
|
|
"""
|
|
if not debuggees:
|
|
return None
|
|
best = None
|
|
best_version = None
|
|
name = None
|
|
for d in debuggees:
|
|
if not name:
|
|
name = d.name
|
|
elif name != d.name:
|
|
return None
|
|
minor_version = d.labels.get('minorversion', 0)
|
|
if not minor_version:
|
|
return None
|
|
try:
|
|
minor_version = int(minor_version)
|
|
if not best_version or minor_version > best_version:
|
|
best_version = minor_version
|
|
best = d
|
|
except ValueError:
|
|
# Got a bogus minor version. We can't determine which is best.
|
|
return None
|
|
return best
|
|
|
|
|
|
class _MessageDict(dict):
|
|
"""An extensible wrapper around message data.
|
|
|
|
Fields can be added as dictionary items and retrieved as attributes.
|
|
"""
|
|
|
|
def __init__(self, message, hidden_fields=None):
|
|
super(_MessageDict, self).__init__()
|
|
self._orig_type = type(message).__name__
|
|
if hidden_fields:
|
|
self._hidden_fields = hidden_fields
|
|
else:
|
|
self._hidden_fields = {}
|
|
for field in message.all_fields():
|
|
value = getattr(message, field.name)
|
|
if not value:
|
|
self._hidden_fields[field.name] = value
|
|
else:
|
|
self[field.name] = value
|
|
|
|
def __getattr__(self, attr):
|
|
if attr in self:
|
|
return self[attr]
|
|
if attr in self._hidden_fields:
|
|
return self._hidden_fields[attr]
|
|
raise AttributeError('Type "{0}" does not have attribute "{1}"'.format(
|
|
self._orig_type, attr))
|
|
|
|
def HideExistingField(self, field_name):
|
|
if field_name in self._hidden_fields:
|
|
return
|
|
self._hidden_fields[field_name] = self.pop(field_name, None)
|