Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .bingo/golangci-lint.mod
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT

go 1.25.0
go 1.26.0

require github.com/golangci/golangci-lint/v2 v2.7.0 // cmd/golangci-lint
49 changes: 47 additions & 2 deletions docs/adapter-authoring-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,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.
Expand Down Expand Up @@ -1587,10 +1607,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")
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# 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"
Expand Down Expand Up @@ -1628,6 +1663,10 @@ resources.?myResource.?metadata.?name.orValue("").trim()
<summary>Extract a condition status from a Kubernetes-style conditions array</summary>

```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
Expand All @@ -1641,6 +1680,12 @@ resources.?myResource.?status.?conditions.orValue([])
<summary>Check ManifestWork statusFeedback for a namespace phase</summary>

```cel
# Using statusFeedbackValue() helper (preferred):
has(resources.namespace0) && has(resources.namespace0.statusFeedback)
? statusFeedbackValue(resources.namespace0.statusFeedback, "phase")
: ""

# Equivalent verbose form:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
has(resources.namespace0)
&& has(resources.namespace0.statusFeedback)
&& has(resources.namespace0.statusFeedback.values)
Expand Down
52 changes: 52 additions & 0 deletions docs/conventions/cel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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`
Expand Down
171 changes: 171 additions & 0 deletions internal/criteria/cel_evaluator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}),
),
),
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) {
Expand Down
Loading