diff --git a/charts/vela-core/templates/defwithtemplate/deploy-components.yaml b/charts/vela-core/templates/defwithtemplate/deploy-components.yaml new file mode 100644 index 000000000..de1a96025 --- /dev/null +++ b/charts/vela-core/templates/defwithtemplate/deploy-components.yaml @@ -0,0 +1,73 @@ +# Code generated by KubeVela templates. DO NOT EDIT. Please edit the original cue file. +# Definition source cue file: vela-templates/definitions/internal/deploy-components.cue +apiVersion: core.oam.dev/v1beta1 +kind: WorkflowStepDefinition +metadata: + annotations: + custom.definition.oam.dev/category: Application Delivery + definition.oam.dev/description: Deploy each component to the cluster(s) resolved from its own topology policies. Applies are executed sequentially, one at a time -- unlike "deploy", there is no parallelism setting to configure. + labels: + custom.definition.oam.dev/scope: Application + name: deploy-components + namespace: {{ include "systemDefinitionNamespace" . }} +spec: + schematic: + cue: + template: | + import ( + "list" + "strings" + "vela/builtin" + "vela/multicluster" + "vela/oam" + ) + components: oam.#LoadComponets + + // Iterating (not an indexed lookup) avoids evaluating before "components" resolves. + _loadedNames: [for name, _ in components.$returns.value {name}] + + _missingComponents: [for entry in parameter.components if !list.Contains(_loadedNames, entry.name) {entry.name}] + + if len(_missingComponents) > 0 { + validateComponents: builtin.#Fail & { + $params: message: "component(s) not found in application: \(strings.Join(_missingComponents, ", "))" + } + } + + // Gated so nothing is applied unless every component name is valid. + if len(_missingComponents) == 0 { + deploy: { + // "comp", not "value" -- shadows the $params.value field below otherwise. + for name, comp in components.$returns.value { + for entry in parameter.components if entry.name == name { + "\(name)": { + placements: multicluster.#GetPlacementsFromTopologyPolicies & { + $params: policies: entry.policies + } + apply: { + for p in placements.$returns.placements { + "\(p.cluster)-\(p.namespace)": oam.#ApplyComponent & { + $params: { + value: comp + cluster: p.cluster + namespace: p.namespace + } + } + } + } + } + } + } + } + } + + parameter: { + // +usage=Per-component mapping of which topology policies determine its target cluster(s) + components: [...{ + // +usage=the name of the component in the application to apply + name: string + // +usage=names of topology policies (declared at the Application level) used to resolve this component's target cluster(s) + policies: [...string] + }] + } + diff --git a/pkg/workflow/providers/multicluster/multicluster.cue b/pkg/workflow/providers/multicluster/multicluster.cue index 3677a272b..893bb45a4 100644 --- a/pkg/workflow/providers/multicluster/multicluster.cue +++ b/pkg/workflow/providers/multicluster/multicluster.cue @@ -11,9 +11,9 @@ } } -#GetPlacementsFromTmulticlusterologyPolicies: { +#GetPlacementsFromTopologyPolicies: { #provider: "multicluster" - #do: "get-placements-from-tmulticlusterology-policies" + #do: "get-placements-from-topology-policies" $params: { policies: [...string] diff --git a/references/docgen/def-doc/workflowstep/deploy-components.eg.md b/references/docgen/def-doc/workflowstep/deploy-components.eg.md new file mode 100644 index 000000000..6cb9e865f --- /dev/null +++ b/references/docgen/def-doc/workflowstep/deploy-components.eg.md @@ -0,0 +1,36 @@ +```yaml +apiVersion: core.oam.dev/v1beta1 +kind: Application +metadata: + name: deploy-components-example + namespace: examples +spec: + components: + - name: web-on-local + type: webservice + properties: + image: nginx + - name: web-on-worker + type: webservice + properties: + image: nginx + policies: + - name: topology-local + type: topology + properties: + clusters: ["local"] + - name: topology-worker + type: topology + properties: + clusters: ["cluster-worker"] + workflow: + steps: + - name: deploy-components + type: deploy-components + properties: + components: + - name: web-on-local + policies: ["topology-local"] + - name: web-on-worker + policies: ["topology-worker"] +``` diff --git a/test/e2e-multicluster-test/multicluster_deploy_components_test.go b/test/e2e-multicluster-test/multicluster_deploy_components_test.go new file mode 100644 index 000000000..78afe41fe --- /dev/null +++ b/test/e2e-multicluster-test/multicluster_deploy_components_test.go @@ -0,0 +1,107 @@ +/* +Copyright 2021 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 e2e_multicluster_test + +import ( + "context" + "os" + "time" + + workflowv1alpha1 "github.com/kubevela/workflow/api/v1alpha1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + kerrors "k8s.io/apimachinery/pkg/api/errors" + "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/common" + "github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1" +) + +var _ = Describe("Test deploy-components workflow step", 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 deploying each component to the cluster resolved from its own topology policy", func() { + app := &v1beta1.Application{} + bs, err := os.ReadFile("./testdata/app/app-deploy-components.yaml") + Expect(err).Should(Succeed()) + Expect(yaml.Unmarshal(bs, app)).Should(Succeed()) + app.SetNamespace(namespace) + Eventually(func(g Gomega) { + g.Expect(k8sClient.Create(context.Background(), app)).Should(Succeed()) + }).WithPolling(2 * time.Second).WithTimeout(5 * time.Second).Should(Succeed()) + + appKey := client.ObjectKeyFromObject(app) + Eventually(func(g Gomega) { + _app := &v1beta1.Application{} + g.Expect(k8sClient.Get(context.Background(), appKey, _app)).Should(Succeed()) + g.Expect(_app.Status.Phase).Should(Equal(common.ApplicationRunning)) + }).WithPolling(2 * time.Second).WithTimeout(20 * time.Second).Should(Succeed()) + + By("component mapped to the local topology policy only lands on the hub cluster") + Expect(k8sClient.Get(hubCtx, types.NamespacedName{Namespace: namespace, Name: "cm-on-local"}, &corev1.ConfigMap{})).Should(Succeed()) + Expect(kerrors.IsNotFound(k8sClient.Get(workerCtx, types.NamespacedName{Namespace: namespace, Name: "cm-on-local"}, &corev1.ConfigMap{}))).Should(BeTrue()) + + By("component mapped to the worker topology policy only lands on the worker cluster") + Expect(k8sClient.Get(workerCtx, types.NamespacedName{Namespace: namespace, Name: "cm-on-worker"}, &corev1.ConfigMap{})).Should(Succeed()) + Expect(kerrors.IsNotFound(k8sClient.Get(hubCtx, types.NamespacedName{Namespace: namespace, Name: "cm-on-worker"}, &corev1.ConfigMap{}))).Should(BeTrue()) + + By("Deleting") + _app := &v1beta1.Application{} + Expect(k8sClient.Get(context.Background(), appKey, _app)).Should(Succeed()) + Expect(k8sClient.Delete(context.Background(), _app)).Should(Succeed()) + Eventually(func(g Gomega) { + g.Expect(kerrors.IsNotFound(k8sClient.Get(context.Background(), appKey, _app))).Should(BeTrue()) + }).WithPolling(2 * time.Second).WithTimeout(20 * time.Second).Should(Succeed()) + Expect(kerrors.IsNotFound(k8sClient.Get(hubCtx, types.NamespacedName{Namespace: namespace, Name: "cm-on-local"}, &corev1.ConfigMap{}))).Should(BeTrue()) + Expect(kerrors.IsNotFound(k8sClient.Get(workerCtx, types.NamespacedName{Namespace: namespace, Name: "cm-on-worker"}, &corev1.ConfigMap{}))).Should(BeTrue()) + }) + + It("Test deploy-components fails fast with a clear message when a component name is wrong", func() { + app := &v1beta1.Application{} + bs, err := os.ReadFile("./testdata/app/app-deploy-components-missing.yaml") + Expect(err).Should(Succeed()) + Expect(yaml.Unmarshal(bs, app)).Should(Succeed()) + app.SetNamespace(namespace) + Eventually(func(g Gomega) { + g.Expect(k8sClient.Create(context.Background(), app)).Should(Succeed()) + }).WithPolling(2 * time.Second).WithTimeout(5 * time.Second).Should(Succeed()) + + appKey := client.ObjectKeyFromObject(app) + Eventually(func(g Gomega) { + _app := &v1beta1.Application{} + g.Expect(k8sClient.Get(context.Background(), appKey, _app)).Should(Succeed()) + g.Expect(_app.Status.Workflow).ShouldNot(BeNil()) + g.Expect(len(_app.Status.Workflow.Steps)).ShouldNot(Equal(0)) + g.Expect(_app.Status.Workflow.Steps[0].Phase).Should(Equal(workflowv1alpha1.WorkflowStepPhaseFailed)) + g.Expect(_app.Status.Workflow.Steps[0].Message).Should(ContainSubstring("component(s) not found in application: cm-on-lcal")) + }).WithPolling(2 * time.Second).WithTimeout(20 * time.Second).Should(Succeed()) + }) +}) diff --git a/test/e2e-multicluster-test/testdata/app/app-deploy-components-missing.yaml b/test/e2e-multicluster-test/testdata/app/app-deploy-components-missing.yaml new file mode 100644 index 000000000..da7e849d8 --- /dev/null +++ b/test/e2e-multicluster-test/testdata/app/app-deploy-components-missing.yaml @@ -0,0 +1,45 @@ +apiVersion: core.oam.dev/v1beta1 +kind: Application +metadata: + name: app-deploy-components-missing +spec: + components: + - name: cm-on-local + type: k8s-objects + properties: + objects: + - apiVersion: v1 + kind: ConfigMap + metadata: + name: cm-on-local + data: + key: "local" + - name: cm-on-worker + type: k8s-objects + properties: + objects: + - apiVersion: v1 + kind: ConfigMap + metadata: + name: cm-on-worker + data: + key: "worker" + policies: + - name: topology-local + type: topology + properties: + clusters: ["local"] + - name: topology-worker + type: topology + properties: + clusters: ["cluster-worker"] + workflow: + steps: + - name: deploy-components + type: deploy-components + properties: + components: + - name: cm-on-lcal # intentional typo, should trigger validation failure + policies: ["topology-local"] + - name: cm-on-worker + policies: ["topology-worker"] diff --git a/test/e2e-multicluster-test/testdata/app/app-deploy-components.yaml b/test/e2e-multicluster-test/testdata/app/app-deploy-components.yaml new file mode 100644 index 000000000..acebcc2a1 --- /dev/null +++ b/test/e2e-multicluster-test/testdata/app/app-deploy-components.yaml @@ -0,0 +1,45 @@ +apiVersion: core.oam.dev/v1beta1 +kind: Application +metadata: + name: app-deploy-components +spec: + components: + - name: cm-on-local + type: k8s-objects + properties: + objects: + - apiVersion: v1 + kind: ConfigMap + metadata: + name: cm-on-local + data: + key: "local" + - name: cm-on-worker + type: k8s-objects + properties: + objects: + - apiVersion: v1 + kind: ConfigMap + metadata: + name: cm-on-worker + data: + key: "worker" + policies: + - name: topology-local + type: topology + properties: + clusters: ["local"] + - name: topology-worker + type: topology + properties: + clusters: ["cluster-worker"] + workflow: + steps: + - name: deploy-components + type: deploy-components + properties: + components: + - name: cm-on-local + policies: ["topology-local"] + - name: cm-on-worker + policies: ["topology-worker"] diff --git a/vela-templates/definitions/internal/workflowstep/deploy-components.cue b/vela-templates/definitions/internal/workflowstep/deploy-components.cue new file mode 100644 index 000000000..fb5eb61d5 --- /dev/null +++ b/vela-templates/definitions/internal/workflowstep/deploy-components.cue @@ -0,0 +1,69 @@ +import ( + "list" + "strings" + "vela/builtin" + "vela/multicluster" + "vela/oam" +) + +"deploy-components": { + type: "workflow-step" + annotations: { + "category": "Application Delivery" + } + labels: { + "scope": "Application" + } + description: "Deploy each component to the cluster(s) resolved from its own topology policies. Applies are executed sequentially, one at a time -- unlike \"deploy\", there is no parallelism setting to configure." +} +template: { + components: oam.#LoadComponets + + // Iterating (not an indexed lookup) avoids evaluating before "components" resolves. + _loadedNames: [for name, _ in components.$returns.value {name}] + + _missingComponents: [for entry in parameter.components if !list.Contains(_loadedNames, entry.name) {entry.name}] + + if len(_missingComponents) > 0 { + validateComponents: builtin.#Fail & { + $params: message: "component(s) not found in application: \(strings.Join(_missingComponents, ", "))" + } + } + + // Gated so nothing is applied unless every component name is valid. + if len(_missingComponents) == 0 { + deploy: { + // "comp", not "value" -- shadows the $params.value field below otherwise. + for name, comp in components.$returns.value { + for entry in parameter.components if entry.name == name { + "\(name)": { + placements: multicluster.#GetPlacementsFromTopologyPolicies & { + $params: policies: entry.policies + } + apply: { + for p in placements.$returns.placements { + "\(p.cluster)-\(p.namespace)": oam.#ApplyComponent & { + $params: { + value: comp + cluster: p.cluster + namespace: p.namespace + } + } + } + } + } + } + } + } + } + + parameter: { + // +usage=Per-component mapping of which topology policies determine its target cluster(s) + components: [...{ + // +usage=the name of the component in the application to apply + name: string + // +usage=names of topology policies (declared at the Application level) used to resolve this component's target cluster(s) + policies: [...string] + }] + } +}