diff --git a/docs/conventions/cel.md b/docs/conventions/cel.md index b8af1ca..5e22719 100644 --- a/docs/conventions/cel.md +++ b/docs/conventions/cel.md @@ -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: + +- `.distinct()` — remove duplicate elements +- `.sort()` — sort comparable elements (string, int, bool, …) +- `.sortBy(e, keyExpr)` — sort objects by a derived key expression +- `.slice(start, end)` — sub-list from `start` (inclusive) to `end` (exclusive) +- `.flatten()` — recursively collapse nested lists; `flatten(depth)` limits depth +- `lists.range(n)` — generate `[0, 1, …, n-1]` + ## Examples ```cel @@ -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` diff --git a/internal/criteria/cel_evaluator.go b/internal/criteria/cel_evaluator.go index 62b1857..1e8a7e6 100644 --- a/internal/criteria/cel_evaluator.go +++ b/internal/criteria/cel_evaluator.go @@ -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()) options = append(options, customCELFunctions()...) // Get a snapshot of the data for thread safety diff --git a/internal/criteria/cel_evaluator_test.go b/internal/criteria/cel_evaluator_test.go index 0940fc8..12bfc32 100644 --- a/internal/criteria/cel_evaluator_test.go +++ b/internal/criteria/cel_evaluator_test.go @@ -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" @@ -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))) + }) +} + // 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) { 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 3731baf..379d500 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 @@ -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) params: - name: "clusterId" @@ -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" @@ -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 # ---------------------------------------------------------