diff --git a/client/client_test.go b/client/client_test.go index 089a62883958..d40c9b6f3805 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -7277,6 +7277,62 @@ func testExportAttestations(t *testing.T, sb integration.Sandbox) { require.Equal(t, subjects, attest2.Subject) } }) + + t.Run("tar", func(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "out.tar") + outW, err := os.Create(out) + require.NoError(t, err) + + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterTar, + Output: fixedWriteCloser(outW), + Attrs: map[string]string{ + "attestation-prefix": "test.", + }, + }, + }, + }, "", frontend, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(out) + require.NoError(t, err) + + m, err := testutil.ReadTarToMap(dt, false) + require.NoError(t, err) + + for _, p := range ps { + var attest intoto.Statement + dt := m[path.Join(strings.ReplaceAll(platforms.Format(p), "/", "_"), "test.attestation.json")].Data + require.NoError(t, json.Unmarshal(dt, &attest)) + + require.Equal(t, "https://in-toto.io/Statement/v0.1", attest.Type) + require.Equal(t, "https://example.com/attestations/v1.0", attest.PredicateType) + require.Equal(t, map[string]interface{}{"success": true}, attest.Predicate) + + require.Equal(t, []intoto.Subject{{ + Name: "greeting", + Digest: result.ToDigestMap(digest.Canonical.FromString("hello " + platforms.Format(p) + "!")), + }}, attest.Subject) + + var attest2 intoto.Statement + dt = m[path.Join(strings.ReplaceAll(platforms.Format(p), "/", "_"), "test.attestation2.json")].Data + require.NoError(t, json.Unmarshal(dt, &attest2)) + + require.Equal(t, "https://in-toto.io/Statement/v0.1", attest2.Type) + require.Equal(t, "https://example.com/attestations2/v1.0", attest2.PredicateType) + require.Nil(t, attest2.Predicate) + subjects := []intoto.Subject{{ + Name: "/attestation.json", + Digest: map[string]string{ + "sha256": successDigest.Encoded(), + }, + }} + require.Equal(t, subjects, attest2.Subject) + } + }) } func testAttestationDefaultSubject(t *testing.T, sb integration.Sandbox) { diff --git a/exporter/local/export.go b/exporter/local/export.go index a822d1bb4386..381e68f4b87e 100644 --- a/exporter/local/export.go +++ b/exporter/local/export.go @@ -3,28 +3,18 @@ package local import ( "context" "encoding/json" - "io" - "io/fs" "os" - "path" "strings" "time" - "github.com/docker/docker/pkg/idtools" - intoto "github.com/in-toto/in-toto-golang/in_toto" "github.com/moby/buildkit/cache" "github.com/moby/buildkit/exporter" - "github.com/moby/buildkit/exporter/attestation" "github.com/moby/buildkit/exporter/containerimage/exptypes" "github.com/moby/buildkit/exporter/util/epoch" "github.com/moby/buildkit/session" "github.com/moby/buildkit/session/filesync" - "github.com/moby/buildkit/snapshot" "github.com/moby/buildkit/solver/result" "github.com/moby/buildkit/util/progress" - "github.com/moby/buildkit/util/staticfs" - digest "github.com/opencontainers/go-digest" - ocispecs "github.com/opencontainers/image-spec/specs-go/v1" "github.com/pkg/errors" "github.com/tonistiigi/fsutil" fstypes "github.com/tonistiigi/fsutil/types" @@ -56,12 +46,17 @@ func (e *localExporter) Resolve(ctx context.Context, opt map[string]string) (exp return nil, err } - i := &localExporterInstance{localExporter: e, epoch: tm} + i := &localExporterInstance{ + localExporter: e, + opts: CreateFSOpts{ + Epoch: tm, + }, + } for k, v := range opt { switch k { case keyAttestationPrefix: - i.attestationPrefix = v + i.opts.AttestationPrefix = v } } @@ -70,8 +65,7 @@ func (e *localExporter) Resolve(ctx context.Context, opt map[string]string) (exp type localExporterInstance struct { *localExporter - epoch *time.Time - attestationPrefix string + opts CreateFSOpts } func (e *localExporterInstance) Name() string { @@ -86,11 +80,11 @@ func (e *localExporterInstance) Export(ctx context.Context, inp *exporter.Source timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() - if e.epoch == nil { + if e.opts.Epoch == nil { if tm, ok, err := epoch.ParseSource(inp); err != nil { return nil, err } else if ok { - e.epoch = tm + e.opts.Epoch = tm } } @@ -114,127 +108,14 @@ func (e *localExporterInstance) Export(ctx context.Context, inp *exporter.Source now := time.Now().Truncate(time.Second) - export := func(ctx context.Context, k string, p *ocispecs.Platform, ref cache.ImmutableRef, attestations []result.Attestation) func() error { + export := func(ctx context.Context, k string, ref cache.ImmutableRef, attestations []result.Attestation) func() error { return func() error { - var src string - var err error - var idmap *idtools.IdentityMapping - if ref == nil { - src, err = os.MkdirTemp("", "buildkit") - if err != nil { - return err - } - defer os.RemoveAll(src) - } else { - mount, err := ref.Mount(ctx, true, session.NewGroup(sessionID)) - if err != nil { - return err - } - - lm := snapshot.LocalMounter(mount) - - src, err = lm.Mount() - if err != nil { - return err - } - - idmap = mount.IdentityMapping() - - defer lm.Unmount() - } - - walkOpt := &fsutil.WalkOpt{} - var idMapFunc func(p string, st *fstypes.Stat) fsutil.MapResult - if idmap != nil { - idMapFunc = func(p string, st *fstypes.Stat) fsutil.MapResult { - uid, gid, err := idmap.ToContainer(idtools.Identity{ - UID: int(st.Uid), - GID: int(st.Gid), - }) - if err != nil { - return fsutil.MapResultExclude - } - st.Uid = uint32(uid) - st.Gid = uint32(gid) - return fsutil.MapResultKeep - } - } - walkOpt.Map = func(p string, st *fstypes.Stat) fsutil.MapResult { - res := fsutil.MapResultKeep - if idMapFunc != nil { - res = idMapFunc(p, st) - } - if e.epoch != nil { - st.ModTime = e.epoch.UnixNano() - } - return res - } - - outputFS := fsutil.NewFS(src, walkOpt) - - attestations, err = attestation.Unbundle(ctx, session.NewGroup(sessionID), inp.Refs, attestations) + outputFS, cleanup, err := CreateFS(ctx, sessionID, k, ref, inp.Refs, attestations, now, e.opts) if err != nil { return err } - if len(attestations) > 0 { - subjects := []intoto.Subject{} - err = outputFS.Walk(ctx, func(path string, info fs.FileInfo, err error) error { - if err != nil { - return err - } - if !info.Mode().IsRegular() { - return nil - } - f, err := outputFS.Open(path) - if err != nil { - return err - } - defer f.Close() - d := digest.Canonical.Digester() - if _, err := io.Copy(d.Hash(), f); err != nil { - return err - } - subjects = append(subjects, intoto.Subject{ - Name: path, - Digest: result.ToDigestMap(d.Digest()), - }) - return nil - }) - if err != nil { - return err - } - - stmts, err := attestation.MakeInTotoStatements(ctx, session.NewGroup(sessionID), inp.Refs, attestations, subjects) - if err != nil { - return err - } - stmtFS := staticfs.NewFS() - - names := map[string]struct{}{} - for i, stmt := range stmts { - dt, err := json.Marshal(stmt) - if err != nil { - return errors.Wrap(err, "failed to marshal attestation") - } - - if attestations[i].Path == "" { - return errors.New("attestation does not have set path") - } - name := e.attestationPrefix + path.Base(attestations[i].Path) - if _, ok := names[name]; ok { - return errors.Errorf("duplicate attestation path name %s", name) - } - names[name] = struct{}{} - - st := fstypes.Stat{ - Mode: 0600, - Path: name, - ModTime: now.UnixNano(), - } - stmtFS.Add(name, st, dt) - } - - outputFS = staticfs.NewMergeFS(outputFS, stmtFS) + if cleanup != nil { + defer cleanup() } lbl := "copying files" @@ -244,8 +125,8 @@ func (e *localExporterInstance) Export(ctx context.Context, inp *exporter.Source Mode: uint32(os.ModeDir | 0755), Path: strings.Replace(k, "/", "_", -1), } - if e.epoch != nil { - st.ModTime = e.epoch.UnixNano() + if e.opts.Epoch != nil { + st.ModTime = e.opts.Epoch.UnixNano() } outputFS, err = fsutil.SubDirFS([]fsutil.Dir{{FS: outputFS, Stat: st}}) @@ -270,13 +151,13 @@ func (e *localExporterInstance) Export(ctx context.Context, inp *exporter.Source if !ok { return nil, errors.Errorf("failed to find ref for ID %s", p.ID) } - eg.Go(export(ctx, p.ID, &p.Platform, r, inp.Attestations[p.ID])) + eg.Go(export(ctx, p.ID, r, inp.Attestations[p.ID])) if !isMap { break } } } else { - eg.Go(export(ctx, "", nil, inp.Ref, nil)) + eg.Go(export(ctx, "", inp.Ref, nil)) } if err := eg.Wait(); err != nil { diff --git a/exporter/local/fs.go b/exporter/local/fs.go new file mode 100644 index 000000000000..96f9b7b2f9fb --- /dev/null +++ b/exporter/local/fs.go @@ -0,0 +1,159 @@ +package local + +import ( + "context" + "encoding/json" + "io" + "io/fs" + "os" + "path" + "time" + + "github.com/docker/docker/pkg/idtools" + intoto "github.com/in-toto/in-toto-golang/in_toto" + "github.com/moby/buildkit/cache" + "github.com/moby/buildkit/exporter/attestation" + "github.com/moby/buildkit/session" + "github.com/moby/buildkit/snapshot" + "github.com/moby/buildkit/solver/result" + "github.com/moby/buildkit/util/staticfs" + digest "github.com/opencontainers/go-digest" + "github.com/pkg/errors" + "github.com/tonistiigi/fsutil" + fstypes "github.com/tonistiigi/fsutil/types" +) + +type CreateFSOpts struct { + Epoch *time.Time + AttestationPrefix string +} + +func CreateFS(ctx context.Context, sessionID string, k string, ref cache.ImmutableRef, refs map[string]cache.ImmutableRef, attestations []result.Attestation, defaultTime time.Time, opt CreateFSOpts) (fsutil.FS, func() error, error) { + var cleanup func() error + var src string + var err error + var idmap *idtools.IdentityMapping + if ref == nil { + src, err = os.MkdirTemp("", "buildkit") + if err != nil { + return nil, nil, err + } + cleanup = func() error { return os.RemoveAll(src) } + } else { + mount, err := ref.Mount(ctx, true, session.NewGroup(sessionID)) + if err != nil { + return nil, nil, err + } + + lm := snapshot.LocalMounter(mount) + + src, err = lm.Mount() + if err != nil { + return nil, nil, err + } + + idmap = mount.IdentityMapping() + + cleanup = lm.Unmount + } + + walkOpt := &fsutil.WalkOpt{} + var idMapFunc func(p string, st *fstypes.Stat) fsutil.MapResult + + if idmap != nil { + idMapFunc = func(p string, st *fstypes.Stat) fsutil.MapResult { + uid, gid, err := idmap.ToContainer(idtools.Identity{ + UID: int(st.Uid), + GID: int(st.Gid), + }) + if err != nil { + return fsutil.MapResultExclude + } + st.Uid = uint32(uid) + st.Gid = uint32(gid) + return fsutil.MapResultKeep + } + } + + walkOpt.Map = func(p string, st *fstypes.Stat) fsutil.MapResult { + res := fsutil.MapResultKeep + if idMapFunc != nil { + res = idMapFunc(p, st) + } + if opt.Epoch != nil { + st.ModTime = opt.Epoch.UnixNano() + } + return res + } + + outputFS := fsutil.NewFS(src, walkOpt) + attestations, err = attestation.Unbundle(ctx, session.NewGroup(sessionID), refs, attestations) + if err != nil { + return nil, nil, err + } + if len(attestations) > 0 { + subjects := []intoto.Subject{} + err = outputFS.Walk(ctx, func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return nil + } + f, err := outputFS.Open(path) + if err != nil { + return err + } + defer f.Close() + d := digest.Canonical.Digester() + if _, err := io.Copy(d.Hash(), f); err != nil { + return err + } + subjects = append(subjects, intoto.Subject{ + Name: path, + Digest: result.ToDigestMap(d.Digest()), + }) + return nil + }) + if err != nil { + return nil, nil, err + } + + stmts, err := attestation.MakeInTotoStatements(ctx, session.NewGroup(sessionID), refs, attestations, subjects) + if err != nil { + return nil, nil, err + } + stmtFS := staticfs.NewFS() + + names := map[string]struct{}{} + for i, stmt := range stmts { + dt, err := json.Marshal(stmt) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to marshal attestation") + } + + if attestations[i].Path == "" { + return nil, nil, errors.New("attestation does not have set path") + } + name := opt.AttestationPrefix + path.Base(attestations[i].Path) + if _, ok := names[name]; ok { + return nil, nil, errors.Errorf("duplicate attestation path name %s", name) + } + names[name] = struct{}{} + + st := fstypes.Stat{ + Mode: 0600, + Path: name, + ModTime: defaultTime.UnixNano(), + } + if opt.Epoch != nil { + st.ModTime = opt.Epoch.UnixNano() + } + stmtFS.Add(name, st, dt) + } + + outputFS = staticfs.NewMergeFS(outputFS, stmtFS) + } + + return outputFS, cleanup, nil +} diff --git a/exporter/tar/export.go b/exporter/tar/export.go index 56e2a8f6f9c8..42c6313fddb6 100644 --- a/exporter/tar/export.go +++ b/exporter/tar/export.go @@ -8,14 +8,14 @@ import ( "strings" "time" - "github.com/docker/docker/pkg/idtools" "github.com/moby/buildkit/cache" "github.com/moby/buildkit/exporter" "github.com/moby/buildkit/exporter/containerimage/exptypes" + "github.com/moby/buildkit/exporter/local" "github.com/moby/buildkit/exporter/util/epoch" "github.com/moby/buildkit/session" "github.com/moby/buildkit/session/filesync" - "github.com/moby/buildkit/snapshot" + "github.com/moby/buildkit/solver/result" "github.com/moby/buildkit/util/progress" "github.com/pkg/errors" "github.com/tonistiigi/fsutil" @@ -23,6 +23,8 @@ import ( ) const ( + attestationPrefixKey = "attestation-prefix" + // preferNondistLayersKey is an exporter option which can be used to mark a layer as non-distributable if the layer reference was // already found to use a non-distributable media type. // When this option is not set, the exporter will change the media type of the layer to a distributable one. @@ -50,15 +52,19 @@ func (e *localExporter) Resolve(ctx context.Context, opt map[string]string) (exp if err != nil { return nil, err } - li.epoch = tm + li.opts.Epoch = tm - v, ok := opt[preferNondistLayersKey] - if ok { - b, err := strconv.ParseBool(v) - if err != nil { - return nil, errors.Wrapf(err, "non-bool value for %s: %s", preferNondistLayersKey, v) + for k, v := range opt { + switch k { + case preferNondistLayersKey: + b, err := strconv.ParseBool(v) + if err != nil { + return nil, errors.Wrapf(err, "non-bool value for %s: %s", preferNondistLayersKey, v) + } + li.preferNonDist = b + case attestationPrefixKey: + li.opts.AttestationPrefix = v } - li.preferNonDist = b } return li, nil @@ -66,8 +72,8 @@ func (e *localExporter) Resolve(ctx context.Context, opt map[string]string) (exp type localExporterInstance struct { *localExporter + opts local.CreateFSOpts preferNonDist bool - epoch *time.Time } func (e *localExporterInstance) Name() string { @@ -79,7 +85,7 @@ func (e *localExporterInstance) Config() *exporter.Config { } func (e *localExporterInstance) Export(ctx context.Context, inp *exporter.Source, sessionID string) (map[string]string, error) { - var defers []func() + var defers []func() error defer func() { for i := len(defers) - 1; i >= 0; i-- { @@ -87,81 +93,35 @@ func (e *localExporterInstance) Export(ctx context.Context, inp *exporter.Source } }() - if e.epoch == nil { + if e.opts.Epoch == nil { if tm, ok, err := epoch.ParseSource(inp); err != nil { return nil, err } else if ok { - e.epoch = tm + e.opts.Epoch = tm } } - getDir := func(ctx context.Context, k string, ref cache.ImmutableRef) (*fsutil.Dir, error) { - var src string - var err error - var idmap *idtools.IdentityMapping - if ref == nil { - src, err = os.MkdirTemp("", "buildkit") - if err != nil { - return nil, err - } - defers = append(defers, func() { os.RemoveAll(src) }) - } else { - mount, err := ref.Mount(ctx, true, session.NewGroup(sessionID)) - if err != nil { - return nil, err - } - - lm := snapshot.LocalMounter(mount) - - src, err = lm.Mount() - if err != nil { - return nil, err - } - - idmap = mount.IdentityMapping() - - defers = append(defers, func() { lm.Unmount() }) - } + now := time.Now().Truncate(time.Second) - walkOpt := &fsutil.WalkOpt{} - var idMapFunc func(p string, st *fstypes.Stat) fsutil.MapResult - - if idmap != nil { - idMapFunc = func(p string, st *fstypes.Stat) fsutil.MapResult { - uid, gid, err := idmap.ToContainer(idtools.Identity{ - UID: int(st.Uid), - GID: int(st.Gid), - }) - if err != nil { - return fsutil.MapResultExclude - } - st.Uid = uint32(uid) - st.Gid = uint32(gid) - return fsutil.MapResultKeep - } + getDir := func(ctx context.Context, k string, ref cache.ImmutableRef, attestations []result.Attestation) (*fsutil.Dir, error) { + outputFS, cleanup, err := local.CreateFS(ctx, sessionID, k, ref, inp.Refs, attestations, now, e.opts) + if err != nil { + return nil, err } - - walkOpt.Map = func(p string, st *fstypes.Stat) fsutil.MapResult { - res := fsutil.MapResultKeep - if idMapFunc != nil { - res = idMapFunc(p, st) - } - if e.epoch != nil { - st.ModTime = e.epoch.UnixNano() - } - return res + if cleanup != nil { + defers = append(defers, cleanup) } st := fstypes.Stat{ Mode: uint32(os.ModeDir | 0755), Path: strings.Replace(k, "/", "_", -1), } - if e.epoch != nil { - st.ModTime = e.epoch.UnixNano() + if e.opts.Epoch != nil { + st.ModTime = e.opts.Epoch.UnixNano() } return &fsutil.Dir{ - FS: fsutil.NewFS(src, walkOpt), + FS: outputFS, Stat: st, }, nil } @@ -188,7 +148,7 @@ func (e *localExporterInstance) Export(ctx context.Context, inp *exporter.Source if !ok { return nil, errors.Errorf("failed to find ref for ID %s", p.ID) } - d, err := getDir(ctx, p.ID, r) + d, err := getDir(ctx, p.ID, r, inp.Attestations[p.ID]) if err != nil { return nil, err } @@ -206,7 +166,7 @@ func (e *localExporterInstance) Export(ctx context.Context, inp *exporter.Source } } } else { - d, err := getDir(ctx, "", inp.Ref) + d, err := getDir(ctx, "", inp.Ref, nil) if err != nil { return nil, err }