From 8e3ca95d3adb7a0c5e54a4849464fcb09167b009 Mon Sep 17 00:00:00 2001 From: Balaji Ganesan Date: Fri, 24 Jul 2026 13:36:50 -0700 Subject: [PATCH 1/6] feat(cloud-tasks): add multi-arch OCI image target cloud-tasks had no container image target under Bazel: nvct-service declared only the Spring Boot jar, so it could not be released by any mechanism. Add the missing piece. - rules/oci: new java_oci_image macro, mirroring go_oci_image. Packages a runnable jar and emits the same target set, including the _index multi-arch label that release tooling pushes. - MODULE.bazel: pull nvcr.io/nvidia/distroless/java (25-jdk-v4.0.8) pinned by digest, matching the distroless_go / distroless_cc convention and the base the pre-Bazel nvct-service Dockerfile used. - nvct-service: java_oci_image target producing nvct-service-oss. Two base-image details the macro encodes so callers cannot get them wrong: the distroless entrypoint is the shelless_ulimit shim and oci_image REPLACES rather than appends the base entrypoint, so the default entrypoint keeps the shim (dropping it silently leaves the container on a 1024 fd soft limit, the same issue grpc-proxy hit); and java is invoked via /usr/bin/java rather than JAVA_HOME, which the base sets per-architecture and would break arm64. Verified: bazel query resolves //src/control-plane-services/cloud-tasks/nvct-service:nvct-service-oss-image_index Co-authored-by: Balaji Ganesan --- MODULE.bazel | 18 +++ rules/oci/defs.bzl | 2 + rules/oci/private/java.bzl | 125 ++++++++++++++++++ .../cloud-tasks/nvct-service/BUILD.bazel | 14 ++ 4 files changed, 159 insertions(+) create mode 100644 rules/oci/private/java.bzl diff --git a/MODULE.bazel b/MODULE.bazel index ba2b262ee..ec936ede9 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -151,8 +151,26 @@ oci.pull( ], tag = "21-jre", ) + +# NVIDIA distroless java: the base for Java service images, matching the +# distroless_go / distroless_cc bases the Go and Rust services use and the base +# the pre-Bazel nvct-service Dockerfile used (25-jdk-v4.0.8). Publicly +# pullable, multi-arch. Pinned by digest like the other distroless bases. +# JDK 25 matches --java_language_version=25 above. +oci.pull( + name = "distroless_java", + digest = "sha256:cc9dbc2560e39f6e0a67cd85421370e46fd818b1660800b507b82819a79ea21f", + image = "nvcr.io/nvidia/distroless/java", + platforms = [ + "linux/amd64", + "linux/arm64/v8", + ], +) use_repo( oci, + "distroless_java", + "distroless_java_linux_amd64", + "distroless_java_linux_arm64_v8", "temurin_jre", "temurin_jre_linux_amd64", "temurin_jre_linux_arm64_v8", diff --git a/rules/oci/defs.bzl b/rules/oci/defs.bzl index aca1d72ee..08f2b8544 100644 --- a/rules/oci/defs.bzl +++ b/rules/oci/defs.bzl @@ -16,5 +16,7 @@ "OCI image rules for packaging binaries into multi-arch containers." load("//rules/oci/private:go.bzl", _go_oci_image = "go_oci_image") +load("//rules/oci/private:java.bzl", _java_oci_image = "java_oci_image") go_oci_image = _go_oci_image +java_oci_image = _java_oci_image diff --git a/rules/oci/private/java.bzl b/rules/oci/private/java.bzl new file mode 100644 index 000000000..135682680 --- /dev/null +++ b/rules/oci/private/java.bzl @@ -0,0 +1,125 @@ +# SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# 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. + +"OCI image rules for Java applications." + +load("@rules_pkg//pkg:mappings.bzl", "strip_prefix") +load("@rules_pkg//pkg:tar.bzl", "pkg_tar") +load("//rules/oci/private:common.bzl", "create_oci_image") + +# NVIDIA distroless java, matching the distroless_go / distroless_cc bases the +# Go and Rust services already use. Publicly pullable, multi-arch, and the same +# base the pre-Bazel service Dockerfiles use. Keep the JDK major version in +# lockstep with --java_language_version in //.bazelrc: a runtime older than the +# emitted bytecode fails at container startup with UnsupportedClassVersionError, +# which no build-time check catches. +DEFAULT_JAVA_BASE = "@distroless_java" + +# The distroless bases set ENTRYPOINT ["/usr/bin/shelless_ulimit"], a shim that +# raises the soft file-descriptor limit before exec'ing the real command. +# oci_image REPLACES the base entrypoint rather than appending, so omitting the +# shim here would silently drop it and leave the container on the default 1024 +# soft limit. Same fix grpc-proxy applies for its Go image. +ULIMIT_SHIM = "/usr/bin/shelless_ulimit" + +# Arch-stable java path. Do NOT build this from JAVA_HOME: the base sets it +# per-architecture (/usr/lib/jvm/temurin-25-jdk-amd64 vs -arm64), so a +# hardcoded JAVA_HOME path would break the arm64 half of the image index. +# /usr/bin/java is present on both arches. +JAVA_BIN = "/usr/bin/java" + +def _java_oci_image_impl(name, visibility, jar, base, jar_path, entrypoint, jvm_flags, registry, tags): + layer_name = name + "_layer" + + # Place the jar at a fixed absolute path so the entrypoint below can name + # it. Without strip_prefix + package_dir, rules_pkg writes it at its full + # workspace short-path and `java -jar /app.jar` fails at runtime with + # "Unable to access jarfile" while `bazel build :image` still succeeds, + # because building only assembles the layer and never runs the container. + # + # mode 0644 is correct here and is NOT the go_oci_image case: a jar is read + # by the JVM, never exec'd, so it needs no exec bit. The entrypoint + # executable is `java`, which comes from the base image. + pkg_tar( + name = layer_name, + extension = "tar.gz", + srcs = [jar], + mode = "0644", + package_dir = "/", + strip_prefix = strip_prefix.from_pkg(""), + visibility = ["//visibility:private"], + ) + + entry = entrypoint + if not entry: + entry = [ULIMIT_SHIM, JAVA_BIN] + list(jvm_flags) + ["-jar", jar_path] + + create_oci_image( + name = name, + tars = [layer_name], + base = base, + entrypoint = entry, + visibility = visibility, + registry = registry, + tags = tags, + ) + +java_oci_image = macro( + doc = """Packages a Java application jar into a multi-arch OCI image. + + Intended for a Spring Boot executable jar (see //rules/java:spring.bzl), + but works with any self-contained runnable jar. Produces the same target + set as go_oci_image: {name}, {name}_index (multi-arch, this is what you + push), {name}_load, {name}.tar, and {name}_push when registry is set. + """, + implementation = _java_oci_image_impl, + attrs = { + "jar": attr.label( + doc = "The runnable jar to package (e.g. a spring_boot_app target).", + mandatory = True, + allow_single_file = True, + configurable = False, + ), + "base": attr.label( + doc = "Base OCI image. Must provide a JRE on PATH.", + default = DEFAULT_JAVA_BASE, + configurable = False, + ), + "jar_path": attr.string( + doc = "Absolute in-image path of the jar.", + default = "/app.jar", + configurable = False, + ), + "jvm_flags": attr.string_list( + doc = "JVM flags inserted before -jar. Ignored when entrypoint is set.", + configurable = False, + ), + "entrypoint": attr.string_list( + doc = """Container entrypoint. Defaults to + [/usr/bin/shelless_ulimit, /usr/bin/java, {jvm_flags}..., -jar, {jar_path}]. + If you override this, keep the shelless_ulimit shim first or the + container loses the raised file-descriptor limit.""", + configurable = False, + ), + "registry": attr.string( + doc = "Registry to push to. If not set, push target is not created.", + configurable = False, + ), + "tags": attr.string_list( + doc = "Tags for generated targets. 'manual' is always added.", + configurable = False, + ), + }, +) diff --git a/src/control-plane-services/cloud-tasks/nvct-service/BUILD.bazel b/src/control-plane-services/cloud-tasks/nvct-service/BUILD.bazel index 5b07d030b..b01c75bcb 100644 --- a/src/control-plane-services/cloud-tasks/nvct-service/BUILD.bazel +++ b/src/control-plane-services/cloud-tasks/nvct-service/BUILD.bazel @@ -1,5 +1,6 @@ load("//rules/java:defs.bzl", "nvct_library", "nvct_library_test") load("//rules/java:spring.bzl", "spring_boot_app") +load("//rules/oci:defs.bzl", "java_oci_image") package(default_visibility = ["//visibility:public"]) @@ -43,6 +44,19 @@ spring_boot_app( main_class = "com.nvidia.nvct.App", ) +# Release image. Push the _index label (multi-arch amd64 + arm64), never +# :nvct-service-oss-image, which is the single host-arch image. +# +# Parity with the pre-Bazel nvct-service/Dockerfile: same distroless java base +# (nvcr.io/nvidia/distroless/java 25-jdk, pinned by digest in //MODULE.bazel), +# same executable Spring Boot jar at /app.jar, same java -jar entrypoint behind +# the base's shelless_ulimit shim. +java_oci_image( + name = "nvct-service-oss-image", + jar = ":app", + jar_path = "/app.jar", +) + nvct_library_test( name = "tests", coverage_library = ":app_classes", From b856f34a6bf5ac65e3d7f2b7dbd17672387ac0bc Mon Sep 17 00:00:00 2001 From: Balaji Ganesan Date: Fri, 24 Jul 2026 14:34:33 -0700 Subject: [PATCH 2/6] build(bazel): update MODULE.bazel.lock for the distroless_java pull Generated by bazel; additive only (the distroless_java oci_pull entries plus the usagesDigest). No existing pin changes. Co-authored-by: Balaji Ganesan --- MODULE.bazel.lock | 50 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index dc12c256c..4938ff2b0 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -379,7 +379,7 @@ "@@rules_oci+//oci:extensions.bzl%oci": { "general": { "bzlTransitiveDigest": "IPpbSHX7+8yLBfvZyia9qXU/WRxkP+dWAO8m5+BsBY8=", - "usagesDigest": "OlwIdJhTnYJJAA+ezJIG6rT/iRycvHf0Yuf0EPuhO7c=", + "usagesDigest": "HCv5iVmsywJDYi04nXaMJHFn8CwQx+Ryy/HVpSiPdaQ=", "recordedInputs": [ "REPO_MAPPING:aspect_bazel_lib+,bazel_tools bazel_tools", "REPO_MAPPING:bazel_features+,bazel_tools bazel_tools", @@ -474,6 +474,49 @@ "reproducible": true } }, + "distroless_java_linux_amd64": { + "repoRuleId": "@@rules_oci+//oci/private:pull.bzl%oci_pull", + "attributes": { + "www_authenticate_challenges": {}, + "scheme": "https", + "registry": "nvcr.io", + "repository": "nvidia/distroless/java", + "identifier": "sha256:cc9dbc2560e39f6e0a67cd85421370e46fd818b1660800b507b82819a79ea21f", + "platform": "linux/amd64", + "target_name": "distroless_java_linux_amd64", + "bazel_tags": [] + } + }, + "distroless_java_linux_arm64_v8": { + "repoRuleId": "@@rules_oci+//oci/private:pull.bzl%oci_pull", + "attributes": { + "www_authenticate_challenges": {}, + "scheme": "https", + "registry": "nvcr.io", + "repository": "nvidia/distroless/java", + "identifier": "sha256:cc9dbc2560e39f6e0a67cd85421370e46fd818b1660800b507b82819a79ea21f", + "platform": "linux/arm64/v8", + "target_name": "distroless_java_linux_arm64_v8", + "bazel_tags": [] + } + }, + "distroless_java": { + "repoRuleId": "@@rules_oci+//oci/private:pull.bzl%oci_alias", + "attributes": { + "target_name": "distroless_java", + "www_authenticate_challenges": {}, + "scheme": "https", + "registry": "nvcr.io", + "repository": "nvidia/distroless/java", + "identifier": "sha256:cc9dbc2560e39f6e0a67cd85421370e46fd818b1660800b507b82819a79ea21f", + "platforms": { + "@@platforms//cpu:x86_64": "@distroless_java_linux_amd64", + "@@platforms//cpu:arm64": "@distroless_java_linux_arm64_v8" + }, + "bzlmod_repository": "distroless_java", + "reproducible": true + } + }, "oci_crane_darwin_amd64": { "repoRuleId": "@@rules_oci+//oci:repositories.bzl%crane_repositories", "attributes": { @@ -595,7 +638,10 @@ "ubuntu_noble_linux_amd64", "temurin_jre", "temurin_jre_linux_arm64_v8", - "temurin_jre_linux_amd64" + "temurin_jre_linux_amd64", + "distroless_java", + "distroless_java_linux_amd64", + "distroless_java_linux_arm64_v8" ], "explicitRootModuleDirectDevDeps": [], "useAllRepos": "NO", From a0aab8e7d4517204ca56723a087824365beabd2d Mon Sep 17 00:00:00 2001 From: Balaji Ganesan Date: Fri, 24 Jul 2026 18:01:37 -0700 Subject: [PATCH 3/6] test(cloud-tasks): add image contract test; make java_bin configurable Addresses both review comments. Contract test: the image's runtime contract was only verified by hand, and every part of it fails at container startup rather than at build time. The test asserts the jar is at /app.jar (the path the entrypoint names), the base image's shelless_ulimit shim survives (oci_image replaces rather than appends the base entrypoint), and java is invoked via the arch-stable /usr/bin/java rather than a per-arch JAVA_HOME path. Unlike the Go services' image_entrypoint_mode_test it does not assert an exec bit: a jar is read by the JVM, never executed, so mode 0644 is correct. sh_test is loaded explicitly from @rules_shell. The Go subtrees rely on it being a built-in global, which works only because they are still on Bazel 8; cloud-tasks builds in the root module on Bazel 9, where that global is gone. java_bin: the base attribute documented "a JRE on PATH" while the generated entrypoint hardcoded /usr/bin/java, so a compliant custom base with Java elsewhere would have failed at startup. The path is now a configurable attribute defaulting to /usr/bin/java, and the base contract states the requirement precisely. Verified: test passes against the real image, and fails when given a wrong expected entrypoint (negative control), so it is not vacuous. Co-authored-by: Balaji Ganesan --- rules/oci/private/java.bzl | 17 ++++- .../cloud-tasks/nvct-service/BUILD.bazel | 16 +++++ .../nvct-service/image_contract_test.sh | 65 +++++++++++++++++++ 3 files changed, 95 insertions(+), 3 deletions(-) create mode 100755 src/control-plane-services/cloud-tasks/nvct-service/image_contract_test.sh diff --git a/rules/oci/private/java.bzl b/rules/oci/private/java.bzl index 135682680..a48ba431a 100644 --- a/rules/oci/private/java.bzl +++ b/rules/oci/private/java.bzl @@ -40,7 +40,7 @@ ULIMIT_SHIM = "/usr/bin/shelless_ulimit" # /usr/bin/java is present on both arches. JAVA_BIN = "/usr/bin/java" -def _java_oci_image_impl(name, visibility, jar, base, jar_path, entrypoint, jvm_flags, registry, tags): +def _java_oci_image_impl(name, visibility, jar, base, jar_path, java_bin, entrypoint, jvm_flags, registry, tags): layer_name = name + "_layer" # Place the jar at a fixed absolute path so the entrypoint below can name @@ -64,7 +64,7 @@ def _java_oci_image_impl(name, visibility, jar, base, jar_path, entrypoint, jvm_ entry = entrypoint if not entry: - entry = [ULIMIT_SHIM, JAVA_BIN] + list(jvm_flags) + ["-jar", jar_path] + entry = [ULIMIT_SHIM, java_bin] + list(jvm_flags) + ["-jar", jar_path] create_oci_image( name = name, @@ -93,10 +93,21 @@ java_oci_image = macro( configurable = False, ), "base": attr.label( - doc = "Base OCI image. Must provide a JRE on PATH.", + doc = """Base OCI image. It must provide a Java launcher at the + absolute path given by java_bin (default /usr/bin/java) and the + shelless_ulimit shim, which the NVIDIA distroless bases do. A base + with Java only on PATH, or at a different absolute path, must set + java_bin accordingly.""", default = DEFAULT_JAVA_BASE, configurable = False, ), + "java_bin": attr.string( + doc = """Absolute path to the Java launcher in the base image. + Absolute rather than PATH-resolved, and deliberately not derived + from JAVA_HOME, which the distroless bases set per architecture.""", + default = JAVA_BIN, + configurable = False, + ), "jar_path": attr.string( doc = "Absolute in-image path of the jar.", default = "/app.jar", diff --git a/src/control-plane-services/cloud-tasks/nvct-service/BUILD.bazel b/src/control-plane-services/cloud-tasks/nvct-service/BUILD.bazel index b01c75bcb..558393b29 100644 --- a/src/control-plane-services/cloud-tasks/nvct-service/BUILD.bazel +++ b/src/control-plane-services/cloud-tasks/nvct-service/BUILD.bazel @@ -1,5 +1,6 @@ load("//rules/java:defs.bzl", "nvct_library", "nvct_library_test") load("//rules/java:spring.bzl", "spring_boot_app") +load("@rules_shell//shell:sh_test.bzl", "sh_test") load("//rules/oci:defs.bzl", "java_oci_image") package(default_visibility = ["//visibility:public"]) @@ -88,3 +89,18 @@ nvct_library_test( ], timeout = "long", ) + +# Runtime-contract guard for the image: the jar path matches the entrypoint, +# the base image's shelless_ulimit shim survives (oci_image replaces rather +# than appends the base entrypoint), and java is invoked via the arch-stable +# /usr/bin/java rather than a per-arch JAVA_HOME path. Each of these otherwise +# fails only at container startup. +# +# sh_test is loaded explicitly: cloud-tasks builds in the root module on Bazel +# 9, which no longer provides sh_test as a built-in global. +sh_test( + name = "image_contract_test", + srcs = ["image_contract_test.sh"], + args = ["$(location :nvct-service-oss-image.tar)"], + data = [":nvct-service-oss-image.tar"], +) diff --git a/src/control-plane-services/cloud-tasks/nvct-service/image_contract_test.sh b/src/control-plane-services/cloud-tasks/nvct-service/image_contract_test.sh new file mode 100755 index 000000000..8b93bc9ce --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-service/image_contract_test.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Asserts the runtime contract of the nvct-service image, which is otherwise +# only observable by running the container: +# +# 1. The jar is at /app.jar, the exact path the entrypoint names. If the +# layer path and the entrypoint ever disagree, the image builds fine and +# fails at startup with "Unable to access jarfile". +# 2. The entrypoint keeps the base image's shelless_ulimit shim. oci_image +# REPLACES the base entrypoint rather than appending, so dropping the shim +# is silent and leaves the container on the default 1024 fd soft limit. +# 3. Java is invoked through /usr/bin/java, not a JAVA_HOME-derived path. The +# base sets JAVA_HOME per architecture, so an absolute JAVA_HOME path +# would break the arm64 half of the image index. +# +# Unlike the Go services' image_entrypoint_mode_test this does not assert an +# exec bit: a jar is read by the JVM, never executed, so mode 0644 is correct. +set -euo pipefail + +image_tar="$1" +tmp_dir="${TEST_TMPDIR:-/tmp}/nvct-image-contract-${RANDOM}-${RANDOM}" +outer_dir="${tmp_dir}/outer" +mkdir -p "${outer_dir}" +trap 'rm -rf "${tmp_dir}"' EXIT + +tar -xf "${image_tar}" -C "${outer_dir}" + +# 1. the jar is present at /app.jar in some layer +jar_found=false +while IFS= read -r candidate; do + tar -tf "${candidate}" >/dev/null 2>&1 || continue + if tar -tf "${candidate}" | grep -Eq '^(\./)?app\.jar$'; then + jar_found=true + break + fi +done < <(find "${outer_dir}" -type f) + +if [[ "${jar_found}" != "true" ]]; then + echo "no image layer contains /app.jar" >&2 + find "${outer_dir}" -type f -print >&2 + exit 1 +fi + +# 2 + 3. the entrypoint keeps the shim and uses the absolute java path. Scan +# every regular file rather than parsing manifest.json, so the test needs no +# JSON tool beyond coreutils. Note the config is a content-addressed blob under +# blobs/sha256/ with NO .json extension, so it cannot be found by suffix. +entrypoint_ok=false +while IFS= read -r meta; do + grep -q '"Entrypoint"' "${meta}" 2>/dev/null || continue + if tr -d ' \n' < "${meta}" \ + | grep -q '"Entrypoint":\["/usr/bin/shelless_ulimit","/usr/bin/java","-jar","/app.jar"\]'; then + entrypoint_ok=true + break + fi +done < <(find "${outer_dir}" -type f) + +if [[ "${entrypoint_ok}" != "true" ]]; then + echo "image entrypoint is not [/usr/bin/shelless_ulimit /usr/bin/java -jar /app.jar]" >&2 + echo "entrypoints found in the image:" >&2 + find "${outer_dir}" -type f -exec grep -ho '"Entrypoint":[^]]*]' {} \; 2>/dev/null >&2 || true + exit 1 +fi From 343d5d5afc3330048c4b403f8a2e9d197c2efcd9 Mon Sep 17 00:00:00 2001 From: Balaji Ganesan Date: Fri, 24 Jul 2026 19:40:26 -0700 Subject: [PATCH 4/6] fix(cloud-tasks): preserve the Dockerfile runtime contract in the image Addresses the blocking and important findings from review. The image did not reproduce the runtime the pre-Bazel nvct-service/Dockerfile establishes. Runtime parity. The Dockerfile sets ULIMIT_FLAG=1, JDK_JAVA_OPTIONS, WORKDIR /home/app, and installs the jar at /usr/share/app.jar. None of that was carried over. ULIMIT_FLAG is load-bearing: the base's shelless_ulimit shim reads it, so preserving the shim in the entrypoint while dropping the variable meant the shim ran and raised nothing. JDK_JAVA_OPTIONS carries the container heap sizing (MaxRAMPercentage=40.0), so losing it silently changes JVM memory behavior in production. create_oci_image and java_oci_image now take env and workdir, both optional and defaulting to leaving the base untouched, so go_oci_image callers are unaffected. jar_path now controls where the jar is installed. pkg_tar preserves the source basename, so jar_path only renamed the entrypoint: a jar not already called app.jar, or a jar_path pointing elsewhere, produced an image whose entrypoint referenced a nonexistent file. pkg_files renames and relocates it, so the layer and the entrypoint are derived from one value and cannot disagree. This is what made /usr/share/app.jar expressible at all. Test coverage extended to the full contract: jar location, entrypoint, ULIMIT_FLAG, MaxRAMPercentage, and WorkingDir. Verified on the built index, both amd64 and arm64: Entrypoint /usr/bin/shelless_ulimit /usr/bin/java -jar /usr/share/app.jar WorkingDir /home/app ULIMIT_FLAG=1 and JDK_JAVA_OPTIONS present Co-authored-by: Balaji Ganesan --- rules/oci/private/common.bzl | 9 ++- rules/oci/private/java.bzl | 55 +++++++++++----- .../cloud-tasks/nvct-service/BUILD.bazel | 63 ++++++------------- .../nvct-service/image_contract_test.sh | 28 +++++++-- 4 files changed, 89 insertions(+), 66 deletions(-) diff --git a/rules/oci/private/common.bzl b/rules/oci/private/common.bzl index 2120261c7..50d4ec890 100644 --- a/rules/oci/private/common.bzl +++ b/rules/oci/private/common.bzl @@ -35,7 +35,9 @@ def create_oci_image( entrypoint, visibility, registry = None, - tags = None): + tags = None, + env = None, + workdir = None): """Creates OCI image targets with platform transitions and tarball output. Generates: @@ -44,6 +46,9 @@ def create_oci_image( - {name}_load: Local docker load target - {name}.tar: Tarball filegroup - {name}_push: Push to registry (if registry is set) + + env and workdir are optional and default to leaving the base image's + values untouched, so existing callers are unaffected. """ all_tags = ["manual"] + (tags or []) @@ -53,6 +58,8 @@ def create_oci_image( base = base, tars = tars + COMMON_LAYERS, entrypoint = entrypoint, + env = env, + workdir = workdir, visibility = ["//visibility:private"], tags = all_tags, ) diff --git a/rules/oci/private/java.bzl b/rules/oci/private/java.bzl index a48ba431a..459ece804 100644 --- a/rules/oci/private/java.bzl +++ b/rules/oci/private/java.bzl @@ -15,7 +15,7 @@ "OCI image rules for Java applications." -load("@rules_pkg//pkg:mappings.bzl", "strip_prefix") +load("@rules_pkg//pkg:mappings.bzl", "pkg_attributes", "pkg_files") load("@rules_pkg//pkg:tar.bzl", "pkg_tar") load("//rules/oci/private:common.bzl", "create_oci_image") @@ -40,25 +40,36 @@ ULIMIT_SHIM = "/usr/bin/shelless_ulimit" # /usr/bin/java is present on both arches. JAVA_BIN = "/usr/bin/java" -def _java_oci_image_impl(name, visibility, jar, base, jar_path, java_bin, entrypoint, jvm_flags, registry, tags): +def _java_oci_image_impl(name, visibility, jar, base, jar_path, java_bin, entrypoint, jvm_flags, env, workdir, registry, tags): layer_name = name + "_layer" + files_name = name + "_files" - # Place the jar at a fixed absolute path so the entrypoint below can name - # it. Without strip_prefix + package_dir, rules_pkg writes it at its full - # workspace short-path and `java -jar /app.jar` fails at runtime with - # "Unable to access jarfile" while `bazel build :image` still succeeds, - # because building only assembles the layer and never runs the container. + if not jar_path.startswith("/"): + fail("jar_path must be absolute, got: " + jar_path) + jar_dir, _, jar_name = jar_path.rpartition("/") + + # Install the jar AT jar_path. pkg_tar alone preserves the source + # basename and only the entrypoint would name jar_path, so a jar not + # already called app.jar, or a jar_path pointing elsewhere, would produce + # an image whose entrypoint references a file that does not exist. pkg_files + # renames and relocates it so the layer and the entrypoint cannot disagree. # - # mode 0644 is correct here and is NOT the go_oci_image case: a jar is read - # by the JVM, never exec'd, so it needs no exec bit. The entrypoint - # executable is `java`, which comes from the base image. + # mode 0644 is correct and is NOT the go_oci_image case: a jar is read by + # the JVM, never exec'd, so it needs no exec bit. The executable in the + # entrypoint is java, which comes from the base image. + pkg_files( + name = files_name, + srcs = [jar], + prefix = jar_dir, + renames = {jar: jar_name}, + attributes = pkg_attributes(mode = "0644"), + visibility = ["//visibility:private"], + ) + pkg_tar( name = layer_name, extension = "tar.gz", - srcs = [jar], - mode = "0644", - package_dir = "/", - strip_prefix = strip_prefix.from_pkg(""), + srcs = [files_name], visibility = ["//visibility:private"], ) @@ -71,6 +82,8 @@ def _java_oci_image_impl(name, visibility, jar, base, jar_path, java_bin, entryp tars = [layer_name], base = base, entrypoint = entry, + env = env, + workdir = workdir, visibility = visibility, registry = registry, tags = tags, @@ -109,10 +122,22 @@ java_oci_image = macro( configurable = False, ), "jar_path": attr.string( - doc = "Absolute in-image path of the jar.", + doc = """Absolute in-image path the jar is installed at. The layer + and the entrypoint are both derived from this, so they cannot + disagree.""", default = "/app.jar", configurable = False, ), + "env": attr.string_dict( + doc = """Environment variables set in the image. Required for + runtime behavior the base image expects, for example ULIMIT_FLAG=1, + without which the shelless_ulimit shim runs but raises no limit.""", + configurable = False, + ), + "workdir": attr.string( + doc = "Container working directory. Unset leaves the base's value.", + configurable = False, + ), "jvm_flags": attr.string_list( doc = "JVM flags inserted before -jar. Ignored when entrypoint is set.", configurable = False, diff --git a/src/control-plane-services/cloud-tasks/nvct-service/BUILD.bazel b/src/control-plane-services/cloud-tasks/nvct-service/BUILD.bazel index 558393b29..834ecf2c8 100644 --- a/src/control-plane-services/cloud-tasks/nvct-service/BUILD.bazel +++ b/src/control-plane-services/cloud-tasks/nvct-service/BUILD.bazel @@ -48,56 +48,29 @@ spring_boot_app( # Release image. Push the _index label (multi-arch amd64 + arm64), never # :nvct-service-oss-image, which is the single host-arch image. # -# Parity with the pre-Bazel nvct-service/Dockerfile: same distroless java base -# (nvcr.io/nvidia/distroless/java 25-jdk, pinned by digest in //MODULE.bazel), -# same executable Spring Boot jar at /app.jar, same java -jar entrypoint behind -# the base's shelless_ulimit shim. +# Runtime parity with the pre-Bazel nvct-service/Dockerfile, which is the +# contract this image has to preserve: +# COPY app.jar /usr/share/app.jar +# ENV ULIMIT_FLAG=1 +# ENV JDK_JAVA_OPTIONS="-XX:MaxRAMPercentage=40.0 ..." +# WORKDIR /home/app +# CMD ["java", "-jar", "/usr/share/app.jar"] +# +# ULIMIT_FLAG is load-bearing, not decoration: the base's shelless_ulimit shim +# reads it, so without it the shim runs and raises nothing. JDK_JAVA_OPTIONS +# carries the container heap sizing (MaxRAMPercentage), so dropping it silently +# changes the JVM's memory behavior in production. java_oci_image( name = "nvct-service-oss-image", jar = ":app", - jar_path = "/app.jar", -) - -nvct_library_test( - name = "tests", - coverage_library = ":app_classes", - srcs = NVCT_SERVICE_TEST_SRCS, - deps = [ - ":app_classes", - "//src/control-plane-services/cloud-tasks/nvct-core:nvct_core", - "//src/control-plane-services/cloud-tasks/nvct-core:nvct_core_test_fixtures", - "//src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test:nv_boot_mock_servers_test", - "@nv_third_party_deps//:io_opentelemetry_opentelemetry_sdk_testing", - "@nv_third_party_deps//:org_springframework_boot_spring_boot", - "@nv_third_party_deps//:org_springframework_boot_spring_boot_restclient", - "@nv_third_party_deps//:org_springframework_boot_spring_boot_resttestclient", - "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_webmvc_test", - "@nv_third_party_deps//:org_springframework_boot_spring_boot_web_server", - "@nv_third_party_deps//:org_springframework_spring_beans", - "@nv_third_party_deps//:org_springframework_spring_web", - "@nv_third_party_deps//:org_springframework_spring_webmvc", - "@nv_third_party_deps//:org_testcontainers_testcontainers", - "@nv_third_party_deps//:org_testcontainers_testcontainers_cassandra", - ], - data = [ - "//src/control-plane-services/cloud-tasks:integration_local_env", - ], - resources = NVCT_SERVICE_TEST_RESOURCES, - tags = [ - "exclusive", - "requires-docker", - ], - timeout = "long", + jar_path = "/usr/share/app.jar", + env = { + "ULIMIT_FLAG": "1", + "JDK_JAVA_OPTIONS": "-XX:MaxRAMPercentage=40.0 -XX:+EnableDynamicAgentLoading -Dreactor.netty.pool.maxIdleTime=30000 -Dreactor.netty.pool.maxConnections=500", + }, + workdir = "/home/app", ) -# Runtime-contract guard for the image: the jar path matches the entrypoint, -# the base image's shelless_ulimit shim survives (oci_image replaces rather -# than appends the base entrypoint), and java is invoked via the arch-stable -# /usr/bin/java rather than a per-arch JAVA_HOME path. Each of these otherwise -# fails only at container startup. -# -# sh_test is loaded explicitly: cloud-tasks builds in the root module on Bazel -# 9, which no longer provides sh_test as a built-in global. sh_test( name = "image_contract_test", srcs = ["image_contract_test.sh"], diff --git a/src/control-plane-services/cloud-tasks/nvct-service/image_contract_test.sh b/src/control-plane-services/cloud-tasks/nvct-service/image_contract_test.sh index 8b93bc9ce..6ab6a8432 100755 --- a/src/control-plane-services/cloud-tasks/nvct-service/image_contract_test.sh +++ b/src/control-plane-services/cloud-tasks/nvct-service/image_contract_test.sh @@ -5,7 +5,7 @@ # Asserts the runtime contract of the nvct-service image, which is otherwise # only observable by running the container: # -# 1. The jar is at /app.jar, the exact path the entrypoint names. If the +# 1. The jar is at /usr/share/app.jar, the exact path the entrypoint names. If the # layer path and the entrypoint ever disagree, the image builds fine and # fails at startup with "Unable to access jarfile". # 2. The entrypoint keeps the base image's shelless_ulimit shim. oci_image @@ -31,14 +31,14 @@ tar -xf "${image_tar}" -C "${outer_dir}" jar_found=false while IFS= read -r candidate; do tar -tf "${candidate}" >/dev/null 2>&1 || continue - if tar -tf "${candidate}" | grep -Eq '^(\./)?app\.jar$'; then + if tar -tf "${candidate}" | grep -Eq '^(\./)?usr/share/app\.jar$'; then jar_found=true break fi done < <(find "${outer_dir}" -type f) if [[ "${jar_found}" != "true" ]]; then - echo "no image layer contains /app.jar" >&2 + echo "no image layer contains /usr/share/app.jar" >&2 find "${outer_dir}" -type f -print >&2 exit 1 fi @@ -51,15 +51,33 @@ entrypoint_ok=false while IFS= read -r meta; do grep -q '"Entrypoint"' "${meta}" 2>/dev/null || continue if tr -d ' \n' < "${meta}" \ - | grep -q '"Entrypoint":\["/usr/bin/shelless_ulimit","/usr/bin/java","-jar","/app.jar"\]'; then + | grep -q '"Entrypoint":\["/usr/bin/shelless_ulimit","/usr/bin/java","-jar","/usr/share/app.jar"\]'; then entrypoint_ok=true break fi done < <(find "${outer_dir}" -type f) if [[ "${entrypoint_ok}" != "true" ]]; then - echo "image entrypoint is not [/usr/bin/shelless_ulimit /usr/bin/java -jar /app.jar]" >&2 + echo "image entrypoint is not [/usr/bin/shelless_ulimit /usr/bin/java -jar /usr/share/app.jar]" >&2 echo "entrypoints found in the image:" >&2 find "${outer_dir}" -type f -exec grep -ho '"Entrypoint":[^]]*]' {} \; 2>/dev/null >&2 || true exit 1 fi + +# 4. runtime parity with the pre-Bazel Dockerfile: ULIMIT_FLAG (which the +# shelless_ulimit shim reads; without it the shim raises nothing), +# JDK_JAVA_OPTIONS (container heap sizing), and the working directory. +for expected in 'ULIMIT_FLAG=1' 'MaxRAMPercentage=40.0' '"WorkingDir":"/home/app"'; do + found=false + while IFS= read -r meta; do + grep -q '"Entrypoint"' "${meta}" 2>/dev/null || continue + if tr -d ' \n' < "${meta}" | grep -qF "${expected}"; then + found=true + break + fi + done < <(find "${outer_dir}" -type f) + if [[ "${found}" != "true" ]]; then + echo "image config is missing expected runtime setting: ${expected}" >&2 + exit 1 + fi +done From 17c22ddcba28ed2526760f98499d340bac82fbf8 Mon Sep 17 00:00:00 2001 From: Balaji Ganesan Date: Fri, 24 Jul 2026 19:51:04 -0700 Subject: [PATCH 5/6] fix(cloud-tasks): restore the Java test target; harden the image test Three review findings. Restore nvct_library_test(name = "tests"). An earlier edit to this BUILD file replaced a span of text rather than inserting, and silently removed the Java test suite along with it. The image contract test does not substitute for it; both targets belong here. Re-added the image targets additively so this cannot recur. Avoid grep -q inside a pipeline. Under pipefail, grep -q closes the pipe on the first match, the producer takes SIGPIPE, and the pipeline reports failure, so a valid image could be rejected. The pipeline cases now consume the full stream. The remaining grep -q calls read a file directly rather than a pipe, so they carry no SIGPIPE risk and are unchanged. Check whiteouts. Finding the jar in some layer is necessary but not sufficient: a later layer can delete it with an OCI whiteout marker, leaving the merged filesystem without it. The test now also asserts no layer whites out the jar, its parent directories, or the tree via an opaque marker. Verified: contract test passes, and //...:tests resolves again. Co-authored-by: Balaji Ganesan --- .../cloud-tasks/nvct-service/BUILD.bazel | 43 +++++++++++++++++-- .../nvct-service/image_contract_test.sh | 22 ++++++++-- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/src/control-plane-services/cloud-tasks/nvct-service/BUILD.bazel b/src/control-plane-services/cloud-tasks/nvct-service/BUILD.bazel index 834ecf2c8..5d65f7364 100644 --- a/src/control-plane-services/cloud-tasks/nvct-service/BUILD.bazel +++ b/src/control-plane-services/cloud-tasks/nvct-service/BUILD.bazel @@ -1,6 +1,6 @@ load("//rules/java:defs.bzl", "nvct_library", "nvct_library_test") -load("//rules/java:spring.bzl", "spring_boot_app") load("@rules_shell//shell:sh_test.bzl", "sh_test") +load("//rules/java:spring.bzl", "spring_boot_app") load("//rules/oci:defs.bzl", "java_oci_image") package(default_visibility = ["//visibility:public"]) @@ -45,11 +45,43 @@ spring_boot_app( main_class = "com.nvidia.nvct.App", ) +nvct_library_test( + name = "tests", + coverage_library = ":app_classes", + srcs = NVCT_SERVICE_TEST_SRCS, + deps = [ + ":app_classes", + "//src/control-plane-services/cloud-tasks/nvct-core:nvct_core", + "//src/control-plane-services/cloud-tasks/nvct-core:nvct_core_test_fixtures", + "//src/libraries/java/nv-boot-parent/nv-boot-mock-servers-test:nv_boot_mock_servers_test", + "@nv_third_party_deps//:io_opentelemetry_opentelemetry_sdk_testing", + "@nv_third_party_deps//:org_springframework_boot_spring_boot", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_restclient", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_resttestclient", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_starter_webmvc_test", + "@nv_third_party_deps//:org_springframework_boot_spring_boot_web_server", + "@nv_third_party_deps//:org_springframework_spring_beans", + "@nv_third_party_deps//:org_springframework_spring_web", + "@nv_third_party_deps//:org_springframework_spring_webmvc", + "@nv_third_party_deps//:org_testcontainers_testcontainers", + "@nv_third_party_deps//:org_testcontainers_testcontainers_cassandra", + ], + data = [ + "//src/control-plane-services/cloud-tasks:integration_local_env", + ], + resources = NVCT_SERVICE_TEST_RESOURCES, + tags = [ + "exclusive", + "requires-docker", + ], + timeout = "long", +) + # Release image. Push the _index label (multi-arch amd64 + arm64), never # :nvct-service-oss-image, which is the single host-arch image. # # Runtime parity with the pre-Bazel nvct-service/Dockerfile, which is the -# contract this image has to preserve: +# contract this image must preserve: # COPY app.jar /usr/share/app.jar # ENV ULIMIT_FLAG=1 # ENV JDK_JAVA_OPTIONS="-XX:MaxRAMPercentage=40.0 ..." @@ -59,7 +91,7 @@ spring_boot_app( # ULIMIT_FLAG is load-bearing, not decoration: the base's shelless_ulimit shim # reads it, so without it the shim runs and raises nothing. JDK_JAVA_OPTIONS # carries the container heap sizing (MaxRAMPercentage), so dropping it silently -# changes the JVM's memory behavior in production. +# changes JVM memory behavior in production. java_oci_image( name = "nvct-service-oss-image", jar = ":app", @@ -71,6 +103,11 @@ java_oci_image( workdir = "/home/app", ) +# Runtime-contract guard for the image. Complements, and does not replace, +# :tests above (the Java suite). +# +# sh_test is loaded explicitly: cloud-tasks builds in the root module on Bazel +# 9, which no longer provides sh_test as a built-in global. sh_test( name = "image_contract_test", srcs = ["image_contract_test.sh"], diff --git a/src/control-plane-services/cloud-tasks/nvct-service/image_contract_test.sh b/src/control-plane-services/cloud-tasks/nvct-service/image_contract_test.sh index 6ab6a8432..74581c3c0 100755 --- a/src/control-plane-services/cloud-tasks/nvct-service/image_contract_test.sh +++ b/src/control-plane-services/cloud-tasks/nvct-service/image_contract_test.sh @@ -31,7 +31,9 @@ tar -xf "${image_tar}" -C "${outer_dir}" jar_found=false while IFS= read -r candidate; do tar -tf "${candidate}" >/dev/null 2>&1 || continue - if tar -tf "${candidate}" | grep -Eq '^(\./)?usr/share/app\.jar$'; then + # grep -q would close the pipe on first match, SIGPIPE tar, and under + # pipefail reject a valid image. Consume the whole stream. + if tar -tf "${candidate}" | grep -E '^(\./)?usr/share/app\.jar$' >/dev/null; then jar_found=true break fi @@ -43,6 +45,20 @@ if [[ "${jar_found}" != "true" ]]; then exit 1 fi +# Presence in a layer is necessary but not sufficient: a later layer could +# delete the file via an OCI whiteout marker, leaving the merged filesystem +# without it. Assert no layer whites out the jar or its directory. +while IFS= read -r candidate; do + tar -tf "${candidate}" >/dev/null 2>&1 || continue + if tar -tf "${candidate}" \ + | grep -E '^(\./)?usr/share/\.wh\.app\.jar$|^(\./)?usr/\.wh\.share$|^(\./)?\.wh\.usr$|\.wh\.\.wh\.opq$' \ + >/dev/null; then + echo "a layer whites out /usr/share/app.jar or a parent directory" >&2 + tar -tf "${candidate}" | grep '\.wh\.' >&2 || true + exit 1 + fi +done < <(find "${outer_dir}" -type f) + # 2 + 3. the entrypoint keeps the shim and uses the absolute java path. Scan # every regular file rather than parsing manifest.json, so the test needs no # JSON tool beyond coreutils. Note the config is a content-addressed blob under @@ -51,7 +67,7 @@ entrypoint_ok=false while IFS= read -r meta; do grep -q '"Entrypoint"' "${meta}" 2>/dev/null || continue if tr -d ' \n' < "${meta}" \ - | grep -q '"Entrypoint":\["/usr/bin/shelless_ulimit","/usr/bin/java","-jar","/usr/share/app.jar"\]'; then + | grep '"Entrypoint":\["/usr/bin/shelless_ulimit","/usr/bin/java","-jar","/usr/share/app.jar"\]' >/dev/null; then entrypoint_ok=true break fi @@ -71,7 +87,7 @@ for expected in 'ULIMIT_FLAG=1' 'MaxRAMPercentage=40.0' '"WorkingDir":"/home/app found=false while IFS= read -r meta; do grep -q '"Entrypoint"' "${meta}" 2>/dev/null || continue - if tr -d ' \n' < "${meta}" | grep -qF "${expected}"; then + if tr -d ' \n' < "${meta}" | grep -F "${expected}" >/dev/null; then found=true break fi From a4c13e6c0c3566b911e9df94f18a7785f8b9a07b Mon Sep 17 00:00:00 2001 From: Balaji Ganesan Date: Sat, 25 Jul 2026 09:01:06 -0700 Subject: [PATCH 6/6] test(cloud-tasks): assert against the image config blob only Two review findings on the contract test. Scanning every file in the archive for the expected Entrypoint/Env text meant a layer tar or bundled payload containing that text could satisfy the assertions even when the real image config was wrong. Resolve manifest.json to its "Config" blob and assert against that blob alone. Scope the opaque-whiteout check to the jar's ancestors. The previous pattern matched an opaque marker anywhere in any layer, so an unrelated directory could fail the contract. It now covers only /, /usr and /usr/share, plus the file whiteouts for the jar and its parents. The old pattern was also wrong: an OCI opaque marker is .wh..wh..opq and it was written .wh..wh.opq, so it would never have matched a real one. Verified: passes against the real image, and fails when given a wrong expected entrypoint. Co-authored-by: Balaji Ganesan --- .../nvct-service/image_contract_test.sh | 109 +++++++++++------- 1 file changed, 65 insertions(+), 44 deletions(-) diff --git a/src/control-plane-services/cloud-tasks/nvct-service/image_contract_test.sh b/src/control-plane-services/cloud-tasks/nvct-service/image_contract_test.sh index 74581c3c0..56861dbd3 100755 --- a/src/control-plane-services/cloud-tasks/nvct-service/image_contract_test.sh +++ b/src/control-plane-services/cloud-tasks/nvct-service/image_contract_test.sh @@ -5,15 +5,19 @@ # Asserts the runtime contract of the nvct-service image, which is otherwise # only observable by running the container: # -# 1. The jar is at /usr/share/app.jar, the exact path the entrypoint names. If the -# layer path and the entrypoint ever disagree, the image builds fine and -# fails at startup with "Unable to access jarfile". +# 1. The jar is at /usr/share/app.jar, the exact path the entrypoint names, +# and no layer whites it out. If the layer path and the entrypoint ever +# disagree the image builds fine and fails at startup with "Unable to +# access jarfile". # 2. The entrypoint keeps the base image's shelless_ulimit shim. oci_image # REPLACES the base entrypoint rather than appending, so dropping the shim # is silent and leaves the container on the default 1024 fd soft limit. # 3. Java is invoked through /usr/bin/java, not a JAVA_HOME-derived path. The # base sets JAVA_HOME per architecture, so an absolute JAVA_HOME path # would break the arm64 half of the image index. +# 4. Runtime parity with the pre-Bazel Dockerfile: ULIMIT_FLAG (which the +# shim reads; without it the shim raises nothing), JDK_JAVA_OPTIONS +# (container heap sizing), and the working directory. # # Unlike the Go services' image_entrypoint_mode_test this does not assert an # exec bit: a jar is read by the JVM, never executed, so mode 0644 is correct. @@ -27,72 +31,89 @@ trap 'rm -rf "${tmp_dir}"' EXIT tar -xf "${image_tar}" -C "${outer_dir}" -# 1. the jar is present at /app.jar in some layer +jar_path="usr/share/app.jar" + +# --------------------------------------------------------------------------- +# 1. the jar is installed at the path the entrypoint names +# --------------------------------------------------------------------------- +# grep -q would close the pipe on first match, SIGPIPE tar, and under pipefail +# reject a valid image. Consume the whole stream. jar_found=false while IFS= read -r candidate; do tar -tf "${candidate}" >/dev/null 2>&1 || continue - # grep -q would close the pipe on first match, SIGPIPE tar, and under - # pipefail reject a valid image. Consume the whole stream. - if tar -tf "${candidate}" | grep -E '^(\./)?usr/share/app\.jar$' >/dev/null; then + if tar -tf "${candidate}" | grep -E "^(\./)?${jar_path}$" >/dev/null; then jar_found=true break fi done < <(find "${outer_dir}" -type f) if [[ "${jar_found}" != "true" ]]; then - echo "no image layer contains /usr/share/app.jar" >&2 + echo "no image layer contains /${jar_path}" >&2 find "${outer_dir}" -type f -print >&2 exit 1 fi -# Presence in a layer is necessary but not sufficient: a later layer could -# delete the file via an OCI whiteout marker, leaving the merged filesystem -# without it. Assert no layer whites out the jar or its directory. +# Presence in a layer is necessary but not sufficient: a later layer can delete +# the file with a whiteout, leaving the merged filesystem without it. Check for +# a whiteout of the jar itself and for opaque whiteouts on its ancestor +# directories ONLY. An opaque marker on an unrelated directory does not affect +# this file and must not fail the test. +# file whiteout : /.wh. +# opaque whiteout : /.wh..wh..opq (empties that directory) +whiteout_re='^(\./)?usr/share/\.wh\.app\.jar$' +whiteout_re+='|^(\./)?usr/\.wh\.share$' +whiteout_re+='|^(\./)?\.wh\.usr$' +whiteout_re+='|^(\./)?\.wh\.\.wh\.\.opq$' +whiteout_re+='|^(\./)?usr/\.wh\.\.wh\.\.opq$' +whiteout_re+='|^(\./)?usr/share/\.wh\.\.wh\.\.opq$' + while IFS= read -r candidate; do tar -tf "${candidate}" >/dev/null 2>&1 || continue - if tar -tf "${candidate}" \ - | grep -E '^(\./)?usr/share/\.wh\.app\.jar$|^(\./)?usr/\.wh\.share$|^(\./)?\.wh\.usr$|\.wh\.\.wh\.opq$' \ - >/dev/null; then - echo "a layer whites out /usr/share/app.jar or a parent directory" >&2 - tar -tf "${candidate}" | grep '\.wh\.' >&2 || true + if tar -tf "${candidate}" | grep -E "${whiteout_re}" >/dev/null; then + echo "a layer whites out /${jar_path} or one of its parent directories" >&2 + tar -tf "${candidate}" | grep -E "${whiteout_re}" >&2 || true exit 1 fi done < <(find "${outer_dir}" -type f) -# 2 + 3. the entrypoint keeps the shim and uses the absolute java path. Scan -# every regular file rather than parsing manifest.json, so the test needs no -# JSON tool beyond coreutils. Note the config is a content-addressed blob under -# blobs/sha256/ with NO .json extension, so it cannot be found by suffix. -entrypoint_ok=false -while IFS= read -r meta; do - grep -q '"Entrypoint"' "${meta}" 2>/dev/null || continue - if tr -d ' \n' < "${meta}" \ - | grep '"Entrypoint":\["/usr/bin/shelless_ulimit","/usr/bin/java","-jar","/usr/share/app.jar"\]' >/dev/null; then - entrypoint_ok=true - break - fi -done < <(find "${outer_dir}" -type f) +# --------------------------------------------------------------------------- +# 2-4. assert against the image config blob, and only that blob +# --------------------------------------------------------------------------- +# Resolve manifest.json -> the "Config" blob rather than scanning every file in +# the archive. Scanning everything would let a layer tar or bundled payload that +# merely contains the expected text satisfy these assertions even when the real +# image config is wrong. +manifest="${outer_dir}/manifest.json" +if [[ ! -f "${manifest}" ]]; then + echo "archive has no manifest.json; cannot resolve the image config" >&2 + exit 1 +fi + +config_rel="$(tr -d ' \n' < "${manifest}" \ + | sed -n 's/.*"Config":"\([^"]*\)".*/\1/p' | head -1)" +if [[ -z "${config_rel}" ]]; then + echo "could not read the Config entry from manifest.json" >&2 + cat "${manifest}" >&2 + exit 1 +fi + +config="${outer_dir}/${config_rel}" +if [[ ! -f "${config}" ]]; then + echo "manifest.json points at a missing config blob: ${config_rel}" >&2 + exit 1 +fi + +config_flat="$(tr -d ' \n' < "${config}")" -if [[ "${entrypoint_ok}" != "true" ]]; then +expected_entrypoint='"Entrypoint":["/usr/bin/shelless_ulimit","/usr/bin/java","-jar","/usr/share/app.jar"]' +if ! printf '%s' "${config_flat}" | grep -F "${expected_entrypoint}" >/dev/null; then echo "image entrypoint is not [/usr/bin/shelless_ulimit /usr/bin/java -jar /usr/share/app.jar]" >&2 - echo "entrypoints found in the image:" >&2 - find "${outer_dir}" -type f -exec grep -ho '"Entrypoint":[^]]*]' {} \; 2>/dev/null >&2 || true + printf '%s' "${config_flat}" | grep -o '"Entrypoint":[^]]*]' >&2 || true exit 1 fi -# 4. runtime parity with the pre-Bazel Dockerfile: ULIMIT_FLAG (which the -# shelless_ulimit shim reads; without it the shim raises nothing), -# JDK_JAVA_OPTIONS (container heap sizing), and the working directory. for expected in 'ULIMIT_FLAG=1' 'MaxRAMPercentage=40.0' '"WorkingDir":"/home/app"'; do - found=false - while IFS= read -r meta; do - grep -q '"Entrypoint"' "${meta}" 2>/dev/null || continue - if tr -d ' \n' < "${meta}" | grep -F "${expected}" >/dev/null; then - found=true - break - fi - done < <(find "${outer_dir}" -type f) - if [[ "${found}" != "true" ]]; then + if ! printf '%s' "${config_flat}" | grep -F "${expected}" >/dev/null; then echo "image config is missing expected runtime setting: ${expected}" >&2 exit 1 fi