Feat(validation): fail-fast CUE validation for required parameters (incl. dynamic sources) (#6774)

* Chore: Added fail fast validation logic of component having multiple steps including workflow, component etc.

Signed-off-by: Chaitanya Reddy Onteddu <chaitanyareddy0702@gmail.com>

* testing updated param filter logic

Signed-off-by: Amit Singh <singhamitch@outlook.com>

* Added validation logic for struct type parameter

Signed-off-by: Chaitanya Reddy Onteddu <chaitanyareddy0702@gmail.com>

* fixed code when struct type parameter is provided in component

Signed-off-by: Chaitanya Reddy Onteddu <chaitanyareddy0702@gmail.com>

* refactor: minor code improvements

Signed-off-by: Amit Singh <singhamitch@outlook.com>

* fixed go lint issue

Signed-off-by: Chaitanya Reddy Onteddu <chaitanyareddy0702@gmail.com>

* Chore: Add test cases for fail fast logic

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

* updated expect logic

Signed-off-by: Chaitanya Reddy Onteddu <chaitanyareddy0702@gmail.com>

* Added e2e test cases for required param validation

Signed-off-by: Chaitanya Reddy Onteddu <chaitanyareddy0702@gmail.com>

* Added feature gate in e2e test cases for required param validation

Signed-off-by: Chaitanya Reddy Onteddu <chaitanyareddy0702@gmail.com>

* Added feature gate make e2e_test file and removed for ginkgo test file

Signed-off-by: Chaitanya Reddy Onteddu <chaitanyareddy0702@gmail.com>

* Fixed code to quoted string

Signed-off-by: Chaitanya Reddy Onteddu <chaitanyareddy0702@gmail.com>

* Added logic and test case for policy type override

Signed-off-by: Chaitanya Reddy Onteddu <chaitanyareddy0702@gmail.com>

* Added license header

Signed-off-by: Chaitanya Reddy Onteddu <chaitanyareddy0702@gmail.com>

---------

Signed-off-by: Chaitanya Reddy Onteddu <chaitanyareddy0702@gmail.com>
Signed-off-by: Amit Singh <singhamitch@outlook.com>
Signed-off-by: Vishal Kumar <vishal210893@gmail.com>
Co-authored-by: Amit Singh <singhamitch@outlook.com>
Co-authored-by: Chaitanya Reddy Onteddu <chaitanyareddy0702@gmail.com>
This commit is contained in:
Vishal Kumar
2025-05-03 12:54:05 +05:30
committed by GitHub
parent a1145f21fe
commit d3ce7ad118
8 changed files with 583 additions and 285 deletions

1
go.mod
View File

@@ -37,6 +37,7 @@ require (
github.com/hashicorp/hcl/v2 v2.18.0
github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174
github.com/imdario/mergo v0.3.16
github.com/jeremywohl/flatten/v2 v2.0.0-20211013061545-07e4a09fb8e4
github.com/kubevela/pkg v1.9.3-0.20241203070234-2cf98778c0a9
github.com/kubevela/workflow v0.6.2
github.com/kyokomi/emoji v2.2.4+incompatible

2
go.sum
View File

@@ -587,6 +587,8 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOl
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jellydator/ttlcache/v3 v3.0.1 h1:cHgCSMS7TdQcoprXnWUptJZzyFsqs18Lt8VVhRuZYVU=
github.com/jellydator/ttlcache/v3 v3.0.1/go.mod h1:WwTaEmcXQ3MTjOm4bsZoDFiCu/hMvNWLO1w67RXz6h4=
github.com/jeremywohl/flatten/v2 v2.0.0-20211013061545-07e4a09fb8e4 h1:eA9wi6ZzpIRobvXkn/S2Lyw1hr2pc71zxzOPl7Xjs4w=
github.com/jeremywohl/flatten/v2 v2.0.0-20211013061545-07e4a09fb8e4/go.mod h1:s9g9Dfls+aEgucKXKW+i8MRZuLXT2MrD/WjYpMnWfOw=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=

View File

@@ -30,6 +30,7 @@ e2e-setup-core-wo-auth:
--set image.tag=$(GIT_COMMIT) \
--set multicluster.clusterGateway.image.repository=ghcr.io/oam-dev/cluster-gateway \
--set admissionWebhooks.patch.image.repository=ghcr.io/oam-dev/kube-webhook-certgen/kube-webhook-certgen \
--set featureGates.enableCueValidation=true \
--wait kubevela ./charts/vela-core \
--debug

View File

@@ -17,7 +17,17 @@ limitations under the License.
package appfile
import (
"encoding/json"
"fmt"
"strings"
"cuelang.org/go/cue"
"github.com/jeremywohl/flatten/v2"
"github.com/kubevela/pkg/cue/cuex"
"github.com/kubevela/workflow/pkg/cue/model/value"
utilfeature "k8s.io/apiserver/pkg/util/feature"
"github.com/oam-dev/kubevela/pkg/features"
"github.com/pkg/errors"
@@ -36,11 +46,20 @@ func (p *Parser) ValidateCUESchematicAppfile(a *Appfile) error {
if wl.CapabilityCategory != types.CUECategory || wl.Type == v1alpha1.RefObjectsComponentType {
continue
}
ctxData := GenerateContextDataFromAppFile(a, wl.Name)
if utilfeature.DefaultMutableFeatureGate.Enabled(features.EnableCueValidation) {
err := p.ValidateComponentParams(ctxData, wl, a)
if err != nil {
return err
}
}
pCtx, err := newValidationProcessContext(wl, ctxData)
if err != nil {
return errors.WithMessagef(err, "cannot create the validation process context of app=%s in namespace=%s", a.Name, a.Namespace)
}
for _, tr := range wl.Traits {
if tr.CapabilityCategory != types.CUECategory {
continue
@@ -53,6 +72,225 @@ func (p *Parser) ValidateCUESchematicAppfile(a *Appfile) error {
return nil
}
// ValidateComponentParams performs CUElevel validation for a Components
// parameters and emits helpful, contextrich errors.
//
// Flow
// 1. Assemble a synthetic CUE document (template + params + app context).
// 2. Compile it; if compilation fails, return the compiler error.
// 3. When the EnableCueValidation gate is on, ensure *all* nonoptional,
// nondefaulted parameters are provided—either in the Component.Params
// block or as workflowstep inputs.
// 4. Run cue.Value.Validate to enforce usersupplied values against
// template constraints.
func (p *Parser) ValidateComponentParams(ctxData velaprocess.ContextData, wl *Component, app *Appfile) error {
// ---------------------------------------------------------------------
// 1. Build synthetic CUE source
// ---------------------------------------------------------------------
ctx := velaprocess.NewContext(ctxData)
baseCtx, err := ctx.BaseContextFile()
if err != nil {
return errors.WithStack(err)
}
paramSnippet, err := cueParamBlock(wl.Params)
if err != nil {
return errors.WithMessagef(err, "component %q: invalid params", wl.Name)
}
cueSrc := strings.Join([]string{
renderTemplate(wl.FullTemplate.TemplateStr),
paramSnippet,
baseCtx,
}, "\n")
val, err := cuex.DefaultCompiler.Get().CompileString(ctx.GetCtx(), cueSrc)
if err != nil {
return errors.WithMessagef(err, "component %q: CUE compile error", wl.Name)
}
// ---------------------------------------------------------------------
// 2. Strict requiredfield enforcement (featuregated)
// ---------------------------------------------------------------------
if err := enforceRequiredParams(val, wl.Params, app); err != nil {
return errors.WithMessagef(err, "component %q", wl.Name)
}
// ---------------------------------------------------------------------
// 3. Validate concrete values
// ---------------------------------------------------------------------
paramVal := val.LookupPath(value.FieldPath(velaprocess.ParameterFieldName))
if err := paramVal.Validate(cue.Concrete(false)); err != nil {
return errors.WithMessagef(err, "component %q: parameter constraint violation", wl.Name)
}
return nil
}
// cueParamBlock marshals the Params map into a `parameter:` block suitable
// for inclusion in a CUE document.
func cueParamBlock(params map[string]any) (string, error) {
if len(params) == 0 {
return velaprocess.ParameterFieldName + ": {}", nil
}
b, err := json.Marshal(params)
if err != nil {
return "", err
}
return fmt.Sprintf("%s: %s", velaprocess.ParameterFieldName, string(b)), nil
}
// enforceRequiredParams checks that every required field declared in the
// templates `parameter:` stanza is satisfied either directly (Params) or
// indirectly (workflowstep inputs). It returns an error describing any
// missing keys.
func enforceRequiredParams(root cue.Value, params map[string]any, app *Appfile) error {
requiredParams, err := requiredFields(root.LookupPath(value.FieldPath(velaprocess.ParameterFieldName)))
if err != nil {
return err
}
// filter out params that are initialized directly
requiredParams, err = filterMissing(requiredParams, params)
if err != nil {
return err
}
// if there are still required params not initialized
if len(requiredParams) > 0 {
// collect params that are initialized in workflow steps
wfInitParams := make(map[string]bool)
for _, step := range app.WorkflowSteps {
for _, in := range step.Inputs {
wfInitParams[in.ParameterKey] = true
}
}
for _, p := range app.Policies {
if p.Type != "override" {
continue
}
var spec overrideSpec
if err := json.Unmarshal(p.Properties.Raw, &spec); err != nil {
return fmt.Errorf("override policy %q: parse properties: %w", p.Name, err)
}
for _, c := range spec.Components {
if len(c.Properties) == 0 {
continue
}
flat, err := flatten.Flatten(c.Properties, "", flatten.DotStyle)
if err != nil {
return fmt.Errorf("override policy %q: flatten properties: %w", p.Name, err)
}
for k := range flat {
wfInitParams[k] = true // idempotent set-style insert
}
}
}
// collect required params that were not initialized even in workflow steps
var missingParams []string
for _, key := range requiredParams {
if !wfInitParams[key] {
missingParams = append(missingParams, key)
}
}
if len(missingParams) > 0 {
return fmt.Errorf("missing parameters: %v", strings.Join(missingParams, ","))
}
}
return nil
}
type overrideSpec struct {
Components []struct {
Properties map[string]any `json:"properties"`
} `json:"components"`
}
// requiredFields returns the list of "parameter" fields that must be supplied
// by the caller. Nested struct leaves are returned as dot-separated paths.
//
// Rules:
// - A field with a trailing '?' is optional -> ignore
// - A field that has a default (*value | …) is optional -> ignore
// - Everything else is required.
// - Traverses arbitrarily deep into structs.
func requiredFields(v cue.Value) ([]string, error) {
var out []string
err := collect("", v, &out)
return out, err
}
func collect(prefix string, v cue.Value, out *[]string) error {
// Only structs can contain nested required fields.
if v.Kind() != cue.StructKind {
return nil
}
it, err := v.Fields(
cue.Optional(false),
cue.Definitions(false),
cue.Hidden(false),
)
if err != nil {
return err
}
for it.Next() {
// Skip fields that provide a default (*").
if _, hasDef := it.Value().Default(); hasDef {
continue
}
label := it.Selector().Unquoted()
path := label
if prefix != "" {
path = prefix + "." + label
}
// Recurse if the value itself is a struct; otherwise record the leaf.
if it.Value().Kind() == cue.StructKind {
if err := collect(path, it.Value(), out); err != nil {
return err
}
} else {
*out = append(*out, path)
}
}
return nil
}
// filterMissing removes every key that is already present in the provided map.
//
// It reuses the original slices backing array to avoid allocations.
func filterMissing(keys []string, provided map[string]any) ([]string, error) {
flattenProvided, err := flatten.Flatten(provided, "", flatten.DotStyle)
if err != nil {
return nil, err
}
out := keys[:0]
for _, k := range keys {
if _, ok := flattenProvided[k]; !ok {
out = append(out, k)
}
}
return out, nil
}
// renderTemplate appends the placeholders expected by KubeVelas template
// compiler so that the generated snippet is always syntactically complete.
func renderTemplate(tmpl string) string {
return tmpl + `
context: _
parameter: _
`
}
func newValidationProcessContext(c *Component, ctxData velaprocess.ContextData) (process.Context, error) {
baseHooks := []process.BaseHook{
// add more hook funcs here to validate CUE base

View File

@@ -151,3 +151,114 @@ var _ = Describe("Test validate CUE schematic Appfile", func() {
}),
)
})
var _ = Describe("Test ValidateComponentParams", func() {
type ParamTestCase struct {
name string
template string
params map[string]interface{}
wantErr string
}
DescribeTable("ValidateComponentParams cases", func(tc ParamTestCase) {
wl := &Component{
Name: tc.name,
Type: "worker",
FullTemplate: &Template{TemplateStr: tc.template},
Params: tc.params,
}
app := &Appfile{
Name: "myapp",
Namespace: "test-ns",
}
ctxData := GenerateContextDataFromAppFile(app, wl.Name)
parser := &Parser{}
err := parser.ValidateComponentParams(ctxData, wl, app)
if tc.wantErr == "" {
Expect(err).To(BeNil())
} else {
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring(tc.wantErr))
}
},
Entry("valid params and template", ParamTestCase{
name: "valid",
template: `
parameter: {
replicas: int | *1
}
output: {
apiVersion: "apps/v1"
kind: "Deployment"
}
`,
params: map[string]interface{}{
"replicas": 2,
},
wantErr: "",
}),
Entry("invalid CUE in template", ParamTestCase{
name: "invalid-cue",
template: `
parameter: {
replicas: int | *1
}
output: {
apiVersion: "apps/v1"
kind: "Deployment"
invalidField: {
}
`,
params: map[string]interface{}{
"replicas": 2,
},
wantErr: "CUE compile error",
}),
Entry("missing required parameter", ParamTestCase{
name: "missing-required",
template: `
parameter: {
replicas: int
}
output: {
apiVersion: "apps/v1"
kind: "Deployment"
}
`,
params: map[string]interface{}{},
wantErr: "component \"missing-required\": missing parameters: replicas",
}),
Entry("parameter constraint violation", ParamTestCase{
name: "constraint-violation",
template: `
parameter: {
replicas: int & >0
}
output: {
apiVersion: "apps/v1"
kind: "Deployment"
}
`,
params: map[string]interface{}{
"replicas": -1,
},
wantErr: "parameter constraint violation",
}),
Entry("invalid parameter block", ParamTestCase{
name: "invalid-param-block",
template: `
parameter: {
replicas: int | *1
}
output: {
apiVersion: "apps/v1"
kind: "Deployment"
}
`,
params: map[string]interface{}{
"replicas": "not-an-int",
},
wantErr: "parameter constraint violation",
}),
)
})

View File

@@ -41,10 +41,6 @@ import (
"github.com/oam-dev/kubevela/pkg/cue/task"
"github.com/oam-dev/kubevela/pkg/oam"
"github.com/oam-dev/kubevela/pkg/oam/util"
utilfeature "k8s.io/apiserver/pkg/util/feature"
"github.com/oam-dev/kubevela/pkg/features"
)
const (
@@ -133,14 +129,6 @@ func (wd *workloadDef) Complete(ctx process.Context, abstractTemplate string, pa
return err
}
// Strict Cue required field parameter validation
if utilfeature.DefaultMutableFeatureGate.Enabled(features.EnableCueValidation) {
paramCue := val.LookupPath(value.FieldPath(velaprocess.ParameterFieldName))
if err := paramCue.Validate(cue.Concrete(true)); err != nil {
return errors.WithMessagef(err, "parameter error for %s", wd.name)
}
}
// we will support outputs for workload composition, and it will become trait in AppConfig.
outputs := val.LookupPath(value.FieldPath(OutputsFieldName))
if !outputs.Exists() {

View File

@@ -28,11 +28,6 @@ import (
"github.com/oam-dev/kubevela/apis/types"
"github.com/oam-dev/kubevela/pkg/cue/process"
utilfeature "k8s.io/apiserver/pkg/util/feature"
featuregatetesting "k8s.io/component-base/featuregate/testing"
"github.com/oam-dev/kubevela/pkg/features"
)
func TestWorkloadTemplateComplete(t *testing.T) {
@@ -1570,271 +1565,3 @@ parameter: {
assert.Contains(t, err.Error(), v.err)
}
}
func TestWorkloadParamsValidations(t *testing.T) {
defer featuregatetesting.SetFeatureGateDuringTest(&testing.T{}, utilfeature.DefaultFeatureGate, features.EnableCueValidation, true)()
testCases := map[string]struct {
workloadTemplate string
params map[string]interface{}
expectObj runtime.Object
expAssObjs map[string]runtime.Object
category types.CapabilityCategory
hasCompileErr bool
errorString string
}{
"Missing Required Param that is used in template": {
workloadTemplate: `
output:{
apiVersion: "apps/v1"
kind: "Deployment"
metadata: name: context.name
spec: {
replicas: parameter.replicas
host: parameter.requiredParam
}
}
parameter: {
replicas: *1 | int
type: string
requiredParam!: string
}
`,
params: map[string]interface{}{
"replicas": 2,
"type": "ClusterIP",
},
expectObj: &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": map[string]interface{}{"name": "test"},
"spec": map[string]interface{}{"replicas": int64(2)},
}},
hasCompileErr: true,
errorString: "parameter error for testWorkload: parameter.requiredParam: field is required but not present",
},
// Missing Required Param that is not used in template
"Missing Required Param that is not used in template": {
workloadTemplate: `
output:{
apiVersion: "apps/v1"
kind: "Deployment"
metadata: name: context.name
spec: {
replicas: parameter.replicas
}
}
parameter: {
replicas: *1 | int
type: string
requiredParam!: string
}
`,
params: map[string]interface{}{
"replicas": 2,
"type": "ClusterIP",
},
expectObj: &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": map[string]interface{}{"name": "test"},
"spec": map[string]interface{}{"replicas": int64(2)},
}},
hasCompileErr: true,
errorString: "parameter error for testWorkload: parameter.requiredParam: field is required but not present",
},
//required param that is nested
"required param that is nested": {
workloadTemplate: `
output:{
apiVersion: "apps/v1"
kind: "Deployment"
metadata: name: context.name
spec: {
replicas: parameter.replicas
}
}
parameter: {
replicas: *1 | int
type: string
host: requiredParam!: string
}
`,
params: map[string]interface{}{
"replicas": 2,
"type": "ClusterIP",
"host": map[string]string{},
},
expectObj: &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": map[string]interface{}{"name": "test"},
"spec": map[string]interface{}{"replicas": int64(2)},
}},
hasCompileErr: true,
errorString: "parameter error for testWorkload: parameter.host.requiredParam: field is required but not present",
},
//required params that are provided
"required params that are provided": {
workloadTemplate: `
output:{
apiVersion: "apps/v1"
kind: "Deployment"
metadata: name: context.name
spec: {
replicas: parameter.replicas
host: parameter.host.requiredParam
}
}
parameter: {
replicas: *1 | int
type: string
host: requiredParam!: string
param1!: string
}
`,
params: map[string]interface{}{
"replicas": 2,
"type": "ClusterIP",
"host": map[string]interface{}{"requiredParam": "example.com"},
"param1": "newparam",
},
expectObj: &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": map[string]interface{}{"name": "test"},
"spec": map[string]interface{}{"replicas": int64(2), "host": "example.com"},
}},
hasCompileErr: false,
errorString: "",
},
//optional and regular param with default value should not give error
"optional and regular param with default value should not give error": {
workloadTemplate: `
output:{
apiVersion: "apps/v1"
kind: "Deployment"
metadata: name: context.name
spec: {
replicas: parameter.replicas
}
}
parameter: {
replicas: *1 | int
type: string
requiredParam!: string
optionalParam?: string
regularParam: string | *""
}
`,
params: map[string]interface{}{
"replicas": 2,
"type": "ClusterIP",
"requiredParam": "example.com",
},
expectObj: &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": map[string]interface{}{"name": "test"},
"spec": map[string]interface{}{"replicas": int64(2)},
}},
hasCompileErr: false,
errorString: "",
},
// regular param should give error
"regular param should give error": {
workloadTemplate: `
output:{
apiVersion: "apps/v1"
kind: "Deployment"
metadata: name: context.name
spec: {
replicas: parameter.replicas
}
}
parameter: {
replicas: *1 | int
type: string
requiredParam!: string
regularParam: string
}
`,
params: map[string]interface{}{
"replicas": 2,
"type": "ClusterIP",
"requiredParam": "example.com",
},
expectObj: &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": map[string]interface{}{"name": "test"},
"spec": map[string]interface{}{"replicas": int64(2)},
}},
hasCompileErr: true,
errorString: "parameter error for testWorkload: parameter.regularParam: incomplete value string",
},
// multiple errors
"multiple errors": {
workloadTemplate: `
output:{
apiVersion: "apps/v1"
kind: "Deployment"
metadata: name: context.name
spec: {
replicas: parameter.replicas
}
}
parameter: {
replicas: *1 | int
type: string
requiredParam!: string
regularParam: string
}
`,
params: map[string]interface{}{
"replicas": 2,
"type": "ClusterIP",
},
expectObj: &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": map[string]interface{}{"name": "test"},
"spec": map[string]interface{}{"replicas": int64(2)},
}},
hasCompileErr: true,
errorString: "parameter error for testWorkload: parameter.requiredParam: field is required but not present (and 1 more errors)",
},
}
for _, v := range testCases {
ctx := process.NewContext(process.ContextData{
AppName: "myapp",
CompName: "test",
Namespace: "default",
AppRevisionName: "myapp-v1",
ClusterVersion: types.ClusterVersion{Minor: "19+"},
})
wt := NewWorkloadAbstractEngine("testWorkload")
err := wt.Complete(ctx, v.workloadTemplate, v.params)
hasError := err != nil
assert.Equal(t, v.hasCompileErr, hasError)
if v.hasCompileErr {
if err != nil {
assert.Equal(t, err.Error(), v.errorString)
}
continue
}
base, assists := ctx.Output()
assert.Equal(t, len(v.expAssObjs), len(assists))
assert.NotNil(t, base)
baseObj, err := base.Unstructured()
assert.Equal(t, nil, err)
assert.Equal(t, v.expectObj, baseObj)
for _, ss := range assists {
assert.Equal(t, AuxiliaryWorkload, ss.Type)
got, err := ss.Ins.Unstructured()
assert.NoError(t, err)
assert.Equal(t, got, v.expAssObjs[ss.Name])
}
}
}

