Skip to content

Commit 2ed2735

Browse files
author
Doug Greiman
committed
Support the --substitutions flag as per 'gcloud container builds submit'
1 parent 0ba16cb commit 2ed2735

6 files changed

Lines changed: 239 additions & 34 deletions

scripts/local_cloudbuild.py

Lines changed: 84 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121
commands are output as a shell script and optionally executed.
2222
2323
The output images are not pushed to the Google Container Registry.
24-
Not all cloudbuild.yaml functionality is supported.
24+
Not all cloudbuild.yaml functionality is supported. In particular,
25+
substitutions are a simplified subset that doesn't include all the
26+
corner cases and error conditions.
2527
2628
See https://cloud.google.com/container-builder/docs/api/build-steps
2729
for more information.
@@ -44,6 +46,28 @@
4446
# Exclude non-printable control characters (including newlines)
4547
PRINTABLE_REGEX = re.compile(r"""^[^\x00-\x1f]*$""")
4648

49+
# Container Builder substitutions
50+
# https://cloud.google.com/container-builder/docs/api/build-requests#substitutions
51+
SUBSTITUTION_REGEX = re.compile(r"""(?x)
52+
(?<!\\) # Don't match if backslash before dollar sign
53+
\$ # Dollar sign
54+
(
55+
[A-Z0-9_]+ # Variable name, no curly brackets
56+
|
57+
{[A-Z0-9_]+} # Variable name, with curly brackets
58+
)
59+
""")
60+
KEY_VALUE_REGEX = re.compile(r'^([A-Z0-9_]+)=(.*)$')
61+
62+
# Default builtin substitutions
63+
DEFAULT_SUBSTITUTIONS = {
64+
'PROJECT_ID': 'dummy-project-id',
65+
'REPO_NAME': '',
66+
'BRANCH_NAME': '',
67+
'TAG_NAME': '',
68+
'REVISION_ID': '',
69+
}
70+
4771
# File template
4872
BUILD_SCRIPT_HEADER = """\
4973
#!/bin/bash
@@ -77,12 +101,39 @@
77101

78102

79103
# Validated cloudbuild recipe + flags
80-
CloudBuild = collections.namedtuple('CloudBuild', 'output_script run steps')
104+
CloudBuild = collections.namedtuple('CloudBuild', 'output_script run steps substitutions')
81105

82106
# Single validated step in a cloudbuild recipe
83107
Step = collections.namedtuple('Step', 'args dir_ env name')
84108

85109

110+
def sub_and_quote(s, substitutions):
111+
"""Return a shell-escaped, variable substituted, version of the string s."""
112+
113+
def sub(match):
114+
"""Perform a single substitution."""
115+
variable_name = match.group(1)
116+
if variable_name[0] == '{':
117+
# Strip curly brackets
118+
variable_name = variable_name[1:-1]
119+
if variable_name not in substitutions:
120+
if variable_name.startswith('_'):
121+
# User variables must be set
122+
raise ValueError(
123+
'Variable "{}" used without being defined. Try adding '
124+
'it to the --substitutions flag'.format(
125+
variable_name))
126+
else:
127+
# Builtin variables are silently turned into empty strings
128+
value = ''
129+
else:
130+
value = substitutions.get(variable_name)
131+
return value
132+
133+
substituted_s = re.sub(SUBSTITUTION_REGEX, sub, s)
134+
quoted_s = shlex.quote(substituted_s)
135+
return quoted_s
136+
86137
def get_field_value(container, field_name, field_type):
87138
"""Fetch a field from a container with typechecking and default values.
88139
@@ -153,6 +204,7 @@ def get_cloudbuild(raw_config, args):
153204
output_script=args.output_script,
154205
run=args.run,
155206
steps=steps,
207+
substitutions=args.substitutions,
156208
)
157209

158210

@@ -185,23 +237,24 @@ def get_step(raw_step):
185237
)
186238

187239

188-
def generate_command(step):
240+
def generate_command(step, subs):
189241
"""Generate a single shell command to run for a single cloudbuild step
190242
191243
Args:
192244
step (Step): Valid build step
245+
subs (dict): Substitution map to apply
193246
194247
Returns:
195248
[str]: A single shell command, expressed as a list of quoted tokens.
196249
"""
197-
quoted_args = [shlex.quote(arg) for arg in step.args]
250+
quoted_args = [sub_and_quote(arg, subs) for arg in step.args]
198251
quoted_env = []
199252
for env in step.env:
200-
quoted_env.extend(['--env', shlex.quote(env)])
201-
quoted_name = shlex.quote(step.name)
253+
quoted_env.extend(['--env', sub_and_quote(env, subs)])
254+
quoted_name = sub_and_quote(step.name, subs)
202255
workdir = '/workspace'
203256
if step.dir_:
204-
workdir = os.path.join(workdir, shlex.quote(step.dir_))
257+
workdir = os.path.join(workdir, sub_and_quote(step.dir_, subs))
205258
process_args = [
206259
'docker',
207260
'run',
@@ -228,7 +281,8 @@ def generate_script(cloudbuild):
228281
"""
229282
outfile = io.StringIO()
230283
outfile.write(BUILD_SCRIPT_HEADER)
231-
docker_commands = [generate_command(step) for step in cloudbuild.steps]
284+
docker_commands = [generate_command(step, cloudbuild.substitutions)
285+
for step in cloudbuild.steps]
232286
for docker_command in docker_commands:
233287
line = ' '.join(docker_command) + '\n\n'
234288
outfile.write(line)
@@ -286,6 +340,22 @@ def validate_arg_regex(flag_value, flag_regex):
286340
return flag_value
287341

288342

343+
def validate_arg_dict(flag_value):
344+
"""Parse a command line flag as a key=val,... dict"""
345+
if not flag_value:
346+
return {}
347+
entries = flag_value.split(',')
348+
pairs = []
349+
for entry in entries:
350+
match = re.match(KEY_VALUE_REGEX, entry)
351+
if not match:
352+
raise argparse.ArgumentTypeError(
353+
'Value "{}" should be a list like _KEY1=value1,_KEY2=value2"'.format(
354+
flag_value))
355+
pairs.append((match.group(1), match.group(2)))
356+
return dict(pairs)
357+
358+
289359
def parse_args(argv):
290360
"""Parse and validate command line flags"""
291361
parser = argparse.ArgumentParser(
@@ -309,6 +379,12 @@ def parse_args(argv):
309379
help='Create shell script but don\'t execute it',
310380
dest='run',
311381
)
382+
parser.add_argument(
383+
'--substitutions',
384+
type=validate_arg_dict,
385+
default={},
386+
help='Parameters to be substituted in the build specification',
387+
)
312388
args = parser.parse_args(argv[1:])
313389
if not args.output_script:
314390
args.output_script = args.config + "_local.sh"

0 commit comments

Comments
 (0)