Skip to content

Commit 915d245

Browse files
authored
Merge pull request moby#4057 from AkihiroSuda/rewrite-epoch
exporter/containerimage: new option: rewrite-timestamp (Apply `SOURCE_DATE_EPOCH` to file timestamps)
2 parents 83b1db2 + 2f8292c commit 915d245

File tree

13 files changed

+340
-64
lines changed

13 files changed

+340
-64
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,8 @@ Keys supported by image output:
261261
* `name-canonical=true`: add additional canonical name `name@<digest>`
262262
* `compression=<uncompressed|gzip|estargz|zstd>`: choose compression type for layers newly created and cached, gzip is default value. estargz should be used with `oci-mediatypes=true`.
263263
* `compression-level=<value>`: compression level for gzip, estargz (0-9) and zstd (0-22)
264+
* `rewrite-timestamp=true` (Present in the `master` branch <!-- TODO: v0.13-->): rewrite the file timestamps to the `SOURCE_DATE_EPOCH` value.
265+
See [`docs/build-repro.md`](docs/build-repro.md) for how to specify the `SOURCE_DATE_EPOCH` value.
264266
* `force-compression=true`: forcefully apply `compression` option to all layers (including already existing layers)
265267
* `store=true`: store the result images to the worker's (e.g. containerd) image store as well as ensures that the image has all blobs in the content store (default `true`). Ignored if the worker doesn't have image store (e.g. OCI worker).
266268
* `annotation.<key>=<value>`: attach an annotation with the respective `key` and `value` to the built image

cache/blobs.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/moby/buildkit/session"
1515
"github.com/moby/buildkit/util/bklog"
1616
"github.com/moby/buildkit/util/compression"
17+
"github.com/moby/buildkit/util/converter"
1718
"github.com/moby/buildkit/util/flightcontrol"
1819
"github.com/moby/buildkit/util/winlayers"
1920
digest "github.com/opencontainers/go-digest"
@@ -422,7 +423,7 @@ func ensureCompression(ctx context.Context, ref *immutableRef, comp compression.
422423
}
423424

