diff --git a/configs/config.yaml.example b/configs/config.yaml.example index 19dd20b8..fc896a58 100644 --- a/configs/config.yaml.example +++ b/configs/config.yaml.example @@ -105,6 +105,40 @@ health: # Entity Registration # Generic resource types registered at startup. Each entry auto-generates # REST endpoints, spec validation, and delete policies. +# +# Condition Mapping (HYPERFLEET-538): +# Each entity can define CEL-based condition mapping rules that expose +# provider-specific adapter conditions in the public status.conditions array. +# +# Rules are compiled at startup (fail-fast). Invalid CEL expressions prevent API startup. +# Evaluation happens during status aggregation. Unknown adapter conditions are filtered. +# +# Reserved condition types (cannot be overridden by mapping): +# - Reconciled +# - LastKnownReconciled +# - Per-adapter synthesized types (auto-generated from required_adapters): +# Example: "validation" adapter → "ValidationSuccessful" condition type +# +# CEL Context Variables: +# - statuses: array of adapter statuses (adapter entries with any Unknown condition are excluded entirely) +# Each status: adapter (string), observed_generation (number), conditions (array), data (map) +# - resource: full cluster/nodepool object as map (sensitive fields masked) +# - env: environment variables as map (currently always empty, future enhancement) +# +# Custom CEL Functions: +# - toJson(value): marshal to JSON string +# - dig(target, "dot.path"): safe nested navigation +# +# Security: Adapter data fields matching sensitive patterns (password, secret, token, +# auth, private, connection, cert, credential, etc.) are automatically masked with +# "***REDACTED***" before CEL evaluation. This prevents credential leakage in public +# condition messages/reasons. See pkg/util/mask_sensitive.go for the full pattern list. +# +# Field Length Constraints: +# - type: 128 bytes (validation error if exceeded, prevents startup) +# - reason: 256 bytes (truncated if exceeded) +# - message: 2048 bytes (truncated if exceeded) +# entities: - kind: Cluster plural: clusters @@ -115,6 +149,28 @@ entities: name_max_len: 53 require_spec_schema: true + # CEL-based condition mapping rules (HYPERFLEET-538) + # Maps adapter conditions to public API conditions + # See inline comments below for detailed documentation + conditions: [] + # Example: Expose Landing Zone namespace readiness + # - type: LandingZoneReady + # when: + # expression: 'statuses.exists(s, s.adapter == "landing-zone-adapter" && s.conditions.exists(c, c.type == "NamespaceReady"))' + # output: + # status: + # expression: | + # statuses.filter(s, s.adapter == "landing-zone-adapter")[0] + # .conditions.filter(c, c.type == "NamespaceReady")[0].status + # reason: + # expression: | + # statuses.filter(s, s.adapter == "landing-zone-adapter")[0] + # .conditions.filter(c, c.type == "NamespaceReady")[0].reason + # message: + # expression: | + # "Landing zone: " + statuses.filter(s, s.adapter == "landing-zone-adapter")[0] + # .conditions.filter(c, c.type == "NamespaceReady")[0].message + - kind: NodePool plural: nodepools parent_kind: Cluster @@ -126,6 +182,26 @@ entities: name_max_len: 15 require_spec_schema: true + # CEL-based condition mapping rules (HYPERFLEET-538) + conditions: [] + # Example: Expose Validation quota check status + # - type: QuotaValid + # when: + # expression: 'statuses.exists(s, s.adapter == "validation-adapter" && s.conditions.exists(c, c.type == "QuotaSufficient"))' + # output: + # status: + # expression: | + # statuses.filter(s, s.adapter == "validation-adapter")[0] + # .conditions.filter(c, c.type == "QuotaSufficient")[0].status + # reason: + # expression: | + # statuses.filter(s, s.adapter == "validation-adapter")[0] + # .conditions.filter(c, c.type == "QuotaSufficient")[0].reason + # message: + # expression: | + # statuses.filter(s, s.adapter == "validation-adapter")[0] + # .conditions.filter(c, c.type == "QuotaSufficient")[0].message + - kind: Channel plural: channels spec_schema_name: ChannelSpec @@ -142,7 +218,6 @@ entities: plural: wifconfigs spec_schema_name: WifConfigSpec - # ---------------------------------------------------------------------------- # Configuration Priority (highest to lowest): # 1. Command-line flags (e.g., --server-host=0.0.0.0 --server-port=8000) diff --git a/go.mod b/go.mod index d6cd20e8..21009f09 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.29.0 github.com/google/uuid v1.6.0 github.com/jinzhu/inflection v1.0.0 github.com/lib/pq v1.12.3 @@ -43,6 +44,8 @@ require ( ) require ( + cel.dev/expr v0.25.1 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect @@ -52,6 +55,7 @@ require ( go.opentelemetry.io/contrib/propagators/jaeger v1.44.0 // indirect go.opentelemetry.io/contrib/propagators/ot v1.44.0 // indirect go.uber.org/multierr v1.11.0 // indirect + golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect golang.org/x/time v0.15.0 // indirect ) @@ -140,6 +144,6 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect google.golang.org/grpc v1.82.0 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + 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..11787cb7 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.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= @@ -103,6 +107,8 @@ github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.29.0 h1:fEG+Ja3YRwNOqnQxTyJwoByAUAvTuxUGiro/jhrm4F4= +github.com/google/cel-go v0.29.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= @@ -299,6 +305,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= diff --git a/pkg/config/loader.go b/pkg/config/loader.go index d7ef7d5d..746336ff 100644 --- a/pkg/config/loader.go +++ b/pkg/config/loader.go @@ -187,6 +187,7 @@ func (l *ConfigLoader) validateConfig(config *ApplicationConfig) error { if valErr := config.Metrics.Validate(); valErr != nil { return fmt.Errorf("metrics config validation failed: %w", valErr) } + // Conditions validation now happens in registry.Validate() after entity descriptors are loaded return nil } diff --git a/pkg/registry/conditions.go b/pkg/registry/conditions.go new file mode 100644 index 00000000..0025a422 --- /dev/null +++ b/pkg/registry/conditions.go @@ -0,0 +1,202 @@ +package registry + +import ( + "fmt" + + "github.com/google/cel-go/cel" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/util" +) + +// Reserved condition types that cannot be overridden by mapping rules +// Using string literals to avoid import cycle with pkg/api +var reservedConditionTypes = map[string]bool{ + "Reconciled": true, // api.ResourceConditionTypeReconciled + "LastKnownReconciled": true, // api.ResourceConditionTypeLastKnownReconciled +} + +// Field length constraints +const ( + MaxConditionTypeLength = 128 + MaxConditionReasonLength = 256 + MaxConditionMessageLength = 2048 +) + +// MappingExpression wraps a CEL expression string +type MappingExpression struct { + Expression string `mapstructure:"expression" json:"expression" validate:"required"` +} + +// MappingOutput defines the output expressions for a mapped condition +type MappingOutput struct { + Status MappingExpression `mapstructure:"status" json:"status" validate:"required"` + Reason MappingExpression `mapstructure:"reason" json:"reason" validate:"required"` + Message MappingExpression `mapstructure:"message" json:"message" validate:"required"` +} + +// ConditionMappingRule defines a single condition mapping rule +type ConditionMappingRule struct { + Type string `mapstructure:"type" json:"type" validate:"required"` + When MappingExpression `mapstructure:"when" json:"when" validate:"required"` + Output MappingOutput `mapstructure:"output" json:"output" validate:"required"` +} + +// ValidateEntityConditions validates condition mappings for a single entity descriptor +// Used by registry.Validate() to check conditions inline in entity descriptors +// +// entities: all registered entity descriptors (needed to compute per-adapter synthesized types) +// descriptor: the specific entity descriptor being validated +func ValidateEntityConditions(entities []EntityDescriptor, descriptor EntityDescriptor) error { + if len(descriptor.Conditions) == 0 { + return nil + } + + // Build reserved types for this specific entity + reserved := buildReservedConditionTypes(entities) + + // Create CEL environment once + env, err := util.NewConditionMappingEnvironment() + if err != nil { + return fmt.Errorf("failed to create CEL environment for validation: %w", err) + } + + // Validate each condition mapping rule and detect duplicates + seen := make(map[string]bool, len(descriptor.Conditions)) + for _, rule := range descriptor.Conditions { + // Check for duplicate types (fail-fast, consistent with CEL validation) + if seen[rule.Type] { + return fmt.Errorf( + "%s condition type '%s' is defined multiple times (each type must be unique)", + descriptor.Kind, rule.Type, + ) + } + seen[rule.Type] = true + + if err := validateConditionMapping(descriptor.Kind, rule.Type, rule, reserved, env); err != nil { + return err + } + } + + return nil +} + +// buildReservedConditionTypes computes the full set of reserved condition types: +// - Static types: Reconciled, LastKnownReconciled +// - Per-adapter synthesized types: computed from required_adapters in all entity descriptors +// (e.g., "validation" → "ValidationSuccessful") +func buildReservedConditionTypes(entities []EntityDescriptor) map[string]bool { + reserved := make(map[string]bool) + + // Add static reserved types + for k, v := range reservedConditionTypes { + reserved[k] = v + } + + // Add per-adapter synthesized types from all entities + seen := make(map[string]bool) + for _, entity := range entities { + for _, adapter := range entity.RequiredAdapters { + // Skip duplicates across entities (e.g., "validation" appears in both Cluster and NodePool) + if seen[adapter] { + continue + } + seen[adapter] = true + + // Compute the synthesized condition type name using shared helper + condType := util.MapAdapterToConditionType(adapter) + reserved[condType] = true + } + } + + return reserved +} + +// validateConditionMapping validates a single mapping rule +func validateConditionMapping( + resourceType, condType string, + rule ConditionMappingRule, + reserved map[string]bool, + env *cel.Env, +) error { + // Check empty type - YAML can have empty string keys + if condType == "" { + return fmt.Errorf( + "%s condition type cannot be empty", + resourceType, + ) + } + + // Check reserved types + if reserved[condType] { + return fmt.Errorf( + "%s condition type '%s' is reserved and cannot be overridden by mapping rules", + resourceType, condType, + ) + } + + // Check condition type length + if len(condType) > MaxConditionTypeLength { + return fmt.Errorf( + "%s condition type '%s' exceeds max length %d (got %d)", + resourceType, condType, MaxConditionTypeLength, len(condType), + ) + } + + // Validate CEL expressions + if err := validateCELExpression(resourceType, condType, "when", rule.When.Expression, env); err != nil { + return err + } + if err := validateCELExpression( + resourceType, condType, "output.status", rule.Output.Status.Expression, env, + ); err != nil { + return err + } + if err := validateCELExpression( + resourceType, condType, "output.reason", rule.Output.Reason.Expression, env, + ); err != nil { + return err + } + if err := validateCELExpression( + resourceType, condType, "output.message", rule.Output.Message.Expression, env, + ); err != nil { + return err + } + + return nil +} + +// validateCELExpression validates a CEL expression by attempting to compile it +// This provides fail-fast validation at startup +func validateCELExpression(resourceType, condType, field, expression string, env *cel.Env) error { + // Parse expression + ast, issues := env.Parse(expression) + if issues != nil && issues.Err() != nil { + return fmt.Errorf( + "%s.%s.%s: invalid CEL expression: %w\nExpression: %s", + resourceType, condType, field, issues.Err(), expression, + ) + } + + // Check for function arity errors and undefined functions + // While DynType limits compile-time type checking, Check still catches + // undefined functions and incorrect argument counts + _, issues = env.Check(ast) + if issues != nil && issues.Err() != nil { + return fmt.Errorf( + "%s.%s.%s: CEL check failed: %w\nExpression: %s", + resourceType, condType, field, issues.Err(), expression, + ) + } + + // Create program with same cost limit as runtime (see util.CELCostLimit and compileExpression) + // This ensures overly expensive expressions are rejected at startup, not runtime + _, err := env.Program(ast, cel.CostLimit(util.CELCostLimit)) + if err != nil { + return fmt.Errorf( + "%s.%s.%s: failed to compile CEL expression: %w\nExpression: %s", + resourceType, condType, field, err, expression, + ) + } + + return nil +} 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/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..3b74516d --- /dev/null +++ b/pkg/services/condition_mapper.go @@ -0,0 +1,689 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "math" + "sort" + "strings" + "sync" + "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/logger" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" + "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 + cachedResource *cachedResourceContext // Cache for masked resource map (PERF-03) + resourceKind string + 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 +// marshal + MaskSensitiveFields operations across adapter 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 + generation int32 +} + +// compiledRule holds pre-compiled CEL programs for a mapping rule +type compiledRule struct { + whenProgram cel.Program + statusProgram cel.Program + reasonProgram cel.Program + messageProgram cel.Program + conditionType string +} + +// 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 []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() + 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 _, 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", rule.Type, err) + } + compiled[rule.Type] = 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. +// 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, nil + } + + // 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) + + // 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 { + // 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, nil +} + +// 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 { + // 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) + 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) > registry.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) > 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) > 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") + } + + 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 + switch { + case strings.EqualFold(statusStr, celBoolTrue): + status = api.ConditionTrue + case strings.EqualFold(statusStr, celBoolFalse): + status = api.ConditionFalse + default: + return nil, fmt.Errorf( + "invalid status value: %s (must be %s or %s)", + statusStr, celBoolTrue, celBoolFalse, + ) + } + + resourceGen := extractResourceGeneration(ctx, activation, m.resourceKind, rule.conditionType) + + // 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 +} + +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 registry.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 +} + +// 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{}, +) map[string]interface{} { + // 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). +// 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{}, +) 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)) + } + + // 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 && + m.cachedResource.generation == r.Generation { + cached := m.cachedResource.maskedMap + m.mu.RUnlock() + return cached + } + m.mu.RUnlock() + + // 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, + generation: r.Generation, + maskedMap: maskedMap, + } + m.mu.Unlock() + + return maskedMap +} + +// buildStatusesList converts adapter statuses to CEL-compatible format, filtering Unknown conditions. +// 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 { + // 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) + } + } + return statusesList +} + +// 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 - buildStatusesList will discard this entire statusMap + break + } + + condMap := map[string]interface{}{ + 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 + // 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 - buildStatusesList discards the map anyway (PERF-03) + // Return nil instead of allocating a throwaway map (saves allocation on hot path) + if hasUnknown { + return nil, 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..a5d19316 --- /dev/null +++ b/pkg/services/condition_mapper_test.go @@ -0,0 +1,1780 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "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/registry" + "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 := []registry.ConditionMappingRule{ + {Type: "Test", + When: registry.MappingExpression{ + Expression: `undefinedFunction(statuses)`, // No such function + }, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"True"`}, + Reason: registry.MappingExpression{Expression: `"OK"`}, + Message: registry.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 := []registry.ConditionMappingRule{ + {Type: "Test", + When: registry.MappingExpression{ + // size() expects 1 argument, not 2 + Expression: `size(statuses, 'extra arg')`, + }, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"True"`}, + Reason: registry.MappingExpression{Expression: `"OK"`}, + Message: registry.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 := []registry.ConditionMappingRule{ + {Type: "Test", + When: registry.MappingExpression{ + Expression: `size(statuses) > 0`, // Valid + }, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `"True"`}, + Reason: registry.MappingExpression{Expression: `"OK"`}, + Message: registry.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 := []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}, + }, + }, + } + + _, 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 := []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: registry.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, 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] + + // 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 := []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: registry.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, 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] + + // 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 := []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: registry.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, 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] + + // 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 := []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: registry.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": "test-secret-value-not-real-auth-json", + } + 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, 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] + + // 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("test-secret")) + }) + + t.Run("arrays with sensitive fields are masked in CEL context", func(t *testing.T) { + RegisterTestingT(t) + + 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: registry.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, 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] + + // 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 := []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: registry.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, 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] + + // 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 := []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: registry.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, 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] + + // 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 := testBuildActivation(t, 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 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 +// ============================================================================ + +func TestConditionMapper_TimestampPreservation(t *testing.T) { + t.Run("new condition gets refTime for all timestamps", func(t *testing.T) { + RegisterTestingT(t) + + 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"`}, + }, + }, + } + + mapper, err := NewConditionMapper("Cluster", rules) + Expect(err).NotTo(HaveOccurred()) + + refTime := time.Date(2026, 7, 25, 10, 0, 0, 0, time.UTC) + 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] + + // 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 := []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"`}, + }, + }, + } + + 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, 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] + + // 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 := []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"`}, + }, + }, + } + + 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, 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] + + // 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 := []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"`}, + }, + }, + {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"`}, + }, + }, + } + + 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, 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)) + + // Find ConditionA and ConditionB + var condA, condB *api.ResourceCondition + for i := range result { + switch result[i].Type { + case "ConditionA": + condA = &result[i] + case "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 := testBuildActivation(t, 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") + }) +} + +// ============================================================================ +// 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 +// ============================================================================ + +// 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 := []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"`}, + }, + }, + {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"`}, + }, + }, + {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"`}, + }, + }, + {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"`}, + }, + }, + {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"`}, + }, + }, + } + + 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([]registry.ConditionMappingRule, 0, 10) + for i := 0; i < 10; i++ { + name := "Condition" + string(rune('A'+i)) + 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) + 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)) + }) +} + +func TestConditionMapper_InvalidStatus(t *testing.T) { + RegisterTestingT(t) + + // CEL rule that outputs "Maybe" instead of "True"/"False" + rules := []registry.ConditionMappingRule{ + {Type: "TestCondition", + When: registry.MappingExpression{ + Expression: "true", + }, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{ + Expression: `"Maybe"`, // Invalid status + }, + Reason: registry.MappingExpression{ + Expression: `"test"`, + }, + Message: registry.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 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) { + RegisterTestingT(t) + + // CEL rule with when expression that returns string instead of boolean + rules := []registry.ConditionMappingRule{ + {Type: "TestCondition", + When: registry.MappingExpression{ + Expression: `"not a boolean"`, // Should return bool + }, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{ + Expression: `"True"`, + }, + Reason: registry.MappingExpression{ + Expression: `"test"`, + }, + Message: registry.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 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) { + RegisterTestingT(t) + + // Build a CEL expression that produces a message exceeding MaxConditionMessageLength (2048) + longMsg := strings.Repeat("x", registry.MaxConditionMessageLength+100) + msgExpr := fmt.Sprintf(`"%s"`, longMsg) + + rules := []registry.ConditionMappingRule{ + {Type: "TestCondition", + When: registry.MappingExpression{ + Expression: "true", + }, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{ + Expression: `"True"`, + }, + Reason: registry.MappingExpression{ + Expression: `"TestReason"`, + }, + Message: registry.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, 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") + Expect(*result[0].Reason).To(Equal("TestReason"), "reason should be unchanged") +} + +func TestConditionMapper_OutputExpressionRuntimeError(t *testing.T) { + t.Run("status 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: `statuses[99].adapter`, // out of bounds → runtime error + }, + Reason: registry.MappingExpression{ + Expression: `"reason"`, + }, + 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(), "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", + }, + 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"`, + }, + }, + }, + } + + 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()) + + input := ApplyInput{ + AdapterStatuses: api.AdapterStatusList{}, + Resource: map[string]interface{}{}, + RefTime: time.Now(), + } + + 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) { + RegisterTestingT(t) + + rules := []registry.ConditionMappingRule{ + {Type: "TestCondition", + When: registry.MappingExpression{ + Expression: "true", + }, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{ + Expression: `"True"`, + }, + Reason: registry.MappingExpression{ + Expression: `"test"`, + }, + Message: registry.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 { + err error + 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(), + } + + // Collect result, don't assert in goroutine + conditions, err := mapper.Apply(context.Background(), input) + 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")) + } +} + +// 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( + 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) + + // 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) + } +} diff --git a/pkg/services/resource.go b/pkg/services/resource.go index 33783391..08bf6bb7 100644 --- a/pkg/services/resource.go +++ b/pkg/services/resource.go @@ -40,12 +40,32 @@ func NewResourceService( resourceConditionDao dao.ResourceConditionDao, generic GenericService, ) ResourceService { + // Create condition mappers from entity descriptors + // Iterate all registered entities and build mappers from inline conditions + conditionMappers := make(map[string]*ConditionMapper) + 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", descriptor.Kind). + WithError(err). + Error("Failed to create condition mapper, continuing without CEL mapping") + } else { + conditionMappers[descriptor.Kind] = mapper + } + } + } + return &sqlResourceService{ resourceDao: resourceDao, resourceLabelDao: resourceLabelDao, adapterStatusDao: adapterStatusDao, resourceConditionDao: resourceConditionDao, generic: generic, + conditionMappers: conditionMappers, } } @@ -57,6 +77,7 @@ type sqlResourceService struct { adapterStatusDao dao.AdapterStatusDao resourceConditionDao dao.ResourceConditionDao generic GenericService + 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. @@ -568,9 +589,20 @@ 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 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 + + // 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 { @@ -638,11 +670,33 @@ 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 != nil { + 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 + 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...) + } + // 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..c9a59c36 100644 --- a/pkg/services/resource_test.go +++ b/pkg/services/resource_test.go @@ -306,6 +306,16 @@ func newTestResourceServiceWithAdapterStatus( return svc, mockDao, asDao, rcDao } +func newTestResourceServiceWithConditions( + mockDao *mockResourceDao, +) (ResourceService, *mockResourceDao, *mockAdapterStatusDao, *resourceConditionMock) { + asDao := newMockAdapterStatusDao() + rcDao := newResourceConditionMock() + generic := &resourceGenericMock{} + svc := NewResourceService(mockDao, newMockResourceLabelDao(), asDao, rcDao, generic) + return svc, mockDao, asDao, rcDao +} + func testResource(kind, id, name string) *api.Resource { spec, _ := json.Marshal(map[string]interface{}{"key": "value"}) r := &api.Resource{ @@ -1770,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() @@ -3142,3 +3219,83 @@ 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() + t.Cleanup(registry.Reset) + // Register entity descriptor with inline conditions + registry.Register(registry.EntityDescriptor{ + Kind: "Cluster", + Plural: "clusters", + 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: registry.MappingOutput{ + Status: registry.MappingExpression{ + Expression: `"True"`, + }, + Reason: registry.MappingExpression{ + Expression: `"CustomOK"`, + }, + Message: registry.MappingExpression{ + Expression: `"Custom condition is ready"`, + }, + }, + }, + }, + }) + + mockDao := newMockResourceDao() + // Conditions now come from entity descriptor registered above + svc, _, _, rcDao := newTestResourceServiceWithConditions(mockDao) + + 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..8b3ce452 --- /dev/null +++ b/pkg/util/cel.go @@ -0,0 +1,153 @@ +package util + +import ( + "encoding/json" + "errors" + "strconv" + "strings" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "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. +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() + + // 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 + + // 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(result)) +} + +// 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..aa84c685 --- /dev/null +++ b/pkg/util/cel_test.go @@ -0,0 +1,325 @@ +package util + +import ( + "strings" + "testing" + + "github.com/google/cel-go/cel" + . "github.com/onsi/gomega" +) + +// ============================================================================ +// Tests from cel_test.go +// ============================================================================ + +func TestNewConditionMappingEnvironment(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + env, err := NewConditionMappingEnvironment() + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(env).NotTo(BeNil()) +} + +func TestDigFunc_MapNavigation(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + env, err := NewConditionMappingEnvironment() + g.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 { + want interface{} + name string + expression string + 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) { + g := NewWithT(t) + + ast, issues := env.Parse(tt.expression) + 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:389-412) + _, issues = env.Check(ast) + g.Expect(issues).To(BeNil()) + + prg, err := env.Program(ast, cel.CostLimit(CELCostLimit)) + g.Expect(err).NotTo(HaveOccurred()) + + out, _, err := prg.Eval(map[string]interface{}{ + CELVarResource: resourceData, + }) + + if tt.wantErr { + g.Expect(err).To(HaveOccurred()) + } else { + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(out.Value()).To(Equal(tt.want)) + } + }) + } +} + +func TestDigFunc_ArrayNavigation(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + env, err := NewConditionMappingEnvironment() + g.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 { + want interface{} + name string + expression string + }{ + { + 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) { + g := NewWithT(t) + + ast, issues := env.Parse(tt.expression) + 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:389-412) + _, issues = env.Check(ast) + g.Expect(issues).To(BeNil()) + + prg, err := env.Program(ast, cel.CostLimit(CELCostLimit)) + g.Expect(err).NotTo(HaveOccurred()) + + out, _, err := prg.Eval(map[string]interface{}{ + CELVarStatuses: statusesData, + }) + + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(out.Value()).To(Equal(tt.want)) + }) + } +} + +func TestToJsonFunc(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + env, err := NewConditionMappingEnvironment() + g.Expect(err).NotTo(HaveOccurred()) + + resourceData := map[string]interface{}{ + "name": "test", + "count": int64(42), + } + + ast, issues := env.Parse(`toJson(resource)`) + 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:389-412) + _, issues = env.Check(ast) + g.Expect(issues).To(BeNil()) + + prg, err := env.Program(ast, cel.CostLimit(CELCostLimit)) + g.Expect(err).NotTo(HaveOccurred()) + + out, _, err := prg.Eval(map[string]interface{}{ + CELVarResource: resourceData, + }) + + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(out.Value()).To(Equal(`{"count":42,"name":"test"}`)) +} + +func TestToJsonFunc_SizeLimit(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + env, err := NewConditionMappingEnvironment() + 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)`) + g.Expect(issues).To(BeNil()) + + _, issues = env.Check(ast) + g.Expect(issues).To(BeNil()) + + prg, err := env.Program(ast, cel.CostLimit(CELCostLimit)) + g.Expect(err).NotTo(HaveOccurred()) + + _, _, err = prg.Eval(map[string]interface{}{ + CELVarResource: largeData, + }) + g.Expect(err).To(HaveOccurred()) + g.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) { + g := NewWithT(t) + + // Create environment using constants + env, err := NewConditionMappingEnvironment() + 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 + 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) + g.Expect(issues).To(BeNil(), "Expression should parse: %s", expr) + + _, issues = env.Check(ast) + g.Expect(issues).To(BeNil(), "Expression should check: %s", expr) + } + }) + + t.Run("typo in variable name causes compile error", func(t *testing.T) { + g := NewWithT(t) + + env, err := NewConditionMappingEnvironment() + 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) + g.Expect(issues).To(BeNil(), "Parse should succeed even with undefined variable") + + // Check should fail because "status" is not declared + _, issues = env.Check(ast) + 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) { + g := NewWithT(t) + + // Verify constant values are what we expect (prevents accidental changes) + 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.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..3c1db7c6 --- /dev/null +++ b/pkg/util/mask_sensitive_test.go @@ -0,0 +1,701 @@ +package util + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +// ============================================================================ +// Core Masking Tests +// ============================================================================ + +func TestMaskSensitiveFields(t *testing.T) { + t.Parallel() + 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) { + t.Parallel() + 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.Parallel() + 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.Parallel() + 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.Parallel() + 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..8ddce841 --- /dev/null +++ b/pkg/util/naming_test.go @@ -0,0 +1,33 @@ +package util + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +func TestMapAdapterToConditionType(t *testing.T) { + t.Parallel() + 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/test/integration/condition_mapping_test.go b/test/integration/condition_mapping_test.go new file mode 100644 index 00000000..7bd6d428 --- /dev/null +++ b/test/integration/condition_mapping_test.go @@ -0,0 +1,338 @@ +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" +) + +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. +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) + + // 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 conditionTypeQuotaValid, "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 conditionTypeQuotaValid, "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 + // 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: "PolicyCheckPassed", + 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 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" { + 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 PolicyCheckPassed adapter condition") +}