mirror of
https://github.com/kubevela/kubevela.git
synced 2026-08-18 20:17:04 +00:00
* feat(cue/upgrade): auto-remediate legacy CUE syntax at render time
Transparently rewrite CUE templates that use deprecated list arithmetic
(+, *) and conflicting field names (error) so that older definitions
continue to work with CUE ≥ v0.14 (KubeVela ≥ 1.11).
- CUEUpgradeFunc registry with ID, CUE/KubeVela version guards, precheck,
and upgrade function fields
- upgradeListConcatenation: rewrites list1+list2 → list.Concat([list1,list2])
and list*n → list.Repeat(list, n); adds "list" import as needed
- collectAddChain + extractListConcatArgs: flatten left-associative + chains
and existing list.Concat([...]) leaves into a single flat call, so both
fresh chains (a+b+c+d) and partially-upgraded chains produce one
list.Concat([a,b,c,d]) with no nesting across repeated passes
- upgradeErrorFieldLabel: rewrites unquoted `error` field labels to "error"
to avoid conflict with the CUE 0.14 built-in; precheck uses a tighter
\berror\s*: regex to avoid false positives on identifiers like errorMessage
- EnsureCueVersionCompatibility: single entry point used at render time;
LRU cache with TTL eviction, Prometheus metrics, feature flag
- ParseVersion: regex anchored to reject garbage suffixes (e.g. "1.11foo")
while accepting pre-release+build metadata (e.g. "v1.13.0-alpha.1+dev")
- template.go: call EnsureCueVersionCompatibility for every template area
(main, health, custom status, status detail) with correct DefinitionKind
derived from which definition pointer is non-nil
- validate.go: upgrade policy templates before compiling in
validateNoRequiredParameters
- `vela def upgrade FILE [-o OUTPUT]`: upgrades a single .cue file
- `vela def upgrade FILE --validate [--quiet]`: exit 1 if upgrade needed
- `vela def compat definitions` / `vela def compat applications`: scan
cluster definitions/apps for compat issues; output as table or YAML
- Cyclomatic complexity kept below threshold by extracting scanDefinitions,
scanDefRevisions, buildDefCompatReport, scanApplications, scanAppRevision
as standalone functions with options structs
- revisionNum() helper for numeric vN comparison (avoids lexicographic bugs)
- mergeImports() dedup helper shared by ToCUEString and formatCUEString
- ANSI escape sequences replaced with fatih/color for portability
- goconst: "yaml" → outputFormatYAML named constant throughout
- Component, trait, and policy definition validating handlers: removed
spurious obj.Name argument from fmt.Sprintf in warning messages
- FromCUEString: only prepend importString to the stored template when
imports are non-empty; empty importString ("\n") was causing a leading
newline that made yaml.v3 use |2 block scalar on every generated YAML
- gen_sdk testdata: removed unused imports (vela/op, encoding/base64) from
one_of.cue that were exposed by our importString+templateString change
- e2e test: fix flaky trait-order assertion using ContainElements instead
of index-based equality
Upgraded all built-in .cue files that used deprecated list arithmetic:
- vela-templates/definitions/internal/component/cron-task.cue
- vela-templates/definitions/internal/trait/command.cue
- vela-templates/definitions/internal/trait/container-ports.cue
- vela-templates/definitions/internal/trait/env.cue
- vela-templates/definitions/internal/trait/init-container.cue
Removed unused stdlib imports that caused `def gen-api` to fail:
- vela-templates/definitions/internal/workflowstep/apply-deployment.cue
- vela-templates/definitions/internal/workflowstep/apply-terraform-provider.cue
- vela-templates/definitions/internal/workflowstep/build-push-image.cue
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Brian Kane <briankane1@gmail.com>
* fix(cue/upgrade): address PR review comments
- sync.atomic.Pointer for compatCache to fix data race on reinit
- SummaryVec → HistogramVec for both duration metrics (aggregatable
across HA replicas); buckets tuned to sub-millisecond upgrade path
and millisecond render path respectively
- errorFieldLabelRe: extend to match optional (?) and required (!)
field constraint markers before the colon
- cue-compatibility-cache-size: clamp negative values to 0 (disabled)
with warning log; document 0=disabled in flag help; cache put is
no-op when capacity <= 0
- webhook: replace RequiresUpgrade+EnsureCueVersionCompatibility double
parse with single EnsureCueVersionCompatibility call; use string
comparison to detect upgrade and emit warning
- def compat: log warning when ApplicationRevision fetch fails instead
of silently skipping (partial results are preserved)
- e2e: only delete definitions in DeferCleanup if this test created
them (avoid deleting pre-existing shared resources)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Brian Kane <briankane1@gmail.com>
* fix(cue/upgrade): address further PR review comments
- EnsureCueVersionCompatibility: return (string, bool) where bool
indicates semantic upgrades were applied (len(applied)>0), not
string inequality — prevents false-positive warnings from
formatting-only normalisation; update all call sites
- webhook handlers (component, trait, policy): switch from
RequiresUpgrade+EnsureCueVersionCompatibility double-call to single
EnsureCueVersionCompatibility call using wasUpgraded bool; remove
now-unused strings imports
- cache: skip eviction goroutine when capacity==0 (disabled); set
compatCacheCancel=nil on disabled path to avoid stale cancel on
next InitCompatibilityCache call
- e2e: replace boolean ownership tracking with createAndTrack helper
that checks pre-existence via Get before Create, eliminating both
the ambiguous-create leak and the boilerplate booleans
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Brian Kane <briankane1@gmail.com>
* fix: address reviewer comments — cache determinism, e2e ownership race
- cache: store normalised string in compatEntry.upgraded even when no
semantic fixes were applied, so cache-hit and cache-miss paths return
identical output (fixes non-deterministic behaviour flagged in review)
- upgrade: return entry.upgraded on the requiresUpgrade=false cache-hit
path instead of the raw input cueStr
- e2e: replace GET-then-CREATE ownership inference with atomic CREATE-
first pattern; err==nil means we created it (register DeferCleanup),
IsAlreadyExists means it pre-existed (skip cleanup), eliminating the
GET/CREATE race window that could misattribute ownership
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Brian Kane <briankane1@gmail.com>
---------
Signed-off-by: Brian Kane <briankane1@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1569 lines
40 KiB
Go
1569 lines
40 KiB
Go
/*
|
|
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 appfile
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
|
|
"cuelang.org/go/cue"
|
|
wfTypesv1alpha1 "github.com/kubevela/pkg/apis/oam/v1alpha1"
|
|
"github.com/stretchr/testify/assert"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
utilfeature "k8s.io/apiserver/pkg/util/feature"
|
|
|
|
"github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1"
|
|
"github.com/oam-dev/kubevela/apis/types"
|
|
"github.com/oam-dev/kubevela/pkg/cue/definition"
|
|
"github.com/oam-dev/kubevela/pkg/cue/upgrade"
|
|
"github.com/oam-dev/kubevela/pkg/features"
|
|
)
|
|
|
|
func TestTrait_EvalContext_OutputNameUniqueness(t *testing.T) {
|
|
type SubTestCase struct {
|
|
name string
|
|
compDefTmpl string
|
|
traitDefTmpl1 string
|
|
traitDefTmpl2 string
|
|
wantErrMsg string
|
|
}
|
|
|
|
testCases := []SubTestCase{
|
|
{
|
|
name: "Succeed",
|
|
compDefTmpl: `
|
|
output: {
|
|
apiVersion: "apps/v1"
|
|
kind: "Deployment"
|
|
}
|
|
outputs: mysvc: {
|
|
apiVersion: "v1"
|
|
kind: "Service"
|
|
}
|
|
`,
|
|
traitDefTmpl1: `
|
|
outputs: mysvc1: {
|
|
apiVersion: "v1"
|
|
kind: "Service"
|
|
}
|
|
`,
|
|
traitDefTmpl2: `
|
|
outputs: mysvc2: {
|
|
apiVersion: "v1"
|
|
kind: "Service"
|
|
}
|
|
`,
|
|
wantErrMsg: "",
|
|
},
|
|
{
|
|
name: "CompDef and TraitDef have same outputs",
|
|
compDefTmpl: `
|
|
output: {
|
|
apiVersion: "apps/v1"
|
|
kind: "Deployment"
|
|
}
|
|
outputs: mysvc1: {
|
|
apiVersion: "v1"
|
|
kind: "Service"
|
|
}
|
|
`,
|
|
traitDefTmpl1: `
|
|
outputs: mysvc1: {
|
|
apiVersion: "v1"
|
|
kind: "Service"
|
|
}
|
|
`,
|
|
traitDefTmpl2: `
|
|
outputs: mysvc2: {
|
|
apiVersion: "v1"
|
|
kind: "Service"
|
|
}
|
|
`,
|
|
wantErrMsg: `auxiliary "mysvc1" already exits`,
|
|
},
|
|
{
|
|
name: "TraitDefs have same outputs",
|
|
compDefTmpl: `
|
|
output: {
|
|
apiVersion: "apps/v1"
|
|
kind: "Deployment"
|
|
}
|
|
outputs: mysvc: {
|
|
apiVersion: "v1"
|
|
kind: "Service"
|
|
}
|
|
`,
|
|
traitDefTmpl1: `
|
|
outputs: mysvc1: {
|
|
apiVersion: "v1"
|
|
kind: "Service"
|
|
}
|
|
`,
|
|
traitDefTmpl2: `
|
|
outputs: mysvc1: {
|
|
apiVersion: "v1"
|
|
kind: "Service"
|
|
}
|
|
`,
|
|
wantErrMsg: `auxiliary "mysvc1" already exits`,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
wl := &Component{
|
|
Name: "myweb",
|
|
Type: "worker",
|
|
CapabilityCategory: types.CUECategory,
|
|
Traits: []*Trait{
|
|
{
|
|
Name: "myscaler",
|
|
CapabilityCategory: types.CUECategory,
|
|
Template: tc.traitDefTmpl1,
|
|
engine: definition.NewTraitAbstractEngine("myscaler"),
|
|
},
|
|
{
|
|
Name: "myingress",
|
|
CapabilityCategory: types.CUECategory,
|
|
Template: tc.traitDefTmpl2,
|
|
engine: definition.NewTraitAbstractEngine("myingress"),
|
|
},
|
|
},
|
|
FullTemplate: &Template{
|
|
TemplateStr: tc.compDefTmpl,
|
|
},
|
|
engine: definition.NewWorkloadAbstractEngine("myweb"),
|
|
}
|
|
|
|
ctxData := GenerateContextDataFromAppFile(&Appfile{
|
|
Name: "myapp",
|
|
Namespace: "test-ns",
|
|
AppRevisionName: "myapp-v1",
|
|
}, wl.Name)
|
|
pCtx, err := newValidationProcessContext(wl, ctxData)
|
|
assert.NoError(t, err)
|
|
|
|
var evalErr error
|
|
for _, tr := range wl.Traits {
|
|
if err := tr.EvalContext(pCtx); err != nil {
|
|
evalErr = err
|
|
break
|
|
}
|
|
}
|
|
|
|
if tc.wantErrMsg != "" {
|
|
assert.Error(t, evalErr)
|
|
assert.Contains(t, evalErr.Error(), tc.wantErrMsg)
|
|
} else {
|
|
assert.NoError(t, evalErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParser_ValidateComponentParams(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
compName string
|
|
template string
|
|
params map[string]interface{}
|
|
wantErr string
|
|
setup func(t *testing.T)
|
|
}{
|
|
{
|
|
name: "valid params and template",
|
|
compName: "valid",
|
|
template: `
|
|
parameter: {
|
|
replicas: int | *1
|
|
}
|
|
output: {
|
|
apiVersion: "apps/v1"
|
|
kind: "Deployment"
|
|
}
|
|
`,
|
|
params: map[string]interface{}{
|
|
"replicas": 2,
|
|
},
|
|
wantErr: "",
|
|
},
|
|
{
|
|
name: "invalid CUE in template",
|
|
compName: "invalid-cue",
|
|
template: `
|
|
parameter: {
|
|
replicas: int | *1
|
|
}
|
|
output: {
|
|
apiVersion: "apps/v1"
|
|
kind: "Deployment"
|
|
invalidField: {
|
|
}
|
|
`,
|
|
params: map[string]interface{}{
|
|
"replicas": 2,
|
|
},
|
|
wantErr: "CUE compile error",
|
|
},
|
|
{
|
|
name: "missing required parameter",
|
|
compName: "missing-required",
|
|
template: `
|
|
parameter: {
|
|
replicas: int
|
|
}
|
|
output: {
|
|
apiVersion: "apps/v1"
|
|
kind: "Deployment"
|
|
}
|
|
`,
|
|
params: map[string]interface{}{},
|
|
wantErr: "component \"missing-required\": missing parameters: replicas",
|
|
},
|
|
{
|
|
name: "parameter constraint violation",
|
|
compName: "constraint-violation",
|
|
template: `
|
|
parameter: {
|
|
replicas: int & >0
|
|
}
|
|
output: {
|
|
apiVersion: "apps/v1"
|
|
kind: "Deployment"
|
|
}
|
|
`,
|
|
params: map[string]interface{}{
|
|
"replicas": -1,
|
|
},
|
|
wantErr: "parameter constraint violation",
|
|
},
|
|
{
|
|
name: "invalid parameter block",
|
|
compName: "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",
|
|
},
|
|
{
|
|
name: "legacy list-arithmetic syntax is accepted when flag enabled",
|
|
compName: "legacy-list",
|
|
template: `
|
|
envWithDefaults: parameter.env + [{name: "MANAGED_BY", value: "kubevela"}]
|
|
output: {
|
|
apiVersion: "apps/v1"
|
|
kind: "Deployment"
|
|
spec: containers: [{env: envWithDefaults}]
|
|
}
|
|
parameter: {
|
|
env: *[] | [...{name: string, value?: string}]
|
|
}
|
|
`,
|
|
params: map[string]interface{}{},
|
|
wantErr: "",
|
|
setup: func(t *testing.T) {
|
|
prev := upgrade.EnableCUEVersionCompatibility
|
|
upgrade.EnableCUEVersionCompatibility = true
|
|
t.Cleanup(func() { upgrade.EnableCUEVersionCompatibility = prev })
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if tc.setup != nil {
|
|
tc.setup(t)
|
|
}
|
|
wl := &Component{
|
|
Name: tc.compName,
|
|
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 == "" {
|
|
assert.NoError(t, err)
|
|
} else {
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), tc.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidationHelpers(t *testing.T) {
|
|
t.Run("renderTemplate", func(t *testing.T) {
|
|
tmpl := "output: {}"
|
|
expected := "output: {}\ncontext: _\nparameter: _\n"
|
|
assert.Equal(t, expected, renderTemplate(tmpl))
|
|
})
|
|
|
|
t.Run("cueParamBlock", func(t *testing.T) {
|
|
t.Run("should handle empty params", func(t *testing.T) {
|
|
out, err := cueParamBlock(map[string]any{})
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "parameter: {}", out)
|
|
})
|
|
|
|
t.Run("should handle valid params", func(t *testing.T) {
|
|
params := map[string]any{"key": "value"}
|
|
out, err := cueParamBlock(params)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, `parameter: {"key":"value"}`, out)
|
|
})
|
|
|
|
t.Run("should return error for unmarshallable params", func(t *testing.T) {
|
|
params := map[string]any{"key": make(chan int)}
|
|
_, err := cueParamBlock(params)
|
|
assert.Error(t, err)
|
|
})
|
|
})
|
|
|
|
t.Run("filterMissing", func(t *testing.T) {
|
|
t.Run("should filter missing keys", func(t *testing.T) {
|
|
keys := []string{"a", "b.c", "d"}
|
|
provided := map[string]any{
|
|
"a": 1,
|
|
"b": map[string]any{
|
|
"c": 2,
|
|
},
|
|
}
|
|
out, err := filterMissing(keys, provided)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, []string{"d"}, out)
|
|
})
|
|
|
|
t.Run("should handle no missing keys", func(t *testing.T) {
|
|
keys := []string{"a"}
|
|
provided := map[string]any{"a": 1}
|
|
out, err := filterMissing(keys, provided)
|
|
assert.NoError(t, err)
|
|
assert.Empty(t, out)
|
|
})
|
|
})
|
|
|
|
t.Run("requiredFields", func(t *testing.T) {
|
|
t.Run("should identify required fields", func(t *testing.T) {
|
|
cueStr := `
|
|
parameter: {
|
|
name: string
|
|
age: int
|
|
nested: {
|
|
field1: string
|
|
field2: bool
|
|
}
|
|
}
|
|
`
|
|
var r cue.Runtime
|
|
inst, err := r.Compile("", cueStr)
|
|
assert.NoError(t, err)
|
|
val := inst.Value()
|
|
paramVal := val.LookupPath(cue.ParsePath("parameter"))
|
|
|
|
fields, err := requiredFields(paramVal)
|
|
assert.NoError(t, err)
|
|
assert.ElementsMatch(t, []string{"name", "age", "nested.field1", "nested.field2"}, fields)
|
|
})
|
|
|
|
t.Run("should ignore optional and default fields", func(t *testing.T) {
|
|
cueStr := `
|
|
parameter: {
|
|
name: string
|
|
age?: int
|
|
location: string | *"unknown"
|
|
nested: {
|
|
field1: string
|
|
field2?: bool
|
|
}
|
|
}
|
|
`
|
|
var r cue.Runtime
|
|
inst, err := r.Compile("", cueStr)
|
|
assert.NoError(t, err)
|
|
val := inst.Value()
|
|
paramVal := val.LookupPath(cue.ParsePath("parameter"))
|
|
|
|
fields, err := requiredFields(paramVal)
|
|
assert.NoError(t, err)
|
|
assert.ElementsMatch(t, []string{"name", "nested.field1"}, fields)
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestEnforceRequiredParams(t *testing.T) {
|
|
var r cue.Runtime
|
|
cueStr := `
|
|
parameter: {
|
|
image: string
|
|
replicas: int
|
|
port: int
|
|
data: {
|
|
key: string
|
|
value: string
|
|
}
|
|
}
|
|
`
|
|
inst, err := r.Compile("", cueStr)
|
|
assert.NoError(t, err)
|
|
root := inst.Value()
|
|
|
|
t.Run("should pass if all params are provided directly", func(t *testing.T) {
|
|
params := map[string]any{
|
|
"image": "nginx",
|
|
"replicas": 2,
|
|
"port": 80,
|
|
"data": map[string]any{
|
|
"key": "k",
|
|
"value": "v",
|
|
},
|
|
}
|
|
app := &Appfile{}
|
|
err := enforceRequiredParams(root, params, app)
|
|
assert.NoError(t, err)
|
|
})
|
|
|
|
t.Run("should fail if params are missing", func(t *testing.T) {
|
|
params := map[string]any{
|
|
"image": "nginx",
|
|
}
|
|
app := &Appfile{}
|
|
err := enforceRequiredParams(root, params, app)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "missing parameters: replicas,port,data.key,data.value")
|
|
})
|
|
}
|
|
|
|
func TestParser_ValidateCUESchematicAppfile(t *testing.T) {
|
|
assert.NoError(t, utilfeature.DefaultMutableFeatureGate.Set(string(features.EnableCueValidation)+"=true"))
|
|
t.Cleanup(func() {
|
|
assert.NoError(t, utilfeature.DefaultMutableFeatureGate.Set(string(features.EnableCueValidation)+"=false"))
|
|
})
|
|
|
|
t.Run("should validate a valid CUE schematic appfile", func(t *testing.T) {
|
|
appfile := &Appfile{
|
|
Name: "test-app",
|
|
Namespace: "test-ns",
|
|
ParsedComponents: []*Component{
|
|
{
|
|
Name: "my-comp",
|
|
Type: "worker",
|
|
CapabilityCategory: types.CUECategory,
|
|
Params: map[string]any{
|
|
"image": "nginx",
|
|
},
|
|
FullTemplate: &Template{
|
|
TemplateStr: `
|
|
parameter: {
|
|
image: string
|
|
}
|
|
output: {
|
|
apiVersion: "apps/v1"
|
|
kind: "Deployment"
|
|
spec: {
|
|
template: {
|
|
spec: {
|
|
containers: [{
|
|
name: "my-container"
|
|
image: parameter.image
|
|
}]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
},
|
|
engine: definition.NewWorkloadAbstractEngine("my-comp"),
|
|
Traits: []*Trait{
|
|
{
|
|
Name: "my-trait",
|
|
CapabilityCategory: types.CUECategory,
|
|
Template: `
|
|
parameter: {
|
|
domain: string
|
|
}
|
|
patch: {}
|
|
`,
|
|
Params: map[string]any{
|
|
"domain": "example.com",
|
|
},
|
|
engine: definition.NewTraitAbstractEngine("my-trait"),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
p := &Parser{}
|
|
err := p.ValidateCUESchematicAppfile(appfile)
|
|
assert.NoError(t, err)
|
|
})
|
|
|
|
t.Run("should return error for invalid trait evaluation", func(t *testing.T) {
|
|
appfile := &Appfile{
|
|
Name: "test-app",
|
|
Namespace: "test-ns",
|
|
ParsedComponents: []*Component{
|
|
{
|
|
Name: "my-comp",
|
|
Type: "worker",
|
|
CapabilityCategory: types.CUECategory,
|
|
Params: map[string]any{
|
|
"image": "nginx",
|
|
},
|
|
FullTemplate: &Template{
|
|
TemplateStr: `
|
|
parameter: {
|
|
image: string
|
|
}
|
|
output: {
|
|
apiVersion: "apps/v1"
|
|
kind: "Deployment"
|
|
}
|
|
`,
|
|
},
|
|
engine: definition.NewWorkloadAbstractEngine("my-comp"),
|
|
Traits: []*Trait{
|
|
{
|
|
Name: "my-trait",
|
|
CapabilityCategory: types.CUECategory,
|
|
Template: `
|
|
// invalid CUE template
|
|
parameter: {
|
|
domain: string
|
|
}
|
|
patch: {
|
|
invalid: {
|
|
}
|
|
`,
|
|
Params: map[string]any{
|
|
"domain": "example.com",
|
|
},
|
|
engine: definition.NewTraitAbstractEngine("my-trait"),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
p := &Parser{}
|
|
err := p.ValidateCUESchematicAppfile(appfile)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "cannot evaluate trait \"my-trait\"")
|
|
})
|
|
|
|
t.Run("should return error for missing parameters", func(t *testing.T) {
|
|
appfile := &Appfile{
|
|
Name: "test-app",
|
|
Namespace: "test-ns",
|
|
ParsedComponents: []*Component{
|
|
{
|
|
Name: "my-comp",
|
|
Type: "worker",
|
|
CapabilityCategory: types.CUECategory,
|
|
Params: map[string]any{}, // no params provided
|
|
FullTemplate: &Template{
|
|
TemplateStr: `
|
|
parameter: {
|
|
image: string
|
|
}
|
|
output: {
|
|
apiVersion: "apps/v1"
|
|
kind: "Deployment"
|
|
}
|
|
`,
|
|
},
|
|
engine: definition.NewWorkloadAbstractEngine("my-comp"),
|
|
},
|
|
},
|
|
}
|
|
|
|
p := &Parser{}
|
|
err := p.ValidateCUESchematicAppfile(appfile)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "missing parameters: image")
|
|
})
|
|
|
|
t.Run("should skip non-CUE components", func(t *testing.T) {
|
|
appfile := &Appfile{
|
|
Name: "test-app",
|
|
Namespace: "test-ns",
|
|
ParsedComponents: []*Component{
|
|
{
|
|
Name: "my-comp",
|
|
Type: "helm",
|
|
CapabilityCategory: types.TerraformCategory,
|
|
},
|
|
},
|
|
}
|
|
p := &Parser{}
|
|
err := p.ValidateCUESchematicAppfile(appfile)
|
|
assert.NoError(t, err)
|
|
})
|
|
}
|
|
|
|
func TestCheckUndeclaredParams(t *testing.T) {
|
|
var r cue.Runtime
|
|
|
|
t.Run("undeclared top-level parameter", func(t *testing.T) {
|
|
inst, err := r.Compile("", `parameter: { name: string }`)
|
|
assert.NoError(t, err)
|
|
schema := inst.Value().LookupPath(cue.ParsePath("parameter"))
|
|
err = checkUndeclaredParams(schema, map[string]any{
|
|
"name": "test",
|
|
"otherUndeclared": "bad",
|
|
})
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "undeclared parameters")
|
|
assert.Contains(t, err.Error(), "otherUndeclared")
|
|
})
|
|
|
|
t.Run("undeclared nested parameter", func(t *testing.T) {
|
|
inst, err := r.Compile("", `parameter: { data: { key: string } }`)
|
|
assert.NoError(t, err)
|
|
schema := inst.Value().LookupPath(cue.ParsePath("parameter"))
|
|
err = checkUndeclaredParams(schema, map[string]any{
|
|
"data": map[string]any{
|
|
"key": "ok",
|
|
"extra": "bad",
|
|
},
|
|
})
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "data.extra")
|
|
})
|
|
|
|
t.Run("all valid parameters", func(t *testing.T) {
|
|
inst, err := r.Compile("", `parameter: { name: string, count: int }`)
|
|
assert.NoError(t, err)
|
|
schema := inst.Value().LookupPath(cue.ParsePath("parameter"))
|
|
err = checkUndeclaredParams(schema, map[string]any{
|
|
"name": "test",
|
|
"count": 1,
|
|
})
|
|
assert.NoError(t, err)
|
|
})
|
|
|
|
t.Run("empty parameters", func(t *testing.T) {
|
|
inst, err := r.Compile("", `parameter: { name: string }`)
|
|
assert.NoError(t, err)
|
|
schema := inst.Value().LookupPath(cue.ParsePath("parameter"))
|
|
err = checkUndeclaredParams(schema, map[string]any{})
|
|
assert.NoError(t, err)
|
|
})
|
|
|
|
t.Run("optional parameters omitted", func(t *testing.T) {
|
|
inst, err := r.Compile("", `parameter: { name: string, label?: string }`)
|
|
assert.NoError(t, err)
|
|
schema := inst.Value().LookupPath(cue.ParsePath("parameter"))
|
|
err = checkUndeclaredParams(schema, map[string]any{
|
|
"name": "test",
|
|
})
|
|
assert.NoError(t, err)
|
|
})
|
|
|
|
t.Run("optional parameter provided is valid", func(t *testing.T) {
|
|
inst, err := r.Compile("", `parameter: { name: string, label?: string }`)
|
|
assert.NoError(t, err)
|
|
schema := inst.Value().LookupPath(cue.ParsePath("parameter"))
|
|
err = checkUndeclaredParams(schema, map[string]any{
|
|
"name": "test",
|
|
"label": "ok",
|
|
})
|
|
assert.NoError(t, err)
|
|
})
|
|
|
|
t.Run("pattern constraint at top level allows any keys", func(t *testing.T) {
|
|
inst, err := r.Compile("", `parameter: [string]: string`)
|
|
assert.NoError(t, err)
|
|
schema := inst.Value().LookupPath(cue.ParsePath("parameter"))
|
|
err = checkUndeclaredParams(schema, map[string]any{
|
|
"anyKey": "value1",
|
|
"anotherKey": "value2",
|
|
})
|
|
assert.NoError(t, err)
|
|
})
|
|
|
|
t.Run("nested pattern constraint allows any keys", func(t *testing.T) {
|
|
inst, err := r.Compile("", `parameter: { name: string, labels?: [string]: string }`)
|
|
assert.NoError(t, err)
|
|
schema := inst.Value().LookupPath(cue.ParsePath("parameter"))
|
|
err = checkUndeclaredParams(schema, map[string]any{
|
|
"name": "test",
|
|
"labels": map[string]any{
|
|
"app": "myapp",
|
|
"version": "v1",
|
|
},
|
|
})
|
|
assert.NoError(t, err)
|
|
})
|
|
|
|
t.Run("webservice-like schema with labels and annotations", func(t *testing.T) {
|
|
inst, err := r.Compile("", `parameter: {
|
|
image: string
|
|
labels?: [string]: string
|
|
annotations?: [string]: string
|
|
}`)
|
|
assert.NoError(t, err)
|
|
schema := inst.Value().LookupPath(cue.ParsePath("parameter"))
|
|
err = checkUndeclaredParams(schema, map[string]any{
|
|
"image": "nginx",
|
|
"labels": map[string]any{
|
|
"app": "myapp",
|
|
},
|
|
"annotations": map[string]any{
|
|
"note": "test",
|
|
},
|
|
})
|
|
assert.NoError(t, err)
|
|
})
|
|
|
|
t.Run("undeclared fields sorted in error message", func(t *testing.T) {
|
|
inst, err := r.Compile("", `parameter: { name: string }`)
|
|
assert.NoError(t, err)
|
|
schema := inst.Value().LookupPath(cue.ParsePath("parameter"))
|
|
err = checkUndeclaredParams(schema, map[string]any{
|
|
"name": "test",
|
|
"zebra": "z",
|
|
"alpha": "a",
|
|
})
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "alpha,zebra")
|
|
})
|
|
}
|
|
|
|
func TestValidateUndeclaredParams_FeatureGate(t *testing.T) {
|
|
prevCue := utilfeature.DefaultMutableFeatureGate.Enabled(features.EnableCueValidation)
|
|
prevUndeclared := utilfeature.DefaultMutableFeatureGate.Enabled(features.ValidateUndeclaredParameters)
|
|
t.Cleanup(func() {
|
|
assert.NoError(t, utilfeature.DefaultMutableFeatureGate.Set(
|
|
fmt.Sprintf("%s=%t,%s=%t",
|
|
string(features.EnableCueValidation), prevCue,
|
|
string(features.ValidateUndeclaredParameters), prevUndeclared)))
|
|
})
|
|
|
|
t.Run("gate disabled - undeclared params allowed", func(t *testing.T) {
|
|
assert.NoError(t, utilfeature.DefaultMutableFeatureGate.Set(
|
|
string(features.EnableCueValidation)+"=true,"+
|
|
string(features.ValidateUndeclaredParameters)+"=false"))
|
|
|
|
wl := &Component{
|
|
Name: "my-comp",
|
|
Type: "worker",
|
|
FullTemplate: &Template{TemplateStr: `
|
|
parameter: { name: string }
|
|
output: { apiVersion: "v1", kind: "ConfigMap" }
|
|
`},
|
|
Params: map[string]any{"name": "test", "extra": "bad"},
|
|
}
|
|
app := &Appfile{Name: "myapp", Namespace: "test-ns"}
|
|
ctxData := GenerateContextDataFromAppFile(app, wl.Name)
|
|
err := (&Parser{}).ValidateComponentParams(ctxData, wl, app)
|
|
assert.NoError(t, err, "Should allow undeclared params when gate is disabled")
|
|
})
|
|
|
|
t.Run("gate enabled - undeclared params rejected", func(t *testing.T) {
|
|
assert.NoError(t, utilfeature.DefaultMutableFeatureGate.Set(
|
|
string(features.EnableCueValidation)+"=true,"+
|
|
string(features.ValidateUndeclaredParameters)+"=true"))
|
|
|
|
wl := &Component{
|
|
Name: "my-comp",
|
|
Type: "worker",
|
|
FullTemplate: &Template{TemplateStr: `
|
|
parameter: { name: string }
|
|
output: { apiVersion: "v1", kind: "ConfigMap" }
|
|
`},
|
|
Params: map[string]any{"name": "test", "extra": "bad"},
|
|
}
|
|
app := &Appfile{Name: "myapp", Namespace: "test-ns"}
|
|
ctxData := GenerateContextDataFromAppFile(app, wl.Name)
|
|
err := (&Parser{}).ValidateComponentParams(ctxData, wl, app)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "undeclared parameters")
|
|
assert.Contains(t, err.Error(), "extra")
|
|
})
|
|
}
|
|
|
|
// TestValidateCUESchematicAppfile_WorkflowSuppliedParams tests validation with workflow-supplied parameters (issue #7022)
|
|
func TestValidateCUESchematicAppfile_WorkflowSuppliedParams(t *testing.T) {
|
|
prevCue := utilfeature.DefaultMutableFeatureGate.Enabled(features.EnableCueValidation)
|
|
assert.NoError(t, utilfeature.DefaultMutableFeatureGate.Set(string(features.EnableCueValidation)+"=true"))
|
|
t.Cleanup(func() {
|
|
assert.NoError(t, utilfeature.DefaultMutableFeatureGate.Set(
|
|
fmt.Sprintf("%s=%t", string(features.EnableCueValidation), prevCue)))
|
|
})
|
|
|
|
componentTemplate := `
|
|
parameter: {
|
|
image: string
|
|
port: int | *80
|
|
}
|
|
output: {
|
|
apiVersion: "apps/v1"
|
|
kind: "Deployment"
|
|
spec: {
|
|
template: {
|
|
spec: {
|
|
containers: [{
|
|
name: "main"
|
|
image: parameter.image
|
|
ports: [{
|
|
containerPort: parameter.port
|
|
}]
|
|
}]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
traitTemplate := `
|
|
parameter: {
|
|
key: string
|
|
value: string
|
|
}
|
|
patch: {
|
|
metadata: {
|
|
labels: {
|
|
(parameter.key): parameter.value
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
t.Run("workflow supplies param - NO traits - should PASS", func(t *testing.T) {
|
|
appfile := &Appfile{
|
|
Name: "test-app",
|
|
Namespace: "test-ns",
|
|
ParsedComponents: []*Component{
|
|
{
|
|
Name: "my-webservice",
|
|
Type: "webservice",
|
|
CapabilityCategory: types.CUECategory,
|
|
Params: map[string]any{
|
|
"port": 80,
|
|
},
|
|
FullTemplate: &Template{
|
|
TemplateStr: componentTemplate,
|
|
},
|
|
engine: definition.NewWorkloadAbstractEngine("my-webservice"),
|
|
},
|
|
},
|
|
WorkflowSteps: []wfTypesv1alpha1.WorkflowStep{
|
|
{
|
|
WorkflowStepBase: wfTypesv1alpha1.WorkflowStepBase{
|
|
Name: "apply-microservice",
|
|
Type: "apply-component",
|
|
Inputs: wfTypesv1alpha1.StepInputs{
|
|
{
|
|
From: "dynamicValue",
|
|
ParameterKey: "image",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
p := &Parser{}
|
|
err := p.ValidateCUESchematicAppfile(appfile)
|
|
assert.NoError(t, err, "Should pass when workflow supplies missing param and NO traits present")
|
|
})
|
|
|
|
t.Run("workflow supplies param - WITH traits - should PASS", func(t *testing.T) {
|
|
appfile := &Appfile{
|
|
Name: "test-app",
|
|
Namespace: "test-ns",
|
|
ParsedComponents: []*Component{
|
|
{
|
|
Name: "my-webservice",
|
|
Type: "webservice",
|
|
CapabilityCategory: types.CUECategory,
|
|
Params: map[string]any{
|
|
"port": 80,
|
|
},
|
|
FullTemplate: &Template{
|
|
TemplateStr: componentTemplate,
|
|
},
|
|
engine: definition.NewWorkloadAbstractEngine("my-webservice"),
|
|
Traits: []*Trait{
|
|
{
|
|
Name: "labels",
|
|
CapabilityCategory: types.CUECategory,
|
|
Template: traitTemplate,
|
|
Params: map[string]any{
|
|
"key": "release",
|
|
"value": "stable",
|
|
},
|
|
engine: definition.NewTraitAbstractEngine("labels"),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
WorkflowSteps: []wfTypesv1alpha1.WorkflowStep{
|
|
{
|
|
WorkflowStepBase: wfTypesv1alpha1.WorkflowStepBase{
|
|
Name: "apply-microservice",
|
|
Type: "apply-component",
|
|
Inputs: wfTypesv1alpha1.StepInputs{
|
|
{
|
|
From: "dynamicValue",
|
|
ParameterKey: "image",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
p := &Parser{}
|
|
err := p.ValidateCUESchematicAppfile(appfile)
|
|
assert.NoError(t, err, "Should pass when workflow supplies missing param even WITH traits")
|
|
})
|
|
|
|
t.Run("workflow supplies param with ENUM - should use first enum value", func(t *testing.T) {
|
|
enumComponentTemplate := `
|
|
parameter: {
|
|
image: "nginx:latest" | "apache:latest" | "httpd:latest"
|
|
port: int | *80
|
|
}
|
|
output: {
|
|
apiVersion: "apps/v1"
|
|
kind: "Deployment"
|
|
spec: {
|
|
template: {
|
|
spec: {
|
|
containers: [{
|
|
name: "main"
|
|
image: parameter.image
|
|
ports: [{
|
|
containerPort: parameter.port
|
|
}]
|
|
}]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
appfile := &Appfile{
|
|
Name: "test-app",
|
|
Namespace: "test-ns",
|
|
ParsedComponents: []*Component{
|
|
{
|
|
Name: "my-webservice",
|
|
Type: "webservice",
|
|
CapabilityCategory: types.CUECategory,
|
|
Params: map[string]any{
|
|
"port": 80,
|
|
},
|
|
FullTemplate: &Template{
|
|
TemplateStr: enumComponentTemplate,
|
|
},
|
|
engine: definition.NewWorkloadAbstractEngine("my-webservice"),
|
|
Traits: []*Trait{
|
|
{
|
|
Name: "labels",
|
|
CapabilityCategory: types.CUECategory,
|
|
Template: traitTemplate,
|
|
Params: map[string]any{
|
|
"key": "release",
|
|
"value": "stable",
|
|
},
|
|
engine: definition.NewTraitAbstractEngine("labels"),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
WorkflowSteps: []wfTypesv1alpha1.WorkflowStep{
|
|
{
|
|
WorkflowStepBase: wfTypesv1alpha1.WorkflowStepBase{
|
|
Name: "apply-microservice",
|
|
Type: "apply-component",
|
|
Inputs: wfTypesv1alpha1.StepInputs{
|
|
{
|
|
From: "dynamicValue",
|
|
ParameterKey: "image",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
p := &Parser{}
|
|
err := p.ValidateCUESchematicAppfile(appfile)
|
|
assert.NoError(t, err, "Should use first enum value as default")
|
|
})
|
|
|
|
t.Run("param missing everywhere - should FAIL", func(t *testing.T) {
|
|
appfile := &Appfile{
|
|
Name: "test-app",
|
|
Namespace: "test-ns",
|
|
ParsedComponents: []*Component{
|
|
{
|
|
Name: "my-webservice",
|
|
Type: "webservice",
|
|
CapabilityCategory: types.CUECategory,
|
|
Params: map[string]any{
|
|
"port": 80,
|
|
},
|
|
FullTemplate: &Template{
|
|
TemplateStr: componentTemplate,
|
|
},
|
|
engine: definition.NewWorkloadAbstractEngine("my-webservice"),
|
|
Traits: []*Trait{
|
|
{
|
|
Name: "labels",
|
|
CapabilityCategory: types.CUECategory,
|
|
Template: traitTemplate,
|
|
Params: map[string]any{
|
|
"key": "release",
|
|
"value": "stable",
|
|
},
|
|
engine: definition.NewTraitAbstractEngine("labels"),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
p := &Parser{}
|
|
err := p.ValidateCUESchematicAppfile(appfile)
|
|
assert.Error(t, err, "Should fail when param is missing everywhere")
|
|
assert.Contains(t, err.Error(), "missing parameters: image")
|
|
})
|
|
|
|
t.Run("override policy supplies param - WITH traits - should PASS", func(t *testing.T) {
|
|
policyJSON := `{
|
|
"components": [{
|
|
"properties": {
|
|
"image": "nginx:1.20"
|
|
}
|
|
}]
|
|
}`
|
|
|
|
appfile := &Appfile{
|
|
Name: "test-app",
|
|
Namespace: "test-ns",
|
|
ParsedComponents: []*Component{
|
|
{
|
|
Name: "my-webservice",
|
|
Type: "webservice",
|
|
CapabilityCategory: types.CUECategory,
|
|
Params: map[string]any{
|
|
"port": 80,
|
|
},
|
|
FullTemplate: &Template{
|
|
TemplateStr: componentTemplate,
|
|
},
|
|
engine: definition.NewWorkloadAbstractEngine("my-webservice"),
|
|
Traits: []*Trait{
|
|
{
|
|
Name: "labels",
|
|
CapabilityCategory: types.CUECategory,
|
|
Template: traitTemplate,
|
|
Params: map[string]any{
|
|
"key": "release",
|
|
"value": "stable",
|
|
},
|
|
engine: definition.NewTraitAbstractEngine("labels"),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Policies: []v1beta1.AppPolicy{
|
|
{
|
|
Name: "override-policy",
|
|
Type: "override",
|
|
Properties: &runtime.RawExtension{
|
|
Raw: []byte(policyJSON),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
p := &Parser{}
|
|
err := p.ValidateCUESchematicAppfile(appfile)
|
|
assert.NoError(t, err, "Should pass when override policy supplies missing param")
|
|
})
|
|
|
|
t.Run("workflow supplies different param types - should use correct defaults", func(t *testing.T) {
|
|
multiTypeTemplate := `
|
|
parameter: {
|
|
count: int
|
|
enabled: bool
|
|
tags: [...string]
|
|
port: int | *80
|
|
}
|
|
output: {
|
|
apiVersion: "v1"
|
|
kind: "ConfigMap"
|
|
data: {
|
|
count: "\(parameter.count)"
|
|
enabled: "\(parameter.enabled)"
|
|
port: "\(parameter.port)"
|
|
}
|
|
metadata: {
|
|
labels: {
|
|
for i, tag in parameter.tags {
|
|
"tag-\(i)": tag
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
appfile := &Appfile{
|
|
Name: "test-app",
|
|
Namespace: "test-ns",
|
|
ParsedComponents: []*Component{
|
|
{
|
|
Name: "my-config",
|
|
Type: "raw",
|
|
CapabilityCategory: types.CUECategory,
|
|
Params: map[string]any{
|
|
"port": 80,
|
|
},
|
|
FullTemplate: &Template{
|
|
TemplateStr: multiTypeTemplate,
|
|
},
|
|
engine: definition.NewWorkloadAbstractEngine("my-config"),
|
|
Traits: []*Trait{
|
|
{
|
|
Name: "labels",
|
|
CapabilityCategory: types.CUECategory,
|
|
Template: traitTemplate,
|
|
Params: map[string]any{
|
|
"key": "env",
|
|
"value": "test",
|
|
},
|
|
engine: definition.NewTraitAbstractEngine("labels"),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
WorkflowSteps: []wfTypesv1alpha1.WorkflowStep{
|
|
{
|
|
WorkflowStepBase: wfTypesv1alpha1.WorkflowStepBase{
|
|
Name: "apply-config",
|
|
Type: "apply-component",
|
|
Inputs: wfTypesv1alpha1.StepInputs{
|
|
{From: "dynamicCount", ParameterKey: "count"},
|
|
{From: "dynamicEnabled", ParameterKey: "enabled"},
|
|
{From: "dynamicTags", ParameterKey: "tags"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
p := &Parser{}
|
|
err := p.ValidateCUESchematicAppfile(appfile)
|
|
assert.NoError(t, err, "Should handle int, bool, list types with correct defaults")
|
|
})
|
|
|
|
t.Run("workflow supplies param with numeric bounds - should skip validation", func(t *testing.T) {
|
|
// Component with complex validation that can't be easily defaulted
|
|
complexTemplate := `
|
|
parameter: {
|
|
port: int & >1024 & <65535
|
|
image: string
|
|
}
|
|
output: {
|
|
apiVersion: "v1"
|
|
kind: "Service"
|
|
spec: {
|
|
ports: [{
|
|
port: parameter.port
|
|
}]
|
|
}
|
|
}
|
|
`
|
|
|
|
appfile := &Appfile{
|
|
Name: "test-app",
|
|
Namespace: "test-ns",
|
|
ParsedComponents: []*Component{
|
|
{
|
|
Name: "my-service",
|
|
Type: "service",
|
|
CapabilityCategory: types.CUECategory,
|
|
Params: map[string]any{
|
|
"image": "nginx:latest",
|
|
},
|
|
FullTemplate: &Template{
|
|
TemplateStr: complexTemplate,
|
|
},
|
|
engine: definition.NewWorkloadAbstractEngine("my-service"),
|
|
Traits: []*Trait{
|
|
{
|
|
Name: "labels",
|
|
CapabilityCategory: types.CUECategory,
|
|
Template: traitTemplate,
|
|
Params: map[string]any{
|
|
"key": "version",
|
|
"value": "v1",
|
|
},
|
|
engine: definition.NewTraitAbstractEngine("labels"),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
WorkflowSteps: []wfTypesv1alpha1.WorkflowStep{
|
|
{
|
|
WorkflowStepBase: wfTypesv1alpha1.WorkflowStepBase{
|
|
Name: "apply-service",
|
|
Type: "apply-component",
|
|
Inputs: wfTypesv1alpha1.StepInputs{
|
|
{From: "dynamicPort", ParameterKey: "port"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
p := &Parser{}
|
|
err := p.ValidateCUESchematicAppfile(appfile)
|
|
// Should pass by skipping validation due to complex constraints
|
|
assert.NoError(t, err, "Should skip validation when complex constraints cannot be satisfied")
|
|
})
|
|
|
|
t.Run("workflow param already provided in component - should not augment", func(t *testing.T) {
|
|
appfile := &Appfile{
|
|
Name: "test-app",
|
|
Namespace: "test-ns",
|
|
ParsedComponents: []*Component{
|
|
{
|
|
Name: "my-webservice",
|
|
Type: "webservice",
|
|
CapabilityCategory: types.CUECategory,
|
|
Params: map[string]any{
|
|
"image": "custom-image:v1.0",
|
|
"port": 8080,
|
|
},
|
|
FullTemplate: &Template{
|
|
TemplateStr: componentTemplate,
|
|
},
|
|
engine: definition.NewWorkloadAbstractEngine("my-webservice"),
|
|
Traits: []*Trait{
|
|
{
|
|
Name: "labels",
|
|
CapabilityCategory: types.CUECategory,
|
|
Template: traitTemplate,
|
|
Params: map[string]any{
|
|
"key": "app",
|
|
"value": "myapp",
|
|
},
|
|
engine: definition.NewTraitAbstractEngine("labels"),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
WorkflowSteps: []wfTypesv1alpha1.WorkflowStep{
|
|
{
|
|
WorkflowStepBase: wfTypesv1alpha1.WorkflowStepBase{
|
|
Name: "apply-webservice",
|
|
Type: "apply-component",
|
|
Inputs: wfTypesv1alpha1.StepInputs{
|
|
{From: "dynamicImage", ParameterKey: "image"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
p := &Parser{}
|
|
err := p.ValidateCUESchematicAppfile(appfile)
|
|
assert.NoError(t, err, "Should use existing param value, not augment from workflow")
|
|
})
|
|
}
|
|
|
|
func TestGetTypeDefault(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
kind cue.Kind
|
|
expected interface{}
|
|
}{
|
|
{"string", cue.StringKind, "__workflow_supplied__"},
|
|
{"float", cue.FloatKind, 0.0},
|
|
{"int", cue.IntKind, 0},
|
|
{"number", cue.NumberKind, 0},
|
|
{"bool", cue.BoolKind, false},
|
|
{"list", cue.ListKind, []interface{}{}},
|
|
{"struct", cue.StructKind, map[string]interface{}{}},
|
|
{"bottom (unknown)", cue.BottomKind, "__workflow_supplied__"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result := getTypeDefault(tc.kind)
|
|
assert.Equal(t, tc.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetDefaultForMissingParameter(t *testing.T) {
|
|
var r cue.Runtime
|
|
|
|
t.Run("simple string type should return string default", func(t *testing.T) {
|
|
inst, err := r.Compile("", `parameter: { name: string }`)
|
|
assert.NoError(t, err)
|
|
val := inst.Value().LookupPath(cue.ParsePath("parameter.name"))
|
|
canDefault, defaultVal := getDefaultForMissingParameter(val)
|
|
assert.True(t, canDefault)
|
|
assert.Equal(t, "__workflow_supplied__", defaultVal)
|
|
})
|
|
|
|
t.Run("simple int type should return 0", func(t *testing.T) {
|
|
inst, err := r.Compile("", `parameter: { count: int }`)
|
|
assert.NoError(t, err)
|
|
val := inst.Value().LookupPath(cue.ParsePath("parameter.count"))
|
|
canDefault, defaultVal := getDefaultForMissingParameter(val)
|
|
assert.True(t, canDefault)
|
|
assert.Equal(t, 0, defaultVal)
|
|
})
|
|
|
|
t.Run("simple bool type should return false", func(t *testing.T) {
|
|
inst, err := r.Compile("", `parameter: { enabled: bool }`)
|
|
assert.NoError(t, err)
|
|
val := inst.Value().LookupPath(cue.ParsePath("parameter.enabled"))
|
|
canDefault, defaultVal := getDefaultForMissingParameter(val)
|
|
assert.True(t, canDefault)
|
|
assert.Equal(t, false, defaultVal)
|
|
})
|
|
|
|
t.Run("field with default should use default", func(t *testing.T) {
|
|
inst, err := r.Compile("", `parameter: { port: int | *80 }`)
|
|
assert.NoError(t, err)
|
|
val := inst.Value().LookupPath(cue.ParsePath("parameter.port"))
|
|
canDefault, _ := getDefaultForMissingParameter(val)
|
|
assert.True(t, canDefault)
|
|
})
|
|
|
|
t.Run("concrete value should return true with nil default", func(t *testing.T) {
|
|
inst, err := r.Compile("", `parameter: { port: 80 }`)
|
|
assert.NoError(t, err)
|
|
val := inst.Value().LookupPath(cue.ParsePath("parameter.port"))
|
|
canDefault, defaultVal := getDefaultForMissingParameter(val)
|
|
assert.True(t, canDefault)
|
|
assert.Nil(t, defaultVal)
|
|
})
|
|
|
|
t.Run("enum disjunction should return first value", func(t *testing.T) {
|
|
inst, err := r.Compile("", `parameter: { image: "nginx:latest" | "apache:latest" }`)
|
|
assert.NoError(t, err)
|
|
val := inst.Value().LookupPath(cue.ParsePath("parameter.image"))
|
|
canDefault, defaultVal := getDefaultForMissingParameter(val)
|
|
assert.True(t, canDefault)
|
|
assert.Equal(t, "nginx:latest", defaultVal)
|
|
})
|
|
|
|
t.Run("list type should be defaultable", func(t *testing.T) {
|
|
inst, err := r.Compile("", `parameter: { tags: [...string] }`)
|
|
assert.NoError(t, err)
|
|
val := inst.Value().LookupPath(cue.ParsePath("parameter.tags"))
|
|
canDefault, _ := getDefaultForMissingParameter(val)
|
|
assert.True(t, canDefault)
|
|
})
|
|
}
|
|
|
|
func TestGetMapKeys(t *testing.T) {
|
|
t.Run("should return all keys", func(t *testing.T) {
|
|
m := map[string]interface{}{
|
|
"a": 1,
|
|
"b": 2,
|
|
"c": 3,
|
|
}
|
|
keys := getMapKeys(m)
|
|
assert.Len(t, keys, 3)
|
|
assert.ElementsMatch(t, []string{"a", "b", "c"}, keys)
|
|
})
|
|
|
|
t.Run("should return empty slice for empty map", func(t *testing.T) {
|
|
keys := getMapKeys(map[string]interface{}{})
|
|
assert.Empty(t, keys)
|
|
})
|
|
}
|
|
|
|
func TestGetWorkflowAndPolicySuppliedParams(t *testing.T) {
|
|
t.Run("should collect from workflow step inputs", func(t *testing.T) {
|
|
app := &Appfile{
|
|
WorkflowSteps: []wfTypesv1alpha1.WorkflowStep{
|
|
{
|
|
WorkflowStepBase: wfTypesv1alpha1.WorkflowStepBase{
|
|
Name: "step1",
|
|
Type: "apply-component",
|
|
Inputs: wfTypesv1alpha1.StepInputs{
|
|
{From: "dynamic", ParameterKey: "image"},
|
|
{From: "dynamic", ParameterKey: "port"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
result := getWorkflowAndPolicySuppliedParams(app)
|
|
assert.True(t, result["image"])
|
|
assert.True(t, result["port"])
|
|
assert.False(t, result["other"])
|
|
})
|
|
|
|
t.Run("should collect from override policies", func(t *testing.T) {
|
|
policyJSON := `{"components": [{"properties": {"image": "nginx:1.20", "replicas": 3}}]}`
|
|
app := &Appfile{
|
|
Policies: []v1beta1.AppPolicy{
|
|
{
|
|
Name: "override",
|
|
Type: "override",
|
|
Properties: &runtime.RawExtension{
|
|
Raw: []byte(policyJSON),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
result := getWorkflowAndPolicySuppliedParams(app)
|
|
assert.True(t, result["image"])
|
|
assert.True(t, result["replicas"])
|
|
})
|
|
|
|
t.Run("should skip non-override policies", func(t *testing.T) {
|
|
app := &Appfile{
|
|
Policies: []v1beta1.AppPolicy{
|
|
{
|
|
Name: "topology",
|
|
Type: "topology",
|
|
Properties: &runtime.RawExtension{
|
|
Raw: []byte(`{"clusters": ["local"]}`),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
result := getWorkflowAndPolicySuppliedParams(app)
|
|
assert.Empty(t, result)
|
|
})
|
|
|
|
t.Run("should return empty for empty appfile", func(t *testing.T) {
|
|
result := getWorkflowAndPolicySuppliedParams(&Appfile{})
|
|
assert.Empty(t, result)
|
|
})
|
|
|
|
t.Run("should skip malformed policy JSON gracefully", func(t *testing.T) {
|
|
app := &Appfile{
|
|
Policies: []v1beta1.AppPolicy{
|
|
{
|
|
Name: "bad-override",
|
|
Type: "override",
|
|
Properties: &runtime.RawExtension{
|
|
Raw: []byte(`{invalid json`),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
result := getWorkflowAndPolicySuppliedParams(app)
|
|
assert.Empty(t, result)
|
|
})
|
|
}
|
|
|
|
func TestGetDeclaredFieldNames(t *testing.T) {
|
|
var r cue.Runtime
|
|
|
|
t.Run("should return all declared field names including optional", func(t *testing.T) {
|
|
inst, err := r.Compile("", `parameter: { name: string, label?: string, count: int }`)
|
|
assert.NoError(t, err)
|
|
schema := inst.Value().LookupPath(cue.ParsePath("parameter"))
|
|
declared := getDeclaredFieldNames(schema)
|
|
assert.True(t, declared["name"])
|
|
assert.True(t, declared["label"])
|
|
assert.True(t, declared["count"])
|
|
assert.False(t, declared["nonexistent"])
|
|
})
|
|
|
|
t.Run("should return empty map for non-struct", func(t *testing.T) {
|
|
inst, err := r.Compile("", `parameter: string`)
|
|
assert.NoError(t, err)
|
|
schema := inst.Value().LookupPath(cue.ParsePath("parameter"))
|
|
declared := getDeclaredFieldNames(schema)
|
|
assert.Empty(t, declared)
|
|
})
|
|
}
|
|
|
|
func TestValidateCUESchematicAppfile_LegacySyntax(t *testing.T) {
|
|
assert.NoError(t, utilfeature.DefaultMutableFeatureGate.Set(string(features.EnableCueValidation)+"=true"))
|
|
t.Cleanup(func() {
|
|
assert.NoError(t, utilfeature.DefaultMutableFeatureGate.Set(string(features.EnableCueValidation)+"=false"))
|
|
})
|
|
|
|
legacyTemplate := `
|
|
envWithDefaults: parameter.env + [{name: "MANAGED_BY", value: "kubevela"}]
|
|
output: {
|
|
apiVersion: "apps/v1"
|
|
kind: "Deployment"
|
|
spec: containers: [{env: envWithDefaults}]
|
|
}
|
|
parameter: {
|
|
env: *[] | [...{name: string, value?: string}]
|
|
}
|
|
`
|
|
|
|
newAppfile := func(template string) *Appfile {
|
|
return &Appfile{
|
|
Name: "test-app",
|
|
Namespace: "test-ns",
|
|
ParsedComponents: []*Component{{
|
|
Name: "my-comp",
|
|
Type: "worker",
|
|
CapabilityCategory: types.CUECategory,
|
|
Params: map[string]any{},
|
|
FullTemplate: &Template{TemplateStr: template},
|
|
engine: definition.NewWorkloadAbstractEngine("my-comp"),
|
|
}},
|
|
}
|
|
}
|
|
|
|
t.Run("legacy list-arithmetic is accepted when flag enabled", func(t *testing.T) {
|
|
original := upgrade.EnableCUEVersionCompatibility
|
|
upgrade.EnableCUEVersionCompatibility = true
|
|
t.Cleanup(func() { upgrade.EnableCUEVersionCompatibility = original })
|
|
|
|
err := (&Parser{}).ValidateCUESchematicAppfile(newAppfile(legacyTemplate))
|
|
assert.NoError(t, err)
|
|
})
|
|
|
|
t.Run("legacy list-arithmetic is rejected when flag disabled", func(t *testing.T) {
|
|
original := upgrade.EnableCUEVersionCompatibility
|
|
upgrade.EnableCUEVersionCompatibility = false
|
|
t.Cleanup(func() { upgrade.EnableCUEVersionCompatibility = original })
|
|
|
|
err := (&Parser{}).ValidateCUESchematicAppfile(newAppfile(legacyTemplate))
|
|
assert.Error(t, err)
|
|
})
|
|
}
|