diff --git a/go.mod b/go.mod index ce77426f8..46c301d1c 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 72f23621d..e29242077 100644 --- a/go.sum +++ b/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= diff --git a/makefiles/e2e.mk b/makefiles/e2e.mk index d5c14046f..f3b2957d9 100644 --- a/makefiles/e2e.mk +++ b/makefiles/e2e.mk @@ -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 diff --git a/pkg/appfile/validate.go b/pkg/appfile/validate.go index ca8ed35b7..84c0d2492 100644 --- a/pkg/appfile/validate.go +++ b/pkg/appfile/validate.go @@ -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 diff --git a/pkg/appfile/validate_test.go b/pkg/appfile/validate_test.go index 3ae6d2b63..4fe61bc7b 100644 --- a/pkg/appfile/validate_test.go +++ b/pkg/appfile/validate_test.go @@ -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", + }), + ) +}) diff --git a/pkg/cue/definition/template.go b/pkg/cue/definition/template.go index d50a75e15..1a9e6a850 100644 --- a/pkg/cue/definition/template.go +++ b/pkg/cue/definition/template.go @@ -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() { diff --git a/pkg/cue/definition/template_test.go b/pkg/cue/definition/template_test.go index 5117d8ead..9d13a949f 100644 --- a/pkg/cue/definition/template_test.go +++ b/pkg/cue/definition/template_test.go @@ -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]) - } - } -} diff --git a/test/e2e-test/requiredparam_validation_test.go b/test/e2e-test/requiredparam_validation_test.go new file mode 100644 index 000000000..b38a90488 --- /dev/null +++ b/test/e2e-test/requiredparam_validation_test.go @@ -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 +}