-
Notifications
You must be signed in to change notification settings - Fork 24
HYPERFLEET-538 - feat: CEL-based condition mapping engine #315
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
02b9dcf
18224b7
e78dd41
9dad315
668556c
c137ceb
53ea847
65ccab5
c903cd9
6c463ca
b836d57
a17e0aa
293a58c
5634ae9
80c2d7a
a24c899
909ecd9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -105,6 +105,40 @@ health: | |||||||||||
| # Entity Registration | ||||||||||||
| # Generic resource types registered at startup. Each entry auto-generates | ||||||||||||
| # REST endpoints, spec validation, and delete policies. | ||||||||||||
| # | ||||||||||||
| # Condition Mapping (HYPERFLEET-538): | ||||||||||||
| # Each entity can define CEL-based condition mapping rules that expose | ||||||||||||
| # provider-specific adapter conditions in the public status.conditions array. | ||||||||||||
| # | ||||||||||||
| # Rules are compiled at startup (fail-fast). Invalid CEL expressions prevent API startup. | ||||||||||||
| # Evaluation happens during status aggregation. Unknown adapter conditions are filtered. | ||||||||||||
|
Comment on lines
+113
to
+114
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π Maintainability & Code Quality | π‘ Minor | β‘ Quick win Line 114 contradicts line 123. Line 114 states that unknown adapter conditions are filtered. The implementation in π Proposed fix # Rules are compiled at startup (fail-fast). Invalid CEL expressions prevent API startup.
-# Evaluation happens during status aggregation. Unknown adapter conditions are filtered.
+# Evaluation happens during status aggregation. An adapter reporting any Unknown
+# condition is excluded from `statuses` entirely (see CEL Context Variables below).π Committable suggestion
Suggested change
π€ Prompt for AI Agents |
||||||||||||
| # | ||||||||||||
| # Reserved condition types (cannot be overridden by mapping): | ||||||||||||
| # - Reconciled | ||||||||||||
| # - LastKnownReconciled | ||||||||||||
| # - Per-adapter synthesized types (auto-generated from required_adapters): | ||||||||||||
| # Example: "validation" adapter β "ValidationSuccessful" condition type | ||||||||||||
| # | ||||||||||||
| # CEL Context Variables: | ||||||||||||
| # - statuses: array of adapter statuses (adapter entries with any Unknown condition are excluded entirely) | ||||||||||||
| # Each status: adapter (string), observed_generation (number), conditions (array), data (map) | ||||||||||||
| # - resource: full cluster/nodepool object as map (sensitive fields masked) | ||||||||||||
| # - env: environment variables as map (currently always empty, future enhancement) | ||||||||||||
| # | ||||||||||||
| # Custom CEL Functions: | ||||||||||||
| # - toJson(value): marshal to JSON string | ||||||||||||
| # - dig(target, "dot.path"): safe nested navigation | ||||||||||||
| # | ||||||||||||
| # Security: Adapter data fields matching sensitive patterns (password, secret, token, | ||||||||||||
| # auth, private, connection, cert, credential, etc.) are automatically masked with | ||||||||||||
| # "***REDACTED***" before CEL evaluation. This prevents credential leakage in public | ||||||||||||
| # condition messages/reasons. See pkg/util/mask_sensitive.go for the full pattern list. | ||||||||||||
| # | ||||||||||||
| # Field Length Constraints: | ||||||||||||
| # - type: 128 bytes (validation error if exceeded, prevents startup) | ||||||||||||
| # - reason: 256 bytes (truncated if exceeded) | ||||||||||||
| # - message: 2048 bytes (truncated if exceeded) | ||||||||||||
| # | ||||||||||||
| entities: | ||||||||||||
|
Comment on lines
+108
to
142
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would decouple this block somewhere into /docs and instead, eg. |
||||||||||||
| - kind: Cluster | ||||||||||||
| plural: clusters | ||||||||||||
|
|
@@ -115,6 +149,28 @@ entities: | |||||||||||
| name_max_len: 53 | ||||||||||||
| require_spec_schema: true | ||||||||||||
|
|
||||||||||||
| # CEL-based condition mapping rules (HYPERFLEET-538) | ||||||||||||
| # Maps adapter conditions to public API conditions | ||||||||||||
| # See inline comments below for detailed documentation | ||||||||||||
| conditions: [] | ||||||||||||
| # Example: Expose Landing Zone namespace readiness | ||||||||||||
| # - type: LandingZoneReady | ||||||||||||
| # when: | ||||||||||||
| # expression: 'statuses.exists(s, s.adapter == "landing-zone-adapter" && s.conditions.exists(c, c.type == "NamespaceReady"))' | ||||||||||||
| # output: | ||||||||||||
| # status: | ||||||||||||
| # expression: | | ||||||||||||
| # statuses.filter(s, s.adapter == "landing-zone-adapter")[0] | ||||||||||||
| # .conditions.filter(c, c.type == "NamespaceReady")[0].status | ||||||||||||
| # reason: | ||||||||||||
| # expression: | | ||||||||||||
| # statuses.filter(s, s.adapter == "landing-zone-adapter")[0] | ||||||||||||
| # .conditions.filter(c, c.type == "NamespaceReady")[0].reason | ||||||||||||
| # message: | ||||||||||||
| # expression: | | ||||||||||||
| # "Landing zone: " + statuses.filter(s, s.adapter == "landing-zone-adapter")[0] | ||||||||||||
| # .conditions.filter(c, c.type == "NamespaceReady")[0].message | ||||||||||||
|
|
||||||||||||
| - kind: NodePool | ||||||||||||
| plural: nodepools | ||||||||||||
| parent_kind: Cluster | ||||||||||||
|
|
@@ -126,6 +182,26 @@ entities: | |||||||||||
| name_max_len: 15 | ||||||||||||
| require_spec_schema: true | ||||||||||||
|
|
||||||||||||
| # CEL-based condition mapping rules (HYPERFLEET-538) | ||||||||||||
| conditions: [] | ||||||||||||
| # Example: Expose Validation quota check status | ||||||||||||
| # - type: QuotaValid | ||||||||||||
| # when: | ||||||||||||
| # expression: 'statuses.exists(s, s.adapter == "validation-adapter" && s.conditions.exists(c, c.type == "QuotaSufficient"))' | ||||||||||||
| # output: | ||||||||||||
| # status: | ||||||||||||
| # expression: | | ||||||||||||
| # statuses.filter(s, s.adapter == "validation-adapter")[0] | ||||||||||||
| # .conditions.filter(c, c.type == "QuotaSufficient")[0].status | ||||||||||||
| # reason: | ||||||||||||
| # expression: | | ||||||||||||
| # statuses.filter(s, s.adapter == "validation-adapter")[0] | ||||||||||||
| # .conditions.filter(c, c.type == "QuotaSufficient")[0].reason | ||||||||||||
| # message: | ||||||||||||
| # expression: | | ||||||||||||
| # statuses.filter(s, s.adapter == "validation-adapter")[0] | ||||||||||||
| # .conditions.filter(c, c.type == "QuotaSufficient")[0].message | ||||||||||||
|
|
||||||||||||
| - kind: Channel | ||||||||||||
| plural: channels | ||||||||||||
| spec_schema_name: ChannelSpec | ||||||||||||
|
|
@@ -142,7 +218,6 @@ entities: | |||||||||||
| plural: wifconfigs | ||||||||||||
| spec_schema_name: WifConfigSpec | ||||||||||||
|
|
||||||||||||
|
|
||||||||||||
| # ---------------------------------------------------------------------------- | ||||||||||||
| # Configuration Priority (highest to lowest): | ||||||||||||
| # 1. Command-line flags (e.g., --server-host=0.0.0.0 --server-port=8000) | ||||||||||||
|
|
||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,6 +12,7 @@ require ( | |
| github.com/go-gormigrate/gormigrate/v2 v2.1.6 | ||
| github.com/go-playground/validator/v10 v10.30.3 | ||
| github.com/golang-jwt/jwt/v5 v5.3.1 | ||
| github.com/google/cel-go v0.29.0 | ||
| github.com/google/uuid v1.6.0 | ||
| github.com/jinzhu/inflection v1.0.0 | ||
| github.com/lib/pq v1.12.3 | ||
|
|
@@ -43,6 +44,8 @@ require ( | |
| ) | ||
|
|
||
| require ( | ||
| cel.dev/expr v0.25.1 // indirect | ||
| github.com/antlr4-go/antlr/v4 v4.13.1 // indirect | ||
| github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect | ||
| github.com/cenkalti/backoff/v5 v5.0.3 // indirect | ||
| github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect | ||
|
|
@@ -52,6 +55,7 @@ require ( | |
| go.opentelemetry.io/contrib/propagators/jaeger v1.44.0 // indirect | ||
| go.opentelemetry.io/contrib/propagators/ot v1.44.0 // indirect | ||
| go.uber.org/multierr v1.11.0 // indirect | ||
| golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect | ||
| golang.org/x/time v0.15.0 // indirect | ||
| ) | ||
|
|
||
|
|
@@ -140,6 +144,6 @@ require ( | |
| google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect | ||
| google.golang.org/grpc v1.82.0 // indirect | ||
| google.golang.org/protobuf v1.36.11 // indirect | ||
| gopkg.in/yaml.v3 v3.0.1 // indirect | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. go mod tidy bringing it back to indirect |
||
| gopkg.in/yaml.v3 v3.0.1 | ||
| gorm.io/driver/mysql v1.6.0 // indirect | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -187,6 +187,7 @@ func (l *ConfigLoader) validateConfig(config *ApplicationConfig) error { | |
| if valErr := config.Metrics.Validate(); valErr != nil { | ||
| return fmt.Errorf("metrics config validation failed: %w", valErr) | ||
| } | ||
| // Conditions validation now happens in registry.Validate() after entity descriptors are loaded | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need this comment? (can be easily forgotten if anything changes which could make this statement not true) |
||
| return nil | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,202 @@ | ||
| package registry | ||
|
|
||
| import ( | ||
| "fmt" | ||
|
|
||
| "github.com/google/cel-go/cel" | ||
|
|
||
| "github.com/openshift-hyperfleet/hyperfleet-api/pkg/util" | ||
| ) | ||
|
|
||
| // Reserved condition types that cannot be overridden by mapping rules | ||
| // Using string literals to avoid import cycle with pkg/api | ||
| var reservedConditionTypes = map[string]bool{ | ||
| "Reconciled": true, // api.ResourceConditionTypeReconciled | ||
| "LastKnownReconciled": true, // api.ResourceConditionTypeLastKnownReconciled | ||
| } | ||
|
|
||
| // Field length constraints | ||
| const ( | ||
| MaxConditionTypeLength = 128 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. database has VARCHAR(100) |
||
| MaxConditionReasonLength = 256 | ||
| MaxConditionMessageLength = 2048 | ||
| ) | ||
|
|
||
| // MappingExpression wraps a CEL expression string | ||
| type MappingExpression struct { | ||
| Expression string `mapstructure:"expression" json:"expression" validate:"required"` | ||
| } | ||
|
|
||
| // MappingOutput defines the output expressions for a mapped condition | ||
| type MappingOutput struct { | ||
| Status MappingExpression `mapstructure:"status" json:"status" validate:"required"` | ||
| Reason MappingExpression `mapstructure:"reason" json:"reason" validate:"required"` | ||
| Message MappingExpression `mapstructure:"message" json:"message" validate:"required"` | ||
| } | ||
|
|
||
| // ConditionMappingRule defines a single condition mapping rule | ||
| type ConditionMappingRule struct { | ||
| Type string `mapstructure:"type" json:"type" validate:"required"` | ||
| When MappingExpression `mapstructure:"when" json:"when" validate:"required"` | ||
| Output MappingOutput `mapstructure:"output" json:"output" validate:"required"` | ||
| } | ||
|
|
||
| // ValidateEntityConditions validates condition mappings for a single entity descriptor | ||
| // Used by registry.Validate() to check conditions inline in entity descriptors | ||
| // | ||
| // entities: all registered entity descriptors (needed to compute per-adapter synthesized types) | ||
| // descriptor: the specific entity descriptor being validated | ||
| func ValidateEntityConditions(entities []EntityDescriptor, descriptor EntityDescriptor) error { | ||
| if len(descriptor.Conditions) == 0 { | ||
| return nil | ||
| } | ||
|
|
||
| // Build reserved types for this specific entity | ||
| reserved := buildReservedConditionTypes(entities) | ||
|
|
||
| // Create CEL environment once | ||
| env, err := util.NewConditionMappingEnvironment() | ||
| if err != nil { | ||
| return fmt.Errorf("failed to create CEL environment for validation: %w", err) | ||
| } | ||
|
|
||
| // Validate each condition mapping rule and detect duplicates | ||
| seen := make(map[string]bool, len(descriptor.Conditions)) | ||
| for _, rule := range descriptor.Conditions { | ||
| // Check for duplicate types (fail-fast, consistent with CEL validation) | ||
| if seen[rule.Type] { | ||
| return fmt.Errorf( | ||
| "%s condition type '%s' is defined multiple times (each type must be unique)", | ||
| descriptor.Kind, rule.Type, | ||
| ) | ||
| } | ||
| seen[rule.Type] = true | ||
|
|
||
| if err := validateConditionMapping(descriptor.Kind, rule.Type, rule, reserved, env); err != nil { | ||
| return err | ||
| } | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| // buildReservedConditionTypes computes the full set of reserved condition types: | ||
| // - Static types: Reconciled, LastKnownReconciled | ||
| // - Per-adapter synthesized types: computed from required_adapters in all entity descriptors | ||
| // (e.g., "validation" β "ValidationSuccessful") | ||
| func buildReservedConditionTypes(entities []EntityDescriptor) map[string]bool { | ||
| reserved := make(map[string]bool) | ||
|
|
||
| // Add static reserved types | ||
| for k, v := range reservedConditionTypes { | ||
| reserved[k] = v | ||
| } | ||
|
|
||
| // Add per-adapter synthesized types from all entities | ||
| seen := make(map[string]bool) | ||
| for _, entity := range entities { | ||
| for _, adapter := range entity.RequiredAdapters { | ||
| // Skip duplicates across entities (e.g., "validation" appears in both Cluster and NodePool) | ||
| if seen[adapter] { | ||
| continue | ||
| } | ||
| seen[adapter] = true | ||
|
|
||
| // Compute the synthesized condition type name using shared helper | ||
| condType := util.MapAdapterToConditionType(adapter) | ||
| reserved[condType] = true | ||
| } | ||
| } | ||
|
|
||
| return reserved | ||
| } | ||
|
|
||
| // validateConditionMapping validates a single mapping rule | ||
| func validateConditionMapping( | ||
| resourceType, condType string, | ||
| rule ConditionMappingRule, | ||
| reserved map[string]bool, | ||
| env *cel.Env, | ||
| ) error { | ||
| // Check empty type - YAML can have empty string keys | ||
| if condType == "" { | ||
| return fmt.Errorf( | ||
| "%s condition type cannot be empty", | ||
| resourceType, | ||
| ) | ||
| } | ||
|
|
||
| // Check reserved types | ||
| if reserved[condType] { | ||
| return fmt.Errorf( | ||
| "%s condition type '%s' is reserved and cannot be overridden by mapping rules", | ||
| resourceType, condType, | ||
| ) | ||
| } | ||
|
|
||
| // Check condition type length | ||
| if len(condType) > MaxConditionTypeLength { | ||
| return fmt.Errorf( | ||
| "%s condition type '%s' exceeds max length %d (got %d)", | ||
| resourceType, condType, MaxConditionTypeLength, len(condType), | ||
| ) | ||
| } | ||
|
|
||
| // Validate CEL expressions | ||
| if err := validateCELExpression(resourceType, condType, "when", rule.When.Expression, env); err != nil { | ||
| return err | ||
| } | ||
| if err := validateCELExpression( | ||
| resourceType, condType, "output.status", rule.Output.Status.Expression, env, | ||
| ); err != nil { | ||
| return err | ||
| } | ||
| if err := validateCELExpression( | ||
| resourceType, condType, "output.reason", rule.Output.Reason.Expression, env, | ||
| ); err != nil { | ||
| return err | ||
| } | ||
| if err := validateCELExpression( | ||
| resourceType, condType, "output.message", rule.Output.Message.Expression, env, | ||
| ); err != nil { | ||
| return err | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| // validateCELExpression validates a CEL expression by attempting to compile it | ||
| // This provides fail-fast validation at startup | ||
| func validateCELExpression(resourceType, condType, field, expression string, env *cel.Env) error { | ||
| // Parse expression | ||
| ast, issues := env.Parse(expression) | ||
| if issues != nil && issues.Err() != nil { | ||
| return fmt.Errorf( | ||
| "%s.%s.%s: invalid CEL expression: %w\nExpression: %s", | ||
| resourceType, condType, field, issues.Err(), expression, | ||
| ) | ||
| } | ||
|
|
||
| // Check for function arity errors and undefined functions | ||
| // While DynType limits compile-time type checking, Check still catches | ||
| // undefined functions and incorrect argument counts | ||
| _, issues = env.Check(ast) | ||
| if issues != nil && issues.Err() != nil { | ||
| return fmt.Errorf( | ||
| "%s.%s.%s: CEL check failed: %w\nExpression: %s", | ||
| resourceType, condType, field, issues.Err(), expression, | ||
| ) | ||
| } | ||
|
|
||
| // Create program with same cost limit as runtime (see util.CELCostLimit and compileExpression) | ||
| // This ensures overly expensive expressions are rejected at startup, not runtime | ||
| _, err := env.Program(ast, cel.CostLimit(util.CELCostLimit)) | ||
| if err != nil { | ||
| return fmt.Errorf( | ||
| "%s.%s.%s: failed to compile CEL expression: %w\nExpression: %s", | ||
| resourceType, condType, field, err, expression, | ||
| ) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suggest to remove jira task references across this file - "HYPERFLEET-538", to not tie config with Jira