From 1e04273d475f1ea0922cdf20cefccd1e413a4d7a Mon Sep 17 00:00:00 2001 From: Wen Long Date: Sat, 11 May 2024 13:03:13 +0800 Subject: [PATCH] third_party/ko: Add ko as third party tools Add Install, BuildLocal and GetLocalImage functions. Signed-off-by: Wen Long --- examples/third_party_integration/README.md | 3 +- .../third_party_integration/ko/ko_test.go | 102 +++++++++ .../third_party_integration/ko/main_test.go | 57 +++++ .../ko/testdata/example_goapp/main.go | 23 ++ third_party/helm/helm.go | 2 +- third_party/ko/ko.go | 205 ++++++++++++++++++ 6 files changed, 390 insertions(+), 2 deletions(-) create mode 100644 examples/third_party_integration/ko/ko_test.go create mode 100644 examples/third_party_integration/ko/main_test.go create mode 100644 examples/third_party_integration/ko/testdata/example_goapp/main.go create mode 100644 third_party/ko/ko.go diff --git a/examples/third_party_integration/README.md b/examples/third_party_integration/README.md index ef921736..5ffaef60 100644 --- a/examples/third_party_integration/README.md +++ b/examples/third_party_integration/README.md @@ -4,4 +4,5 @@ This section of the repository contains the example of how the third party tooli `e2e-framework` 1. [Helm](./helm) -2. [Flux](./flux) \ No newline at end of file +2. [Flux](./flux) +3. [Ko](./ko) \ No newline at end of file diff --git a/examples/third_party_integration/ko/ko_test.go b/examples/third_party_integration/ko/ko_test.go new file mode 100644 index 00000000..1057fdbd --- /dev/null +++ b/examples/third_party_integration/ko/ko_test.go @@ -0,0 +1,102 @@ +/* +Copyright 2024 The Kubernetes 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 ko + +import ( + "context" + "fmt" + "path/filepath" + "runtime" + "testing" + "time" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "sigs.k8s.io/e2e-framework/klient/wait" + "sigs.k8s.io/e2e-framework/klient/wait/conditions" + "sigs.k8s.io/e2e-framework/pkg/envconf" + "sigs.k8s.io/e2e-framework/pkg/features" + "sigs.k8s.io/e2e-framework/third_party/ko" +) + +var packagePath = "./" + filepath.Join(curDir, "testdata", "example_goapp") + +func TestBuildLocalKind(t *testing.T) { + feature := features.New("ko build with local kind cluster"). + Setup(func(ctx context.Context, t *testing.T, config *envconf.Config) context.Context { + var err error + + // Apple Silicon with Podman as kind provider need to build with linux/arm64 + platform := fmt.Sprintf("linux/%s", runtime.GOARCH) + + manager := ko.New() + err = manager.Install("latest") + if err != nil { + t.Fatalf("failed to install ko: %v", err) + } + + ctx, err = manager.BuildLocal(ctx, packagePath, ko.WithLocalKindName(kindClusterName), ko.WithPlatforms(platform)) + if err != nil { + t.Fatalf("failed to build with local kind: %v", err) + } + + return ctx + }). + Assess("Deployment is running successfully", func(ctx context.Context, t *testing.T, config *envconf.Config) context.Context { + manager := ko.New() + goappImage, err := manager.GetLocalImage(ctx, packagePath) + if err != nil { + t.Fatalf("failed to get previous built image: %v", err) + } + + deployment := newDeployment(config.Namespace(), goappImage, 1) + client, err := config.NewClient() + if err != nil { + t.Fatalf("failed to init k8s client: %v", err) + } + if err = client.Resources().Create(ctx, deployment); err != nil { + t.Fatalf("failed to create deployment: %v", err) + } + err = wait.For(conditions.New(client.Resources()).DeploymentConditionMatch(deployment, appsv1.DeploymentAvailable, corev1.ConditionTrue), wait.WithTimeout(time.Minute*5)) + if err != nil { + t.Fatalf("failed to wait for deployment to be ready: %v", err) + } + + return ctx + }).Feature() + + _ = testEnv.Test(t, feature) +} + +func newDeployment(namespace, image string, replicas int32) *appsv1.Deployment { + labels := map[string]string{"app": "goapp"} + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "goapp", Namespace: namespace}, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: labels, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: labels}, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "goapp", Image: image}}}, + }, + }, + } +} diff --git a/examples/third_party_integration/ko/main_test.go b/examples/third_party_integration/ko/main_test.go new file mode 100644 index 00000000..f076ab24 --- /dev/null +++ b/examples/third_party_integration/ko/main_test.go @@ -0,0 +1,57 @@ +/* +Copyright 2024 The Kubernetes 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 ko + +import ( + "os" + "testing" + + "sigs.k8s.io/e2e-framework/pkg/env" + "sigs.k8s.io/e2e-framework/pkg/envconf" + "sigs.k8s.io/e2e-framework/pkg/envfuncs" + "sigs.k8s.io/e2e-framework/support/kind" +) + +var ( + testEnv env.Environment + namespace string + kindClusterName string + curDir string +) + +func TestMain(m *testing.M) { + c, err := os.Getwd() + if err != nil { + panic(err) + } + curDir = c + cfg, _ := envconf.NewFromFlags() + testEnv = env.NewWithConfig(cfg) + kindClusterName = envconf.RandomName("ko", 16) + namespace = envconf.RandomName("ko", 16) + + testEnv.Setup( + envfuncs.CreateCluster(kind.NewProvider(), kindClusterName), + envfuncs.CreateNamespace(namespace), + ) + + testEnv.Finish( + envfuncs.DeleteNamespace(namespace), + envfuncs.DestroyCluster(kindClusterName), + ) + os.Exit(testEnv.Run(m)) +} diff --git a/examples/third_party_integration/ko/testdata/example_goapp/main.go b/examples/third_party_integration/ko/testdata/example_goapp/main.go new file mode 100644 index 00000000..6158a9c8 --- /dev/null +++ b/examples/third_party_integration/ko/testdata/example_goapp/main.go @@ -0,0 +1,23 @@ +/* +Copyright 2024 The Kubernetes 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 main + +import "time" + +func main() { + time.Sleep(time.Hour) +} diff --git a/third_party/helm/helm.go b/third_party/helm/helm.go index 4ff7a287..93af15e9 100644 --- a/third_party/helm/helm.go +++ b/third_party/helm/helm.go @@ -260,7 +260,7 @@ func (m *Manager) run(opts *Opts) (err error) { // WithPath is used to provide a custom path where the `helm` executable command // can be found. This is useful in case if your binary is in a non standard location -// and you want to framework to use that instead of retunring an error. +// and you want to framework to use that instead of returning an error. func (m *Manager) WithPath(path string) *Manager { m.path = path return m diff --git a/third_party/ko/ko.go b/third_party/ko/ko.go new file mode 100644 index 00000000..469b8cfe --- /dev/null +++ b/third_party/ko/ko.go @@ -0,0 +1,205 @@ +/* +Copyright 2024 The Kubernetes 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 ko + +import ( + "bytes" + "context" + "fmt" + "strings" + + "github.com/vladimirvivien/gexe" + log "k8s.io/klog/v2" + "sigs.k8s.io/e2e-framework/support/utils" +) + +type localImageContextKey string + +type Opts struct { + // LocalKindName is used to indicate the local kind cluster to publish. + LocalKindName string + // Platforms is used to indicate the platforms to build and publish. + Platforms []string + // ConfigFile is used to indicate the ko config file path. + ConfigFile string + + baseCmdAndOpt string +} + +type Manager struct { + e *gexe.Echo + path string +} + +type Option func(*Opts) + +const ( + missingKo = "'ko' command is missing. Please ensure the tool exists before using the ko manager" +) + +// WithConfigFile is used to configure the ko config file path. +func WithConfigFile(configFile string) Option { + return func(opts *Opts) { + opts.ConfigFile = configFile + } +} + +// WithPlatforms is used to configure the platform to use when pulling +// a multi-platform base at the building phase. When platform="all", +// it will build and push an image for all platforms supported by the +// configured base image. +// platform string format: all | [/[/]][,platform]* +func WithPlatforms(platforms ...string) Option { + return func(opts *Opts) { + opts.Platforms = append(opts.Platforms, platforms...) + } +} + +// WithLocalKind is used to configure the build and publish target as +// a local kind cluster. +func WithLocalKindName(name string) Option { + return func(opts *Opts) { + opts.LocalKindName = name + } +} + +// processOpts is used to generate the Opts resource that will be used to generate +// the actual helm command to be run using the getCommand helper +func (m *Manager) processOpts(opts ...Option) *Opts { + option := &Opts{} + for _, op := range opts { + op(option) + } + return option +} + +// getCommand is used to convert the Opts into a ko suitable command to be run +func (m *Manager) getCommand(opt *Opts) string { + commandParts := []string{m.path, opt.baseCmdAndOpt} + + if len(opt.Platforms) != 0 { + commandParts = append(commandParts, "--platform", strings.Join(opt.Platforms, ",")) + } + + return strings.Join(commandParts, " ") +} + +// getEnvs is used to convert the Opts into environment variable that ko need. +func (m *Manager) getEnvs(opt *Opts) map[string]string { + envs := map[string]string{} + + if opt.ConfigFile != "" { + envs["KO_CONFIG_PATH"] = opt.ConfigFile + } + + if opt.LocalKindName != "" { + envs["KO_DOCKER_REPO"] = "kind.local" + envs["KIND_CLUSTER_NAME"] = opt.LocalKindName + } + + return envs +} + +// Install install ko with `go install` if ko not found in PATH +func (m *Manager) Install(version string) error { + path, err := utils.FindOrInstallGoBasedProvider(m.path, "ko", "github.com/google/ko", version) + if path != "" { + m.path = path + } + + return err +} + +// BuildLocal builds container image from the given packagePath and publishes it to a +// local repository supported by ko. It returns the container image ID within the ctx. +func (m *Manager) BuildLocal(ctx context.Context, packagePath string, opts ...Option) (context.Context, error) { + o := m.processOpts(opts...) + o.baseCmdAndOpt = fmt.Sprintf("build %s", packagePath) + + image, err := m.run(o) + if err != nil { + return ctx, err + } + + return context.WithValue(ctx, localImageContextKey(packagePath), image), nil +} + +// GetLocalImage returns the previously built container image ID for packagePath from ctx. +func (m *Manager) GetLocalImage(ctx context.Context, packagePath string) (string, error) { + var image string + + imgVal := ctx.Value(localImageContextKey(packagePath)) + if imgVal == nil { + return "", fmt.Errorf("container image not found for packagePath %s", packagePath) + } + + if img, ok := imgVal.(string); ok { + image = img + } + + return image, nil +} + +// run method is used to invoke a ko command to perform a suitable operation. +// Please make sure to configure the right Opts using the Option helpers +func (m *Manager) run(opts *Opts) (out string, err error) { + log.V(4).InfoS("Determining if ko binary is available or not", "executable", m.path) + if m.e.Prog().Avail(m.path) == "" { + return "", fmt.Errorf(missingKo) + } + + envs := m.getEnvs(opts) + command := m.getCommand(opts) + + var envsString string + for k, v := range envs { + envsString += k + "=" + v + " " + m.e = m.e.SetEnv(k, v) + } + log.V(4).InfoS("Running Ko Operation", "envs", envsString, "command", command) + proc := m.e.NewProc(command) + + var stderr bytes.Buffer + var stdout bytes.Buffer + proc.SetStderr(&stderr) + proc.SetStdout(&stdout) + + result := proc.Run().Result() + log.V(4).Info("Ko Command output \n", result) + + if !proc.IsSuccess() { + return "", fmt.Errorf("%s: %w", strings.TrimSuffix(stderr.String(), "\n"), proc.Err()) + } + + return strings.TrimSuffix(stdout.String(), "\n"), nil +} + +// WithPath is used to provide a custom path where the `ko` executable command +// can be found. This is useful in case if your binary is in a non standard location +// and you want to framework to use that instead of returning an error. +func (m *Manager) WithPath(path string) *Manager { + m.path = path + return m +} + +// New creates a ko Manager. +func New() *Manager { + return &Manager{ + path: "ko", + e: gexe.New(), + } +}