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):
# 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.
#
# 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:
- 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
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
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
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