Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
02b9dcf
HYPERFLEET-538 - feat: CEL-based condition mapping engine
ldornele Jul 27, 2026
18224b7
HYPERFLEET-538 - feat: CEL-based condition mapping engine
ldornele Jul 27, 2026
e78dd41
HYPERFLEET-538 - feat: CEL-based condition mapping engine
ldornele Jul 27, 2026
9dad315
HYPERFLEET-538 - feat: CEL-based condition mapping engine
ldornele Jul 27, 2026
668556c
HYPERFLEET-538 - feat: CEL-based condition mapping engine
ldornele Jul 27, 2026
c137ceb
HYPERFLEET-538 - feat: CEL-based condition mapping engine
ldornele Jul 27, 2026
53ea847
HYPERFLEET-538 - feat: CEL-based condition mapping engine
ldornele Jul 27, 2026
65ccab5
HYPERFLEET-538 - feat: CEL-based condition mapping engine
ldornele Jul 28, 2026
c903cd9
HYPERFLEET-538 - feat: CEL-based condition mapping engine
ldornele Jul 28, 2026
6c463ca
HYPERFLEET-538 - refactor: address code review feedback
ldornele Jul 30, 2026
b836d57
HYPERFLEET-538 - fix: enforce CEL error rollback per design doc
ldornele Jul 30, 2026
a17e0aa
HYPERFLEET-538 - fix: add missing db.MarkForRollback on CEL error
ldornele Jul 30, 2026
293a58c
HYPERFLEET-538 - fix: move Gomega assertion out of goroutine
ldornele Jul 30, 2026
5634ae9
HYPERFLEET-538 - fix: propagate field validation error instead of sil…
ldornele Jul 30, 2026
80c2d7a
HYPERFLEET-538 - fix: address code review feedback
ldornele Jul 30, 2026
a24c899
HYPERFLEET-538 - perf: avoid map allocation when hasUnknown=true
ldornele Jul 30, 2026
909ecd9
HYPERFLEET-538 - test: add coverage for reason/message expression errors
ldornele Jul 30, 2026
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
77 changes: 76 additions & 1 deletion configs/config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Copy link
Copy Markdown
Contributor

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

# 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 pkg/services/condition_mapper.go (parseConditionsWithUnknownCheck breaks and buildStatusesList discards the map) removes the whole adapter entry from statuses, not the single condition. Line 123 documents that correctly. An operator who reads only line 114 will write a when expression that never matches.

πŸ“ 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Rules are compiled at startup (fail-fast). Invalid CEL expressions prevent API startup.
# Evaluation happens during status aggregation. Unknown adapter conditions are filtered.
# Rules are compiled at startup (fail-fast). Invalid CEL expressions prevent API startup.
# Evaluation happens during status aggregation. An adapter reporting any Unknown
# condition is excluded from `statuses` entirely (see CEL Context Variables below).
πŸ€– 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 `@configs/config.yaml.example` around lines 113 - 114, Update the configuration
comment near the status aggregation rules to state that adapters with unknown
conditions are discarded from statuses, matching parseConditionsWithUnknownCheck
and buildStatusesList; remove the claim that only individual unknown conditions
are filtered.

#
# 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

@mliptak0 mliptak0 Aug 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I would decouple this block somewhere into /docs and instead, eg. docs/config.md, and keep only a link here or keep just the configuration example to keep it minimal

- kind: Cluster
plural: clusters
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
)

Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
)
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
Expand All @@ -17,6 +19,8 @@ github.com/MicahParks/keyfunc/v3 v3.8.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcM
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
Expand Down Expand Up @@ -103,6 +107,8 @@ github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/cel-go v0.29.0 h1:fEG+Ja3YRwNOqnQxTyJwoByAUAvTuxUGiro/jhrm4F4=
github.com/google/cel-go v0.29.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg=
Expand Down Expand Up @@ -299,6 +305,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA=
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
Expand Down
1 change: 1 addition & 0 deletions pkg/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

@mliptak0 mliptak0 Aug 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
}

Expand Down
202 changes: 202 additions & 0 deletions pkg/registry/conditions.go
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
}
Loading