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
22 changes: 21 additions & 1 deletion docs/conventions/cel.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ Extracted params are injected as **top-level** names — write `clusterID`, not

`charAt`, `indexOf`, `lastIndexOf`, `lowerAscii`, `replace`, `split`, `substring`, `trim`, `upperAscii`, `join`

## List Extensions

`ext.Lists()` is registered — available on list values:

- `<list>.distinct()` — remove duplicate elements
- `<list>.sort()` — sort comparable elements (string, int, bool, …)
- `<list>.sortBy(e, keyExpr)` — sort objects by a derived key expression
- `<list>.slice(start, end)` — sub-list from `start` (inclusive) to `end` (exclusive)
- `<list>.flatten()` — recursively collapse nested lists; `flatten(depth)` limits depth
- `lists.range(n)` — generate `[0, 1, …, n-1]`
Comment thread
rh-amarin marked this conversation as resolved.

## Examples

```cel
Expand All @@ -32,10 +43,19 @@ adapter.?executionStatus.orValue("") == "success"

// Post-action gate: skip when resources were skipped
adapter.?resourcesSkipped.orValue(false)

// Deduplicate and sort a tag list
spec.tags.distinct().sort()

// Sort node pools by replica count, extract names
spec.node_pools.sortBy(p, p.replicas).map(p, p.name)

// Flatten condition type+status pairs into one list
status.conditions.map(c, [c.type, c.status]).flatten()
```

## Reference

