diff --git a/docs/BUILD.bazel b/docs/BUILD.bazel index 2fd486cd..6027684d 100644 --- a/docs/BUILD.bazel +++ b/docs/BUILD.bazel @@ -17,6 +17,11 @@ stardoc_with_diff_test( bzl_library_target = "//oci/private:image_index", ) +stardoc_with_diff_test( + name = "push", + bzl_library_target = "//oci/private:push", +) + stardoc_with_diff_test( name = "cosign_sign", bzl_library_target = "//cosign/private:sign", diff --git a/docs/push.md b/docs/push.md new file mode 100644 index 00000000..46238344 --- /dev/null +++ b/docs/push.md @@ -0,0 +1,80 @@ + + +Implementation details for the push rule + + + +## oci_push + +
+oci_push(name, default_tags, image, repository)
+
+ +Push an oci_image or oci_image_index to a remote registry. + +Pushing and tagging are performed sequentially which MAY lead to non-atomic pushes if one the following events occur; + +- Remote registry rejects a tag due to various reasons. eg: forbidden characters, existing tags +- Remote registry closes the connection during the tagging +- Local network outages + +In order to avoid incomplete pushes oci_push will push the image by its digest and then apply the `default_tags` sequentially at +the remote registry. + +Any failure during pushing or tagging will be reported with non-zero exit code cause remaining steps to be skipped. + + +Push an oci_image to docker registry with latest tag + +```starlark +oci_image(name = "image") + +oci_push( + image = ":image", + repository = "index.docker.io//image", + default_tags = ["latest"] +) +``` + +Push an oci_image_index to github container registry with a semver tag + +```starlark +oci_image(name = "app_linux_arm64") + +oci_image(name = "app_linux_amd64") + +oci_image(name = "app_windows_amd64") + +oci_image_index( + name = "app_image", + images = [ + ":app_linux_arm64", + ":app_linux_amd64", + ":app_windows_amd64", + ] +) + +oci_push( + image = ":app_image", + repository = "ghcr.io//image", + default_tags = ["0.0.0"] +) +``` + +Ideally the semver information is gathered from a vcs, like git, instead of being hardcoded to the BUILD files. +However, due to nature of BUILD files being static, one has to use `-t|--tag` flag to pass the tag at runtime instead of using `default_tags`. eg. `bazel run //target:push -- --tag $(git tag)` + +Similary, the `repository` attribute can be overridden at runtime with the `-r|--repository` flag. eg. `bazel run //target:push -- --repository index.docker.io//image` + + +**ATTRIBUTES** + + +| Name | Description | Type | Mandatory | Default | +| :------------- | :------------- | :------------- | :------------- | :------------- | +| name | A unique name for this target. | Name | required | | +| default_tags | List of tags to apply to the image at remote registry. | List of strings | optional | [] | +| image | Label to an oci_image or oci_image_index | Label | optional | None | +| repository | Repository URL where the image will be signed at. eg: index.docker.io/<user>/image. digests and tags are disallowed. | String | required | | + + diff --git a/example/push/BUILD.bazel b/example/push/BUILD.bazel new file mode 100644 index 00000000..0d74517d --- /dev/null +++ b/example/push/BUILD.bazel @@ -0,0 +1,59 @@ +load("//oci:defs.bzl", "oci_image", "oci_image_index", "oci_push") + +oci_image( + name = "image", + architecture = "amd64", + entrypoint = ["/fail"], + os = "linux", +) + +oci_push( + name = "push_image", + image = ":image", + repository = "index.docker.io//image", + default_tags = ["latest"] +) + +oci_push( + name = "push_image_wo_tags", + image = ":image", + repository = "index.docker.io//image" +) + +oci_image_index( + name = "image_index", + images = [ + ":image" + ] +) + +oci_push( + name = "push_image_index", + image = ":image_index", + repository = "index.docker.io//image", + default_tags = ["nightly"] +) + + +sh_test( + name = "test", + srcs = ["test.bash"], + args = [ + "$(CRANE_BIN)", + "$(LAUNCHER)", + "$(location :push_image)", + "$(location :push_image_index)", + "$(location :push_image_wo_tags)" + ], + data = [ + ":push_image", + ":push_image_index", + ":push_image_wo_tags", + "@oci_crane_toolchains//:current_toolchain", + "@oci_zot_toolchains//:current_toolchain", + ], + toolchains = [ + "@oci_crane_toolchains//:current_toolchain", + "@oci_zot_toolchains//:current_toolchain", + ], +) diff --git a/example/push/test.bash b/example/push/test.bash new file mode 100755 index 00000000..1800a8d8 --- /dev/null +++ b/example/push/test.bash @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -o pipefail -o errexit -o nounset + +readonly CRANE="$1" +readonly REGISTRY_LAUNCHER="$2" + +# Launch a registry instance at a random port +source "${REGISTRY_LAUNCHER}" +start_registry $TEST_TMPDIR $TEST_TMPDIR/output.log +echo "Registry is running at ${REGISTRY}" + + +readonly PUSH_IMAGE="$3" +readonly PUSH_IMAGE_INDEX="$4" +readonly PUSH_IMAGE_WO_TAGS="$5" + + +# should push image with default tags +REPOSITORY="${REGISTRY}/local" +"${PUSH_IMAGE}" --repository "${REPOSITORY}" +"${CRANE}" digest "$REPOSITORY:latest" + +# should push image_index with default tags +REPOSITORY="${REGISTRY}/local-index" +"${PUSH_IMAGE_INDEX}" --repository "${REPOSITORY}" +"${CRANE}" digest "$REPOSITORY:nightly" + + +# should push image without default tags +REPOSITORY="${REGISTRY}/local-wo-tags" +"${PUSH_IMAGE_WO_TAGS}" --repository "${REPOSITORY}" +TAGS=$("${CRANE}" ls "$REPOSITORY") +if [ -n "${TAGS}" ]; then + echo "image is not supposed to have any tags but got" + echo "${TAGS}" + exit 1 +fi + +# should push image with the --tag flag. +REPOSITORY="${REGISTRY}/local-flag-tag" +"${PUSH_IMAGE_WO_TAGS}" --repository "${REPOSITORY}" --tag "custom" +TAGS=$("${CRANE}" ls "$REPOSITORY") +if [ "${TAGS}" != "custom" ]; then + echo "image is supposed to have custom tag but got" + echo "${TAGS}" + exit 1 +fi diff --git a/oci/defs.bzl b/oci/defs.bzl index aa1125ee..c93a2244 100644 --- a/oci/defs.bzl +++ b/oci/defs.bzl @@ -3,9 +3,11 @@ load("//oci/private:tarball.bzl", _oci_tarball = "oci_tarball") load("//oci/private:image.bzl", _oci_image = "oci_image") load("//oci/private:image_index.bzl", _oci_image_index = "oci_image_index") +load("//oci/private:push.bzl", _oci_push = "oci_push") load("//oci/private:structure_test.bzl", _structure_test = "structure_test") oci_tarball = _oci_tarball oci_image = _oci_image oci_image_index = _oci_image_index +oci_push = _oci_push structure_test = _structure_test diff --git a/oci/private/BUILD.bazel b/oci/private/BUILD.bazel index e875561e..41945a3a 100644 --- a/oci/private/BUILD.bazel +++ b/oci/private/BUILD.bazel @@ -9,6 +9,7 @@ exports_files([ "image.sh.tpl", "image_index.sh.tpl", "tarball.sh.tpl", + "push.sh.tpl" ]) filegroup( @@ -48,6 +49,15 @@ bzl_library( ], ) +bzl_library( + name = "push", + srcs = ["push.bzl"], + visibility = [ + "//docs:__pkg__", + "//oci:__subpackages__", + ], +) + bzl_library( name = "toolchains_repo", srcs = ["toolchains_repo.bzl"], diff --git a/oci/private/push.bzl b/oci/private/push.bzl new file mode 100644 index 00000000..8b2185f9 --- /dev/null +++ b/oci/private/push.bzl @@ -0,0 +1,110 @@ +"Implementation details for the push rule" + +_DOC = """Push an oci_image or oci_image_index to a remote registry. + +Pushing and tagging are performed sequentially which MAY lead to non-atomic pushes if one the following events occur; + +- Remote registry rejects a tag due to various reasons. eg: forbidden characters, existing tags +- Remote registry closes the connection during the tagging +- Local network outages + +In order to avoid incomplete pushes oci_push will push the image by its digest and then apply the `default_tags` sequentially at +the remote registry. + +Any failure during pushing or tagging will be reported with non-zero exit code cause remaining steps to be skipped. + + +Push an oci_image to docker registry with latest tag + +```starlark +oci_image(name = "image") + +oci_push( + image = ":image", + repository = "index.docker.io//image", + default_tags = ["latest"] +) +``` + +Push an oci_image_index to github container registry with a semver tag + +```starlark +oci_image(name = "app_linux_arm64") + +oci_image(name = "app_linux_amd64") + +oci_image(name = "app_windows_amd64") + +oci_image_index( + name = "app_image", + images = [ + ":app_linux_arm64", + ":app_linux_amd64", + ":app_windows_amd64", + ] +) + +oci_push( + image = ":app_image", + repository = "ghcr.io//image", + default_tags = ["0.0.0"] +) +``` + +Ideally the semver information is gathered from a vcs, like git, instead of being hardcoded to the BUILD files. +However, due to nature of BUILD files being static, one has to use `-t|--tag` flag to pass the tag at runtime instead of using `default_tags`. eg. `bazel run //target:push -- --tag $(git tag)` + +Similary, the `repository` attribute can be overridden at runtime with the `-r|--repository` flag. eg. `bazel run //target:push -- --repository index.docker.io//image` +""" +_attrs = { + "image": attr.label(allow_single_file = True, doc = "Label to an oci_image or oci_image_index"), + "repository": attr.string(mandatory = True, doc = "Repository URL where the image will be signed at. eg: index.docker.io//image. digests and tags are disallowed."), + "default_tags": attr.string_list(doc = "List of tags to apply to the image at remote registry."), + "_push_sh_tpl": attr.label(default = "push.sh.tpl", allow_single_file = True), +} + +def _quote_args(args): + return ["\"{}\"".format(arg) for arg in args] + +def _impl(ctx): + crane = ctx.toolchains["@contrib_rules_oci//oci:crane_toolchain_type"] + jq = ctx.toolchains["@aspect_bazel_lib//lib:yq_toolchain_type"] + + if not ctx.file.image.is_directory: + fail("image attribute must be a oci_image or oci_image_index") + + if ctx.attr.repository.find(":") != -1 or ctx.attr.repository.find("@") != -1: + fail("repository attribute should not contain digest or tag.") + + fixed_args = ["--tag={}".format(tag) for tag in ctx.attr.default_tags] + fixed_args.extend(["--repository", ctx.attr.repository]) + + executable = ctx.actions.declare_file("push_%s.sh" % ctx.label.name) + ctx.actions.expand_template( + template = ctx.file._push_sh_tpl, + output = executable, + is_executable = True, + substitutions = { + "{{crane_path}}": crane.crane_info.crane_path, + "{{yq_path}}": jq.yqinfo.bin.short_path, + "{{image_dir}}": ctx.file.image.short_path, + "{{fixed_args}}": " ".join(_quote_args(fixed_args)), + }, + ) + + runfiles = ctx.runfiles(files = [ctx.file.image]) + runfiles = runfiles.merge(jq.default.default_runfiles) + runfiles = runfiles.merge(crane.default.default_runfiles) + + return DefaultInfo(executable = executable, runfiles = runfiles) + +oci_push = rule( + implementation = _impl, + attrs = _attrs, + doc = _DOC, + executable = True, + toolchains = [ + "@contrib_rules_oci//oci:crane_toolchain_type", + "@aspect_bazel_lib//lib:yq_toolchain_type", + ], +) diff --git a/oci/private/push.sh.tpl b/oci/private/push.sh.tpl new file mode 100644 index 00000000..a420c83e --- /dev/null +++ b/oci/private/push.sh.tpl @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -o pipefail -o errexit -o nounset + +readonly CRANE="{{crane_path}}" +readonly YQ="{{yq_path}}" +readonly IMAGE_DIR="{{image_dir}}" +readonly FIXED_ARGS=({{fixed_args}}) + +# set $@ to be FIXED_ARGS+$@ +ALL_ARGS=(${FIXED_ARGS[@]} $@) +set -- ${ALL_ARGS[@]} + +REPOSITORY="{{repository}}" +TAGS=() +ARGS=() + +while (( $# > 0 )); do + case $1 in + (-t|--tag) + TAGS+=( "$2" ) + shift + shift;; + (--tag=*) + TAGS+=( "${1#--tag=}" ) + shift;; + (-r|--repository) + REPOSITORY="$2" + shift + shift;; + (--repository=*) + REPOSITORY="${1#--repository=}" + shift;; + (*) + ARGS+=( "$1" ) + shift;; + esac +done + +DIGEST=$("${YQ}" eval '.manifests[0].digest' "${IMAGE_DIR}/index.json") + +REFS=$(mktemp) +"${CRANE}" push "${IMAGE_DIR}" "${REPOSITORY}@${DIGEST}" "${ARGS[@]+"${ARGS[@]}"}" --image-refs "${REFS}" + +for tag in "${TAGS[@]+"${TAGS[@]}"}" +do + "${CRANE}" tag $(cat "${REFS}") "${tag}" +done \ No newline at end of file