internal/acceptance/defs.bzl (426 lines of code) (raw):
"""Macros for running acceptance tests."""
load("@rules_pkg//pkg:zip.bzl", "pkg_zip")
load("@io_bazel_rules_go//go:def.bzl", "go_test")
# acceptance_test_suite defines several targets that are useful with buildpacks acceptance tests.
# It defines a test target for each version, a test suite that runs all of the versioned targets,
# a cloudbuild build configuration, and tarball for submitting to cloudbuild. See below for more
# specifics on each of the targets.
#
# Given a name of 'gae_test' and versions value of [1.13, 1.14], it defines the following:
# * 1.13_gae_test: A test target with a version of 1.13.
# * 1.14_gae_test: A test target with a version of 1.14.
# * gae_test: A test suite target that aliases to [1.13_gae_test, 1.14_gae_test]. Useful for running
# all versions of the tests.
# * gae_test_cloudbuild.yaml: A cloudbuild config which contains a step for running the tests
# against 1.13 and a step for running against 1.14.
# * gae_test_cloudbuild.zip: A zip which can be used with 'gae_test_cloudbuild.yaml' to
# submit a cloudbuild.
#
# To submit a cloudbuild with the go builder:
# blaze build //third_party/gcp_buildpacks/builders/go/acceptance:gae_test_cloudbuild.zip
# blaze build //third_party/gcp_buildpacks/builders/go/acceptance:gae_test_cloudbuild.yaml
# gcloud builds submit \
# blaze-bin/third_party/gcp_buildpacks/builders/go/acceptance/gae_test_cloudbuild.zip \
# --config blaze-genfiles/third_party/gcp_buildpacks/builders/go/acceptance/gae_test_cloudbuild.yaml
def acceptance_test_suite(
name,
srcs,
testdata,
builder = None,
structure_test_config = ":config.yaml",
versions = {},
runtime_to_builder_map = None,
args = None,
deps = None,
argsmap = None,
**kwargs):
"""Macro to define an acceptance test.
Args:
name: the name of the test
srcs: the test source files
testdata: a build target for a directory containing sample test applications
builder: a build target for a builder.tar to test
structure_test_config: a build target for the structured container test's config file
versions: a list of GOOGLE_RUNTIME_VERSIONS to test, these correspond to language versions
args: additional arguments to be passed to the test binary beyond ones corresponding to the arguments to this function
deps: additional test dependencies beyond the acceptance package
argsmap: version specific arguments map where the key is the version and the value is a list of flags that will be passed to the acceptance test framework
**kwargs: this argument captures all additional arguments and forwards them to the generated go_test rule
"""
deps = _build_deps(deps)
_build_tests(name, srcs, args, testdata, builder, structure_test_config, deps, versions, runtime_to_builder_map, argsmap, **kwargs)
_cloudbuild_targets(name, srcs, structure_test_config, builder, args, deps, versions, argsmap, testdata)
def _build_tests(name, srcs, args, testdata, builder, structure_test_config, deps, versions, runtime_to_builder_map, argsmap, **kwargs):
# if there are no versions passed in then create a go_test(...) rule directly without changing
# the name of the test
if versions == {}:
test_args = _build_args(args, name, testdata, builder, structure_test_config)
data = _build_data(structure_test_config, builder, testdata)
_new_go_test_for_single_version(name, srcs, test_args, data, deps, **kwargs)
else:
_new_go_test_for_versions(versions, name, srcs, args, testdata, builder, structure_test_config, runtime_to_builder_map, deps, argsmap, **kwargs)
def _new_go_test_for_versions(versions, name, srcs, args, testdata, builder, structure_test_config, runtime_to_builder_map, deps, argsmap, **kwargs):
tests = []
if type(versions) == type({}):
for _n, v in versions.items():
selected_builder = _select_builder(builder, runtime_to_builder_map, _n)
test_args = _build_args(args, name, testdata, selected_builder, structure_test_config)
data = _build_data(structure_test_config, selected_builder, testdata)
ver_name = v + "_" + name
tests.append(ver_name)
ver_args = list(test_args)
ver_args.append("-runtime-version=" + v)
if argsmap != None and argsmap.get(v) != None:
for ak, av in argsmap[v].items():
ver_args.append(ak + "=" + av)
_new_go_test(ver_name, srcs, ver_args, data, deps, **kwargs)
# Each of the go_test(...) rules generated by _new_go_test have a name that is hard for
# developers to reference, for example, '//acceptance:gcp_test_3.1.416'. To make this
# easy to use, generate a test suite with the name of the original build target, so
# developers can reference simply '//acceptance:gcp_test'.
native.test_suite(
name = name,
tests = tests,
)
if len(tests) > 0:
_new_bin_filegroup_alias(name, tests[0])
def _select_builder(builder, runtime_to_builder_map, runtime):
selected_builder = builder
if runtime_to_builder_map != None and runtime in runtime_to_builder_map:
selected_builder = runtime_to_builder_map[runtime]
return selected_builder
def _new_go_test_for_single_version(name, srcs, args, data, deps, **kwargs):
_new_go_test(name, srcs, args, data, deps, **kwargs)
_new_bin_filegroup_alias(name, name)
def _new_go_test(name, srcs, args, data, deps, **kwargs):
go_test(
name = name,
size = "enormous",
srcs = srcs,
args = args,
data = data,
tags = [
"local",
],
gc_linkopts = [],
deps = deps,
**kwargs
)
def _new_bin_filegroup_alias(name, test_name):
# The test binaries generated in this file have names such as '1.13_gae_test'. For external
# consumers who wish to invoke the test binary directly, the following filegroup gives them
# a static name that they can reference such as 'gae_test_bin'.
native.filegroup(
name = name + "_bin",
srcs = [":" + test_name],
testonly = 1,
)
def _build_args(args, name, testdata, builder, structure_test_config):
short_name = _remove_suffix(name, "_test")
builder_name = _extract_builder_name(builder)
if args == None:
args = []
else:
# make a copy of the args list to prevent mutating the passed in value
args = list(args)
args.append("-test-data=$(location " + testdata + ")")
args.append("-structure-test-config=$(location " + structure_test_config + ")")
args.append("-builder-source=$(location " + builder + ")")
args.append("-builder-prefix=" + builder_name + "-" + short_name + "-acceptance-test-")
args.append("-runtime-name=" + builder_name)
return args
def _build_data(structure_test_config, builder, testdata):
return [
structure_test_config,
builder,
testdata,
]
def _extract_builder_name(builder):
# A builder target is a full google3 path, the name of the builder, and then :builder.tar, the following
# extracts the name of the builder.
builder_name = _remove_suffix(builder, ":builder.tar")
builder_name = builder_name[builder_name.rindex("/") + 1:]
return builder_name
def _build_deps(deps):
if deps == None:
deps = []
else:
# make a copy of the list to prevent mutating a shared 'deps' value declared in a BUILD file
deps = list(deps)
deps.append("//internal/acceptance")
return deps
# Once bazel supports python 3.9, this function can be replaced with `value.removesuffix(suffix)`:
def _remove_suffix(value, suffix):
if value.endswith(suffix):
value = value[:-len(suffix)]
return value
def is_bazel_build(testdata):
return not testdata.startswith("//third_party/gcp_buildpacks")
# _cloudbuild_targets builds two rules that can be used to run the acceptance tests in gcloud.
def _cloudbuild_targets(name, srcs, structure_test_config, builder, args, deps, versions, argsmap, testdata):
bin_name = _build_cloudbuild_test_binary(name, srcs, deps)
# this hack is to conditionally prevent testdata from being zipped in bazel, as
# _build_testdata_target makes use of Fileset which is not available in bazel.
if not is_bazel_build(testdata):
_build_cloudbuild_zip(name, bin_name, structure_test_config, builder, testdata)
_build_cloudbuild_config_target(name, bin_name, builder, args, versions, argsmap, testdata)
_build_per_version_cloudbuild_config_targets(name, bin_name, builder, args, versions, argsmap, testdata)
def _build_cloudbuild_zip(name, bin_name, structure_test_config, builder, testdata):
if "java" in testdata:
testdata_fileset_name = testdata
else:
testdata_fileset_name = _build_testdata_target(name, testdata)
pkg_zip(
name = name + "_cloudbuild",
srcs = [
bin_name,
builder,
testdata_fileset_name,
structure_test_config,
],
testonly = 1,
)
def _build_cloudbuild_test_binary(name, srcs, deps):
bin_name = name + "_cloudbuild_bin"
_new_go_test(bin_name, srcs, None, None, deps)
return bin_name
# _build_testdata_target creates a Fileset target for the given testdata label. The reason to do
# this is our testdata is accessed via exports_files(...) which copies the testdata into writeable
# folders. The acceptance test suite relies on the the folders being writeable. We assume the
# existence of a filegroup with the name "[testdata_label]_files" to bring in the required files.
def _build_testdata_target(name, testdata):
testdata_pkg, testdata_label = testdata.split(":")
fileset_name = name + "_" + testdata_label
testdata_filegroup = testdata_label + "_files"
native.Fileset(
name = fileset_name,
out = name + "_generated/" + testdata_label,
entries = [
native.FilesetEntry(
srcdir = testdata_pkg + ":BUILD",
files = [testdata_filegroup],
strip_prefix = testdata_label,
),
],
)
return fileset_name
def _build_per_version_cloudbuild_config_targets(name, bin_name, builder, args, versions, argsmap, testdata):
if versions != {} and type(versions) == type({}):
for n, version in versions.items():
version_name = name + "_" + n
cloudbuild_config = _build_cloudbuild_config(name, bin_name, builder, args, {n: version}, argsmap, testdata)
native.genrule(
name = version_name + "_cloudbuild_config",
outs = [version_name + "_cloudbuild.yaml"],
cmd = "echo '" + cloudbuild_config + "' >> $@",
)
def _build_cloudbuild_config_target(name, bin_name, builder, args, versions, argsmap, testdata):
cloudbuild_config = _build_cloudbuild_config(name, bin_name, builder, args, versions, argsmap, testdata)
native.genrule(
name = name + "_cloudbuild_config",
outs = [name + "_cloudbuild.yaml"],
cmd = "echo '" + cloudbuild_config + "' >> $@",
)
# "LOOSE" substitution option is enabled so that unused variables can be defined like
# '_RUNTIME_LANGUAGE'. This which will be useful to consumers who wish to insert their own
# substitutions and want to reference common properties.
#
# To use a different builder than the 'builder.tar' that was passed into acceptance_test_suite,
# define the _BUILDER_IMAGE substitution. For example, replace the empty string with
# gcr.io/gae-runtimes/buildpacks/{_RUNTIME_LANGUAGE}/builder:latest
_buildconfig_template = """options:
machineType: E2_HIGHCPU_8
dynamic_substitutions: true
substitution_option: 'ALLOW_LOOSE'
substitutions:
_PULL_IMAGES: \"true\"
_BUILDER_IMAGE: \"\"
_RUNTIME_LANGUAGE: ${runtime_language}
timeout: 3600s
steps:
- id: fix-permissions
name: gcr.io/gae-runtimes/utilities/pack:latest
entrypoint: /bin/bash
args:
- -c
- chmod -R 755 *
"""
def _build_cloudbuild_config(name, bin_name, builder, args, versions, argsmap, testdata):
builder_name = _extract_builder_name(builder)
result = _format_cloudbuild_config(builder_name)
steps = _build_cloudbuild_steps(name, bin_name, args, versions, argsmap, testdata)
for s in steps:
s = indent(s, 2)
result += "- " + s + "\n"
return result
def _format_cloudbuild_config(runtime_language):
result = _buildconfig_template
result = result.replace("${runtime_language}", runtime_language)
return result
def indent(value, n, ch = " "):
padding = n * ch
lines = value.splitlines(True)
return padding.join(lines)
def _build_cloudbuild_steps(name, bin_name, args, versions, argsmap, testdata):
testdata_label = testdata[testdata.rfind(":") + 1:]
# if there were no versions passed in then create a list with a single None value so the
# below loop will still have a single iteration and pass a None value for version to _build_step
if versions == {}:
versions = {"None": None}
steps = []
if type(versions) == type({}):
for _n, ver in versions.items():
if args == None:
ver_args = []
else:
ver_args = list(args)
if argsmap != None and argsmap.get(ver) != None:
for key, val in argsmap[ver].items():
ver_args.append(key + "=" + val)
step_config = _build_step(name, bin_name, ver, ver_args, testdata_label)
steps.append(step_config)
return steps
# By default, _BUILDER_IMAGE is defined as the empty string. The acceptance test framework will
# use the flag -builder-source when -builder-image is empty. When -builder-image has a value then
# it takes precedence over -builder-source.
_step_template = """entrypoint: /bin/bash
id: ${test_name}
name: gcr.io/gae-runtimes/utilities/pack:latest
waitFor: ['fix-permissions']
args:
- -c
- >
./${bin_name} \\
-cloudbuild \\
-pull-images=$${_PULL_IMAGES} \\
-test-data=${testdata} \\
-builder-source=builder.tar \\
-builder-image=$${_BUILDER_IMAGE} \\
-runtime-name=$${_RUNTIME_LANGUAGE} \\
-structure-test-config=config.yaml"""
def _build_step(name, bin_name, version, args, testdata_label):
result = _step_template
result = result.replace("${bin_name}", bin_name)
if version != None:
name = name + "-" + version
result = result.replace("${test_name}", name)
result = result.replace("${testdata}", testdata_label)
if args == None:
args = []
if version != None:
args.append("-runtime-version=" + version)
for a in args:
result += " \\\n " + a
return result
_default_cloudbuild_bin_targets = ["flex_test_cloudbuild_bin", "gae_test_cloudbuild_bin", "gcf_test_cloudbuild_bin", "gcp_test_cloudbuild_bin"]
_firebase_acceptance_test_cloudbuild_bin_targets = ["nodejs_acceptance_test_cloudbuild_bin"]
def _build_argo_source_testdata_fileset_target(name, testdata):
fileset_name = name + "_testdata"
testdata_pkg, testdata_label = testdata.split(":")
native.Fileset(
name = fileset_name,
out = "testdata",
entries = [
native.FilesetEntry(
srcdir = testdata_pkg,
files = [testdata_label],
strip_prefix = "",
),
],
)
return fileset_name
def generate_universal_acceptance_test_cloudbuild_bin_targets():
"""Generates the cloudbuild binary targets for universal builder acceptance tests.
Returns:
A list of cloudbuild binary targets for universal builder acceptance tests.
"""
language_products_map = {
"nodejs": ["generic", "functions"],
"dart": ["generic"],
"dotnet": ["generic", "functions"],
"go": ["generic", "functions"],
"java": ["generic", "functions"],
"php": ["generic"],
"python": ["generic", "functions"],
"ruby": ["generic", "functions"],
}
_universal_acceptance_test_cloudbuild_bin_targets = []
bin_name_suffix = "_acceptance_test_cloudbuild_bin"
for language, products in language_products_map.items():
for product in products:
bin_name_prefix = language
if product == "functions":
bin_name_prefix += "_fn"
_universal_acceptance_test_cloudbuild_bin_targets.append(bin_name_prefix + bin_name_suffix)
return _universal_acceptance_test_cloudbuild_bin_targets
def acceptance_test_argo_source(name, testdata, srcs = [], structure_test_config = ":config.yaml", isUniversal = False):
# this hack is to conditionally prevent testdata from being zipped in bazel, as
# _build_testdata_target makes use of Fileset which is not available in bazel.
if is_bazel_build(testdata):
return
testdata_fileset_target = _build_argo_source_testdata_fileset_target(name, testdata)
cloudbuild_bin_targets = _default_cloudbuild_bin_targets
if isUniversal:
_universal_acceptance_test_cloudbuild_bin_targets = generate_universal_acceptance_test_cloudbuild_bin_targets()
cloudbuild_bin_targets = _universal_acceptance_test_cloudbuild_bin_targets
pkg_zip(
name = name,
srcs = srcs + cloudbuild_bin_targets + [
testdata_fileset_target,
structure_test_config,
],
testonly = 1,
)
# acceptance_test_argo_source_firebase generates the cloud build source for firebase builder acceptance tests.
def acceptance_test_argo_source_firebase(name, testdata, srcs = [], structure_test_config = ":config.yaml"):
# this hack is to conditionally prevent testdata from being zipped in bazel, as
# _build_testdata_target makes use of Fileset which is not available in bazel.
if is_bazel_build(testdata):
return
testdata_fileset_target = _build_argo_source_testdata_fileset_target(name, testdata)
pkg_zip(
name = name,
srcs = srcs + _firebase_acceptance_test_cloudbuild_bin_targets + [
testdata_fileset_target,
structure_test_config,
],
testonly = 1,
)
def create_acceptance_versions_dict_file(name, file, flex_runtime_versions, gae_runtime_versions, gcf_runtime_versions, gcp_runtime_versions, **kwargs):
"""Export a file that contains a dictionary of product to a list of strings, each of which can be parsed as {{runtime id}}:{{runtime semver version}}
Takes input dictionaries for each of the products for a single language
Creates an output file with the following structure:
========
{
flex: [go118:1.18.10, go119:1.19.13, go120:1.20.10, go121:1.21.3],
gae: [go111:1.11.13, go112:1.12.17, go113:1.13.15, go114:1.14.15, go115:1.15.15, go116:1.16.15, go118:1.18.10, go119:1.19.13, go120:1.20.10, go121:1.21.3],
gcf: [go113:1.13.15, go116:1.16.15, go118:1.18.10, go119:1.19.13, go120:1.20.10, go121:1.21.3],
gcp: [go118:1.18.10, go119:1.19.13, go120:1.20.10, go121:1.21.3, go111:1.11.13, go112:1.12.17, go113:1.13.15, go114:1.14.15, go115:1.15.15, go116:1.16.15],
}
========
Args:
name: name of the target to create
file: the output file name
multiline with additional indentation.
flex_runtime_versions: bzl/python map of flex runtimes to exact runtime semvers for the
given runtime
gae_runtime_versions: bzl/python map of gae runtimes to exact runtime semvers for the
given runtime
gcf_runtime_versions: bzl/python map of gcf runtimes to exact runtime semvers for the
given runtime
gcp_runtime_versions: bzl/python map of gcp runtimes to exact runtime semvers for the
given runtime
**kwargs: passed through to the native.genrule.
"""
d = dict()
d["flex"] = [k + ":" + v for k, v in flex_runtime_versions.items()]
d["gae"] = [k + ":" + v for k, v in gae_runtime_versions.items()]
d["gcf"] = [k + ":" + v for k, v in gcf_runtime_versions.items()]
d["gcp"] = [k + ":" + v for k, v in gcp_runtime_versions.items()]
native.genrule(
name = name,
outs = [file],
cmd = ("echo " + str(d) + " > $@"),
visibility = ["//visibility:public"],
**kwargs
)