From acef850f0f1f638016c2f0ec276c05e7a07d6ce8 Mon Sep 17 00:00:00 2001 From: Paul Johnston Date: Mon, 20 Jul 2026 16:04:36 -0600 Subject: [PATCH] feat: preserve JVM data runfiles in container layers - collect non-JAR runfiles from the JVM binary automatically - support additional files and targets through the data attribute - emit runfiles in a dedicated cacheable data layer under /app - exclude runtime JARs, launchers, and host JDK files - reject unsafe paths, directory symlinks, and destination collisions - document JAVA_RUNFILES configuration and data-layer behavior - add unit and example coverage --- README.md | 13 +++ cmd/jar_layerer/README.md | 4 + cmd/jar_layerer/main.go | 24 +++++ example/hello/BUILD.bazel | 3 + example/hello/greeting.txt | 1 + example/hello/runtime/native.marker | 1 + jvm_jar_layers.bzl | 73 +++++++++++++++ pkg/jarlayer/jarlayer.go | 132 ++++++++++++++++++++++++++++ pkg/jarlayer/jarlayer_test.go | 67 ++++++++++++++ 9 files changed, 318 insertions(+) create mode 100644 example/hello/greeting.txt create mode 100644 example/hello/runtime/native.marker diff --git a/README.md b/README.md index c0cf6bd..cd838b3 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ load("@jvm_image//:jvm_jar_layers.bzl", "jvm_jar_layers") jvm_jar_layers( name = "server_layers", binary = ":server", + data = ["@fincad//:libraries"], ) ``` @@ -67,16 +68,28 @@ entrypoint = [ "@/app/lib/classpath", "com.example.Server", ] +env = {"JAVA_RUNFILES": "/app"} ``` The generated `classpath` file is included in the fallback tar. It is also available through the target's `classpath` output group. The `binary` must provide a non-empty JVM runtime classpath; analysis fails otherwise. +Non-JAR files in the binary's `data` runfiles are included automatically. +Additional `data` targets are collected explicitly and written to a dedicated +runfiles layer under `/app`. Workspace files use +`/app///`; external files use +`/app///`. Set `JAVA_RUNFILES=/app` when +the application uses Bazel's runfiles lookup conventions. Host JDK files, +binary launchers, and runtime JARs are excluded from this data layer. + ## Configuration notes - `maven_lock_file` is optional. When omitted, every runtime JAR goes to the fallback tar. +- `data` is optional. It accepts files and targets and includes their transitive + runfiles without adding them to the JVM classpath. Destination collisions + fail the build instead of silently overwriting a file. - `max_layers` limits Maven-derived tar layers. It excludes the fallback tar and has no effect without `maven_lock_file`. Set it to `0` to disable Maven-derived layers. diff --git a/cmd/jar_layerer/README.md b/cmd/jar_layerer/README.md index 3097552..0e38db0 100644 --- a/cmd/jar_layerer/README.md +++ b/cmd/jar_layerer/README.md @@ -108,6 +108,8 @@ The rule: - Uses `_maven_artifacts_aspect` and `_MavenArtifactsInfo` to collect Maven artifact IDs from `maven_coordinates=` tags. - Writes a jar list file and passes it to `jar_layerer`. +- Writes non-JAR binary runfiles and explicit `data` targets to a separate + runfiles layer rooted at `app/`, excluding the host JDK and binary launcher. - Declares per-artifact (or per-group) output tars. - Outputs all tars via `DefaultInfo` and the classpath file via `OutputGroupInfo`. @@ -123,5 +125,7 @@ The rule: | `--path_prefix` | Prefix prepended to tar entry paths (default `app/lib/`) | | `--artifact_layer` | `ARTIFACT_ID=path.tar` — one artifact per layer (repeatable) | | `--artifact_group_layer` | `ID1,ID2,...=path.tar` — multiple artifacts sharing a layer (repeatable) | +| `--data_manifest` | JSON list mapping data source paths to runfiles destinations | +| `--data_layer` | Output tar for files from `--data_manifest` | Positional arguments are also accepted as additional JAR paths. diff --git a/cmd/jar_layerer/main.go b/cmd/jar_layerer/main.go index d7b3880..496affc 100644 --- a/cmd/jar_layerer/main.go +++ b/cmd/jar_layerer/main.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "flag" "fmt" "os" @@ -24,6 +25,8 @@ func main() { appPrefix := flag.String("app_prefix", "/app/lib", "classpath prefix in the container") pathPrefix := flag.String("path_prefix", "app/lib/", "prefix prepended to tar entry paths") jarList := flag.String("jar_list", "", "path to file listing JAR paths, one per line") + dataManifest := flag.String("data_manifest", "", "path to a JSON data-file manifest") + dataLayer := flag.String("data_layer", "", "path to the data runfiles output tar") var artifactLayers repeatedFlag flag.Var(&artifactLayers, "artifact_layer", "ARTIFACT_ID=path.tar (repeatable)") @@ -100,4 +103,25 @@ func main() { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } + + if (*dataManifest == "") != (*dataLayer == "") { + fmt.Fprintln(os.Stderr, "--data_manifest and --data_layer must be set together") + os.Exit(1) + } + if *dataManifest != "" { + data, err := os.ReadFile(*dataManifest) + if err != nil { + fmt.Fprintf(os.Stderr, "reading data manifest: %v\n", err) + os.Exit(1) + } + var files []jarlayer.DataFile + if err := json.Unmarshal(data, &files); err != nil { + fmt.Fprintf(os.Stderr, "parsing data manifest: %v\n", err) + os.Exit(1) + } + if err := jarlayer.LayerData(*dataLayer, files); err != nil { + fmt.Fprintf(os.Stderr, "layering data: %v\n", err) + os.Exit(1) + } + } } diff --git a/example/hello/BUILD.bazel b/example/hello/BUILD.bazel index 487a12d..4841670 100644 --- a/example/hello/BUILD.bazel +++ b/example/hello/BUILD.bazel @@ -6,6 +6,7 @@ load("@rules_java//java:java_binary.bzl", "java_binary") java_binary( name = "hello", srcs = ["Main.java"], + data = ["greeting.txt"], main_class = "example.hello.Main", deps = ["@maven2//:com_google_guava_guava"], ) @@ -13,6 +14,7 @@ java_binary( jvm_jar_layers( name = "layers", binary = ":hello", + data = ["runtime/native.marker"], maven_lock_file = "maven2_install.json", ) @@ -25,6 +27,7 @@ image_manifest( "@/app/lib/classpath", "example.hello.Main", ], + env = {"JAVA_RUNFILES": "/app"}, layers = [":layers"], platform = "@rules_img//img/private/platforms:linux_amd64", stamp = "auto", diff --git a/example/hello/greeting.txt b/example/hello/greeting.txt new file mode 100644 index 0000000..cf95237 --- /dev/null +++ b/example/hello/greeting.txt @@ -0,0 +1 @@ +Hello from a binary data runfile. diff --git a/example/hello/runtime/native.marker b/example/hello/runtime/native.marker new file mode 100644 index 0000000..eaa5230 --- /dev/null +++ b/example/hello/runtime/native.marker @@ -0,0 +1 @@ +Example explicit runtime data file. diff --git a/jvm_jar_layers.bzl b/jvm_jar_layers.bzl index 9e5e272..f4b4317 100644 --- a/jvm_jar_layers.bzl +++ b/jvm_jar_layers.bzl @@ -65,6 +65,39 @@ def _runtime_jars(binary): return binary[java_common.JavaRuntimeClasspathInfo].runtime_classpath.to_list() return binary[JavaInfo].transitive_runtime_jars.to_list() +def _runfiles_archive_path(file, workspace_name): + """Return a Bazel-compatible runfiles path rooted below app/.""" + short_path = file.short_path + if short_path.startswith("../"): + relative_path = short_path[3:] + elif short_path.startswith("external/"): + relative_path = short_path[len("external/"):] + else: + relative_path = workspace_name + "/" + short_path + if not relative_path or ".." in relative_path.split("/"): + fail("data file %s has unsafe runfiles path %r" % (file.path, relative_path)) + return "app/" + relative_path + +def _data_files(ctx, runtime_jars): + """Collect non-JDK, non-classpath runfiles from the binary and data attr.""" + binary_info = ctx.attr.binary[DefaultInfo] + transitive = [binary_info.data_runfiles.files] + for target in ctx.attr.data: + info = target[DefaultInfo] + transitive.append(info.files) + transitive.append(info.default_runfiles.files) + transitive.append(info.data_runfiles.files) + + excluded = { + file.path: True + for file in runtime_jars + binary_info.files.to_list() + ctx.files._jdk + } + return [ + file + for file in depset(transitive = transitive).to_list() + if file.path not in excluded + ] + def _group_key(artifact_id, depth): """Extract a grouping key from an artifact ID at the given depth. @@ -124,6 +157,7 @@ def _group_artifacts(artifact_ids, max_groups): def jvm_jar_layers( name, binary, + data = [], maven_lock_file = None, max_layers = 121, layer_strategy = "group_by_prefix", @@ -141,6 +175,9 @@ def jvm_jar_layers( Args: name: target name binary: label of a java_binary or scala_binary target + data: optional files or targets whose files and runfiles are added under + /app using Bazel's runfiles layout. The binary's own non-JAR data + runfiles are included automatically. maven_lock_file: optional label of a rules_jvm_external lock file JSON for Maven artifact-based layer grouping. When omitted, all runtime JARs are written to the fallback tar. @@ -155,6 +192,7 @@ def jvm_jar_layers( _jvm_jar_layers( name = name, binary = binary, + data = data, maven_lock_file = maven_lock_file, max_layers = max_layers, layer_strategy = layer_strategy, @@ -182,6 +220,33 @@ def _jvm_jar_layers_impl(ctx): args.add("--app_prefix", ctx.attr.app_prefix) args.add("--path_prefix", ctx.attr.path_prefix) + data_files = _data_files(ctx, runtime_jars) + if data_files: + data_by_destination = {} + for file in data_files: + destination = _runfiles_archive_path(file, ctx.workspace_name) + previous = data_by_destination.get(destination) + if previous and previous.path != file.path: + fail("data files %s and %s collide at /%s" % (previous.path, file.path, destination)) + data_by_destination[destination] = file + + data_manifest = ctx.actions.declare_file(ctx.label.name + "_data.json") + manifest_entries = [ + { + "destination": destination, + "source": data_by_destination[destination].path, + } + for destination in sorted(data_by_destination.keys()) + ] + ctx.actions.write(data_manifest, json.encode(manifest_entries)) + inputs.extend(data_by_destination.values()) + inputs.append(data_manifest) + args.add("--data_manifest", data_manifest) + + data_layer = ctx.actions.declare_file(ctx.label.name + ".data.tar") + args.add("--data_layer", data_layer) + tar_outputs.append(data_layer) + # Classpath file (not a tar — kept separate from tar outputs). classpath_file = ctx.actions.declare_file(ctx.label.name + "_classpath") args.add("--classpath", classpath_file) @@ -250,6 +315,10 @@ _jvm_jar_layers = rule( aspects = [_maven_artifacts_aspect], doc = "The java_binary or scala_binary target.", ), + "data": attr.label_list( + allow_files = True, + doc = "Additional files and runfiles to place under /app using Bazel's runfiles layout.", + ), "maven_lock_file": attr.label( allow_single_file = [".json"], doc = "Maven lock file JSON for artifact-based layer grouping.", @@ -277,5 +346,9 @@ _jvm_jar_layers = rule( cfg = "exec", doc = "The jar_layerer Go binary.", ), + "_jdk": attr.label( + default = "@bazel_tools//tools/jdk:current_java_runtime", + providers = [java_common.JavaRuntimeInfo], + ), }, ) diff --git a/pkg/jarlayer/jarlayer.go b/pkg/jarlayer/jarlayer.go index 2e56bcd..bdab41a 100644 --- a/pkg/jarlayer/jarlayer.go +++ b/pkg/jarlayer/jarlayer.go @@ -10,6 +10,7 @@ import ( "io" "os" "path" + "path/filepath" "sort" "strings" ) @@ -39,6 +40,12 @@ type ArtifactLayer struct { OutputPath string } +// DataFile maps an input file or directory to a path in a data layer tar. +type DataFile struct { + Source string `json:"source"` + Destination string `json:"destination"` +} + // MavenLockFile represents the relevant parts of the maven lock file JSON. type MavenLockFile struct { Packages map[string][]string `json:"packages"` @@ -183,6 +190,131 @@ func LayerJars(opts LayerOptions) (retErr error) { return nil } +// LayerData writes files into a deterministic tar using their Bazel runfiles +// paths. Directories are expanded recursively and symlinks are rejected. +func LayerData(outputPath string, files []DataFile) (retErr error) { + if outputPath == "" { + return errors.New("data layer output path is required") + } + + lw, err := newLayerWriter(outputPath) + if err != nil { + return fmt.Errorf("creating data layer: %w", err) + } + defer func() { + if closeErr := lw.Close(); closeErr != nil { + retErr = errors.Join(retErr, fmt.Errorf("closing data layer: %w", closeErr)) + } + }() + + orderedFiles := append([]DataFile(nil), files...) + sort.Slice(orderedFiles, func(i, j int) bool { + if orderedFiles[i].Destination == orderedFiles[j].Destination { + return orderedFiles[i].Source < orderedFiles[j].Source + } + return orderedFiles[i].Destination < orderedFiles[j].Destination + }) + written := make(map[string]string) + for _, file := range orderedFiles { + if err := writeDataPath(lw.tw, file.Source, file.Destination, written); err != nil { + return err + } + } + return nil +} + +func writeDataPath(tw *tar.Writer, source, destination string, written map[string]string) error { + if source == "" { + return errors.New("data source path is empty") + } + if err := validateArchivePath(destination); err != nil { + return fmt.Errorf("invalid data destination %q: %w", destination, err) + } + + linkInfo, err := os.Lstat(source) + if err != nil { + return fmt.Errorf("stating data source %s: %w", source, err) + } + info, err := os.Stat(source) + if err != nil { + return fmt.Errorf("resolving data source %s: %w", source, err) + } + if info.IsDir() { + walkRoot := source + if linkInfo.Mode()&os.ModeSymlink != 0 { + walkRoot, err = filepath.EvalSymlinks(source) + if err != nil { + return fmt.Errorf("resolving data directory %s: %w", source, err) + } + } + return filepath.WalkDir(walkRoot, func(child string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if child == walkRoot { + return nil + } + if entry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("data directory %s contains symlink %s", source, child) + } + if entry.IsDir() { + return nil + } + relative, err := filepath.Rel(walkRoot, child) + if err != nil { + return err + } + return writeDataPath(tw, child, path.Join(destination, filepath.ToSlash(relative)), written) + }) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("data source %s is not a regular file or directory", source) + } + if previous, ok := written[destination]; ok { + if previous == source { + return nil + } + return fmt.Errorf("data sources %s and %s collide at %s", previous, source, destination) + } + written[destination] = source + + f, err := os.Open(source) + if err != nil { + return fmt.Errorf("opening data source %s: %w", source, err) + } + defer f.Close() + + mode := int64(0644) + if info.Mode().Perm()&0111 != 0 { + mode = 0755 + } + if err := tw.WriteHeader(&tar.Header{ + Typeflag: tar.TypeReg, + Name: destination, + Size: info.Size(), + Mode: mode, + }); err != nil { + return fmt.Errorf("writing data header %s: %w", destination, err) + } + if _, err := io.Copy(tw, f); err != nil { + return fmt.Errorf("writing data file %s: %w", destination, err) + } + return nil +} + +func validateArchivePath(name string) error { + if name == "" || path.IsAbs(name) { + return errors.New("path must be non-empty and relative") + } + if strings.ContainsRune(name, '\x00') || strings.ContainsAny(name, "\\\r\n") { + return errors.New("path contains an invalid character") + } + if name != path.Clean(name) || hasParentPathSegment(name) { + return errors.New("path must be clean and must not escape the archive root") + } + return nil +} + // buildPackageMap parses the lock file and returns a mapping from Java package // prefix (e.g., "com/google/common/collect/") to artifact ID. Split packages // map to the empty string so they cannot cause nondeterministic routing. diff --git a/pkg/jarlayer/jarlayer_test.go b/pkg/jarlayer/jarlayer_test.go index 8cf4714..a1f121b 100644 --- a/pkg/jarlayer/jarlayer_test.go +++ b/pkg/jarlayer/jarlayer_test.go @@ -555,3 +555,70 @@ func TestLayerJars_RejectsUnsafeAppPrefix(t *testing.T) { t.Fatalf("LayerJars() error = %v, want invalid app prefix error", err) } } + +func TestLayerData_WritesFilesAndDirectories(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.txt") + if err := os.WriteFile(configPath, []byte("config"), 0644); err != nil { + t.Fatal(err) + } + nativeDir := filepath.Join(dir, "native") + if err := os.MkdirAll(filepath.Join(nativeDir, "lib"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(nativeDir, "lib", "libexample.so"), []byte("native"), 0755); err != nil { + t.Fatal(err) + } + + outputPath := filepath.Join(dir, "data.tar") + if err := LayerData(outputPath, []DataFile{ + {Source: nativeDir, Destination: "app/fincad/libraries"}, + {Source: configPath, Destination: "app/service/config.txt"}, + }); err != nil { + t.Fatal(err) + } + + entries := readTar(t, outputPath) + if got := entries["app/service/config.txt"]; got != "config" { + t.Fatalf("config contents = %q, want config", got) + } + if got := entries["app/fincad/libraries/lib/libexample.so"]; got != "native" { + t.Fatalf("native library contents = %q, want native", got) + } +} + +func TestLayerData_RejectsCollisions(t *testing.T) { + dir := t.TempDir() + first := filepath.Join(dir, "first") + second := filepath.Join(dir, "second") + if err := os.WriteFile(first, []byte("first"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(second, []byte("second"), 0644); err != nil { + t.Fatal(err) + } + + err := LayerData(filepath.Join(dir, "data.tar"), []DataFile{ + {Source: first, Destination: "app/service/data"}, + {Source: second, Destination: "app/service/data"}, + }) + if err == nil || !strings.Contains(err.Error(), "collide") { + t.Fatalf("LayerData() error = %v, want collision error", err) + } +} + +func TestLayerData_RejectsUnsafeDestination(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "input") + if err := os.WriteFile(input, []byte("data"), 0644); err != nil { + t.Fatal(err) + } + + err := LayerData(filepath.Join(dir, "data.tar"), []DataFile{{ + Source: input, + Destination: "../etc/passwd", + }}) + if err == nil || !strings.Contains(err.Error(), "invalid data destination") { + t.Fatalf("LayerData() error = %v, want unsafe destination error", err) + } +}