mirror of
https://github.com/kubevela/kubevela.git
synced 2026-02-14 10:00:06 +00:00
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:
1
go.mod
1
go.mod
@@ -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
2
go.sum
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 CUE‑level validation for a Component’s
|
||||
// parameters and emits helpful, context‑rich 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* non‑optional,
|
||||
// non‑defaulted parameters are provided—either in the Component.Params
|
||||
// block or as workflow‑step inputs.
|
||||
// 4. Run cue.Value.Validate to enforce user‑supplied 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 required‑field enforcement (feature‑gated)
|
||||
// ---------------------------------------------------------------------
|
||||
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
|
||||
// template’s `parameter:` stanza is satisfied either directly (Params) or
|
||||
// indirectly (workflow‑step 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 re‑uses the original slice’s 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 KubeVela’s 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
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
230
test/e2e-test/requiredparam_validation_test.go
Normal file
230
test/e2e-test/requiredparam_validation_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user