424425
// Resolve converters
425-
layerConvertFunc, err := getConverter(ctx, ref.cm.ContentStore, desc, comp)
426+
layerConvertFunc, err := converter.New(ctx, ref.cm.ContentStore, desc, comp)
426427
if err != nil {
427428
return struct{}{}, err
428429
} else if layerConvertFunc == nil {

cache/manager_test.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import (
4444
"github.com/moby/buildkit/solver"
4545
"github.com/moby/buildkit/util/compression"
4646
"github.com/moby/buildkit/util/contentutil"
47+
"github.com/moby/buildkit/util/converter"
4748
"github.com/moby/buildkit/util/iohelper"
4849
"github.com/moby/buildkit/util/leaseutil"
4950
"github.com/moby/buildkit/util/overlay"
@@ -1423,7 +1424,7 @@ func testSharingCompressionVariant(ctx context.Context, t *testing.T, co *cmOut,
14231424
require.NoError(t, err, "compression: %v", c)
14241425
uDgst := bDesc.Digest
14251426
if c != compression.Uncompressed {
1426-
convertFunc, err := getConverter(ctx, co.cs, bDesc, compression.New(compression.Uncompressed))
1427+
convertFunc, err := converter.New(ctx, co.cs, bDesc, compression.New(compression.Uncompressed))
14271428
require.NoError(t, err, "compression: %v", c)
14281429
uDesc, err := convertFunc(ctx, co.cs, bDesc)
14291430
require.NoError(t, err, "compression: %v", c)
@@ -1558,7 +1559,7 @@ func TestConversion(t *testing.T) {
15581559
testName := fmt.Sprintf("%s=>%s", i, j)
15591560

15601561
// Prepare the source compression type
1561-
convertFunc, err := getConverter(egctx, store, orgDesc, compSrc)
1562+
convertFunc, err := converter.New(egctx, store, orgDesc, compSrc)
15621563
require.NoError(t, err, testName)
15631564
srcDesc := &orgDesc
15641565
if convertFunc != nil {
@@ -1567,7 +1568,7 @@ func TestConversion(t *testing.T) {
15671568
}
15681569

15691570
// Convert the blob
1570-
convertFunc, err = getConverter(egctx, store, *srcDesc, compDest)
1571+
convertFunc, err = converter.New(egctx, store, *srcDesc, compDest)
15711572
require.NoError(t, err, testName)
15721573
resDesc := srcDesc
15731574
if convertFunc != nil {
@@ -1576,7 +1577,7 @@ func TestConversion(t *testing.T) {
15761577
}
15771578

15781579
// Check the uncompressed digest is the same as the original
1579-
convertFunc, err = getConverter(egctx, store, *resDesc, compression.New(compression.Uncompressed))
1580+
convertFunc, err = converter.New(egctx, store, *resDesc, compression.New(compression.Uncompressed))
15801581
require.NoError(t, err, testName)
15811582
recreatedDesc := resDesc
15821583
if convertFunc != nil {

docs/build-repro.md

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -57,26 +57,14 @@ The build arg value is used for:
5757
- the timestamp of the files exported with the `local` exporter
5858
- the timestamp of the files exported with the `tar` exporter
5959

60-
The build arg value is not used for the timestamps of the files inside the image currently ([Caveats](#caveats)).
61-
62-
See also the [documentation](/frontend/dockerfile/docs/reference.md#buildkit-built-in-build-args) of the Dockerfile frontend.
63-
64-
## Caveats
65-
### Timestamps of the files inside the image
66-
Currently, the `SOURCE_DATE_EPOCH` value is not used for the timestamps of the files inside the image.
67-
68-
Workaround:
69-
```dockerfile
70-
# Limit the timestamp upper bound to SOURCE_DATE_EPOCH.
71-
# Workaround for https://github.com/moby/buildkit/issues/3180
72-
ARG SOURCE_DATE_EPOCH
73-
RUN find $( ls / | grep -E -v "^(dev|mnt|proc|sys)$" ) -newermt "@${SOURCE_DATE_EPOCH}" -writable -xdev | xargs touch --date="@${SOURCE_DATE_EPOCH}" --no-dereference
74-
75-
# Squashing is needed so that only files with the defined timestamp from the last layer are added to the image.
76-
# This squashing also addresses non-reproducibility of whiteout timestamps (https://github.com/moby/buildkit/issues/3168) on BuildKit prior to v0.12.
77-
FROM scratch
78-
COPY --from=0 / /
60+
To apply the build arg value to the timestamps of the files inside the image, specify `rewrite-timestamp=true` as an image exporter option:
61+
```
62+
--output type=image,name=docker.io/username/image,push=true,rewrite-timestamp=true
7963
```
8064

81-
The `touch` command above is [not effective](https://github.com/moby/buildkit/issues/3309) for mount point directories.
82-
A workaround is to create mount point directories below `/dev` (tmpfs) so that the mount points will not be included in the image layer.
65+
<!-- TODO: s/master/v0.13/ -->
66+
The `rewrite-timestamp` option is only available in the `master` branch of BuildKit.
67+
See [v0.12 documentation](https://github.com/moby/buildkit/blob/v0.12/docs/build-repro.md#caveats) for dealing with timestamps
68+
in BuildKit v0.12 and v0.11.
69+
70+
See also the [documentation](/frontend/dockerfile/docs/reference.md#buildkit-built-in-build-args) of the Dockerfile frontend.

exporter/containerimage/export.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,13 @@ func (e *imageExporterInstance) Export(ctx context.Context, src *exporter.Source
274274
tagDone(nil)
275275

276276
if e.unpack {
277+
if opts.RewriteTimestamp {
278+
// e.unpackImage cannot be used because src ref does not point to the rewritten image
279+
///
280+
// TODO: change e.unpackImage so that it takes Result[Remote] as parameter.
281+
// https://github.com/moby/buildkit/pull/4057#discussion_r1324106088
282+
return nil, nil, errors.New("exporter option \"rewrite-timestamp\" conflicts with \"unpack\"")
283+
}
277284
if err := e.unpackImage(ctx, img, src, session.NewGroup(sessionID)); err != nil {
278285
return nil, nil, err
279286
}
@@ -310,7 +317,18 @@ func (e *imageExporterInstance) Export(ctx context.Context, src *exporter.Source
310317
}
311318
}
312319
if e.push {
313-
err := e.pushImage(ctx, src, sessionID, targetName, desc.Digest)
320+
if opts.RewriteTimestamp {
321+
annotations := map[digest.Digest]map[string]string{}
322+
addAnnotations(annotations, *desc)
323+
// e.pushImage cannot be used because src ref does not point to the rewritten image
324+
//
325+
// TODO: change e.pushImage so that it takes Result[Remote] as parameter.
326+
// https://github.com/moby/buildkit/pull/4057#discussion_r1324106088
327+
err = push.Push(ctx, e.opt.SessionManager, sessionID, e.opt.ImageWriter.opt.ContentStore, e.opt.ImageWriter.ContentStore(),
328+
desc.Digest, targetName, e.insecure, e.opt.RegistryHosts, e.pushByDigest, annotations)
329+
} else {
330+
err = e.pushImage(ctx, src, sessionID, targetName, desc.Digest)
331+
}
314332
if err != nil {
315333
return nil, nil, errors.Wrapf(err, "failed to push %v", targetName)
316334
}

exporter/containerimage/exptypes/keys.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,8 @@ var (
7272
// Value: int (0-9) for gzip and estargz
7373
// Value: int (0-22) for zstd
7474
OptKeyCompressionLevel ImageExporterOptKey = "compression-level"
75+
76+
// Rewrite timestamps in layers to match SOURCE_DATE_EPOCH
77+
// Value: bool <true|false>
78+
OptKeyRewriteTimestamp ImageExporterOptKey = "rewrite-timestamp"
7579
)

exporter/containerimage/opts.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type ImageCommitOpts struct {
2121
Epoch *time.Time
2222

2323
ForceInlineAttestations bool // force inline attestations to be attached
24+
RewriteTimestamp bool // rewrite timestamps in layers to match the epoch
2425
}
2526

2627
func (c *ImageCommitOpts) Load(ctx context.Context, opt map[string]string) (map[string]string, error) {
@@ -52,6 +53,8 @@ func (c *ImageCommitOpts) Load(ctx context.Context, opt map[string]string) (map[
5253
err = parseBool(&c.ForceInlineAttestations, k, v)
5354
case exptypes.OptKeyPreferNondistLayers:
5455
err = parseBool(&c.RefCfg.PreferNonDistributable, k, v)
56+
case exptypes.OptKeyRewriteTimestamp:
57+
err = parseBool(&c.RewriteTimestamp, k, v)
5558
default:
5659
rest[k] = v
5760
}

exporter/containerimage/writer.go

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import (
2828
attestationTypes "github.com/moby/buildkit/util/attestation"
2929
"github.com/moby/buildkit/util/bklog"
3030
"github.com/moby/buildkit/util/compression"
31+
"github.com/moby/buildkit/util/contentutil"
32+
"github.com/moby/buildkit/util/converter"
3133
"github.com/moby/buildkit/util/progress"
3234
"github.com/moby/buildkit/util/purl"
3335
"github.com/moby/buildkit/util/system"
@@ -134,7 +136,14 @@ func (ic *ImageWriter) Commit(ctx context.Context, inp *exporter.Source, session
134136

135137
config := exptypes.ParseKey(inp.Metadata, exptypes.ExporterImageConfigKey, p)
136138
inlineCache := exptypes.ParseKey(inp.Metadata, exptypes.ExporterInlineCache, p)
137-
mfstDesc, configDesc, err := ic.commitDistributionManifest(ctx, opts, ref, config, &remotes[0], annotations, inlineCache, opts.Epoch, session.NewGroup(sessionID))
139+
remote := &remotes[0]
140+
if opts.RewriteTimestamp {
141+
remote, err = ic.rewriteRemoteWithEpoch(ctx, opts, remote)
142+
if err != nil {
143+
return nil, err
144+
}
145+
}
146+
mfstDesc, configDesc, err := ic.commitDistributionManifest(ctx, opts, ref, config, remote, annotations, inlineCache, opts.Epoch, session.NewGroup(sessionID))
138147
if err != nil {
139148
return nil, err
140149
}
@@ -200,6 +209,13 @@ func (ic *ImageWriter) Commit(ctx context.Context, inp *exporter.Source, session
200209
}
201210
}
202211

212+
if opts.RewriteTimestamp {
213+
remote, err = ic.rewriteRemoteWithEpoch(ctx, opts, remote)
214+
if err != nil {
215+
return nil, err
216+
}
217+
}
218+
203219
desc, _, err := ic.commitDistributionManifest(ctx, opts, r, config, remote, opts.Annotations.Platform(&p.Platform), inlineCache, opts.Epoch, session.NewGroup(sessionID))
204220
if err != nil {
205221
return nil, err
@@ -324,6 +340,52 @@ func (ic *ImageWriter) exportLayers(ctx context.Context, refCfg cacheconfig.RefC
324340
return out, err
325341
}
326342

343+
// rewriteImageLayerWithEpoch rewrites the file timestamps in the layer blob to match the epoch, and returns a new descriptor that points to
344+
// the new blob.
345+
//
346+
// If no conversion is needed, this returns nil without error.
347+
func rewriteImageLayerWithEpoch(ctx context.Context, cs content.Store, desc ocispecs.Descriptor, comp compression.Config, epoch *time.Time) (*ocispecs.Descriptor, error) {
348+
converterFn, err := converter.NewWithRewriteTimestamp(ctx, cs, desc, comp, epoch)
349+
if err != nil {
350+
return nil, err
351+
}
352+
if converterFn == nil {
353+
return nil, nil
354+
}
355+
return converterFn(ctx, cs, desc)
356+
}
357+
358+
func (ic *ImageWriter) rewriteRemoteWithEpoch(ctx context.Context, opts *ImageCommitOpts, remote *solver.Remote) (*solver.Remote, error) {
359+
if opts.Epoch == nil {
360+
bklog.G(ctx).Warn("rewrite-timestamp is specified, but no source-date-epoch was found")
361+
return remote, nil
362+
}
363+
remoteDescriptors := remote.Descriptors
364+
cs := contentutil.NewStoreWithProvider(ic.opt.ContentStore, remote.Provider)
365+
eg, ctx := errgroup.WithContext(ctx)
366+
rewriteDone := progress.OneOff(ctx,
367+
fmt.Sprintf("rewriting layers with source-date-epoch %d (%s)", opts.Epoch.Unix(), opts.Epoch.String()))
368+
for i, desc := range remoteDescriptors {
369+
i, desc := i, desc
370+
eg.Go(func() error {
371+
if rewrittenDesc, err := rewriteImageLayerWithEpoch(ctx, cs, desc, opts.RefCfg.Compression, opts.Epoch); err != nil {
372+
bklog.G(ctx).WithError(err).Warnf("failed to rewrite layer %d/%d to match source-date-epoch %d (%s)",
373+
i+1, len(remoteDescriptors), opts.Epoch.Unix(), opts.Epoch.String())
374+
} else if rewrittenDesc != nil {
375+
remoteDescriptors[i] = *rewrittenDesc
376+
}
377+
return nil
378+
})
379+
}
380+
if err := rewriteDone(eg.Wait()); err != nil {
381+
return nil, err
382+
}
383+
return &solver.Remote{
384+
Provider: cs,
385+
Descriptors: remoteDescriptors,
386+
}, nil
387+
}
388+
327389
func (ic *ImageWriter) commitDistributionManifest(ctx context.Context, opts *ImageCommitOpts, ref cache.ImmutableRef, config []byte, remote *solver.Remote, annotations *Annotations, inlineCache []byte, epoch *time.Time, sg session.Group) (*ocispecs.Descriptor, *ocispecs.Descriptor, error) {
328390
if len(config) == 0 {
329391
var err error

frontend/dockerfile/dockerfile_test.go

Lines changed: 64 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,10 @@ import (
4343
"github.com/moby/buildkit/solver/errdefs"
4444
"github.com/moby/buildkit/solver/pb"
4545
"github.com/moby/buildkit/util/contentutil"
46-
"github.com/moby/buildkit/util/iohelper"
4746
"github.com/moby/buildkit/util/testutil"
4847
"github.com/moby/buildkit/util/testutil/httpserver"
4948
"github.com/moby/buildkit/util/testutil/integration"
5049
"github.com/moby/buildkit/util/testutil/workers"
51-
digest "github.com/opencontainers/go-digest"
5250
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
5351
"github.com/pkg/errors"
5452
"github.com/stretchr/testify/require"
@@ -6549,9 +6547,16 @@ func testReproSourceDateEpoch(t *testing.T, sb integration.Sandbox) {
65496547
if sb.Snapshotter() == "native" {
65506548
t.Skip("the digest is not reproducible with the \"native\" snapshotter because hardlinks are processed in a different way: https://github.com/moby/buildkit/pull/3456#discussion_r1062650263")
65516549
}
6550+
6551+
registry, err := sb.NewRegistry()
6552+
if errors.Is(err, integration.ErrRequirements) {
6553+
t.Skip(err.Error())
6554+
}
6555+
require.NoError(t, err)
6556+
65526557
f := getFrontend(t, sb)
65536558

6554-
tm := time.Date(2023, time.January, 10, 12, 34, 56, 0, time.UTC)
6559+
tm := time.Date(2023, time.January, 10, 12, 34, 56, 0, time.UTC) // 1673354096
65556560
t.Logf("SOURCE_DATE_EPOCH=%d", tm.Unix())
65566561

65576562
dockerfile := []byte(`# The base image cannot be busybox, due to https://github.com/moby/buildkit/issues/3455
@@ -6565,34 +6570,23 @@ RUN touch -d '2030-01-01 12:34:56' /foo-2030.1
65656570
RUN rm -f /foo.1
65666571
RUN rm -f /foo-2010.1
65676572
RUN rm -f /foo-2030.1
6568-
6569-
# Limit the timestamp upper bound to SOURCE_DATE_EPOCH.
6570-
# Workaround for https://github.com/moby/buildkit/issues/3180
6571-
ARG SOURCE_DATE_EPOCH
6572-
RUN find $( ls / | grep -E -v "^(dev|mnt|proc|sys)$" ) -newermt "@${SOURCE_DATE_EPOCH}" -writable -xdev | xargs touch --date="@${SOURCE_DATE_EPOCH}" --no-dereference
6573-
6574-
# Squashing is needed to apply the touched timestamps across multiple "RUN" instructions.
6575-
# This squashing also addresses non-reproducibility of whiteout timestamps (https://github.com/moby/buildkit/issues/3168).
6576-
FROM scratch
6577-
COPY --from=0 / /
65786573
`)
65796574

6580-
const expectedDigest = "sha256:d286483eccf4d57c313a3f389cdc196e668d914d319c574b15aabdf1963c5eeb"
6575+
const expectedDigest = "sha256:29f2980a804038b0f910af98e9ddb18bfa4d5514995ee6bb4343ddf621a4e183"
65816576

65826577
dir := integration.Tmpdir(
65836578
t,
65846579
fstest.CreateFile("Dockerfile", dockerfile, 0600),
65856580
)
65866581
defer os.RemoveAll(dir)
65876582

6588-
c, err := client.New(sb.Context(), sb.Address())
6583+
ctx := sb.Context()
6584+
c, err := client.New(ctx, sb.Address())
65896585
require.NoError(t, err)
65906586
defer c.Close()
65916587

6592-
outDigester := digest.SHA256.Digester()
6593-
outW := &iohelper.NopWriteCloser{Writer: outDigester.Hash()}
6594-
6595-
_, err = f.Solve(sb.Context(), c, client.SolveOpt{
6588+
target := registry + "/buildkit/testreprosourcedateepoch:" + fmt.Sprintf("%d", tm.Unix())
6589+
solveOpt := client.SolveOpt{
65966590
FrontendAttrs: map[string]string{
65976591
"build-arg:SOURCE_DATE_EPOCH": fmt.Sprintf("%d", tm.Unix()),
65986592
"platform": "linux/amd64",
@@ -6603,17 +6597,62 @@ COPY --from=0 / /
66036597
},
66046598
Exports: []client.ExportEntry{
66056599
{
6606-
Type: client.ExporterOCI,
6607-
Output: fixedWriteCloser(outW),
6600+
Type: client.ExporterImage,
6601+
Attrs: map[string]string{
6602+
"name": target,
6603+
"push": "true",
6604+
"oci-mediatypes": "true",
6605+
"rewrite-timestamp": "true",
6606+
},
66086607
},
66096608
},
6610-
}, nil)
6609+
CacheExports: []client.CacheOptionsEntry{
6610+
{
6611+
Type: "registry",
6612+
Attrs: map[string]string{
6613+
"ref": target + "-cache",
6614+
"oci-mediatypes": "true",
6615+
"image-manifest": "true",
6616+
},
6617+
},
6618+
},
6619+
}
6620+
_, err = f.Solve(ctx, c, solveOpt, nil)
66116621
require.NoError(t, err)
66126622

6613-
outDigest := outDigester.Digest().String()
6614-
t.Logf("OCI archive digest=%q", outDigest)
6623+
desc, manifest := readImage(t, ctx, target)
6624+
_, cacheManifest := readImage(t, ctx, target+"-cache")
66156625
t.Log("The digest may change depending on the BuildKit version, the snapshotter configuration, etc.")
6616-
require.Equal(t, expectedDigest, outDigest)
6626+
require.Equal(t, expectedDigest, desc.Digest.String())
6627+
// Image layers must have rewritten-timestamp
6628+
for _, l := range manifest.Layers {
6629+
require.Equal(t, fmt.Sprintf("%d", tm.Unix()), l.Annotations["buildkit/rewritten-timestamp"])
6630+
}
6631+
// Cache layers must *not* have rewritten-timestamp
6632+
for _, l := range cacheManifest.Layers {
6633+
require.Empty(t, l.Annotations["buildkit/rewritten-timestamp"])
6634+
}
6635+
6636+
// Build again, but without rewrite-timestamp
6637+
solveOpt2 := solveOpt
6638+
delete(solveOpt2.Exports[0].Attrs, "rewrite-timestamp")
6639+
_, err = f.Solve(ctx, c, solveOpt2, nil)
6640+
require.NoError(t, err)
6641+
_, manifest2 := readImage(t, ctx, target)
6642+
for _, l := range manifest2.Layers {
6643+
require.Empty(t, l.Annotations["buildkit/rewritten-timestamp"])
6644+
}
6645+
}
6646+
6647+
//nolint:revive // context-as-argument: context.Context should be the first parameter of a function
6648+
func readImage(t *testing.T, ctx context.Context, ref string) (ocispecs.Descriptor, ocispecs.Manifest) {
6649+
desc, provider, err := contentutil.ProviderFromRef(ref)
6650+
require.NoError(t, err)
6651+
dt, err := content.ReadBlob(ctx, provider, desc)
6652+
require.NoError(t, err)
6653+
var manifest ocispecs.Manifest
6654+
require.NoError(t, json.Unmarshal(dt, &manifest))
6655+
return desc, manifest
66176656
}
66186657

66196658
func testNilContextInSolveGateway(t *testing.T, sb integration.Sandbox) {

0 commit comments

Comments
 (0)