diff --git a/client/build_test.go b/client/build_test.go index 35a587b28926..27af3631b50c 100644 --- a/client/build_test.go +++ b/client/build_test.go @@ -28,7 +28,6 @@ import ( "github.com/moby/buildkit/util/testutil/echoserver" "github.com/moby/buildkit/util/testutil/integration" "github.com/moby/buildkit/util/testutil/workers" - digest "github.com/opencontainers/go-digest" "github.com/pkg/errors" "github.com/stretchr/testify/require" "github.com/tonistiigi/fsutil" @@ -210,43 +209,14 @@ func testWarnings(t *testing.T, sb integration.Sandbox) { return r, nil } - status := make(chan *SolveStatus) - statusDone := make(chan struct{}) - done := make(chan struct{}) + wc := newWarningsCapture() - var warnings []*VertexWarning - vertexes := map[digest.Digest]struct{}{} - - go func() { - defer close(statusDone) - for { - select { - case st, ok := <-status: - if !ok { - return - } - for _, s := range st.Vertexes { - vertexes[s.Digest] = struct{}{} - } - warnings = append(warnings, st.Warnings...) - case <-done: - return - } - } - }() - - _, err = c.Build(ctx, SolveOpt{}, product, b, status) + _, err = c.Build(ctx, SolveOpt{}, product, b, wc.status) require.NoError(t, err) - select { - case <-statusDone: - case <-time.After(10 * time.Second): - close(done) - } - - <-statusDone + warnings := wc.wait() - require.Equal(t, 1, len(vertexes)) + require.Equal(t, 1, len(wc.vertexes)) require.Equal(t, 1, len(warnings)) w := warnings[0] @@ -257,7 +227,7 @@ func testWarnings(t *testing.T, sb integration.Sandbox) { require.Equal(t, "and more detail", string(w.Detail[1])) require.Equal(t, "https://example.com", w.URL) require.Equal(t, 3, w.Level) - _, ok := vertexes[w.Vertex] + _, ok := wc.vertexes[w.Vertex] require.True(t, ok) require.Equal(t, "mydockerfile", w.SourceInfo.Filename) diff --git a/client/client_test.go b/client/client_test.go index 33614fa6c1aa..a868e35fed05 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -219,6 +219,7 @@ var allTests = []func(t *testing.T, sb integration.Sandbox){ testSolverOptLocalDirsStillWorks, testOCIIndexMediatype, testLayerLimitOnMounts, + testFrontendVerifyPlatforms, } func TestIntegration(t *testing.T) { @@ -9999,6 +10000,166 @@ func testMountStubsTimestamp(t *testing.T, sb integration.Sandbox) { } } +func testFrontendVerifyPlatforms(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + st := llb.Scratch().File( + llb.Mkfile("foo", 0600, []byte("data")), + ) + + def, err := st.Marshal(sb.Context()) + if err != nil { + return nil, err + } + + return c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + } + + wc := newWarningsCapture() + _, err = c.Build(sb.Context(), SolveOpt{ + FrontendAttrs: map[string]string{ + "platform": "linux/amd64,linux/arm64", + }, + }, "", frontend, wc.status) + require.NoError(t, err) + warnings := wc.wait() + + require.Len(t, warnings, 1) + require.Contains(t, string(warnings[0].Short), "Multiple platforms requested but result is not multi-platform") + + wc = newWarningsCapture() + _, err = c.Build(sb.Context(), SolveOpt{ + FrontendAttrs: map[string]string{}, + }, "", frontend, wc.status) + require.NoError(t, err) + + warnings = wc.wait() + require.Len(t, warnings, 0) + + frontend = func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + platformsToTest := []string{"linux/amd64", "linux/arm64"} + expPlatforms := &exptypes.Platforms{ + Platforms: make([]exptypes.Platform, len(platformsToTest)), + } + for i, platform := range platformsToTest { + st := llb.Scratch().File( + llb.Mkfile("platform", 0600, []byte(platform)), + ) + + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + + r, err := c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + + ref, err := r.SingleRef() + if err != nil { + return nil, err + } + + _, err = ref.ToState() + if err != nil { + return nil, err + } + res.AddRef(platform, ref) + + expPlatforms.Platforms[i] = exptypes.Platform{ + ID: platform, + Platform: platforms.MustParse(platform), + } + } + dt, err := json.Marshal(expPlatforms) + if err != nil { + return nil, err + } + res.AddMeta(exptypes.ExporterPlatformsKey, dt) + + return res, nil + } + + wc = newWarningsCapture() + _, err = c.Build(sb.Context(), SolveOpt{ + FrontendAttrs: map[string]string{ + "platform": "linux/amd64,linux/arm64", + }, + }, "", frontend, wc.status) + require.NoError(t, err) + warnings = wc.wait() + + require.Len(t, warnings, 0) + + wc = newWarningsCapture() + _, err = c.Build(sb.Context(), SolveOpt{ + FrontendAttrs: map[string]string{}, + }, "", frontend, wc.status) + require.NoError(t, err) + + warnings = wc.wait() + require.Len(t, warnings, 1) + require.Contains(t, string(warnings[0].Short), "do not match result platforms linux/amd64,linux/arm64") +} + +type warningsCapture struct { + status chan *SolveStatus + statusDone chan struct{} + done chan struct{} + warnings []*VertexWarning + vertexes map[digest.Digest]struct{} +} + +func newWarningsCapture() *warningsCapture { + w := &warningsCapture{ + status: make(chan *SolveStatus), + statusDone: make(chan struct{}), + done: make(chan struct{}), + vertexes: map[digest.Digest]struct{}{}, + } + + go func() { + defer close(w.statusDone) + for { + select { + case st, ok := <-w.status: + if !ok { + return + } + for _, s := range st.Vertexes { + w.vertexes[s.Digest] = struct{}{} + } + w.warnings = append(w.warnings, st.Warnings...) + case <-w.done: + return + } + } + }() + + return w +} + +func (w *warningsCapture) wait() []*VertexWarning { + select { + case <-w.statusDone: + case <-time.After(10 * time.Second): + close(w.done) + } + + <-w.statusDone + return w.warnings +} + func ensureFile(t *testing.T, path string) { st, err := os.Stat(path) require.NoError(t, err, "expected file at %s", path) diff --git a/exporter/verifier/opts.go b/exporter/verifier/opts.go new file mode 100644 index 000000000000..48cba10a6498 --- /dev/null +++ b/exporter/verifier/opts.go @@ -0,0 +1,59 @@ +package verifier + +import ( + "encoding/json" + "strings" + + "github.com/containerd/containerd/platforms" + "github.com/moby/buildkit/solver/result" +) + +const requestOptsKeys = "verifier.requestopts" + +const ( + platformsKey = "platform" + labelsPrefix = "label:" + keyRequestID = "requestid" +) + +type RequestOpts struct { + Platforms []string + Labels map[string]string + Request string +} + +func CaptureFrontendOpts[T comparable](m map[string]string, res *result.Result[T]) error { + req := &RequestOpts{} + if v, ok := m[platformsKey]; ok { + req.Platforms = strings.Split(v, ",") + } else { + req.Platforms = []string{platforms.Format(platforms.Normalize(platforms.DefaultSpec()))} + } + + req.Labels = map[string]string{} + for k, v := range m { + if strings.HasPrefix(k, labelsPrefix) { + req.Labels[strings.TrimPrefix(k, labelsPrefix)] = v + } + } + req.Request = m[keyRequestID] + + dt, err := json.Marshal(req) + if err != nil { + return err + } + res.AddMeta(requestOptsKeys, dt) + return nil +} + +func getRequestOpts[T comparable](res *result.Result[T]) (*RequestOpts, error) { + dt, ok := res.Metadata[requestOptsKeys] + if !ok { + return nil, nil + } + req := &RequestOpts{} + if err := json.Unmarshal(dt, req); err != nil { + return nil, err + } + return req, nil +} diff --git a/exporter/verifier/platforms.go b/exporter/verifier/platforms.go new file mode 100644 index 000000000000..4517ecee18e4 --- /dev/null +++ b/exporter/verifier/platforms.go @@ -0,0 +1,107 @@ +package verifier + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/containerd/containerd/platforms" + "github.com/moby/buildkit/client" + "github.com/moby/buildkit/exporter/containerimage/exptypes" + "github.com/moby/buildkit/solver/result" + "github.com/pkg/errors" +) + +func CheckInvalidPlatforms[T comparable](ctx context.Context, res *result.Result[T]) ([]client.VertexWarning, error) { + req, err := getRequestOpts(res) + if err != nil { + return nil, err + } + + if req.Request != "" { + return nil, nil + } + + if _, ok := res.Metadata[exptypes.ExporterPlatformsKey]; len(res.Refs) > 0 && !ok { + return nil, errors.Errorf("build result contains multiple refs without platforms mapping") + } + + isMap := len(res.Refs) > 0 + + ps, err := exptypes.ParsePlatforms(res.Metadata) + if err != nil { + return nil, err + } + + warnings := []client.VertexWarning{} + reqMap := map[string]struct{}{} + reqList := []exptypes.Platform{} + + for _, v := range req.Platforms { + p, err := platforms.Parse(v) + if err != nil { + warnings = append(warnings, client.VertexWarning{ + Short: []byte(fmt.Sprintf("Invalid platform result requested %q: %s", v, err.Error())), + }) + } + p = platforms.Normalize(p) + _, ok := reqMap[platforms.Format(p)] + if ok { + warnings = append(warnings, client.VertexWarning{ + Short: []byte(fmt.Sprintf("Duplicate platform result requested %q", v)), + }) + } + reqMap[platforms.Format(p)] = struct{}{} + reqList = append(reqList, exptypes.Platform{Platform: p}) + } + + if len(warnings) > 0 { + return warnings, nil + } + + if len(reqMap) == 1 && len(ps.Platforms) == 1 { + pp := platforms.Normalize(ps.Platforms[0].Platform) + if _, ok := reqMap[platforms.Format(pp)]; !ok { + return []client.VertexWarning{{ + Short: []byte(fmt.Sprintf("Requested platform %q does not match result platform %q", req.Platforms[0], platforms.Format(pp))), + }}, nil + } + return nil, nil + } + + if !isMap && len(reqMap) > 1 { + return []client.VertexWarning{{ + Short: []byte("Multiple platforms requested but result is not multi-platform"), + }}, nil + } + + mismatch := len(reqMap) != len(ps.Platforms) + + if !mismatch { + for _, p := range ps.Platforms { + pp := platforms.Normalize(p.Platform) + if _, ok := reqMap[platforms.Format(pp)]; !ok { + mismatch = true + break + } + } + } + + if mismatch { + return []client.VertexWarning{{ + Short: []byte(fmt.Sprintf("Requested platforms %s do not match result platforms %s", platformsString(reqList), platformsString(ps.Platforms))), + }}, nil + } + + return nil, nil +} + +func platformsString(ps []exptypes.Platform) string { + var ss []string + for _, p := range ps { + ss = append(ss, platforms.Format(platforms.Normalize(p.Platform))) + } + sort.Strings(ss) + return strings.Join(ss, ",") +} diff --git a/solver/llbsolver/solver.go b/solver/llbsolver/solver.go index 837e002a0efe..3fd0a7932031 100644 --- a/solver/llbsolver/solver.go +++ b/solver/llbsolver/solver.go @@ -21,6 +21,7 @@ import ( resourcestypes "github.com/moby/buildkit/executor/resources/types" "github.com/moby/buildkit/exporter" "github.com/moby/buildkit/exporter/containerimage/exptypes" + "github.com/moby/buildkit/exporter/verifier" "github.com/moby/buildkit/frontend" "github.com/moby/buildkit/frontend/attestations" "github.com/moby/buildkit/frontend/gateway" @@ -535,6 +536,10 @@ func (s *Solver) Solve(ctx context.Context, id string, sessionID string, req fro res = &frontend.Result{} } + if err := verifier.CaptureFrontendOpts(req.FrontendOpt, res); err != nil { + return nil, err + } + releasers = append(releasers, func() { res.EachRef(func(ref solver.ResultProxy) error { go ref.Release(context.TODO()) @@ -709,6 +714,11 @@ func runInlineCacheExporter(ctx context.Context, e exporter.ExporterInstance, in } func (s *Solver) runExporters(ctx context.Context, exporters []exporter.ExporterInstance, inlineCacheExporter inlineCacheExporter, job *solver.Job, cached *result.Result[solver.CachedResult], inp *result.Result[cache.ImmutableRef]) (exporterResponse map[string]string, descrefs []exporter.DescriptorReference, err error) { + warnings, err := verifier.CheckInvalidPlatforms(ctx, inp) + if err != nil { + return nil, nil, err + } + eg, ctx := errgroup.WithContext(ctx) resps := make([]map[string]string, len(exporters)) descs := make([]exporter.DescriptorReference, len(exporters)) @@ -717,6 +727,15 @@ func (s *Solver) runExporters(ctx context.Context, exporters []exporter.Exporter eg.Go(func() error { id := fmt.Sprint(job.SessionID, "-export-", i) return inBuilderContext(ctx, job, exp.Name(), id, func(ctx context.Context, _ session.Group) error { + if i == 0 && len(warnings) > 0 { + pw, _, _ := progress.NewFromContext(ctx) + for _, w := range warnings { + pw.Write(identity.NewID(), w) + } + if err := pw.Close(); err != nil { + return err + } + } inlineCache := exptypes.InlineCache(func(ctx context.Context) (*result.Result[*exptypes.InlineCacheEntry], error) { return runInlineCacheExporter(ctx, exp, inlineCacheExporter, job, cached) }) @@ -733,6 +752,19 @@ func (s *Solver) runExporters(ctx context.Context, exporters []exporter.Exporter return nil, nil, err } + if len(exporters) == 0 && len(warnings) > 0 { + err := inBuilderContext(ctx, job, "Verifying build result", identity.NewID(), func(ctx context.Context, _ session.Group) error { + pw, _, _ := progress.NewFromContext(ctx) + for _, w := range warnings { + pw.Write(identity.NewID(), w) + } + return pw.Close() + }) + if err != nil { + return nil, nil, err + } + } + // TODO: separate these out, and return multiple exporter responses to the // client for _, resp := range resps {