1# -*- coding: utf-8 -*- # 2# Copyright 2017 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"""Common helper methods for Service Management commands.""" 17 18from __future__ import absolute_import 19from __future__ import division 20from __future__ import unicode_literals 21 22import json 23import re 24 25from apitools.base.py import encoding 26from apitools.base.py import exceptions as apitools_exceptions 27from apitools.base.py import list_pager 28 29from googlecloudsdk.api_lib.endpoints import exceptions 30from googlecloudsdk.api_lib.util import apis 31from googlecloudsdk.calliope import exceptions as calliope_exceptions 32from googlecloudsdk.core import log 33from googlecloudsdk.core import properties 34from googlecloudsdk.core import resources 35from googlecloudsdk.core import yaml 36from googlecloudsdk.core.resource import resource_printer 37from googlecloudsdk.core.util import files 38from googlecloudsdk.core.util import retry 39import six 40 41 42EMAIL_REGEX = re.compile(r'^.+@([^.@][^@]+)$') 43FINGERPRINT_REGEX = re.compile( 44 r'^([a-f0-9][a-f0-9]:){19}[a-f0-9][a-f0-9]$', re.IGNORECASE) 45OP_BASE_CMD = 'gcloud endpoints operations ' 46OP_DESCRIBE_CMD = OP_BASE_CMD + 'describe {0}' 47OP_WAIT_CMD = OP_BASE_CMD + 'wait {0}' 48SERVICES_COLLECTION = 'servicemanagement.services' 49CONFIG_COLLECTION = 'servicemanagement.services.configs' 50 51ALL_IAM_PERMISSIONS = [ 52 'servicemanagement.services.get', 53 'servicemanagement.services.getProjectSettings', 54 'servicemanagement.services.delete', 55 'servicemanagement.services.update', 56 'servicemanagement.services.bind', 57 'servicemanagement.services.updateProjectSettings', 58 'servicemanagement.services.check', 59 'servicemanagement.services.report', 60 'servicemanagement.services.setIamPolicy', 61 'servicemanagement.services.getIamPolicy', 62] 63 64 65def GetMessagesModule(): 66 return apis.GetMessagesModule('servicemanagement', 'v1') 67 68 69def GetClientInstance(): 70 return apis.GetClientInstance('servicemanagement', 'v1') 71 72 73def GetServiceManagementServiceName(): 74 return 'servicemanagement.googleapis.com' 75 76 77def GetValidatedProject(project_id): 78 """Validate the project ID, if supplied, otherwise return the default project. 79 80 Args: 81 project_id: The ID of the project to validate. If None, gcloud's default 82 project's ID will be returned. 83 84 Returns: 85 The validated project ID. 86 """ 87 if project_id: 88 properties.VALUES.core.project.Validate(project_id) 89 else: 90 project_id = properties.VALUES.core.project.Get(required=True) 91 return project_id 92 93 94def GetProjectSettings(service, consumer_project_id, view): 95 """Returns the project settings for a given service, project, and view. 96 97 Args: 98 service: The service for which to return project settings. 99 consumer_project_id: The consumer project id for which to return settings. 100 view: The view (CONSUMER_VIEW or PRODUCER_VIEW). 101 102 Returns: 103 A ProjectSettings message with the settings populated. 104 """ 105 # Shorten the request names for better readability 106 get_request = (GetMessagesModule() 107 .ServicemanagementServicesProjectSettingsGetRequest) 108 109 # Get the current list of quota settings to see if the quota override 110 # exists in the first place. 111 request = get_request( 112 serviceName=service, 113 consumerProjectId=consumer_project_id, 114 view=view, 115 ) 116 117 return GetClientInstance().services_projectSettings.Get(request) 118 119 120def GetProducedListRequest(project_id): 121 return GetMessagesModule().ServicemanagementServicesListRequest( 122 producerProjectId=project_id 123 ) 124 125 126def PrettyPrint(resource, print_format='json'): 127 """Prints the given resource. 128 129 Args: 130 resource: The resource to print out. 131 print_format: The print_format value to pass along to the resource_printer. 132 """ 133 resource_printer.Print( 134 resources=[resource], 135 print_format=print_format, 136 out=log.out) 137 138 139def PushAdvisorChangeTypeToString(change_type): 140 """Convert a ConfigChange.ChangeType enum to a string. 141 142 Args: 143 change_type: The ConfigChange.ChangeType enum to convert. 144 145 Returns: 146 An easily readable string representing the ConfigChange.ChangeType enum. 147 """ 148 messages = GetMessagesModule() 149 enums = messages.ConfigChange.ChangeTypeValueValuesEnum 150 if change_type in [enums.ADDED, enums.REMOVED, enums.MODIFIED]: 151 return six.text_type(change_type).lower() 152 else: 153 return '[unknown]' 154 155 156def PushAdvisorConfigChangeToString(config_change): 157 """Convert a ConfigChange message to a printable string. 158 159 Args: 160 config_change: The ConfigChange message to convert. 161 162 Returns: 163 An easily readable string representing the ConfigChange message. 164 """ 165 result = ('Element [{element}] (old value = {old_value}, ' 166 'new value = {new_value}) was {change_type}. Advice:\n').format( 167 element=config_change.element, 168 old_value=config_change.oldValue, 169 new_value=config_change.newValue, 170 change_type=PushAdvisorChangeTypeToString( 171 config_change.changeType)) 172 173 for advice in config_change.advices: 174 result += '\t* {0}'.format(advice.description) 175 176 return result 177 178 179def GetActiveRolloutForService(service): 180 """Return the latest Rollout for a service. 181 182 This function returns the most recent Rollout that has a status of SUCCESS 183 or IN_PROGRESS. 184 185 Args: 186 service: The name of the service for which to retrieve the active Rollout. 187 188 Returns: 189 The Rollout message corresponding to the active Rollout for the service. 190 """ 191 client = GetClientInstance() 192 messages = GetMessagesModule() 193 statuses = messages.Rollout.StatusValueValuesEnum 194 allowed_statuses = [statuses.SUCCESS, statuses.IN_PROGRESS] 195 196 req = messages.ServicemanagementServicesRolloutsListRequest( 197 serviceName=service) 198 199 result = list( 200 list_pager.YieldFromList( 201 client.services_rollouts, 202 req, 203 predicate=lambda r: r.status in allowed_statuses, 204 limit=1, 205 batch_size_attribute='pageSize', 206 field='rollouts', 207 ) 208 ) 209 210 return result[0] if result else None 211 212 213def GetActiveServiceConfigIdsFromRollout(rollout): 214 """Get the active service config IDs from a Rollout message. 215 216 Args: 217 rollout: The rollout message to inspect. 218 219 Returns: 220 A list of active service config IDs as indicated in the rollout message. 221 """ 222 if rollout and rollout.trafficPercentStrategy: 223 return [p.key for p in rollout.trafficPercentStrategy.percentages 224 .additionalProperties] 225 else: 226 return [] 227 228 229def GetActiveServiceConfigIdsForService(service): 230 active_rollout = GetActiveRolloutForService(service) 231 return GetActiveServiceConfigIdsFromRollout(active_rollout) 232 233 234def FilenameMatchesExtension(filename, extensions): 235 """Checks to see if a file name matches one of the given extensions. 236 237 Args: 238 filename: The full path to the file to check 239 extensions: A list of candidate extensions. 240 241 Returns: 242 True if the filename matches one of the extensions, otherwise False. 243 """ 244 f = filename.lower() 245 for ext in extensions: 246 if f.endswith(ext.lower()): 247 return True 248 return False 249 250 251def IsProtoDescriptor(filename): 252 return FilenameMatchesExtension( 253 filename, ['.pb', '.descriptor', '.proto.bin']) 254 255 256def IsRawProto(filename): 257 return FilenameMatchesExtension(filename, ['.proto']) 258 259 260def ReadServiceConfigFile(file_path): 261 try: 262 if IsProtoDescriptor(file_path): 263 return files.ReadBinaryFileContents(file_path) 264 return files.ReadFileContents(file_path) 265 except files.Error as ex: 266 raise calliope_exceptions.BadFileException( 267 'Could not open service config file [{0}]: {1}'.format(file_path, ex)) 268 269 270def PushNormalizedGoogleServiceConfig(service_name, project, config_dict, 271 config_id=None): 272 """Pushes a given normalized Google service configuration. 273 274 Args: 275 service_name: name of the service 276 project: the producer project Id 277 config_dict: the parsed contents of the Google Service Config file. 278 config_id: The id name for the config 279 280 Returns: 281 Result of the ServicesConfigsCreate request (a Service object) 282 """ 283 messages = GetMessagesModule() 284 client = GetClientInstance() 285 286 # Be aware: DictToMessage takes the value first and message second; 287 # JsonToMessage takes the message first and value second 288 service_config = encoding.DictToMessage(config_dict, messages.Service) 289 service_config.producerProjectId = project 290 service_config.id = config_id 291 create_request = ( 292 messages.ServicemanagementServicesConfigsCreateRequest( 293 serviceName=service_name, 294 service=service_config, 295 )) 296 return client.services_configs.Create(create_request) 297 298 299def GetServiceConfigIdFromSubmitConfigSourceResponse(response): 300 return response.get('serviceConfig', {}).get('id') 301 302 303def PushMultipleServiceConfigFiles(service_name, config_files, is_async, 304 validate_only=False, config_id=None): 305 """Pushes a given set of service configuration files. 306 307 Args: 308 service_name: name of the service. 309 config_files: a list of ConfigFile message objects. 310 is_async: whether to wait for aync operations or not. 311 validate_only: whether to perform a validate-only run of the operation 312 or not. 313 config_id: an optional name for the config 314 315 Returns: 316 Full response from the SubmitConfigSource request. 317 318 Raises: 319 ServiceDeployErrorException: the SubmitConfigSource API call returned a 320 diagnostic with a level of ERROR. 321 """ 322 messages = GetMessagesModule() 323 client = GetClientInstance() 324 325 config_source = messages.ConfigSource(id=config_id) 326 config_source.files.extend(config_files) 327 328 config_source_request = messages.SubmitConfigSourceRequest( 329 configSource=config_source, 330 validateOnly=validate_only, 331 ) 332 submit_request = ( 333 messages.ServicemanagementServicesConfigsSubmitRequest( 334 serviceName=service_name, 335 submitConfigSourceRequest=config_source_request, 336 )) 337 api_response = client.services_configs.Submit(submit_request) 338 operation = ProcessOperationResult(api_response, is_async) 339 340 response = operation.get('response', {}) 341 diagnostics = response.get('diagnostics', []) 342 343 num_errors = 0 344 for diagnostic in diagnostics: 345 kind = diagnostic.get('kind', '').upper() 346 logger = log.error if kind == 'ERROR' else log.warning 347 msg = '{l}: {m}\n'.format( 348 l=diagnostic.get('location'), m=diagnostic.get('message')) 349 logger(msg) 350 351 if kind == 'ERROR': 352 num_errors += 1 353 354 if num_errors > 0: 355 exception_msg = ('{0} diagnostic error{1} found in service configuration ' 356 'deployment. See log for details.').format( 357 num_errors, 's' if num_errors > 1 else '') 358 raise exceptions.ServiceDeployErrorException(exception_msg) 359 360 return response 361 362 363def PushOpenApiServiceConfig( 364 service_name, spec_file_contents, spec_file_path, is_async, 365 validate_only=False): 366 """Pushes a given Open API service configuration. 367 368 Args: 369 service_name: name of the service 370 spec_file_contents: the contents of the Open API spec file. 371 spec_file_path: the path of the Open API spec file. 372 is_async: whether to wait for aync operations or not. 373 validate_only: whether to perform a validate-only run of the operation 374 or not. 375 376 Returns: 377 Full response from the SubmitConfigSource request. 378 """ 379 messages = GetMessagesModule() 380 381 config_file = messages.ConfigFile( 382 fileContents=spec_file_contents, 383 filePath=spec_file_path, 384 # Always use YAML because JSON is a subset of YAML. 385 fileType=(messages.ConfigFile. 386 FileTypeValueValuesEnum.OPEN_API_YAML), 387 ) 388 return PushMultipleServiceConfigFiles(service_name, [config_file], is_async, 389 validate_only=validate_only) 390 391 392def DoesServiceExist(service_name): 393 """Check if a service resource exists. 394 395 Args: 396 service_name: name of the service to check if exists. 397 398 Returns: 399 Whether or not the service exists. 400 """ 401 messages = GetMessagesModule() 402 client = GetClientInstance() 403 get_request = messages.ServicemanagementServicesGetRequest( 404 serviceName=service_name, 405 ) 406 try: 407 client.services.Get(get_request) 408 except (apitools_exceptions.HttpForbiddenError, 409 apitools_exceptions.HttpNotFoundError): 410 # Older versions of service management backend return a 404 when service is 411 # new, but more recent versions return a 403. Check for either one for now. 412 return False 413 else: 414 return True 415 416 417def CreateService(service_name, project, is_async=False): 418 """Creates a Service resource. 419 420 Args: 421 service_name: name of the service to be created. 422 project: the project Id 423 is_async: If False, the method will block until the operation completes. 424 """ 425 messages = GetMessagesModule() 426 client = GetClientInstance() 427 # create service 428 create_request = messages.ManagedService( 429 serviceName=service_name, 430 producerProjectId=project, 431 ) 432 result = client.services.Create(create_request) 433 434 GetProcessedOperationResult(result, is_async=is_async) 435 436 437def ValidateFingerprint(fingerprint): 438 return re.match(FINGERPRINT_REGEX, fingerprint) is not None 439 440 441def ValidateEmailString(email): 442 """Returns true if the input is a valid email string. 443 444 This method uses a somewhat rudimentary regular expression to determine 445 input validity, but it should suffice for basic sanity checking. 446 447 It also verifies that the email string is no longer than 254 characters, 448 since that is the specified maximum length. 449 450 Args: 451 email: The email string to validate 452 453 Returns: 454 A bool -- True if the input is valid, False otherwise 455 """ 456 return EMAIL_REGEX.match(email or '') is not None and len(email) <= 254 457 458 459def ProcessOperationResult(result, is_async=False): 460 """Validate and process Operation outcome for user display. 461 462 Args: 463 result: The message to process (expected to be of type Operation)' 464 is_async: If False, the method will block until the operation completes. 465 466 Returns: 467 The processed Operation message in Python dict form 468 """ 469 op = GetProcessedOperationResult(result, is_async) 470 if is_async: 471 cmd = OP_WAIT_CMD.format(op.get('name')) 472 log.status.Print('Asynchronous operation is in progress... ' 473 'Use the following command to wait for its ' 474 'completion:\n {0}\n'.format(cmd)) 475 else: 476 cmd = OP_DESCRIBE_CMD.format(op.get('name')) 477 log.status.Print('Operation finished successfully. ' 478 'The following command can describe ' 479 'the Operation details:\n {0}\n'.format(cmd)) 480 return op 481 482 483def GetProcessedOperationResult(result, is_async=False): 484 """Validate and process Operation result message for user display. 485 486 This method checks to make sure the result is of type Operation and 487 converts the StartTime field from a UTC timestamp to a local datetime 488 string. 489 490 Args: 491 result: The message to process (expected to be of type Operation)' 492 is_async: If False, the method will block until the operation completes. 493 494 Returns: 495 The processed message in Python dict form 496 """ 497 if not result: 498 return 499 500 messages = GetMessagesModule() 501 502 RaiseIfResultNotTypeOf(result, messages.Operation) 503 504 result_dict = encoding.MessageToDict(result) 505 506 if not is_async: 507 op_name = result_dict['name'] 508 op_ref = resources.REGISTRY.Parse( 509 op_name, 510 collection='servicemanagement.operations') 511 log.status.Print( 512 'Waiting for async operation {0} to complete...'.format(op_name)) 513 result_dict = encoding.MessageToDict(WaitForOperation( 514 op_ref, GetClientInstance())) 515 516 return result_dict 517 518 519def RaiseIfResultNotTypeOf(test_object, expected_type, nonetype_ok=False): 520 if nonetype_ok and test_object is None: 521 return 522 if not isinstance(test_object, expected_type): 523 raise TypeError('result must be of type %s' % expected_type) 524 525 526def WaitForOperation(operation_ref, client): 527 """Waits for an operation to complete. 528 529 Args: 530 operation_ref: A reference to the operation on which to wait. 531 client: The client object that contains the GetOperation request object. 532 533 Raises: 534 TimeoutError: if the operation does not complete in time. 535 OperationErrorException: if the operation fails. 536 537 Returns: 538 The Operation object, if successful. Raises an exception on failure. 539 """ 540 WaitForOperation.operation_response = None 541 messages = GetMessagesModule() 542 operation_id = operation_ref.operationsId 543 544 def _CheckOperation(operation_id): # pylint: disable=missing-docstring 545 request = messages.ServicemanagementOperationsGetRequest( 546 operationsId=operation_id, 547 ) 548 549 result = client.operations.Get(request) 550 551 if result.done: 552 WaitForOperation.operation_response = result 553 return True 554 else: 555 return False 556 557 # Wait for no more than 30 minutes while retrying the Operation retrieval 558 try: 559 retry.Retryer(exponential_sleep_multiplier=1.1, wait_ceiling_ms=10000, 560 max_wait_ms=30*60*1000).RetryOnResult( 561 _CheckOperation, [operation_id], should_retry_if=False, 562 sleep_ms=1500) 563 except retry.MaxRetrialsException: 564 raise exceptions.TimeoutError('Timed out while waiting for ' 565 'operation {0}. Note that the operation ' 566 'is still pending.'.format(operation_id)) 567 568 # Check to see if the operation resulted in an error 569 if WaitForOperation.operation_response.error is not None: 570 raise exceptions.OperationErrorException( 571 'The operation with ID {0} resulted in a failure.'.format(operation_id)) 572 573 # If we've gotten this far, the operation completed successfully, 574 # so return the Operation object 575 return WaitForOperation.operation_response 576 577 578def LoadJsonOrYaml(input_string): 579 """Tries to load input string as JSON first, then YAML if that fails. 580 581 Args: 582 input_string: The string to convert to a dictionary 583 584 Returns: 585 A dictionary of the resulting decoding, or None if neither format could be 586 detected. 587 """ 588 def TryJson(): 589 try: 590 return json.loads(input_string) 591 except ValueError: 592 log.info('No JSON detected in service config. Trying YAML...') 593 594 def TryYaml(): 595 try: 596 return yaml.load(input_string) 597 except yaml.YAMLParseError as e: 598 if hasattr(e.inner_error, 'problem_mark'): 599 mark = e.inner_error.problem_mark 600 log.error('Service config YAML had an error at position (%s:%s)' 601 % (mark.line+1, mark.column+1)) 602 603 # First, try to decode JSON. If that fails, try to decode YAML. 604 return TryJson() or TryYaml() 605 606 607def CreateRollout(service_config_id, service_name, is_async=False): 608 """Creates a Rollout for a Service Config within it's service. 609 610 Args: 611 service_config_id: The service config id 612 service_name: The name of the service 613 is_async: (Optional) Wheter or not operation should be asynchronous 614 615 Returns: 616 The rollout object or long running operation if is_async is true 617 """ 618 messages = GetMessagesModule() 619 client = GetClientInstance() 620 621 percentages = messages.TrafficPercentStrategy.PercentagesValue() 622 percentages.additionalProperties.append( 623 (messages.TrafficPercentStrategy.PercentagesValue.AdditionalProperty( 624 key=service_config_id, value=100.0))) 625 traffic_percent_strategy = messages.TrafficPercentStrategy( 626 percentages=percentages) 627 rollout = messages.Rollout( 628 serviceName=service_name, 629 trafficPercentStrategy=traffic_percent_strategy,) 630 rollout_create = messages.ServicemanagementServicesRolloutsCreateRequest( 631 rollout=rollout, 632 serviceName=service_name, 633 ) 634 rollout_operation = client.services_rollouts.Create(rollout_create) 635 op = ProcessOperationResult(rollout_operation, is_async) 636 637 return op.get('response', None) 638