diff --git a/client/client_test.go b/client/client_test.go index 4ea9da48dc34..7cf6582920ae 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -5194,12 +5194,13 @@ func testBuildInfoInline(t *testing.T, sb integration.Sandbox) { require.NoError(t, err) var config binfotypes.ImageConfig - err = json.Unmarshal(dt, &config) + require.NoError(t, json.Unmarshal(dt, &config)) + + dec, err := base64.StdEncoding.DecodeString(config.BuildInfo) require.NoError(t, err) var bi binfotypes.BuildInfo - err = json.Unmarshal(config.BuildInfo, &bi) - require.NoError(t, err) + require.NoError(t, json.Unmarshal(dec, &bi)) if tt.buildAttrs { attrval := "bar" diff --git a/docs/build-repro.md b/docs/build-repro.md index f8356d75eaa5..4c11bd5755a1 100644 --- a/docs/build-repro.md +++ b/docs/build-repro.md @@ -6,18 +6,6 @@ Build dependencies are generated when your image has been built. These dependencies include versions of used images, git repositories and HTTP URLs used by LLB `Source` operation as well as build request attributes. -By default, the build dependencies are inlined in the image configuration. You -can disable this behavior with the [`buildinfo` attribute](../README.md#imageregistry). - -### Image config - -A new field similar to the one for inline cache has been added to the image -configuration to embed build dependencies: - -```text -"moby.buildkit.buildinfo.v1": -``` - The structure is base64 encoded and has the following format when decoded: ```json @@ -57,10 +45,25 @@ The structure is base64 encoded and has the following format when decoded: * `frontend` defines the frontend used to build. * `attrs` defines build request attributes. -* `sources` defines build dependencies. +* `sources` defines build sources. * `type` defines the source type (`docker-image`, `git` or `http`). * `ref` is the reference of the source. * `pin` is the source digest. +* `deps` defines build dependencies of input contexts. + +### Image config + +A new field similar to the one for inline cache has been added to the image +configuration to embed build dependencies: + +```json +{ + "moby.buildkit.buildinfo.v0": "" +} +``` + +By default, the build dependencies are inlined in the image configuration. You +can disable this behavior with the [`buildinfo` attribute](../README.md#imageregistry). ### Exporter response (metadata) diff --git a/frontend/dockerfile/dockerfile2llb/convert.go b/frontend/dockerfile/dockerfile2llb/convert.go index 33bc1bfb2664..69e1b7ed4366 100644 --- a/frontend/dockerfile/dockerfile2llb/convert.go +++ b/frontend/dockerfile/dockerfile2llb/convert.go @@ -382,16 +382,16 @@ func Dockerfile2LLB(ctx context.Context, dt []byte, opt ConvertOpt) (*llb.State, buildContext := &mutableOutput{} ctxPaths := map[string]struct{}{} - buildinfo := &binfotypes.BuildInfo{} + buildInfo := &binfotypes.BuildInfo{} for _, d := range allDispatchStates.states { if !isReachable(target, d) { continue } - // collect build dependencies + // collect build sources and dependencies if d.buildSource != nil { - buildinfo.Sources = append(buildinfo.Sources, *d.buildSource) + buildInfo.Sources = append(buildInfo.Sources, *d.buildSource) } if d.base != nil { @@ -469,9 +469,9 @@ func Dockerfile2LLB(ctx context.Context, dt []byte, opt ConvertOpt) (*llb.State, } // sort build sources - if len(buildinfo.Sources) > 0 { - sort.Slice(buildinfo.Sources, func(i, j int) bool { - return buildinfo.Sources[i].Ref < buildinfo.Sources[j].Ref + if len(buildInfo.Sources) > 0 { + sort.Slice(buildInfo.Sources, func(i, j int) bool { + return buildInfo.Sources[i].Ref < buildInfo.Sources[j].Ref }) } @@ -512,7 +512,7 @@ func Dockerfile2LLB(ctx context.Context, dt []byte, opt ConvertOpt) (*llb.State, target.image.Variant = platformOpt.targetPlatform.Variant } - return &st, &target.image, buildinfo, nil + return &st, &target.image, buildInfo, nil } func metaArgsToMap(metaArgs []instructions.KeyValuePairOptional) map[string]string { diff --git a/frontend/dockerfile/dockerfile_buildinfo_test.go b/frontend/dockerfile/dockerfile_buildinfo_test.go index f564831bd002..5ee8f60b7589 100644 --- a/frontend/dockerfile/dockerfile_buildinfo_test.go +++ b/frontend/dockerfile/dockerfile_buildinfo_test.go @@ -1,6 +1,7 @@ package dockerfile import ( + "context" "encoding/base64" "encoding/json" "fmt" @@ -16,16 +17,21 @@ import ( "github.com/moby/buildkit/client" "github.com/moby/buildkit/exporter/containerimage/exptypes" "github.com/moby/buildkit/frontend/dockerfile/builder" + gateway "github.com/moby/buildkit/frontend/gateway/client" + "github.com/moby/buildkit/solver/pb" binfotypes "github.com/moby/buildkit/util/buildinfo/types" "github.com/moby/buildkit/util/testutil/integration" + "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) var buildinfoTests = integration.TestFuncs( - testBuildSources, - testBuildAttrs, + testBuildInfoSources, + testBuildInfoAttrs, testBuildInfoMultiPlatform, + testBuildInfoDeps, + testBuildInfoDepsMultiPlatform, ) func init() { @@ -33,7 +39,7 @@ func init() { } // moby/buildkit#2311 -func testBuildSources(t *testing.T, sb integration.Sandbox) { +func testBuildInfoSources(t *testing.T, sb integration.Sandbox) { f := getFrontend(t, sb) gitDir, err := ioutil.TempDir("", "buildkit") @@ -114,7 +120,7 @@ COPY --from=alpine /bin/busybox /alpine-busybox } // moby/buildkit#2476 -func testBuildAttrs(t *testing.T, sb integration.Sandbox) { +func testBuildInfoAttrs(t *testing.T, sb integration.Sandbox) { f := getFrontend(t, sb) f.RequiresBuildctl(t) @@ -245,3 +251,276 @@ ADD https://raw.githubusercontent.com/moby/moby/master/README.md / assert.Equal(t, "sha256:419455202b0ef97e480d7f8199b26a721a417818bc0e2d106975f74323f25e6c", sources[1].Pin) } } + +func testBuildInfoDeps(t *testing.T, sb integration.Sandbox) { + ctx := sb.Context() + f := getFrontend(t, sb) + f.RequiresBuildctl(t) + + c, err := client.New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + dockerfile := []byte(` +FROM alpine +ENV FOO=bar +RUN echo first > /out +`) + + dir, err := tmpdir( + fstest.CreateFile("Dockerfile", dockerfile, 0600), + ) + require.NoError(t, err) + defer os.RemoveAll(dir) + + dockerfile2 := []byte(` +FROM base AS build +RUN echo "foo is $FOO" > /foo +FROM busybox +COPY --from=build /foo /out / +`) + + dir2, err := tmpdir( + fstest.CreateFile("Dockerfile", dockerfile2, 0600), + ) + require.NoError(t, err) + defer os.RemoveAll(dir) + + b := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res, err := f.SolveGateway(ctx, c, gateway.SolveRequest{}) + if err != nil { + return nil, err + } + ref, err := res.SingleRef() + if err != nil { + return nil, err + } + st, err := ref.ToState() + if err != nil { + return nil, err + } + + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + + dtic, ok := res.Metadata[exptypes.ExporterImageConfigKey] + if !ok { + return nil, errors.Errorf("no containerimage.config in metadata") + } + + dtbi, ok := res.Metadata[exptypes.ExporterBuildInfo] + if !ok { + return nil, errors.Errorf("no containerimage.buildinfo in metadata") + } + + dt, err := json.Marshal(map[string][]byte{ + exptypes.ExporterImageConfigKey: dtic, + exptypes.ExporterBuildInfo: dtbi, + }) + if err != nil { + return nil, err + } + + res, err = f.SolveGateway(ctx, c, gateway.SolveRequest{ + FrontendOpt: map[string]string{ + "dockerfilekey": builder.DefaultLocalNameDockerfile + "2", + "context:base": "input:base", + "input-metadata:base": string(dt), + }, + FrontendInputs: map[string]*pb.Definition{ + "base": def.ToPB(), + }, + }) + if err != nil { + return nil, err + } + return res, nil + } + + destDir, err := ioutil.TempDir("", "buildkit") + require.NoError(t, err) + defer os.RemoveAll(destDir) + + res, err := c.Build(ctx, client.SolveOpt{ + LocalDirs: map[string]string{ + builder.DefaultLocalNameDockerfile: dir, + builder.DefaultLocalNameContext: dir, + builder.DefaultLocalNameDockerfile + "2": dir2, + }, + Exports: []client.ExportEntry{ + { + Type: client.ExporterLocal, + OutputDir: destDir, + }, + }, + }, "", b, nil) + require.NoError(t, err) + + require.Contains(t, res.ExporterResponse, exptypes.ExporterBuildInfo) + dtbi, err := base64.StdEncoding.DecodeString(res.ExporterResponse[exptypes.ExporterBuildInfo]) + require.NoError(t, err) + + var bi binfotypes.BuildInfo + err = json.Unmarshal(dtbi, &bi) + require.NoError(t, err) + + require.Equal(t, 2, len(bi.Sources)) + assert.Equal(t, binfotypes.SourceTypeDockerImage, bi.Sources[0].Type) + assert.True(t, strings.HasPrefix(bi.Sources[0].Ref, "docker.io/library/alpine")) + assert.NotEmpty(t, bi.Sources[0].Pin) + assert.Equal(t, binfotypes.SourceTypeDockerImage, bi.Sources[1].Type) + assert.Equal(t, "docker.io/library/busybox:latest", bi.Sources[1].Ref) + assert.NotEmpty(t, bi.Sources[1].Pin) + + require.Contains(t, bi.Deps, "base") + depsrc := bi.Deps["base"].Sources + require.Equal(t, 1, len(depsrc)) + assert.Equal(t, binfotypes.SourceTypeDockerImage, depsrc[0].Type) + assert.Equal(t, "alpine", depsrc[0].Ref) + assert.NotEmpty(t, depsrc[0].Pin) +} + +func testBuildInfoDepsMultiPlatform(t *testing.T, sb integration.Sandbox) { + ctx := sb.Context() + f := getFrontend(t, sb) + f.RequiresBuildctl(t) + + platforms := []string{"linux/amd64", "linux/arm64"} + + c, err := client.New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + dockerfile := []byte(` +FROM --platform=$BUILDPLATFORM alpine +ARG TARGETARCH +ENV FOO=bar-$TARGETARCH +RUN echo "foo $TARGETARCH" > /out +`) + + dir, err := tmpdir( + fstest.CreateFile("Dockerfile", dockerfile, 0600), + ) + require.NoError(t, err) + defer os.RemoveAll(dir) + + dockerfile2 := []byte(` +FROM base AS build +RUN echo "foo is $FOO" > /foo +FROM busybox +COPY --from=build /foo /out / +`) + + dir2, err := tmpdir( + fstest.CreateFile("Dockerfile", dockerfile2, 0600), + ) + require.NoError(t, err) + defer os.RemoveAll(dir) + + b := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res, err := f.SolveGateway(ctx, c, gateway.SolveRequest{ + FrontendOpt: map[string]string{ + "platform": strings.Join(platforms, ","), + }, + }) + if err != nil { + return nil, err + } + + if len(res.Refs) != 2 { + return nil, errors.Errorf("expected 2 refs, got %d", len(res.Refs)) + } + + frontendOpt := map[string]string{ + "dockerfilekey": builder.DefaultLocalNameDockerfile + "2", + "platform": strings.Join(platforms, ","), + } + + inputs := map[string]*pb.Definition{} + for _, platform := range platforms { + frontendOpt["context:base::"+platform] = "input:base::" + platform + + st, err := res.Refs[platform].ToState() + if err != nil { + return nil, err + } + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + inputs["base::"+platform] = def.ToPB() + + dtic, ok := res.Metadata[exptypes.ExporterImageConfigKey+"/"+platform] + if !ok { + return nil, errors.Errorf("no containerimage.config/" + platform + " in metadata") + } + dtbi, ok := res.Metadata[exptypes.ExporterBuildInfo+"/"+platform] + if !ok { + return nil, errors.Errorf("no containerimage.buildinfo/" + platform + " in metadata") + } + dt, err := json.Marshal(map[string][]byte{ + exptypes.ExporterImageConfigKey: dtic, + exptypes.ExporterBuildInfo: dtbi, + }) + if err != nil { + return nil, err + } + frontendOpt["input-metadata:base::"+platform] = string(dt) + } + + res, err = f.SolveGateway(ctx, c, gateway.SolveRequest{ + FrontendOpt: frontendOpt, + FrontendInputs: inputs, + }) + if err != nil { + return nil, err + } + return res, nil + } + + destDir, err := ioutil.TempDir("", "buildkit") + require.NoError(t, err) + defer os.RemoveAll(destDir) + + res, err := c.Build(ctx, client.SolveOpt{ + LocalDirs: map[string]string{ + builder.DefaultLocalNameDockerfile: dir, + builder.DefaultLocalNameContext: dir, + builder.DefaultLocalNameDockerfile + "2": dir2, + }, + Exports: []client.ExportEntry{ + { + Type: client.ExporterLocal, + OutputDir: destDir, + }, + }, + }, "", b, nil) + require.NoError(t, err) + + for _, platform := range platforms { + require.Contains(t, res.ExporterResponse, fmt.Sprintf("%s/%s", exptypes.ExporterBuildInfo, platform)) + dtbi, err := base64.StdEncoding.DecodeString(res.ExporterResponse[fmt.Sprintf("%s/%s", exptypes.ExporterBuildInfo, platform)]) + require.NoError(t, err) + + var bi binfotypes.BuildInfo + err = json.Unmarshal(dtbi, &bi) + require.NoError(t, err) + + require.Equal(t, 2, len(bi.Sources)) + assert.Equal(t, binfotypes.SourceTypeDockerImage, bi.Sources[0].Type) + assert.True(t, strings.HasPrefix(bi.Sources[0].Ref, "docker.io/library/alpine")) + assert.NotEmpty(t, bi.Sources[0].Pin) + assert.Equal(t, binfotypes.SourceTypeDockerImage, bi.Sources[1].Type) + assert.Equal(t, "docker.io/library/busybox:latest", bi.Sources[1].Ref) + assert.NotEmpty(t, bi.Sources[1].Pin) + + require.Contains(t, bi.Deps, "base") + depsrc := bi.Deps["base"].Sources + require.Equal(t, 1, len(depsrc)) + assert.Equal(t, binfotypes.SourceTypeDockerImage, depsrc[0].Type) + assert.Equal(t, "alpine", depsrc[0].Ref) + assert.NotEmpty(t, depsrc[0].Pin) + } +} diff --git a/solver/llbsolver/solver.go b/solver/llbsolver/solver.go index 1ffcbcbd5c9d..1ab8e634055c 100644 --- a/solver/llbsolver/solver.go +++ b/solver/llbsolver/solver.go @@ -161,7 +161,7 @@ func (s *Solver) Solve(ctx context.Context, id string, sessionID string, req fro res.Metadata = make(map[string][]byte) } if r := res.Ref; r != nil { - dtbi, err := buildinfo.Encode(ctx, res.Metadata[exptypes.ExporterBuildInfo], r.BuildSources()) + dtbi, err := buildinfo.Encode(ctx, res.Metadata, exptypes.ExporterBuildInfo, r.BuildSources()) if err != nil { return nil, err } @@ -174,7 +174,7 @@ func (s *Solver) Solve(ctx context.Context, id string, sessionID string, req fro if r == nil { continue } - dtbi, err := buildinfo.Encode(ctx, res.Metadata[fmt.Sprintf("%s/%s", exptypes.ExporterBuildInfo, k)], r.BuildSources()) + dtbi, err := buildinfo.Encode(ctx, res.Metadata, fmt.Sprintf("%s/%s", exptypes.ExporterBuildInfo, k), r.BuildSources()) if err != nil { return nil, err } diff --git a/util/buildinfo/buildinfo.go b/util/buildinfo/buildinfo.go index b14d723361ac..e419cf24c1b0 100644 --- a/util/buildinfo/buildinfo.go +++ b/util/buildinfo/buildinfo.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/docker/distribution/reference" + "github.com/moby/buildkit/exporter/containerimage/exptypes" "github.com/moby/buildkit/source" binfotypes "github.com/moby/buildkit/util/buildinfo/types" "github.com/moby/buildkit/util/urlutil" @@ -25,22 +26,28 @@ func Decode(enc string) (bi binfotypes.BuildInfo, _ error) { } // Encode encodes build info. -func Encode(ctx context.Context, buildInfo []byte, buildSources map[string]string) ([]byte, error) { +func Encode(ctx context.Context, metadata map[string][]byte, key string, buildSources map[string]string) ([]byte, error) { var bi binfotypes.BuildInfo - if buildInfo != nil { - if err := json.Unmarshal(buildInfo, &bi); err != nil { + if metadata == nil { + metadata = make(map[string][]byte) + } + if v, ok := metadata[key]; ok && v != nil { + if err := json.Unmarshal(v, &bi); err != nil { return nil, err } } - msources, err := mergeSources(ctx, buildSources, bi.Sources) - if err != nil { + if deps, err := decodeDeps(key, bi.Attrs); err == nil { + bi.Deps = reduceMapBuildInfo(deps, bi.Deps) + } else { return nil, err } - return json.Marshal(binfotypes.BuildInfo{ - Frontend: bi.Frontend, - Attrs: filterAttrs(bi.Attrs), - Sources: msources, - }) + if sources, err := mergeSources(ctx, buildSources, bi.Sources); err == nil { + bi.Sources = sources + } else { + return nil, err + } + bi.Attrs = filterAttrs(key, bi.Attrs) + return json.Marshal(bi) } // mergeSources combines and fixes build sources from frontend sources. @@ -136,6 +143,60 @@ func mergeSources(ctx context.Context, buildSources map[string]string, frontendS return srcs, nil } +// decodeDeps decodes dependencies (buildinfo) added via the input context. +func decodeDeps(key string, attrs map[string]*string) (map[string]binfotypes.BuildInfo, error) { + var platform string + // extract platform from metadata key + skey := strings.SplitN(key, "/", 2) + if len(skey) == 2 { + platform = skey[1] + } + + res := make(map[string]binfotypes.BuildInfo) + for k, v := range attrs { + // dependencies are only handled via the input context + if v == nil || !strings.HasPrefix(k, "input-metadata:") { + continue + } + + // if platform is defined, only decode dependencies for that platform + if platform != "" && !strings.HasSuffix(k, "::"+platform) { + continue + } + + // decode input metadata + var inputresp map[string]string + if err := json.Unmarshal([]byte(*v), &inputresp); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal input-metadata") + } + + // check buildinfo key is present + if _, ok := inputresp[exptypes.ExporterBuildInfo]; !ok { + continue + } + + // decode buildinfo + bi, err := Decode(inputresp[exptypes.ExporterBuildInfo]) + if err != nil { + return nil, errors.Wrap(err, "failed to decode buildinfo from input-metadata") + } + + // set dep key + var depkey string + kl := strings.SplitN(k, ":", 2) + depkey = kl[1] + if platform != "" { + depkey = strings.TrimSuffix(depkey, "::"+platform) + } + + res[depkey] = bi + } + if len(res) == 0 { + return nil, nil + } + return res, nil +} + // FormatOpts holds build info format options. type FormatOpts struct { RemoveAttrs bool @@ -178,22 +239,43 @@ var knownAttrs = []string{ // filterAttrs filters frontent opt by picking only those that // could effectively change the build result. -func filterAttrs(attrs map[string]*string) map[string]*string { +func filterAttrs(key string, attrs map[string]*string) map[string]*string { + var platform string + // extract platform from metadata key + skey := strings.SplitN(key, "/", 2) + if len(skey) == 2 { + platform = skey[1] + } filtered := make(map[string]*string) for k, v := range attrs { if v == nil { continue } - // Control args are filtered out + // control args are filtered out if isControlArg(k) { continue } - // Always include args and labels + // always include if strings.HasPrefix(k, "build-arg:") || strings.HasPrefix(k, "label:") { filtered[k] = v continue } - // Filter only for known attributes + // input context key and value has to be cleaned up + // before being included + if strings.HasPrefix(k, "context:") { + if platform != "" { + // if platform is defined, only include the relevant platform + if !strings.HasSuffix(k, "::"+platform) { + continue + } + ctxival := strings.TrimSuffix(*v, "::"+platform) + filtered[strings.TrimSuffix(k, "::"+platform)] = &ctxival + continue + } + filtered[k] = v + continue + } + // filter only for known attributes for _, knownAttr := range knownAttrs { if knownAttr == k { filtered[k] = v @@ -227,11 +309,11 @@ func isControlArg(attrKey string) bool { // GetMetadata returns buildinfo metadata for the specified key. If the key // is already there, result will be merged. func GetMetadata(metadata map[string][]byte, key string, reqFrontend string, reqAttrs map[string]string) ([]byte, error) { - var ( - dtbi []byte - err error - ) - if v, ok := metadata[key]; ok { + if metadata == nil { + metadata = make(map[string][]byte) + } + var dtbi []byte + if v, ok := metadata[key]; ok && v != nil { var mbi binfotypes.BuildInfo if errm := json.Unmarshal(v, &mbi); errm != nil { return nil, errors.Wrapf(errm, "failed to unmarshal build info for %q", key) @@ -239,15 +321,26 @@ func GetMetadata(metadata map[string][]byte, key string, reqFrontend string, req if reqFrontend != "" { mbi.Frontend = reqFrontend } - mbi.Attrs = convertMap(reduceMap(reqAttrs, mbi.Attrs)) + if deps, err := decodeDeps(key, convertMap(reduceMapString(reqAttrs, mbi.Attrs))); err == nil { + mbi.Deps = reduceMapBuildInfo(deps, mbi.Deps) + } else { + return nil, err + } + mbi.Attrs = filterAttrs(key, convertMap(reduceMapString(reqAttrs, mbi.Attrs))) + var err error dtbi, err = json.Marshal(mbi) if err != nil { return nil, errors.Wrapf(err, "failed to marshal build info for %q", key) } } else { + deps, err := decodeDeps(key, convertMap(reqAttrs)) + if err != nil { + return nil, err + } dtbi, err = json.Marshal(binfotypes.BuildInfo{ Frontend: reqFrontend, - Attrs: convertMap(reqAttrs), + Attrs: filterAttrs(key, convertMap(reqAttrs)), + Deps: deps, }) if err != nil { return nil, errors.Wrapf(err, "failed to marshal build info for %q", key) @@ -256,7 +349,26 @@ func GetMetadata(metadata map[string][]byte, key string, reqFrontend string, req return dtbi, nil } -func reduceMap(m1 map[string]string, m2 map[string]*string) map[string]string { +// FromImageConfig returns build info from image config. +func FromImageConfig(dt []byte) (*binfotypes.BuildInfo, error) { + if len(dt) == 0 { + return nil, nil + } + var config binfotypes.ImageConfig + if err := json.Unmarshal(dt, &config); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal image config") + } + if len(config.BuildInfo) == 0 { + return nil, nil + } + bi, err := Decode(config.BuildInfo) + if err != nil { + return nil, errors.Wrap(err, "failed to decode build info from image config") + } + return &bi, nil +} + +func reduceMapString(m1 map[string]string, m2 map[string]*string) map[string]string { if m1 == nil && m2 == nil { return nil } @@ -271,6 +383,19 @@ func reduceMap(m1 map[string]string, m2 map[string]*string) map[string]string { return m1 } +func reduceMapBuildInfo(m1 map[string]binfotypes.BuildInfo, m2 map[string]binfotypes.BuildInfo) map[string]binfotypes.BuildInfo { + if m1 == nil && m2 == nil { + return nil + } + if m1 == nil { + m1 = map[string]binfotypes.BuildInfo{} + } + for k, v := range m2 { + m1[k] = v + } + return m1 +} + func convertMap(m map[string]string) map[string]*string { res := make(map[string]*string) for k, v := range m { diff --git a/util/buildinfo/buildinfo_test.go b/util/buildinfo/buildinfo_test.go index bdfb0a95783b..3f13656c5ad5 100644 --- a/util/buildinfo/buildinfo_test.go +++ b/util/buildinfo/buildinfo_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "testing" + "github.com/moby/buildkit/exporter/containerimage/exptypes" binfotypes "github.com/moby/buildkit/util/buildinfo/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -91,6 +92,131 @@ func TestMergeSources(t *testing.T) { }, srcs) } +func TestDecodeDeps(t *testing.T) { + cases := []struct { + name string + key string + attrs map[string]*string + want map[string]binfotypes.BuildInfo + }{ + { + name: "simple", + key: exptypes.ExporterBuildInfo, + attrs: map[string]*string{ + "build-arg:bar": stringPtr("foo"), + "build-arg:foo": stringPtr("bar"), + "context:baseapp": stringPtr("input:0-base"), + "filename": stringPtr("Dockerfile"), + "input-metadata:0-base": stringPtr("{\"containerimage.buildinfo\":\"eyJmcm9udGVuZCI6ImRvY2tlcmZpbGUudjAiLCJhdHRycyI6eyJidWlsZC1hcmc6YmFyIjoiZm9vIiwiYnVpbGQtYXJnOmZvbyI6ImJhciIsImZpbGVuYW1lIjoiYmFzZWFwcC5Eb2NrZXJmaWxlIn0sInNvdXJjZXMiOlt7InR5cGUiOiJkb2NrZXItaW1hZ2UiLCJyZWYiOiJidXN5Ym94IiwiYWxpYXMiOiJkb2NrZXIuaW8vbGlicmFyeS9idXN5Ym94QHNoYTI1NjphZmNjN2YxYWMxYjQ5ZGIzMTdhNzE5NmM5MDJlNjFjNmMzYzQ2MDdkNjM1OTllZTFhODJkNzAyZDI0OWEwY2NiIiwicGluIjoic2hhMjU2OmFmY2M3ZjFhYzFiNDlkYjMxN2E3MTk2YzkwMmU2MWM2YzNjNDYwN2Q2MzU5OWVlMWE4MmQ3MDJkMjQ5YTBjY2IifV19\",\"containerimage.config\":\"eyJhcmNoaXRlY3R1cmUiOiJhbWQ2NCIsIm9zIjoibGludXgiLCJyb290ZnMiOnsidHlwZSI6ImxheWVycyIsImRpZmZfaWRzIjpbInNoYTI1NjpkMzE1MDVmZDUwNTBmNmI5NmNhMzI2OGQxZGI1OGZjOTFhZTU2MWRkZjE0ZWFhYmM0MWQ2M2VhMmVmOGMxYzZkIl19LCJoaXN0b3J5IjpbeyJjcmVhdGVkIjoiMjAyMi0wMi0wNFQyMToyMDoxMi4zMTg5MTc4MjJaIiwiY3JlYXRlZF9ieSI6Ii9iaW4vc2ggLWMgIyhub3ApIEFERCBmaWxlOjFjODUwN2UzZTliMjJiOTc3OGYyZWRiYjk1MDA2MWUwNmJkZTZhMWY1M2I2OWUxYzYxMDI1MDAyOWMzNzNiNzIgaW4gLyAifSx7ImNyZWF0ZWQiOiIyMDIyLTAyLTA0VDIxOjIwOjEyLjQ5Nzc5NDgwOVoiLCJjcmVhdGVkX2J5IjoiL2Jpbi9zaCAtYyAjKG5vcCkgIENNRCBbXCJzaFwiXSIsImVtcHR5X2xheWVyIjp0cnVlfSx7ImNyZWF0ZWRfYnkiOiJXT1JLRElSIC9zcmMiLCJjb21tZW50IjoiYnVpbGRraXQuZG9ja2VyZmlsZS52MCJ9XSwiY29uZmlnIjp7IkVudiI6WyJQQVRIPS91c3IvbG9jYWwvc2JpbjovdXNyL2xvY2FsL2JpbjovdXNyL3NiaW46L3Vzci9iaW46L3NiaW46L2JpbiJdLCJDbWQiOlsic2giXSwiV29ya2luZ0RpciI6Ii9zcmMiLCJPbkJ1aWxkIjpudWxsfX0=\"}"), + }, + want: map[string]binfotypes.BuildInfo{ + "0-base": { + Frontend: "dockerfile.v0", + Attrs: map[string]*string{ + "build-arg:bar": stringPtr("foo"), + "build-arg:foo": stringPtr("bar"), + "filename": stringPtr("baseapp.Dockerfile"), + }, + Sources: []binfotypes.Source{ + { + Type: binfotypes.SourceTypeDockerImage, + Ref: "busybox", + Alias: "docker.io/library/busybox@sha256:afcc7f1ac1b49db317a7196c902e61c6c3c4607d63599ee1a82d702d249a0ccb", + Pin: "sha256:afcc7f1ac1b49db317a7196c902e61c6c3c4607d63599ee1a82d702d249a0ccb", + }, + }, + }, + }, + }, + { + name: "multiplatform", + key: exptypes.ExporterBuildInfo + "/linux/amd64", + attrs: map[string]*string{ + "context:base::linux/amd64": stringPtr("input:base::linux/amd64"), + "context:base::linux/arm64": stringPtr("input:base::linux/arm64"), + "dockerfilekey": stringPtr("dockerfile2"), + "input-metadata:base::linux/amd64": stringPtr("{\"containerimage.buildinfo\":\"eyJmcm9udGVuZCI6ImRvY2tlcmZpbGUudjAiLCJzb3VyY2VzIjpbeyJ0eXBlIjoiZG9ja2VyLWltYWdlIiwicmVmIjoiYWxwaW5lIiwiYWxpYXMiOiJkb2NrZXIuaW8vbGlicmFyeS9hbHBpbmVAc2hhMjU2OmU3ZDg4ZGU3M2RiM2QzZmQ5YjJkNjNhYTdmNDQ3YTEwZmQwMjIwYjdjYmYzOTgwM2M4MDNmMmFmOWJhMjU2YjMiLCJwaW4iOiJzaGEyNTY6ZTdkODhkZTczZGIzZDNmZDliMmQ2M2FhN2Y0NDdhMTBmZDAyMjBiN2NiZjM5ODAzYzgwM2YyYWY5YmEyNTZiMyJ9XX0=\",\"containerimage.config\":\"eyJhcmNoaXRlY3R1cmUiOiJhbWQ2NCIsIm9zIjoibGludXgiLCJyb290ZnMiOnsidHlwZSI6ImxheWVycyIsImRpZmZfaWRzIjpbInNoYTI1Njo4ZDNhYzM0ODk5OTY0MjNmNTNkNjA4N2M4MTE4MDAwNjI2M2I3OWYyMDZkM2ZkZWM5ZTY2ZjBlMjdjZWI4NzU5Il19LCJoaXN0b3J5IjpbeyJjcmVhdGVkIjoiMjAyMS0xMS0yNFQyMDoxOTo0MC4xOTk3MDA5NDZaIiwiY3JlYXRlZF9ieSI6Ii9iaW4vc2ggLWMgIyhub3ApIEFERCBmaWxlOjkyMzNmNmYyMjM3ZDc5NjU5YTk1MjFmN2UzOTBkZjIxN2NlYzQ5ZjFhOGFhM2ExMjE0N2JiY2ExOTU2YWNkYjkgaW4gLyAifSx7ImNyZWF0ZWQiOiIyMDIxLTExLTI0VDIwOjE5OjQwLjQ4MzM2NzU0NloiLCJjcmVhdGVkX2J5IjoiL2Jpbi9zaCAtYyAjKG5vcCkgIENNRCBbXCIvYmluL3NoXCJdIiwiZW1wdHlfbGF5ZXIiOnRydWV9LHsiY3JlYXRlZF9ieSI6IkFSRyBUQVJHRVRBUkNIIiwiY29tbWVudCI6ImJ1aWxka2l0LmRvY2tlcmZpbGUudjAiLCJlbXB0eV9sYXllciI6dHJ1ZX0seyJjcmVhdGVkX2J5IjoiRU5WIEZPTz1iYXItYW1kNjQiLCJjb21tZW50IjoiYnVpbGRraXQuZG9ja2VyZmlsZS52MCIsImVtcHR5X2xheWVyIjp0cnVlfSx7ImNyZWF0ZWRfYnkiOiJSVU4gfDEgVEFSR0VUQVJDSD1hbWQ2NCAvYmluL3NoIC1jIGVjaG8gXCJmb28gJFRBUkdFVEFSQ0hcIiBcdTAwM2UgL291dCAjIGJ1aWxka2l0IiwiY29tbWVudCI6ImJ1aWxka2l0LmRvY2tlcmZpbGUudjAifV0sImNvbmZpZyI6eyJFbnYiOlsiUEFUSD0vdXNyL2xvY2FsL3NiaW46L3Vzci9sb2NhbC9iaW46L3Vzci9zYmluOi91c3IvYmluOi9zYmluOi9iaW4iLCJGT089YmFyLWFtZDY0Il0sIkNtZCI6WyIvYmluL3NoIl0sIk9uQnVpbGQiOm51bGx9fQ==\"}"), + "input-metadata:base::linux/arm64": stringPtr("{\"containerimage.buildinfo\":\"eyJmcm9udGVuZCI6ImRvY2tlcmZpbGUudjAiLCJzb3VyY2VzIjpbeyJ0eXBlIjoiZG9ja2VyLWltYWdlIiwicmVmIjoiYWxwaW5lIiwiYWxpYXMiOiJkb2NrZXIuaW8vbGlicmFyeS9hbHBpbmVAc2hhMjU2OmU3ZDg4ZGU3M2RiM2QzZmQ5YjJkNjNhYTdmNDQ3YTEwZmQwMjIwYjdjYmYzOTgwM2M4MDNmMmFmOWJhMjU2YjMiLCJwaW4iOiJzaGEyNTY6ZTdkODhkZTczZGIzZDNmZDliMmQ2M2FhN2Y0NDdhMTBmZDAyMjBiN2NiZjM5ODAzYzgwM2YyYWY5YmEyNTZiMyJ9XX0=\",\"containerimage.config\":\"eyJhcmNoaXRlY3R1cmUiOiJhcm02NCIsIm9zIjoibGludXgiLCJyb290ZnMiOnsidHlwZSI6ImxheWVycyIsImRpZmZfaWRzIjpbInNoYTI1Njo4ZDNhYzM0ODk5OTY0MjNmNTNkNjA4N2M4MTE4MDAwNjI2M2I3OWYyMDZkM2ZkZWM5ZTY2ZjBlMjdjZWI4NzU5Il19LCJoaXN0b3J5IjpbeyJjcmVhdGVkIjoiMjAyMS0xMS0yNFQyMDoxOTo0MC4xOTk3MDA5NDZaIiwiY3JlYXRlZF9ieSI6Ii9iaW4vc2ggLWMgIyhub3ApIEFERCBmaWxlOjkyMzNmNmYyMjM3ZDc5NjU5YTk1MjFmN2UzOTBkZjIxN2NlYzQ5ZjFhOGFhM2ExMjE0N2JiY2ExOTU2YWNkYjkgaW4gLyAifSx7ImNyZWF0ZWQiOiIyMDIxLTExLTI0VDIwOjE5OjQwLjQ4MzM2NzU0NloiLCJjcmVhdGVkX2J5IjoiL2Jpbi9zaCAtYyAjKG5vcCkgIENNRCBbXCIvYmluL3NoXCJdIiwiZW1wdHlfbGF5ZXIiOnRydWV9LHsiY3JlYXRlZF9ieSI6IkFSRyBUQVJHRVRBUkNIIiwiY29tbWVudCI6ImJ1aWxka2l0LmRvY2tlcmZpbGUudjAiLCJlbXB0eV9sYXllciI6dHJ1ZX0seyJjcmVhdGVkX2J5IjoiRU5WIEZPTz1iYXItYXJtNjQiLCJjb21tZW50IjoiYnVpbGRraXQuZG9ja2VyZmlsZS52MCIsImVtcHR5X2xheWVyIjp0cnVlfSx7ImNyZWF0ZWRfYnkiOiJSVU4gfDEgVEFSR0VUQVJDSD1hcm02NCAvYmluL3NoIC1jIGVjaG8gXCJmb28gJFRBUkdFVEFSQ0hcIiBcdTAwM2UgL291dCAjIGJ1aWxka2l0IiwiY29tbWVudCI6ImJ1aWxka2l0LmRvY2tlcmZpbGUudjAifV0sImNvbmZpZyI6eyJFbnYiOlsiUEFUSD0vdXNyL2xvY2FsL3NiaW46L3Vzci9sb2NhbC9iaW46L3Vzci9zYmluOi91c3IvYmluOi9zYmluOi9iaW4iLCJGT089YmFyLWFybTY0Il0sIkNtZCI6WyIvYmluL3NoIl0sIk9uQnVpbGQiOm51bGx9fQ==\"}"), + "platform": stringPtr("linux/amd64,linux/arm64"), + }, + want: map[string]binfotypes.BuildInfo{ + "base": { + Frontend: "dockerfile.v0", + Attrs: nil, + Sources: []binfotypes.Source{ + { + Type: binfotypes.SourceTypeDockerImage, + Ref: "alpine", + Alias: "docker.io/library/alpine@sha256:e7d88de73db3d3fd9b2d63aa7f447a10fd0220b7cbf39803c803f2af9ba256b3", + Pin: "sha256:e7d88de73db3d3fd9b2d63aa7f447a10fd0220b7cbf39803c803f2af9ba256b3", + }, + }, + }, + }, + }, + } + for _, tt := range cases { + tt := tt + t.Run(tt.name, func(t *testing.T) { + deps, err := decodeDeps(tt.key, tt.attrs) + require.NoError(t, err) + assert.Equal(t, tt.want, deps) + }) + } +} + +func TestFilterAttrs(t *testing.T) { + cases := []struct { + name string + key string + attrs map[string]*string + want map[string]*string + }{ + { + name: "simple", + key: exptypes.ExporterBuildInfo, + attrs: map[string]*string{ + "build-arg:foo": stringPtr("bar"), + "cmdline": stringPtr("crazymax/dockerfile:buildattrs"), + "context": stringPtr("https://github.com/crazy-max/buildkit-buildsources-test.git#master"), + "filename": stringPtr("Dockerfile"), + "source": stringPtr("crazymax/dockerfile:master"), + }, + want: map[string]*string{ + "build-arg:foo": stringPtr("bar"), + "context": stringPtr("https://github.com/crazy-max/buildkit-buildsources-test.git#master"), + "filename": stringPtr("Dockerfile"), + "source": stringPtr("crazymax/dockerfile:master"), + }, + }, + { + name: "multiplatform", + key: exptypes.ExporterBuildInfo + "/linux/amd64", + attrs: map[string]*string{ + "build-arg:bar": stringPtr("foo"), + "build-arg:foo": stringPtr("bar"), + "context:base::linux/amd64": stringPtr("input:base::linux/amd64"), + "context:base::linux/arm64": stringPtr("input:base::linux/arm64"), + "dockerfilekey": stringPtr("dockerfile2"), + "input-metadata:base::linux/amd64": stringPtr("{\"containerimage.buildinfo\":\"eyJmcm9udGVuZCI6ImRvY2tlcmZpbGUudjAiLCJzb3VyY2VzIjpbeyJ0eXBlIjoiZG9ja2VyLWltYWdlIiwicmVmIjoiYWxwaW5lIiwiYWxpYXMiOiJkb2NrZXIuaW8vbGlicmFyeS9hbHBpbmVAc2hhMjU2OmU3ZDg4ZGU3M2RiM2QzZmQ5YjJkNjNhYTdmNDQ3YTEwZmQwMjIwYjdjYmYzOTgwM2M4MDNmMmFmOWJhMjU2YjMiLCJwaW4iOiJzaGEyNTY6ZTdkODhkZTczZGIzZDNmZDliMmQ2M2FhN2Y0NDdhMTBmZDAyMjBiN2NiZjM5ODAzYzgwM2YyYWY5YmEyNTZiMyJ9XX0=\",\"containerimage.config\":\"eyJhcmNoaXRlY3R1cmUiOiJhbWQ2NCIsIm9zIjoibGludXgiLCJyb290ZnMiOnsidHlwZSI6ImxheWVycyIsImRpZmZfaWRzIjpbInNoYTI1Njo4ZDNhYzM0ODk5OTY0MjNmNTNkNjA4N2M4MTE4MDAwNjI2M2I3OWYyMDZkM2ZkZWM5ZTY2ZjBlMjdjZWI4NzU5Il19LCJoaXN0b3J5IjpbeyJjcmVhdGVkIjoiMjAyMS0xMS0yNFQyMDoxOTo0MC4xOTk3MDA5NDZaIiwiY3JlYXRlZF9ieSI6Ii9iaW4vc2ggLWMgIyhub3ApIEFERCBmaWxlOjkyMzNmNmYyMjM3ZDc5NjU5YTk1MjFmN2UzOTBkZjIxN2NlYzQ5ZjFhOGFhM2ExMjE0N2JiY2ExOTU2YWNkYjkgaW4gLyAifSx7ImNyZWF0ZWQiOiIyMDIxLTExLTI0VDIwOjE5OjQwLjQ4MzM2NzU0NloiLCJjcmVhdGVkX2J5IjoiL2Jpbi9zaCAtYyAjKG5vcCkgIENNRCBbXCIvYmluL3NoXCJdIiwiZW1wdHlfbGF5ZXIiOnRydWV9LHsiY3JlYXRlZF9ieSI6IkFSRyBUQVJHRVRBUkNIIiwiY29tbWVudCI6ImJ1aWxka2l0LmRvY2tlcmZpbGUudjAiLCJlbXB0eV9sYXllciI6dHJ1ZX0seyJjcmVhdGVkX2J5IjoiRU5WIEZPTz1iYXItYW1kNjQiLCJjb21tZW50IjoiYnVpbGRraXQuZG9ja2VyZmlsZS52MCIsImVtcHR5X2xheWVyIjp0cnVlfSx7ImNyZWF0ZWRfYnkiOiJSVU4gfDEgVEFSR0VUQVJDSD1hbWQ2NCAvYmluL3NoIC1jIGVjaG8gXCJmb28gJFRBUkdFVEFSQ0hcIiBcdTAwM2UgL291dCAjIGJ1aWxka2l0IiwiY29tbWVudCI6ImJ1aWxka2l0LmRvY2tlcmZpbGUudjAifV0sImNvbmZpZyI6eyJFbnYiOlsiUEFUSD0vdXNyL2xvY2FsL3NiaW46L3Vzci9sb2NhbC9iaW46L3Vzci9zYmluOi91c3IvYmluOi9zYmluOi9iaW4iLCJGT089YmFyLWFtZDY0Il0sIkNtZCI6WyIvYmluL3NoIl0sIk9uQnVpbGQiOm51bGx9fQ==\"}"), + "input-metadata:base::linux/arm64": stringPtr("{\"containerimage.buildinfo\":\"eyJmcm9udGVuZCI6ImRvY2tlcmZpbGUudjAiLCJzb3VyY2VzIjpbeyJ0eXBlIjoiZG9ja2VyLWltYWdlIiwicmVmIjoiYWxwaW5lIiwiYWxpYXMiOiJkb2NrZXIuaW8vbGlicmFyeS9hbHBpbmVAc2hhMjU2OmU3ZDg4ZGU3M2RiM2QzZmQ5YjJkNjNhYTdmNDQ3YTEwZmQwMjIwYjdjYmYzOTgwM2M4MDNmMmFmOWJhMjU2YjMiLCJwaW4iOiJzaGEyNTY6ZTdkODhkZTczZGIzZDNmZDliMmQ2M2FhN2Y0NDdhMTBmZDAyMjBiN2NiZjM5ODAzYzgwM2YyYWY5YmEyNTZiMyJ9XX0=\",\"containerimage.config\":\"eyJhcmNoaXRlY3R1cmUiOiJhcm02NCIsIm9zIjoibGludXgiLCJyb290ZnMiOnsidHlwZSI6ImxheWVycyIsImRpZmZfaWRzIjpbInNoYTI1Njo4ZDNhYzM0ODk5OTY0MjNmNTNkNjA4N2M4MTE4MDAwNjI2M2I3OWYyMDZkM2ZkZWM5ZTY2ZjBlMjdjZWI4NzU5Il19LCJoaXN0b3J5IjpbeyJjcmVhdGVkIjoiMjAyMS0xMS0yNFQyMDoxOTo0MC4xOTk3MDA5NDZaIiwiY3JlYXRlZF9ieSI6Ii9iaW4vc2ggLWMgIyhub3ApIEFERCBmaWxlOjkyMzNmNmYyMjM3ZDc5NjU5YTk1MjFmN2UzOTBkZjIxN2NlYzQ5ZjFhOGFhM2ExMjE0N2JiY2ExOTU2YWNkYjkgaW4gLyAifSx7ImNyZWF0ZWQiOiIyMDIxLTExLTI0VDIwOjE5OjQwLjQ4MzM2NzU0NloiLCJjcmVhdGVkX2J5IjoiL2Jpbi9zaCAtYyAjKG5vcCkgIENNRCBbXCIvYmluL3NoXCJdIiwiZW1wdHlfbGF5ZXIiOnRydWV9LHsiY3JlYXRlZF9ieSI6IkFSRyBUQVJHRVRBUkNIIiwiY29tbWVudCI6ImJ1aWxka2l0LmRvY2tlcmZpbGUudjAiLCJlbXB0eV9sYXllciI6dHJ1ZX0seyJjcmVhdGVkX2J5IjoiRU5WIEZPTz1iYXItYXJtNjQiLCJjb21tZW50IjoiYnVpbGRraXQuZG9ja2VyZmlsZS52MCIsImVtcHR5X2xheWVyIjp0cnVlfSx7ImNyZWF0ZWRfYnkiOiJSVU4gfDEgVEFSR0VUQVJDSD1hcm02NCAvYmluL3NoIC1jIGVjaG8gXCJmb28gJFRBUkdFVEFSQ0hcIiBcdTAwM2UgL291dCAjIGJ1aWxka2l0IiwiY29tbWVudCI6ImJ1aWxka2l0LmRvY2tlcmZpbGUudjAifV0sImNvbmZpZyI6eyJFbnYiOlsiUEFUSD0vdXNyL2xvY2FsL3NiaW46L3Vzci9sb2NhbC9iaW46L3Vzci9zYmluOi91c3IvYmluOi9zYmluOi9iaW4iLCJGT089YmFyLWFybTY0Il0sIkNtZCI6WyIvYmluL3NoIl0sIk9uQnVpbGQiOm51bGx9fQ==\"}"), + "platform": stringPtr("linux/amd64,linux/arm64"), + }, + want: map[string]*string{ + "build-arg:bar": stringPtr("foo"), + "build-arg:foo": stringPtr("bar"), + "context:base": stringPtr("input:base"), + }, + }, + } + for _, tt := range cases { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, filterAttrs(tt.key, tt.attrs)) + }) + } +} + func TestFormat(t *testing.T) { bi := binfotypes.BuildInfo{ Frontend: "dockerfile.v0", @@ -152,7 +278,7 @@ func TestFormat(t *testing.T) { } } -func TestReduceMap(t *testing.T) { +func TestReduceMapString(t *testing.T) { cases := []struct { name string m1 map[string]*string @@ -193,7 +319,7 @@ func TestReduceMap(t *testing.T) { for _, tt := range cases { tt := tt t.Run(tt.name, func(t *testing.T) { - require.Equal(t, tt.expected, reduceMap(tt.m2, tt.m1)) + require.Equal(t, tt.expected, reduceMapString(tt.m2, tt.m1)) }) } } diff --git a/util/buildinfo/types/types.go b/util/buildinfo/types/types.go index 74b1e393d555..93abcd1b4f12 100644 --- a/util/buildinfo/types/types.go +++ b/util/buildinfo/types/types.go @@ -10,7 +10,7 @@ const ImageConfigField = "moby.buildkit.buildinfo.v1" // ImageConfig defines the structure of build dependencies // inside image config. type ImageConfig struct { - BuildInfo []byte `json:"moby.buildkit.buildinfo.v1,omitempty"` + BuildInfo string `json:"moby.buildkit.buildinfo.v1,omitempty"` } // BuildInfo defines the main structure added to image config as @@ -23,6 +23,8 @@ type BuildInfo struct { Attrs map[string]*string `json:"attrs,omitempty"` // Sources defines build dependencies. Sources []Source `json:"sources,omitempty"` + // Deps defines context dependencies. + Deps map[string]BuildInfo `json:"deps,omitempty"` } // Source defines a build dependency.