scripts/local_cloudbuild.py (191 lines of code) (raw):

#!/usr/bin/env python3 # Copyright 2017 Google Inc. 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. """Emulate the Google Cloud Build locally. The input is a local cloudbuild.yaml file. This is translated into a series of commands for the locally installed Docker daemon. These commands are output as a shell script and optionally executed. The output images are not pushed to the Google Container Registry. Not all cloudbuild.yaml functionality is supported. In particular, substitutions are a simplified subset that doesn't include all the corner cases and error conditions. See https://cloud.google.com/container-builder/docs/api/build-steps for more information. """ import argparse import collections import collections.abc import functools import io import os import re import shlex import subprocess import sys import yaml import validation_utils # Exclude non-printable control characters (including newlines) PRINTABLE_REGEX = re.compile(r"""^[^\x00-\x1f]*$""") # Cloud Build substitutions # https://cloud.google.com/cloud-build/docs/api/build-requests#substitutions SUBSTITUTION_REGEX = re.compile(r"""(?x) [$] # Dollar sign ( [A-Z_][A-Z0-9_]* # Variable name, no curly brackets | {[A-Z_][A-Z0-9_]*} # Variable name, with curly brackets | [$] # $$, translated to a single literal $ ) """) # Default builtin substitutions DEFAULT_SUBSTITUTIONS = { 'BRANCH_NAME': '', 'BUILD_ID': 'abcdef12-3456-7890-abcd-ef0123456789', 'COMMIT_SHA': '', 'PROJECT_ID': 'dummy-project-id', 'REPO_NAME': '', 'REVISION_ID': '', 'TAG_NAME': '', } # Use this image for cleanup actions DEBIAN_IMAGE = 'gcr.io/google-appengine/debian8' # File template BUILD_SCRIPT_TEMPLATE = """\ #!/bin/bash # This is a generated file. Do not edit. set -euo pipefail SOURCE_DIR=. # Setup staging directory HOST_WORKSPACE=$(mktemp -d -t local_cloudbuild_XXXXXXXXXX) function cleanup {{ if [ "${{HOST_WORKSPACE}}" != '/' -a -d "${{HOST_WORKSPACE}}" ]; then # Expect a single error message about /workspace busy {cleanup_str} 2>/dev/null || true # Do not expect error messages here. Display but ignore. rmdir "${{HOST_WORKSPACE}}" || true fi }} trap cleanup EXIT # Copy source to staging directory echo "Copying source to staging directory ${{HOST_WORKSPACE}}" rsync -avzq --exclude=.git "${{SOURCE_DIR}}" "${{HOST_WORKSPACE}}" # Build commands {docker_str} # End of build commands echo "Build completed successfully" """ # Validated cloudbuild recipe + flags CloudBuild = collections.namedtuple('CloudBuild', 'output_script run steps substitutions') # Single validated step in a cloudbuild recipe Step = collections.namedtuple('Step', 'args dir_ env name') def sub_and_quote(s, substitutions, substitutions_used): """Return a shell-escaped, variable substituted, version of the string s. Args: s (str): Any string subs (dict): Substitution map to apply subs_used (set): Updated with names from `subs.keys()` when those substitutions are encountered in `s` """ def sub(match): """Perform a single substitution.""" variable_name = match.group(1) if variable_name[0] == '{': # Strip curly brackets variable_name = variable_name[1:-1] if variable_name == '$': value = '$' elif variable_name not in substitutions: # Variables must be set raise ValueError( 'Variable "{}" used without being defined. Try adding ' 'it to the --substitutions flag'.format(variable_name)) else: value = substitutions.get(variable_name) substitutions_used.add(variable_name) return value substituted_s = re.sub(SUBSTITUTION_REGEX, sub, s) quoted_s = shlex.quote(substituted_s) return quoted_s def get_cloudbuild(raw_config, args): """Read and validate a cloudbuild recipe Args: raw_config (dict): deserialized cloudbuild.yaml args (argparse.Namespace): command line flags Returns: CloudBuild: valid configuration """ if not isinstance(raw_config, dict): raise ValueError( 'Expected {} contents to be of type "dict", but found type "{}"'. format(args.config, type(raw_config))) raw_steps = validation_utils.get_field_value(raw_config, 'steps', list) if not raw_steps: raise ValueError('No steps defined in {}'.format(args.config)) steps = [get_step(raw_step) for raw_step in raw_steps] return CloudBuild( output_script=args.output_script, run=args.run, steps=steps, substitutions=args.substitutions, ) def get_step(raw_step): """Read and validate a single cloudbuild step Args: raw_step (dict): deserialized step Returns: Step: valid build step """ if not isinstance(raw_step, dict): raise ValueError( 'Expected step to be of type "dict", but found type "{}"'. format(type(raw_step))) raw_args = validation_utils.get_field_value(raw_step, 'args', list) args = [validation_utils.get_field_value(raw_args, index, str) for index in range(len(raw_args))] dir_ = validation_utils.get_field_value(raw_step, 'dir', str) raw_env = validation_utils.get_field_value(raw_step, 'env', list) env = [validation_utils.get_field_value(raw_env, index, str) for index in range(len(raw_env))] name = validation_utils.get_field_value(raw_step, 'name', str) return Step( args=args, dir_=dir_, env=env, name=name, ) def generate_command(step, substitutions, substitutions_used): """Generate a single shell command to run for a single cloudbuild step Args: step (Step): Valid build step subs (dict): Substitution map to apply subs_used (set): Updated with names from `subs.keys()` when those substitutions are encountered in an element of `step` Returns: [str]: A single shell command, expressed as a list of quoted tokens. """ quoted_args = [sub_and_quote(arg, substitutions, substitutions_used) for arg in step.args] quoted_env = [] for env in step.env: quoted_env.extend(['--env', sub_and_quote(env, substitutions, substitutions_used)]) quoted_name = sub_and_quote(step.name, substitutions, substitutions_used) workdir = '/workspace' if step.dir_: workdir = os.path.join(workdir, sub_and_quote(step.dir_, substitutions, substitutions_used)) process_args = [ 'docker', 'run', '--volume', '/var/run/docker.sock:/var/run/docker.sock', '--volume', '/root/.docker:/root/.docker', '--volume', '${HOST_WORKSPACE}:/workspace', '--workdir', workdir, ] + quoted_env + [quoted_name] + quoted_args return process_args def generate_script(cloudbuild): """Generate the contents of a shell script Args: cloudbuild (CloudBuild): Valid cloudbuild configuration Returns: (str): Contents of shell script """ # This deletes everything in /workspace including hidden files, # but not /workspace itself cleanup_step = Step( args=['rm', '-rf', '/workspace'], dir_='', env=[], name=DEBIAN_IMAGE, ) cleanup_command = generate_command(cleanup_step, {}, set()) subs_used = set() docker_commands = [ generate_command(step, cloudbuild.substitutions, subs_used) for step in cloudbuild.steps] # Check that all user variables were referenced at least once user_subs_unused = [name for name in cloudbuild.substitutions.keys() if name not in subs_used and name[0] == '_'] if user_subs_unused: nice_list = '"' + '", "'.join(sorted(user_subs_unused)) + '"' raise ValueError( 'User substitution variables {} were defined in the ' '--substitution flag but never used in the cloudbuild file.'. format(nice_list)) cleanup_str = ' '.join(cleanup_command) docker_lines = [] for docker_command in docker_commands: line = ' '.join(docker_command) + '\n\n' docker_lines.append(line) docker_str = ''.join(docker_lines) s = BUILD_SCRIPT_TEMPLATE.format(cleanup_str=cleanup_str, docker_str=docker_str) return s def make_executable(path): """Set executable bit(s) on file""" # http://stackoverflow.com/questions/12791997 mode = os.stat(path).st_mode mode |= (mode & 0o444) >> 2 # copy R bits to X os.chmod(path, mode) def write_script(cloudbuild, contents): """Write a shell script to a file.""" print('Writing build script to {}'.format(cloudbuild.output_script)) with io.open(cloudbuild.output_script, 'w', encoding='utf8') as outfile: outfile.write(contents) make_executable(cloudbuild.output_script) def local_cloudbuild(args): """Execute the steps of a cloudbuild.yaml locally Args: args: command line flags as per parse_args """ # Load and parse cloudbuild.yaml with io.open(args.config, 'r', encoding='utf8') as cloudbuild_file: raw_config = yaml.safe_load(cloudbuild_file) # Determine configuration cloudbuild = get_cloudbuild(raw_config, args) # Create shell script contents = generate_script(cloudbuild) write_script(cloudbuild, contents) # Run shell script if cloudbuild.run: print('Running {}'.format(cloudbuild.output_script)) args = [os.path.abspath(cloudbuild.output_script)] subprocess.check_call(args) def parse_args(argv): """Parse and validate command line flags""" parser = argparse.ArgumentParser( description='Process cloudbuild.yaml locally to build Docker images') parser.add_argument( '--config', type=functools.partial( validation_utils.validate_arg_regex, flag_regex=PRINTABLE_REGEX), default='cloudbuild.yaml', help='Path to cloudbuild.yaml file' ) parser.add_argument( '--output_script', type=functools.partial( validation_utils.validate_arg_regex, flag_regex=PRINTABLE_REGEX), help='Filename to write shell script to', ) parser.add_argument( '--no-run', action='store_false', help='Create shell script but don\'t execute it', dest='run', ) parser.add_argument( '--substitutions', type=validation_utils.validate_arg_dict, default={}, help='Parameters to be substituted in the build specification', ) args = parser.parse_args(argv[1:]) if not args.output_script: args.output_script = args.config + "_local.sh" return args def main(): args = parse_args(sys.argv) local_cloudbuild(args) if __name__ == '__main__': main()