1# -*- coding: utf-8 -*- #
2# Copyright 2016 Google LLC. All Rights Reserved.
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"""Debug apis layer."""
17
18from __future__ import absolute_import
19from __future__ import division
20from __future__ import unicode_literals
21
22import re
23import threading
24
25from apitools.base.py import exceptions as apitools_exceptions
26
27from googlecloudsdk.api_lib.debug import errors
28from googlecloudsdk.api_lib.util import apis
29from googlecloudsdk.core import config
30from googlecloudsdk.core import log
31from googlecloudsdk.core import resources
32from googlecloudsdk.core.util import retry
33
34import six
35from six.moves import urllib
36
37# Names for default module and version. In App Engine, the default module and
38# version don't report explicit names to the debugger, so use these strings
39# instead when displaying the target name. Note that this code assumes there
40# will not be a non-default version or module explicitly named 'default', since
41# that would result in a naming conflict between the actual default and the
42# one named 'default'.
43DEFAULT_MODULE = 'default'
44DEFAULT_VERSION = 'default'
45
46
47def SplitLogExpressions(format_string):
48  """Extracts {expression} substrings into a separate array.
49
50  Each substring of the form {expression} will be extracted into an array, and
51  each {expression} substring will be replaced with $N, where N is the index
52  of the extraced expression in the array. Any '$' sequence outside an
53  expression will be escaped with '$$'.
54
55  For example, given the input:
56    'a={a}, b={b}'
57   The return value would be:
58    ('a=$0, b=$1', ['a', 'b'])
59
60  Args:
61    format_string: The string to process.
62  Returns:
63    string, [string] - The new format string and the array of expressions.
64  Raises:
65    InvalidLogFormatException: if the string has unbalanced braces.
66  """
67  expressions = []
68  log_format = ''
69  current_expression = ''
70  brace_count = 0
71  need_separator = False
72  for c in format_string:
73    if need_separator and c.isdigit():
74      log_format += ' '
75    need_separator = False
76    if c == '{':
77      if brace_count:
78        # Nested braces
79        current_expression += c
80      else:
81        # New expression
82        current_expression = ''
83      brace_count += 1
84    elif not brace_count:
85      if c == '}':
86        # Unbalanced left brace.
87        raise errors.InvalidLogFormatException(
88            'There are too many "}" characters in the log format string')
89      elif c == '$':
90        # Escape '$'
91        log_format += '$$'
92      else:
93        # Not in or starting an expression.
94        log_format += c
95    else:
96      # Currently reading an expression.
97      if c != '}':
98        current_expression += c
99        continue
100      brace_count -= 1
101      if brace_count == 0:
102        # Finish processing the expression
103        if current_expression in expressions:
104          i = expressions.index(current_expression)
105        else:
106          i = len(expressions)
107          expressions.append(current_expression)
108        log_format += '${0}'.format(i)
109        # If the next character is a digit, we need an extra space to prevent
110        # the agent from combining the positional argument with the subsequent
111        # digits.
112        need_separator = True
113      else:
114        # Closing a nested brace
115        current_expression += c
116
117  if brace_count:
118    # Unbalanced left brace.
119    raise errors.InvalidLogFormatException(
120        'There are too many "{" characters in the log format string')
121  return log_format, expressions
122
123
124def MergeLogExpressions(log_format, expressions):
125  """Replaces each $N substring with the corresponding {expression}.
126
127  This function is intended for reconstructing an input expression string that
128  has been split using SplitLogExpressions. It is not intended for substituting
129  the expression results at log time.
130
131  Args:
132    log_format: A string containing 0 or more $N substrings, where N is any
133      valid index into the expressions array. Each such substring will be
134      replaced by '{expression}', where "expression" is expressions[N].
135    expressions: The expressions to substitute into the format string.
136  Returns:
137    The combined string.
138  """
139  def GetExpression(m):
140    try:
141      return '{{{0}}}'.format(expressions[int(m.group(0)[1:])])
142    except IndexError:
143      return m.group(0)
144
145  parts = log_format.split('$$')
146  return '$'.join(re.sub(r'\$\d+', GetExpression, part) for part in parts)
147
148
149def DebugViewUrl(breakpoint):
150  """Returns a URL to view a breakpoint in the browser.
151
152  Given a breakpoint, this transform will return a URL which will open the
153  snapshot's location in a debug view pointing at the snapshot.
154
155  Args:
156    breakpoint: A breakpoint object with added information on project and
157    debug target.
158  Returns:
159    The URL for the breakpoint.
160  """
161  debug_view_url = 'https://console.cloud.google.com/debug/fromgcloud?'
162  data = [
163      ('project', breakpoint.project),
164      ('dbgee', breakpoint.target_id),
165      ('bp', breakpoint.id)
166  ]
167  return debug_view_url + urllib.parse.urlencode(data)
168
169
170def LogQueryV2String(breakpoint, separator=' '):
171  """Returns an advanced log query string for use with gcloud logging read.
172
173  Args:
174    breakpoint: A breakpoint object with added information on project, service,
175      and debug target.
176    separator: A string to append between conditions
177  Returns:
178    A log query suitable for use with gcloud logging read.
179  Raises:
180    InvalidLogFormatException if the breakpoint has an invalid log expression.
181  """
182  query = (
183      'resource.type=gae_app{sep}'
184      'logName:request_log{sep}'
185      'resource.labels.module_id="{service}"{sep}'
186      'resource.labels.version_id="{version}"{sep}'
187      'severity={logLevel}').format(
188          service=breakpoint.service, version=breakpoint.version,
189          logLevel=breakpoint.logLevel or 'INFO', sep=separator)
190  if breakpoint.logMessageFormat:
191    # Search for all of the non-expression components of the message.
192    # The re.sub converts the format to a series of quoted strings.
193    query += '{sep}"{text}"'.format(
194        text=re.sub(r'\$([0-9]+)', r'" "',
195                    SplitLogExpressions(breakpoint.logMessageFormat)[0]),
196        sep=separator)
197  return query
198
199
200def LogViewUrl(breakpoint):
201  """Returns a URL to view the output for a logpoint.
202
203  Given a breakpoint in an appengine service, this transform will return a URL
204  which will open the log viewer to the request log for the service.
205
206  Args:
207    breakpoint: A breakpoint object with added information on project, service,
208      debug target, and logQuery.
209  Returns:
210    The URL for the appropriate logs.
211  """
212  debug_view_url = 'https://console.cloud.google.com/logs?'
213  data = [
214      ('project', breakpoint.project),
215      ('advancedFilter', LogQueryV2String(breakpoint, separator='\n') + '\n')
216  ]
217  return debug_view_url + urllib.parse.urlencode(data)
218
219
220class DebugObject(object):
221  """Base class for debug api wrappers."""
222
223  # Lock for remote calls in routines which might be multithreaded. Client
224  # connections are not thread-safe. Currently, only WaitForBreakpoint can
225  # be called from multiple threads.
226  _client_lock = threading.Lock()
227
228  # Breakpoint type name constants
229  SNAPSHOT_TYPE = 'SNAPSHOT'
230  LOGPOINT_TYPE = 'LOGPOINT'
231
232  def BreakpointAction(self, type_name):
233    if type_name == self.SNAPSHOT_TYPE:
234      return self._debug_messages.Breakpoint.ActionValueValuesEnum.CAPTURE
235    if type_name == self.LOGPOINT_TYPE:
236      return self._debug_messages.Breakpoint.ActionValueValuesEnum.LOG
237    raise errors.InvalidBreakpointTypeError(type_name)
238
239  CLIENT_VERSION = 'google.com/gcloud/{0}'.format(config.CLOUD_SDK_VERSION)
240
241  def __init__(self, debug_client=None, debug_messages=None,
242               resource_client=None, resource_messages=None):
243    """Sets up class with instantiated api client."""
244    self._debug_client = (
245        debug_client or apis.GetClientInstance('clouddebugger', 'v2'))
246    self._debug_messages = (
247        debug_messages or apis.GetMessagesModule('clouddebugger', 'v2'))
248    self._resource_client = (
249        resource_client or
250        apis.GetClientInstance('cloudresourcemanager', 'v1beta1'))
251    self._resource_messages = (
252        resource_messages or
253        apis.GetMessagesModule('cloudresourcemanager', 'v1beta1'))
254    self._resource_parser = resources.REGISTRY.Clone()
255    self._resource_parser.RegisterApiByName('clouddebugger', 'v2')
256
257
258class Debugger(DebugObject):
259  """Abstracts Cloud Debugger service for a project."""
260
261  def __init__(self, project, debug_client=None, debug_messages=None,
262               resource_client=None, resource_messages=None):
263    super(Debugger, self).__init__(
264        debug_client=debug_client, debug_messages=debug_messages,
265        resource_client=resource_client, resource_messages=resource_messages)
266    self._project = project
267
268  def ListDebuggees(self, include_inactive=False, include_stale=False):
269    """Lists all debug targets registered with the debug service.
270
271    Args:
272      include_inactive: If true, also include debuggees that are not currently
273        running.
274      include_stale: If false, filter out any debuggees that refer to
275        stale minor versions. A debugge represents a stale minor version if it
276        meets the following criteria:
277            1. It has a minorversion label.
278            2. All other debuggees with the same name (i.e., all debuggees with
279               the same module and version, in the case of app engine) have a
280               minorversion label.
281            3. The minorversion value for the debuggee is less than the
282               minorversion value for at least one other debuggee with the same
283               name.
284    Returns:
285      [Debuggee] A list of debuggees.
286    """
287    request = self._debug_messages.ClouddebuggerDebuggerDebuggeesListRequest(
288        project=self._project, includeInactive=include_inactive,
289        clientVersion=self.CLIENT_VERSION)
290    try:
291      response = self._debug_client.debugger_debuggees.List(request)
292    except apitools_exceptions.HttpError as error:
293      raise errors.UnknownHttpError(error)
294
295    result = [Debuggee(debuggee) for debuggee in response.debuggees]
296
297    if not include_stale:
298      return _FilterStaleMinorVersions(result)
299
300    return result
301
302  def DefaultDebuggee(self):
303    """Find the default debuggee.
304
305    Returns:
306      The default debug target, which is either the only target available
307      or the latest minor version of the application, if all targets have the
308      same module and version.
309    Raises:
310      errors.NoDebuggeeError if no debuggee was found.
311      errors.MultipleDebuggeesError if there is not a unique default.
312    """
313    debuggees = self.ListDebuggees()
314    if len(debuggees) == 1:
315      # Just one possible target
316      return debuggees[0]
317
318    if not debuggees:
319      raise errors.NoDebuggeeError()
320
321    # More than one module or version. Can't determine the default target.
322    raise errors.MultipleDebuggeesError(None, debuggees)
323
324  def FindDebuggee(self, pattern=None):
325    """Find the unique debuggee matching the given pattern.
326
327    Args:
328      pattern: A string containing a debuggee ID or a regular expression that
329        matches a single debuggee's name or description. If it matches any
330        debuggee name, the description will not be inspected.
331    Returns:
332      The matching Debuggee.
333    Raises:
334      errors.MultipleDebuggeesError if the pattern matches multiple debuggees.
335      errors.NoDebuggeeError if the pattern matches no debuggees.
336    """
337    if not pattern:
338      debuggee = self.DefaultDebuggee()
339      log.status.write(
340          'Debug target not specified. Using default target: {0}\n'.format(
341              debuggee.name))
342      return debuggee
343
344    try:
345      # Look for active debuggees first, since there are usually very
346      # few of them compared to inactive debuggees.
347      all_debuggees = self.ListDebuggees()
348      return self._FilterDebuggeeList(all_debuggees, pattern)
349    except errors.NoDebuggeeError:
350      # Try looking at inactive debuggees
351      pass
352    all_debuggees = self.ListDebuggees(include_inactive=True,
353                                       include_stale=True)
354    return self._FilterDebuggeeList(all_debuggees, pattern)
355
356  def _FilterDebuggeeList(self, all_debuggees, pattern):
357    """Finds the debuggee which matches the given pattern.
358
359    Args:
360      all_debuggees: A list of debuggees to search.
361      pattern: A string containing a debuggee ID or a regular expression that
362        matches a single debuggee's name or description. If it matches any
363        debuggee name, the description will not be inspected.
364    Returns:
365      The matching Debuggee.
366    Raises:
367      errors.MultipleDebuggeesError if the pattern matches multiple debuggees.
368      errors.NoDebuggeeError if the pattern matches no debuggees.
369    """
370    if not all_debuggees:
371      raise errors.NoDebuggeeError()
372
373    latest_debuggees = _FilterStaleMinorVersions(all_debuggees)
374
375    # Find all debuggees specified by ID, plus all debuggees which are the
376    # latest minor version when specified by name.
377    debuggees = ([d for d in all_debuggees if d.target_id == pattern] +
378                 [d for d in latest_debuggees if pattern == d.name])
379    if not debuggees:
380      # Try matching as an RE on name or description. Name and description
381      # share common substrings, so filter out duplicates.
382      match_re = re.compile(pattern)
383      debuggees = (
384          [d for d in latest_debuggees if match_re.search(d.name)] +
385          [d for d in latest_debuggees
386           if d.description and match_re.search(d.description)])
387
388    if not debuggees:
389      raise errors.NoDebuggeeError(pattern, debuggees=all_debuggees)
390
391    debuggee_ids = set(d.target_id for d in debuggees)
392    if len(debuggee_ids) > 1:
393      raise errors.MultipleDebuggeesError(pattern, debuggees)
394
395    # Just one possible target
396    return debuggees[0]
397
398  def RegisterDebuggee(self, description, uniquifier, agent_version=None):
399    """Register a debuggee with the Cloud Debugger.
400
401    This method is primarily intended to simplify testing, since it registering
402    a debuggee is only a small part of the functionality of a debug agent, and
403    the rest of the API is not supported here.
404    Args:
405      description: A concise description of the debuggee.
406      uniquifier: A string uniquely identifying the debug target. Note that the
407        uniquifier distinguishes between different deployments of a service,
408        not between different replicas of a single deployment. I.e., all
409        replicas of a single deployment should report the same uniquifier.
410      agent_version: A string describing the program registering the debuggee.
411        Defaults to "google.com/gcloud/NNN" where NNN is the gcloud version.
412    Returns:
413      The registered Debuggee.
414    """
415    if not agent_version:
416      agent_version = self.CLIENT_VERSION
417    request = self._debug_messages.RegisterDebuggeeRequest(
418        debuggee=self._debug_messages.Debuggee(
419            project=self._project, description=description,
420            uniquifier=uniquifier, agentVersion=agent_version))
421    try:
422      response = self._debug_client.controller_debuggees.Register(request)
423    except apitools_exceptions.HttpError as error:
424      raise errors.UnknownHttpError(error)
425    return Debuggee(response.debuggee)
426
427
428class Debuggee(DebugObject):
429  """Represents a single debuggee."""
430
431  def __init__(self, message, debug_client=None, debug_messages=None,
432               resource_client=None, resource_messages=None):
433    super(Debuggee, self).__init__(
434        debug_client=debug_client, debug_messages=debug_messages,
435        resource_client=resource_client, resource_messages=resource_messages)
436    self.project = message.project
437    self.agent_version = message.agentVersion
438    self.description = message.description
439    self.ext_source_contexts = message.extSourceContexts
440    self.target_id = message.id
441    self.is_disabled = message.isDisabled
442    self.is_inactive = message.isInactive
443    self.source_contexts = message.sourceContexts
444    self.status = message.status
445    self.target_uniquifier = message.uniquifier
446    self.labels = {}
447    if message.labels:
448      for l in message.labels.additionalProperties:
449        self.labels[l.key] = l.value
450
451  def __eq__(self, other):
452    return (isinstance(other, self.__class__) and
453            self.target_id == other.target_id)
454
455  def __ne__(self, other):
456    return not self.__eq__(other)
457
458  def __repr__(self):
459    return '<id={0}, name={1}{2}>'.format(
460        self.target_id, self.name, ', description={0}'.format(self.description)
461        if self.description else '')
462
463  @property
464  def service(self):
465    return self.labels.get('module', None)
466
467  @property
468  def version(self):
469    return self.labels.get('version', None)
470
471  @property
472  def minorversion(self):
473    return self.labels.get('minorversion', None)
474
475  @property
476  def name(self):
477    service = self.service
478    version = self.version
479    if service or version:
480      return (service or DEFAULT_MODULE) + '-' + (version or DEFAULT_VERSION)
481    return self.description
482
483  def _BreakpointDescription(self, restrict_to_type):
484    if not restrict_to_type:
485      return 'breakpoint'
486    elif restrict_to_type == self.SNAPSHOT_TYPE:
487      return 'snapshot'
488    else:
489      return 'logpoint'
490
491  def GetBreakpoint(self, breakpoint_id):
492    """Gets the details for a breakpoint.
493
494    Args:
495      breakpoint_id: A breakpoint ID.
496    Returns:
497      The full Breakpoint message for the ID.
498    """
499    request = (self._debug_messages.
500               ClouddebuggerDebuggerDebuggeesBreakpointsGetRequest(
501                   breakpointId=breakpoint_id, debuggeeId=self.target_id,
502                   clientVersion=self.CLIENT_VERSION))
503    try:
504      response = self._debug_client.debugger_debuggees_breakpoints.Get(request)
505    except apitools_exceptions.HttpError as error:
506      raise errors.UnknownHttpError(error)
507    return self.AddTargetInfo(response.breakpoint)
508
509  def DeleteBreakpoint(self, breakpoint_id):
510    """Deletes a breakpoint.
511
512    Args:
513      breakpoint_id: A breakpoint ID.
514    """
515    request = (self._debug_messages.
516               ClouddebuggerDebuggerDebuggeesBreakpointsDeleteRequest(
517                   breakpointId=breakpoint_id, debuggeeId=self.target_id,
518                   clientVersion=self.CLIENT_VERSION))
519    try:
520      self._debug_client.debugger_debuggees_breakpoints.Delete(request)
521    except apitools_exceptions.HttpError as error:
522      raise errors.UnknownHttpError(error)
523
524  def ListBreakpoints(self, location_regexp=None, resource_ids=None,
525                      include_all_users=False, include_inactive=False,
526                      restrict_to_type=None, full_details=False):
527    """Returns all breakpoints matching the given IDs or patterns.
528
529    Lists all breakpoints for this debuggee, and returns every breakpoint
530    where the location field contains the given pattern or the ID is exactly
531    equal to the pattern (there can be at most one breakpoint matching by ID).
532
533    Args:
534      location_regexp: A list of regular expressions to compare against the
535        location ('path:line') of the breakpoints. If both location_regexp and
536        resource_ids are empty or None, all breakpoints will be returned.
537      resource_ids: Zero or more resource IDs in the form expected by the
538        resource parser. These breakpoints will be retrieved regardless
539        of the include_all_users or include_inactive flags
540      include_all_users: If true, search breakpoints created by all users.
541      include_inactive: If true, search breakpoints that are in the final state.
542        This option controls whether regular expressions can match inactive
543        breakpoints. If an object is specified by ID, it will be returned
544        whether or not this flag is set.
545      restrict_to_type: An optional breakpoint type (LOGPOINT_TYPE or
546        SNAPSHOT_TYPE)
547      full_details: If true, issue a GetBreakpoint request for every result to
548        get full details including the call stack and variable table.
549    Returns:
550      A list of all matching breakpoints.
551    Raises:
552      InvalidLocationException if a regular expression is not valid.
553    """
554    resource_ids = resource_ids or []
555    location_regexp = location_regexp or []
556    ids = set(
557        [self._resource_parser.Parse(
558            r, params={'debuggeeId': self.target_id},
559            collection='clouddebugger.debugger.debuggees.breakpoints').Name()
560         for r in resource_ids])
561    patterns = []
562    for r in location_regexp:
563      try:
564        patterns.append(re.compile(r'^(.*/)?(' + r + ')$'))
565      except re.error as e:
566        raise errors.InvalidLocationException(
567            'The location pattern "{0}" is not a valid Python regular '
568            'expression: {1}'.format(r, e))
569
570    request = (self._debug_messages.
571               ClouddebuggerDebuggerDebuggeesBreakpointsListRequest(
572                   debuggeeId=self.target_id,
573                   includeAllUsers=include_all_users,
574                   includeInactive=include_inactive or bool(ids),
575                   clientVersion=self.CLIENT_VERSION))
576    try:
577      response = self._debug_client.debugger_debuggees_breakpoints.List(request)
578    except apitools_exceptions.HttpError as error:
579      raise errors.UnknownHttpError(error)
580    if not patterns and not ids:
581      return self._FilteredDictListWithInfo(response.breakpoints,
582                                            restrict_to_type)
583
584    if include_inactive:
585      # Match everything (including inactive breakpoints) against all ids and
586      # patterns.
587      result = [bp for bp in response.breakpoints
588                if _BreakpointMatchesIdOrRegexp(bp, ids, patterns)]
589    else:
590      # Return everything that is listed by ID, plus every breakpoint that
591      # is not inactive (i.e. isFinalState is false) which matches any pattern.
592      # Breakpoints that are inactive should not be matched against the
593      # patterns.
594      result = [bp for bp in response.breakpoints
595                if _BreakpointMatchesIdOrRegexp(
596                    bp, ids, [] if bp.isFinalState else patterns)]
597    # Check if any ids were missing, and fetch them individually. This can
598    # happen if an ID for another user's breakpoint was specified, but the
599    # all_users flag was false. This code will also raise an error for any
600    # missing IDs.
601    missing_ids = ids - set([bp.id for bp in result])
602    if missing_ids:
603      raise errors.BreakpointNotFoundError(
604          missing_ids, self._BreakpointDescription(restrict_to_type))
605
606    # Verify that all patterns matched at least one breakpoint.
607    for p in patterns:
608      if not [bp for bp in result
609              if _BreakpointMatchesIdOrRegexp(bp, [], [p])]:
610        raise errors.NoMatchError(self._BreakpointDescription(restrict_to_type),
611                                  p.pattern)
612    result = self._FilteredDictListWithInfo(result, restrict_to_type)
613    if full_details:
614      def IsCompletedSnapshot(bp):
615        return ((not bp.action or
616                 bp.action == self.BreakpointAction(self.SNAPSHOT_TYPE)) and
617                bp.isFinalState and not (bp.status and bp.status.isError))
618      result = [
619          self.GetBreakpoint(bp.id) if IsCompletedSnapshot(bp) else bp
620          for bp in result
621      ]
622    return result
623
624  def CreateSnapshot(self, location, condition=None, expressions=None,
625                     user_email=None, labels=None):
626    """Creates a "snapshot" breakpoint.
627
628    Args:
629      location: The breakpoint source location, which will be interpreted by
630        the debug agents on the machines running the Debuggee. Usually of the
631        form file:line-number
632      condition: An optional conditional expression in the target's programming
633        language. The snapshot will be taken when the expression is true.
634      expressions: A list of expressions to evaluate when the snapshot is
635        taken.
636      user_email: The email of the user who created the snapshot.
637      labels: A dictionary containing key-value pairs which will be stored
638        with the snapshot definition and reported when the snapshot is queried.
639    Returns:
640      The created Breakpoint message.
641    """
642    labels_value = None
643    if labels:
644      labels_value = self._debug_messages.Breakpoint.LabelsValue(
645          additionalProperties=[
646              self._debug_messages.Breakpoint.LabelsValue.AdditionalProperty(
647                  key=key, value=value)
648              for key, value in six.iteritems(labels)])
649    location = self._LocationFromString(location)
650    if not expressions:
651      expressions = []
652    request = (
653        self._debug_messages.
654        ClouddebuggerDebuggerDebuggeesBreakpointsSetRequest(
655            debuggeeId=self.target_id,
656            breakpoint=self._debug_messages.Breakpoint(
657                location=location, condition=condition, expressions=expressions,
658                labels=labels_value, userEmail=user_email,
659                action=(self._debug_messages.Breakpoint.
660                        ActionValueValuesEnum.CAPTURE)),
661            clientVersion=self.CLIENT_VERSION))
662    try:
663      response = self._debug_client.debugger_debuggees_breakpoints.Set(request)
664    except apitools_exceptions.HttpError as error:
665      raise errors.UnknownHttpError(error)
666    return self.AddTargetInfo(response.breakpoint)
667
668  def CreateLogpoint(self, location, log_format_string, log_level=None,
669                     condition=None, user_email=None, labels=None):
670    """Creates a logpoint in the debuggee.
671
672    Args:
673      location: The breakpoint source location, which will be interpreted by
674        the debug agents on the machines running the Debuggee. Usually of the
675        form file:line-number
676      log_format_string: The message to log, optionally containin {expression}-
677        style formatting.
678      log_level: String (case-insensitive), one of 'info', 'warning', or
679        'error', indicating the log level that should be used for logging.
680      condition: An optional conditional expression in the target's programming
681        language. The snapshot will be taken when the expression is true.
682      user_email: The email of the user who created the snapshot.
683      labels: A dictionary containing key-value pairs which will be stored
684        with the snapshot definition and reported when the snapshot is queried.
685    Returns:
686      The created Breakpoint message.
687    Raises:
688      InvalidLocationException: if location is empty or malformed.
689      InvalidLogFormatException: if log_format is empty or malformed.
690    """
691    if not location:
692      raise errors.InvalidLocationException(
693          'The location must not be empty.')
694    if not log_format_string:
695      raise errors.InvalidLogFormatException(
696          'The log format string must not be empty.')
697    labels_value = None
698    if labels:
699      labels_value = self._debug_messages.Breakpoint.LabelsValue(
700          additionalProperties=[
701              self._debug_messages.Breakpoint.LabelsValue.AdditionalProperty(
702                  key=key, value=value)
703              for key, value in six.iteritems(labels)])
704    location = self._LocationFromString(location)
705    if log_level:
706      log_level = (
707          self._debug_messages.Breakpoint.LogLevelValueValuesEnum(
708              log_level.upper()))
709    log_message_format, expressions = SplitLogExpressions(log_format_string)
710    request = (
711        self._debug_messages.
712        ClouddebuggerDebuggerDebuggeesBreakpointsSetRequest(
713            debuggeeId=self.target_id,
714            breakpoint=self._debug_messages.Breakpoint(
715                location=location, condition=condition, logLevel=log_level,
716                logMessageFormat=log_message_format, expressions=expressions,
717                labels=labels_value, userEmail=user_email,
718                action=(self._debug_messages.Breakpoint.
719                        ActionValueValuesEnum.LOG)),
720            clientVersion=self.CLIENT_VERSION))
721    try:
722      response = self._debug_client.debugger_debuggees_breakpoints.Set(request)
723    except apitools_exceptions.HttpError as error:
724      raise errors.UnknownHttpError(error)
725    return self.AddTargetInfo(response.breakpoint)
726
727  def _CallGet(self, request):
728    with self._client_lock:
729      return self._debug_client.debugger_debuggees_breakpoints.Get(request)
730
731  def WaitForBreakpointSet(self, breakpoint_id, original_location, timeout=None,
732                           retry_ms=500):
733    """Waits for a breakpoint to be set by at least one agent.
734
735      Breakpoint set can be detected in two ways: it can be completed, or the
736      location may change if the breakpoint could not be set at the specified
737      location. A breakpoint may also be set without any change being reported
738      to the server, in which case this function will wait until the timeout
739      is reached.
740    Args:
741      breakpoint_id: A breakpoint ID.
742      original_location: string, the user-specified breakpoint location. If a
743        response has a different location, the function will return immediately.
744      timeout: The number of seconds to wait for completion.
745      retry_ms: Milliseconds to wait betweeen retries.
746    Returns:
747      The Breakpoint message, or None if the breakpoint did not get set before
748      the timeout.
749    """
750    def MovedOrFinal(r):
751      return (
752          r.breakpoint.isFinalState or
753          (original_location and
754           original_location != _FormatLocation(r.breakpoint.location)))
755    try:
756      return self.WaitForBreakpoint(
757          breakpoint_id=breakpoint_id, timeout=timeout, retry_ms=retry_ms,
758          completion_test=MovedOrFinal)
759    except apitools_exceptions.HttpError as error:
760      raise errors.UnknownHttpError(error)
761
762  def WaitForBreakpoint(self, breakpoint_id, timeout=None, retry_ms=500,
763                        completion_test=None):
764    """Waits for a breakpoint to be completed.
765
766    Args:
767      breakpoint_id: A breakpoint ID.
768      timeout: The number of seconds to wait for completion.
769      retry_ms: Milliseconds to wait betweeen retries.
770      completion_test: A function that accepts a Breakpoint message and
771        returns True if the breakpoint wait is not finished. If not specified,
772        defaults to a function which just checks the isFinalState flag.
773    Returns:
774      The Breakpoint message, or None if the breakpoint did not complete before
775      the timeout,
776    """
777    if not completion_test:
778      completion_test = lambda r: r.breakpoint.isFinalState
779    retry_if = lambda r, _: not completion_test(r)
780    retryer = retry.Retryer(
781        max_wait_ms=1000*timeout if timeout is not None else None,
782        wait_ceiling_ms=1000)
783    request = (self._debug_messages.
784               ClouddebuggerDebuggerDebuggeesBreakpointsGetRequest(
785                   breakpointId=breakpoint_id, debuggeeId=self.target_id,
786                   clientVersion=self.CLIENT_VERSION))
787    try:
788      result = retryer.RetryOnResult(self._CallGet, [request],
789                                     should_retry_if=retry_if,
790                                     sleep_ms=retry_ms)
791    except retry.RetryException:
792      # Timeout before the beakpoint was finalized.
793      return None
794    except apitools_exceptions.HttpError as error:
795      raise errors.UnknownHttpError(error)
796    if not completion_test(result):
797      # Termination condition was not met
798      return None
799    return self.AddTargetInfo(result.breakpoint)
800
801  def WaitForMultipleBreakpoints(self, ids, wait_all=False, timeout=None):
802    """Waits for one or more breakpoints to complete.
803
804    Args:
805      ids: A list of breakpoint IDs.
806      wait_all: If True, wait for all breakpoints to complete. Otherwise, wait
807        for any breakpoint to complete.
808      timeout: The number of seconds to wait for completion.
809    Returns:
810      The completed Breakpoint messages, in the order requested. If wait_all was
811      specified and the timeout was reached, the result will still comprise the
812      completed Breakpoints.
813    """
814    waiter = _BreakpointWaiter(wait_all, timeout)
815    for i in ids:
816      waiter.AddTarget(self, i)
817    results = waiter.Wait()
818    return [results[i] for i in ids if i in results]
819
820  def AddTargetInfo(self, message):
821    """Converts a message into an object with added debuggee information.
822
823    Args:
824      message: A message returned from a debug API call.
825    Returns:
826      An object including the fields of the original object plus the following
827      fields: project, target_uniquifier, and target_id.
828    """
829    result = _MessageDict(message, hidden_fields={
830        'project': self.project,
831        'target_uniquifier': self.target_uniquifier,
832        'target_id': self.target_id,
833        'service': self.service,
834        'version': self.version})
835    # Restore some default values if they were stripped
836    if (message.action ==
837        self._debug_messages.Breakpoint.ActionValueValuesEnum.LOG and
838        not message.logLevel):
839      result['logLevel'] = (
840          self._debug_messages.Breakpoint.LogLevelValueValuesEnum.INFO)
841
842    if message.isFinalState is None:
843      result['isFinalState'] = False
844
845    # Reformat a few fields for readability
846    if message.location:
847      result['location'] = _FormatLocation(message.location)
848    if message.logMessageFormat:
849      result['logMessageFormat'] = MergeLogExpressions(message.logMessageFormat,
850                                                       message.expressions)
851      result.HideExistingField('expressions')
852
853    if not message.status or not message.status.isError:
854      if message.action == self.BreakpointAction(self.LOGPOINT_TYPE):
855        # We can only generate view URLs for GAE, since there's not a standard
856        # way to view them in GCE. Use the presence of minorversion as an
857        # indicator that it's GAE.
858        if self.minorversion:
859          result['logQuery'] = LogQueryV2String(result)
860          result['logViewUrl'] = LogViewUrl(result)
861      else:
862        result['consoleViewUrl'] = DebugViewUrl(result)
863
864    return result
865
866  def _LocationFromString(self, location):
867    """Converts a file:line location string into a SourceLocation.
868
869    Args:
870      location: A string of the form file:line.
871    Returns:
872      The corresponding SourceLocation message.
873    Raises:
874      InvalidLocationException: if the line is not of the form path:line
875    """
876    components = location.split(':')
877    if len(components) != 2:
878      raise errors.InvalidLocationException(
879          'Location must be of the form "path:line"')
880    try:
881      return self._debug_messages.SourceLocation(path=components[0],
882                                                 line=int(components[1]))
883    except ValueError:
884      raise errors.InvalidLocationException(
885          'Location must be of the form "path:line", where "line" must be an '
886          'integer.')
887
888  def _FilteredDictListWithInfo(self, result, restrict_to_type):
889    """Filters a result list to contain only breakpoints of the given type.
890
891    Args:
892      result: A list of breakpoint messages, to be filtered.
893      restrict_to_type: An optional breakpoint type. If None, no filtering
894        will be done.
895    Returns:
896      The filtered result, converted to equivalent dicts with debug info fields
897      added.
898    """
899    return [self.AddTargetInfo(r) for r in result
900            if not restrict_to_type
901            or r.action == self.BreakpointAction(restrict_to_type)
902            or (not r.action and restrict_to_type == self.SNAPSHOT_TYPE)]
903
904
905class _BreakpointWaiter(object):
906  """Waits for multiple breakpoints.
907
908  Attributes:
909    _result_lock: Lock for modifications to all fields
910    _done: Flag to indicate that the wait condition is satisfied and wait
911        should stop even if some threads are not finished.
912    _threads: The list of active threads
913    _results: The set of completed breakpoints.
914    _failures: All exceptions which caused any thread to stop waiting.
915    _wait_all: If true, wait for all breakpoints to complete, else wait for
916        any breakpoint to complete. Controls whether to set _done after any
917        breakpoint completes.
918    _timeout: Mazimum time (in ms) to wait for breakpoints to complete.
919  """
920
921  def __init__(self, wait_all, timeout):
922    self._result_lock = threading.Lock()
923    self._done = False
924    self._threads = []
925    self._results = {}
926    self._failures = []
927    self._wait_all = wait_all
928    self._timeout = timeout
929
930  def _IsComplete(self, response):
931    if response.breakpoint.isFinalState:
932      return True
933    with self._result_lock:
934      return self._done
935
936  def _WaitForOne(self, debuggee, breakpoint_id):
937    try:
938      breakpoint = debuggee.WaitForBreakpoint(
939          breakpoint_id, timeout=self._timeout,
940          completion_test=self._IsComplete)
941      if not breakpoint:
942        # Breakpoint never completed (i.e. timeout)
943        with self._result_lock:
944          if not self._wait_all:
945            self._done = True
946        return
947      if breakpoint.isFinalState:
948        with self._result_lock:
949          self._results[breakpoint_id] = breakpoint
950          if not self._wait_all:
951            self._done = True
952    except errors.DebugError as e:
953      with self._result_lock:
954        self._failures.append(e)
955        self._done = True
956
957  def AddTarget(self, debuggee, breakpoint_id):
958    self._threads.append(
959        threading.Thread(target=self._WaitForOne,
960                         args=(debuggee, breakpoint_id)))
961
962  def Wait(self):
963    for t in self._threads:
964      t.start()
965    for t in self._threads:
966      t.join()
967    if self._failures:
968      # Just raise the first exception we handled
969      raise self._failures[0]
970    return self._results
971
972
973def _FormatLocation(location):
974  if not location:
975    return None
976  return '{0}:{1}'.format(location.path, location.line)
977
978
979def _BreakpointMatchesIdOrRegexp(breakpoint, ids, patterns):
980  """Check if a breakpoint matches any of the given IDs or regexps.
981
982  Args:
983    breakpoint: Any _debug_messages.Breakpoint message object.
984    ids: A set of strings to search for exact matches on breakpoint ID.
985    patterns: A list of regular expressions to match against the file:line
986      location of the breakpoint.
987  Returns:
988    True if the breakpoint matches any ID or pattern.
989  """
990  if breakpoint.id in ids:
991    return True
992  if not breakpoint.location:
993    return False
994  location = _FormatLocation(breakpoint.location)
995  for p in patterns:
996    if p.match(location):
997      return True
998  return False
999
1000
1001def _FilterStaleMinorVersions(debuggees):
1002  """Filter out any debugees referring to a stale minor version.
1003
1004  Args:
1005    debuggees: A list of Debuggee objects.
1006  Returns:
1007    A filtered list containing only the debuggees denoting the most recent
1008    minor version with the given name. If any debuggee with a given name does
1009    not have a 'minorversion' label, the resulting list will contain all
1010    debuggees with that name.
1011  """
1012  # First group by name
1013  byname = {}
1014  for debuggee in debuggees:
1015    if debuggee.name in byname:
1016      byname[debuggee.name].append(debuggee)
1017    else:
1018      byname[debuggee.name] = [debuggee]
1019  # Now look at each list for a given name, choosing only the latest
1020  # version.
1021  result = []
1022  for name_list in byname.values():
1023    latest = _FindLatestMinorVersion(name_list)
1024    if latest:
1025      result.append(latest)
1026    else:
1027      result.extend(name_list)
1028  return result
1029
1030
1031def _FindLatestMinorVersion(debuggees):
1032  """Given a list of debuggees, find the one with the highest minor version.
1033
1034  Args:
1035    debuggees: A list of Debuggee objects.
1036  Returns:
1037    If all debuggees have the same name, return the one with the highest
1038    integer value in its 'minorversion' label. If any member of the list does
1039    not have a minor version, or if elements of the list have different
1040    names, returns None.
1041  """
1042  if not debuggees:
1043    return None
1044  best = None
1045  best_version = None
1046  name = None
1047  for d in debuggees:
1048    if not name:
1049      name = d.name
1050    elif name != d.name:
1051      return None
1052    minor_version = d.labels.get('minorversion', 0)
1053    if not minor_version:
1054      return None
1055    try:
1056      minor_version = int(minor_version)
1057      if not best_version or minor_version > best_version:
1058        best_version = minor_version
1059        best = d
1060    except ValueError:
1061      # Got a bogus minor version. We can't determine which is best.
1062      return None
1063  return best
1064
1065
1066class _MessageDict(dict):
1067  """An extensible wrapper around message data.
1068
1069  Fields can be added as dictionary items and retrieved as attributes.
1070  """
1071
1072  def __init__(self, message, hidden_fields=None):
1073    super(_MessageDict, self).__init__()
1074    self._orig_type = type(message).__name__
1075    if hidden_fields:
1076      self._hidden_fields = hidden_fields
1077    else:
1078      self._hidden_fields = {}
1079    for field in message.all_fields():
1080      value = getattr(message, field.name)
1081      if not value:
1082        self._hidden_fields[field.name] = value
1083      else:
1084        self[field.name] = value
1085
1086  def __getattr__(self, attr):
1087    if attr in self:
1088      return self[attr]
1089    if attr in self._hidden_fields:
1090      return self._hidden_fields[attr]
1091    raise AttributeError('Type "{0}" does not have attribute "{1}"'.format(
1092        self._orig_type, attr))
1093
1094  def HideExistingField(self, field_name):
1095    if field_name in self._hidden_fields:
1096      return
1097    self._hidden_fields[field_name] = self.pop(field_name, None)
1098