- CEL evaluator: `internal/criteria/cel_evaluator.go`
- Custom functions registered: `internal/criteria/cel_evaluator.go:71` (`ext.Strings()`)
- Custom functions registered: `internal/criteria/cel_evaluator.go:71` (`ext.Strings()`, `ext.Lists()`)
- CEL validation at config load: `internal/configloader/validator.go`
1 change: 1 addition & 0 deletions internal/criteria/cel_evaluator.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ func buildCELOptions(ctx *EvaluationContext) []cel.EnvOption {
// Enable optional types for optional chaining syntax (e.g., a.?b.?c)
options = append(options, cel.OptionalTypes())
options = append(options, ext.Strings())
options = append(options, ext.Lists())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Cross-component CEL drift risks policy mismatch (CWE-436/CWE-693).

Line 72 enables ext.Lists() in Adapter, but linked findings show Sentinel still building CEL env without ext.Lists(). That creates evaluator divergence where the same expression can pass compile/eval here and fail in Sentinel. For gate/policy paths, this is a major integration risk; align env options across components or gate rollout behind shared compatibility.

As per coding guidelines, “Validate changes against HyperFleet architecture standards from the linked architecture repository” and “Prioritize Critical and Major severity issues.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/criteria/cel_evaluator.go` at line 72, The Adapter is enabling
ext.Lists() (options = append(options, ext.Lists())) which causes CEL
environment drift versus Sentinel’s CEL builder; to fix, ensure both evaluators
use the same CEL options by either adding ext.Lists() to the Sentinel CEL
environment construction or gating Adapter’s use of ext.Lists() behind a shared
compatibility flag used by both components; update the Sentinel CEL builder (the
function that constructs its CEL options) to include ext.Lists() or add a
centralized feature flag that both Adapter and Sentinel check before appending
ext.Lists(), and run the CEL compatibility tests to verify parity.

Sources: Coding guidelines, Linked repositories

options = append(options, customCELFunctions()...)

// Get a snapshot of the data for thread safety
Expand Down
67 changes: 67 additions & 0 deletions internal/criteria/cel_evaluator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"testing"
"time"

"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
"github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/logger"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -390,6 +392,71 @@ func TestCELEvaluatorExtStrings(t *testing.T) {
})
}

func TestCELEvaluatorExtLists(t *testing.T) {
ctx := NewEvaluationContext()
ctx.Set("tags", []interface{}{"production", "tier-1", "us-east", "tier-1"})
ctx.Set("nodePools", []interface{}{
map[string]interface{}{"name": "worker-pool", "replicas": int64(3)},
map[string]interface{}{"name": "infra-pool", "replicas": int64(2)},
})
ctx.Set("nested", []interface{}{
[]interface{}{"a", "b"},
[]interface{}{"c", "d"},
})

evaluator, err := newCELEvaluator(ctx)
require.NoError(t, err)

t.Run("distinct removes duplicates", func(t *testing.T) {
result, err := evaluator.EvaluateSafe(`tags.distinct()`)
require.NoError(t, err)
require.False(t, result.HasError())
vals, ok := result.Value.([]ref.Val)
require.True(t, ok)
assert.Len(t, vals, 3)
})

t.Run("sort orders strings alphabetically", func(t *testing.T) {
result, err := evaluator.EvaluateSafe(`tags.distinct().sort()`)
require.NoError(t, err)
require.False(t, result.HasError())
vals, ok := result.Value.([]ref.Val)
require.True(t, ok)
assert.Equal(t, "production", string(vals[0].(types.String)))
assert.Equal(t, "tier-1", string(vals[1].(types.String)))
assert.Equal(t, "us-east", string(vals[2].(types.String)))
})

t.Run("slice returns sub-list", func(t *testing.T) {
result, err := evaluator.EvaluateSafe(`tags.slice(0, 2)`)
require.NoError(t, err)
require.False(t, result.HasError())
vals, ok := result.Value.([]ref.Val)
require.True(t, ok)
assert.Len(t, vals, 2)
})

t.Run("flatten collapses nested lists", func(t *testing.T) {
result, err := evaluator.EvaluateSafe(`nested.flatten()`)
require.NoError(t, err)
require.False(t, result.HasError())
vals, ok := result.Value.([]ref.Val)
require.True(t, ok)
assert.Len(t, vals, 4)
})

t.Run("sortBy orders objects by field", func(t *testing.T) {
result, err := evaluator.EvaluateSafe(`nodePools.sortBy(p, p.name).map(p, p.name)`)
require.NoError(t, err)
require.False(t, result.HasError())
vals, ok := result.Value.([]ref.Val)
require.True(t, ok)
require.Len(t, vals, 2)
assert.Equal(t, "infra-pool", string(vals[0].(types.String)))
assert.Equal(t, "worker-pool", string(vals[1].(types.String)))
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// TestEvaluateSafeErrorHandling tests how EvaluateSafe handles various error scenarios
// and how callers can use the result to make decisions at a higher level
func TestEvaluateSafeErrorHandling(t *testing.T) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
# 13. toJson() — JSON serialization (new helper)
# 14. adapter.? — adapter execution metadata access
# 15. Go templates — template rendering with {{ }}
# 16. distinct() — deduplicate list elements (ext.Lists)
# 17. sort() — sort list elements (ext.Lists)
# 18. slice() — extract a sub-list by index range (ext.Lists)
# 19. flatten() — collapse nested lists into one (ext.Lists)
# 20. sortBy() — sort objects by a derived key (ext.Lists)
Comment on lines +23 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

CEL list extension version skew with hyperfleet-sentinel.

Sentinel's decision.go and payload/builder.go register ext.Strings() but not ext.Lists(). ADR 0006 defines CEL as a shared expression engine. Configs using .distinct(), .sort(), .sortBy(), .slice(), or .flatten() will fail in Sentinel with undeclared reference errors until its CEL environment is updated to match.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/testdata/dryrun/cel-showcase/dryrun-cel-showcase-task-config.yaml`
around lines 23 - 27, Sentinel's CEL environment registers ext.Strings() but not
ext.Lists(), causing configs that use .distinct(), .sort(), .sortBy(), .slice(),
or .flatten() to fail; update the CEL environment initialization (the same
location that registers ext.Strings() in decision.go and payload/builder.go) to
also register ext.Lists(), rebuild the CEL environment to match ADR 0006's
shared expression engine expectation, and run unit/integration tests to validate
that expressions using distinct/sort/sortBy/slice/flatten no longer produce
undeclared reference errors.

Source: Linked repositories


params:
- name: "clusterId"
Expand Down Expand Up @@ -141,6 +146,46 @@ preconditions:
- name: "timestamp"
expression: "\"2006-01-02T15:04:05Z07:00\""

# ---------------------------------------------------------------
# Pattern 16: distinct() — deduplicate list elements
# Removes duplicate entries from the tags list.
# ---------------------------------------------------------------
- name: "uniqueTags"
expression: |
spec.tags.distinct()

# ---------------------------------------------------------------
# Pattern 17: sort() — sort list elements alphabetically
# Sorts the deduplicated tags list in ascending order.
# ---------------------------------------------------------------
- name: "sortedTags"
expression: |
spec.tags.distinct().sort()

# ---------------------------------------------------------------
# Pattern 18: slice() — extract a sub-list by index range
# Returns the first node pool name only (indices 0..1 exclusive).
# ---------------------------------------------------------------
- name: "firstNodePoolName"
expression: |
spec.node_pools.map(p, p.name).slice(0, 1)

# ---------------------------------------------------------------
# Pattern 19: flatten() — collapse nested lists into one
# Extracts type and status from each condition into a flat list.
# ---------------------------------------------------------------
- name: "conditionSummary"
expression: |
status.conditions.map(c, [c.type, c.status]).flatten()

# ---------------------------------------------------------------
# Pattern 20: sortBy() — sort objects by a derived key
# Sorts node pools by replica count ascending, then extracts names.
# ---------------------------------------------------------------
- name: "nodePoolsByReplicas"
expression: |
spec.node_pools.sortBy(p, p.replicas).map(p, p.name)

conditions:
- field: "clusterStatus"
operator: "notEquals"
Expand Down Expand Up @@ -350,6 +395,33 @@ post:
expression: |
dig(resources, "configmap1.data.endpoint")

# ---------------------------------------------------------
# Pattern 16-20 (payload): ext.Lists functions via captured variables
# Note: raw API fields (spec.*, status.*) are not in scope here —
# reference the variables captured in the precondition phase instead.
# ---------------------------------------------------------
lists:
unique_tags:
# Pattern 16: distinct() — captured uniqueTags list as JSON
expression: |
toJson(uniqueTags)
sorted_tags:
# Pattern 17: sort() — captured sortedTags list as JSON
expression: |
toJson(sortedTags)
first_pool:
# Pattern 18: slice() — first element from captured firstNodePoolName
expression: |
firstNodePoolName[0]
condition_summary:
# Pattern 19: flatten() — captured conditionSummary flat list as JSON
expression: |
toJson(conditionSummary)
pools_by_replicas:
# Pattern 20: sortBy() — captured nodePoolsByReplicas list as JSON
expression: |
toJson(nodePoolsByReplicas)

# ---------------------------------------------------------
# Pattern 9 (payload): nested ternary chains — multi-level branching
# ---------------------------------------------------------
Expand Down