Build performance & isolation
Two engine capabilities that need no change to your project: isolation puts the whole build - or just the program it launches - inside a throwaway container, so untrusted code cannot reach your host; and the build cache hands a step the output of an earlier build instead of re-running it. Both switch on from the command line, so any project gains them for free.
Why isolate a build
A build runs untrusted code even when you customise nothing. The stock pipeline compiles and runs your
tests - and everything the test dependencies drag in - and the artifact it produces has a main that
runs later, all with the full rights of whoever started the build. A single compromised dependency (a malicious
release, a hijacked account, a typo-squatted coordinate) executes with those same rights.
Pinning guarantees you get the exact bytes you vetted rather than a silently swapped artifact - but it guarantees what runs, not that what runs is safe. Docker addresses the other half: it confines what that code can reach when it executes, so even a malicious dependency cannot read your host secrets or write outside the sandbox.
jenesis
(see Getting started). The Docker flags below confine the remaining
untrusted code: the dependencies, the tests, the artifact's main, and the plugins the project
names (see Extending the build), which every Jenesis that builds the
project runs, the installed jenesis included.
Running the build in a container
Set -Djenesis. to run the entire build inside a throwaway container instead of directly on
the host JVM:
java -Djenesis.project.docker=true build/jenesis/Make.java
A minimal image is built on demand the first time and cached for later runs. Inside the container neither
your home directory nor the host environment is present, so a test or dependency that reaches for
~/ or a CI secret finds nothing.
To target a different image, add -Djenesis.. The implicit image runs with
--cap-drop ALL and --security-opt no-new-privileges; a named image is run as you named it, without those
two flags, so harden it in the image itself if you swap it.
What runs on the host
All of the project's code runs inside the container. On the host, Jenesis only reads the settings and starts
the container, handing it every jenesis. setting in force - from the command line, jenesis. or a
profile alike - except the jenesis. settings that start it. It runs no test there and none of the
plugins the project names until the container is up and the isolation is in place.
The project also cannot undo the isolation. Its jenesis. and its profiles are refused if they set any
jenesis. or jenesis. key, so a project can neither switch Docker off nor widen
the container with a mount or an environment variable. Only your command line or your own
~/ configures Docker.
jenesis.project.docker=true in your own
~/.jenesis/jenesis.properties. No project can switch it off.
Building a project you do not trust
Building an untrusted project takes two steps. First, check with the installed Jenesis that the vendored engine is the released one (see Getting started):
jenesis-validate
Then build the project inside the container, where its tests and its plugins run only after the isolation is in place:
java -Djenesis.project.docker=true build/jenesis/Make.java
Once you have vetted the project and it pins its dependencies, you can trust it to build on the host. Build it there with strict pinning, so any dependency that lacks a pinned checksum fails the build instead of running:
java -Djenesis.dependency.pin=strict build/jenesis/Make.java
What is mounted automatically
Every location the project is configured with is represented inside the container, almost all of them at their host path so that paths resolve identically:
- the project root - writable;
- the JDK - read-only, at
/, so the same Java runs inside;opt/ java-home - the local Maven and module repositories (
~/,. m2 ~/) - read-only, with. jenesis MAVEN_/REPOSITORY_ LOCAL JENESIS_forwarded so the in-container JVM finds them despite its different home;REPOSITORY_ LOCAL - out-of-root
target/artifactslocations - writable; - out-of-root configuration, BOM, and
jenesis.folders - read-only;project. metadata - an out-of-root
jenesis.orproject. cache file:// jenesis.cache - writable, created on the host first so it is not left root-owned.cache. uri
Anything else the build needs from outside the root is invisible inside the container.
Adding mounts and environment
A build/ symlinked to a shared engine checkout, a sibling source tree, or a generated-sources directory
lives outside the root and so is not present. Add such paths with
-Djenesis.:
- a bare
hostis mounted at the same path inside the container (host:host) - what a symlink or absolute reference needs to resolve;host:containerremaps it instead; - these mounts are read-only - the build should not write outside its own tree;
- relative host paths resolve against the project root, and several mounts are comma-separated.
For the rare case that the build must write to a host path outside the project root, use
-Djenesis.. Reach for it sparingly: every writable mount
is a hole in the confinement.
By default no host environment is forwarded into the container. Pass selected variables with
-Djenesis.: a bare name forwards the host's current value, while
name= sets it explicitly. This is the channel for a build input that legitimately lives in the
environment - a private-repository token, a proxy setting - and is opt-in so ambient host secrets do not leak in
by default.
~/.m2, ~/.jenesis) are mounted
read-only. So dependencies must already be cached - warm the cache with a host build first -
and export fails with an AccessDeniedException, since publishing writes into those
repositories. Staging works inside the container (stage only writes under target/);
run export on the host.
Running the launched program in a container
Isolating the build does not isolate the program it produces, whose main runs later with the same host rights.
Execute. (see Building & running) can launch that program inside a
container too, independently of whether the build itself was dockerised:
java -Djenesis.execute.docker=true build/jenesis/Execute.java
The container does not receive the host environment and its home is not the host's, so the artifact runs but the
secrets are out of reach. -Djenesis. overrides the image, and
-Djenesis. (read-only), -Djenesis. (read-write), and
-Djenesis. behave exactly like their jenesis.
counterparts. Because the build runs as usual and only the launch crosses the container boundary, the build
image and the runtime image can differ.
jenesis.print.docker is on by default and prints the image the JVM is wrapped in; set it
false to suppress.
The build cache
Every build already has an incremental cache: Jenesis content-hashes each step's inputs and outputs under
target/, so a warm rebuild only re-runs the steps whose inputs changed (see
Core concepts). The build cache adds a second tier outside target/ that can
hand a step the output of an earlier build - a different checkout, machine, or CI job - instead of re-running it
at all. It lives in two places that compose: a project-local folder and a shared location you name.
A project-local cache
The simplest form needs only a flag. Jenesis keeps a content-addressed cache under ., rooted at
the project root:
java -Djenesis.project.cache build/jenesis/Make.java
The value is a filesystem path (never a URI): an empty value, as above, resolves to . under
the project root, and a value relocates it. Each entry lives at .,
where the step hash identifies the step by its serialised form and the inputs hash folds every input
file's content hash. On a miss the build runs the step and stores the result. On a hit it materialises the
cached output - hard-linked, so near free - and the step body never runs. Because it sits outside target/,
it survives a target/ wipe.
That survival is the point. -Djenesis. deletes target/ first, so the incremental
cache is gone and every step is a forced miss that would normally re-run from scratch. The build cache
serves them anyway:
java -Djenesis.project.cache \
-Djenesis.executor.rebuild=true \
build/jenesis/Make.java
The steps still print [EXECUTED] - their output was produced - but it came from the cache, not from javac,
so each returns almost instantly. On a real module a compile that took seconds returns at once. Add
-Djenesis. to make it explicit: each step served from the cache prints a [LOADED] line and each
written to it a [STORED] line. Delete . to start over.
A shared cache
. is private to one checkout. To share results across checkouts, machines, or CI, name an
explicit location with -Djenesis.. The value is a URI:
-Djenesis.cache.uri=https://cache.example.com # a cache server
-Djenesis.cache.uri=file:///mnt/team/jenesis-cache # a shared (or local) folder
A file:/ URI resolves the same on-disk format as the local cache. An http(s):/ URL selects an HTTP
backend that GETs and PUTs the same entries to a cache server, naming the project with
-Djenesis. and authenticating with -Djenesis.. Both are sent as
headers, never in the URL, and both fall back to the JENESIS_ / JENESIS_
environment variables. -Djenesis. and -Djenesis. set the HTTP timeouts as ISO-8601
durations (PT1S and PT10S by default). A non-URI value is rejected; use file:/ for an on-disk
location.
Jenesis Repository serves this protocol, and is the reference implementation of a cache server for it: projects, keys granted per project, and size limits, on the same server as your artifacts.
The shared cache can be used two ways:
- As a replacement - the shared cache only, no local tier. Fitting for an ephemeral CI runner whose disk is
thrown away anyway: pass
jenesis.alone.cache. uri - Layered behind the local cache - set both
-Djenesis.andproject. cache -Djenesis.. Every read triescache. uri= . . . .first and falls through to the shared cache only on a miss. A shared hit is copied into the local cache on the way past, so the next read is local, and a store writes through to both.jenesis/ cache
java -Djenesis.project.cache \
-Djenesis.cache.uri=https://cache.example.com \
-Djenesis.cache.project=acme -Djenesis.cache.key=alice \
build/jenesis/Make.java
GET reaches the server, which would let that shared
entry age toward eviction even while in active use. So a local hit also sends the server a best-effort
HEAD, never the body, and the server treats it as a read and bumps the entry's recency. Each
tier keeps its own LRU and both stay warm.
A step can hold itself back from the shared tier: a custom step that overrides shouldCacheRemotely() to
return false is never fetched from, stored in or announced to a cache server, and is still cached locally.
Use it for a step whose output is large against what it costs to produce, or is re-derivable from what the
machine already has, so the shared cache carries the results that are worth downloading. See
extending the build.
Tuning with cache.properties
Drop an optional cache. at the cache root - . for the project-local
cache, or <folder>/ for a file-system shared one - to tune the writes. Every key has a default,
so the file may be omitted entirely:
| key | default | effect |
|---|---|---|
digest |
SHA-256 |
algorithm folding the inputs into the entry-folder name |
steps |
250 |
maximum number of step folders kept |
versions |
10 |
maximum input-variants kept per step |
size |
unset | maximum total bytes; over it, whole entries are evicted by lru until under (unset = no cap) |
lru |
true |
evict the least-recently-updated entry when over a limit (false = most-recently) |
touch |
true |
bump an entry's timestamp on read, so reads keep hot entries alive |
ttl |
unset | ISO-8601 duration (e.g. P30D); entries not touched within it are evicted on a background sweep |
compressed |
false |
store each entry as a single zip file rather than a folder of files |
read |
true |
serve cache reads; read= makes every lookup a miss |
write |
true |
populate the cache; write= serves reads but never writes, evicts, or touches |
Eviction runs on write and goes by file timestamp, which touch keeps fresh on every read - so the three caps
(steps, versions, size) approximate an LRU, and ttl adds an age dimension on top of them.
read and write are the typical CI split: a privileged job builds with the defaults
(both true) to populate the cache, while everyone else sets
write=false to consume it read-only without mutating it. Setting both
false turns the cache off entirely.