# -*- coding: utf-8 -*- # # Copyright 2016 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Utilities for the cloudbuild API.""" from __future__ import absolute_import from __future__ import division from __future__ import unicode_literals import enum import re from apitools.base.protorpclite import messages as proto_messages from apitools.base.py import encoding as apitools_encoding from googlecloudsdk.api_lib.cloudbuild import cloudbuild_exceptions from googlecloudsdk.api_lib.util import apis from googlecloudsdk.calliope import base from googlecloudsdk.calliope import exceptions as c_exceptions from googlecloudsdk.core import yaml from googlecloudsdk.core.resource import resource_property from googlecloudsdk.core.util import files import six _API_NAME = 'cloudbuild' _GA_API_VERSION = 'v1' _BETA_API_VERSION = 'v1beta1' RELEASE_TRACK_TO_API_VERSION = { base.ReleaseTrack.GA: _GA_API_VERSION, base.ReleaseTrack.BETA: _GA_API_VERSION, base.ReleaseTrack.ALPHA: _GA_API_VERSION, } WORKERPOOL_NAME_MATCHER = r'projects/.*/locations/.*/workerPools/.*' WORKERPOOL_NAME_SELECTOR = r'projects/.*/locations/.*/workerPools/(.*)' WORKERPOOL_REGION_SELECTOR = r'projects/.*/locations/(.*)/workerPools/.*' # Default for optionally-regional requests when the user does not specify. DEFAULT_REGION = 'global' BYTES_IN_ONE_GB = 2**30 class WorkerpoolTypes(enum.Enum): UNKNOWN = 0 PRIVATE = 1 HYBRID = 2 def GetMessagesModule(release_track=base.ReleaseTrack.GA): """Returns the messages module for Cloud Build. Args: release_track: The desired value of the enum googlecloudsdk.calliope.base.ReleaseTrack. Returns: Module containing the definitions of messages for Cloud Build. """ return apis.GetMessagesModule(_API_NAME, RELEASE_TRACK_TO_API_VERSION[release_track]) def GetClientClass(release_track=base.ReleaseTrack.GA): """Returns the client class for Cloud Build. Args: release_track: The desired value of the enum googlecloudsdk.calliope.base.ReleaseTrack. Returns: base_api.BaseApiClient, Client class for Cloud Build. """ return apis.GetClientClass(_API_NAME, RELEASE_TRACK_TO_API_VERSION[release_track]) def GetClientInstance( release_track=base.ReleaseTrack.GA, use_http=True, skip_activation_prompt=False, ): """Returns an instance of the Cloud Build client. Args: release_track: The desired value of the enum googlecloudsdk.calliope.base.ReleaseTrack. use_http: bool, True to create an http object for this client. skip_activation_prompt: bool, True to skip prompting for service activation. Should be used only if service activation was checked earlier in the command. Returns: base_api.BaseApiClient, An instance of the Cloud Build client. """ return apis.GetClientInstance( _API_NAME, RELEASE_TRACK_TO_API_VERSION[release_track], no_http=(not use_http), skip_activation_prompt=skip_activation_prompt, ) def EncodeSubstitutions(substitutions, messages): if not substitutions: return None # Sort for tests return apitools_encoding.DictToAdditionalPropertyMessage( substitutions, messages.Build.SubstitutionsValue, sort_items=True) def EncodeTriggerSubstitutions(substitutions, value_type): if not substitutions: return None substitution_properties = [] for key, value in sorted(six.iteritems(substitutions)): # Sort for tests substitution_properties.append( value_type.AdditionalProperty(key=key, value=value)) return value_type(additionalProperties=substitution_properties) def EncodeUpdatedTriggerSubstitutions(old_substitutions, substitutions, messages): """Encodes the trigger substitutions for the update command. Args: old_substitutions: The existing substitutions to be updated. substitutions: The substitutions to be added to the existing substitutions. messages: A Cloud Build messages module. Returns: The updated trigger substitutions. """ if not substitutions: return old_substitutions substitution_map = {} if old_substitutions: for sub in old_substitutions.additionalProperties: substitution_map[sub.key] = sub.value for key, value in six.iteritems(substitutions): substitution_map[key] = value updated_substitutions = [] for key, value in sorted(substitution_map.items()): # Sort for tests. updated_substitutions.append( messages.BuildTrigger.SubstitutionsValue.AdditionalProperty( key=key, value=value ) ) return messages.BuildTrigger.SubstitutionsValue( additionalProperties=updated_substitutions ) def RemoveTriggerSubstitutions( old_substitutions, substitutions_to_be_removed, messages ): """Removes existing substitutions for the update command. Args: old_substitutions: The existing substitutions. substitutions_to_be_removed: The substitutions to be removed if exist. messages: A Cloud Build messages module. Returns: The updated trigger substitutions. """ if not substitutions_to_be_removed: return None substitution_properties = [] if old_substitutions: for sub in old_substitutions.additionalProperties: if sub.key not in substitutions_to_be_removed: substitution_properties.append( messages.BuildTrigger.SubstitutionsValue.AdditionalProperty( key=sub.key, value=sub.value ) ) if not substitution_properties: substitution_properties.append( messages.BuildTrigger.SubstitutionsValue.AdditionalProperty() ) return messages.BuildTrigger.SubstitutionsValue( additionalProperties=substitution_properties ) def EncodeEmptyTriggerSubstitutions(messages): substitution_properties = [ messages.BuildTrigger.SubstitutionsValue.AdditionalProperty() ] return messages.BuildTrigger.SubstitutionsValue( additionalProperties=substitution_properties ) def SnakeToCamelString(snake): """Change a snake_case string into a camelCase string. Args: snake: str, the string to be transformed. Returns: str, the transformed string. """ parts = snake.split('_') if not parts: return snake # Handle snake with leading '_'s by collapsing them into the next part. # Legit field names will never look like this, but completeness of the # function is important. leading_blanks = 0 for p in parts: if not p: leading_blanks += 1 else: break if leading_blanks: parts = parts[leading_blanks:] if not parts: # If they were all blanks, then we over-counted by one because of split # behavior. return '_' * (leading_blanks - 1) parts[0] = '_' * leading_blanks + parts[0] return ''.join(parts[:1] + [s.capitalize() for s in parts[1:]]) def SnakeToCamel(msg, skip=None): """Recursively transform all keys and values from snake_case to camelCase. If a key is in skip, then its value is left alone. Args: msg: dict, list, or other. If 'other', the function returns immediately. skip: contains dict keys whose values should not have camel case applied. Returns: Same type as msg, except all strings that were snake_case are now CamelCase, except for the values of dict keys contained in skip. """ if skip is None: skip = [] if isinstance(msg, dict): return { SnakeToCamelString(key): (SnakeToCamel(val, skip) if key not in skip else val) for key, val in six.iteritems(msg) } elif isinstance(msg, list): return [SnakeToCamel(elem, skip) for elem in msg] else: return msg def MessageToFieldPaths(msg): """Produce field paths from a message object. The result is used to create a FieldMask proto message that contains all field paths presented in the object. https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/field_mask.proto Args: msg: A user defined message object that extends the messages.Message class. https://github.com/google/apitools/blob/master/apitools/base/protorpclite/messages.py Returns: The list of field paths. """ fields = [] for field in msg.all_fields(): v = msg.get_assigned_value(field.name) if field.repeated and not v: # Repeated field is initialized as an empty list. continue if v is not None: # ConvertToSnakeCase produces private_poolv1_config or hybrid_pool_config. if field.name == 'privatePoolV1Config': name = 'private_pool_v1_config' elif field.name == 'hybridPoolConfig': name = 'hybrid_pool_config' else: name = resource_property.ConvertToSnakeCase(field.name) if hasattr(v, 'all_fields'): # message has sub-messages, constructing subpaths. for f in MessageToFieldPaths(v): fields.append('{}.{}'.format(name, f)) else: fields.append(name) return fields def _UnpackCheckUnused(obj, msg_type): """Stuff a dict into a proto message, and fail if there are unused values. Args: obj: dict(), The structured data to be reflected into the message type. msg_type: type, The proto message type. Raises: ValueError: If there is an unused value in obj. Returns: Proto message, The message that was created from obj. """ msg = apitools_encoding.DictToMessage(obj, msg_type) def _CheckForUnusedFields(obj): """Check for any unused fields in nested messages or lists.""" if isinstance(obj, proto_messages.Message): unused_fields = obj.all_unrecognized_fields() if unused_fields: if len(unused_fields) > 1: # Because this message shows up in a dotted path, use braces. # eg .foo.bar.{x,y,z} unused_msg = '{%s}' % ','.join(sorted(unused_fields)) else: # For single items, omit the braces. # eg .foo.bar.x unused_msg = unused_fields[0] raise ValueError('.%s: unused' % unused_msg) for used_field in obj.all_fields(): try: field = getattr(obj, used_field.name) _CheckForUnusedFields(field) except ValueError as e: raise ValueError('.%s%s' % (used_field.name, e)) if isinstance(obj, list): for i, item in enumerate(obj): try: _CheckForUnusedFields(item) except ValueError as e: raise ValueError('[%d]%s' % (i, e)) _CheckForUnusedFields(msg) return msg def LoadMessageFromStream(stream, msg_type, msg_friendly_name, skip_camel_case=None, path=None): """Load a proto message from a stream of JSON or YAML text. Args: stream: file-like object containing the JSON or YAML data to be decoded. msg_type: The protobuf message type to create. msg_friendly_name: A readable name for the message type, for use in error messages. skip_camel_case: Contains proto field names or map keys whose values should not have camel case applied. path: str or None. Optional path to be used in error messages. Raises: ParserError: If there was a problem parsing the stream as a dict. ParseProtoException: If there was a problem interpreting the stream as the given message type. Returns: Proto message, The message that got decoded. """ if skip_camel_case is None: skip_camel_case = [] # Turn the data into a dict try: structured_data = yaml.load(stream, file_hint=path) except yaml.Error as e: raise cloudbuild_exceptions.ParserError(path, e.inner_error) if not isinstance(structured_data, dict): raise cloudbuild_exceptions.ParserError(path, 'Could not parse as a dictionary.') return _YamlToMessage(structured_data, msg_type, msg_friendly_name, skip_camel_case, path) def LoadMessagesFromStream(stream, msg_type, msg_friendly_name, skip_camel_case=None, path=None): """Load multiple proto message from a stream of JSON or YAML text. Args: stream: file-like object containing the JSON or YAML data to be decoded. msg_type: The protobuf message type to create. msg_friendly_name: A readable name for the message type, for use in error messages. skip_camel_case: Contains proto field names or map keys whose values should not have camel case applied. path: str or None. Optional path to be used in error messages. Raises: ParserError: If there was a problem parsing the stream. ParseProtoException: If there was a problem interpreting the stream as the given message type. Returns: Proto message list of the messages that got decoded. """ if skip_camel_case is None: skip_camel_case = [] # Turn the data into a dict try: structured_data = yaml.load_all(stream, file_hint=path) except yaml.Error as e: raise cloudbuild_exceptions.ParserError(path, e.inner_error) return [ _YamlToMessage(item, msg_type, msg_friendly_name, skip_camel_case, path) for item in structured_data ] def _YamlToMessage(structured_data, msg_type, msg_friendly_name, skip_camel_case=None, path=None): """Load a proto message from a file containing JSON or YAML text. Args: structured_data: Dict containing the decoded YAML data. msg_type: The protobuf message type to create. msg_friendly_name: A readable name for the message type, for use in error messages. skip_camel_case: Contains proto field names or map keys whose values should not have camel case applied. path: str or None. Optional path to be used in error messages. Raises: ParseProtoException: If there was a problem interpreting the file as the given message type. Returns: Proto message, The message that got decoded. """ # Transform snake_case into camelCase. structured_data = SnakeToCamel(structured_data, skip_camel_case) # Then, turn the dict into a proto message. try: msg = _UnpackCheckUnused(structured_data, msg_type) except Exception as e: # Catch all exceptions here because a valid YAML can sometimes not be a # valid message, so we need to catch all errors in the dict to message # conversion. raise cloudbuild_exceptions.ParseProtoException(path, msg_friendly_name, '%s' % e) return msg def LoadMessageFromPath(path, msg_type, msg_friendly_name, skip_camel_case=None): """Load a proto message from a file containing JSON or YAML text. Args: path: The path to a file containing the JSON or YAML data to be decoded. msg_type: The protobuf message type to create. msg_friendly_name: A readable name for the message type, for use in error messages. skip_camel_case: Contains proto field names or map keys whose values should not have camel case applied. Raises: files.MissingFileError: If the file does not exist. ParserError: If there was a problem parsing the file as a dict. ParseProtoException: If there was a problem interpreting the file as the given message type. Returns: Proto message, The message that got decoded. """ with files.FileReader(path) as f: # Returns user-friendly error messages return LoadMessageFromStream(f, msg_type, msg_friendly_name, skip_camel_case, path) def LoadMessagesFromPath(path, msg_type, msg_friendly_name, skip_camel_case=None): """Load a proto message from a file containing JSON or YAML text. Args: path: The path to a file containing the JSON or YAML data to be decoded. msg_type: The protobuf message type to create. msg_friendly_name: A readable name for the message type, for use in error messages. skip_camel_case: Contains proto field names or map keys whose values should not have camel case applied. Raises: files.MissingFileError: If the file does not exist. ParseProtoException: If there was a problem interpreting the file as the given message type. Returns: Proto message list of the messages that got decoded. """ with files.FileReader(path) as f: # Returns user-friendly error messages return LoadMessagesFromStream(f, msg_type, msg_friendly_name, skip_camel_case, path) def IsWorkerPool(resource_name): """Determine if the provided full resource name is a worker pool. Args: resource_name: str, The string to test. Returns: bool, True if the string is a worker pool's full resource name. """ return bool(re.match(WORKERPOOL_NAME_MATCHER, resource_name)) def WorkerPoolShortName(resource_name): """Get the name part of a worker pool's full resource name. For example, "projects/abc/locations/def/workerPools/ghi" returns "ghi". Args: resource_name: A worker pool's full resource name. Raises: ValueError: If the full resource name was not well-formatted. Returns: The worker pool's short name. """ match = re.search(WORKERPOOL_NAME_SELECTOR, resource_name) if match: return match.group(1) raise ValueError('The worker pool resource name must match "%s"' % (WORKERPOOL_NAME_MATCHER,)) def WorkerPoolRegion(resource_name): """Get the region part of a worker pool's full resource name. For example, "projects/abc/locations/def/workerPools/ghi" returns "def". Args: resource_name: str, A worker pool's full resource name. Raises: ValueError: If the full resource name was not well-formatted. Returns: str, The worker pool's region string. """ match = re.search(WORKERPOOL_REGION_SELECTOR, resource_name) if match: return match.group(1) raise ValueError('The worker pool resource name must match "%s"' % (WORKERPOOL_NAME_MATCHER,)) def GitHubEnterpriseConfigFromArgs(args, update=False): """Construct the GitHubEnterpriseConfig resource from the command line args. Args: args: An argparse namespace. All the arguments that were provided to this command invocation. update: bool, if the args are for an update. Returns: A populated GitHubEnterpriseConfig message. """ messages = GetMessagesModule() ghe = messages.GitHubEnterpriseConfig() ghe.hostUrl = args.host_uri ghe.appId = args.app_id if args.webhook_key is not None: ghe.webhookKey = args.webhook_key if not update and args.peered_network is not None: ghe.peeredNetwork = args.peered_network if args.gcs_bucket is not None: gcs_location = messages.GCSLocation() gcs_location.bucket = args.gcs_bucket gcs_location.object = args.gcs_object if args.generation is not None: gcs_location.generation = args.generation ghe.appConfigJson = gcs_location else: secret_location = messages.GitHubEnterpriseSecrets() secret_location.privateKeyName = args.private_key_name secret_location.webhookSecretName = args.webhook_secret_name secret_location.oauthSecretName = args.oauth_secret_name secret_location.oauthClientIdName = args.oauth_client_id_name ghe.secrets = secret_location return ghe def BitbucketServerConfigFromArgs(args, update=False): """Construct the BitbucketServer resource from the command line args. Args: args: an argparse namespace. All the arguments that were provided to this command invocation. update: bool, if the args are for an update. Returns: A populated BitbucketServerConfig message. """ messages = GetMessagesModule() bbs = messages.BitbucketServerConfig() bbs.hostUri = args.host_uri bbs.username = args.user_name bbs.apiKey = args.api_key secret_location = messages.BitbucketServerSecrets() secret_location.adminAccessTokenVersionName = ( args.admin_access_token_secret_version ) secret_location.readAccessTokenVersionName = ( args.read_access_token_secret_version ) secret_location.webhookSecretVersionName = args.webhook_secret_secret_version if update or secret_location is not None: bbs.secrets = secret_location if not update: if args.peered_network is None and args.peered_network_ip_range is not None: raise c_exceptions.RequiredArgumentException( 'peered-network-ip-range', ( '--peered-network is required when specifying' ' --peered-network-ip-range.' ), ) if args.peered_network is not None: bbs.peeredNetwork = args.peered_network bbs.peeredNetworkIpRange = args.peered_network_ip_range if args.IsSpecified('ssl_ca_file'): bbs.sslCa = args.ssl_ca_file return bbs def GitLabConfigFromArgs(args): """Construct the GitLabConfig resource from the command line args. Args: args: an argparse namespace. All the arguments that were provided to this command invocation. Returns: A populated GitLabConfig message. """ messages = GetMessagesModule() config = messages.GitLabConfig() config.username = args.user_name secrets = messages.GitLabSecrets() secrets.apiAccessTokenVersion = args.api_access_token_secret_version secrets.readAccessTokenVersion = args.read_access_token_secret_version secrets.webhookSecretVersion = args.webhook_secret_secret_version secrets.apiKeyVersion = args.api_key_secret_version if not _IsEmptyMessage(secrets): config.secrets = secrets enterprise_config = messages.GitLabEnterpriseConfig() enterprise_config.hostUri = args.host_uri service_directory_config = messages.ServiceDirectoryConfig() service_directory_config.service = args.service_directory_service enterprise_config.serviceDirectoryConfig = service_directory_config if args.IsSpecified('ssl_ca_file'): enterprise_config.sslCa = args.ssl_ca_file if not _IsEmptyMessage(enterprise_config): config.enterpriseConfig = enterprise_config return config def _IsEmptyMessage(message): if message is None: return True message_dict = apitools_encoding.MessageToDict(message) return not any(message_dict.values()) def WorkerPoolIsSpecified(build_config): return ( build_config is not None and build_config.options is not None and build_config.options.pool is not None and build_config.options.pool.name is not None ) def WorkerPoolConfigIsSpecified(build_config): return ( build_config is not None and build_config.options is not None and build_config.options.pool is not None and build_config.options.pool.workerConfig is not None ) def BytesToGb(size): """Converts bytes to GB. Args: size: a size in GB Does not require size to be a multiple of 1 GB unlike utils.BytesToGb from from googlecloudsdk.api_lib.compute Returns: size in bytes. """ if not size: return None return size // BYTES_IN_ONE_GB