diff --git a/cmd/hyperfleet-api/container/services.go b/cmd/hyperfleet-api/container/services.go
index fd652433..e2c202f8 100644
--- a/cmd/hyperfleet-api/container/services.go
+++ b/cmd/hyperfleet-api/container/services.go
@@ -6,13 +6,17 @@ import (
func (c *Container) ResourceService() services.ResourceService {
if c.resourceService == nil {
- c.resourceService = services.NewResourceService(
+ svc, err := services.NewResourceService(
c.ResourceDao(),
c.ResourceLabelDao(),
c.AdapterStatusDao(),
c.ResourceConditionDao(),
c.GenericService(),
)
+ if err != nil {
+ panic("failed to create resource service: " + err.Error())
+ }
+ c.resourceService = svc
}
return c.resourceService
}
diff --git a/configs/config.yaml.example b/configs/config.yaml.example
index 19dd20b8..8ddde292 100644
--- a/configs/config.yaml.example
+++ b/configs/config.yaml.example
@@ -105,6 +105,7 @@ health:
# Entity Registration
# Generic resource types registered at startup. Each entry auto-generates
# REST endpoints, spec validation, and delete policies.
+# See docs/config.md for condition mapping documentation.
entities:
- kind: Cluster
plural: clusters
@@ -115,6 +116,26 @@ entities:
name_max_len: 53
require_spec_schema: true
+ # CEL-based condition mapping rules
+ # 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 +147,26 @@ entities:
name_max_len: 15
require_spec_schema: true
+ # CEL-based condition mapping rules
+ # 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 +183,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/docs/config.md b/docs/config.md
index febe0b64..c7b629fa 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -312,6 +312,79 @@ health:
+
+Condition Mapping (CEL) (click to expand)
+
+Each entity can define CEL-based condition mapping rules that expose
+provider-specific adapter conditions in the public `status.conditions` array.
+
+**Lifecycle:**
+
+- Rules are compiled at startup (fail-fast). Invalid CEL expressions prevent API startup.
+- Evaluation happens during status aggregation. Adapter entries with any `Unknown` condition are excluded entirely.
+
+**Reserved condition types** (cannot be overridden by mapping):
+
+- `Reconciled`
+- `LastKnownReconciled`
+- Per-adapter synthesized types (auto-generated from `required_adapters`).
+ Example: `validation` adapter produces `ValidationSuccessful` condition type.
+
+**CEL Context Variables:**
+
+| Variable | Type | Description |
+|----------|------|-------------|
+| `statuses` | `list(dyn)` | Array of adapter statuses. Each entry: `adapter` (string), `observed_generation` (number), `conditions` (array), `data` (map) |
+| `resource` | `dyn` | Full cluster/nodepool object as map (sensitive fields masked) |
+
+**Custom CEL Functions:**
+
+| Function | Description |
+|----------|-------------|
+| `toJson(value)` | Marshal any value to a JSON string |
+| `dig(target, "dot.path")` | Safe nested navigation returning `null` on missing keys |
+
+**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:**
+
+| Field | Limit | Behavior |
+|-------|-------|----------|
+| `type` | 128 bytes | Validation error (prevents startup) |
+| `reason` | 256 bytes | Truncated if exceeded |
+| `message` | 2048 bytes | Truncated if exceeded |
+
+**Example:**
+
+```yaml
+entities:
+ - kind: Cluster
+ conditions:
+ - 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
+```
+
+
+
---
## Complete Reference
diff --git a/go.mod b/go.mod
index 366fcb0e..4b758901 100755
--- a/go.mod
+++ b/go.mod
@@ -11,6 +11,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
@@ -42,6 +43,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
@@ -51,6 +54,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
)
diff --git a/go.sum b/go.sum
index 41e20c1f..98df8b0b 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=
@@ -15,6 +17,8 @@ github.com/MicahParks/keyfunc/v3 v3.8.1/go.mod h1:LcorJ0sz2tZGvgZqIfaeyLkJmM+kxI
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=
@@ -100,6 +104,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=
@@ -291,6 +297,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/registry/conditions.go b/pkg/registry/conditions.go
new file mode 100644
index 00000000..e0e2d927
--- /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 = 100
+ MaxConditionReasonLength = 256
+ MaxConditionMessageLength = 2048
+)
+
+// MappingExpression wraps a CEL expression string
+type MappingExpression struct {
+ Expression string `mapstructure:"expression" json:"expression" validate:"required"`
+}
+
+// MappingOutput defines the output expressions for a mapped condition
+type MappingOutput struct {
+ Status MappingExpression `mapstructure:"status" json:"status" validate:"required"`
+ Reason MappingExpression `mapstructure:"reason" json:"reason" validate:"required"`
+ Message MappingExpression `mapstructure:"message" json:"message" validate:"required"`
+}
+
+// ConditionMappingRule defines a single condition mapping rule
+type ConditionMappingRule struct {
+ Type string `mapstructure:"type" json:"type" validate:"required"`
+ When MappingExpression `mapstructure:"when" json:"when" validate:"required"`
+ Output MappingOutput `mapstructure:"output" json:"output" validate:"required"`
+}
+
+// ValidateEntityConditions validates condition mappings for a single entity descriptor
+// Used by registry.Validate() to check conditions inline in entity descriptors
+//
+// entities: all registered entity descriptors (needed to compute per-adapter synthesized types)
+// descriptor: the specific entity descriptor being validated
+func ValidateEntityConditions(entities []EntityDescriptor, descriptor EntityDescriptor) error {
+ if len(descriptor.Conditions) == 0 {
+ return nil
+ }
+
+ // Build reserved types from all entities (adapter-synthesized types are global)
+ 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..35e22795
--- /dev/null
+++ b/pkg/registry/conditions_test.go
@@ -0,0 +1,536 @@
+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())
+
+ 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("100"))
+}
+
+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 {
+ name string
+ errorMatch string
+ descriptor EntityDescriptor
+ entities []EntityDescriptor
+ 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..eb116544 100644
--- a/pkg/registry/descriptor.go
+++ b/pkg/registry/descriptor.go
@@ -38,10 +38,12 @@ type EntityDescriptor struct {
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"`
+ // CEL-based condition mapping rules for this entity type
+ Conditions []ConditionMappingRule `mapstructure:"conditions" json:"conditions,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"`
+ // 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..96cd3cf0
--- /dev/null
+++ b/pkg/services/condition_mapper.go
@@ -0,0 +1,669 @@
+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"
+)
+
+// CEL adapter status map keys
+// 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
+// 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
+// Used when building adapter condition maps for CEL context
+const (
+ condKeyType = "type"
+ condKeyStatus = "status"
+ condKeyReason = "reason"
+ condKeyMessage = "message"
+)
+
+// Resource field keys
+// 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
+ 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
+ activation := m.buildActivationWithCache(ctx, input.AdapterStatuses, input.Resource)
+
+ 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]
+
+ 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 if needed
+ validatedReason, validatedMessage := m.validateFieldLengths(ctx, rule, reasonStr, messageStr)
+
+ // Build the mapped condition with all required fields
+ return m.buildMappedCondition(
+ ctx, rule, statusStr, validatedReason, validatedMessage, activation, refTime, prevCondition,
+ )
+}
+
+// validateFieldLengths enforces field length constraints by truncating oversized values.
+func (m *ConditionMapper) validateFieldLengths(
+ ctx context.Context,
+ rule *compiledRule,
+ reasonStr string,
+ messageStr string,
+) (string, string) {
+ 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")
+ }
+
+ 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
+}
+
+// buildMappedCondition builds the final ResourceCondition from validated inputs
+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) {
+ if len(condType) > registry.MaxConditionTypeLength {
+ return nil, fmt.Errorf(
+ "condition type exceeds max length %d (got %d)",
+ registry.MaxConditionTypeLength, len(condType),
+ )
+ }
+
+ // 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
+ 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,
+ }
+}
+
+// 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.
+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.
+ // 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
+ 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: "",
+ }
+ // 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.
+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
+ conditions, hasUnknown := parseConditionsWithUnknownCheck(ctx, status.Conditions, status.Adapter)
+
+ // Early return if Unknown found - buildStatusesList discards the map anyway
+ // Return nil instead of allocating a throwaway map (saves allocation on hot path)
+ if hasUnknown {
+ return nil, true
+ }
+
+ // Parse data field from JSONB
+ 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.
+ // 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.
+ // 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..0202f1e5
--- /dev/null
+++ b/pkg/services/condition_mapper_test.go
@@ -0,0 +1,1760 @@
+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
+ mapper := &ConditionMapper{resourceKind: "Cluster"}
+ activation := mapper.buildActivationWithCache(
+ context.Background(), statuses, map[string]interface{}{},
+ )
+
+ 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
+ }
+
+ mapper := &ConditionMapper{resourceKind: "Cluster"}
+ activation := mapper.buildActivationWithCache(
+ context.Background(), statuses, resource,
+ )
+
+ // 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"))
+ }
+}
diff --git a/pkg/services/resource.go b/pkg/services/resource.go
index 33783391..eeacdc06 100644
--- a/pkg/services/resource.go
+++ b/pkg/services/resource.go
@@ -39,14 +39,33 @@ func NewResourceService(
adapterStatusDao dao.AdapterStatusDao,
resourceConditionDao dao.ResourceConditionDao,
generic GenericService,
-) ResourceService {
+) (ResourceService, error) {
+ mappers, err := buildConditionMappers(registry.All())
+ if err != nil {
+ return nil, err
+ }
return &sqlResourceService{
resourceDao: resourceDao,
resourceLabelDao: resourceLabelDao,
adapterStatusDao: adapterStatusDao,
resourceConditionDao: resourceConditionDao,
generic: generic,
+ conditionMappers: mappers,
+ }, nil
+}
+
+func buildConditionMappers(entities []registry.EntityDescriptor) (map[string]*ConditionMapper, error) {
+ conditionMappers := make(map[string]*ConditionMapper)
+ for _, descriptor := range entities {
+ if len(descriptor.Conditions) > 0 {
+ mapper, err := NewConditionMapper(descriptor.Kind, descriptor.Conditions)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create condition mapper for %s: %w", descriptor.Kind, err)
+ }
+ conditionMappers[descriptor.Kind] = mapper
+ }
}
+ return conditionMappers, nil
}
var _ ResourceService = &sqlResourceService{}
@@ -57,6 +76,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 +588,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 +669,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..6b0c388b 100644
--- a/pkg/services/resource_test.go
+++ b/pkg/services/resource_test.go
@@ -279,7 +279,7 @@ var _ dao.ResourceConditionDao = &resourceConditionMock{}
func newTestResourceService(mockDao *mockResourceDao) (ResourceService, *mockResourceDao, *resourceGenericMock) {
generic := &resourceGenericMock{}
- svc := NewResourceService(
+ svc, _ := NewResourceService(
mockDao, newMockResourceLabelDao(), newMockAdapterStatusDao(), newResourceConditionMock(), generic,
)
return svc, mockDao, generic
@@ -290,7 +290,7 @@ func newTestResourceServiceWithLabelDao(
) (ResourceService, *mockResourceDao, *resourceGenericMock, *mockResourceLabelDao) {
generic := &resourceGenericMock{}
labelDao := newMockResourceLabelDao()
- svc := NewResourceService(
+ svc, _ := NewResourceService(
mockDao, labelDao, newMockAdapterStatusDao(), newResourceConditionMock(), generic,
)
return svc, mockDao, generic, labelDao
@@ -302,7 +302,17 @@ func newTestResourceServiceWithAdapterStatus(
asDao := newMockAdapterStatusDao()
rcDao := newResourceConditionMock()
generic := &resourceGenericMock{}
- svc := NewResourceService(mockDao, newMockResourceLabelDao(), asDao, rcDao, generic)
+ svc, _ := NewResourceService(mockDao, newMockResourceLabelDao(), asDao, rcDao, generic)
+ 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
}
@@ -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..cd96a180
--- /dev/null
+++ b/pkg/util/cel.go
@@ -0,0 +1,161 @@
+package util
+
+import (
+ "encoding/json"
+ "errors"
+ "reflect"
+ "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
+// These constants ensure consistency between environment declaration and activation map
+const (
+ CELVarStatuses = "statuses" // Array of adapter statuses
+ CELVarResource = "resource" // Full cluster/nodepool object
+)
+
+// 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),
+
+ // 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 reject output exceeding 1MB.
+ // Note: json.Encoder buffers the full value in memory before writing, so this
+ // bounds written output size, not peak memory during encoding.
+ 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:
+ rv := reflect.ValueOf(current)
+ if rv.Kind() != reflect.Slice && rv.Kind() != reflect.Array {
+ return types.NullValue
+ }
+ idx, err := strconv.Atoi(part)
+ if err != nil || idx < 0 || idx >= rv.Len() {
+ return types.NullValue
+ }
+ current = rv.Index(idx).Interface()
+ }
+ }
+
+ 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..4a5a4b40
--- /dev/null
+++ b/pkg/util/cel_test.go
@@ -0,0 +1,323 @@
+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", 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
+ "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"))
+ })
+}
diff --git a/pkg/util/mask_sensitive.go b/pkg/util/mask_sensitive.go
new file mode 100644
index 00000000..0ed89da4
--- /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
+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
+// Matches common secret/credential field names case-insensitively
+var sensitivePatterns = []string{
+ "password",
+ "secret",
+ "token",
+ // Specific key patterns
+ "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: 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
+ "kubeconfig", // Kubernetes config blobs
+ "bearer", // Bearer tokens
+}
+
+// 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..add253d4
--- /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) {
+ g := gomega.NewWithT(t)
+ result := MaskSensitiveFields(nil)
+ g.Expect(result).To(gomega.BeNil())
+ })
+
+ t.Run("empty map returns empty", func(t *testing.T) {
+ g := gomega.NewWithT(t)
+ result := MaskSensitiveFields(map[string]interface{}{})
+ g.Expect(result).To(gomega.BeEmpty())
+ })
+
+ t.Run("non-sensitive fields pass through unchanged", func(t *testing.T) {
+ g := gomega.NewWithT(t)
+
+ input := map[string]interface{}{
+ "clusterName": "test-cluster",
+ "region": "us-west-2",
+ "count": 3,
+ }
+
+ result := MaskSensitiveFields(input)
+
+ g.Expect(result).To(gomega.HaveLen(3))
+ g.Expect(result["clusterName"]).To(gomega.Equal("test-cluster"))
+ g.Expect(result["region"]).To(gomega.Equal("us-west-2"))
+ g.Expect(result["count"]).To(gomega.Equal(3))
+ })
+
+ t.Run("password field is redacted", func(t *testing.T) {
+ g := gomega.NewWithT(t)
+
+ input := map[string]interface{}{
+ "username": "admin",
+ "password": "super-secret-123",
+ }
+
+ result := MaskSensitiveFields(input)
+
+ g.Expect(result["username"]).To(gomega.Equal("admin"))
+ g.Expect(result["password"]).To(gomega.Equal(RedactedPlaceholder))
+ })
+
+ t.Run("all sensitive patterns are redacted case-insensitively", func(t *testing.T) {
+ g := gomega.NewWithT(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 {
+ g.Expect(result[k]).To(gomega.Equal(RedactedPlaceholder), "field %s should be redacted", k)
+ }
+ })
+
+ t.Run("nested maps are recursively masked", func(t *testing.T) {
+ g := gomega.NewWithT(t)
+
+ input := map[string]interface{}{
+ "clusterName": "test-cluster",
+ "auth": map[string]interface{}{
+ "username": "admin",
+ "password": "secret123",
+ },
+ }
+
+ result := MaskSensitiveFields(input)
+
+ g.Expect(result["clusterName"]).To(gomega.Equal("test-cluster"))
+
+ // "auth" key itself is redacted (contains "auth" pattern)
+ g.Expect(result["auth"]).To(gomega.Equal(RedactedPlaceholder))
+ })
+
+ t.Run("nested non-sensitive map is recursively processed", func(t *testing.T) {
+ g := gomega.NewWithT(t)
+
+ input := map[string]interface{}{
+ "clusterName": "test-cluster",
+ "config": map[string]interface{}{
+ "region": "us-west-2",
+ "password": "secret123",
+ },
+ }
+
+ result := MaskSensitiveFields(input)
+
+ g.Expect(result["clusterName"]).To(gomega.Equal("test-cluster"))
+
+ // "config" itself is not sensitive, so we get nested map
+ configMap, ok := result["config"].(map[string]interface{})
+ g.Expect(ok).To(gomega.BeTrue())
+ g.Expect(configMap["region"]).To(gomega.Equal("us-west-2"))
+ g.Expect(configMap["password"]).To(gomega.Equal(RedactedPlaceholder))
+ })
+
+ t.Run("deeply nested sensitive fields are redacted", func(t *testing.T) {
+ g := gomega.NewWithT(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{})
+
+ g.Expect(level3["password"]).To(gomega.Equal(RedactedPlaceholder))
+ g.Expect(level3["region"]).To(gomega.Equal("us-east-1"))
+ })
+
+ t.Run("complex real-world adapter data", func(t *testing.T) {
+ g := gomega.NewWithT(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
+ g.Expect(result["cluster_name"]).To(gomega.Equal("prod-cluster-1"))
+ g.Expect(result["namespace"]).To(gomega.Equal("default"))
+ g.Expect(result["region"]).To(gomega.Equal("us-west-2"))
+ g.Expect(result["node_count"]).To(gomega.Equal(3))
+
+ // Sensitive top-level fields redacted
+ g.Expect(result["pull_secret"]).To(gomega.Equal(RedactedPlaceholder))
+ g.Expect(result["admin_password"]).To(gomega.Equal(RedactedPlaceholder))
+
+ // Nested sensitive field redacted
+ sa := result["service_account"].(map[string]interface{})
+ g.Expect(sa["name"]).To(gomega.Equal("hypershift-sa"))
+ g.Expect(sa["privateKey"]).To(gomega.Equal(RedactedPlaceholder))
+ })
+
+ t.Run("arrays with sensitive fields in elements are masked", func(t *testing.T) {
+ g := gomega.NewWithT(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)
+
+ g.Expect(result["clusterName"]).To(gomega.Equal("test-cluster"))
+
+ users := result["users"].([]interface{})
+ g.Expect(users).To(gomega.HaveLen(2))
+
+ // First user: password should be masked
+ user0 := users[0].(map[string]interface{})
+ g.Expect(user0["name"]).To(gomega.Equal("admin"))
+ g.Expect(user0["password"]).To(gomega.Equal(RedactedPlaceholder))
+
+ // Second user: token should be masked
+ user1 := users[1].(map[string]interface{})
+ g.Expect(user1["name"]).To(gomega.Equal("viewer"))
+ g.Expect(user1["token"]).To(gomega.Equal(RedactedPlaceholder))
+ })
+
+ t.Run("nested arrays are recursively masked", func(t *testing.T) {
+ g := gomega.NewWithT(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{})
+ g.Expect(env0["name"]).To(gomega.Equal("production"))
+
+ providers := env0["providers"].([]interface{})
+ provider0 := providers[0].(map[string]interface{})
+ g.Expect(provider0["type"]).To(gomega.Equal("aws"))
+ g.Expect(provider0["apiKey"]).To(gomega.Equal(RedactedPlaceholder))
+ g.Expect(provider0["region"]).To(gomega.Equal("us-east-1"))
+ })
+
+ t.Run("arrays of primitives pass through unchanged", func(t *testing.T) {
+ g := gomega.NewWithT(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{})
+ g.Expect(regions).To(gomega.Equal([]interface{}{"us-east-1", "us-west-2", "eu-west-1"}))
+
+ ports := result["ports"].([]interface{})
+ g.Expect(ports).To(gomega.Equal([]interface{}{80, 443, 8080}))
+ })
+
+ t.Run("deeply nested arrays of arrays are masked", func(t *testing.T) {
+ g := gomega.NewWithT(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{})
+ g.Expect(cell["value"]).To(gomega.Equal("data"))
+ g.Expect(cell["password"]).To(gomega.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},
+ // security 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}, // "key" alone is too broad
+ {"region", "region", false},
+ {"count", "nodeCount", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ g := gomega.NewWithT(t)
+ result := isSensitiveKey(tt.key)
+ g.Expect(result).To(gomega.Equal(tt.sensitive))
+ })
+ }
+}
+
+// ============================================================================
+// security Pattern Tests
+// ============================================================================
+
+func TestMaskSensitiveFields_SEC02Patterns(t *testing.T) {
+ t.Parallel()
+ t.Run("TLS certificate fields are masked", func(t *testing.T) {
+ g := gomega.NewWithT(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
+ g.Expect(result["clusterName"]).To(gomega.Equal("prod-cluster"))
+
+ // All cert fields should be masked
+ g.Expect(result["tlsCert"]).To(gomega.Equal(RedactedPlaceholder))
+ g.Expect(result["clientCert"]).To(gomega.Equal(RedactedPlaceholder))
+ g.Expect(result["caCertificate"]).To(gomega.Equal(RedactedPlaceholder))
+ g.Expect(result["serverCertChain"]).To(gomega.Equal(RedactedPlaceholder))
+ })
+
+ t.Run("kubeconfig fields are masked", func(t *testing.T) {
+ g := gomega.NewWithT(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)
+
+ g.Expect(result["clusterName"]).To(gomega.Equal("prod-cluster"))
+ g.Expect(result["kubeconfig"]).To(gomega.Equal(RedactedPlaceholder))
+ g.Expect(result["kubeconfigRaw"]).To(gomega.Equal(RedactedPlaceholder))
+ g.Expect(result["adminKubeconfig"]).To(gomega.Equal(RedactedPlaceholder))
+ })
+
+ t.Run("bearer token fields are masked", func(t *testing.T) {
+ g := gomega.NewWithT(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)
+
+ g.Expect(result["clusterName"]).To(gomega.Equal("prod-cluster"))
+ g.Expect(result["bearerToken"]).To(gomega.Equal(RedactedPlaceholder))
+ g.Expect(result["bearer"]).To(gomega.Equal(RedactedPlaceholder))
+ g.Expect(result["bearerHeader"]).To(gomega.Equal(RedactedPlaceholder))
+ })
+
+ t.Run("connection string fields are masked", func(t *testing.T) {
+ g := gomega.NewWithT(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
+ g.Expect(result["dbHost"]).To(gomega.Equal("postgres.example.com"))
+
+ // All connection string fields should be masked (contain "connection")
+ g.Expect(result["connection_string"]).To(gomega.Equal(RedactedPlaceholder))
+ g.Expect(result["dbConnectionString"]).To(gomega.Equal(RedactedPlaceholder))
+ g.Expect(result["redisConnectionString"]).To(gomega.Equal(RedactedPlaceholder))
+ })
+
+ t.Run("nested security patterns in adapter data", func(t *testing.T) {
+ g := gomega.NewWithT(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)
+
+ g.Expect(result["clusterName"]).To(gomega.Equal("prod-cluster"))
+
+ // hypershift is not sensitive, but nested fields are
+ hypershift := result["hypershift"].(map[string]interface{})
+ g.Expect(hypershift["kubeconfig"]).To(gomega.Equal(RedactedPlaceholder))
+ g.Expect(hypershift["pullSecrets"]).To(gomega.Equal(RedactedPlaceholder), "pullSecrets contains 'secret'")
+
+ // tlsConfig is not sensitive, but nested cert fields are
+ tlsConfig := result["tlsConfig"].(map[string]interface{})
+ g.Expect(tlsConfig["caCert"]).To(gomega.Equal(RedactedPlaceholder))
+ g.Expect(tlsConfig["clientCert"]).To(gomega.Equal(RedactedPlaceholder))
+ g.Expect(tlsConfig["serverName"]).To(gomega.Equal("api.cluster.example.com"))
+ })
+
+ t.Run("security patterns in arrays", func(t *testing.T) {
+ g := gomega.NewWithT(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{})
+ g.Expect(clusters).To(gomega.HaveLen(2))
+
+ cluster1 := clusters[0].(map[string]interface{})
+ g.Expect(cluster1["name"]).To(gomega.Equal("cluster-1"))
+ g.Expect(cluster1["kubeconfig"]).To(gomega.Equal(RedactedPlaceholder))
+
+ cluster2 := clusters[1].(map[string]interface{})
+ g.Expect(cluster2["name"]).To(gomega.Equal("cluster-2"))
+ g.Expect(cluster2["connection"]).To(gomega.Equal(RedactedPlaceholder), "connection contains 'connection'")
+ })
+
+ t.Run("real-world HyperShift adapter data with security patterns", func(t *testing.T) {
+ g := gomega.NewWithT(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
+ g.Expect(result["clusterName"]).To(gomega.Equal("prod-hypershift-cluster"))
+ g.Expect(result["namespace"]).To(gomega.Equal("clusters"))
+ g.Expect(result["baseDomain"]).To(gomega.Equal("example.com"))
+
+ // Sensitive fields masked
+ g.Expect(result["pullSecret"]).To(gomega.Equal(RedactedPlaceholder))
+ g.Expect(result["sshKey"]).To(gomega.Equal(RedactedPlaceholder), "sshKey contains 'key'")
+
+ // hostedCluster contains kubeconfig (nested sensitive)
+ hostedCluster := result["hostedCluster"].(map[string]interface{})
+ g.Expect(hostedCluster["name"]).To(gomega.Equal("my-cluster"))
+ g.Expect(hostedCluster["kubeconfig"]).To(gomega.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) {
+ g := gomega.NewWithT(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
+ g.Expect(result["level"]).To(gomega.Equal(0))
+ g.Expect(result["data"]).To(gomega.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{})
+ g.Expect(ok).To(gomega.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{})
+ g.Expect(ok).To(gomega.BeTrue())
+ g.Expect(nested).To(gomega.BeEmpty(), "Depth 20 should return empty map (limit reached)")
+ })
+
+ t.Run("deeply nested array stops at max depth (SEC-03)", func(t *testing.T) {
+ g := gomega.NewWithT(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++ {
+ g.Expect(currentSlice).To(gomega.HaveLen(1))
+ next, ok := currentSlice[0].([]interface{})
+ g.Expect(ok).To(gomega.BeTrue(), "Level %d should be a slice", i)
+ currentSlice = next
+ }
+
+ // At depth 20, should get empty slice (limit reached)
+ g.Expect(currentSlice).To(gomega.HaveLen(1))
+ final, ok := currentSlice[0].([]interface{})
+ g.Expect(ok).To(gomega.BeTrue())
+ g.Expect(final).To(gomega.BeEmpty(), "Depth 20 should return empty slice (limit reached)")
+ })
+
+ t.Run("normal nested structures work fine (SEC-03)", func(t *testing.T) {
+ g := gomega.NewWithT(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
+ g.Expect(level5["password"]).To(gomega.Equal(RedactedPlaceholder))
+ g.Expect(level5["name"]).To(gomega.Equal("final"))
+ })
+}
+
+// ============================================================================
+// False Positive Prevention Tests
+// ============================================================================
+
+func TestMaskSensitiveFields_FalsePositivePrevention(t *testing.T) {
+ t.Parallel()
+ t.Run("database fields with 'key' are NOT redacted", func(t *testing.T) {
+ g := gomega.NewWithT(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
+ g.Expect(result["partitionKey"]).To(gomega.Equal("user-123"))
+ g.Expect(result["sortKey"]).To(gomega.Equal("timestamp"))
+ g.Expect(result["primaryKey"]).To(gomega.Equal(42))
+ g.Expect(result["foreignKey"]).To(gomega.Equal(99))
+ g.Expect(result["uniqueKey"]).To(gomega.Equal("abc"))
+ g.Expect(result["indexKey"]).To(gomega.Equal("idx_users_email"))
+ g.Expect(result["cacheKey"]).To(gomega.Equal("session:xyz"))
+ g.Expect(result["lookupKey"]).To(gomega.Equal("search-term"))
+ g.Expect(result["routingKey"]).To(gomega.Equal("orders"))
+ g.Expect(result["shardKey"]).To(gomega.Equal("region-us-west"))
+ })
+
+ t.Run("legitimate credential 'key' fields ARE redacted", func(t *testing.T) {
+ g := gomega.NewWithT(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 {
+ g.Expect(result[k]).To(gomega.Equal(RedactedPlaceholder), "field %s should be redacted", k)
+ }
+ })
+
+ t.Run("service/product names with 'key' are NOT redacted", func(t *testing.T) {
+ g := gomega.NewWithT(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
+ g.Expect(result["keystoneEndpoint"]).To(gomega.Equal("https://keystone.example.com"))
+ g.Expect(result["monkeyPatch"]).To(gomega.Equal(true))
+ g.Expect(result["turkeyMode"]).To(gomega.Equal(false))
+ g.Expect(result["keyValueStore"]).To(gomega.Equal("redis"))
+ g.Expect(result["hotkey"]).To(gomega.Equal("Ctrl+S"))
+ g.Expect(result["keyframe"]).To(gomega.Equal(60))
+ })
+}
diff --git a/pkg/util/naming.go b/pkg/util/naming.go
new file mode 100644
index 00000000..8828a5cc
--- /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
+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..1f39011d
--- /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) {
+ g := gomega.NewWithT(t)
+ result := MapAdapterToConditionType(tt.adapter)
+ g.Expect(result).To(gomega.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")
+}