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