diff --git a/pkg/utils/file.go b/pkg/utils/file.go index b5db81ba5..0798508ed 100644 --- a/pkg/utils/file.go +++ b/pkg/utils/file.go @@ -86,11 +86,12 @@ func LoadDataFromPath(ctx context.Context, path string, pathFilter func(string) return []FileData{{Path: path, Data: bs}}, nil } -// IsJSONOrYAMLFile check if the path is a json or yaml file -func IsJSONOrYAMLFile(path string) bool { +// IsJSONYAMLorCUEFile check if the path is a json or yaml file +func IsJSONYAMLorCUEFile(path string) bool { return strings.HasSuffix(path, ".json") || strings.HasSuffix(path, ".yaml") || - strings.HasSuffix(path, ".yml") + strings.HasSuffix(path, ".yml") || + strings.HasSuffix(path, ".cue") } // IsEmptyDir checks if a given path is an empty directory diff --git a/references/cli/addon_test.go b/references/cli/addon_test.go index 777af418d..461fdf308 100644 --- a/references/cli/addon_test.go +++ b/references/cli/addon_test.go @@ -22,16 +22,14 @@ import ( "strings" "testing" - "github.com/fatih/color" - - pkgaddon "github.com/oam-dev/kubevela/pkg/addon" - - "github.com/getkin/kin-openapi/openapi3" - . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" + + "github.com/fatih/color" + "github.com/getkin/kin-openapi/openapi3" "gotest.tools/assert" + pkgaddon "github.com/oam-dev/kubevela/pkg/addon" "github.com/oam-dev/kubevela/pkg/utils/common" "github.com/oam-dev/kubevela/pkg/utils/util" ) diff --git a/references/cli/kube.go b/references/cli/kube.go index 1b234f232..8c14fb76d 100644 --- a/references/cli/kube.go +++ b/references/cli/kube.go @@ -18,6 +18,8 @@ package cli import ( "bytes" + "context" + "encoding/json" "fmt" "io" "strings" @@ -40,6 +42,7 @@ import ( "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/cue/model/value" "github.com/oam-dev/kubevela/pkg/multicluster" "github.com/oam-dev/kubevela/pkg/utils" "github.com/oam-dev/kubevela/pkg/utils/util" @@ -76,10 +79,7 @@ type KubeApplyOptions struct { } // Complete . -func (opt *KubeApplyOptions) Complete(f velacmd.Factory, cmd *cobra.Command) error { - opt.namespace = velacmd.GetNamespace(f, cmd) - opt.clusters = velacmd.GetClusters(cmd) - +func (opt *KubeApplyOptions) Complete(ctx context.Context) error { var paths []string for _, file := range opt.files { path := strings.TrimSpace(file) @@ -88,7 +88,7 @@ func (opt *KubeApplyOptions) Complete(f velacmd.Factory, cmd *cobra.Command) err } } for _, path := range paths { - data, err := utils.LoadDataFromPath(cmd.Context(), path, utils.IsJSONOrYAMLFile) + data, err := utils.LoadDataFromPath(ctx, path, utils.IsJSONYAMLorCUEFile) if err != nil { return err } @@ -97,7 +97,7 @@ func (opt *KubeApplyOptions) Complete(f velacmd.Factory, cmd *cobra.Command) err return nil } -// Validate . +// Validate will not only validate the args but also read from files and generate the objects func (opt *KubeApplyOptions) Validate() error { if len(opt.files) == 0 { return fmt.Errorf("at least one file should be specified with the --file flag") @@ -105,21 +105,61 @@ func (opt *KubeApplyOptions) Validate() error { if len(opt.filesData) == 0 { return fmt.Errorf("not file found") } + if len(opt.clusters) == 0 { + opt.clusters = []string{"local"} + } + jsonObj := func(data []byte, path string) (*unstructured.Unstructured, error) { + obj := &unstructured.Unstructured{Object: map[string]interface{}{}} + err := json.Unmarshal(data, &obj.Object) + if err != nil { + return nil, fmt.Errorf("failed to decode object in %s: %w", path, err) + } + if opt.namespace != "" { + obj.SetNamespace(opt.namespace) + } else if obj.GetNamespace() == "" { + obj.SetNamespace(metav1.NamespaceDefault) + } + return obj, nil + } + 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 + switch { + case strings.HasSuffix(fileData.Path, ".yaml"), strings.HasSuffix(fileData.Path, ".yml"): + 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) + } + case strings.HasSuffix(fileData.Path, ".json"): + obj, err := jsonObj(fileData.Data, fileData.Path) + if err != nil { + return err + } + opt.objects = append(opt.objects, obj) + case strings.HasSuffix(fileData.Path, ".cue"): + val, err := value.NewValue(string(fileData.Data), nil, "") + if err != nil { 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) + data, err := val.CueValue().MarshalJSON() + if err != nil { + return fmt.Errorf("failed to marhsal to json for CUE object in %s: %w", fileData.Path, err) + } + obj, err := jsonObj(data, fileData.Path) + if err != nil { + return err } opt.objects = append(opt.objects, obj) } @@ -127,8 +167,8 @@ func (opt *KubeApplyOptions) Validate() error { return nil } -// Run . -func (opt *KubeApplyOptions) Run(f velacmd.Factory, cmd *cobra.Command) error { +// Run will apply objects to clusters +func (opt *KubeApplyOptions) Run(ctx context.Context, cli client.Client) error { if opt.dryRun { for i, obj := range opt.objects { if i > 0 { @@ -147,8 +187,9 @@ func (opt *KubeApplyOptions) Run(f velacmd.Factory, cmd *cobra.Command) error { _, _ = fmt.Fprintf(opt.Out, "\n") } _, _ = fmt.Fprintf(opt.Out, "Apply objects in cluster %s.\n", cluster) - ctx := multicluster.ContextWithClusterName(cmd.Context(), cluster) + ctx := multicluster.ContextWithClusterName(ctx, cluster) for _, obj := range opt.objects { + fmt.Println("XXXXXX creating", obj.GetName()) copiedObj := &unstructured.Unstructured{} bs, err := obj.MarshalJSON() if err != nil { @@ -157,7 +198,7 @@ func (opt *KubeApplyOptions) Run(f velacmd.Factory, cmd *cobra.Command) error { if err = copiedObj.UnmarshalJSON(bs); err != nil { return err } - res, err := utils.CreateOrUpdate(ctx, f.Client(), copiedObj) + res, err := utils.CreateOrUpdate(ctx, cli, copiedObj) if err != nil { return err } @@ -175,21 +216,33 @@ var ( 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.`)) + You can use -f/--file to specify the object file/folder to apply. Multiple file inputs are allowed. + Directory input and web url input is supported as well. + File format can be in YAML, JSON or CUE. +`)) kubeApplyExample = templates.Examples(i18n.T(` # Apply single object file in managed cluster vela kube apply -f my.yaml --cluster cluster-1 - + + # Apply object in CUE, the whole CUE file MUST follow the kubernetes API and contain only one object. + vela kube apply -f my.cue --cluster cluster-1 + + # Apply object in JSON, the whole JSON file MUST follow the kubernetes API and contain only one object. + vela kube apply -f my.json --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 + vela kube apply -f my-1.yaml -f my-2.cue --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`)) + vela kube apply -f ./resources -n demo --cluster cluster-1 --cluster cluster-2 + + # Use dry-run to see what will be rendered out in YAML + vela kube apply -f my.cue --cluster cluster-1 --dry-run +`)) ) // NewKubeApplyCommand kube apply command @@ -205,13 +258,16 @@ func NewKubeApplyCommand(f velacmd.Factory, streams util.IOStreams) *cobra.Comma }, Args: cobra.ExactValidArgs(0), Run: func(cmd *cobra.Command, args []string) { - cmdutil.CheckErr(o.Complete(f, cmd)) + o.namespace = velacmd.GetNamespace(f, cmd) + o.clusters = velacmd.GetClusters(cmd) + + cmdutil.CheckErr(o.Complete(cmd.Context())) cmdutil.CheckErr(o.Validate()) - cmdutil.CheckErr(o.Run(f, cmd)) + cmdutil.CheckErr(o.Run(cmd.Context(), f.Client())) }, } 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.") + cmd.Flags().BoolVarP(&o.dryRun, FlagDryRun, "", 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{}, diff --git a/references/cli/kube_test.go b/references/cli/kube_test.go new file mode 100644 index 000000000..18bfd932f --- /dev/null +++ b/references/cli/kube_test.go @@ -0,0 +1,81 @@ +/* + 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" + "context" + "os" + "strings" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/oam-dev/kubevela/pkg/utils/util" +) + +var _ = Describe("Test kube apply cli", func() { + + When("test vela kube apply", func() { + + It("should not have err and applied all objects", func() { + + buffer := bytes.NewBuffer(nil) + ioStreams := util.IOStreams{In: os.Stdin, Out: buffer, ErrOut: buffer} + + o := &KubeApplyOptions{} + o.IOStreams = ioStreams + o.files = []string{"./test-data/kubeapply"} + ctx := context.Background() + Expect(o.Complete(ctx)).Should(BeNil()) + Expect(o.Validate()).Should(BeNil()) + Expect(o.Run(ctx, k8sClient)).Should(BeNil()) + + By("test kube apply in dry-run mod") + o.dryRun = true + Expect(o.Run(ctx, k8sClient)).Should(BeNil()) + buf, ok := ioStreams.Out.(*bytes.Buffer) + Expect(ok).Should(BeTrue()) + Expect(strings.Contains(buf.String(), "error")).Should(BeFalse()) + + By("test kube apply in different namespace, new namespace") + var newns = "test-kube-apply" + err := k8sClient.Create(ctx, &corev1.Namespace{ + ObjectMeta: v1.ObjectMeta{Name: newns}, + }) + Expect(err).Should(BeNil()) + + By("apply objects") + o = &KubeApplyOptions{} + o.IOStreams = ioStreams + o.files = []string{"./test-data/kubeapply"} + o.namespace = newns + Expect(o.Complete(ctx)).Should(BeNil()) + Expect(o.Validate()).Should(BeNil()) + Expect(o.Run(ctx, k8sClient)).Should(BeNil()) + + By("check objects configmap created") + cml := corev1.ConfigMapList{} + Expect(k8sClient.List(ctx, &cml, client.InNamespace(newns))).Should(BeNil()) + Expect(len(cml.Items)).Should(BeEquivalentTo(3)) + + }) + }) +}) diff --git a/references/cli/test-data/kubeapply/kube.cue b/references/cli/test-data/kubeapply/kube.cue new file mode 100644 index 000000000..e1f8ef58e --- /dev/null +++ b/references/cli/test-data/kubeapply/kube.cue @@ -0,0 +1,41 @@ +import "encoding/json" + +_clickhouse: { + group: "clickhouse.altinity.com" + kind: "ClickHouseInstallation" +} +_statefulset: { + apiVersion: "apps/v1" + kind: "StatefulSet" +} +_service: { + apiVersion: "v1" + kind: "Service" +} + +_seldon: { + group: "machinelearning.seldon.io" + kind: "SeldonDeployment" +} + +_rule1: { + parentResourceType: _clickhouse + childrenResourceType: [_statefulset, _service] +} + +_rule2: { + parentResourceType: _seldon + childrenResourceType: [_service] +} + +apiVersion: "v1" +kind: "ConfigMap" +metadata: { + name: "toplogy-cue" + namespace: "vela-system" + labels: { + "rules.oam.dev/resources": "true" + "rules.oam.dev/resource-format": "json" + } +} +data: rules: json.Marshal([_rule1, _rule2]) diff --git a/references/cli/test-data/kubeapply/kube.json b/references/cli/test-data/kubeapply/kube.json new file mode 100644 index 000000000..587bb7a18 --- /dev/null +++ b/references/cli/test-data/kubeapply/kube.json @@ -0,0 +1,15 @@ +{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": "toplogy-json", + "namespace": "vela-system", + "labels": { + "rules.oam.dev/resources": "true", + "rules.oam.dev/resource-format": "json" + } + }, + "data": { + "rules": "[{\"parentResourceType\":{\"group\":\"clickhouse.altinity.com\",\"kind\":\"ClickHouseInstallation\"},\"childrenResourceType\":[{\"apiVersion\":\"apps/v1\",\"kind\":\"StatefulSet\"},{\"apiVersion\":\"v1\",\"kind\":\"Service\"}]},{\"parentResourceType\":{\"group\":\"machinelearning.seldon.io\",\"kind\":\"SeldonDeployment\"},\"childrenResourceType\":[{\"apiVersion\":\"v1\",\"kind\":\"Service\"}]}]" + } +} \ No newline at end of file diff --git a/references/cli/test-data/kubeapply/kube.yaml b/references/cli/test-data/kubeapply/kube.yaml new file mode 100644 index 000000000..1e023cc70 --- /dev/null +++ b/references/cli/test-data/kubeapply/kube.yaml @@ -0,0 +1,69 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kubevela-io + namespace: vela-system +spec: + progressDeadlineSeconds: 600 + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app.oam.dev/component: kubevela-io + strategy: + rollingUpdate: + maxSurge: 25% + maxUnavailable: 25% + type: RollingUpdate + template: + metadata: + creationTimestamp: null + labels: + app.oam.dev/component: kubevela-io + spec: + containers: + - image: oamdev/kubevela-io:latest + imagePullPolicy: Always + name: kubevela-io + ports: + - containerPort: 80 + name: port-80 + protocol: TCP + resources: + limits: + cpu: 200m + memory: 100Mi + requests: + cpu: 200m + memory: 100Mi + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + dnsPolicy: ClusterFirst + restartPolicy: Always + schedulerName: default-scheduler + securityContext: {} + terminationGracePeriodSeconds: 30 + +--- + +apiVersion: v1 +kind: ConfigMap +metadata: + name: kruise-rollout-relation + namespace: vela-system + labels: + "rules.oam.dev/resources": "true" +data: + rules: |- + - parentResourceType: + group: rollouts.kruise.io + kind: Rollout + childrenResourceType: + - apiVersion: rollouts.kruise.io/v1alpha1 + kind: BatchRelease + - parentResourceType: + group: rollouts.kruise.io + kind: BatchRelease + childrenResourceType: + - apiVersion: apps/v1 + kind: Deployment \ No newline at end of file