diff --git a/cache/manager.go b/cache/manager.go index 29866bf62f74..6eb6f4a8e2e9 100644 --- a/cache/manager.go +++ b/cache/manager.go @@ -288,7 +288,7 @@ func (cm *cacheManager) DiskUsage(ctx context.Context, opt client.DiskUsageInfo) createdAt: getCreatedAt(cr.md), usageCount: usageCount, lastUsedAt: lastUsedAt, - description: getDescription(cr.md), + description: GetDescription(cr.md), } if cr.parent != nil { c.parent = cr.parent.ID() diff --git a/cache/metadata.go b/cache/metadata.go index f837fedea7a1..3884ee552e1a 100644 --- a/cache/metadata.go +++ b/cache/metadata.go @@ -111,7 +111,7 @@ func queueDescription(si *metadata.StorageItem, descr string) error { return nil } -func getDescription(si *metadata.StorageItem) string { +func GetDescription(si *metadata.StorageItem) string { v := si.Get(keyDescription) if v == nil { return "" diff --git a/cache/refs.go b/cache/refs.go index 6fbe69f2fae5..e22c0322a0a2 100644 --- a/cache/refs.go +++ b/cache/refs.go @@ -256,7 +256,7 @@ func (sr *mutableRef) commit(ctx context.Context) (ImmutableRef, error) { md: md, } - if descr := getDescription(sr.md); descr != "" { + if descr := GetDescription(sr.md); descr != "" { if err := queueDescription(md, descr); err != nil { return nil, err } diff --git a/client/client_test.go b/client/client_test.go index 181a3b151514..cbd2e01442b0 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -172,6 +172,7 @@ func testBuildPushAndValidate(t *testing.T, sb integration.Sandbox) { } run(`sh -c "mkdir -p foo/sub; echo -n first > foo/sub/bar; chmod 0741 foo;"`) + run(`true`) // this doesn't create a layer run(`sh -c "echo -n second > foo/sub/baz"`) def, err := st.Marshal() @@ -268,6 +269,14 @@ func testBuildPushAndValidate(t *testing.T, sb integration.Sandbox) { return false }) + require.Equal(t, 3, len(ociimg.History)) + require.Contains(t, ociimg.History[0].CreatedBy, "foo/sub/bar") + require.Contains(t, ociimg.History[1].CreatedBy, "true") + require.Contains(t, ociimg.History[2].CreatedBy, "foo/sub/baz") + require.False(t, ociimg.History[0].EmptyLayer) + require.True(t, ociimg.History[1].EmptyLayer) + require.False(t, ociimg.History[2].EmptyLayer) + dt, err = content.ReadBlob(ctx, img.ContentStore(), img.Target().Digest) require.NoError(t, err) diff --git a/exporter/containerimage/export.go b/exporter/containerimage/export.go index a3d3688284e6..5204ee1f6836 100644 --- a/exporter/containerimage/export.go +++ b/exporter/containerimage/export.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "runtime" + "strings" "time" "github.com/containerd/containerd/content" @@ -32,6 +33,8 @@ const ( keyPush = "push" keyInsecure = "registry.insecure" exporterImageConfig = "containerimage.config" + + emptyGZLayer = digest.Digest("sha256:4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1") ) type Opt struct { @@ -62,7 +65,7 @@ func (e *imageExporter) Resolve(ctx context.Context, opt map[string]string) (exp case keyInsecure: i.insecure = true default: - logrus.Warnf("unknown exporter option %s", k) + logrus.Warnf("image exporter: unknown option %s", k) } } return i, nil @@ -83,44 +86,46 @@ func (e *imageExporterInstance) Export(ctx context.Context, ref cache.ImmutableR layersDone := oneOffProgress(ctx, "exporting layers") diffPairs, err := blobs.GetDiffPairs(ctx, e.opt.ContentStore, e.opt.Snapshotter, e.opt.Differ, ref) if err != nil { - return err + return errors.Wrap(err, "failed calculaing diff pairs for exported snapshot") } layersDone(nil) - diffIDs := make([]digest.Digest, 0, len(diffPairs)) - for _, dp := range diffPairs { - diffIDs = append(diffIDs, dp.DiffID) - } - - var dt []byte - if config, ok := opt[exporterImageConfig]; ok { - dt, err = setDiffIDs(config, diffIDs) + config, ok := opt[exporterImageConfig] + if !ok { + config, err = emptyImageConfig() if err != nil { return err } - } else { - dt, err = json.Marshal(imageConfig(diffIDs)) - if err != nil { - return errors.Wrap(err, "failed to marshal image config") - } + } + + history, err := parseHistoryFromConfig(config) + if err != nil { + return err + } + + diffPairs, history = normalizeLayersAndHistory(diffPairs, history, ref) + + config, err = patchImageConfig(config, diffPairs, history) + if err != nil { + return err } addAsRoot := content.WithLabels(map[string]string{ "containerd.io/gc.root": time.Now().UTC().Format(time.RFC3339Nano), }) - dgst := digest.FromBytes(dt) - configDone := oneOffProgress(ctx, "exporting config "+dgst.String()) + configDigest := digest.FromBytes(config) + configDone := oneOffProgress(ctx, "exporting config "+configDigest.String()) - if err := content.WriteBlob(ctx, e.opt.ContentStore, dgst.String(), bytes.NewReader(dt), int64(len(dt)), dgst, addAsRoot); err != nil { + if err := content.WriteBlob(ctx, e.opt.ContentStore, configDigest.String(), bytes.NewReader(config), int64(len(config)), configDigest, addAsRoot); err != nil { return configDone(errors.Wrap(err, "error writing config blob")) } configDone(nil) mfst := schema2.Manifest{ Config: distribution.Descriptor{ - Digest: dgst, - Size: int64(len(dt)), + Digest: configDigest, + Size: int64(len(config)), MediaType: schema2.MediaTypeImageConfig, }, } @@ -130,7 +135,7 @@ func (e *imageExporterInstance) Export(ctx context.Context, ref cache.ImmutableR for _, dp := range diffPairs { info, err := e.opt.ContentStore.Info(ctx, dp.Blobsum) if err != nil { - return configDone(errors.Wrapf(err, "could not get blob %s", dp.Blobsum)) + return configDone(errors.Wrapf(err, "could not find blob %s from contentstore", dp.Blobsum)) } mfst.Layers = append(mfst.Layers, distribution.Descriptor{ Digest: dp.Blobsum, @@ -139,80 +144,166 @@ func (e *imageExporterInstance) Export(ctx context.Context, ref cache.ImmutableR }) } - dt, err = json.Marshal(mfst) + mfstJSON, err := json.Marshal(mfst) if err != nil { return errors.Wrap(err, "failed to marshal manifest") } - dgst = digest.FromBytes(dt) - mfstDone := oneOffProgress(ctx, "exporting manifest "+dgst.String()) + mfstDigest := digest.FromBytes(mfstJSON) + mfstDone := oneOffProgress(ctx, "exporting manifest "+mfstDigest.String()) - if err := content.WriteBlob(ctx, e.opt.ContentStore, dgst.String(), bytes.NewReader(dt), int64(len(dt)), dgst, addAsRoot); err != nil { - return mfstDone(errors.Wrap(err, "error writing manifest blob")) + if err := content.WriteBlob(ctx, e.opt.ContentStore, mfstDigest.String(), bytes.NewReader(mfstJSON), int64(len(mfstJSON)), mfstDigest, addAsRoot); err != nil { + return mfstDone(errors.Wrapf(err, "error writing manifest blob %s", mfstDigest)) } - mfstDone(nil) if e.targetName != "" { if e.opt.Images != nil { tagDone := oneOffProgress(ctx, "naming to "+e.targetName) - imgrec := images.Image{ + img := images.Image{ Name: e.targetName, Target: ocispec.Descriptor{ - Digest: dgst, - Size: int64(len(dt)), + Digest: mfstDigest, + Size: int64(len(mfstJSON)), MediaType: ocispec.MediaTypeImageManifest, }, CreatedAt: time.Now(), } - _, err := e.opt.Images.Update(ctx, imgrec) - if err != nil { + + if _, err := e.opt.Images.Update(ctx, img); err != nil { if !errdefs.IsNotFound(err) { return tagDone(err) } - _, err := e.opt.Images.Create(ctx, imgrec) - if err != nil { + if _, err := e.opt.Images.Create(ctx, img); err != nil { return tagDone(err) } } tagDone(nil) } if e.push { - return push.Push(ctx, e.opt.SessionManager, e.opt.ContentStore, dgst, e.targetName, e.insecure) + return push.Push(ctx, e.opt.SessionManager, e.opt.ContentStore, mfstDigest, e.targetName, e.insecure) } } - return err + return nil } -// this is temporary: should move to dockerfile frontend -func imageConfig(diffIDs []digest.Digest) ocispec.Image { +func emptyImageConfig() ([]byte, error) { img := ocispec.Image{ Architecture: runtime.GOARCH, OS: runtime.GOOS, } img.RootFS.Type = "layers" - img.RootFS.DiffIDs = diffIDs img.Config.WorkingDir = "/" img.Config.Env = []string{"PATH=" + system.DefaultPathEnv} - return img + dt, err := json.Marshal(img) + return dt, errors.Wrap(err, "failed to create empty image config") +} + +func parseHistoryFromConfig(dt []byte) ([]ocispec.History, error) { + var config struct { + History []ocispec.History + } + if err := json.Unmarshal(dt, &config); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal history from config") + } + return config.History, nil } -func setDiffIDs(config []byte, diffIDs []digest.Digest) ([]byte, error) { - mp := map[string]json.RawMessage{} - if err := json.Unmarshal(config, &mp); err != nil { - return nil, err +func patchImageConfig(dt []byte, dps []blobs.DiffPair, history []ocispec.History) ([]byte, error) { + m := map[string]json.RawMessage{} + if err := json.Unmarshal(dt, &m); err != nil { + return nil, errors.Wrap(err, "failed to parse image config for patch") } + var rootFS ocispec.RootFS rootFS.Type = "layers" - rootFS.DiffIDs = diffIDs + for _, dp := range dps { + rootFS.DiffIDs = append(rootFS.DiffIDs, dp.DiffID) + } dt, err := json.Marshal(rootFS) if err != nil { - return nil, err + return nil, errors.Wrap(err, "failed to marshal rootfs") + } + m["rootfs"] = dt + + dt, err = json.Marshal(history) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal history") + } + m["history"] = dt + + dt, err = json.Marshal(m) + return dt, errors.Wrap(err, "failed to marshal config after patch") +} + +func normalizeLayersAndHistory(diffs []blobs.DiffPair, history []ocispec.History, ref cache.ImmutableRef) ([]blobs.DiffPair, []ocispec.History) { + var historyLayers int + for _, h := range history { + if !h.EmptyLayer { + historyLayers += 1 + } + } + if historyLayers > len(diffs) { + // this case shouldn't happen but if it does force set history layers empty + // from the bottom + logrus.Warn("invalid image config with unaccounted layers") + historyCopy := make([]ocispec.History, 0, len(history)) + var l int + for _, h := range history { + if l >= len(diffs) { + h.EmptyLayer = true + } + if !h.EmptyLayer { + l++ + } + historyCopy = append(historyCopy, h) + } + history = historyCopy + } + + if len(diffs) > historyLayers { + // some history items are missing. add them based on the ref metadata + for _, msg := range getRefDesciptions(ref, len(diffs)-historyLayers) { + tm := time.Now().UTC() + history = append(history, ocispec.History{ + Created: &tm, + CreatedBy: msg, + Comment: "buildkit.exporter.image.v0", + }) + } + } + + var layerIndex int + for i, h := range history { + if !h.EmptyLayer { + if diffs[layerIndex].Blobsum == emptyGZLayer { + h.EmptyLayer = true + diffs = append(diffs[:layerIndex], diffs[layerIndex+1:]...) + } else { + layerIndex++ + } + } + history[i] = h + } + + return diffs, history +} + +func getRefDesciptions(ref cache.ImmutableRef, limit int) []string { + if limit <= 0 { + return nil + } + defaultMsg := "created by buildkit" // shouldn't happen but don't fail build + if ref == nil { + strings.Repeat(defaultMsg, limit) + } + descr := cache.GetDescription(ref.Metadata()) + if descr == "" { + descr = defaultMsg } - mp["rootfs"] = dt - return json.Marshal(mp) + return append(getRefDesciptions(ref.Parent(), limit-1), descr) } func oneOffProgress(ctx context.Context, id string) func(err error) error { diff --git a/frontend/dockerfile/dockerfile2llb/convert.go b/frontend/dockerfile/dockerfile2llb/convert.go index c216c39e89d6..ddbeba515adf 100644 --- a/frontend/dockerfile/dockerfile2llb/convert.go +++ b/frontend/dockerfile/dockerfile2llb/convert.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strconv" "strings" + "time" "github.com/docker/distribution/reference" "github.com/docker/docker/builder/dockerfile/instructions" @@ -19,6 +20,8 @@ import ( "github.com/docker/go-connections/nat" "github.com/moby/buildkit/client/llb" "github.com/moby/buildkit/client/llb/imagemetaresolver" + digest "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/pkg/errors" "golang.org/x/sync/errgroup" ) @@ -26,6 +29,7 @@ import ( const ( emptyImageName = "scratch" localNameContext = "context" + historyComment = "buildkit.dockerfile.v0" ) type ConvertOpt struct { @@ -148,17 +152,17 @@ func Dockerfile2LLB(ctx context.Context, dt []byte, opt ConvertOpt) (*llb.State, if len(parts) > 1 { v = parts[1] } - if err := dispatchEnv(d, &instructions.EnvCommand{Env: []instructions.KeyValuePair{{Key: parts[0], Value: v}}}); err != nil { + if err := dispatchEnv(d, &instructions.EnvCommand{Env: []instructions.KeyValuePair{{Key: parts[0], Value: v}}}, false); err != nil { return nil, nil, err } } if d.image.Config.WorkingDir != "" { - if err = dispatchWorkdir(d, &instructions.WorkdirCommand{Path: d.image.Config.WorkingDir}); err != nil { + if err = dispatchWorkdir(d, &instructions.WorkdirCommand{Path: d.image.Config.WorkingDir}, false); err != nil { return nil, nil, err } } if d.image.Config.User != "" { - if err = dispatchUser(d, &instructions.UserCommand{User: d.image.Config.User}); err != nil { + if err = dispatchUser(d, &instructions.UserCommand{User: d.image.Config.User}, false); err != nil { return nil, nil, err } } @@ -218,11 +222,11 @@ func dispatch(d *dispatchState, cmd instructions.Command, opt dispatchOpt) error var err error switch c := cmd.(type) { case *instructions.EnvCommand: - err = dispatchEnv(d, c) + err = dispatchEnv(d, c, true) case *instructions.RunCommand: err = dispatchRun(d, c) case *instructions.WorkdirCommand: - err = dispatchWorkdir(d, c) + err = dispatchWorkdir(d, c, true) case *instructions.AddCommand: err = dispatchCopy(d, c.SourcesAndDest, opt.buildContext, true, c) case *instructions.LabelCommand: @@ -238,7 +242,7 @@ func dispatch(d *dispatchState, cmd instructions.Command, opt dispatchOpt) error case *instructions.ExposeCommand: err = dispatchExpose(d, c) case *instructions.UserCommand: - err = dispatchUser(d, c) + err = dispatchUser(d, c, true) case *instructions.VolumeCommand: err = dispatchVolume(d, c) case *instructions.StopSignalCommand: @@ -299,11 +303,16 @@ func dispatchOnBuild(d *dispatchState, triggers []string, opt dispatchOpt) error return nil } -func dispatchEnv(d *dispatchState, c *instructions.EnvCommand) error { +func dispatchEnv(d *dispatchState, c *instructions.EnvCommand, commit bool) error { + commitMessage := bytes.NewBufferString("ENV") for _, e := range c.Env { + commitMessage.WriteString(" " + e.String()) d.state = d.state.AddEnv(e.Key, e.Value) d.image.Config.Env = addEnv(d.image.Config.Env, e.Key, e.Value, true) } + if commit { + return commitToHistory(&d.image, commitMessage.String(), false, nil) + } return nil } @@ -320,16 +329,19 @@ func dispatchRun(d *dispatchState, c *instructions.RunCommand) error { } opt = append(opt, dfCmd(c)) d.state = d.state.Run(opt...).Root() - return nil + return commitToHistory(&d.image, "RUN "+runCommandString(args, d.buildArgs), true, &d.state) } -func dispatchWorkdir(d *dispatchState, c *instructions.WorkdirCommand) error { +func dispatchWorkdir(d *dispatchState, c *instructions.WorkdirCommand, commit bool) error { d.state = d.state.Dir(c.Path) wd := c.Path if !path.IsAbs(c.Path) { wd = path.Join("/", d.image.Config.WorkingDir, wd) } d.image.Config.WorkingDir = wd + if commit { + return commitToHistory(&d.image, "WORKDIR "+wd, false, nil) + } return nil } @@ -345,8 +357,17 @@ func dispatchCopy(d *dispatchState, c instructions.SourcesAndDest, sourceState l if isAddCommand { args = append(args, "--unpack") } + + commitMessage := bytes.NewBufferString("") + if isAddCommand { + commitMessage.WriteString("ADD") + } else { + commitMessage.WriteString("COPY") + } + mounts := make([]llb.RunOption, 0, len(c.Sources())) for i, src := range c.Sources() { + commitMessage.WriteString(" " + src) if isAddCommand && urlutil.IsURL(src) { u, err := url.Parse(src) f := "__unnamed__" @@ -369,25 +390,30 @@ func dispatchCopy(d *dispatchState, c instructions.SourcesAndDest, sourceState l } } + commitMessage.WriteString(" " + c.Dest()) + args = append(args, dest) run := img.Run(append([]llb.RunOption{llb.Args(args), dfCmd(cmdToPrint)}, mounts...)...) d.state = run.AddMount("/dest", d.state) - return nil + + return commitToHistory(&d.image, commitMessage.String(), true, &d.state) } func dispatchMaintainer(d *dispatchState, c instructions.MaintainerCommand) error { d.image.Author = c.Maintainer - return nil + return commitToHistory(&d.image, fmt.Sprintf("MAINTAINER %v", c.Maintainer), false, nil) } func dispatchLabel(d *dispatchState, c *instructions.LabelCommand) error { + commitMessage := bytes.NewBufferString("LABEL") if d.image.Config.Labels == nil { d.image.Config.Labels = make(map[string]string) } for _, v := range c.Labels { d.image.Config.Labels[v.Key] = v.Value + commitMessage.WriteString(" " + v.String()) } - return nil + return commitToHistory(&d.image, commitMessage.String(), false, nil) } func dispatchOnbuild(d *dispatchState, c *instructions.OnbuildCommand) error { @@ -402,7 +428,7 @@ func dispatchCmd(d *dispatchState, c *instructions.CmdCommand) error { } d.image.Config.Cmd = args d.image.Config.ArgsEscaped = true - return nil + return commitToHistory(&d.image, fmt.Sprintf("CMD %q", args), false, nil) } func dispatchEntrypoint(d *dispatchState, c *instructions.EntrypointCommand) error { @@ -411,7 +437,7 @@ func dispatchEntrypoint(d *dispatchState, c *instructions.EntrypointCommand) err args = append(defaultShell(), strings.Join(args, " ")) } d.image.Config.Entrypoint = args - return nil + return commitToHistory(&d.image, fmt.Sprintf("ENTRYPOINT %q", args), false, nil) } func dispatchHealthcheck(d *dispatchState, c *instructions.HealthCheckCommand) error { @@ -422,7 +448,7 @@ func dispatchHealthcheck(d *dispatchState, c *instructions.HealthCheckCommand) e StartPeriod: c.Health.StartPeriod, Retries: c.Health.Retries, } - return nil + return commitToHistory(&d.image, fmt.Sprintf("HEALTHCHECK %q", d.image.Config.Healthcheck), false, nil) } func dispatchExpose(d *dispatchState, c *instructions.ExposeCommand) error { @@ -439,10 +465,13 @@ func dispatchExpose(d *dispatchState, c *instructions.ExposeCommand) error { d.image.Config.ExposedPorts[string(p)] = struct{}{} } - return nil + return commitToHistory(&d.image, fmt.Sprintf("EXPOSE %v", ps), false, nil) } -func dispatchUser(d *dispatchState, c *instructions.UserCommand) error { +func dispatchUser(d *dispatchState, c *instructions.UserCommand, commit bool) error { d.image.Config.User = c.User + if commit { + return commitToHistory(&d.image, fmt.Sprintf("USER %v", c.User), false, nil) + } return nil } @@ -456,7 +485,7 @@ func dispatchVolume(d *dispatchState, c *instructions.VolumeCommand) error { } d.image.Config.Volumes[v] = struct{}{} } - return nil + return commitToHistory(&d.image, fmt.Sprintf("VOLUME %v", c.Volumes), false, nil) } func dispatchStopSignal(d *dispatchState, c *instructions.StopSignalCommand) error { @@ -464,15 +493,19 @@ func dispatchStopSignal(d *dispatchState, c *instructions.StopSignalCommand) err return err } d.image.Config.StopSignal = c.Signal - return nil + return commitToHistory(&d.image, fmt.Sprintf("STOPSIGNAL %v", c.Signal), false, nil) } func dispatchShell(d *dispatchState, c *instructions.ShellCommand) error { d.image.Config.Shell = c.Shell - return nil + return commitToHistory(&d.image, fmt.Sprintf("SHELL %v", c.Shell), false, nil) } func dispatchArg(d *dispatchState, c *instructions.ArgCommand, metaArgs []instructions.ArgCommand, buildArgValues map[string]string) error { + commitStr := "ARG " + c.Key + if c.Value != nil { + commitStr += "=" + *c.Value + } if c.Value == nil { for _, ma := range metaArgs { if ma.Key == c.Key { @@ -482,7 +515,7 @@ func dispatchArg(d *dispatchState, c *instructions.ArgCommand, metaArgs []instru } d.buildArgs = append(d.buildArgs, setBuildArgValue(*c, buildArgValues)) - return nil + return commitToHistory(&d.image, commitStr, false, nil) } func pathRelativeToWorkingDir(s llb.State, p string) string { @@ -554,6 +587,7 @@ func getArgValue(arg instructions.ArgCommand) string { } func dfCmd(cmd interface{}) llb.MetadataOpt { + // TODO: add fmt.Stringer to instructions.Command to remove interface{} var cmdStr string if cmd, ok := cmd.(fmt.Stringer); ok { cmdStr = cmd.String() @@ -565,3 +599,34 @@ func dfCmd(cmd interface{}) llb.MetadataOpt { "com.docker.dockerfile.v1.command": cmdStr, }) } + +func runCommandString(args []string, buildArgs []instructions.ArgCommand) string { + var tmpBuildEnv []string + for _, arg := range buildArgs { + tmpBuildEnv = append(tmpBuildEnv, arg.Key+"="+getArgValue(arg)) + } + if len(tmpBuildEnv) > 0 { + tmpBuildEnv = append([]string{fmt.Sprintf("|%d", len(tmpBuildEnv))}, tmpBuildEnv...) + } + + return strings.Join(append(tmpBuildEnv, args...), " ") +} + +func commitToHistory(img *Image, msg string, withLayer bool, st *llb.State) error { + if st != nil { + def, err := st.Marshal() + if err != nil { + return err + } + msg += " # buildkit:" + digest.FromBytes(def.Def[len(def.Def)-1]).String() + } + + tm := time.Now().UTC() + img.History = append(img.History, ocispec.History{ + Created: &tm, + CreatedBy: msg, + Comment: historyComment, + EmptyLayer: !withLayer, + }) + return nil +} diff --git a/frontend/dockerfile/dockerfile_test.go b/frontend/dockerfile/dockerfile_test.go index b0b47d2bbe61..b1487f3df7f5 100644 --- a/frontend/dockerfile/dockerfile_test.go +++ b/frontend/dockerfile/dockerfile_test.go @@ -34,6 +34,7 @@ func TestIntegration(t *testing.T) { testDockerfileADDFromURL, testDockerfileAddArchive, testDockerfileScratchConfig, + testExportedHistory, }) } @@ -404,6 +405,11 @@ ENV foo=bar require.NotEqual(t, "", ociimg.Architecture) require.NotEqual(t, "", ociimg.Config.WorkingDir) require.Equal(t, "layers", ociimg.RootFS.Type) + require.Equal(t, 0, len(ociimg.RootFS.DiffIDs)) + + require.Equal(t, 1, len(ociimg.History)) + require.Contains(t, ociimg.History[0].CreatedBy, "ENV foo=bar") + require.Equal(t, true, ociimg.History[0].EmptyLayer) require.Contains(t, ociimg.Config.Env, "foo=bar") require.Condition(t, func() bool { @@ -416,6 +422,80 @@ ENV foo=bar }) } +func testExportedHistory(t *testing.T, sb integration.Sandbox) { + t.Parallel() + + // using multi-stage to test that history is scoped to one stage + dockerfile := []byte(` +FROM busybox AS base +ENV foo=bar +COPY foo /foo2 +FROM busybox +COPY --from=base foo2 foo3 +WORKDIR / +RUN echo bar > foo4 +RUN ["ls"] +`) + + dir, err := tmpdir( + fstest.CreateFile("Dockerfile", dockerfile, 0600), + fstest.CreateFile("foo", []byte("contents0"), 0600), + ) + require.NoError(t, err) + defer os.RemoveAll(dir) + + args, trace := dfCmdArgs(dir, dir) + defer os.RemoveAll(trace) + + target := "example.com/moby/dockerfilescratch:test" + cmd := sb.Cmd(args + " --exporter=image --exporter-opt=name=" + target) + require.NoError(t, cmd.Run()) + + // TODO: expose this test to standalone + + var cdAddress string + if cd, ok := sb.(interface { + ContainerdAddress() string + }); !ok { + t.Skip("only for containerd worker") + } else { + cdAddress = cd.ContainerdAddress() + } + + client, err := containerd.New(cdAddress) + require.NoError(t, err) + defer client.Close() + + ctx := namespaces.WithNamespace(context.Background(), "buildkit") + + img, err := client.ImageService().Get(ctx, target) + require.NoError(t, err) + + desc, err := img.Config(ctx, client.ContentStore(), platforms.Default()) + require.NoError(t, err) + + dt, err := content.ReadBlob(ctx, client.ContentStore(), desc.Digest) + require.NoError(t, err) + + var ociimg ocispec.Image + err = json.Unmarshal(dt, &ociimg) + require.NoError(t, err) + + require.Equal(t, "layers", ociimg.RootFS.Type) + // this depends on busybox. should be ok after freezing images + require.Equal(t, 3, len(ociimg.RootFS.DiffIDs)) + + require.Equal(t, 6, len(ociimg.History)) + require.Contains(t, ociimg.History[2].CreatedBy, "COPY foo2 foo3") + require.Equal(t, false, ociimg.History[2].EmptyLayer) + require.Contains(t, ociimg.History[3].CreatedBy, "WORKDIR /") + require.Equal(t, true, ociimg.History[3].EmptyLayer) + require.Contains(t, ociimg.History[4].CreatedBy, "echo bar > foo4") + require.Equal(t, false, ociimg.History[4].EmptyLayer) + require.Contains(t, ociimg.History[5].CreatedBy, "RUN ls") + require.Equal(t, true, ociimg.History[5].EmptyLayer) +} + func tmpdir(appliers ...fstest.Applier) (string, error) { tmpdir, err := ioutil.TempDir("", "buildkit-dockerfile") if err != nil {