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: