diff --git a/pkg/cue/upgrade/README.md b/pkg/cue/upgrade/README.md new file mode 100644 index 000000000..058b30d9b --- /dev/null +++ b/pkg/cue/upgrade/README.md @@ -0,0 +1,124 @@ +# CUE Upgrade System + +This package provides an extensible system for upgrading CUE templates to handle version compatibility as KubeVela evolves. + +## Architecture + +- **`upgrade.go`**: Core interface, registry, and main `Upgrade()` function +- **`upgrade_1_11.go`**: KubeVela 1.11+ specific upgrades (list concatenation) +- **`upgrade_test.go`**: Comprehensive test suite +- **Future**: `upgrade_1_12.go`, `upgrade_1_13.go`, etc. + +## Usage + +```go +import "github.com/oam-dev/kubevela/pkg/cue/upgrade" + +// Upgrade using current KubeVela CLI version (auto-detected) +result, err := upgrade.Upgrade(cueTemplate) +if err != nil { + // If version detection fails, you'll get a helpful error message + // suggesting to use the --target-version flag + log.Fatal(err) +} + +// Upgrade to specific KubeVela version +result, err := upgrade.Upgrade(cueTemplate, "1.11") + +// Future: upgrade to newer versions +result, err := upgrade.Upgrade(cueTemplate, "1.12") +``` + +## Adding New Version Upgrades + +To add support for KubeVela 1.12 (example): + +1. **Create** `upgrade_1_12.go`: + +```go +package upgrade + +import "fmt" + +func init() { + // Register your new upgrade functions + RegisterUpgrade("1.12", upgradeSomeNewFeature) + RegisterUpgrade("1.12", upgradeAnotherFeature) +} + +// upgradeSomeNewFeature handles a breaking change in CUE 0.15 +func upgradeSomeNewFeature(cueStr string) (string, error) { + // Your upgrade logic here + // Parse, transform, format, return + return transformedCue, nil +} +``` + +2. **Update** `upgrade.go` `supportedVersions`: + +```go +// In the Upgrade() function +supportedVersions := []string{"1.11", "1.12"} +``` + +3. **Add tests** to `upgrade_test.go` for the new functionality + +4. **Done!** The system automatically applies all relevant upgrades + +## Current Upgrades + +### 1.11 - List Concatenation +- **Problem**: `list1 + list2` syntax deprecated in underlying CUE version +- **Solution**: Converts to `list.Concat([list1, list2])` +- **Auto-imports**: Adds `import "list"` when needed + +## Features + +- **Version-aware**: Only applies upgrades up to target version +- **Composable**: Multiple upgrades can be registered per version +- **Safe**: Falls back gracefully on errors +- **Extensible**: Easy to add new version support +- **Well-tested**: Comprehensive test coverage + +## Examples + +### Before (Old CUE): +```cue +myList1: [1, 2, 3] +myList2: [4, 5, 6] +combined: myList1 + myList2 +``` + +### After (CUE 0.14+): +```cue +import "list" + +myList1: [1, 2, 3] +myList2: [4, 5, 6] +combined: list.Concat([myList1, myList2]) +``` + +This system ensures KubeVela templates remain compatible as CUE continues to evolve. + +## CLI Usage + +The upgrade functionality is available through the KubeVela CLI: + +```bash +# Upgrade using current KubeVela CLI version (auto-detected) +vela def upgrade my-definition.cue + +# Save upgraded definition to a file +vela def upgrade my-definition.cue -o upgraded-definition.cue + +# Upgrade for specific KubeVela version +vela def upgrade my-definition.cue --target-version=v1.11 + +# Also works with just the version number +vela def upgrade my-definition.cue --target-version=1.11 +``` + +**Version Detection:** +- When no `--target-version` is specified, the system automatically detects the current KubeVela CLI version +- If version detection fails (e.g., in development builds), you'll get a helpful error message suggesting to use `--target-version=1.11` +- **Note:** Use `--target-version=` (with equals sign) for the version flag \ No newline at end of file diff --git a/pkg/cue/upgrade/upgrade.go b/pkg/cue/upgrade/upgrade.go new file mode 100644 index 000000000..34445cba0 --- /dev/null +++ b/pkg/cue/upgrade/upgrade.go @@ -0,0 +1,136 @@ +/* +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 upgrade + +import ( + "fmt" + "regexp" + "strings" + + "github.com/oam-dev/kubevela/version" +) + +// UpgradeFunc represents a function that upgrades CUE code for version compatibility +type UpgradeFunc func(string) (string, error) + +// upgradeRegistry holds upgrade functions for different CUE versions +var upgradeRegistry = make(map[string][]UpgradeFunc) + +// RegisterUpgrade registers an upgrade function for a specific KubeVela version +// version should be in format "1.11", "1.12", etc. +func RegisterUpgrade(version string, upgradeFunc UpgradeFunc) { + upgradeRegistry[version] = append(upgradeRegistry[version], upgradeFunc) +} + +// getCurrentKubeVelaMinorVersion extracts the minor version (e.g., "1.11") from the full KubeVela version +func getCurrentKubeVelaMinorVersion() (string, error) { + versionStr := version.VelaVersion + if versionStr == "" || versionStr == "UNKNOWN" { + return "", fmt.Errorf("unable to determine KubeVela version (got %q). Please specify the target version explicitly using --target-version=1.11", versionStr) + } + + // Remove 'v' prefix if present + versionStr = strings.TrimPrefix(versionStr, "v") + + // Use regex to extract major.minor version (e.g., "1.11.2" -> "1.11") + re := regexp.MustCompile(`^(\d+\.\d+)`) + matches := re.FindStringSubmatch(versionStr) + if len(matches) >= 2 { + return matches[1], nil + } + + return "", fmt.Errorf("unable to parse KubeVela version %q. Please specify the target version explicitly using --target-version=1.11", versionStr) +} + +// Upgrade applies all registered upgrades for KubeVela versions up to and including the target version +// targetVersion should be in format "1.11", "1.12", etc. +// If targetVersion is empty, applies upgrades for the current KubeVela CLI version +func Upgrade(cueStr string, targetVersion ...string) (string, error) { + var version string + var err error + + if len(targetVersion) > 0 && targetVersion[0] != "" { + version = targetVersion[0] + } else { + version, err = getCurrentKubeVelaMinorVersion() // Default to current CLI version + if err != nil { + return "", err + } + } + + result := cueStr + + // Apply upgrades for all versions up to and including the target version + // Currently we only support 1.11, but this can be extended + supportedVersions := []string{"1.11"} + + for _, v := range supportedVersions { + if shouldApplyUpgrade(v, version) { + if upgrades, exists := upgradeRegistry[v]; exists { + for _, upgrade := range upgrades { + result, err = upgrade(result) + if err != nil { + return cueStr, fmt.Errorf("failed to apply upgrade for version %s: %w", v, err) + } + } + } + } + } + + return result, nil +} + +// shouldApplyUpgrade determines if upgrades for a given version should be applied +// based on the target version +func shouldApplyUpgrade(upgradeVersion, targetVersion string) bool { + // For now, simple string comparison works since we only have 1.11 + // In the future, this could be enhanced with proper semantic version comparison + return upgradeVersion <= targetVersion +} + +// GetSupportedVersions returns a list of supported upgrade versions +func GetSupportedVersions() []string { + versions := make([]string, 0, len(upgradeRegistry)) + for version := range upgradeRegistry { + versions = append(versions, version) + } + return versions +} + +// RequiresUpgrade checks if the CUE string requires upgrading to the target version +// Returns: (needsUpgrade bool, reasons []string, error) +// If targetVersion is empty, uses the current KubeVela CLI version +func RequiresUpgrade(cueStr string, targetVersion ...string) (bool, []string, error) { + var version string + var err error + + if len(targetVersion) > 0 && targetVersion[0] != "" { + version = targetVersion[0] + } else { + version, err = getCurrentKubeVelaMinorVersion() + if err != nil { + return false, nil, err + } + } + + // For now, we only check for v1.11 upgrades + // In the future, this can check against multiple version requirements + if version >= "1.11" { + return requires111Upgrade(cueStr) + } + + return false, nil, nil +} diff --git a/pkg/cue/upgrade/upgrade_1_11.go b/pkg/cue/upgrade/upgrade_1_11.go new file mode 100644 index 000000000..59e0e9a26 --- /dev/null +++ b/pkg/cue/upgrade/upgrade_1_11.go @@ -0,0 +1,353 @@ +/* +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 upgrade + +import ( + "fmt" + + "cuelang.org/go/cue/ast" + "cuelang.org/go/cue/ast/astutil" + "cuelang.org/go/cue/format" + "cuelang.org/go/cue/parser" + "cuelang.org/go/cue/token" +) + +func init() { + RegisterUpgrade("1.11", upgradeListConcatenation) +} + +func requires111Upgrade(cueStr string) (bool, []string, error) { + file, err := parser.ParseFile("", cueStr, parser.ParseComments) + if err != nil { + return false, nil, fmt.Errorf("failed to parse CUE: %w", err) + } + + var reasons []string + if hasOldListConcatenation(file) { + reasons = append(reasons, "contains deprecated list operators (+ or *) that need upgrading to list.Concat() or list.Repeat()") + } + + return len(reasons) > 0, reasons, nil +} + +func hasOldListConcatenation(file *ast.File) bool { + listRegistry := collectListDeclarations(file) + + found := false + astutil.Apply(file, func(cursor astutil.Cursor) bool { + if binExpr, ok := cursor.Node().(*ast.BinaryExpr); ok { + if binExpr.Op.String() == "+" { + if isListExpression(binExpr.X, listRegistry) && isListExpression(binExpr.Y, listRegistry) { + found = true + return false + } + } + if binExpr.Op.String() == "*" { + if (isListExpression(binExpr.X, listRegistry) && isNumericExpression(binExpr.Y, listRegistry)) || + (isNumericExpression(binExpr.X, listRegistry) && isListExpression(binExpr.Y, listRegistry)) { + found = true + return false + } + } + } + return true + }, nil) + + return found +} + +// upgradeListConcatenation handles: +// - list1 + list2 -> list.Concat([list1, list2]) +// - list * n -> list.Repeat(list, n) +// - n * list -> list.Repeat(list, n) +func upgradeListConcatenation(cueStr string) (string, error) { + file, err := parser.ParseFile("", cueStr, parser.ParseComments) + if err != nil { + return "", fmt.Errorf("failed to parse CUE: %w", err) + } + + transformed := upgradeListConcatenationAST(file) + + result, err := format.Node(transformed) + if err != nil { + return "", fmt.Errorf("failed to format CUE: %w", err) + } + + return string(result), nil +} + +func upgradeListConcatenationAST(file *ast.File) *ast.File { + listRegistry := collectListDeclarations(file) + + needsListImport := false + + result := astutil.Apply(file, func(cursor astutil.Cursor) bool { + if binExpr, ok := cursor.Node().(*ast.BinaryExpr); ok { + if binExpr.Op.String() == "+" { + if isListExpression(binExpr.X, listRegistry) && isListExpression(binExpr.Y, listRegistry) { + callExpr := &ast.CallExpr{ + Fun: &ast.SelectorExpr{ + X: &ast.Ident{Name: "list"}, + Sel: &ast.Ident{Name: "Concat"}, + }, + Args: []ast.Expr{ + &ast.ListLit{ + Elts: []ast.Expr{binExpr.X, binExpr.Y}, + }, + }, + } + + cursor.Replace(callExpr) + needsListImport = true + } + } + + if binExpr.Op.String() == "*" { + var listExpr, countExpr ast.Expr + + if isListExpression(binExpr.X, listRegistry) && isNumericExpression(binExpr.Y, listRegistry) { + listExpr = binExpr.X + countExpr = binExpr.Y + } else if isNumericExpression(binExpr.X, listRegistry) && isListExpression(binExpr.Y, listRegistry) { + countExpr = binExpr.X + listExpr = binExpr.Y + } + + if listExpr != nil && countExpr != nil { + ast.SetRelPos(listExpr, 0) + ast.SetRelPos(countExpr, 0) + + callExpr := &ast.CallExpr{ + Fun: &ast.SelectorExpr{ + X: &ast.Ident{Name: "list"}, + Sel: &ast.Ident{Name: "Repeat"}, + }, + Args: []ast.Expr{listExpr, countExpr}, + } + + cursor.Replace(callExpr) + needsListImport = true + } + } + } + return true + }, nil) + + if file, ok := result.(*ast.File); ok && needsListImport { + ensureListImport(file) + return file + } + + return file +} + +func ensureListImport(file *ast.File) { + for _, imp := range file.Imports { + if imp.Path != nil && imp.Path.Value == "\"list\"" { + return + } + } + + for _, decl := range file.Decls { + if importDecl, ok := decl.(*ast.ImportDecl); ok { + for _, spec := range importDecl.Specs { + if spec.Path != nil && spec.Path.Value == "\"list\"" { + return + } + } + } + } + + if file.Imports != nil || len(file.Decls) > 0 { + listImport := &ast.ImportSpec{ + Path: &ast.BasicLit{ + Kind: token.STRING, + Value: "\"list\"", + }, + } + + file.Imports = append([]*ast.ImportSpec{listImport}, file.Imports...) + + importDecl := &ast.ImportDecl{ + Specs: []*ast.ImportSpec{listImport}, + } + + file.Decls = append([]ast.Decl{importDecl}, file.Decls...) + } +} + +func collectListDeclarations(file *ast.File) map[string]bool { + listRegistry := make(map[string]bool) + + astutil.Apply(file, func(cursor astutil.Cursor) bool { + if node, ok := cursor.Node().(*ast.Field); ok { + if label, ok := node.Label.(*ast.Ident); ok { + if isListLiteral(node.Value) { + listRegistry[label.Name] = true + } else if structLit, ok := node.Value.(*ast.StructLit); ok { + prefix := label.Name + collectNestedListDeclarationsFirstPass(structLit, prefix, listRegistry) + } + } + } + return true + }, nil) + + // Second pass: iteratively collect fields that are results of list operations + changed := true + for changed { + changed = false + astutil.Apply(file, func(cursor astutil.Cursor) bool { + if node, ok := cursor.Node().(*ast.Field); ok { + if label, ok := node.Label.(*ast.Ident); ok { + if !listRegistry[label.Name] && isListOperationResult(node.Value, listRegistry) { + listRegistry[label.Name] = true + changed = true + } else if structLit, ok := node.Value.(*ast.StructLit); ok { + prefix := label.Name + if collectNestedListDeclarationsSecondPass(structLit, prefix, listRegistry) { + changed = true + } + } + } + } + return true + }, nil) + } + + return listRegistry +} + +func collectNestedListDeclarationsFirstPass(structLit *ast.StructLit, prefix string, listRegistry map[string]bool) { + for _, elt := range structLit.Elts { + if field, ok := elt.(*ast.Field); ok { + if label, ok := field.Label.(*ast.Ident); ok { + qualifiedName := prefix + "." + label.Name + if isListLiteral(field.Value) { + listRegistry[qualifiedName] = true + listRegistry[label.Name] = true + } else if nestedStruct, ok := field.Value.(*ast.StructLit); ok { + collectNestedListDeclarationsFirstPass(nestedStruct, qualifiedName, listRegistry) + } + } + } + } +} + +func collectNestedListDeclarationsSecondPass(structLit *ast.StructLit, prefix string, listRegistry map[string]bool) bool { + changed := false + for _, elt := range structLit.Elts { + if field, ok := elt.(*ast.Field); ok { + if label, ok := field.Label.(*ast.Ident); ok { + qualifiedName := prefix + "." + label.Name + if !listRegistry[qualifiedName] && isListOperationResult(field.Value, listRegistry) { + listRegistry[qualifiedName] = true + listRegistry[label.Name] = true + changed = true + } else if nestedStruct, ok := field.Value.(*ast.StructLit); ok { + if collectNestedListDeclarationsSecondPass(nestedStruct, qualifiedName, listRegistry) { + changed = true + } + } + } + } + } + return changed +} + +func isListLiteral(expr ast.Expr) bool { + switch e := expr.(type) { + case *ast.ListLit: + return true + case *ast.Comprehension: + return true + case *ast.Ellipsis: + return true + case *ast.CallExpr: + if sel, ok := e.Fun.(*ast.SelectorExpr); ok { + if id, ok := sel.X.(*ast.Ident); ok && id.Name == "list" { + return true + } + } + return false + } + return false +} + +func isListExpression(expr ast.Expr, listRegistry map[string]bool) bool { + switch e := expr.(type) { + case *ast.ListLit: + return true + + case *ast.CallExpr: + if sel, ok := e.Fun.(*ast.SelectorExpr); ok { + if id, ok := sel.X.(*ast.Ident); ok && id.Name == "list" { + return true + } + } + return false + + case *ast.Ident: + return listRegistry[e.Name] + + case *ast.SelectorExpr: + if base, ok := e.X.(*ast.Ident); ok { + if sel, ok := e.Sel.(*ast.Ident); ok { + qualifiedName := base.Name + "." + sel.Name + return listRegistry[qualifiedName] + } + } + return false + } + + return false +} + +// isNumericExpression checks if an expression is a numeric literal or identifier that is not a known list. +func isNumericExpression(expr ast.Expr, listRegistry map[string]bool) bool { + switch e := expr.(type) { + case *ast.BasicLit: + return e.Kind == token.INT || e.Kind == token.FLOAT + case *ast.Ident: + return !listRegistry[e.Name] + case *ast.UnaryExpr: + return isNumericExpression(e.X, listRegistry) + } + return false +} + +// isListOperationResult checks if an expression is the result of a list operation +func isListOperationResult(expr ast.Expr, listRegistry map[string]bool) bool { + if binExpr, ok := expr.(*ast.BinaryExpr); ok { + if binExpr.Op.String() == "+" { + return isListExpression(binExpr.X, listRegistry) && isListExpression(binExpr.Y, listRegistry) + } + if binExpr.Op.String() == "*" { + return (isListExpression(binExpr.X, listRegistry) && isNumericExpression(binExpr.Y, listRegistry)) || + (isNumericExpression(binExpr.X, listRegistry) && isListExpression(binExpr.Y, listRegistry)) + } + } + if callExpr, ok := expr.(*ast.CallExpr); ok { + if sel, ok := callExpr.Fun.(*ast.SelectorExpr); ok { + if id, ok := sel.X.(*ast.Ident); ok && id.Name == "list" { + if selName, ok := sel.Sel.(*ast.Ident); ok { + return selName.Name == "Concat" || selName.Name == "Repeat" + } + } + } + } + return false +} diff --git a/pkg/cue/upgrade/upgrade_test.go b/pkg/cue/upgrade/upgrade_test.go new file mode 100644 index 000000000..53d846023 --- /dev/null +++ b/pkg/cue/upgrade/upgrade_test.go @@ -0,0 +1,509 @@ +/* +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 upgrade + +import ( + "strings" + "testing" + + "github.com/oam-dev/kubevela/version" +) + +func TestUpgrade(t *testing.T) { + tests := []struct { + name string + input string + expected string + wantErr bool + }{ + { + name: "simple list concatenation", + input: ` +myList1: [1, 2, 3] +myList2: [4, 5, 6] +combined: myList1 + myList2 +`, + expected: "list.Concat", + wantErr: false, + }, + { + name: "list concatenation in object", + input: ` +object: { + items: baseItems + extraItems + baseItems: ["a", "b"] + extraItems: ["c", "d"] +} +`, + expected: "list.Concat", + wantErr: false, + }, + { + name: "non-list addition should not be transformed", + input: ` +number1: 5 +number2: 10 +sum: number1 + number2 +`, + expected: "number1 + number2", // Should remain as is + wantErr: false, + }, + { + name: "mixed with existing imports", + input: ` +import "strings" + +myList1: [1, 2, 3] +myList2: [4, 5, 6] +combined: myList1 + myList2 +`, + expected: "list.Concat", + wantErr: false, + }, + { + name: "simple list repeat", + input: ` +myList: ["a", "b"] +repeated: myList * 3 +`, + expected: "list.Repeat", + wantErr: false, + }, + { + name: "reverse list repeat", + input: ` +myList: ["x", "y", "z"] +repeated: 2 * myList +`, + expected: "list.Repeat", + wantErr: false, + }, + { + name: "list repeat with field references", + input: ` +parameter: { + items: ["item1", "item2"] + count: 5 + repeated1: items * 2 + repeated2: 3 * items +} +`, + expected: "list.Repeat", + wantErr: false, + }, + { + name: "mixed concatenation and repeat", + input: ` +list1: ["a", "b"] +list2: ["c", "d"] +concatenated: list1 + list2 +repeated: concatenated * 2 +`, + expected: "list.Concat", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Upgrade(tt.input, "1.11") // Explicitly provide version for tests + if (err != nil) != tt.wantErr { + t.Errorf("Upgrade() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + + // Check if the expected transformation occurred + if tt.expected == "list.Concat" || tt.expected == "list.Repeat" { + if !strings.Contains(got, tt.expected) { + t.Errorf("Upgrade() did not transform to %s, got = %v", tt.expected, got) + } + if !strings.Contains(got, `import "list"`) { + t.Errorf("Upgrade() did not add list import, got = %v", got) + } + } else { + // Check that the expected string is still present (not transformed) + if !strings.Contains(got, tt.expected) { + t.Errorf("Upgrade() unexpectedly transformed non-list operation, got = %v", got) + } + } + }) + } +} + +func TestUpgradeWithComplexTemplate(t *testing.T) { + // Test with a template similar to what would be used in a workload definition + input := ` +template: { + apiVersion: "apps/v1" + kind: "Deployment" + spec: { + selector: matchLabels: app: context.name + template: { + metadata: labels: app: context.name + spec: { + containers: [{ + name: context.name + image: parameter.image + env: parameter.env + [{name: "EXTRA", value: "value"}] + }] + } + } + } +} + +parameter: { + image: string + env: [...{name: string, value: string}] +} + +output: template +` + + got, err := Upgrade(input, "1.11") // Explicitly provide version for tests + if err != nil { + t.Fatalf("Upgrade() error = %v", err) + } + + // Check that env concatenation was transformed + if !strings.Contains(got, "list.Concat") { + t.Errorf("Upgrade() did not transform env list concatenation") + } + + // Check that list import was added + if !strings.Contains(got, `import "list"`) { + t.Errorf("Upgrade() did not add list import") + } + + t.Logf("Transformed template:\n%s", got) +} + +func TestUpgradeWithStringsJoin(t *testing.T) { + // Test the specific case from test-component-lists.cue + input := ` +import "strings" + +template: { + output: { + spec: { + selector: matchLabels: "app.oam.dev/component": parameter.name + template: { + metadata: labels: "app.oam.dev/component": parameter.name + spec: containers: [{ + name: parameter.name + image: parameter.image + }] + } + } + apiVersion: "apps/v1" + kind: "Deployment" + metadata: { + name: strings.Join(parameter.list1 + parameter.list2, "-") + } + } + outputs: {} + + parameter: { + list1: [...string] + list2: [...string] + name: string + image: string + } +} +` + + got, err := Upgrade(input, "1.11") // Explicitly provide version for tests + if err != nil { + t.Fatalf("Upgrade() error = %v", err) + } + + // Check that the concatenation inside strings.Join was transformed + // list.Concat takes a list of lists as a single argument + if !strings.Contains(got, "strings.Join(list.Concat([") { + t.Errorf("Upgrade() did not transform list concatenation inside strings.Join") + t.Logf("Got:\n%s", got) + } + + // Check that list import was added + if !strings.Contains(got, `import "list"`) { + t.Errorf("Upgrade() did not add list import") + } + + // The original strings import should still be there + if !strings.Contains(got, `import "strings"`) { + t.Errorf("Upgrade() removed the strings import") + } + + t.Logf("Transformed template:\n%s", got) +} + +func TestUpgradeRegistry(t *testing.T) { + // Test that the registry system works and can handle version-specific upgrades + + // Test with explicit version (should apply 1.11 upgrades) + input := ` +list1: [1, 2, 3] +list2: [4, 5, 6] +result: list1 + list2 +` + result, err := Upgrade(input, "1.11") // Provide explicit version for test + if err != nil { + t.Fatalf("Upgrade() error = %v", err) + } + if !strings.Contains(result, "list.Concat") { + t.Errorf("Default upgrade should apply 1.11 list concatenation upgrade, got = %v", result) + } + + // Test explicit version 1.11 + result, err = Upgrade(input, "1.11") + if err != nil { + t.Fatalf("Upgrade() error = %v", err) + } + if !strings.Contains(result, "list.Concat") { + t.Errorf("1.11 upgrade should apply list concatenation upgrade, got = %v", result) + } + + // Test future version (should still apply 1.11 upgrades) + result, err = Upgrade(input, "1.12") + if err != nil { + t.Fatalf("Upgrade() error = %v", err) + } + if !strings.Contains(result, "list.Concat") { + t.Errorf("Future version upgrade should still apply 1.11 upgrades, got = %v", result) + } +} + +func TestGetSupportedVersions(t *testing.T) { + versions := GetSupportedVersions() + if len(versions) == 0 { + t.Error("Expected at least one supported version") + } + + // Should include 1.11 since that's registered in init() + found := false + for _, v := range versions { + if v == "1.11" { + found = true + break + } + } + if !found { + t.Errorf("Expected 1.11 to be in supported versions, got %v", versions) + } +} + +func TestGetCurrentKubeVelaMinorVersion(t *testing.T) { + tests := []struct { + name string + mockVersion string + expectedVersion string + expectError bool + }{ + { + name: "full semantic version", + mockVersion: "v1.11.2", + expectedVersion: "1.11", + expectError: false, + }, + { + name: "full semantic version without v prefix", + mockVersion: "1.12.0", + expectedVersion: "1.12", + expectError: false, + }, + { + name: "dev version", + mockVersion: "v1.13.0-alpha.1+dev", + expectedVersion: "1.13", + expectError: false, + }, + { + name: "unknown version error", + mockVersion: "UNKNOWN", + expectError: true, + }, + { + name: "empty version error", + mockVersion: "", + expectError: true, + }, + { + name: "invalid version error", + mockVersion: "invalid-version", + expectError: true, + }, + } + + // Save original version + originalVersion := version.VelaVersion + defer func() { + version.VelaVersion = originalVersion + }() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Mock the version + version.VelaVersion = tt.mockVersion + + got, err := getCurrentKubeVelaMinorVersion() + if tt.expectError { + if err == nil { + t.Errorf("getCurrentKubeVelaMinorVersion() expected error but got none") + } + return + } + + if err != nil { + t.Errorf("getCurrentKubeVelaMinorVersion() unexpected error: %v", err) + return + } + + if got != tt.expectedVersion { + t.Errorf("getCurrentKubeVelaMinorVersion() = %v, want %v", got, tt.expectedVersion) + } + }) + } +} + +func TestRequiresUpgrade(t *testing.T) { + tests := []struct { + name string + input string + shouldRequire bool + expectReasons int + }{ + { + name: "requires upgrade - list concatenation", + input: ` +myList1: [1, 2, 3] +myList2: [4, 5, 6] +combined: myList1 + myList2 +`, + shouldRequire: true, + expectReasons: 1, + }, + { + name: "no upgrade needed - already uses list.Concat", + input: ` +import "list" +myList1: [1, 2, 3] +myList2: [4, 5, 6] +combined: list.Concat([myList1, myList2]) +`, + shouldRequire: false, + expectReasons: 0, + }, + { + name: "no upgrade needed - numeric addition", + input: ` +x: 1 +y: 2 +sum: x + y +`, + shouldRequire: false, + expectReasons: 0, + }, + { + name: "requires upgrade - nested structure", + input: ` +parameter: { + env: [...{name: string, value: string}] + extraEnv: [{name: "DEBUG", value: "true"}] +} +combined: parameter.env + parameter.extraEnv +`, + shouldRequire: true, + expectReasons: 1, + }, + { + name: "requires upgrade - list repeat", + input: ` +items: ["a", "b", "c"] +repeated: items * 5 +`, + shouldRequire: true, + expectReasons: 1, + }, + { + name: "no upgrade needed - already uses list.Repeat", + input: ` +import "list" +items: ["a", "b", "c"] +repeated: list.Repeat(items, 5) +`, + shouldRequire: false, + expectReasons: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + needsUpgrade, reasons, err := RequiresUpgrade(tt.input, "1.11") + if err != nil { + t.Fatalf("RequiresUpgrade() error = %v", err) + } + + if needsUpgrade != tt.shouldRequire { + t.Errorf("RequiresUpgrade() = %v, want %v", needsUpgrade, tt.shouldRequire) + } + + if len(reasons) != tt.expectReasons { + t.Errorf("RequiresUpgrade() returned %d reasons, want %d. Reasons: %v", + len(reasons), tt.expectReasons, reasons) + } + }) + } +} + +func TestUpgradeWithUnknownVersionError(t *testing.T) { + // Save original version + originalVersion := version.VelaVersion + defer func() { + version.VelaVersion = originalVersion + }() + + // Mock unknown version + version.VelaVersion = "UNKNOWN" + + input := ` +list1: [1, 2, 3] +list2: [4, 5, 6] +combined: list1 + list2 +` + + // Should return error when no version is specified and VelaVersion is UNKNOWN + _, err := Upgrade(input) + if err == nil { + t.Errorf("Upgrade() expected error when version is UNKNOWN but got none") + } + + // Should contain helpful message + expectedMsg := "Please specify the target version explicitly using --target-version=1.11" + if !strings.Contains(err.Error(), expectedMsg) { + t.Errorf("Error message should contain guidance about using --target-version flag, got: %v", err.Error()) + } + + // Should work when explicit version is provided + result, err := Upgrade(input, "1.11") + if err != nil { + t.Errorf("Upgrade() with explicit version should work even when VelaVersion is UNKNOWN, got error: %v", err) + } + if !strings.Contains(result, "list.Concat") { + t.Errorf("Upgrade() with explicit version should still apply transformations") + } +} diff --git a/references/cli/adopt.go b/references/cli/adopt.go index 2a80ccc88..91c407e6d 100644 --- a/references/cli/adopt.go +++ b/references/cli/adopt.go @@ -401,7 +401,7 @@ func (opt *AdoptOptions) Complete(f velacmd.Factory, cmd *cobra.Command, args [] if opt.AppName != "" { app := &v1beta1.Application{} err := f.Client().Get(cmd.Context(), apitypes.NamespacedName{Namespace: opt.AppNamespace, Name: opt.AppName}, app) - if err == nil && app != nil { + if err == nil { if !opt.Yes && opt.Apply { userInput := NewUserInput() confirm := userInput.AskBool( diff --git a/references/cli/def.go b/references/cli/def.go index fd501bbf6..b02613577 100644 --- a/references/cli/def.go +++ b/references/cli/def.go @@ -53,6 +53,7 @@ import ( "github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1" "github.com/oam-dev/kubevela/apis/types" "github.com/oam-dev/kubevela/pkg/cue/process" + "github.com/oam-dev/kubevela/pkg/cue/upgrade" pkgdef "github.com/oam-dev/kubevela/pkg/definition" "github.com/oam-dev/kubevela/pkg/definition/gen_sdk" "github.com/oam-dev/kubevela/pkg/definition/goloader" @@ -94,6 +95,7 @@ func DefinitionCommandGroup(c common.Args, order string, ioStreams util.IOStream NewDefinitionDelCommand(c), NewDefinitionInitCommand(c), NewDefinitionValidateCommand(c), + NewDefinitionUpgradeCommand(c, ioStreams), NewDefinitionDocGenCommand(c, ioStreams), NewCapabilityShowCommand(c, "", ioStreams), NewDefinitionGenAPICommand(c), @@ -1996,3 +1998,115 @@ func NewDefinitionGenDocCommand(_ common.Args, streams util.IOStreams) *cobra.Co return cmd } + +// NewDefinitionUpgradeCommand create the `vela def upgrade` command to help user upgrade CUE templates for version compatibility +func NewDefinitionUpgradeCommand(c common.Args, ioStreams util.IOStreams) *cobra.Command { + var ( + outputFile string + targetVersion string + checkOnly bool + quiet bool + ) + + cmd := &cobra.Command{ + Use: "upgrade DEFINITION_FILE", + Short: "Upgrade CUE definition for version compatibility", + Long: "Upgrade CUE definition files to be compatible with a specific KubeVela version.\n" + + "This command automatically applies all necessary upgrades to ensure your definitions work with the target KubeVela version.\n" + + "If no version is specified, upgrades to the current CLI version.\n\n" + + "Currently supported upgrades:\n" + + "• List concatenation syntax compatibility\n" + + "• Import statement management\n" + + "• Template syntax modernization", + Example: "# Validate if definition needs upgrading (exit code 1 if upgrade needed)\n" + + "vela def upgrade my-definition.cue --validate\n\n" + + "# Silent validation for scripting (only exit code)\n" + + "vela def upgrade my-definition.cue --validate --quiet\n\n" + + "# Upgrade definition for current KubeVela version\n" + + "vela def upgrade my-definition.cue\n\n" + + "# Upgrade and save to specific file\n" + + "vela def upgrade my-definition.cue -o upgraded-definition.cue\n\n" + + "# Upgrade for specific KubeVela version\n" + + "vela def upgrade my-definition.cue --target-version=v1.11", + Annotations: map[string]string{ + types.TagCommandType: types.TypeDefGeneration, + types.TagCommandOrder: "5", + }, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + sourceFile := args[0] + + // Read the source file + content, err := os.ReadFile(sourceFile) //nolint:gosec + if err != nil { + return fmt.Errorf("failed to read source file %s: %w", sourceFile, err) + } + + // Prepare target version (strip 'v' prefix if present for consistency) + version := strings.TrimPrefix(targetVersion, "v") + + // Check-only mode + if checkOnly { + var needsUpgrade bool + var reasons []string + + if version != "" { + needsUpgrade, reasons, err = upgrade.RequiresUpgrade(string(content), version) + } else { + needsUpgrade, reasons, err = upgrade.RequiresUpgrade(string(content)) + } + + if err != nil { + return fmt.Errorf("failed to check upgrade requirements: %w", err) + } + + if needsUpgrade { + if !quiet { + fmt.Fprintf(ioStreams.Out, "✗ Definition %s requires upgrade:\n", sourceFile) + for _, reason := range reasons { + fmt.Fprintf(ioStreams.Out, " - %s\n", reason) + } + } + os.Exit(1) // Non-zero exit code for scripts + } else if !quiet { + fmt.Fprintf(ioStreams.Out, "✓ Definition %s is up to date\n", sourceFile) + } + return nil + } + + // Apply upgrades + var upgradedContent string + if version != "" { + upgradedContent, err = upgrade.Upgrade(string(content), version) + } else { + // Use default version (current KubeVela) + upgradedContent, err = upgrade.Upgrade(string(content)) + } + + if err != nil { + return fmt.Errorf("failed to upgrade CUE template: %w", err) + } + + // Determine output destination + if outputFile != "" { + // Write to specified output file + if err := os.WriteFile(outputFile, []byte(upgradedContent), 0600); err != nil { //nolint:gosec + return fmt.Errorf("failed to write output file %s: %w", outputFile, err) + } + fmt.Fprintf(ioStreams.Out, "Successfully upgraded %s and saved to %s\n", sourceFile, outputFile) + } else { + // Write to stdout + fmt.Fprint(ioStreams.Out, upgradedContent) + } + + return nil + }, + } + + cmd.Flags().StringVarP(&outputFile, "output", "o", "", "Output file path. If not specified, outputs to stdout.") + cmd.Flags().StringVar(&targetVersion, "target-version", "", "Target KubeVela version (e.g., --target-version=v1.11). If not specified, uses current CLI version.") + cmd.Flags().BoolVar(&checkOnly, "validate", false, "Validate if definition needs upgrading without making changes (exit code 1 if upgrade required)") + cmd.Flags().BoolVarP(&quiet, "quiet", "q", false, "Suppress output in validate mode, only return exit code") + + return cmd +}