diff --git a/cmd/skaffold/app/cmd/build.go b/cmd/skaffold/app/cmd/build.go index e5bf0e086c7..611cf6234d5 100644 --- a/cmd/skaffold/app/cmd/build.go +++ b/cmd/skaffold/app/cmd/build.go @@ -19,6 +19,7 @@ package cmd import ( "bytes" "context" + "errors" "fmt" "io" "io/ioutil" @@ -67,7 +68,13 @@ func doBuild(ctx context.Context, out io.Writer) error { } return withRunner(ctx, func(r runner.Runner, config *latest.SkaffoldConfig) error { - bRes, err := r.BuildAndTest(ctx, buildOut, targetArtifacts(opts, config)) + ar := targetArtifacts(opts, config) + + // TODO: [#4891] Remove this block after implementing proper image cache invalidation for artifacts with dependencies + if err := failForArtifactDependenciesWithCacheEnabled(ar, opts.CacheArtifacts); err != nil { + return err + } + bRes, err := r.BuildAndTest(ctx, buildOut, ar) if quietFlag || buildOutputFlag != "" { cmdOut := flags.BuildOutput{Builds: bRes} @@ -104,3 +111,15 @@ func targetArtifacts(opts config.SkaffoldOptions, cfg *latest.SkaffoldConfig) [] return targetArtifacts } + +func failForArtifactDependenciesWithCacheEnabled(artifacts []*latest.Artifact, cacheEnabled bool) error { + if !cacheEnabled { + return nil + } + for _, a := range artifacts { + if len(a.Dependencies) > 0 { + return errors.New("defining dependencies between artifacts is not yet supported for `skaffold build` with cache enabled. Run with `--cache-artifacts=false` flag") + } + } + return nil +} diff --git a/cmd/skaffold/app/cmd/dev.go b/cmd/skaffold/app/cmd/dev.go index 524a17ce4b0..b583408ff3b 100644 --- a/cmd/skaffold/app/cmd/dev.go +++ b/cmd/skaffold/app/cmd/dev.go @@ -61,6 +61,10 @@ func runDev(ctx context.Context, out io.Writer) error { return nil default: err := withRunner(ctx, func(r runner.Runner, config *latest.SkaffoldConfig) error { + // TODO: [#4892] Remove this block after fixing devloop rebuild and redeploy logic for artifacts with dependencies + if err := checkForArtifactDependencies(config.Build.Artifacts); err != nil { + return err + } err := r.Dev(ctx, out, config.Build.Artifacts) if r.HasDeployed() { @@ -91,3 +95,12 @@ func runDev(ctx context.Context, out io.Writer) error { } } } + +func checkForArtifactDependencies(artifacts []*latest.Artifact) error { + for _, a := range artifacts { + if len(a.Dependencies) > 0 { + return errors.New("defining dependencies between artifacts is not yet supported for `skaffold dev` and `skaffold debug`") + } + } + return nil +} diff --git a/docs/content/en/schemas/v2beta9.json b/docs/content/en/schemas/v2beta9.json index 4674dc459e9..33695479db0 100755 --- a/docs/content/en/schemas/v2beta9.json +++ b/docs/content/en/schemas/v2beta9.json @@ -64,6 +64,14 @@ "gcr.io/k8s-skaffold/example" ] }, + "requires": { + "items": { + "$ref": "#/definitions/ArtifactDependency" + }, + "type": "array", + "description": "describes build artifacts that this artifact depends on.", + "x-intellij-html-description": "describes build artifacts that this artifact depends on." + }, "sync": { "$ref": "#/definitions/Sync", "description": "*beta* local files synced to pods instead of triggering an image build when modified. If no files are listed, sync all the files and infer the destination.", @@ -74,7 +82,8 @@ "preferredOrder": [ "image", "context", - "sync" + "sync", + "requires" ], "additionalProperties": false }, @@ -99,6 +108,14 @@ "gcr.io/k8s-skaffold/example" ] }, + "requires": { + "items": { + "$ref": "#/definitions/ArtifactDependency" + }, + "type": "array", + "description": "describes build artifacts that this artifact depends on.", + "x-intellij-html-description": "describes build artifacts that this artifact depends on." + }, "sync": { "$ref": "#/definitions/Sync", "description": "*beta* local files synced to pods instead of triggering an image build when modified. If no files are listed, sync all the files and infer the destination.", @@ -110,6 +127,7 @@ "image", "context", "sync", + "requires", "docker" ], "additionalProperties": false @@ -135,6 +153,14 @@ "gcr.io/k8s-skaffold/example" ] }, + "requires": { + "items": { + "$ref": "#/definitions/ArtifactDependency" + }, + "type": "array", + "description": "describes build artifacts that this artifact depends on.", + "x-intellij-html-description": "describes build artifacts that this artifact depends on." + }, "sync": { "$ref": "#/definitions/Sync", "description": "*beta* local files synced to pods instead of triggering an image build when modified. If no files are listed, sync all the files and infer the destination.", @@ -146,6 +172,7 @@ "image", "context", "sync", + "requires", "bazel" ], "additionalProperties": false @@ -171,6 +198,14 @@ "description": "builds images using the [Jib plugins for Maven or Gradle](https://github.com/GoogleContainerTools/jib/).", "x-intellij-html-description": "builds images using the Jib plugins for Maven or Gradle." }, + "requires": { + "items": { + "$ref": "#/definitions/ArtifactDependency" + }, + "type": "array", + "description": "describes build artifacts that this artifact depends on.", + "x-intellij-html-description": "describes build artifacts that this artifact depends on." + }, "sync": { "$ref": "#/definitions/Sync", "description": "*beta* local files synced to pods instead of triggering an image build when modified. If no files are listed, sync all the files and infer the destination.", @@ -182,6 +217,7 @@ "image", "context", "sync", + "requires", "jib" ], "additionalProperties": false @@ -207,6 +243,14 @@ "description": "builds images using [kaniko](https://github.com/GoogleContainerTools/kaniko).", "x-intellij-html-description": "builds images using kaniko." }, + "requires": { + "items": { + "$ref": "#/definitions/ArtifactDependency" + }, + "type": "array", + "description": "describes build artifacts that this artifact depends on.", + "x-intellij-html-description": "describes build artifacts that this artifact depends on." + }, "sync": { "$ref": "#/definitions/Sync", "description": "*beta* local files synced to pods instead of triggering an image build when modified. If no files are listed, sync all the files and infer the destination.", @@ -218,6 +262,7 @@ "image", "context", "sync", + "requires", "kaniko" ], "additionalProperties": false @@ -243,6 +288,14 @@ "gcr.io/k8s-skaffold/example" ] }, + "requires": { + "items": { + "$ref": "#/definitions/ArtifactDependency" + }, + "type": "array", + "description": "describes build artifacts that this artifact depends on.", + "x-intellij-html-description": "describes build artifacts that this artifact depends on." + }, "sync": { "$ref": "#/definitions/Sync", "description": "*beta* local files synced to pods instead of triggering an image build when modified. If no files are listed, sync all the files and infer the destination.", @@ -254,6 +307,7 @@ "image", "context", "sync", + "requires", "buildpacks" ], "additionalProperties": false @@ -279,6 +333,14 @@ "gcr.io/k8s-skaffold/example" ] }, + "requires": { + "items": { + "$ref": "#/definitions/ArtifactDependency" + }, + "type": "array", + "description": "describes build artifacts that this artifact depends on.", + "x-intellij-html-description": "describes build artifacts that this artifact depends on." + }, "sync": { "$ref": "#/definitions/Sync", "description": "*beta* local files synced to pods instead of triggering an image build when modified. If no files are listed, sync all the files and infer the destination.", @@ -290,6 +352,7 @@ "image", "context", "sync", + "requires", "custom" ], "additionalProperties": false @@ -298,6 +361,30 @@ "description": "items that need to be built, along with the context in which they should be built.", "x-intellij-html-description": "items that need to be built, along with the context in which they should be built." }, + "ArtifactDependency": { + "required": [ + "image" + ], + "properties": { + "alias": { + "type": "string", + "description": "a token that is replaced with the image reference in the builder definition files. For example, the `docker` builder will use the alias as a build-arg key. Defaults to the value of `image`.", + "x-intellij-html-description": "a token that is replaced with the image reference in the builder definition files. For example, the docker builder will use the alias as a build-arg key. Defaults to the value of image." + }, + "image": { + "type": "string", + "description": "a reference to an artifact's image name.", + "x-intellij-html-description": "a reference to an artifact's image name." + } + }, + "preferredOrder": [ + "image", + "alias" + ], + "additionalProperties": false, + "description": "describes a specific build dependency for an artifact.", + "x-intellij-html-description": "describes a specific build dependency for an artifact." + }, "Auto": { "description": "cannot be customized.", "x-intellij-html-description": "cannot be customized." diff --git a/integration/build_dependencies_test.go b/integration/build_dependencies_test.go new file mode 100644 index 00000000000..b49933ad423 --- /dev/null +++ b/integration/build_dependencies_test.go @@ -0,0 +1,114 @@ +/* +Copyright 2020 The Skaffold Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package integration + +import ( + "strings" + "testing" + + "github.com/sirupsen/logrus" + + "github.com/GoogleContainerTools/skaffold/integration/skaffold" +) + +func TestBuild_WithDependencies(t *testing.T) { + MarkIntegrationTest(t, CanRunWithoutGcp) + + tests := []struct { + description string + args []string + cacheEnabled bool + failure string + }{ + { + description: "default concurrency=1", + }, + { + description: "concurrency=0", + args: []string{"-p", "concurrency-0"}, + }, + { + description: "concurrency=3", + args: []string{"-p", "concurrency-3"}, + }, + { + description: "invalid dependency", + args: []string{"-p", "invalid-dependency"}, + failure: `invalid skaffold config: unknown build dependency "image5" for artifact "image1"`, + }, + { + description: "circular dependency", + args: []string{"-p", "circular-dependency"}, + failure: `invalid skaffold config: cycle detected in build dependencies involving "image1"`, + }, + { + description: "build failure with concurrency=1", + args: []string{"-p", "failed-dependency"}, + failure: `unable to stream build output: The command '/bin/sh -c [ "${FAIL}" == "0" ] || false' returned a non-zero code: 1`, + }, + { + description: "build failure with concurrency=0", + args: []string{"-p", "failed-dependency", "-p", "concurrency-0"}, + failure: `unable to stream build output: The command '/bin/sh -c [ "${FAIL}" == "0" ] || false' returned a non-zero code: 1`, + }, + { + description: "build failure with cache-artifacts=true", + cacheEnabled: true, + failure: "defining dependencies between artifacts is not yet supported for `skaffold build` with cache enabled. Run with `--cache-artifacts=false` flag", + }, + } + + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + if test.cacheEnabled { + test.args = append(test.args, "--cache-artifacts=true") + } else { + test.args = append(test.args, "--cache-artifacts=false") + } + + if test.failure == "" { + // Run without artifact caching + skaffold.Build(test.args...).InDir("testdata/build-dependencies").RunOrFail(t) + checkImagesExist(t) + } else { + if out, err := skaffold.Build(test.args...).InDir("testdata/build-dependencies").RunWithCombinedOutput(t); err == nil { + t.Fatal("expected build to fail") + } else if !strings.Contains(string(out), test.failure) { + logrus.Info("build output: ", string(out)) + t.Fatalf("build failed but for wrong reason") + } + } + }) + } +} + +func TestDev_WithDependencies(t *testing.T) { + MarkIntegrationTest(t, CanRunWithoutGcp) + if out, err := skaffold.Dev().InDir("testdata/build-dependencies").RunWithCombinedOutput(t); err == nil { + t.Fatal("expected build to fail") + } else if !strings.Contains(string(out), "defining dependencies between artifacts is not yet supported for `skaffold dev` and `skaffold debug`") { + logrus.Info("dev output: ", string(out)) + t.Fatalf("dev failed but for wrong reason") + } +} + +func checkImagesExist(t *testing.T) { + checkImageExists(t, "gcr.io/k8s-skaffold/image1:latest") + checkImageExists(t, "gcr.io/k8s-skaffold/image2:latest") + checkImageExists(t, "gcr.io/k8s-skaffold/image3:latest") + checkImageExists(t, "gcr.io/k8s-skaffold/image4:latest") +} diff --git a/integration/testdata/build-dependencies/app1/Dockerfile b/integration/testdata/build-dependencies/app1/Dockerfile new file mode 100644 index 00000000000..0930d698c85 --- /dev/null +++ b/integration/testdata/build-dependencies/app1/Dockerfile @@ -0,0 +1,15 @@ +FROM busybox + +# SLEEP is to simulate build time +ARG SLEEP=0 +# FAIL=1 will cause the build to fail +ARG FAIL=0 +COPY foo /foo + +ENV SLEEP_TIMEOUT=${SLEEP} +ENV FAIL=${FAIL} +RUN echo "sleep ${SLEEP_TIMEOUT}" +RUN sleep ${SLEEP_TIMEOUT} +RUN [ "${FAIL}" == "0" ] || false + +CMD while true; do cat /foo; sleep 1; done \ No newline at end of file diff --git a/integration/testdata/build-dependencies/app1/foo b/integration/testdata/build-dependencies/app1/foo new file mode 100644 index 00000000000..e69de29bb2d diff --git a/integration/testdata/build-dependencies/app2/Dockerfile b/integration/testdata/build-dependencies/app2/Dockerfile new file mode 100644 index 00000000000..0930d698c85 --- /dev/null +++ b/integration/testdata/build-dependencies/app2/Dockerfile @@ -0,0 +1,15 @@ +FROM busybox + +# SLEEP is to simulate build time +ARG SLEEP=0 +# FAIL=1 will cause the build to fail +ARG FAIL=0 +COPY foo /foo + +ENV SLEEP_TIMEOUT=${SLEEP} +ENV FAIL=${FAIL} +RUN echo "sleep ${SLEEP_TIMEOUT}" +RUN sleep ${SLEEP_TIMEOUT} +RUN [ "${FAIL}" == "0" ] || false + +CMD while true; do cat /foo; sleep 1; done \ No newline at end of file diff --git a/integration/testdata/build-dependencies/app2/foo b/integration/testdata/build-dependencies/app2/foo new file mode 100644 index 00000000000..e69de29bb2d diff --git a/integration/testdata/build-dependencies/app3/Dockerfile b/integration/testdata/build-dependencies/app3/Dockerfile new file mode 100644 index 00000000000..0930d698c85 --- /dev/null +++ b/integration/testdata/build-dependencies/app3/Dockerfile @@ -0,0 +1,15 @@ +FROM busybox + +# SLEEP is to simulate build time +ARG SLEEP=0 +# FAIL=1 will cause the build to fail +ARG FAIL=0 +COPY foo /foo + +ENV SLEEP_TIMEOUT=${SLEEP} +ENV FAIL=${FAIL} +RUN echo "sleep ${SLEEP_TIMEOUT}" +RUN sleep ${SLEEP_TIMEOUT} +RUN [ "${FAIL}" == "0" ] || false + +CMD while true; do cat /foo; sleep 1; done \ No newline at end of file diff --git a/integration/testdata/build-dependencies/app3/foo b/integration/testdata/build-dependencies/app3/foo new file mode 100644 index 00000000000..e69de29bb2d diff --git a/integration/testdata/build-dependencies/app4/Dockerfile b/integration/testdata/build-dependencies/app4/Dockerfile new file mode 100644 index 00000000000..0930d698c85 --- /dev/null +++ b/integration/testdata/build-dependencies/app4/Dockerfile @@ -0,0 +1,15 @@ +FROM busybox + +# SLEEP is to simulate build time +ARG SLEEP=0 +# FAIL=1 will cause the build to fail +ARG FAIL=0 +COPY foo /foo + +ENV SLEEP_TIMEOUT=${SLEEP} +ENV FAIL=${FAIL} +RUN echo "sleep ${SLEEP_TIMEOUT}" +RUN sleep ${SLEEP_TIMEOUT} +RUN [ "${FAIL}" == "0" ] || false + +CMD while true; do cat /foo; sleep 1; done \ No newline at end of file diff --git a/integration/testdata/build-dependencies/app4/foo b/integration/testdata/build-dependencies/app4/foo new file mode 100644 index 00000000000..e69de29bb2d diff --git a/integration/testdata/build-dependencies/skaffold.yaml b/integration/testdata/build-dependencies/skaffold.yaml new file mode 100644 index 00000000000..50290de99ae --- /dev/null +++ b/integration/testdata/build-dependencies/skaffold.yaml @@ -0,0 +1,67 @@ +apiVersion: skaffold/v2beta9 +kind: Config +build: + tagPolicy: + sha256: {} + + artifacts: + - image: image1 + context: app1 + docker: + noCache: true + buildArgs: + SLEEP: "1" + FAIL: "0" + requires: + - image: image2 + + - image: image2 + context: app2 + docker: + noCache: true + buildArgs: + SLEEP: "3" + FAIL: "0" + requires: + - image: image3 + + - image: image3 + context: app3 + docker: + noCache: true + buildArgs: + SLEEP: "6" + FAIL: "0" + + - image: image4 + context: app4 + docker: + noCache: true + buildArgs: + SLEEP: "9" + FAIL: "0" +profiles: +- name: concurrency-0 + build: + local: + concurrency: 0 +- name: concurrency-3 + build: + local: + concurrency: 3 +- name: invalid-dependency + patches: + - op: add + path: /build/artifacts/0/requires/0/image + value: "image5" +- name: circular-dependency + patches: + - op: add + path: /build/artifacts/1/requires/0/image + value: "image1" +- name: failed-dependency + patches: + - path: /build/artifacts/1/docker/buildArgs + value: + SLEEP: "3" + FAIL: "1" diff --git a/pkg/skaffold/build/cluster/cluster.go b/pkg/skaffold/build/cluster/cluster.go index e049ce007d5..4b2b1da2068 100644 --- a/pkg/skaffold/build/cluster/cluster.go +++ b/pkg/skaffold/build/cluster/cluster.go @@ -46,7 +46,7 @@ func (b *Builder) Build(ctx context.Context, out io.Writer, tags tag.ImageTags, } builder := build.WithLogFile(b.buildArtifact, b.cfg.Muted()) - return build.InParallel(ctx, out, tags, artifacts, builder, b.ClusterDetails.Concurrency) + return build.InOrder(ctx, out, tags, artifacts, builder, b.ClusterDetails.Concurrency) } func (b *Builder) buildArtifact(ctx context.Context, out io.Writer, artifact *latest.Artifact, tag string) (string, error) { diff --git a/pkg/skaffold/build/gcb/cloud_build.go b/pkg/skaffold/build/gcb/cloud_build.go index 082d1b453c3..93b1ab74982 100644 --- a/pkg/skaffold/build/gcb/cloud_build.go +++ b/pkg/skaffold/build/gcb/cloud_build.go @@ -47,7 +47,7 @@ import ( // Build builds a list of artifacts with Google Cloud Build. func (b *Builder) Build(ctx context.Context, out io.Writer, tags tag.ImageTags, artifacts []*latest.Artifact) ([]build.Artifact, error) { builder := build.WithLogFile(b.buildArtifactWithCloudBuild, b.muted) - return build.InParallel(ctx, out, tags, artifacts, builder, b.GoogleCloudBuild.Concurrency) + return build.InOrder(ctx, out, tags, artifacts, builder, b.GoogleCloudBuild.Concurrency) } func (b *Builder) buildArtifactWithCloudBuild(ctx context.Context, out io.Writer, artifact *latest.Artifact, tag string) (string, error) { diff --git a/pkg/skaffold/build/local/local.go b/pkg/skaffold/build/local/local.go index 33099092fc3..74230a9f393 100644 --- a/pkg/skaffold/build/local/local.go +++ b/pkg/skaffold/build/local/local.go @@ -48,7 +48,7 @@ func (b *Builder) Build(ctx context.Context, out io.Writer, tags tag.ImageTags, } builder := build.WithLogFile(b.buildArtifact, b.muted) - rt, err := build.InParallel(ctx, out, tags, artifacts, builder, *b.local.Concurrency) + rt, err := build.InOrder(ctx, out, tags, artifacts, builder, *b.local.Concurrency) if b.prune { if b.mode == config.RunModes.Build { diff --git a/pkg/skaffold/build/local/prune.go b/pkg/skaffold/build/local/prune.go index 367fa961bdb..a01dfd507f0 100644 --- a/pkg/skaffold/build/local/prune.go +++ b/pkg/skaffold/build/local/prune.go @@ -158,6 +158,10 @@ func (p *pruner) collectImagesToPrune(ctx context.Context, artifacts []*latest.A imgs, err := p.listImages(ctx, a.ImageName) if err != nil { + switch err { + case context.Canceled, context.DeadlineExceeded: + return nil + } logrus.Warnf("failed to list images: %v", err) continue } diff --git a/pkg/skaffold/build/model.go b/pkg/skaffold/build/model.go new file mode 100644 index 00000000000..0886962bf14 --- /dev/null +++ b/pkg/skaffold/build/model.go @@ -0,0 +1,94 @@ +/* +Copyright 2020 The Skaffold Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package build + +import ( + "context" + + "github.com/GoogleContainerTools/skaffold/pkg/skaffold/schema/latest" +) + +// node models the artifact dependency graph using a set of channels. +// Each build node has a wait channel which it closes once it completes building by calling markComplete. +// This notifies all listeners waiting for this node's build to complete. +// Additionally it has a reference to the channels for each of its dependencies. +// Calling `waitForDependencies` ensures that all required nodes' channels have already been closed and as such have finished building before the current artifact build starts. +type node struct { + imageName string + wait chan interface{} + dependencies []node +} + +// markComplete broadcasts that this node's build is complete. +func (a *node) markComplete() { + // closing channel notifies all listeners + close(a.wait) +} + +// waitForDependencies waits for all required builds to complete or returns an error if any build fails +func (a *node) waitForDependencies(ctx context.Context) error { + for _, dep := range a.dependencies { + // wait for required builds to complete + select { + case <-ctx.Done(): + return ctx.Err() + case <-dep.wait: + } + } + return nil +} + +func createNodes(artifacts []*latest.Artifact) []node { + nodeMap := make(map[string]node) + for _, a := range artifacts { + nodeMap[a.ImageName] = node{ + imageName: a.ImageName, + wait: make(chan interface{}), + } + } + + var nodes []node + for _, a := range artifacts { + ar := nodeMap[a.ImageName] + for _, d := range a.Dependencies { + ch, found := nodeMap[d.ImageName] + if !found { + // if a dependency is not present in `artifacts` slice then we ignore it. + continue + } + ar.dependencies = append(ar.dependencies, ch) + } + nodes = append(nodes, ar) + } + return nodes +} + +// countingSemaphore uses a buffered channel of size `n` that acts like a counting semaphore, allowing up to `n` concurrent operations +type countingSemaphore struct { + sem chan bool +} + +func newCountingSemaphore(count int) countingSemaphore { + return countingSemaphore{sem: make(chan bool, count)} +} + +func (c countingSemaphore) acquire() (release func()) { + c.sem <- true + return func() { + <-c.sem + } +} diff --git a/pkg/skaffold/build/parallel.go b/pkg/skaffold/build/parallel.go deleted file mode 100644 index 2d04b4c2533..00000000000 --- a/pkg/skaffold/build/parallel.go +++ /dev/null @@ -1,147 +0,0 @@ -/* -Copyright 2019 The Skaffold Authors - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package build - -import ( - "bufio" - "context" - "fmt" - "io" - "sync" - - "github.com/GoogleContainerTools/skaffold/pkg/skaffold/build/tag" - "github.com/GoogleContainerTools/skaffold/pkg/skaffold/color" - "github.com/GoogleContainerTools/skaffold/pkg/skaffold/event" - "github.com/GoogleContainerTools/skaffold/pkg/skaffold/schema/latest" -) - -const bufferedLinesPerArtifact = 10000 - -type ArtifactBuilder func(ctx context.Context, out io.Writer, artifact *latest.Artifact, tag string) (string, error) - -// For testing -var ( - buffSize = bufferedLinesPerArtifact - runInSequence = InSequence -) - -// InParallel builds a list of artifacts in parallel but prints the logs in sequential order. -func InParallel(ctx context.Context, out io.Writer, tags tag.ImageTags, artifacts []*latest.Artifact, buildArtifact ArtifactBuilder, concurrency int) ([]Artifact, error) { - if len(artifacts) == 0 { - return nil, nil - } - - if len(artifacts) == 1 || concurrency == 1 { - return runInSequence(ctx, out, tags, artifacts, buildArtifact) - } - - var wg sync.WaitGroup - defer wg.Wait() - - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - results := new(sync.Map) - outputs := make([]chan string, len(artifacts)) - - if concurrency == 0 { - concurrency = len(artifacts) - } - sem := make(chan bool, concurrency) - - // Run builds in // - wg.Add(len(artifacts)) - for i := range artifacts { - outputs[i] = make(chan string, buffSize) - r, w := io.Pipe() - - // Run build and write output/logs to piped writer and store build result in - // sync.Map - go func(i int) { - sem <- true - runBuild(ctx, w, tags, artifacts[i], results, buildArtifact) - <-sem - - wg.Done() - }(i) - - // Read build output/logs and write to buffered channel - go readOutputAndWriteToChannel(r, outputs[i]) - } - - // Print logs and collect results in order. - return collectResults(out, artifacts, results, outputs) -} - -func runBuild(ctx context.Context, cw io.WriteCloser, tags tag.ImageTags, artifact *latest.Artifact, results *sync.Map, build ArtifactBuilder) { - event.BuildInProgress(artifact.ImageName) - - finalTag, err := getBuildResult(ctx, cw, tags, artifact, build) - if err != nil { - event.BuildFailed(artifact.ImageName, err) - results.Store(artifact.ImageName, err) - } else { - event.BuildComplete(artifact.ImageName) - artifact := Artifact{ImageName: artifact.ImageName, Tag: finalTag} - results.Store(artifact.ImageName, artifact) - } - cw.Close() -} - -func readOutputAndWriteToChannel(r io.Reader, lines chan string) { - scanner := bufio.NewScanner(r) - for scanner.Scan() { - lines <- scanner.Text() - } - close(lines) -} - -func getBuildResult(ctx context.Context, cw io.Writer, tags tag.ImageTags, artifact *latest.Artifact, build ArtifactBuilder) (string, error) { - color.Default.Fprintf(cw, "Building [%s]...\n", artifact.ImageName) - tag, present := tags[artifact.ImageName] - if !present { - return "", fmt.Errorf("unable to find tag for image %s", artifact.ImageName) - } - return build(ctx, cw, artifact, tag) -} - -func collectResults(out io.Writer, artifacts []*latest.Artifact, results *sync.Map, outputs []chan string) ([]Artifact, error) { - var built []Artifact - for i, artifact := range artifacts { - // Wait for build to complete. - printResult(out, outputs[i]) - v, ok := results.Load(artifact.ImageName) - if !ok { - return nil, fmt.Errorf("could not find build result for image %s", artifact.ImageName) - } - switch t := v.(type) { - case error: - return nil, fmt.Errorf("couldn't build %q: %w", artifact.ImageName, t) - case Artifact: - built = append(built, t) - default: - return nil, fmt.Errorf("unknown type %T for %s", t, artifact.ImageName) - } - } - return built, nil -} - -func printResult(out io.Writer, output chan string) { - for line := range output { - fmt.Fprintln(out, line) - } -} diff --git a/pkg/skaffold/build/result.go b/pkg/skaffold/build/result.go new file mode 100644 index 00000000000..97bb870155d --- /dev/null +++ b/pkg/skaffold/build/result.go @@ -0,0 +1,153 @@ +/* +Copyright 2020 The Skaffold Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package build + +import ( + "bufio" + "context" + "fmt" + "io" + "sync" + + "github.com/sirupsen/logrus" + + "github.com/GoogleContainerTools/skaffold/pkg/skaffold/schema/latest" +) + +const bufferedLinesPerArtifact = 10000 + +// For testing +var ( + buffSize = bufferedLinesPerArtifact +) + +// logAggregator provides an interface to create an output writer for each artifact build and later aggregate the logs in build order. +// The order of output is not guaranteed between multiple builds running concurrently. +type logAggregator interface { + // GetWriter returns an output writer tracked by the logAggregator + GetWriter() (io.WriteCloser, error) + // PrintInOrder prints the output from each allotted writer in build order. + // It blocks until the instantiated capacity of io writers have been all allotted and closed, or the context is cancelled. + PrintInOrder(ctx context.Context, out io.Writer) +} + +type logAggregatorImpl struct { + messages chan chan string + size int + capacity int + countMutex sync.Mutex +} + +func (l *logAggregatorImpl) GetWriter() (io.WriteCloser, error) { + if err := l.checkCapacity(); err != nil { + return nil, err + } + r, w := io.Pipe() + ch := make(chan string, buffSize) + l.messages <- ch + // write the build output to a buffered channel. + go l.writeToChannel(r, ch) + return w, nil +} + +func (l *logAggregatorImpl) PrintInOrder(ctx context.Context, out io.Writer) { + go func() { + <-ctx.Done() + // we handle cancellation by passing a nil struct instead of closing the channel. + // This makes it easier to flush all pending messages on the buffered channel before returning and avoid any race with pending requests for new writers. + l.messages <- nil + }() + for i := 0; i < l.capacity; i++ { + ch := <-l.messages + if ch == nil { + return + } + // read from each build's message channel and write to the given output. + printResult(out, ch) + } +} + +func (l *logAggregatorImpl) checkCapacity() error { + l.countMutex.Lock() + defer l.countMutex.Unlock() + if l.size == l.capacity { + return fmt.Errorf("failed to create writer: capacity exceeded") + } + l.size++ + return nil +} + +func printResult(out io.Writer, output chan string) { + for line := range output { + fmt.Fprintln(out, line) + } +} + +func (l *logAggregatorImpl) writeToChannel(r io.Reader, lines chan string) { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + lines <- scanner.Text() + } + close(lines) +} + +func newLogAggregator(capacity int) logAggregator { + return &logAggregatorImpl{capacity: capacity, messages: make(chan chan string, capacity)} +} + +// builtArtifacts stores the results of each artifact build. +type builtArtifacts interface { + Record(a *latest.Artifact, tag string) + GetTag(a *latest.Artifact) (string, error) + GetArtifacts(s []*latest.Artifact) ([]Artifact, error) +} + +func newArtifactsStore() builtArtifacts { + return &builtArtifactsImpl{m: new(sync.Map)} +} + +type builtArtifactsImpl struct { + m *sync.Map +} + +func (ba *builtArtifactsImpl) Record(a *latest.Artifact, tag string) { + ba.m.Store(a.ImageName, tag) +} + +func (ba *builtArtifactsImpl) GetTag(a *latest.Artifact) (string, error) { + v, ok := ba.m.Load(a.ImageName) + if !ok { + return "", fmt.Errorf("could not find build result for image %s", a.ImageName) + } + t, ok := v.(string) + if !ok { + logrus.Fatalf("invalid build output recorded for image %s", a.ImageName) + } + return t, nil +} + +func (ba *builtArtifactsImpl) GetArtifacts(s []*latest.Artifact) ([]Artifact, error) { + var builds []Artifact + for _, a := range s { + t, err := ba.GetTag(a) + if err != nil { + return nil, err + } + builds = append(builds, Artifact{ImageName: a.ImageName, Tag: t}) + } + return builds, nil +} diff --git a/pkg/skaffold/build/scheduler.go b/pkg/skaffold/build/scheduler.go new file mode 100644 index 00000000000..27223485783 --- /dev/null +++ b/pkg/skaffold/build/scheduler.go @@ -0,0 +1,131 @@ +/* +Copyright 2020 The Skaffold Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package build + +import ( + "context" + "fmt" + "io" + + "golang.org/x/sync/errgroup" + + "github.com/GoogleContainerTools/skaffold/pkg/skaffold/build/tag" + "github.com/GoogleContainerTools/skaffold/pkg/skaffold/color" + "github.com/GoogleContainerTools/skaffold/pkg/skaffold/event" + "github.com/GoogleContainerTools/skaffold/pkg/skaffold/schema/latest" +) + +type ArtifactBuilder func(ctx context.Context, out io.Writer, artifact *latest.Artifact, tag string) (string, error) + +type scheduler struct { + artifacts []*latest.Artifact + nodes []node // size len(artifacts) + artifactBuilder ArtifactBuilder + logger logAggregator + results builtArtifacts + concurrencySem countingSemaphore +} + +func newScheduler(artifacts []*latest.Artifact, artifactBuilder ArtifactBuilder, concurrency int) *scheduler { + s := scheduler{ + artifacts: artifacts, + nodes: createNodes(artifacts), + artifactBuilder: artifactBuilder, + logger: newLogAggregator(len(artifacts)), + results: newArtifactsStore(), + concurrencySem: newCountingSemaphore(concurrency), + } + return &s +} + +func (s *scheduler) run(ctx context.Context, out io.Writer, tags tag.ImageTags) ([]Artifact, error) { + g, gCtx := errgroup.WithContext(ctx) + + for i := range s.artifacts { + i := i + + // Create a goroutine for each element in dag. Each goroutine waits on its dependencies to finish building. + // Because our artifacts form a DAG, at least one of the goroutines should be able to start building. + // Wrap in an error group so that all other builds are cancelled as soon as any one fails. + g.Go(func() error { + return s.build(gCtx, tags, i) + }) + } + // print output for all artifact builds in order + s.logger.PrintInOrder(gCtx, out) + if err := g.Wait(); err != nil { + return nil, err + } + return s.results.GetArtifacts(s.artifacts) +} + +func (s *scheduler) build(ctx context.Context, tags tag.ImageTags, i int) error { + n := s.nodes[i] + a := s.artifacts[i] + err := n.waitForDependencies(ctx) + if err != nil { + // `waitForDependencies` only returns `context.Canceled` error + event.BuildCanceled(a.ImageName) + return err + } + release := s.concurrencySem.acquire() + defer release() + + event.BuildInProgress(a.ImageName) + + w, err := s.logger.GetWriter() + if err != nil { + event.BuildFailed(a.ImageName, err) + return err + } + defer w.Close() + + finalTag, err := performBuild(ctx, w, tags, a, s.artifactBuilder) + if err != nil { + event.BuildFailed(a.ImageName, err) + return err + } + + s.results.Record(a, finalTag) + n.markComplete() + event.BuildComplete(a.ImageName) + return nil +} + +// InOrder builds a list of artifacts in dependency order. +func InOrder(ctx context.Context, out io.Writer, tags tag.ImageTags, artifacts []*latest.Artifact, artifactBuilder ArtifactBuilder, concurrency int) ([]Artifact, error) { + // `concurrency` specifies the max number of builds that can run at any one time. If concurrency is 0, then all builds can run in parallel. + if concurrency == 0 { + concurrency = len(artifacts) + } + if concurrency > 1 { + color.Default.Fprintf(out, "Building %d artifacts in parallel\n", concurrency) + } + s := newScheduler(artifacts, artifactBuilder, concurrency) + ctx, cancel := context.WithCancel(ctx) + defer cancel() + return s.run(ctx, out, tags) +} + +func performBuild(ctx context.Context, cw io.Writer, tags tag.ImageTags, artifact *latest.Artifact, build ArtifactBuilder) (string, error) { + color.Default.Fprintf(cw, "Building [%s]...\n", artifact.ImageName) + tag, present := tags[artifact.ImageName] + if !present { + return "", fmt.Errorf("unable to find tag for image %s", artifact.ImageName) + } + return build(ctx, cw, artifact, tag) +} diff --git a/pkg/skaffold/build/parallel_test.go b/pkg/skaffold/build/scheduler_test.go similarity index 59% rename from pkg/skaffold/build/parallel_test.go rename to pkg/skaffold/build/scheduler_test.go index d73d8067e5e..715fea0422d 100644 --- a/pkg/skaffold/build/parallel_test.go +++ b/pkg/skaffold/build/scheduler_test.go @@ -27,7 +27,10 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" + "github.com/GoogleContainerTools/skaffold/pkg/skaffold/build/tag" + "github.com/GoogleContainerTools/skaffold/pkg/skaffold/event" "github.com/GoogleContainerTools/skaffold/pkg/skaffold/schema/latest" "github.com/GoogleContainerTools/skaffold/testutil" ) @@ -77,7 +80,7 @@ func TestGetBuild(t *testing.T) { out := new(bytes.Buffer) artifact := &latest.Artifact{ImageName: "skaffold/image1"} - got, err := getBuildResult(context.Background(), out, test.tags, artifact, test.buildArtifact) + got, err := performBuild(context.Background(), out, test.tags, artifact, test.buildArtifact) t.CheckErrorAndDeepEqual(test.shouldErr, err, test.expectedTag, got) t.CheckDeepEqual(test.expectedOut, out.String()) @@ -85,7 +88,7 @@ func TestGetBuild(t *testing.T) { } } -func TestCollectResults(t *testing.T) { +func TestFormatResults(t *testing.T) { tests := []struct { description string artifacts []*latest.Artifact @@ -104,52 +107,9 @@ func TestCollectResults(t *testing.T) { {ImageName: "skaffold/image2", Tag: "skaffold/image2:v0.0.2@sha256:abac"}, }, results: map[string]interface{}{ - "skaffold/image1": Artifact{ - ImageName: "skaffold/image1", - Tag: "skaffold/image1:v0.0.1@sha256:abac", - }, - "skaffold/image2": Artifact{ - ImageName: "skaffold/image2", - Tag: "skaffold/image2:v0.0.2@sha256:abac", - }, - }, - }, - { - description: "first build errors", - artifacts: []*latest.Artifact{ - {ImageName: "skaffold/image1"}, - {ImageName: "skaffold/image2"}, - }, - expected: nil, - results: map[string]interface{}{ - "skaffold/image1": fmt.Errorf("Could not build image skaffold/image1"), - "skaffold/image2": Artifact{ - ImageName: "skaffold/image2", - Tag: "skaffold/image2:v0.0.2@sha256:abac", - }, - }, - shouldErr: true, - }, - { - description: "arbitrary image build failure", - artifacts: []*latest.Artifact{ - {ImageName: "skaffold/image1"}, - {ImageName: "skaffold/image2"}, - {ImageName: "skaffold/image3"}, - }, - expected: nil, - results: map[string]interface{}{ - "skaffold/image1": Artifact{ - ImageName: "skaffold/image1", - Tag: "skaffold/image1:v0.0.1@sha256:abac", - }, - "skaffold/image2": fmt.Errorf("Could not build image skaffold/image1"), - "skaffold/image3": Artifact{ - ImageName: "skaffold/image3", - Tag: "skaffold/image3:v0.0.1@sha256:abac", - }, + "skaffold/image1": "skaffold/image1:v0.0.1@sha256:abac", + "skaffold/image2": "skaffold/image2:v0.0.2@sha256:abac", }, - shouldErr: true, }, { description: "no build result produced for a build", @@ -159,42 +119,26 @@ func TestCollectResults(t *testing.T) { }, expected: nil, results: map[string]interface{}{ - "skaffold/image1": Artifact{ - ImageName: "skaffold/image1:v0.0.1@sha256:abac", - Tag: "skaffold/image1:v0.0.1@sha256:abac", - }, - }, - shouldErr: true, - }, - { - description: "build produced an incorrect value type", - artifacts: []*latest.Artifact{ - {ImageName: "skaffold/image1"}, - {ImageName: "skaffold/image2"}, - }, - expected: nil, - results: map[string]interface{}{ - "skaffold/image1": 1, + "skaffold/image1": "skaffold/image1:v0.0.1@sha256:abac", }, shouldErr: true, }, } for _, test := range tests { testutil.Run(t, test.description, func(t *testutil.T) { - outputs := setUpChannels(len(test.artifacts)) - resultMap := new(sync.Map) + m := new(sync.Map) for k, v := range test.results { - resultMap.Store(k, v) + m.Store(k, v) } - - got, err := collectResults(ioutil.Discard, test.artifacts, resultMap, outputs) + results := &builtArtifactsImpl{m: m} + got, err := results.GetArtifacts(test.artifacts) t.CheckErrorAndDeepEqual(test.shouldErr, err, test.expected, got) }) } } -func TestInParallel(t *testing.T) { +func TestInOrder(t *testing.T) { tests := []struct { description string buildFunc ArtifactBuilder @@ -202,7 +146,7 @@ func TestInParallel(t *testing.T) { }{ { description: "short and nice build log", - expected: "Building [skaffold/image1]...\nshort\nBuilding [skaffold/image2]...\nshort\n", + expected: "Building 2 artifacts in parallel\nBuilding [skaffold/image1]...\nshort\nBuilding [skaffold/image2]...\nshort\n", buildFunc: func(ctx context.Context, out io.Writer, artifact *latest.Artifact, tag string) (string, error) { out.Write([]byte("short")) return fmt.Sprintf("%s:tag", artifact.ImageName), nil @@ -210,7 +154,8 @@ func TestInParallel(t *testing.T) { }, { description: "long build log gets printed correctly", - expected: `Building [skaffold/image1]... + expected: `Building 2 artifacts in parallel +Building [skaffold/image1]... This is a long string more than 10 bytes. And new lines Building [skaffold/image2]... @@ -228,7 +173,7 @@ And new lines out := new(bytes.Buffer) artifacts := []*latest.Artifact{ {ImageName: "skaffold/image1"}, - {ImageName: "skaffold/image2"}, + {ImageName: "skaffold/image2", Dependencies: []*latest.ArtifactDependency{{ImageName: "skaffold/image1"}}}, } tags := tag.ImageTags{ "skaffold/image1": "skaffold/image1:v0.0.1", @@ -236,14 +181,14 @@ And new lines } initializeEvents() - InParallel(context.Background(), out, tags, artifacts, test.buildFunc, 0) + InOrder(context.Background(), out, tags, artifacts, test.buildFunc, 0) t.CheckDeepEqual(test.expected, out.String()) }) } } -func TestInParallelConcurrency(t *testing.T) { +func TestInOrderConcurrency(t *testing.T) { tests := []struct { artifacts int limit int @@ -291,7 +236,7 @@ func TestInParallelConcurrency(t *testing.T) { } initializeEvents() - results, err := InParallel(context.Background(), ioutil.Discard, tags, artifacts, builder, test.limit) + results, err := InOrder(context.Background(), ioutil.Discard, tags, artifacts, builder, test.limit) t.CheckNoError(err) t.CheckDeepEqual(test.artifacts, len(results)) @@ -299,31 +244,66 @@ func TestInParallelConcurrency(t *testing.T) { } } -func TestInParallelForArgs(t *testing.T) { +func TestInOrderForArgs(t *testing.T) { tests := []struct { description string - inSeqFunc func(context.Context, io.Writer, tag.ImageTags, []*latest.Artifact, ArtifactBuilder) ([]Artifact, error) buildArtifact ArtifactBuilder artifactLen int + concurrency int + dependency map[int][]int expected []Artifact + err error }{ { - description: "runs in sequence for 1 artifact", - inSeqFunc: func(context.Context, io.Writer, tag.ImageTags, []*latest.Artifact, ArtifactBuilder) ([]Artifact, error) { - return []Artifact{{ImageName: "singleArtifact", Tag: "one"}}, nil + description: "runs in parallel for 2 artifacts with no dependency", + buildArtifact: func(_ context.Context, _ io.Writer, _ *latest.Artifact, tag string) (string, error) { + return tag, nil + }, + artifactLen: 2, + expected: []Artifact{ + {ImageName: "artifact1", Tag: "artifact1@tag1"}, + {ImageName: "artifact2", Tag: "artifact2@tag2"}, + }, + }, + { + description: "runs in parallel for 5 artifacts with dependencies", + buildArtifact: func(_ context.Context, _ io.Writer, _ *latest.Artifact, tag string) (string, error) { + return tag, nil + }, + dependency: map[int][]int{ + 0: {2, 3}, + 1: {3}, + 2: {1}, + 3: {4}, + }, + artifactLen: 5, + expected: []Artifact{ + {ImageName: "artifact1", Tag: "artifact1@tag1"}, + {ImageName: "artifact2", Tag: "artifact2@tag2"}, + {ImageName: "artifact3", Tag: "artifact3@tag3"}, + {ImageName: "artifact4", Tag: "artifact4@tag4"}, + {ImageName: "artifact5", Tag: "artifact5@tag5"}, }, - artifactLen: 1, - expected: []Artifact{{ImageName: "singleArtifact", Tag: "one"}}, }, { - description: "runs in parallel for 2 artifacts", + description: "runs with max concurrency of 2 for 5 artifacts with dependencies", buildArtifact: func(_ context.Context, _ io.Writer, _ *latest.Artifact, tag string) (string, error) { return tag, nil }, - artifactLen: 2, + dependency: map[int][]int{ + 0: {2, 3}, + 1: {3}, + 2: {1}, + 3: {4}, + }, + artifactLen: 5, + concurrency: 2, expected: []Artifact{ {ImageName: "artifact1", Tag: "artifact1@tag1"}, {ImageName: "artifact2", Tag: "artifact2@tag2"}, + {ImageName: "artifact3", Tag: "artifact3@tag3"}, + {ImageName: "artifact4", Tag: "artifact4@tag4"}, + {ImageName: "artifact5", Tag: "artifact5@tag5"}, }, }, { @@ -331,6 +311,41 @@ func TestInParallelForArgs(t *testing.T) { artifactLen: 0, expected: nil, }, + { + description: "build fails for artifacts without dependencies", + buildArtifact: func(c context.Context, _ io.Writer, a *latest.Artifact, tag string) (string, error) { + if a.ImageName == "artifact2" { + return "", fmt.Errorf(`some error occurred while building "artifact2"`) + } + select { + case <-c.Done(): + return "", c.Err() + case <-time.After(5 * time.Second): + return tag, nil + } + }, + artifactLen: 5, + expected: nil, + err: fmt.Errorf(`some error occurred while building "artifact2"`), + }, + { + description: "build fails for artifacts with dependencies", + buildArtifact: func(_ context.Context, _ io.Writer, a *latest.Artifact, tag string) (string, error) { + if a.ImageName == "artifact2" { + return "", fmt.Errorf(`some error occurred while building "artifact2"`) + } + return tag, nil + }, + dependency: map[int][]int{ + 0: {1}, + 1: {2}, + 2: {3}, + 3: {4}, + }, + artifactLen: 5, + expected: nil, + err: fmt.Errorf(`some error occurred while building "artifact2"`), + }, } for _, test := range tests { testutil.Run(t, test.description, func(t *testutil.T) { @@ -341,22 +356,52 @@ func TestInParallelForArgs(t *testing.T) { artifacts[i] = &latest.Artifact{ImageName: a} tags[a] = fmt.Sprintf("%s@tag%d", a, i+1) } - if test.inSeqFunc != nil { - t.Override(&runInSequence, test.inSeqFunc) - } + + setDependencies(artifacts, test.dependency) initializeEvents() - actual, _ := InParallel(context.Background(), ioutil.Discard, tags, artifacts, test.buildArtifact, 0) + actual, err := InOrder(context.Background(), ioutil.Discard, tags, artifacts, test.buildArtifact, test.concurrency) t.CheckDeepEqual(test.expected, actual) + t.CheckDeepEqual(test.err, err, cmp.Comparer(errorsComparer)) }) } } -func setUpChannels(n int) []chan string { - outputs := make([]chan string, n) - for i := 0; i < n; i++ { - outputs[i] = make(chan string, 10) - close(outputs[i]) +// setDependencies constructs a graph of artifact dependencies using the map as an adjacency list representation of indices in the artifacts array. +// For example: +// m = { +// 0 : {1, 2}, +// 2 : {3}, +//} +// implies that a[0] artifact depends on a[1] and a[2]; and a[2] depends on a[3]. +func setDependencies(a []*latest.Artifact, d map[int][]int) { + for k, dep := range d { + for i := range dep { + a[k].Dependencies = append(a[k].Dependencies, &latest.ArtifactDependency{ + ImageName: a[dep[i]].ImageName, + }) + } + } +} + +func initializeEvents() { + pipe := latest.Pipeline{ + Deploy: latest.DeployConfig{}, + Build: latest.BuildConfig{ + BuildType: latest.BuildType{ + LocalBuild: &latest.LocalBuild{}, + }, + }, + } + event.InitializeState(pipe, "temp", true, true, true) +} + +func errorsComparer(a, b error) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false } - return outputs + return a.Error() == b.Error() } diff --git a/pkg/skaffold/build/sequence.go b/pkg/skaffold/build/sequence.go deleted file mode 100644 index e63ed7b70af..00000000000 --- a/pkg/skaffold/build/sequence.go +++ /dev/null @@ -1,59 +0,0 @@ -/* -Copyright 2019 The Skaffold Authors - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package build - -import ( - "context" - "fmt" - "io" - - "github.com/GoogleContainerTools/skaffold/pkg/skaffold/build/tag" - "github.com/GoogleContainerTools/skaffold/pkg/skaffold/color" - "github.com/GoogleContainerTools/skaffold/pkg/skaffold/event" - "github.com/GoogleContainerTools/skaffold/pkg/skaffold/schema/latest" -) - -// InSequence builds a list of artifacts in sequence. -func InSequence(ctx context.Context, out io.Writer, tags tag.ImageTags, artifacts []*latest.Artifact, buildArtifact ArtifactBuilder) ([]Artifact, error) { - var builds []Artifact - - for _, artifact := range artifacts { - color.Default.Fprintf(out, "Building [%s]...\n", artifact.ImageName) - - event.BuildInProgress(artifact.ImageName) - - tag, present := tags[artifact.ImageName] - if !present { - return nil, fmt.Errorf("unable to find tag for image %s", artifact.ImageName) - } - - finalTag, err := buildArtifact(ctx, out, artifact, tag) - if err != nil { - event.BuildFailed(artifact.ImageName, err) - return nil, fmt.Errorf("couldn't build %q: %w", artifact.ImageName, err) - } - - event.BuildComplete(artifact.ImageName) - - builds = append(builds, Artifact{ - ImageName: artifact.ImageName, - Tag: finalTag, - }) - } - - return builds, nil -} diff --git a/pkg/skaffold/build/sequence_test.go b/pkg/skaffold/build/sequence_test.go deleted file mode 100644 index d9dc3d19479..00000000000 --- a/pkg/skaffold/build/sequence_test.go +++ /dev/null @@ -1,156 +0,0 @@ -/* -Copyright 2019 The Skaffold Authors - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package build - -import ( - "bytes" - "context" - "fmt" - "io" - "io/ioutil" - "testing" - - "github.com/GoogleContainerTools/skaffold/pkg/skaffold/build/tag" - "github.com/GoogleContainerTools/skaffold/pkg/skaffold/event" - "github.com/GoogleContainerTools/skaffold/pkg/skaffold/schema/latest" - "github.com/GoogleContainerTools/skaffold/testutil" -) - -func TestInSequence(t *testing.T) { - tests := []struct { - description string - buildArtifact ArtifactBuilder - tags tag.ImageTags - expectedArtifacts []Artifact - expectedOut string - shouldErr bool - }{ - { - description: "build succeeds", - buildArtifact: func(ctx context.Context, out io.Writer, artifact *latest.Artifact, tag string) (string, error) { - return fmt.Sprintf("%s@sha256:abac", tag), nil - }, - tags: tag.ImageTags{ - "skaffold/image1": "skaffold/image1:v0.0.1", - "skaffold/image2": "skaffold/image2:v0.0.2", - }, - expectedArtifacts: []Artifact{ - {ImageName: "skaffold/image1", Tag: "skaffold/image1:v0.0.1@sha256:abac"}, - {ImageName: "skaffold/image2", Tag: "skaffold/image2:v0.0.2@sha256:abac"}, - }, - expectedOut: "Building [skaffold/image1]...\nBuilding [skaffold/image2]...\n", - }, - { - description: "build fails", - buildArtifact: func(ctx context.Context, out io.Writer, artifact *latest.Artifact, tag string) (string, error) { - return "", fmt.Errorf("build fails") - }, - tags: tag.ImageTags{ - "skaffold/image1": "", - }, - expectedOut: "Building [skaffold/image1]...\n", - shouldErr: true, - }, - { - description: "tag not found", - tags: tag.ImageTags{}, - expectedOut: "Building [skaffold/image1]...\n", - shouldErr: true, - }, - } - for _, test := range tests { - testutil.Run(t, test.description, func(t *testutil.T) { - out := new(bytes.Buffer) - artifacts := []*latest.Artifact{ - {ImageName: "skaffold/image1"}, - {ImageName: "skaffold/image2"}, - } - - got, err := InSequence(context.Background(), out, test.tags, artifacts, test.buildArtifact) - - t.CheckErrorAndDeepEqual(test.shouldErr, err, test.expectedArtifacts, got) - t.CheckDeepEqual(test.expectedOut, out.String()) - }) - } -} - -func TestInSequenceResultsOrder(t *testing.T) { - tests := []struct { - description string - images []string - expected []Artifact - shouldErr bool - }{ - { - description: "shd concatenate the tag", - images: []string{"a", "b", "c", "d"}, - expected: []Artifact{ - {ImageName: "a", Tag: "a:a"}, - {ImageName: "b", Tag: "b:ab"}, - {ImageName: "c", Tag: "c:abc"}, - {ImageName: "d", Tag: "d:abcd"}, - }, - }, - } - for _, test := range tests { - testutil.Run(t, test.description, func(t *testutil.T) { - out := ioutil.Discard - initializeEvents() - artifacts := make([]*latest.Artifact, len(test.images)) - tags := tag.ImageTags{} - for i, image := range test.images { - artifacts[i] = &latest.Artifact{ - ImageName: image, - } - tags[image] = image - } - builder := concatTagger{} - - got, err := InSequence(context.Background(), out, tags, artifacts, builder.doBuild) - - t.CheckErrorAndDeepEqual(test.shouldErr, err, test.expected, got) - }) - } -} - -// concatTagger builder sums all the numbers -type concatTagger struct { - tag string -} - -// doBuild calculate the tag based by concatinating the tag values for artifact -// builds seen so far. It mimics artifact dependency where the next build result -// depends on the previous build result. -func (t *concatTagger) doBuild(ctx context.Context, out io.Writer, artifact *latest.Artifact, tag string) (string, error) { - t.tag += tag - return fmt.Sprintf("%s:%s", artifact.ImageName, t.tag), nil -} - -func initializeEvents() { - event.InitializeState(latest.Pipeline{ - Deploy: latest.DeployConfig{}, - Build: latest.BuildConfig{ - BuildType: latest.BuildType{ - LocalBuild: &latest.LocalBuild{}, - }, - }, - }, - "temp", - true, - true, - true) -} diff --git a/pkg/skaffold/event/event.go b/pkg/skaffold/event/event.go index 511c4a400b1..3e6a8dcccf5 100644 --- a/pkg/skaffold/event/event.go +++ b/pkg/skaffold/event/event.go @@ -39,6 +39,7 @@ const ( Started = "Started" Succeeded = "Succeeded" Terminated = "Terminated" + Canceled = "Canceled" ) var handler = newHandler() @@ -300,6 +301,11 @@ func BuildInProgress(imageName string) { handler.handleBuildEvent(&proto.BuildEvent{Artifact: imageName, Status: InProgress}) } +// BuildCanceled notifies that a build has been canceled. +func BuildCanceled(imageName string) { + handler.handleBuildEvent(&proto.BuildEvent{Artifact: imageName, Status: Canceled}) +} + // BuildFailed notifies that a build has failed. func BuildFailed(imageName string, err error) { aiErr := sErrors.ActionableErr(sErrors.Build, err) diff --git a/pkg/skaffold/schema/defaults/defaults.go b/pkg/skaffold/schema/defaults/defaults.go index 2ed376e0e7e..da0abf6cba1 100644 --- a/pkg/skaffold/schema/defaults/defaults.go +++ b/pkg/skaffold/schema/defaults/defaults.go @@ -68,6 +68,10 @@ func Set(c *latest.SkaffoldConfig) error { case a.BuildpackArtifact != nil: setBuildpackArtifactDefaults(a.BuildpackArtifact) } + + for _, d := range a.Dependencies { + setDefaultArtifactDependencyAlias(d) + } } withLocalBuild(c, @@ -371,3 +375,9 @@ func setDefaultAddress(pf *latest.PortForwardResource) { pf.Address = constants.DefaultPortForwardAddress } } + +func setDefaultArtifactDependencyAlias(d *latest.ArtifactDependency) { + if d.Alias == "" { + d.Alias = d.ImageName + } +} diff --git a/pkg/skaffold/schema/defaults/defaults_test.go b/pkg/skaffold/schema/defaults/defaults_test.go index e341f4332df..453d32b6484 100644 --- a/pkg/skaffold/schema/defaults/defaults_test.go +++ b/pkg/skaffold/schema/defaults/defaults_test.go @@ -35,6 +35,10 @@ func TestSetDefaults(t *testing.T) { Artifacts: []*latest.Artifact{ { ImageName: "first", + Dependencies: []*latest.ArtifactDependency{ + {ImageName: "second", Alias: "secondAlias"}, + {ImageName: "third"}, + }, }, { ImageName: "second", @@ -83,6 +87,8 @@ func TestSetDefaults(t *testing.T) { testutil.CheckDeepEqual(t, "first", cfg.Build.Artifacts[0].ImageName) testutil.CheckDeepEqual(t, ".", cfg.Build.Artifacts[0].Workspace) testutil.CheckDeepEqual(t, "Dockerfile", cfg.Build.Artifacts[0].DockerArtifact.DockerfilePath) + testutil.CheckDeepEqual(t, "secondAlias", cfg.Build.Artifacts[0].Dependencies[0].Alias) + testutil.CheckDeepEqual(t, "third", cfg.Build.Artifacts[0].Dependencies[1].Alias) testutil.CheckDeepEqual(t, "second", cfg.Build.Artifacts[1].ImageName) testutil.CheckDeepEqual(t, "folder", cfg.Build.Artifacts[1].Workspace) diff --git a/pkg/skaffold/schema/latest/config.go b/pkg/skaffold/schema/latest/config.go index a592893db93..66c0447484c 100644 --- a/pkg/skaffold/schema/latest/config.go +++ b/pkg/skaffold/schema/latest/config.go @@ -762,6 +762,9 @@ type Artifact struct { // ArtifactType describes how to build an artifact. ArtifactType `yaml:",inline"` + + // Dependencies describes build artifacts that this artifact depends on. + Dependencies []*ArtifactDependency `yaml:"requires,omitempty"` } // Sync *beta* specifies what files to sync into the container. @@ -884,6 +887,16 @@ type ArtifactType struct { CustomArtifact *CustomArtifact `yaml:"custom,omitempty" yamltags:"oneOf=artifact"` } +// ArtifactDependency describes a specific build dependency for an artifact. +type ArtifactDependency struct { + // ImageName is a reference to an artifact's image name. + ImageName string `yaml:"image" yamltags:"required"` + // Alias is a token that is replaced with the image reference in the builder definition files. + // For example, the `docker` builder will use the alias as a build-arg key. + // Defaults to the value of `image`. + Alias string `yaml:"alias,omitempty"` +} + // BuildpackArtifact *alpha* describes an artifact built using [Cloud Native Buildpacks](https://buildpacks.io/). // It can be used to build images out of project's sources without any additional configuration. type BuildpackArtifact struct { diff --git a/pkg/skaffold/schema/validation/validation.go b/pkg/skaffold/schema/validation/validation.go index 6c3c1a6448c..08308749433 100644 --- a/pkg/skaffold/schema/validation/validation.go +++ b/pkg/skaffold/schema/validation/validation.go @@ -19,6 +19,7 @@ package validation import ( "fmt" "reflect" + "regexp" "strings" "github.com/GoogleContainerTools/skaffold/pkg/skaffold/build/misc" @@ -30,13 +31,15 @@ import ( var ( // for testing - validateYamltags = yamltags.ValidateStruct + validateYamltags = yamltags.ValidateStruct + dependencyAliasPattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) ) // Process checks if the Skaffold pipeline is valid and returns all encountered errors as a concatenated string func Process(config *latest.SkaffoldConfig) error { errs := visitStructs(config, validateYamltags) errs = append(errs, validateImageNames(config.Build.Artifacts)...) + errs = append(errs, validateArtifactDependencies(config.Build.Artifacts)...) errs = append(errs, validateDockerNetworkMode(config.Build.Artifacts)...) errs = append(errs, validateCustomDependencies(config.Build.Artifacts)...) errs = append(errs, validateSyncRules(config.Build.Artifacts)...) @@ -77,6 +80,93 @@ func validateImageNames(artifacts []*latest.Artifact) (errs []error) { return } +func validateArtifactDependencies(artifacts []*latest.Artifact) (errs []error) { + errs = append(errs, validateUniqueDependencyAliases(artifacts)...) + errs = append(errs, validateAcyclicDependencies(artifacts)...) + errs = append(errs, validateValidDependencyAliases(artifacts)...) + return +} + +// validateAcyclicDependencies makes sure all artifact dependencies are found and don't have cyclic references +func validateAcyclicDependencies(artifacts []*latest.Artifact) (errs []error) { + m := make(map[string]*latest.Artifact) + for _, artifact := range artifacts { + m[artifact.ImageName] = artifact + } + visited := make(map[string]bool) + for _, artifact := range artifacts { + if err := dfs(artifact, visited, make(map[string]bool), m); err != nil { + errs = append(errs, err) + return + } + } + return +} + +// dfs runs a Depth First Search algorithm for cycle detection in a directed graph +func dfs(artifact *latest.Artifact, visited, marked map[string]bool, artifacts map[string]*latest.Artifact) error { + if marked[artifact.ImageName] { + return fmt.Errorf("cycle detected in build dependencies involving %q", artifact.ImageName) + } + marked[artifact.ImageName] = true + defer func() { + marked[artifact.ImageName] = false + }() + if visited[artifact.ImageName] { + return nil + } + visited[artifact.ImageName] = true + + for _, dep := range artifact.Dependencies { + d, found := artifacts[dep.ImageName] + if !found { + return fmt.Errorf("unknown build dependency %q for artifact %q", dep.ImageName, artifact.ImageName) + } + if err := dfs(d, visited, marked, artifacts); err != nil { + return err + } + } + return nil +} + +// validateValidDependencyAliases makes sure that artifact dependency aliases are valid. +// docker and custom builders require aliases match [a-zA-Z_][a-zA-Z0-9_]* pattern +func validateValidDependencyAliases(artifacts []*latest.Artifact) (errs []error) { + for _, a := range artifacts { + if a.DockerArtifact == nil && a.CustomArtifact == nil { + continue + } + for _, d := range a.Dependencies { + if !dependencyAliasPattern.MatchString(d.Alias) { + errs = append(errs, fmt.Errorf("invalid build dependency for artifact %q: alias %q doesn't match required pattern %q", a.ImageName, d.Alias, dependencyAliasPattern.String())) + } + } + } + return +} + +// validateUniqueDependencyAliases makes sure that artifact dependency aliases are unique for each artifact +func validateUniqueDependencyAliases(artifacts []*latest.Artifact) (errs []error) { + type State int + var ( + unseen State = 0 + seen State = 1 + recorded State = 2 + ) + for _, a := range artifacts { + aliasMap := make(map[string]State) + for _, d := range a.Dependencies { + if aliasMap[d.Alias] == seen { + errs = append(errs, fmt.Errorf("invalid build dependency for artifact %q: alias %q repeated", a.ImageName, d.Alias)) + aliasMap[d.Alias] = recorded + } else if aliasMap[d.Alias] == unseen { + aliasMap[d.Alias] = seen + } + } + } + return +} + // validateDockerNetworkMode makes sure that networkMode is one of `bridge`, `none`, or `host` if set. func validateDockerNetworkMode(artifacts []*latest.Artifact) (errs []error) { for _, a := range artifacts { diff --git a/pkg/skaffold/schema/validation/validation_test.go b/pkg/skaffold/schema/validation/validation_test.go index 12cb2380f0b..9fff1af2c25 100644 --- a/pkg/skaffold/schema/validation/validation_test.go +++ b/pkg/skaffold/schema/validation/validation_test.go @@ -20,6 +20,8 @@ import ( "fmt" "testing" + "github.com/google/go-cmp/cmp" + "github.com/GoogleContainerTools/skaffold/pkg/skaffold/schema/latest" "github.com/GoogleContainerTools/skaffold/testutil" ) @@ -750,3 +752,188 @@ func TestValidateLogsConfig(t *testing.T) { }) } } + +func TestValidateAcyclicDependencies(t *testing.T) { + tests := []struct { + description string + artifactLen int + dependency map[int][]int + shouldErr bool + }{ + { + description: "artifacts with no dependency", + artifactLen: 5, + }, + { + description: "artifacts with no circular dependencies 1", + dependency: map[int][]int{ + 0: {2, 3}, + 1: {3}, + 2: {1}, + 3: {4}, + }, + artifactLen: 5, + }, + { + description: "artifacts with no circular dependencies 2", + dependency: map[int][]int{ + 0: {4, 5}, + 1: {4, 5}, + 2: {4, 5}, + 3: {4, 5}, + }, + artifactLen: 6, + }, + { + description: "artifacts with circular dependencies", + dependency: map[int][]int{ + 0: {2, 3}, + 1: {0}, + 2: {1}, + 3: {4}, + }, + artifactLen: 5, + shouldErr: true, + }, + { + description: "artifacts with circular dependencies (self)", + dependency: map[int][]int{ + 0: {0}, + 1: {}, + }, + artifactLen: 2, + shouldErr: true, + }, + { + description: "0 artifacts", + artifactLen: 0, + }, + } + for _, test := range tests { + testutil.Run(t, test.description, func(t *testutil.T) { + artifacts := make([]*latest.Artifact, test.artifactLen) + for i := 0; i < test.artifactLen; i++ { + a := fmt.Sprintf("artifact%d", i+1) + artifacts[i] = &latest.Artifact{ImageName: a} + } + + setDependencies(artifacts, test.dependency) + errs := validateAcyclicDependencies(artifacts) + expected := []error{ + fmt.Errorf(`cycle detected in build dependencies involving "artifact1"`), + } + if test.shouldErr { + t.CheckDeepEqual(expected, errs, cmp.Comparer(errorsComparer)) + } else { + t.CheckDeepEqual(0, len(errs)) + } + }) + } +} + +// setDependencies constructs a graph of artifact dependencies using the map as an adjacency list representation of indices in the artifacts array. +// For example: +// m = { +// 0 : {1, 2}, +// 2 : {3}, +//} +// implies that a[0] artifact depends on a[1] and a[2]; and a[2] depends on a[3]. +func setDependencies(a []*latest.Artifact, d map[int][]int) { + for k, dep := range d { + for i := range dep { + a[k].Dependencies = append(a[k].Dependencies, &latest.ArtifactDependency{ + ImageName: a[dep[i]].ImageName, + }) + } + } +} + +func TestValidateUniqueDependencyAliases(t *testing.T) { + artifacts := []*latest.Artifact{ + { + ImageName: "artifact1", + Dependencies: []*latest.ArtifactDependency{ + {Alias: "alias2", ImageName: "artifact2a"}, + {Alias: "alias2", ImageName: "artifact2b"}, + }, + }, + { + ImageName: "artifact2", + Dependencies: []*latest.ArtifactDependency{ + {Alias: "alias1", ImageName: "artifact1"}, + {Alias: "alias2", ImageName: "artifact1"}, + }, + }, + } + expected := []error{ + fmt.Errorf(`invalid build dependency for artifact "artifact1": alias "alias2" repeated`), + fmt.Errorf(`unknown build dependency "artifact2a" for artifact "artifact1"`), + } + errs := validateArtifactDependencies(artifacts) + testutil.CheckDeepEqual(t, expected, errs, cmp.Comparer(errorsComparer)) +} + +func TestValidateValidDependencyAliases(t *testing.T) { + artifacts := []*latest.Artifact{ + { + ImageName: "artifact1", + }, + { + ImageName: "artifact2", + ArtifactType: latest.ArtifactType{ + DockerArtifact: &latest.DockerArtifact{}, + }, + Dependencies: []*latest.ArtifactDependency{ + {Alias: "ARTIFACT_1", ImageName: "artifact1"}, + {Alias: "1_ARTIFACT", ImageName: "artifact1"}, + }, + }, + { + ImageName: "artifact3", + ArtifactType: latest.ArtifactType{ + DockerArtifact: &latest.DockerArtifact{}, + }, + Dependencies: []*latest.ArtifactDependency{ + {Alias: "artifact!", ImageName: "artifact1"}, + {Alias: "artifact#1", ImageName: "artifact1"}, + }, + }, + { + ImageName: "artifact4", + ArtifactType: latest.ArtifactType{ + CustomArtifact: &latest.CustomArtifact{}, + }, + Dependencies: []*latest.ArtifactDependency{ + {Alias: "alias1", ImageName: "artifact1"}, + {Alias: "alias2", ImageName: "artifact2"}, + }, + }, + { + ImageName: "artifact5", + ArtifactType: latest.ArtifactType{ + BuildpackArtifact: &latest.BuildpackArtifact{}, + }, + Dependencies: []*latest.ArtifactDependency{ + {Alias: "artifact!", ImageName: "artifact1"}, + {Alias: "artifact#1", ImageName: "artifact1"}, + }, + }, + } + expected := []error{ + fmt.Errorf(`invalid build dependency for artifact "artifact2": alias "1_ARTIFACT" doesn't match required pattern %q`, dependencyAliasPattern), + fmt.Errorf(`invalid build dependency for artifact "artifact3": alias "artifact!" doesn't match required pattern %q`, dependencyAliasPattern), + fmt.Errorf(`invalid build dependency for artifact "artifact3": alias "artifact#1" doesn't match required pattern %q`, dependencyAliasPattern), + } + errs := validateArtifactDependencies(artifacts) + testutil.CheckDeepEqual(t, expected, errs, cmp.Comparer(errorsComparer)) +} + +func errorsComparer(a, b error) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return a.Error() == b.Error() +}