1# -*- coding: utf-8 -*- #
2# Copyright 2021 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"""A library that is used to support Functions commands."""
17
18from __future__ import absolute_import
19from __future__ import division
20from __future__ import print_function
21from __future__ import unicode_literals
22
23import argparse
24import functools
25import json
26import os
27import re
28
29from apitools.base.py import exceptions as apitools_exceptions
30from googlecloudsdk.api_lib.functions.v1 import exceptions
31from googlecloudsdk.api_lib.functions.v1 import operations
32from googlecloudsdk.api_lib.storage import storage_util
33from googlecloudsdk.api_lib.util import apis
34from googlecloudsdk.api_lib.util import exceptions as exceptions_util
35from googlecloudsdk.calliope import arg_parsers
36from googlecloudsdk.calliope import base as calliope_base
37from googlecloudsdk.calliope import exceptions as base_exceptions
38from googlecloudsdk.command_lib.iam import iam_util
39from googlecloudsdk.core import exceptions as core_exceptions
40from googlecloudsdk.core import properties
41from googlecloudsdk.core import resources
42from googlecloudsdk.core.util import encoding
43
44import six.moves.http_client
45
46_DEPLOY_WAIT_NOTICE = 'Deploying function (may take a while - up to 2 minutes)'
47
48_ENTRY_POINT_NAME_RE = re.compile(
49    r'^(?=.{1,128}$)[_a-zA-Z0-9]+(?:\.[_a-zA-Z0-9]+)*$')
50_ENTRY_POINT_NAME_ERROR = (
51    'Entry point name must contain only Latin letters (lower- or '
52    'upper-case), digits, dot (.) and underscore (_), and must be at most 128 '
53    'characters long. It can neither begin nor end with a dot (.), '
54    'nor contain two consecutive dots (..).')
55
56_FUNCTION_NAME_RE = re.compile(r'^[A-Za-z](?:[-_A-Za-z0-9]{0,61}[A-Za-z0-9])?$')
57_FUNCTION_NAME_ERROR = (
58    'Function name must contain only lower case Latin letters, digits and a '
59    'hyphen (-). It must start with letter, must not end with a hyphen, '
60    'and must be at most 63 characters long.')
61
62_TOPIC_NAME_RE = re.compile(r'^[a-zA-Z][\-\._~%\+a-zA-Z0-9]{2,254}$')
63_TOPIC_NAME_ERROR = (
64    'Topic must contain only Latin letters (lower- or upper-case), digits and '
65    'the characters - + . _ ~ %. It must start with a letter and be from 3 to '
66    '255 characters long.')
67
68_BUCKET_RESOURCE_URI_RE = re.compile(r'^projects/_/buckets/.{3,222}$')
69
70_API_NAME = 'cloudfunctions'
71_API_VERSION = 'v1'
72
73
74def _GetApiVersion(track=calliope_base.ReleaseTrack.GA):  # pylint: disable=unused-argument
75  """Returns the current cloudfunctions Api Version configured in the sdk.
76
77  NOTE: Currently the value is hard-coded to v1, and surface/functions/deploy.py
78  assumes this to parse OperationMetadataV1 from the response.
79  Please change the parsing if more versions should be supported.
80
81  Args:
82    track: The gcloud track.
83
84  Returns:
85    The current cloudfunctions Api Version.
86  """
87  return _API_VERSION
88
89
90def GetApiClientInstance(track=calliope_base.ReleaseTrack.GA):
91  return apis.GetClientInstance(_API_NAME, _GetApiVersion(track))
92
93
94def GetResourceManagerApiClientInstance():
95  return apis.GetClientInstance('cloudresourcemanager', 'v1')
96
97
98def GetApiMessagesModule(track=calliope_base.ReleaseTrack.GA):
99  return apis.GetMessagesModule(_API_NAME, _GetApiVersion(track))
100
101
102def GetFunctionRef(name):
103  return resources.REGISTRY.Parse(
104      name, params={
105          'projectsId': properties.VALUES.core.project.Get(required=True),
106          'locationsId': properties.VALUES.functions.region.Get()},
107      collection='cloudfunctions.projects.locations.functions')
108
109
110_ID_CHAR = '[a-zA-Z0-9_]'
111_P_CHAR = "[][~@#$%&.,?:;+*='()-]"
112# capture: '{' ID_CHAR+ ('=' '*''*'?)? '}'
113# Named wildcards may be written in curly brackets (e.g. {variable}). The
114# value that matched this parameter will be included  in the event
115# parameters.
116_CAPTURE = r'(\{' + _ID_CHAR + r'(=\*\*?)?})'
117# segment: (ID_CHAR | P_CHAR)+
118_SEGMENT = '((' + _ID_CHAR + '|' + _P_CHAR + ')+)'
119# part: '/' segment | capture
120_PART = '(/(' + _SEGMENT + '|' + _CAPTURE + '))'
121# path: part+ (but first / is optional)
122_PATH = '(/?(' + _SEGMENT + '|' + _CAPTURE + ')' + _PART + '*)'
123
124_PATH_RE_ERROR = ('Path must be a slash-separated list of segments and '
125                  'captures. For example, [users/{userId}/profilePic].')
126
127
128def GetHttpErrorMessage(error):
129  """Returns a human readable string representation from the http response.
130
131  Args:
132    error: HttpException representing the error response.
133
134  Returns:
135    A human readable string representation of the error.
136  """
137  status = error.response.status
138  code = error.response.reason
139  message = ''
140  try:
141    data = json.loads(error.content)
142    if 'error' in data:
143      error_info = data['error']
144      if 'message' in error_info:
145        message = error_info['message']
146      violations = _GetViolationsFromError(error)
147      if violations:
148        message += '\nProblems:\n' + violations
149      if status == 403:
150        permission_issues = _GetPermissionErrorDetails(error_info)
151        if permission_issues:
152          message += '\nPermission Details:\n' + permission_issues
153  except (ValueError, TypeError):
154    message = error.content
155  return 'ResponseError: status=[{0}], code=[{1}], message=[{2}]'.format(
156      status, code, encoding.Decode(message))
157
158
159def _ValidateArgumentByRegexOrRaise(argument, regex, error_message):
160  if isinstance(regex, str):
161    match = re.match(regex, argument)
162  else:
163    match = regex.match(argument)
164  if not match:
165    raise arg_parsers.ArgumentTypeError(
166        "Invalid value '{0}': {1}".format(argument, error_message))
167  return argument
168
169
170def ValidateFunctionNameOrRaise(name):
171  """Checks if a function name provided by user is valid.
172
173  Args:
174    name: Function name provided by user.
175  Returns:
176    Function name.
177  Raises:
178    ArgumentTypeError: If the name provided by user is not valid.
179  """
180  return _ValidateArgumentByRegexOrRaise(name, _FUNCTION_NAME_RE,
181                                         _FUNCTION_NAME_ERROR)
182
183
184def ValidateEntryPointNameOrRaise(entry_point):
185  """Checks if a entry point name provided by user is valid.
186
187  Args:
188    entry_point: Entry point name provided by user.
189  Returns:
190    Entry point name.
191  Raises:
192    ArgumentTypeError: If the entry point name provided by user is not valid.
193  """
194  return _ValidateArgumentByRegexOrRaise(entry_point, _ENTRY_POINT_NAME_RE,
195                                         _ENTRY_POINT_NAME_ERROR)
196
197
198def ValidateAndStandarizeBucketUriOrRaise(bucket):
199  """Checks if a bucket uri provided by user is valid.
200
201  If the Bucket uri is valid, converts it to a standard form.
202
203  Args:
204    bucket: Bucket uri provided by user.
205  Returns:
206    Sanitized bucket uri.
207  Raises:
208    ArgumentTypeError: If the name provided by user is not valid.
209  """
210  if _BUCKET_RESOURCE_URI_RE.match(bucket):
211    bucket_ref = storage_util.BucketReference.FromUrl(bucket)
212  else:
213    try:
214      bucket_ref = storage_util.BucketReference.FromArgument(
215          bucket, require_prefix=False)
216    except argparse.ArgumentTypeError as e:
217      raise arg_parsers.ArgumentTypeError(
218          "Invalid value '{}': {}".format(bucket, e))
219
220  # strip any extrenuous '/' and append single '/'
221  bucket = bucket_ref.ToUrl().rstrip('/') + '/'
222  return bucket
223
224
225def ValidatePubsubTopicNameOrRaise(topic):
226  """Checks if a Pub/Sub topic name provided by user is valid.
227
228  Args:
229    topic: Pub/Sub topic name provided by user.
230  Returns:
231    Topic name.
232  Raises:
233    ArgumentTypeError: If the name provided by user is not valid.
234  """
235  topic = _ValidateArgumentByRegexOrRaise(topic, _TOPIC_NAME_RE,
236                                          _TOPIC_NAME_ERROR)
237  return topic
238
239
240def ValidateDirectoryExistsOrRaiseFunctionError(directory):
241  """Checks if a source directory exists.
242
243  Args:
244    directory: A string: a local path to directory provided by user.
245  Returns:
246    The argument provided, if found valid.
247  Raises:
248    ArgumentTypeError: If the user provided a directory which is not valid.
249  """
250  if not os.path.exists(directory):
251    raise exceptions.FunctionsError(
252        'argument `--source`: Provided directory does not exist')
253  if not os.path.isdir(directory):
254    raise exceptions.FunctionsError(
255        'argument `--source`: Provided path does not point to a directory')
256  return directory
257
258
259def ValidatePathOrRaise(path):
260  """Check if path provided by user is valid.
261
262  Args:
263    path: A string: resource path
264  Returns:
265    The argument provided, if found valid.
266  Raises:
267    ArgumentTypeError: If the user provided a path which is not valid
268  """
269  path = _ValidateArgumentByRegexOrRaise(path, _PATH, _PATH_RE_ERROR)
270  return path
271
272
273def _GetViolationsFromError(error):
274  """Looks for violations descriptions in error message.
275
276  Args:
277    error: HttpError containing error information.
278  Returns:
279    String of newline-separated violations descriptions.
280  """
281  error_payload = exceptions_util.HttpErrorPayload(error)
282  errors = []
283  errors.extend(
284      ['{}:\n{}'.format(k, v) for k, v in error_payload.violations.items()])
285  errors.extend([
286      '{}:\n{}'.format(k, v) for k, v in error_payload.field_violations.items()
287  ])
288  if errors:
289    return '\n'.join(errors) + '\n'
290  return ''
291
292
293def _GetPermissionErrorDetails(error_info):
294  """Looks for permission denied details in error message.
295
296  Args:
297    error_info: json containing error information.
298  Returns:
299    string containing details on permission issue and suggestions to correct.
300  """
301  try:
302    if 'details' in error_info:
303      details = error_info['details'][0]
304      if 'detail' in details:
305        return details['detail']
306
307  except (ValueError, TypeError):
308    pass
309  return None
310
311
312def CatchHTTPErrorRaiseHTTPException(func):
313  """Decorator that catches HttpError and raises corresponding exception."""
314
315  @functools.wraps(func)
316  def CatchHTTPErrorRaiseHTTPExceptionFn(*args, **kwargs):
317    try:
318      return func(*args, **kwargs)
319    except apitools_exceptions.HttpError as error:
320      core_exceptions.reraise(
321          base_exceptions.HttpException(GetHttpErrorMessage(error)))
322
323  return CatchHTTPErrorRaiseHTTPExceptionFn
324
325
326def FormatTimestamp(timestamp):
327  """Formats a timestamp which will be presented to a user.
328
329  Args:
330    timestamp: Raw timestamp string in RFC3339 UTC "Zulu" format.
331  Returns:
332    Formatted timestamp string.
333  """
334  return re.sub(r'(\.\d{3})\d*Z$', r'\1', timestamp.replace('T', ' '))
335
336
337@CatchHTTPErrorRaiseHTTPException
338def GetFunction(function_name):
339  """Returns the Get method on function response, None if it doesn't exist."""
340  client = GetApiClientInstance()
341  messages = client.MESSAGES_MODULE
342  try:
343    # We got response for a get request so a function exists.
344    return client.projects_locations_functions.Get(
345        messages.CloudfunctionsProjectsLocationsFunctionsGetRequest(
346            name=function_name))
347  except apitools_exceptions.HttpError as error:
348    if error.status_code == six.moves.http_client.NOT_FOUND:
349      # The function has not been found.
350      return None
351    raise
352
353
354# TODO(b/130604453): Remove try_set_invoker option
355@CatchHTTPErrorRaiseHTTPException
356def WaitForFunctionUpdateOperation(op, try_set_invoker=None,
357                                   on_every_poll=None):
358  """Wait for the specied function update to complete.
359
360  Args:
361    op: Cloud operation to wait on.
362    try_set_invoker: function to try setting invoker, see above TODO.
363    on_every_poll: list of functions to execute every time we poll.
364                   Functions should take in Operation as an argument.
365  """
366  client = GetApiClientInstance()
367  operations.Wait(op, client.MESSAGES_MODULE, client, _DEPLOY_WAIT_NOTICE,
368                  try_set_invoker=try_set_invoker,
369                  on_every_poll=on_every_poll)
370
371
372@CatchHTTPErrorRaiseHTTPException
373def PatchFunction(function, fields_to_patch):
374  """Call the api to patch a function based on updated fields.
375
376  Args:
377    function: the function to patch
378    fields_to_patch: the fields to patch on the function
379
380  Returns:
381    The cloud operation for the Patch.
382  """
383  client = GetApiClientInstance()
384  messages = client.MESSAGES_MODULE
385  fields_to_patch_str = ','.join(sorted(fields_to_patch))
386  return client.projects_locations_functions.Patch(
387      messages.CloudfunctionsProjectsLocationsFunctionsPatchRequest(
388          cloudFunction=function,
389          name=function.name,
390          updateMask=fields_to_patch_str,
391      )
392  )
393
394
395@CatchHTTPErrorRaiseHTTPException
396def CreateFunction(function, location):
397  """Call the api to create a function.
398
399  Args:
400    function: the function to create
401    location: location for function
402
403  Returns:
404    Cloud operation for the create.
405  """
406  client = GetApiClientInstance()
407  messages = client.MESSAGES_MODULE
408  return client.projects_locations_functions.Create(
409      messages.CloudfunctionsProjectsLocationsFunctionsCreateRequest(
410          location=location, cloudFunction=function))
411
412
413@CatchHTTPErrorRaiseHTTPException
414def GetFunctionIamPolicy(function_resource_name):
415  client = GetApiClientInstance()
416  messages = client.MESSAGES_MODULE
417  return client.projects_locations_functions.GetIamPolicy(
418      messages.CloudfunctionsProjectsLocationsFunctionsGetIamPolicyRequest(
419          resource=function_resource_name))
420
421
422@CatchHTTPErrorRaiseHTTPException
423def AddFunctionIamPolicyBinding(function_resource_name,
424                                member='allUsers',
425                                role='roles/cloudfunctions.invoker'):
426  client = GetApiClientInstance()
427  messages = client.MESSAGES_MODULE
428  policy = GetFunctionIamPolicy(function_resource_name)
429  iam_util.AddBindingToIamPolicy(messages.Binding, policy, member, role)
430  return client.projects_locations_functions.SetIamPolicy(
431      messages.CloudfunctionsProjectsLocationsFunctionsSetIamPolicyRequest(
432          resource=function_resource_name,
433          setIamPolicyRequest=messages.SetIamPolicyRequest(policy=policy)))
434
435
436@CatchHTTPErrorRaiseHTTPException
437def RemoveFunctionIamPolicyBindingIfFound(
438    function_resource_name,
439    member='allUsers',
440    role='roles/cloudfunctions.invoker'):
441  """Removes the specified policy binding if it is found."""
442  client = GetApiClientInstance()
443  messages = client.MESSAGES_MODULE
444  policy = GetFunctionIamPolicy(function_resource_name)
445  if iam_util.BindingInPolicy(policy, member, role):
446    iam_util.RemoveBindingFromIamPolicy(policy, member, role)
447    client.projects_locations_functions.SetIamPolicy(
448        messages.CloudfunctionsProjectsLocationsFunctionsSetIamPolicyRequest(
449            resource=function_resource_name,
450            setIamPolicyRequest=messages.SetIamPolicyRequest(policy=policy)))
451    return True
452  else:
453    return False
454
455
456@CatchHTTPErrorRaiseHTTPException
457def CanAddFunctionIamPolicyBinding(project):
458  """Returns True iff the caller can add policy bindings for project."""
459  client = GetResourceManagerApiClientInstance()
460  messages = client.MESSAGES_MODULE
461  needed_permissions = [
462      'resourcemanager.projects.getIamPolicy',
463      'resourcemanager.projects.setIamPolicy']
464  iam_request = messages.CloudresourcemanagerProjectsTestIamPermissionsRequest(
465      resource=project,
466      testIamPermissionsRequest=messages.TestIamPermissionsRequest(
467          permissions=needed_permissions))
468  iam_response = client.projects.TestIamPermissions(iam_request)
469  can_add = True
470  for needed_permission in needed_permissions:
471    if needed_permission not in iam_response.permissions:
472      can_add = False
473  return can_add
474