diff --git a/docs/adapter-authoring-guide.md b/docs/adapter-authoring-guide.md index 3825544..237cf81 100644 --- a/docs/adapter-authoring-guide.md +++ b/docs/adapter-authoring-guide.md @@ -439,6 +439,26 @@ preconditions: - `clusterReconciledTTL` → Captures whether the cluster has been Reconciled for >5 minutes (300 seconds) since the last status transition - `validationCheck` → Evaluates both conditions: run resource phase when cluster is NOT Reconciled OR when cluster has been Reconciled and stable for >5 minutes (self-healing) +**Simplified version** using domain-specific CEL helpers: + +```yaml +preconditions: + - name: "checkClusterState" + api_call: + url: "/api/hyperfleet/v1/clusters/{{ .clusterId }}" + capture: + - name: "clusterNotReconciled" + expression: | + conditionStatus(status.conditions, "Reconciled") != "True" + - name: "clusterReconciledTTL" + expression: | + stableFor(status.conditions, "Reconciled", 300) + + - name: "validationCheck" + expression: | + clusterNotReconciled || clusterReconciledTTL +``` + **Important notes:** - The `now()` function returns the current time in RFC3339 format as a string. @@ -1663,10 +1683,25 @@ status.conditions.filter(c, c.type == "Reconciled") # Array existence check status.conditions.exists(c, c.type == "Reconciled" && c.status == "True") -# Get first matching element with fallback +# Get first matching element with fallback (verbose) status.conditions.filter(c, c.type == "Reconciled").size() > 0 ? status.conditions.filter(c, c.type == "Reconciled")[0].status - : "False" + : "Unknown" + +# Same, using domain-specific helper (preferred) +conditionStatus(conditions, "Reconciled") + +# Stability window — condition True for at least 5 minutes +stableFor(conditions, "Reconciled", 300) + +# Tri-state mapping — "True" / "False" / "Unknown" +triState(isReady, isFailed) + +# Maestro statusFeedback value extraction +statusFeedbackValue(statusFeedback, "phase") + +# Condition age in seconds (-1 if absent) +conditionAge(conditions, "Reconciled") # Ternary condition ? "yes" : "no" @@ -1704,6 +1739,10 @@ resources.?myResource.?metadata.?name.orValue("").trim() Extract a condition status from a Kubernetes-style conditions array ```cel +# Using conditionStatus() helper (preferred): +conditionStatus(conditions, "Available") + +# Equivalent verbose form: resources.?myResource.?status.?conditions.orValue([]) .exists(c, c.type == "Available") ? resources.myResource.status.conditions @@ -1717,6 +1756,12 @@ resources.?myResource.?status.?conditions.orValue([]) Check ManifestWork statusFeedback for a namespace phase ```cel +# Using statusFeedbackValue() helper (preferred): +has(resources.namespace0) && has(resources.namespace0.statusFeedback) + ? statusFeedbackValue(resources.namespace0.statusFeedback, "phase") + : "" + +# Equivalent verbose form: has(resources.namespace0) && has(resources.namespace0.statusFeedback) && has(resources.namespace0.statusFeedback.values) diff --git a/docs/conventions/cel.md b/docs/conventions/cel.md index 8408e69..c3d33ab 100644 --- a/docs/conventions/cel.md +++ b/docs/conventions/cel.md @@ -13,10 +13,20 @@ Extracted params are injected as **top-level** names — write `clusterID`, not ## Custom Functions +### Utility + - `now()` — current time as RFC3339 string - `toJson(val)` — serialize any value to JSON string - `dig(map, "dot.path")` — safe nested map access, returns null if missing +### Domain-Specific + +- `conditionStatus(conditions, type)` — returns the `status` field (`"True"`, `"False"`, etc.) of the first condition matching `type`, or `"Unknown"` if absent +- `conditionAge(conditions, type)` — returns elapsed seconds since `last_transition_time` for the matching condition, or `-1` if absent +- `stableFor(conditions, type, seconds)` — returns `true` only when `conditionStatus` is `"True"` AND `conditionAge` is at least the threshold +- `statusFeedbackValue(statusFeedback, name)` — returns `fieldValue.string` of the named Maestro statusFeedback value, or `""` if absent +- `triState(trueCond, falseCond)` — returns `"True"` when first arg is true, `"False"` when second is true, `"Unknown"` otherwise + ## String Extensions `ext.Strings()` is registered — available on string values: @@ -56,6 +66,48 @@ spec.node_pools.sortBy(p, p.replicas).map(p, p.name) status.conditions.map(c, [c.type, c.status]).flatten() ``` +### Before/After: Domain-Specific Functions + +```cel +// Before — double-filter pattern (12+ occurrences per adapter config): +status.conditions.filter(c, c.type == "Reconciled").size() > 0 + ? status.conditions.filter(c, c.type == "Reconciled")[0].status + : "Unknown" + +// After: +conditionStatus(conditions, "Reconciled") +``` + +```cel +// Before — stability window with timestamp arithmetic: +status.conditions.filter(c, c.type == "Reconciled").size() > 0 + && status.conditions.filter(c, c.type == "Reconciled")[0].status == "True" + && (timestamp(now()) - timestamp( + status.conditions.filter(c, c.type == "Reconciled")[0].last_transition_time + )).getSeconds() > 300 + +// After: +stableFor(conditions, "Reconciled", 300) +``` + +```cel +// Before — statusFeedback value extraction (4-step filter chain): +statusFeedback.values.filter(v, v.name == "phase").size() > 0 + ? statusFeedback.values.filter(v, v.name == "phase")[0].fieldValue.string + : "" + +// After: +statusFeedbackValue(statusFeedback, "phase") +``` + +```cel +// Before — nested ternary for tri-state condition: +isReady ? "True" : (isFailed ? "False" : "Unknown") + +// After: +triState(isReady, isFailed) +``` + ## Reference - CEL evaluator: `internal/criteria/cel_evaluator.go` diff --git a/internal/criteria/cel_evaluator.go b/internal/criteria/cel_evaluator.go index 1e8a7e6..6421dcf 100644 --- a/internal/criteria/cel_evaluator.go +++ b/internal/criteria/cel_evaluator.go @@ -139,7 +139,178 @@ func customCELFunctions() []cel.EnvOption { }), ), ), + cel.Function("conditionStatus", + cel.Overload( + "conditionStatus_list_string", + []*cel.Type{cel.ListType(cel.DynType), cel.StringType}, + cel.StringType, + cel.BinaryBinding(func(listArg ref.Val, typeArg ref.Val) ref.Val { + condType, ok := typeArg.Value().(string) + if !ok { + return types.NewErr("conditionStatus() type must be a string") + } + conditions, ok := unwrapCELList(listArg) + if !ok { + return types.NewErr("conditionStatus() conditions must be a list") + } + status, _ := findCondition(conditions, condType) + return types.String(status) + }), + ), + ), + cel.Function("conditionAge", + cel.Overload( + "conditionAge_list_string", + []*cel.Type{cel.ListType(cel.DynType), cel.StringType}, + cel.IntType, + cel.BinaryBinding(func(listArg ref.Val, typeArg ref.Val) ref.Val { + condType, ok := typeArg.Value().(string) + if !ok { + return types.NewErr("conditionAge() type must be a string") + } + conditions, ok := unwrapCELList(listArg) + if !ok { + return types.NewErr("conditionAge() conditions must be a list") + } + _, cond := findCondition(conditions, condType) + if cond == nil { + return types.Int(-1) + } + transitionTime, ok := cond["last_transition_time"].(string) + if !ok { + return types.Int(-1) + } + t, err := time.Parse(time.RFC3339, transitionTime) + if err != nil { + return types.Int(-1) + } + return types.Int(int64(time.Since(t).Seconds())) + }), + ), + ), + cel.Function("stableFor", + cel.Overload( + "stableFor_list_string_int", + []*cel.Type{cel.ListType(cel.DynType), cel.StringType, cel.IntType}, + cel.BoolType, + cel.FunctionBinding(func(args ...ref.Val) ref.Val { + if len(args) != 3 { + return types.NewErr("stableFor() requires 3 arguments") + } + condType, ok := args[1].Value().(string) + if !ok { + return types.NewErr("stableFor() type must be a string") + } + threshold, ok := args[2].Value().(int64) + if !ok { + return types.NewErr("stableFor() seconds must be an int") + } + conditions, ok := unwrapCELList(args[0]) + if !ok { + return types.NewErr("stableFor() conditions must be a list") + } + status, cond := findCondition(conditions, condType) + if status != "True" || cond == nil { + return types.Bool(false) + } + transitionTime, ok := cond["last_transition_time"].(string) + if !ok { + return types.Bool(false) + } + t, err := time.Parse(time.RFC3339, transitionTime) + if err != nil { + return types.Bool(false) + } + return types.Bool(int64(time.Since(t).Seconds()) >= threshold) + }), + ), + ), + cel.Function("statusFeedbackValue", + cel.Overload( + "statusFeedbackValue_dyn_string", + []*cel.Type{cel.DynType, cel.StringType}, + cel.StringType, + cel.BinaryBinding(func(feedbackArg ref.Val, nameArg ref.Val) ref.Val { + name, ok := nameArg.Value().(string) + if !ok { + return types.NewErr("statusFeedbackValue() name must be a string") + } + feedback, ok := unwrapCELValue(feedbackArg) + if !ok { + return types.String("") + } + feedbackMap, ok := feedback.(map[string]interface{}) + if !ok { + return types.String("") + } + values, ok := feedbackMap["values"].([]interface{}) + if !ok { + return types.String("") + } + for _, v := range values { + entry, ok := v.(map[string]interface{}) + if !ok { + continue + } + if entry["name"] == name { + if fv, ok := entry["fieldValue"].(map[string]interface{}); ok { + if s, ok := fv["string"].(string); ok { + return types.String(s) + } + } + } + } + return types.String("") + }), + ), + ), + cel.Function("triState", + cel.Overload( + "triState_bool_bool", + []*cel.Type{cel.BoolType, cel.BoolType}, + cel.StringType, + cel.BinaryBinding(func(trueArg ref.Val, falseArg ref.Val) ref.Val { + if trueArg.Value() == true { + return types.String("True") + } + if falseArg.Value() == true { + return types.String("False") + } + return types.String("Unknown") + }), + ), + ), + } +} + +// findCondition searches a conditions list for a matching type. +// Returns the status string and the condition map if found, or ("Unknown", nil) if absent. +func findCondition(conditions []interface{}, condType string) (string, map[string]interface{}) { + for _, c := range conditions { + cond, ok := c.(map[string]interface{}) + if !ok { + continue + } + if cond["type"] == condType { + if status, ok := cond["status"].(string); ok { + return status, cond + } + return "Unknown", cond + } + } + return "Unknown", nil +} + +// unwrapCELList converts a CEL ref.Val list to a Go []interface{}. +func unwrapCELList(val ref.Val) ([]interface{}, bool) { + raw, ok := unwrapCELValue(val) + if !ok { + return nil, false + } + if list, ok := raw.([]interface{}); ok { + return list, true } + return nil, false } func unwrapCELValue(value ref.Val) (interface{}, bool) { diff --git a/internal/criteria/cel_evaluator_test.go b/internal/criteria/cel_evaluator_test.go index 12bfc32..12f38ad 100644 --- a/internal/criteria/cel_evaluator_test.go +++ b/internal/criteria/cel_evaluator_test.go @@ -372,6 +372,267 @@ func TestCELEvaluatorCustomFunctions(t *testing.T) { }) } +func TestCELEvaluatorDomainFunctions(t *testing.T) { + recentTime := time.Now().Add(-30 * time.Second).Format(time.RFC3339) + oldTime := time.Now().Add(-10 * time.Minute).Format(time.RFC3339) + + ctx := NewEvaluationContext() + ctx.Set("conditions", []interface{}{ + map[string]interface{}{ + "type": "Reconciled", + "status": "True", + "last_transition_time": recentTime, + }, + map[string]interface{}{ + "type": "Available", + "status": "False", + "last_transition_time": oldTime, + }, + }) + ctx.Set("emptyConditions", []interface{}{}) + ctx.Set("statusFeedback", map[string]interface{}{ + "values": []interface{}{ + map[string]interface{}{ + "name": "phase", + "fieldValue": map[string]interface{}{"string": "Active"}, + }, + map[string]interface{}{ + "name": "replicas", + "fieldValue": map[string]interface{}{"string": "3"}, + }, + }, + }) + ctx.Set("emptyFeedback", map[string]interface{}{}) + + evaluator, err := newCELEvaluator(ctx) + require.NoError(t, err) + + t.Run("conditionStatus returns status for existing condition", func(t *testing.T) { + result, err := evaluator.EvaluateSafe(`conditionStatus(conditions, "Reconciled")`) + require.NoError(t, err) + require.False(t, result.HasError()) + assert.Equal(t, "True", result.Value) + }) + + t.Run("conditionStatus returns Unknown for missing condition", func(t *testing.T) { + result, err := evaluator.EvaluateSafe(`conditionStatus(conditions, "MissingType")`) + require.NoError(t, err) + require.False(t, result.HasError()) + assert.Equal(t, "Unknown", result.Value) + }) + + t.Run("conditionStatus returns Unknown for empty list", func(t *testing.T) { + result, err := evaluator.EvaluateSafe(`conditionStatus(emptyConditions, "Reconciled")`) + require.NoError(t, err) + require.False(t, result.HasError()) + assert.Equal(t, "Unknown", result.Value) + }) + + t.Run("conditionStatus returns False for Available condition", func(t *testing.T) { + result, err := evaluator.EvaluateSafe(`conditionStatus(conditions, "Available")`) + require.NoError(t, err) + require.False(t, result.HasError()) + assert.Equal(t, "False", result.Value) + }) + + t.Run("conditionAge returns positive seconds for existing condition", func(t *testing.T) { + result, err := evaluator.EvaluateSafe(`conditionAge(conditions, "Reconciled")`) + require.NoError(t, err) + require.False(t, result.HasError()) + age, ok := result.Value.(int64) + require.True(t, ok) + assert.True(t, age >= 29 && age <= 60, "expected age ~30s, got %d", age) + }) + + t.Run("conditionAge returns -1 for missing condition", func(t *testing.T) { + result, err := evaluator.EvaluateSafe(`conditionAge(conditions, "MissingType")`) + require.NoError(t, err) + require.False(t, result.HasError()) + assert.Equal(t, int64(-1), result.Value) + }) + + t.Run("conditionAge returns -1 for empty list", func(t *testing.T) { + result, err := evaluator.EvaluateSafe(`conditionAge(emptyConditions, "Reconciled")`) + require.NoError(t, err) + require.False(t, result.HasError()) + assert.Equal(t, int64(-1), result.Value) + }) + + t.Run("stableFor returns true when condition is True and age exceeds threshold", func(t *testing.T) { + result, err := evaluator.EvaluateSafe(`stableFor(conditions, "Reconciled", 10)`) + require.NoError(t, err) + require.False(t, result.HasError()) + assert.Equal(t, true, result.Value) + }) + + t.Run("stableFor returns false when age below threshold", func(t *testing.T) { + result, err := evaluator.EvaluateSafe(`stableFor(conditions, "Reconciled", 9999)`) + require.NoError(t, err) + require.False(t, result.HasError()) + assert.Equal(t, false, result.Value) + }) + + t.Run("stableFor returns false when condition is False", func(t *testing.T) { + result, err := evaluator.EvaluateSafe(`stableFor(conditions, "Available", 1)`) + require.NoError(t, err) + require.False(t, result.HasError()) + assert.Equal(t, false, result.Value) + }) + + t.Run("stableFor returns false for missing condition", func(t *testing.T) { + result, err := evaluator.EvaluateSafe(`stableFor(conditions, "MissingType", 1)`) + require.NoError(t, err) + require.False(t, result.HasError()) + assert.Equal(t, false, result.Value) + }) + + t.Run("statusFeedbackValue returns value for existing name", func(t *testing.T) { + result, err := evaluator.EvaluateSafe(`statusFeedbackValue(statusFeedback, "phase")`) + require.NoError(t, err) + require.False(t, result.HasError()) + assert.Equal(t, "Active", result.Value) + }) + + t.Run("statusFeedbackValue returns empty for missing name", func(t *testing.T) { + result, err := evaluator.EvaluateSafe(`statusFeedbackValue(statusFeedback, "missing")`) + require.NoError(t, err) + require.False(t, result.HasError()) + assert.Equal(t, "", result.Value) + }) + + t.Run("statusFeedbackValue returns empty for empty feedback", func(t *testing.T) { + result, err := evaluator.EvaluateSafe(`statusFeedbackValue(emptyFeedback, "phase")`) + require.NoError(t, err) + require.False(t, result.HasError()) + assert.Equal(t, "", result.Value) + }) + + t.Run("triState returns True when first arg true", func(t *testing.T) { + result, err := evaluator.EvaluateSafe(`triState(true, false)`) + require.NoError(t, err) + require.False(t, result.HasError()) + assert.Equal(t, "True", result.Value) + }) + + t.Run("triState returns False when second arg true", func(t *testing.T) { + result, err := evaluator.EvaluateSafe(`triState(false, true)`) + require.NoError(t, err) + require.False(t, result.HasError()) + assert.Equal(t, "False", result.Value) + }) + + t.Run("triState returns Unknown when both false", func(t *testing.T) { + result, err := evaluator.EvaluateSafe(`triState(false, false)`) + require.NoError(t, err) + require.False(t, result.HasError()) + assert.Equal(t, "Unknown", result.Value) + }) + + t.Run("triState returns True when both true (trueCond takes priority)", func(t *testing.T) { + result, err := evaluator.EvaluateSafe(`triState(true, true)`) + require.NoError(t, err) + require.False(t, result.HasError()) + assert.Equal(t, "True", result.Value) + }) + + t.Run("conditionStatus with CEL expressions as arguments", func(t *testing.T) { + result, err := evaluator.EvaluateSafe( + `conditionStatus(conditions, "Reconciled") == "True"`) + require.NoError(t, err) + require.False(t, result.HasError()) + assert.Equal(t, true, result.Value) + }) + + t.Run("triState with CEL expressions replacing nested ternaries", func(t *testing.T) { + result, err := evaluator.EvaluateSafe( + `triState(conditionStatus(conditions, "Reconciled") == "True", ` + + `conditionStatus(conditions, "Reconciled") == "False")`) + require.NoError(t, err) + require.False(t, result.HasError()) + assert.Equal(t, "True", result.Value) + }) +} + +func TestCELEvaluatorDomainFunctions_MalformedInputs(t *testing.T) { + ctx := NewEvaluationContext() + ctx.Set("badTimeConditions", []interface{}{ + map[string]interface{}{ + "type": "Ready", + "status": "True", + "last_transition_time": "not-a-timestamp", + }, + }) + ctx.Set("noTimeConditions", []interface{}{ + map[string]interface{}{ + "type": "Ready", + "status": "True", + }, + }) + ctx.Set("malformedFeedback", map[string]interface{}{ + "values": "not-a-list", + }) + ctx.Set("feedbackBadEntry", map[string]interface{}{ + "values": []interface{}{"not-a-map"}, + }) + ctx.Set("feedbackNoFieldValue", map[string]interface{}{ + "values": []interface{}{ + map[string]interface{}{"name": "phase"}, + }, + }) + + evaluator, err := newCELEvaluator(ctx) + require.NoError(t, err) + + t.Run("conditionAge returns -1 for invalid timestamp", func(t *testing.T) { + result, err := evaluator.EvaluateSafe(`conditionAge(badTimeConditions, "Ready")`) + require.NoError(t, err) + require.False(t, result.HasError()) + assert.Equal(t, int64(-1), result.Value) + }) + + t.Run("conditionAge returns -1 when last_transition_time is missing", func(t *testing.T) { + result, err := evaluator.EvaluateSafe(`conditionAge(noTimeConditions, "Ready")`) + require.NoError(t, err) + require.False(t, result.HasError()) + assert.Equal(t, int64(-1), result.Value) + }) + + t.Run("stableFor returns false for invalid timestamp", func(t *testing.T) { + result, err := evaluator.EvaluateSafe(`stableFor(badTimeConditions, "Ready", 1)`) + require.NoError(t, err) + require.False(t, result.HasError()) + assert.Equal(t, false, result.Value) + }) + + t.Run("stableFor returns false when last_transition_time is missing", func(t *testing.T) { + result, err := evaluator.EvaluateSafe(`stableFor(noTimeConditions, "Ready", 1)`) + require.NoError(t, err) + require.False(t, result.HasError()) + assert.Equal(t, false, result.Value) + }) + + t.Run("statusFeedbackValue returns empty for non-list values", func(t *testing.T) { + result, err := evaluator.EvaluateSafe(`statusFeedbackValue(malformedFeedback, "phase")`) + require.NoError(t, err) + require.False(t, result.HasError()) + assert.Equal(t, "", result.Value) + }) + + t.Run("statusFeedbackValue returns empty for non-map entry", func(t *testing.T) { + result, err := evaluator.EvaluateSafe(`statusFeedbackValue(feedbackBadEntry, "phase")`) + require.NoError(t, err) + require.False(t, result.HasError()) + assert.Equal(t, "", result.Value) + }) + + t.Run("statusFeedbackValue returns empty when fieldValue is missing", func(t *testing.T) { + result, err := evaluator.EvaluateSafe(`statusFeedbackValue(feedbackNoFieldValue, "phase")`) + require.NoError(t, err) + require.False(t, result.HasError()) + assert.Equal(t, "", result.Value) + }) +} + func TestCELEvaluatorExtStrings(t *testing.T) { ctx := NewEvaluationContext() ctx.Set("channelGroup", "candidate") diff --git a/test/testdata/dryrun/cel-showcase/dryrun-cel-showcase-api-responses.json b/test/testdata/dryrun/cel-showcase/dryrun-cel-showcase-api-responses.json index 59c6dd2..ddb82fd 100644 --- a/test/testdata/dryrun/cel-showcase/dryrun-cel-showcase-api-responses.json +++ b/test/testdata/dryrun/cel-showcase/dryrun-cel-showcase-api-responses.json @@ -47,19 +47,22 @@ "type": "Reconciled", "status": "False", "reason": "ClusterProvisioning", - "message": "Cluster is still provisioning" + "message": "Cluster is still provisioning", + "last_transition_time": "2025-01-01T00:00:00Z" }, { "type": "Provisioned", "status": "True", "reason": "InfrastructureReady", - "message": "Infrastructure provisioned successfully" + "message": "Infrastructure provisioned successfully", + "last_transition_time": "2025-01-01T00:00:00Z" }, { "type": "Degraded", "status": "False", "reason": "NoDegradation", - "message": "No degradation detected" + "message": "No degradation detected", + "last_transition_time": "2025-01-01T00:00:00Z" } ] }, diff --git a/test/testdata/dryrun/cel-showcase/dryrun-cel-showcase-task-config.yaml b/test/testdata/dryrun/cel-showcase/dryrun-cel-showcase-task-config.yaml index a324889..96c6d85 100644 --- a/test/testdata/dryrun/cel-showcase/dryrun-cel-showcase-task-config.yaml +++ b/test/testdata/dryrun/cel-showcase/dryrun-cel-showcase-task-config.yaml @@ -180,6 +180,15 @@ params: expression: | fetchCluster.spec.node_pools.sortBy(p, p.replicas).map(p, p.name) + # --------------------------------------------------------------- + # Pattern 21: conditionStatus() — capture conditions list for + # use with domain-specific helpers in the payload phase. + # --------------------------------------------------------------- + - name: "conditions" + source: + expression: | + fetchCluster.status.conditions + # =========================================================================== # Preconditions # =========================================================================== @@ -431,6 +440,46 @@ post: # Pattern 15: Go template rendering for payload values cluster_url: "https://console.{{ .region }}.hyperfleet.io/clusters/{{ .clusterId }}" + # --------------------------------------------------------- + # Pattern 21: conditionStatus() — replaces double-filter + # Before: conditions.filter(c, c.type == "Reconciled").size() > 0 + # ? conditions.filter(c, c.type == "Reconciled")[0].status + # : "Unknown" + # --------------------------------------------------------- + reconciled_status: + expression: | + conditionStatus(conditions, "Reconciled") + + # --------------------------------------------------------- + # Pattern 22: conditionAge() — elapsed seconds since transition + # Before: (timestamp(now()) - timestamp( + # conditions.filter(c, c.type == "Reconciled")[0].last_transition_time + # )).getSeconds() + # --------------------------------------------------------- + reconciled_age_seconds: + expression: | + conditionAge(conditions, "Reconciled") + + # --------------------------------------------------------- + # Pattern 23: stableFor() — stability window check + # Before: conditionStatus == "True" && conditionAge > 300 + # (as a single combined expression with all the filter chains) + # --------------------------------------------------------- + is_reconciled_stable: + expression: | + stableFor(conditions, "Reconciled", 60) + + # --------------------------------------------------------- + # Pattern 24: triState() — replaces nested ternaries + # Before: isReady ? "True" : (isFailed ? "False" : "Unknown") + # --------------------------------------------------------- + health_status: + expression: | + triState( + conditionStatus(conditions, "Reconciled") == "True", + conditionStatus(conditions, "Reconciled") == "False" + ) + post_actions: - name: "updateStatus" when: