Skip to content

Commit 50bc2b6

Browse files
committed
build: implement --builder host (skip docker, use ./gradlew)
Adds realHostRunner: runs the source tree's ./gradlew wrapper natively on the host, no eclipse-temurin container. Selected by `trond build --builder host` (or `build.builder: host` in intent); previously stubbed with "not implemented in Phase 1". Key design points: - Builder identity: hashes `java -version` output as BuilderImageDigest so the cache invalidates when the host's JDK changes. The ref shows up as `host:openjdk version "..."` in `trond build list` tables for visual distinction from pinned docker refs. - resolveBuild bypasses pins.Resolve when Builder=host — host builds work with any JDK version, not just the ones with pinned eclipse-temurin images. (Pre-fix, --builder host with --jdk 99 would fail at pin lookup, never reaching the runner.) - findLargestFatJAR walks build/libs/ subdirs picking the largest matching JAR, mirroring the docker runner's `find ... | xargs ls -S | head -n1` so the two builders agree on which fat jar to promote out of a multi-module gradle layout. - For image artifacts, snapshots `docker images` host-side before/after gradle so image.go's diff logic still works unchanged. No docker.sock bind-mount needed (we're on the host already). - Refuses to run if the source tree has no ./gradlew so we don't silently fall back to the host's PATH `gradle`, which would break the "version pinned by source" guarantee. Interface rename for clarity: internal dockerRunner → buildRunner, RunDockerBuild → RunBuild. The exported TestRunner API keeps its historical RunDockerBuild method name (adapter bridges); existing test fakes were updated to the new internal method name. Smoke-tested against real java-tron source on macOS arm64 + JDK 17.0.15: ./gradlew help completed in 4.2s, manifest correctly records `builder: host` + `builder_image: host:openjdk version "17.0.15" ...`. Bad-task path returns the same structured BUILD_FAILED envelope as the docker path. Tests: 5 new (TestResolveHostIdentity, TestFindLargestFatJAR, TestFindLargestFatJAR_NoMatches, TestHostRunner_RequiresGradlew, TestResolveBuild_HostBuilderSkipsPins). Test that touches `java -version` skips when no JDK is on PATH so CI without a JVM isn't blocked.
1 parent 41b2a34 commit 50bc2b6

7 files changed

Lines changed: 511 additions & 31 deletions

File tree

internal/build/builder.go

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -218,25 +218,44 @@ func resolveBuild(ctx context.Context, req Request) (*resolved, error) {
218218
return nil, err
219219
}
220220

221-
imageRef, imageDigest, ok := pins.Resolve(req.JDKVersion, req.Platform, req.BuilderImageOverride)
222-
if !ok {
223-
platforms := pins.Platforms(req.JDKVersion)
224-
if len(platforms) == 0 {
221+
// Builder identity: for docker we resolve the pinned eclipse-
222+
// temurin image; for host we hash the host's `java -version`
223+
// output. Either way the result feeds CacheKey.BuilderImageDigest
224+
// so the cache invalidates when the toolchain changes.
225+
var imageRef, imageDigest string
226+
if req.Builder == "host" {
227+
var err error
228+
imageRef, imageDigest, err = resolveHostIdentity(ctx)
229+
if err != nil {
225230
return nil, output.NewErrorf("VALIDATION_ERROR", output.ExitValidationError,
226-
"no pinned builder image for JDK version %q (available: %v)",
227-
req.JDKVersion, pins.Versions()).
231+
"resolve host builder identity: %s", err.Error()).
228232
WithSuggestions(
229-
"Use one of "+strings.Join(pins.Versions(), ", "),
233+
"Verify 'java' is installed and on PATH",
234+
"Or use --builder docker to skip the host JDK requirement",
235+
)
236+
}
237+
} else {
238+
var ok bool
239+
imageRef, imageDigest, ok = pins.Resolve(req.JDKVersion, req.Platform, req.BuilderImageOverride)
240+
if !ok {
241+
platforms := pins.Platforms(req.JDKVersion)
242+
if len(platforms) == 0 {
243+
return nil, output.NewErrorf("VALIDATION_ERROR", output.ExitValidationError,
244+
"no pinned builder image for JDK version %q (available: %v)",
245+
req.JDKVersion, pins.Versions()).
246+
WithSuggestions(
247+
"Use one of "+strings.Join(pins.Versions(), ", "),
248+
"Or pass --builder-image-override <ref@sha256:...>",
249+
)
250+
}
251+
return nil, output.NewErrorf("VALIDATION_ERROR", output.ExitValidationError,
252+
"JDK %q has no pinned builder image for platform %q (supported: %v)",
253+
req.JDKVersion, req.Platform, platforms).
254+
WithSuggestions(
255+
"Use one of platforms "+strings.Join(platforms, ", "),
230256
"Or pass --builder-image-override <ref@sha256:...>",
231257
)
232258
}
233-
return nil, output.NewErrorf("VALIDATION_ERROR", output.ExitValidationError,
234-
"JDK %q has no pinned builder image for platform %q (supported: %v)",
235-
req.JDKVersion, req.Platform, platforms).
236-
WithSuggestions(
237-
"Use one of platforms "+strings.Join(platforms, ", "),
238-
"Or pass --builder-image-override <ref@sha256:...>",
239-
)
240259
}
241260

242261
src := Source{Path: req.SourcePath, RevisionSpec: req.RevisionSpec}
@@ -280,7 +299,7 @@ func buildJAR(ctx context.Context, r *resolved, started time.Time) (*Manifest, e
280299

281300
_ = os.Remove(outTmp) // stale .tmp from a prior cancelled run
282301

283-
runErr := defaultRunner.RunDockerBuild(ctx, r, outDir, outTmp)
302+
runErr := defaultRunner.RunBuild(ctx, r, outDir, outTmp)
284303
if runErr != nil {
285304
_ = os.Remove(outTmp)
286305
if errors.Is(ctx.Err(), context.Canceled) {

internal/build/builder_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ type recordingRunner struct {
2626
respectCancel bool
2727
}
2828

29-
func (r *recordingRunner) RunDockerBuild(ctx context.Context, res *resolved, outDir, outTmp string) error {
29+
func (r *recordingRunner) RunBuild(ctx context.Context, res *resolved, outDir, outTmp string) error {
3030
r.called = true
3131
r.resolved = res
3232
r.outTmp = outTmp
@@ -52,7 +52,7 @@ func (r *recordingRunner) RunDockerBuild(ctx context.Context, res *resolved, out
5252
// withMockRunner swaps the package-level defaultRunner for the
5353
// duration of one test. Restoration is registered via t.Cleanup so
5454
// parallel-safe across the suite.
55-
func withMockRunner(t *testing.T, mock dockerRunner) {
55+
func withMockRunner(t *testing.T, mock buildRunner) {
5656
t.Helper()
5757
orig := defaultRunner
5858
defaultRunner = mock
@@ -317,7 +317,7 @@ type capturingRunner struct {
317317
cb func(*resolved)
318318
}
319319

320-
func (c *capturingRunner) RunDockerBuild(ctx context.Context, r *resolved, outDir, outTmp string) error {
320+
func (c *capturingRunner) RunBuild(ctx context.Context, r *resolved, outDir, outTmp string) error {
321321
c.cb(r)
322322
// Return error so Run cleans up and exits — caller doesn't care
323323
// about the result, only the captured resolved.

internal/build/image.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func buildImage(ctx context.Context, r *resolved, started time.Time) (*Manifest,
6565
_ = os.Remove(filepath.Join(outDir, r.cacheKeyStr+"-images-after"))
6666
}()
6767

68-
if err := defaultRunner.RunDockerBuild(ctx, r, outDir, "" /* outTmp unused for image */); err != nil {
68+
if err := defaultRunner.RunBuild(ctx, r, outDir, "" /* outTmp unused for image */); err != nil {
6969
if errors.Is(ctx.Err(), context.Canceled) {
7070
return nil, output.NewErrorf("BUILD_CANCELLED", 130,
7171
"build cancelled by user").

internal/build/runner.go

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,30 @@ package build
22

33
import (
44
"context"
5-
"fmt"
65
"os"
76
"os/exec"
87
"path/filepath"
98
)
109

11-
// dockerRunner abstracts the "run a docker command" step so tests can
12-
// substitute a recorder/mock without spinning up a real Docker
13-
// daemon. Production wiring is the exec-based realDockerRunner.
10+
// buildRunner abstracts the "run gradle to produce the artifact"
11+
// step so tests can substitute a recorder/mock without spinning up
12+
// a real Docker daemon (or a real gradle install for host-builder
13+
// tests). Production has two implementations: realDockerRunner runs
14+
// gradle inside a pinned eclipse-temurin container; realHostRunner
15+
// runs it directly via the source tree's ./gradlew wrapper.
1416
//
1517
// The interface intentionally accepts the full argv (not pieces) —
1618
// tests assert on that argv to enforce FR-022's argv-only invocation
1719
// contract (no `bash -c "...interpolated..."`).
18-
type dockerRunner interface {
19-
RunDockerBuild(ctx context.Context, r *resolved, outDir, outTmp string) error
20+
type buildRunner interface {
21+
RunBuild(ctx context.Context, r *resolved, outDir, outTmp string) error
2022
}
2123

2224
// defaultRunner is package-level so tests can swap it via
2325
// `t.Cleanup(func() { defaultRunner = orig })`. Production uses
24-
// realDockerRunner which shells out to the docker CLI.
25-
var defaultRunner dockerRunner = realDockerRunner{}
26+
// dispatchRunner which routes to realDockerRunner or realHostRunner
27+
// based on r.req.Builder ("docker" or "host").
28+
var defaultRunner buildRunner = dispatchRunner{}
2629

2730
// dockerBuildScript (JAR variant) is the only piece of shell trond
2831
// runs for artifact=jar and it's a compile-time constant. User input
@@ -84,13 +87,24 @@ docker images -q --no-trunc --filter dangling=false 2>/dev/null | sort -u > "/ou
8487
docker images -q --no-trunc --filter dangling=false 2>/dev/null | sort -u > "/out/$CACHE_KEY-images-after"
8588
`
8689

87-
type realDockerRunner struct{}
90+
// dispatchRunner is the production buildRunner. It looks at
91+
// r.req.Builder and forwards to the docker or host variant. Pulling
92+
// the routing into its own type (rather than putting an `if Builder
93+
// == "host"` check inside realDockerRunner) keeps each concrete
94+
// runner single-purpose and lets tests substitute either backend
95+
// independently.
96+
type dispatchRunner struct{}
8897

89-
func (realDockerRunner) RunDockerBuild(ctx context.Context, r *resolved, outDir, outTmp string) error {
98+
func (dispatchRunner) RunBuild(ctx context.Context, r *resolved, outDir, outTmp string) error {
9099
if r.req.Builder == "host" {
91-
return fmt.Errorf("--builder host not implemented in Phase 1 (use docker)")
100+
return realHostRunner{}.RunBuild(ctx, r, outDir, outTmp)
92101
}
102+
return realDockerRunner{}.RunBuild(ctx, r, outDir, outTmp)
103+
}
104+
105+
type realDockerRunner struct{}
93106

107+
func (realDockerRunner) RunBuild(ctx context.Context, r *resolved, outDir, outTmp string) error {
94108
// Gradle cache: use a DOCKER NAMED VOLUME, not a bind mount.
95109
// macOS Docker Desktop's bind-mount layer (virtiofs) doesn't
96110
// reliably preserve the exec bit for files the container writes

0 commit comments

Comments
 (0)