diff --git a/MODULE.bazel b/MODULE.bazel index a4d344b7..95019230 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -12,6 +12,7 @@ bazel_dep(name = "rules_img", version = "0.3.13") bazel_dep(name = "rules_pkg", version = "1.0.1") bazel_dep(name = "rules_go", version = "0.54.0") bazel_dep(name = "rules_shell", version = "0.8.0") +bazel_dep(name = "yq.bzl", version = "0.3.6") go_sdk = use_extension("@rules_go//go:extensions.bzl", "go_sdk") go_sdk.download(version = "1.23.9") diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 7f0b0e4e..ae2814fc 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -28,6 +28,8 @@ "https://bcr.bazel.build/modules/bazel_features/1.47.0/source.json": "4ba0b5138327f2d73352a51547a4e49a0a828ef400e046b15334d8905bf6b7ff", "https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b", "https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a", + "https://bcr.bazel.build/modules/bazel_lib/3.0.0/MODULE.bazel": "22b70b80ac89ad3f3772526cd9feee2fa412c2b01933fea7ed13238a448d370d", + "https://bcr.bazel.build/modules/bazel_lib/3.0.0/source.json": "895f21909c6fba01d7c17914bb6c8e135982275a1b18cdaa4e62272217ef1751", "https://bcr.bazel.build/modules/bazel_skylib/1.0.3/MODULE.bazel": "bcb0fd896384802d1ad283b4e4eb4d718eebd8cb820b0a2c3a347fb971afd9d8", "https://bcr.bazel.build/modules/bazel_skylib/1.1.1/MODULE.bazel": "1add3e7d93ff2e6998f9e118022c84d163917d912f5afafb3058e3d2f1545b5e", "https://bcr.bazel.build/modules/bazel_skylib/1.2.0/MODULE.bazel": "44fe84260e454ed94ad326352a698422dbe372b21a1ac9f3eab76eb531223686", @@ -39,6 +41,7 @@ "https://bcr.bazel.build/modules/bazel_skylib/1.6.1/MODULE.bazel": "8fdee2dbaace6c252131c00e1de4b165dc65af02ea278476187765e1a617b917", "https://bcr.bazel.build/modules/bazel_skylib/1.7.0/MODULE.bazel": "0db596f4563de7938de764cc8deeabec291f55e8ec15299718b93c4423e9796d", "https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b", + "https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6", "https://bcr.bazel.build/modules/bazel_skylib/1.9.0/MODULE.bazel": "72997b29dfd95c3fa0d0c48322d05590418edef451f8db8db5509c57875fb4b7", "https://bcr.bazel.build/modules/bazel_skylib/1.9.0/source.json": "7ad77c1e8c1b84222d9b3f3cae016a76639435744c19330b0b37c0a3c9da7dc0", "https://bcr.bazel.build/modules/buildifier_prebuilt/8.0.3/MODULE.bazel": "4791b031727d1183c183a0b8fd5fc0fe8d8834c45f9a22efd26be6c962b9cfee", @@ -63,6 +66,7 @@ "https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902", "https://bcr.bazel.build/modules/package_metadata/0.0.10/MODULE.bazel": "cb66e0ce830e01bc00cd9edc984963704f2759ca893a649996e2b1c5c1ea7271", "https://bcr.bazel.build/modules/package_metadata/0.0.10/source.json": "8fc6bece244828a69b3cc608f381118d67fa3cc2aa1a2851dfe8d99a8076d7d0", + "https://bcr.bazel.build/modules/package_metadata/0.0.2/MODULE.bazel": "fb8d25550742674d63d7b250063d4580ca530499f045d70748b1b142081ebb92", "https://bcr.bazel.build/modules/package_metadata/0.0.3/MODULE.bazel": "77890552ecea9e284b5424c9de827a58099348763a4359e975c359a83d4faa83", "https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5", "https://bcr.bazel.build/modules/platforms/0.0.11/MODULE.bazel": "0daefc49732e227caa8bfa834d65dc52e8cc18a2faf80df25e8caea151a9413f", @@ -162,6 +166,7 @@ "https://bcr.bazel.build/modules/rules_runfiles_group/0.0.1-rc.5/source.json": "32efc01a4b29cbb5b2b97fd1f7d6025e68eb20eea498f1127b068082e315d6cf", "https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c", "https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b", + "https://bcr.bazel.build/modules/rules_shell/0.4.1/MODULE.bazel": "00e501db01bbf4e3e1dd1595959092c2fadf2087b2852d3f553b5370f5633592", "https://bcr.bazel.build/modules/rules_shell/0.8.0/MODULE.bazel": "f6a89f1d6a669a26f28fe814503857055d76306b79cfc11d12399af08d0b80ae", "https://bcr.bazel.build/modules/rules_shell/0.8.0/source.json": "eb53cc815bc503c6683c5fe12d943f98883f81fc22f51403ec8a95610cba4195", "https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8", @@ -173,6 +178,8 @@ "https://bcr.bazel.build/modules/stardoc/0.7.1/MODULE.bazel": "3548faea4ee5dda5580f9af150e79d0f6aea934fc60c1cc50f4efdd9420759e7", "https://bcr.bazel.build/modules/stardoc/0.7.1/source.json": "b6500ffcd7b48cd72c29bb67bcac781e12701cc0d6d55d266a652583cfcdab01", "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43", + "https://bcr.bazel.build/modules/yq.bzl/0.3.6/MODULE.bazel": "985c2a0cb4ad9994bb0e33cc7fae931c91105eeefe3faa355b8f4c258d0607c0", + "https://bcr.bazel.build/modules/yq.bzl/0.3.6/source.json": "678aaf6e291164f3cd761bb3e872e8a151248f413dbb63c5524a50b82a5bc890", "https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0", "https://bcr.bazel.build/modules/zlib/1.2.12/MODULE.bazel": "3b1a8834ada2a883674be8cbd36ede1b6ec481477ada359cd2d3ddc562340b27", "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/MODULE.bazel": "eec517b5bbe5492629466e11dae908d043364302283de25581e3eb944326c4ca", @@ -3223,6 +3230,87 @@ ] ] } + }, + "@@yq.bzl+//yq:extensions.bzl%yq": { + "general": { + "bzlTransitiveDigest": "UfFMy8CWK4/dVo/tfaSAIYUiDGNAPes5eRllx9O9Q9Q=", + "usagesDigest": "HIivRx64D04dq6NJIBX8x2pgz4Az6/Qw39gO1ihBaBU=", + "recordedFileInputs": {}, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "yq_darwin_amd64": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", + "attributes": { + "platform": "darwin_amd64", + "version": "4.45.2" + } + }, + "yq_darwin_arm64": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", + "attributes": { + "platform": "darwin_arm64", + "version": "4.45.2" + } + }, + "yq_linux_amd64": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", + "attributes": { + "platform": "linux_amd64", + "version": "4.45.2" + } + }, + "yq_linux_arm64": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", + "attributes": { + "platform": "linux_arm64", + "version": "4.45.2" + } + }, + "yq_linux_s390x": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", + "attributes": { + "platform": "linux_s390x", + "version": "4.45.2" + } + }, + "yq_linux_riscv64": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", + "attributes": { + "platform": "linux_riscv64", + "version": "4.45.2" + } + }, + "yq_linux_ppc64le": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", + "attributes": { + "platform": "linux_ppc64le", + "version": "4.45.2" + } + }, + "yq_windows_amd64": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", + "attributes": { + "platform": "windows_amd64", + "version": "4.45.2" + } + }, + "yq_windows_arm64": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", + "attributes": { + "platform": "windows_arm64", + "version": "4.45.2" + } + }, + "yq_toolchains": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:toolchain.bzl%yq_toolchains_repo", + "attributes": { + "user_repository_name": "yq" + } + } + }, + "recordedRepoMappingEntries": [] + } } }, "facts": {} diff --git a/README.md b/README.md index c93e1356..3392ebd6 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Bazel GitOps Rules is an alternative to [rules_k8s](https://github.com/bazelbuil ## Rules * [k8s_deploy](#k8s_deploy) +* [cloudrun_deploy](#cloudrun_deploy) * [k8s_test_setup](#k8s_test_setup) @@ -96,6 +97,23 @@ When you run `bazel run ///helloworld:mynamespace.apply`, it applies this file i | ***visibility*** | [Default_visibility](https://docs.bazel.build/versions/master/be/functions.html#package.default_visibility) | Changes the visibility of all rules generated by this macro. See [Bazel docs on visibility](https://docs.bazel.build/versions/master/be/common-definitions.html#common-attributes). + +## cloudrun_deploy + +The `cloudrun_deploy` macro creates targets for deploying Knative Services to Google Cloud Run. It is defined in [cloudrun.bzl](./gitops/cloudrun.bzl) and is loaded via `load("@rules_gitops//gitops:defs.bzl", "cloudrun_deploy")`. + +Similar to `k8s_deploy`, it uses kustomize to render the Knative Service manifest. +When you run a `.apply` target, it deploys the service by running `gcloud run services replace`. +When you run a `.delete` target, it deletes the service by running `gcloud run services delete`. +When you run a `.gitops` target, it copies the rendered manifest to the location `cloud/{project}/{region}/{name}.yaml` inside the deployment repository. + +It accepts similar parameters to `k8s_deploy`, with the following differences: +- ***project*** (instead of `namespace`): The GCP project ID. This is also automatically set as the `namespace` field in the Knative Service YAML. +- ***region*** (instead of `cluster`): The GCP region (e.g. `us-central1`). + +Note: There is no `service` parameter. The service name is dynamically resolved at runtime from `metadata.name` inside the rendered manifest. + + ### Base Manifests and Overlays diff --git a/gitops/BUILD.bazel b/gitops/BUILD.bazel index 43a7eec5..65d86d21 100644 --- a/gitops/BUILD.bazel +++ b/gitops/BUILD.bazel @@ -12,7 +12,10 @@ load("//gitops/private:kustomize_toolchain.bzl", "current_kustomize_toolchain") package(default_visibility = ["//visibility:public"]) -exports_files(["create_gitops_prs.tpl.sh"]) +exports_files([ + "create_gitops_prs.tpl.sh", + "cloudrun.sh.tpl", +]) toolchain_type( name = "kustomize_toolchain_type", diff --git a/gitops/cloudrun.bzl b/gitops/cloudrun.bzl new file mode 100644 index 00000000..c0320ddc --- /dev/null +++ b/gitops/cloudrun.bzl @@ -0,0 +1,510 @@ +# Copyright 2026 The rules_gitops Authors. +# +# 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. + +load("//gitops:provider.bzl", "GitopsArtifactsInfo", "GitopsPushInfo") +load("//push_oci:push_oci.bzl", "push_oci") +load("//skylib:push_alias.bzl", "pushed_image_alias") +load("//skylib:runfile.bzl", "get_runfile_path") +load("//skylib:stamp.bzl", "stamp") +load( + "//skylib/kustomize:kustomize.bzl", + "imagePushStatements", + "kustomize", +) + +def _image_pushes(name_suffix, images, image_registry, image_repository, image_digest_tag, tags = []): + image_pushes = [] + + def process_image(image_label, image_alias = None): + rule_name_parts = [image_label, image_registry, image_repository] + rule_name_parts = [p for p in rule_name_parts if p] + rule_name = "_".join(rule_name_parts) + rule_name = rule_name.replace("/", "_").replace(":", "_").replace("@", "_").replace(".", "_") + rule_name = rule_name.strip("_") + if not native.existing_rule(rule_name + name_suffix): + push_oci( + name = rule_name + name_suffix, + image = image_label, # buildifier: disable=uninitialized + image_digest_tag = image_digest_tag, + registry = image_registry, + repository = image_repository, + tags = tags, + visibility = ["//visibility:public"], + ) + if not image_alias: + return rule_name + name_suffix + + if not native.existing_rule(rule_name + "_alias_" + name_suffix): + pushed_image_alias( + name = rule_name + "_alias_" + name_suffix, + alias = image_alias, + pushed_image = rule_name + name_suffix, + tags = tags, + visibility = ["//visibility:public"], + ) + return rule_name + "_alias_" + name_suffix + + if type(images) == "dict": + for image_alias in images: + image = images[image_alias] + push = process_image(image, image_alias) + image_pushes.append(push) + else: + for image in images: + push = process_image(image) + image_pushes.append(push) + return image_pushes + +def _gcloud_run_impl(ctx): + files = [] + ctx.files.srcs + yq_bin = ctx.toolchains["@yq.bzl//yq/toolchain:type"].yqinfo.bin + files.append(yq_bin) + statements = "" + transitive = None + transitive_runfiles = [] + + project = ctx.attr.project + if "{" in ctx.attr.project: + project = stamp(ctx, project, files, ctx.label.name + ".project-name", True) + + region = ctx.attr.region + if "{" in ctx.attr.region: + region = stamp(ctx, region, files, ctx.label.name + ".region-name", True) + + command = ctx.attr.command + push = ctx.attr.push + + files += [ctx.executable._template_engine, ctx.file._info_file] + + if push: + pushes = [obj[GitopsArtifactsInfo].image_pushes for obj in ctx.attr.srcs] + trans_img_pushes = depset(transitive = pushes).to_list() + trans_img_pushes = [push for push in trans_img_pushes if push.files_to_run.executable] + statements += "\n".join([ + "# {}\n".format(exe[GitopsPushInfo].image_label) + + "echo pushing {}".format(exe[GitopsPushInfo].repository if GitopsPushInfo in exe else "") + for exe in trans_img_pushes + ]) + "\n" + statements += "\n".join([ + "async \"%s\"" % get_runfile_path(ctx, exe.files_to_run.executable) + for exe in trans_img_pushes + ]) + "\nwaitpids\n" + files += [obj.files_to_run.executable for obj in trans_img_pushes] + transitive = depset(transitive = [obj.default_runfiles.files for obj in trans_img_pushes]) + transitive_runfiles += [exe[DefaultInfo].default_runfiles for exe in trans_img_pushes] + + for inattr in ctx.attr.srcs: + for infile in inattr.files.to_list(): + if command == "replace": + statements += "{template_engine} --template={infile} --variable=PROJECT=\"$PROJECT\" --variable=REGION=\"$REGION\" --stamp_info_file={info_file} | gcloud run services replace - --project=\"$PROJECT\" --region=\"$REGION\"\n".format( + infile = get_runfile_path(ctx, infile), + template_engine = get_runfile_path(ctx, ctx.executable._template_engine), + info_file = get_runfile_path(ctx, ctx.file._info_file), + ) + elif command == "delete": + statements += ("SERVICES=$({yq_bin_path} 'select(.kind == \"Service\") | .metadata.name' {infile})\n" + + "if [ -z \"$SERVICES\" ] || [ \"$SERVICES\" = \"null\" ]; then\n" + + " echo \"Error: Failed to extract any service names from manifest {infile}\" >&2\n" + + " exit 1\n" + + "fi\n" + + "for SERVICE in $SERVICES; do\n" + + " gcloud run services delete \"$SERVICE\" --project=\"$PROJECT\" --region=\"$REGION\"\n" + + "done\n").format( + yq_bin_path = get_runfile_path(ctx, yq_bin), + infile = get_runfile_path(ctx, infile), + ) + else: + fail("Unsupported command: %s" % command) + + ctx.actions.expand_template( + template = ctx.file._template, + substitutions = { + "%{project}": project, + "%{region}": region, + "%{statements}": statements, + }, + output = ctx.outputs.executable, + ) + + runfiles = ctx.runfiles(files = files, transitive_files = transitive) + runfiles = runfiles.merge_all(transitive_runfiles) + + return [ + DefaultInfo(runfiles = runfiles), + ] + +gcloud_run = rule( + attrs = { + "srcs": attr.label_list(providers = (GitopsArtifactsInfo,)), + "project": attr.string(mandatory = True), + "region": attr.string(mandatory = True), + "command": attr.string(default = "replace"), + "push": attr.bool(default = True), + "_build_user_value": attr.label( + default = Label("//skylib:build_user_value.txt"), + allow_single_file = True, + ), + "_info_file": attr.label( + default = Label("//skylib:more_stable_status.txt"), + allow_single_file = True, + ), + "_stamper": attr.label( + default = Label("//stamper:stamper"), + cfg = "exec", + executable = True, + allow_files = True, + ), + "_template": attr.label( + default = Label("//gitops:cloudrun.sh.tpl"), + allow_single_file = True, + ), + "_template_engine": attr.label( + default = Label("//templating:fast_template_engine"), + executable = True, + cfg = "exec", + ), + }, + executable = True, + implementation = _gcloud_run_impl, + toolchains = ["@yq.bzl//yq/toolchain:type"], +) + +def _remove_prefix(s, prefix): + return s[len(prefix):] if s.startswith(prefix) else s + +def _remove_prefixes(s, prefixes): + for prefix in prefixes: + s = _remove_prefix(s, prefix) + return s + +def _cloudrun_gitops_impl(ctx): + region = ctx.attr.region + project = ctx.attr.project + strip_prefixes = ctx.attr.strip_prefixes + files = [] + + push_statements, files, pushes_runfiles = imagePushStatements(ctx, ctx.attr.srcs, files) + statements = """if [ "$PERFORM_PUSH" == "1" ]; then +{} +fi + """.format(push_statements) + + if "{" in region: + fail("unable to gitops region with placeholders %s" % region) + if "{" in project: + fail("unable to gitops project with placeholders %s" % project) + + for inattr in ctx.attr.srcs: + for infile in inattr.files.to_list(): + statements += ("echo $TARGET_DIR/{gitops_path}/{project}/{region}/{file}\n" + + "mkdir -p $TARGET_DIR/{gitops_path}/{project}/{region}\n" + + "echo '# GENERATED BY {rulename} -> {gitopsrulename}' > $TARGET_DIR/{gitops_path}/{project}/{region}/{file}\n" + + "{template_engine} --template={infile} --variable=PROJECT={project} --variable=REGION={region} --stamp_info_file={info_file} >> $TARGET_DIR/{gitops_path}/{project}/{region}/{file}\n").format( + infile = get_runfile_path(ctx, infile), + rulename = inattr.label, + gitopsrulename = ctx.label, + project = project, + gitops_path = ctx.attr.gitops_path, + region = region, + file = _remove_prefixes(infile.path.split("/")[-1], strip_prefixes), + template_engine = get_runfile_path(ctx, ctx.executable._template_engine), + info_file = get_runfile_path(ctx, ctx.file._info_file), + ) + + ctx.actions.expand_template( + template = ctx.file._template, + substitutions = { + "%{deployment_branch}": ctx.attr.deployment_branch, + "%{statements}": statements, + }, + output = ctx.outputs.executable, + ) + runfiles = files + ctx.files.srcs + [ctx.executable._template_engine, ctx.file._info_file] + transitive = depset(transitive = [obj.default_runfiles.files for obj in ctx.attr.srcs]) + + rf = ctx.runfiles(files = runfiles, transitive_files = transitive) + for dep_rf in pushes_runfiles: + rf = rf.merge(dep_rf) + return [ + DefaultInfo(runfiles = rf), + GitopsArtifactsInfo( + image_pushes = depset(transitive = [obj[GitopsArtifactsInfo].image_pushes for obj in ctx.attr.srcs]), + deployment_branch = ctx.attr.deployment_branch, + ), + ] + +cloudrun_gitops = rule( + attrs = { + "srcs": attr.label_list(providers = (GitopsArtifactsInfo,)), + "project": attr.string(mandatory = True), + "region": attr.string(mandatory = True), + "deployment_branch": attr.string(), + "gitops_path": attr.string(), + "release_branch_prefix": attr.string(), + "strip_prefixes": attr.string_list(), + "_info_file": attr.label( + default = Label("//skylib:more_stable_status.txt"), + allow_single_file = True, + ), + "_template_engine": attr.label( + default = Label("//templating:fast_template_engine"), + executable = True, + cfg = "exec", + ), + "_template": attr.label( + default = Label("//skylib:k8s_gitops.sh.tpl"), + allow_single_file = True, + ), + }, + executable = True, + implementation = _cloudrun_gitops_impl, +) + +def _cloudrun_show_impl(ctx): + script_content = """#!/usr/bin/env bash +set -e + +function guess_runfiles() { + if [ -d "${BASH_SOURCE[0]}.runfiles" ]; then + # Runfiles are adjacent to the current script. + echo "$( cd "${BASH_SOURCE[0]}.runfiles" && pwd )" + else + # The current script is within some other script's runfiles. + mydir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + echo $mydir | sed -e 's|\\(.*\\.runfiles\\)/.*|\\1|' + fi +} + +RUNFILES=${RUNFILES:-$(guess_runfiles)} +""" + + outputs = [] + script_template = "{template_engine} --template={infile} --variable=PROJECT={project} --variable=REGION={region} --stamp_info_file={info_file}\n" + for dep in ctx.attr.src.files.to_list(): + outputs.append(script_template.format( + infile = get_runfile_path(ctx, dep), + template_engine = get_runfile_path(ctx, ctx.executable._template_engine), + project = ctx.attr.project, + region = ctx.attr.region, + info_file = get_runfile_path(ctx, ctx.file._info_file), + )) + + script_content += "echo '---'\n".join(outputs) + + ctx.actions.write(ctx.outputs.executable, script_content, is_executable = True) + return [ + DefaultInfo(runfiles = ctx.runfiles(files = [ctx.executable._template_engine, ctx.file._info_file] + ctx.files.src)), + ] + +cloudrun_show = rule( + implementation = _cloudrun_show_impl, + attrs = { + "src": attr.label( + doc = "Input file.", + mandatory = True, + ), + "project": attr.string( + mandatory = True, + ), + "region": attr.string( + mandatory = True, + ), + "_info_file": attr.label( + default = Label("//skylib:more_stable_status.txt"), + allow_single_file = True, + ), + "_template_engine": attr.label( + default = Label("//templating:fast_template_engine"), + executable = True, + cfg = "exec", + ), + }, + executable = True, +) + +def cloudrun_deploy( + name, + project = None, + region = None, + manifests = None, + name_prefix = None, + name_suffix = None, + patches = None, + image_name_patches = {}, + image_tag_patches = {}, + substitutions = {}, + configurations = [], + common_labels = {}, + common_annotations = {}, + openapi_path = "@rules_gitops//skylib:run_schema.json", + deps = [], + deps_aliases = {}, + images = [], + image_digest_tag = False, + image_registry = "gcr.io", + image_repository = None, + gitops = True, + gitops_path = "cloud", + deployment_branch = None, + release_branch_prefix = "main", + start_tag = "{{", + end_tag = "}}", + tags = [], + visibility = None): + if not manifests: + manifests = native.glob(["*.yaml", "*.yaml.tpl"]) + + for reservedname in ["PROJECT", "REGION"]: + if substitutions.get(reservedname): + fail("do not put %s in substitutions parameter of cloudrun_deploy. It will be added autimatically" % reservedname) + substitutions = dict(substitutions) + substitutions["PROJECT"] = project + substitutions["REGION"] = region + + if not gitops: + image_pushes = _image_pushes( + name_suffix = "-mynamespace.push", + images = images, + image_registry = image_registry + "/mynamespace", + image_repository = image_repository, + image_digest_tag = image_digest_tag, + tags = tags, + ) + kustomize( + name = name, + namespace = project, + images = image_pushes, + manifests = manifests, + substitutions = substitutions, + deps = deps, + deps_aliases = deps_aliases, + start_tag = start_tag, + end_tag = end_tag, + name_prefix = name_prefix, + name_suffix = name_suffix, + configurations = configurations, + common_labels = common_labels, + common_annotations = common_annotations, + patches = patches, + image_name_patches = image_name_patches, + image_tag_patches = image_tag_patches, + openapi_path = openapi_path, + tags = tags, + visibility = visibility, + ) + gcloud_run( + name = name + ".apply", + srcs = [name], + project = project, + region = region, + command = "replace", + tags = tags, + visibility = visibility, + ) + gcloud_run( + name = name + ".delete", + srcs = [name], + command = "delete", + project = project, + region = region, + push = False, + tags = tags, + visibility = visibility, + ) + cloudrun_show( + name = name + ".show", + src = name, + project = project, + region = region, + tags = tags, + visibility = visibility, + ) + else: + if not region: + fail("region must be defined for gitops cloudrun_deploy") + if not project: + fail("project must be defined for gitops cloudrun_deploy") + + image_pushes = _image_pushes( + name_suffix = ".push", + images = images, + image_registry = image_registry, + image_repository = image_repository, + image_digest_tag = image_digest_tag, + tags = tags, + ) + kustomize( + name = name, + namespace = project, + images = image_pushes, + manifests = manifests, + visibility = visibility, + substitutions = substitutions, + deps = deps, + deps_aliases = deps_aliases, + start_tag = start_tag, + end_tag = end_tag, + name_prefix = name_prefix, + name_suffix = name_suffix, + configurations = configurations, + common_labels = common_labels, + common_annotations = common_annotations, + patches = patches, + image_name_patches = image_name_patches, + image_tag_patches = image_tag_patches, + openapi_path = openapi_path, + tags = tags, + ) + gcloud_run( + name = name + ".apply", + srcs = [name], + project = project, + region = region, + command = "replace", + tags = tags, + visibility = visibility, + ) + gcloud_run( + name = name + ".delete", + srcs = [name], + command = "delete", + project = project, + region = region, + push = False, + tags = tags, + visibility = visibility, + ) + cloudrun_gitops( + name = name + ".gitops", + srcs = [name], + project = project, + region = region, + gitops_path = gitops_path, + strip_prefixes = [ + project + "-", + region + "-", + ], + deployment_branch = deployment_branch, + release_branch_prefix = release_branch_prefix, + tags = tags, + visibility = ["//visibility:public"], + ) + cloudrun_show( + name = name + ".show", + src = name, + project = project, + region = region, + tags = tags, + visibility = visibility, + ) diff --git a/gitops/cloudrun.sh.tpl b/gitops/cloudrun.sh.tpl new file mode 100644 index 00000000..ec5a80fa --- /dev/null +++ b/gitops/cloudrun.sh.tpl @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Copyright 2026 The rules_gitops Authors. +set -eu + +function guess_runfiles() { + if [ -d ${BASH_SOURCE[0]}.runfiles ]; then + # Runfiles are adjacent to the current script. + echo "$( cd ${BASH_SOURCE[0]}.runfiles && pwd )" + else + # The current script is within some other script's runfiles. + mydir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + echo $mydir | sed -e 's|\(.*\.runfiles\)/.*|\1|' + fi +} + +RUNFILES="${PYTHON_RUNFILES:-$(guess_runfiles)}" + +PIDS=() +function async() { + # Launch the command asynchronously and track its process id. + PYTHON_RUNFILES=${RUNFILES} RUNFILES_DIR=${RUNFILES} "$@" & + PIDS+=($!) +} + +function waitpids() { + # Wait for all of the subprocesses, failing the script if any of them failed. + if [ "${#PIDS[@]}" != 0 ]; then + for pid in ${PIDS[@]}; do + wait ${pid} + done + fi +} + +PROJECT=%{project} +REGION=%{region} + +%{statements} diff --git a/gitops/defs.bzl b/gitops/defs.bzl index 3bbf0ff8..e32ef7d9 100644 --- a/gitops/defs.bzl +++ b/gitops/defs.bzl @@ -14,7 +14,9 @@ GitOps rules public interface load("@rules_gitops//skylib:external_image.bzl", _external_iamge = "external_image") load("@rules_gitops//skylib:k8s.bzl", _k8s_deploy = "k8s_deploy", _k8s_test_setup = "k8s_test_setup") +load("@rules_gitops//gitops:cloudrun.bzl", _cloudrun_deploy = "cloudrun_deploy") k8s_deploy = _k8s_deploy k8s_test_setup = _k8s_test_setup external_image = _external_iamge +cloudrun_deploy = _cloudrun_deploy diff --git a/gitops/prer/prer_test.sh b/gitops/prer/prer_test.sh index e5f26a6f..e350bf05 100755 --- a/gitops/prer/prer_test.sh +++ b/gitops/prer/prer_test.sh @@ -51,6 +51,7 @@ EXPECTED_TARGETS=( "//gitops/testing:legacy_alias.gitops" "//gitops/testing:legacy_label.gitops" "//gitops/testing:legacy_renamed_alias.gitops" + "//gitops/testing:prer_cloudrun_target.gitops" ) # Define expected push binaries (relative paths under workspace bazel-out/.../bin) @@ -62,6 +63,7 @@ EXPECTED_PUSH_BINARIES=( "gitops/testing/skylib_kustomize_tests_img_image_docker_io.push" "gitops/testing/img_pushed_image" "gitops/testing/img_pushed_image_docker_io.push" + "gitops/testing/push_apply_cloudrun_pushed_image.sh" ) echo "Verifying gitops targets..." diff --git a/gitops/testing/BUILD.bazel b/gitops/testing/BUILD.bazel index cfa50298..570b49b1 100644 --- a/gitops/testing/BUILD.bazel +++ b/gitops/testing/BUILD.bazel @@ -11,7 +11,7 @@ load("@aspect_bazel_lib//lib:write_source_files.bzl", "write_source_files") load("@bazel_skylib//rules:write_file.bzl", "write_file") load("@rules_shell//shell:sh_test.bzl", "sh_test") -load("//gitops:defs.bzl", "external_image", "k8s_deploy", "k8s_test_setup") +load("//gitops:defs.bzl", "cloudrun_deploy", "external_image", "k8s_deploy", "k8s_test_setup") load("//push_oci:push_oci.bzl", "push_oci") licenses(["notice"]) # Apache 2.0 @@ -301,3 +301,70 @@ sh_test( "REGISTRY_BIN": "$(location @com_github_google_go_containerregistry//cmd/registry)", }, ) + +push_oci( + name = "apply_cloudrun_pushed_image", + image = "//skylib/kustomize/tests:image", + registry = "localhost:1338", + remote_tags = ":apply_tags_file", + repository = "repo/apply_cloudrun_pushed_image", +) + +cloudrun_deploy( + name = "apply_cloudrun_test_target", + project = "ci", + region = "us-central1", + gitops = False, + images = { + "//skylib/tests/cloudrun:image": ":apply_cloudrun_pushed_image", + }, + manifests = [ + "//skylib/tests/cloudrun:run.yaml", + ":run_second.yaml", + ], + visibility = ["//visibility:public"], +) + +sh_test( + name = "cloudrun_apply_test", + size = "small", + srcs = ["cloudrun_apply_test.sh"], + tags = ["exclusive"], + args = [ + "$(location :apply_cloudrun_test_target.apply)", + "$(location :apply_cloudrun_test_target.delete)", + "localhost:1338/repo/apply_cloudrun_pushed_image:testtag", + "localhost:1338/repo/apply_cloudrun_pushed_image", + "ci", + "us-central1", + "webhook", + "webhook-secondary", + ], + data = [ + ":apply_cloudrun_test_target.apply", + ":apply_cloudrun_test_target.delete", + "@com_github_google_go_containerregistry//cmd/crane", + "@com_github_google_go_containerregistry//cmd/registry", + ], + env = { + "CRANE_BIN": "$(location @com_github_google_go_containerregistry//cmd/crane)", + "REGISTRY_BIN": "$(location @com_github_google_go_containerregistry//cmd/registry)", + }, +) + +cloudrun_deploy( + name = "prer_cloudrun_target", + project = "ci", + region = "us-central1", + deployment_branch = "test1", + gitops = True, + images = { + "//skylib/tests/cloudrun:image": ":apply_cloudrun_pushed_image", + }, + manifests = [ + "//skylib/tests/cloudrun:run.yaml", + ], + release_branch_prefix = "gitops_test_release_branch", + visibility = ["//visibility:public"], +) + diff --git a/gitops/testing/cloudrun_apply_test.sh b/gitops/testing/cloudrun_apply_test.sh new file mode 100755 index 00000000..b6077b88 --- /dev/null +++ b/gitops/testing/cloudrun_apply_test.sh @@ -0,0 +1,104 @@ +#!/bin/bash -x +# +# Integration test for cloudrun_deploy .apply and .delete targets using mock gcloud. +# + +apply_bin=$1 +delete_bin=$2 +expected_image_ref=$3 +expected_manifest_sub=$4 +expected_project=$5 +expected_region=$6 +expected_services="${@:7}" + +# Start the local in-memory registry on the default port 1338 +${REGISTRY_BIN} & +registry_pid=$! +trap "kill -9 $registry_pid" EXIT + +# Wait for registry to start up +for i in {1..50}; do + if curl -s -f http://localhost:1338/v2/ >/dev/null; then + break + fi + sleep 0.05 +done + +# Create a temporary directory for the mock gcloud and manifest dump +tmp_dir=$(mktemp -d) +trap "rm -rf $tmp_dir; kill -9 $registry_pid" EXIT + +manifest_out="${tmp_dir}/applied_manifest.yaml" +gcloud_log="${tmp_dir}/gcloud.log" +mock_gcloud="${tmp_dir}/gcloud" + +cat < "${mock_gcloud}" +#!/bin/bash +echo "gcloud \$@" >> "${gcloud_log}" +has_replace=false +for arg in "\$@"; do + if [ "\$arg" = "replace" ]; then + has_replace=true + fi +done + +if [ "\$has_replace" = true ]; then + cat > "${manifest_out}" +fi +EOF + +chmod +x "${mock_gcloud}" + +# Prepend mock gcloud to PATH +export PATH="${tmp_dir}:${PATH}" + +# 1. Execute the apply target executable +${apply_bin} + +# Verify that the image is successfully pushed using crane +${CRANE_BIN} validate -v --fast --remote ${expected_image_ref} + +# Verify that the manifest was applied and has the expected image reference +if [ ! -f "${manifest_out}" ]; then + echo "Error: Applied manifest file was not created by mock gcloud" + exit 1 +fi + +cat "${manifest_out}" + +if ! grep -q "${expected_manifest_sub}" "${manifest_out}"; then + echo "Error: Applied manifest does not contain expected substring '${expected_manifest_sub}'" + exit 1 +fi + +# Verify gcloud apply arguments +expected_apply_args="gcloud run services replace - --project=${expected_project} --region=${expected_region}" +if ! grep -Fq "${expected_apply_args}" "${gcloud_log}"; then + echo "Error: gcloud was not called with the expected replace arguments: ${expected_apply_args}" + echo "gcloud log contents:" + cat "${gcloud_log}" + exit 1 +fi + +num_replaces=$(grep -c "gcloud run services replace" "${gcloud_log}") +if [ "${num_replaces}" -ne 1 ]; then + echo "Error: expected gcloud run services replace to be called exactly once, but it was called ${num_replaces} times" + cat "${gcloud_log}" + exit 1 +fi + +# 2. Execute the delete target executable +${delete_bin} + +# Verify gcloud delete arguments +for service in ${expected_services}; do + expected_delete_args="gcloud run services delete ${service} --project=${expected_project} --region=${expected_region}" + if ! grep -Fq "${expected_delete_args}" "${gcloud_log}"; then + echo "Error: gcloud was not called with the expected delete arguments: ${expected_delete_args}" + echo "gcloud log contents:" + cat "${gcloud_log}" + exit 1 + fi +done + +echo "Success!" diff --git a/gitops/testing/run_second.yaml b/gitops/testing/run_second.yaml new file mode 100644 index 00000000..c2f9a91d --- /dev/null +++ b/gitops/testing/run_second.yaml @@ -0,0 +1,14 @@ +apiVersion: serving.knative.dev/v1 +kind: Service +metadata: + name: webhook-secondary + labels: + cloud.googleapis.com/location: us-central1 +spec: + template: + spec: + containers: + - name: main + image: //skylib/tests/cloudrun:image + ports: + - containerPort: 8080 diff --git a/skylib/tests/cloudrun/BUILD.bazel b/skylib/tests/cloudrun/BUILD.bazel index 390a2cf0..c96e6ecd 100644 --- a/skylib/tests/cloudrun/BUILD.bazel +++ b/skylib/tests/cloudrun/BUILD.bazel @@ -1,5 +1,7 @@ load("@aspect_bazel_lib//lib:write_source_files.bzl", "write_source_files") -load("@rules_gitops//gitops:defs.bzl", "external_image", "k8s_deploy") +load("@rules_gitops//gitops:defs.bzl", "cloudrun_deploy", "external_image") + +exports_files(["run.yaml"]) external_image( name = "image", @@ -7,13 +9,13 @@ external_image( image = "gcr.io/repo/someimage:thetag", ) -k8s_deploy( +cloudrun_deploy( name = "prod", gitops = False, images = [":image"], manifests = ["run.yaml"], - namespace = "cloudrun_project", - openapi_path = "//skylib:run_schema.json", + project = "cloudrun_project", + region = "us-central1", patches = ["run_patch.yaml"], )