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