mirror of https://github.com/kubevela/kubevela.git
Feat: Support Native Cue in HealthPolicy and CustomStatus (#6859)
* Feat: Support Native Cue in HealthPolicy and CustomStatus Signed-off-by: Brian Kane <briankane1@gmail.com> * Feat: Support Native Cue in HealthPolicy and CustomStatus - Fix PR Comments & Bugs Signed-off-by: Brian Kane <briankane1@gmail.com> --------- Signed-off-by: Brian Kane <briankane1@gmail.com>
This commit is contained in:
parent
3aa94842fb
commit
a5de74ec1e
|
@ -27,14 +27,22 @@ import (
|
|||
|
||||
const (
|
||||
// status is the path to the status field in the metadata
|
||||
status = "attributes.status.details"
|
||||
status = "attributes.status.details"
|
||||
healthPolicy = "attributes.status.healthPolicy"
|
||||
customStatus = "attributes.status.customStatus"
|
||||
// localFieldPrefix is the prefix for local fields not output to the status
|
||||
localFieldPrefix = "$"
|
||||
)
|
||||
|
||||
// EncodeMetadata encodes native CUE in the metadata fields to a CUE string literal
|
||||
func EncodeMetadata(field *ast.Field) error {
|
||||
if err := marshalStatusDetailsField(field); err != nil {
|
||||
if err := marshalField[*ast.StructLit](field, healthPolicy, validateHealthPolicyField); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := marshalField[*ast.StructLit](field, customStatus, validateCustomStatusField); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := marshalField[*ast.StructLit](field, status, validateStatusField); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
@ -42,65 +50,75 @@ func EncodeMetadata(field *ast.Field) error {
|
|||
|
||||
// DecodeMetadata decodes a CUE string literal in the metadata fields to native CUE expressions
|
||||
func DecodeMetadata(field *ast.Field) error {
|
||||
if err := unmarshalStatusDetailsField(field); err != nil {
|
||||
if err := unmarshalField[*ast.StructLit](field, healthPolicy, validateHealthPolicyField); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := unmarshalField[*ast.StructLit](field, customStatus, validateCustomStatusField); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := unmarshalField[*ast.StructLit](field, status, validateStatusField); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func marshalStatusDetailsField(field *ast.Field) error {
|
||||
if statusField, ok := GetFieldByPath(field, status); ok {
|
||||
func marshalField[T ast.Node](field *ast.Field, key string, validator func(T) error) error {
|
||||
if statusField, ok := GetFieldByPath(field, key); ok {
|
||||
switch expr := statusField.Value.(type) {
|
||||
case *ast.BasicLit:
|
||||
if expr.Kind != token.STRING {
|
||||
return fmt.Errorf("expected status field to be string, got %v", expr.Kind)
|
||||
return fmt.Errorf("expected %s field to be string, got %v", key, expr.Kind)
|
||||
}
|
||||
if err := ValidateCueStringLiteral[*ast.StructLit](expr, validateStatusField); err != nil {
|
||||
return fmt.Errorf("status.details field failed validation: %w", err)
|
||||
if err := ValidateCueStringLiteral[T](expr, validator); err != nil {
|
||||
return fmt.Errorf("%s field failed validation: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
|
||||
case *ast.StructLit:
|
||||
v, _ := statusField.Value.(*ast.StructLit)
|
||||
err := validateStatusField(v)
|
||||
structLit := expr
|
||||
v, ok := ast.Node(structLit).(T)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s field: cannot convert *ast.StructLit to expected type", key)
|
||||
}
|
||||
err := validator(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
strLit, err := StringifyStructLitAsCueString(v)
|
||||
strLit, err := StringifyStructLitAsCueString(structLit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
UpdateNodeByPath(field, status, strLit)
|
||||
UpdateNodeByPath(field, key, strLit)
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unexpected type for status field: %T", expr)
|
||||
return fmt.Errorf("unexpected type for %s field: %T", key, expr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unmarshalStatusDetailsField(field *ast.Field) error {
|
||||
if statusField, ok := GetFieldByPath(field, status); ok {
|
||||
func unmarshalField[T ast.Node](field *ast.Field, key string, validator func(T) error) error {
|
||||
if statusField, ok := GetFieldByPath(field, key); ok {
|
||||
basicLit, ok := statusField.Value.(*ast.BasicLit)
|
||||
if !ok || basicLit.Kind != token.STRING {
|
||||
return fmt.Errorf("status.details field is not a string literal")
|
||||
return fmt.Errorf("%s field is not a string literal", key)
|
||||
}
|
||||
|
||||
err := ValidateCueStringLiteral[*ast.StructLit](basicLit, validateStatusField)
|
||||
err := ValidateCueStringLiteral[T](basicLit, validator)
|
||||
if err != nil {
|
||||
return fmt.Errorf("status field failed validation: %w", err)
|
||||
return fmt.Errorf("%s field failed validation: %w", key, err)
|
||||
}
|
||||
|
||||
unquoted := strings.TrimSpace(TrimCueRawString(basicLit.Value))
|
||||
expr, err := parser.ParseExpr("-", WrapCueStruct(unquoted))
|
||||
if err != nil {
|
||||
return fmt.Errorf("unexpected error re-parsing validated string: %w", err)
|
||||
return fmt.Errorf("unexpected error re-parsing validated %s string: %w", key, err)
|
||||
}
|
||||
|
||||
structLit, ok := expr.(*ast.StructLit)
|
||||
if !ok {
|
||||
return fmt.Errorf("expected struct after validation")
|
||||
return fmt.Errorf("expected struct after validation in field %s", key)
|
||||
}
|
||||
|
||||
statusField.Value = structLit
|
||||
|
@ -134,3 +152,55 @@ func validateStatusField(sl *ast.StructLit) error {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCustomStatusField(sl *ast.StructLit) error {
|
||||
validator := func(expr ast.Expr) error {
|
||||
switch v := expr.(type) {
|
||||
case *ast.BasicLit:
|
||||
if v.Kind != token.STRING {
|
||||
return fmt.Errorf("customStatus field 'message' must be a string, got %v", v.Kind)
|
||||
}
|
||||
case *ast.Interpolation, *ast.CallExpr, *ast.SelectorExpr, *ast.Ident, *ast.BinaryExpr, *ast.ParenExpr,
|
||||
*ast.ListLit, *ast.IndexExpr, *ast.SliceExpr, *ast.Comprehension:
|
||||
default:
|
||||
return fmt.Errorf("customStatus field 'message' must be a string expression, got %T", v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
found, err := FindAndValidateField(sl, "message", validator)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("customStatus must contain a 'message' field")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateHealthPolicyField(sl *ast.StructLit) error {
|
||||
validator := func(expr ast.Expr) error {
|
||||
switch v := expr.(type) {
|
||||
case *ast.Ident:
|
||||
case *ast.BasicLit:
|
||||
if v.Kind != token.TRUE && v.Kind != token.FALSE {
|
||||
return fmt.Errorf("healthPolicy field 'isHealth' must be a boolean literal (true/false), got %v", v.Kind)
|
||||
}
|
||||
case *ast.BinaryExpr, *ast.UnaryExpr, *ast.CallExpr, *ast.SelectorExpr, *ast.ParenExpr:
|
||||
default:
|
||||
return fmt.Errorf("healthPolicy field 'isHealth' must be a boolean expression, got %T", v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
found, err := FindAndValidateField(sl, "isHealth", validator)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("healthPolicy must contain an 'isHealth' field")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -229,3 +229,891 @@ func TestMarshalAndUnmarshalMetadata(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalAndUnmarshalHealthPolicy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expectMarshalErr string
|
||||
expectUnmarshalErr string
|
||||
expectContains string
|
||||
}{
|
||||
{
|
||||
name: "valid healthPolicy with boolean literal",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
healthPolicy: {
|
||||
isHealth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectContains: "isHealth",
|
||||
},
|
||||
{
|
||||
name: "valid healthPolicy with binary expression",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
healthPolicy: {
|
||||
isHealth: context.output.status.phase == "Running"
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectContains: "isHealth",
|
||||
},
|
||||
{
|
||||
name: "valid healthPolicy with complex expression",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
healthPolicy: {
|
||||
isHealth: context.output.status.ready && context.output.status.replicas > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectContains: "isHealth",
|
||||
},
|
||||
{
|
||||
name: "valid healthPolicy with selector expression",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
healthPolicy: {
|
||||
isHealth: context.output.status.conditions[0].status
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectContains: "isHealth",
|
||||
},
|
||||
{
|
||||
name: "valid healthPolicy with call expression",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
healthPolicy: {
|
||||
isHealth: len(context.output.status.conditions) > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectContains: "isHealth",
|
||||
},
|
||||
{
|
||||
name: "healthPolicy missing isHealth field",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
healthPolicy: {
|
||||
someOtherField: true
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectMarshalErr: "healthPolicy must contain an 'isHealth' field",
|
||||
},
|
||||
{
|
||||
name: "healthPolicy with invalid isHealth type (struct)",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
healthPolicy: {
|
||||
isHealth: { nested: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectMarshalErr: "healthPolicy field 'isHealth' must be a boolean expression",
|
||||
},
|
||||
{
|
||||
name: "healthPolicy with invalid isHealth type (list)",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
healthPolicy: {
|
||||
isHealth: [true, false]
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectMarshalErr: "healthPolicy field 'isHealth' must be a boolean expression",
|
||||
},
|
||||
{
|
||||
name: "valid stringified healthPolicy round trip",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
healthPolicy: #"""
|
||||
isHealth: context.output.status.phase == "Running"
|
||||
"""#
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectContains: "isHealth",
|
||||
},
|
||||
{
|
||||
name: "malformed stringified healthPolicy fails validation",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
healthPolicy: #"""
|
||||
invalid cue: abc
|
||||
"""#
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectMarshalErr: "invalid cue content in string literal",
|
||||
},
|
||||
{
|
||||
name: "healthPolicy as plain string is valid",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
healthPolicy: "isHealth: true"
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectContains: "isHealth",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
file, err := parser.ParseFile("-", tt.input)
|
||||
require.NoError(t, err)
|
||||
|
||||
var rootField *ast.Field
|
||||
for _, decl := range file.Decls {
|
||||
if f, ok := decl.(*ast.Field); ok {
|
||||
rootField = f
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, rootField)
|
||||
|
||||
err = EncodeMetadata(rootField)
|
||||
if tt.expectMarshalErr != "" {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.expectMarshalErr)
|
||||
return
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
err = DecodeMetadata(rootField)
|
||||
if tt.expectUnmarshalErr != "" {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.expectUnmarshalErr)
|
||||
return
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
if tt.expectContains != "" {
|
||||
healthPolicyField, ok := GetFieldByPath(rootField, "attributes.status.healthPolicy")
|
||||
require.True(t, ok)
|
||||
|
||||
switch v := healthPolicyField.Value.(type) {
|
||||
case *ast.BasicLit:
|
||||
require.Contains(t, v.Value, tt.expectContains)
|
||||
case *ast.StructLit:
|
||||
out, err := format.Node(v)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(out), tt.expectContains)
|
||||
default:
|
||||
t.Fatalf("unexpected healthPolicy value type: %T", v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalAndUnmarshalCustomStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expectMarshalErr string
|
||||
expectUnmarshalErr string
|
||||
expectContains string
|
||||
}{
|
||||
{
|
||||
name: "valid customStatus with string message",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
customStatus: {
|
||||
message: "Service is healthy"
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectContains: "message",
|
||||
},
|
||||
{
|
||||
name: "valid customStatus with interpolation",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
customStatus: {
|
||||
message: "\(context.output.metadata.name) is running"
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectContains: "message",
|
||||
},
|
||||
{
|
||||
name: "valid customStatus with selector expression",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
customStatus: {
|
||||
message: context.output.status.message
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectContains: "message",
|
||||
},
|
||||
{
|
||||
name: "valid customStatus with binary expression",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
customStatus: {
|
||||
message: "Replicas: " + context.output.status.replicas
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectContains: "message",
|
||||
},
|
||||
{
|
||||
name: "valid customStatus with call expression",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
customStatus: {
|
||||
message: strconv.FormatInt(context.output.status.replicas, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectContains: "message",
|
||||
},
|
||||
{
|
||||
name: "valid customStatus with list expression",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
customStatus: {
|
||||
message: [for c in context.output.status.conditions if c.type == "Ready" { c.message }][0]
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectContains: "message",
|
||||
},
|
||||
{
|
||||
name: "customStatus missing message field",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
customStatus: {
|
||||
someOtherField: "value"
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectMarshalErr: "customStatus must contain a 'message' field",
|
||||
},
|
||||
{
|
||||
name: "customStatus with invalid message type (struct)",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
customStatus: {
|
||||
message: { nested: "value" }
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectMarshalErr: "customStatus field 'message' must be a string expression",
|
||||
},
|
||||
{
|
||||
name: "customStatus with integer literal message",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
customStatus: {
|
||||
message: 42
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectMarshalErr: "customStatus field 'message' must be a string",
|
||||
},
|
||||
{
|
||||
name: "valid stringified customStatus round trip",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
customStatus: #"""
|
||||
message: "Pod \(context.output.metadata.name) is running"
|
||||
"""#
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectContains: "message",
|
||||
},
|
||||
{
|
||||
name: "malformed stringified customStatus fails validation",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
customStatus: #"""
|
||||
invalid cue: abc
|
||||
"""#
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectMarshalErr: "invalid cue content in string literal",
|
||||
},
|
||||
{
|
||||
name: "customStatus with additional fields alongside message",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
customStatus: {
|
||||
message: "Service is healthy"
|
||||
severity: "info"
|
||||
timestamp: context.output.metadata.creationTimestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectContains: "message",
|
||||
},
|
||||
{
|
||||
name: "customStatus as plain string is valid",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
customStatus: "message: \"Hello\""
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectContains: "message",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
file, err := parser.ParseFile("-", tt.input)
|
||||
require.NoError(t, err)
|
||||
|
||||
var rootField *ast.Field
|
||||
for _, decl := range file.Decls {
|
||||
if f, ok := decl.(*ast.Field); ok {
|
||||
rootField = f
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, rootField)
|
||||
|
||||
err = EncodeMetadata(rootField)
|
||||
if tt.expectMarshalErr != "" {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.expectMarshalErr)
|
||||
return
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
err = DecodeMetadata(rootField)
|
||||
if tt.expectUnmarshalErr != "" {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.expectUnmarshalErr)
|
||||
return
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
if tt.expectContains != "" {
|
||||
customStatusField, ok := GetFieldByPath(rootField, "attributes.status.customStatus")
|
||||
require.True(t, ok)
|
||||
|
||||
switch v := customStatusField.Value.(type) {
|
||||
case *ast.BasicLit:
|
||||
require.Contains(t, v.Value, tt.expectContains)
|
||||
case *ast.StructLit:
|
||||
out, err := format.Node(v)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(out), tt.expectContains)
|
||||
default:
|
||||
t.Fatalf("unexpected customStatus value type: %T", v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthPolicyEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expectMarshalErr string
|
||||
expectUnmarshalErr string
|
||||
}{
|
||||
{
|
||||
name: "healthPolicy with unary expression",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
healthPolicy: {
|
||||
isHealth: !context.output.status.failed
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "healthPolicy with parenthesized expression",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
healthPolicy: {
|
||||
isHealth: (context.output.status.phase == "Running" || context.output.status.phase == "Succeeded")
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "healthPolicy with nested binary expressions",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
healthPolicy: {
|
||||
isHealth: context.output.status.ready && (context.output.status.replicas > 0 || context.output.status.phase == "Ready")
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "healthPolicy with string literal should fail",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
healthPolicy: {
|
||||
isHealth: "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectMarshalErr: "healthPolicy field 'isHealth' must be a boolean literal (true/false)",
|
||||
},
|
||||
{
|
||||
name: "healthPolicy with comprehension should fail",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
healthPolicy: {
|
||||
isHealth: [for c in context.output.status.conditions if c.type == "Ready" { c.status }][0]
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectMarshalErr: "healthPolicy field 'isHealth' must be a boolean expression",
|
||||
},
|
||||
{
|
||||
name: "healthPolicy with empty struct should fail",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
healthPolicy: {}
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectMarshalErr: "healthPolicy must contain an 'isHealth' field",
|
||||
},
|
||||
{
|
||||
name: "healthPolicy with additional fields is allowed",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
healthPolicy: {
|
||||
isHealth: true
|
||||
reason: "always healthy"
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
file, err := parser.ParseFile("-", tt.input)
|
||||
require.NoError(t, err)
|
||||
|
||||
var rootField *ast.Field
|
||||
for _, decl := range file.Decls {
|
||||
if f, ok := decl.(*ast.Field); ok {
|
||||
rootField = f
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, rootField)
|
||||
|
||||
err = EncodeMetadata(rootField)
|
||||
if tt.expectMarshalErr != "" {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.expectMarshalErr)
|
||||
return
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
err = DecodeMetadata(rootField)
|
||||
if tt.expectUnmarshalErr != "" {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.expectUnmarshalErr)
|
||||
return
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomStatusEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expectMarshalErr string
|
||||
expectUnmarshalErr string
|
||||
}{
|
||||
{
|
||||
name: "customStatus with comprehension expression",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
customStatus: {
|
||||
message: [for i, v in context.output.status.conditions { "\(i): \(v.message)" }][0]
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "customStatus with index expression",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
customStatus: {
|
||||
message: context.output.status.conditions[0].message
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "customStatus with slice expression",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
customStatus: {
|
||||
message: context.output.status.message[0:10]
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "customStatus with parenthesized expression",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
customStatus: {
|
||||
message: ("Status: " + context.output.status.phase)
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "customStatus with nested interpolation",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
customStatus: {
|
||||
message: "Pod \(context.output.metadata.name) has \(context.output.status.replicas) replicas"
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "customStatus with boolean literal should fail",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
customStatus: {
|
||||
message: true
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectMarshalErr: "customStatus field 'message' must be a string",
|
||||
},
|
||||
{
|
||||
name: "customStatus with empty struct should fail",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
customStatus: {}
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectMarshalErr: "customStatus must contain a 'message' field",
|
||||
},
|
||||
{
|
||||
name: "customStatus with only non-message fields should fail",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
customStatus: {
|
||||
severity: "error"
|
||||
code: 500
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectMarshalErr: "customStatus must contain a 'message' field",
|
||||
},
|
||||
{
|
||||
name: "customStatus with field expression should fail",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
customStatus: {
|
||||
message: { template: "Hello" }
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectMarshalErr: "customStatus field 'message' must be a string expression",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
file, err := parser.ParseFile("-", tt.input)
|
||||
require.NoError(t, err)
|
||||
|
||||
var rootField *ast.Field
|
||||
for _, decl := range file.Decls {
|
||||
if f, ok := decl.(*ast.Field); ok {
|
||||
rootField = f
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, rootField)
|
||||
|
||||
err = EncodeMetadata(rootField)
|
||||
if tt.expectMarshalErr != "" {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.expectMarshalErr)
|
||||
return
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
err = DecodeMetadata(rootField)
|
||||
if tt.expectUnmarshalErr != "" {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.expectUnmarshalErr)
|
||||
return
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackwardCompatibility(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{
|
||||
name: "existing worker component healthPolicy format",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
healthPolicy: #"""
|
||||
isHealth: context.output.status.readyReplicas > 0 && context.output.status.readyReplicas == context.output.status.replicas
|
||||
"""#
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "existing worker component customStatus format",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
customStatus: #"""
|
||||
appName: context.appName
|
||||
internal: "\($appName) is running"
|
||||
exposeType: *"" | string
|
||||
if context.outputs.service != _|_ {
|
||||
exposeType: context.outputs.service.spec.type
|
||||
}
|
||||
if exposeType == "ClusterIP" {
|
||||
message: "\(appName) has ClusterIP service"
|
||||
}
|
||||
if exposeType == "NodePort" {
|
||||
message: "\(appName) has NodePort service"
|
||||
}
|
||||
if exposeType == "LoadBalancer" {
|
||||
message: "\(appName) has LoadBalancer service"
|
||||
}
|
||||
if exposeType == "" {
|
||||
message: internal
|
||||
}
|
||||
"""#
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "complex multi-field definition with both healthPolicy and customStatus",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
healthPolicy: #"""
|
||||
isHealth: context.output.status.phase == "Running" || (context.output.status.phase == "Succeeded" && context.output.spec.restartPolicy == "Never")
|
||||
"""#
|
||||
customStatus: #"""
|
||||
ready: [ for c in context.output.status.conditions if c.type == "Ready" { c.status }][0] == "True"
|
||||
message: "Pod \(context.output.metadata.name): phase=\(context.output.status.phase), ready=\(ready)"
|
||||
"""#
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "simple string format for healthPolicy",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
healthPolicy: "isHealth: true"
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "simple string format for customStatus",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
customStatus: "message: \"Service is healthy\""
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "healthPolicy with list comprehensions and complex conditions",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
healthPolicy: #"""
|
||||
conditions: [ for c in context.output.status.conditions if c.type == "Ready" || c.type == "ContainersReady" { c.status }]
|
||||
isHealth: len($conditions) > 0 && ![ for c in conditions if c != "True" { c }] != []
|
||||
"""#
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "customStatus with nested conditionals and string interpolation",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
customStatus: #"""
|
||||
phase: context.output.status.phase
|
||||
replicas: context.output.status.replicas
|
||||
readyReplicas: *0 | int
|
||||
if context.output.status.readyReplicas != _|_ {
|
||||
readyReplicas: context.output.status.readyReplicas
|
||||
}
|
||||
if phase == "Running" {
|
||||
if readyReplicas == replicas {
|
||||
message: "All \(replicas) replicas are ready"
|
||||
}
|
||||
if readyReplicas < replicas {
|
||||
message: "Only \(readyReplicas) of \(replicas) replicas are ready"
|
||||
}
|
||||
}
|
||||
if phase != "Running" {
|
||||
message: "Deployment is in phase: \(phase)"
|
||||
}
|
||||
"""#
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "preserving local fields that start with $",
|
||||
input: `
|
||||
attributes: {
|
||||
status: {
|
||||
customStatus: #"""
|
||||
internal: "internal state"
|
||||
compute: context.output.status.replicas * 2
|
||||
debugInfo: {
|
||||
phase: context.output.status.phase
|
||||
replicas: context.output.status.replicas
|
||||
}
|
||||
message: "Status: \(internal), computed: \(compute)"
|
||||
"""#
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
file, err := parser.ParseFile("-", tt.input)
|
||||
require.NoError(t, err)
|
||||
|
||||
var rootField *ast.Field
|
||||
for _, decl := range file.Decls {
|
||||
if f, ok := decl.(*ast.Field); ok {
|
||||
rootField = f
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, rootField)
|
||||
|
||||
err = EncodeMetadata(rootField)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = DecodeMetadata(rootField)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -104,50 +104,40 @@ func StringifyStructLitAsCueString(structLit *ast.StructLit) (*ast.BasicLit, err
|
|||
}, nil
|
||||
}
|
||||
|
||||
formatted, err := format.Node(structLit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to format struct: %w", err)
|
||||
}
|
||||
|
||||
content := string(formatted)
|
||||
|
||||
content = strings.TrimSpace(content)
|
||||
if strings.HasPrefix(content, "{") && strings.HasSuffix(content, "}") {
|
||||
content = strings.TrimPrefix(content, "{")
|
||||
content = strings.TrimSuffix(content, "}")
|
||||
content = strings.Trim(content, "\n")
|
||||
}
|
||||
|
||||
if content == "" {
|
||||
return &ast.BasicLit{
|
||||
Kind: token.STRING,
|
||||
Value: `"{}"`,
|
||||
}, nil
|
||||
}
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`#"""`)
|
||||
sb.WriteString("\n")
|
||||
|
||||
for _, elt := range structLit.Elts {
|
||||
field, ok := elt.(*ast.Field)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
var labelStr, valueStr string
|
||||
|
||||
switch l := field.Label.(type) {
|
||||
case *ast.Ident:
|
||||
labelStr = l.Name
|
||||
case *ast.BasicLit:
|
||||
labelStr = strings.Trim(l.Value, `"`)
|
||||
default:
|
||||
labelStr = "<unknown>"
|
||||
}
|
||||
|
||||
for _, attr := range field.Attrs {
|
||||
sb.WriteString(" " + attr.Text + "\n")
|
||||
}
|
||||
|
||||
b, err := format.Node(field.Value)
|
||||
if err != nil {
|
||||
valueStr = "<complex>"
|
||||
} else {
|
||||
lines := strings.Split(string(b), "\n")
|
||||
for i := range lines {
|
||||
lines[i] = " " + lines[i]
|
||||
}
|
||||
valueStr = strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf(" %s: %s\n", labelStr, valueStr))
|
||||
}
|
||||
|
||||
sb.WriteString(strings.Join(lines, "\n"))
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(`"""#`)
|
||||
val := strings.ReplaceAll(sb.String(), "\t", " ")
|
||||
|
||||
result := strings.ReplaceAll(sb.String(), "\t", " ")
|
||||
|
||||
return &ast.BasicLit{
|
||||
Kind: token.STRING,
|
||||
Value: val,
|
||||
Value: result,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
@ -177,20 +167,26 @@ func ValidateCueStringLiteral[T ast.Node](lit *ast.BasicLit, validator func(T) e
|
|||
return validator(node)
|
||||
}
|
||||
|
||||
// TrimCueRawString trims a CUE raw string literal
|
||||
// TrimCueRawString trims a CUE raw string literal and handles escape sequences
|
||||
func TrimCueRawString(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasPrefix(s, `#"""`) && strings.HasSuffix(s, `"""#`) {
|
||||
return strings.TrimSuffix(strings.TrimPrefix(s, `#"""`), `"""#`)
|
||||
switch {
|
||||
case strings.HasPrefix(s, `#"""`) && strings.HasSuffix(s, `"""#`):
|
||||
s = strings.TrimSuffix(strings.TrimPrefix(s, `#"""`), `"""#`)
|
||||
case strings.HasPrefix(s, `"""`) && strings.HasSuffix(s, `"""`):
|
||||
s = strings.TrimSuffix(strings.TrimPrefix(s, `"""`), `"""`)
|
||||
default:
|
||||
fallback, err := strconv.Unquote(s)
|
||||
if err == nil {
|
||||
s = fallback
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(s, `"""`) && strings.HasSuffix(s, `"""`) {
|
||||
return strings.TrimSuffix(strings.TrimPrefix(s, `"""`), `"""`)
|
||||
}
|
||||
fallback, err := strconv.Unquote(s)
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
return fallback
|
||||
|
||||
// Handle escape sequences for backward compatibility with existing definitions
|
||||
s = strings.ReplaceAll(s, "\\t", " ")
|
||||
s = strings.ReplaceAll(s, "\\\\", "\\")
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// WrapCueStruct wraps a string in a CUE struct format
|
||||
|
@ -198,6 +194,48 @@ func WrapCueStruct(s string) string {
|
|||
return fmt.Sprintf("{\n%s\n}", s)
|
||||
}
|
||||
|
||||
// FindAndValidateField searches for a field at the top level or within top-level if statements
|
||||
func FindAndValidateField(sl *ast.StructLit, fieldName string, validator fieldValidator) (found bool, err error) {
|
||||
// First check top-level fields
|
||||
for _, elt := range sl.Elts {
|
||||
if field, ok := elt.(*ast.Field); ok {
|
||||
label := GetFieldLabel(field.Label)
|
||||
if label == fieldName {
|
||||
found = true
|
||||
if validator != nil {
|
||||
err = validator(field.Value)
|
||||
}
|
||||
return found, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If not found at top level, check within top-level if statements
|
||||
for _, elt := range sl.Elts {
|
||||
if comp, ok := elt.(*ast.Comprehension); ok {
|
||||
// Check if this comprehension has if clauses (conditional fields)
|
||||
hasIfClause := false
|
||||
for _, clause := range comp.Clauses {
|
||||
if _, ok := clause.(*ast.IfClause); ok {
|
||||
hasIfClause = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If it has an if clause and the value is a struct, search within it
|
||||
if hasIfClause {
|
||||
if structLit, ok := comp.Value.(*ast.StructLit); ok {
|
||||
if innerFound, innerErr := FindAndValidateField(structLit, fieldName, validator); innerFound {
|
||||
return true, innerErr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return found, err
|
||||
}
|
||||
|
||||
func lookupTopLevelField(node ast.Node, key string) (*ast.Field, ast.Expr, bool) {
|
||||
switch n := node.(type) {
|
||||
case *ast.Field:
|
||||
|
@ -220,6 +258,9 @@ func lookupTopLevelField(node ast.Node, key string) (*ast.Field, ast.Expr, bool)
|
|||
return nil, nil, false
|
||||
}
|
||||
|
||||
// fieldValidator is a function that validates a field's value
|
||||
type fieldValidator func(ast.Expr) error
|
||||
|
||||
func traversePath(val ast.Expr, pathParts []string, lastField *ast.Field) (ast.Node, *ast.Field, bool) {
|
||||
currentField := lastField
|
||||
currentVal := val
|
||||
|
|
|
@ -281,6 +281,25 @@ func TestStringifyStructLitAsCueString(t *testing.T) {
|
|||
}`,
|
||||
shouldFail: true,
|
||||
},
|
||||
{
|
||||
name: "Conditional fields with if statements",
|
||||
input: `
|
||||
{
|
||||
if context.status.healthy {
|
||||
message: "Healthy! (\(context.status.details.replicaReadyRatio * 100)% pods running)"
|
||||
}
|
||||
|
||||
if !context.status.healthy {
|
||||
message: "Unhealthy! (\(context.status.details.replicaReadyRatio * 100)% pods running)"
|
||||
}
|
||||
}`,
|
||||
contains: []string{
|
||||
`if context.status.healthy {`,
|
||||
`message: "Healthy! (\(context.status.details.replicaReadyRatio*100)% pods running)"`,
|
||||
`if !context.status.healthy {`,
|
||||
`message: "Unhealthy! (\(context.status.details.replicaReadyRatio*100)% pods running)"`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
@ -536,3 +555,270 @@ func TestWrapCueStruct(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAndValidateField(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
fieldName string
|
||||
expectedFound bool
|
||||
expectedErrMessage string
|
||||
validator fieldValidator
|
||||
}{
|
||||
{
|
||||
name: "find field at top level without validator",
|
||||
input: `{
|
||||
field1: "value1"
|
||||
field2: "value2"
|
||||
}`,
|
||||
fieldName: "field1",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "field not found at any level",
|
||||
input: `{
|
||||
field1: "value1"
|
||||
field2: "value2"
|
||||
}`,
|
||||
fieldName: "field3",
|
||||
expectedFound: false,
|
||||
},
|
||||
{
|
||||
name: "find field at top level with validator that passes",
|
||||
input: `{
|
||||
message: "hello world"
|
||||
other: "value"
|
||||
}`,
|
||||
fieldName: "message",
|
||||
expectedFound: true,
|
||||
validator: func(expr ast.Expr) error {
|
||||
if basicLit, ok := expr.(*ast.BasicLit); ok {
|
||||
if basicLit.Value != `"hello world"` {
|
||||
return fmt.Errorf("expected hello world, got %s", basicLit.Value)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "find field at top level with validator that fails",
|
||||
input: `{
|
||||
message: "hello world"
|
||||
other: "value"
|
||||
}`,
|
||||
fieldName: "message",
|
||||
expectedFound: true,
|
||||
expectedErrMessage: "expected goodbye, got",
|
||||
validator: func(expr ast.Expr) error {
|
||||
if basicLit, ok := expr.(*ast.BasicLit); ok {
|
||||
if basicLit.Value != `"goodbye"` {
|
||||
return fmt.Errorf("expected goodbye, got %s", basicLit.Value)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "find field in simple if statement",
|
||||
input: `{
|
||||
otherField: "value"
|
||||
if condition {
|
||||
message: "found in if"
|
||||
}
|
||||
}`,
|
||||
fieldName: "message",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "find field in nested if statements",
|
||||
input: `{
|
||||
otherField: "value"
|
||||
if condition1 {
|
||||
if condition2 {
|
||||
message: "deeply nested"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
fieldName: "message",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "find field in if-else chain",
|
||||
input: `{
|
||||
status: "running"
|
||||
if status == "running" {
|
||||
message: "service is running"
|
||||
}
|
||||
if status == "stopped" {
|
||||
message: "service is stopped"
|
||||
}
|
||||
}`,
|
||||
fieldName: "message",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "find field in complex nested conditionals",
|
||||
input: `{
|
||||
phase: context.output.status.phase
|
||||
replicas: context.output.status.replicas
|
||||
readyReplicas: *0 | int
|
||||
if context.output.status.readyReplicas != _|_ {
|
||||
readyReplicas: context.output.status.readyReplicas
|
||||
}
|
||||
if phase == "Running" {
|
||||
if readyReplicas == replicas {
|
||||
message: "All replicas are ready"
|
||||
}
|
||||
if readyReplicas < replicas {
|
||||
message: "Some replicas are not ready"
|
||||
}
|
||||
}
|
||||
if phase != "Running" {
|
||||
message: "Deployment is not running"
|
||||
}
|
||||
}`,
|
||||
fieldName: "message",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "find field in if with validator",
|
||||
input: `{
|
||||
condition: true
|
||||
if condition {
|
||||
isHealth: true
|
||||
}
|
||||
}`,
|
||||
fieldName: "isHealth",
|
||||
expectedFound: true,
|
||||
validator: func(expr ast.Expr) error {
|
||||
if basicLit, ok := expr.(*ast.BasicLit); ok {
|
||||
if basicLit.Value != "true" {
|
||||
return fmt.Errorf("expected true, got %s", basicLit.Value)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "find field in nested if with failing validator",
|
||||
input: `{
|
||||
condition: true
|
||||
if condition {
|
||||
if nestedCondition {
|
||||
isHealth: false
|
||||
}
|
||||
}
|
||||
}`,
|
||||
fieldName: "isHealth",
|
||||
expectedFound: true,
|
||||
expectedErrMessage: "expected true, got",
|
||||
validator: func(expr ast.Expr) error {
|
||||
if basicLit, ok := expr.(*ast.BasicLit); ok {
|
||||
if basicLit.Value != "true" {
|
||||
return fmt.Errorf("expected true, got %s", basicLit.Value)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "field not found in comprehension without if clause",
|
||||
input: `{
|
||||
items: [for x in list { value: x }]
|
||||
}`,
|
||||
fieldName: "message",
|
||||
expectedFound: false,
|
||||
},
|
||||
{
|
||||
name: "find field with complex expressions in if",
|
||||
input: `{
|
||||
replicas: context.output.status.replicas
|
||||
readyReplicas: context.output.status.readyReplicas
|
||||
if (replicas | *0) != (readyReplicas | *0) {
|
||||
message: "not ready"
|
||||
}
|
||||
}`,
|
||||
fieldName: "message",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "find field with quoted label in if statement",
|
||||
input: `{
|
||||
condition: true
|
||||
if condition {
|
||||
"field-with-dash": "value"
|
||||
}
|
||||
}`,
|
||||
fieldName: "field-with-dash",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "find field in multiple nested levels",
|
||||
input: `{
|
||||
level1: "value"
|
||||
if condition1 {
|
||||
level2: "value"
|
||||
if condition2 {
|
||||
level3: "value"
|
||||
if condition3 {
|
||||
message: "deeply nested field"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
fieldName: "message",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "empty struct with if statements",
|
||||
input: `{
|
||||
if condition {
|
||||
}
|
||||
}`,
|
||||
fieldName: "message",
|
||||
expectedFound: false,
|
||||
},
|
||||
{
|
||||
name: "field exists in both top level and if statement (finds top level first)",
|
||||
input: `{
|
||||
message: "top level"
|
||||
if condition {
|
||||
message: "in if"
|
||||
}
|
||||
}`,
|
||||
fieldName: "message",
|
||||
expectedFound: true,
|
||||
validator: func(expr ast.Expr) error {
|
||||
if basicLit, ok := expr.(*ast.BasicLit); ok {
|
||||
if basicLit.Value == `"top level"` {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("expected top level message, got %s", basicLit.Value)
|
||||
}
|
||||
return fmt.Errorf("expected string literal")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
expr, err := parser.ParseExpr("-", tt.input)
|
||||
require.NoError(t, err)
|
||||
|
||||
structLit, ok := expr.(*ast.StructLit)
|
||||
require.True(t, ok, "input should parse as struct literal")
|
||||
|
||||
found, err := FindAndValidateField(structLit, tt.fieldName, tt.validator)
|
||||
|
||||
require.Equal(t, tt.expectedFound, found, "found result should match expected")
|
||||
|
||||
if tt.expectedErrMessage != "" {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.expectedErrMessage)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -280,13 +280,13 @@ func TestCueNativeStatusFromCueString(t *testing.T) {
|
|||
}
|
||||
}
|
||||
status: {
|
||||
customStatus: #"""
|
||||
customStatus: {
|
||||
message: "\(context.output.status.readyReplicas) / \(context.output.status.replicas) replicas are ready"
|
||||
"""#
|
||||
}
|
||||
|
||||
healthPolicy: #"""
|
||||
healthPolicy: {
|
||||
isHealth: context.output.status.readyReplicas == context.output.status.replicas
|
||||
"""#
|
||||
}
|
||||
|
||||
details: {
|
||||
$temp: context.output.status.replicas
|
||||
|
|
|
@ -29,11 +29,14 @@ import (
|
|||
. "github.com/onsi/gomega"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
pkgruntime "k8s.io/apimachinery/pkg/runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
|
||||
"github.com/oam-dev/kubevela/apis/core.oam.dev/common"
|
||||
"github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1"
|
||||
"github.com/oam-dev/kubevela/apis/types"
|
||||
pkgdef "github.com/oam-dev/kubevela/pkg/definition"
|
||||
"github.com/oam-dev/kubevela/pkg/oam"
|
||||
"github.com/oam-dev/kubevela/pkg/oam/util"
|
||||
utilcommon "github.com/oam-dev/kubevela/pkg/utils/common"
|
||||
|
@ -260,4 +263,90 @@ var _ = Describe("ComponentDefinition Normal tests", func() {
|
|||
By("Verify application is running")
|
||||
verifyApplicationPhase(context.TODO(), newApp.Namespace, newApp.Name, common.ApplicationRunning)
|
||||
})
|
||||
|
||||
Context("Definition Retrieval and CUE Parsing Validation", func() {
|
||||
It("should successfully parse all definitions loaded from helm chart templates", func() {
|
||||
By("Loading all definition YAML files from charts/vela-core/templates/defwithtemplate")
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
definitionDir := filepath.Join(file, "../../../charts/vela-core/templates/defwithtemplate")
|
||||
|
||||
files, err := filepath.Glob(filepath.Join(definitionDir, "*.yaml"))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(len(files)).To(BeNumerically(">", 0))
|
||||
|
||||
By(fmt.Sprintf("Found %d definition YAML files to test", len(files)))
|
||||
|
||||
// Install all definitions
|
||||
installedDefinitions := []string{}
|
||||
for _, definitionFile := range files {
|
||||
definitionName := strings.TrimSuffix(filepath.Base(definitionFile), ".yaml")
|
||||
By(fmt.Sprintf("Installing definition from %s", definitionName))
|
||||
|
||||
err := testdef.InstallDefinitionFromYAML(ctx, k8sClient, definitionFile, func(s string) string {
|
||||
// Replace helm template placeholders like the existing notification test
|
||||
s = strings.ReplaceAll(s, `{{ include "systemDefinitionNamespace" . }}`, namespace)
|
||||
s = strings.ReplaceAll(s, `{{- include "systemDefinitionNamespace" . }}`, namespace)
|
||||
return s
|
||||
})
|
||||
if err != nil {
|
||||
// Some definitions might fail to install due to dependencies, that's ok
|
||||
fmt.Printf("Warning: Failed to install definition from %s: %v\n", definitionFile, err)
|
||||
} else {
|
||||
installedDefinitions = append(installedDefinitions, definitionName)
|
||||
}
|
||||
}
|
||||
|
||||
By(fmt.Sprintf("Successfully installed %d definitions, now testing CUE parsing", len(installedDefinitions)))
|
||||
|
||||
// Test CUE parsing on all installed ComponentDefinitions
|
||||
By("Testing CUE parsing for ComponentDefinitions")
|
||||
var componentDefs v1beta1.ComponentDefinitionList
|
||||
Expect(k8sClient.List(ctx, &componentDefs, &client.ListOptions{Namespace: namespace})).Should(Succeed())
|
||||
|
||||
componentErrorCount := 0
|
||||
for i := range componentDefs.Items {
|
||||
unstructuredObj, err := pkgruntime.DefaultUnstructuredConverter.ToUnstructured(&componentDefs.Items[i])
|
||||
if err != nil {
|
||||
componentErrorCount++
|
||||
fmt.Printf("ERROR: ComponentDefinition %s failed to convert to unstructured: %v\n", componentDefs.Items[i].Name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
def := &pkgdef.Definition{Unstructured: unstructured.Unstructured{Object: unstructuredObj}}
|
||||
_, err = def.ToCUEString()
|
||||
if err != nil {
|
||||
componentErrorCount++
|
||||
fmt.Printf("ERROR: ComponentDefinition %s failed CUE parsing: %v\n", componentDefs.Items[i].Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Test CUE parsing on all installed TraitDefinitions
|
||||
By("Testing CUE parsing for TraitDefinitions")
|
||||
var traitDefs v1beta1.TraitDefinitionList
|
||||
Expect(k8sClient.List(ctx, &traitDefs, &client.ListOptions{Namespace: namespace})).Should(Succeed())
|
||||
|
||||
traitErrorCount := 0
|
||||
for i := range traitDefs.Items {
|
||||
unstructuredObj, err := pkgruntime.DefaultUnstructuredConverter.ToUnstructured(&traitDefs.Items[i])
|
||||
if err != nil {
|
||||
traitErrorCount++
|
||||
fmt.Printf("ERROR: TraitDefinition %s failed to convert to unstructured: %v\n", traitDefs.Items[i].Name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
def := &pkgdef.Definition{Unstructured: unstructured.Unstructured{Object: unstructuredObj}}
|
||||
_, err = def.ToCUEString()
|
||||
if err != nil {
|
||||
traitErrorCount++
|
||||
fmt.Printf("ERROR: TraitDefinition %s failed CUE parsing: %v\n", traitDefs.Items[i].Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
By(fmt.Sprintf("CUE parsing results: %d ComponentDefinitions tested, %d TraitDefinitions tested",
|
||||
len(componentDefs.Items), len(traitDefs.Items)))
|
||||
|
||||
totalErrors := componentErrorCount + traitErrorCount
|
||||
Expect(totalErrors).To(Equal(0), fmt.Sprintf("%d definitions failed CUE parsing with updated logic", totalErrors))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Reference in New Issue