From 2d63eacbfdfbe522a7810e06a2de45bd9e84abe8 Mon Sep 17 00:00:00 2001 From: Kieran Mann Date: Fri, 9 Oct 2026 13:11:52 -0700 Subject: [PATCH 1/8] test(solver): show merged-edge logs now reach every attached job Red test for the progress duplication EarthBuild hit after the v0.14.1 merge (earthbuild#1004). Two jobs build vertices with different digests but the same cache key, so the scheduler merges j1's edge into j0's. Since upstream e1da8b7f ("solver: fix printing progress messages after merged edges", first shipped in v0.13), state.setEdge adds the merge source's MultiWriter to the target and MultiWriter.Add replays history, so j1 also receives j0's log line. TestEarthlyMergedEdgeLogsDeliveredOnce passes on old main (f0b332cf) and fails here. TestEarthlySharedVertexLogsDeliveredToEveryJob is a control: identical-digest vertices have always fanned logs out to every job, on both old main and this branch. This is upstream behaviour pulled in by the merge, not a conflict resolution mistake; per AGENTS.md dedupe belongs in EarthBuild. Co-Authored-By: Claude Opus 5.5 --- solver/earthly_progress_fanout_test.go | 141 +++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 solver/earthly_progress_fanout_test.go diff --git a/solver/earthly_progress_fanout_test.go b/solver/earthly_progress_fanout_test.go new file mode 100644 index 000000000..c05d2b0b2 --- /dev/null +++ b/solver/earthly_progress_fanout_test.go @@ -0,0 +1,141 @@ +package solver + +import ( + "context" + "sync" + "testing" + + "github.com/moby/buildkit/client" + "github.com/moby/buildkit/identity" + "github.com/moby/buildkit/util/progress" + "github.com/stretchr/testify/require" +) + +// earthly-specific: these tests characterise how a vertex's log output is +// fanned out to concurrent jobs (separate solve requests) that end up sharing +// work in the scheduler. EarthBuild prints every log line it receives from +// every Status stream, so a log line that is delivered to more than one job is +// printed more than once. + +// runFanoutJobs builds e0 in job j0, then (with j0 still alive) builds e1 in +// job j1, and returns how many times logLine was delivered to each job's +// Status stream. +func runFanoutJobs(t *testing.T, e0, e1 Edge, logLine string) (j0Count, j1Count int) { + t.Helper() + ctx := context.TODO() + + l := NewSolver(SolverOpt{ + ResolveOpFunc: testOpResolver, + DefaultCache: NewInMemoryCacheManager(), + }) + defer l.Close() + + j0, err := l.NewJob("j0") + require.NoError(t, err) + j1, err := l.NewJob("j1") + require.NoError(t, err) + + count := func(j *Job, out *int, wg *sync.WaitGroup) { + defer wg.Done() + ch := make(chan *client.SolveStatus) + go func() { + _ = j.Status(ctx, false, ch) + }() + for ss := range ch { + for _, lg := range ss.Logs { + if string(lg.Data) == logLine { + *out++ + } + } + } + } + + var wg sync.WaitGroup + wg.Add(2) + go count(j0, &j0Count, &wg) + go count(j1, &j1Count, &wg) + + _, err = j0.Build(ctx, e0) + require.NoError(t, err) + + // j0 is intentionally not discarded yet, so its active state (and edge + // index entry) is still live while j1 solves. + _, err = j1.Build(ctx, e1) + require.NoError(t, err) + + j0.CloseProgress() + j1.CloseProgress() + wg.Wait() + + require.NoError(t, j0.Discard()) + require.NoError(t, j1.Discard()) + return j0Count, j1Count +} + +// logOnExec returns an execPreFunc that writes logLine to the vertex's +// progress stream, the same way an ExecOp writes RUN output. +func logOnExec(logLine string) func(context.Context) error { + return func(ctx context.Context) error { + pw, _, _ := progress.NewFromContext(ctx) + defer pw.Close() + return pw.Write(identity.NewID(), client.VertexLog{Stream: 1, Data: []byte(logLine)}) + } +} + +// TestEarthlyMergedEdgeLogsDeliveredOnce covers two solves whose vertices +// have different digests but the same cache key, so the scheduler merges j1's +// edge into j0's active edge and the operation runs once. +// +// On old EarthBuild main (BuildKit v0.12 base) the log line of the merged +// operation was delivered only to the job that ran it. Since upstream commit +// e1da8b7f ("solver: fix printing progress messages after merged edges", +// first released in v0.13 and therefore part of this v0.14.1 merge), +// state.setEdge adds the merge source's progress writer to the target, and +// MultiWriter.Add replays the target's history, so j1 also receives the log +// line of j0's vertex. +func TestEarthlyMergedEdgeLogsDeliveredOnce(t *testing.T) { + t.Parallel() + + const logLine = "merged-edge-log\n" + e0 := Edge{Vertex: vtx(vtxOpt{ + name: "v0", + cacheKeySeed: "same-cache-key", + value: "result", + execPreFunc: logOnExec(logLine), + })} + e1 := Edge{Vertex: vtx(vtxOpt{ + name: "v1", // different digest from v0 + cacheKeySeed: "same-cache-key", + value: "result", + execPreFunc: logOnExec(logLine), + })} + + j0Count, j1Count := runFanoutJobs(t, e0, e1, logLine) + + require.Equal(t, 1, j0Count, "job that ran the operation should see its log once") + require.Equal(t, 0, j1Count, "job whose edge was merged into another job's edge received that job's log line, so the line is printed twice across the two solves") +} + +// TestEarthlySharedVertexLogsDeliveredToEveryJob covers two solves that load +// a vertex with the identical digest, so both jobs attach to the same active +// state. This is a control case that passes on both old EarthBuild main and +// this merge: loadUnlocked has always added every job's progress writer to +// the shared state's MultiWriter, and MultiWriter.Add replays history, so +// every attached job receives the log line. This fan-out is longstanding +// upstream behaviour, not a regression from the v0.14.1 merge. +func TestEarthlySharedVertexLogsDeliveredToEveryJob(t *testing.T) { + t.Parallel() + + const logLine = "shared-vertex-log\n" + v := vtx(vtxOpt{ + name: "shared", + cacheKeySeed: "shared-cache-key", + value: "result", + execPreFunc: logOnExec(logLine), + }) + + j0Count, j1Count := runFanoutJobs(t, Edge{Vertex: v}, Edge{Vertex: v}, logLine) + + require.Equal(t, 1, j0Count, "job that ran the operation should see its log once") + require.Equal(t, 1, j1Count, "job attached to an already-executed shared vertex should have its log replayed") +} From fd768dc5797cb2b690146e4238bed7cfcf9a1d95 Mon Sep 17 00:00:00 2001 From: Kieran Mann Date: Fri, 9 Oct 2026 13:11:52 -0700 Subject: [PATCH 2/8] test(llbsolver): show platform check skipped for non-earthly exporters runExporters skips verifier.CheckInvalidPlatforms for the whole solve as soon as any exporter has Type() == client.ExporterEarthly. A non-EarthBuild exporter in the same solve therefore loses the upstream v0.14 result validation and is run on a multi-ref result without a platforms mapping. The control case (image exporter alone) still errors as upstream does. The earthlyoutputs exporter only builds a per-image platforms mapping from "platform" metadata; it does not compare requested and produced platforms, so the "handled per-image inside the Earthly exporter" comment is inaccurate. Linux only (llbsolver imports cache -> overlayutils). Old main had no exporter/verifier package and no ExporterInstance.Type(), so this is a divergence from upstream v0.14.1, not a regression from old main. Co-Authored-By: Claude Opus 5.5 --- solver/llbsolver/earthly_exporters_test.go | 74 ++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 solver/llbsolver/earthly_exporters_test.go diff --git a/solver/llbsolver/earthly_exporters_test.go b/solver/llbsolver/earthly_exporters_test.go new file mode 100644 index 000000000..a6c70f017 --- /dev/null +++ b/solver/llbsolver/earthly_exporters_test.go @@ -0,0 +1,74 @@ +package llbsolver + +import ( + "context" + "sync/atomic" + "testing" + + "github.com/moby/buildkit/cache" + "github.com/moby/buildkit/client" + "github.com/moby/buildkit/exporter" + "github.com/moby/buildkit/exporter/containerimage/exptypes" + "github.com/moby/buildkit/exporter/verifier" + "github.com/moby/buildkit/solver" + "github.com/moby/buildkit/solver/result" + "github.com/stretchr/testify/require" +) + +// fakeExporterInstance is a minimal exporter.ExporterInstance that records +// whether Export was called. +type fakeExporterInstance struct { + typ string + exported atomic.Bool +} + +func (e *fakeExporterInstance) ID() int { return 0 } +func (e *fakeExporterInstance) Name() string { return "fake " + e.typ } +func (e *fakeExporterInstance) Config() *exporter.Config { return exporter.NewConfig() } +func (e *fakeExporterInstance) Type() string { return e.typ } +func (e *fakeExporterInstance) Attrs() map[string]string { return nil } +func (e *fakeExporterInstance) Export(_ context.Context, _ *exporter.Source, _ exptypes.InlineCache, _ string) (map[string]string, exporter.DescriptorReference, error) { + e.exported.Store(true) + return nil, nil, nil +} + +// earthly-specific: runExporters skips verifier.CheckInvalidPlatforms for +// the whole solve as soon as any exporter is the EarthBuild exporter, so a +// non-EarthBuild exporter in the same solve loses the upstream v0.14 result +// validation and is handed a multi-ref result without a platforms mapping. +func TestRunExportersValidatesNonEarthlyExporterAlongsideEarthly(t *testing.T) { + t.Parallel() + ctx := context.TODO() + + jl := solver.NewSolver(solver.SolverOpt{}) + defer jl.Close() + job, err := jl.NewJob("j0") + require.NoError(t, err) + defer job.Discard() + + // EarthBuild-shaped result: several named refs, no platforms mapping. + inp := &result.Result[cache.ImmutableRef]{ + Refs: map[string]cache.ImmutableRef{ + "output-0": nil, + "output-1": nil, + }, + } + // Solve always records the frontend request options before exporting. + require.NoError(t, verifier.CaptureFrontendOpts(map[string]string{}, inp)) + + s := &Solver{} + + // Control: with only a non-EarthBuild exporter the upstream check runs. + imageOnly := &fakeExporterInstance{typ: client.ExporterImage} + _, _, err = s.runExporters(ctx, []exporter.ExporterInstance{imageOnly}, nil, job, nil, inp) + require.ErrorContains(t, err, "build result contains multiple refs without platforms mapping") + require.False(t, imageOnly.exported.Load()) + + // Mixed solve: the image exporter must still be protected by the check. + earthly := &fakeExporterInstance{typ: client.ExporterEarthly} + image := &fakeExporterInstance{typ: client.ExporterImage} + _, _, err = s.runExporters(ctx, []exporter.ExporterInstance{earthly, image}, nil, job, nil, inp) + require.ErrorContains(t, err, "build result contains multiple refs without platforms mapping", + "platform validation was skipped for the image exporter because an EarthBuild exporter was also present") + require.False(t, image.exported.Load(), "image exporter ran on a result that failed platform validation") +} From 08c7a4e5f7d188835a98e8c011041a6de1a3ab18 Mon Sep 17 00:00:00 2001 From: Kieran Mann Date: Fri, 9 Oct 2026 13:38:08 -0700 Subject: [PATCH 3/8] test(solver): pin merged-edge log fan-out as upstream behaviour Turn TestEarthlyMergedEdgeLogsDeliveredOnce into the green characterisation test TestEarthlyMergedEdgeLogsReachMergedJob, which asserts that a job whose edge is merged into another job's edge receives the replayed log line. This is upstream behaviour from e1da8b7f ("solver: fix printing progress messages after merged edges", v0.13), not a regression introduced while resolving the v0.14.1 merge. AGENTS.md says BuildKit must not be patched to dedupe progress logs across concurrent solves, so no solver code changes here. EarthBuild relies on this fan-out contract and must dedupe on its side (earthbuild#1004). The test comment says so, so that an upstream change to the contract shows up as a test failure instead of a silent behaviour change. Co-Authored-By: Claude Opus 5.5 --- solver/earthly_progress_fanout_test.go | 28 +++++++++++++++++--------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/solver/earthly_progress_fanout_test.go b/solver/earthly_progress_fanout_test.go index c05d2b0b2..35d3e400b 100644 --- a/solver/earthly_progress_fanout_test.go +++ b/solver/earthly_progress_fanout_test.go @@ -82,18 +82,26 @@ func logOnExec(logLine string) func(context.Context) error { } } -// TestEarthlyMergedEdgeLogsDeliveredOnce covers two solves whose vertices +// TestEarthlyMergedEdgeLogsReachMergedJob covers two solves whose vertices // have different digests but the same cache key, so the scheduler merges j1's // edge into j0's active edge and the operation runs once. // -// On old EarthBuild main (BuildKit v0.12 base) the log line of the merged -// operation was delivered only to the job that ran it. Since upstream commit -// e1da8b7f ("solver: fix printing progress messages after merged edges", -// first released in v0.13 and therefore part of this v0.14.1 merge), -// state.setEdge adds the merge source's progress writer to the target, and -// MultiWriter.Add replays the target's history, so j1 also receives the log -// line of j0's vertex. -func TestEarthlyMergedEdgeLogsDeliveredOnce(t *testing.T) { +// This is a characterisation test of upstream behaviour, not of +// earthly-specific code. On old EarthBuild main (BuildKit v0.12 base) the log +// line of the merged operation was delivered only to the job that ran it. +// Since upstream commit e1da8b7f ("solver: fix printing progress messages +// after merged edges", first released in v0.13), state.setEdge adds the merge +// source's progress writer to the target, and MultiWriter.Add replays the +// target's history, so j1 also receives the log line of j0's vertex. +// +// BuildKit is deliberately not patched to suppress this (see AGENTS.md): +// streaming vertex output to every attached caller is upstream's contract. +// EarthBuild relies on that contract and must dedupe on its side, by not +// issuing redundant concurrent solves for the same work, or by +// deduplicating log output across the Status streams of concurrent solves +// (earthbuild#1004). If this test starts failing after an upstream bump, the +// fan-out contract has changed and EarthBuild's dedupe must be revisited. +func TestEarthlyMergedEdgeLogsReachMergedJob(t *testing.T) { t.Parallel() const logLine = "merged-edge-log\n" @@ -113,7 +121,7 @@ func TestEarthlyMergedEdgeLogsDeliveredOnce(t *testing.T) { j0Count, j1Count := runFanoutJobs(t, e0, e1, logLine) require.Equal(t, 1, j0Count, "job that ran the operation should see its log once") - require.Equal(t, 0, j1Count, "job whose edge was merged into another job's edge received that job's log line, so the line is printed twice across the two solves") + require.Equal(t, 1, j1Count, "job whose edge was merged into another job's edge should have that job's log replayed (upstream e1da8b7f)") } // TestEarthlySharedVertexLogsDeliveredToEveryJob covers two solves that load From f821ecf1ffae0c7a91ee6493d9a550faf5ccf16f Mon Sep 17 00:00:00 2001 From: Kieran Mann Date: Fri, 9 Oct 2026 13:40:49 -0700 Subject: [PATCH 4/8] fix(llbsolver): only skip platform check when every exporter is earthly runExporters skipped verifier.CheckInvalidPlatforms for the whole solve as soon as any exporter was the EarthBuild exporter. A non-EarthBuild exporter in the same solve then lost the upstream v0.14 validation and ran on a multi-ref result with no platforms mapping. All exporters share one input result, so a per-exporter check is not possible. The EarthBuild frontend always returns named Refs and never sets refs.platforms, so CheckInvalidPlatforms always rejects an EarthBuild-shaped result. The narrowest correct rule is to skip the check only when every exporter is the EarthBuild exporter. A mixed solve is then checked for the other exporter's sake and is rejected with the upstream error, as the red test expects. EarthBuild never issues mixed solves: every SolveOpt it builds has exactly one export entry. A solve with no exporters still runs the check, as upstream does. Also correct the comment, which claimed that platform verification was handled per image inside the EarthBuild exporter. It is not: earthlyoutputs only groups refs into images by their "platform" metadata and never compares requested and produced platforms. The test keeps its original expectations and adds an earthly-only case, so the skip cannot be removed without the test noticing. Co-Authored-By: Claude Opus 5.5 --- solver/llbsolver/earthly_exporters_test.go | 15 +++++++++++---- solver/llbsolver/solver.go | 20 +++++++++++++------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/solver/llbsolver/earthly_exporters_test.go b/solver/llbsolver/earthly_exporters_test.go index a6c70f017..4e1f9f788 100644 --- a/solver/llbsolver/earthly_exporters_test.go +++ b/solver/llbsolver/earthly_exporters_test.go @@ -32,10 +32,10 @@ func (e *fakeExporterInstance) Export(_ context.Context, _ *exporter.Source, _ e return nil, nil, nil } -// earthly-specific: runExporters skips verifier.CheckInvalidPlatforms for -// the whole solve as soon as any exporter is the EarthBuild exporter, so a -// non-EarthBuild exporter in the same solve loses the upstream v0.14 result -// validation and is handed a multi-ref result without a platforms mapping. +// earthly-specific: runExporters skips verifier.CheckInvalidPlatforms only +// when every exporter is the EarthBuild exporter. A non-EarthBuild exporter in +// the same solve must keep the upstream v0.14 result validation rather than +// be handed a multi-ref result without a platforms mapping. func TestRunExportersValidatesNonEarthlyExporterAlongsideEarthly(t *testing.T) { t.Parallel() ctx := context.TODO() @@ -64,6 +64,12 @@ func TestRunExportersValidatesNonEarthlyExporterAlongsideEarthly(t *testing.T) { require.ErrorContains(t, err, "build result contains multiple refs without platforms mapping") require.False(t, imageOnly.exported.Load()) + // EarthBuild-only solve: its named-output refs skip the check. + earthlyOnly := &fakeExporterInstance{typ: client.ExporterEarthly} + _, _, err = s.runExporters(ctx, []exporter.ExporterInstance{earthlyOnly}, nil, job, nil, inp) + require.NoError(t, err) + require.True(t, earthlyOnly.exported.Load()) + // Mixed solve: the image exporter must still be protected by the check. earthly := &fakeExporterInstance{typ: client.ExporterEarthly} image := &fakeExporterInstance{typ: client.ExporterImage} @@ -71,4 +77,5 @@ func TestRunExportersValidatesNonEarthlyExporterAlongsideEarthly(t *testing.T) { require.ErrorContains(t, err, "build result contains multiple refs without platforms mapping", "platform validation was skipped for the image exporter because an EarthBuild exporter was also present") require.False(t, image.exported.Load(), "image exporter ran on a result that failed platform validation") + require.False(t, earthly.exported.Load()) } diff --git a/solver/llbsolver/solver.go b/solver/llbsolver/solver.go index 17054a6c9..a61a4b053 100644 --- a/solver/llbsolver/solver.go +++ b/solver/llbsolver/solver.go @@ -739,19 +739,25 @@ func runInlineCacheExporter(ctx context.Context, e exporter.ExporterInstance, in } func (s *Solver) runExporters(ctx context.Context, exporters []exporter.ExporterInstance, inlineCacheExporter inlineCacheExporter, job *solver.Job, cached *result.Result[solver.CachedResult], inp *result.Result[cache.ImmutableRef]) (exporterResponse map[string]string, descrefs []exporter.DescriptorReference, err error) { - var isEarthBuild bool - - // EarthBuild uses res.Refs as a dictionary of named build outputs, not target platforms. - // Platform verification is handled per-image inside the Earthly exporter. + // earthly-specific: the EarthBuild frontend uses inp.Refs as a dictionary + // of named build outputs, not target platforms, and sets no platforms + // mapping, so CheckInvalidPlatforms always rejects its result. The + // EarthBuild exporter does not verify platforms itself either; it only + // groups refs into images by their "platform" metadata. Skip the check + // only when every exporter is the EarthBuild exporter. All exporters + // share inp, so a solve that mixes the EarthBuild exporter with another + // exporter is still checked for the other exporter's sake, which rejects + // an EarthBuild-shaped result. EarthBuild never issues such solves. + onlyEarthBuild := len(exporters) > 0 for _, exp := range exporters { - if exp.Type() == client.ExporterEarthly { - isEarthBuild = true + if exp.Type() != client.ExporterEarthly { + onlyEarthBuild = false break } } var warnings []client.VertexWarning - if !isEarthBuild { + if !onlyEarthBuild { warnings, err = verifier.CheckInvalidPlatforms(ctx, inp) if err != nil { return nil, nil, err From dd17522a69676e11a308b0170363e885e1b98f93 Mon Sep 17 00:00:00 2001 From: Kieran Mann Date: Fri, 9 Oct 2026 13:50:03 -0700 Subject: [PATCH 5/8] chore(deps): bump earthbuild/fsutil to e66a226 (fsutil#4 fixes) Picks up the fsutil merge fixes: send progress is counted once, and verbose progress callbacks report native paths on both sides. fsutil#4 is unmerged; go.mod carries a TODO to advance the replace to the merged earthly-main commit once fsutil#3 and #4 land. Co-Authored-By: Claude Opus 5.5 --- go.mod | 4 +++- go.sum | 4 ++-- vendor/github.com/tonistiigi/fsutil/receive.go | 10 ++++++---- vendor/github.com/tonistiigi/fsutil/send.go | 7 +++---- vendor/modules.txt | 4 ++-- 5 files changed, 16 insertions(+), 13 deletions(-) diff --git a/go.mod b/go.mod index 2d4931137..f983a2de8 100644 --- a/go.mod +++ b/go.mod @@ -183,4 +183,6 @@ require ( kernel.org/pub/linux/libs/security/libcap/psx v1.2.67 // indirect ) -replace github.com/tonistiigi/fsutil => github.com/earthbuild/fsutil v0.0.0-20261002194825-890baaee2294 +// TODO: fsutil points at the head of the unmerged EarthBuild/fsutil#4 (stacked on #3). +// Advance this to the merged commit on EarthBuild/fsutil earthly-main once those land. +replace github.com/tonistiigi/fsutil => github.com/earthbuild/fsutil v0.0.0-20261009204740-e66a226757f7 diff --git a/go.sum b/go.sum index 545c44553..42a0043a5 100644 --- a/go.sum +++ b/go.sum @@ -155,8 +155,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4= github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= -github.com/earthbuild/fsutil v0.0.0-20261002194825-890baaee2294 h1:gFtQodZi786++bc4OPIeVKIwrQWdP0sJLGCHKWenQmo= -github.com/earthbuild/fsutil v0.0.0-20261002194825-890baaee2294/go.mod h1:vbbYqJlnswsbJqWUcJN8fKtBhnEgldDrcagTgnBVKKM= +github.com/earthbuild/fsutil v0.0.0-20261009204740-e66a226757f7 h1:u29QbXHxE6KijCAUwplJyPexWRL4oMcenhBMFaAgQ2Y= +github.com/earthbuild/fsutil v0.0.0-20261009204740-e66a226757f7/go.mod h1:vbbYqJlnswsbJqWUcJN8fKtBhnEgldDrcagTgnBVKKM= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= diff --git a/vendor/github.com/tonistiigi/fsutil/receive.go b/vendor/github.com/tonistiigi/fsutil/receive.go index afb118bb7..dc7335199 100644 --- a/vendor/github.com/tonistiigi/fsutil/receive.go +++ b/vendor/github.com/tonistiigi/fsutil/receive.go @@ -221,10 +221,6 @@ func (r *receiver) run(ctx context.Context) error { } break } - if r.verboseProgressCb != nil { - r.verboseProgressCb(p.Stat.Path, StatusStat, p.Size()) - } - // normalize unix wire-specific paths to platform-specific paths path := filepath.FromSlash(p.Stat.Path) if filepath.ToSlash(path) != p.Stat.Path { @@ -234,6 +230,12 @@ func (r *receiver) run(ctx context.Context) error { p.Stat.Path = path p.Stat.Linkname = filepath.FromSlash(p.Stat.Linkname) + // earthly-specific: report the native path, matching the + // StatusReceiving/StatusReceived paths (r.files keys) and NotifyHashed. + if r.verboseProgressCb != nil { + r.verboseProgressCb(path, StatusStat, p.Size()) + } + if fileCanRequestData(os.FileMode(p.Stat.Mode)) { r.mu.Lock() r.files[p.Stat.Path] = i diff --git a/vendor/github.com/tonistiigi/fsutil/send.go b/vendor/github.com/tonistiigi/fsutil/send.go index 8e6942215..908ba6473 100644 --- a/vendor/github.com/tonistiigi/fsutil/send.go +++ b/vendor/github.com/tonistiigi/fsutil/send.go @@ -114,9 +114,6 @@ func (s *sender) run(ctx context.Context) error { } func (s *sender) updateProgress(size int, last bool) { - s.mu.Lock() - defer s.mu.Unlock() - s.progressCurrent += size if s.progressCb != nil { s.progressCurrentMu.Lock() defer s.progressCurrentMu.Unlock() @@ -149,7 +146,9 @@ func (s *sender) sendFile(h *sendHandle) error { return err } if s.verboseProgressCb != nil { - s.verboseProgressCb(h.path, StatusSent, fs.bytesWritten) + // h.path is the unix wire path from s.files; report the native + // path, matching the StatusStat path reported by walk. + s.verboseProgressCb(filepath.FromSlash(h.path), StatusSent, fs.bytesWritten) } } return s.conn.SendMsg(&types.Packet{ID: h.id, Type: types.PACKET_DATA}) diff --git a/vendor/modules.txt b/vendor/modules.txt index e59863d8f..49ae4bb20 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -821,7 +821,7 @@ github.com/spdx/tools-golang/spdx/v2/v2_3 ## explicit; go 1.20 github.com/stretchr/testify/assert github.com/stretchr/testify/require -# github.com/tonistiigi/fsutil v0.0.0-20240424095704-91a3fc46842c => github.com/earthbuild/fsutil v0.0.0-20261002194825-890baaee2294 +# github.com/tonistiigi/fsutil v0.0.0-20240424095704-91a3fc46842c => github.com/earthbuild/fsutil v0.0.0-20261009204740-e66a226757f7 ## explicit; go 1.20 github.com/tonistiigi/fsutil github.com/tonistiigi/fsutil/copy @@ -1140,4 +1140,4 @@ kernel.org/pub/linux/libs/security/libcap/cap # kernel.org/pub/linux/libs/security/libcap/psx v1.2.67 ## explicit; go 1.11 kernel.org/pub/linux/libs/security/libcap/psx -# github.com/tonistiigi/fsutil => github.com/earthbuild/fsutil v0.0.0-20261002194825-890baaee2294 +# github.com/tonistiigi/fsutil => github.com/earthbuild/fsutil v0.0.0-20261009204740-e66a226757f7 From 2cd6cbcc3ca7375c37e7e60e8906c15301fad402 Mon Sep 17 00:00:00 2001 From: Kieran Mann Date: Fri, 9 Oct 2026 14:57:20 -0700 Subject: [PATCH 6/8] test(llbsolver/ops): show EarthBuild RUN mounts content-hash the rootfs Every EarthBuild RUN carries, alongside the rootfs, a secret mount (/run/secrets/earthly_debugger_settings) and two socket mounts (/var/run/earthly_interactive, /var/run/earthly_save). They arrive with Input:0 and no selector. getMountDeps treats them as whole-source mounts of input 0 and, because the ContentBasedHash flag is last-mount-wins, overrides the rootfs mount's "no content cache on /" decision. The rootfs dep of every RUN therefore gets a content-based cache key (daemon logs show dep_has_slow_cache=true). A RUN after a `RUN --no-cache` that did not change the filesystem then matches its previous record and is reported as *cached* (functional test T8). The content-hash behaviour came in with the v0.13.2 merge (upstream 98bfcf44, ab17c1dd). Old main did not have it. Linux only (ops imports cache -> overlayutils). Co-Authored-By: Claude Opus 5.5 --- .../llbsolver/ops/earthly_exec_mounts_test.go | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 solver/llbsolver/ops/earthly_exec_mounts_test.go diff --git a/solver/llbsolver/ops/earthly_exec_mounts_test.go b/solver/llbsolver/ops/earthly_exec_mounts_test.go new file mode 100644 index 000000000..2c2d5408d --- /dev/null +++ b/solver/llbsolver/ops/earthly_exec_mounts_test.go @@ -0,0 +1,49 @@ +package ops + +import ( + "context" + "testing" + + "github.com/moby/buildkit/session" + "github.com/moby/buildkit/solver/pb" + "github.com/stretchr/testify/require" +) + +// earthly-specific +// +// TestExecOpEarthlySpecialMountsNotContentHashed pins the mount shape that +// EarthBuild sends for every RUN (decoded from a real Solve request): the +// rootfs, a host-bind debugger binary, a secret mount and two socket mounts +// (earthly_interactive / earthly_save_file). The secret and socket mounts +// carry Input:0 and no selector even though they do not consume the rootfs. +// +// If getMountDeps treats them as real inputs, the "whole source" branch +// marks input 0 as content-hashed and, being last-mount-wins, overrides the +// rootfs mount's own "no content cache on /" decision. Every RUN then gets a +// content-based cache key for its rootfs, so a RUN following a +// `RUN --no-cache` that did not change the filesystem is wrongly reported as +// *cached*. +func TestExecOpEarthlySpecialMountsNotContentHashed(t *testing.T) { + op := &ExecOp{numInputs: 1, op: &pb.ExecOp{ + Meta: &pb.Meta{Args: []string{"/bin/sh", "-c", "echo hi"}}, + Mounts: []*pb.Mount{ + {Input: 0, Dest: "/", Output: 0}, + {Input: pb.Empty, Selector: "/usr/bin/earth_debugger", Dest: "/usr/bin/earth_debugger", Output: 1, MountType: pb.MountType_HOST_BIND}, + {Input: 0, Dest: "/run/secrets/earthly_debugger_settings", Output: 0, MountType: pb.MountType_SECRET, SecretOpt: &pb.SecretOpt{ID: "earthly_debugger_settings"}}, + {Input: 0, Dest: "/var/run/earthly_interactive", Output: 0, MountType: pb.MountType_SOCKET, SockOpt: &pb.SockOpt{ID: "earthly_interactive"}}, + {Input: 0, Dest: "/var/run/earthly_save", Output: 0, MountType: pb.MountType_SOCKET, SockOpt: &pb.SockOpt{ID: "earthly_save_file"}}, + }, + }} + + deps, err := op.getMountDeps() + require.NoError(t, err) + require.Len(t, deps, 1) + require.False(t, deps[0].ContentBasedHash, "rootfs input must not be content-hashed") + require.Equal(t, []string{"/"}, deps[0].Selectors, "only the rootfs mount should select from input 0") + + m, ok, err := op.CacheMap(context.Background(), session.NewGroup(t.Name()), 1) + require.NoError(t, err) + require.True(t, ok) + require.Len(t, m.Deps, 1) + require.Nil(t, m.Deps[0].ComputeDigestFunc, "rootfs dep must not have a content-based (slow) cache key") +} From 4f8b3685c2dd2fda799b8b5e646ddaabe3eb82ed Mon Sep 17 00:00:00 2001 From: Tonis Tiigi Date: Tue, 13 Aug 2024 13:04:43 +0300 Subject: [PATCH 7/8] exec: fix incorrect deps computation for special mounts Secret, SSH and Tmpfs mounts never have an input vertex and zero value for input should not be considered and input at index 0 . Currently, if for example secret mount had a input=0, it caused content based checksum from first input(rootfs), that could take quite a lot of time when image was big and had lots of files. The computation result was cached but if there was a cache invalidation in previous commands it caused checksum to recomputed again for the command with the secret mount. Signed-off-by: Tonis Tiigi (cherry picked from commit 3c87ba1bd502986f627115c6756c257b4a0a66e9) Cherry-picked from moby/buildkit#5251 for the T8 regression (`RUN --no-cache` no longer invalidates the following RUN). This alone does not fix it: TestExecOpEarthlySpecialMountsNotContentHashed still fails because EarthBuild's two socket mounts (MountType_SOCKET, /var/run/earthly_interactive and /var/run/earthly_save) also arrive with Input:0 and still mark the rootfs input as content-hashed. Skipping SOCKET follows in the next commit. Co-Authored-By: Claude Opus 5.5 --- solver/llbsolver/ops/exec.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/solver/llbsolver/ops/exec.go b/solver/llbsolver/ops/exec.go index 927533584..b1136ded6 100644 --- a/solver/llbsolver/ops/exec.go +++ b/solver/llbsolver/ops/exec.go @@ -289,6 +289,11 @@ type dep struct { func (e *ExecOp) getMountDeps() ([]dep, error) { deps := make([]dep, e.numInputs) for _, m := range e.op.Mounts { + switch m.MountType { + case pb.MountType_SECRET, pb.MountType_SSH, pb.MountType_TMPFS: + continue + } + if m.Input == pb.Empty { continue } From 1f6ea5314ef785fb8a0c8ae271546098e236e5f7 Mon Sep 17 00:00:00 2001 From: Kieran Mann Date: Fri, 9 Oct 2026 14:58:10 -0700 Subject: [PATCH 8/8] fix(llbsolver/ops): skip earthly socket mounts in getMountDeps EarthBuild's socket mounts (MountType_SOCKET, used for /var/run/earthly_interactive and /var/run/earthly_save) have no input vertex, like the secret/SSH/tmpfs mounts upstream 3c87ba1b already skips, but arrive with Input:0. getMountDeps counted them as whole-source mounts of the rootfs input and, last-mount-wins, turned on ContentBasedHash for it. Every RUN's rootfs dep then got a content-based cache key, so a RUN after a `RUN --no-cache` that left the filesystem unchanged was reported as *cached* (T8). Skip SOCKET too, on its own case line so the upstream line stays unchanged. TestExecOpEarthlySpecialMountsNotContentHashed now passes; TestExecOpContentCache and TestDedupePaths still pass. Co-Authored-By: Claude Opus 5.5 --- solver/llbsolver/ops/exec.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/solver/llbsolver/ops/exec.go b/solver/llbsolver/ops/exec.go index b1136ded6..8b8bdce48 100644 --- a/solver/llbsolver/ops/exec.go +++ b/solver/llbsolver/ops/exec.go @@ -292,6 +292,8 @@ func (e *ExecOp) getMountDeps() ([]dep, error) { switch m.MountType { case pb.MountType_SECRET, pb.MountType_SSH, pb.MountType_TMPFS: continue + case pb.MountType_SOCKET: // earthly-specific: socket mounts have no input vertex either + continue } if m.Input == pb.Empty {