Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
)
```

Expand All @@ -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/<workspace>/<package>/<file>`; external files use
`/app/<canonical-repository>/<package>/<file>`. 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.
Expand Down
4 changes: 4 additions & 0 deletions cmd/jar_layerer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand All @@ -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.
24 changes: 24 additions & 0 deletions cmd/jar_layerer/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"encoding/json"
"flag"
"fmt"
"os"
Expand All @@ -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)")
Expand Down Expand Up @@ -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)
}
}
}
3 changes: 3 additions & 0 deletions example/hello/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ 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"],
)

jvm_jar_layers(
name = "layers",
binary = ":hello",
data = ["runtime/native.marker"],
maven_lock_file = "maven2_install.json",
)

Expand All @@ -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",
Expand Down
1 change: 1 addition & 0 deletions example/hello/greeting.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hello from a binary data runfile.
1 change: 1 addition & 0 deletions example/hello/runtime/native.marker
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Example explicit runtime data file.
73 changes: 73 additions & 0 deletions jvm_jar_layers.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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",
Expand All @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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],
),
},
)
132 changes: 132 additions & 0 deletions pkg/jarlayer/jarlayer.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"io"
"os"
"path"
"path/filepath"
"sort"
"strings"
)
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading