diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index de2db88..410ab62 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -86,7 +86,7 @@ jobs: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: bazel-contrib/setup-bazel@c5acdfb288317d0b5c0bbd7a396a3dc868bb0f86 # v0.19.0 + - uses: bazel-contrib/setup-bazel@c5acdfb288317d0b5c0bbd7a396a3dc868bb0f86 # 0.19.0 with: repository-cache: true bazelrc: | @@ -113,6 +113,138 @@ jobs: if: ${{ matrix.bazelversion <= '8.' && matrix.folder == '.' }} run: bazel query 'tests(//...) except docs/...' | xargs bazel test + # Bazel 9 dropped WORKSPACE; on 7 and 8 the published snippet still has to work. + - name: Test WORKSPACE mode + working-directory: ${{ matrix.folder }} + if: ${{ matrix.bazelversion < '9.' && matrix.folder == 'e2e/smoke' }} + run: bazel test //... --noenable_bzlmod --enable_workspace + + # Runs the build through actiond, a local Remote Execution API worker that + # executes each action inside an empty chroot in a Linux VM on the runner. + # This is what keeps remote-execution support from regressing: an action that + # depends on the executor's userspace, or that opts out of remote execution, + # fails here and nowhere else. x86_64 only - GitHub's arm64 Linux runners + # have no /dev/kvm. + remote-execution: + name: Build examples/wolfi-base via actiond + runs-on: ubuntu-24.04 + timeout-minutes: 60 + permissions: + contents: read # Clone the repository + env: + ACTIOND_VERSION: v0.0.6 + ACTIOND_SHA256: 006dc798d4363596fe8ab997606fc93766a0cc427c2d005cf4fc1765fa4c2052 + ACTIOND_ENDPOINT: 127.0.0.1:8980 + + steps: + - name: Harden Runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Check out code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Enable KVM and vhost-vsock + run: | + set -euxo pipefail + test -c /dev/kvm + sudo chmod a+rw /dev/kvm + if [[ ! -e /dev/vhost-vsock ]]; then + sudo modprobe vhost_vsock + fi + test -c /dev/vhost-vsock + sudo chmod a+rw /dev/vhost-vsock + + - name: Download actiond + env: + GH_TOKEN: ${{ github.token }} + working-directory: ${{ runner.temp }} + run: | + set -euxo pipefail + gh release download "${ACTIOND_VERSION}" \ + --repo hermeticbuild/actiond \ + --pattern 'linux-actiond_linux_x86_64' \ + --clobber + # actiond publishes no build provenance, so the digest is pinned + # here instead of read from a checksum file next to the asset. + echo "${ACTIOND_SHA256} linux-actiond_linux_x86_64" | sha256sum -c - + chmod +x linux-actiond_linux_x86_64 + + - name: Start actiond worker + working-directory: ${{ runner.temp }} + run: | + set -euxo pipefail + setsid ./linux-actiond_linux_x86_64 serve-vm \ + --listen="${ACTIOND_ENDPOINT}" \ + --root="${RUNNER_TEMP}/actiond-root" \ + --cas-image-size-mib=8192 \ + --memory-mib=4096 \ + --cpus=4 \ + --start-timeout-ms=180000 \ + >"${RUNNER_TEMP}/actiond.log" 2>&1 & + echo "$!" > "${RUNNER_TEMP}/actiond.pid" + + # The worker is ready when it logs the gRPC bridge endpoint. + for _ in $(seq 1 240); do + if grep -Fq "actiond VM raw gRPC bridge listening on ${ACTIOND_ENDPOINT}" "${RUNNER_TEMP}/actiond.log"; then + exit 0 + fi + if ! kill -0 "$(cat "${RUNNER_TEMP}/actiond.pid")" 2>/dev/null; then + echo "actiond exited before becoming ready" >&2 + cat "${RUNNER_TEMP}/actiond.log" >&2 + exit 1 + fi + sleep 1 + done + echo "timed out waiting for ${ACTIOND_ENDPOINT}" >&2 + cat "${RUNNER_TEMP}/actiond.log" >&2 + exit 1 + + - name: Set up Bazel + uses: bazel-contrib/setup-bazel@c5acdfb288317d0b5c0bbd7a396a3dc868bb0f86 # 0.19.0 + with: + repository-cache: true + bazelrc: | + common --announce_rc --color=yes + + # Regression gate for remote execution. --spawn_strategy=remote with no + # local fallback means an action that opts out of remote execution (a + # no-remote-exec tag) fails the build outright rather than quietly + # running on the host and passing. requires-bash is needed + # because rules_apko stages inputs with run_shell actions; everything + # they call is either a bash builtin or comes from the coreutils + # toolchain, so nothing else is inherited from the executor. + - name: Build examples/wolfi-base with remote execution + run: | + set -euxo pipefail + bazel build //examples/wolfi-base \ + --remote_executor="grpc://${ACTIOND_ENDPOINT}" \ + --remote_cache="grpc://${ACTIOND_ENDPOINT}" \ + --remote_default_exec_properties=requires-bash= \ + --remote_upload_local_results=false \ + --noremote_cache_compression \ + --spawn_strategy=remote \ + --remote_local_fallback=false \ + --verbose_failures + + - name: Stop actiond + if: always() + run: | + if [[ -f "${RUNNER_TEMP}/actiond.pid" ]]; then + kill "$(cat "${RUNNER_TEMP}/actiond.pid")" 2>/dev/null || true + fi + + - name: Upload actiond log + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: actiond-log + if-no-files-found: warn + path: ${{ runner.temp }}/actiond.log + # Single summary gate that aggregates the dynamic test matrix. Branch # protection only needs to require this one context, so the Bazel version # matrix above can change without an infra change to the required checks. @@ -121,6 +253,7 @@ jobs: needs: - matrix-prep-bazelversion - matrix-prep-os + - remote-execution - test if: always() steps: diff --git a/apko/private/apko_config.bzl b/apko/private/apko_config.bzl index 7ab9c52..24f8f37 100644 --- a/apko/private/apko_config.bzl +++ b/apko/private/apko_config.bzl @@ -2,6 +2,26 @@ load("@bazel_skylib//lib:paths.bzl", "paths") +# Prebuilt uutils coreutils, registered by aspect_bazel_lib under bzlmod. +# WORKSPACE users register it themselves (see e2e/smoke/WORKSPACE.bazel). The +# Linux builds are statically linked against musl in recent aspect_bazel_lib +# releases; older ones, including the 1.x rules_apko_dependencies() fetches, +# link glibc. Declaring it as an action tool means mkdir/cp travel with the +# action instead of being inherited from the executor image, which is what +# lets these actions run on executors that ship no userspace of their own. +COREUTILS_TOOLCHAIN_TYPE = "@aspect_bazel_lib//lib:coreutils_toolchain_type" + +def coreutils_bin(ctx): + """The coreutils binary from the resolved toolchain. + + Args: + ctx: rule context + + Returns: + The coreutils File. + """ + return ctx.toolchains[COREUTILS_TOOLCHAIN_TYPE].coreutils_info.bin + _PATH_CONVENTION_DOC = """ When referencing other files in the config yaml file use paths relative to your Bazel workspace root. For example, if you want to reference source file foo/bar/baz use foo/bar/baz. If you want to reference output file of foo/bar:rule and rule's @@ -69,7 +89,8 @@ def copy_to_workdir(ctx, src, dst): Uses cp instead of ctx.actions.symlink because Bazel 9 sandbox changes cause symlink outputs to dangle when used as inputs to subsequent - sandboxed actions. + sandboxed actions. mkdir and cp come from the coreutils toolchain rather + than the executor's PATH, so the action carries its own tools. When src is a list, dst must be a declared directory (tree artifact); each source is staged at its workspace-relative path inside dst. This @@ -86,12 +107,17 @@ def copy_to_workdir(ctx, src, dst): dst: declared output File or directory """ - # When f is a directory, append "/." so its contents land directly in - # target instead of nesting at target/ (Bazel pre-creates - # declared output directories, so plain cp -r src tgt would nest). - copy_file_cmd = lambda f, target: 'mkdir -p "{dir}" && cp -rfL "{src}" "{tgt}"'.format( + coreutils = coreutils_bin(ctx) + + # When f is a directory, remove the pre-created output directory first so + # cp creates it as a copy of f rather than nesting at target/. + # Copying "f/." into it does not work with uutils 0.0.16 (aspect_bazel_lib + # 1.x), which nests that as well. + copy_file_cmd = lambda f, target: '"{cu}" mkdir -p "{dir}" && {rm}"{cu}" cp -rfL "{src}" "{tgt}"'.format( + cu = coreutils.path, dir = paths.dirname(target), - src = (f.path + "/.") if f.is_directory else f.path, + rm = '"{}" rm -rf "{}" && '.format(coreutils.path, target) if f.is_directory else "", + src = f.path, tgt = target, ) @@ -110,9 +136,11 @@ def copy_to_workdir(ctx, src, dst): ctx.actions.run_shell( inputs = inputs, outputs = [dst], + tools = [coreutils], command = "\n".join(cmds), mnemonic = "CopyToWorkdir", progress_message = "Staging %s" % dst.short_path, + toolchain = COREUTILS_TOOLCHAIN_TYPE, ) def prepare_apko_config_in_workdir(workdir, ctx): diff --git a/apko/private/apko_image.bzl b/apko/private/apko_image.bzl index 8c40947..e7a3ac0 100644 --- a/apko/private/apko_image.bzl +++ b/apko/private/apko_image.bzl @@ -2,7 +2,7 @@ load("@bazel_skylib//lib:paths.bzl", "paths") load("@bazel_skylib//lib:versions.bzl", "versions") -load("//apko/private:apko_config.bzl", "copy_to_workdir", "prepare_apko_config_in_workdir") +load("//apko/private:apko_config.bzl", "COREUTILS_TOOLCHAIN_TYPE", "copy_to_workdir", "coreutils_bin", "prepare_apko_config_in_workdir") load("//apko/private:apko_run.bzl", "apko_run") _ATTRS = { @@ -12,6 +12,10 @@ _ATTRS = { "architecture": attr.string(doc = "the CPU architecture which this image should be built to run on. See https://github.com/chainguard-dev/apko/blob/main/docs/apko_file.md#archs-top-level-element"), "tag": attr.string(doc = "tag to apply to the resulting docker tarball. only applicable when `output` is `docker`", mandatory = True), "args": attr.string_list(doc = "additional arguments to provide when running the `apko build` command."), + "use_default_shell_env": attr.bool( + doc = "whether the apko build action inherits the default shell environment (the client environment as filtered by `--action_env` / `--incompatible_strict_action_env`). Set to `False` to run apko with a hermetic action environment.", + default = True, + ), } def _impl(ctx): @@ -81,7 +85,6 @@ def _impl(ctx): inputs.append(lockfile_copy) args.add("--lockfile={}".format(lockfile.short_path)) - args.add("--cache-dir={}".format(cache_name)) args.add("--offline") if ctx.attr.architecture: @@ -102,15 +105,51 @@ def _impl(ctx): copy_to_workdir(ctx, apko_info.binary, apko_binary) inputs.append(apko_binary) + # apko writes into its --cache-dir even with --offline (it expands + # packages and creates directories on cache lookup). Bazel provides the + # prepopulated cache tree artifact as a read-only input, which remote + # executors enforce strictly, so we mirror it into a writable scratch + # directory as real directories containing per-file symlinks. apko only + # adds new cache entries and never rewrites existing files, so the + # symlinks stay read-only while new writes land in the scratch + # directories, avoiding a full copy of the cache. HOME falls back to a + # scratch directory when the action environment leaves it unset or empty, + # as with use_default_shell_env = False or an executor that sets none. + # + # Every command below is either a bash builtin or a coreutils subcommand, + # so the action depends on nothing from the executor image beyond a shell. + # COREUTILS is resolved to an absolute path up front because the EXIT trap + # fires after the cd into the workdir. The mirror is copied to a path that + # does not exist yet rather than as "src/." into an existing directory, + # because uutils 0.0.16 (registered by aspect_bazel_lib 1.x) nests the + # latter at dst/. + coreutils = coreutils_bin(ctx) + command = "\n".join([ + "set -e", + 'COREUTILS="$PWD/{coreutils}"', + 'SCRATCH="$("$COREUTILS" mktemp -d)"', + "trap '\"$COREUTILS\" rm -rf \"$SCRATCH\"' EXIT", + '"$COREUTILS" mkdir "$SCRATCH/home"', + 'CACHE_SRC="$(cd {cache_src} && pwd -P)"', + '"$COREUTILS" cp -Rs "$CACHE_SRC" "$SCRATCH/cache"', + 'export HOME="${{HOME:-$SCRATCH/home}}"', + "cd {workdir}", + '{apko} "$@" --cache-dir="$SCRATCH/cache"', + ]).format( + coreutils = coreutils.path, + cache_src = cache_dir.path, + workdir = paths.join(ctx.bin_dir.path, ctx.label.workspace_root, ctx.label.package, workdir), + apko = apko_info.binary.short_path, + ) + ctx.actions.run_shell( - command = "cd {} && {} $@".format(paths.join(ctx.bin_dir.path, ctx.label.workspace_root, ctx.label.package, workdir), apko_info.binary.short_path), + command = command, arguments = [args], inputs = inputs, - tools = [apko_info.binary], + tools = [apko_info.binary, coreutils], outputs = [output], - use_default_shell_env = True, - execution_requirements = {"no-remote-exec": "1"}, - toolchain = None, + use_default_shell_env = ctx.attr.use_default_shell_env, + toolchain = "@rules_apko//apko:toolchain_type", ) return DefaultInfo( @@ -120,7 +159,10 @@ def _impl(ctx): apko_image_lib = struct( attrs = _ATTRS, implementation = _impl, - toolchains = ["@rules_apko//apko:toolchain_type"], + toolchains = [ + "@rules_apko//apko:toolchain_type", + COREUTILS_TOOLCHAIN_TYPE, + ], ) _apko_image = rule( @@ -137,6 +179,7 @@ def apko_image( output = "oci", architecture = None, args = [], + use_default_shell_env = True, **kwargs): """Build OCI images from APK packages directly without Dockerfile @@ -181,6 +224,9 @@ def apko_image( architecture: the CPU architecture which this image should be built to run on. See https://github.com/chainguard-dev/apko/blob/main/docs/apko_file.md#archs-top-level-element"), tag: tag to apply to the resulting docker tarball. only applicable when `output` is `docker` args: additional arguments to provide when running the `apko build` command. + use_default_shell_env: whether the apko build action inherits the default shell environment + (the client environment as filtered by `--action_env` / `--incompatible_strict_action_env`). + Set to `False` to run apko with a hermetic action environment. **kwargs: other common arguments like: tags, visibility. """ _apko_image( @@ -191,6 +237,7 @@ def apko_image( architecture = architecture, tag = tag, args = args, + use_default_shell_env = use_default_shell_env, **kwargs ) config_label = native.package_relative_label(config) diff --git a/apko/private/apko_show_config.bzl b/apko/private/apko_show_config.bzl index c0884be..9b1f7ff 100644 --- a/apko/private/apko_show_config.bzl +++ b/apko/private/apko_show_config.bzl @@ -1,7 +1,7 @@ "A rule for expanding apko config" load("@bazel_skylib//lib:paths.bzl", "paths") -load("//apko/private:apko_config.bzl", "copy_to_workdir", "prepare_apko_config_in_workdir") +load("//apko/private:apko_config.bzl", "COREUTILS_TOOLCHAIN_TYPE", "copy_to_workdir", "prepare_apko_config_in_workdir") def _impl(ctx): output = ctx.actions.declare_file(ctx.attr.name) @@ -45,6 +45,7 @@ def _impl(ctx): inputs = inputs, tools = [apko_info.binary], outputs = [output], + toolchain = "@rules_apko//apko:toolchain_type", ) return DefaultInfo( @@ -63,5 +64,8 @@ For more advanced use-cases (multi-file configuration), use target providing `Ap ), }, doc = "Wrapper around `apko show-config` command to generate expanded config as bazel build action.", - toolchains = ["@rules_apko//apko:toolchain_type"], + toolchains = [ + "@rules_apko//apko:toolchain_type", + COREUTILS_TOOLCHAIN_TYPE, + ], ) diff --git a/docs/rules.md b/docs/rules.md index 22ede14..2dda526 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -75,7 +75,7 @@ When referencing other files in the config yaml file use paths relative to your
 load("@rules_apko//apko:defs.bzl", "apko_image")
 
