From 4e1c8acd57ee142e4da78291b4a9bcf9906a3482 Mon Sep 17 00:00:00 2001 From: Tonis Tiigi Date: Mon, 23 Mar 2026 17:08:41 -0700 Subject: [PATCH] dockerfile: support SOURCE_DATE_EPOCH=context Resolve SOURCE_DATE_EPOCH=context in the Dockerfile frontend from the main build context and pass the resolved numeric epoch through normal ARG handling and exporter metadata. Use git commit time for git contexts, HTTP Last-Modified when present, and newest archive entry mtime for HTTP archives. Leave local contexts unset. Signed-off-by: Tonis Tiigi --- docs/build-repro.md | 7 + exporter/util/epoch/parse.go | 8 +- exporter/util/epoch/parse_test.go | 19 ++ frontend/dockerfile/builder/build.go | 2 + frontend/dockerfile/dockerfile2llb/convert.go | 71 ++++- .../dockerfile/dockerfile2llb/convert_test.go | 16 + frontend/dockerfile/dockerfile_test.go | 274 +++++++++++++++++- frontend/dockerui/attr.go | 13 - frontend/dockerui/config.go | 9 - frontend/dockerui/context.go | 145 ++++++++- source/git/source.go | 6 +- 11 files changed, 526 insertions(+), 44 deletions(-) create mode 100644 exporter/util/epoch/parse_test.go diff --git a/docs/build-repro.md b/docs/build-repro.md index 5294ecbbb..c1058ecfa 100644 --- a/docs/build-repro.md +++ b/docs/build-repro.md @@ -51,6 +51,13 @@ ARG SOURCE_DATE_EPOCH=1704067200 FROM alpine ``` +The Dockerfile frontend also supports the special value `SOURCE_DATE_EPOCH=context`. +This resolves the main build context to a numeric Unix timestamp before the build: + +- git context: commit time +- HTTP context: `Last-Modified`, or the newest archive entry mtime when building from an archive without `Last-Modified` +- local context: ignored, leaving `SOURCE_DATE_EPOCH` unset + ```console buildctl build --frontend dockerfile.v0 --opt build-arg:SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) ... ``` diff --git a/exporter/util/epoch/parse.go b/exporter/util/epoch/parse.go index aa32b16b8..efd0e9782 100644 --- a/exporter/util/epoch/parse.go +++ b/exporter/util/epoch/parse.go @@ -21,7 +21,13 @@ type Epoch struct { func ParseBuildArgs(opt map[string]string) (string, bool) { v, ok := opt[frontendSourceDateEpochArg] - return v, ok + if !ok { + return "", false + } + if _, err := parseTime(frontendSourceDateEpochArg, v); err != nil { + return "", false + } + return v, true } func ParseExporterAttrs(opt map[string]string) (*Epoch, map[string]string, error) { diff --git a/exporter/util/epoch/parse_test.go b/exporter/util/epoch/parse_test.go new file mode 100644 index 000000000..9b128d0c0 --- /dev/null +++ b/exporter/util/epoch/parse_test.go @@ -0,0 +1,19 @@ +package epoch + +import "testing" + +func TestParseBuildArgs(t *testing.T) { + t.Parallel() + + if v, ok := ParseBuildArgs(map[string]string{frontendSourceDateEpochArg: "1700000601"}); !ok || v != "1700000601" { + t.Fatalf("expected numeric SOURCE_DATE_EPOCH to be forwarded, got %q %v", v, ok) + } + + if _, ok := ParseBuildArgs(map[string]string{frontendSourceDateEpochArg: "context"}); ok { + t.Fatal("expected SOURCE_DATE_EPOCH=context to stay frontend-only") + } + + if v, ok := ParseBuildArgs(map[string]string{frontendSourceDateEpochArg: ""}); !ok || v != "" { + t.Fatalf("expected empty SOURCE_DATE_EPOCH to remain a valid exporter override, got %q %v", v, ok) + } +} diff --git a/frontend/dockerfile/builder/build.go b/frontend/dockerfile/builder/build.go index 33a8c9294..ae3a734bf 100644 --- a/frontend/dockerfile/builder/build.go +++ b/frontend/dockerfile/builder/build.go @@ -2,6 +2,7 @@ package builder import ( "context" + "maps" "strings" "github.com/containerd/platforms" @@ -131,6 +132,7 @@ func Build(ctx context.Context, c client.Client) (_ *client.Result, err error) { rb, err := bc.Build(ctx, func(ctx context.Context, platform *ocispecs.Platform, idx int) (*dockerui.BuildResult, error) { opt := convertOpt + opt.BuildArgs = maps.Clone(opt.BuildArgs) opt.TargetPlatform = platform if idx != 0 { opt.Warn = nil diff --git a/frontend/dockerfile/dockerfile2llb/convert.go b/frontend/dockerfile/dockerfile2llb/convert.go index 3220046f1..6c6927c51 100644 --- a/frontend/dockerfile/dockerfile2llb/convert.go +++ b/frontend/dockerfile/dockerfile2llb/convert.go @@ -208,6 +208,7 @@ type dispatchContext struct { opt ConvertOpt platformOpt *platformOpt globalArgs *llb.EnvList + epoch *time.Time shlex *shell.Lex outline outlineCapture lint *linter.Linter @@ -300,9 +301,19 @@ func toDispatchState(ctx context.Context, dt []byte, opt ConvertOpt) (*dispatchS return nil, err } - opt.Epoch, err = resolveSourceDateEpoch(opt.Epoch, globalArgs) - if err != nil { - return nil, err + var resolvedEpoch *time.Time + if sourceDateEpoch, ok := getBuildArgValue(opt.BuildArgs, globalArgs, "SOURCE_DATE_EPOCH"); ok { + resolvedEpoch, err = resolveSourceDateEpochValue(ctx, sourceDateEpoch, opt.Client) + if err != nil { + return nil, err + } + if sourceDateEpoch == "context" { + var resolvedValue string + if resolvedEpoch != nil { + resolvedValue = strconv.FormatInt(resolvedEpoch.Unix(), 10) + } + globalArgs = setBuildArgValue(opt.BuildArgs, globalArgs, "SOURCE_DATE_EPOCH", resolvedValue) + } } metaResolver := opt.MetaResolver @@ -314,6 +325,7 @@ func toDispatchState(ctx context.Context, dt []byte, opt ConvertOpt) (*dispatchS opt: opt, platformOpt: platformOpt, globalArgs: globalArgs, + epoch: resolvedEpoch, shlex: shlex, outline: outline, lint: lint, @@ -353,17 +365,15 @@ func toDispatchState(ctx context.Context, dt []byte, opt ConvertOpt) (*dispatchS return target, nil } -func resolveSourceDateEpoch(explicit *time.Time, globalArgs *llb.EnvList) (*time.Time, error) { - if explicit != nil { - return explicit, nil - } - if globalArgs == nil { +func resolveSourceDateEpochValue(ctx context.Context, v string, client *dockerui.Client) (*time.Time, error) { + if v == "" { return nil, nil } - - v, ok := globalArgs.Get("SOURCE_DATE_EPOCH") - if !ok || v == "" { - return nil, nil + if v == "context" { + if client == nil { + return nil, nil + } + return client.ResolveMainContextSourceDateEpoch(ctx) } sde, err := strconv.ParseInt(v, 10, 64) @@ -374,6 +384,41 @@ func resolveSourceDateEpoch(explicit *time.Time, globalArgs *llb.EnvList) (*time return &tm, nil } +func getBuildArgValue(buildArgs map[string]string, globalArgs *llb.EnvList, key string) (string, bool) { + if v, ok := buildArgs[key]; ok { + return v, true + } + if globalArgs == nil { + return "", false + } + v, ok := globalArgs.Get(key) + if !ok || v == "" { + return "", false + } + return v, true +} + +func setBuildArgValue(buildArgs map[string]string, globalArgs *llb.EnvList, key, value string) *llb.EnvList { + if _, ok := buildArgs[key]; ok { + if value == "" { + delete(buildArgs, key) + } else { + buildArgs[key] = value + } + } + if globalArgs != nil { + if _, ok := globalArgs.Get(key); ok { + if value == "" { + updated := globalArgs.Delete(key) + globalArgs = &updated + } else { + globalArgs = globalArgs.AddOrReplace(key, value) + } + } + } + return globalArgs +} + func (dctx *dispatchContext) buildDispatchStates(stages []instructions.Stage) error { for i, st := range stages { lint := dctx.lint.WithMergedConfigFromComments(st.Comments) @@ -402,7 +447,7 @@ func (dctx *dispatchContext) buildDispatchStates(stages []instructions.Stage) er stageName: st.Name, prefixPlatform: dctx.opt.MultiPlatformRequested, outline: dctx.outline.clone(), - epoch: dctx.opt.Epoch, + epoch: dctx.epoch, } if v := st.Platform; v != "" { diff --git a/frontend/dockerfile/dockerfile2llb/convert_test.go b/frontend/dockerfile/dockerfile2llb/convert_test.go index bfb9d0117..632f018b0 100644 --- a/frontend/dockerfile/dockerfile2llb/convert_test.go +++ b/frontend/dockerfile/dockerfile2llb/convert_test.go @@ -276,3 +276,19 @@ func TestDispatchHealthcheckHistory(t *testing.T) { want := `HEALTHCHECK {Test:[bin -c exit 0] Interval:1s Timeout:10s StartPeriod:3s StartInterval:100ms Retries:5}` require.Equal(t, want, d.image.History[0].CreatedBy) } + +func TestResolveSourceDateEpochValue(t *testing.T) { + t.Parallel() + + tm, err := resolveSourceDateEpochValue(context.Background(), "1700000501", nil) + require.NoError(t, err) + require.NotNil(t, tm) + assert.Equal(t, time.Unix(1700000501, 0).UTC(), *tm) + + tm, err = resolveSourceDateEpochValue(context.Background(), "context", nil) + require.NoError(t, err) + assert.Nil(t, tm) + + _, err = resolveSourceDateEpochValue(context.Background(), "not-a-timestamp", nil) + require.ErrorContains(t, err, "invalid SOURCE_DATE_EPOCH") +} diff --git a/frontend/dockerfile/dockerfile_test.go b/frontend/dockerfile/dockerfile_test.go index da3e713a3..a41b7e83e 100644 --- a/frontend/dockerfile/dockerfile_test.go +++ b/frontend/dockerfile/dockerfile_test.go @@ -234,6 +234,10 @@ var reproTests = integration.TestFuncs( testSourceDateEpochDockerfileDefaultOverride, testSourceDateEpochDockerfileDefaultReset, testSourceDateEpochDockerfileDefaultInvalid, + testSourceDateEpochContextGit, + testSourceDateEpochContextHTTPLastModified, + testSourceDateEpochContextHTTPArchive, + testSourceDateEpochContextLocalUnset, ) var ( @@ -1031,6 +1035,61 @@ WORKDIR /mydir require.Equal(t, index1, index2) } +type tarContextFile struct { + name string + data []byte + modTime time.Time +} + +func makeTarContext(t *testing.T, files ...tarContextFile) []byte { + t.Helper() + + buf := bytes.NewBuffer(nil) + tw := tar.NewWriter(buf) + for _, file := range files { + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: file.name, + Mode: 0600, + Size: int64(len(file.data)), + Typeflag: tar.TypeReg, + ModTime: file.modTime, + })) + _, err := tw.Write(file.data) + require.NoError(t, err) + } + require.NoError(t, tw.Close()) + return buf.Bytes() +} + +func readOCIImage(t *testing.T, dt []byte) ocispecs.Image { + t.Helper() + + m, err := testutil.ReadTarToMap(dt, false) + require.NoError(t, err) + + var idx ocispecs.Index + err = json.Unmarshal(m[ocispecs.ImageIndexFile].Data, &idx) + require.NoError(t, err) + + var mfst ocispecs.Manifest + err = json.Unmarshal(m[ocispecs.ImageBlobsDir+"/sha256/"+idx.Manifests[0].Digest.Hex()].Data, &mfst) + require.NoError(t, err) + + var img ocispecs.Image + err = json.Unmarshal(m[ocispecs.ImageBlobsDir+"/sha256/"+mfst.Config.Digest.Hex()].Data, &img) + require.NoError(t, err) + + return img +} + +func readOCIImageCreated(t *testing.T, dt []byte) time.Time { + t.Helper() + + img := readOCIImage(t, dt) + require.NotNil(t, img.Created) + return *img.Created +} + func readOCIManifest(t *testing.T, dt []byte) ocispecs.Manifest { t.Helper() @@ -1048,6 +1107,18 @@ func readOCIManifest(t *testing.T, dt []byte) ocispecs.Manifest { return mfst } +func readOCILayerMap(t *testing.T, dt []byte, layer ocispecs.Descriptor) map[string]*testutil.TarItem { + t.Helper() + + m, err := testutil.ReadTarToMap(dt, false) + require.NoError(t, err) + + layerMap, err := testutil.ReadTarToMap(m[ocispecs.ImageBlobsDir+"/sha256/"+layer.Digest.Hex()].Data, true) + require.NoError(t, err) + + return layerMap +} + func testCacheReleased(t *testing.T, sb integration.Sandbox) { f := getFrontend(t, sb) @@ -9185,10 +9256,7 @@ COPY Dockerfile . }, Exports: []client.ExportEntry{ { - Type: client.ExporterOCI, - Attrs: map[string]string{ - "source-date-epoch": "", - }, + Type: client.ExporterOCI, Output: fixedWriteCloser(outW), }, }, @@ -9196,6 +9264,204 @@ COPY Dockerfile . require.ErrorContains(t, err, "invalid SOURCE_DATE_EPOCH: not-a-timestamp") } +func testSourceDateEpochContextGit(t *testing.T, sb integration.Sandbox) { + integration.SkipOnPlatform(t, "windows") + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureSourceDateEpoch) + f := getFrontend(t, sb) + + gitDir := t.TempDir() + dockerfile := []byte("FROM scratch\nCOPY Dockerfile /\n") + require.NoError(t, os.WriteFile(filepath.Join(gitDir, "Dockerfile"), dockerfile, 0600)) + + commitTime := time.Unix(1700000101, 0).UTC() + require.NoError(t, runShell(gitDir, + "git init", + "git config --local user.email test@example.com", + "git config --local user.name test", + "git add Dockerfile", + fmt.Sprintf("GIT_AUTHOR_DATE=%q GIT_COMMITTER_DATE=%q git commit -m msg", commitTime.Format(time.RFC3339), commitTime.Format(time.RFC3339)), + "git update-server-info", + )) + + server := httptest.NewServer(http.FileServer(http.Dir(filepath.Clean(gitDir)))) + defer server.Close() + + c, err := client.New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + out := filepath.Join(t.TempDir(), "out.tar") + outW, err := os.Create(out) + require.NoError(t, err) + + _, err = f.Solve(sb.Context(), c, client.SolveOpt{ + FrontendAttrs: map[string]string{ + "context": server.URL + "/.git", + "build-arg:SOURCE_DATE_EPOCH": "context", + }, + Exports: []client.ExportEntry{ + { + Type: client.ExporterOCI, + Output: fixedWriteCloser(outW), + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(out) + require.NoError(t, err) + require.Equal(t, commitTime.Unix(), readOCIImageCreated(t, dt).Unix()) +} + +func testSourceDateEpochContextHTTPLastModified(t *testing.T, sb integration.Sandbox) { + integration.SkipOnPlatform(t, "windows") + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureSourceDateEpoch) + f := getFrontend(t, sb) + + lastModified := time.Unix(1700000201, 0).UTC() + archiveMaxTime := time.Unix(1700000202, 0).UTC() + resp := &httpserver.Response{ + Etag: identity.NewID(), + LastModified: &lastModified, + Content: makeTarContext(t, + tarContextFile{name: "Dockerfile", data: []byte("FROM scratch\nCOPY foo /\n"), modTime: archiveMaxTime.Add(-time.Hour)}, + tarContextFile{name: "foo", data: []byte("bar"), modTime: archiveMaxTime}, + ), + } + server := httpserver.NewTestServer(map[string]*httpserver.Response{ + "/context.tar": resp, + }) + defer server.Close() + + c, err := client.New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + out := filepath.Join(t.TempDir(), "out.tar") + outW, err := os.Create(out) + require.NoError(t, err) + + _, err = f.Solve(sb.Context(), c, client.SolveOpt{ + FrontendAttrs: map[string]string{ + "context": server.URL + "/context.tar", + "build-arg:SOURCE_DATE_EPOCH": "context", + }, + Exports: []client.ExportEntry{ + { + Type: client.ExporterOCI, + Output: fixedWriteCloser(outW), + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(out) + require.NoError(t, err) + require.Equal(t, lastModified.Unix(), readOCIImageCreated(t, dt).Unix()) +} + +func testSourceDateEpochContextHTTPArchive(t *testing.T, sb integration.Sandbox) { + integration.SkipOnPlatform(t, "windows") + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureSourceDateEpoch) + f := getFrontend(t, sb) + + archiveMaxTime := time.Unix(1700000302, 0).UTC() + resp := &httpserver.Response{ + Etag: identity.NewID(), + Content: makeTarContext(t, + tarContextFile{name: "Dockerfile", data: []byte("FROM busybox\nARG SOURCE_DATE_EPOCH\nRUN echo -n \"$SOURCE_DATE_EPOCH\" >/epoch\n"), modTime: archiveMaxTime.Add(-time.Hour)}, + tarContextFile{name: "foo", data: []byte("bar"), modTime: archiveMaxTime}, + ), + } + server := httpserver.NewTestServer(map[string]*httpserver.Response{ + "/context.tar": resp, + }) + defer server.Close() + + c, err := client.New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + out := filepath.Join(t.TempDir(), "out.tar") + outW, err := os.Create(out) + require.NoError(t, err) + + _, err = f.Solve(sb.Context(), c, client.SolveOpt{ + FrontendAttrs: map[string]string{ + "context": server.URL + "/context.tar", + "build-arg:SOURCE_DATE_EPOCH": "context", + }, + Exports: []client.ExportEntry{ + { + Type: client.ExporterOCI, + Attrs: map[string]string{ + "rewrite-timestamp": "true", + }, + Output: fixedWriteCloser(outW), + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(out) + require.NoError(t, err) + + mfst := readOCIManifest(t, dt) + require.NotEmpty(t, mfst.Layers) + require.Equal(t, fmt.Sprintf("%d", archiveMaxTime.Unix()), mfst.Layers[len(mfst.Layers)-1].Annotations["buildkit/rewritten-timestamp"]) + + layerMap := readOCILayerMap(t, dt, mfst.Layers[len(mfst.Layers)-1]) + require.Equal(t, fmt.Sprintf("%d", archiveMaxTime.Unix()), string(layerMap["epoch"].Data)) + + require.Equal(t, archiveMaxTime.Unix(), readOCIImageCreated(t, dt).Unix()) +} + +func testSourceDateEpochContextLocalUnset(t *testing.T, sb integration.Sandbox) { + integration.SkipOnPlatform(t, "windows") + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureSourceDateEpoch) + f := getFrontend(t, sb) + + dockerfile := []byte("FROM scratch\nCOPY Dockerfile /\n") + dir := integration.Tmpdir( + t, + fstest.CreateFile("Dockerfile", dockerfile, 0600), + ) + + c, err := client.New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + out := filepath.Join(t.TempDir(), "out.tar") + outW, err := os.Create(out) + require.NoError(t, err) + + _, err = f.Solve(sb.Context(), c, client.SolveOpt{ + FrontendAttrs: map[string]string{ + "build-arg:SOURCE_DATE_EPOCH": "context", + }, + LocalMounts: map[string]fsutil.FS{ + dockerui.DefaultLocalNameDockerfile: dir, + dockerui.DefaultLocalNameContext: dir, + }, + Exports: []client.ExportEntry{ + { + Type: client.ExporterOCI, + Attrs: map[string]string{ + "rewrite-timestamp": "true", + }, + Output: fixedWriteCloser(outW), + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(out) + require.NoError(t, err) + mfst := readOCIManifest(t, dt) + require.Len(t, mfst.Layers, 1) + require.Empty(t, mfst.Layers[0].Annotations["buildkit/rewritten-timestamp"]) +} + func testSBOMScannerImage(t *testing.T, sb integration.Sandbox) { integration.SkipOnPlatform(t, "windows") workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush, workers.FeatureSBOM) diff --git a/frontend/dockerui/attr.go b/frontend/dockerui/attr.go index ffb618cb2..a6f717d04 100644 --- a/frontend/dockerui/attr.go +++ b/frontend/dockerui/attr.go @@ -4,7 +4,6 @@ import ( "net" "strconv" "strings" - "time" "github.com/containerd/platforms" "github.com/docker/go-units" @@ -113,18 +112,6 @@ func parseNetMode(v string) (pb.NetMode, error) { } } -func parseSourceDateEpoch(v string) (*time.Time, error) { - if v == "" { - return nil, nil - } - sde, err := strconv.ParseInt(v, 10, 64) - if err != nil { - return nil, errors.Wrapf(err, "invalid SOURCE_DATE_EPOCH: %s", v) - } - tm := time.Unix(sde, 0).UTC() - return &tm, nil -} - func parseLocalSessionIDs(opt map[string]string) map[string]string { m := map[string]string{} for k, v := range opt { diff --git a/frontend/dockerui/config.go b/frontend/dockerui/config.go index 619b83457..bc59d77ad 100644 --- a/frontend/dockerui/config.go +++ b/frontend/dockerui/config.go @@ -8,7 +8,6 @@ import ( "strconv" "strings" "sync" - "time" "github.com/containerd/platforms" "github.com/distribution/reference" @@ -51,14 +50,12 @@ const ( keyHostnameArg = "build-arg:BUILDKIT_SANDBOX_HOSTNAME" keyDockerfileLintArg = "build-arg:BUILDKIT_DOCKERFILE_CHECK" keyContextKeepGitDirArg = "build-arg:BUILDKIT_CONTEXT_KEEP_GIT_DIR" - keySourceDateEpoch = "build-arg:SOURCE_DATE_EPOCH" ) type Config struct { BuildArgs map[string]string CacheIDNamespace string CgroupParent string - Epoch *time.Time ExtraHosts []llb.HostIP Hostname string ImageResolveMode llb.ResolveMode @@ -251,12 +248,6 @@ func (bc *Client) init() error { } bc.CacheImports = cacheImports - epoch, err := parseSourceDateEpoch(opts[keySourceDateEpoch]) - if err != nil { - return err - } - bc.Epoch = epoch - attests, err := attestations.Parse(opts) if err != nil { return err diff --git a/frontend/dockerui/context.go b/frontend/dockerui/context.go index 3bd5094f6..acc7909d4 100644 --- a/frontend/dockerui/context.go +++ b/frontend/dockerui/context.go @@ -4,15 +4,23 @@ import ( "archive/tar" "bytes" "context" + "io" + "maps" "path/filepath" "regexp" "slices" "strconv" + "strings" + "time" "github.com/moby/buildkit/client/llb" + "github.com/moby/buildkit/client/llb/sourceresolver" "github.com/moby/buildkit/frontend/dockerfile/dfgitutil" "github.com/moby/buildkit/frontend/gateway/client" gwpb "github.com/moby/buildkit/frontend/gateway/pb" + "github.com/moby/buildkit/solver/pb" + "github.com/moby/buildkit/util/gitutil/gitobject" + archivecompression "github.com/moby/go-archive/compression" "github.com/pkg/errors" ) @@ -36,10 +44,14 @@ var httpPrefix = regexp.MustCompile(`^https?://`) type buildContext struct { context *llb.State // set if not local dockerfile *llb.State // override remoteContext if set + contextRef client.Reference contextLocalName string dockerfileLocalName string filename string forceLocalDockerfile bool + sourceOp *pb.SourceOp + httpContextIsArchive bool + httpContextFilename string } func (bc *Client) marshalOpts() []llb.ConstraintsOpt { @@ -75,16 +87,26 @@ func (bc *Client) initContext(ctx context.Context) (*buildContext, error) { keepGit = &v } var extraGitOpts []llb.GitOption - if opts[keySourceDateEpoch] != "" { + if opts[buildArgPrefix+"SOURCE_DATE_EPOCH"] != "" { extraGitOpts = append(extraGitOpts, llb.GitMTimeCommit()) } if st, ok, err := DetectGitContext(opts[localNameContext], keepGit, extraGitOpts...); ok { if err != nil { return nil, err } + sourceOp, err := sourceOpFromState(ctx, st, bc.marshalOpts()...) + if err != nil { + return nil, errors.Wrapf(err, "failed to derive git source op") + } bctx.context = st bctx.dockerfile = st + bctx.sourceOp = sourceOp } else if st, filename, ok := DetectHTTPContext(opts[localNameContext]); ok { + sourceOp, err := sourceOpFromState(ctx, st, bc.marshalOpts()...) + if err != nil { + return nil, errors.Wrapf(err, "failed to derive http source op") + } + def, err := st.Marshal(ctx, bc.marshalOpts()...) if err != nil { return nil, errors.Wrapf(err, "failed to marshal httpcontext") @@ -115,11 +137,15 @@ func (bc *Client) initContext(ctx context.Context) (*buildContext, error) { AttemptUnpack: true, })) bctx.context = &bc + bctx.httpContextIsArchive = true } else { bctx.filename = filename bctx.context = st } + bctx.contextRef = ref bctx.dockerfile = bctx.context + bctx.sourceOp = sourceOp + bctx.httpContextFilename = filename } else if (&gwcaps).Supports(gwpb.CapFrontendInputs) == nil { inputs, err := bc.client.Inputs(ctx) if err != nil { @@ -148,6 +174,123 @@ func (bc *Client) initContext(ctx context.Context) (*buildContext, error) { return bctx, nil } +func (bc *Client) ResolveMainContextSourceDateEpoch(ctx context.Context) (*time.Time, error) { + bctx, err := bc.buildContext(ctx) + if err != nil { + return nil, err + } + if bctx.sourceOp == nil { + return nil, nil + } + + opt := sourceresolver.Opt{ + LogName: "[internal] resolve main build context metadata", + } + if strings.HasPrefix(bctx.sourceOp.Identifier, "git://") { + opt.GitOpt = &sourceresolver.ResolveGitOpt{ReturnObject: true} + } + md, err := bc.client.ResolveSourceMetadata(ctx, cloneSourceOp(bctx.sourceOp), opt) + if err != nil { + return nil, err + } + if md.Git != nil && len(md.Git.CommitObject) > 0 { + obj, err := gitobject.Parse(md.Git.CommitObject) + if err != nil { + return nil, err + } + commit, err := obj.ToCommit() + if err != nil { + return nil, err + } + return commit.Committer.When, nil + } + if md.HTTP != nil { + if md.HTTP.LastModified != nil { + return md.HTTP.LastModified, nil + } + if bctx.httpContextIsArchive { + return archiveMaxTimeFromHTTPArchive(ctx, bctx) + } + } + return nil, nil +} + +func archiveMaxTimeFromHTTPArchive(ctx context.Context, bctx *buildContext) (*time.Time, error) { + if bctx.contextRef == nil || bctx.httpContextFilename == "" { + return nil, nil + } + dt, err := bctx.contextRef.ReadFile(ctx, client.ReadRequest{ + Filename: bctx.httpContextFilename, + }) + if err != nil { + return nil, err + } + rc, err := archivecompression.DecompressStream(bytes.NewReader(dt)) + if err != nil { + return nil, err + } + defer rc.Close() + + tr := tar.NewReader(rc) + var maxTime *time.Time + for { + hdr, err := tr.Next() + if err != nil { + if errors.Is(err, io.EOF) { + return maxTime, nil + } + return nil, err + } + if !hdr.FileInfo().Mode().IsRegular() { + continue + } + tm := hdr.ModTime.UTC() + if maxTime == nil || tm.After(*maxTime) { + maxTime = &tm + } + } +} + +func cloneSourceOp(op *pb.SourceOp) *pb.SourceOp { + if op == nil { + return nil + } + return &pb.SourceOp{ + Identifier: op.Identifier, + Attrs: maps.Clone(op.Attrs), + } +} + +func sourceOpFromState(ctx context.Context, st *llb.State, opts ...llb.ConstraintsOpt) (*pb.SourceOp, error) { + if st == nil { + return nil, nil + } + def, err := st.Marshal(ctx, opts...) + if err != nil { + return nil, err + } + dt := def.ToPB().Def + var src *pb.SourceOp + for _, d := range dt { + var op pb.Op + if err := op.Unmarshal(d); err != nil { + return nil, err + } + opSrc := op.GetSource() + if opSrc == nil { + continue + } + if src != nil { + return nil, errors.New("state marshaled to multiple source ops") + } + src = opSrc + } + if src == nil { + return nil, errors.New("state did not marshal to a source op") + } + return cloneSourceOp(src), nil +} + func DetectGitContext(ref string, keepGit *bool, opts ...llb.GitOption) (*llb.State, bool, error) { g, isGit, err := dfgitutil.ParseGitRef(ref) if err != nil { diff --git a/source/git/source.go b/source/git/source.go index c3a20ca60..6b2042b94 100644 --- a/source/git/source.go +++ b/source/git/source.go @@ -287,13 +287,13 @@ func (gs *Source) ResolveMetadata(ctx context.Context, id *GitIdentifier, sm *se return nil, err } + gsh.cacheCommit = md.Checksum + gsh.sha256 = len(md.Checksum) == 64 + if !opt.ReturnObject && id.VerifySignature == nil { return md, nil } - gsh.cacheCommit = md.Checksum - gsh.sha256 = len(md.Checksum) == 64 - if err := gsh.addGitObjectsToMetadata(ctx, jobCtx, md); err != nil { return nil, err }