From 0cd1ae155c09abeddd11be18d35acbdc3fa77cc8 Mon Sep 17 00:00:00 2001 From: Somefive Date: Fri, 20 May 2022 12:11:58 +0800 Subject: [PATCH] Feat: vela kube apply --cluster (#3931) Signed-off-by: Somefive --- pkg/cmd/builder.go | 73 +++++- pkg/cmd/types.go | 2 + pkg/cmd/utils.go | 20 ++ pkg/utils/file.go | 82 +++++++ references/cli/auth.go | 14 +- references/cli/cli.go | 1 + references/cli/kube.go | 218 ++++++++++++++++++ .../multicluster_cli_test.go | 40 ++++ .../testdata/kube/data/configmap.yaml | 13 ++ .../testdata/kube/service.yaml | 11 + 10 files changed, 458 insertions(+), 16 deletions(-) create mode 100644 pkg/utils/file.go create mode 100644 references/cli/kube.go create mode 100644 test/e2e-multicluster-test/testdata/kube/data/configmap.yaml create mode 100644 test/e2e-multicluster-test/testdata/kube/service.yaml diff --git a/pkg/cmd/builder.go b/pkg/cmd/builder.go index 1245c93f4..076bb3bc9 100644 --- a/pkg/cmd/builder.go +++ b/pkg/cmd/builder.go @@ -20,6 +20,7 @@ import ( "github.com/spf13/cobra" "k8s.io/kubectl/pkg/util/term" + "github.com/oam-dev/kubevela/apis/types" cmdutil "github.com/oam-dev/kubevela/pkg/cmd/util" "github.com/oam-dev/kubevela/pkg/utils/util" ) @@ -54,19 +55,53 @@ func newNamespaceFlagOptions(options ...NamespaceFlagOption) NamespaceFlagConfig return cfg } -// NamespaceFlagNoCompletionOption disable auto-completion for namespace flag -type NamespaceFlagNoCompletionOption struct{} +// ClusterFlagConfig config for cluster flag in cmd +type ClusterFlagConfig struct { + completion bool + usage string + disableSliceInput bool +} + +// ClusterFlagOption the option for configuring cluster flag +type ClusterFlagOption interface { + ApplyToClusterFlagOptions(*ClusterFlagConfig) +} + +func newClusterFlagOptions(options ...ClusterFlagOption) ClusterFlagConfig { + cfg := ClusterFlagConfig{ + completion: true, + usage: usageCluster, + disableSliceInput: false, + } + for _, option := range options { + option.ApplyToClusterFlagOptions(&cfg) + } + return cfg +} + +// FlagNoCompletionOption disable auto-completion for flag +type FlagNoCompletionOption struct{} // ApplyToNamespaceFlagOptions . -func (option NamespaceFlagNoCompletionOption) ApplyToNamespaceFlagOptions(cfg *NamespaceFlagConfig) { +func (option FlagNoCompletionOption) ApplyToNamespaceFlagOptions(cfg *NamespaceFlagConfig) { cfg.completion = false } -// NamespaceFlagUsageOption the usage description for namespace flag -type NamespaceFlagUsageOption string +// ApplyToClusterFlagOptions . +func (option FlagNoCompletionOption) ApplyToClusterFlagOptions(cfg *ClusterFlagConfig) { + cfg.completion = false +} + +// UsageOption the usage description for flag +type UsageOption string // ApplyToNamespaceFlagOptions . -func (option NamespaceFlagUsageOption) ApplyToNamespaceFlagOptions(cfg *NamespaceFlagConfig) { +func (option UsageOption) ApplyToNamespaceFlagOptions(cfg *NamespaceFlagConfig) { + cfg.usage = string(option) +} + +// ApplyToClusterFlagOptions . +func (option UsageOption) ApplyToClusterFlagOptions(cfg *ClusterFlagConfig) { cfg.usage = string(option) } @@ -101,6 +136,32 @@ func (builder *Builder) WithEnvFlag() *Builder { return builder } +// ClusterFlagDisableSliceInputOption set the cluster flag to allow multiple input +type ClusterFlagDisableSliceInputOption struct{} + +// ApplyToClusterFlagOptions . +func (option ClusterFlagDisableSliceInputOption) ApplyToClusterFlagOptions(cfg *ClusterFlagConfig) { + cfg.disableSliceInput = true +} + +// WithClusterFlag add cluster flag to the command +func (builder *Builder) WithClusterFlag(options ...ClusterFlagOption) *Builder { + cfg := newClusterFlagOptions(options...) + if cfg.disableSliceInput { + builder.cmd.Flags().StringP(flagCluster, "c", types.ClusterLocalName, cfg.usage) + } else { + builder.cmd.Flags().StringSliceP(flagCluster, "c", []string{types.ClusterLocalName}, cfg.usage) + } + if cfg.completion { + cmdutil.CheckErr(builder.cmd.RegisterFlagCompletionFunc( + flagCluster, + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return GetClustersForCompletion(cmd.Context(), builder.f, toComplete) + })) + } + return builder +} + // WithStreams set the in/out/err streams for the command func (builder *Builder) WithStreams(streams util.IOStreams) *Builder { builder.cmd.SetIn(streams.In) diff --git a/pkg/cmd/types.go b/pkg/cmd/types.go index 8cf637f39..476318a31 100644 --- a/pkg/cmd/types.go +++ b/pkg/cmd/types.go @@ -19,9 +19,11 @@ package cmd const ( flagNamespace = "namespace" flagEnv = "env" + flagCluster = "cluster" ) const ( usageNamespace = "If present, the namespace scope for this CLI request" usageEnv = "The environment name for the CLI request" + usageCluster = "The cluster to execute the current command" ) diff --git a/pkg/cmd/utils.go b/pkg/cmd/utils.go index cf80e0df0..8b43cb3a7 100644 --- a/pkg/cmd/utils.go +++ b/pkg/cmd/utils.go @@ -50,3 +50,23 @@ func GetNamespace(f Factory, cmd *cobra.Command) string { } return envMeta.Namespace } + +// GetCluster get cluster from command flags +func GetCluster(cmd *cobra.Command) string { + cluster, err := cmd.Flags().GetString(flagCluster) + cmdutil.CheckErr(err) + if cluster == "" { + return types.ClusterLocalName + } + return cluster +} + +// GetClusters get cluster from command flags +func GetClusters(cmd *cobra.Command) []string { + clusters, err := cmd.Flags().GetStringSlice(flagCluster) + cmdutil.CheckErr(err) + if len(clusters) == 0 { + return []string{types.ClusterLocalName} + } + return clusters +} diff --git a/pkg/utils/file.go b/pkg/utils/file.go new file mode 100644 index 000000000..2d496be60 --- /dev/null +++ b/pkg/utils/file.go @@ -0,0 +1,82 @@ +/* +Copyright 2022 The KubeVela 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 utils + +import ( + "context" + "fmt" + "io/fs" + "io/ioutil" + "os" + "path/filepath" + "strings" + + "github.com/oam-dev/kubevela/pkg/utils/common" +) + +// FileData data of a file at the path +type FileData struct { + Path string + Data []byte +} + +// LoadDataFromPath load FileData from path +// If path is an url, fetch the data from the url +// If path is a file, fetch the data from the file +// If path is a dir, fetch the data from all the files inside the dir that passes the pathFilter +func LoadDataFromPath(ctx context.Context, path string, pathFilter func(string) bool) ([]FileData, error) { + if IsValidURL(path) { + bs, err := common.HTTPGetWithOption(ctx, path, nil) + if err != nil { + return nil, fmt.Errorf("failed to get data from %s: %w", path, err) + } + return []FileData{{Path: path, Data: bs}}, nil + } + fileInfo, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("failed to get file info for %s: %w", path, err) + } + if fileInfo.IsDir() { + var data []FileData + err = filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error { + if pathFilter == nil || pathFilter(path) { + bs, e := ioutil.ReadFile(filepath.Clean(path)) + if e != nil { + return e + } + data = append(data, FileData{Path: path, Data: bs}) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("failed to traverse directory %s: %w", path, err) + } + return data, nil + } + bs, e := ioutil.ReadFile(filepath.Clean(path)) + if e != nil { + return nil, fmt.Errorf("failed to read file %s: %w", path, err) + } + return []FileData{{Path: path, Data: bs}}, nil +} + +// IsJSONOrYAMLFile check if the path is a json or yaml file +func IsJSONOrYAMLFile(path string) bool { + return strings.HasSuffix(path, ".json") || + strings.HasSuffix(path, ".yaml") || + strings.HasSuffix(path, ".yml") +} diff --git a/references/cli/auth.go b/references/cli/auth.go index 0b37473c0..1b0eb5263 100644 --- a/references/cli/auth.go +++ b/references/cli/auth.go @@ -159,7 +159,7 @@ func NewGenKubeConfigCommand(f velacmd.Factory, streams util.IOStreams) *cobra.C })) return velacmd.NewCommandBuilder(f, cmd). - WithNamespaceFlag(velacmd.NamespaceFlagUsageOption("The namespace of the serviceaccount. This flag only works when `--serviceaccount` is set.")). + WithNamespaceFlag(velacmd.UsageOption("The namespace of the serviceaccount. This flag only works when `--serviceaccount` is set.")). WithStreams(streams). WithResponsiveWriter(). Build() @@ -183,9 +183,7 @@ func (opt *ListPrivilegesOptions) Complete(f velacmd.Factory, cmd *cobra.Command if opt.Identity.ServiceAccount != "" { opt.Identity.ServiceAccountNamespace = velacmd.GetNamespace(f, cmd) } - if len(opt.Clusters) == 0 { - opt.Clusters = []string{types.ClusterLocalName} - } + opt.Clusters = velacmd.GetClusters(cmd) opt.Regularize() } @@ -278,7 +276,6 @@ func NewListPrivilegesCommand(f velacmd.Factory, streams util.IOStreams) *cobra. cmd.Flags().StringVarP(&o.User, "user", "u", o.User, "The user to list privileges.") cmd.Flags().StringSliceVarP(&o.Groups, "group", "g", o.Groups, "The group to list privileges. Can be set together with --user.") cmd.Flags().StringVarP(&o.ServiceAccount, "serviceaccount", "", o.ServiceAccount, "The serviceaccount to list privileges. Cannot be set with --user and --group.") - cmd.Flags().StringSliceVarP(&o.Clusters, "cluster", "c", o.Clusters, "The cluster to list privileges. If not set, the command will list privileges in the control plane.") cmd.Flags().StringVarP(&o.KubeConfig, "kubeconfig", "", o.KubeConfig, "The kubeconfig to list privileges. If set, it will override all the other identity flags.") cmdutil.CheckErr(cmd.RegisterFlagCompletionFunc( "serviceaccount", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { @@ -288,13 +285,10 @@ func NewListPrivilegesCommand(f velacmd.Factory, streams util.IOStreams) *cobra. namespace := velacmd.GetNamespace(f, cmd) return velacmd.GetServiceAccountForCompletion(cmd.Context(), f, namespace, toComplete) })) - cmdutil.CheckErr(cmd.RegisterFlagCompletionFunc( - "cluster", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return velacmd.GetClustersForCompletion(cmd.Context(), f, toComplete) - })) return velacmd.NewCommandBuilder(f, cmd). - WithNamespaceFlag(velacmd.NamespaceFlagUsageOption("The namespace of the serviceaccount. This flag only works when `--serviceaccount` is set.")). + WithNamespaceFlag(velacmd.UsageOption("The namespace of the serviceaccount. This flag only works when `--serviceaccount` is set.")). + WithClusterFlag(velacmd.UsageOption("The cluster to list privileges. If not set, the command will list privileges in the control plane.")). WithStreams(streams). WithResponsiveWriter(). Build() diff --git a/references/cli/cli.go b/references/cli/cli.go index d9fde3f5a..c4162e096 100644 --- a/references/cli/cli.go +++ b/references/cli/cli.go @@ -112,6 +112,7 @@ func NewCommandWithIOStreams(ioStream util.IOStreams) *cobra.Command { NewComponentsCommand(commandArgs, ioStream), NewProviderCommand(commandArgs, "10", ioStream), AuthCommandGroup(f, ioStream), + KubeCommandGroup(f, ioStream), // System NewInstallCommand(commandArgs, "1", ioStream), diff --git a/references/cli/kube.go b/references/cli/kube.go new file mode 100644 index 000000000..a68d14cd2 --- /dev/null +++ b/references/cli/kube.go @@ -0,0 +1,218 @@ +/* +Copyright 2022 The KubeVela 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 cli + +import ( + "bytes" + "fmt" + "io" + "strings" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" + "k8s.io/utils/strings/slices" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + "github.com/oam-dev/kubevela/apis/types" + velacmd "github.com/oam-dev/kubevela/pkg/cmd" + cmdutil "github.com/oam-dev/kubevela/pkg/cmd/util" + "github.com/oam-dev/kubevela/pkg/multicluster" + "github.com/oam-dev/kubevela/pkg/utils" + "github.com/oam-dev/kubevela/pkg/utils/util" +) + +// KubeCommandGroup command group for native resource management +func KubeCommandGroup(f velacmd.Factory, streams util.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "kube", + Short: i18n.T("Managing native Kubernetes resources across clusters."), + Annotations: map[string]string{ + types.TagCommandType: types.TypeCD, + }, + Run: func(cmd *cobra.Command, args []string) { + + }, + } + cmd.AddCommand(NewKubeApplyCommand(f, streams)) + return cmd +} + +// KubeApplyOptions options for kube apply +type KubeApplyOptions struct { + files []string + clusters []string + namespace string + dryRun bool + + filesData []utils.FileData + objects []*unstructured.Unstructured + + util.IOStreams +} + +// Complete . +func (opt *KubeApplyOptions) Complete(f velacmd.Factory, cmd *cobra.Command) error { + opt.namespace = velacmd.GetNamespace(f, cmd) + opt.clusters = velacmd.GetClusters(cmd) + + var paths []string + for _, file := range opt.files { + path := strings.TrimSpace(file) + if !slices.Contains(paths, path) { + paths = append(paths, path) + } + } + for _, path := range paths { + data, err := utils.LoadDataFromPath(cmd.Context(), path, utils.IsJSONOrYAMLFile) + if err != nil { + return err + } + opt.filesData = append(opt.filesData, data...) + } + return nil +} + +// Validate . +func (opt *KubeApplyOptions) Validate() error { + if len(opt.files) == 0 { + return fmt.Errorf("at least one file should be specified with the --file flag") + } + if len(opt.filesData) == 0 { + return fmt.Errorf("not file found") + } + for _, fileData := range opt.filesData { + decoder := yaml.NewDecoder(bytes.NewReader(fileData.Data)) + for { + obj := &unstructured.Unstructured{Object: map[string]interface{}{}} + err := decoder.Decode(obj.Object) + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return fmt.Errorf("failed to decode object in %s: %w", fileData.Path, err) + } + if opt.namespace != "" { + obj.SetNamespace(opt.namespace) + } else if obj.GetNamespace() == "" { + obj.SetNamespace(metav1.NamespaceDefault) + } + opt.objects = append(opt.objects, obj) + } + } + return nil +} + +// Run . +func (opt *KubeApplyOptions) Run(f velacmd.Factory, cmd *cobra.Command) error { + if opt.dryRun { + for i, obj := range opt.objects { + if i > 0 { + _, _ = fmt.Fprintf(opt.Out, "---\n") + } + bs, err := yaml.Marshal(obj.Object) + if err != nil { + return err + } + _, _ = opt.Out.Write(bs) + } + return nil + } + for i, cluster := range opt.clusters { + if i > 0 { + _, _ = fmt.Fprintf(opt.Out, "\n") + } + _, _ = fmt.Fprintf(opt.Out, "Apply objects in cluster %s.\n", cluster) + ctx := multicluster.ContextWithClusterName(cmd.Context(), cluster) + for _, obj := range opt.objects { + copiedObj := &unstructured.Unstructured{} + bs, err := obj.MarshalJSON() + if err != nil { + return err + } + if err = copiedObj.UnmarshalJSON(bs); err != nil { + return err + } + res, err := controllerutil.CreateOrPatch(ctx, f.Client(), copiedObj, nil) + if err != nil { + return err + } + key := strings.TrimPrefix(obj.GetNamespace()+"/"+obj.GetName(), "/") + _, _ = fmt.Fprintf(opt.Out, " %s %s %s.\n", obj.GetKind(), key, res) + } + } + return nil +} + +var ( + kubeApplyLong = templates.LongDesc(i18n.T(` + Apply Kubernetes objects in clusters + + Apply Kubernetes objects in multiple clusters. Use --clusters to specify which clusters to + apply. If -n/--namespace is used, the original object namespace will be overrode. + + You can use -f/--file to specify the object file to apply. Multiple file inputs are allowed. + Directory input and web url input is supported as well.`)) + + kubeApplyExample = templates.Examples(i18n.T(` + # Apply single object file in managed cluster + vela kube apply -f my.yaml --cluster cluster-1 + + # Apply multiple object files in multiple managed clusters + vela kube apply -f my-1.yaml -f my-2.yaml --cluster cluster-1 --cluster cluster-2 + + # Apply object file with web url in control plane + vela kube apply -f https://raw.githubusercontent.com/kubevela/kubevela/master/docs/examples/app-with-probe/app-with-probe.yaml + + # Apply object files in directory to specified namespace in managed clusters + vela kube apply -f ./resources -n demo --cluster cluster-1 --cluster cluster-2`)) +) + +// NewKubeApplyCommand kube apply command +func NewKubeApplyCommand(f velacmd.Factory, streams util.IOStreams) *cobra.Command { + o := &KubeApplyOptions{IOStreams: streams} + cmd := &cobra.Command{ + Use: "apply", + Short: i18n.T("Apply resources in Kubernetes YAML file to clusters."), + Long: kubeApplyLong, + Example: kubeApplyExample, + Annotations: map[string]string{ + types.TagCommandType: types.TypeCD, + }, + Args: cobra.ExactValidArgs(0), + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.Run(f, cmd)) + }, + } + cmd.Flags().StringSliceVarP(&o.files, "file", "f", o.files, "Files that include native Kubernetes objects to apply.") + cmd.Flags().BoolVarP(&o.dryRun, "dryrun", "", o.dryRun, "Setting this flag will not apply resources in clusters. It will print out the resource to be applied.") + return velacmd.NewCommandBuilder(f, cmd). + WithNamespaceFlag( + velacmd.NamespaceFlagDisableEnvOption{}, + velacmd.UsageOption("The namespace to apply objects. If empty, the namespace declared in the YAML will be used."), + ). + WithClusterFlag(velacmd.UsageOption("The cluster to apply objects. Setting multiple clusters will apply objects in order.")). + WithStreams(streams). + WithResponsiveWriter(). + Build() +} diff --git a/test/e2e-multicluster-test/multicluster_cli_test.go b/test/e2e-multicluster-test/multicluster_cli_test.go index 81e327306..bc2024106 100644 --- a/test/e2e-multicluster-test/multicluster_cli_test.go +++ b/test/e2e-multicluster-test/multicluster_cli_test.go @@ -27,11 +27,14 @@ import ( . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" "github.com/onsi/gomega/gexec" + appsv1 "k8s.io/api/apps/v1" v1 "k8s.io/api/core/v1" + apitypes "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/yaml" "github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1" + "github.com/oam-dev/kubevela/apis/types" ) var _ = Describe("Test multicluster CLI commands", func() { @@ -118,3 +121,40 @@ var _ = Describe("Test multicluster CLI commands", func() { }) }) + +var _ = Describe("Test kube commands", func() { + + Context("Test apply command", func() { + + var namespace string + var hubCtx context.Context + var workerCtx context.Context + + BeforeEach(func() { + hubCtx, workerCtx, namespace = initializeContextAndNamespace() + }) + + AfterEach(func() { + cleanUpNamespace(hubCtx, workerCtx, namespace) + }) + + It("Test vela kube apply", func() { + _, err := execCommand("kube", "apply", + "--cluster", types.ClusterLocalName, "--cluster", WorkerClusterName, "-n", namespace, + "-f", "./testdata/kube", + "-f", "https://gist.githubusercontent.com/Somefive/b189219a9222eaa70b8908cf4379402b/raw/e603987b3e0989e01e50f69ebb1e8bb436461326/example-busybox-deployment.yaml", + ) + Expect(err).Should(Succeed()) + Expect(k8sClient.Get(hubCtx, apitypes.NamespacedName{Namespace: namespace, Name: "busybox"}, &appsv1.Deployment{})).Should(Succeed()) + Expect(k8sClient.Get(workerCtx, apitypes.NamespacedName{Namespace: namespace, Name: "busybox"}, &appsv1.Deployment{})).Should(Succeed()) + Expect(k8sClient.Get(hubCtx, apitypes.NamespacedName{Namespace: namespace, Name: "busybox"}, &v1.Service{})).Should(Succeed()) + Expect(k8sClient.Get(workerCtx, apitypes.NamespacedName{Namespace: namespace, Name: "busybox"}, &v1.Service{})).Should(Succeed()) + Expect(k8sClient.Get(hubCtx, apitypes.NamespacedName{Namespace: namespace, Name: "busybox-1"}, &v1.ConfigMap{})).Should(Succeed()) + Expect(k8sClient.Get(workerCtx, apitypes.NamespacedName{Namespace: namespace, Name: "busybox-1"}, &v1.ConfigMap{})).Should(Succeed()) + Expect(k8sClient.Get(hubCtx, apitypes.NamespacedName{Namespace: namespace, Name: "busybox-2"}, &v1.ConfigMap{})).Should(Succeed()) + Expect(k8sClient.Get(workerCtx, apitypes.NamespacedName{Namespace: namespace, Name: "busybox-2"}, &v1.ConfigMap{})).Should(Succeed()) + }) + + }) + +}) diff --git a/test/e2e-multicluster-test/testdata/kube/data/configmap.yaml b/test/e2e-multicluster-test/testdata/kube/data/configmap.yaml new file mode 100644 index 000000000..e63c8898b --- /dev/null +++ b/test/e2e-multicluster-test/testdata/kube/data/configmap.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: busybox-1 +data: + key: value-1 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: busybox-2 +data: + key: value-2 \ No newline at end of file diff --git a/test/e2e-multicluster-test/testdata/kube/service.yaml b/test/e2e-multicluster-test/testdata/kube/service.yaml new file mode 100644 index 000000000..73d28c8c0 --- /dev/null +++ b/test/e2e-multicluster-test/testdata/kube/service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: busybox +spec: + selector: + app: busybox + ports: + - protocol: TCP + port: 80 + targetPort: 80