-apko_image(name, contents, config, tag, output, architecture, args, **kwargs)
+apko_image(name, contents, config, tag, output, architecture, args, use_default_shell_env, **kwargs)
 
Build OCI images from APK packages directly without Dockerfile @@ -125,6 +125,7 @@ For more examples checkout the [examples](/examples) directory. | output | "oci" of "docker", | `"oci"` | | architecture | the CPU architecture which this image should be built to run on. See https://github.com/chainguard-dev/apko/blob/main/docs/apko_file.md#archs-top-level-element"), | `None` | | args | additional arguments to provide when running the `apko build` command. | `[]` | +| use_default_shell_env | whether the apko build action inherits the default shell environment (the client environment as filtered by `--action_env` / `--incompatible_strict_action_env`). Set to `False` to run apko with a hermetic action environment. | `True` | | kwargs | other common arguments like: tags, visibility. | none | diff --git a/e2e/smoke/BUILD b/e2e/smoke/BUILD index 581aef2..deb771f 100644 --- a/e2e/smoke/BUILD +++ b/e2e/smoke/BUILD @@ -10,9 +10,19 @@ apko_image( tag = "lock:latest", ) +# Exercises the hermetic action environment opt-in. +apko_image( + name = "lock_hermetic_env", + config = ":apko.yaml", + contents = "@example_lock//:contents", + tag = "lock:latest", + use_default_shell_env = False, +) + build_test( name = "test", targets = [ ":lock", + ":lock_hermetic_env", ], ) diff --git a/e2e/smoke/WORKSPACE.bazel b/e2e/smoke/WORKSPACE.bazel index cbdf567..b43facd 100644 --- a/e2e/smoke/WORKSPACE.bazel +++ b/e2e/smoke/WORKSPACE.bazel @@ -1,5 +1,5 @@ # Override http_archive for local testing -load("@bazel//tools/build_defs/repo:local.bzl", "local_repository") +load("@bazel_tools//tools/build_defs/repo:local.bzl", "local_repository") local_repository( name = "rules_apko", @@ -20,6 +20,13 @@ load("@rules_apko//apko:repositories.bzl", "apko_register_toolchains", "rules_ap rules_apko_dependencies() +# apko_image and apko_show_config take mkdir/cp/mktemp from aspect_bazel_lib's +# coreutils toolchain. Skip this if your WORKSPACE already registers it, for +# example through aspect_bazel_lib_dependencies() on aspect_bazel_lib 1.x. +load("@aspect_bazel_lib//lib:repositories.bzl", "register_coreutils_toolchains") + +register_coreutils_toolchains() + apko_register_toolchains(name = "apko") load("@rules_apko//apko:translate_lock.bzl", "translate_apko_lock") diff --git a/e2e/smoke/tree_staging/BUILD b/e2e/smoke/tree_staging/BUILD new file mode 100644 index 0000000..704a713 --- /dev/null +++ b/e2e/smoke/tree_staging/BUILD @@ -0,0 +1,14 @@ +load("@bazel_skylib//rules:build_test.bzl", "build_test") +load(":tree.bzl", "staged_tree_check", "tree") + +tree(name = "input_tree") + +staged_tree_check( + name = "staged_tree", + src = ":input_tree", +) + +build_test( + name = "test", + targets = [":staged_tree"], +) diff --git a/e2e/smoke/tree_staging/tree.bzl b/e2e/smoke/tree_staging/tree.bzl new file mode 100644 index 0000000..68f2455 --- /dev/null +++ b/e2e/smoke/tree_staging/tree.bzl @@ -0,0 +1,37 @@ +"Fixtures for staging a tree artifact through copy_to_workdir." + +# buildifier: disable=bzl-visibility +load("@rules_apko//apko/private:apko_config.bzl", "COREUTILS_TOOLCHAIN_TYPE", "copy_to_workdir") + +def _tree_impl(ctx): + out = ctx.actions.declare_directory(ctx.attr.name) + ctx.actions.run_shell( + outputs = [out], + command = 'mkdir -p "$1/sub" && echo extra > "$1/sub/file"', + arguments = [out.path], + ) + return DefaultInfo(files = depset([out])) + +tree = rule(implementation = _tree_impl) + +def _staged_tree_check_impl(ctx): + src = ctx.attr.src[DefaultInfo].files.to_list()[0] + staged = ctx.actions.declare_directory(ctx.attr.name + "_staged") + copy_to_workdir(ctx, src, staged) + + # Staging a directory must put its contents at the destination, not nest + # the directory one level down at /. + marker = ctx.actions.declare_file(ctx.attr.name + ".ok") + ctx.actions.run_shell( + inputs = [staged], + outputs = [marker], + command = 'test -f "$1/sub/file" && ! test -e "$1/$3" && touch "$2"', + arguments = [staged.path, marker.path, src.basename], + ) + return DefaultInfo(files = depset([marker])) + +staged_tree_check = rule( + implementation = _staged_tree_check_impl, + attrs = {"src": attr.label(mandatory = True)}, + toolchains = [COREUTILS_TOOLCHAIN_TYPE], +)