Skip to content

Commit

Permalink
image/save: set a stable timestamp for assets
Browse files Browse the repository at this point in the history
When saving a docker image with `docker save`, output may have the
current timestamp, resulting in slightly changed content each time the
`save` command gets run. This patch attemtps to stabilize that effort to
clean up some spots where we've missed setting the timestamps.

It's not totally clear that setting these timestamps to 0 is the correct
behavior but it will fix the hash stability problem on output.

Signed-off-by: Stephen Day <[email protected]>
  • Loading branch information
stevvooe authored and thaJeztah committed Dec 14, 2024
1 parent 1ce3474 commit 1f34fbf
Showing 1 changed file with 94 additions and 26 deletions.
120 changes: 94 additions & 26 deletions image/tarexport/save.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"path"
"path/filepath"
"syscall"
"time"

"github.com/containerd/containerd/images"
Expand Down Expand Up @@ -261,7 +262,7 @@ func (s *saveSession) save(ctx context.Context, outStream io.Writer) error {
dgst := digest.FromBytes(data)

mFile := filepath.Join(s.outDir, ocispec.ImageBlobsDir, dgst.Algorithm().String(), dgst.Encoded())
if err := os.MkdirAll(filepath.Dir(mFile), 0o755); err != nil {
if err := mkdirAllWithChtimes(filepath.Dir(mFile), 0o755, time.Unix(0, 0), time.Unix(0, 0)); err != nil {
return errors.Wrap(err, "error creating blob directory")
}
if err := system.Chtimes(filepath.Dir(mFile), time.Unix(0, 0), time.Unix(0, 0)); err != nil {
Expand Down Expand Up @@ -385,6 +386,9 @@ func (s *saveSession) save(ctx context.Context, outStream io.Writer) error {
if err := os.WriteFile(idxFile, data, 0o644); err != nil {
return errors.Wrap(err, "error writing oci index file")
}
if err := system.Chtimes(idxFile, time.Unix(0, 0), time.Unix(0, 0)); err != nil {
return errors.Wrap(err, "error setting oci index file timestamps")
}

return s.writeTar(ctx, tempDir, outStream)
}
Expand Down Expand Up @@ -419,6 +423,11 @@ func (s *saveSession) saveImage(ctx context.Context, id image.ID) (_ map[layer.D
return nil, fmt.Errorf("empty export - not implemented")
}

ts := time.Unix(0, 0)
if img.Created != nil {
ts = *img.Created
}

var parent digest.Digest
var layers []layer.DiffID
var foreignSrcs map[layer.DiffID]distribution.Descriptor
Expand Down Expand Up @@ -450,7 +459,7 @@ func (s *saveSession) saveImage(ctx context.Context, id image.ID) (_ map[layer.D
}

v1Img.OS = img.OS
src, err := s.saveConfigAndLayer(ctx, rootFS.ChainID(), v1Img, img.Created)
src, err := s.saveConfigAndLayer(ctx, rootFS.ChainID(), v1Img, &ts)
if err != nil {
return nil, err
}
Expand All @@ -469,26 +478,22 @@ func (s *saveSession) saveImage(ctx context.Context, id image.ID) (_ map[layer.D
dgst := digest.FromBytes(data)

blobDir := filepath.Join(s.outDir, ocispec.ImageBlobsDir, dgst.Algorithm().String())
if err := os.MkdirAll(blobDir, 0o755); err != nil {
if err := mkdirAllWithChtimes(blobDir, 0o755, ts, ts); err != nil {
return nil, err
}
if img.Created != nil {
if err := system.Chtimes(blobDir, *img.Created, *img.Created); err != nil {
return nil, err
}
if err := system.Chtimes(filepath.Dir(blobDir), *img.Created, *img.Created); err != nil {
return nil, err
}
if err := system.Chtimes(blobDir, ts, ts); err != nil {
return nil, err
}
if err := system.Chtimes(filepath.Dir(blobDir), ts, ts); err != nil {
return nil, err
}

configFile := filepath.Join(blobDir, dgst.Encoded())
if err := os.WriteFile(configFile, img.RawJSON(), 0o644); err != nil {
return nil, err
}
if img.Created != nil {
if err := system.Chtimes(configFile, *img.Created, *img.Created); err != nil {
return nil, err
}
if err := system.Chtimes(configFile, ts, ts); err != nil {
return nil, err
}

s.images[id].layers = layers
Expand All @@ -506,6 +511,11 @@ func (s *saveSession) saveConfigAndLayer(ctx context.Context, id layer.ChainID,
span.SetStatus(outErr)
}()

ts := time.Unix(0, 0)
if createdTime != nil {
ts = *createdTime
}

outDir := filepath.Join(s.outDir, ocispec.ImageBlobsDir)

if _, ok := s.savedConfigs[legacyImg.ID]; !ok {
Expand Down Expand Up @@ -542,7 +552,7 @@ func (s *saveSession) saveConfigAndLayer(ctx context.Context, id layer.ChainID,

// We use sequential file access to avoid depleting the standby list on
// Windows. On Linux, this equates to a regular os.Create.
if err := os.MkdirAll(filepath.Dir(layerPath), 0o755); err != nil {
if err := mkdirAllWithChtimes(filepath.Dir(layerPath), 0o755, ts, ts); err != nil {
return distribution.Descriptor{}, errors.Wrap(err, "could not create layer dir parent")
}
tarFile, err := sequential.Create(layerPath)
Expand Down Expand Up @@ -576,12 +586,10 @@ func (s *saveSession) saveConfigAndLayer(ctx context.Context, id layer.ChainID,
layerPath = filepath.Join(outDir, lDgst.Algorithm().String(), lDgst.Encoded())
}

if createdTime != nil {
for _, fname := range []string{outDir, layerPath} {
// todo: maybe save layer created timestamp?
if err := system.Chtimes(fname, *createdTime, *createdTime); err != nil {
return distribution.Descriptor{}, errors.Wrap(err, "could not set layer timestamp")
}
for _, fname := range []string{outDir, layerPath} {
// todo: maybe save layer created timestamp?
if err := system.Chtimes(fname, ts, ts); err != nil {
return distribution.Descriptor{}, errors.Wrap(err, "could not set layer timestamp")
}
}

Expand All @@ -608,22 +616,82 @@ func (s *saveSession) saveConfig(legacyImg image.V1Image, outDir string, created
return err
}

ts := time.Unix(0, 0)
if createdTime != nil {
ts = *createdTime
}

cfgDgst := digest.FromBytes(imageConfig)
configPath := filepath.Join(outDir, cfgDgst.Algorithm().String(), cfgDgst.Encoded())
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
if err := mkdirAllWithChtimes(filepath.Dir(configPath), 0o755, ts, ts); err != nil {
return errors.Wrap(err, "could not create layer dir parent")
}

if err := os.WriteFile(configPath, imageConfig, 0o644); err != nil {
return err
}

if createdTime != nil {
if err := system.Chtimes(configPath, *createdTime, *createdTime); err != nil {
return errors.Wrap(err, "could not set config timestamp")
}
if err := system.Chtimes(configPath, ts, ts); err != nil {
return errors.Wrap(err, "could not set config timestamp")
}

s.savedConfigs[legacyImg.ID] = struct{}{}
return nil
}

// mkdirAllWithChtimes is nearly an identical copy to the os.MkdirAll but
// tracks created directories and applies the provided mtime and atime using
// system.Chtimes.
func mkdirAllWithChtimes(path string, perm os.FileMode, atime, mtime time.Time) error {
// Fast path: if we can tell whether path is a directory or file, stop with success or error.
dir, err := os.Stat(path)
if err == nil {
if dir.IsDir() {
return nil
}
return &os.PathError{Op: "mkdir", Path: path, Err: syscall.ENOTDIR}
}

// Slow path: make sure parent exists and then call Mkdir for path.

// Extract the parent folder from path by first removing any trailing
// path separator and then scanning backward until finding a path
// separator or reaching the beginning of the string.
i := len(path) - 1
for i >= 0 && os.IsPathSeparator(path[i]) {
i--
}
for i >= 0 && !os.IsPathSeparator(path[i]) {
i--
}
if i < 0 {
i = 0
}

// If there is a parent directory, and it is not the volume name,
// recurse to ensure parent directory exists.
if parent := path[:i]; len(parent) > len(filepath.VolumeName(path)) {
err = mkdirAllWithChtimes(parent, perm, atime, mtime)
if err != nil {
return err
}
}

// Parent now exists; invoke Mkdir and use its result.
err = os.Mkdir(path, perm)
if err != nil {
// Handle arguments like "foo/." by
// double-checking that directory doesn't exist.
dir, err1 := os.Lstat(path)
if err1 == nil && dir.IsDir() {
return nil
}
return err
}

if err := system.Chtimes(path, atime, mtime); err != nil {
return fmt.Errorf("applying atime=%v and mtime=%v to %s: %w",
atime, mtime, path, err)
}
return nil
}

0 comments on commit 1f34fbf

Please sign in to comment.