# -*- 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. """Parse cloudbuild config files. """ from __future__ import absolute_import from __future__ import division from __future__ import unicode_literals import re from googlecloudsdk.api_lib.cloudbuild import cloudbuild_util from googlecloudsdk.core import exceptions # Don't apply camel case to keys for dict or list values with these field names. # These correspond to map fields in our proto message, where we expect keys to # be sent exactly as the user typed them, without transformation to camelCase. _SKIP_CAMEL_CASE = [ 'secretEnv', 'secret_env', 'substitutions', 'envMap', 'env_map' ] # Regex for a valid user-defined substitution variable. _BUILTIN_SUBSTITUTION_REGEX = re.compile('^_[A-Z0-9_]+$') # What we call cloudbuild.yaml for error messages that try to parse it. _BUILD_CONFIG_FRIENDLY_NAME = 'build config' class InvalidBuildConfigException(exceptions.Error): """Build config message is not valid. """ def __init__(self, path, msg): msg = 'validating {path} as build config: {msg}'.format( path=path, msg=msg, ) super(InvalidBuildConfigException, self).__init__(msg) def FinalizeCloudbuildConfig(build, path, params=None, no_source=None): """Validate the given build message, and merge substitutions. Args: build: The build message to finalize. path: The path of the original build config, for error messages. params: Any additional substitution parameters as a dict. no_source: CLI flag value for --no-source. If set, the build config can provide a remote build source. Raises: InvalidBuildConfigException: If the build config is invalid. Returns: The valid build message with substitutions complete. """ subst_value = build.substitutions if subst_value is None: subst_value = build.SubstitutionsValue() if params is None: params = {} # Convert substitutions value to dict temporarily. subst_dict = {} for kv in subst_value.additionalProperties: subst_dict[kv.key] = kv.value # Validate the substitution keys in the message. for key in subst_dict: if not _BUILTIN_SUBSTITUTION_REGEX.match(key): raise InvalidBuildConfigException( path, 'substitution key {} does not respect format {}'.format( key, _BUILTIN_SUBSTITUTION_REGEX.pattern ), ) # Merge the substitutions passed in the flag. subst_dict.update(params) # Convert substitutions dict back into value, and store it. # Sort so that tests work. subst_value = build.SubstitutionsValue() for key, value in sorted(subst_dict.items()): ap = build.SubstitutionsValue.AdditionalProperty() ap.key = key ap.value = value subst_value.additionalProperties.append(ap) if subst_value.additionalProperties: build.substitutions = subst_value # Some problems can be caught before talking to the cloudbuild service. if not no_source and build.source: raise InvalidBuildConfigException( path, 'config cannot specify source without the flag --no-source' ) if not build.remoteConfig and not build.steps: raise InvalidBuildConfigException( path, 'config must list at least one step or specify remote_config', ) return build def LoadCloudbuildConfigFromStream( stream, messages, params=None, path=None, ): """Load a cloudbuild config file into a Build message. Args: stream: file-like object containing the JSON or YAML data to be decoded. messages: module, The messages module that has a Build type. params: dict, parameters to substitute into a templated Build spec. 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. InvalidBuildConfigException: If the build config has illegal values. Returns: Build message, The build that got decoded. """ build = cloudbuild_util.LoadMessageFromStream(stream, messages.Build, _BUILD_CONFIG_FRIENDLY_NAME, _SKIP_CAMEL_CASE, path) build = FinalizeCloudbuildConfig(build, path, params) return build def LoadCloudbuildConfigFromPath(path, messages, params=None, no_source=None): """Load a cloudbuild config file into a Build message. Args: path: str. Path to the JSON or YAML data to be decoded. messages: module, The messages module that has a Build type. params: dict, parameters to substitute into a templated Build spec. no_source: CLI flag value for --no-source. If set, the build config can provide a remote build source. 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. InvalidBuildConfigException: If the build config has illegal values. Returns: Build message, The build that got decoded. """ build = cloudbuild_util.LoadMessageFromPath( path, messages.Build, _BUILD_CONFIG_FRIENDLY_NAME, _SKIP_CAMEL_CASE) build = FinalizeCloudbuildConfig(build, path, params, no_source) return build