mirror of
https://github.com/kubevela/kubevela.git
synced 2026-08-23 22:46:53 +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>
395 lines
13 KiB
Go
395 lines
13 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 definition
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
ast2 "cuelang.org/go/cue/ast"
|
|
"cuelang.org/go/cue/parser"
|
|
|
|
"github.com/oam-dev/kubevela/pkg/definition/ast"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
|
"sigs.k8s.io/yaml"
|
|
|
|
common2 "github.com/oam-dev/kubevela/apis/core.oam.dev/common"
|
|
"github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1"
|
|
addonutils "github.com/oam-dev/kubevela/pkg/utils/addon"
|
|
"github.com/oam-dev/kubevela/pkg/utils/common"
|
|
"github.com/oam-dev/kubevela/pkg/utils/filters"
|
|
)
|
|
|
|
func TestDefinitionBasicFunctions(t *testing.T) {
|
|
c := fake.NewClientBuilder().WithScheme(common.Scheme).Build()
|
|
def := &Definition{Unstructured: unstructured.Unstructured{}}
|
|
def.SetAnnotations(map[string]string{
|
|
UserPrefix + "annotation": "annotation",
|
|
"other": "other",
|
|
})
|
|
def.SetLabels(map[string]string{
|
|
UserPrefix + "label": "label",
|
|
"other": "other",
|
|
})
|
|
def.SetName("test-trait")
|
|
def.SetGVK("TraitDefinition")
|
|
def.SetOwnerReferences([]v1.OwnerReference{{
|
|
Name: addonutils.Addon2AppName("test-addon"),
|
|
}})
|
|
if _type := def.GetType(); _type != "trait" {
|
|
t.Fatalf("set gvk invalid, expected trait got %s", _type)
|
|
}
|
|
if err := def.SetType("abc"); err == nil {
|
|
t.Fatalf("set type should failed due to invalid type, but got no error")
|
|
}
|
|
def.Object["spec"] = GetDefinitionDefaultSpec("TraitDefinition")
|
|
_ = unstructured.SetNestedField(def.Object, "patch: metadata: labels: \"KubeVela-test\": parameter.tag\nparameter: tag: string\n", "spec", "schematic", "cue", "template")
|
|
cueString, err := def.ToCUEString()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error when getting to cue: %v", err)
|
|
}
|
|
trait := &v1beta1.TraitDefinition{}
|
|
s, _ := json.Marshal(def.Object)
|
|
_ = json.Unmarshal(s, trait)
|
|
if err = c.Create(context.Background(), trait); err != nil {
|
|
t.Fatalf("unexpected error when creating new definition with fake client: %v", err)
|
|
}
|
|
if err = def.FromCUEString("abc:]{xa}", nil); err == nil {
|
|
t.Fatalf("should encounter invalid cue string but not found error")
|
|
}
|
|
if err = def.FromCUEString(cueString+"abc: {xa}", nil); err == nil {
|
|
t.Fatalf("should encounter invalid cue string but not found error")
|
|
}
|
|
parts := strings.Split(cueString, "template: ")
|
|
if err = def.FromCUEString(parts[0], nil); err == nil {
|
|
t.Fatalf("should encounter no template found error but not found error")
|
|
}
|
|
if err = def.FromCUEString("template:"+parts[1], nil); err == nil {
|
|
t.Fatalf("should encounter no metadata found error but not found error")
|
|
}
|
|
if err = def.FromCUEString("import \"strconv\"\n"+cueString, nil); err != nil {
|
|
t.Fatalf("should not encounter cue compile error due to useless import")
|
|
}
|
|
if err = def.FromCUEString("abc: {}\n"+cueString, nil); err == nil {
|
|
t.Fatalf("should encounter duplicated object name error but not found error")
|
|
}
|
|
if err = def.FromCUEString(strings.Replace(cueString, "\"trait\"", "\"tr\"", 1), nil); err == nil {
|
|
t.Fatalf("should encounter invalid type error but not found error")
|
|
}
|
|
if err = def.FromCUEString(cueString, nil); err != nil {
|
|
t.Fatalf("unexpected error when setting from cue: %v", err)
|
|
}
|
|
if _cueString, err := def.ToCUEString(); err != nil {
|
|
t.Fatalf("failed to generate cue string: %v", err)
|
|
} else if _cueString != cueString {
|
|
t.Fatalf("the bidirectional conversion of cue string is not idempotent")
|
|
}
|
|
templateString, _, _ := unstructured.NestedString(def.Object, DefinitionTemplateKeys...)
|
|
_ = unstructured.SetNestedField(def.Object, "import \"strconv\"\n"+templateString, DefinitionTemplateKeys...)
|
|
if s, err := def.ToCUEString(); err != nil {
|
|
t.Fatalf("failed to generate cue string: %v", err)
|
|
} else if !strings.Contains(s, "import \"strconv\"\n") {
|
|
t.Fatalf("definition ToCUEString missed import, val: %v", s)
|
|
}
|
|
def = &Definition{}
|
|
if err = def.FromCUEString(cueString, nil); err != nil {
|
|
t.Fatalf("unexpected error when setting from cue for empty def: %v", err)
|
|
}
|
|
|
|
// test other definition default spec
|
|
_ = GetDefinitionDefaultSpec("ComponentDefinition")
|
|
_ = GetDefinitionDefaultSpec("WorkloadDefinition")
|
|
_ = ValidDefinitionTypes()
|
|
|
|
if _, err = SearchDefinition(c, "", ""); err != nil {
|
|
t.Fatalf("failed to search definition: %v", err)
|
|
}
|
|
if _, err = SearchDefinition(c, "trait", "default"); err != nil {
|
|
t.Fatalf("failed to search definition: %v", err)
|
|
}
|
|
res, err := SearchDefinition(c, "", "", filters.ByOwnerAddon("test-addon"))
|
|
if err != nil {
|
|
t.Fatalf("failed to search definition: %v", err)
|
|
}
|
|
if len(res) < 1 {
|
|
t.Fatalf("failed to search definition with addon filter applied: %s", "no result returned")
|
|
}
|
|
res, err = SearchDefinition(c, "", "", filters.ByName("test-trait"), filters.ByOwnerAddon("test-addon"))
|
|
if err != nil {
|
|
t.Fatalf("failed to search definition: %v", err)
|
|
}
|
|
if len(res) < 1 {
|
|
t.Fatalf("failed to search definition with addon filter applied: %s", "no result returned")
|
|
}
|
|
res, err = SearchDefinition(c, "", "", filters.ByOwnerAddon("this-is-a-non-existent-addon"))
|
|
if err != nil {
|
|
t.Fatalf("failed to search definition: %v", err)
|
|
}
|
|
if len(res) >= 1 {
|
|
t.Fatalf("failed to search definition with addon filter applied: %s", "too many results returned")
|
|
}
|
|
}
|
|
|
|
func TestDefinitionRevisionSearch(t *testing.T) {
|
|
c := fake.NewClientBuilder().WithScheme(common.Scheme).Build()
|
|
|
|
var err error
|
|
|
|
// Load test DefinitionRevisions files into client
|
|
testFiles, err := os.ReadDir("testdata")
|
|
assert.NoError(t, err, "read testdata failed")
|
|
for _, file := range testFiles {
|
|
if !strings.HasSuffix(file.Name(), ".yaml") {
|
|
continue
|
|
}
|
|
content, err := os.ReadFile(filepath.Join("testdata", file.Name()))
|
|
assert.NoError(t, err)
|
|
def := &v1beta1.DefinitionRevision{}
|
|
err = yaml.Unmarshal(content, def)
|
|
assert.NoError(t, err)
|
|
err = c.Create(context.TODO(), def)
|
|
assert.NoError(t, err, "cannot create "+file.Name())
|
|
}
|
|
|
|
var defrevs []v1beta1.DefinitionRevision
|
|
|
|
// Read with no conditions, should at least have 4 defrevs
|
|
defrevs, err = SearchDefinitionRevisions(context.TODO(), c, "", "", "", 0)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, true, len(defrevs) >= 4)
|
|
|
|
// Restrict namespace
|
|
defrevs, err = SearchDefinitionRevisions(context.TODO(), c, "rev-test-custom-ns", "", "", 0)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, 1, len(defrevs))
|
|
|
|
// Restrict type
|
|
defrevs, err = SearchDefinitionRevisions(context.TODO(), c, "rev-test-ns", "", common2.ComponentType, 0)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, 2, len(defrevs))
|
|
|
|
// Restrict revision
|
|
defrevs, err = SearchDefinitionRevisions(context.TODO(), c, "rev-test-ns", "", "", 1)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, 2, len(defrevs))
|
|
|
|
// Restrict name
|
|
defrevs, err = SearchDefinitionRevisions(context.TODO(), c, "rev-test-ns", "webservice", "", 1)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, 1, len(defrevs))
|
|
|
|
// Test GetDefinitionFromDefinitionRevision
|
|
defrev := defrevs[0]
|
|
|
|
// Simulate ComponentDefinition
|
|
defrev.Spec.DefinitionType = common2.ComponentType
|
|
_, err = GetDefinitionFromDefinitionRevision(&defrev)
|
|
assert.NoError(t, err)
|
|
|
|
// Simulate TraitDefinition
|
|
defrev.Spec.DefinitionType = common2.TraitType
|
|
_, err = GetDefinitionFromDefinitionRevision(&defrev)
|
|
assert.NoError(t, err)
|
|
|
|
// Simulate PolicyDefinition
|
|
defrev.Spec.DefinitionType = common2.PolicyType
|
|
_, err = GetDefinitionFromDefinitionRevision(&defrev)
|
|
assert.NoError(t, err)
|
|
|
|
// Simulate WorkflowStepDefinition
|
|
defrev.Spec.DefinitionType = common2.WorkflowStepType
|
|
_, err = GetDefinitionFromDefinitionRevision(&defrev)
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
func TestValidateSpec(t *testing.T) {
|
|
testcases := map[string]struct {
|
|
Input string
|
|
Type string
|
|
HasErr bool
|
|
}{
|
|
"comp": {
|
|
Input: `{"podSpecPath": "a"}`,
|
|
Type: "component",
|
|
},
|
|
"trait": {
|
|
Input: `{"appliesToWorkloads":["deployments"]}`,
|
|
Type: "trait",
|
|
},
|
|
"workflow-step": {
|
|
Input: `{"definitionRef":{"name":"v"}}`,
|
|
Type: "workflow-step",
|
|
},
|
|
"bad-policy": {
|
|
Input: `{"definitionRef":{"invalid":5}}`,
|
|
Type: "policy",
|
|
HasErr: true,
|
|
},
|
|
"unknown": {
|
|
Input: `{}`,
|
|
Type: "unknown",
|
|
HasErr: false,
|
|
},
|
|
}
|
|
for name, tt := range testcases {
|
|
t.Run(name, func(t *testing.T) {
|
|
spec := map[string]interface{}{}
|
|
require.NoError(t, json.Unmarshal([]byte(tt.Input), &spec))
|
|
err := validateSpec(spec, tt.Type)
|
|
if tt.HasErr {
|
|
require.Error(t, err)
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCueNativeStatusFromCueString(t *testing.T) {
|
|
cueStr := strings.TrimSpace(`
|
|
"a-component": {
|
|
attributes: {
|
|
workload: {
|
|
definition: {
|
|
apiVersion: "apps/v1"
|
|
kind: "Deployment"
|
|
}
|
|
}
|
|
status: {
|
|
customStatus: {
|
|
message: "\(context.output.status.readyReplicas) / \(context.output.status.replicas) replicas are ready"
|
|
}
|
|
|
|
healthPolicy: {
|
|
isHealth: context.output.status.readyReplicas == context.output.status.replicas
|
|
}
|
|
|
|
details: {
|
|
$temp: context.output.status.replicas
|
|
deploymentReady: *(context.output.status.replicas == context.output.status.readyReplicas) | false
|
|
}
|
|
}
|
|
}
|
|
type: "component"
|
|
}
|
|
|
|
template: output: {}
|
|
`)
|
|
|
|
def := &Definition{}
|
|
err := def.FromCUEString(cueStr, nil)
|
|
require.NoError(t, err, "failed to parse cue string")
|
|
|
|
str, err := def.ToCUEString()
|
|
require.NoError(t, err, "failed to convert definition to CUE")
|
|
|
|
f, err := parser.ParseFile("-", str, parser.ParseComments)
|
|
require.NoError(t, err, "failed to parse resulting CUE string")
|
|
|
|
statusField, ok := ast.GetFieldByPath(f, fmt.Sprintf("%s.attributes.status.details", def.GetName()))
|
|
require.True(t, ok, "status field not found in CUE definition")
|
|
require.IsType(t, &ast2.StructLit{}, statusField.Value, "expected status field to be of type StructLit")
|
|
}
|
|
|
|
func TestStringStatusFromCueString(t *testing.T) {
|
|
cueStr := strings.TrimSpace(`
|
|
"a-component": {
|
|
attributes: {
|
|
workload: {
|
|
definition: {
|
|
apiVersion: "apps/v1"
|
|
kind: "Deployment"
|
|
}
|
|
}
|
|
status: {
|
|
customStatus: #"""
|
|
message: "\(context.output.status.readyReplicas) / \(context.output.status.replicas) replicas are ready"
|
|
"""#
|
|
|
|
healthPolicy: #"""
|
|
isHealth: context.output.status.readyReplicas == context.output.status.replicas
|
|
"""#
|
|
|
|
details: #"""
|
|
$someValue: context.output.status.replicas
|
|
deploymentReady: *(context.output.status.replicas == context.output.status.readyReplicas) | false
|
|
replicas: *context.output.status.replicas | 0
|
|
"""#
|
|
}
|
|
}
|
|
type: "component"
|
|
}
|
|
|
|
template: output: {}
|
|
`)
|
|
def := &Definition{}
|
|
err := def.FromCUEString(cueStr, nil)
|
|
require.NoError(t, err, "failed to parse cue string")
|
|
|
|
str, err := def.ToCUEString()
|
|
require.NoError(t, err, "failed to convert definition to CUE")
|
|
|
|
f, err := parser.ParseFile("-", str, parser.ParseComments)
|
|
require.NoError(t, err, "failed to parse resulting CUE string")
|
|
|
|
statusField, ok := ast.GetFieldByPath(f, fmt.Sprintf("%s.attributes.status.details", def.GetName()))
|
|
require.True(t, ok, "status field not found in CUE definition")
|
|
require.IsType(t, &ast2.StructLit{}, statusField.Value, "expected status field to be of type StructLit ")
|
|
}
|
|
|
|
// TestFromCUEString_PreservesLegacySyntax verifies that FromCUEString stores the
|
|
// original user-supplied CUE verbatim. Validation runs against the upgraded form,
|
|
// but the stored template retains the legacy syntax for the compat layer to handle at render time.
|
|
func TestFromCUEString_PreservesLegacySyntax(t *testing.T) {
|
|
legacyCUE := strings.TrimSpace(`
|
|
"legacy-worker": {
|
|
type: "component"
|
|
annotations: {}
|
|
}
|
|
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}]
|
|
}
|
|
}
|
|
`)
|
|
def := &Definition{}
|
|
require.NoError(t, def.FromCUEString(legacyCUE, nil))
|
|
|
|
storedTemplate, found, err := unstructured.NestedString(def.Object, DefinitionTemplateKeys...)
|
|
require.NoError(t, err)
|
|
require.True(t, found)
|
|
|
|
assert.Contains(t, storedTemplate, "parameter.env +")
|
|
assert.NotContains(t, storedTemplate, "list.Concat")
|
|
}
|