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()