Feat: Added new deploy-components workflow step (#7213)

* feat(workflow): add deploy-components step for per-component multi-cluster targeting

Adds a pure-CUE workflow-step definition that lets each component in an
Application resolve its own target cluster(s) via topology policies,
filling the gap between apply-component (single component, static
cluster) and deploy (all components, cross-product against policies).

Signed-off-by: vishal210893 <vishal210893@gmail.com>

* chore(workflow): add rendered deploy-components WorkflowStepDefinition manifest

Generated from vela-templates/definitions/internal/workflowstep/deploy-components.cue
via make def-install, for installation as part of the vela-core Helm chart.

Signed-off-by: vishal210893 <vishal210893@gmail.com>

* test(e2e): add multicluster e2e for deploy-components step

Covers per-component topology-policy targeting (each component lands only
on its resolved cluster, no cross-product) and fast-fail validation when a
referenced component name is not present in the application.

Signed-off-by: vishal210893 <vishal210893@gmail.com>

* docs(workflow): add example doc for deploy-components step

Required by the definition-doc CI check for any new WorkflowStepDefinition.

Signed-off-by: vishal210893 <vishal210893@gmail.com>

* fix(workflow): normalize deploy-components import block to grouped form

The rendered manifest was generated with flat "import X" lines; the
project's CUE formatter (as used by CI's make reviewable) renders
multi-import blocks grouped as import (...). Fixes the check-diff CI job.

Signed-off-by: vishal210893 <vishal210893@gmail.com>

* fix(workflow): gate deploy-components applies on validation to prevent partial rollout

Previously, _missingComponents was derived from deploy's own realized keys,
which meant an invalid component name could only be detected after applies
for valid components had already started -- a partial rollout on failure.

Compute _missingComponents directly from the loaded component set instead
(via iteration, matching the pattern deploy itself already uses reliably --
an indexed lookup against the same field was empirically found to evaluate
before the load task resolves, causing false positives), and wrap deploy in
an if-guard so no component is applied unless every referenced name is
valid. Verified against a live multicluster setup: the negative case now
applies zero resources before failing, and the positive case is unaffected.

Signed-off-by: vishal210893 <vishal210893@gmail.com>

* style(workflow): tighten deploy-components comments

Comments narrated the debugging history behind each constraint; state
them as plain engineering facts instead.

Signed-off-by: vishal210893 <vishal210893@gmail.com>

* fix(workflow): correct mistyped #do string in multicluster.#GetPlacementsFromTopologyPolicies

The exported CUE symbol's #do value ("get-placements-from-tmulticlusterology-policies")
never matched the registered provider handler key ("get-placements-from-topology-policies"),
making it unusable by any CUE template. Fix the string and rename the symbol
to match. Unused elsewhere in the tree, so no other callers are affected.

deploy-components keeps its own local copy (for backport safety) but now
documents that it mirrors the corrected symbol.

Signed-off-by: vishal210893 <vishal210893@gmail.com>

* refactor(workflow): use the shared topology-placement symbol in deploy-components

Now that multicluster.#GetPlacementsFromTopologyPolicies is fixed, drop the
local #GetPlacements duplicate and reference the shared symbol directly.
deploy-components is new in this release, so there's no backport scenario
where it could ship without the fix landing alongside it.

Verified against a live multicluster controller build: positive case
resolves placements and applies correctly, negative case still gates
deploy on validation with zero partial rollout.

Signed-off-by: vishal210893 <vishal210893@gmail.com>

---------

Signed-off-by: vishal210893 <vishal210893@gmail.com>
This commit is contained in:
Vishal Kumar
2026-07-03 19:05:11 +05:30
committed by GitHub
parent 5a44b5f10c
commit d7f67354f8
7 changed files with 377 additions and 2 deletions

View File

@@ -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]
}]
}

View File

@@ -11,9 +11,9 @@
}
}
#GetPlacementsFromTmulticlusterologyPolicies: {
#GetPlacementsFromTopologyPolicies: {
#provider: "multicluster"
#do: "get-placements-from-tmulticlusterology-policies"
#do: "get-placements-from-topology-policies"
$params: {
policies: [...string]

View File

@@ -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"]
```

View File

@@ -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())
})
})

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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]
}]
}
}