diff --git a/docs/README.md b/docs/README.md index ad31598..012cb90 100644 --- a/docs/README.md +++ b/docs/README.md @@ -151,16 +151,41 @@ This configuration function declares and stores configuration needed to connect #### Output `kube_config()` returns a struct with the following fields. -| Field | Description | -| --------| --------- | -| `path` | The path to the local Kubernetes config that was set | -| `cluster_context` | The name of a context that was set for the cluster | -| `capi_provider`|A provider that was set for Cluster-API usage| - #### Example ```python kube_config(path=args.kube_conf, cluster_context="my-cluster") ``` + +### `kube_exec()` +This function executes an arbitrary command inside a K8s pod + +#### Parameters + +| Param | Description | Required | +|------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------| +| `namespace` | Namespace of the target pod. The default value is 'default'. | No | +| `pod` | The name of the target pod. | Yes | +| `container` | Container name. If omitted, use the kubectl.kubernetes.io/default-container annotation for selecting the container to be attached or the first container in the pod will be chosen. | No | +| `cmd` | The command to be executed inside the container. | Yes | +| `workdir` | A parent directory where the result file from the executed command will be saved. Defaults to `crashd_config.workdir` or if `crashd_config.workdir` doesn't exist, it defaults to `/tmp/crashd` | No | +| `kube_config` | A struct with Kubernetes configuration.If not provided defaults to Kubernetes config returned by `kube_config() | No | +| `timeout_in_seconds`| The maximum duration (in seconds) to wait for the command to complete. If not specified, the default is 120 seconds. | No | +| `output_file` | The file (relative to the working directory) where the command output will be streamed. If not specified, the output is appended /workdir/.out | No | + + +#### Output +`kube_exec()` returns a struct with the following fields. + +| Field | Description | +|-----------------|------------------------------------------------------------| +| `file` | The path to a file where the command result was redirected | +| `error` | An error message if one was encountered | + +#### Example +```python +kube_exec(pod="nginx", output_file="nginx_version.txt",container="nginx", cmd=["nginx", "-v"]) +``` + ### `ssh_config()` This function creates configuration that can be used to connect via SSH to remote machines. diff --git a/examples/kube_exec.crsh b/examples/kube_exec.crsh new file mode 100644 index 0000000..ab60381 --- /dev/null +++ b/examples/kube_exec.crsh @@ -0,0 +1,14 @@ +work_dir = args.workdir if hasattr(args, "workdir") else fail("Error: workdir argument is required but not provided.") +conf = crashd_config(workdir=work_dir) +kube_config_path = args.kubeconfig +set_defaults(kube_config(path=kube_config_path)) + +# Exec into pod and run a long-running command. The command timeout period is controlled via timeout_in_seconds +#Output is appended in file under work_dir/.out +kube_exec(namespace=args.namespace,pod="nginx", timeout_in_seconds=3, cmd=["sh", "-c" ,"while true; do echo 'Running'; sleep 1; done"]) + +# Exec into pod and run short-lived command. The output will be appended in work_dir/.out +kube_exec(pod="nginx", cmd=["ls"]) + +# Exec into pod and run short-lived command. The output will be stored into file: work_dir/nginx_version.txt +kube_exec(pod="nginx", output_file="nginx_version.txt",container="nginx", cmd=["nginx", "-v"]) \ No newline at end of file diff --git a/go.mod b/go.mod index 924d56d..6481f5e 100644 --- a/go.mod +++ b/go.mod @@ -34,16 +34,19 @@ require ( github.com/google/go-cmp v0.6.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/uuid v1.3.0 // indirect + github.com/gorilla/websocket v1.5.0 // indirect github.com/imdario/mergo v0.3.11 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/moby/spdystream v0.2.0 // indirect github.com/moby/term v0.0.0-20221205130635-1aeaba878587 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/nxadm/tail v1.4.8 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/spf13/pflag v1.0.5 // indirect diff --git a/go.sum b/go.sum index f758734..b64042f 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= @@ -52,6 +54,9 @@ github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/Z github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA= github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= @@ -74,6 +79,8 @@ github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhn github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/moby/spdystream v0.2.0 h1:cjW1zVyyoiM0T7b6UoySUFqzXMoqRckQtXwGPiBhOM8= +github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c= github.com/moby/term v0.0.0-20221205130635-1aeaba878587 h1:HfkjXDfhgVaN5rmueG8cL8KKeFNecRCXFhaJ2qZ5SKA= github.com/moby/term v0.0.0-20221205130635-1aeaba878587/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -83,6 +90,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= diff --git a/k8s/executor.go b/k8s/executor.go new file mode 100644 index 0000000..5656b62 --- /dev/null +++ b/k8s/executor.go @@ -0,0 +1,109 @@ +// Copyright (c) 2019 VMware, Inc. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package k8s + +import ( + "context" + "fmt" + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/tools/remotecommand" + "os" + "time" +) + +// Executor is a struct that facilitates the execution of commands in Kubernetes pods. +// It uses the SPDYExecutor to stream command +type Executor struct { + Executor remotecommand.Executor +} + +type ExecOptions struct { + Namespace string + Command []string + Podname string + ContainerName string + Config *Config + Timeout time.Duration +} + +func NewExecutor(kubeconfig string, clusterCtxName string, opts ExecOptions) (*Executor, error) { + restCfg, err := restConfig(kubeconfig, clusterCtxName) + if err != nil { + return nil, err + } + setCoreDefaultConfig(restCfg) + restc, err := rest.RESTClientFor(restCfg) + if err != nil { + return nil, err + } + + request := restc.Post(). + Namespace(opts.Namespace). + Resource("pods"). + Name(opts.Podname). + SubResource("exec"). + VersionedParams(&corev1.PodExecOptions{ + Container: opts.ContainerName, + Command: opts.Command, + Stdout: true, + Stderr: true, + TTY: false, + }, scheme.ParameterCodec) + executor, err := remotecommand.NewSPDYExecutor(restCfg, "POST", request.URL()) + if err != nil { + return nil, err + + } + return &Executor{Executor: executor}, nil +} + +// makeRESTConfig creates a new *rest.Config with a k8s context name if one is provided. +func restConfig(fileName, contextName string) (*rest.Config, error) { + if fileName == "" { + return nil, fmt.Errorf("kubeconfig file path required") + } + + if contextName != "" { + // create the config object from k8s config path and context + return clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + &clientcmd.ClientConfigLoadingRules{ExplicitPath: fileName}, + &clientcmd.ConfigOverrides{ + CurrentContext: contextName, + }).ClientConfig() + } + + return clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + &clientcmd.ClientConfigLoadingRules{ExplicitPath: fileName}, + &clientcmd.ConfigOverrides{}, + ).ClientConfig() +} + +// ExecCommand executes a command inside a specified Kubernetes pod using the SPDYExecutor. +func (k8sc *Executor) ExecCommand(ctx context.Context, outputFilePath string, execOptions ExecOptions) error { + ctx, cancel := context.WithTimeout(ctx, execOptions.Timeout) + defer cancel() + + file, err := os.OpenFile(outputFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("error creating output file: %v", err) + } + defer file.Close() + + // Execute the command and stream the stdout and stderr to the file. Some commands are using stderr. + err = k8sc.Executor.StreamWithContext(ctx, remotecommand.StreamOptions{ + Stdout: file, + Stderr: file, + }) + if err != nil { + if err == context.DeadlineExceeded { + return fmt.Errorf("command execution timed out. command:%s", execOptions.Command) + } + return err + } + + return nil +} diff --git a/starlark/kube_exec.go b/starlark/kube_exec.go new file mode 100644 index 0000000..c907159 --- /dev/null +++ b/starlark/kube_exec.go @@ -0,0 +1,96 @@ +// Copyright (c) 2020 VMware, Inc. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package starlark + +import ( + "context" + "fmt" + "github.com/pkg/errors" + "github.com/vmware-tanzu/crash-diagnostics/k8s" + "go.starlark.net/starlark" + "go.starlark.net/starlarkstruct" + "path/filepath" + "time" +) + +// KubeExecFn is a starlark built-in for executing command in target K8s pods +func KubeExecFn(thread *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var namespace, pod, container, workdir, outputfile string + var timeout int + var command *starlark.List + var kubeConfig *starlarkstruct.Struct + + if err := starlark.UnpackArgs( + identifiers.kubeExec, args, kwargs, + "namespace?", &namespace, + "pod", &pod, + "container?", &container, + "cmd", &command, + "workdir?", &workdir, + "output_file?", &outputfile, + "kube_config?", &kubeConfig, + "timeout_in_seconds?", &timeout, + ); err != nil { + return starlark.None, errors.Wrap(err, "failed to read args") + } + + if namespace == "" { + namespace = "default" + } + if timeout == 0 { + //Default timeout if not specified is 2 Minutes + timeout = 120 + } + + if len(workdir) == 0 { + //Defaults to crashd_config.workdir or /tmp/crashd + if dir, err := getWorkdirFromThread(thread); err == nil { + workdir = dir + } + } + + ctx, ok := thread.Local(identifiers.scriptCtx).(context.Context) + if !ok || ctx == nil { + return starlark.None, fmt.Errorf("script context not found") + } + + if kubeConfig == nil { + kubeConfig = thread.Local(identifiers.kubeCfg).(*starlarkstruct.Struct) + } + path, err := getKubeConfigPathFromStruct(kubeConfig) + if err != nil { + return starlark.None, errors.Wrap(err, "failed to get kubeconfig") + } + clusterCtxName := getKubeConfigContextNameFromStruct(kubeConfig) + + execOpts := k8s.ExecOptions{ + Namespace: namespace, + Podname: pod, + ContainerName: container, + Command: toSlice(command), + Timeout: time.Duration(timeout) * time.Second, + } + executor, err := k8s.NewExecutor(path, clusterCtxName, execOpts) + if err != nil { + return starlark.None, errors.Wrap(err, "could not initialize search client") + } + + outputFilePath := filepath.Join(trimQuotes(workdir), outputfile) + if outputfile == "" { + outputFilePath = filepath.Join(trimQuotes(workdir), pod+".out") + } + err = executor.ExecCommand(ctx, outputFilePath, execOpts) + + return starlarkstruct.FromStringDict( + starlark.String(identifiers.kubeCapture), + starlark.StringDict{ + "file": starlark.String(outputFilePath), + "error": func() starlark.String { + if err != nil { + return starlark.String(err.Error()) + } + return "" + }(), + }), nil +} diff --git a/starlark/kube_exec_test.go b/starlark/kube_exec_test.go new file mode 100644 index 0000000..1dd1090 --- /dev/null +++ b/starlark/kube_exec_test.go @@ -0,0 +1,134 @@ +// Copyright (c) 2020 VMware, Inc. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package starlark + +import ( + "bufio" + "fmt" + "os" + "strings" + "testing" + + "go.starlark.net/starlark" + "go.starlark.net/starlarkstruct" +) + +func TestKubeExecScript(t *testing.T) { + workdir := testSupport.TmpDirRoot() + k8sconfig := testSupport.KindKubeConfigFile() + clusterName := testSupport.KindClusterContextName() + err := testSupport.StartNginxPod() + if err != nil { + t.Error("Unexpected error while starting nginx pod", err) + return + } + + execute := func(t *testing.T, script string) *starlarkstruct.Struct { + executor := New() + if err := executor.Exec("test.kube.exec", strings.NewReader(script)); err != nil { + t.Fatalf("failed to exec: %s", err) + } + if !executor.result.Has("kube_exec_output") { + t.Fatalf("script result must be assigned to a value") + } + + data, ok := executor.result["kube_exec_output"].(*starlarkstruct.Struct) + if !ok { + t.Fatal("script result is not a struct") + } + return data + } + + tests := []struct { + name string + script string + eval func(t *testing.T, script string) + }{ + { + name: "exec into pod and run long-running operation", + script: fmt.Sprintf(` +crashd_config(workdir="%s") +set_defaults(kube_config(path="%s", cluster_context="%s")) +kube_exec_output=kube_exec(pod="nginx", timeout_in_seconds=3,cmd=["sh", "-c" ,"while true; do echo 'Running'; sleep 1; done"]) +`, workdir, k8sconfig, clusterName), + eval: func(t *testing.T, script string) { + data := execute(t, script) + + errVal, err := data.Attr("error") + if err != nil { + t.Fatal(err) + } + + resultErr := errVal.(starlark.String).GoString() + if resultErr == "" || !strings.HasPrefix(resultErr, "command execution timed out.") { + t.Fatalf("Unexpected error result: %s", resultErr) + } + + ouputFilePath, err := data.Attr("file") + if err != nil { + t.Fatal(err) + } + + file, err := os.Open(trimQuotes(ouputFilePath.String())) + if err != nil { + t.Fatalf("result file does not exist: %s", err) + } + defer file.Close() + + var actual int + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if line == "Running" { + actual++ + } + } + expected := 3 + if expected != actual { + t.Fatalf("Unexpected file content. expected line numbers: %d but was %d", expected, actual) + } + + }, + }, + { + name: "exec into pod and run short-lived command.Output to specified file", + script: fmt.Sprintf(` +crashd_config(workdir="%s") +set_defaults(kube_config(path="%s", cluster_context="%s")) +kube_exec_output=kube_exec(pod="nginx", output_file="nginx.version",container="nginx", cmd=["nginx", "-v"]) +`, workdir, k8sconfig, clusterName), + eval: func(t *testing.T, script string) { + data := execute(t, script) + + errVal, err := data.Attr("error") + if err != nil { + t.Fatal(err) + } + + resultErr := errVal.(starlark.String).GoString() + if resultErr != "" { + t.Fatalf("expected ouput error to be empty but was %s", resultErr) + } + + ouputFilePath, err := data.Attr("file") + if err != nil { + t.Fatal(err) + } + + fileContents, err := os.ReadFile(trimQuotes(ouputFilePath.String())) + if err != nil { + t.Fatalf("Error reading output file: %v", err) + } + strings.Contains(string(fileContents), "nginx version:") + + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + test.eval(t, test.script) + }) + } +} diff --git a/starlark/starlark_exec.go b/starlark/starlark_exec.go index 1ccabde..acb23e1 100644 --- a/starlark/starlark_exec.go +++ b/starlark/starlark_exec.go @@ -137,6 +137,7 @@ func newPredeclareds(restrictedMode []bool) starlark.StringDict { identifiers.kubeCfg: starlark.NewBuiltin(identifiers.kubeCfg, KubeConfigFn), identifiers.kubeCapture: starlark.NewBuiltin(identifiers.kubeGet, KubeCaptureFn), identifiers.kubeGet: starlark.NewBuiltin(identifiers.kubeGet, KubeGetFn), + identifiers.kubeExec: starlark.NewBuiltin(identifiers.kubeExec, KubeExecFn), identifiers.kubeNodesProvider: starlark.NewBuiltin(identifiers.kubeNodesProvider, KubeNodesProviderFn), identifiers.capvProvider: starlark.NewBuiltin(identifiers.capvProvider, CapvProviderFn), identifiers.capaProvider: starlark.NewBuiltin(identifiers.capaProvider, CapaProviderFn), diff --git a/starlark/support.go b/starlark/support.go index 6f3f323..2ebeef8 100644 --- a/starlark/support.go +++ b/starlark/support.go @@ -51,6 +51,7 @@ var ( kubeCapture string kubeGet string + kubeExec string kubeNodesProvider string capvProvider string capaProvider string @@ -88,6 +89,7 @@ var ( kubeCapture: "kube_capture", kubeGet: "kube_get", + kubeExec: "kube_exec", kubeNodesProvider: "kube_nodes_provider", capvProvider: "capv_provider", capaProvider: "capa_provider", diff --git a/testing/kindcluster.go b/testing/kindcluster.go index 934a78b..558eaac 100644 --- a/testing/kindcluster.go +++ b/testing/kindcluster.go @@ -161,6 +161,51 @@ spec: } } +func (k *KindCluster) StartNginxPod() error { + logrus.Infof("Starting pod in kind cluster %s", k.name) + podConfig := ` +apiVersion: v1 +kind: Pod +metadata: + name: nginx + labels: + app: nginx +spec: + containers: + - name: nginx + image: nginx + ports: + - containerPort: 80 +` + + filePath := fmt.Sprintf("%s/nginx-pod.yaml", k.tmpRootDir) + file, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return fmt.Errorf("failed to create file for pod configuration: %s", err) + } + defer file.Close() + + if _, err := file.WriteString(podConfig); err != nil { + return fmt.Errorf("failed to write pod configuration to file: %s", err) + } + p := k.e.RunProc(fmt.Sprintf("timeout 60s bash -c 'while ! kubectl --context kind-%s get sa default -n default &>/dev/null; do sleep 1; done'", k.name)) + if p.Err() != nil { + return fmt.Errorf("default service account has not been created: %s: %s", p.Err(), p.Result()) + } + + p = k.e.RunProc(fmt.Sprintf(`kubectl --context kind-%s apply -f %s`, k.name, filePath)) + if p.Err() != nil { + return fmt.Errorf("failed to apply pod configuration: %s: %s", p.Err(), p.Result()) + } + + p = k.e.RunProc(fmt.Sprintf("kubectl --context kind-%s wait --for=condition=Ready pod -l app=nginx --timeout=60s", k.name)) + if p.Err() != nil { + return fmt.Errorf("faild to schedule a pod: %s: %s", p.Err(), p.Result()) + } + + return nil +} + func (k *KindCluster) GetKubeCtlContext() string { return fmt.Sprintf("kind-%s", k.name) } diff --git a/testing/setup.go b/testing/setup.go index f68483c..011441a 100644 --- a/testing/setup.go +++ b/testing/setup.go @@ -200,6 +200,9 @@ func (t *TestSupport) KindClusterContextName() string { func (t *TestSupport) SimulateTerminatingPod() error { return t.kindCluster.SimulateTerminatingPod() } +func (t *TestSupport) StartNginxPod() error { + return t.kindCluster.StartNginxPod() +} func (t *TestSupport) TearDown() error { var errs []error