From 02b9dcf5b1ad7257c767c02d66a9494232caee42 Mon Sep 17 00:00:00 2001 From: Leonardo Dorneles Date: Mon, 27 Jul 2026 14:54:16 -0300 Subject: [PATCH 01/17] HYPERFLEET-538 - feat: CEL-based condition mapping engine --- configs/config.yaml.example | 74 ++ go.mod | 7 +- go.sum | 12 + pkg/config/conditions.go | 220 ++++ pkg/config/conditions_test.go | 531 ++++++++ pkg/config/config.go | 24 +- pkg/config/loader.go | 3 + pkg/services/aggregation.go | 37 +- pkg/services/aggregation_test.go | 48 - pkg/services/condition_mapper.go | 595 +++++++++ pkg/services/condition_mapper_test.go | 1392 ++++++++++++++++++++ pkg/services/resource.go | 52 +- pkg/services/resource_test.go | 94 +- pkg/util/cel.go | 114 ++ pkg/util/cel_test.go | 286 ++++ pkg/util/mask_sensitive.go | 129 ++ pkg/util/mask_sensitive_test.go | 695 ++++++++++ pkg/util/naming.go | 28 + pkg/util/naming_test.go | 32 + plugins/resources/plugin.go | 1 + test/integration/condition_mapping_test.go | 291 ++++ 21 files changed, 4565 insertions(+), 100 deletions(-) create mode 100644 pkg/config/conditions.go create mode 100644 pkg/config/conditions_test.go create mode 100644 pkg/services/condition_mapper.go create mode 100644 pkg/services/condition_mapper_test.go create mode 100644 pkg/util/cel.go create mode 100644 pkg/util/cel_test.go create mode 100644 pkg/util/mask_sensitive.go create mode 100644 pkg/util/mask_sensitive_test.go create mode 100644 pkg/util/naming.go create mode 100644 pkg/util/naming_test.go create mode 100644 test/integration/condition_mapping_test.go diff --git a/configs/config.yaml.example b/configs/config.yaml.example index 19dd20b8..02e4f2d1 100644 --- a/configs/config.yaml.example +++ b/configs/config.yaml.example @@ -142,6 +142,80 @@ entities: plural: wifconfigs spec_schema_name: WifConfigSpec +# Condition Mapping Configuration +# CEL-based declarative mapping of adapter conditions to public API conditions. +# Enables exposing provider-specific adapter conditions (e.g., Landing Zone readiness, +# Validation quota status) in the public status.conditions array without code changes. +# +# 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): +# - Cluster: ValidationSuccessful, DnsSuccessful, PullsecretSuccessful, HypershiftSuccessful +# - NodePool: ValidationSuccessful, HypershiftSuccessful +# (Pattern: PascalCase adapter name + "Successful", e.g., "validation" → "ValidationSuccessful") +# +# CEL Context Variables: +# - statuses: array of adapter statuses (statuses with any Unknown condition excluded) +# - Each status has: adapter (string), observed_generation (number), +# conditions (array), data (map, sensitive fields masked) +# - resource: full cluster/nodepool object as map (sensitive fields masked) +# - env: environment variables as map (NOT YET IMPLEMENTED - currently always empty) +# TODO: Create HYPERFLEET ticket for env variable support in CEL context +# Feature: Allow CEL expressions to access runtime config (e.g., env.REGION, env.ENVIRONMENT) +# +# Custom CEL Functions: +# - toJson(value): marshal to JSON string +# - dig(target, "dot.path"): safe nested navigation (supports map keys and array indices, e.g., "statuses.0.conditions.1.type") +# +# Note: Timestamps are automatically set from resource.updated_time (not wall clock) +# to ensure reproducible condition evaluation +# +# Field Length Constraints: +# - type: 128 bytes (condition skipped if exceeded) +# - reason: 256 bytes (truncated if exceeded, preserving UTF-8 boundaries) +# - message: 2048 bytes (truncated if exceeded, preserving UTF-8 boundaries) +conditions: + clusters: + # Example: Expose Landing Zone namespace readiness + # 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 + + nodepools: + # Example: Expose Validation quota check status + # 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 # ---------------------------------------------------------------------------- # Configuration Priority (highest to lowest): diff --git a/go.mod b/go.mod index d6cd20e8..da3b6261 100755 --- a/go.mod +++ b/go.mod @@ -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.26.1 github.com/google/uuid v1.6.0 github.com/jinzhu/inflection v1.0.0 github.com/lib/pq v1.12.3 @@ -43,15 +44,19 @@ require ( ) require ( + cel.dev/expr v0.25.1 // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // 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 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/stoewer/go-strcase v1.2.0 // indirect go.opentelemetry.io/contrib/propagators/aws v1.44.0 // indirect go.opentelemetry.io/contrib/propagators/b3 v1.44.0 // indirect 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-20240404231335-c0f41cb1a7a0 // indirect golang.org/x/time v0.15.0 // indirect ) @@ -140,6 +145,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 ) diff --git a/go.sum b/go.sum index 3097ad25..9183663d 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= 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= @@ -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.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= +github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= 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= @@ -231,11 +237,14 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= +github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= @@ -299,6 +308,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-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8= +golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= 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= @@ -333,6 +344,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/resty.v1 v1.12.0 h1:CuXP0Pjfw9rOuY6EP+UvtNvt5DSqHpIxILZKT/quCZI= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/config/conditions.go b/pkg/config/conditions.go new file mode 100644 index 00000000..68094a4b --- /dev/null +++ b/pkg/config/conditions.go @@ -0,0 +1,220 @@ +package config + +import ( + "fmt" + "sort" + + "github.com/google/cel-go/cel" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/util" +) + +// Reserved condition types that cannot be overridden by mapping rules +var reservedConditionTypes = map[string]bool{ + api.ResourceConditionTypeReconciled: true, + api.ResourceConditionTypeLastKnownReconciled: true, +} + +// 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 { + When MappingExpression `mapstructure:"when" json:"when" validate:"required"` + Output MappingOutput `mapstructure:"output" json:"output" validate:"required"` +} + +// ConditionsConfig holds condition mapping configuration per resource type +// Map key is the output condition type (e.g., "LandingZoneReady") +type ConditionsConfig struct { + Clusters map[string]ConditionMappingRule `mapstructure:"clusters" json:"clusters"` + NodePools map[string]ConditionMappingRule `mapstructure:"nodepools" json:"nodepools"` +} + +// NewConditionsConfig returns a default ConditionsConfig with empty maps +func NewConditionsConfig() *ConditionsConfig { + return &ConditionsConfig{ + Clusters: make(map[string]ConditionMappingRule), + NodePools: make(map[string]ConditionMappingRule), + } +} + +// Validate validates the conditions configuration +// Returns error on: +// - Reserved condition types (Reconciled, LastKnownReconciled, per-adapter synthesized types) +// - Invalid CEL expressions (fail-fast at startup) +// - Field length constraint violations +// +// entities: entity descriptors from ApplicationConfig.Entities - used to compute +// per-adapter synthesized condition types (e.g., ValidationSuccessful from "validation" adapter) +func (c *ConditionsConfig) Validate(entities []registry.EntityDescriptor) error { + // Nil receiver guard - YAML can set `conditions: null` + if c == nil { + return nil + } + + // Build the complete set of reserved types: static + per-adapter synthesized + reserved := buildReservedConditionTypes(entities) + + // Create CEL environment once and reuse for all validations + // This matches the pattern in NewConditionMapper and avoids recreating the environment per-expression + env, err := util.NewConditionMappingEnvironment() + if err != nil { + return fmt.Errorf("failed to create CEL environment for validation: %w", err) + } + + // Validate cluster mappings (sort keys for deterministic error messages) + clusterKeys := make([]string, 0, len(c.Clusters)) + for condType := range c.Clusters { + clusterKeys = append(clusterKeys, condType) + } + sort.Strings(clusterKeys) + for _, condType := range clusterKeys { + if err := validateConditionMapping("clusters", condType, c.Clusters[condType], reserved, env); err != nil { + return err + } + } + + // Validate nodepool mappings (sort keys for deterministic error messages) + nodepoolKeys := make([]string, 0, len(c.NodePools)) + for condType := range c.NodePools { + nodepoolKeys = append(nodepoolKeys, condType) + } + sort.Strings(nodepoolKeys) + for _, condType := range nodepoolKeys { + if err := validateConditionMapping("nodepools", condType, c.NodePools[condType], 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 []registry.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 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 +} + +// IsEmpty returns true if no condition mappings are configured +func (c *ConditionsConfig) IsEmpty() bool { + // Nil receiver guard - treat nil config as empty + if c == nil { + return true + } + return len(c.Clusters) == 0 && len(c.NodePools) == 0 +} diff --git a/pkg/config/conditions_test.go b/pkg/config/conditions_test.go new file mode 100644 index 00000000..b2202d4d --- /dev/null +++ b/pkg/config/conditions_test.go @@ -0,0 +1,531 @@ +package config + +import ( + "strings" + "testing" + + . "github.com/onsi/gomega" + "gopkg.in/yaml.v3" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" +) + +// ============================================================================ +// Tests from conditions_test.go +// ============================================================================ + +func TestConditionsConfig_Validate(t *testing.T) { + // Typical entity config with required adapters + entities := []registry.EntityDescriptor{ + { + Kind: "Cluster", + RequiredAdapters: []string{"validation", "dns", "pullsecret", "hypershift"}, + }, + { + Kind: "NodePool", + RequiredAdapters: []string{"validation"}, + }, + } + + tests := []struct { + name string + config *ConditionsConfig + entities []registry.EntityDescriptor + wantError bool + errorMsg string + }{ + { + name: "valid config with single cluster rule", + entities: entities, + config: &ConditionsConfig{ + Clusters: map[string]ConditionMappingRule{ + "QuotaValid": { + When: MappingExpression{ + Expression: `statuses.exists(s, s.adapter == "validation")`, + }, + Output: MappingOutput{ + Status: MappingExpression{ + Expression: `"True"`, + }, + Reason: MappingExpression{ + Expression: `"QuotaOK"`, + }, + Message: MappingExpression{ + Expression: `"Quota is sufficient"`, + }, + }, + }, + }, + NodePools: map[string]ConditionMappingRule{}, + }, + wantError: false, + }, + { + name: "empty config is valid", + entities: entities, + config: &ConditionsConfig{ + Clusters: map[string]ConditionMappingRule{}, + NodePools: map[string]ConditionMappingRule{}, + }, + wantError: false, + }, + { + name: "reserved type Reconciled is rejected", + entities: entities, + config: &ConditionsConfig{ + Clusters: map[string]ConditionMappingRule{ + "Reconciled": { + When: MappingExpression{Expression: `true`}, + Output: MappingOutput{ + Status: MappingExpression{Expression: `"True"`}, + Reason: MappingExpression{Expression: `"OK"`}, + Message: MappingExpression{Expression: `"OK"`}, + }, + }, + }, + NodePools: map[string]ConditionMappingRule{}, + }, + wantError: true, + errorMsg: "reserved", + }, + { + name: "reserved type LastKnownReconciled is rejected", + entities: entities, + config: &ConditionsConfig{ + Clusters: map[string]ConditionMappingRule{}, + NodePools: map[string]ConditionMappingRule{ + "LastKnownReconciled": { + When: MappingExpression{Expression: `true`}, + Output: MappingOutput{ + Status: MappingExpression{Expression: `"True"`}, + Reason: MappingExpression{Expression: `"OK"`}, + Message: MappingExpression{Expression: `"OK"`}, + }, + }, + }, + }, + wantError: true, + errorMsg: "reserved", + }, + { + name: "per-adapter synthesized type ValidationSuccessful is rejected", + entities: entities, // Contains "validation" adapter + config: &ConditionsConfig{ + Clusters: map[string]ConditionMappingRule{ + "ValidationSuccessful": { // Synthesized from "validation" adapter + When: MappingExpression{Expression: `true`}, + Output: MappingOutput{ + Status: MappingExpression{Expression: `"True"`}, + Reason: MappingExpression{Expression: `"OK"`}, + Message: MappingExpression{Expression: `"OK"`}, + }, + }, + }, + NodePools: map[string]ConditionMappingRule{}, + }, + wantError: true, + errorMsg: "reserved", + }, + { + name: "per-adapter synthesized type DnsSuccessful is rejected", + entities: entities, // Contains "dns" adapter + config: &ConditionsConfig{ + Clusters: map[string]ConditionMappingRule{ + "DnsSuccessful": { // Synthesized from "dns" adapter + When: MappingExpression{Expression: `true`}, + Output: MappingOutput{ + Status: MappingExpression{Expression: `"True"`}, + Reason: MappingExpression{Expression: `"OK"`}, + Message: MappingExpression{Expression: `"OK"`}, + }, + }, + }, + NodePools: map[string]ConditionMappingRule{}, + }, + wantError: true, + errorMsg: "reserved", + }, + { + name: "condition type exceeding max length is rejected", + entities: entities, + config: &ConditionsConfig{ + Clusters: map[string]ConditionMappingRule{ + strings.Repeat("A", 129): { // 129 chars > 128 max + When: MappingExpression{Expression: `true`}, + Output: MappingOutput{ + Status: MappingExpression{Expression: `"True"`}, + Reason: MappingExpression{Expression: `"OK"`}, + Message: MappingExpression{Expression: `"OK"`}, + }, + }, + }, + NodePools: map[string]ConditionMappingRule{}, + }, + wantError: true, + errorMsg: "max length", + }, + { + name: "invalid CEL syntax in when expression", + entities: entities, + config: &ConditionsConfig{ + Clusters: map[string]ConditionMappingRule{ + "Valid": { + When: MappingExpression{ + Expression: `statuses.exists(s, s.adapter == `, // incomplete + }, + Output: MappingOutput{ + Status: MappingExpression{Expression: `"True"`}, + Reason: MappingExpression{Expression: `"OK"`}, + Message: MappingExpression{Expression: `"OK"`}, + }, + }, + }, + NodePools: map[string]ConditionMappingRule{}, + }, + wantError: true, + errorMsg: "invalid CEL expression", + }, + { + name: "invalid CEL syntax in output status", + entities: entities, + config: &ConditionsConfig{ + Clusters: map[string]ConditionMappingRule{ + "Valid": { + When: MappingExpression{Expression: `true`}, + Output: MappingOutput{ + Status: MappingExpression{ + Expression: `statuses[`, // incomplete + }, + Reason: MappingExpression{Expression: `"OK"`}, + Message: MappingExpression{Expression: `"OK"`}, + }, + }, + }, + NodePools: map[string]ConditionMappingRule{}, + }, + wantError: true, + errorMsg: "invalid CEL expression", + }, + { + name: "complex valid CEL expression", + entities: entities, + config: &ConditionsConfig{ + Clusters: map[string]ConditionMappingRule{ + "ComplexCondition": { + When: MappingExpression{ + Expression: `statuses.exists(s, s.adapter == "validation" && s.conditions.exists(c, c.type == "QuotaSufficient" && c.status == "True"))`, + }, + Output: MappingOutput{ + Status: MappingExpression{ + Expression: `statuses.filter(s, s.adapter == "validation")[0].conditions.filter(c, c.type == "QuotaSufficient")[0].status`, + }, + Reason: MappingExpression{ + Expression: `statuses.filter(s, s.adapter == "validation")[0].conditions.filter(c, c.type == "QuotaSufficient")[0].reason`, + }, + Message: MappingExpression{ + Expression: `"Quota: " + statuses.filter(s, s.adapter == "validation")[0].conditions.filter(c, c.type == "QuotaSufficient")[0].message`, + }, + }, + }, + }, + NodePools: map[string]ConditionMappingRule{}, + }, + wantError: false, + }, + { + name: "non-boolean when expression passes validation but fails at runtime", + entities: entities, + config: &ConditionsConfig{ + Clusters: map[string]ConditionMappingRule{ + "StringWhen": { + When: MappingExpression{ + Expression: `"true"`, // String, not boolean - CEL validates types at runtime + }, + Output: MappingOutput{ + Status: MappingExpression{Expression: `"True"`}, + Reason: MappingExpression{Expression: `"OK"`}, + Message: MappingExpression{Expression: `"OK"`}, + }, + }, + }, + NodePools: map[string]ConditionMappingRule{}, + }, + wantError: false, // Validation passes - CEL type checking happens at runtime, not compile time + }, + { + name: "nested comprehensions pass validation (cost limit enforced at runtime)", + entities: entities, + config: &ConditionsConfig{ + Clusters: map[string]ConditionMappingRule{ + "Expensive": { + When: MappingExpression{ + // Nested comprehensions - valid syntax, but may be expensive at runtime + // Cost limit of 10000 is enforced during Program creation, not Check + Expression: `statuses.map(s, s.conditions.map(c, c.type + c.status)).size() > 0`, + }, + Output: MappingOutput{ + Status: MappingExpression{Expression: `"True"`}, + Reason: MappingExpression{Expression: `"OK"`}, + Message: MappingExpression{Expression: `"OK"`}, + }, + }, + }, + NodePools: map[string]ConditionMappingRule{}, + }, + wantError: false, // Validation passes - cost limit is compile-time static analysis estimate + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + RegisterTestingT(t) + err := tt.config.Validate(tt.entities) + + if tt.wantError { + Expect(err).To(HaveOccurred(), "expected validation to fail") + Expect(err.Error()).To(ContainSubstring(tt.errorMsg), "error message should contain expected text") + } else { + Expect(err).NotTo(HaveOccurred(), "expected validation to succeed") + } + }) + } +} +func TestConditionsConfig_IsEmpty(t *testing.T) { + tests := []struct { + name string + config *ConditionsConfig + expected bool + }{ + { + name: "empty config", + config: &ConditionsConfig{ + Clusters: map[string]ConditionMappingRule{}, + NodePools: map[string]ConditionMappingRule{}, + }, + expected: true, + }, + { + name: "nil maps are empty", + config: &ConditionsConfig{ + Clusters: nil, + NodePools: nil, + }, + expected: true, + }, + { + name: "clusters has rules", + config: &ConditionsConfig{ + Clusters: map[string]ConditionMappingRule{ + "Test": { + When: MappingExpression{Expression: `true`}, + Output: MappingOutput{}, + }, + }, + NodePools: map[string]ConditionMappingRule{}, + }, + expected: false, + }, + { + name: "nodepools has rules", + config: &ConditionsConfig{ + Clusters: map[string]ConditionMappingRule{}, + NodePools: map[string]ConditionMappingRule{ + "Test": { + When: MappingExpression{Expression: `true`}, + Output: MappingOutput{}, + }, + }, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + RegisterTestingT(t) + result := tt.config.IsEmpty() + Expect(result).To(Equal(tt.expected)) + }) + } +} +func TestNewConditionsConfig(t *testing.T) { + RegisterTestingT(t) + config := NewConditionsConfig() + + Expect(config).NotTo(BeNil()) + Expect(config.Clusters).NotTo(BeNil()) + Expect(config.NodePools).NotTo(BeNil()) + Expect(config.Clusters).To(BeEmpty()) + Expect(config.NodePools).To(BeEmpty()) + Expect(config.IsEmpty()).To(BeTrue()) +} +func TestConditionsConfig_NilReceiverGuards(t *testing.T) { + t.Run("Validate on nil receiver returns nil", func(t *testing.T) { + RegisterTestingT(t) + var c *ConditionsConfig + err := c.Validate(nil) + Expect(err).NotTo(HaveOccurred(), "nil receiver should return nil, not panic") + }) + + t.Run("IsEmpty on nil receiver returns true", func(t *testing.T) { + RegisterTestingT(t) + var c *ConditionsConfig + result := c.IsEmpty() + Expect(result).To(BeTrue(), "nil receiver should be considered empty") + }) +} +func TestConditionsConfig_YAMLNullHandling(t *testing.T) { + t.Run("YAML with conditions: null is handled gracefully", func(t *testing.T) { + RegisterTestingT(t) + yamlContent := ` +server: + port: 8000 +conditions: null +` + // Simulate viper unmarshaling + type testConfig struct { + Server struct{ Port int } + Conditions *ConditionsConfig + } + + var cfg testConfig + err := yaml.Unmarshal([]byte(yamlContent), &cfg) + Expect(err).NotTo(HaveOccurred()) + + // Verify Conditions is nil + Expect(cfg.Conditions).To(BeNil()) + + // Verify Validate doesn't panic + Expect(func() { + err := cfg.Conditions.Validate(nil) + Expect(err).NotTo(HaveOccurred()) + }).NotTo(Panic()) + + // Verify IsEmpty doesn't panic + Expect(func() { + empty := cfg.Conditions.IsEmpty() + Expect(empty).To(BeTrue()) + }).NotTo(Panic()) + }) +} + +// ============================================================================ +// Tests from conditions_determinism_test.go +// ============================================================================ + +func TestConditionsConfig_Validate_DeterministicErrors(t *testing.T) { + t.Run("validation errors are deterministic with multiple invalid rules", func(t *testing.T) { + RegisterTestingT(t) + + // Config with multiple invalid rules + // If sorted: "AInvalid" is checked before "ZInvalid" + // If unsorted: order is non-deterministic + config := &ConditionsConfig{ + Clusters: map[string]ConditionMappingRule{ + "ZInvalid": { + When: MappingExpression{Expression: `invalid syntax`}, + Output: MappingOutput{ + Status: MappingExpression{Expression: `"True"`}, + Reason: MappingExpression{Expression: `"OK"`}, + Message: MappingExpression{Expression: `"OK"`}, + }, + }, + "AInvalid": { + When: MappingExpression{Expression: `another bad`}, + Output: MappingOutput{ + Status: MappingExpression{Expression: `"True"`}, + Reason: MappingExpression{Expression: `"OK"`}, + Message: MappingExpression{Expression: `"OK"`}, + }, + }, + }, + } + + // Run validation multiple times - should always fail on same rule first + for i := 0; i < 10; i++ { + err := config.Validate(nil) + Expect(err).To(HaveOccurred()) + // Should always fail on "AInvalid" first (alphabetically first) + Expect(err.Error()).To(ContainSubstring("clusters.AInvalid")) + } + }) +} + +// ============================================================================ +// Tests from conditions_cel_check_test.go +// ============================================================================ + +func TestValidate_CELCheckCatchesErrors(t *testing.T) { + t.Run("undefined function caught by Check", func(t *testing.T) { + RegisterTestingT(t) + + config := &ConditionsConfig{ + Clusters: map[string]ConditionMappingRule{ + "TestCondition": { + When: MappingExpression{ + Expression: "undefinedFunction(statuses)", // No such function + }, + Output: MappingOutput{ + Status: MappingExpression{Expression: "'True'"}, + Reason: MappingExpression{Expression: "'TestReason'"}, + Message: MappingExpression{Expression: "'TestMessage'"}, + }, + }, + }, + } + + err := config.Validate([]registry.EntityDescriptor{}) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("CEL check failed")) + Expect(err.Error()).To(ContainSubstring("undefinedFunction")) + }) + + t.Run("wrong function arity caught by Check", func(t *testing.T) { + RegisterTestingT(t) + + config := &ConditionsConfig{ + Clusters: map[string]ConditionMappingRule{ + "TestCondition": { + When: MappingExpression{ + // size() expects 1 argument, not 2 + Expression: "size(statuses, 'extra arg')", + }, + Output: MappingOutput{ + Status: MappingExpression{Expression: "'True'"}, + Reason: MappingExpression{Expression: "'TestReason'"}, + Message: MappingExpression{Expression: "'TestMessage'"}, + }, + }, + }, + } + + err := config.Validate([]registry.EntityDescriptor{}) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("CEL check failed")) + }) + + t.Run("valid CEL expression passes Check", func(t *testing.T) { + RegisterTestingT(t) + + config := &ConditionsConfig{ + Clusters: map[string]ConditionMappingRule{ + "TestCondition": { + When: MappingExpression{ + Expression: "size(statuses) > 0", // Valid + }, + Output: MappingOutput{ + Status: MappingExpression{Expression: "'True'"}, + Reason: MappingExpression{Expression: "'TestReason'"}, + Message: MappingExpression{Expression: "'TestMessage'"}, + }, + }, + }, + } + + err := config.Validate([]registry.EntityDescriptor{}) + + Expect(err).NotTo(HaveOccurred()) + }) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index e14d6f0a..ad6f2700 100755 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -5,22 +5,24 @@ import "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" // ApplicationConfig holds all application configuration // Follows HyperFleet Configuration Standard with validation and structured marshaling type ApplicationConfig struct { - Server *ServerConfig `mapstructure:"server" json:"server" validate:"required"` - Metrics *MetricsConfig `mapstructure:"metrics" json:"metrics" validate:"required"` - Health *HealthConfig `mapstructure:"health" json:"health" validate:"required"` - Database *DatabaseConfig `mapstructure:"database" json:"database" validate:"required"` - Logging *LoggingConfig `mapstructure:"logging" json:"logging" validate:"required"` - Entities []registry.EntityDescriptor `mapstructure:"entities" json:"entities"` + Server *ServerConfig `mapstructure:"server" json:"server" validate:"required"` + Metrics *MetricsConfig `mapstructure:"metrics" json:"metrics" validate:"required"` + Health *HealthConfig `mapstructure:"health" json:"health" validate:"required"` + Database *DatabaseConfig `mapstructure:"database" json:"database" validate:"required"` + Logging *LoggingConfig `mapstructure:"logging" json:"logging" validate:"required"` + Entities []registry.EntityDescriptor `mapstructure:"entities" json:"entities"` + Conditions *ConditionsConfig `mapstructure:"conditions" json:"conditions"` } // NewApplicationConfig returns default ApplicationConfig with all sub-configs initialized // These defaults can be overridden by config file, env vars, or CLI flags func NewApplicationConfig() *ApplicationConfig { return &ApplicationConfig{ - Server: NewServerConfig(), - Metrics: NewMetricsConfig(), - Health: NewHealthConfig(), - Database: NewDatabaseConfig(), - Logging: NewLoggingConfig(), + Server: NewServerConfig(), + Metrics: NewMetricsConfig(), + Health: NewHealthConfig(), + Database: NewDatabaseConfig(), + Logging: NewLoggingConfig(), + Conditions: NewConditionsConfig(), } } diff --git a/pkg/config/loader.go b/pkg/config/loader.go index d7ef7d5d..3ca8ed59 100644 --- a/pkg/config/loader.go +++ b/pkg/config/loader.go @@ -187,6 +187,9 @@ func (l *ConfigLoader) validateConfig(config *ApplicationConfig) error { if valErr := config.Metrics.Validate(); valErr != nil { return fmt.Errorf("metrics config validation failed: %w", valErr) } + if valErr := config.Conditions.Validate(config.Entities); valErr != nil { + return fmt.Errorf("conditions config validation failed: %w", valErr) + } return nil } diff --git a/pkg/services/aggregation.go b/pkg/services/aggregation.go index 24c8f9fe..392ca066 100644 --- a/pkg/services/aggregation.go +++ b/pkg/services/aggregation.go @@ -7,10 +7,10 @@ import ( "sort" "strings" "time" - "unicode" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/util" ) // mandatoryConditions returns the condition types that must be present in all adapter status updates. @@ -60,37 +60,6 @@ func ValidateMandatoryConditions(conditions []api.AdapterCondition) (errorType, // --- Aggregated Reconciled / LastKnownReconciled ---------------------------------- -// adapterConditionSuffixMap allows overriding the default suffix for specific adapters (reserved). -var adapterConditionSuffixMap = map[string]string{} - -// MapAdapterToConditionType converts an adapter name to a semantic condition type (PascalCase + suffix). -// Used to derive the type name for per-adapter conditions mirrored into resource status -// (e.g. "adapter1" → "Adapter1Successful", "my-adapter" → "MyAdapterSuccessful"). -func MapAdapterToConditionType(adapterName string) string { - return mapAdapterToConditionType(adapterName, adapterConditionSuffixMap) -} - -func mapAdapterToConditionType(adapterName string, suffixMap map[string]string) string { - suffix, exists := suffixMap[adapterName] - if !exists { - suffix = "Successful" - } - - parts := strings.Split(adapterName, "-") - var result strings.Builder - - for _, part := range parts { - if len(part) > 0 { - runes := []rune(part) - runes[0] = unicode.ToUpper(runes[0]) - result.WriteString(string(runes)) - } - } - - result.WriteString(suffix) - return result.String() -} - // AggregateResourceStatusInput carries everything needed to compute deterministic conditions. // RefTime must be resource.updated_time (never time.Now) so results are reproducible. // @@ -683,7 +652,7 @@ func computeGenericLastTransitionTime( } // computeAdapterConditions produces one ResourceCondition per required adapter that has reported. -// The condition type is derived from the adapter name via MapAdapterToConditionType. +// The condition type is derived from the adapter name via util.MapAdapterToConditionType. // Status, reason, and message are taken from the adapter's Available condition snapshot. // last_transition_time is updated only when the status (True/False) changes from the previous value. func computeAdapterConditions( @@ -698,7 +667,7 @@ func computeAdapterConditions( if !ok { continue } - condType := MapAdapterToConditionType(adapterName) + condType := util.MapAdapterToConditionType(adapterName) prev := prevByType[condType] status := api.ConditionFalse diff --git a/pkg/services/aggregation_test.go b/pkg/services/aggregation_test.go index 42f8520a..11ccf723 100644 --- a/pkg/services/aggregation_test.go +++ b/pkg/services/aggregation_test.go @@ -1453,54 +1453,6 @@ func TestComputeAdapterConditions(t *testing.T) { }) } -// --------------------------------------------------------------------------- -// MapAdapterToConditionType -// --------------------------------------------------------------------------- - -func TestMapAdapterToConditionType(t *testing.T) { - tests := []struct { - adapter string - expected string - }{ - {"validator", "ValidatorSuccessful"}, - {"dns", "DnsSuccessful"}, - {"gcp-provisioner", "GcpProvisionerSuccessful"}, - {"unknown-adapter", "UnknownAdapterSuccessful"}, - {"multi-word-adapter", "MultiWordAdapterSuccessful"}, - {"single", "SingleSuccessful"}, - } - - for _, tt := range tests { - result := MapAdapterToConditionType(tt.adapter) - if result != tt.expected { - t.Errorf("MapAdapterToConditionType(%q) = %q, want %q", - tt.adapter, result, tt.expected) - } - } -} - -// Test custom suffix mapping (for future use). -func TestMapAdapterToConditionType_CustomSuffix(t *testing.T) { - t.Parallel() - // Use a local map so the package-level adapterConditionSuffixMap is never mutated. - localMap := map[string]string{"test-adapter": "Ready"} - result := mapAdapterToConditionType("test-adapter", localMap) - expected := "TestAdapterReady" - if result != expected { - t.Errorf("mapAdapterToConditionType(%q) = %q, want %q", "test-adapter", result, expected) - } -} - -// Test that the default suffix applies when no override is present. -func TestMapAdapterToConditionType_DefaultSuffix(t *testing.T) { - t.Parallel() - result := mapAdapterToConditionType("dns", map[string]string{}) - expected := "DnsSuccessful" - if result != expected { - t.Errorf("mapAdapterToConditionType(%q) = %q, want %q", "dns", result, expected) - } -} - // --------------------------------------------------------------------------- // ValidateMandatoryConditions // --------------------------------------------------------------------------- diff --git a/pkg/services/condition_mapper.go b/pkg/services/condition_mapper.go new file mode 100644 index 00000000..7a7e99c8 --- /dev/null +++ b/pkg/services/condition_mapper.go @@ -0,0 +1,595 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "math" + "sort" + "strings" + "time" + "unicode/utf8" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/util" +) + +// emptyEnvMap is a shared empty map for the env CEL variable (PERF-03) +// Hoisted to package level to avoid allocation on every Apply() call +// Safe to share because it is never mutated (env variables not yet implemented) +var emptyEnvMap = map[string]string{} + +// CEL adapter status map keys (QUAL-01) +// Used in both nil-guard and normal paths to prevent key mismatches +const ( + celKeyAdapter = "adapter" + celKeyObservedGeneration = "observed_generation" + celKeyConditions = "conditions" + celKeyData = "data" +) + +// CEL boolean string representations (QUAL-01) +// Used in status parsing (evaluateRule) and extractString to ensure consistency +const ( + celBoolTrue = "True" // Title-case matches extractString output + celBoolFalse = "False" // Title-case matches extractString output +) + +// Condition sub-field keys (QUAL-01) +// Used when building adapter condition maps for CEL context +const ( + condKeyType = "type" + condKeyStatus = "status" + condKeyReason = "reason" + condKeyMessage = "message" +) + +// Resource field keys (QUAL-01) +// Used when extracting fields from resource map in CEL context +const ( + resourceKeyGeneration = "generation" +) + +// ConditionMapper evaluates CEL-based condition mapping rules +type ConditionMapper struct { + rules map[string]*compiledRule + sortedNames []string // Pre-sorted rule names for deterministic ordering + resourceKind string +} + +// compiledRule holds pre-compiled CEL programs for a mapping rule +type compiledRule struct { + conditionType string + whenProgram cel.Program + statusProgram cel.Program + reasonProgram cel.Program + messageProgram cel.Program +} + +// ApplyInput holds the input data for condition mapping +type ApplyInput struct { + AdapterStatuses api.AdapterStatusList + Resource interface{} + RefTime time.Time + // PrevConditions is the previous mapped conditions from resource.Conditions + // Used to preserve CreatedTime and LastTransitionTime (matching aggregation.go pattern) + PrevConditions []api.ResourceCondition +} + +// NewConditionMapper creates a new condition mapper with pre-compiled rules +func NewConditionMapper(resourceKind string, rules map[string]config.ConditionMappingRule) (*ConditionMapper, error) { + // Create CEL environment with context variables and custom functions + // Uses the same environment as validation to ensure consistency + env, err := util.NewConditionMappingEnvironment() + if err != nil { + return nil, fmt.Errorf("failed to create CEL environment: %w", err) + } + + // Compile all rules at initialization (fail-fast) + compiled := make(map[string]*compiledRule, len(rules)) + for condType, rule := range rules { + compiledRule, err := compileRule(env, condType, rule) + if err != nil { + return nil, fmt.Errorf("failed to compile rule for condition %s: %w", condType, err) + } + compiled[condType] = compiledRule + } + + // Pre-sort rule names for deterministic ordering (avoids repeated sort in Apply) + sortedNames := make([]string, 0, len(compiled)) + for name := range compiled { + sortedNames = append(sortedNames, name) + } + sort.Strings(sortedNames) + + return &ConditionMapper{ + rules: compiled, + sortedNames: sortedNames, + resourceKind: resourceKind, + }, nil +} + +// Apply evaluates mapping rules and returns mapped conditions +func (m *ConditionMapper) Apply(ctx context.Context, input ApplyInput) []api.ResourceCondition { + if len(m.rules) == 0 { + return nil + } + + // Build CEL activation context (filtering Unknown conditions happens inside buildActivation) + activation := buildActivation(ctx, input.AdapterStatuses, input.Resource, m.resourceKind) + + // Build lookup map for previous conditions to avoid O(N×M) linear scans + prevConditionsByType := make(map[string]*api.ResourceCondition, len(input.PrevConditions)) + for i := range input.PrevConditions { + prevConditionsByType[input.PrevConditions[i].Type] = &input.PrevConditions[i] + } + + // Evaluate each rule in sorted order to produce deterministic condition ordering + // This prevents spurious DB writes from jsonEqual comparison in resource.go + // Pre-allocate with capacity to avoid incremental growth + mappedConditions := make([]api.ResourceCondition, 0, len(m.sortedNames)) + for _, name := range m.sortedNames { + rule := m.rules[name] + + // Lookup previous condition for this type (O(1) instead of O(N)) + prevCondition := prevConditionsByType[rule.conditionType] + + condition, err := m.evaluateRule(ctx, rule, activation, input.RefTime, prevCondition) + if err != nil { + // Log error but don't fail the entire aggregation + logger.With(ctx, "resource_kind", m.resourceKind, "condition_type", rule.conditionType). + WithError(err). + Warn("Failed to evaluate condition mapping rule, skipping") + continue + } + if condition != nil { + mappedConditions = append(mappedConditions, *condition) + } + } + + return mappedConditions +} + +// evaluateRule evaluates a single mapping rule +func (m *ConditionMapper) evaluateRule( + ctx context.Context, + rule *compiledRule, + activation map[string]interface{}, + refTime time.Time, + prevCondition *api.ResourceCondition, +) (*api.ResourceCondition, error) { + // Evaluate when expression + whenResult, _, err := rule.whenProgram.Eval(activation) + if err != nil { + return nil, fmt.Errorf("when expression evaluation failed: %w", err) + } + + // Check if when condition is met + whenBool, ok := whenResult.Value().(bool) + if !ok { + return nil, fmt.Errorf("when expression did not return boolean (got %T)", whenResult.Value()) + } + if !whenBool { + // Condition not met, skip this rule + return nil, nil + } + + // Evaluate output expressions + statusResult, _, err := rule.statusProgram.Eval(activation) + if err != nil { + return nil, fmt.Errorf("status expression evaluation failed: %w", err) + } + + reasonResult, _, err := rule.reasonProgram.Eval(activation) + if err != nil { + return nil, fmt.Errorf("reason expression evaluation failed: %w", err) + } + + messageResult, _, err := rule.messageProgram.Eval(activation) + if err != nil { + return nil, fmt.Errorf("message expression evaluation failed: %w", err) + } + + // Extract and validate values + statusStr := extractString(statusResult) + reasonStr := extractString(reasonResult) + messageStr := extractString(messageResult) + + // Validate field lengths and truncate message if needed (QUAL-03) + validatedReason, validatedMessage, err := m.validateFieldLengths(ctx, rule, reasonStr, messageStr) + if err != nil { + // Field length validation failed, skip this condition + return nil, nil + } + + // Build the mapped condition with all required fields (QUAL-03) + return m.buildMappedCondition(ctx, rule, statusStr, validatedReason, validatedMessage, activation, refTime, prevCondition) +} + +// validateFieldLengths validates and enforces field length constraints (QUAL-03) +// Returns (validatedReason, validatedMessage, error) +// If error is non-nil, the condition should be skipped +func (m *ConditionMapper) validateFieldLengths( + ctx context.Context, + rule *compiledRule, + reasonStr string, + messageStr string, +) (string, string, error) { + // Validate condition type length + if len(rule.conditionType) > config.MaxConditionTypeLength { + logger.With(ctx, "resource_kind", m.resourceKind, "condition_type", rule.conditionType, "length", len(rule.conditionType)). + Warn("Condition type exceeds max length, skipping") + return "", "", fmt.Errorf("condition type exceeds max length") + } + + // Truncate reason if too long (rune-aware to preserve valid UTF-8) + validatedReason := reasonStr + if len(reasonStr) > config.MaxConditionReasonLength { + validatedReason = truncateUTF8(reasonStr, config.MaxConditionReasonLength) + logger.With(ctx, "resource_kind", m.resourceKind, "condition_type", rule.conditionType). + Info("Condition reason truncated to max length") + } + + // Truncate message if too long (rune-aware to preserve valid UTF-8) + validatedMessage := messageStr + if len(messageStr) > config.MaxConditionMessageLength { + validatedMessage = truncateUTF8(messageStr, config.MaxConditionMessageLength) + logger.With(ctx, "resource_kind", m.resourceKind, "condition_type", rule.conditionType). + Info("Condition message truncated to max length") + } + + return validatedReason, validatedMessage, nil +} + +// buildMappedCondition builds the final ResourceCondition from validated inputs (QUAL-03) +func (m *ConditionMapper) buildMappedCondition( + ctx context.Context, + rule *compiledRule, + statusStr string, + reasonStr string, + messageStr string, + activation map[string]interface{}, + refTime time.Time, + prevCondition *api.ResourceCondition, +) (*api.ResourceCondition, error) { + // Parse status string to ResourceConditionStatus (case-insensitive) + var status api.ResourceConditionStatus + if strings.EqualFold(statusStr, celBoolTrue) { + status = api.ConditionTrue + } else if strings.EqualFold(statusStr, celBoolFalse) { + status = api.ConditionFalse + } else { + return nil, fmt.Errorf("invalid status value: %s (must be %s or %s)", statusStr, celBoolTrue, celBoolFalse) + } + + // Extract resource generation for ObservedGeneration field + // Use type assertion via map to extract generation field + resourceGen := int32(0) + if resourceMap, ok := activation[util.CELVarResource].(map[string]interface{}); ok { + if gen, ok := resourceMap[resourceKeyGeneration].(float64); ok { + // Bounds check before narrowing conversion to prevent wrapping (critical) + if gen >= math.MinInt32 && gen <= math.MaxInt32 { + resourceGen = int32(gen) + } else { + // Out of bounds: log warning and use 0 (safe fallback) + logger.With(ctx, "resource_kind", m.resourceKind, "condition_type", rule.conditionType, "generation", gen). + Warn("Resource generation out of int32 range, using 0") + } + } + } + + // Preserve CreatedTime from previous condition (matching aggregation.go:332-333) + createdTime := refTime.UTC().Truncate(time.Microsecond) + if prevCondition != nil && !prevCondition.CreatedTime.IsZero() { + createdTime = prevCondition.CreatedTime + } + + // Preserve LastTransitionTime if status unchanged (matching aggregation.go:640-652) + lastTransitionTime := refTime.UTC().Truncate(time.Microsecond) + if prevCondition != nil && prevCondition.Status == status && !prevCondition.LastTransitionTime.IsZero() { + lastTransitionTime = prevCondition.LastTransitionTime + } + + // Build the mapped condition + // Use refTime (not time.Now) to ensure reproducible results - same pattern as aggregation.go:63-64 + condition := api.ResourceCondition{ + Type: rule.conditionType, + Status: status, + Reason: strPtr(reasonStr), + Message: strPtr(messageStr), + ObservedGeneration: resourceGen, + LastTransitionTime: lastTransitionTime, + CreatedTime: createdTime, + LastUpdatedTime: refTime.UTC().Truncate(time.Microsecond), + } + + return &condition, nil +} + +// compileRule compiles all CEL expressions in a mapping rule +func compileRule(env *cel.Env, condType string, rule config.ConditionMappingRule) (*compiledRule, error) { + // Compile when expression + whenPrg, err := compileExpression(env, rule.When.Expression) + if err != nil { + return nil, fmt.Errorf("when: %w", err) + } + + // Compile output expressions + statusPrg, err := compileExpression(env, rule.Output.Status.Expression) + if err != nil { + return nil, fmt.Errorf("output.status: %w", err) + } + + reasonPrg, err := compileExpression(env, rule.Output.Reason.Expression) + if err != nil { + return nil, fmt.Errorf("output.reason: %w", err) + } + + messagePrg, err := compileExpression(env, rule.Output.Message.Expression) + if err != nil { + return nil, fmt.Errorf("output.message: %w", err) + } + + return &compiledRule{ + conditionType: condType, + whenProgram: whenPrg, + statusProgram: statusPrg, + reasonProgram: reasonPrg, + messageProgram: messagePrg, + }, nil +} + +// compileExpression compiles a single CEL expression +func compileExpression(env *cel.Env, expression string) (cel.Program, error) { + ast, issues := env.Parse(expression) + if issues != nil && issues.Err() != nil { + return nil, fmt.Errorf("parse error: %w", issues.Err()) + } + + // Check for function arity errors and undefined functions + // Matches the validation pipeline in conditions.go for consistent fail-fast behavior + _, issues = env.Check(ast) + if issues != nil && issues.Err() != nil { + return nil, fmt.Errorf("check error: %w", issues.Err()) + } + + // Bound CEL evaluation cost to prevent CPU spikes from misconfigured expressions. + // Status aggregation is on the hot path (every adapter status update triggers it). + // Cost limit allows moderately complex expressions while preventing + // pathological cases (e.g., nested loops, unbounded string operations). + prg, err := env.Program(ast, cel.CostLimit(util.CELCostLimit)) + if err != nil { + return nil, fmt.Errorf("program error: %w", err) + } + + return prg, nil +} + +// buildActivation builds the CEL activation context from input data +// Combined filter + conversion in single pass to avoid double JSON unmarshal (PERF-03) +func buildActivation(ctx context.Context, statuses api.AdapterStatusList, resource interface{}, resourceKind string) map[string]interface{} { + // Convert adapter statuses to CEL-compatible format, filtering Unknown conditions in same pass + statusesList := make([]interface{}, 0, len(statuses)) + for _, status := range statuses { + // Skip nil entries (can occur in AdapterStatusList []*AdapterStatus) + if status == nil { + continue + } + // Parse conditions once and check for Unknown in same operation + statusMap, hasUnknown := adapterStatusToMapWithUnknownCheck(ctx, status) + if !hasUnknown { + statusesList = append(statusesList, statusMap) + } + } + + // Convert resource to map and mask sensitive fields (defense-in-depth) + // Matches the protection applied to adapter data to prevent credential leakage + resourceMap := util.MaskSensitiveFields(resourceToMap(ctx, resource, resourceKind)) + + return map[string]interface{}{ + util.CELVarStatuses: statusesList, + util.CELVarResource: resourceMap, + // Environment variables not implemented yet + // TODO: Create HYPERFLEET ticket for env variable support in CEL context + // Feature: Allow CEL expressions to access runtime config (e.g., env.REGION, env.ENVIRONMENT) + // SEC-02: Must use an allowlist of safe variables - NEVER expose all process env (contains secrets) + util.CELVarEnv: emptyEnvMap, // Shared package-level var (PERF-03) + } +} + +// parseConditionsWithUnknownCheck unmarshals adapter conditions from JSONB and converts to CEL maps. +// Returns (conditions, hasUnknown) where hasUnknown indicates if any condition has Unknown status. +// Returns empty slice (not nil) on unmarshal failure so CEL receives [] instead of null. +func parseConditionsWithUnknownCheck(ctx context.Context, conditionsJSON []byte, adapterName string) ([]map[string]interface{}, bool) { + // Initialize to empty slice (not nil) so CEL receives [] instead of null + conditions := make([]map[string]interface{}, 0) + hasUnknown := false + + if conditionsJSON == nil { + return conditions, hasUnknown + } + + var parsedConds []api.AdapterCondition + if err := json.Unmarshal(conditionsJSON, &parsedConds); err != nil { + // Unmarshal failure: return empty conditions array (ERR-02). + // Degraded mode: statuses array contains entry with empty conditions, allowing + // resource-level CEL expressions to still run (e.g., counting adapters). + logger.With(ctx, "adapter", adapterName). + WithError(err). + Warn("Failed to unmarshal adapter conditions JSONB, using empty conditions array") + return conditions, hasUnknown + } + + // Preallocate with exact capacity to avoid reallocation (PERF-01) + conditions = make([]map[string]interface{}, 0, len(parsedConds)) + for _, cond := range parsedConds { + // Check for Unknown status while building the map (single pass) + if cond.Status == api.AdapterConditionUnknown { + hasUnknown = true + // Break early - buildActivation will discard this entire statusMap + break + } + + condMap := map[string]interface{}{ + condKeyType: cond.Type, + condKeyStatus: string(cond.Status), + } + // SEC-02: Adapter contract requires condition reason/message to be user-safe + // (no secrets, credentials, or sensitive data). Currently unmasked to preserve + // human-readable diagnostic messages. Defense-in-depth improvement (HYPERFLEET-1128): + // Consider applying pattern-based string scanning to reason/message before placing + // in CEL context, matching the approach already applied to adapter data blobs via + // MaskSensitiveFields (e.g., regex for AWS keys, JWT tokens, base64 secrets). + if cond.Reason != nil { + condMap[condKeyReason] = *cond.Reason + } + if cond.Message != nil { + condMap[condKeyMessage] = *cond.Message + } + conditions = append(conditions, condMap) + } + + return conditions, hasUnknown +} + +// parseAdapterData unmarshals adapter data from JSONB to a map. +// Returns empty map on unmarshal failure to maintain CEL context consistency. +func parseAdapterData(ctx context.Context, dataJSON []byte, adapterName string) map[string]interface{} { + data := make(map[string]interface{}) + + if dataJSON == nil { + return data + } + + if err := json.Unmarshal(dataJSON, &data); err != nil { + // Reset to empty map on parse failure to maintain consistency + logger.With(ctx, "adapter", adapterName). + WithError(err). + Warn("Failed to unmarshal adapter data JSONB, using empty map") + return make(map[string]interface{}) + } + + return data +} + +// adapterStatusToMapWithUnknownCheck converts an AdapterStatus to a CEL-compatible map +// and reports whether any condition has Unknown status. +// Returns (statusMap, hasUnknown) to allow filtering in a single pass (PERF-03). +func adapterStatusToMapWithUnknownCheck(ctx context.Context, status *api.AdapterStatus) (map[string]interface{}, bool) { + // Guard against nil pointer (AdapterStatusList is []*AdapterStatus, so elements can be nil) + if status == nil { + return map[string]interface{}{ + celKeyAdapter: "", + celKeyObservedGeneration: float64(0), + celKeyConditions: []map[string]interface{}{}, + celKeyData: map[string]interface{}{}, + }, false + } + + // Parse conditions and check for Unknown status (QUAL-03) + conditions, hasUnknown := parseConditionsWithUnknownCheck(ctx, status.Conditions, status.Adapter) + + // Early return if Unknown found - buildActivation will discard this statusMap anyway (PERF-03) + // Skips data parsing and MaskSensitiveFields call to avoid wasted work + if hasUnknown { + return map[string]interface{}{ + celKeyAdapter: status.Adapter, + celKeyObservedGeneration: float64(status.ObservedGeneration), + celKeyConditions: conditions, + celKeyData: map[string]interface{}{}, + }, true + } + + // Parse data field from JSONB (QUAL-03) + data := parseAdapterData(ctx, status.Data, status.Adapter) + + statusMap := map[string]interface{}{ + celKeyAdapter: status.Adapter, + // Normalize observed_generation to float64 for consistency with resource.generation + // (which becomes float64 after JSON marshal/unmarshal round-trip) + celKeyObservedGeneration: float64(status.ObservedGeneration), + celKeyConditions: conditions, + // Mask sensitive fields before exposing to CEL evaluation context + // This prevents accidental leakage of credentials in public API condition messages/reasons + celKeyData: util.MaskSensitiveFields(data), + } + + return statusMap, hasUnknown +} + +// resourceToMap converts a resource to a CEL-compatible map +func resourceToMap(ctx context.Context, resource interface{}, resourceKind string) map[string]interface{} { + // Use JSON marshaling for generic conversion + data, err := json.Marshal(resource) + if err != nil { + // Marshal failure: return empty map (ERR-02). + // Degraded mode: CEL expressions using resource.* will receive empty object. + logger.With(ctx, "resource_kind", resourceKind).WithError(err). + Warn("Failed to marshal resource to JSON, using empty map") + return make(map[string]interface{}) + } + + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + // Unmarshal failure: return empty map (ERR-02). + // Degraded mode: CEL expressions using resource.* will receive empty object. + logger.With(ctx, "resource_kind", resourceKind).WithError(err). + Warn("Failed to unmarshal resource JSON to map, using empty map") + return make(map[string]interface{}) + } + + return result +} + +// extractString extracts a string value from a CEL result +func extractString(result ref.Val) string { + if types.IsUnknownOrError(result) { + return "" + } + // Handle CEL null values explicitly to prevent "" in API responses + if result.Equal(types.NullValue) == types.True { + return "" + } + switch v := result.Value().(type) { + case string: + return v + case bool: + if v { + return celBoolTrue + } + return celBoolFalse + default: + return fmt.Sprintf("%v", v) + } +} + +// truncateUTF8 truncates a string to maxBytes preserving valid UTF-8 encoding. +// It ensures we don't cut in the middle of a multi-byte character. +func truncateUTF8(s string, maxBytes int) string { + if len(s) <= maxBytes { + return s + } + + // Start from maxBytes and walk backward to find a valid rune boundary + for i := maxBytes; i > 0; i-- { + // Check if this position is a valid rune start + if (s[i] & 0xC0) != 0x80 { + // This is either ASCII (0xxxxxxx) or a multibyte start (11xxxxxx) + // Verify the rune is complete + r, size := utf8.DecodeRuneInString(s[i:]) + if r != utf8.RuneError && i+size <= len(s) { + // Valid rune boundary, truncate here + return s[:i] + } + } + } + + // Fallback: return empty string if we can't find a valid boundary + return "" +} diff --git a/pkg/services/condition_mapper_test.go b/pkg/services/condition_mapper_test.go new file mode 100644 index 00000000..51775d87 --- /dev/null +++ b/pkg/services/condition_mapper_test.go @@ -0,0 +1,1392 @@ +package services + +import ( + "context" + "encoding/json" + "testing" + "time" + "unicode/utf8" + + "github.com/google/cel-go/common/types" + . "github.com/onsi/gomega" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/util" +) + +// ============================================================================ +// Tests from condition_mapper_check_test.go +// ============================================================================ + +func TestConditionMapper_CELCheckValidation(t *testing.T) { + t.Run("undefined function caught at compile time", func(t *testing.T) { + RegisterTestingT(t) + + rules := map[string]config.ConditionMappingRule{ + "Test": { + When: config.MappingExpression{ + Expression: `undefinedFunction(statuses)`, // No such function + }, + Output: config.MappingOutput{ + Status: config.MappingExpression{Expression: `"True"`}, + Reason: config.MappingExpression{Expression: `"OK"`}, + Message: config.MappingExpression{Expression: `"OK"`}, + }, + }, + } + + _, err := NewConditionMapper("Cluster", rules) + + // Should fail at compile time with check error + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("check error")) + Expect(err.Error()).To(ContainSubstring("undefinedFunction")) + }) + + t.Run("wrong function arity caught at compile time", func(t *testing.T) { + RegisterTestingT(t) + + rules := map[string]config.ConditionMappingRule{ + "Test": { + When: config.MappingExpression{ + // size() expects 1 argument, not 2 + Expression: `size(statuses, 'extra arg')`, + }, + Output: config.MappingOutput{ + Status: config.MappingExpression{Expression: `"True"`}, + Reason: config.MappingExpression{Expression: `"OK"`}, + Message: config.MappingExpression{Expression: `"OK"`}, + }, + }, + } + + _, err := NewConditionMapper("Cluster", rules) + + // Should fail at compile time with check error + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("check error")) + }) + + t.Run("valid CEL expression compiles successfully", func(t *testing.T) { + RegisterTestingT(t) + + rules := map[string]config.ConditionMappingRule{ + "Test": { + When: config.MappingExpression{ + Expression: `size(statuses) > 0`, // Valid + }, + Output: config.MappingOutput{ + Status: config.MappingExpression{Expression: `"True"`}, + Reason: config.MappingExpression{Expression: `"OK"`}, + Message: config.MappingExpression{Expression: `"OK"`}, + }, + }, + } + + mapper, err := NewConditionMapper("Cluster", rules) + + Expect(err).NotTo(HaveOccurred()) + Expect(mapper).NotTo(BeNil()) + }) + + t.Run("Check detects error before Program in all expressions", func(t *testing.T) { + RegisterTestingT(t) + + // Test that Check runs for all 4 expression types (when, status, reason, message) + testCases := []struct { + name string + badField string + badExpr string + goodWhen string + goodStatus string + goodReason string + goodMessage string + }{ + { + name: "undefined in when", + badField: "when", + badExpr: `noSuchFunc()`, + goodStatus: `"True"`, + goodReason: `"OK"`, + goodMessage: `"OK"`, + }, + { + name: "undefined in status", + badField: "status", + badExpr: `noSuchFunc()`, + goodWhen: `true`, + goodReason: `"OK"`, + goodMessage: `"OK"`, + }, + { + name: "undefined in reason", + badField: "reason", + badExpr: `noSuchFunc()`, + goodWhen: `true`, + goodStatus: `"True"`, + goodMessage: `"OK"`, + }, + { + name: "undefined in message", + badField: "message", + badExpr: `noSuchFunc()`, + goodWhen: `true`, + goodStatus: `"True"`, + goodReason: `"OK"`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + RegisterTestingT(t) + + // Build rule with bad expression in the specified field + whenExpr := tc.goodWhen + if tc.badField == "when" { + whenExpr = tc.badExpr + } + + statusExpr := tc.goodStatus + if tc.badField == "status" { + statusExpr = tc.badExpr + } + + reasonExpr := tc.goodReason + if tc.badField == "reason" { + reasonExpr = tc.badExpr + } + + messageExpr := tc.goodMessage + if tc.badField == "message" { + messageExpr = tc.badExpr + } + + rules := map[string]config.ConditionMappingRule{ + "Test": { + When: config.MappingExpression{Expression: whenExpr}, + Output: config.MappingOutput{ + Status: config.MappingExpression{Expression: statusExpr}, + Reason: config.MappingExpression{Expression: reasonExpr}, + Message: config.MappingExpression{Expression: messageExpr}, + }, + }, + } + + _, err := NewConditionMapper("Cluster", rules) + + // Should fail at compile time + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("check error")) + Expect(err.Error()).To(ContainSubstring(tc.badField)) // Error mentions which field + }) + } + }) +} + +// ============================================================================ +// Tests from condition_mapper_masking_test.go +// ============================================================================ + +func TestConditionMapper_SensitiveDataMasking(t *testing.T) { + t.Run("sensitive adapter data is masked in CEL context", func(t *testing.T) { + RegisterTestingT(t) + + // Rule that tries to extract adapter data fields + rules := map[string]config.ConditionMappingRule{ + "DataExtract": { + When: config.MappingExpression{Expression: `statuses.exists(s, s.adapter == "test")`}, + Output: config.MappingOutput{ + Status: config.MappingExpression{Expression: `"True"`}, + Reason: config.MappingExpression{Expression: `"OK"`}, + // Try to extract sensitive field from data + Message: config.MappingExpression{ + Expression: `statuses.filter(s, s.adapter == "test")[0].data.adminPassword`, + }, + }, + }, + } + + mapper, err := NewConditionMapper("Cluster", rules) + Expect(err).NotTo(HaveOccurred()) + + // Adapter status with sensitive data + sensitiveData := map[string]interface{}{ + "clusterName": "prod-cluster", + "adminPassword": "super-secret-password-123", // Should be masked + } + dataJSON, _ := json.Marshal(sensitiveData) // static test data, marshal cannot fail + + statuses := api.AdapterStatusList{ + { + Adapter: "test", + Conditions: []byte(`[]`), + Data: dataJSON, + }, + } + + result := mapper.Apply(context.Background(), ApplyInput{ + AdapterStatuses: statuses, + Resource: map[string]interface{}{}, + RefTime: time.Now(), + }) + + Expect(result).To(HaveLen(1)) + cond := result[0] + + // The CEL expression should get the masked value, not the real password + Expect(*cond.Message).To(Equal("***REDACTED***")) + Expect(*cond.Message).NotTo(ContainSubstring("super-secret-password-123")) + }) + + t.Run("non-sensitive adapter data passes through", func(t *testing.T) { + RegisterTestingT(t) + + rules := map[string]config.ConditionMappingRule{ + "DataExtract": { + When: config.MappingExpression{Expression: `statuses.exists(s, s.adapter == "test")`}, + Output: config.MappingOutput{ + Status: config.MappingExpression{Expression: `"True"`}, + Reason: config.MappingExpression{Expression: `"OK"`}, + // Extract non-sensitive field + Message: config.MappingExpression{ + Expression: `statuses.filter(s, s.adapter == "test")[0].data.clusterName`, + }, + }, + }, + } + + mapper, err := NewConditionMapper("Cluster", rules) + Expect(err).NotTo(HaveOccurred()) + + // Adapter status with non-sensitive data + data := map[string]interface{}{ + "clusterName": "prod-cluster-1", + "region": "us-west-2", + } + dataJSON, _ := json.Marshal(data) // static test data, marshal cannot fail + + statuses := api.AdapterStatusList{ + { + Adapter: "test", + Conditions: []byte(`[]`), + Data: dataJSON, + }, + } + + result := mapper.Apply(context.Background(), ApplyInput{ + AdapterStatuses: statuses, + Resource: map[string]interface{}{}, + RefTime: time.Now(), + }) + + Expect(result).To(HaveLen(1)) + cond := result[0] + + // Non-sensitive data should pass through + Expect(*cond.Message).To(Equal("prod-cluster-1")) + }) + + t.Run("nested sensitive data is masked", func(t *testing.T) { + RegisterTestingT(t) + + rules := map[string]config.ConditionMappingRule{ + "DataExtract": { + When: config.MappingExpression{Expression: `statuses.exists(s, s.adapter == "test")`}, + Output: config.MappingOutput{ + Status: config.MappingExpression{Expression: `"True"`}, + Reason: config.MappingExpression{Expression: `"OK"`}, + // Try to extract nested sensitive field + Message: config.MappingExpression{ + Expression: `statuses.filter(s, s.adapter == "test")[0].data.serviceAccount.privateKey`, + }, + }, + }, + } + + mapper, err := NewConditionMapper("Cluster", rules) + Expect(err).NotTo(HaveOccurred()) + + // Adapter status with nested sensitive data + data := map[string]interface{}{ + "clusterName": "prod-cluster", + "serviceAccount": map[string]interface{}{ + "name": "sa-123", + "privateKey": "-----BEGIN PRIVATE KEY-----\n...", // Should be masked + }, + } + dataJSON, _ := json.Marshal(data) // static test data, marshal cannot fail + + statuses := api.AdapterStatusList{ + { + Adapter: "test", + Conditions: []byte(`[]`), + Data: dataJSON, + }, + } + + result := mapper.Apply(context.Background(), ApplyInput{ + AdapterStatuses: statuses, + Resource: map[string]interface{}{}, + RefTime: time.Now(), + }) + + Expect(result).To(HaveLen(1)) + cond := result[0] + + // Nested sensitive field should be masked + Expect(*cond.Message).To(Equal("***REDACTED***")) + Expect(*cond.Message).NotTo(ContainSubstring("BEGIN PRIVATE KEY")) + }) + + t.Run("multiple adapters with mixed sensitive and non-sensitive data", func(t *testing.T) { + RegisterTestingT(t) + + rules := map[string]config.ConditionMappingRule{ + "Summary": { + When: config.MappingExpression{Expression: `size(statuses) > 0`}, + Output: config.MappingOutput{ + Status: config.MappingExpression{Expression: `"True"`}, + Reason: config.MappingExpression{Expression: `"OK"`}, + // Build message from multiple adapters + Message: config.MappingExpression{ + Expression: `"Cluster: " + statuses[0].data.name + ", Secret: " + statuses[1].data.pullSecret`, + }, + }, + }, + } + + mapper, err := NewConditionMapper("Cluster", rules) + Expect(err).NotTo(HaveOccurred()) + + // First adapter: non-sensitive + data1 := map[string]interface{}{ + "name": "my-cluster", + } + data1JSON, _ := json.Marshal(data1) // static test data, marshal cannot fail + + // Second adapter: sensitive + data2 := map[string]interface{}{ + "pullSecret": "eyJhdXRocyI6eyJjbG91ZC5vcGVuc2hpZnQuY29tIjp7ImF1dGgi...", + } + data2JSON, _ := json.Marshal(data2) // static test data, marshal cannot fail + + statuses := api.AdapterStatusList{ + { + Adapter: "adapter1", + Conditions: []byte(`[]`), + Data: data1JSON, + }, + { + Adapter: "adapter2", + Conditions: []byte(`[]`), + Data: data2JSON, + }, + } + + result := mapper.Apply(context.Background(), ApplyInput{ + AdapterStatuses: statuses, + Resource: map[string]interface{}{}, + RefTime: time.Now(), + }) + + Expect(result).To(HaveLen(1)) + cond := result[0] + + // Message should have non-sensitive data but masked sensitive data + Expect(*cond.Message).To(Equal("Cluster: my-cluster, Secret: ***REDACTED***")) + Expect(*cond.Message).NotTo(ContainSubstring("eyJhdXRocyI6")) + }) + + t.Run("arrays with sensitive fields are masked in CEL context", func(t *testing.T) { + RegisterTestingT(t) + + rules := map[string]config.ConditionMappingRule{ + "UserPassword": { + When: config.MappingExpression{Expression: `statuses.exists(s, s.adapter == "test")`}, + Output: config.MappingOutput{ + Status: config.MappingExpression{Expression: `"True"`}, + Reason: config.MappingExpression{Expression: `"OK"`}, + // Try to extract password from users array + Message: config.MappingExpression{ + Expression: `statuses.filter(s, s.adapter == "test")[0].data.users[0].password`, + }, + }, + }, + } + + mapper, err := NewConditionMapper("Cluster", rules) + Expect(err).NotTo(HaveOccurred()) + + // Adapter with array containing sensitive fields + data := map[string]interface{}{ + "clusterName": "test", + "users": []interface{}{ + map[string]interface{}{ + "name": "admin", + "password": "super-secret-password-123", // Should be masked + }, + }, + } + dataJSON, _ := json.Marshal(data) // static test data, marshal cannot fail + + statuses := api.AdapterStatusList{ + { + Adapter: "test", + Conditions: []byte(`[]`), + Data: dataJSON, + }, + } + + result := mapper.Apply(context.Background(), ApplyInput{ + AdapterStatuses: statuses, + Resource: map[string]interface{}{}, + RefTime: time.Now(), + }) + + Expect(result).To(HaveLen(1)) + cond := result[0] + + // CEL should receive masked value from array element + Expect(*cond.Message).To(Equal("***REDACTED***")) + Expect(*cond.Message).NotTo(ContainSubstring("super-secret-password-123")) + }) + + t.Run("sensitive resource fields are masked in CEL context (defense-in-depth)", func(t *testing.T) { + RegisterTestingT(t) + + rules := map[string]config.ConditionMappingRule{ + "ResourceSecret": { + When: config.MappingExpression{Expression: `true`}, + Output: config.MappingOutput{ + Status: config.MappingExpression{Expression: `"True"`}, + Reason: config.MappingExpression{Expression: `"OK"`}, + // Try to extract sensitive field from resource + Message: config.MappingExpression{ + Expression: `"Admin password: " + resource.spec.adminPassword`, + }, + }, + }, + } + + mapper, err := NewConditionMapper("Cluster", rules) + Expect(err).NotTo(HaveOccurred()) + + // Resource with sensitive field in spec + resource := map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test-cluster", + }, + "spec": map[string]interface{}{ + "region": "us-west-2", + "adminPassword": "cluster-admin-secret-123", // Should be masked + }, + } + + result := mapper.Apply(context.Background(), ApplyInput{ + AdapterStatuses: api.AdapterStatusList{}, + Resource: resource, + RefTime: time.Now(), + }) + + Expect(result).To(HaveLen(1)) + cond := result[0] + + // CEL should receive masked value from resource + Expect(*cond.Message).To(Equal("Admin password: ***REDACTED***")) + Expect(*cond.Message).NotTo(ContainSubstring("cluster-admin-secret-123")) + }) + + t.Run("nested sensitive fields in resource arrays are masked", func(t *testing.T) { + RegisterTestingT(t) + + rules := map[string]config.ConditionMappingRule{ + "NodeToken": { + When: config.MappingExpression{Expression: `size(resource.spec.nodes) > 0`}, + Output: config.MappingOutput{ + Status: config.MappingExpression{Expression: `"True"`}, + Reason: config.MappingExpression{Expression: `"OK"`}, + // Try to extract token from nodes array + Message: config.MappingExpression{ + Expression: `"Node token: " + resource.spec.nodes[0].authToken`, + }, + }, + }, + } + + mapper, err := NewConditionMapper("Cluster", rules) + Expect(err).NotTo(HaveOccurred()) + + // Resource with sensitive fields in nested arrays + resource := map[string]interface{}{ + "spec": map[string]interface{}{ + "nodes": []interface{}{ + map[string]interface{}{ + "name": "node-1", + "authToken": "secret-node-token-abc123", // Should be masked + }, + }, + }, + } + + result := mapper.Apply(context.Background(), ApplyInput{ + AdapterStatuses: api.AdapterStatusList{}, + Resource: resource, + RefTime: time.Now(), + }) + + Expect(result).To(HaveLen(1)) + cond := result[0] + + // CEL should receive masked value from nested array + Expect(*cond.Message).To(Equal("Node token: ***REDACTED***")) + Expect(*cond.Message).NotTo(ContainSubstring("secret-node-token-abc123")) + }) +} + +// ============================================================================ +// Tests from condition_mapper_nil_guard_test.go +// ============================================================================ + +func TestAdapterStatusToMapWithUnknownCheck_NilGuard(t *testing.T) { + t.Run("nil status pointer returns safe empty map", func(t *testing.T) { + RegisterTestingT(t) + + statusMap, hasUnknown := adapterStatusToMapWithUnknownCheck(context.Background(), nil) + + // Should not panic and return safe defaults + Expect(hasUnknown).To(BeFalse()) + Expect(statusMap).NotTo(BeNil()) + Expect(statusMap[celKeyAdapter]).To(Equal("")) + Expect(statusMap[celKeyObservedGeneration]).To(Equal(float64(0))) + Expect(statusMap[celKeyConditions]).To(Equal([]map[string]interface{}{})) + Expect(statusMap[celKeyData]).To(Equal(map[string]interface{}{})) + }) + + t.Run("buildActivation handles nil element in AdapterStatusList gracefully", func(t *testing.T) { + RegisterTestingT(t) + + // AdapterStatusList with nil element + statuses := api.AdapterStatusList{ + nil, // Could happen if DAO has a bug + { + Adapter: "test-adapter", + Conditions: []byte(`[]`), + }, + } + + // Should not panic + activation := buildActivation(context.Background(), statuses, map[string]interface{}{}, "Cluster") + + statusesList := activation[util.CELVarStatuses].([]interface{}) + // Nil element should be skipped, only valid adapter present + Expect(statusesList).To(HaveLen(1)) + + // Only element is the valid adapter (nil was skipped) + first := statusesList[0].(map[string]interface{}) + Expect(first[celKeyAdapter]).To(Equal("test-adapter")) + }) +} + +// ============================================================================ +// Tests from condition_mapper_timestamps_test.go +// ============================================================================ + +func TestConditionMapper_TimestampPreservation(t *testing.T) { + t.Run("new condition gets refTime for all timestamps", func(t *testing.T) { + RegisterTestingT(t) + + rules := map[string]config.ConditionMappingRule{ + "TestReady": { + When: config.MappingExpression{Expression: `true`}, + Output: config.MappingOutput{ + Status: config.MappingExpression{Expression: `"True"`}, + Reason: config.MappingExpression{Expression: `"OK"`}, + Message: config.MappingExpression{Expression: `"All good"`}, + }, + }, + } + + mapper, err := NewConditionMapper("Cluster", rules) + Expect(err).NotTo(HaveOccurred()) + + refTime := time.Date(2026, 7, 25, 10, 0, 0, 0, time.UTC) + result := mapper.Apply(context.Background(), ApplyInput{ + AdapterStatuses: api.AdapterStatusList{}, + Resource: map[string]interface{}{}, + RefTime: refTime, + PrevConditions: nil, // No previous conditions + }) + + Expect(result).To(HaveLen(1)) + cond := result[0] + + // All timestamps should be refTime for new condition + expectedTime := refTime.UTC().Truncate(time.Microsecond) + Expect(cond.CreatedTime).To(Equal(expectedTime)) + Expect(cond.LastTransitionTime).To(Equal(expectedTime)) + Expect(cond.LastUpdatedTime).To(Equal(expectedTime)) + }) + + t.Run("status unchanged preserves CreatedTime and LastTransitionTime", func(t *testing.T) { + RegisterTestingT(t) + + rules := map[string]config.ConditionMappingRule{ + "TestReady": { + When: config.MappingExpression{Expression: `true`}, + Output: config.MappingOutput{ + Status: config.MappingExpression{Expression: `"True"`}, + Reason: config.MappingExpression{Expression: `"OK"`}, + Message: config.MappingExpression{Expression: `"All good"`}, + }, + }, + } + + mapper, err := NewConditionMapper("Cluster", rules) + Expect(err).NotTo(HaveOccurred()) + + // Previous condition with old timestamps + oldCreatedTime := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC) + oldTransitionTime := time.Date(2026, 7, 21, 10, 0, 0, 0, time.UTC) + prevConditions := []api.ResourceCondition{ + { + Type: "TestReady", + Status: api.ConditionTrue, // Same status + CreatedTime: oldCreatedTime, + LastTransitionTime: oldTransitionTime, + }, + } + + // New evaluation at a later time + newRefTime := time.Date(2026, 7, 25, 10, 0, 0, 0, time.UTC) + result := mapper.Apply(context.Background(), ApplyInput{ + AdapterStatuses: api.AdapterStatusList{}, + Resource: map[string]interface{}{}, + RefTime: newRefTime, + PrevConditions: prevConditions, + }) + + Expect(result).To(HaveLen(1)) + cond := result[0] + + // Status unchanged → preserve CreatedTime and LastTransitionTime + Expect(cond.CreatedTime).To(Equal(oldCreatedTime)) + Expect(cond.LastTransitionTime).To(Equal(oldTransitionTime)) + // LastUpdatedTime should be refTime + Expect(cond.LastUpdatedTime).To(Equal(newRefTime.UTC().Truncate(time.Microsecond))) + }) + + t.Run("status changed updates LastTransitionTime but preserves CreatedTime", func(t *testing.T) { + RegisterTestingT(t) + + rules := map[string]config.ConditionMappingRule{ + "TestReady": { + When: config.MappingExpression{Expression: `true`}, + Output: config.MappingOutput{ + Status: config.MappingExpression{Expression: `"False"`}, // Changed from True + Reason: config.MappingExpression{Expression: `"NotReady"`}, + Message: config.MappingExpression{Expression: `"Something broke"`}, + }, + }, + } + + mapper, err := NewConditionMapper("Cluster", rules) + Expect(err).NotTo(HaveOccurred()) + + // Previous condition with True status + oldCreatedTime := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC) + oldTransitionTime := time.Date(2026, 7, 21, 10, 0, 0, 0, time.UTC) + prevConditions := []api.ResourceCondition{ + { + Type: "TestReady", + Status: api.ConditionTrue, // Will change to False + CreatedTime: oldCreatedTime, + LastTransitionTime: oldTransitionTime, + }, + } + + // New evaluation at a later time + newRefTime := time.Date(2026, 7, 25, 10, 0, 0, 0, time.UTC) + result := mapper.Apply(context.Background(), ApplyInput{ + AdapterStatuses: api.AdapterStatusList{}, + Resource: map[string]interface{}{}, + RefTime: newRefTime, + PrevConditions: prevConditions, + }) + + Expect(result).To(HaveLen(1)) + cond := result[0] + + // Status changed → preserve CreatedTime, update LastTransitionTime + Expect(cond.CreatedTime).To(Equal(oldCreatedTime)) + Expect(cond.LastTransitionTime).To(Equal(newRefTime.UTC().Truncate(time.Microsecond))) + Expect(cond.LastUpdatedTime).To(Equal(newRefTime.UTC().Truncate(time.Microsecond))) + }) + + t.Run("multiple conditions preserve timestamps independently", func(t *testing.T) { + RegisterTestingT(t) + + rules := map[string]config.ConditionMappingRule{ + "ConditionA": { + When: config.MappingExpression{Expression: `true`}, + Output: config.MappingOutput{ + Status: config.MappingExpression{Expression: `"True"`}, + Reason: config.MappingExpression{Expression: `"OK"`}, + Message: config.MappingExpression{Expression: `"A is ready"`}, + }, + }, + "ConditionB": { + When: config.MappingExpression{Expression: `true`}, + Output: config.MappingOutput{ + Status: config.MappingExpression{Expression: `"False"`}, // Changed + Reason: config.MappingExpression{Expression: `"NotReady"`}, + Message: config.MappingExpression{Expression: `"B is not ready"`}, + }, + }, + } + + mapper, err := NewConditionMapper("Cluster", rules) + Expect(err).NotTo(HaveOccurred()) + + // Previous conditions: A unchanged (True), B changed (True→False) + timeA := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC) + timeB := time.Date(2026, 7, 21, 10, 0, 0, 0, time.UTC) + prevConditions := []api.ResourceCondition{ + { + Type: "ConditionA", + Status: api.ConditionTrue, + CreatedTime: timeA, + LastTransitionTime: timeA, + }, + { + Type: "ConditionB", + Status: api.ConditionTrue, // Will change to False + CreatedTime: timeB, + LastTransitionTime: timeB, + }, + } + + newRefTime := time.Date(2026, 7, 25, 10, 0, 0, 0, time.UTC) + result := mapper.Apply(context.Background(), ApplyInput{ + AdapterStatuses: api.AdapterStatusList{}, + Resource: map[string]interface{}{}, + RefTime: newRefTime, + PrevConditions: prevConditions, + }) + + Expect(result).To(HaveLen(2)) + + // Find ConditionA and ConditionB + var condA, condB *api.ResourceCondition + for i := range result { + if result[i].Type == "ConditionA" { + condA = &result[i] + } else if result[i].Type == "ConditionB" { + condB = &result[i] + } + } + + Expect(condA).NotTo(BeNil()) + Expect(condB).NotTo(BeNil()) + + // ConditionA: status unchanged → preserve both timestamps + Expect(condA.CreatedTime).To(Equal(timeA)) + Expect(condA.LastTransitionTime).To(Equal(timeA)) + + // ConditionB: status changed → preserve CreatedTime, update LastTransitionTime + Expect(condB.CreatedTime).To(Equal(timeB)) + Expect(condB.LastTransitionTime).To(Equal(newRefTime.UTC().Truncate(time.Microsecond))) + }) +} + +// ============================================================================ +// Tests from condition_mapper_logging_test.go +// ============================================================================ + +func TestAdapterStatusToMapWithUnknownCheck_LogsJSONErrors(t *testing.T) { + t.Run("invalid conditions JSONB logs warning", func(t *testing.T) { + RegisterTestingT(t) + + status := &api.AdapterStatus{ + Adapter: "test-adapter", + ObservedGeneration: 1, + Conditions: []byte(`{invalid json`), // Invalid JSON + Data: []byte(`{}`), + } + + // Should not panic, should return empty conditions and log warning + statusMap, hasUnknown := adapterStatusToMapWithUnknownCheck(context.Background(), status) + + Expect(hasUnknown).To(BeFalse()) + Expect(statusMap[celKeyConditions]).To(BeEmpty(), "invalid JSON should result in empty conditions") + Expect(statusMap[celKeyAdapter]).To(Equal("test-adapter")) + }) + + t.Run("invalid data JSONB logs warning", func(t *testing.T) { + RegisterTestingT(t) + + status := &api.AdapterStatus{ + Adapter: "test-adapter", + ObservedGeneration: 1, + Conditions: []byte(`[]`), + Data: []byte(`{invalid json`), // Invalid JSON + } + + // Should not panic, should return empty data map and log warning + statusMap, hasUnknown := adapterStatusToMapWithUnknownCheck(context.Background(), status) + + Expect(hasUnknown).To(BeFalse()) + Expect(statusMap[celKeyData]).To(BeEmpty(), "invalid JSON should result in empty data map") + Expect(statusMap[celKeyAdapter]).To(Equal("test-adapter")) + }) +} +func TestResourceToMap_LogsJSONErrors(t *testing.T) { + t.Run("unmarshalable resource logs warning", func(t *testing.T) { + RegisterTestingT(t) + + // Channels cannot be marshaled to JSON + resource := make(chan int) + + // Should not panic, should return empty map and log warning + result := resourceToMap(context.Background(), resource, "Cluster") + + Expect(result).To(BeEmpty(), "unmarshalable resource should result in empty map") + }) + + t.Run("normal resource works without warnings", func(t *testing.T) { + RegisterTestingT(t) + + resource := map[string]interface{}{ + "name": "test", + "generation": 1, + } + + result := resourceToMap(context.Background(), resource, "Cluster") + + Expect(result).NotTo(BeEmpty()) + Expect(result["name"]).To(Equal("test")) + }) +} +func TestBuildActivation_NumericTypesConsistency(t *testing.T) { + t.Run("observed_generation is float64 for CEL type consistency", func(t *testing.T) { + RegisterTestingT(t) + + statuses := api.AdapterStatusList{ + { + Adapter: "test", + ObservedGeneration: 5, // int32 + Conditions: []byte(`[]`), + Data: []byte(`{}`), + }, + } + + resource := map[string]interface{}{ + "generation": 5, // Will be float64 after JSON round-trip + } + + activation := buildActivation(context.Background(), statuses, resource, "Cluster") + + // Verify statuses[0].observed_generation is float64 + statusesList := activation[util.CELVarStatuses].([]interface{}) + Expect(statusesList).To(HaveLen(1)) + + firstStatus := statusesList[0].(map[string]interface{}) + observedGen := firstStatus[celKeyObservedGeneration] + + // Should be float64, not int32 + Expect(observedGen).To(BeAssignableToTypeOf(float64(0)), + "observed_generation should be float64 for consistency with resource.generation after JSON round-trip") + + // Verify resource.generation is also float64 (from resourceToMap JSON round-trip) + resourceMap := activation[util.CELVarResource].(map[string]interface{}) + resourceGen := resourceMap[resourceKeyGeneration] + + Expect(resourceGen).To(BeAssignableToTypeOf(float64(0)), + "resource.generation should be float64 after JSON round-trip") + + // Both should be the same type for CEL expression consistency + Expect(observedGen).To(BeAssignableToTypeOf(resourceGen), + "observed_generation and generation should have the same type for CEL consistency") + }) +} + +// ============================================================================ +// Benchmarks +// ============================================================================ + +// BenchmarkConditionMapper_Apply benchmarks the Apply method with varying numbers of previous conditions +// to demonstrate the O(1) map lookup optimization vs O(N) linear scan +func BenchmarkConditionMapper_Apply(b *testing.B) { + // Create mapper with multiple rules + rules := map[string]config.ConditionMappingRule{ + "Condition1": { + When: config.MappingExpression{Expression: `true`}, + Output: config.MappingOutput{ + Status: config.MappingExpression{Expression: `"True"`}, + Reason: config.MappingExpression{Expression: `"OK"`}, + Message: config.MappingExpression{Expression: `"All good"`}, + }, + }, + "Condition2": { + When: config.MappingExpression{Expression: `true`}, + Output: config.MappingOutput{ + Status: config.MappingExpression{Expression: `"True"`}, + Reason: config.MappingExpression{Expression: `"OK"`}, + Message: config.MappingExpression{Expression: `"All good"`}, + }, + }, + "Condition3": { + When: config.MappingExpression{Expression: `true`}, + Output: config.MappingOutput{ + Status: config.MappingExpression{Expression: `"True"`}, + Reason: config.MappingExpression{Expression: `"OK"`}, + Message: config.MappingExpression{Expression: `"All good"`}, + }, + }, + "Condition4": { + When: config.MappingExpression{Expression: `true`}, + Output: config.MappingOutput{ + Status: config.MappingExpression{Expression: `"True"`}, + Reason: config.MappingExpression{Expression: `"OK"`}, + Message: config.MappingExpression{Expression: `"All good"`}, + }, + }, + "Condition5": { + When: config.MappingExpression{Expression: `true`}, + Output: config.MappingOutput{ + Status: config.MappingExpression{Expression: `"True"`}, + Reason: config.MappingExpression{Expression: `"OK"`}, + Message: config.MappingExpression{Expression: `"All good"`}, + }, + }, + } + + mapper, err := NewConditionMapper("Cluster", rules) + if err != nil { + b.Fatalf("Failed to create mapper: %v", err) + } + + benchmarks := []struct { + name string + prevCondCount int + }{ + {"0_previous_conditions", 0}, + {"5_previous_conditions", 5}, + {"10_previous_conditions", 10}, + {"20_previous_conditions", 20}, + {"50_previous_conditions", 50}, + } + + for _, bm := range benchmarks { + b.Run(bm.name, func(b *testing.B) { + // Create previous conditions + prevConditions := make([]api.ResourceCondition, bm.prevCondCount) + for i := 0; i < bm.prevCondCount; i++ { + prevConditions[i] = api.ResourceCondition{ + Type: "OtherCondition" + string(rune('A'+i)), + Status: api.ConditionTrue, + CreatedTime: time.Now(), + LastTransitionTime: time.Now(), + } + } + // Add the conditions our mapper actually looks for + if bm.prevCondCount > 0 { + prevConditions[0].Type = "Condition1" + } + + input := ApplyInput{ + AdapterStatuses: api.AdapterStatusList{}, + Resource: map[string]interface{}{}, + RefTime: time.Now(), + PrevConditions: prevConditions, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = mapper.Apply(context.Background(), input) + } + }) + } +} + +// BenchmarkConditionMapper_PreAllocation benchmarks the effect of pre-allocating the result slice +func BenchmarkConditionMapper_PreAllocation(b *testing.B) { + rules := make(map[string]config.ConditionMappingRule) + for i := 0; i < 10; i++ { + name := "Condition" + string(rune('A'+i)) + rules[name] = config.ConditionMappingRule{ + When: config.MappingExpression{Expression: `true`}, + Output: config.MappingOutput{ + Status: config.MappingExpression{Expression: `"True"`}, + Reason: config.MappingExpression{Expression: `"OK"`}, + Message: config.MappingExpression{Expression: `"All good"`}, + }, + } + } + + mapper, err := NewConditionMapper("Cluster", rules) + if err != nil { + b.Fatalf("Failed to create mapper: %v", err) + } + + input := ApplyInput{ + AdapterStatuses: api.AdapterStatusList{}, + Resource: map[string]interface{}{}, + RefTime: time.Now(), + PrevConditions: nil, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = mapper.Apply(context.Background(), input) + } +} + +// ============================================================================ +// Tests from extract_string_test.go +// ============================================================================ + +func TestExtractString_NullHandling(t *testing.T) { + t.Run("CEL null value returns empty string", func(t *testing.T) { + RegisterTestingT(t) + // CEL expressions can return null (e.g., optional field access, map lookup miss) + // We must handle this to prevent "" appearing in API responses + result := extractString(types.NullValue) + Expect(result).To(Equal(""), "null should produce empty string, not ''") + }) + + t.Run("string value returns as-is", func(t *testing.T) { + RegisterTestingT(t) + result := extractString(types.String("test")) + Expect(result).To(Equal("test")) + }) + + t.Run("bool true returns True", func(t *testing.T) { + RegisterTestingT(t) + result := extractString(types.Bool(true)) + Expect(result).To(Equal("True")) + }) + + t.Run("bool false returns False", func(t *testing.T) { + RegisterTestingT(t) + result := extractString(types.Bool(false)) + Expect(result).To(Equal("False")) + }) + + t.Run("int value returns stringified", func(t *testing.T) { + RegisterTestingT(t) + result := extractString(types.Int(42)) + Expect(result).To(Equal("42")) + }) +} + +// ============================================================================ +// Tests from truncate_utf8_test.go +// ============================================================================ + +func TestTruncateUTF8(t *testing.T) { + t.Run("empty string returns empty", func(t *testing.T) { + RegisterTestingT(t) + result := truncateUTF8("", 10) + Expect(result).To(Equal("")) + }) + + t.Run("maxBytes is 0 returns empty", func(t *testing.T) { + RegisterTestingT(t) + result := truncateUTF8("hello", 0) + Expect(result).To(Equal("")) + }) + + t.Run("string shorter than maxBytes returns unchanged", func(t *testing.T) { + RegisterTestingT(t) + input := "hello" + result := truncateUTF8(input, 10) + Expect(result).To(Equal(input)) + }) + + t.Run("string equal to maxBytes returns unchanged", func(t *testing.T) { + RegisterTestingT(t) + input := "hello" + result := truncateUTF8(input, 5) + Expect(result).To(Equal(input)) + }) + + t.Run("ASCII truncation at exact boundary", func(t *testing.T) { + RegisterTestingT(t) + input := "hello world" + result := truncateUTF8(input, 5) + Expect(result).To(Equal("hello")) + Expect(utf8.ValidString(result)).To(BeTrue()) + }) + + t.Run("2-byte UTF-8 character (é) at truncation boundary", func(t *testing.T) { + RegisterTestingT(t) + // "café" = 5 bytes: c(1) a(1) f(1) é(2) + input := "café" + + // Truncate at 4 bytes - should cut before é + result := truncateUTF8(input, 4) + Expect(result).To(Equal("caf")) + Expect(utf8.ValidString(result)).To(BeTrue()) + + // Truncate at 5 bytes - should include é + result = truncateUTF8(input, 5) + Expect(result).To(Equal("café")) + Expect(utf8.ValidString(result)).To(BeTrue()) + }) + + t.Run("3-byte UTF-8 character (€) at truncation boundary", func(t *testing.T) { + RegisterTestingT(t) + // "10€" = 5 bytes: 1(1) 0(1) €(3) + input := "10€" + + // Truncate at 2 bytes - should be "10" + result := truncateUTF8(input, 2) + Expect(result).To(Equal("10")) + Expect(utf8.ValidString(result)).To(BeTrue()) + + // Truncate at 3 or 4 bytes - still "10" (can't fit complete €) + result = truncateUTF8(input, 3) + Expect(result).To(Equal("10")) + Expect(utf8.ValidString(result)).To(BeTrue()) + + result = truncateUTF8(input, 4) + Expect(result).To(Equal("10")) + Expect(utf8.ValidString(result)).To(BeTrue()) + + // Truncate at 5 bytes - includes € + result = truncateUTF8(input, 5) + Expect(result).To(Equal("10€")) + Expect(utf8.ValidString(result)).To(BeTrue()) + }) + + t.Run("4-byte UTF-8 character (emoji 😀) at truncation boundary", func(t *testing.T) { + RegisterTestingT(t) + // "hi😀" = 6 bytes: h(1) i(1) 😀(4) + input := "hi😀" + + // Truncate at 2 bytes - should be "hi" + result := truncateUTF8(input, 2) + Expect(result).To(Equal("hi")) + Expect(utf8.ValidString(result)).To(BeTrue()) + + // Truncate at 3/4/5 bytes - still "hi" (can't fit 😀) + result = truncateUTF8(input, 5) + Expect(result).To(Equal("hi")) + Expect(utf8.ValidString(result)).To(BeTrue()) + + // Truncate at 6 bytes - includes emoji + result = truncateUTF8(input, 6) + Expect(result).To(Equal("hi😀")) + Expect(utf8.ValidString(result)).To(BeTrue()) + }) + + t.Run("all multibyte string with maxBytes smaller than first rune", func(t *testing.T) { + RegisterTestingT(t) + // "€€€" = 9 bytes (3 bytes each) + input := "€€€" + + // maxBytes=1 or 2 - can't fit even one € + result := truncateUTF8(input, 1) + Expect(result).To(Equal("")) + + result = truncateUTF8(input, 2) + Expect(result).To(Equal("")) + + // maxBytes=3 - fits one € + result = truncateUTF8(input, 3) + Expect(result).To(Equal("€")) + Expect(utf8.ValidString(result)).To(BeTrue()) + }) + + t.Run("mixed ASCII and multibyte characters", func(t *testing.T) { + RegisterTestingT(t) + // "Hello世界" = 11 bytes: H(1) e(1) l(1) l(1) o(1) 世(3) 界(3) + input := "Hello世界" + + // Truncate at 5 - "Hello" + result := truncateUTF8(input, 5) + Expect(result).To(Equal("Hello")) + Expect(utf8.ValidString(result)).To(BeTrue()) + + // Truncate at 7 - "Hello" (can't fit 世) + result = truncateUTF8(input, 7) + Expect(result).To(Equal("Hello")) + Expect(utf8.ValidString(result)).To(BeTrue()) + + // Truncate at 8 - "Hello世" + result = truncateUTF8(input, 8) + Expect(result).To(Equal("Hello世")) + Expect(utf8.ValidString(result)).To(BeTrue()) + }) + + t.Run("result is always valid UTF-8", func(t *testing.T) { + RegisterTestingT(t) + testCases := []string{ + "café", + "10€20", + "hi😀bye", + "世界你好", + "mixed-ASCII-and-日本語", + "emoji🎉test🎊string", + } + + for _, input := range testCases { + // Try various truncation points + for maxBytes := 0; maxBytes <= len(input); maxBytes++ { + result := truncateUTF8(input, maxBytes) + Expect(utf8.ValidString(result)).To(BeTrue(), + "truncateUTF8(%q, %d) = %q should be valid UTF-8", + input, maxBytes, result) + } + } + }) + + t.Run("preserves complete characters only", func(t *testing.T) { + RegisterTestingT(t) + // "test😀end" = 11 bytes: t(1) e(1) s(1) t(1) 😀(4) e(1) n(1) d(1) + input := "test😀end" + + // Truncate at 7 - should be "test" (can't fit 😀) + result := truncateUTF8(input, 7) + Expect(result).To(Equal("test")) + Expect(utf8.RuneCountInString(result)).To(Equal(4)) + + // Truncate at 8 - should include emoji + result = truncateUTF8(input, 8) + Expect(result).To(Equal("test😀")) + Expect(utf8.RuneCountInString(result)).To(Equal(5)) + }) + + t.Run("invalid status string from CEL expression", func(t *testing.T) { + RegisterTestingT(t) + + // CEL rule that outputs "Maybe" instead of "True"/"False" + rules := map[string]config.ConditionMappingRule{ + "TestCondition": { + When: config.MappingExpression{ + Expression: "true", + }, + Output: config.MappingOutput{ + Status: config.MappingExpression{ + Expression: `"Maybe"`, // Invalid status + }, + Reason: config.MappingExpression{ + Expression: `"test"`, + }, + Message: config.MappingExpression{ + Expression: `"test message"`, + }, + }, + }, + } + + mapper, err := NewConditionMapper("Cluster", rules) + Expect(err).NotTo(HaveOccurred()) + + input := ApplyInput{ + AdapterStatuses: api.AdapterStatusList{}, + Resource: map[string]interface{}{}, + RefTime: time.Now(), + } + + // Should not panic, should skip the condition and log error + result := mapper.Apply(context.Background(), input) + Expect(result).To(BeEmpty(), "invalid status should skip condition") + }) + + t.Run("non-boolean when expression return type", func(t *testing.T) { + RegisterTestingT(t) + + // CEL rule with when expression that returns string instead of boolean + rules := map[string]config.ConditionMappingRule{ + "TestCondition": { + When: config.MappingExpression{ + Expression: `"not a boolean"`, // Should return bool + }, + Output: config.MappingOutput{ + Status: config.MappingExpression{ + Expression: `"True"`, + }, + Reason: config.MappingExpression{ + Expression: `"test"`, + }, + Message: config.MappingExpression{ + Expression: `"test message"`, + }, + }, + }, + } + + mapper, err := NewConditionMapper("Cluster", rules) + Expect(err).NotTo(HaveOccurred()) + + input := ApplyInput{ + AdapterStatuses: api.AdapterStatusList{}, + Resource: map[string]interface{}{}, + RefTime: time.Now(), + } + + // Should not panic, should skip the condition + result := mapper.Apply(context.Background(), input) + Expect(result).To(BeEmpty(), "non-boolean when expression should skip condition") + }) + + t.Run("concurrent Apply() access from multiple goroutines", func(t *testing.T) { + RegisterTestingT(t) + + rules := map[string]config.ConditionMappingRule{ + "TestCondition": { + When: config.MappingExpression{ + Expression: "true", + }, + Output: config.MappingOutput{ + Status: config.MappingExpression{ + Expression: `"True"`, + }, + Reason: config.MappingExpression{ + Expression: `"test"`, + }, + Message: config.MappingExpression{ + Expression: `"test message"`, + }, + }, + }, + } + + mapper, err := NewConditionMapper("Cluster", rules) + Expect(err).NotTo(HaveOccurred()) + + // Run under -race flag to detect data races + const numGoroutines = 10 + type result struct { + conditions []api.ResourceCondition + err error + } + results := make(chan result, numGoroutines) + + for i := 0; i < numGoroutines; i++ { + go func() { + input := ApplyInput{ + AdapterStatuses: api.AdapterStatusList{}, + Resource: map[string]interface{}{ + "generation": int64(1), + }, + RefTime: time.Now(), + } + + // Collect result, don't assert in goroutine + conditions := mapper.Apply(context.Background(), input) + results <- result{conditions: conditions} + }() + } + + // Wait for all goroutines and assert on main test goroutine + for i := 0; i < numGoroutines; i++ { + res := <-results + Expect(res.err).NotTo(HaveOccurred()) + Expect(res.conditions).To(HaveLen(1)) + Expect(res.conditions[0].Type).To(Equal("TestCondition")) + } + }) +} diff --git a/pkg/services/resource.go b/pkg/services/resource.go index 33783391..69ecf678 100644 --- a/pkg/services/resource.go +++ b/pkg/services/resource.go @@ -8,6 +8,7 @@ import ( "time" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" @@ -39,13 +40,44 @@ func NewResourceService( adapterStatusDao dao.AdapterStatusDao, resourceConditionDao dao.ResourceConditionDao, generic GenericService, + conditionsConfig *config.ConditionsConfig, ) ResourceService { + // Create condition mappers from config (nil if config is empty or nil) + // Using a map indexed by Kind simplifies runtime lookup, but adding new entity kinds + // requires code changes: new field in ConditionsConfig (pkg/config/conditions.go) + // and new if-block here to instantiate the mapper for that kind. + conditionMappers := make(map[string]*ConditionMapper) + if conditionsConfig != nil && !conditionsConfig.IsEmpty() { + if len(conditionsConfig.Clusters) > 0 { + mapper, err := NewConditionMapper(kindCluster, conditionsConfig.Clusters) + if err != nil { + // This should not happen since config was validated at startup - indicates a code bug + // (e.g., validation logic vs. mapper logic mismatch). Use Error level to alert ops team, + // but continue in degraded mode (no CEL mapping) to keep service available per HYG-02. + logger.With(context.Background(), "resource_kind", kindCluster).WithError(err).Error("Failed to create condition mapper, continuing without CEL mapping") + } else { + conditionMappers[kindCluster] = mapper + } + } + if len(conditionsConfig.NodePools) > 0 { + mapper, err := NewConditionMapper(kindNodePool, conditionsConfig.NodePools) + if err != nil { + // This should not happen since config was validated at startup - indicates a code bug. + // Use Error level to alert ops team, but continue in degraded mode per HYG-02. + logger.With(context.Background(), "resource_kind", kindNodePool).WithError(err).Error("Failed to create condition mapper, continuing without CEL mapping") + } else { + conditionMappers[kindNodePool] = mapper + } + } + } + return &sqlResourceService{ resourceDao: resourceDao, resourceLabelDao: resourceLabelDao, adapterStatusDao: adapterStatusDao, resourceConditionDao: resourceConditionDao, generic: generic, + conditionMappers: conditionMappers, } } @@ -57,6 +89,7 @@ type sqlResourceService struct { adapterStatusDao dao.AdapterStatusDao resourceConditionDao dao.ResourceConditionDao generic GenericService + conditionMappers map[string]*ConditionMapper // Indexed by Kind (e.g., kindCluster, kindNodePool) } // Get returns a single resource by kind and ID. Returns 404 if not found. @@ -568,9 +601,11 @@ func (s *sqlResourceService) ProcessAdapterStatus( } // Step 4: Re-aggregate conditions from all adapter statuses and persist - // to the resource_conditions table. Only runs when the Available condition - // changed to True or False (not on Unknown or discarded updates). - if triggerAggregation { + // to the resource_conditions table. Runs when: + // 1. Available condition changed to True or False (not on Unknown or discarded updates), OR + // 2. A CEL condition mapper is configured for this resource kind (to keep mapped conditions current) + hasMapper := s.conditionMappers[resource.Kind] != nil + if triggerAggregation || hasMapper { if aggregateErr := s.recomputeAndSaveResourceConditions( ctx, resource, updatedStatuses, ); aggregateErr != nil { @@ -643,6 +678,17 @@ func (s *sqlResourceService) recomputeAndSaveResourceConditions( newConditions = append(newConditions, reconciled, lastKnownReconciled) newConditions = append(newConditions, adapterConditions...) + // Apply CEL condition mapping if configured + if mapper := s.conditionMappers[resource.Kind]; mapper != nil { + mappedConditions := mapper.Apply(ctx, ApplyInput{ + AdapterStatuses: adapterStatuses, + Resource: resource, + RefTime: refTime, + PrevConditions: resource.Conditions, // Preserve timestamps from previous conditions + }) + newConditions = append(newConditions, mappedConditions...) + } + // Compare via JSON to detect actual changes. newJSON, marshalErr := json.Marshal(newConditions) if marshalErr != nil { diff --git a/pkg/services/resource_test.go b/pkg/services/resource_test.go index 13a7bb55..071a6c13 100644 --- a/pkg/services/resource_test.go +++ b/pkg/services/resource_test.go @@ -13,6 +13,7 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/auth" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" @@ -280,7 +281,7 @@ var _ dao.ResourceConditionDao = &resourceConditionMock{} func newTestResourceService(mockDao *mockResourceDao) (ResourceService, *mockResourceDao, *resourceGenericMock) { generic := &resourceGenericMock{} svc := NewResourceService( - mockDao, newMockResourceLabelDao(), newMockAdapterStatusDao(), newResourceConditionMock(), generic, + mockDao, newMockResourceLabelDao(), newMockAdapterStatusDao(), newResourceConditionMock(), generic, nil, ) return svc, mockDao, generic } @@ -291,7 +292,7 @@ func newTestResourceServiceWithLabelDao( generic := &resourceGenericMock{} labelDao := newMockResourceLabelDao() svc := NewResourceService( - mockDao, labelDao, newMockAdapterStatusDao(), newResourceConditionMock(), generic, + mockDao, labelDao, newMockAdapterStatusDao(), newResourceConditionMock(), generic, nil, ) return svc, mockDao, generic, labelDao } @@ -302,7 +303,18 @@ func newTestResourceServiceWithAdapterStatus( asDao := newMockAdapterStatusDao() rcDao := newResourceConditionMock() generic := &resourceGenericMock{} - svc := NewResourceService(mockDao, newMockResourceLabelDao(), asDao, rcDao, generic) + svc := NewResourceService(mockDao, newMockResourceLabelDao(), asDao, rcDao, generic, nil) + return svc, mockDao, asDao, rcDao +} + +func newTestResourceServiceWithConditions( + mockDao *mockResourceDao, + conditionsConfig *config.ConditionsConfig, +) (ResourceService, *mockResourceDao, *mockAdapterStatusDao, *resourceConditionMock) { + asDao := newMockAdapterStatusDao() + rcDao := newResourceConditionMock() + generic := &resourceGenericMock{} + svc := NewResourceService(mockDao, newMockResourceLabelDao(), asDao, rcDao, generic, conditionsConfig) return svc, mockDao, asDao, rcDao } @@ -3142,3 +3154,79 @@ func TestProcessAdapterStatus_FinalizedTrue_RecomputesConditions_WhenHardDeleteB Expect(*recon.Reason).To(ContainSubstring("Children"), "Reason should indicate waiting for child resources") } + +// --- Condition Mapping --- + +func TestResourceService_ConditionMapper_IntegrationPath(t *testing.T) { + RegisterTestingT(t) + registry.Reset() + registry.Register(registry.EntityDescriptor{ + Kind: "Cluster", + Plural: "clusters", + }) + + // Create config with CEL condition mapping rule + conditionsConfig := &config.ConditionsConfig{ + Clusters: map[string]config.ConditionMappingRule{ + "CustomReady": { + When: config.MappingExpression{ + Expression: `statuses.exists(s, s.adapter == "test-adapter" && s.conditions.exists(c, c.type == "CustomCondition" && c.status == "True"))`, + }, + Output: config.MappingOutput{ + Status: config.MappingExpression{ + Expression: `"True"`, + }, + Reason: config.MappingExpression{ + Expression: `"CustomOK"`, + }, + Message: config.MappingExpression{ + Expression: `"Custom condition is ready"`, + }, + }, + }, + }, + } + + mockDao := newMockResourceDao() + svc, _, _, rcDao := newTestResourceServiceWithConditions(mockDao, conditionsConfig) + + cluster := testResource("Cluster", "cl-1", "test-cluster") + cluster.Generation = 1 + mockDao.addResource(cluster) + + // Report adapter status with custom condition + req := &api.AdapterStatus{ + Adapter: "test-adapter", + ObservedGeneration: 1, + LastReportTime: time.Now().UTC(), + Conditions: testConditionsJSON( + api.AdapterCondition{Type: api.AdapterConditionTypeAvailable, Status: api.AdapterConditionTrue}, + api.AdapterCondition{Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue}, + api.AdapterCondition{Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue}, + api.AdapterCondition{Type: "CustomCondition", Status: api.AdapterConditionTrue}, + ), + } + + result, svcErr := svc.ProcessAdapterStatus(context.Background(), "Cluster", cluster.ID, req) + Expect(svcErr).To(BeNil()) + Expect(result).ToNot(BeNil()) + + // Verify mapped condition was created + conditions := rcDao.conditions[cluster.ID] + Expect(conditions).ToNot(BeEmpty()) + + // Should have standard conditions + mapped condition + var customReady *api.ResourceCondition + for i := range conditions { + if conditions[i].Type == "CustomReady" { + customReady = &conditions[i] + break + } + } + + Expect(customReady).ToNot(BeNil(), "CustomReady condition should be created by mapper") + Expect(customReady.Status).To(Equal(api.ConditionTrue)) + Expect(*customReady.Reason).To(Equal("CustomOK")) + Expect(*customReady.Message).To(Equal("Custom condition is ready")) + Expect(customReady.ObservedGeneration).To(Equal(cluster.Generation), "ObservedGeneration should match resource generation") +} diff --git a/pkg/util/cel.go b/pkg/util/cel.go new file mode 100644 index 00000000..0e4b9383 --- /dev/null +++ b/pkg/util/cel.go @@ -0,0 +1,114 @@ +package util + +import ( + "encoding/json" + "strconv" + "strings" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" +) + +// CELCostLimit is the maximum cost allowed for CEL expression evaluation. +// Prevents CPU spikes from misconfigured or pathological expressions. +// Used by both runtime evaluation and startup validation. +const CELCostLimit = 10000 + +// CEL context variable names (QUAL-01: prevent silent name mismatches) +// These constants ensure consistency between environment declaration and activation map +const ( + CELVarStatuses = "statuses" // Array of adapter statuses + CELVarResource = "resource" // Full cluster/nodepool object + CELVarEnv = "env" // Environment variables map +) + +// NewConditionMappingEnvironment creates a CEL environment for condition mapping +// with context variables and custom functions. +// This environment is used both for validation (at config load time) and runtime evaluation. +func NewConditionMappingEnvironment() (*cel.Env, error) { + return cel.NewEnv( + // Enable optional chaining for safe navigation + cel.OptionalTypes(), + + // Context variables + cel.Variable(CELVarStatuses, cel.ListType(cel.DynType)), + cel.Variable(CELVarResource, cel.DynType), + cel.Variable(CELVarEnv, cel.MapType(cel.StringType, cel.StringType)), + + // Custom functions (reused from hyperfleet-adapter patterns) + cel.Function("toJson", + cel.Overload("toJson_dyn", + []*cel.Type{cel.DynType}, + cel.StringType, + cel.UnaryBinding(toJSONFunc))), + + cel.Function("dig", + cel.Overload("dig_dyn_string", + []*cel.Type{cel.DynType, cel.StringType}, + cel.DynType, + cel.BinaryBinding(digFunc))), + ) +} + +// toJSONFunc implements the toJson() CEL function +func toJSONFunc(val ref.Val) ref.Val { + v := val.Value() + data, err := json.Marshal(v) + if err != nil { + return types.NewErr("toJson: %v", err) + } + + // Size guard: prevent unbounded intermediate allocations from large payloads + // Limit matches Kubernetes ConfigMap max size (1MB) as a reasonable upper bound + const maxJSONSize = 1 * 1024 * 1024 // 1MB + if len(data) > maxJSONSize { + return types.NewErr("toJson: output exceeds 1MB limit (%d bytes)", len(data)) + } + + return types.String(string(data)) +} + +// digFunc implements the dig() CEL function for safe nested navigation +// Supports both map keys and array indices (e.g., "statuses.0.conditions.1.type") +func digFunc(target ref.Val, path ref.Val) ref.Val { + pathStr, ok := path.Value().(string) + if !ok { + return types.NullValue + } + + pathStr = strings.TrimSpace(pathStr) + if pathStr == "" { + return target + } + + // Navigate through nested map/list structure + current := target.Value() + parts := strings.Split(pathStr, ".") + for _, rawPart := range parts { + part := strings.TrimSpace(rawPart) + if part == "" { + continue + } + + switch v := current.(type) { + case map[string]interface{}: + next, found := v[part] + if !found { + return types.NullValue + } + current = next + case []interface{}: + // Try parsing as array index + idx, err := strconv.Atoi(part) + if err != nil || idx < 0 || idx >= len(v) { + return types.NullValue + } + current = v[idx] + default: + return types.NullValue + } + } + + return types.DefaultTypeAdapter.NativeToValue(current) +} diff --git a/pkg/util/cel_test.go b/pkg/util/cel_test.go new file mode 100644 index 00000000..d7a37bc0 --- /dev/null +++ b/pkg/util/cel_test.go @@ -0,0 +1,286 @@ +package util + +import ( + "testing" + + "github.com/google/cel-go/cel" + . "github.com/onsi/gomega" +) + +// ============================================================================ +// Tests from cel_test.go +// ============================================================================ + +func TestNewConditionMappingEnvironment(t *testing.T) { + RegisterTestingT(t) + + env, err := NewConditionMappingEnvironment() + Expect(err).NotTo(HaveOccurred()) + Expect(env).NotTo(BeNil()) +} +func TestDigFunc_MapNavigation(t *testing.T) { + RegisterTestingT(t) + + env, err := NewConditionMappingEnvironment() + Expect(err).NotTo(HaveOccurred()) + + // Test data: nested map + resourceData := map[string]interface{}{ + "adapter": "validation", + "status": map[string]interface{}{ + "conditions": []interface{}{ + map[string]interface{}{ + "type": "Available", + "status": "True", + }, + }, + }, + } + + tests := []struct { + name string + expression string + want interface{} + wantErr bool + }{ + { + name: "simple map key", + expression: `dig(resource, "adapter")`, + want: "validation", + }, + { + name: "nested map key", + expression: `dig(resource, "status.conditions")`, + want: []interface{}{ + map[string]interface{}{ + "type": "Available", + "status": "True", + }, + }, + }, + { + name: "array index", + expression: `dig(resource, "status.conditions.0.type")`, + want: "Available", + }, + { + name: "missing key returns null", + expression: `dig(resource, "missing") == null`, + want: true, + }, + { + name: "out of bounds index returns null", + expression: `dig(resource, "status.conditions.99") == null`, + want: true, + }, + { + name: "negative index returns null", + expression: `dig(resource, "status.conditions.-1") == null`, + want: true, + }, + { + name: "empty path returns original", + expression: `dig(resource, "").adapter`, + want: "validation", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + RegisterTestingT(t) + + ast, issues := env.Parse(tt.expression) + Expect(issues).To(BeNil()) + + // Check step: validates variable names and function signatures match environment declaration + // Matches the 3-step compilation pipeline in compileExpression (condition_mapper.go:348-371) + _, issues = env.Check(ast) + Expect(issues).To(BeNil()) + + prg, err := env.Program(ast, cel.CostLimit(CELCostLimit)) + Expect(err).NotTo(HaveOccurred()) + + out, _, err := prg.Eval(map[string]interface{}{ + CELVarResource: resourceData, + }) + + if tt.wantErr { + Expect(err).To(HaveOccurred()) + } else { + Expect(err).NotTo(HaveOccurred()) + Expect(out.Value()).To(Equal(tt.want)) + } + }) + } +} +func TestDigFunc_ArrayNavigation(t *testing.T) { + RegisterTestingT(t) + + env, err := NewConditionMappingEnvironment() + Expect(err).NotTo(HaveOccurred()) + + // Test data: array at root + statusesData := []interface{}{ + map[string]interface{}{ + "adapter": "validation", + "conditions": []interface{}{ + map[string]interface{}{ + "type": "Available", + "status": "True", + }, + map[string]interface{}{ + "type": "Health", + "status": "False", + }, + }, + }, + map[string]interface{}{ + "adapter": "dns", + "conditions": []interface{}{ + map[string]interface{}{ + "type": "Ready", + "status": "True", + }, + }, + }, + } + + tests := []struct { + name string + expression string + want interface{} + }{ + { + name: "first adapter name", + expression: `dig(statuses, "0.adapter")`, + want: "validation", + }, + { + name: "second adapter name", + expression: `dig(statuses, "1.adapter")`, + want: "dns", + }, + { + name: "nested array access", + expression: `dig(statuses, "0.conditions.1.type")`, + want: "Health", + }, + { + name: "deep nested navigation", + expression: `dig(statuses, "1.conditions.0.status")`, + want: "True", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + RegisterTestingT(t) + + ast, issues := env.Parse(tt.expression) + Expect(issues).To(BeNil()) + + // Check step: validates variable names and function signatures match environment declaration + // Matches the 3-step compilation pipeline in compileExpression (condition_mapper.go:348-371) + _, issues = env.Check(ast) + Expect(issues).To(BeNil()) + + prg, err := env.Program(ast, cel.CostLimit(CELCostLimit)) + Expect(err).NotTo(HaveOccurred()) + + out, _, err := prg.Eval(map[string]interface{}{ + CELVarStatuses: statusesData, + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(out.Value()).To(Equal(tt.want)) + }) + } +} +func TestToJsonFunc(t *testing.T) { + RegisterTestingT(t) + + env, err := NewConditionMappingEnvironment() + Expect(err).NotTo(HaveOccurred()) + + resourceData := map[string]interface{}{ + "name": "test", + "count": int64(42), + } + + ast, issues := env.Parse(`toJson(resource)`) + Expect(issues).To(BeNil()) + + // Check step: validates variable names and function signatures match environment declaration + // Matches the 3-step compilation pipeline in compileExpression (condition_mapper.go:348-371) + _, issues = env.Check(ast) + Expect(issues).To(BeNil()) + + prg, err := env.Program(ast, cel.CostLimit(CELCostLimit)) + Expect(err).NotTo(HaveOccurred()) + + out, _, err := prg.Eval(map[string]interface{}{ + CELVarResource: resourceData, + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(out.Value()).To(Equal(`{"count":42,"name":"test"}`)) +} + +// ============================================================================ +// Tests from cel_constants_test.go +// ============================================================================ + +func TestCELVariableConstants(t *testing.T) { + t.Run("CEL variable constants prevent name mismatches (QUAL-01)", func(t *testing.T) { + RegisterTestingT(t) + + // Create environment using constants + env, err := NewConditionMappingEnvironment() + Expect(err).NotTo(HaveOccurred()) + + // Verify that expressions using the documented variable names compile successfully + // This test ensures that if we change a constant, the environment declaration changes too + validExpressions := []string{ + "size(statuses) > 0", // Uses CELVarStatuses + "resource.metadata.name", // Uses CELVarResource + "env.REGION", // Uses CELVarEnv + "statuses.exists(s, s.adapter == 'x')", // Complex usage + "resource.generation > 0", // Resource field access + } + + for _, expr := range validExpressions { + ast, issues := env.Parse(expr) + Expect(issues).To(BeNil(), "Expression should parse: %s", expr) + + _, issues = env.Check(ast) + Expect(issues).To(BeNil(), "Expression should check: %s", expr) + } + }) + + t.Run("typo in variable name causes compile error", func(t *testing.T) { + RegisterTestingT(t) + + env, err := NewConditionMappingEnvironment() + Expect(err).NotTo(HaveOccurred()) + + // If someone hardcodes "status" instead of "statuses", Check catches it + invalidExpr := "size(status) > 0" // Typo: "status" instead of "statuses" + + ast, issues := env.Parse(invalidExpr) + Expect(issues).To(BeNil(), "Parse should succeed even with undefined variable") + + // Check should fail because "status" is not declared + _, issues = env.Check(ast) + Expect(issues).NotTo(BeNil(), "Check should fail for undefined variable 'status'") + Expect(issues.Err().Error()).To(ContainSubstring("status"), "Error should mention the undefined variable") + }) + + t.Run("constant values match expected strings", func(t *testing.T) { + RegisterTestingT(t) + + // Verify constant values are what we expect (prevents accidental changes) + Expect(CELVarStatuses).To(Equal("statuses")) + Expect(CELVarResource).To(Equal("resource")) + Expect(CELVarEnv).To(Equal("env")) + }) +} diff --git a/pkg/util/mask_sensitive.go b/pkg/util/mask_sensitive.go new file mode 100644 index 00000000..8212e8e3 --- /dev/null +++ b/pkg/util/mask_sensitive.go @@ -0,0 +1,129 @@ +package util + +import "strings" + +// RedactedPlaceholder is the string used to replace sensitive field values (QUAL-01) +const RedactedPlaceholder = "***REDACTED***" + +// maxMaskingDepth limits recursion depth to prevent stack overflow from +// deeply-nested adapter data payloads (SEC-03) +const maxMaskingDepth = 20 + +// Sensitive field patterns per HyperFleet security best practices (SEC-02) +// Matches common secret/credential field names case-insensitively +var sensitivePatterns = []string{ + "password", + "secret", + "token", + // Specific key patterns (SEC-02: avoid false positives like "partitionKey", "sortKey") + "apikey", // API keys + "privatekey", // Private keys (SSH, TLS, etc.) + "secretkey", // Secret keys + "accesskey", // AWS access keys + "sshkey", // SSH keys + "encryptionkey", // Encryption keys + "accountkey", // Service account keys (GCP, Azure) + "servicekey", // Service keys + "registrykey", // Container registry keys + "signingkey", // Code signing keys + "credential", + "api_key", // Snake_case variant + "passphrase", + // Broad patterns (SEC-02): intentional over-masking for defense-in-depth + // Trade-off: May mask non-sensitive fields like "privateEndpoint", "authProvider", "connectionTimeout" + // Decision: Prefer over-masking to under-masking for security (prevents credential leakage) + "private", // Private keys, private data - broad but catches "privateKey", "privateData" + "auth", // Auth tokens, auth keys - broad but catches "authToken", "authKey", "authorization" + "connection", // Connection strings - broad but catches "connectionString", "dbConnection" + "cert", // TLS certificates (SEC-02) + "kubeconfig", // Kubernetes config blobs (SEC-02) + "bearer", // Bearer tokens (SEC-02) +} + +// MaskSensitiveFields redacts adapter data keys matching sensitive patterns +// before exposing to CEL evaluation context. This prevents accidental leakage +// of credentials in public API condition messages/reasons. +// +// Keys are checked case-insensitively against sensitivePatterns. +// Redacted values are replaced with RedactedPlaceholder. +// +// Recursion is limited to maxMaskingDepth to prevent stack overflow (SEC-03). +// +// Examples: +// - "adminPassword" → redacted (contains "password") +// - "pullSecret" → redacted (contains "secret") +// - "gcpServiceAccountKey" → redacted (contains "accountkey") +// - "clusterName" → NOT redacted (no sensitive pattern match) +func MaskSensitiveFields(data map[string]interface{}) map[string]interface{} { + return maskSensitiveFieldsDepth(data, 0) +} + +// maskSensitiveFieldsDepth is the internal implementation with depth tracking (SEC-03) +func maskSensitiveFieldsDepth(data map[string]interface{}, depth int) map[string]interface{} { + if data == nil { + return nil + } + + // SEC-03: Stop recursion at maxMaskingDepth to prevent stack overflow + // from malicious/malformed deeply-nested adapter payloads + if depth >= maxMaskingDepth { + // Return empty map at depth limit (safe degradation) + return make(map[string]interface{}) + } + + masked := make(map[string]interface{}, len(data)) + for k, v := range data { + if isSensitiveKey(k) { + masked[k] = RedactedPlaceholder + } else { + // Recursively mask nested maps and slices + if nestedMap, ok := v.(map[string]interface{}); ok { + masked[k] = maskSensitiveFieldsDepth(nestedMap, depth+1) + } else if nestedSlice, ok := v.([]interface{}); ok { + masked[k] = maskSensitiveSliceDepth(nestedSlice, depth+1) + } else { + masked[k] = v + } + } + } + return masked +} + +// maskSensitiveSliceDepth recursively masks sensitive fields in slice elements +// with depth tracking to prevent stack overflow (SEC-03) +func maskSensitiveSliceDepth(slice []interface{}, depth int) []interface{} { + if slice == nil { + return nil + } + + // SEC-03: Stop recursion at maxMaskingDepth + if depth >= maxMaskingDepth { + // Return empty slice at depth limit (safe degradation) + return []interface{}{} + } + + masked := make([]interface{}, len(slice)) + for i, elem := range slice { + // Recursively mask maps inside the slice + if elemMap, ok := elem.(map[string]interface{}); ok { + masked[i] = maskSensitiveFieldsDepth(elemMap, depth+1) + } else if elemSlice, ok := elem.([]interface{}); ok { + // Handle nested slices (arrays of arrays) + masked[i] = maskSensitiveSliceDepth(elemSlice, depth+1) + } else { + masked[i] = elem + } + } + return masked +} + +// isSensitiveKey returns true if the key matches any sensitive pattern +func isSensitiveKey(key string) bool { + lowerKey := strings.ToLower(key) + for _, pattern := range sensitivePatterns { + if strings.Contains(lowerKey, pattern) { + return true + } + } + return false +} diff --git a/pkg/util/mask_sensitive_test.go b/pkg/util/mask_sensitive_test.go new file mode 100644 index 00000000..a5f6d5c7 --- /dev/null +++ b/pkg/util/mask_sensitive_test.go @@ -0,0 +1,695 @@ +package util + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +// ============================================================================ +// Core Masking Tests +// ============================================================================ + +func TestMaskSensitiveFields(t *testing.T) { + t.Run("nil map returns nil", func(t *testing.T) { + RegisterTestingT(t) + result := MaskSensitiveFields(nil) + Expect(result).To(BeNil()) + }) + + t.Run("empty map returns empty", func(t *testing.T) { + RegisterTestingT(t) + result := MaskSensitiveFields(map[string]interface{}{}) + Expect(result).To(BeEmpty()) + }) + + t.Run("non-sensitive fields pass through unchanged", func(t *testing.T) { + RegisterTestingT(t) + + input := map[string]interface{}{ + "clusterName": "test-cluster", + "region": "us-west-2", + "count": 3, + } + + result := MaskSensitiveFields(input) + + Expect(result).To(HaveLen(3)) + Expect(result["clusterName"]).To(Equal("test-cluster")) + Expect(result["region"]).To(Equal("us-west-2")) + Expect(result["count"]).To(Equal(3)) + }) + + t.Run("password field is redacted", func(t *testing.T) { + RegisterTestingT(t) + + input := map[string]interface{}{ + "username": "admin", + "password": "super-secret-123", + } + + result := MaskSensitiveFields(input) + + Expect(result["username"]).To(Equal("admin")) + Expect(result["password"]).To(Equal(RedactedPlaceholder)) + }) + + t.Run("all sensitive patterns are redacted case-insensitively", func(t *testing.T) { + RegisterTestingT(t) + + input := map[string]interface{}{ + "adminPassword": "secret1", + "pullSecret": "secret2", + "apiToken": "secret3", + "gcpServiceAccountKey": "secret4", + "awsCredential": "secret5", + "ApiKey": "secret6", // Mixed case + "PRIVATE_KEY": "secret7", // Upper case + "authToken": "secret8", + } + + result := MaskSensitiveFields(input) + + // All should be redacted + for k := range input { + Expect(result[k]).To(Equal(RedactedPlaceholder), "field %s should be redacted", k) + } + }) + + t.Run("nested maps are recursively masked", func(t *testing.T) { + RegisterTestingT(t) + + input := map[string]interface{}{ + "clusterName": "test-cluster", + "auth": map[string]interface{}{ + "username": "admin", + "password": "secret123", + }, + } + + result := MaskSensitiveFields(input) + + Expect(result["clusterName"]).To(Equal("test-cluster")) + + // "auth" key itself is redacted (contains "auth" pattern) + Expect(result["auth"]).To(Equal(RedactedPlaceholder)) + }) + + t.Run("nested non-sensitive map is recursively processed", func(t *testing.T) { + RegisterTestingT(t) + + input := map[string]interface{}{ + "clusterName": "test-cluster", + "config": map[string]interface{}{ + "region": "us-west-2", + "password": "secret123", + }, + } + + result := MaskSensitiveFields(input) + + Expect(result["clusterName"]).To(Equal("test-cluster")) + + // "config" itself is not sensitive, so we get nested map + configMap, ok := result["config"].(map[string]interface{}) + Expect(ok).To(BeTrue()) + Expect(configMap["region"]).To(Equal("us-west-2")) + Expect(configMap["password"]).To(Equal(RedactedPlaceholder)) + }) + + t.Run("deeply nested sensitive fields are redacted", func(t *testing.T) { + RegisterTestingT(t) + + input := map[string]interface{}{ + "level1": map[string]interface{}{ + "level2": map[string]interface{}{ + "level3": map[string]interface{}{ + "password": "deep-secret", + "region": "us-east-1", + }, + }, + }, + } + + result := MaskSensitiveFields(input) + + level1 := result["level1"].(map[string]interface{}) + level2 := level1["level2"].(map[string]interface{}) + level3 := level2["level3"].(map[string]interface{}) + + Expect(level3["password"]).To(Equal(RedactedPlaceholder)) + Expect(level3["region"]).To(Equal("us-east-1")) + }) + + t.Run("complex real-world adapter data", func(t *testing.T) { + RegisterTestingT(t) + + input := map[string]interface{}{ + "cluster_name": "prod-cluster-1", + "namespace": "default", + "service_account": map[string]interface{}{ + "name": "hypershift-sa", + "privateKey": "fake-private-key-for-testing-not-real", + }, + "pull_secret": "fake-pull-secret-for-testing", + "admin_password": "admin123", + "region": "us-west-2", + "node_count": 3, + } + + result := MaskSensitiveFields(input) + + // Non-sensitive fields preserved + Expect(result["cluster_name"]).To(Equal("prod-cluster-1")) + Expect(result["namespace"]).To(Equal("default")) + Expect(result["region"]).To(Equal("us-west-2")) + Expect(result["node_count"]).To(Equal(3)) + + // Sensitive top-level fields redacted + Expect(result["pull_secret"]).To(Equal(RedactedPlaceholder)) + Expect(result["admin_password"]).To(Equal(RedactedPlaceholder)) + + // Nested sensitive field redacted + sa := result["service_account"].(map[string]interface{}) + Expect(sa["name"]).To(Equal("hypershift-sa")) + Expect(sa["privateKey"]).To(Equal(RedactedPlaceholder)) + }) + + t.Run("arrays with sensitive fields in elements are masked", func(t *testing.T) { + RegisterTestingT(t) + + input := map[string]interface{}{ + "clusterName": "test-cluster", + "users": []interface{}{ + map[string]interface{}{ + "name": "admin", + "password": "secret123", // Should be masked + }, + map[string]interface{}{ + "name": "viewer", + "token": "abc-def-ghi", // Should be masked + }, + }, + } + + result := MaskSensitiveFields(input) + + Expect(result["clusterName"]).To(Equal("test-cluster")) + + users := result["users"].([]interface{}) + Expect(users).To(HaveLen(2)) + + // First user: password should be masked + user0 := users[0].(map[string]interface{}) + Expect(user0["name"]).To(Equal("admin")) + Expect(user0["password"]).To(Equal(RedactedPlaceholder)) + + // Second user: token should be masked + user1 := users[1].(map[string]interface{}) + Expect(user1["name"]).To(Equal("viewer")) + Expect(user1["token"]).To(Equal(RedactedPlaceholder)) + }) + + t.Run("nested arrays are recursively masked", func(t *testing.T) { + RegisterTestingT(t) + + input := map[string]interface{}{ + "environments": []interface{}{ + map[string]interface{}{ + "name": "production", + "providers": []interface{}{ // "providers" is not a sensitive key + map[string]interface{}{ + "type": "aws", + "apiKey": "fake-aws-key-for-testing", // Should be masked + "region": "us-east-1", + }, + }, + }, + }, + } + + result := MaskSensitiveFields(input) + + envs := result["environments"].([]interface{}) + env0 := envs[0].(map[string]interface{}) + Expect(env0["name"]).To(Equal("production")) + + providers := env0["providers"].([]interface{}) + provider0 := providers[0].(map[string]interface{}) + Expect(provider0["type"]).To(Equal("aws")) + Expect(provider0["apiKey"]).To(Equal(RedactedPlaceholder)) + Expect(provider0["region"]).To(Equal("us-east-1")) + }) + + t.Run("arrays of primitives pass through unchanged", func(t *testing.T) { + RegisterTestingT(t) + + input := map[string]interface{}{ + "regions": []interface{}{"us-east-1", "us-west-2", "eu-west-1"}, + "ports": []interface{}{80, 443, 8080}, + } + + result := MaskSensitiveFields(input) + + regions := result["regions"].([]interface{}) + Expect(regions).To(Equal([]interface{}{"us-east-1", "us-west-2", "eu-west-1"})) + + ports := result["ports"].([]interface{}) + Expect(ports).To(Equal([]interface{}{80, 443, 8080})) + }) + + t.Run("deeply nested arrays of arrays are masked", func(t *testing.T) { + RegisterTestingT(t) + + input := map[string]interface{}{ + "matrix": []interface{}{ + []interface{}{ + map[string]interface{}{ + "value": "data", + "password": "secret", // Should be masked + }, + }, + }, + } + + result := MaskSensitiveFields(input) + + matrix := result["matrix"].([]interface{}) + row0 := matrix[0].([]interface{}) + cell := row0[0].(map[string]interface{}) + Expect(cell["value"]).To(Equal("data")) + Expect(cell["password"]).To(Equal(RedactedPlaceholder)) + }) +} +func TestIsSensitiveKey(t *testing.T) { + tests := []struct { + name string + key string + sensitive bool + }{ + {"password lowercase", "password", true}, + {"password mixed case", "adminPassword", true}, + {"password uppercase", "ADMIN_PASSWORD", true}, + {"secret lowercase", "secret", true}, + {"secret in compound", "pullSecret", true}, + {"token", "apiToken", true}, + {"key", "privateKey", true}, + {"credential", "awsCredential", true}, + {"apikey compound", "gcpApiKey", true}, + {"api_key underscore", "service_api_key", true}, + {"auth", "authToken", true}, + {"private", "privateData", true}, + // SEC-02 patterns + {"cert", "tlsCert", true}, + {"kubeconfig", "adminKubeconfig", true}, + {"bearer", "bearerToken", true}, + {"connection", "dbConnectionString", true}, + // Non-sensitive + {"non-sensitive", "clusterName", false}, + {"non-sensitive with key substring", "keyboard", false}, // SEC-02: "key" alone is too broad + {"region", "region", false}, + {"count", "nodeCount", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + RegisterTestingT(t) + result := isSensitiveKey(tt.key) + Expect(result).To(Equal(tt.sensitive)) + }) + } +} + +// ============================================================================ +// SEC-02 Pattern Tests +// ============================================================================ + +func TestMaskSensitiveFields_SEC02Patterns(t *testing.T) { + t.Run("TLS certificate fields are masked (SEC-02)", func(t *testing.T) { + RegisterTestingT(t) + + input := map[string]interface{}{ + "clusterName": "prod-cluster", + "tlsCert": "-----BEGIN CERTIFICATE-----\nMIIC...", + "clientCert": "-----BEGIN CERTIFICATE-----\nMIID...", + "caCertificate": "-----BEGIN CERTIFICATE-----\nMIIE...", + "serverCertChain": "multi-cert-chain", + } + + result := MaskSensitiveFields(input) + + // Non-sensitive field preserved + Expect(result["clusterName"]).To(Equal("prod-cluster")) + + // All cert fields should be masked + Expect(result["tlsCert"]).To(Equal(RedactedPlaceholder)) + Expect(result["clientCert"]).To(Equal(RedactedPlaceholder)) + Expect(result["caCertificate"]).To(Equal(RedactedPlaceholder)) + Expect(result["serverCertChain"]).To(Equal(RedactedPlaceholder)) + }) + + t.Run("kubeconfig fields are masked (SEC-02)", func(t *testing.T) { + RegisterTestingT(t) + + input := map[string]interface{}{ + "clusterName": "prod-cluster", + "kubeconfig": "apiVersion: v1\nclusters:\n- cluster:\n certificate-authority-data: LS0t...", + "kubeconfigRaw": "base64-encoded-kubeconfig-blob", + "adminKubeconfig": map[string]interface{}{ + "data": "kubeconfig-content", + }, + } + + result := MaskSensitiveFields(input) + + Expect(result["clusterName"]).To(Equal("prod-cluster")) + Expect(result["kubeconfig"]).To(Equal(RedactedPlaceholder)) + Expect(result["kubeconfigRaw"]).To(Equal(RedactedPlaceholder)) + Expect(result["adminKubeconfig"]).To(Equal(RedactedPlaceholder)) + }) + + t.Run("bearer token fields are masked (SEC-02)", func(t *testing.T) { + RegisterTestingT(t) + + input := map[string]interface{}{ + "clusterName": "prod-cluster", + "bearerToken": "fake-jwt-token-for-testing-not-real", + "bearer": "fake-bearer-token-for-testing", + "bearerHeader": "Bearer fake-token-for-testing", + } + + result := MaskSensitiveFields(input) + + Expect(result["clusterName"]).To(Equal("prod-cluster")) + Expect(result["bearerToken"]).To(Equal(RedactedPlaceholder)) + Expect(result["bearer"]).To(Equal(RedactedPlaceholder)) + Expect(result["bearerHeader"]).To(Equal(RedactedPlaceholder)) + }) + + t.Run("connection string fields are masked (SEC-02)", func(t *testing.T) { + RegisterTestingT(t) + + input := map[string]interface{}{ + "dbHost": "postgres.example.com", + "connection_string": "postgresql://user:pass@host:5432/dbname", + "dbConnectionString": "mysql://root:pass@localhost:3306/app", + "redisConnectionString": "redis://:pass@host:6379/0", + } + + result := MaskSensitiveFields(input) + + // Non-sensitive field preserved + Expect(result["dbHost"]).To(Equal("postgres.example.com")) + + // All connection string fields should be masked (contain "connection") + Expect(result["connection_string"]).To(Equal(RedactedPlaceholder)) + Expect(result["dbConnectionString"]).To(Equal(RedactedPlaceholder)) + Expect(result["redisConnectionString"]).To(Equal(RedactedPlaceholder)) + }) + + t.Run("nested SEC-02 patterns in adapter data", func(t *testing.T) { + RegisterTestingT(t) + + input := map[string]interface{}{ + "clusterName": "prod-cluster", + "hypershift": map[string]interface{}{ + "kubeconfig": "admin-kubeconfig-blob", + "pullSecrets": "registry-credentials", + }, + "tlsConfig": map[string]interface{}{ + "caCert": "-----BEGIN CERTIFICATE-----...", + "clientCert": "-----BEGIN CERTIFICATE-----...", + "serverName": "api.cluster.example.com", // Not sensitive + }, + } + + result := MaskSensitiveFields(input) + + Expect(result["clusterName"]).To(Equal("prod-cluster")) + + // hypershift is not sensitive, but nested fields are + hypershift := result["hypershift"].(map[string]interface{}) + Expect(hypershift["kubeconfig"]).To(Equal(RedactedPlaceholder)) + Expect(hypershift["pullSecrets"]).To(Equal(RedactedPlaceholder), "pullSecrets contains 'secret'") + + // tlsConfig is not sensitive, but nested cert fields are + tlsConfig := result["tlsConfig"].(map[string]interface{}) + Expect(tlsConfig["caCert"]).To(Equal(RedactedPlaceholder)) + Expect(tlsConfig["clientCert"]).To(Equal(RedactedPlaceholder)) + Expect(tlsConfig["serverName"]).To(Equal("api.cluster.example.com")) + }) + + t.Run("SEC-02 patterns in arrays", func(t *testing.T) { + RegisterTestingT(t) + + input := map[string]interface{}{ + "clusters": []interface{}{ + map[string]interface{}{ + "name": "cluster-1", + "kubeconfig": "kubeconfig-1", + }, + map[string]interface{}{ + "name": "cluster-2", + "connection": "postgresql://...", + }, + }, + } + + result := MaskSensitiveFields(input) + + clusters := result["clusters"].([]interface{}) + Expect(clusters).To(HaveLen(2)) + + cluster1 := clusters[0].(map[string]interface{}) + Expect(cluster1["name"]).To(Equal("cluster-1")) + Expect(cluster1["kubeconfig"]).To(Equal(RedactedPlaceholder)) + + cluster2 := clusters[1].(map[string]interface{}) + Expect(cluster2["name"]).To(Equal("cluster-2")) + Expect(cluster2["connection"]).To(Equal(RedactedPlaceholder), "connection contains 'connection'") + }) + + t.Run("real-world HyperShift adapter data with SEC-02 patterns", func(t *testing.T) { + RegisterTestingT(t) + + input := map[string]interface{}{ + "clusterName": "prod-hypershift-cluster", + "namespace": "clusters", + "hostedCluster": map[string]interface{}{ + "name": "my-cluster", + "kubeconfig": map[string]interface{}{ + "name": "admin-kubeconfig", + "data": "fake-base64-kubeconfig-data-for-testing-not-real", + }, + }, + "pullSecret": "fake-pull-secret-for-testing-not-real", + "sshKey": "fake-ssh-key-for-testing-not-real", + "baseDomain": "example.com", // Not sensitive + } + + result := MaskSensitiveFields(input) + + // Non-sensitive fields preserved + Expect(result["clusterName"]).To(Equal("prod-hypershift-cluster")) + Expect(result["namespace"]).To(Equal("clusters")) + Expect(result["baseDomain"]).To(Equal("example.com")) + + // Sensitive fields masked + Expect(result["pullSecret"]).To(Equal(RedactedPlaceholder)) + Expect(result["sshKey"]).To(Equal(RedactedPlaceholder), "sshKey contains 'key'") + + // hostedCluster contains kubeconfig (nested sensitive) + hostedCluster := result["hostedCluster"].(map[string]interface{}) + Expect(hostedCluster["name"]).To(Equal("my-cluster")) + Expect(hostedCluster["kubeconfig"]).To(Equal(RedactedPlaceholder)) + }) +} + +// ============================================================================ +// SEC-03 Depth Limit Tests +// ============================================================================ + +func TestMaskSensitiveFields_DepthLimit(t *testing.T) { + t.Run("deeply nested structure stops at max depth (SEC-03)", func(t *testing.T) { + RegisterTestingT(t) + + // Build a structure nested 25 levels deep (exceeds maxMaskingDepth=20) + deeplyNested := make(map[string]interface{}) + current := deeplyNested + for i := 0; i < 25; i++ { + current["level"] = i + current["data"] = "value" + next := make(map[string]interface{}) + current["nested"] = next + current = next + } + current["final"] = "deepest" + + // Should not panic (stack overflow protection) + result := MaskSensitiveFields(deeplyNested) + + // Verify first few levels are intact + Expect(result["level"]).To(Equal(0)) + Expect(result["data"]).To(Equal("value")) + + // Navigate down to depth 19 (last allowed level) + current = result + for i := 0; i < 19; i++ { + nested, ok := current["nested"].(map[string]interface{}) + Expect(ok).To(BeTrue(), "Level %d should be a map", i) + current = nested + } + + // At depth 20, we should hit the limit and get an empty map + nested, ok := current["nested"].(map[string]interface{}) + Expect(ok).To(BeTrue()) + Expect(nested).To(BeEmpty(), "Depth 20 should return empty map (limit reached)") + }) + + t.Run("deeply nested array stops at max depth (SEC-03)", func(t *testing.T) { + RegisterTestingT(t) + + // Build an array nested 25 levels deep + deepest := []interface{}{"final"} + for i := 0; i < 25; i++ { + deepest = []interface{}{deepest} + } + + result := maskSensitiveSliceDepth(deepest, 0) + + // Navigate down to depth 19 + currentSlice := result + for i := 0; i < 19; i++ { + Expect(currentSlice).To(HaveLen(1)) + next, ok := currentSlice[0].([]interface{}) + Expect(ok).To(BeTrue(), "Level %d should be a slice", i) + currentSlice = next + } + + // At depth 20, should get empty slice (limit reached) + Expect(currentSlice).To(HaveLen(1)) + final, ok := currentSlice[0].([]interface{}) + Expect(ok).To(BeTrue()) + Expect(final).To(BeEmpty(), "Depth 20 should return empty slice (limit reached)") + }) + + t.Run("normal nested structures work fine (SEC-03)", func(t *testing.T) { + RegisterTestingT(t) + + // Build a reasonably nested structure (5 levels, well under limit) + input := map[string]interface{}{ + "level1": map[string]interface{}{ + "level2": map[string]interface{}{ + "level3": map[string]interface{}{ + "level4": map[string]interface{}{ + "level5": map[string]interface{}{ + "password": "secret123", + "name": "final", + }, + }, + }, + }, + }, + } + + result := MaskSensitiveFields(input) + + // Navigate to level5 + level1 := result["level1"].(map[string]interface{}) + level2 := level1["level2"].(map[string]interface{}) + level3 := level2["level3"].(map[string]interface{}) + level4 := level3["level4"].(map[string]interface{}) + level5 := level4["level5"].(map[string]interface{}) + + // Verify masking works at all levels + Expect(level5["password"]).To(Equal(RedactedPlaceholder)) + Expect(level5["name"]).To(Equal("final")) + }) +} + +// ============================================================================ +// False Positive Prevention Tests (SEC-02) +// ============================================================================ + +func TestMaskSensitiveFields_FalsePositivePrevention(t *testing.T) { + t.Run("database fields with 'key' are NOT redacted (SEC-02)", func(t *testing.T) { + RegisterTestingT(t) + + // Common database field names that should NOT be redacted + input := map[string]interface{}{ + "partitionKey": "user-123", // DynamoDB partition key + "sortKey": "timestamp", // DynamoDB sort key + "primaryKey": 42, // Database primary key + "foreignKey": 99, // Database foreign key + "uniqueKey": "abc", // Database unique constraint + "indexKey": "idx_users_email", // Database index + "cacheKey": "session:xyz", // Redis/cache key + "lookupKey": "search-term", // Search index key + "routingKey": "orders", // Message queue routing key + "shardKey": "region-us-west", // Sharding key + } + + result := MaskSensitiveFields(input) + + // None should be redacted - these are metadata, not credentials + Expect(result["partitionKey"]).To(Equal("user-123")) + Expect(result["sortKey"]).To(Equal("timestamp")) + Expect(result["primaryKey"]).To(Equal(42)) + Expect(result["foreignKey"]).To(Equal(99)) + Expect(result["uniqueKey"]).To(Equal("abc")) + Expect(result["indexKey"]).To(Equal("idx_users_email")) + Expect(result["cacheKey"]).To(Equal("session:xyz")) + Expect(result["lookupKey"]).To(Equal("search-term")) + Expect(result["routingKey"]).To(Equal("orders")) + Expect(result["shardKey"]).To(Equal("region-us-west")) + }) + + t.Run("legitimate credential 'key' fields ARE redacted (SEC-02)", func(t *testing.T) { + RegisterTestingT(t) + + // Real credential fields that SHOULD be redacted + input := map[string]interface{}{ + "apiKey": "fake-api-key-for-testing", + "privateKey": "fake-private-key-for-testing", + "secretKey": "fake-secret-key-for-testing", + "accessKey": "fake-access-key-for-testing", + "sshKey": "fake-ssh-key-for-testing", + "encryptionKey": "fake-encryption-key-for-testing", + "gcpServiceAccountKey": "fake-gcp-key-for-testing", + "azureAccountKey": "fake-azure-key-for-testing", + "registryKey": "docker-registry-token", + "signingKey": "code-signing-key", + } + + result := MaskSensitiveFields(input) + + // All should be redacted - these are credentials + for k := range input { + Expect(result[k]).To(Equal(RedactedPlaceholder), "field %s should be redacted", k) + } + }) + + t.Run("service/product names with 'key' are NOT redacted (SEC-02)", func(t *testing.T) { + RegisterTestingT(t) + + // Service/product names that contain 'key' but aren't credentials + input := map[string]interface{}{ + "keystoneEndpoint": "https://keystone.example.com", // OpenStack Keystone + "monkeyPatch": true, // Code patching + "turkeyMode": false, // Silly example + "keyValueStore": "redis", // Generic KV store + "hotkey": "Ctrl+S", // Keyboard shortcut + "keyframe": 60, // Video encoding + } + + result := MaskSensitiveFields(input) + + // None should be redacted - these are not credentials + Expect(result["keystoneEndpoint"]).To(Equal("https://keystone.example.com")) + Expect(result["monkeyPatch"]).To(Equal(true)) + Expect(result["turkeyMode"]).To(Equal(false)) + Expect(result["keyValueStore"]).To(Equal("redis")) + Expect(result["hotkey"]).To(Equal("Ctrl+S")) + Expect(result["keyframe"]).To(Equal(60)) + }) +} diff --git a/pkg/util/naming.go b/pkg/util/naming.go new file mode 100644 index 00000000..c45f56b5 --- /dev/null +++ b/pkg/util/naming.go @@ -0,0 +1,28 @@ +package util + +import ( + "strings" + "unicode" +) + +// adapterConditionSuffix is the suffix appended to adapter names when generating condition types (QUAL-01) +const adapterConditionSuffix = "Successful" + +// MapAdapterToConditionType converts an adapter name to a semantic condition type (PascalCase + "Successful" suffix). +// Used to derive the type name for per-adapter conditions mirrored into resource status +// (e.g. "adapter1" → "Adapter1Successful", "my-adapter" → "MyAdapterSuccessful"). +func MapAdapterToConditionType(adapterName string) string { + parts := strings.Split(adapterName, "-") + var result strings.Builder + + for _, part := range parts { + if len(part) > 0 { + runes := []rune(part) + runes[0] = unicode.ToUpper(runes[0]) + result.WriteString(string(runes)) + } + } + + result.WriteString(adapterConditionSuffix) + return result.String() +} diff --git a/pkg/util/naming_test.go b/pkg/util/naming_test.go new file mode 100644 index 00000000..2e12f4e8 --- /dev/null +++ b/pkg/util/naming_test.go @@ -0,0 +1,32 @@ +package util + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +func TestMapAdapterToConditionType(t *testing.T) { + testCases := []struct { + adapter string + expected string + }{ + {"validation", "ValidationSuccessful"}, + {"dns", "DnsSuccessful"}, + {"pullsecret", "PullsecretSuccessful"}, + {"hypershift", "HypershiftSuccessful"}, + {"my-adapter", "MyAdapterSuccessful"}, + {"multi-word-adapter", "MultiWordAdapterSuccessful"}, + {"a", "ASuccessful"}, + {"adapter1", "Adapter1Successful"}, + } + + for _, tt := range testCases { + t.Run(tt.adapter, func(t *testing.T) { + RegisterTestingT(t) + result := MapAdapterToConditionType(tt.adapter) + Expect(result).To(Equal(tt.expected), + "MapAdapterToConditionType(%q) should return %q", tt.adapter, tt.expected) + }) + } +} diff --git a/plugins/resources/plugin.go b/plugins/resources/plugin.go index 1e159883..fd21f6f7 100644 --- a/plugins/resources/plugin.go +++ b/plugins/resources/plugin.go @@ -19,6 +19,7 @@ func NewServiceLocator(env *environments.Env) ServiceLocator { dao.NewAdapterStatusDao(env.Database.SessionFactory), dao.NewResourceConditionDao(env.Database.SessionFactory), generic.Service(&env.Services), + env.Config.Conditions, ) } } diff --git a/test/integration/condition_mapping_test.go b/test/integration/condition_mapping_test.go new file mode 100644 index 00000000..6e3328d7 --- /dev/null +++ b/test/integration/condition_mapping_test.go @@ -0,0 +1,291 @@ +package integration + +import ( + "net/http" + "os" + "testing" + + . "github.com/onsi/gomega" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/util" + "github.com/openshift-hyperfleet/hyperfleet-api/test" +) + +// TestConditionMapping_BEFORE tests behavior without CEL mapping configured +// Expected: adapter custom conditions do NOT appear in public status.conditions +// NOTE: This test will PASS when the API is configured WITHOUT CEL mapping. +func TestConditionMapping_BEFORE(t *testing.T) { + h, client := test.RegisterIntegration(t) + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + + // Create a cluster + cluster, err := h.Factories.NewClusters(h.NewID()) + Expect(err).NotTo(HaveOccurred()) + + // Report validation adapter status with custom condition QuotaSufficient + statusInput := newAdapterStatusRequest( + "validation", + cluster.Generation, + []openapi.ConditionRequest{ + { + Type: api.AdapterConditionTypeAvailable, + Status: openapi.AdapterConditionStatusTrue, + Reason: util.PtrString("ValidationPassed"), + Message: util.PtrString("Validation passed"), + }, + { + Type: api.AdapterConditionTypeApplied, + Status: openapi.AdapterConditionStatusTrue, + Reason: util.PtrString("Applied"), + Message: util.PtrString("Applied"), + }, + { + Type: api.AdapterConditionTypeHealth, + Status: openapi.AdapterConditionStatusTrue, + Reason: util.PtrString("Healthy"), + Message: util.PtrString("Healthy"), + }, + { + Type: "QuotaSufficient", // Custom condition + Status: openapi.AdapterConditionStatusTrue, + Reason: util.PtrString("QuotaOK"), + Message: util.PtrString("Cluster quota is sufficient"), + }, + }, + nil, + ) + + resp, err := client.PutClusterStatusesWithResponse( + ctx, cluster.ID, + openapi.PutClusterStatusesJSONRequestBody(statusInput), test.WithAuthToken(ctx), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusCreated)) + + // Get the cluster and verify conditions + getResp, err := client.GetClusterByIdWithResponse(ctx, cluster.ID, nil, test.WithAuthToken(ctx)) + Expect(err).NotTo(HaveOccurred()) + Expect(getResp.StatusCode()).To(Equal(http.StatusOK)) + Expect(getResp.JSON200).NotTo(BeNil()) + + resource := getResp.JSON200 + + // Verify conditions (BEFORE - without mapping) + conditions := resource.Status.Conditions + Expect(conditions).NotTo(BeEmpty()) + + // Should have standard conditions + hasReconciled := false + hasLastKnownReconciled := false + hasValidationSuccessful := false + hasQuotaValid := false + + for _, cond := range conditions { + switch cond.Type { + case "Reconciled": + hasReconciled = true + case "LastKnownReconciled": + hasLastKnownReconciled = true + case "ValidationSuccessful": + hasValidationSuccessful = true + case "QuotaValid", "quotavalid": + hasQuotaValid = true + } + } + + // BEFORE expectations + Expect(hasReconciled).To(BeTrue(), "should have Reconciled condition") + Expect(hasLastKnownReconciled).To(BeTrue(), "should have LastKnownReconciled condition") + Expect(hasValidationSuccessful).To(BeTrue(), "should have ValidationSuccessful condition") + Expect(hasQuotaValid).To(BeFalse(), "BEFORE: should NOT have QuotaValid/quotavalid condition (no CEL mapping)") +} + +// TestConditionMapping_AFTER tests behavior with CEL mapping configured +// This test expects the API to be started with condition mapping configured in config.yaml +// Expected: adapter custom conditions ARE mapped and appear in public status.conditions +// +// To enable this test, set environment variable: +// +// HYPERFLEET_TEST_CONDITION_MAPPING=1 +// +// And ensure your config.yaml has the QuotaValid mapping configured: +// +// conditions: +// clusters: +// QuotaValid: +// when: +// expression: 'statuses.exists(s, s.adapter == "validation" && s.conditions.exists(c, c.type == "QuotaSufficient"))' +// output: +// status: +// expression: 'statuses.filter(s, s.adapter == "validation")[0].conditions.filter(c, c.type == "QuotaSufficient")[0].status' +// reason: +// expression: 'statuses.filter(s, s.adapter == "validation")[0].conditions.filter(c, c.type == "QuotaSufficient")[0].reason' +// message: +// expression: '"Quota: " + statuses.filter(s, s.adapter == "validation")[0].conditions.filter(c, c.type == "QuotaSufficient")[0].message' +func TestConditionMapping_AFTER(t *testing.T) { + if os.Getenv("HYPERFLEET_TEST_CONDITION_MAPPING") == "" { + t.Skip("Skipped by default - set HYPERFLEET_TEST_CONDITION_MAPPING=1 to enable. Requires API configured with CEL mapping.") + } + + h, client := test.RegisterIntegration(t) + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + + // Create a cluster + cluster, err := h.Factories.NewClusters(h.NewID()) + Expect(err).NotTo(HaveOccurred()) + + // Report validation adapter status with custom condition QuotaSufficient + statusInput := newAdapterStatusRequest( + "validation", + cluster.Generation, + []openapi.ConditionRequest{ + { + Type: api.AdapterConditionTypeAvailable, + Status: openapi.AdapterConditionStatusTrue, + Reason: util.PtrString("ValidationPassed"), + Message: util.PtrString("Validation passed"), + }, + { + Type: api.AdapterConditionTypeApplied, + Status: openapi.AdapterConditionStatusTrue, + Reason: util.PtrString("Applied"), + Message: util.PtrString("Applied"), + }, + { + Type: api.AdapterConditionTypeHealth, + Status: openapi.AdapterConditionStatusTrue, + Reason: util.PtrString("Healthy"), + Message: util.PtrString("Healthy"), + }, + { + Type: "QuotaSufficient", // Custom condition + Status: openapi.AdapterConditionStatusTrue, + Reason: util.PtrString("QuotaOK"), + Message: util.PtrString("Cluster quota is sufficient"), + }, + }, + nil, + ) + + resp, err := client.PutClusterStatusesWithResponse( + ctx, cluster.ID, + openapi.PutClusterStatusesJSONRequestBody(statusInput), test.WithAuthToken(ctx), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusCreated)) + + // Get the cluster and verify conditions + getResp, err := client.GetClusterByIdWithResponse(ctx, cluster.ID, nil, test.WithAuthToken(ctx)) + Expect(err).NotTo(HaveOccurred()) + Expect(getResp.StatusCode()).To(Equal(http.StatusOK)) + Expect(getResp.JSON200).NotTo(BeNil()) + + resource := getResp.JSON200 + + // Verify conditions (AFTER - with mapping) + conditions := resource.Status.Conditions + Expect(conditions).NotTo(BeEmpty()) + + // Should have standard conditions + mapped condition + hasReconciled := false + hasLastKnownReconciled := false + hasValidationSuccessful := false + hasQuotaValid := false + var quotaValidCondition *openapi.ResourceCondition + + for _, cond := range conditions { + switch cond.Type { + case "Reconciled": + hasReconciled = true + case "LastKnownReconciled": + hasLastKnownReconciled = true + case "ValidationSuccessful": + hasValidationSuccessful = true + case "QuotaValid", "quotavalid": + hasQuotaValid = true + quotaValidCondition = &cond + } + } + + // AFTER expectations + Expect(hasReconciled).To(BeTrue(), "should have Reconciled condition") + Expect(hasLastKnownReconciled).To(BeTrue(), "should have LastKnownReconciled condition") + Expect(hasValidationSuccessful).To(BeTrue(), "should have ValidationSuccessful condition") + Expect(hasQuotaValid).To(BeTrue(), "AFTER: should have QuotaValid/quotavalid condition (CEL mapping active)") + + // Verify the mapped condition details + Expect(quotaValidCondition).NotTo(BeNil()) + Expect(string(quotaValidCondition.Status)).To(Equal(string(api.ConditionTrue))) + Expect(*quotaValidCondition.Reason).To(Equal("QuotaOK")) + Expect(*quotaValidCondition.Message).To(ContainSubstring("Quota")) +} + +// TestConditionMapping_MultipleRules tests multiple mapping rules active simultaneously +// +// To enable this test, set environment variable: +// +// HYPERFLEET_TEST_CONDITION_MAPPING=1 +// +// And ensure your config.yaml has multiple mapping rules configured. +func TestConditionMapping_MultipleRules(t *testing.T) { + if os.Getenv("HYPERFLEET_TEST_CONDITION_MAPPING") == "" { + t.Skip("Skipped by default - set HYPERFLEET_TEST_CONDITION_MAPPING=1 to enable. Requires API configured with multiple CEL mappings.") + } + + h, client := test.RegisterIntegration(t) + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + + // Create a cluster + cluster, err := h.Factories.NewClusters(h.NewID()) + Expect(err).NotTo(HaveOccurred()) + + // Report validation adapter with TWO custom conditions + statusInput := newAdapterStatusRequest( + "validation", + cluster.Generation, + []openapi.ConditionRequest{ + {Type: api.AdapterConditionTypeAvailable, Status: openapi.AdapterConditionStatusTrue, Reason: util.PtrString("OK"), Message: util.PtrString("OK")}, + {Type: api.AdapterConditionTypeApplied, Status: openapi.AdapterConditionStatusTrue, Reason: util.PtrString("OK"), Message: util.PtrString("OK")}, + {Type: api.AdapterConditionTypeHealth, Status: openapi.AdapterConditionStatusTrue, Reason: util.PtrString("OK"), Message: util.PtrString("OK")}, + {Type: "QuotaSufficient", Status: openapi.AdapterConditionStatusTrue, Reason: util.PtrString("QuotaOK"), Message: util.PtrString("Quota OK")}, + {Type: "PolicyValid", Status: openapi.AdapterConditionStatusTrue, Reason: util.PtrString("PolicyOK"), Message: util.PtrString("Policy OK")}, + }, + nil, + ) + + resp, err := client.PutClusterStatusesWithResponse( + ctx, cluster.ID, + openapi.PutClusterStatusesJSONRequestBody(statusInput), test.WithAuthToken(ctx), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusCreated)) + + // Get the cluster + getResp, err := client.GetClusterByIdWithResponse(ctx, cluster.ID, nil, test.WithAuthToken(ctx)) + Expect(err).NotTo(HaveOccurred()) + Expect(getResp.JSON200).NotTo(BeNil()) + + resource := getResp.JSON200 + + // Verify both custom conditions were mapped + // The test assumes config has QuotaValid and PolicyValid mapping rules configured + var hasQuotaValid, hasPolicyValid bool + for _, cond := range resource.Status.Conditions { + if cond.Type == "QuotaValid" { + hasQuotaValid = true + Expect(string(cond.Status)).To(Equal(string(api.ConditionTrue))) + } + if cond.Type == "PolicyValid" { + hasPolicyValid = true + Expect(string(cond.Status)).To(Equal(string(api.ConditionTrue))) + } + } + + Expect(hasQuotaValid).To(BeTrue(), "QuotaValid condition should be mapped from QuotaSufficient adapter condition") + Expect(hasPolicyValid).To(BeTrue(), "PolicyValid condition should be mapped from PolicyValid adapter condition") +} From 18224b7c7055c9a4911053157449e9f7d7eed179 Mon Sep 17 00:00:00 2001 From: Leonardo Dorneles Date: Mon, 27 Jul 2026 15:44:11 -0300 Subject: [PATCH 02/17] HYPERFLEET-538 - feat: CEL-based condition mapping engine --- pkg/config/conditions.go | 19 ++++++++--- pkg/config/conditions_test.go | 12 ++++--- pkg/services/condition_mapper.go | 37 ++++++++++++++++------ pkg/services/condition_mapper_test.go | 5 +-- pkg/services/resource.go | 8 +++-- pkg/services/resource_test.go | 8 +++-- test/integration/condition_mapping_test.go | 8 +++-- 7 files changed, 72 insertions(+), 25 deletions(-) diff --git a/pkg/config/conditions.go b/pkg/config/conditions.go index 68094a4b..958de6a5 100644 --- a/pkg/config/conditions.go +++ b/pkg/config/conditions.go @@ -140,7 +140,12 @@ func buildReservedConditionTypes(entities []registry.EntityDescriptor) map[strin } // validateConditionMapping validates a single mapping rule -func validateConditionMapping(resourceType, condType string, rule ConditionMappingRule, reserved map[string]bool, env *cel.Env) error { +func validateConditionMapping( + resourceType, condType string, + rule ConditionMappingRule, + reserved map[string]bool, + env *cel.Env, +) error { // Check reserved types if reserved[condType] { return fmt.Errorf( @@ -161,13 +166,19 @@ func validateConditionMapping(resourceType, condType string, rule ConditionMappi 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 { + 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 { + 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 { + if err := validateCELExpression( + resourceType, condType, "output.message", rule.Output.Message.Expression, env, + ); err != nil { return err } diff --git a/pkg/config/conditions_test.go b/pkg/config/conditions_test.go index b2202d4d..2dba845c 100644 --- a/pkg/config/conditions_test.go +++ b/pkg/config/conditions_test.go @@ -213,17 +213,21 @@ func TestConditionsConfig_Validate(t *testing.T) { Clusters: map[string]ConditionMappingRule{ "ComplexCondition": { When: MappingExpression{ - Expression: `statuses.exists(s, s.adapter == "validation" && s.conditions.exists(c, c.type == "QuotaSufficient" && c.status == "True"))`, + Expression: `statuses.exists(s, s.adapter == "validation" && ` + + `s.conditions.exists(c, c.type == "QuotaSufficient" && c.status == "True"))`, }, Output: MappingOutput{ Status: MappingExpression{ - Expression: `statuses.filter(s, s.adapter == "validation")[0].conditions.filter(c, c.type == "QuotaSufficient")[0].status`, + Expression: `statuses.filter(s, s.adapter == "validation")[0].` + + `conditions.filter(c, c.type == "QuotaSufficient")[0].status`, }, Reason: MappingExpression{ - Expression: `statuses.filter(s, s.adapter == "validation")[0].conditions.filter(c, c.type == "QuotaSufficient")[0].reason`, + Expression: `statuses.filter(s, s.adapter == "validation")[0].` + + `conditions.filter(c, c.type == "QuotaSufficient")[0].reason`, }, Message: MappingExpression{ - Expression: `"Quota: " + statuses.filter(s, s.adapter == "validation")[0].conditions.filter(c, c.type == "QuotaSufficient")[0].message`, + Expression: `"Quota: " + statuses.filter(s, s.adapter == "validation")[0].` + + `conditions.filter(c, c.type == "QuotaSufficient")[0].message`, }, }, }, diff --git a/pkg/services/condition_mapper.go b/pkg/services/condition_mapper.go index 7a7e99c8..1d70738f 100644 --- a/pkg/services/condition_mapper.go +++ b/pkg/services/condition_mapper.go @@ -209,7 +209,9 @@ func (m *ConditionMapper) evaluateRule( } // Build the mapped condition with all required fields (QUAL-03) - return m.buildMappedCondition(ctx, rule, statusStr, validatedReason, validatedMessage, activation, refTime, prevCondition) + return m.buildMappedCondition( + ctx, rule, statusStr, validatedReason, validatedMessage, activation, refTime, prevCondition, + ) } // validateFieldLengths validates and enforces field length constraints (QUAL-03) @@ -223,8 +225,12 @@ func (m *ConditionMapper) validateFieldLengths( ) (string, string, error) { // Validate condition type length if len(rule.conditionType) > config.MaxConditionTypeLength { - logger.With(ctx, "resource_kind", m.resourceKind, "condition_type", rule.conditionType, "length", len(rule.conditionType)). - Warn("Condition type exceeds max length, skipping") + logger.With( + ctx, + "resource_kind", m.resourceKind, + "condition_type", rule.conditionType, + "length", len(rule.conditionType), + ).Warn("Condition type exceeds max length, skipping") return "", "", fmt.Errorf("condition type exceeds max length") } @@ -260,12 +266,16 @@ func (m *ConditionMapper) buildMappedCondition( ) (*api.ResourceCondition, error) { // Parse status string to ResourceConditionStatus (case-insensitive) var status api.ResourceConditionStatus - if strings.EqualFold(statusStr, celBoolTrue) { + switch strings.ToLower(statusStr) { + case strings.ToLower(celBoolTrue): status = api.ConditionTrue - } else if strings.EqualFold(statusStr, celBoolFalse) { + case strings.ToLower(celBoolFalse): status = api.ConditionFalse - } else { - return nil, fmt.Errorf("invalid status value: %s (must be %s or %s)", statusStr, celBoolTrue, celBoolFalse) + default: + return nil, fmt.Errorf( + "invalid status value: %s (must be %s or %s)", + statusStr, celBoolTrue, celBoolFalse, + ) } // Extract resource generation for ObservedGeneration field @@ -373,7 +383,12 @@ func compileExpression(env *cel.Env, expression string) (cel.Program, error) { // buildActivation builds the CEL activation context from input data // Combined filter + conversion in single pass to avoid double JSON unmarshal (PERF-03) -func buildActivation(ctx context.Context, statuses api.AdapterStatusList, resource interface{}, resourceKind string) map[string]interface{} { +func buildActivation( + ctx context.Context, + statuses api.AdapterStatusList, + resource interface{}, + resourceKind string, +) map[string]interface{} { // Convert adapter statuses to CEL-compatible format, filtering Unknown conditions in same pass statusesList := make([]interface{}, 0, len(statuses)) for _, status := range statuses { @@ -406,7 +421,11 @@ func buildActivation(ctx context.Context, statuses api.AdapterStatusList, resour // parseConditionsWithUnknownCheck unmarshals adapter conditions from JSONB and converts to CEL maps. // Returns (conditions, hasUnknown) where hasUnknown indicates if any condition has Unknown status. // Returns empty slice (not nil) on unmarshal failure so CEL receives [] instead of null. -func parseConditionsWithUnknownCheck(ctx context.Context, conditionsJSON []byte, adapterName string) ([]map[string]interface{}, bool) { +func parseConditionsWithUnknownCheck( + ctx context.Context, + conditionsJSON []byte, + adapterName string, +) ([]map[string]interface{}, bool) { // Initialize to empty slice (not nil) so CEL receives [] instead of null conditions := make([]map[string]interface{}, 0) hasUnknown := false diff --git a/pkg/services/condition_mapper_test.go b/pkg/services/condition_mapper_test.go index 51775d87..fbb51ae4 100644 --- a/pkg/services/condition_mapper_test.go +++ b/pkg/services/condition_mapper_test.go @@ -779,9 +779,10 @@ func TestConditionMapper_TimestampPreservation(t *testing.T) { // Find ConditionA and ConditionB var condA, condB *api.ResourceCondition for i := range result { - if result[i].Type == "ConditionA" { + switch result[i].Type { + case "ConditionA": condA = &result[i] - } else if result[i].Type == "ConditionB" { + case "ConditionB": condB = &result[i] } } diff --git a/pkg/services/resource.go b/pkg/services/resource.go index 69ecf678..a8126276 100644 --- a/pkg/services/resource.go +++ b/pkg/services/resource.go @@ -54,7 +54,9 @@ func NewResourceService( // This should not happen since config was validated at startup - indicates a code bug // (e.g., validation logic vs. mapper logic mismatch). Use Error level to alert ops team, // but continue in degraded mode (no CEL mapping) to keep service available per HYG-02. - logger.With(context.Background(), "resource_kind", kindCluster).WithError(err).Error("Failed to create condition mapper, continuing without CEL mapping") + logger.With(context.Background(), "resource_kind", kindCluster). + WithError(err). + Error("Failed to create condition mapper, continuing without CEL mapping") } else { conditionMappers[kindCluster] = mapper } @@ -64,7 +66,9 @@ func NewResourceService( if err != nil { // This should not happen since config was validated at startup - indicates a code bug. // Use Error level to alert ops team, but continue in degraded mode per HYG-02. - logger.With(context.Background(), "resource_kind", kindNodePool).WithError(err).Error("Failed to create condition mapper, continuing without CEL mapping") + logger.With(context.Background(), "resource_kind", kindNodePool). + WithError(err). + Error("Failed to create condition mapper, continuing without CEL mapping") } else { conditionMappers[kindNodePool] = mapper } diff --git a/pkg/services/resource_test.go b/pkg/services/resource_test.go index 071a6c13..56eb138f 100644 --- a/pkg/services/resource_test.go +++ b/pkg/services/resource_test.go @@ -3170,7 +3170,8 @@ func TestResourceService_ConditionMapper_IntegrationPath(t *testing.T) { Clusters: map[string]config.ConditionMappingRule{ "CustomReady": { When: config.MappingExpression{ - Expression: `statuses.exists(s, s.adapter == "test-adapter" && s.conditions.exists(c, c.type == "CustomCondition" && c.status == "True"))`, + Expression: `statuses.exists(s, s.adapter == "test-adapter" && ` + + `s.conditions.exists(c, c.type == "CustomCondition" && c.status == "True"))`, }, Output: config.MappingOutput{ Status: config.MappingExpression{ @@ -3228,5 +3229,8 @@ func TestResourceService_ConditionMapper_IntegrationPath(t *testing.T) { Expect(customReady.Status).To(Equal(api.ConditionTrue)) Expect(*customReady.Reason).To(Equal("CustomOK")) Expect(*customReady.Message).To(Equal("Custom condition is ready")) - Expect(customReady.ObservedGeneration).To(Equal(cluster.Generation), "ObservedGeneration should match resource generation") + Expect(customReady.ObservedGeneration).To( + Equal(cluster.Generation), + "ObservedGeneration should match resource generation", + ) } diff --git a/test/integration/condition_mapping_test.go b/test/integration/condition_mapping_test.go index 6e3328d7..b955ee9e 100644 --- a/test/integration/condition_mapping_test.go +++ b/test/integration/condition_mapping_test.go @@ -13,6 +13,10 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/test" ) +const ( + conditionTypeQuotaValid = "QuotaValid" +) + // TestConditionMapping_BEFORE tests behavior without CEL mapping configured // Expected: adapter custom conditions do NOT appear in public status.conditions // NOTE: This test will PASS when the API is configured WITHOUT CEL mapping. @@ -91,7 +95,7 @@ func TestConditionMapping_BEFORE(t *testing.T) { hasLastKnownReconciled = true case "ValidationSuccessful": hasValidationSuccessful = true - case "QuotaValid", "quotavalid": + case conditionTypeQuotaValid, "quotavalid": hasQuotaValid = true } } @@ -205,7 +209,7 @@ func TestConditionMapping_AFTER(t *testing.T) { hasLastKnownReconciled = true case "ValidationSuccessful": hasValidationSuccessful = true - case "QuotaValid", "quotavalid": + case conditionTypeQuotaValid, "quotavalid": hasQuotaValid = true quotaValidCondition = &cond } From e78dd41b90229d2327b1e38c3e37fa2d758bd478 Mon Sep 17 00:00:00 2001 From: Leonardo Dorneles Date: Mon, 27 Jul 2026 18:10:12 -0300 Subject: [PATCH 03/17] HYPERFLEET-538 - feat: CEL-based condition mapping engine --- configs/config.yaml.example | 5 +- go.mod | 7 +- go.sum | 16 ++-- pkg/services/condition_mapper.go | 86 +++++++++++++++++++--- pkg/services/condition_mapper_test.go | 4 +- pkg/services/resource.go | 12 ++- test/integration/condition_mapping_test.go | 69 +++++++++++++---- 7 files changed, 156 insertions(+), 43 deletions(-) diff --git a/configs/config.yaml.example b/configs/config.yaml.example index 02e4f2d1..c984d408 100644 --- a/configs/config.yaml.example +++ b/configs/config.yaml.example @@ -164,8 +164,7 @@ entities: # conditions (array), data (map, sensitive fields masked) # - resource: full cluster/nodepool object as map (sensitive fields masked) # - env: environment variables as map (NOT YET IMPLEMENTED - currently always empty) -# TODO: Create HYPERFLEET ticket for env variable support in CEL context -# Feature: Allow CEL expressions to access runtime config (e.g., env.REGION, env.ENVIRONMENT) +# Future: Allow CEL expressions to access runtime config (e.g., env.REGION, env.ENVIRONMENT) # # Custom CEL Functions: # - toJson(value): marshal to JSON string @@ -175,7 +174,7 @@ entities: # to ensure reproducible condition evaluation # # Field Length Constraints: -# - type: 128 bytes (condition skipped if exceeded) +# - type: 128 bytes (validation error if exceeded, prevents startup) # - reason: 256 bytes (truncated if exceeded, preserving UTF-8 boundaries) # - message: 2048 bytes (truncated if exceeded, preserving UTF-8 boundaries) conditions: diff --git a/go.mod b/go.mod index da3b6261..21009f09 100755 --- a/go.mod +++ b/go.mod @@ -12,7 +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.26.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 @@ -45,18 +45,17 @@ require ( require ( cel.dev/expr v0.25.1 // indirect - github.com/antlr4-go/antlr/v4 v4.13.0 // 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 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect - github.com/stoewer/go-strcase v1.2.0 // indirect go.opentelemetry.io/contrib/propagators/aws v1.44.0 // indirect go.opentelemetry.io/contrib/propagators/b3 v1.44.0 // indirect 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-20240404231335-c0f41cb1a7a0 // indirect + golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect golang.org/x/time v0.15.0 // indirect ) diff --git a/go.sum b/go.sum index 9183663d..11787cb7 100644 --- a/go.sum +++ b/go.sum @@ -19,8 +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.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= -github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +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= @@ -107,8 +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.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= -github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= +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= @@ -237,14 +237,11 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= -github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= -github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= @@ -308,8 +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-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8= -golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= +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= @@ -344,7 +341,6 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/resty.v1 v1.12.0 h1:CuXP0Pjfw9rOuY6EP+UvtNvt5DSqHpIxILZKT/quCZI= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/services/condition_mapper.go b/pkg/services/condition_mapper.go index 1d70738f..f8e216cd 100644 --- a/pkg/services/condition_mapper.go +++ b/pkg/services/condition_mapper.go @@ -58,9 +58,19 @@ const ( // ConditionMapper evaluates CEL-based condition mapping rules type ConditionMapper struct { - rules map[string]*compiledRule - sortedNames []string // Pre-sorted rule names for deterministic ordering - resourceKind string + rules map[string]*compiledRule + sortedNames []string // Pre-sorted rule names for deterministic ordering + resourceKind string + cachedResource *cachedResourceContext // Cache for masked resource map (PERF-03) +} + +// cachedResourceContext caches the masked resource map to avoid redundant +// marshal + MaskSensitiveFields operations across adapter status reports. +// The resource (spec, metadata) only changes on PATCH operations, not status reports. +type cachedResourceContext struct { + resourceID string + generation int32 + maskedMap map[string]interface{} } // compiledRule holds pre-compiled CEL programs for a mapping rule @@ -122,7 +132,8 @@ func (m *ConditionMapper) Apply(ctx context.Context, input ApplyInput) []api.Res } // Build CEL activation context (filtering Unknown conditions happens inside buildActivation) - activation := buildActivation(ctx, input.AdapterStatuses, input.Resource, m.resourceKind) + // Use cached resource map when possible to avoid redundant marshal + mask operations (PERF-03) + activation := m.buildActivationWithCache(ctx, input.AdapterStatuses, input.Resource) // Build lookup map for previous conditions to avoid O(N×M) linear scans prevConditionsByType := make(map[string]*api.ResourceCondition, len(input.PrevConditions)) @@ -381,15 +392,59 @@ func compileExpression(env *cel.Env, expression string) (cel.Program, error) { return prg, nil } -// buildActivation builds the CEL activation context from input data -// Combined filter + conversion in single pass to avoid double JSON unmarshal (PERF-03) -func buildActivation( +// buildActivationWithCache builds the CEL activation context using cached resource map when possible. +// Caches the masked resource map to avoid redundant marshal + MaskSensitiveFields on every Apply(). +// The resource (spec, metadata) only changes on PATCH operations, not adapter status reports. +func (m *ConditionMapper) buildActivationWithCache( ctx context.Context, statuses api.AdapterStatusList, resource interface{}, - resourceKind string, ) map[string]interface{} { - // Convert adapter statuses to CEL-compatible format, filtering Unknown conditions in same pass + // Build statuses list using shared logic (PERF-03: avoid duplication) + statusesList := buildStatusesList(ctx, statuses) + + // Get cached or build resource map (cache invalidates on ID/generation change) + resourceMap := m.getCachedOrBuildResource(ctx, resource) + + return map[string]interface{}{ + util.CELVarStatuses: statusesList, + util.CELVarResource: resourceMap, + util.CELVarEnv: emptyEnvMap, + } +} + +// getCachedOrBuildResource returns the masked resource map, using cache when valid. +// Cache key: resourceID + generation. Invalidates automatically on PATCH (generation bump). +func (m *ConditionMapper) getCachedOrBuildResource( + ctx context.Context, + resource interface{}, +) map[string]interface{} { + r, ok := resource.(*api.Resource) + if !ok { + // Fallback for non-Resource types (shouldn't happen in production) + return util.MaskSensitiveFields(resourceToMap(ctx, resource, m.resourceKind)) + } + + // Cache hit: same resource ID and generation + if m.cachedResource != nil && + m.cachedResource.resourceID == r.ID && + m.cachedResource.generation == r.Generation { + return m.cachedResource.maskedMap + } + + // Cache miss: rebuild and update cache + maskedMap := util.MaskSensitiveFields(resourceToMap(ctx, resource, m.resourceKind)) + m.cachedResource = &cachedResourceContext{ + resourceID: r.ID, + generation: r.Generation, + maskedMap: maskedMap, + } + return maskedMap +} + +// buildStatusesList converts adapter statuses to CEL-compatible format, filtering Unknown conditions. +// Extracted to avoid duplication between buildActivation and buildActivationWithCache (PERF-03). +func buildStatusesList(ctx context.Context, statuses api.AdapterStatusList) []interface{} { statusesList := make([]interface{}, 0, len(statuses)) for _, status := range statuses { // Skip nil entries (can occur in AdapterStatusList []*AdapterStatus) @@ -402,6 +457,19 @@ func buildActivation( statusesList = append(statusesList, statusMap) } } + return statusesList +} + +// buildActivation builds the CEL activation context from input data +// Combined filter + conversion in single pass to avoid double JSON unmarshal (PERF-03) +func buildActivation( + ctx context.Context, + statuses api.AdapterStatusList, + resource interface{}, + resourceKind string, +) map[string]interface{} { + // Convert adapter statuses using shared logic (PERF-03: avoid duplication) + statusesList := buildStatusesList(ctx, statuses) // Convert resource to map and mask sensitive fields (defense-in-depth) // Matches the protection applied to adapter data to prevent credential leakage diff --git a/pkg/services/condition_mapper_test.go b/pkg/services/condition_mapper_test.go index fbb51ae4..0dde6176 100644 --- a/pkg/services/condition_mapper_test.go +++ b/pkg/services/condition_mapper_test.go @@ -367,7 +367,7 @@ func TestConditionMapper_SensitiveDataMasking(t *testing.T) { // Second adapter: sensitive data2 := map[string]interface{}{ - "pullSecret": "eyJhdXRocyI6eyJjbG91ZC5vcGVuc2hpZnQuY29tIjp7ImF1dGgi...", + "pullSecret": "test-secret-value-not-real-auth-json", } data2JSON, _ := json.Marshal(data2) // static test data, marshal cannot fail @@ -395,7 +395,7 @@ func TestConditionMapper_SensitiveDataMasking(t *testing.T) { // Message should have non-sensitive data but masked sensitive data Expect(*cond.Message).To(Equal("Cluster: my-cluster, Secret: ***REDACTED***")) - Expect(*cond.Message).NotTo(ContainSubstring("eyJhdXRocyI6")) + Expect(*cond.Message).NotTo(ContainSubstring("test-secret")) }) t.Run("arrays with sensitive fields are masked in CEL context", func(t *testing.T) { diff --git a/pkg/services/resource.go b/pkg/services/resource.go index a8126276..1ef6bab6 100644 --- a/pkg/services/resource.go +++ b/pkg/services/resource.go @@ -607,9 +607,17 @@ func (s *sqlResourceService) ProcessAdapterStatus( // Step 4: Re-aggregate conditions from all adapter statuses and persist // to the resource_conditions table. Runs when: // 1. Available condition changed to True or False (not on Unknown or discarded updates), OR - // 2. A CEL condition mapper is configured for this resource kind (to keep mapped conditions current) + // 2. A CEL condition mapper is configured AND (conditions or data changed from previous report) + // + // Rationale: mapper recompute is expensive (JSON marshal + MaskSensitiveFields + CEL eval), + // runs inside the GetForUpdate row-level lock, and most adapter reports are duplicates. + // Gating on actual changes reduces CPU waste and lock hold time (CWE-400 mitigation). hasMapper := s.conditionMappers[resource.Kind] != nil - if triggerAggregation || hasMapper { + statusChanged := existingStatus == nil || // First report + !jsonEqual(existingStatus.Conditions, adapterStatus.Conditions) || + !jsonEqual(existingStatus.Data, adapterStatus.Data) + + if triggerAggregation || (hasMapper && statusChanged) { if aggregateErr := s.recomputeAndSaveResourceConditions( ctx, resource, updatedStatuses, ); aggregateErr != nil { diff --git a/test/integration/condition_mapping_test.go b/test/integration/condition_mapping_test.go index b955ee9e..7bd6d428 100644 --- a/test/integration/condition_mapping_test.go +++ b/test/integration/condition_mapping_test.go @@ -21,6 +21,11 @@ const ( // Expected: adapter custom conditions do NOT appear in public status.conditions // NOTE: This test will PASS when the API is configured WITHOUT CEL mapping. func TestConditionMapping_BEFORE(t *testing.T) { + if os.Getenv("HYPERFLEET_TEST_CONDITION_MAPPING") != "" { + t.Skip("Skipped when CEL mapping is enabled - set HYPERFLEET_TEST_CONDITION_MAPPING='' (empty) to run. " + + "Requires API configured WITHOUT CEL mapping.") + } + h, client := test.RegisterIntegration(t) account := h.NewRandAccount() ctx := h.NewAuthenticatedContext(account) @@ -121,17 +126,26 @@ func TestConditionMapping_BEFORE(t *testing.T) { // clusters: // QuotaValid: // when: -// expression: 'statuses.exists(s, s.adapter == "validation" && s.conditions.exists(c, c.type == "QuotaSufficient"))' +// expression: > +// statuses.exists(s, s.adapter == "validation" && +// s.conditions.exists(c, c.type == "QuotaSufficient")) // output: // status: -// expression: 'statuses.filter(s, s.adapter == "validation")[0].conditions.filter(c, c.type == "QuotaSufficient")[0].status' +// expression: > +// statuses.filter(s, s.adapter == "validation")[0] +// .conditions.filter(c, c.type == "QuotaSufficient")[0].status // reason: -// expression: 'statuses.filter(s, s.adapter == "validation")[0].conditions.filter(c, c.type == "QuotaSufficient")[0].reason' +// expression: > +// statuses.filter(s, s.adapter == "validation")[0] +// .conditions.filter(c, c.type == "QuotaSufficient")[0].reason // message: -// expression: '"Quota: " + statuses.filter(s, s.adapter == "validation")[0].conditions.filter(c, c.type == "QuotaSufficient")[0].message' +// expression: > +// "Quota: " + statuses.filter(s, s.adapter == "validation")[0] +// .conditions.filter(c, c.type == "QuotaSufficient")[0].message func TestConditionMapping_AFTER(t *testing.T) { if os.Getenv("HYPERFLEET_TEST_CONDITION_MAPPING") == "" { - t.Skip("Skipped by default - set HYPERFLEET_TEST_CONDITION_MAPPING=1 to enable. Requires API configured with CEL mapping.") + t.Skip("Skipped by default - set HYPERFLEET_TEST_CONDITION_MAPPING=1 to enable. " + + "Requires API configured with CEL mapping.") } h, client := test.RegisterIntegration(t) @@ -237,7 +251,8 @@ func TestConditionMapping_AFTER(t *testing.T) { // And ensure your config.yaml has multiple mapping rules configured. func TestConditionMapping_MultipleRules(t *testing.T) { if os.Getenv("HYPERFLEET_TEST_CONDITION_MAPPING") == "" { - t.Skip("Skipped by default - set HYPERFLEET_TEST_CONDITION_MAPPING=1 to enable. Requires API configured with multiple CEL mappings.") + t.Skip("Skipped by default - set HYPERFLEET_TEST_CONDITION_MAPPING=1 to enable. " + + "Requires API configured with multiple CEL mappings.") } h, client := test.RegisterIntegration(t) @@ -249,15 +264,41 @@ func TestConditionMapping_MultipleRules(t *testing.T) { Expect(err).NotTo(HaveOccurred()) // Report validation adapter with TWO custom conditions + // Use distinct adapter condition names to verify CEL mapping actually transforms them statusInput := newAdapterStatusRequest( "validation", cluster.Generation, []openapi.ConditionRequest{ - {Type: api.AdapterConditionTypeAvailable, Status: openapi.AdapterConditionStatusTrue, Reason: util.PtrString("OK"), Message: util.PtrString("OK")}, - {Type: api.AdapterConditionTypeApplied, Status: openapi.AdapterConditionStatusTrue, Reason: util.PtrString("OK"), Message: util.PtrString("OK")}, - {Type: api.AdapterConditionTypeHealth, Status: openapi.AdapterConditionStatusTrue, Reason: util.PtrString("OK"), Message: util.PtrString("OK")}, - {Type: "QuotaSufficient", Status: openapi.AdapterConditionStatusTrue, Reason: util.PtrString("QuotaOK"), Message: util.PtrString("Quota OK")}, - {Type: "PolicyValid", Status: openapi.AdapterConditionStatusTrue, Reason: util.PtrString("PolicyOK"), Message: util.PtrString("Policy OK")}, + { + Type: api.AdapterConditionTypeAvailable, + Status: openapi.AdapterConditionStatusTrue, + Reason: util.PtrString("OK"), + Message: util.PtrString("OK"), + }, + { + Type: api.AdapterConditionTypeApplied, + Status: openapi.AdapterConditionStatusTrue, + Reason: util.PtrString("OK"), + Message: util.PtrString("OK"), + }, + { + Type: api.AdapterConditionTypeHealth, + Status: openapi.AdapterConditionStatusTrue, + Reason: util.PtrString("OK"), + Message: util.PtrString("OK"), + }, + { + Type: "QuotaSufficient", + Status: openapi.AdapterConditionStatusTrue, + Reason: util.PtrString("QuotaOK"), + Message: util.PtrString("Quota OK"), + }, + { + Type: "PolicyCheckPassed", + Status: openapi.AdapterConditionStatusTrue, + Reason: util.PtrString("PolicyOK"), + Message: util.PtrString("Policy OK"), + }, }, nil, ) @@ -277,7 +318,9 @@ func TestConditionMapping_MultipleRules(t *testing.T) { resource := getResp.JSON200 // Verify both custom conditions were mapped - // The test assumes config has QuotaValid and PolicyValid mapping rules configured + // The test assumes config has two mapping rules: + // - QuotaValid maps from QuotaSufficient + // - PolicyValid maps from PolicyCheckPassed var hasQuotaValid, hasPolicyValid bool for _, cond := range resource.Status.Conditions { if cond.Type == "QuotaValid" { @@ -291,5 +334,5 @@ func TestConditionMapping_MultipleRules(t *testing.T) { } Expect(hasQuotaValid).To(BeTrue(), "QuotaValid condition should be mapped from QuotaSufficient adapter condition") - Expect(hasPolicyValid).To(BeTrue(), "PolicyValid condition should be mapped from PolicyValid adapter condition") + Expect(hasPolicyValid).To(BeTrue(), "PolicyValid condition should be mapped from PolicyCheckPassed adapter condition") } From 9dad315854ddbee5fa23f364c49a707185dcb3cb Mon Sep 17 00:00:00 2001 From: Leonardo Dorneles Date: Mon, 27 Jul 2026 18:59:09 -0300 Subject: [PATCH 04/17] HYPERFLEET-538 - feat: CEL-based condition mapping engine --- pkg/config/conditions_test.go | 8 ++++---- pkg/config/config.go | 2 +- pkg/services/condition_mapper.go | 8 ++++---- pkg/services/condition_mapper_test.go | 2 +- pkg/util/cel_test.go | 4 ++-- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pkg/config/conditions_test.go b/pkg/config/conditions_test.go index 2dba845c..9e2ecff9 100644 --- a/pkg/config/conditions_test.go +++ b/pkg/config/conditions_test.go @@ -28,11 +28,11 @@ func TestConditionsConfig_Validate(t *testing.T) { } tests := []struct { - name string config *ConditionsConfig + name string + errorMsg string entities []registry.EntityDescriptor wantError bool - errorMsg string }{ { name: "valid config with single cluster rule", @@ -296,8 +296,8 @@ func TestConditionsConfig_Validate(t *testing.T) { } func TestConditionsConfig_IsEmpty(t *testing.T) { tests := []struct { - name string config *ConditionsConfig + name string expected bool }{ { @@ -388,8 +388,8 @@ conditions: null ` // Simulate viper unmarshaling type testConfig struct { - Server struct{ Port int } Conditions *ConditionsConfig + Server struct{ Port int } } var cfg testConfig diff --git a/pkg/config/config.go b/pkg/config/config.go index ad6f2700..a08f5d23 100755 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -10,8 +10,8 @@ type ApplicationConfig struct { Health *HealthConfig `mapstructure:"health" json:"health" validate:"required"` Database *DatabaseConfig `mapstructure:"database" json:"database" validate:"required"` Logging *LoggingConfig `mapstructure:"logging" json:"logging" validate:"required"` - Entities []registry.EntityDescriptor `mapstructure:"entities" json:"entities"` Conditions *ConditionsConfig `mapstructure:"conditions" json:"conditions"` + Entities []registry.EntityDescriptor `mapstructure:"entities" json:"entities"` } // NewApplicationConfig returns default ApplicationConfig with all sub-configs initialized diff --git a/pkg/services/condition_mapper.go b/pkg/services/condition_mapper.go index f8e216cd..b588416f 100644 --- a/pkg/services/condition_mapper.go +++ b/pkg/services/condition_mapper.go @@ -59,27 +59,27 @@ const ( // ConditionMapper evaluates CEL-based condition mapping rules type ConditionMapper struct { rules map[string]*compiledRule - sortedNames []string // Pre-sorted rule names for deterministic ordering - resourceKind string cachedResource *cachedResourceContext // Cache for masked resource map (PERF-03) + resourceKind string + sortedNames []string // Pre-sorted rule names for deterministic ordering } // cachedResourceContext caches the masked resource map to avoid redundant // marshal + MaskSensitiveFields operations across adapter status reports. // The resource (spec, metadata) only changes on PATCH operations, not status reports. type cachedResourceContext struct { + maskedMap map[string]interface{} resourceID string generation int32 - maskedMap map[string]interface{} } // compiledRule holds pre-compiled CEL programs for a mapping rule type compiledRule struct { - conditionType string whenProgram cel.Program statusProgram cel.Program reasonProgram cel.Program messageProgram cel.Program + conditionType string } // ApplyInput holds the input data for condition mapping diff --git a/pkg/services/condition_mapper_test.go b/pkg/services/condition_mapper_test.go index 0dde6176..0ec8058b 100644 --- a/pkg/services/condition_mapper_test.go +++ b/pkg/services/condition_mapper_test.go @@ -1361,8 +1361,8 @@ func TestTruncateUTF8(t *testing.T) { // Run under -race flag to detect data races const numGoroutines = 10 type result struct { - conditions []api.ResourceCondition err error + conditions []api.ResourceCondition } results := make(chan result, numGoroutines) diff --git a/pkg/util/cel_test.go b/pkg/util/cel_test.go index d7a37bc0..bcfc4025 100644 --- a/pkg/util/cel_test.go +++ b/pkg/util/cel_test.go @@ -38,9 +38,9 @@ func TestDigFunc_MapNavigation(t *testing.T) { } tests := []struct { + want interface{} name string expression string - want interface{} wantErr bool }{ { @@ -146,9 +146,9 @@ func TestDigFunc_ArrayNavigation(t *testing.T) { } tests := []struct { + want interface{} name string expression string - want interface{} }{ { name: "first adapter name", From 668556c3a20b00bae52402a13917d260bbe81af6 Mon Sep 17 00:00:00 2001 From: Leonardo Dorneles Date: Mon, 27 Jul 2026 20:02:22 -0300 Subject: [PATCH 05/17] HYPERFLEET-538 - feat: CEL-based condition mapping engine --- pkg/services/condition_mapper.go | 48 +++++++++++---------------- pkg/services/condition_mapper_test.go | 47 +++++++++++++++++++++----- 2 files changed, 58 insertions(+), 37 deletions(-) diff --git a/pkg/services/condition_mapper.go b/pkg/services/condition_mapper.go index b588416f..bcd0a52f 100644 --- a/pkg/services/condition_mapper.go +++ b/pkg/services/condition_mapper.go @@ -7,6 +7,7 @@ import ( "math" "sort" "strings" + "sync" "time" "unicode/utf8" @@ -61,7 +62,8 @@ type ConditionMapper struct { rules map[string]*compiledRule cachedResource *cachedResourceContext // Cache for masked resource map (PERF-03) resourceKind string - sortedNames []string // Pre-sorted rule names for deterministic ordering + sortedNames []string // Pre-sorted rule names for deterministic ordering + mu sync.RWMutex // Protects cachedResource from concurrent access } // cachedResourceContext caches the masked resource map to avoid redundant @@ -415,6 +417,8 @@ func (m *ConditionMapper) buildActivationWithCache( // getCachedOrBuildResource returns the masked resource map, using cache when valid. // Cache key: resourceID + generation. Invalidates automatically on PATCH (generation bump). +// Thread-safe: protected by RWMutex to prevent data races when different resources of the +// same kind are processed concurrently (GetForUpdate only serializes same resourceID). func (m *ConditionMapper) getCachedOrBuildResource( ctx context.Context, resource interface{}, @@ -425,20 +429,29 @@ func (m *ConditionMapper) getCachedOrBuildResource( return util.MaskSensitiveFields(resourceToMap(ctx, resource, m.resourceKind)) } - // Cache hit: same resource ID and generation + // Try cache read (RLock allows concurrent reads) + m.mu.RLock() if m.cachedResource != nil && - m.cachedResource.resourceID == r.ID && + m.cachedResource.resourceID == r.Name && m.cachedResource.generation == r.Generation { - return m.cachedResource.maskedMap + cached := m.cachedResource.maskedMap + m.mu.RUnlock() + return cached } + m.mu.RUnlock() - // Cache miss: rebuild and update cache + // Cache miss: rebuild (expensive operation outside of lock) maskedMap := util.MaskSensitiveFields(resourceToMap(ctx, resource, m.resourceKind)) + + // Update cache (Lock for exclusive write access) + m.mu.Lock() m.cachedResource = &cachedResourceContext{ - resourceID: r.ID, + resourceID: r.Name, generation: r.Generation, maskedMap: maskedMap, } + m.mu.Unlock() + return maskedMap } @@ -462,29 +475,6 @@ func buildStatusesList(ctx context.Context, statuses api.AdapterStatusList) []in // buildActivation builds the CEL activation context from input data // Combined filter + conversion in single pass to avoid double JSON unmarshal (PERF-03) -func buildActivation( - ctx context.Context, - statuses api.AdapterStatusList, - resource interface{}, - resourceKind string, -) map[string]interface{} { - // Convert adapter statuses using shared logic (PERF-03: avoid duplication) - statusesList := buildStatusesList(ctx, statuses) - - // Convert resource to map and mask sensitive fields (defense-in-depth) - // Matches the protection applied to adapter data to prevent credential leakage - resourceMap := util.MaskSensitiveFields(resourceToMap(ctx, resource, resourceKind)) - - return map[string]interface{}{ - util.CELVarStatuses: statusesList, - util.CELVarResource: resourceMap, - // Environment variables not implemented yet - // TODO: Create HYPERFLEET ticket for env variable support in CEL context - // Feature: Allow CEL expressions to access runtime config (e.g., env.REGION, env.ENVIRONMENT) - // SEC-02: Must use an allowlist of safe variables - NEVER expose all process env (contains secrets) - util.CELVarEnv: emptyEnvMap, // Shared package-level var (PERF-03) - } -} // parseConditionsWithUnknownCheck unmarshals adapter conditions from JSONB and converts to CEL maps. // Returns (conditions, hasUnknown) where hasUnknown indicates if any condition has Unknown status. diff --git a/pkg/services/condition_mapper_test.go b/pkg/services/condition_mapper_test.go index 0ec8058b..973c3002 100644 --- a/pkg/services/condition_mapper_test.go +++ b/pkg/services/condition_mapper_test.go @@ -3,6 +3,7 @@ package services import ( "context" "encoding/json" + "fmt" "testing" "time" "unicode/utf8" @@ -576,7 +577,7 @@ func TestAdapterStatusToMapWithUnknownCheck_NilGuard(t *testing.T) { } // Should not panic - activation := buildActivation(context.Background(), statuses, map[string]interface{}{}, "Cluster") + activation := testBuildActivation(context.Background(), statuses, map[string]interface{}{}, "Cluster") statusesList := activation[util.CELVarStatuses].([]interface{}) // Nil element should be skipped, only valid adapter present @@ -885,7 +886,7 @@ func TestBuildActivation_NumericTypesConsistency(t *testing.T) { "generation": 5, // Will be float64 after JSON round-trip } - activation := buildActivation(context.Background(), statuses, resource, "Cluster") + activation := testBuildActivation(context.Background(), statuses, resource, "Cluster") // Verify statuses[0].observed_generation is float64 statusesList := activation[util.CELVarStatuses].([]interface{}) @@ -1366,20 +1367,27 @@ func TestTruncateUTF8(t *testing.T) { } results := make(chan result, numGoroutines) + // Create different *api.Resource instances to exercise cache path + // (using map[string]interface{} bypasses cache via type assertion fallback) for i := 0; i < numGoroutines; i++ { - go func() { + go func(id int) { + // Different resources to trigger cache eviction/thrashing and race detection + resource := &api.Resource{ + Kind: "Cluster", + Name: fmt.Sprintf("cluster-%d", id), + Generation: int32(id), + } + input := ApplyInput{ AdapterStatuses: api.AdapterStatusList{}, - Resource: map[string]interface{}{ - "generation": int64(1), - }, - RefTime: time.Now(), + Resource: resource, + RefTime: time.Now(), } // Collect result, don't assert in goroutine conditions := mapper.Apply(context.Background(), input) results <- result{conditions: conditions} - }() + }(i) } // Wait for all goroutines and assert on main test goroutine @@ -1391,3 +1399,26 @@ func TestTruncateUTF8(t *testing.T) { } }) } + +// testBuildActivation is the test-only variant of buildActivationWithCache that doesn't use caching. +// Used by unit tests to validate CEL context construction without needing a full ConditionMapper instance. +// Production code exclusively uses buildActivationWithCache through the mapper. +func testBuildActivation( + ctx context.Context, + statuses api.AdapterStatusList, + resource interface{}, + resourceKind string, +) map[string]interface{} { + // Convert adapter statuses using shared logic (PERF-03: avoid duplication) + statusesList := buildStatusesList(ctx, statuses) + + // Convert resource to map and mask sensitive fields (defense-in-depth) + // Matches the protection applied to adapter data to prevent credential leakage + resourceMap := util.MaskSensitiveFields(resourceToMap(ctx, resource, resourceKind)) + + return map[string]interface{}{ + util.CELVarStatuses: statusesList, + util.CELVarResource: resourceMap, + util.CELVarEnv: emptyEnvMap, // Shared package-level var (PERF-03) + } +} From c137ceb6124c4819a546fb77ba493221fc03c1fc Mon Sep 17 00:00:00 2001 From: Leonardo Dorneles Date: Mon, 27 Jul 2026 20:31:28 -0300 Subject: [PATCH 06/17] HYPERFLEET-538 - feat: CEL-based condition mapping engine --- pkg/services/condition_mapper.go | 11 ++++------- pkg/services/condition_mapper_test.go | 12 ++++++++---- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/pkg/services/condition_mapper.go b/pkg/services/condition_mapper.go index bcd0a52f..24069c88 100644 --- a/pkg/services/condition_mapper.go +++ b/pkg/services/condition_mapper.go @@ -133,7 +133,7 @@ func (m *ConditionMapper) Apply(ctx context.Context, input ApplyInput) []api.Res return nil } - // Build CEL activation context (filtering Unknown conditions happens inside buildActivation) + // Build CEL activation context (filtering Unknown conditions happens inside buildActivationWithCache) // Use cached resource map when possible to avoid redundant marshal + mask operations (PERF-03) activation := m.buildActivationWithCache(ctx, input.AdapterStatuses, input.Resource) @@ -432,7 +432,7 @@ func (m *ConditionMapper) getCachedOrBuildResource( // Try cache read (RLock allows concurrent reads) m.mu.RLock() if m.cachedResource != nil && - m.cachedResource.resourceID == r.Name && + m.cachedResource.resourceID == r.ID && m.cachedResource.generation == r.Generation { cached := m.cachedResource.maskedMap m.mu.RUnlock() @@ -446,7 +446,7 @@ func (m *ConditionMapper) getCachedOrBuildResource( // Update cache (Lock for exclusive write access) m.mu.Lock() m.cachedResource = &cachedResourceContext{ - resourceID: r.Name, + resourceID: r.ID, generation: r.Generation, maskedMap: maskedMap, } @@ -456,7 +456,7 @@ func (m *ConditionMapper) getCachedOrBuildResource( } // buildStatusesList converts adapter statuses to CEL-compatible format, filtering Unknown conditions. -// Extracted to avoid duplication between buildActivation and buildActivationWithCache (PERF-03). +// Extracted to shared function to avoid duplication (PERF-03). func buildStatusesList(ctx context.Context, statuses api.AdapterStatusList) []interface{} { statusesList := make([]interface{}, 0, len(statuses)) for _, status := range statuses { @@ -473,9 +473,6 @@ func buildStatusesList(ctx context.Context, statuses api.AdapterStatusList) []in return statusesList } -// buildActivation builds the CEL activation context from input data -// Combined filter + conversion in single pass to avoid double JSON unmarshal (PERF-03) - // parseConditionsWithUnknownCheck unmarshals adapter conditions from JSONB and converts to CEL maps. // Returns (conditions, hasUnknown) where hasUnknown indicates if any condition has Unknown status. // Returns empty slice (not nil) on unmarshal failure so CEL receives [] instead of null. diff --git a/pkg/services/condition_mapper_test.go b/pkg/services/condition_mapper_test.go index 973c3002..d68a8f75 100644 --- a/pkg/services/condition_mapper_test.go +++ b/pkg/services/condition_mapper_test.go @@ -577,7 +577,7 @@ func TestAdapterStatusToMapWithUnknownCheck_NilGuard(t *testing.T) { } // Should not panic - activation := testBuildActivation(context.Background(), statuses, map[string]interface{}{}, "Cluster") + activation := testBuildActivation(t, context.Background(), statuses, map[string]interface{}{}, "Cluster") statusesList := activation[util.CELVarStatuses].([]interface{}) // Nil element should be skipped, only valid adapter present @@ -886,7 +886,7 @@ func TestBuildActivation_NumericTypesConsistency(t *testing.T) { "generation": 5, // Will be float64 after JSON round-trip } - activation := testBuildActivation(context.Background(), statuses, resource, "Cluster") + activation := testBuildActivation(t, context.Background(), statuses, resource, "Cluster") // Verify statuses[0].observed_generation is float64 statusesList := activation[util.CELVarStatuses].([]interface{}) @@ -1362,7 +1362,6 @@ func TestTruncateUTF8(t *testing.T) { // Run under -race flag to detect data races const numGoroutines = 10 type result struct { - err error conditions []api.ResourceCondition } results := make(chan result, numGoroutines) @@ -1373,6 +1372,9 @@ func TestTruncateUTF8(t *testing.T) { go func(id int) { // Different resources to trigger cache eviction/thrashing and race detection resource := &api.Resource{ + Meta: api.Meta{ + ID: fmt.Sprintf("resource-%d", id), + }, Kind: "Cluster", Name: fmt.Sprintf("cluster-%d", id), Generation: int32(id), @@ -1393,7 +1395,6 @@ func TestTruncateUTF8(t *testing.T) { // Wait for all goroutines and assert on main test goroutine for i := 0; i < numGoroutines; i++ { res := <-results - Expect(res.err).NotTo(HaveOccurred()) Expect(res.conditions).To(HaveLen(1)) Expect(res.conditions[0].Type).To(Equal("TestCondition")) } @@ -1404,11 +1405,14 @@ func TestTruncateUTF8(t *testing.T) { // Used by unit tests to validate CEL context construction without needing a full ConditionMapper instance. // Production code exclusively uses buildActivationWithCache through the mapper. func testBuildActivation( + t *testing.T, ctx context.Context, statuses api.AdapterStatusList, resource interface{}, resourceKind string, ) map[string]interface{} { + t.Helper() + // Convert adapter statuses using shared logic (PERF-03: avoid duplication) statusesList := buildStatusesList(ctx, statuses) From 53ea84768f7e1478f05c18d4e52e3e28cbe88061 Mon Sep 17 00:00:00 2001 From: Leonardo Dorneles Date: Mon, 27 Jul 2026 20:41:09 -0300 Subject: [PATCH 07/17] HYPERFLEET-538 - feat: CEL-based condition mapping engine --- pkg/services/condition_mapper.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/services/condition_mapper.go b/pkg/services/condition_mapper.go index 24069c88..a20325b8 100644 --- a/pkg/services/condition_mapper.go +++ b/pkg/services/condition_mapper.go @@ -506,7 +506,7 @@ func parseConditionsWithUnknownCheck( // Check for Unknown status while building the map (single pass) if cond.Status == api.AdapterConditionUnknown { hasUnknown = true - // Break early - buildActivation will discard this entire statusMap + // Break early - buildStatusesList will discard this entire statusMap break } @@ -569,7 +569,7 @@ func adapterStatusToMapWithUnknownCheck(ctx context.Context, status *api.Adapter // Parse conditions and check for Unknown status (QUAL-03) conditions, hasUnknown := parseConditionsWithUnknownCheck(ctx, status.Conditions, status.Adapter) - // Early return if Unknown found - buildActivation will discard this statusMap anyway (PERF-03) + // Early return if Unknown found - buildStatusesList will discard this statusMap anyway (PERF-03) // Skips data parsing and MaskSensitiveFields call to avoid wasted work if hasUnknown { return map[string]interface{}{ From 65ccab53b155dcb9cd1c3a920da8dadb5ab40422 Mon Sep 17 00:00:00 2001 From: Leonardo Dorneles Date: Mon, 27 Jul 2026 22:52:48 -0300 Subject: [PATCH 08/17] HYPERFLEET-538 - feat: CEL-based condition mapping engine --- pkg/config/conditions_test.go | 7 + pkg/services/condition_mapper.go | 37 +++-- pkg/services/condition_mapper_test.go | 228 +++++++++++++------------- pkg/services/resource.go | 11 +- pkg/util/cel_test.go | 33 ++++ pkg/util/mask_sensitive_test.go | 5 + pkg/util/naming_test.go | 1 + 7 files changed, 188 insertions(+), 134 deletions(-) diff --git a/pkg/config/conditions_test.go b/pkg/config/conditions_test.go index 9e2ecff9..990d4743 100644 --- a/pkg/config/conditions_test.go +++ b/pkg/config/conditions_test.go @@ -15,6 +15,7 @@ import ( // ============================================================================ func TestConditionsConfig_Validate(t *testing.T) { + t.Parallel() // Typical entity config with required adapters entities := []registry.EntityDescriptor{ { @@ -295,6 +296,7 @@ func TestConditionsConfig_Validate(t *testing.T) { } } func TestConditionsConfig_IsEmpty(t *testing.T) { + t.Parallel() tests := []struct { config *ConditionsConfig name string @@ -353,6 +355,7 @@ func TestConditionsConfig_IsEmpty(t *testing.T) { } } func TestNewConditionsConfig(t *testing.T) { + t.Parallel() RegisterTestingT(t) config := NewConditionsConfig() @@ -364,6 +367,7 @@ func TestNewConditionsConfig(t *testing.T) { Expect(config.IsEmpty()).To(BeTrue()) } func TestConditionsConfig_NilReceiverGuards(t *testing.T) { + t.Parallel() t.Run("Validate on nil receiver returns nil", func(t *testing.T) { RegisterTestingT(t) var c *ConditionsConfig @@ -379,6 +383,7 @@ func TestConditionsConfig_NilReceiverGuards(t *testing.T) { }) } func TestConditionsConfig_YAMLNullHandling(t *testing.T) { + t.Parallel() t.Run("YAML with conditions: null is handled gracefully", func(t *testing.T) { RegisterTestingT(t) yamlContent := ` @@ -418,6 +423,7 @@ conditions: null // ============================================================================ func TestConditionsConfig_Validate_DeterministicErrors(t *testing.T) { + t.Parallel() t.Run("validation errors are deterministic with multiple invalid rules", func(t *testing.T) { RegisterTestingT(t) @@ -460,6 +466,7 @@ func TestConditionsConfig_Validate_DeterministicErrors(t *testing.T) { // ============================================================================ func TestValidate_CELCheckCatchesErrors(t *testing.T) { + t.Parallel() t.Run("undefined function caught by Check", func(t *testing.T) { RegisterTestingT(t) diff --git a/pkg/services/condition_mapper.go b/pkg/services/condition_mapper.go index a20325b8..09caf149 100644 --- a/pkg/services/condition_mapper.go +++ b/pkg/services/condition_mapper.go @@ -280,9 +280,9 @@ func (m *ConditionMapper) buildMappedCondition( // Parse status string to ResourceConditionStatus (case-insensitive) var status api.ResourceConditionStatus switch strings.ToLower(statusStr) { - case strings.ToLower(celBoolTrue): + case "true": status = api.ConditionTrue - case strings.ToLower(celBoolFalse): + case "false": status = api.ConditionFalse default: return nil, fmt.Errorf( @@ -291,21 +291,7 @@ func (m *ConditionMapper) buildMappedCondition( ) } - // Extract resource generation for ObservedGeneration field - // Use type assertion via map to extract generation field - resourceGen := int32(0) - if resourceMap, ok := activation[util.CELVarResource].(map[string]interface{}); ok { - if gen, ok := resourceMap[resourceKeyGeneration].(float64); ok { - // Bounds check before narrowing conversion to prevent wrapping (critical) - if gen >= math.MinInt32 && gen <= math.MaxInt32 { - resourceGen = int32(gen) - } else { - // Out of bounds: log warning and use 0 (safe fallback) - logger.With(ctx, "resource_kind", m.resourceKind, "condition_type", rule.conditionType, "generation", gen). - Warn("Resource generation out of int32 range, using 0") - } - } - } + resourceGen := extractResourceGeneration(ctx, activation, m.resourceKind, rule.conditionType) // Preserve CreatedTime from previous condition (matching aggregation.go:332-333) createdTime := refTime.UTC().Truncate(time.Microsecond) @@ -335,6 +321,23 @@ func (m *ConditionMapper) buildMappedCondition( return &condition, nil } +func extractResourceGeneration(ctx context.Context, activation map[string]interface{}, resourceKind, conditionType string) int32 { + resourceMap, ok := activation[util.CELVarResource].(map[string]interface{}) + if !ok { + return 0 + } + gen, ok := resourceMap[resourceKeyGeneration].(float64) + if !ok { + return 0 + } + if gen < math.MinInt32 || gen > math.MaxInt32 { + logger.With(ctx, "resource_kind", resourceKind, "condition_type", conditionType, "generation", gen). + Warn("Resource generation out of int32 range, using 0") + return 0 + } + return int32(gen) +} + // compileRule compiles all CEL expressions in a mapping rule func compileRule(env *cel.Env, condType string, rule config.ConditionMappingRule) (*compiledRule, error) { // Compile when expression diff --git a/pkg/services/condition_mapper_test.go b/pkg/services/condition_mapper_test.go index d68a8f75..ce027dc1 100644 --- a/pkg/services/condition_mapper_test.go +++ b/pkg/services/condition_mapper_test.go @@ -1259,146 +1259,146 @@ func TestTruncateUTF8(t *testing.T) { Expect(result).To(Equal("test😀")) Expect(utf8.RuneCountInString(result)).To(Equal(5)) }) +} - t.Run("invalid status string from CEL expression", func(t *testing.T) { - RegisterTestingT(t) +func TestConditionMapper_InvalidStatus(t *testing.T) { + RegisterTestingT(t) - // CEL rule that outputs "Maybe" instead of "True"/"False" - rules := map[string]config.ConditionMappingRule{ - "TestCondition": { - When: config.MappingExpression{ - Expression: "true", + // CEL rule that outputs "Maybe" instead of "True"/"False" + rules := map[string]config.ConditionMappingRule{ + "TestCondition": { + When: config.MappingExpression{ + Expression: "true", + }, + Output: config.MappingOutput{ + Status: config.MappingExpression{ + Expression: `"Maybe"`, // Invalid status }, - Output: config.MappingOutput{ - Status: config.MappingExpression{ - Expression: `"Maybe"`, // Invalid status - }, - Reason: config.MappingExpression{ - Expression: `"test"`, - }, - Message: config.MappingExpression{ - Expression: `"test message"`, - }, + Reason: config.MappingExpression{ + Expression: `"test"`, + }, + Message: config.MappingExpression{ + Expression: `"test message"`, }, }, - } + }, + } - mapper, err := NewConditionMapper("Cluster", rules) - Expect(err).NotTo(HaveOccurred()) + mapper, err := NewConditionMapper("Cluster", rules) + Expect(err).NotTo(HaveOccurred()) - input := ApplyInput{ - AdapterStatuses: api.AdapterStatusList{}, - Resource: map[string]interface{}{}, - RefTime: time.Now(), - } + input := ApplyInput{ + AdapterStatuses: api.AdapterStatusList{}, + Resource: map[string]interface{}{}, + RefTime: time.Now(), + } - // Should not panic, should skip the condition and log error - result := mapper.Apply(context.Background(), input) - Expect(result).To(BeEmpty(), "invalid status should skip condition") - }) + // Should not panic, should skip the condition and log error + result := mapper.Apply(context.Background(), input) + Expect(result).To(BeEmpty(), "invalid status should skip condition") +} - t.Run("non-boolean when expression return type", func(t *testing.T) { - RegisterTestingT(t) +func TestConditionMapper_NonBooleanWhen(t *testing.T) { + RegisterTestingT(t) - // CEL rule with when expression that returns string instead of boolean - rules := map[string]config.ConditionMappingRule{ - "TestCondition": { - When: config.MappingExpression{ - Expression: `"not a boolean"`, // Should return bool + // CEL rule with when expression that returns string instead of boolean + rules := map[string]config.ConditionMappingRule{ + "TestCondition": { + When: config.MappingExpression{ + Expression: `"not a boolean"`, // Should return bool + }, + Output: config.MappingOutput{ + Status: config.MappingExpression{ + Expression: `"True"`, }, - Output: config.MappingOutput{ - Status: config.MappingExpression{ - Expression: `"True"`, - }, - Reason: config.MappingExpression{ - Expression: `"test"`, - }, - Message: config.MappingExpression{ - Expression: `"test message"`, - }, + Reason: config.MappingExpression{ + Expression: `"test"`, + }, + Message: config.MappingExpression{ + Expression: `"test message"`, }, }, - } + }, + } - mapper, err := NewConditionMapper("Cluster", rules) - Expect(err).NotTo(HaveOccurred()) + mapper, err := NewConditionMapper("Cluster", rules) + Expect(err).NotTo(HaveOccurred()) - input := ApplyInput{ - AdapterStatuses: api.AdapterStatusList{}, - Resource: map[string]interface{}{}, - RefTime: time.Now(), - } + input := ApplyInput{ + AdapterStatuses: api.AdapterStatusList{}, + Resource: map[string]interface{}{}, + RefTime: time.Now(), + } - // Should not panic, should skip the condition - result := mapper.Apply(context.Background(), input) - Expect(result).To(BeEmpty(), "non-boolean when expression should skip condition") - }) + // Should not panic, should skip the condition + result := mapper.Apply(context.Background(), input) + Expect(result).To(BeEmpty(), "non-boolean when expression should skip condition") +} - t.Run("concurrent Apply() access from multiple goroutines", func(t *testing.T) { - RegisterTestingT(t) +func TestConditionMapper_ConcurrentApply(t *testing.T) { + RegisterTestingT(t) - rules := map[string]config.ConditionMappingRule{ - "TestCondition": { - When: config.MappingExpression{ - Expression: "true", + rules := map[string]config.ConditionMappingRule{ + "TestCondition": { + When: config.MappingExpression{ + Expression: "true", + }, + Output: config.MappingOutput{ + Status: config.MappingExpression{ + Expression: `"True"`, }, - Output: config.MappingOutput{ - Status: config.MappingExpression{ - Expression: `"True"`, - }, - Reason: config.MappingExpression{ - Expression: `"test"`, - }, - Message: config.MappingExpression{ - Expression: `"test message"`, - }, + Reason: config.MappingExpression{ + Expression: `"test"`, + }, + Message: config.MappingExpression{ + Expression: `"test message"`, }, }, - } + }, + } - mapper, err := NewConditionMapper("Cluster", rules) - Expect(err).NotTo(HaveOccurred()) + mapper, err := NewConditionMapper("Cluster", rules) + Expect(err).NotTo(HaveOccurred()) - // Run under -race flag to detect data races - const numGoroutines = 10 - type result struct { - conditions []api.ResourceCondition - } - results := make(chan result, numGoroutines) - - // Create different *api.Resource instances to exercise cache path - // (using map[string]interface{} bypasses cache via type assertion fallback) - for i := 0; i < numGoroutines; i++ { - go func(id int) { - // Different resources to trigger cache eviction/thrashing and race detection - resource := &api.Resource{ - Meta: api.Meta{ - ID: fmt.Sprintf("resource-%d", id), - }, - Kind: "Cluster", - Name: fmt.Sprintf("cluster-%d", id), - Generation: int32(id), - } + // Run under -race flag to detect data races + const numGoroutines = 10 + type result struct { + conditions []api.ResourceCondition + } + results := make(chan result, numGoroutines) + + // Create different *api.Resource instances to exercise cache path + // (using map[string]interface{} bypasses cache via type assertion fallback) + for i := 0; i < numGoroutines; i++ { + go func(id int) { + // Different resources to trigger cache eviction/thrashing and race detection + resource := &api.Resource{ + Meta: api.Meta{ + ID: fmt.Sprintf("resource-%d", id), + }, + Kind: "Cluster", + Name: fmt.Sprintf("cluster-%d", id), + Generation: int32(id), + } - input := ApplyInput{ - AdapterStatuses: api.AdapterStatusList{}, - Resource: resource, - RefTime: time.Now(), - } + input := ApplyInput{ + AdapterStatuses: api.AdapterStatusList{}, + Resource: resource, + RefTime: time.Now(), + } - // Collect result, don't assert in goroutine - conditions := mapper.Apply(context.Background(), input) - results <- result{conditions: conditions} - }(i) - } + // Collect result, don't assert in goroutine + conditions := mapper.Apply(context.Background(), input) + results <- result{conditions: conditions} + }(i) + } - // Wait for all goroutines and assert on main test goroutine - for i := 0; i < numGoroutines; i++ { - res := <-results - Expect(res.conditions).To(HaveLen(1)) - Expect(res.conditions[0].Type).To(Equal("TestCondition")) - } - }) + // Wait for all goroutines and assert on main test goroutine + for i := 0; i < numGoroutines; i++ { + res := <-results + Expect(res.conditions).To(HaveLen(1)) + Expect(res.conditions[0].Type).To(Equal("TestCondition")) + } } // testBuildActivation is the test-only variant of buildActivationWithCache that doesn't use caching. diff --git a/pkg/services/resource.go b/pkg/services/resource.go index 1ef6bab6..c8780c84 100644 --- a/pkg/services/resource.go +++ b/pkg/services/resource.go @@ -685,13 +685,18 @@ func (s *sqlResourceService) recomputeAndSaveResourceConditions( }, ) - // Build the full conditions slice: Reconciled + LastKnownReconciled + per-adapter conditions. - newConditions := make([]api.ResourceCondition, 0, fixedConditionCount+len(adapterConditions)) + // Build the full conditions slice: Reconciled + LastKnownReconciled + per-adapter + mapped conditions. + mapper := s.conditionMappers[resource.Kind] + var mappedCapacity int + if mapper != nil { + mappedCapacity = len(mapper.sortedNames) + } + newConditions := make([]api.ResourceCondition, 0, fixedConditionCount+len(adapterConditions)+mappedCapacity) newConditions = append(newConditions, reconciled, lastKnownReconciled) newConditions = append(newConditions, adapterConditions...) // Apply CEL condition mapping if configured - if mapper := s.conditionMappers[resource.Kind]; mapper != nil { + if mapper != nil { mappedConditions := mapper.Apply(ctx, ApplyInput{ AdapterStatuses: adapterStatuses, Resource: resource, diff --git a/pkg/util/cel_test.go b/pkg/util/cel_test.go index bcfc4025..a5f3e062 100644 --- a/pkg/util/cel_test.go +++ b/pkg/util/cel_test.go @@ -1,6 +1,7 @@ package util import ( + "strings" "testing" "github.com/google/cel-go/cel" @@ -12,6 +13,7 @@ import ( // ============================================================================ func TestNewConditionMappingEnvironment(t *testing.T) { + t.Parallel() RegisterTestingT(t) env, err := NewConditionMappingEnvironment() @@ -19,6 +21,7 @@ func TestNewConditionMappingEnvironment(t *testing.T) { Expect(env).NotTo(BeNil()) } func TestDigFunc_MapNavigation(t *testing.T) { + t.Parallel() RegisterTestingT(t) env, err := NewConditionMappingEnvironment() @@ -114,6 +117,7 @@ func TestDigFunc_MapNavigation(t *testing.T) { } } func TestDigFunc_ArrayNavigation(t *testing.T) { + t.Parallel() RegisterTestingT(t) env, err := NewConditionMappingEnvironment() @@ -197,6 +201,7 @@ func TestDigFunc_ArrayNavigation(t *testing.T) { } } func TestToJsonFunc(t *testing.T) { + t.Parallel() RegisterTestingT(t) env, err := NewConditionMappingEnvironment() @@ -226,11 +231,39 @@ func TestToJsonFunc(t *testing.T) { Expect(out.Value()).To(Equal(`{"count":42,"name":"test"}`)) } +func TestToJsonFunc_SizeLimit(t *testing.T) { + t.Parallel() + RegisterTestingT(t) + + env, err := NewConditionMappingEnvironment() + Expect(err).NotTo(HaveOccurred()) + + largeData := map[string]interface{}{ + "big": strings.Repeat("x", 1024*1024+1), + } + + ast, issues := env.Parse(`toJson(resource)`) + Expect(issues).To(BeNil()) + + _, issues = env.Check(ast) + Expect(issues).To(BeNil()) + + prg, err := env.Program(ast, cel.CostLimit(CELCostLimit)) + Expect(err).NotTo(HaveOccurred()) + + _, _, err = prg.Eval(map[string]interface{}{ + CELVarResource: largeData, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("exceeds 1MB")) +} + // ============================================================================ // Tests from cel_constants_test.go // ============================================================================ func TestCELVariableConstants(t *testing.T) { + t.Parallel() t.Run("CEL variable constants prevent name mismatches (QUAL-01)", func(t *testing.T) { RegisterTestingT(t) diff --git a/pkg/util/mask_sensitive_test.go b/pkg/util/mask_sensitive_test.go index a5f6d5c7..dba3daf4 100644 --- a/pkg/util/mask_sensitive_test.go +++ b/pkg/util/mask_sensitive_test.go @@ -11,6 +11,7 @@ import ( // ============================================================================ func TestMaskSensitiveFields(t *testing.T) { + t.Parallel() t.Run("nil map returns nil", func(t *testing.T) { RegisterTestingT(t) result := MaskSensitiveFields(nil) @@ -282,6 +283,7 @@ func TestMaskSensitiveFields(t *testing.T) { }) } func TestIsSensitiveKey(t *testing.T) { + t.Parallel() tests := []struct { name string key string @@ -325,6 +327,7 @@ func TestIsSensitiveKey(t *testing.T) { // ============================================================================ func TestMaskSensitiveFields_SEC02Patterns(t *testing.T) { + t.Parallel() t.Run("TLS certificate fields are masked (SEC-02)", func(t *testing.T) { RegisterTestingT(t) @@ -510,6 +513,7 @@ func TestMaskSensitiveFields_SEC02Patterns(t *testing.T) { // ============================================================================ func TestMaskSensitiveFields_DepthLimit(t *testing.T) { + t.Parallel() t.Run("deeply nested structure stops at max depth (SEC-03)", func(t *testing.T) { RegisterTestingT(t) @@ -612,6 +616,7 @@ func TestMaskSensitiveFields_DepthLimit(t *testing.T) { // ============================================================================ func TestMaskSensitiveFields_FalsePositivePrevention(t *testing.T) { + t.Parallel() t.Run("database fields with 'key' are NOT redacted (SEC-02)", func(t *testing.T) { RegisterTestingT(t) diff --git a/pkg/util/naming_test.go b/pkg/util/naming_test.go index 2e12f4e8..8ddce841 100644 --- a/pkg/util/naming_test.go +++ b/pkg/util/naming_test.go @@ -7,6 +7,7 @@ import ( ) func TestMapAdapterToConditionType(t *testing.T) { + t.Parallel() testCases := []struct { adapter string expected string From c903cd99183a9ee681dced0f0ee25de956e5220b Mon Sep 17 00:00:00 2001 From: Leonardo Dorneles Date: Mon, 27 Jul 2026 23:22:24 -0300 Subject: [PATCH 09/17] HYPERFLEET-538 - feat: CEL-based condition mapping engine --- pkg/services/condition_mapper.go | 22 +++++--- pkg/services/condition_mapper_test.go | 80 +++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 7 deletions(-) diff --git a/pkg/services/condition_mapper.go b/pkg/services/condition_mapper.go index 09caf149..da8ddeaf 100644 --- a/pkg/services/condition_mapper.go +++ b/pkg/services/condition_mapper.go @@ -279,10 +279,10 @@ func (m *ConditionMapper) buildMappedCondition( ) (*api.ResourceCondition, error) { // Parse status string to ResourceConditionStatus (case-insensitive) var status api.ResourceConditionStatus - switch strings.ToLower(statusStr) { - case "true": + switch { + case strings.EqualFold(statusStr, celBoolTrue): status = api.ConditionTrue - case "false": + case strings.EqualFold(statusStr, celBoolFalse): status = api.ConditionFalse default: return nil, fmt.Errorf( @@ -321,7 +321,11 @@ func (m *ConditionMapper) buildMappedCondition( return &condition, nil } -func extractResourceGeneration(ctx context.Context, activation map[string]interface{}, resourceKind, conditionType string) int32 { +func extractResourceGeneration( + ctx context.Context, + activation map[string]interface{}, + resourceKind, conditionType string, +) int32 { resourceMap, ok := activation[util.CELVarResource].(map[string]interface{}) if !ok { return 0 @@ -432,7 +436,9 @@ func (m *ConditionMapper) getCachedOrBuildResource( return util.MaskSensitiveFields(resourceToMap(ctx, resource, m.resourceKind)) } - // Try cache read (RLock allows concurrent reads) + // Try cache read (RLock allows concurrent reads). + // IMPORTANT: callers MUST NOT mutate the returned map — it may be a cached reference. + // CEL evaluation is read-only, so this is safe for current usage. m.mu.RLock() if m.cachedResource != nil && m.cachedResource.resourceID == r.ID && @@ -514,8 +520,10 @@ func parseConditionsWithUnknownCheck( } condMap := map[string]interface{}{ - condKeyType: cond.Type, - condKeyStatus: string(cond.Status), + condKeyType: cond.Type, + condKeyStatus: string(cond.Status), + condKeyReason: "", + condKeyMessage: "", } // SEC-02: Adapter contract requires condition reason/message to be user-safe // (no secrets, credentials, or sensitive data). Currently unmasked to preserve diff --git a/pkg/services/condition_mapper_test.go b/pkg/services/condition_mapper_test.go index ce027dc1..13be2bc5 100644 --- a/pkg/services/condition_mapper_test.go +++ b/pkg/services/condition_mapper_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "testing" "time" "unicode/utf8" @@ -1335,6 +1336,85 @@ func TestConditionMapper_NonBooleanWhen(t *testing.T) { Expect(result).To(BeEmpty(), "non-boolean when expression should skip condition") } +func TestConditionMapper_MessageTruncationThroughPipeline(t *testing.T) { + RegisterTestingT(t) + + // Build a CEL expression that produces a message exceeding MaxConditionMessageLength (2048) + longMsg := strings.Repeat("x", config.MaxConditionMessageLength+100) + msgExpr := fmt.Sprintf(`"%s"`, longMsg) + + rules := map[string]config.ConditionMappingRule{ + "TestCondition": { + When: config.MappingExpression{ + Expression: "true", + }, + Output: config.MappingOutput{ + Status: config.MappingExpression{ + Expression: `"True"`, + }, + Reason: config.MappingExpression{ + Expression: `"TestReason"`, + }, + Message: config.MappingExpression{ + Expression: msgExpr, + }, + }, + }, + } + + mapper, err := NewConditionMapper("Cluster", rules) + Expect(err).NotTo(HaveOccurred()) + + input := ApplyInput{ + AdapterStatuses: api.AdapterStatusList{}, + Resource: map[string]interface{}{}, + RefTime: time.Now(), + } + + result := mapper.Apply(context.Background(), input) + Expect(result).To(HaveLen(1), "should produce a condition even with long message") + Expect(len(*result[0].Message)).To(BeNumerically("<=", config.MaxConditionMessageLength), + "message should be truncated to MaxConditionMessageLength") + Expect(*result[0].Reason).To(Equal("TestReason"), "reason should be unchanged") +} + +func TestConditionMapper_OutputExpressionRuntimeError(t *testing.T) { + RegisterTestingT(t) + + // CEL rule where the status expression causes a runtime error (out-of-bounds index) + rules := map[string]config.ConditionMappingRule{ + "TestCondition": { + When: config.MappingExpression{ + Expression: "true", + }, + Output: config.MappingOutput{ + Status: config.MappingExpression{ + Expression: `statuses[99].adapter`, // out of bounds → runtime error + }, + Reason: config.MappingExpression{ + Expression: `"reason"`, + }, + Message: config.MappingExpression{ + Expression: `"message"`, + }, + }, + }, + } + + mapper, err := NewConditionMapper("Cluster", rules) + Expect(err).NotTo(HaveOccurred()) + + input := ApplyInput{ + AdapterStatuses: api.AdapterStatusList{}, + Resource: map[string]interface{}{}, + RefTime: time.Now(), + } + + // Should not panic, should skip the condition and log error + result := mapper.Apply(context.Background(), input) + Expect(result).To(BeEmpty(), "output expression runtime error should skip condition") +} + func TestConditionMapper_ConcurrentApply(t *testing.T) { RegisterTestingT(t) From 6c463caabbd16215f4dc27793c0de4e1bec4cbf5 Mon Sep 17 00:00:00 2001 From: Leonardo Dorneles Date: Thu, 30 Jul 2026 02:00:00 -0300 Subject: [PATCH 10/17] HYPERFLEET-538 - refactor: address code review feedback Address code review findings from PR review: - Move condition validation from pkg/config to pkg/registry (better cohesion) - Add blank lines between adjacent top-level function declarations - Update config.yaml.example documentation for Unknown filtering behavior - Format code with gofmt Changes: - pkg/registry/conditions.go: Moved from pkg/config (validation logic belongs with registry) - pkg/registry/conditions_test.go: Moved from pkg/config - configs/config.yaml.example: Clarify Unknown filtering (entire adapter status dropped) - pkg/services/condition_mapper_test.go: Add blank lines between functions - pkg/util/cel_test.go: Add blank lines between functions - pkg/util/mask_sensitive_test.go: Add blank line before TestIsSensitiveKey All tests passing. No functional changes. Co-Authored-By: Claude Sonnet 4.5 --- configs/config.yaml.example | 145 ++++--- pkg/config/conditions_test.go | 542 ------------------------- pkg/config/config.go | 24 +- pkg/config/loader.go | 4 +- pkg/{config => registry}/conditions.go | 95 ++--- pkg/registry/conditions_test.go | 537 ++++++++++++++++++++++++ pkg/registry/descriptor.go | 22 +- pkg/registry/registry.go | 10 + pkg/services/condition_mapper.go | 36 +- pkg/services/condition_mapper_test.go | 379 ++++++++--------- pkg/services/resource.go | 41 +- pkg/services/resource_test.go | 36 +- pkg/util/cel.go | 53 ++- pkg/util/cel_test.go | 106 ++--- pkg/util/mask_sensitive_test.go | 1 + plugins/resources/plugin.go | 1 - 16 files changed, 1024 insertions(+), 1008 deletions(-) delete mode 100644 pkg/config/conditions_test.go rename pkg/{config => registry}/conditions.go (63%) create mode 100644 pkg/registry/conditions_test.go diff --git a/configs/config.yaml.example b/configs/config.yaml.example index c984d408..952978ba 100644 --- a/configs/config.yaml.example +++ b/configs/config.yaml.example @@ -105,6 +105,35 @@ 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 +# +# 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 @@ -115,6 +144,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 +177,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,80 +213,6 @@ entities: plural: wifconfigs spec_schema_name: WifConfigSpec -# Condition Mapping Configuration -# CEL-based declarative mapping of adapter conditions to public API conditions. -# Enables exposing provider-specific adapter conditions (e.g., Landing Zone readiness, -# Validation quota status) in the public status.conditions array without code changes. -# -# 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): -# - Cluster: ValidationSuccessful, DnsSuccessful, PullsecretSuccessful, HypershiftSuccessful -# - NodePool: ValidationSuccessful, HypershiftSuccessful -# (Pattern: PascalCase adapter name + "Successful", e.g., "validation" → "ValidationSuccessful") -# -# CEL Context Variables: -# - statuses: array of adapter statuses (statuses with any Unknown condition excluded) -# - Each status has: adapter (string), observed_generation (number), -# conditions (array), data (map, sensitive fields masked) -# - resource: full cluster/nodepool object as map (sensitive fields masked) -# - env: environment variables as map (NOT YET IMPLEMENTED - currently always empty) -# Future: Allow CEL expressions to access runtime config (e.g., env.REGION, env.ENVIRONMENT) -# -# Custom CEL Functions: -# - toJson(value): marshal to JSON string -# - dig(target, "dot.path"): safe nested navigation (supports map keys and array indices, e.g., "statuses.0.conditions.1.type") -# -# Note: Timestamps are automatically set from resource.updated_time (not wall clock) -# to ensure reproducible condition evaluation -# -# Field Length Constraints: -# - type: 128 bytes (validation error if exceeded, prevents startup) -# - reason: 256 bytes (truncated if exceeded, preserving UTF-8 boundaries) -# - message: 2048 bytes (truncated if exceeded, preserving UTF-8 boundaries) -conditions: - clusters: - # Example: Expose Landing Zone namespace readiness - # 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 - - nodepools: - # Example: Expose Validation quota check status - # 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 - # ---------------------------------------------------------------------------- # Configuration Priority (highest to lowest): # 1. Command-line flags (e.g., --server-host=0.0.0.0 --server-port=8000) diff --git a/pkg/config/conditions_test.go b/pkg/config/conditions_test.go deleted file mode 100644 index 990d4743..00000000 --- a/pkg/config/conditions_test.go +++ /dev/null @@ -1,542 +0,0 @@ -package config - -import ( - "strings" - "testing" - - . "github.com/onsi/gomega" - "gopkg.in/yaml.v3" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" -) - -// ============================================================================ -// Tests from conditions_test.go -// ============================================================================ - -func TestConditionsConfig_Validate(t *testing.T) { - t.Parallel() - // Typical entity config with required adapters - entities := []registry.EntityDescriptor{ - { - Kind: "Cluster", - RequiredAdapters: []string{"validation", "dns", "pullsecret", "hypershift"}, - }, - { - Kind: "NodePool", - RequiredAdapters: []string{"validation"}, - }, - } - - tests := []struct { - config *ConditionsConfig - name string - errorMsg string - entities []registry.EntityDescriptor - wantError bool - }{ - { - name: "valid config with single cluster rule", - entities: entities, - config: &ConditionsConfig{ - Clusters: map[string]ConditionMappingRule{ - "QuotaValid": { - When: MappingExpression{ - Expression: `statuses.exists(s, s.adapter == "validation")`, - }, - Output: MappingOutput{ - Status: MappingExpression{ - Expression: `"True"`, - }, - Reason: MappingExpression{ - Expression: `"QuotaOK"`, - }, - Message: MappingExpression{ - Expression: `"Quota is sufficient"`, - }, - }, - }, - }, - NodePools: map[string]ConditionMappingRule{}, - }, - wantError: false, - }, - { - name: "empty config is valid", - entities: entities, - config: &ConditionsConfig{ - Clusters: map[string]ConditionMappingRule{}, - NodePools: map[string]ConditionMappingRule{}, - }, - wantError: false, - }, - { - name: "reserved type Reconciled is rejected", - entities: entities, - config: &ConditionsConfig{ - Clusters: map[string]ConditionMappingRule{ - "Reconciled": { - When: MappingExpression{Expression: `true`}, - Output: MappingOutput{ - Status: MappingExpression{Expression: `"True"`}, - Reason: MappingExpression{Expression: `"OK"`}, - Message: MappingExpression{Expression: `"OK"`}, - }, - }, - }, - NodePools: map[string]ConditionMappingRule{}, - }, - wantError: true, - errorMsg: "reserved", - }, - { - name: "reserved type LastKnownReconciled is rejected", - entities: entities, - config: &ConditionsConfig{ - Clusters: map[string]ConditionMappingRule{}, - NodePools: map[string]ConditionMappingRule{ - "LastKnownReconciled": { - When: MappingExpression{Expression: `true`}, - Output: MappingOutput{ - Status: MappingExpression{Expression: `"True"`}, - Reason: MappingExpression{Expression: `"OK"`}, - Message: MappingExpression{Expression: `"OK"`}, - }, - }, - }, - }, - wantError: true, - errorMsg: "reserved", - }, - { - name: "per-adapter synthesized type ValidationSuccessful is rejected", - entities: entities, // Contains "validation" adapter - config: &ConditionsConfig{ - Clusters: map[string]ConditionMappingRule{ - "ValidationSuccessful": { // Synthesized from "validation" adapter - When: MappingExpression{Expression: `true`}, - Output: MappingOutput{ - Status: MappingExpression{Expression: `"True"`}, - Reason: MappingExpression{Expression: `"OK"`}, - Message: MappingExpression{Expression: `"OK"`}, - }, - }, - }, - NodePools: map[string]ConditionMappingRule{}, - }, - wantError: true, - errorMsg: "reserved", - }, - { - name: "per-adapter synthesized type DnsSuccessful is rejected", - entities: entities, // Contains "dns" adapter - config: &ConditionsConfig{ - Clusters: map[string]ConditionMappingRule{ - "DnsSuccessful": { // Synthesized from "dns" adapter - When: MappingExpression{Expression: `true`}, - Output: MappingOutput{ - Status: MappingExpression{Expression: `"True"`}, - Reason: MappingExpression{Expression: `"OK"`}, - Message: MappingExpression{Expression: `"OK"`}, - }, - }, - }, - NodePools: map[string]ConditionMappingRule{}, - }, - wantError: true, - errorMsg: "reserved", - }, - { - name: "condition type exceeding max length is rejected", - entities: entities, - config: &ConditionsConfig{ - Clusters: map[string]ConditionMappingRule{ - strings.Repeat("A", 129): { // 129 chars > 128 max - When: MappingExpression{Expression: `true`}, - Output: MappingOutput{ - Status: MappingExpression{Expression: `"True"`}, - Reason: MappingExpression{Expression: `"OK"`}, - Message: MappingExpression{Expression: `"OK"`}, - }, - }, - }, - NodePools: map[string]ConditionMappingRule{}, - }, - wantError: true, - errorMsg: "max length", - }, - { - name: "invalid CEL syntax in when expression", - entities: entities, - config: &ConditionsConfig{ - Clusters: map[string]ConditionMappingRule{ - "Valid": { - When: MappingExpression{ - Expression: `statuses.exists(s, s.adapter == `, // incomplete - }, - Output: MappingOutput{ - Status: MappingExpression{Expression: `"True"`}, - Reason: MappingExpression{Expression: `"OK"`}, - Message: MappingExpression{Expression: `"OK"`}, - }, - }, - }, - NodePools: map[string]ConditionMappingRule{}, - }, - wantError: true, - errorMsg: "invalid CEL expression", - }, - { - name: "invalid CEL syntax in output status", - entities: entities, - config: &ConditionsConfig{ - Clusters: map[string]ConditionMappingRule{ - "Valid": { - When: MappingExpression{Expression: `true`}, - Output: MappingOutput{ - Status: MappingExpression{ - Expression: `statuses[`, // incomplete - }, - Reason: MappingExpression{Expression: `"OK"`}, - Message: MappingExpression{Expression: `"OK"`}, - }, - }, - }, - NodePools: map[string]ConditionMappingRule{}, - }, - wantError: true, - errorMsg: "invalid CEL expression", - }, - { - name: "complex valid CEL expression", - entities: entities, - config: &ConditionsConfig{ - Clusters: map[string]ConditionMappingRule{ - "ComplexCondition": { - When: MappingExpression{ - Expression: `statuses.exists(s, s.adapter == "validation" && ` + - `s.conditions.exists(c, c.type == "QuotaSufficient" && c.status == "True"))`, - }, - Output: MappingOutput{ - Status: MappingExpression{ - Expression: `statuses.filter(s, s.adapter == "validation")[0].` + - `conditions.filter(c, c.type == "QuotaSufficient")[0].status`, - }, - Reason: MappingExpression{ - Expression: `statuses.filter(s, s.adapter == "validation")[0].` + - `conditions.filter(c, c.type == "QuotaSufficient")[0].reason`, - }, - Message: MappingExpression{ - Expression: `"Quota: " + statuses.filter(s, s.adapter == "validation")[0].` + - `conditions.filter(c, c.type == "QuotaSufficient")[0].message`, - }, - }, - }, - }, - NodePools: map[string]ConditionMappingRule{}, - }, - wantError: false, - }, - { - name: "non-boolean when expression passes validation but fails at runtime", - entities: entities, - config: &ConditionsConfig{ - Clusters: map[string]ConditionMappingRule{ - "StringWhen": { - When: MappingExpression{ - Expression: `"true"`, // String, not boolean - CEL validates types at runtime - }, - Output: MappingOutput{ - Status: MappingExpression{Expression: `"True"`}, - Reason: MappingExpression{Expression: `"OK"`}, - Message: MappingExpression{Expression: `"OK"`}, - }, - }, - }, - NodePools: map[string]ConditionMappingRule{}, - }, - wantError: false, // Validation passes - CEL type checking happens at runtime, not compile time - }, - { - name: "nested comprehensions pass validation (cost limit enforced at runtime)", - entities: entities, - config: &ConditionsConfig{ - Clusters: map[string]ConditionMappingRule{ - "Expensive": { - When: MappingExpression{ - // Nested comprehensions - valid syntax, but may be expensive at runtime - // Cost limit of 10000 is enforced during Program creation, not Check - Expression: `statuses.map(s, s.conditions.map(c, c.type + c.status)).size() > 0`, - }, - Output: MappingOutput{ - Status: MappingExpression{Expression: `"True"`}, - Reason: MappingExpression{Expression: `"OK"`}, - Message: MappingExpression{Expression: `"OK"`}, - }, - }, - }, - NodePools: map[string]ConditionMappingRule{}, - }, - wantError: false, // Validation passes - cost limit is compile-time static analysis estimate - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - RegisterTestingT(t) - err := tt.config.Validate(tt.entities) - - if tt.wantError { - Expect(err).To(HaveOccurred(), "expected validation to fail") - Expect(err.Error()).To(ContainSubstring(tt.errorMsg), "error message should contain expected text") - } else { - Expect(err).NotTo(HaveOccurred(), "expected validation to succeed") - } - }) - } -} -func TestConditionsConfig_IsEmpty(t *testing.T) { - t.Parallel() - tests := []struct { - config *ConditionsConfig - name string - expected bool - }{ - { - name: "empty config", - config: &ConditionsConfig{ - Clusters: map[string]ConditionMappingRule{}, - NodePools: map[string]ConditionMappingRule{}, - }, - expected: true, - }, - { - name: "nil maps are empty", - config: &ConditionsConfig{ - Clusters: nil, - NodePools: nil, - }, - expected: true, - }, - { - name: "clusters has rules", - config: &ConditionsConfig{ - Clusters: map[string]ConditionMappingRule{ - "Test": { - When: MappingExpression{Expression: `true`}, - Output: MappingOutput{}, - }, - }, - NodePools: map[string]ConditionMappingRule{}, - }, - expected: false, - }, - { - name: "nodepools has rules", - config: &ConditionsConfig{ - Clusters: map[string]ConditionMappingRule{}, - NodePools: map[string]ConditionMappingRule{ - "Test": { - When: MappingExpression{Expression: `true`}, - Output: MappingOutput{}, - }, - }, - }, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - RegisterTestingT(t) - result := tt.config.IsEmpty() - Expect(result).To(Equal(tt.expected)) - }) - } -} -func TestNewConditionsConfig(t *testing.T) { - t.Parallel() - RegisterTestingT(t) - config := NewConditionsConfig() - - Expect(config).NotTo(BeNil()) - Expect(config.Clusters).NotTo(BeNil()) - Expect(config.NodePools).NotTo(BeNil()) - Expect(config.Clusters).To(BeEmpty()) - Expect(config.NodePools).To(BeEmpty()) - Expect(config.IsEmpty()).To(BeTrue()) -} -func TestConditionsConfig_NilReceiverGuards(t *testing.T) { - t.Parallel() - t.Run("Validate on nil receiver returns nil", func(t *testing.T) { - RegisterTestingT(t) - var c *ConditionsConfig - err := c.Validate(nil) - Expect(err).NotTo(HaveOccurred(), "nil receiver should return nil, not panic") - }) - - t.Run("IsEmpty on nil receiver returns true", func(t *testing.T) { - RegisterTestingT(t) - var c *ConditionsConfig - result := c.IsEmpty() - Expect(result).To(BeTrue(), "nil receiver should be considered empty") - }) -} -func TestConditionsConfig_YAMLNullHandling(t *testing.T) { - t.Parallel() - t.Run("YAML with conditions: null is handled gracefully", func(t *testing.T) { - RegisterTestingT(t) - yamlContent := ` -server: - port: 8000 -conditions: null -` - // Simulate viper unmarshaling - type testConfig struct { - Conditions *ConditionsConfig - Server struct{ Port int } - } - - var cfg testConfig - err := yaml.Unmarshal([]byte(yamlContent), &cfg) - Expect(err).NotTo(HaveOccurred()) - - // Verify Conditions is nil - Expect(cfg.Conditions).To(BeNil()) - - // Verify Validate doesn't panic - Expect(func() { - err := cfg.Conditions.Validate(nil) - Expect(err).NotTo(HaveOccurred()) - }).NotTo(Panic()) - - // Verify IsEmpty doesn't panic - Expect(func() { - empty := cfg.Conditions.IsEmpty() - Expect(empty).To(BeTrue()) - }).NotTo(Panic()) - }) -} - -// ============================================================================ -// Tests from conditions_determinism_test.go -// ============================================================================ - -func TestConditionsConfig_Validate_DeterministicErrors(t *testing.T) { - t.Parallel() - t.Run("validation errors are deterministic with multiple invalid rules", func(t *testing.T) { - RegisterTestingT(t) - - // Config with multiple invalid rules - // If sorted: "AInvalid" is checked before "ZInvalid" - // If unsorted: order is non-deterministic - config := &ConditionsConfig{ - Clusters: map[string]ConditionMappingRule{ - "ZInvalid": { - When: MappingExpression{Expression: `invalid syntax`}, - Output: MappingOutput{ - Status: MappingExpression{Expression: `"True"`}, - Reason: MappingExpression{Expression: `"OK"`}, - Message: MappingExpression{Expression: `"OK"`}, - }, - }, - "AInvalid": { - When: MappingExpression{Expression: `another bad`}, - Output: MappingOutput{ - Status: MappingExpression{Expression: `"True"`}, - Reason: MappingExpression{Expression: `"OK"`}, - Message: MappingExpression{Expression: `"OK"`}, - }, - }, - }, - } - - // Run validation multiple times - should always fail on same rule first - for i := 0; i < 10; i++ { - err := config.Validate(nil) - Expect(err).To(HaveOccurred()) - // Should always fail on "AInvalid" first (alphabetically first) - Expect(err.Error()).To(ContainSubstring("clusters.AInvalid")) - } - }) -} - -// ============================================================================ -// Tests from conditions_cel_check_test.go -// ============================================================================ - -func TestValidate_CELCheckCatchesErrors(t *testing.T) { - t.Parallel() - t.Run("undefined function caught by Check", func(t *testing.T) { - RegisterTestingT(t) - - config := &ConditionsConfig{ - Clusters: map[string]ConditionMappingRule{ - "TestCondition": { - When: MappingExpression{ - Expression: "undefinedFunction(statuses)", // No such function - }, - Output: MappingOutput{ - Status: MappingExpression{Expression: "'True'"}, - Reason: MappingExpression{Expression: "'TestReason'"}, - Message: MappingExpression{Expression: "'TestMessage'"}, - }, - }, - }, - } - - err := config.Validate([]registry.EntityDescriptor{}) - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("CEL check failed")) - Expect(err.Error()).To(ContainSubstring("undefinedFunction")) - }) - - t.Run("wrong function arity caught by Check", func(t *testing.T) { - RegisterTestingT(t) - - config := &ConditionsConfig{ - Clusters: map[string]ConditionMappingRule{ - "TestCondition": { - When: MappingExpression{ - // size() expects 1 argument, not 2 - Expression: "size(statuses, 'extra arg')", - }, - Output: MappingOutput{ - Status: MappingExpression{Expression: "'True'"}, - Reason: MappingExpression{Expression: "'TestReason'"}, - Message: MappingExpression{Expression: "'TestMessage'"}, - }, - }, - }, - } - - err := config.Validate([]registry.EntityDescriptor{}) - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("CEL check failed")) - }) - - t.Run("valid CEL expression passes Check", func(t *testing.T) { - RegisterTestingT(t) - - config := &ConditionsConfig{ - Clusters: map[string]ConditionMappingRule{ - "TestCondition": { - When: MappingExpression{ - Expression: "size(statuses) > 0", // Valid - }, - Output: MappingOutput{ - Status: MappingExpression{Expression: "'True'"}, - Reason: MappingExpression{Expression: "'TestReason'"}, - Message: MappingExpression{Expression: "'TestMessage'"}, - }, - }, - }, - } - - err := config.Validate([]registry.EntityDescriptor{}) - - Expect(err).NotTo(HaveOccurred()) - }) -} diff --git a/pkg/config/config.go b/pkg/config/config.go index a08f5d23..e14d6f0a 100755 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -5,24 +5,22 @@ import "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" // ApplicationConfig holds all application configuration // Follows HyperFleet Configuration Standard with validation and structured marshaling type ApplicationConfig struct { - Server *ServerConfig `mapstructure:"server" json:"server" validate:"required"` - Metrics *MetricsConfig `mapstructure:"metrics" json:"metrics" validate:"required"` - Health *HealthConfig `mapstructure:"health" json:"health" validate:"required"` - Database *DatabaseConfig `mapstructure:"database" json:"database" validate:"required"` - Logging *LoggingConfig `mapstructure:"logging" json:"logging" validate:"required"` - Conditions *ConditionsConfig `mapstructure:"conditions" json:"conditions"` - Entities []registry.EntityDescriptor `mapstructure:"entities" json:"entities"` + Server *ServerConfig `mapstructure:"server" json:"server" validate:"required"` + Metrics *MetricsConfig `mapstructure:"metrics" json:"metrics" validate:"required"` + Health *HealthConfig `mapstructure:"health" json:"health" validate:"required"` + Database *DatabaseConfig `mapstructure:"database" json:"database" validate:"required"` + Logging *LoggingConfig `mapstructure:"logging" json:"logging" validate:"required"` + Entities []registry.EntityDescriptor `mapstructure:"entities" json:"entities"` } // NewApplicationConfig returns default ApplicationConfig with all sub-configs initialized // These defaults can be overridden by config file, env vars, or CLI flags func NewApplicationConfig() *ApplicationConfig { return &ApplicationConfig{ - Server: NewServerConfig(), - Metrics: NewMetricsConfig(), - Health: NewHealthConfig(), - Database: NewDatabaseConfig(), - Logging: NewLoggingConfig(), - Conditions: NewConditionsConfig(), + Server: NewServerConfig(), + Metrics: NewMetricsConfig(), + Health: NewHealthConfig(), + Database: NewDatabaseConfig(), + Logging: NewLoggingConfig(), } } diff --git a/pkg/config/loader.go b/pkg/config/loader.go index 3ca8ed59..746336ff 100644 --- a/pkg/config/loader.go +++ b/pkg/config/loader.go @@ -187,9 +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) } - if valErr := config.Conditions.Validate(config.Entities); valErr != nil { - return fmt.Errorf("conditions config validation failed: %w", valErr) - } + // Conditions validation now happens in registry.Validate() after entity descriptors are loaded return nil } diff --git a/pkg/config/conditions.go b/pkg/registry/conditions.go similarity index 63% rename from pkg/config/conditions.go rename to pkg/registry/conditions.go index 958de6a5..0025a422 100644 --- a/pkg/config/conditions.go +++ b/pkg/registry/conditions.go @@ -1,20 +1,18 @@ -package config +package registry import ( "fmt" - "sort" "github.com/google/cel-go/cel" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" "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{ - api.ResourceConditionTypeReconciled: true, - api.ResourceConditionTypeLastKnownReconciled: true, + "Reconciled": true, // api.ResourceConditionTypeReconciled + "LastKnownReconciled": true, // api.ResourceConditionTypeLastKnownReconciled } // Field length constraints @@ -38,69 +36,43 @@ type MappingOutput struct { // 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"` } -// ConditionsConfig holds condition mapping configuration per resource type -// Map key is the output condition type (e.g., "LandingZoneReady") -type ConditionsConfig struct { - Clusters map[string]ConditionMappingRule `mapstructure:"clusters" json:"clusters"` - NodePools map[string]ConditionMappingRule `mapstructure:"nodepools" json:"nodepools"` -} - -// NewConditionsConfig returns a default ConditionsConfig with empty maps -func NewConditionsConfig() *ConditionsConfig { - return &ConditionsConfig{ - Clusters: make(map[string]ConditionMappingRule), - NodePools: make(map[string]ConditionMappingRule), - } -} - -// Validate validates the conditions configuration -// Returns error on: -// - Reserved condition types (Reconciled, LastKnownReconciled, per-adapter synthesized types) -// - Invalid CEL expressions (fail-fast at startup) -// - Field length constraint violations +// ValidateEntityConditions validates condition mappings for a single entity descriptor +// Used by registry.Validate() to check conditions inline in entity descriptors // -// entities: entity descriptors from ApplicationConfig.Entities - used to compute -// per-adapter synthesized condition types (e.g., ValidationSuccessful from "validation" adapter) -func (c *ConditionsConfig) Validate(entities []registry.EntityDescriptor) error { - // Nil receiver guard - YAML can set `conditions: null` - if c == nil { +// 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 the complete set of reserved types: static + per-adapter synthesized + // Build reserved types for this specific entity reserved := buildReservedConditionTypes(entities) - // Create CEL environment once and reuse for all validations - // This matches the pattern in NewConditionMapper and avoids recreating the environment per-expression + // Create CEL environment once env, err := util.NewConditionMappingEnvironment() if err != nil { return fmt.Errorf("failed to create CEL environment for validation: %w", err) } - // Validate cluster mappings (sort keys for deterministic error messages) - clusterKeys := make([]string, 0, len(c.Clusters)) - for condType := range c.Clusters { - clusterKeys = append(clusterKeys, condType) - } - sort.Strings(clusterKeys) - for _, condType := range clusterKeys { - if err := validateConditionMapping("clusters", condType, c.Clusters[condType], reserved, env); err != nil { - return 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 - // Validate nodepool mappings (sort keys for deterministic error messages) - nodepoolKeys := make([]string, 0, len(c.NodePools)) - for condType := range c.NodePools { - nodepoolKeys = append(nodepoolKeys, condType) - } - sort.Strings(nodepoolKeys) - for _, condType := range nodepoolKeys { - if err := validateConditionMapping("nodepools", condType, c.NodePools[condType], reserved, env); err != nil { + if err := validateConditionMapping(descriptor.Kind, rule.Type, rule, reserved, env); err != nil { return err } } @@ -112,7 +84,7 @@ func (c *ConditionsConfig) Validate(entities []registry.EntityDescriptor) error // - Static types: Reconciled, LastKnownReconciled // - Per-adapter synthesized types: computed from required_adapters in all entity descriptors // (e.g., "validation" → "ValidationSuccessful") -func buildReservedConditionTypes(entities []registry.EntityDescriptor) map[string]bool { +func buildReservedConditionTypes(entities []EntityDescriptor) map[string]bool { reserved := make(map[string]bool) // Add static reserved types @@ -146,6 +118,14 @@ func validateConditionMapping( 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( @@ -220,12 +200,3 @@ func validateCELExpression(resourceType, condType, field, expression string, env return nil } - -// IsEmpty returns true if no condition mappings are configured -func (c *ConditionsConfig) IsEmpty() bool { - // Nil receiver guard - treat nil config as empty - if c == nil { - return true - } - return len(c.Clusters) == 0 && len(c.NodePools) == 0 -} diff --git a/pkg/registry/conditions_test.go b/pkg/registry/conditions_test.go new file mode 100644 index 00000000..ae11e9f2 --- /dev/null +++ b/pkg/registry/conditions_test.go @@ -0,0 +1,537 @@ +package registry + +import ( + "strings" + "testing" + + . "github.com/onsi/gomega" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/util" +) + +func TestValidateConditionMapping_EmptyType(t *testing.T) { + g := NewWithT(t) + + env, err := util.NewConditionMappingEnvironment() + g.Expect(err).ToNot(HaveOccurred()) + + rule := ConditionMappingRule{ + Type: "", // Empty type + When: MappingExpression{Expression: "true"}, + Output: MappingOutput{ + Status: MappingExpression{Expression: `"True"`}, + Reason: MappingExpression{Expression: `"Ready"`}, + Message: MappingExpression{Expression: `"All good"`}, + }, + } + + err = validateConditionMapping("Cluster", "", rule, map[string]bool{}, env) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("condition type cannot be empty")) +} + +func TestValidateConditionMapping_ReservedTypes(t *testing.T) { + g := NewWithT(t) + + env, err := util.NewConditionMappingEnvironment() + g.Expect(err).ToNot(HaveOccurred()) + + reserved := map[string]bool{ + "Reconciled": true, + "LastKnownReconciled": true, + "ValidationSuccessful": true, // Per-adapter synthesized + } + + tests := []struct { + name string + condType string + errorContains string + expectError bool + }{ + { + name: "Reconciled is reserved", + condType: "Reconciled", + expectError: true, + errorContains: "reserved and cannot be overridden", + }, + { + name: "LastKnownReconciled is reserved", + condType: "LastKnownReconciled", + expectError: true, + errorContains: "reserved and cannot be overridden", + }, + { + name: "Per-adapter synthesized type is reserved", + condType: "ValidationSuccessful", + expectError: true, + errorContains: "reserved and cannot be overridden", + }, + { + name: "Custom type is allowed", + condType: "CustomReady", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + rule := ConditionMappingRule{ + Type: tt.condType, + When: MappingExpression{Expression: "true"}, + Output: MappingOutput{ + Status: MappingExpression{Expression: `"True"`}, + Reason: MappingExpression{Expression: `"Ready"`}, + Message: MappingExpression{Expression: `"msg"`}, + }, + } + + err := validateConditionMapping("Cluster", tt.condType, rule, reserved, env) + if tt.expectError { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.errorContains)) + } else { + g.Expect(err).ToNot(HaveOccurred()) + } + }) + } +} + +func TestValidateConditionMapping_OversizedType(t *testing.T) { + g := NewWithT(t) + + env, err := util.NewConditionMappingEnvironment() + g.Expect(err).ToNot(HaveOccurred()) + + // Create a type string longer than MaxConditionTypeLength (128) + oversizedType := strings.Repeat("A", MaxConditionTypeLength+1) + + rule := ConditionMappingRule{ + Type: oversizedType, + When: MappingExpression{Expression: "true"}, + Output: MappingOutput{ + Status: MappingExpression{Expression: `"True"`}, + Reason: MappingExpression{Expression: `"Ready"`}, + Message: MappingExpression{Expression: `"msg"`}, + }, + } + + err = validateConditionMapping("Cluster", oversizedType, rule, map[string]bool{}, env) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("exceeds max length")) + g.Expect(err.Error()).To(ContainSubstring("128")) +} + +func TestValidateCELExpression_ValidExpressions(t *testing.T) { + g := NewWithT(t) + + env, err := util.NewConditionMappingEnvironment() + g.Expect(err).ToNot(HaveOccurred()) + + tests := []struct { + name string + expression string + }{ + { + name: "Boolean literal", + expression: "true", + }, + { + name: "String literal", + expression: `"Ready"`, + }, + { + name: "Statuses filter with exists", + expression: `statuses.exists(s, s.adapter == "test" && ` + + `s.conditions.exists(c, c.type == "Available" && c.status == "True"))`, + }, + { + name: "Statuses filter with array access", + expression: `statuses.filter(s, s.adapter == "test")[0].conditions.filter(c, c.type == "Available")[0].status`, + }, + { + name: "String concatenation", + expression: `"Prefix: " + statuses[0].conditions[0].message`, + }, + { + name: "Conditional expression", + expression: `statuses.size() > 0 ? "True" : "False"`, + }, + { + name: "Resource field access", + expression: `resource.name + " is ready"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + err := validateCELExpression("Cluster", "TestCondition", "when", tt.expression, env) + g.Expect(err).ToNot(HaveOccurred()) + }) + } +} + +func TestValidateCELExpression_MalformedExpressions(t *testing.T) { + g := NewWithT(t) + + env, err := util.NewConditionMappingEnvironment() + g.Expect(err).ToNot(HaveOccurred()) + + tests := []struct { + name string + expression string + errorContains string + }{ + { + name: "Syntax error - unclosed brace", + expression: `statuses.filter(s, s.adapter == "test"`, + errorContains: "invalid CEL expression", + }, + { + name: "Syntax error - invalid operator", + expression: `statuses === "test"`, + errorContains: "invalid CEL expression", + }, + { + name: "Undefined function", + expression: `undefinedFunction()`, + errorContains: "CEL check failed", + }, + { + name: "Wrong function arity", + expression: `statuses.exists()`, + errorContains: "CEL check failed", + }, + { + name: "Invalid field access", + expression: `statuses..adapter`, + errorContains: "invalid CEL expression", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + err := validateCELExpression("Cluster", "TestCondition", "when", tt.expression, env) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.errorContains)) + }) + } +} + +func TestValidateEntityConditions_Integration(t *testing.T) { + //nolint:govet // fieldalignment: gofmt ordering conflicts with memory alignment + tests := []struct { + entities []EntityDescriptor + descriptor EntityDescriptor + name string + errorMatch string + expectError bool + }{ + { + name: "Valid single condition", + entities: []EntityDescriptor{ + { + Kind: "Cluster", + Plural: "clusters", + RequiredAdapters: []string{"test-adapter"}, + Conditions: []ConditionMappingRule{ + { + Type: "CustomReady", + When: MappingExpression{Expression: "true"}, + Output: MappingOutput{ + Status: MappingExpression{Expression: `"True"`}, + Reason: MappingExpression{Expression: `"Ready"`}, + Message: MappingExpression{Expression: `"All systems operational"`}, + }, + }, + }, + }, + }, + descriptor: EntityDescriptor{ + Kind: "Cluster", + Plural: "clusters", + RequiredAdapters: []string{"test-adapter"}, + Conditions: []ConditionMappingRule{ + { + Type: "CustomReady", + When: MappingExpression{Expression: "true"}, + Output: MappingOutput{ + Status: MappingExpression{Expression: `"True"`}, + Reason: MappingExpression{Expression: `"Ready"`}, + Message: MappingExpression{Expression: `"All systems operational"`}, + }, + }, + }, + }, + expectError: false, + }, + { + name: "Empty type should fail", + entities: []EntityDescriptor{ + { + Kind: "Cluster", + Plural: "clusters", + Conditions: []ConditionMappingRule{ + { + Type: "", + When: MappingExpression{Expression: "true"}, + Output: MappingOutput{ + Status: MappingExpression{Expression: `"True"`}, + Reason: MappingExpression{Expression: `"Ready"`}, + Message: MappingExpression{Expression: `"msg"`}, + }, + }, + }, + }, + }, + descriptor: EntityDescriptor{ + Kind: "Cluster", + Plural: "clusters", + Conditions: []ConditionMappingRule{ + { + Type: "", + When: MappingExpression{Expression: "true"}, + Output: MappingOutput{ + Status: MappingExpression{Expression: `"True"`}, + Reason: MappingExpression{Expression: `"Ready"`}, + Message: MappingExpression{Expression: `"msg"`}, + }, + }, + }, + }, + expectError: true, + errorMatch: "condition type cannot be empty", + }, + { + name: "Reserved type Reconciled should fail", + entities: []EntityDescriptor{ + { + Kind: "Cluster", + Plural: "clusters", + Conditions: []ConditionMappingRule{ + { + Type: "Reconciled", + When: MappingExpression{Expression: "true"}, + Output: MappingOutput{ + Status: MappingExpression{Expression: `"True"`}, + Reason: MappingExpression{Expression: `"Ready"`}, + Message: MappingExpression{Expression: `"msg"`}, + }, + }, + }, + }, + }, + descriptor: EntityDescriptor{ + Kind: "Cluster", + Plural: "clusters", + Conditions: []ConditionMappingRule{ + { + Type: "Reconciled", + When: MappingExpression{Expression: "true"}, + Output: MappingOutput{ + Status: MappingExpression{Expression: `"True"`}, + Reason: MappingExpression{Expression: `"Ready"`}, + Message: MappingExpression{Expression: `"msg"`}, + }, + }, + }, + }, + expectError: true, + errorMatch: "reserved and cannot be overridden", + }, + { + name: "Per-adapter synthesized type should fail", + entities: []EntityDescriptor{ + { + Kind: "Cluster", + Plural: "clusters", + RequiredAdapters: []string{"validation"}, + Conditions: []ConditionMappingRule{ + { + Type: "ValidationSuccessful", // Synthesized from "validation" adapter + When: MappingExpression{Expression: "true"}, + Output: MappingOutput{ + Status: MappingExpression{Expression: `"True"`}, + Reason: MappingExpression{Expression: `"OK"`}, + Message: MappingExpression{Expression: `"msg"`}, + }, + }, + }, + }, + }, + descriptor: EntityDescriptor{ + Kind: "Cluster", + Plural: "clusters", + RequiredAdapters: []string{"validation"}, + Conditions: []ConditionMappingRule{ + { + Type: "ValidationSuccessful", + When: MappingExpression{Expression: "true"}, + Output: MappingOutput{ + Status: MappingExpression{Expression: `"True"`}, + Reason: MappingExpression{Expression: `"OK"`}, + Message: MappingExpression{Expression: `"msg"`}, + }, + }, + }, + }, + expectError: true, + errorMatch: "reserved and cannot be overridden", + }, + { + name: "Invalid CEL expression should fail", + entities: []EntityDescriptor{ + { + Kind: "Cluster", + Plural: "clusters", + Conditions: []ConditionMappingRule{ + { + Type: "BadCondition", + When: MappingExpression{Expression: "invalid CEL {{"}, + Output: MappingOutput{ + Status: MappingExpression{Expression: `"True"`}, + Reason: MappingExpression{Expression: `"OK"`}, + Message: MappingExpression{Expression: `"msg"`}, + }, + }, + }, + }, + }, + descriptor: EntityDescriptor{ + Kind: "Cluster", + Plural: "clusters", + Conditions: []ConditionMappingRule{ + { + Type: "BadCondition", + When: MappingExpression{Expression: "invalid CEL {{"}, + Output: MappingOutput{ + Status: MappingExpression{Expression: `"True"`}, + Reason: MappingExpression{Expression: `"OK"`}, + Message: MappingExpression{Expression: `"msg"`}, + }, + }, + }, + }, + expectError: true, + errorMatch: "invalid CEL expression", + }, + { + name: "Duplicate condition type should fail", + entities: []EntityDescriptor{ + { + Kind: "Cluster", + Plural: "clusters", + Conditions: []ConditionMappingRule{ + { + Type: "CustomReady", + When: MappingExpression{Expression: "true"}, + Output: MappingOutput{ + Status: MappingExpression{Expression: `"True"`}, + Reason: MappingExpression{Expression: `"OK"`}, + Message: MappingExpression{Expression: `"msg"`}, + }, + }, + { + Type: "CustomReady", // DUPLICATE! + When: MappingExpression{Expression: "false"}, + Output: MappingOutput{ + Status: MappingExpression{Expression: `"False"`}, + Reason: MappingExpression{Expression: `"NotOK"`}, + Message: MappingExpression{Expression: `"msg2"`}, + }, + }, + }, + }, + }, + descriptor: EntityDescriptor{ + Kind: "Cluster", + Plural: "clusters", + Conditions: []ConditionMappingRule{ + { + Type: "CustomReady", + When: MappingExpression{Expression: "true"}, + Output: MappingOutput{ + Status: MappingExpression{Expression: `"True"`}, + Reason: MappingExpression{Expression: `"OK"`}, + Message: MappingExpression{Expression: `"msg"`}, + }, + }, + { + Type: "CustomReady", // DUPLICATE! + When: MappingExpression{Expression: "false"}, + Output: MappingOutput{ + Status: MappingExpression{Expression: `"False"`}, + Reason: MappingExpression{Expression: `"NotOK"`}, + Message: MappingExpression{Expression: `"msg2"`}, + }, + }, + }, + }, + expectError: true, + errorMatch: "defined multiple times", + }, + { + name: "No conditions should pass", + entities: []EntityDescriptor{ + { + Kind: "Cluster", + Plural: "clusters", + }, + }, + descriptor: EntityDescriptor{ + Kind: "Cluster", + Plural: "clusters", + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + err := ValidateEntityConditions(tt.entities, tt.descriptor) + if tt.expectError { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.errorMatch)) + } else { + g.Expect(err).ToNot(HaveOccurred()) + } + }) + } +} + +func TestBuildReservedConditionTypes(t *testing.T) { + entities := []EntityDescriptor{ + { + Kind: "Cluster", + RequiredAdapters: []string{"validation", "landing-zone"}, + }, + { + Kind: "NodePool", + RequiredAdapters: []string{"validation", "compute"}, // "validation" duplicated + }, + } + + reserved := buildReservedConditionTypes(entities) + + g := NewWithT(t) + + // Static reserved types + g.Expect(reserved["Reconciled"]).To(BeTrue()) + g.Expect(reserved["LastKnownReconciled"]).To(BeTrue()) + + // Per-adapter synthesized types + g.Expect(reserved["ValidationSuccessful"]).To(BeTrue()) + g.Expect(reserved["LandingZoneSuccessful"]).To(BeTrue()) + g.Expect(reserved["ComputeSuccessful"]).To(BeTrue()) + + // Should not have duplicates + expectedCount := 2 + 3 // 2 static + 3 unique adapters + g.Expect(len(reserved)).To(Equal(expectedCount)) +} diff --git a/pkg/registry/descriptor.go b/pkg/registry/descriptor.go index 3c5a64c8..9567b129 100644 --- a/pkg/registry/descriptor.go +++ b/pkg/registry/descriptor.go @@ -23,7 +23,17 @@ type ReferenceDescriptor struct { // EntityDescriptor defines everything specific to a HyperFleet entity type. // Descriptors are loaded from the application config YAML at startup via LoadDescriptors. +// +//nolint:govet // fieldalignment: gofmt field ordering conflicts with memory alignment optimization type EntityDescriptor struct { + // adapters that must finalize before hard-delete + RequiredAdapters []string `mapstructure:"required_adapters" json:"required_adapters,omitempty"` + // non-ownership associations to other entity types (HYPERFLEET-1156) + References []ReferenceDescriptor `mapstructure:"references" json:"references,omitempty"` + // CEL-based condition mapping rules for this entity type + // Each rule maps adapter conditions to public API conditions + // See HYPERFLEET-538 for condition mapping design + Conditions []ConditionMappingRule `mapstructure:"conditions" json:"conditions,omitempty"` // discriminator value stored in Resource.Kind Kind string `mapstructure:"kind" json:"kind"` // URL path segment, e.g. "channels" @@ -32,16 +42,12 @@ type EntityDescriptor struct { ParentKind string `mapstructure:"parent_kind" json:"parent_kind,omitempty"` // OpenAPI component name for spec validation SpecSchemaName string `mapstructure:"spec_schema_name" json:"spec_schema_name,omitempty"` - // only meaningful when ParentKind != "" - OnParentDelete OnParentDeletePolicy `mapstructure:"on_parent_delete" json:"on_parent_delete,omitempty"` - // adapters that must finalize before hard-delete - RequiredAdapters []string `mapstructure:"required_adapters" json:"required_adapters,omitempty"` - // non-ownership associations to other entity types (HYPERFLEET-1156) - References []ReferenceDescriptor `mapstructure:"references" json:"references,omitempty"` - // panic at startup if SpecSchemaName missing from spec - RequireSpecSchema bool `mapstructure:"require_spec_schema" json:"require_spec_schema,omitempty"` // minimum name length (0 = no constraint) NameMinLen int `mapstructure:"name_min_len" json:"name_min_len,omitempty"` // maximum name length (0 = no constraint) NameMaxLen int `mapstructure:"name_max_len" json:"name_max_len,omitempty"` + // only meaningful when ParentKind != "" + OnParentDelete OnParentDeletePolicy `mapstructure:"on_parent_delete" json:"on_parent_delete,omitempty"` + // panic at startup if SpecSchemaName missing from spec + RequireSpecSchema bool `mapstructure:"require_spec_schema" json:"require_spec_schema,omitempty"` } diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index b5562242..2b87a216 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -168,6 +168,16 @@ func Validate() { } } + // Validate condition mappings for each entity + entities := All() // snapshot of all descriptors + for _, d := range entities { + if len(d.Conditions) > 0 { + if err := ValidateEntityConditions(entities, d); err != nil { + panic(fmt.Sprintf("entity %q: invalid conditions: %v", d.Kind, err)) + } + } + } + // Detect cycles among required references (Min > 0). // A cycle means two or more kinds mutually require each other, making // Create impossible (each resource needs the other to exist first). diff --git a/pkg/services/condition_mapper.go b/pkg/services/condition_mapper.go index da8ddeaf..66a30c9b 100644 --- a/pkg/services/condition_mapper.go +++ b/pkg/services/condition_mapper.go @@ -16,8 +16,8 @@ import ( "github.com/google/cel-go/common/types/ref" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/util" ) @@ -68,7 +68,17 @@ type ConditionMapper struct { // cachedResourceContext caches the masked resource map to avoid redundant // marshal + MaskSensitiveFields operations across adapter status reports. -// The resource (spec, metadata) only changes on PATCH operations, not status reports. +// +// Cache Correctness Rationale: +// - Resource.spec/metadata only change on PATCH operations, which bump generation +// - Resource.Conditions has json:"-" tag, so it's excluded from json.Marshal() +// - ProcessAdapterStatus() never modifies the Resource object itself +// - UpdatedTime is included in the map BUT doesn't change during status updates +// (only resource_conditions table is modified, not the resource row) +// - Therefore, cache key (resourceID + generation) is sufficient for correctness +// +// Performance Note: Single-entry cache may thrash under high concurrent load +// processing different resources. Consider LRU if profiling shows contention. type cachedResourceContext struct { maskedMap map[string]interface{} resourceID string @@ -95,7 +105,7 @@ type ApplyInput struct { } // NewConditionMapper creates a new condition mapper with pre-compiled rules -func NewConditionMapper(resourceKind string, rules map[string]config.ConditionMappingRule) (*ConditionMapper, error) { +func NewConditionMapper(resourceKind string, rules []registry.ConditionMappingRule) (*ConditionMapper, error) { // Create CEL environment with context variables and custom functions // Uses the same environment as validation to ensure consistency env, err := util.NewConditionMappingEnvironment() @@ -105,12 +115,12 @@ func NewConditionMapper(resourceKind string, rules map[string]config.ConditionMa // Compile all rules at initialization (fail-fast) compiled := make(map[string]*compiledRule, len(rules)) - for condType, rule := range rules { - compiledRule, err := compileRule(env, condType, rule) + for _, rule := range rules { + compiledRule, err := compileRule(env, rule.Type, rule) if err != nil { - return nil, fmt.Errorf("failed to compile rule for condition %s: %w", condType, err) + return nil, fmt.Errorf("failed to compile rule for condition %s: %w", rule.Type, err) } - compiled[condType] = compiledRule + compiled[rule.Type] = compiledRule } // Pre-sort rule names for deterministic ordering (avoids repeated sort in Apply) @@ -237,7 +247,7 @@ func (m *ConditionMapper) validateFieldLengths( messageStr string, ) (string, string, error) { // Validate condition type length - if len(rule.conditionType) > config.MaxConditionTypeLength { + if len(rule.conditionType) > registry.MaxConditionTypeLength { logger.With( ctx, "resource_kind", m.resourceKind, @@ -249,16 +259,16 @@ func (m *ConditionMapper) validateFieldLengths( // Truncate reason if too long (rune-aware to preserve valid UTF-8) validatedReason := reasonStr - if len(reasonStr) > config.MaxConditionReasonLength { - validatedReason = truncateUTF8(reasonStr, config.MaxConditionReasonLength) + if len(reasonStr) > registry.MaxConditionReasonLength { + validatedReason = truncateUTF8(reasonStr, registry.MaxConditionReasonLength) logger.With(ctx, "resource_kind", m.resourceKind, "condition_type", rule.conditionType). Info("Condition reason truncated to max length") } // Truncate message if too long (rune-aware to preserve valid UTF-8) validatedMessage := messageStr - if len(messageStr) > config.MaxConditionMessageLength { - validatedMessage = truncateUTF8(messageStr, config.MaxConditionMessageLength) + if len(messageStr) > registry.MaxConditionMessageLength { + validatedMessage = truncateUTF8(messageStr, registry.MaxConditionMessageLength) logger.With(ctx, "resource_kind", m.resourceKind, "condition_type", rule.conditionType). Info("Condition message truncated to max length") } @@ -343,7 +353,7 @@ func extractResourceGeneration( } // compileRule compiles all CEL expressions in a mapping rule -func compileRule(env *cel.Env, condType string, rule config.ConditionMappingRule) (*compiledRule, error) { +func compileRule(env *cel.Env, condType string, rule registry.ConditionMappingRule) (*compiledRule, error) { // Compile when expression whenPrg, err := compileExpression(env, rule.When.Expression) if err != nil { diff --git a/pkg/services/condition_mapper_test.go b/pkg/services/condition_mapper_test.go index 13be2bc5..c93fc44c 100644 --- a/pkg/services/condition_mapper_test.go +++ b/pkg/services/condition_mapper_test.go @@ -13,7 +13,7 @@ import ( . "github.com/onsi/gomega" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/util" ) @@ -25,15 +25,15 @@ func TestConditionMapper_CELCheckValidation(t *testing.T) { t.Run("undefined function caught at compile time", func(t *testing.T) { RegisterTestingT(t) - rules := map[string]config.ConditionMappingRule{ - "Test": { - When: config.MappingExpression{ + rules := []registry.ConditionMappingRule{ + {Type: "Test", + When: registry.MappingExpression{ Expression: `undefinedFunction(statuses)`, // No such function }, - Output: config.MappingOutput{ - Status: config.MappingExpression{Expression: `"True"`}, - Reason: config.MappingExpression{Expression: `"OK"`}, - Message: config.MappingExpression{Expression: `"OK"`}, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"True"`}, + Reason: registry.MappingExpression{Expression: `"OK"`}, + Message: registry.MappingExpression{Expression: `"OK"`}, }, }, } @@ -49,16 +49,16 @@ func TestConditionMapper_CELCheckValidation(t *testing.T) { t.Run("wrong function arity caught at compile time", func(t *testing.T) { RegisterTestingT(t) - rules := map[string]config.ConditionMappingRule{ - "Test": { - When: config.MappingExpression{ + rules := []registry.ConditionMappingRule{ + {Type: "Test", + When: registry.MappingExpression{ // size() expects 1 argument, not 2 Expression: `size(statuses, 'extra arg')`, }, - Output: config.MappingOutput{ - Status: config.MappingExpression{Expression: `"True"`}, - Reason: config.MappingExpression{Expression: `"OK"`}, - Message: config.MappingExpression{Expression: `"OK"`}, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"True"`}, + Reason: registry.MappingExpression{Expression: `"OK"`}, + Message: registry.MappingExpression{Expression: `"OK"`}, }, }, } @@ -73,15 +73,15 @@ func TestConditionMapper_CELCheckValidation(t *testing.T) { t.Run("valid CEL expression compiles successfully", func(t *testing.T) { RegisterTestingT(t) - rules := map[string]config.ConditionMappingRule{ - "Test": { - When: config.MappingExpression{ + rules := []registry.ConditionMappingRule{ + {Type: "Test", + When: registry.MappingExpression{ Expression: `size(statuses) > 0`, // Valid }, - Output: config.MappingOutput{ - Status: config.MappingExpression{Expression: `"True"`}, - Reason: config.MappingExpression{Expression: `"OK"`}, - Message: config.MappingExpression{Expression: `"OK"`}, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"True"`}, + Reason: registry.MappingExpression{Expression: `"OK"`}, + Message: registry.MappingExpression{Expression: `"OK"`}, }, }, } @@ -164,13 +164,13 @@ func TestConditionMapper_CELCheckValidation(t *testing.T) { messageExpr = tc.badExpr } - rules := map[string]config.ConditionMappingRule{ - "Test": { - When: config.MappingExpression{Expression: whenExpr}, - Output: config.MappingOutput{ - Status: config.MappingExpression{Expression: statusExpr}, - Reason: config.MappingExpression{Expression: reasonExpr}, - Message: config.MappingExpression{Expression: messageExpr}, + rules := []registry.ConditionMappingRule{ + {Type: "Test", + When: registry.MappingExpression{Expression: whenExpr}, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: statusExpr}, + Reason: registry.MappingExpression{Expression: reasonExpr}, + Message: registry.MappingExpression{Expression: messageExpr}, }, }, } @@ -195,14 +195,14 @@ func TestConditionMapper_SensitiveDataMasking(t *testing.T) { RegisterTestingT(t) // Rule that tries to extract adapter data fields - rules := map[string]config.ConditionMappingRule{ - "DataExtract": { - When: config.MappingExpression{Expression: `statuses.exists(s, s.adapter == "test")`}, - Output: config.MappingOutput{ - Status: config.MappingExpression{Expression: `"True"`}, - Reason: config.MappingExpression{Expression: `"OK"`}, + rules := []registry.ConditionMappingRule{ + {Type: "DataExtract", + When: registry.MappingExpression{Expression: `statuses.exists(s, s.adapter == "test")`}, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"True"`}, + Reason: registry.MappingExpression{Expression: `"OK"`}, // Try to extract sensitive field from data - Message: config.MappingExpression{ + Message: registry.MappingExpression{ Expression: `statuses.filter(s, s.adapter == "test")[0].data.adminPassword`, }, }, @@ -244,14 +244,14 @@ func TestConditionMapper_SensitiveDataMasking(t *testing.T) { t.Run("non-sensitive adapter data passes through", func(t *testing.T) { RegisterTestingT(t) - rules := map[string]config.ConditionMappingRule{ - "DataExtract": { - When: config.MappingExpression{Expression: `statuses.exists(s, s.adapter == "test")`}, - Output: config.MappingOutput{ - Status: config.MappingExpression{Expression: `"True"`}, - Reason: config.MappingExpression{Expression: `"OK"`}, + rules := []registry.ConditionMappingRule{ + {Type: "DataExtract", + When: registry.MappingExpression{Expression: `statuses.exists(s, s.adapter == "test")`}, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"True"`}, + Reason: registry.MappingExpression{Expression: `"OK"`}, // Extract non-sensitive field - Message: config.MappingExpression{ + Message: registry.MappingExpression{ Expression: `statuses.filter(s, s.adapter == "test")[0].data.clusterName`, }, }, @@ -292,14 +292,14 @@ func TestConditionMapper_SensitiveDataMasking(t *testing.T) { t.Run("nested sensitive data is masked", func(t *testing.T) { RegisterTestingT(t) - rules := map[string]config.ConditionMappingRule{ - "DataExtract": { - When: config.MappingExpression{Expression: `statuses.exists(s, s.adapter == "test")`}, - Output: config.MappingOutput{ - Status: config.MappingExpression{Expression: `"True"`}, - Reason: config.MappingExpression{Expression: `"OK"`}, + rules := []registry.ConditionMappingRule{ + {Type: "DataExtract", + When: registry.MappingExpression{Expression: `statuses.exists(s, s.adapter == "test")`}, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"True"`}, + Reason: registry.MappingExpression{Expression: `"OK"`}, // Try to extract nested sensitive field - Message: config.MappingExpression{ + Message: registry.MappingExpression{ Expression: `statuses.filter(s, s.adapter == "test")[0].data.serviceAccount.privateKey`, }, }, @@ -344,14 +344,14 @@ func TestConditionMapper_SensitiveDataMasking(t *testing.T) { t.Run("multiple adapters with mixed sensitive and non-sensitive data", func(t *testing.T) { RegisterTestingT(t) - rules := map[string]config.ConditionMappingRule{ - "Summary": { - When: config.MappingExpression{Expression: `size(statuses) > 0`}, - Output: config.MappingOutput{ - Status: config.MappingExpression{Expression: `"True"`}, - Reason: config.MappingExpression{Expression: `"OK"`}, + rules := []registry.ConditionMappingRule{ + {Type: "Summary", + When: registry.MappingExpression{Expression: `size(statuses) > 0`}, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"True"`}, + Reason: registry.MappingExpression{Expression: `"OK"`}, // Build message from multiple adapters - Message: config.MappingExpression{ + Message: registry.MappingExpression{ Expression: `"Cluster: " + statuses[0].data.name + ", Secret: " + statuses[1].data.pullSecret`, }, }, @@ -403,14 +403,14 @@ func TestConditionMapper_SensitiveDataMasking(t *testing.T) { t.Run("arrays with sensitive fields are masked in CEL context", func(t *testing.T) { RegisterTestingT(t) - rules := map[string]config.ConditionMappingRule{ - "UserPassword": { - When: config.MappingExpression{Expression: `statuses.exists(s, s.adapter == "test")`}, - Output: config.MappingOutput{ - Status: config.MappingExpression{Expression: `"True"`}, - Reason: config.MappingExpression{Expression: `"OK"`}, + rules := []registry.ConditionMappingRule{ + {Type: "UserPassword", + When: registry.MappingExpression{Expression: `statuses.exists(s, s.adapter == "test")`}, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"True"`}, + Reason: registry.MappingExpression{Expression: `"OK"`}, // Try to extract password from users array - Message: config.MappingExpression{ + Message: registry.MappingExpression{ Expression: `statuses.filter(s, s.adapter == "test")[0].data.users[0].password`, }, }, @@ -457,14 +457,14 @@ func TestConditionMapper_SensitiveDataMasking(t *testing.T) { t.Run("sensitive resource fields are masked in CEL context (defense-in-depth)", func(t *testing.T) { RegisterTestingT(t) - rules := map[string]config.ConditionMappingRule{ - "ResourceSecret": { - When: config.MappingExpression{Expression: `true`}, - Output: config.MappingOutput{ - Status: config.MappingExpression{Expression: `"True"`}, - Reason: config.MappingExpression{Expression: `"OK"`}, + rules := []registry.ConditionMappingRule{ + {Type: "ResourceSecret", + When: registry.MappingExpression{Expression: `true`}, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"True"`}, + Reason: registry.MappingExpression{Expression: `"OK"`}, // Try to extract sensitive field from resource - Message: config.MappingExpression{ + Message: registry.MappingExpression{ Expression: `"Admin password: " + resource.spec.adminPassword`, }, }, @@ -502,14 +502,14 @@ func TestConditionMapper_SensitiveDataMasking(t *testing.T) { t.Run("nested sensitive fields in resource arrays are masked", func(t *testing.T) { RegisterTestingT(t) - rules := map[string]config.ConditionMappingRule{ - "NodeToken": { - When: config.MappingExpression{Expression: `size(resource.spec.nodes) > 0`}, - Output: config.MappingOutput{ - Status: config.MappingExpression{Expression: `"True"`}, - Reason: config.MappingExpression{Expression: `"OK"`}, + rules := []registry.ConditionMappingRule{ + {Type: "NodeToken", + When: registry.MappingExpression{Expression: `size(resource.spec.nodes) > 0`}, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"True"`}, + Reason: registry.MappingExpression{Expression: `"OK"`}, // Try to extract token from nodes array - Message: config.MappingExpression{ + Message: registry.MappingExpression{ Expression: `"Node token: " + resource.spec.nodes[0].authToken`, }, }, @@ -598,13 +598,13 @@ func TestConditionMapper_TimestampPreservation(t *testing.T) { t.Run("new condition gets refTime for all timestamps", func(t *testing.T) { RegisterTestingT(t) - rules := map[string]config.ConditionMappingRule{ - "TestReady": { - When: config.MappingExpression{Expression: `true`}, - Output: config.MappingOutput{ - Status: config.MappingExpression{Expression: `"True"`}, - Reason: config.MappingExpression{Expression: `"OK"`}, - Message: config.MappingExpression{Expression: `"All good"`}, + rules := []registry.ConditionMappingRule{ + {Type: "TestReady", + When: registry.MappingExpression{Expression: `true`}, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"True"`}, + Reason: registry.MappingExpression{Expression: `"OK"`}, + Message: registry.MappingExpression{Expression: `"All good"`}, }, }, } @@ -633,13 +633,13 @@ func TestConditionMapper_TimestampPreservation(t *testing.T) { t.Run("status unchanged preserves CreatedTime and LastTransitionTime", func(t *testing.T) { RegisterTestingT(t) - rules := map[string]config.ConditionMappingRule{ - "TestReady": { - When: config.MappingExpression{Expression: `true`}, - Output: config.MappingOutput{ - Status: config.MappingExpression{Expression: `"True"`}, - Reason: config.MappingExpression{Expression: `"OK"`}, - Message: config.MappingExpression{Expression: `"All good"`}, + rules := []registry.ConditionMappingRule{ + {Type: "TestReady", + When: registry.MappingExpression{Expression: `true`}, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"True"`}, + Reason: registry.MappingExpression{Expression: `"OK"`}, + Message: registry.MappingExpression{Expression: `"All good"`}, }, }, } @@ -681,13 +681,13 @@ func TestConditionMapper_TimestampPreservation(t *testing.T) { t.Run("status changed updates LastTransitionTime but preserves CreatedTime", func(t *testing.T) { RegisterTestingT(t) - rules := map[string]config.ConditionMappingRule{ - "TestReady": { - When: config.MappingExpression{Expression: `true`}, - Output: config.MappingOutput{ - Status: config.MappingExpression{Expression: `"False"`}, // Changed from True - Reason: config.MappingExpression{Expression: `"NotReady"`}, - Message: config.MappingExpression{Expression: `"Something broke"`}, + rules := []registry.ConditionMappingRule{ + {Type: "TestReady", + When: registry.MappingExpression{Expression: `true`}, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"False"`}, // Changed from True + Reason: registry.MappingExpression{Expression: `"NotReady"`}, + Message: registry.MappingExpression{Expression: `"Something broke"`}, }, }, } @@ -728,21 +728,21 @@ func TestConditionMapper_TimestampPreservation(t *testing.T) { t.Run("multiple conditions preserve timestamps independently", func(t *testing.T) { RegisterTestingT(t) - rules := map[string]config.ConditionMappingRule{ - "ConditionA": { - When: config.MappingExpression{Expression: `true`}, - Output: config.MappingOutput{ - Status: config.MappingExpression{Expression: `"True"`}, - Reason: config.MappingExpression{Expression: `"OK"`}, - Message: config.MappingExpression{Expression: `"A is ready"`}, + rules := []registry.ConditionMappingRule{ + {Type: "ConditionA", + When: registry.MappingExpression{Expression: `true`}, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"True"`}, + Reason: registry.MappingExpression{Expression: `"OK"`}, + Message: registry.MappingExpression{Expression: `"A is ready"`}, }, }, - "ConditionB": { - When: config.MappingExpression{Expression: `true`}, - Output: config.MappingOutput{ - Status: config.MappingExpression{Expression: `"False"`}, // Changed - Reason: config.MappingExpression{Expression: `"NotReady"`}, - Message: config.MappingExpression{Expression: `"B is not ready"`}, + {Type: "ConditionB", + When: registry.MappingExpression{Expression: `true`}, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"False"`}, // Changed + Reason: registry.MappingExpression{Expression: `"NotReady"`}, + Message: registry.MappingExpression{Expression: `"B is not ready"`}, }, }, } @@ -843,6 +843,7 @@ func TestAdapterStatusToMapWithUnknownCheck_LogsJSONErrors(t *testing.T) { Expect(statusMap[celKeyAdapter]).To(Equal("test-adapter")) }) } + func TestResourceToMap_LogsJSONErrors(t *testing.T) { t.Run("unmarshalable resource logs warning", func(t *testing.T) { RegisterTestingT(t) @@ -870,6 +871,7 @@ func TestResourceToMap_LogsJSONErrors(t *testing.T) { Expect(result["name"]).To(Equal("test")) }) } + func TestBuildActivation_NumericTypesConsistency(t *testing.T) { t.Run("observed_generation is float64 for CEL type consistency", func(t *testing.T) { RegisterTestingT(t) @@ -921,45 +923,45 @@ func TestBuildActivation_NumericTypesConsistency(t *testing.T) { // to demonstrate the O(1) map lookup optimization vs O(N) linear scan func BenchmarkConditionMapper_Apply(b *testing.B) { // Create mapper with multiple rules - rules := map[string]config.ConditionMappingRule{ - "Condition1": { - When: config.MappingExpression{Expression: `true`}, - Output: config.MappingOutput{ - Status: config.MappingExpression{Expression: `"True"`}, - Reason: config.MappingExpression{Expression: `"OK"`}, - Message: config.MappingExpression{Expression: `"All good"`}, + rules := []registry.ConditionMappingRule{ + {Type: "Condition1", + When: registry.MappingExpression{Expression: `true`}, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"True"`}, + Reason: registry.MappingExpression{Expression: `"OK"`}, + Message: registry.MappingExpression{Expression: `"All good"`}, }, }, - "Condition2": { - When: config.MappingExpression{Expression: `true`}, - Output: config.MappingOutput{ - Status: config.MappingExpression{Expression: `"True"`}, - Reason: config.MappingExpression{Expression: `"OK"`}, - Message: config.MappingExpression{Expression: `"All good"`}, + {Type: "Condition2", + When: registry.MappingExpression{Expression: `true`}, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"True"`}, + Reason: registry.MappingExpression{Expression: `"OK"`}, + Message: registry.MappingExpression{Expression: `"All good"`}, }, }, - "Condition3": { - When: config.MappingExpression{Expression: `true`}, - Output: config.MappingOutput{ - Status: config.MappingExpression{Expression: `"True"`}, - Reason: config.MappingExpression{Expression: `"OK"`}, - Message: config.MappingExpression{Expression: `"All good"`}, + {Type: "Condition3", + When: registry.MappingExpression{Expression: `true`}, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"True"`}, + Reason: registry.MappingExpression{Expression: `"OK"`}, + Message: registry.MappingExpression{Expression: `"All good"`}, }, }, - "Condition4": { - When: config.MappingExpression{Expression: `true`}, - Output: config.MappingOutput{ - Status: config.MappingExpression{Expression: `"True"`}, - Reason: config.MappingExpression{Expression: `"OK"`}, - Message: config.MappingExpression{Expression: `"All good"`}, + {Type: "Condition4", + When: registry.MappingExpression{Expression: `true`}, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"True"`}, + Reason: registry.MappingExpression{Expression: `"OK"`}, + Message: registry.MappingExpression{Expression: `"All good"`}, }, }, - "Condition5": { - When: config.MappingExpression{Expression: `true`}, - Output: config.MappingOutput{ - Status: config.MappingExpression{Expression: `"True"`}, - Reason: config.MappingExpression{Expression: `"OK"`}, - Message: config.MappingExpression{Expression: `"All good"`}, + {Type: "Condition5", + When: registry.MappingExpression{Expression: `true`}, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"True"`}, + Reason: registry.MappingExpression{Expression: `"OK"`}, + Message: registry.MappingExpression{Expression: `"All good"`}, }, }, } @@ -1014,17 +1016,18 @@ func BenchmarkConditionMapper_Apply(b *testing.B) { // BenchmarkConditionMapper_PreAllocation benchmarks the effect of pre-allocating the result slice func BenchmarkConditionMapper_PreAllocation(b *testing.B) { - rules := make(map[string]config.ConditionMappingRule) + rules := make([]registry.ConditionMappingRule, 0, 10) for i := 0; i < 10; i++ { name := "Condition" + string(rune('A'+i)) - rules[name] = config.ConditionMappingRule{ - When: config.MappingExpression{Expression: `true`}, - Output: config.MappingOutput{ - Status: config.MappingExpression{Expression: `"True"`}, - Reason: config.MappingExpression{Expression: `"OK"`}, - Message: config.MappingExpression{Expression: `"All good"`}, + rules = append(rules, registry.ConditionMappingRule{ + Type: name, + When: registry.MappingExpression{Expression: `true`}, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"True"`}, + Reason: registry.MappingExpression{Expression: `"OK"`}, + Message: registry.MappingExpression{Expression: `"All good"`}, }, - } + }) } mapper, err := NewConditionMapper("Cluster", rules) @@ -1266,19 +1269,19 @@ func TestConditionMapper_InvalidStatus(t *testing.T) { RegisterTestingT(t) // CEL rule that outputs "Maybe" instead of "True"/"False" - rules := map[string]config.ConditionMappingRule{ - "TestCondition": { - When: config.MappingExpression{ + rules := []registry.ConditionMappingRule{ + {Type: "TestCondition", + When: registry.MappingExpression{ Expression: "true", }, - Output: config.MappingOutput{ - Status: config.MappingExpression{ + Output: registry.MappingOutput{ + Status: registry.MappingExpression{ Expression: `"Maybe"`, // Invalid status }, - Reason: config.MappingExpression{ + Reason: registry.MappingExpression{ Expression: `"test"`, }, - Message: config.MappingExpression{ + Message: registry.MappingExpression{ Expression: `"test message"`, }, }, @@ -1303,19 +1306,19 @@ func TestConditionMapper_NonBooleanWhen(t *testing.T) { RegisterTestingT(t) // CEL rule with when expression that returns string instead of boolean - rules := map[string]config.ConditionMappingRule{ - "TestCondition": { - When: config.MappingExpression{ + rules := []registry.ConditionMappingRule{ + {Type: "TestCondition", + When: registry.MappingExpression{ Expression: `"not a boolean"`, // Should return bool }, - Output: config.MappingOutput{ - Status: config.MappingExpression{ + Output: registry.MappingOutput{ + Status: registry.MappingExpression{ Expression: `"True"`, }, - Reason: config.MappingExpression{ + Reason: registry.MappingExpression{ Expression: `"test"`, }, - Message: config.MappingExpression{ + Message: registry.MappingExpression{ Expression: `"test message"`, }, }, @@ -1340,22 +1343,22 @@ func TestConditionMapper_MessageTruncationThroughPipeline(t *testing.T) { RegisterTestingT(t) // Build a CEL expression that produces a message exceeding MaxConditionMessageLength (2048) - longMsg := strings.Repeat("x", config.MaxConditionMessageLength+100) + longMsg := strings.Repeat("x", registry.MaxConditionMessageLength+100) msgExpr := fmt.Sprintf(`"%s"`, longMsg) - rules := map[string]config.ConditionMappingRule{ - "TestCondition": { - When: config.MappingExpression{ + rules := []registry.ConditionMappingRule{ + {Type: "TestCondition", + When: registry.MappingExpression{ Expression: "true", }, - Output: config.MappingOutput{ - Status: config.MappingExpression{ + Output: registry.MappingOutput{ + Status: registry.MappingExpression{ Expression: `"True"`, }, - Reason: config.MappingExpression{ + Reason: registry.MappingExpression{ Expression: `"TestReason"`, }, - Message: config.MappingExpression{ + Message: registry.MappingExpression{ Expression: msgExpr, }, }, @@ -1373,7 +1376,7 @@ func TestConditionMapper_MessageTruncationThroughPipeline(t *testing.T) { result := mapper.Apply(context.Background(), input) Expect(result).To(HaveLen(1), "should produce a condition even with long message") - Expect(len(*result[0].Message)).To(BeNumerically("<=", config.MaxConditionMessageLength), + Expect(len(*result[0].Message)).To(BeNumerically("<=", registry.MaxConditionMessageLength), "message should be truncated to MaxConditionMessageLength") Expect(*result[0].Reason).To(Equal("TestReason"), "reason should be unchanged") } @@ -1382,19 +1385,19 @@ func TestConditionMapper_OutputExpressionRuntimeError(t *testing.T) { RegisterTestingT(t) // CEL rule where the status expression causes a runtime error (out-of-bounds index) - rules := map[string]config.ConditionMappingRule{ - "TestCondition": { - When: config.MappingExpression{ + rules := []registry.ConditionMappingRule{ + {Type: "TestCondition", + When: registry.MappingExpression{ Expression: "true", }, - Output: config.MappingOutput{ - Status: config.MappingExpression{ + Output: registry.MappingOutput{ + Status: registry.MappingExpression{ Expression: `statuses[99].adapter`, // out of bounds → runtime error }, - Reason: config.MappingExpression{ + Reason: registry.MappingExpression{ Expression: `"reason"`, }, - Message: config.MappingExpression{ + Message: registry.MappingExpression{ Expression: `"message"`, }, }, @@ -1418,19 +1421,19 @@ func TestConditionMapper_OutputExpressionRuntimeError(t *testing.T) { func TestConditionMapper_ConcurrentApply(t *testing.T) { RegisterTestingT(t) - rules := map[string]config.ConditionMappingRule{ - "TestCondition": { - When: config.MappingExpression{ + rules := []registry.ConditionMappingRule{ + {Type: "TestCondition", + When: registry.MappingExpression{ Expression: "true", }, - Output: config.MappingOutput{ - Status: config.MappingExpression{ + Output: registry.MappingOutput{ + Status: registry.MappingExpression{ Expression: `"True"`, }, - Reason: config.MappingExpression{ + Reason: registry.MappingExpression{ Expression: `"test"`, }, - Message: config.MappingExpression{ + Message: registry.MappingExpression{ Expression: `"test message"`, }, }, diff --git a/pkg/services/resource.go b/pkg/services/resource.go index c8780c84..87b71f19 100644 --- a/pkg/services/resource.go +++ b/pkg/services/resource.go @@ -8,7 +8,6 @@ import ( "time" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" @@ -40,37 +39,22 @@ func NewResourceService( adapterStatusDao dao.AdapterStatusDao, resourceConditionDao dao.ResourceConditionDao, generic GenericService, - conditionsConfig *config.ConditionsConfig, ) ResourceService { - // Create condition mappers from config (nil if config is empty or nil) - // Using a map indexed by Kind simplifies runtime lookup, but adding new entity kinds - // requires code changes: new field in ConditionsConfig (pkg/config/conditions.go) - // and new if-block here to instantiate the mapper for that kind. + // Create condition mappers from entity descriptors + // Iterate all registered entities and build mappers from inline conditions conditionMappers := make(map[string]*ConditionMapper) - if conditionsConfig != nil && !conditionsConfig.IsEmpty() { - if len(conditionsConfig.Clusters) > 0 { - mapper, err := NewConditionMapper(kindCluster, conditionsConfig.Clusters) + for _, descriptor := range registry.All() { + if len(descriptor.Conditions) > 0 { + mapper, err := NewConditionMapper(descriptor.Kind, descriptor.Conditions) if err != nil { // This should not happen since config was validated at startup - indicates a code bug // (e.g., validation logic vs. mapper logic mismatch). Use Error level to alert ops team, // but continue in degraded mode (no CEL mapping) to keep service available per HYG-02. - logger.With(context.Background(), "resource_kind", kindCluster). + logger.With(context.Background(), "resource_kind", descriptor.Kind). WithError(err). Error("Failed to create condition mapper, continuing without CEL mapping") } else { - conditionMappers[kindCluster] = mapper - } - } - if len(conditionsConfig.NodePools) > 0 { - mapper, err := NewConditionMapper(kindNodePool, conditionsConfig.NodePools) - if err != nil { - // This should not happen since config was validated at startup - indicates a code bug. - // Use Error level to alert ops team, but continue in degraded mode per HYG-02. - logger.With(context.Background(), "resource_kind", kindNodePool). - WithError(err). - Error("Failed to create condition mapper, continuing without CEL mapping") - } else { - conditionMappers[kindNodePool] = mapper + conditionMappers[descriptor.Kind] = mapper } } } @@ -93,7 +77,7 @@ type sqlResourceService struct { adapterStatusDao dao.AdapterStatusDao resourceConditionDao dao.ResourceConditionDao generic GenericService - conditionMappers map[string]*ConditionMapper // Indexed by Kind (e.g., kindCluster, kindNodePool) + conditionMappers map[string]*ConditionMapper // Indexed by Kind (e.g., "Cluster", "NodePool") } // Get returns a single resource by kind and ID. Returns 404 if not found. @@ -613,11 +597,12 @@ func (s *sqlResourceService) ProcessAdapterStatus( // runs inside the GetForUpdate row-level lock, and most adapter reports are duplicates. // Gating on actual changes reduces CPU waste and lock hold time (CWE-400 mitigation). hasMapper := s.conditionMappers[resource.Kind] != nil - statusChanged := existingStatus == nil || // First report - !jsonEqual(existingStatus.Conditions, adapterStatus.Conditions) || - !jsonEqual(existingStatus.Data, adapterStatus.Data) - if triggerAggregation || (hasMapper && statusChanged) { + // Inline statusChanged computation so jsonEqual is skipped when hasMapper=false, + // avoiding unnecessary JSON marshaling for entities without condition mappings. + if triggerAggregation || (hasMapper && (existingStatus == nil || + !jsonEqual(existingStatus.Conditions, adapterStatus.Conditions) || + !jsonEqual(existingStatus.Data, adapterStatus.Data))) { if aggregateErr := s.recomputeAndSaveResourceConditions( ctx, resource, updatedStatuses, ); aggregateErr != nil { diff --git a/pkg/services/resource_test.go b/pkg/services/resource_test.go index 56eb138f..39264c4a 100644 --- a/pkg/services/resource_test.go +++ b/pkg/services/resource_test.go @@ -13,7 +13,6 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/auth" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" @@ -281,7 +280,7 @@ var _ dao.ResourceConditionDao = &resourceConditionMock{} func newTestResourceService(mockDao *mockResourceDao) (ResourceService, *mockResourceDao, *resourceGenericMock) { generic := &resourceGenericMock{} svc := NewResourceService( - mockDao, newMockResourceLabelDao(), newMockAdapterStatusDao(), newResourceConditionMock(), generic, nil, + mockDao, newMockResourceLabelDao(), newMockAdapterStatusDao(), newResourceConditionMock(), generic, ) return svc, mockDao, generic } @@ -292,7 +291,7 @@ func newTestResourceServiceWithLabelDao( generic := &resourceGenericMock{} labelDao := newMockResourceLabelDao() svc := NewResourceService( - mockDao, labelDao, newMockAdapterStatusDao(), newResourceConditionMock(), generic, nil, + mockDao, labelDao, newMockAdapterStatusDao(), newResourceConditionMock(), generic, ) return svc, mockDao, generic, labelDao } @@ -303,18 +302,17 @@ func newTestResourceServiceWithAdapterStatus( asDao := newMockAdapterStatusDao() rcDao := newResourceConditionMock() generic := &resourceGenericMock{} - svc := NewResourceService(mockDao, newMockResourceLabelDao(), asDao, rcDao, generic, nil) + svc := NewResourceService(mockDao, newMockResourceLabelDao(), asDao, rcDao, generic) return svc, mockDao, asDao, rcDao } func newTestResourceServiceWithConditions( mockDao *mockResourceDao, - conditionsConfig *config.ConditionsConfig, ) (ResourceService, *mockResourceDao, *mockAdapterStatusDao, *resourceConditionMock) { asDao := newMockAdapterStatusDao() rcDao := newResourceConditionMock() generic := &resourceGenericMock{} - svc := NewResourceService(mockDao, newMockResourceLabelDao(), asDao, rcDao, generic, conditionsConfig) + svc := NewResourceService(mockDao, newMockResourceLabelDao(), asDao, rcDao, generic) return svc, mockDao, asDao, rcDao } @@ -3160,36 +3158,36 @@ func TestProcessAdapterStatus_FinalizedTrue_RecomputesConditions_WhenHardDeleteB func TestResourceService_ConditionMapper_IntegrationPath(t *testing.T) { RegisterTestingT(t) registry.Reset() + t.Cleanup(registry.Reset) + // Register entity descriptor with inline conditions registry.Register(registry.EntityDescriptor{ Kind: "Cluster", Plural: "clusters", - }) - - // Create config with CEL condition mapping rule - conditionsConfig := &config.ConditionsConfig{ - Clusters: map[string]config.ConditionMappingRule{ - "CustomReady": { - When: config.MappingExpression{ + Conditions: []registry.ConditionMappingRule{ + { + Type: "CustomReady", + When: registry.MappingExpression{ Expression: `statuses.exists(s, s.adapter == "test-adapter" && ` + `s.conditions.exists(c, c.type == "CustomCondition" && c.status == "True"))`, }, - Output: config.MappingOutput{ - Status: config.MappingExpression{ + Output: registry.MappingOutput{ + Status: registry.MappingExpression{ Expression: `"True"`, }, - Reason: config.MappingExpression{ + Reason: registry.MappingExpression{ Expression: `"CustomOK"`, }, - Message: config.MappingExpression{ + Message: registry.MappingExpression{ Expression: `"Custom condition is ready"`, }, }, }, }, - } + }) mockDao := newMockResourceDao() - svc, _, _, rcDao := newTestResourceServiceWithConditions(mockDao, conditionsConfig) + // Conditions now come from entity descriptor registered above + svc, _, _, rcDao := newTestResourceServiceWithConditions(mockDao) cluster := testResource("Cluster", "cl-1", "test-cluster") cluster.Generation = 1 diff --git a/pkg/util/cel.go b/pkg/util/cel.go index 0e4b9383..8b3ce452 100644 --- a/pkg/util/cel.go +++ b/pkg/util/cel.go @@ -2,6 +2,7 @@ package util import ( "encoding/json" + "errors" "strconv" "strings" @@ -10,6 +11,30 @@ import ( "github.com/google/cel-go/common/types/ref" ) +// limitedWriter is an io.Writer that stops writing after a size limit is exceeded. +// Used by toJSONFunc to bound allocation during JSON encoding. +type limitedWriter struct { + data []byte + limit int + exceeded bool +} + +// Write implements io.Writer. Returns an error when the limit is exceeded. +func (w *limitedWriter) Write(p []byte) (n int, err error) { + if w.exceeded { + return 0, errors.New("size limit exceeded") + } + + // Check if adding this chunk would exceed the limit + if len(w.data)+len(p) > w.limit { + w.exceeded = true + return 0, errors.New("size limit exceeded") + } + + w.data = append(w.data, p...) + return len(p), nil +} + // CELCostLimit is the maximum cost allowed for CEL expression evaluation. // Prevents CPU spikes from misconfigured or pathological expressions. // Used by both runtime evaluation and startup validation. @@ -54,19 +79,33 @@ func NewConditionMappingEnvironment() (*cel.Env, error) { // toJSONFunc implements the toJson() CEL function func toJSONFunc(val ref.Val) ref.Val { v := val.Value() - data, err := json.Marshal(v) - if err != nil { - return types.NewErr("toJson: %v", err) - } // Size guard: prevent unbounded intermediate allocations from large payloads // Limit matches Kubernetes ConfigMap max size (1MB) as a reasonable upper bound const maxJSONSize = 1 * 1024 * 1024 // 1MB - if len(data) > maxJSONSize { - return types.NewErr("toJson: output exceeds 1MB limit (%d bytes)", len(data)) + + // Use json.Encoder with a size-limited writer to bound allocation during encoding + // (not after). This prevents OOM when encoding very large structures. + var buf limitedWriter + buf.limit = maxJSONSize + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) // Match json.Marshal behavior + + if err := enc.Encode(v); err != nil { + if buf.exceeded { + return types.NewErr("toJson: output exceeds 1MB limit") + } + return types.NewErr("toJson: %v", err) + } + + // json.Encoder.Encode appends a trailing newline — trim it to preserve + // current behavior and match json.Marshal output + result := buf.data + if len(result) > 0 && result[len(result)-1] == '\n' { + result = result[:len(result)-1] } - return types.String(string(data)) + return types.String(string(result)) } // digFunc implements the dig() CEL function for safe nested navigation diff --git a/pkg/util/cel_test.go b/pkg/util/cel_test.go index a5f3e062..aa84c685 100644 --- a/pkg/util/cel_test.go +++ b/pkg/util/cel_test.go @@ -14,18 +14,19 @@ import ( func TestNewConditionMappingEnvironment(t *testing.T) { t.Parallel() - RegisterTestingT(t) + g := NewWithT(t) env, err := NewConditionMappingEnvironment() - Expect(err).NotTo(HaveOccurred()) - Expect(env).NotTo(BeNil()) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(env).NotTo(BeNil()) } + func TestDigFunc_MapNavigation(t *testing.T) { t.Parallel() - RegisterTestingT(t) + g := NewWithT(t) env, err := NewConditionMappingEnvironment() - Expect(err).NotTo(HaveOccurred()) + g.Expect(err).NotTo(HaveOccurred()) // Test data: nested map resourceData := map[string]interface{}{ @@ -90,38 +91,39 @@ func TestDigFunc_MapNavigation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - RegisterTestingT(t) + g := NewWithT(t) ast, issues := env.Parse(tt.expression) - Expect(issues).To(BeNil()) + g.Expect(issues).To(BeNil()) // Check step: validates variable names and function signatures match environment declaration - // Matches the 3-step compilation pipeline in compileExpression (condition_mapper.go:348-371) + // Matches the 3-step compilation pipeline in compileExpression (condition_mapper.go:389-412) _, issues = env.Check(ast) - Expect(issues).To(BeNil()) + g.Expect(issues).To(BeNil()) prg, err := env.Program(ast, cel.CostLimit(CELCostLimit)) - Expect(err).NotTo(HaveOccurred()) + g.Expect(err).NotTo(HaveOccurred()) out, _, err := prg.Eval(map[string]interface{}{ CELVarResource: resourceData, }) if tt.wantErr { - Expect(err).To(HaveOccurred()) + g.Expect(err).To(HaveOccurred()) } else { - Expect(err).NotTo(HaveOccurred()) - Expect(out.Value()).To(Equal(tt.want)) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(out.Value()).To(Equal(tt.want)) } }) } } + func TestDigFunc_ArrayNavigation(t *testing.T) { t.Parallel() - RegisterTestingT(t) + g := NewWithT(t) env, err := NewConditionMappingEnvironment() - Expect(err).NotTo(HaveOccurred()) + g.Expect(err).NotTo(HaveOccurred()) // Test data: array at root statusesData := []interface{}{ @@ -178,34 +180,35 @@ func TestDigFunc_ArrayNavigation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - RegisterTestingT(t) + g := NewWithT(t) ast, issues := env.Parse(tt.expression) - Expect(issues).To(BeNil()) + g.Expect(issues).To(BeNil()) // Check step: validates variable names and function signatures match environment declaration - // Matches the 3-step compilation pipeline in compileExpression (condition_mapper.go:348-371) + // Matches the 3-step compilation pipeline in compileExpression (condition_mapper.go:389-412) _, issues = env.Check(ast) - Expect(issues).To(BeNil()) + g.Expect(issues).To(BeNil()) prg, err := env.Program(ast, cel.CostLimit(CELCostLimit)) - Expect(err).NotTo(HaveOccurred()) + g.Expect(err).NotTo(HaveOccurred()) out, _, err := prg.Eval(map[string]interface{}{ CELVarStatuses: statusesData, }) - Expect(err).NotTo(HaveOccurred()) - Expect(out.Value()).To(Equal(tt.want)) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(out.Value()).To(Equal(tt.want)) }) } } + func TestToJsonFunc(t *testing.T) { t.Parallel() - RegisterTestingT(t) + g := NewWithT(t) env, err := NewConditionMappingEnvironment() - Expect(err).NotTo(HaveOccurred()) + g.Expect(err).NotTo(HaveOccurred()) resourceData := map[string]interface{}{ "name": "test", @@ -213,49 +216,52 @@ func TestToJsonFunc(t *testing.T) { } ast, issues := env.Parse(`toJson(resource)`) - Expect(issues).To(BeNil()) + g.Expect(issues).To(BeNil()) // Check step: validates variable names and function signatures match environment declaration - // Matches the 3-step compilation pipeline in compileExpression (condition_mapper.go:348-371) + // Matches the 3-step compilation pipeline in compileExpression (condition_mapper.go:389-412) _, issues = env.Check(ast) - Expect(issues).To(BeNil()) + g.Expect(issues).To(BeNil()) prg, err := env.Program(ast, cel.CostLimit(CELCostLimit)) - Expect(err).NotTo(HaveOccurred()) + g.Expect(err).NotTo(HaveOccurred()) out, _, err := prg.Eval(map[string]interface{}{ CELVarResource: resourceData, }) - Expect(err).NotTo(HaveOccurred()) - Expect(out.Value()).To(Equal(`{"count":42,"name":"test"}`)) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(out.Value()).To(Equal(`{"count":42,"name":"test"}`)) } func TestToJsonFunc_SizeLimit(t *testing.T) { t.Parallel() - RegisterTestingT(t) + g := NewWithT(t) env, err := NewConditionMappingEnvironment() - Expect(err).NotTo(HaveOccurred()) + g.Expect(err).NotTo(HaveOccurred()) + // Create data that would exceed the 1MB limit when JSON-encoded. + // Implementation uses json.Encoder with limitedWriter to stop encoding + // DURING the operation (not after), preventing unbounded allocation. largeData := map[string]interface{}{ "big": strings.Repeat("x", 1024*1024+1), } ast, issues := env.Parse(`toJson(resource)`) - Expect(issues).To(BeNil()) + g.Expect(issues).To(BeNil()) _, issues = env.Check(ast) - Expect(issues).To(BeNil()) + g.Expect(issues).To(BeNil()) prg, err := env.Program(ast, cel.CostLimit(CELCostLimit)) - Expect(err).NotTo(HaveOccurred()) + g.Expect(err).NotTo(HaveOccurred()) _, _, err = prg.Eval(map[string]interface{}{ CELVarResource: largeData, }) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("exceeds 1MB")) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("exceeds 1MB")) } // ============================================================================ @@ -265,11 +271,11 @@ func TestToJsonFunc_SizeLimit(t *testing.T) { func TestCELVariableConstants(t *testing.T) { t.Parallel() t.Run("CEL variable constants prevent name mismatches (QUAL-01)", func(t *testing.T) { - RegisterTestingT(t) + g := NewWithT(t) // Create environment using constants env, err := NewConditionMappingEnvironment() - Expect(err).NotTo(HaveOccurred()) + g.Expect(err).NotTo(HaveOccurred()) // Verify that expressions using the documented variable names compile successfully // This test ensures that if we change a constant, the environment declaration changes too @@ -283,37 +289,37 @@ func TestCELVariableConstants(t *testing.T) { for _, expr := range validExpressions { ast, issues := env.Parse(expr) - Expect(issues).To(BeNil(), "Expression should parse: %s", expr) + g.Expect(issues).To(BeNil(), "Expression should parse: %s", expr) _, issues = env.Check(ast) - Expect(issues).To(BeNil(), "Expression should check: %s", expr) + g.Expect(issues).To(BeNil(), "Expression should check: %s", expr) } }) t.Run("typo in variable name causes compile error", func(t *testing.T) { - RegisterTestingT(t) + g := NewWithT(t) env, err := NewConditionMappingEnvironment() - Expect(err).NotTo(HaveOccurred()) + g.Expect(err).NotTo(HaveOccurred()) // If someone hardcodes "status" instead of "statuses", Check catches it invalidExpr := "size(status) > 0" // Typo: "status" instead of "statuses" ast, issues := env.Parse(invalidExpr) - Expect(issues).To(BeNil(), "Parse should succeed even with undefined variable") + g.Expect(issues).To(BeNil(), "Parse should succeed even with undefined variable") // Check should fail because "status" is not declared _, issues = env.Check(ast) - Expect(issues).NotTo(BeNil(), "Check should fail for undefined variable 'status'") - Expect(issues.Err().Error()).To(ContainSubstring("status"), "Error should mention the undefined variable") + g.Expect(issues).NotTo(BeNil(), "Check should fail for undefined variable 'status'") + g.Expect(issues.Err().Error()).To(ContainSubstring("status"), "Error should mention the undefined variable") }) t.Run("constant values match expected strings", func(t *testing.T) { - RegisterTestingT(t) + g := NewWithT(t) // Verify constant values are what we expect (prevents accidental changes) - Expect(CELVarStatuses).To(Equal("statuses")) - Expect(CELVarResource).To(Equal("resource")) - Expect(CELVarEnv).To(Equal("env")) + g.Expect(CELVarStatuses).To(Equal("statuses")) + g.Expect(CELVarResource).To(Equal("resource")) + g.Expect(CELVarEnv).To(Equal("env")) }) } diff --git a/pkg/util/mask_sensitive_test.go b/pkg/util/mask_sensitive_test.go index dba3daf4..3c1db7c6 100644 --- a/pkg/util/mask_sensitive_test.go +++ b/pkg/util/mask_sensitive_test.go @@ -282,6 +282,7 @@ func TestMaskSensitiveFields(t *testing.T) { Expect(cell["password"]).To(Equal(RedactedPlaceholder)) }) } + func TestIsSensitiveKey(t *testing.T) { t.Parallel() tests := []struct { diff --git a/plugins/resources/plugin.go b/plugins/resources/plugin.go index fd21f6f7..1e159883 100644 --- a/plugins/resources/plugin.go +++ b/plugins/resources/plugin.go @@ -19,7 +19,6 @@ func NewServiceLocator(env *environments.Env) ServiceLocator { dao.NewAdapterStatusDao(env.Database.SessionFactory), dao.NewResourceConditionDao(env.Database.SessionFactory), generic.Service(&env.Services), - env.Config.Conditions, ) } } From b836d57a2a42f14024f1cfa86ac214d88468c978 Mon Sep 17 00:00:00 2001 From: Leonardo Dorneles Date: Thu, 30 Jul 2026 03:59:57 -0300 Subject: [PATCH 11/17] HYPERFLEET-538 - fix: enforce CEL error rollback per design doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design doc (condition-mapping-design.md § Error Handling) mandates: "If a CEL expression fails, the entire mapping operation fails and the database transaction is rolled back." Changes: - Apply() signature: ([]api.ResourceCondition, error) instead of []api.ResourceCondition - CEL evaluation errors return error instead of skip-and-continue - resource.go propagates error as GeneralError → triggers rollback - Test: TestProcessAdapterStatus_ConditionMapperError_TriggersRollback Impact: CEL failures trigger 10s retry instead of 30min delay. Co-Authored-By: Claude Sonnet 4.5 --- pkg/services/condition_mapper.go | 22 +++++---- pkg/services/condition_mapper_test.go | 67 +++++++++++++++++---------- pkg/services/resource.go | 7 ++- pkg/services/resource_test.go | 67 +++++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 34 deletions(-) diff --git a/pkg/services/condition_mapper.go b/pkg/services/condition_mapper.go index 66a30c9b..19849fe8 100644 --- a/pkg/services/condition_mapper.go +++ b/pkg/services/condition_mapper.go @@ -137,10 +137,13 @@ func NewConditionMapper(resourceKind string, rules []registry.ConditionMappingRu }, nil } -// Apply evaluates mapping rules and returns mapped conditions -func (m *ConditionMapper) Apply(ctx context.Context, input ApplyInput) []api.ResourceCondition { +// Apply evaluates mapping rules and returns mapped conditions. +// Returns error on CEL evaluation failure to trigger transaction rollback per design doc. +// This ensures timely retry (10s) instead of delayed retry (30min) that would occur +// if partial results were committed without mapped conditions. +func (m *ConditionMapper) Apply(ctx context.Context, input ApplyInput) ([]api.ResourceCondition, error) { if len(m.rules) == 0 { - return nil + return nil, nil } // Build CEL activation context (filtering Unknown conditions happens inside buildActivationWithCache) @@ -165,18 +168,19 @@ func (m *ConditionMapper) Apply(ctx context.Context, input ApplyInput) []api.Res condition, err := m.evaluateRule(ctx, rule, activation, input.RefTime, prevCondition) if err != nil { - // Log error but don't fail the entire aggregation - logger.With(ctx, "resource_kind", m.resourceKind, "condition_type", rule.conditionType). - WithError(err). - Warn("Failed to evaluate condition mapping rule, skipping") - continue + // Return error to trigger transaction rollback (per design doc) + // This ensures adapter status update is retried in 10s instead of 30min + return nil, fmt.Errorf( + "condition mapping failed for %s.%s: %w", + m.resourceKind, rule.conditionType, err, + ) } if condition != nil { mappedConditions = append(mappedConditions, *condition) } } - return mappedConditions + return mappedConditions, nil } // evaluateRule evaluates a single mapping rule diff --git a/pkg/services/condition_mapper_test.go b/pkg/services/condition_mapper_test.go index c93fc44c..8fdb6633 100644 --- a/pkg/services/condition_mapper_test.go +++ b/pkg/services/condition_mapper_test.go @@ -227,11 +227,12 @@ func TestConditionMapper_SensitiveDataMasking(t *testing.T) { }, } - result := mapper.Apply(context.Background(), ApplyInput{ + result, err := mapper.Apply(context.Background(), ApplyInput{ AdapterStatuses: statuses, Resource: map[string]interface{}{}, RefTime: time.Now(), }) + Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) cond := result[0] @@ -276,11 +277,12 @@ func TestConditionMapper_SensitiveDataMasking(t *testing.T) { }, } - result := mapper.Apply(context.Background(), ApplyInput{ + result, err := mapper.Apply(context.Background(), ApplyInput{ AdapterStatuses: statuses, Resource: map[string]interface{}{}, RefTime: time.Now(), }) + Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) cond := result[0] @@ -327,11 +329,12 @@ func TestConditionMapper_SensitiveDataMasking(t *testing.T) { }, } - result := mapper.Apply(context.Background(), ApplyInput{ + result, err := mapper.Apply(context.Background(), ApplyInput{ AdapterStatuses: statuses, Resource: map[string]interface{}{}, RefTime: time.Now(), }) + Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) cond := result[0] @@ -386,11 +389,12 @@ func TestConditionMapper_SensitiveDataMasking(t *testing.T) { }, } - result := mapper.Apply(context.Background(), ApplyInput{ + result, err := mapper.Apply(context.Background(), ApplyInput{ AdapterStatuses: statuses, Resource: map[string]interface{}{}, RefTime: time.Now(), }) + Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) cond := result[0] @@ -440,11 +444,12 @@ func TestConditionMapper_SensitiveDataMasking(t *testing.T) { }, } - result := mapper.Apply(context.Background(), ApplyInput{ + result, err := mapper.Apply(context.Background(), ApplyInput{ AdapterStatuses: statuses, Resource: map[string]interface{}{}, RefTime: time.Now(), }) + Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) cond := result[0] @@ -485,11 +490,12 @@ func TestConditionMapper_SensitiveDataMasking(t *testing.T) { }, } - result := mapper.Apply(context.Background(), ApplyInput{ + result, err := mapper.Apply(context.Background(), ApplyInput{ AdapterStatuses: api.AdapterStatusList{}, Resource: resource, RefTime: time.Now(), }) + Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) cond := result[0] @@ -531,11 +537,12 @@ func TestConditionMapper_SensitiveDataMasking(t *testing.T) { }, } - result := mapper.Apply(context.Background(), ApplyInput{ + result, err := mapper.Apply(context.Background(), ApplyInput{ AdapterStatuses: api.AdapterStatusList{}, Resource: resource, RefTime: time.Now(), }) + Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) cond := result[0] @@ -613,12 +620,13 @@ func TestConditionMapper_TimestampPreservation(t *testing.T) { Expect(err).NotTo(HaveOccurred()) refTime := time.Date(2026, 7, 25, 10, 0, 0, 0, time.UTC) - result := mapper.Apply(context.Background(), ApplyInput{ + result, err := mapper.Apply(context.Background(), ApplyInput{ AdapterStatuses: api.AdapterStatusList{}, Resource: map[string]interface{}{}, RefTime: refTime, PrevConditions: nil, // No previous conditions }) + Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) cond := result[0] @@ -661,12 +669,13 @@ func TestConditionMapper_TimestampPreservation(t *testing.T) { // New evaluation at a later time newRefTime := time.Date(2026, 7, 25, 10, 0, 0, 0, time.UTC) - result := mapper.Apply(context.Background(), ApplyInput{ + result, err := mapper.Apply(context.Background(), ApplyInput{ AdapterStatuses: api.AdapterStatusList{}, Resource: map[string]interface{}{}, RefTime: newRefTime, PrevConditions: prevConditions, }) + Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) cond := result[0] @@ -709,12 +718,13 @@ func TestConditionMapper_TimestampPreservation(t *testing.T) { // New evaluation at a later time newRefTime := time.Date(2026, 7, 25, 10, 0, 0, 0, time.UTC) - result := mapper.Apply(context.Background(), ApplyInput{ + result, err := mapper.Apply(context.Background(), ApplyInput{ AdapterStatuses: api.AdapterStatusList{}, Resource: map[string]interface{}{}, RefTime: newRefTime, PrevConditions: prevConditions, }) + Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) cond := result[0] @@ -769,12 +779,13 @@ func TestConditionMapper_TimestampPreservation(t *testing.T) { } newRefTime := time.Date(2026, 7, 25, 10, 0, 0, 0, time.UTC) - result := mapper.Apply(context.Background(), ApplyInput{ + result, err := mapper.Apply(context.Background(), ApplyInput{ AdapterStatuses: api.AdapterStatusList{}, Resource: map[string]interface{}{}, RefTime: newRefTime, PrevConditions: prevConditions, }) + Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) @@ -1008,7 +1019,7 @@ func BenchmarkConditionMapper_Apply(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = mapper.Apply(context.Background(), input) + _, _ = mapper.Apply(context.Background(), input) } }) } @@ -1044,7 +1055,7 @@ func BenchmarkConditionMapper_PreAllocation(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = mapper.Apply(context.Background(), input) + _, _ = mapper.Apply(context.Background(), input) } } @@ -1297,9 +1308,11 @@ func TestConditionMapper_InvalidStatus(t *testing.T) { RefTime: time.Now(), } - // Should not panic, should skip the condition and log error - result := mapper.Apply(context.Background(), input) - Expect(result).To(BeEmpty(), "invalid status should skip condition") + // Should return error (not panic, not skip) to trigger transaction rollback + result, err := mapper.Apply(context.Background(), input) + Expect(err).To(HaveOccurred(), "invalid status should return error") + Expect(err.Error()).To(ContainSubstring("invalid status value")) + Expect(result).To(BeNil(), "result should be nil when error occurs") } func TestConditionMapper_NonBooleanWhen(t *testing.T) { @@ -1334,9 +1347,11 @@ func TestConditionMapper_NonBooleanWhen(t *testing.T) { RefTime: time.Now(), } - // Should not panic, should skip the condition - result := mapper.Apply(context.Background(), input) - Expect(result).To(BeEmpty(), "non-boolean when expression should skip condition") + // Should return error (not panic, not skip) to trigger transaction rollback + result, err := mapper.Apply(context.Background(), input) + Expect(err).To(HaveOccurred(), "non-boolean when expression should return error") + Expect(err.Error()).To(ContainSubstring("did not return boolean")) + Expect(result).To(BeNil(), "result should be nil when error occurs") } func TestConditionMapper_MessageTruncationThroughPipeline(t *testing.T) { @@ -1374,7 +1389,8 @@ func TestConditionMapper_MessageTruncationThroughPipeline(t *testing.T) { RefTime: time.Now(), } - result := mapper.Apply(context.Background(), input) + result, err := mapper.Apply(context.Background(), input) + Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1), "should produce a condition even with long message") Expect(len(*result[0].Message)).To(BeNumerically("<=", registry.MaxConditionMessageLength), "message should be truncated to MaxConditionMessageLength") @@ -1413,9 +1429,11 @@ func TestConditionMapper_OutputExpressionRuntimeError(t *testing.T) { RefTime: time.Now(), } - // Should not panic, should skip the condition and log error - result := mapper.Apply(context.Background(), input) - Expect(result).To(BeEmpty(), "output expression runtime error should skip condition") + // Should return error (not panic, not skip) to trigger transaction rollback + result, err := mapper.Apply(context.Background(), input) + Expect(err).To(HaveOccurred(), "output expression runtime error should return error") + Expect(err.Error()).To(ContainSubstring("status expression evaluation failed")) + Expect(result).To(BeNil(), "result should be nil when error occurs") } func TestConditionMapper_ConcurrentApply(t *testing.T) { @@ -1471,7 +1489,8 @@ func TestConditionMapper_ConcurrentApply(t *testing.T) { } // Collect result, don't assert in goroutine - conditions := mapper.Apply(context.Background(), input) + conditions, err := mapper.Apply(context.Background(), input) + Expect(err).ToNot(HaveOccurred()) results <- result{conditions: conditions} }(i) } diff --git a/pkg/services/resource.go b/pkg/services/resource.go index 87b71f19..fefd3dd7 100644 --- a/pkg/services/resource.go +++ b/pkg/services/resource.go @@ -682,12 +682,17 @@ func (s *sqlResourceService) recomputeAndSaveResourceConditions( // Apply CEL condition mapping if configured if mapper != nil { - mappedConditions := mapper.Apply(ctx, ApplyInput{ + mappedConditions, err := mapper.Apply(ctx, ApplyInput{ AdapterStatuses: adapterStatuses, Resource: resource, RefTime: refTime, PrevConditions: resource.Conditions, // Preserve timestamps from previous conditions }) + if err != nil { + // Mark transaction for rollback - ensures adapter status update is retried + // in 10s instead of 30min delay that would occur with partial commit + return errors.GeneralError("Condition mapping failed: %s", err) + } newConditions = append(newConditions, mappedConditions...) } diff --git a/pkg/services/resource_test.go b/pkg/services/resource_test.go index 39264c4a..c9a59c36 100644 --- a/pkg/services/resource_test.go +++ b/pkg/services/resource_test.go @@ -1780,6 +1780,73 @@ func TestProcessAdapterStatus_HappyPath(t *testing.T) { Expect(rcDao.conditions["r-1"]).ToNot(BeEmpty()) } +func TestProcessAdapterStatus_ConditionMapperError_TriggersRollback(t *testing.T) { + RegisterTestingT(t) + registry.Reset() + t.Cleanup(registry.Reset) + + // Register entity with condition mapping rule that will fail at runtime + // The expression divides by zero, which compiles successfully but fails during evaluation + registry.Register(registry.EntityDescriptor{ + Kind: "Cluster", + Plural: "clusters", + Conditions: []registry.ConditionMappingRule{ + { + Type: "TestCondition", + When: registry.MappingExpression{ + Expression: `true`, // Always evaluates + }, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{ + // Division by zero: compiles but fails at runtime + Expression: `1 / 0 == 0 ? "True" : "False"`, + }, + Reason: registry.MappingExpression{ + Expression: `"TestReason"`, + }, + Message: registry.MappingExpression{ + Expression: `"Test message"`, + }, + }, + }, + }, + }) + + mockDao := newMockResourceDao() + svc, _, _, _ := newTestResourceServiceWithConditions(mockDao) + + cluster := testResource("Cluster", "cl-1", "test-cluster") + cluster.Generation = 1 + mockDao.addResource(cluster) + + req := &api.AdapterStatus{ + Adapter: "test-adapter", + ObservedGeneration: 1, + LastReportTime: time.Now().UTC(), + Conditions: testConditionsJSON( + api.AdapterCondition{Type: api.AdapterConditionTypeAvailable, Status: api.AdapterConditionTrue}, + api.AdapterCondition{Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue}, + api.AdapterCondition{Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue}, + ), + } + + // ProcessAdapterStatus should return error when condition mapper fails + result, svcErr := svc.ProcessAdapterStatus(context.Background(), "Cluster", cluster.ID, req) + + // Verify error is returned (triggers rollback) + Expect(svcErr).ToNot(BeNil(), "should return error when condition mapping fails") + Expect(svcErr.RFC9457Code).To(Equal("HYPERFLEET-INT-001"), "should be GeneralError (internal error)") + Expect(svcErr.Reason).To(ContainSubstring("Condition mapping failed"), "error reason should mention condition mapping") + + Expect(result).To(BeNil(), "result should be nil when error occurs") + + // Note: In a real database transaction, returning an error from the service + // would trigger MarkForRollback() in the DAO layer, causing the transaction to rollback. + // This unit test uses mocks (no real transaction), so the mock DAO still has the data. + // The critical validation is: service returns error → handler marks transaction for rollback. + // For full rollback validation, see integration tests with testcontainers. +} + func TestProcessAdapterStatus_UnknownKind_Returns400(t *testing.T) { RegisterTestingT(t) setupAdapterStatusDescriptors() From a17e0aae74b68e98ddbe301678e62b43c9737d1e Mon Sep 17 00:00:00 2001 From: Leonardo Dorneles Date: Thu, 30 Jul 2026 13:28:32 -0300 Subject: [PATCH 12/17] HYPERFLEET-538 - fix: add missing db.MarkForRollback on CEL error Without MarkForRollback, transaction commits despite error. Aligns with other error paths (lines 234, 265, 339, 932). --- pkg/services/resource.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/services/resource.go b/pkg/services/resource.go index fefd3dd7..08bf6bb7 100644 --- a/pkg/services/resource.go +++ b/pkg/services/resource.go @@ -691,6 +691,7 @@ func (s *sqlResourceService) recomputeAndSaveResourceConditions( if err != nil { // Mark transaction for rollback - ensures adapter status update is retried // in 10s instead of 30min delay that would occur with partial commit + db.MarkForRollback(ctx, fmt.Errorf("condition mapping failed for %s: %w", resource.Kind, err)) return errors.GeneralError("Condition mapping failed: %s", err) } newConditions = append(newConditions, mappedConditions...) From 293a58c66edcc977fd01a1ec8d055d254f69eb60 Mon Sep 17 00:00:00 2001 From: Leonardo Dorneles Date: Thu, 30 Jul 2026 14:41:34 -0300 Subject: [PATCH 13/17] HYPERFLEET-538 - fix: move Gomega assertion out of goroutine Expect(err) inside goroutine calls t.Fatal from non-test goroutine, which panics. Move assertion to main test goroutine instead. Fixes: send err through channel, assert on res.err after receive. --- pkg/services/condition_mapper_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/services/condition_mapper_test.go b/pkg/services/condition_mapper_test.go index 8fdb6633..ea8c833d 100644 --- a/pkg/services/condition_mapper_test.go +++ b/pkg/services/condition_mapper_test.go @@ -1465,6 +1465,7 @@ func TestConditionMapper_ConcurrentApply(t *testing.T) { const numGoroutines = 10 type result struct { conditions []api.ResourceCondition + err error } results := make(chan result, numGoroutines) @@ -1490,14 +1491,14 @@ func TestConditionMapper_ConcurrentApply(t *testing.T) { // Collect result, don't assert in goroutine conditions, err := mapper.Apply(context.Background(), input) - Expect(err).ToNot(HaveOccurred()) - results <- result{conditions: conditions} + results <- result{conditions: conditions, err: err} }(i) } // Wait for all goroutines and assert on main test goroutine for i := 0; i < numGoroutines; i++ { res := <-results + Expect(res.err).ToNot(HaveOccurred()) Expect(res.conditions).To(HaveLen(1)) Expect(res.conditions[0].Type).To(Equal("TestCondition")) } From 5634ae91470d876197d41f5235d2560570868205 Mon Sep 17 00:00:00 2001 From: Leonardo Dorneles Date: Thu, 30 Jul 2026 14:44:28 -0300 Subject: [PATCH 14/17] HYPERFLEET-538 - fix: propagate field validation error instead of silent skip validateFieldLengths error returned (nil, nil) - indistinguishable from when=false skip. Propagate error for consistency with CEL rollback-on-failure design (lines 170-177). Note: conditionType length validated at startup, so this is defense-in-depth (runtime path unreachable in practice). --- pkg/services/condition_mapper.go | 4 ++-- pkg/services/condition_mapper_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/services/condition_mapper.go b/pkg/services/condition_mapper.go index 19849fe8..01ad1c37 100644 --- a/pkg/services/condition_mapper.go +++ b/pkg/services/condition_mapper.go @@ -231,8 +231,8 @@ func (m *ConditionMapper) evaluateRule( // Validate field lengths and truncate message if needed (QUAL-03) validatedReason, validatedMessage, err := m.validateFieldLengths(ctx, rule, reasonStr, messageStr) if err != nil { - // Field length validation failed, skip this condition - return nil, nil + // Propagate error to trigger rollback (consistent with CEL evaluation error handling) + return nil, fmt.Errorf("field validation failed for %s: %w", rule.conditionType, err) } // Build the mapped condition with all required fields (QUAL-03) diff --git a/pkg/services/condition_mapper_test.go b/pkg/services/condition_mapper_test.go index ea8c833d..c5c4c670 100644 --- a/pkg/services/condition_mapper_test.go +++ b/pkg/services/condition_mapper_test.go @@ -1464,8 +1464,8 @@ func TestConditionMapper_ConcurrentApply(t *testing.T) { // Run under -race flag to detect data races const numGoroutines = 10 type result struct { - conditions []api.ResourceCondition err error + conditions []api.ResourceCondition } results := make(chan result, numGoroutines) From 80c2d7a1a8630d4223fb04337c0b6d3e5b05c2b9 Mon Sep 17 00:00:00 2001 From: Leonardo Dorneles Date: Thu, 30 Jul 2026 16:22:39 -0300 Subject: [PATCH 15/17] HYPERFLEET-538 - fix: address code review feedback --- configs/config.yaml.example | 5 ++ pkg/services/condition_mapper_test.go | 70 +++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/configs/config.yaml.example b/configs/config.yaml.example index 952978ba..fc896a58 100644 --- a/configs/config.yaml.example +++ b/configs/config.yaml.example @@ -129,6 +129,11 @@ health: # - 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) diff --git a/pkg/services/condition_mapper_test.go b/pkg/services/condition_mapper_test.go index c5c4c670..ad1522a8 100644 --- a/pkg/services/condition_mapper_test.go +++ b/pkg/services/condition_mapper_test.go @@ -926,6 +926,76 @@ func TestBuildActivation_NumericTypesConsistency(t *testing.T) { }) } +// ============================================================================ +// Tests for Apply() when-expression control flow +// ============================================================================ + +func TestConditionMapper_WhenExpressionSkipsRule(t *testing.T) { + t.Run("when expression returns false skips rule", func(t *testing.T) { + RegisterTestingT(t) + + rules := []registry.ConditionMappingRule{{ + Type: "SkippedCondition", + When: registry.MappingExpression{Expression: `false`}, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"True"`}, + Reason: registry.MappingExpression{Expression: `"OK"`}, + Message: registry.MappingExpression{Expression: `"Should not appear"`}, + }, + }} + + mapper, err := NewConditionMapper("Cluster", rules) + Expect(err).NotTo(HaveOccurred()) + + conditions, err := mapper.Apply(context.Background(), ApplyInput{ + AdapterStatuses: api.AdapterStatusList{}, + Resource: map[string]interface{}{}, + RefTime: time.Now(), + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(conditions).To(BeEmpty(), "when=false should produce no conditions") + }) + + t.Run("mixed when results produce only matching conditions", func(t *testing.T) { + RegisterTestingT(t) + + rules := []registry.ConditionMappingRule{ + { + Type: "AlwaysTrue", + When: registry.MappingExpression{Expression: `true`}, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"True"`}, + Reason: registry.MappingExpression{Expression: `"matched"`}, + Message: registry.MappingExpression{Expression: `"should appear"`}, + }, + }, + { + Type: "AlwaysFalse", + When: registry.MappingExpression{Expression: `false`}, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"True"`}, + Reason: registry.MappingExpression{Expression: `"skipped"`}, + Message: registry.MappingExpression{Expression: `"should not appear"`}, + }, + }, + } + + mapper, err := NewConditionMapper("Cluster", rules) + Expect(err).NotTo(HaveOccurred()) + + conditions, err := mapper.Apply(context.Background(), ApplyInput{ + AdapterStatuses: api.AdapterStatusList{}, + Resource: map[string]interface{}{}, + RefTime: time.Now(), + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(conditions).To(HaveLen(1)) + Expect(conditions[0].Type).To(Equal("AlwaysTrue")) + }) +} + // ============================================================================ // Benchmarks // ============================================================================ From a24c899c4c92908ff62e6234973d3025d63fe087 Mon Sep 17 00:00:00 2001 From: Leonardo Dorneles Date: Thu, 30 Jul 2026 16:56:44 -0300 Subject: [PATCH 16/17] HYPERFLEET-538 - perf: avoid map allocation when hasUnknown=true When hasUnknown=true, adapterStatusToMapWithUnknownCheck allocated a full 4-key map that buildStatusesList immediately discarded. Return nil instead - caller guards with if !hasUnknown so never reads the map. Also adds test coverage for Unknown filtering path (missing coverage for hasUnknown=true code path). --- pkg/services/condition_mapper.go | 11 +-- pkg/services/condition_mapper_test.go | 105 ++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 8 deletions(-) diff --git a/pkg/services/condition_mapper.go b/pkg/services/condition_mapper.go index 01ad1c37..3b74516d 100644 --- a/pkg/services/condition_mapper.go +++ b/pkg/services/condition_mapper.go @@ -594,15 +594,10 @@ func adapterStatusToMapWithUnknownCheck(ctx context.Context, status *api.Adapter // Parse conditions and check for Unknown status (QUAL-03) conditions, hasUnknown := parseConditionsWithUnknownCheck(ctx, status.Conditions, status.Adapter) - // Early return if Unknown found - buildStatusesList will discard this statusMap anyway (PERF-03) - // Skips data parsing and MaskSensitiveFields call to avoid wasted work + // Early return if Unknown found - buildStatusesList discards the map anyway (PERF-03) + // Return nil instead of allocating a throwaway map (saves allocation on hot path) if hasUnknown { - return map[string]interface{}{ - celKeyAdapter: status.Adapter, - celKeyObservedGeneration: float64(status.ObservedGeneration), - celKeyConditions: conditions, - celKeyData: map[string]interface{}{}, - }, true + return nil, true } // Parse data field from JSONB (QUAL-03) diff --git a/pkg/services/condition_mapper_test.go b/pkg/services/condition_mapper_test.go index ad1522a8..540d669e 100644 --- a/pkg/services/condition_mapper_test.go +++ b/pkg/services/condition_mapper_test.go @@ -597,6 +597,111 @@ func TestAdapterStatusToMapWithUnknownCheck_NilGuard(t *testing.T) { }) } +// ============================================================================ +// Tests for Unknown condition filtering +// ============================================================================ + +func TestConditionMapper_UnknownConditionFiltering(t *testing.T) { + t.Run("adapter with Unknown condition excluded from statuses", func(t *testing.T) { + RegisterTestingT(t) + + rules := []registry.ConditionMappingRule{ + { + Type: "TestCondition", + When: registry.MappingExpression{ + // Expression that checks adapter count - should only see healthy-adapter + Expression: `size(statuses) == 1 && statuses[0].adapter == "healthy-adapter"`, + }, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"True"`}, + Reason: registry.MappingExpression{Expression: `"OK"`}, + Message: registry.MappingExpression{Expression: `"Only healthy adapter visible"`}, + }, + }, + } + + mapper, err := NewConditionMapper("Cluster", rules) + Expect(err).NotTo(HaveOccurred()) + + statuses := api.AdapterStatusList{ + { + Adapter: "healthy-adapter", + ObservedGeneration: 1, + Conditions: testConditionsJSON( + api.AdapterCondition{Type: api.AdapterConditionTypeAvailable, Status: api.AdapterConditionTrue}, + ), + Data: []byte(`{}`), + }, + { + Adapter: "unknown-adapter", + ObservedGeneration: 1, + // Adapter with Unknown condition should be filtered out + Conditions: testConditionsJSON( + api.AdapterCondition{Type: api.AdapterConditionTypeAvailable, Status: api.AdapterConditionUnknown}, + ), + Data: []byte(`{}`), + }, + } + + result, err := mapper.Apply(context.Background(), ApplyInput{ + AdapterStatuses: statuses, + Resource: map[string]interface{}{}, + RefTime: time.Now(), + }) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1), "when expression should match (unknown-adapter excluded)") + Expect(result[0].Type).To(Equal("TestCondition")) + Expect(result[0].Status).To(Equal(api.ConditionTrue)) + }) + + t.Run("all adapters with Unknown excluded results in empty statuses array", func(t *testing.T) { + RegisterTestingT(t) + + rules := []registry.ConditionMappingRule{ + { + Type: "TestCondition", + When: registry.MappingExpression{ + Expression: `size(statuses) == 0`, // No adapters should be visible + }, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"True"`}, + Reason: registry.MappingExpression{Expression: `"NoAdapters"`}, + Message: registry.MappingExpression{Expression: `"All adapters filtered"`}, + }, + }, + } + + mapper, err := NewConditionMapper("Cluster", rules) + Expect(err).NotTo(HaveOccurred()) + + statuses := api.AdapterStatusList{ + { + Adapter: "adapter-1", + Conditions: testConditionsJSON( + api.AdapterCondition{Type: api.AdapterConditionTypeAvailable, Status: api.AdapterConditionUnknown}, + ), + }, + { + Adapter: "adapter-2", + Conditions: testConditionsJSON( + api.AdapterCondition{Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionUnknown}, + ), + }, + } + + result, err := mapper.Apply(context.Background(), ApplyInput{ + AdapterStatuses: statuses, + Resource: map[string]interface{}{}, + RefTime: time.Now(), + }) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1), "when expression should match (all adapters excluded)") + Expect(*result[0].Reason).To(Equal("NoAdapters")) + }) +} + // ============================================================================ // Tests from condition_mapper_timestamps_test.go // ============================================================================ From 909ecd9069852380267217ff8a7f33c8bea0a0a3 Mon Sep 17 00:00:00 2001 From: Leonardo Dorneles Date: Thu, 30 Jul 2026 17:12:45 -0300 Subject: [PATCH 17/17] HYPERFLEET-538 - test: add coverage for reason/message expression errors --- pkg/services/condition_mapper_test.go | 128 ++++++++++++++++++++------ 1 file changed, 101 insertions(+), 27 deletions(-) diff --git a/pkg/services/condition_mapper_test.go b/pkg/services/condition_mapper_test.go index 540d669e..a5d19316 100644 --- a/pkg/services/condition_mapper_test.go +++ b/pkg/services/condition_mapper_test.go @@ -1573,42 +1573,116 @@ func TestConditionMapper_MessageTruncationThroughPipeline(t *testing.T) { } func TestConditionMapper_OutputExpressionRuntimeError(t *testing.T) { - RegisterTestingT(t) + t.Run("status expression runtime error returns error", func(t *testing.T) { + RegisterTestingT(t) - // CEL rule where the status expression causes a runtime error (out-of-bounds index) - rules := []registry.ConditionMappingRule{ - {Type: "TestCondition", - When: registry.MappingExpression{ - Expression: "true", + rules := []registry.ConditionMappingRule{ + {Type: "TestCondition", + When: registry.MappingExpression{ + Expression: "true", + }, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{ + Expression: `statuses[99].adapter`, // out of bounds → runtime error + }, + Reason: registry.MappingExpression{ + Expression: `"reason"`, + }, + Message: registry.MappingExpression{ + Expression: `"message"`, + }, + }, }, - Output: registry.MappingOutput{ - Status: registry.MappingExpression{ - Expression: `statuses[99].adapter`, // out of bounds → runtime error + } + + mapper, err := NewConditionMapper("Cluster", rules) + Expect(err).NotTo(HaveOccurred()) + + input := ApplyInput{ + AdapterStatuses: api.AdapterStatusList{}, + Resource: map[string]interface{}{}, + RefTime: time.Now(), + } + + result, err := mapper.Apply(context.Background(), input) + Expect(err).To(HaveOccurred(), "status runtime error should return error") + Expect(err.Error()).To(ContainSubstring("status expression evaluation failed")) + Expect(result).To(BeNil()) + }) + + t.Run("reason expression runtime error returns error", func(t *testing.T) { + RegisterTestingT(t) + + rules := []registry.ConditionMappingRule{ + {Type: "TestCondition", + When: registry.MappingExpression{ + Expression: "true", }, - Reason: registry.MappingExpression{ - Expression: `"reason"`, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{ + Expression: `"True"`, + }, + Reason: registry.MappingExpression{ + Expression: `statuses[99].adapter`, // out of bounds → runtime error + }, + Message: registry.MappingExpression{ + Expression: `"message"`, + }, }, - Message: registry.MappingExpression{ - Expression: `"message"`, + }, + } + + mapper, err := NewConditionMapper("Cluster", rules) + Expect(err).NotTo(HaveOccurred()) + + input := ApplyInput{ + AdapterStatuses: api.AdapterStatusList{}, + Resource: map[string]interface{}{}, + RefTime: time.Now(), + } + + result, err := mapper.Apply(context.Background(), input) + Expect(err).To(HaveOccurred(), "reason runtime error should return error") + Expect(err.Error()).To(ContainSubstring("reason expression evaluation failed")) + Expect(result).To(BeNil()) + }) + + t.Run("message expression runtime error returns error", func(t *testing.T) { + RegisterTestingT(t) + + rules := []registry.ConditionMappingRule{ + {Type: "TestCondition", + When: registry.MappingExpression{ + Expression: "true", + }, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{ + Expression: `"True"`, + }, + Reason: registry.MappingExpression{ + Expression: `"reason"`, + }, + Message: registry.MappingExpression{ + Expression: `statuses[99].adapter`, // out of bounds → runtime error + }, }, }, - }, - } + } - mapper, err := NewConditionMapper("Cluster", rules) - Expect(err).NotTo(HaveOccurred()) + mapper, err := NewConditionMapper("Cluster", rules) + Expect(err).NotTo(HaveOccurred()) - input := ApplyInput{ - AdapterStatuses: api.AdapterStatusList{}, - Resource: map[string]interface{}{}, - RefTime: time.Now(), - } + input := ApplyInput{ + AdapterStatuses: api.AdapterStatusList{}, + Resource: map[string]interface{}{}, + RefTime: time.Now(), + } - // Should return error (not panic, not skip) to trigger transaction rollback - result, err := mapper.Apply(context.Background(), input) - Expect(err).To(HaveOccurred(), "output expression runtime error should return error") - Expect(err.Error()).To(ContainSubstring("status expression evaluation failed")) - Expect(result).To(BeNil(), "result should be nil when error occurs") + result, err := mapper.Apply(context.Background(), input) + Expect(err).To(HaveOccurred(), "message runtime error should return error") + Expect(err.Error()).To(ContainSubstring("message expression evaluation failed")) + Expect(result).To(BeNil()) + }) } func TestConditionMapper_ConcurrentApply(t *testing.T) {