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 }