You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by pc...@apache.org on 2023/05/29 12:33:34 UTC

[camel-k] 01/02: feat(core): Support S2I for builder image generation

This is an automated email from the ASF dual-hosted git repository.

pcongiusti pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-k.git

commit eaee49d3ea8eb14b835f995f5573aad83238ed8e
Author: Gaelle Fournier <ga...@gmail.com>
AuthorDate: Thu May 25 15:12:45 2023 +0200

    feat(core): Support S2I for builder image generation
    
    * Add initialize builder image on catalog with imagestream and buildconfig resource who's owner is the CamelCatalog
    * Light refactoring of S2I code
    
    Ref #4297
---
 pkg/builder/s2i.go                   |  44 +----
 pkg/controller/build/build_pod.go    |   8 +-
 pkg/controller/catalog/initialize.go | 366 +++++++++++++++++++++++++++++++++--
 pkg/util/s2i/build.go                |  70 +++++++
 4 files changed, 434 insertions(+), 54 deletions(-)

diff --git a/pkg/builder/s2i.go b/pkg/builder/s2i.go
index 99cbd00ea..043ba70cf 100644
--- a/pkg/builder/s2i.go
+++ b/pkg/builder/s2i.go
@@ -29,7 +29,6 @@ import (
 	"os"
 	"path/filepath"
 	"strings"
-	"time"
 
 	corev1 "k8s.io/api/core/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -49,6 +48,7 @@ import (
 	"github.com/apache/camel-k/v2/pkg/client"
 	"github.com/apache/camel-k/v2/pkg/util"
 	"github.com/apache/camel-k/v2/pkg/util/log"
+	"github.com/apache/camel-k/v2/pkg/util/s2i"
 )
 
 type s2iTask struct {
@@ -203,11 +203,11 @@ func (t *s2iTask) Do(ctx context.Context) v1.BuildStatus {
 			return fmt.Errorf("cannot unmarshal instantiated binary response: %w", err)
 		}
 
-		err = t.waitForS2iBuildCompletion(ctx, t.c, &s2iBuild)
+		err = s2i.WaitForS2iBuildCompletion(ctx, t.c, &s2iBuild)
 		if err != nil {
 			if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
 				// nolint: contextcheck
-				if err := t.cancelBuild(context.Background(), &s2iBuild); err != nil {
+				if err := s2i.CancelBuild(context.Background(), t.c, &s2iBuild); err != nil {
 					log.Errorf(err, "cannot cancel s2i Build: %s/%s", s2iBuild.Namespace, s2iBuild.Name)
 				}
 			}
@@ -255,44 +255,6 @@ func (t *s2iTask) getControllerReference() metav1.Object {
 	return owner
 }
 
-func (t *s2iTask) waitForS2iBuildCompletion(ctx context.Context, c client.Client, build *buildv1.Build) error {
-	key := ctrl.ObjectKeyFromObject(build)
-	for {
-		select {
-
-		case <-ctx.Done():
-			return ctx.Err()
-
-		case <-time.After(1 * time.Second):
-			err := c.Get(ctx, key, build)
-			if err != nil {
-				if apierrors.IsNotFound(err) {
-					continue
-				}
-				return err
-			}
-
-			if build.Status.Phase == buildv1.BuildPhaseComplete {
-				return nil
-			} else if build.Status.Phase == buildv1.BuildPhaseCancelled ||
-				build.Status.Phase == buildv1.BuildPhaseFailed ||
-				build.Status.Phase == buildv1.BuildPhaseError {
-				return errors.New("build failed")
-			}
-		}
-	}
-}
-
-func (t *s2iTask) cancelBuild(ctx context.Context, build *buildv1.Build) error {
-	target := build.DeepCopy()
-	target.Status.Cancelled = true
-	if err := t.c.Patch(ctx, target, ctrl.MergeFrom(build)); err != nil {
-		return err
-	}
-	*build = *target
-	return nil
-}
-
 func tarDir(src string, writers ...io.Writer) error {
 	// ensure the src actually exists before trying to tar it
 	if _, err := os.Stat(src); err != nil {
diff --git a/pkg/controller/build/build_pod.go b/pkg/controller/build/build_pod.go
index 7f660edb8..ba996bb82 100644
--- a/pkg/controller/build/build_pod.go
+++ b/pkg/controller/build/build_pod.go
@@ -272,6 +272,12 @@ func addBuildTaskToPod(build *v1.Build, taskName string, pod *corev1.Pod) {
 		)
 	}
 
+	var envVars = proxyFromEnvironment()
+	envVars = append(envVars, corev1.EnvVar{
+		Name:  "HOME",
+		Value: filepath.Join(builderDir, build.Name),
+	})
+
 	container := corev1.Container{
 		Name:            taskName,
 		Image:           build.BuilderConfiguration().ToolImage,
@@ -287,7 +293,7 @@ func addBuildTaskToPod(build *v1.Build, taskName string, pod *corev1.Pod) {
 			taskName,
 		},
 		WorkingDir: filepath.Join(builderDir, build.Name),
-		Env:        proxyFromEnvironment(),
+		Env:        envVars,
 	}
 
 	configureResources(build, &container)
diff --git a/pkg/controller/catalog/initialize.go b/pkg/controller/catalog/initialize.go
index 98d4ddfbe..40a0618ab 100644
--- a/pkg/controller/catalog/initialize.go
+++ b/pkg/controller/catalog/initialize.go
@@ -18,11 +18,16 @@ limitations under the License.
 package catalog
 
 import (
+	"archive/tar"
 	"bufio"
+	"compress/gzip"
 	"context"
+	"encoding/json"
+	"errors"
 	"fmt"
 	"io"
 	"os"
+	"path/filepath"
 	"runtime"
 	"strings"
 	"time"
@@ -32,9 +37,19 @@ import (
 	"github.com/apache/camel-k/v2/pkg/client"
 	platformutil "github.com/apache/camel-k/v2/pkg/platform"
 	"github.com/apache/camel-k/v2/pkg/util"
+	"github.com/apache/camel-k/v2/pkg/util/kubernetes"
+	"github.com/apache/camel-k/v2/pkg/util/s2i"
 	spectrum "github.com/container-tools/spectrum/pkg/builder"
 	gcrv1 "github.com/google/go-containerregistry/pkg/v1"
+	buildv1 "github.com/openshift/api/build/v1"
+	imagev1 "github.com/openshift/api/image/v1"
 	corev1 "k8s.io/api/core/v1"
+	k8serrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/apimachinery/pkg/runtime/serializer"
+	ctrl "sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
 )
 
 // NewInitializeAction returns a action that initializes the catalog configuration when not provided by the user.
@@ -66,16 +81,20 @@ func (action *initializeAction) Handle(ctx context.Context, catalog *v1.CamelCat
 		return catalog, nil
 	}
 
+	if platform.Status.Build.PublishStrategy == v1.IntegrationPlatformBuildPublishStrategyS2I {
+		return initializeS2i(ctx, action.client, platform, catalog)
+	}
+	// Default to spectrum
 	// Make basic options for building image in the registry
 	options, err := makeSpectrumOptions(ctx, action.client, platform.Namespace, platform.Status.Build.Registry)
 	if err != nil {
 		return catalog, err
 	}
+	return initializeSpectrum(options, platform, catalog)
 
-	return initialize(options, platform, catalog)
 }
 
-func initialize(options spectrum.Options, ip *v1.IntegrationPlatform, catalog *v1.CamelCatalog) (*v1.CamelCatalog, error) {
+func initializeSpectrum(options spectrum.Options, ip *v1.IntegrationPlatform, catalog *v1.CamelCatalog) (*v1.CamelCatalog, error) {
 	target := catalog.DeepCopy()
 	imageName := fmt.Sprintf(
 		"%s/camel-k-runtime-%s-builder:%s",
@@ -99,7 +118,7 @@ func initialize(options spectrum.Options, ip *v1.IntegrationPlatform, catalog *v
 	options.Stderr = newStdW
 	options.Stdout = newStdW
 
-	if !imageSnapshot(options) && imageExists(options) {
+	if !imageSnapshot(options.Base) && imageExistsSpectrum(options) {
 		target.Status.Phase = v1.CamelCatalogPhaseReady
 		target.Status.SetCondition(
 			v1.CamelCatalogConditionReady,
@@ -116,7 +135,7 @@ func initialize(options spectrum.Options, ip *v1.IntegrationPlatform, catalog *v
 	options.Base = catalog.Spec.GetQuarkusToolingImage()
 	options.Target = imageName
 
-	err := buildRuntimeBuilderWithTimeout(options, ip.Status.Build.GetBuildCatalogToolTimeout().Duration)
+	err := buildRuntimeBuilderWithTimeoutSpectrum(options, ip.Status.Build.GetBuildCatalogToolTimeout().Duration)
 
 	if err != nil {
 		target.Status.Phase = v1.CamelCatalogPhaseError
@@ -139,7 +158,254 @@ func initialize(options spectrum.Options, ip *v1.IntegrationPlatform, catalog *v
 	return target, nil
 }
 
-func imageExists(options spectrum.Options) bool {
+func initializeS2i(ctx context.Context, c client.Client, ip *v1.IntegrationPlatform, catalog *v1.CamelCatalog) (*v1.CamelCatalog, error) {
+	target := catalog.DeepCopy()
+	// No registry in s2i
+	imageName := fmt.Sprintf(
+		"camel-k-runtime-%s-builder",
+		catalog.Spec.Runtime.Provider,
+	)
+	imageTag := strings.ToLower(catalog.Spec.Runtime.Version)
+
+	// Dockfile
+	dockerfile := string([]byte(`
+		FROM ` + catalog.Spec.GetQuarkusToolingImage() + `
+		USER 1000
+		ADD /usr/local/bin/kamel /usr/local/bin/kamel
+		ADD /usr/share/maven/mvnw/ /usr/share/maven/mvnw/
+	`))
+
+	// BuildConfig
+	bc := &buildv1.BuildConfig{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: buildv1.GroupVersion.String(),
+			Kind:       "BuildConfig",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      imageName,
+			Namespace: ip.Namespace,
+			Labels: map[string]string{
+				kubernetes.CamelCreatorLabelKind:      v1.CamelCatalogKind,
+				kubernetes.CamelCreatorLabelName:      catalog.Name,
+				kubernetes.CamelCreatorLabelNamespace: catalog.Namespace,
+				kubernetes.CamelCreatorLabelVersion:   catalog.ResourceVersion,
+				"camel.apache.org/runtime.version":    catalog.Spec.Runtime.Version,
+				"camel.apache.org/runtime.provider":   string(catalog.Spec.Runtime.Provider),
+			},
+			OwnerReferences: []metav1.OwnerReference{
+				{
+					APIVersion: catalog.APIVersion,
+					Kind:       catalog.Kind,
+					Name:       catalog.Name,
+					UID:        catalog.UID,
+				},
+			},
+		},
+		Spec: buildv1.BuildConfigSpec{
+			CommonSpec: buildv1.CommonSpec{
+				Source: buildv1.BuildSource{
+					Type:       buildv1.BuildSourceBinary,
+					Dockerfile: &dockerfile,
+				},
+				Strategy: buildv1.BuildStrategy{
+					DockerStrategy: &buildv1.DockerBuildStrategy{},
+				},
+				Output: buildv1.BuildOutput{
+					To: &corev1.ObjectReference{
+						Kind: "ImageStreamTag",
+						Name: imageName + ":" + imageTag,
+					},
+				},
+			},
+		},
+	}
+
+	// ImageStream
+	is := &imagev1.ImageStream{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: imagev1.GroupVersion.String(),
+			Kind:       "ImageStream",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      bc.Name,
+			Namespace: bc.Namespace,
+			Labels: map[string]string{
+				kubernetes.CamelCreatorLabelKind:      v1.CamelCatalogKind,
+				kubernetes.CamelCreatorLabelName:      catalog.Name,
+				kubernetes.CamelCreatorLabelNamespace: catalog.Namespace,
+				kubernetes.CamelCreatorLabelVersion:   catalog.ResourceVersion,
+				"camel.apache.org/runtime.provider":   string(catalog.Spec.Runtime.Provider),
+			},
+			OwnerReferences: []metav1.OwnerReference{
+				{
+					APIVersion: catalog.APIVersion,
+					Kind:       catalog.Kind,
+					Name:       catalog.Name,
+					UID:        catalog.UID,
+				},
+			},
+		},
+		Spec: imagev1.ImageStreamSpec{
+			LookupPolicy: imagev1.ImageLookupPolicy{
+				Local: true,
+			},
+		},
+	}
+
+	if !imageSnapshot(imageName+":"+imageTag) && imageExistsS2i(ctx, c, is) {
+		target.Status.Phase = v1.CamelCatalogPhaseReady
+		target.Status.SetCondition(
+			v1.CamelCatalogConditionReady,
+			corev1.ConditionTrue,
+			"Builder Image",
+			"Container image exists on registry (later)",
+		)
+		target.Status.Image = imageName
+		return target, nil
+	}
+
+	err := c.Delete(ctx, bc)
+	if err != nil && !k8serrors.IsNotFound(err) {
+		target.Status.Phase = v1.CamelCatalogPhaseError
+		target.Status.SetErrorCondition(
+			v1.CamelCatalogConditionReady,
+			"Builder Image",
+			err,
+		)
+		return target, err
+	}
+
+	err = c.Create(ctx, bc)
+	if err != nil {
+		target.Status.Phase = v1.CamelCatalogPhaseError
+		target.Status.SetErrorCondition(
+			v1.CamelCatalogConditionReady,
+			"Builder Image",
+			err,
+		)
+		return target, err
+	}
+
+	err = c.Delete(ctx, is)
+	if err != nil && !k8serrors.IsNotFound(err) {
+		target.Status.Phase = v1.CamelCatalogPhaseError
+		target.Status.SetErrorCondition(
+			v1.CamelCatalogConditionReady,
+			"Builder Image",
+			err,
+		)
+		return target, err
+	}
+
+	err = c.Create(ctx, is)
+	if err != nil {
+		target.Status.Phase = v1.CamelCatalogPhaseError
+		target.Status.SetErrorCondition(
+			v1.CamelCatalogConditionReady,
+			"Builder Image",
+			err,
+		)
+		return target, err
+	}
+
+	err = util.WithTempDir(imageName+"-s2i-", func(tmpDir string) error {
+		archive := filepath.Join(tmpDir, "archive.tar.gz")
+
+		archiveFile, err := os.Create(archive)
+		if err != nil {
+			return fmt.Errorf("cannot create tar archive: %w", err)
+		}
+
+		err = tarEntries(archiveFile, "/usr/local/bin/kamel:/usr/local/bin/kamel",
+			"/usr/share/maven/mvnw/:/usr/share/maven/mvnw/")
+		if err != nil {
+			return fmt.Errorf("cannot tar path entry: %w", err)
+		}
+
+		f, err := util.Open(archive)
+		if err != nil {
+			return err
+		}
+
+		restClient, err := apiutil.RESTClientForGVK(
+			schema.GroupVersionKind{Group: "build.openshift.io", Version: "v1"}, false,
+			c.GetConfig(), serializer.NewCodecFactory(c.GetScheme()))
+		if err != nil {
+			return err
+		}
+
+		r := restClient.Post().
+			Namespace(bc.Namespace).
+			Body(bufio.NewReader(f)).
+			Resource("buildconfigs").
+			Name(bc.Name).
+			SubResource("instantiatebinary").
+			Do(ctx)
+
+		if r.Error() != nil {
+			return fmt.Errorf("cannot instantiate binary: %w", err)
+		}
+
+		data, err := r.Raw()
+		if err != nil {
+			return fmt.Errorf("no raw data retrieved: %w", err)
+		}
+
+		s2iBuild := buildv1.Build{}
+		err = json.Unmarshal(data, &s2iBuild)
+		if err != nil {
+			return fmt.Errorf("cannot unmarshal instantiated binary response: %w", err)
+		}
+
+		err = s2i.WaitForS2iBuildCompletion(ctx, c, &s2iBuild)
+		if err != nil {
+			if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+				// nolint: contextcheck
+				if err := s2i.CancelBuild(context.Background(), c, &s2iBuild); err != nil {
+					return fmt.Errorf("cannot cancel s2i Build: %s/%s", s2iBuild.Namespace, s2iBuild.Name)
+				}
+			}
+			return err
+		}
+		if s2iBuild.Status.Output.To != nil {
+			Log.Infof("Camel K builder container image %s:%s@%s created", imageName, imageTag, s2iBuild.Status.Output.To.ImageDigest)
+		}
+
+		err = c.Get(ctx, ctrl.ObjectKeyFromObject(is), is)
+		if err != nil {
+			return err
+		}
+
+		if is.Status.DockerImageRepository == "" {
+			return errors.New("dockerImageRepository not available in ImageStream")
+		}
+
+		target.Status.Phase = v1.CamelCatalogPhaseReady
+		target.Status.SetCondition(
+			v1.CamelCatalogConditionReady,
+			corev1.ConditionTrue,
+			"Builder Image",
+			"Container image successfully built",
+		)
+		target.Status.Image = is.Status.DockerImageRepository + ":" + imageTag
+
+		return f.Close()
+	})
+
+	if err != nil {
+		target.Status.Phase = v1.CamelCatalogPhaseError
+		target.Status.SetErrorCondition(
+			v1.CamelCatalogConditionReady,
+			"Builder Image",
+			err,
+		)
+		return target, err
+	}
+
+	return target, nil
+}
+
+func imageExistsSpectrum(options spectrum.Options) bool {
 	Log.Infof("Checking if Camel K builder container %s already exists...", options.Base)
 	ctrImg, err := spectrum.Pull(options)
 	if ctrImg != nil && err == nil {
@@ -156,18 +422,38 @@ func imageExists(options spectrum.Options) bool {
 	return false
 }
 
-func imageSnapshot(options spectrum.Options) bool {
-	return strings.HasSuffix(options.Base, "snapshot")
+func imageExistsS2i(ctx context.Context, c client.Client, is *imagev1.ImageStream) bool {
+	Log.Infof("Checking if Camel K builder container %s already exists...", is.Name)
+	key := ctrl.ObjectKey{
+		Namespace: is.Namespace,
+		Name:      is.Name,
+	}
+
+	err := c.Get(ctx, key, is)
+
+	if err != nil {
+		if !k8serrors.IsNotFound(err) {
+			Log.Infof("Couldn't pull image due to %s", err.Error())
+		}
+		Log.Info("Could not find Camel K builder container")
+		return false
+	}
+	Log.Info("Found Camel K builder container ")
+	return true
+}
+
+func imageSnapshot(imageName string) bool {
+	return strings.HasSuffix(imageName, "snapshot")
 }
 
-func buildRuntimeBuilderWithTimeout(options spectrum.Options, timeout time.Duration) error {
+func buildRuntimeBuilderWithTimeoutSpectrum(options spectrum.Options, timeout time.Duration) error {
 	// Backward compatibility with IP which had not a timeout field
 	if timeout == 0 {
-		return buildRuntimeBuilderImage(options)
+		return buildRuntimeBuilderImageSpectrum(options)
 	}
 	result := make(chan error, 1)
 	go func() {
-		result <- buildRuntimeBuilderImage(options)
+		result <- buildRuntimeBuilderImageSpectrum(options)
 	}()
 	select {
 	case <-time.After(timeout):
@@ -179,7 +465,7 @@ func buildRuntimeBuilderWithTimeout(options spectrum.Options, timeout time.Durat
 
 // This func will take care to dynamically build an image that will contain the tools required
 // by the catalog build plus kamel binary and a maven wrapper required for the build.
-func buildRuntimeBuilderImage(options spectrum.Options) error {
+func buildRuntimeBuilderImageSpectrum(options spectrum.Options) error {
 	if options.Base == "" {
 		return fmt.Errorf("missing base image, likely catalog is not compatible with this Camel K version")
 	}
@@ -189,7 +475,6 @@ func buildRuntimeBuilderImage(options spectrum.Options) error {
 		options.Jobs = jobs
 	}
 
-	// TODO support also S2I
 	_, err := spectrum.Build(options,
 		"/usr/local/bin/kamel:/usr/local/bin/",
 		"/usr/share/maven/mvnw/:/usr/share/maven/mvnw/")
@@ -227,3 +512,60 @@ func makeSpectrumOptions(ctx context.Context, c client.Client, platformNamespace
 
 	return options, nil
 }
+
+// Add entries (files or folders) into tar with the possibility to change its path.
+func tarEntries(writer io.Writer, files ...string) error {
+
+	gzw := gzip.NewWriter(writer)
+	defer util.CloseQuietly(gzw)
+
+	tw := tar.NewWriter(gzw)
+	defer util.CloseQuietly(tw)
+
+	// Iterate over files and and add them to the tar archive
+	for _, fileDetail := range files {
+		fileSource := strings.Split(fileDetail, ":")[0]
+		fileTarget := strings.Split(fileDetail, ":")[1]
+		// ensure the src actually exists before trying to tar it
+		if _, err := os.Stat(fileSource); err != nil {
+			return fmt.Errorf("unable to tar files: %w", err)
+		}
+
+		if err := filepath.Walk(fileSource, func(file string, fi os.FileInfo, err error) error {
+			if err != nil {
+				return err
+			}
+
+			if !fi.Mode().IsRegular() {
+				return nil
+			}
+
+			header, err := tar.FileInfoHeader(fi, fi.Name())
+			if err != nil {
+				return err
+			}
+
+			// update the name to correctly reflect the desired destination when un-taring
+			header.Name = strings.TrimPrefix(strings.ReplaceAll(file, fileSource, fileTarget), string(filepath.Separator))
+
+			if err := tw.WriteHeader(header); err != nil {
+				return err
+			}
+
+			f, err := util.Open(file)
+			if err != nil {
+				return err
+			}
+
+			if _, err := io.Copy(tw, f); err != nil {
+				return err
+			}
+
+			return f.Close()
+		}); err != nil {
+			return fmt.Errorf("unable to tar: %w", err)
+		}
+
+	}
+	return nil
+}
diff --git a/pkg/util/s2i/build.go b/pkg/util/s2i/build.go
new file mode 100644
index 000000000..d30207d59
--- /dev/null
+++ b/pkg/util/s2i/build.go
@@ -0,0 +1,70 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You 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 s2i contains utilities for openshift s2i builds.
+package s2i
+
+import (
+	"context"
+	"errors"
+	"time"
+
+	"github.com/apache/camel-k/v2/pkg/client"
+	buildv1 "github.com/openshift/api/build/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	ctrl "sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+// Cancel the s2i Build by updating its status.
+func CancelBuild(ctx context.Context, c client.Client, build *buildv1.Build) error {
+	target := build.DeepCopy()
+	target.Status.Cancelled = true
+	if err := c.Patch(ctx, target, ctrl.MergeFrom(build)); err != nil {
+		return err
+	}
+	*build = *target
+	return nil
+}
+
+// Wait for the s2i Build to complete with success or cancellation.
+func WaitForS2iBuildCompletion(ctx context.Context, c client.Client, build *buildv1.Build) error {
+	key := ctrl.ObjectKeyFromObject(build)
+	for {
+		select {
+
+		case <-ctx.Done():
+			return ctx.Err()
+
+		case <-time.After(1 * time.Second):
+			err := c.Get(ctx, key, build)
+			if err != nil {
+				if apierrors.IsNotFound(err) {
+					continue
+				}
+				return err
+			}
+
+			if build.Status.Phase == buildv1.BuildPhaseComplete {
+				return nil
+			} else if build.Status.Phase == buildv1.BuildPhaseCancelled ||
+				build.Status.Phase == buildv1.BuildPhaseFailed ||
+				build.Status.Phase == buildv1.BuildPhaseError {
+				return errors.New("build failed")
+			}
+		}
+	}
+}