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/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", 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/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 new file mode 100644 index 000000000..459ece804 --- /dev/null +++ b/rules/oci/private/java.bzl @@ -0,0 +1,161 @@ +# 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", "pkg_attributes", "pkg_files") +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, java_bin, entrypoint, jvm_flags, env, workdir, registry, tags): + layer_name = name + "_layer" + files_name = name + "_files" + + 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 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 = [files_name], + 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, + env = env, + workdir = workdir, + 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. 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 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, + ), + "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..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,5 +1,7 @@ load("//rules/java:defs.bzl", "nvct_library", "nvct_library_test") +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"]) @@ -74,3 +76,41 @@ nvct_library_test( ], 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 must 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 JVM memory behavior in production. +java_oci_image( + name = "nvct-service-oss-image", + jar = ":app", + 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. 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"], + 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..56861dbd3 --- /dev/null +++ b/src/control-plane-services/cloud-tasks/nvct-service/image_contract_test.sh @@ -0,0 +1,120 @@ +#!/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 /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. +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}" + +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 + 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 /${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 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 "${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-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}")" + +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 + printf '%s' "${config_flat}" | grep -o '"Entrypoint":[^]]*]' >&2 || true + exit 1 +fi + +for expected in 'ULIMIT_FLAG=1' 'MaxRAMPercentage=40.0' '"WorkingDir":"/home/app"'; do + if ! printf '%s' "${config_flat}" | grep -F "${expected}" >/dev/null; then + echo "image config is missing expected runtime setting: ${expected}" >&2 + exit 1 + fi +done