From 56bc3b02e953725e0e63f0dd75975f0740e643b9 Mon Sep 17 00:00:00 2001 From: Brian Kane Date: Sat, 23 Aug 2025 19:30:06 +0100 Subject: [PATCH] Feat: Consolidate Health & Status and Pass Status Context Data (#6860) Signed-off-by: Brian Kane --- pkg/appfile/appfile.go | 17 +- pkg/appfile/parser.go | 1 - pkg/appfile/template.go | 1 + .../core.oam.dev/v1beta1/application/apply.go | 41 +- pkg/cue/definition/health/health.go | 89 ++++- pkg/cue/definition/health/health_test.go | 375 +++++++++++++++++- pkg/cue/definition/template.go | 11 - pkg/definition/ast/utils.go | 3 + pkg/definition/ast/utils_test.go | 22 + test/e2e-test/application_test.go | 18 +- .../definition/deployment-with-status.yaml | 14 +- .../definition/trait-with-status.yaml | 18 +- 12 files changed, 528 insertions(+), 82 deletions(-) diff --git a/pkg/appfile/appfile.go b/pkg/appfile/appfile.go index a7f4a7a33..211ad07b9 100644 --- a/pkg/appfile/appfile.go +++ b/pkg/appfile/appfile.go @@ -120,15 +120,6 @@ func (comp *Component) EvalStatus(templateContext map[string]interface{}) (*heal return comp.engine.Status(templateContext, comp.FullTemplate.AsStatusRequest(comp.Params)) } -// EvalHealth eval workload health check -func (comp *Component) EvalHealth(templateContext map[string]interface{}) (bool, error) { - // if the health of template is not set or standard workload is managed by trait always return true - if comp.SkipApplyWorkload { - return true, nil - } - return comp.engine.HealthCheck(templateContext, comp.FullTemplate.Health, comp.Params) -} - // Trait is ComponentTrait type Trait struct { // The Name is name of TraitDefinition, actually it's a type of the trait instance @@ -137,7 +128,6 @@ type Trait struct { Params map[string]interface{} Template string - HealthCheckPolicy string CustomStatusFormat string // RequiredSecrets stores secret names which the trait needs from cloud resource component and its context @@ -161,16 +151,11 @@ func (trait *Trait) GetTemplateContext(ctx process.Context, client client.Client return templateContext, err } -// EvalStatus eval trait status +// EvalStatus eval trait status (including health) func (trait *Trait) EvalStatus(templateContext map[string]interface{}) (*health.StatusResult, error) { return trait.engine.Status(templateContext, trait.FullTemplate.AsStatusRequest(trait.Params)) } -// EvalHealth eval trait health check -func (trait *Trait) EvalHealth(templateContext map[string]interface{}) (bool, error) { - return trait.engine.HealthCheck(templateContext, trait.HealthCheckPolicy, trait.Params) -} - // Appfile describes application type Appfile struct { Name string diff --git a/pkg/appfile/parser.go b/pkg/appfile/parser.go index 4cf940249..0e0a0421b 100644 --- a/pkg/appfile/parser.go +++ b/pkg/appfile/parser.go @@ -704,7 +704,6 @@ func (p *Parser) convertTemplate2Trait(name string, properties map[string]interf CapabilityCategory: templ.CapabilityCategory, Params: properties, Template: templ.TemplateStr, - HealthCheckPolicy: templ.Health, CustomStatusFormat: templ.CustomStatus, FullTemplate: templ, engine: definition.NewTraitAbstractEngine(traitName), diff --git a/pkg/appfile/template.go b/pkg/appfile/template.go index 0a4d29515..d326fd48e 100644 --- a/pkg/appfile/template.go +++ b/pkg/appfile/template.go @@ -405,6 +405,7 @@ func ConvertTemplateJSON2Object(capabilityName string, in *runtime.RawExtension, func (t *Template) AsStatusRequest(parameter map[string]interface{}) *health.StatusRequest { return &health.StatusRequest{ + Health: t.Health, Custom: t.CustomStatus, Details: t.Details, Parameter: parameter, diff --git a/pkg/controller/core.oam.dev/v1beta1/application/apply.go b/pkg/controller/core.oam.dev/v1beta1/application/apply.go index cebeaf84c..a6f11c898 100644 --- a/pkg/controller/core.oam.dev/v1beta1/application/apply.go +++ b/pkg/controller/core.oam.dev/v1beta1/application/apply.go @@ -245,14 +245,12 @@ func (h *AppHandler) collectTraitHealthStatus(comp *appfile.Component, tr *appfi if err != nil { return common.ApplicationTraitStatus{}, nil, errors.WithMessagef(err, "app=%s, comp=%s, trait=%s, get template context error", appName, comp.Name, tr.Name) } - if ok, err := tr.EvalHealth(templateContext); !ok || err != nil { - traitStatus.Healthy = false - } if err != nil { return common.ApplicationTraitStatus{}, nil, errors.WithMessagef(err, "app=%s, comp=%s, trait=%s, evaluate status message error", appName, comp.Name, tr.Name) } statusResult, err := tr.EvalStatus(templateContext) if err == nil && statusResult != nil { + traitStatus.Healthy = statusResult.Healthy traitStatus.Message = statusResult.Message traitStatus.Details = statusResult.Details } @@ -264,9 +262,8 @@ func (h *AppHandler) collectWorkloadHealthStatus(ctx context.Context, comp *appf var output *unstructured.Unstructured var outputs []*unstructured.Unstructured var ( - appRev = h.currentAppRev - appName = appRev.Spec.Application.Name - isHealth = true + appRev = h.currentAppRev + appName = appRev.Spec.Application.Name ) if comp.CapabilityCategory == types.TerraformCategory { var configuration terraforv1beta2.Configuration @@ -276,13 +273,13 @@ func (h *AppHandler) collectWorkloadHealthStatus(ctx context.Context, comp *appf if err := h.Client.Get(ctx, client.ObjectKey{Name: comp.Name, Namespace: accessor.Namespace()}, &legacyConfiguration); err != nil { return false, nil, nil, errors.WithMessagef(err, "app=%s, comp=%s, check health error", appName, comp.Name) } - isHealth = setStatus(status, legacyConfiguration.Status.ObservedGeneration, legacyConfiguration.Generation, + setStatus(status, legacyConfiguration.Status.ObservedGeneration, legacyConfiguration.Generation, legacyConfiguration.GetLabels(), appRev.Name, legacyConfiguration.Status.Apply.State, legacyConfiguration.Status.Apply.Message) } else { return false, nil, nil, errors.WithMessagef(err, "app=%s, comp=%s, check health error", appName, comp.Name) } } else { - isHealth = setStatus(status, configuration.Status.ObservedGeneration, configuration.Generation, configuration.GetLabels(), + setStatus(status, configuration.Status.ObservedGeneration, configuration.Generation, configuration.GetLabels(), appRev.Name, configuration.Status.Apply.State, configuration.Status.Apply.Message) } } else { @@ -290,23 +287,24 @@ func (h *AppHandler) collectWorkloadHealthStatus(ctx context.Context, comp *appf if err != nil { return false, nil, nil, errors.WithMessagef(err, "app=%s, comp=%s, get template context error", appName, comp.Name) } - if ok, err := comp.EvalHealth(templateContext); !ok || err != nil { - isHealth = false - } - status.Healthy = isHealth statusResult, err := comp.EvalStatus(templateContext) - if statusResult.Message != "" { - status.Message = statusResult.Message - } - if statusResult.Details != nil { - status.Details = statusResult.Details - } if err != nil { return false, nil, nil, errors.WithMessagef(err, "app=%s, comp=%s, evaluate workload status message error", appName, comp.Name) } + if statusResult != nil { + status.Healthy = statusResult.Healthy + if statusResult.Message != "" { + status.Message = statusResult.Message + } + if statusResult.Details != nil { + status.Details = statusResult.Details + } + } else { + status.Healthy = false + } output, outputs = extractOutputAndOutputs(templateContext) } - return isHealth, output, outputs, nil + return status.Healthy, output, outputs, nil } // nolint @@ -372,7 +370,7 @@ collectNext: } func setStatus(status *common.ApplicationComponentStatus, observedGeneration, generation int64, labels map[string]string, - appRevName string, state terraformtypes.ConfigurationState, message string) bool { + appRevName string, state terraformtypes.ConfigurationState, message string) { isLatest := func() bool { if observedGeneration != 0 && observedGeneration != generation { return false @@ -388,10 +386,9 @@ func setStatus(status *common.ApplicationComponentStatus, observedGeneration, ge status.Message = message if !isLatest() || state != terraformtypes.Available { status.Healthy = false - return false + return } status.Healthy = true - return true } // ApplyPolicies will render policies into manifests from appfile and dispatch them diff --git a/pkg/cue/definition/health/health.go b/pkg/cue/definition/health/health.go index 3668dd88b..ebcbfd50a 100644 --- a/pkg/cue/definition/health/health.go +++ b/pkg/cue/definition/health/health.go @@ -35,12 +35,14 @@ const ( ) type StatusRequest struct { + Health string Custom string Details string Parameter map[string]interface{} } type StatusResult struct { + Healthy bool `json:"healthy"` Message string `json:"message,omitempty"` Details map[string]string `json:"details,omitempty"` } @@ -64,16 +66,33 @@ func CheckHealth(templateContext map[string]interface{}, healthPolicyTemplate st } func GetStatus(templateContext map[string]interface{}, request *StatusRequest) (*StatusResult, error) { - message, msgErr := getStatusMessage(templateContext, request.Custom, request.Parameter) - if msgErr != nil { - klog.Warningf("failed to get status message: %v", msgErr) + if templateContext["status"] == nil { + templateContext["status"] = make(map[string]interface{}) } - statusMap, mapErr := getStatusMap(templateContext, request.Details, request.Parameter) + + templateContext, statusMap, mapErr := getStatusMap(templateContext, request.Details, request.Parameter) if mapErr != nil { klog.Warningf("failed to get status map: %v", mapErr) } + healthy, healthErr := CheckHealth(templateContext, request.Health, request.Parameter) + if healthErr != nil { + klog.Warningf("failed to check health: %v", healthErr) + } + + if statusMap, ok := templateContext["status"].(map[string]interface{}); ok { + statusMap["healthy"] = healthy + } else { + klog.Warningf("templateContext['status'] is not a map[string]interface{}, cannot set healthy field") + } + + message, msgErr := getStatusMessage(templateContext, request.Custom, request.Parameter) + if msgErr != nil { + klog.Warningf("failed to get status message: %v", msgErr) + } + return &StatusResult{ + Healthy: healthy, Message: message, Details: statusMap, }, nil @@ -100,16 +119,20 @@ func getStatusMessage(templateContext map[string]interface{}, customStatusTempla return message, nil } -func getStatusMap(templateContext map[string]interface{}, statusFields string, parameter interface{}) (map[string]string, error) { +func getStatusMap(templateContext map[string]interface{}, statusFields string, parameter interface{}) (map[string]interface{}, map[string]string, error) { status := make(map[string]string) + if templateContext["status"] == nil { + templateContext["status"] = make(map[string]interface{}) + } + if statusFields == "" { - return status, nil + return templateContext, status, nil } runtimeContextBuff, err := formatRuntimeContext(templateContext, parameter) if err != nil { - return status, errors.WithMessage(err, "format runtime context") + return templateContext, status, errors.WithMessage(err, "format runtime context") } cueCtx := cuecontext.New() @@ -117,7 +140,7 @@ func getStatusMap(templateContext map[string]interface{}, statusFields string, p contextVal := cueCtx.CompileString(runtimeContextBuff) iter, err := contextVal.Fields(cue.All()) if err != nil { - return nil, errors.WithMessage(err, "get context fields") + return templateContext, nil, errors.WithMessage(err, "get context fields") } for iter.Next() { contextLabels = append(contextLabels, iter.Label()) @@ -126,13 +149,15 @@ func getStatusMap(templateContext map[string]interface{}, statusFields string, p cueBuffer := runtimeContextBuff + "\n" + statusFields val := cueCtx.CompileString(cueBuffer) if val.Err() != nil { - return nil, errors.WithMessage(val.Err(), "compile status fields template") + return templateContext, nil, errors.WithMessage(val.Err(), "compile status fields template") } iter, err = val.Fields() if err != nil { - return nil, errors.WithMessage(err, "get status fields") + return templateContext, nil, errors.WithMessage(err, "get status fields") } + detailsMap := make(map[string]interface{}) + outer: for iter.Next() { label := iter.Label() @@ -142,22 +167,43 @@ outer: continue // Skip labels that are too long } - if strings.HasPrefix(label, "$") { - continue - } - if slices.Contains(contextLabels, label) { continue // Skip fields that are already in the context } v := iter.Value() + + // Check if field should be excluded via attributes + shouldExclude := false for _, a := range v.Attributes(cue.FieldAttr) { - if a.Name() == "local" || a.Name() == "exclude" { - continue outer + if a.Name() == "local" || a.Name() == "private" { + shouldExclude = true + break } } + // For $ fields, include in context but not in status map + if strings.HasPrefix(label, "$") { + if err = v.Value().Validate(cue.Concrete(true)); err == nil { + var nonStringValue interface{} + if err := v.Value().Decode(&nonStringValue); err == nil { + detailsMap[label] = nonStringValue + } + } + continue // Skip adding to status map + } + + // Skip excluded fields entirely + if shouldExclude { + continue outer + } + if err = v.Value().Validate(cue.Concrete(true)); err == nil { + var nonStringValue interface{} + if err := v.Value().Decode(&nonStringValue); err == nil { + detailsMap[label] = nonStringValue + } + if v.Value().IncompleteKind() == cue.StringKind { status[label], _ = v.Value().String() continue @@ -165,14 +211,21 @@ outer: node := v.Value().Syntax(cue.Final()) b, err := format.Node(node) if err != nil { - return nil, errors.WithMessagef(err, "format status field %s", label) + return templateContext, nil, errors.WithMessagef(err, "format status field %s", label) } status[label] = string(b) } else { status[label] = cue.BottomKind.String() // Use a default value for invalid fields } } - return status, nil + + if statusContext, ok := templateContext["status"].(map[string]interface{}); ok { + statusContext["details"] = detailsMap + } else { + klog.Warningf("templateContext['status'] is not a map[string]interface{}, cannot store details") + } + + return templateContext, status, nil } func formatRuntimeContext(templateContext map[string]interface{}, parameter interface{}) (string, error) { diff --git a/pkg/cue/definition/health/health_test.go b/pkg/cue/definition/health/health_test.go index a87af5d2b..7420baaa2 100644 --- a/pkg/cue/definition/health/health_test.go +++ b/pkg/cue/definition/health/health_test.go @@ -374,7 +374,7 @@ func TestGetStatus(t *testing.T) { parameter: make(map[string]interface{}), statusCue: strings.TrimSpace(` a: 1 @local() - b: 2 @exclude() + b: 2 @private() c: a + b `), expStatus: map[string]string{ @@ -402,7 +402,7 @@ func TestGetStatus(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { - status, err := getStatusMap(tc.tpContext, tc.statusCue, tc.parameter) + _, status, err := getStatusMap(tc.tpContext, tc.statusCue, tc.parameter) if !tc.expErr { assert.NoError(t, err) } @@ -410,3 +410,374 @@ func TestGetStatus(t *testing.T) { }) } } + +func TestContextPassing(t *testing.T) { + cases := map[string]struct { + initialCtx map[string]interface{} + request StatusRequest + expMessage string + expDetails map[string]string + validateCtx func(t *testing.T, ctx map[string]interface{}) + }{ + "basic-context-passing": { + initialCtx: map[string]interface{}{}, + request: StatusRequest{ + Parameter: map[string]interface{}{}, + Details: strings.TrimSpace(` + stringValue: "example" + intValue: 1 + 2 + `), + Custom: strings.TrimSpace(` + message: "\(context.status.details.stringValue) \(context.status.details.intValue)" + `), + }, + expMessage: "example 3", + expDetails: map[string]string{ + "stringValue": "example", + "intValue": "3", + }, + }, + "complex-types-in-context": { + initialCtx: map[string]interface{}{ + "outputs": map[string]interface{}{ + "service": map[string]interface{}{ + "port": 8080, + }, + }, + }, + request: StatusRequest{ + Parameter: map[string]interface{}{ + "replicas": 3, + }, + Details: strings.TrimSpace(` + replicas: parameter.replicas + port: context.outputs.service.port + isReady: parameter.replicas > 0 && context.outputs.service.port > 0 + config: { + enabled: true + timeout: 30 + } @private() + configEnabled: config.enabled + configTimeout: config.timeout + `), + Custom: strings.TrimSpace(` + message: "Service on port \(context.status.details.port) with \(context.status.details.replicas) replicas is ready: \(context.status.details.isReady)" + `), + }, + expMessage: "Service on port 8080 with 3 replicas is ready: true", + expDetails: map[string]string{ + "replicas": "3", + "port": "8080", + "isReady": "true", + "configEnabled": "true", + "configTimeout": "30", + }, + validateCtx: func(t *testing.T, ctx map[string]interface{}) { + statusCtx := ctx["status"].(map[string]interface{}) + details := statusCtx["details"].(map[string]interface{}) + + assert.Equal(t, 3, details["replicas"]) + assert.Equal(t, 8080, details["port"]) + assert.Equal(t, true, details["isReady"]) + assert.Equal(t, true, details["configEnabled"]) + assert.Equal(t, 30, details["configTimeout"]) + + assert.Nil(t, details["config"]) + }, + }, + "array-handling-in-context": { + initialCtx: map[string]interface{}{}, + request: StatusRequest{ + Parameter: map[string]interface{}{}, + Details: strings.TrimSpace(` + $ports: [80, 443, 8080] + $protocols: ["http", "https", "http"] + $mappings: [ + {port: 80, protocol: "http"}, + {port: 443, protocol: "https"} + ] + portCount: len($ports) + firstPort: $ports[0] + mainProtocol: $protocols[0] + portsString: "80,443,8080" + `), + Custom: strings.TrimSpace(` + message: "Serving on \(len(context.status.details.$ports)) ports" + `), + }, + expMessage: "Serving on 3 ports", + expDetails: map[string]string{ + "portCount": "3", + "firstPort": "80", + "mainProtocol": "http", + "portsString": "80,443,8080", + }, + validateCtx: func(t *testing.T, ctx map[string]interface{}) { + statusCtx := ctx["status"].(map[string]interface{}) + details := statusCtx["details"].(map[string]interface{}) + + ports := details["$ports"].([]interface{}) + assert.Len(t, ports, 3) + assert.Equal(t, 80, ports[0]) + assert.Equal(t, 443, ports[1]) + assert.Equal(t, 8080, ports[2]) + + protocols := details["$protocols"].([]interface{}) + assert.Len(t, protocols, 3) + assert.Equal(t, "http", protocols[0]) + assert.Equal(t, "https", protocols[1]) + + mappings := details["$mappings"].([]interface{}) + assert.Len(t, mappings, 2) + + assert.Equal(t, 3, details["portCount"]) + assert.Equal(t, 80, details["firstPort"]) + assert.Equal(t, "http", details["mainProtocol"]) + assert.Equal(t, "80,443,8080", details["portsString"]) + }, + }, + "nested-references": { + initialCtx: map[string]interface{}{ + "appName": "my-app", + }, + request: StatusRequest{ + Parameter: map[string]interface{}{ + "env": "production", + }, + Details: strings.TrimSpace(` + environment: parameter.env + $appInfo: { + name: context.appName + env: parameter.env + fullName: "\(context.appName)-\(parameter.env)" + } + appName: $appInfo.name + appEnv: $appInfo.env + appFullName: $appInfo.fullName + `), + Custom: strings.TrimSpace(` + message: "Deployed \(context.status.details.$appInfo.fullName) to \(context.status.details.environment)" + `), + }, + expMessage: "Deployed my-app-production to production", + expDetails: map[string]string{ + "environment": "production", + "appName": "my-app", + "appEnv": "production", + "appFullName": "my-app-production", + }, + validateCtx: func(t *testing.T, ctx map[string]interface{}) { + statusCtx := ctx["status"].(map[string]interface{}) + details := statusCtx["details"].(map[string]interface{}) + + appInfo := details["$appInfo"].(map[string]interface{}) + assert.Equal(t, "my-app", appInfo["name"]) + assert.Equal(t, "production", appInfo["env"]) + assert.Equal(t, "my-app-production", appInfo["fullName"]) + + assert.Equal(t, "production", details["environment"]) + assert.Equal(t, "my-app", details["appName"]) + assert.Equal(t, "production", details["appEnv"]) + assert.Equal(t, "my-app-production", details["appFullName"]) + }, + }, + "existing-status-preserved": { + initialCtx: map[string]interface{}{ + "status": map[string]interface{}{ + "existingField": "should-be-preserved", + }, + }, + request: StatusRequest{ + Parameter: map[string]interface{}{}, + Details: strings.TrimSpace(` + newField: "added-value" + `), + Custom: strings.TrimSpace(` + message: "Status has existing: \(context.status.existingField)" + `), + }, + expMessage: "Status has existing: should-be-preserved", + expDetails: map[string]string{ + "newField": "added-value", + }, + validateCtx: func(t *testing.T, ctx map[string]interface{}) { + statusCtx := ctx["status"].(map[string]interface{}) + assert.Equal(t, "should-be-preserved", statusCtx["existingField"]) + assert.NotNil(t, statusCtx["details"]) + }, + }, + "dollar-fields-in-context-only": { + initialCtx: map[string]interface{}{}, + request: StatusRequest{ + Parameter: map[string]interface{}{ + "baseValue": 10, + }, + Details: strings.TrimSpace(` + $multiplier: 2 + $offset: 5 + result: parameter.baseValue * $multiplier + $offset + displayText: "Result is \(result)" + `), + Custom: strings.TrimSpace(` + message: "Computed using multiplier \(context.status.details.$multiplier) and offset \(context.status.details.$offset)" + `), + }, + expMessage: "Computed using multiplier 2 and offset 5", + expDetails: map[string]string{ + "result": "25", + "displayText": "Result is 25", + }, + validateCtx: func(t *testing.T, ctx map[string]interface{}) { + statusCtx := ctx["status"].(map[string]interface{}) + details := statusCtx["details"].(map[string]interface{}) + + assert.Equal(t, 2, details["$multiplier"]) + assert.Equal(t, 5, details["$offset"]) + assert.Equal(t, 25, details["result"]) + assert.Equal(t, "Result is 25", details["displayText"]) + }, + }, + "health-check-references-status-details": { + initialCtx: map[string]interface{}{ + "output": map[string]interface{}{ + "status": map[string]interface{}{ + "replicas": 5, + "readyReplicas": 3, + }, + }, + }, + request: StatusRequest{ + Parameter: map[string]interface{}{}, + Details: strings.TrimSpace(` + replicas: context.output.status.replicas + readyReplicas: context.output.status.readyReplicas + percentReady: "\(readyReplicas * 100 / replicas)%" + `), + Health: strings.TrimSpace(` + isHealth: context.status.details.replicas == context.status.details.readyReplicas + `), + Custom: strings.TrimSpace(` + message: "Deployment status: \(context.status.details.percentReady) ready" + `), + }, + expMessage: "Deployment status: 60% ready", + expDetails: map[string]string{ + "replicas": "5", + "readyReplicas": "3", + "percentReady": "60%", + }, + validateCtx: func(t *testing.T, ctx map[string]interface{}) { + statusCtx := ctx["status"].(map[string]interface{}) + assert.Equal(t, false, statusCtx["healthy"]) + details := statusCtx["details"].(map[string]interface{}) + assert.Equal(t, 5, details["replicas"]) + assert.Equal(t, 3, details["readyReplicas"]) + }, + }, + "message-references-health-and-details": { + initialCtx: map[string]interface{}{ + "output": map[string]interface{}{ + "status": map[string]interface{}{ + "phase": "Running", + "replicas": 3, + "readyReplicas": 3, + }, + }, + }, + request: StatusRequest{ + Parameter: map[string]interface{}{}, + Details: strings.TrimSpace(` + phase: context.output.status.phase + replicas: context.output.status.replicas + readyReplicas: context.output.status.readyReplicas + `), + Health: strings.TrimSpace(` + isHealth: context.status.details.phase == "Running" && context.status.details.readyReplicas == context.status.details.replicas + `), + Custom: strings.TrimSpace(` + if context.status.healthy { + message: "Deployment is healthy: \(context.status.details.readyReplicas)/\(context.status.details.replicas) replicas ready" + } + if !context.status.healthy { + message: "Deployment unhealthy: \(context.status.details.readyReplicas)/\(context.status.details.replicas) replicas ready" + } + `), + }, + expMessage: "Deployment is healthy: 3/3 replicas ready", + expDetails: map[string]string{ + "phase": "Running", + "replicas": "3", + "readyReplicas": "3", + }, + validateCtx: func(t *testing.T, ctx map[string]interface{}) { + statusCtx := ctx["status"].(map[string]interface{}) + assert.Equal(t, true, statusCtx["healthy"]) + }, + }, + "complex-health-with-computed-details": { + initialCtx: map[string]interface{}{ + "output": map[string]interface{}{ + "status": map[string]interface{}{ + "capacity": 100, + "used": 85, + "threshold": 80, + }, + }, + }, + request: StatusRequest{ + Parameter: map[string]interface{}{}, + Details: strings.TrimSpace(` + capacity: context.output.status.capacity + used: context.output.status.used + threshold: context.output.status.threshold + $utilization: used * 100.0 / capacity + utilizationPercent: "\($utilization)%" + $overThreshold: $utilization > threshold + `), + Health: strings.TrimSpace(` + isHealth: !context.status.details.$overThreshold + `), + Custom: strings.TrimSpace(` + if context.status.healthy { + message: "Resource usage OK at \(context.status.details.utilizationPercent)" + } + if !context.status.healthy { + message: "Resource usage HIGH at \(context.status.details.utilizationPercent) (threshold: \(context.status.details.threshold)%)" + } + `), + }, + expMessage: "Resource usage HIGH at 85.0% (threshold: 80%)", + expDetails: map[string]string{ + "capacity": "100", + "used": "85", + "threshold": "80", + "utilizationPercent": "85.0%", + }, + validateCtx: func(t *testing.T, ctx map[string]interface{}) { + statusCtx := ctx["status"].(map[string]interface{}) + assert.Equal(t, false, statusCtx["healthy"]) + details := statusCtx["details"].(map[string]interface{}) + assert.Equal(t, float64(85), details["$utilization"]) + assert.Equal(t, true, details["$overThreshold"]) + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + ctx := make(map[string]interface{}) + for k, v := range tc.initialCtx { + ctx[k] = v + } + + result, err := GetStatus(ctx, &tc.request) + assert.NoError(t, err) + assert.Equal(t, tc.expMessage, result.Message) + assert.Equal(t, tc.expDetails, result.Details) + + if tc.validateCtx != nil { + tc.validateCtx(t, ctx) + } + }) + } +} diff --git a/pkg/cue/definition/template.go b/pkg/cue/definition/template.go index d68b180ee..b16e92a56 100644 --- a/pkg/cue/definition/template.go +++ b/pkg/cue/definition/template.go @@ -66,7 +66,6 @@ const ( // AbstractEngine defines Definition's Render interface type AbstractEngine interface { Complete(ctx process.Context, abstractTemplate string, params interface{}) error - HealthCheck(templateContext map[string]interface{}, healthPolicyTemplate string, parameter interface{}) (bool, error) Status(templateContext map[string]interface{}, request *health.StatusRequest) (*health.StatusResult, error) GetTemplateContext(ctx process.Context, cli client.Client, accessor util.NamespaceAccessor) (map[string]interface{}, error) } @@ -205,11 +204,6 @@ func (wd *workloadDef) getTemplateContext(ctx process.Context, cli client.Reader return root, nil } -// HealthCheck address health check for workload -func (wd *workloadDef) HealthCheck(templateContext map[string]interface{}, healthPolicyTemplate string, parameter interface{}) (bool, error) { - return health.CheckHealth(templateContext, healthPolicyTemplate, parameter) -} - // Status get workload status by customStatusTemplate func (wd *workloadDef) Status(templateContext map[string]interface{}, request *health.StatusRequest) (*health.StatusResult, error) { return health.GetStatus(templateContext, request) @@ -410,11 +404,6 @@ func (td *traitDef) Status(templateContext map[string]interface{}, request *heal return health.GetStatus(templateContext, request) } -// HealthCheck address health check for trait -func (td *traitDef) HealthCheck(templateContext map[string]interface{}, healthPolicyTemplate string, parameter interface{}) (bool, error) { - return health.CheckHealth(templateContext, healthPolicyTemplate, parameter) -} - func (td *traitDef) GetTemplateContext(ctx process.Context, cli client.Client, accessor util.NamespaceAccessor) (map[string]interface{}, error) { return td.getTemplateContext(ctx, cli, accessor) } diff --git a/pkg/definition/ast/utils.go b/pkg/definition/ast/utils.go index e499aaadf..ba683cc2b 100644 --- a/pkg/definition/ast/utils.go +++ b/pkg/definition/ast/utils.go @@ -183,6 +183,9 @@ func TrimCueRawString(s string) string { } // Handle escape sequences for backward compatibility with existing definitions + // For quoted strings (after strconv.Unquote): replace actual tab characters + s = strings.ReplaceAll(s, "\t", " ") + // For raw strings (not unquoted): replace literal \t s = strings.ReplaceAll(s, "\\t", " ") s = strings.ReplaceAll(s, "\\\\", "\\") diff --git a/pkg/definition/ast/utils_test.go b/pkg/definition/ast/utils_test.go index f9f8c739b..3dd1658ab 100644 --- a/pkg/definition/ast/utils_test.go +++ b/pkg/definition/ast/utils_test.go @@ -411,6 +411,28 @@ func TestTrimCueRawString(t *testing.T) { input: `"unterminated`, expected: `"unterminated`, }, + { + name: "raw string with tab character", + input: `#"""hello\tworld"""#`, + expected: `hello world`, + }, + { + name: "quoted string with escaped tab", + input: `"hello\tworld"`, + expected: `hello world`, + }, + { + name: "raw string with multiple tabs", + input: `#"""line1\t\tvalue +line2\t\tvalue"""#`, + expected: `line1 value +line2 value`, + }, + { + name: "triple quoted string with tabs", + input: `"""hello\tworld"""`, + expected: `hello world`, + }, } for _, tt := range tests { diff --git a/test/e2e-test/application_test.go b/test/e2e-test/application_test.go index bb1e3c169..1d7ee476b 100644 --- a/test/e2e-test/application_test.go +++ b/test/e2e-test/application_test.go @@ -506,13 +506,25 @@ var _ = Describe("Application Normal tests", func() { By("Checking the initial application status") Expect(app.Status.Services).ShouldNot(BeEmpty()) Expect(app.Status.Services[0].Healthy).Should(BeFalse()) - Expect(app.Status.Services[0].Message).Should(BeEmpty()) + Expect(app.Status.Services[0].Message).Should(Equal(fmt.Sprintf("Unhealthy - 0 / %d replicas are ready", compReplicas))) Expect(app.Status.Services[0].Details["readyReplicas"]).Should(Equal("0")) Expect(app.Status.Services[0].Details["deploymentReady"]).Should(Equal("false")) verifyWorkloadRunningExpected(ctx, namespaceName, compDef.Name, int32(compReplicas), compImage) verifyWorkloadRunningExpected(ctx, namespaceName, traitDef.Name, int32(traitReplicas), traitImage) + By("Triggering application reconciliation to ensure status is updated (to avoid flakiness)") + Eventually(func() error { + if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: app.Namespace, Name: app.Name}, app); err != nil { + return err + } + if app.Annotations == nil { + app.Annotations = make(map[string]string) + } + app.Annotations["force.reconcile"] = fmt.Sprintf("%d", time.Now().Unix()) + return k8sClient.Update(ctx, app) + }, 10*time.Second, 500*time.Millisecond).Should(Succeed()) + By("Waiting for the app to turn healthy") Eventually(func() bool { err := k8sClient.Get(ctx, client.ObjectKey{ @@ -530,13 +542,13 @@ var _ = Describe("Application Normal tests", func() { By("Checking the component status matches expectations") Expect(app.Status.Services[0].Healthy).Should(BeTrue()) - Expect(app.Status.Services[0].Message).Should(Equal(fmt.Sprintf("%v / %v replicas are ready", compReplicas, compReplicas))) + Expect(app.Status.Services[0].Message).Should(Equal(fmt.Sprintf("Healthy - %v / %v replicas are ready", compReplicas, compReplicas))) Expect(app.Status.Services[0].Details["readyReplicas"]).Should(Equal(fmt.Sprintf("%v", compReplicas))) Expect(app.Status.Services[0].Details["deploymentReady"]).Should(Equal("true")) By("Checking the trait status matches expectations") Expect(app.Status.Services[0].Traits[0].Healthy).Should(BeTrue()) - Expect(app.Status.Services[0].Traits[0].Message).Should(Equal(fmt.Sprintf("%v / %v replicas are ready", traitReplicas, traitReplicas))) + Expect(app.Status.Services[0].Traits[0].Message).Should(Equal(fmt.Sprintf("Healthy - %v / %v replicas are ready", traitReplicas, traitReplicas))) Expect(app.Status.Services[0].Traits[0].Details["allReplicasReady"]).Should(Equal("true")) }) }) diff --git a/test/e2e-test/testdata/definition/deployment-with-status.yaml b/test/e2e-test/testdata/definition/deployment-with-status.yaml index 23ad54e2b..f4cac6a5c 100644 --- a/test/e2e-test/testdata/definition/deployment-with-status.yaml +++ b/test/e2e-test/testdata/definition/deployment-with-status.yaml @@ -40,13 +40,19 @@ spec: } } status: - customStatus: | - message: "\(context.output.status.readyReplicas) / \(context.output.status.replicas) replicas are ready" - healthPolicy: | - isHealth: context.output.status.replicas == context.output.status.readyReplicas details: | deploymentReady: *(context.output.status.replicas == context.output.status.readyReplicas) | false + $expectedReplicas: context.output.spec.replicas readyReplicas: *context.output.status.readyReplicas | 0 + healthPolicy: | + isHealth: context.status.details.readyReplicas == context.status.details.$expectedReplicas + customStatus: | + if context.status.healthy { + message: "Healthy - \(context.status.details.readyReplicas) / \(context.status.details.$expectedReplicas) replicas are ready" + } + if !context.status.healthy { + message: "Unhealthy - \(context.status.details.readyReplicas) / \(context.status.details.$expectedReplicas) replicas are ready" + } workload: definition: apiVersion: apps/v1 diff --git a/test/e2e-test/testdata/definition/trait-with-status.yaml b/test/e2e-test/testdata/definition/trait-with-status.yaml index e56d83912..fa8d071f5 100644 --- a/test/e2e-test/testdata/definition/trait-with-status.yaml +++ b/test/e2e-test/testdata/definition/trait-with-status.yaml @@ -45,10 +45,18 @@ spec: } } status: - customStatus: | - message: "\(context.outputs.deployment.status.readyReplicas) / \(context.outputs.deployment.status.replicas) replicas are ready" - healthPolicy: | - isHealth: context.outputs.deployment.status.replicas == context.outputs.deployment.status.readyReplicas details: | - allReplicasReady: *(context.outputs.deployment.status.replicas == context.outputs.deployment.status.readyReplicas) | false + deploymentReady: *(context.outputs.deployment.status.replicas == context.outputs.deployment.status.readyReplicas) | false + $expectedReplicas: context.outputs.deployment.spec.replicas + readyReplicas: *context.outputs.deployment.status.readyReplicas | 0 + allReplicasReady: readyReplicas == $expectedReplicas + healthPolicy: | + isHealth: context.status.details.readyReplicas == context.status.details.$expectedReplicas + customStatus: | + if context.status.healthy { + message: "Healthy - \(context.status.details.readyReplicas) / \(context.status.details.$expectedReplicas) replicas are ready" + } + if !context.status.healthy { + message: "Unhealthy - \(context.status.details.readyReplicas) / \(context.status.details.$expectedReplicas) replicas are ready" + } workloadRefPath: "" \ No newline at end of file