View File

@@ -0,0 +1,230 @@
/*
Copyright 2024 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 controllers_test
import (
"context"
"encoding/json"
"fmt"
"time"
workflowv1alpha1 "github.com/kubevela/workflow/api/v1alpha1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/oam-dev/kubevela/apis/core.oam.dev/common"
"github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1"
"github.com/oam-dev/kubevela/pkg/oam/util"
)
var _ = Describe("Application required-parameter validation", Ordered, func() {
var (
ctx context.Context
nsName string
namespace corev1.Namespace
)
BeforeAll(func() {
ctx = context.Background()
nsName = randomNamespaceName("requiredparam-validation-test")
namespace = corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName}}
By("creating the test namespace")
Eventually(func() error {
return k8sClient.Create(ctx, &namespace)
}, 3*time.Second, 300*time.Millisecond).Should(SatisfyAny(BeNil(), &util.AlreadyExistMatcher{}))
By("Apply the component definition")
Expect(k8sClient.Create(ctx, newConfigMapComponent(nsName))).To(Succeed())
})
AfterEach(func() {
By("Cleaning up resources after each test")
Expect(k8sClient.DeleteAllOf(ctx, &v1beta1.Application{}, client.InNamespace(nsName))).To(Succeed())
})
AfterAll(func() {
By("Cleaning up resources after all the test")
Expect(k8sClient.DeleteAllOf(ctx, &v1beta1.ComponentDefinition{}, client.InNamespace(nsName))).To(Succeed())
Expect(k8sClient.Delete(ctx, &namespace)).To(Succeed())
})
// -------------------------------------------------------------------------
// Scenario 1: missing parameter → expect failure
// -------------------------------------------------------------------------
It("fails when the required parameter is missing", func() {
app := appWithWorkflow.DeepCopy()
app.Name = "app-missing-param"
app.Namespace = nsName
err := k8sClient.Create(ctx, app)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring(fmt.Sprintf(`component %q: missing parameters: secondkey.value2.value3.value5`, "configmap-component")))
})
// -------------------------------------------------------------------------
// Scenario 2: param provided via workflow → expect success
// -------------------------------------------------------------------------
It("succeeds when the parameter is provided in the workflow", func() {
app := appWithWorkflow.DeepCopy()
app.Name = "app-with-param-wf"
app.Namespace = nsName
// inject missing parameter
app.Spec.Workflow.Steps[0].Inputs = append(app.Spec.Workflow.Steps[0].Inputs,
workflowv1alpha1.InputItem{
ParameterKey: "secondkey.value2.value3.value5",
From: "dummy",
})
Expect(k8sClient.Create(ctx, app)).To(Succeed())
})
// -------------------------------------------------------------------------
// Scenario 3: param provided via policy → expect success
// -------------------------------------------------------------------------
It("succeeds when the parameter is provided in a policy", func() {
app := appWithPolicy.DeepCopy()
app.Name = "app-with-param-policy"
app.Namespace = nsName
Expect(k8sClient.Create(ctx, app)).To(Succeed())
})
})
/* -------------------------------------------------------------------------- */
/* Helpers */
/* -------------------------------------------------------------------------- */
func newConfigMapComponent(namespace string) *v1beta1.ComponentDefinition {
return &v1beta1.ComponentDefinition{
TypeMeta: metav1.TypeMeta{
Kind: "ComponentDefinition",
APIVersion: "core.oam.dev/v1beta1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "configmap-component",
Namespace: namespace, // set it here
},
Spec: v1beta1.ComponentDefinitionSpec{
Schematic: &common.Schematic{
CUE: &common.CUE{Template: configMapOutputTemp},
},
},
}
}
var configMapOutputTemp = `
parameter: {
firstkey: string & !="" & !~".*-$"
secondkey: {
value1: string
value2: {
value3: {
value4: *"default-value-2" | string
value5: string
}
}
}
thirdkey?: string
}
output: {
apiVersion: "v1"
kind: "ConfigMap"
metadata: { name: context.name }
data: {
one: parameter.firstkey
two: parameter.secondkey.value2.value3.value5
three: parameter.secondkey.value1
four: parameter.thirdkey
}
}
`
var appWithWorkflow = v1beta1.Application{
Spec: v1beta1.ApplicationSpec{
Components: []common.ApplicationComponent{{
Name: "configmap-component",
Type: "configmap-component",
Properties: &runtime.RawExtension{Raw: []byte(`{
"secondkey": { "value2": { "value3": { "value4": "1" } } }
}`)},
}},
Workflow: &v1beta1.Workflow{
Steps: []workflowv1alpha1.WorkflowStep{{
WorkflowStepBase: workflowv1alpha1.WorkflowStepBase{
Name: "apply",
Type: "apply-component",
Inputs: workflowv1alpha1.StepInputs{
{ParameterKey: "firstkey", From: "dummy1"},
{ParameterKey: "secondkey.value1", From: "dummy2"},
{ParameterKey: "thirdkey", From: "dummy3"},
},
Properties: util.Object2RawExtension(map[string]any{"component": "express-server"}),
},
}},
},
},
}
var appWithPolicy = v1beta1.Application{
Spec: v1beta1.ApplicationSpec{
Components: []common.ApplicationComponent{{
Name: "app-policy",
Type: "configmap-component",
Properties: &runtime.RawExtension{Raw: []byte(`{
"secondkey": { "value2": { "value3": { "value4": "1" } } }
}`)},
}},
Policies: []v1beta1.AppPolicy{{
Name: "override-configmap-data",
Type: "override",
Properties: &runtime.RawExtension{Raw: mustJSON(policyProperties)},
}},
},
}
var policyProperties = map[string]any{
"components": []any{map[string]any{
"name": "express-server",
"properties": map[string]any{
"firstkey": "nginx:1.20",
"secondkey": map[string]any{
"value1": "abc",
"value2": map[string]any{
"value3": map[string]any{
"value5": "1",
},
},
},
"thirdkey": "123",
},
}},
}
func mustJSON(v any) []byte {
out, err := json.Marshal(v)
if err != nil {
panic(err)
}
return out
}