diff --git a/charts/templates/configmap.yaml b/charts/templates/configmap.yaml index 92e3c981..98ed0f8a 100644 --- a/charts/templates/configmap.yaml +++ b/charts/templates/configmap.yaml @@ -135,26 +135,6 @@ data: shutdown_timeout: {{ .Values.config.health.shutdown_timeout }} db_ping_timeout: {{ .Values.config.health.db_ping_timeout }} - - adapters: - required: - cluster: -{{- if .Values.config.adapters.required.cluster }} -{{- range .Values.config.adapters.required.cluster }} - - {{ . }} -{{- end }} -{{- else }} - [] -{{- end }} - nodepool: -{{- if .Values.config.adapters.required.nodepool }} -{{- range .Values.config.adapters.required.nodepool }} - - {{ . }} -{{- end }} -{{- else }} - [] -{{- end }} - # NOTE: When EntityDescriptor gains many more fields, consider replacing # the explicit range below with: .Values.config.entities | toYaml | indent 6 {{- if .Values.config.entities }} @@ -181,6 +161,15 @@ data: - {{ . }} {{- end }} {{- end }} +{{- if .name_min_len }} + name_min_len: {{ .name_min_len }} +{{- end }} +{{- if .name_max_len }} + name_max_len: {{ .name_max_len }} +{{- end }} +{{- if .require_spec_schema }} + require_spec_schema: {{ .require_spec_schema }} +{{- end }} {{- if .references }} references: {{- range .references }} diff --git a/charts/values.yaml b/charts/values.yaml index b2449829..308debf2 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -194,21 +194,27 @@ config: shutdown_timeout: 20s # -- Timeout for the database liveness ping db_ping_timeout: 2s - - # -- Adapters required for resources to reach "Ready" state. - # Production deployments should list all expected adapters. - adapters: - # -- Adapters required for cluster resources - required: - # -- Required cluster adapters (e.g. `["validation", "dns", "pullsecret", "hypershift"]`) - cluster: [] - # -- Required nodepool adapters (e.g. `["validation", "hypershift"]`) - nodepool: [] - # -- Entity descriptors registered at startup. Each entry auto-generates # REST endpoints, spec validation, and delete policies. - # Cluster and NodePool use legacy typed handlers and are NOT listed here. entities: + - kind: Cluster + plural: clusters + spec_schema_name: ClusterSpec + required_adapters: [validation, dns, pullsecret, hypershift] + search_disallowed_fields: [spec] + name_min_len: 3 + name_max_len: 53 + require_spec_schema: true + - kind: NodePool + plural: nodepools + parent_kind: Cluster + on_parent_delete: cascade + spec_schema_name: NodePoolSpec + required_adapters: [validation, hypershift] + search_disallowed_fields: [spec] + name_min_len: 3 + name_max_len: 15 + require_spec_schema: true - kind: Channel plural: channels spec_schema_name: ChannelSpec diff --git a/cmd/hyperfleet-api/environments/framework_test.go b/cmd/hyperfleet-api/environments/framework_test.go index 8ec30df5..a7316ca4 100755 --- a/cmd/hyperfleet-api/environments/framework_test.go +++ b/cmd/hyperfleet-api/environments/framework_test.go @@ -16,8 +16,6 @@ func TestLoadServices(t *testing.T) { // Create minimal configuration for unit test cfg := config.NewApplicationConfig() - cfg.Adapters.Required.Cluster = []string{"validation", "dns", "pullsecret", "hypershift"} - cfg.Adapters.Required.Nodepool = []string{"validation", "hypershift"} env := Environment() env.Config = cfg diff --git a/cmd/hyperfleet-api/main.go b/cmd/hyperfleet-api/main.go index 16b24792..aad589c7 100755 --- a/cmd/hyperfleet-api/main.go +++ b/cmd/hyperfleet-api/main.go @@ -15,12 +15,9 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" // Import plugins to trigger their init() functions - // _ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/events" // REMOVED: Events plugin no longer exists _ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/adapterStatus" - _ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/clusters" _ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/entities" _ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/generic" - _ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/nodePools" _ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/resources" ) diff --git a/configs/config.yaml.example b/configs/config.yaml.example index 8b0c49e4..bde50a03 100644 --- a/configs/config.yaml.example +++ b/configs/config.yaml.example @@ -101,25 +101,30 @@ health: shutdown_timeout: 20s # Graceful shutdown timeout db_ping_timeout: 2s # Database ping timeout for readiness check - -# Adapter Requirements Configuration -adapters: - required: - cluster: # Required adapters for cluster resources - - validation - - dns - - pullsecret - - hypershift - - nodepool: # Required adapters for nodepool resources - - validation - - hypershift - # Entity Registration # Generic resource types registered at startup. Each entry auto-generates # REST endpoints, spec validation, and delete policies. -# Cluster and NodePool use legacy typed handlers and are NOT listed here. entities: + - kind: Cluster + plural: clusters + spec_schema_name: ClusterSpec + required_adapters: [validation, dns, pullsecret, hypershift] + search_disallowed_fields: [spec] + name_min_len: 3 + name_max_len: 53 + require_spec_schema: true + + - kind: NodePool + plural: nodepools + parent_kind: Cluster + on_parent_delete: cascade + spec_schema_name: NodePoolSpec + required_adapters: [validation, hypershift] + search_disallowed_fields: [spec] + name_min_len: 3 + name_max_len: 15 + require_spec_schema: true + - kind: Channel plural: channels spec_schema_name: ChannelSpec diff --git a/pkg/api/cluster_types.go b/pkg/api/cluster_types.go deleted file mode 100644 index d4e0cd3d..00000000 --- a/pkg/api/cluster_types.go +++ /dev/null @@ -1,81 +0,0 @@ -package api - -import ( - "fmt" - "time" - - "gorm.io/datatypes" - "gorm.io/gorm" -) - -// Cluster database model -type Cluster struct { - Meta - Kind string `json:"kind" gorm:"default:'Cluster'"` - Name string `json:"name" gorm:"uniqueIndex;size:53;not null"` - Href string `json:"href,omitempty" gorm:"size:500"` - CreatedBy string `json:"created_by" gorm:"size:255;not null"` - UpdatedBy string `json:"updated_by" gorm:"size:255;not null"` - DeletedBy *string `json:"deleted_by,omitempty" gorm:"size:255"` - DeletedTime *time.Time `json:"deleted_time,omitempty"` - Spec datatypes.JSON `json:"spec" gorm:"type:jsonb;not null"` - Labels datatypes.JSON `json:"labels,omitempty" gorm:"type:jsonb"` - StatusConditions datatypes.JSON `json:"status_conditions" gorm:"type:jsonb"` - Generation int32 `json:"generation" gorm:"default:1;not null"` -} - -type ( - ClusterList []*Cluster - ClusterIndex map[string]*Cluster -) - -func (l ClusterList) Index() ClusterIndex { - index := ClusterIndex{} - for _, o := range l { - index[o.ID] = o - } - return index -} - -func (c *Cluster) BeforeCreate(tx *gorm.DB) error { - if c.ID == "" { - id, err := NewID() - if err != nil { - return fmt.Errorf("failed to generate cluster ID: %w", err) - } - c.ID = id - } - - now := time.Now() - if c.CreatedTime.IsZero() { - c.CreatedTime = now - } - c.UpdatedTime = now - if c.Generation == 0 { - c.Generation = 1 - } - // Set Href if not already set - if c.Href == "" { - c.Href = "/api/hyperfleet/v1/clusters/" + c.ID - } - return nil -} - -func (c *Cluster) BeforeUpdate(tx *gorm.DB) error { - c.UpdatedTime = time.Now() - return nil -} - -func (c *Cluster) MarkDeleted(by string, t time.Time) { - c.DeletedTime = &t - c.DeletedBy = &by -} - -func (c *Cluster) IncrementGeneration() { - c.Generation++ -} - -type ClusterPatch struct { - Spec map[string]interface{} - Labels map[string]string -} diff --git a/pkg/api/cluster_types_test.go b/pkg/api/cluster_types_test.go deleted file mode 100644 index a5cbd8a3..00000000 --- a/pkg/api/cluster_types_test.go +++ /dev/null @@ -1,175 +0,0 @@ -package api - -import ( - "testing" - - . "github.com/onsi/gomega" -) - -// TestClusterList_Index tests the Index() method for ClusterList -func TestClusterList_Index(t *testing.T) { - RegisterTestingT(t) - - // Test empty list - emptyList := ClusterList{} - emptyIndex := emptyList.Index() - Expect(len(emptyIndex)).To(Equal(0)) - - // Test single cluster - cluster1 := &Cluster{} - cluster1.ID = "cluster-1" - cluster1.Name = "test-cluster-1" - - singleList := ClusterList{cluster1} - singleIndex := singleList.Index() - Expect(len(singleIndex)).To(Equal(1)) - Expect(singleIndex["cluster-1"]).To(Equal(cluster1)) - - // Test multiple clusters - cluster2 := &Cluster{} - cluster2.ID = "cluster-2" - cluster2.Name = "test-cluster-2" - - cluster3 := &Cluster{} - cluster3.ID = "cluster-3" - cluster3.Name = "test-cluster-3" - - multiList := ClusterList{cluster1, cluster2, cluster3} - multiIndex := multiList.Index() - Expect(len(multiIndex)).To(Equal(3)) - Expect(multiIndex["cluster-1"]).To(Equal(cluster1)) - Expect(multiIndex["cluster-2"]).To(Equal(cluster2)) - Expect(multiIndex["cluster-3"]).To(Equal(cluster3)) - - // Test duplicate IDs (later one overwrites earlier one) - cluster1Duplicate := &Cluster{} - cluster1Duplicate.ID = "cluster-1" - cluster1Duplicate.Name = "duplicate-cluster" - - duplicateList := ClusterList{cluster1, cluster1Duplicate} - duplicateIndex := duplicateList.Index() - Expect(len(duplicateIndex)).To(Equal(1)) - Expect(duplicateIndex["cluster-1"]).To(Equal(cluster1Duplicate)) - Expect(duplicateIndex["cluster-1"].Name).To(Equal("duplicate-cluster")) -} - -// TestCluster_BeforeCreate_IDGeneration tests ID auto-generation -func TestCluster_BeforeCreate_IDGeneration(t *testing.T) { - RegisterTestingT(t) - - cluster := &Cluster{ - Name: "test-cluster", - } - - err := cluster.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(cluster.ID).ToNot(BeEmpty()) - Expect(len(cluster.ID)).To(BeNumerically(">", 0)) -} - -// TestCluster_BeforeCreate_KindPreservation tests Kind is preserved (not auto-set) -func TestCluster_BeforeCreate_KindPreservation(t *testing.T) { - RegisterTestingT(t) - - // Kind must be set before BeforeCreate (by handler validation) - cluster := &Cluster{ - Name: "test-cluster", - Kind: "Cluster", - } - - err := cluster.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(cluster.Kind).To(Equal("Cluster")) -} - -// TestCluster_BeforeCreate_KindPreserved tests Kind is not overwritten -func TestCluster_BeforeCreate_KindPreserved(t *testing.T) { - RegisterTestingT(t) - - // Test Kind preservation - cluster := &Cluster{ - Name: "test-cluster", - Kind: "CustomCluster", - } - - err := cluster.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(cluster.Kind).To(Equal("CustomCluster")) -} - -// TestCluster_BeforeCreate_GenerationDefault tests Generation default value -func TestCluster_BeforeCreate_GenerationDefault(t *testing.T) { - RegisterTestingT(t) - - // Test default Generation - cluster := &Cluster{ - Name: "test-cluster", - } - - err := cluster.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(cluster.Generation).To(Equal(int32(1))) -} - -// TestCluster_BeforeCreate_GenerationPreserved tests Generation is not overwritten -func TestCluster_BeforeCreate_GenerationPreserved(t *testing.T) { - RegisterTestingT(t) - - // Test Generation preservation - cluster := &Cluster{ - Name: "test-cluster", - Generation: 5, - } - - err := cluster.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(cluster.Generation).To(Equal(int32(5))) -} - -// TestCluster_BeforeCreate_HrefGeneration tests Href auto-generation -func TestCluster_BeforeCreate_HrefGeneration(t *testing.T) { - RegisterTestingT(t) - - // Test Href generation - cluster := &Cluster{ - Name: "test-cluster", - } - - err := cluster.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(cluster.Href).To(Equal("/api/hyperfleet/v1/clusters/" + cluster.ID)) -} - -// TestCluster_BeforeCreate_HrefPreserved tests Href is not overwritten -func TestCluster_BeforeCreate_HrefPreserved(t *testing.T) { - RegisterTestingT(t) - - // Test Href preservation - cluster := &Cluster{ - Name: "test-cluster", - Href: "/custom/href", - } - - err := cluster.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(cluster.Href).To(Equal("/custom/href")) -} - -// TestCluster_BeforeCreate_Complete tests all defaults set together -func TestCluster_BeforeCreate_Complete(t *testing.T) { - RegisterTestingT(t) - - cluster := &Cluster{ - Name: "test-cluster", - Kind: "Cluster", // Kind must be set before BeforeCreate - } - - err := cluster.BeforeCreate(nil) - Expect(err).To(BeNil()) - - // Verify all defaults - Expect(cluster.ID).ToNot(BeEmpty()) - Expect(cluster.Kind).To(Equal("Cluster")) // Kind is preserved, not auto-set - Expect(cluster.Generation).To(Equal(int32(1))) - Expect(cluster.Href).To(Equal("/api/hyperfleet/v1/clusters/" + cluster.ID)) -} diff --git a/pkg/api/node_pool_types.go b/pkg/api/node_pool_types.go deleted file mode 100644 index 79f1c235..00000000 --- a/pkg/api/node_pool_types.go +++ /dev/null @@ -1,92 +0,0 @@ -package api - -import ( - "fmt" - "time" - - "gorm.io/datatypes" - "gorm.io/gorm" -) - -// NodePool database model -type NodePool struct { - Cluster *Cluster `gorm:"foreignKey:OwnerID;references:ID"` - Meta - Kind string `json:"kind" gorm:"default:'NodePool'"` - Name string `json:"name" gorm:"size:15;not null"` - UpdatedBy string `json:"updated_by" gorm:"size:255;not null"` - DeletedBy *string `json:"deleted_by,omitempty" gorm:"size:255"` - DeletedTime *time.Time `json:"deleted_time,omitempty"` - Href string `json:"href,omitempty" gorm:"size:500"` - CreatedBy string `json:"created_by" gorm:"size:255;not null"` - OwnerID string `json:"owner_id" gorm:"size:255;not null;index"` - OwnerKind string `json:"owner_kind" gorm:"size:50;not null"` - OwnerHref string `json:"owner_href,omitempty" gorm:"size:500"` - Spec datatypes.JSON `json:"spec" gorm:"type:jsonb;not null"` - StatusConditions datatypes.JSON `json:"status_conditions" gorm:"type:jsonb"` - Labels datatypes.JSON `json:"labels,omitempty" gorm:"type:jsonb"` - Generation int32 `json:"generation" gorm:"default:1;not null"` -} - -type ( - NodePoolList []*NodePool - NodePoolIndex map[string]*NodePool -) - -func (l NodePoolList) Index() NodePoolIndex { - index := NodePoolIndex{} - for _, o := range l { - index[o.ID] = o - } - return index -} - -func (np *NodePool) BeforeCreate(tx *gorm.DB) error { - if np.ID == "" { - id, err := NewID() - if err != nil { - return fmt.Errorf("failed to generate node pool ID: %w", err) - } - np.ID = id - } - - now := time.Now() - if np.CreatedTime.IsZero() { - np.CreatedTime = now - } - np.UpdatedTime = now - if np.Generation == 0 { - np.Generation = 1 - } - if np.OwnerKind == "" { - np.OwnerKind = "Cluster" - } - // Set Href if not already set - if np.Href == "" { - np.Href = fmt.Sprintf("/api/hyperfleet/v1/clusters/%s/nodepools/%s", np.OwnerID, np.ID) - } - // Set OwnerHref if not already set - if np.OwnerHref == "" { - np.OwnerHref = "/api/hyperfleet/v1/clusters/" + np.OwnerID - } - return nil -} - -func (np *NodePool) BeforeUpdate(tx *gorm.DB) error { - np.UpdatedTime = time.Now() - return nil -} - -func (np *NodePool) MarkDeleted(by string, t time.Time) { - np.DeletedTime = &t - np.DeletedBy = &by -} - -func (np *NodePool) IncrementGeneration() { - np.Generation++ -} - -type NodePoolPatch struct { - Spec map[string]interface{} - Labels map[string]string -} diff --git a/pkg/api/node_pool_types_test.go b/pkg/api/node_pool_types_test.go deleted file mode 100644 index 97f23c40..00000000 --- a/pkg/api/node_pool_types_test.go +++ /dev/null @@ -1,215 +0,0 @@ -package api - -import ( - "testing" - - . "github.com/onsi/gomega" -) - -// TestNodePoolList_Index tests the Index() method for NodePoolList -func TestNodePoolList_Index(t *testing.T) { - RegisterTestingT(t) - - // Test empty list - emptyList := NodePoolList{} - emptyIndex := emptyList.Index() - Expect(len(emptyIndex)).To(Equal(0)) - - // Test single nodepool - nodepool1 := &NodePool{} - nodepool1.ID = "nodepool-1" - nodepool1.Name = "test-nodepool-1" - - singleList := NodePoolList{nodepool1} - singleIndex := singleList.Index() - Expect(len(singleIndex)).To(Equal(1)) - Expect(singleIndex["nodepool-1"]).To(Equal(nodepool1)) - - // Test multiple nodepools - nodepool2 := &NodePool{} - nodepool2.ID = "nodepool-2" - nodepool2.Name = "test-nodepool-2" - - nodepool3 := &NodePool{} - nodepool3.ID = "nodepool-3" - nodepool3.Name = "test-nodepool-3" - - multiList := NodePoolList{nodepool1, nodepool2, nodepool3} - multiIndex := multiList.Index() - Expect(len(multiIndex)).To(Equal(3)) - Expect(multiIndex["nodepool-1"]).To(Equal(nodepool1)) - Expect(multiIndex["nodepool-2"]).To(Equal(nodepool2)) - Expect(multiIndex["nodepool-3"]).To(Equal(nodepool3)) - - // Test duplicate IDs (later one overwrites earlier one) - nodepool1Duplicate := &NodePool{} - nodepool1Duplicate.ID = "nodepool-1" - nodepool1Duplicate.Name = "duplicate-nodepool" - - duplicateList := NodePoolList{nodepool1, nodepool1Duplicate} - duplicateIndex := duplicateList.Index() - Expect(len(duplicateIndex)).To(Equal(1)) - Expect(duplicateIndex["nodepool-1"]).To(Equal(nodepool1Duplicate)) - Expect(duplicateIndex["nodepool-1"].Name).To(Equal("duplicate-nodepool")) -} - -// TestNodePool_BeforeCreate_IDGeneration tests ID auto-generation -func TestNodePool_BeforeCreate_IDGeneration(t *testing.T) { - RegisterTestingT(t) - - nodepool := &NodePool{ - Name: "test-nodepool", - OwnerID: "cluster-123", - } - - err := nodepool.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(nodepool.ID).ToNot(BeEmpty()) - Expect(len(nodepool.ID)).To(BeNumerically(">", 0)) -} - -// TestNodePool_BeforeCreate_KindPreservation tests Kind is preserved (not auto-set) -func TestNodePool_BeforeCreate_KindPreservation(t *testing.T) { - RegisterTestingT(t) - - // Kind must be set before BeforeCreate (by handler validation) - nodepool := &NodePool{ - Name: "test-nodepool", - OwnerID: "cluster-123", - Kind: "NodePool", - } - - err := nodepool.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(nodepool.Kind).To(Equal("NodePool")) -} - -// TestNodePool_BeforeCreate_KindPreserved tests Kind is not overwritten -func TestNodePool_BeforeCreate_KindPreserved(t *testing.T) { - RegisterTestingT(t) - - // Test Kind preservation - nodepool := &NodePool{ - Name: "test-nodepool", - OwnerID: "cluster-123", - Kind: "CustomNodePool", - } - - err := nodepool.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(nodepool.Kind).To(Equal("CustomNodePool")) -} - -// TestNodePool_BeforeCreate_OwnerKindDefault tests OwnerKind default value -func TestNodePool_BeforeCreate_OwnerKindDefault(t *testing.T) { - RegisterTestingT(t) - - // Test default OwnerKind - nodepool := &NodePool{ - Name: "test-nodepool", - OwnerID: "cluster-123", - } - - err := nodepool.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(nodepool.OwnerKind).To(Equal("Cluster")) -} - -// TestNodePool_BeforeCreate_OwnerKindPreserved tests OwnerKind is not overwritten -func TestNodePool_BeforeCreate_OwnerKindPreserved(t *testing.T) { - RegisterTestingT(t) - - // Test OwnerKind preservation - nodepool := &NodePool{ - Name: "test-nodepool", - OwnerID: "custom-owner-123", - OwnerKind: "CustomOwner", - } - - err := nodepool.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(nodepool.OwnerKind).To(Equal("CustomOwner")) -} - -// TestNodePool_BeforeCreate_HrefGeneration tests Href auto-generation -func TestNodePool_BeforeCreate_HrefGeneration(t *testing.T) { - RegisterTestingT(t) - - // Test Href generation - nodepool := &NodePool{ - Name: "test-nodepool", - OwnerID: "cluster-abc", - } - - err := nodepool.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(nodepool.Href).To(Equal("/api/hyperfleet/v1/clusters/cluster-abc/nodepools/" + nodepool.ID)) -} - -// TestNodePool_BeforeCreate_HrefPreserved tests Href is not overwritten -func TestNodePool_BeforeCreate_HrefPreserved(t *testing.T) { - RegisterTestingT(t) - - // Test Href preservation - nodepool := &NodePool{ - Name: "test-nodepool", - OwnerID: "cluster-abc", - Href: "/custom/href", - } - - err := nodepool.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(nodepool.Href).To(Equal("/custom/href")) -} - -// TestNodePool_BeforeCreate_OwnerHrefGeneration tests OwnerHref auto-generation -func TestNodePool_BeforeCreate_OwnerHrefGeneration(t *testing.T) { - RegisterTestingT(t) - - // Test OwnerHref generation - nodepool := &NodePool{ - Name: "test-nodepool", - OwnerID: "cluster-xyz", - } - - err := nodepool.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(nodepool.OwnerHref).To(Equal("/api/hyperfleet/v1/clusters/cluster-xyz")) -} - -// TestNodePool_BeforeCreate_OwnerHrefPreserved tests OwnerHref is not overwritten -func TestNodePool_BeforeCreate_OwnerHrefPreserved(t *testing.T) { - RegisterTestingT(t) - - // Test OwnerHref preservation - nodepool := &NodePool{ - Name: "test-nodepool", - OwnerID: "cluster-xyz", - OwnerHref: "/custom/owner/href", - } - - err := nodepool.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(nodepool.OwnerHref).To(Equal("/custom/owner/href")) -} - -// TestNodePool_BeforeCreate_Complete tests all defaults set together -func TestNodePool_BeforeCreate_Complete(t *testing.T) { - RegisterTestingT(t) - - nodepool := &NodePool{ - Name: "test-nodepool", - OwnerID: "cluster-complete", - Kind: "NodePool", // Kind must be set before BeforeCreate - } - - err := nodepool.BeforeCreate(nil) - Expect(err).To(BeNil()) - - // Verify all defaults - Expect(nodepool.ID).ToNot(BeEmpty()) - Expect(nodepool.Kind).To(Equal("NodePool")) // Kind is preserved, not auto-set - Expect(nodepool.OwnerKind).To(Equal("Cluster")) - Expect(nodepool.Href).To(Equal("/api/hyperfleet/v1/clusters/cluster-complete/nodepools/" + nodepool.ID)) - Expect(nodepool.OwnerHref).To(Equal("/api/hyperfleet/v1/clusters/cluster-complete")) -} diff --git a/pkg/api/presenters/cluster.go b/pkg/api/presenters/cluster.go deleted file mode 100644 index 0a6bb8bf..00000000 --- a/pkg/api/presenters/cluster.go +++ /dev/null @@ -1,112 +0,0 @@ -package presenters - -import ( - "encoding/json" - "fmt" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" -) - -const clusterKind = "Cluster" - -// ConvertCluster converts openapi.ClusterCreateRequest to api.Cluster (GORM model) -func ConvertCluster(req *openapi.ClusterCreateRequest) (*api.Cluster, error) { - // Marshal Spec - specJSON, err := json.Marshal(req.Spec) - if err != nil { - return nil, fmt.Errorf("failed to marshal cluster spec: %w", err) - } - - // Marshal Labels - labels := make(map[string]string) - if req.Labels != nil { - labels = *req.Labels - } - labelsJSON, err := json.Marshal(labels) - if err != nil { - return nil, fmt.Errorf("failed to marshal cluster labels: %w", err) - } - - kind := req.Kind - if kind == "" { - kind = clusterKind - } - - return &api.Cluster{ - Kind: kind, - Name: req.Name, - Spec: specJSON, - Labels: labelsJSON, - Generation: 1, - }, nil -} - -// PresentCluster converts api.Cluster (GORM model) to openapi.Cluster -func PresentCluster(cluster *api.Cluster) (openapi.Cluster, error) { - // Unmarshal Spec - var spec map[string]interface{} - if len(cluster.Spec) > 0 { - if err := json.Unmarshal(cluster.Spec, &spec); err != nil { - return openapi.Cluster{}, fmt.Errorf("failed to unmarshal cluster spec: %w", err) - } - } - - // Unmarshal Labels - var labels map[string]string - if len(cluster.Labels) > 0 { - if err := json.Unmarshal(cluster.Labels, &labels); err != nil { - return openapi.Cluster{}, fmt.Errorf("failed to unmarshal cluster labels: %w", err) - } - } - - // Unmarshal StatusConditions - var statusConditions []api.ResourceCondition - if len(cluster.StatusConditions) > 0 { - if err := json.Unmarshal(cluster.StatusConditions, &statusConditions); err != nil { - return openapi.Cluster{}, fmt.Errorf("failed to unmarshal cluster status conditions: %w", err) - } - } - - // Generate Href if not set (fallback) - href := cluster.Href - if href == "" { - href = "/api/hyperfleet/v1/clusters/" + cluster.ID - } - - // Convert domain ResourceConditions to openapi format - openapiConditions := make([]openapi.ResourceCondition, len(statusConditions)) - for i, cond := range statusConditions { - openapiConditions[i] = openapi.ResourceCondition{ - CreatedTime: cond.CreatedTime, - LastTransitionTime: cond.LastTransitionTime, - LastUpdatedTime: cond.LastUpdatedTime, - Message: cond.Message, - ObservedGeneration: cond.ObservedGeneration, - Reason: cond.Reason, - Status: openapi.ResourceConditionStatus(cond.Status), - Type: cond.Type, - } - } - - result := openapi.Cluster{ - CreatedBy: cluster.CreatedBy, - CreatedTime: cluster.CreatedTime, - DeletedBy: cluster.DeletedBy, - DeletedTime: cluster.DeletedTime, - Generation: cluster.Generation, - Href: &href, - Id: &cluster.ID, - Kind: cluster.Kind, - Labels: &labels, - Name: cluster.Name, - Spec: spec, - Status: openapi.ClusterStatus{ - Conditions: openapiConditions, - }, - UpdatedBy: cluster.UpdatedBy, - UpdatedTime: cluster.UpdatedTime, - } - - return result, nil -} diff --git a/pkg/api/presenters/cluster_test.go b/pkg/api/presenters/cluster_test.go deleted file mode 100644 index 597bd71f..00000000 --- a/pkg/api/presenters/cluster_test.go +++ /dev/null @@ -1,404 +0,0 @@ -package presenters - -import ( - "encoding/json" - "testing" - "time" - - . "github.com/onsi/gomega" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" -) - -const ( - testConditionReconciled = "Reconciled" - testMessageComponentsUnavailable = "Some components unavailable" -) - -// Helper function to create test ClusterCreateRequest -func createTestClusterRequest() *openapi.ClusterCreateRequest { - labels := map[string]string{"env": "test"} - - return &openapi.ClusterCreateRequest{ - Labels: &labels, - Kind: "Cluster", - Name: "test-cluster", - Spec: map[string]interface{}{ - "region": "us-central1", - "provider": "gcp", - }, - } -} - -// TestConvertCluster_Complete tests conversion with all fields populated -func TestConvertCluster_Complete(t *testing.T) { - RegisterTestingT(t) - - req := createTestClusterRequest() - - result, err := ConvertCluster(req) - Expect(err).To(BeNil()) - - // Verify basic fields - Expect(result.Kind).To(Equal("Cluster")) - Expect(result.Name).To(Equal("test-cluster")) - - // Verify defaults - Expect(result.Generation).To(Equal(int32(1))) - - // Verify Spec marshaled correctly - var spec map[string]interface{} - err = json.Unmarshal(result.Spec, &spec) - Expect(err).To(BeNil()) - Expect(spec["region"]).To(Equal("us-central1")) - Expect(spec["provider"]).To(Equal("gcp")) - - // Verify Labels marshaled correctly - var labels map[string]string - err = json.Unmarshal(result.Labels, &labels) - Expect(err).To(BeNil()) - Expect(labels["env"]).To(Equal("test")) - - // StatusConditions initialization is handled by the service layer on create, not presenters. - Expect(len(result.StatusConditions)).To(Equal(0)) -} - -// TestConvertCluster_WithLabels tests conversion with labels -func TestConvertCluster_WithLabels(t *testing.T) { - RegisterTestingT(t) - - labels := map[string]string{ - "env": "production", - "team": "platform", - } - - req := &openapi.ClusterCreateRequest{ - Labels: &labels, - Kind: "Cluster", - Name: "labeled-cluster", - Spec: map[string]interface{}{"test": "spec"}, - } - - result, err := ConvertCluster(req) - Expect(err).To(BeNil()) - - var resultLabels map[string]string - err = json.Unmarshal(result.Labels, &resultLabels) - Expect(err).To(BeNil()) - Expect(resultLabels["env"]).To(Equal("production")) - Expect(resultLabels["team"]).To(Equal("platform")) -} - -// TestConvertCluster_WithoutLabels tests conversion with nil labels -func TestConvertCluster_WithoutLabels(t *testing.T) { - RegisterTestingT(t) - - req := &openapi.ClusterCreateRequest{ - Labels: nil, // Nil labels - Kind: "Cluster", - Name: "unlabeled-cluster", - Spec: map[string]interface{}{"test": "spec"}, - } - - result, err := ConvertCluster(req) - Expect(err).To(BeNil()) - - var resultLabels map[string]string - err = json.Unmarshal(result.Labels, &resultLabels) - Expect(err).To(BeNil()) - Expect(len(resultLabels)).To(Equal(0)) // Empty map -} - -// TestConvertCluster_SpecMarshaling tests complex spec with nested objects -func TestConvertCluster_SpecMarshaling(t *testing.T) { - RegisterTestingT(t) - - complexSpec := map[string]interface{}{ - "provider": "gcp", - "region": "us-east1", - "config": map[string]interface{}{ - "nodes": 3, - "networking": map[string]interface{}{ - "cidr": "10.0.0.0/16", - }, - }, - "tags": []string{"production", "critical"}, - } - - req := &openapi.ClusterCreateRequest{ - Kind: "Cluster", - Name: "complex-cluster", - Spec: complexSpec, - } - - result, err := ConvertCluster(req) - Expect(err).To(BeNil()) - - var resultSpec map[string]interface{} - err = json.Unmarshal(result.Spec, &resultSpec) - Expect(err).To(BeNil()) - Expect(resultSpec["provider"]).To(Equal("gcp")) - Expect(resultSpec["region"]).To(Equal("us-east1")) - - // Verify nested config - config := resultSpec["config"].(map[string]interface{}) - Expect(config["nodes"]).To(BeNumerically("==", 3)) - - networking := config["networking"].(map[string]interface{}) - Expect(networking["cidr"]).To(Equal("10.0.0.0/16")) - - // Verify tags array - tags := resultSpec["tags"].([]interface{}) - Expect(len(tags)).To(Equal(2)) -} - -// TestPresentCluster_Complete tests presentation with all fields -func TestPresentCluster_Complete(t *testing.T) { - RegisterTestingT(t) - - now := time.Now() - reason := testConditionReconciled - message := "Cluster is ready" - - // Create domain ResourceCondition - conditions := []api.ResourceCondition{ - { - ObservedGeneration: 5, - CreatedTime: now, - LastUpdatedTime: now, - Type: api.ResourceConditionTypeAvailable, - Status: api.ConditionTrue, - Reason: &reason, - Message: &message, - LastTransitionTime: now, - }, - } - conditionsJSON, _ := json.Marshal(conditions) - - spec := map[string]interface{}{"region": "us-west1"} - specJSON, _ := json.Marshal(spec) - - labels := map[string]string{"env": "staging"} - labelsJSON, _ := json.Marshal(labels) - - cluster := &api.Cluster{ - Kind: "Cluster", - Href: "/api/hyperfleet/v1/clusters/cluster-abc123", - Name: "presented-cluster", - Spec: specJSON, - Labels: labelsJSON, - Generation: 10, - StatusConditions: conditionsJSON, - CreatedBy: "user123@example.com", - UpdatedBy: "user456@example.com", - } - cluster.ID = "cluster-abc123" - cluster.CreatedTime = now - cluster.UpdatedTime = now - - result, err := PresentCluster(cluster) - Expect(err).To(BeNil()) - - // Verify basic fields - Expect(*result.Id).To(Equal("cluster-abc123")) - Expect(result.Kind).To(Equal("Cluster")) - Expect(*result.Href).To(Equal("/api/hyperfleet/v1/clusters/cluster-abc123")) - Expect(result.Name).To(Equal("presented-cluster")) - Expect(result.Generation).To(Equal(int32(10))) - Expect(result.CreatedBy).To(Equal("user123@example.com")) - Expect(result.UpdatedBy).To(Equal("user456@example.com")) - - // Verify Spec unmarshaled correctly - Expect(result.Spec["region"]).To(Equal("us-west1")) - - // Verify Labels unmarshaled correctly - Expect((*result.Labels)["env"]).To(Equal("staging")) - - // Verify Status - Expect(len(result.Status.Conditions)).To(Equal(1)) - Expect(result.Status.Conditions[0].Type).To(Equal(api.ResourceConditionTypeAvailable)) - Expect(result.Status.Conditions[0].Status).To(Equal(openapi.ResourceConditionStatusTrue)) - Expect(*result.Status.Conditions[0].Reason).To(Equal(testConditionReconciled)) - - // Verify timestamps - Expect(result.CreatedTime.Unix()).To(Equal(now.Unix())) - Expect(result.UpdatedTime.Unix()).To(Equal(now.Unix())) -} - -// TestPresentCluster_HrefGeneration tests that Href is generated if not set -func TestPresentCluster_HrefGeneration(t *testing.T) { - RegisterTestingT(t) - - cluster := &api.Cluster{ - Kind: "Cluster", - Href: "", // Empty Href - Name: "href-test", - Spec: []byte("{}"), - Labels: []byte("{}"), - StatusConditions: []byte("[]"), - } - cluster.ID = "cluster-xyz789" - - result, err := PresentCluster(cluster) - Expect(err).To(BeNil()) - - Expect(*result.Href).To(Equal("/api/hyperfleet/v1/clusters/cluster-xyz789")) -} - -// TestPresentCluster_StatusConditionsConversion tests condition conversion -func TestPresentCluster_StatusConditionsConversion(t *testing.T) { - RegisterTestingT(t) - - now := time.Now() - reason1 := "Reconciled" - message1 := "All systems operational" - reason2 := "Degraded" - message2 := testMessageComponentsUnavailable - - // Create multiple domain ResourceConditions - conditions := []api.ResourceCondition{ - { - ObservedGeneration: 3, - CreatedTime: now, - LastUpdatedTime: now, - Type: api.ResourceConditionTypeAvailable, - Status: api.ConditionTrue, - Reason: &reason1, - Message: &message1, - LastTransitionTime: now, - }, - { - ObservedGeneration: 3, - CreatedTime: now, - LastUpdatedTime: now, - Type: "Progressing", - Status: api.ConditionFalse, - Reason: &reason2, - Message: &message2, - LastTransitionTime: now, - }, - } - conditionsJSON, _ := json.Marshal(conditions) - - cluster := &api.Cluster{ - Kind: "Cluster", - Name: "multi-conditions-test", - Spec: []byte("{}"), - Labels: []byte("{}"), - StatusConditions: conditionsJSON, - } - cluster.ID = "cluster-multi-conditions" - cluster.CreatedTime = now - cluster.UpdatedTime = now - - result, err := PresentCluster(cluster) - Expect(err).To(BeNil()) - - // Verify both conditions converted correctly - Expect(len(result.Status.Conditions)).To(Equal(2)) - - // First condition - Expect(result.Status.Conditions[0].Type).To(Equal(api.ResourceConditionTypeAvailable)) - Expect(result.Status.Conditions[0].Status).To(Equal(openapi.ResourceConditionStatusTrue)) - Expect(*result.Status.Conditions[0].Reason).To(Equal(testConditionReconciled)) - Expect(*result.Status.Conditions[0].Message).To(Equal("All systems operational")) - - // Second condition - Expect(result.Status.Conditions[1].Type).To(Equal("Progressing")) - Expect(result.Status.Conditions[1].Status).To(Equal(openapi.ResourceConditionStatusFalse)) - Expect(*result.Status.Conditions[1].Reason).To(Equal("Degraded")) - Expect(*result.Status.Conditions[1].Message).To(Equal(testMessageComponentsUnavailable)) -} - -// TestConvertAndPresentCluster_RoundTrip tests data integrity through convert and present -func TestConvertAndPresentCluster_RoundTrip(t *testing.T) { - RegisterTestingT(t) - - originalReq := createTestClusterRequest() - - // Convert from OpenAPI request to domain - cluster, err := ConvertCluster(originalReq) - Expect(err).To(BeNil()) - - // Simulate database fields (ID, timestamps, created_by/updated_by set by service layer) - cluster.ID = "cluster-roundtrip-123" - now := time.Now() - cluster.CreatedTime = now - cluster.UpdatedTime = now - - // Present from domain back to OpenAPI - result, err := PresentCluster(cluster) - Expect(err).To(BeNil()) - - // Verify data integrity - Expect(*result.Id).To(Equal("cluster-roundtrip-123")) - Expect(result.Kind).To(Equal(originalReq.Kind)) - Expect(result.Name).To(Equal(originalReq.Name)) - - // Verify Spec preserved - Expect(result.Spec["region"]).To(Equal(originalReq.Spec["region"])) - Expect(result.Spec["provider"]).To(Equal(originalReq.Spec["provider"])) - - // Verify Labels preserved - Expect((*result.Labels)["env"]).To(Equal((*originalReq.Labels)["env"])) - - // Status initialization is handled by the service layer on create, not presenters. - Expect(len(result.Status.Conditions)).To(Equal(0)) -} - -// TestPresentCluster_MalformedSpec tests error handling for malformed Spec JSON -func TestPresentCluster_MalformedSpec(t *testing.T) { - RegisterTestingT(t) - - cluster := &api.Cluster{ - Kind: "Cluster", - Name: "malformed-spec-cluster", - Spec: []byte("{invalid json}"), // Malformed JSON - Labels: []byte("{}"), - StatusConditions: []byte("[]"), - } - cluster.ID = "cluster-malformed-spec" - - _, err := PresentCluster(cluster) - - Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring("failed to unmarshal cluster spec")) -} - -// TestPresentCluster_MalformedLabels tests error handling for malformed Labels JSON -func TestPresentCluster_MalformedLabels(t *testing.T) { - RegisterTestingT(t) - - cluster := &api.Cluster{ - Kind: "Cluster", - Name: "malformed-labels-cluster", - Spec: []byte("{}"), - Labels: []byte("{not valid json"), // Malformed JSON - StatusConditions: []byte("[]"), - } - cluster.ID = "cluster-malformed-labels" - - _, err := PresentCluster(cluster) - - Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring("failed to unmarshal cluster labels")) -} - -// TestPresentCluster_MalformedStatusConditions tests error handling for malformed StatusConditions JSON -func TestPresentCluster_MalformedStatusConditions(t *testing.T) { - RegisterTestingT(t) - - cluster := &api.Cluster{ - Kind: "Cluster", - Name: "malformed-conditions-cluster", - Spec: []byte("{}"), - Labels: []byte("{}"), - StatusConditions: []byte("[{incomplete"), // Malformed JSON - } - cluster.ID = "cluster-malformed-conditions" - - _, err := PresentCluster(cluster) - - Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring("failed to unmarshal cluster status conditions")) -} diff --git a/pkg/api/presenters/node_pool.go b/pkg/api/presenters/node_pool.go deleted file mode 100644 index 6d6df399..00000000 --- a/pkg/api/presenters/node_pool.go +++ /dev/null @@ -1,124 +0,0 @@ -package presenters - -import ( - "encoding/json" - "fmt" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" -) - -const nodePoolKind = "NodePool" - -// ConvertNodePool converts openapi.NodePoolCreateRequest to api.NodePool (GORM model) -func ConvertNodePool(req *openapi.NodePoolCreateRequest, ownerID string) (*api.NodePool, error) { - // Marshal Spec - specJSON, err := json.Marshal(req.Spec) - if err != nil { - return nil, fmt.Errorf("failed to marshal nodepool spec: %w", err) - } - - // Marshal Labels - labels := make(map[string]string) - if req.Labels != nil { - labels = *req.Labels - } - labelsJSON, err := json.Marshal(labels) - if err != nil { - return nil, fmt.Errorf("failed to marshal nodepool labels: %w", err) - } - - kind := req.Kind - if kind == "" { - kind = nodePoolKind - } - - return &api.NodePool{ - Kind: kind, - Name: req.Name, - Spec: specJSON, - Labels: labelsJSON, - OwnerID: ownerID, - OwnerKind: "Cluster", - }, nil -} - -// PresentNodePool converts api.NodePool (GORM model) to openapi.NodePool -func PresentNodePool(nodePool *api.NodePool) (openapi.NodePool, error) { - // Unmarshal Spec - var spec map[string]interface{} - if len(nodePool.Spec) > 0 { - if err := json.Unmarshal(nodePool.Spec, &spec); err != nil { - return openapi.NodePool{}, fmt.Errorf("failed to unmarshal nodepool spec: %w", err) - } - } - - // Unmarshal Labels - var labels map[string]string - if len(nodePool.Labels) > 0 { - if err := json.Unmarshal(nodePool.Labels, &labels); err != nil { - return openapi.NodePool{}, fmt.Errorf("failed to unmarshal nodepool labels: %w", err) - } - } - - // Unmarshal StatusConditions - var statusConditions []api.ResourceCondition - if len(nodePool.StatusConditions) > 0 { - if err := json.Unmarshal(nodePool.StatusConditions, &statusConditions); err != nil { - return openapi.NodePool{}, fmt.Errorf("failed to unmarshal nodepool status conditions: %w", err) - } - } - - // Generate Href if not set (fallback) - href := nodePool.Href - if href == "" { - href = fmt.Sprintf("/api/hyperfleet/v1/clusters/%s/nodepools/%s", nodePool.OwnerID, nodePool.ID) - } - - // Generate OwnerHref if not set (fallback) - ownerHref := nodePool.OwnerHref - if ownerHref == "" { - ownerHref = "/api/hyperfleet/v1/clusters/" + nodePool.OwnerID - } - - // Convert domain ResourceConditions to openapi format - openapiConditions := make([]openapi.ResourceCondition, len(statusConditions)) - for i, cond := range statusConditions { - openapiConditions[i] = openapi.ResourceCondition{ - CreatedTime: cond.CreatedTime, - LastTransitionTime: cond.LastTransitionTime, - LastUpdatedTime: cond.LastUpdatedTime, - Message: cond.Message, - ObservedGeneration: cond.ObservedGeneration, - Reason: cond.Reason, - Status: openapi.ResourceConditionStatus(cond.Status), - Type: cond.Type, - } - } - - result := openapi.NodePool{ - CreatedBy: nodePool.CreatedBy, - CreatedTime: nodePool.CreatedTime, - DeletedBy: nodePool.DeletedBy, - DeletedTime: nodePool.DeletedTime, - Generation: nodePool.Generation, - Href: &href, - Id: &nodePool.ID, - Kind: nodePool.Kind, - Labels: &labels, - Name: nodePool.Name, - OwnerReferences: openapi.ObjectReference{ - Id: &nodePool.OwnerID, - Kind: nodePool.OwnerKind, - Href: &ownerHref, - }, - Spec: spec, - Status: openapi.NodePoolStatus{ - Conditions: openapiConditions, - }, - UpdatedBy: nodePool.UpdatedBy, - UpdatedTime: nodePool.UpdatedTime, - } - - return result, nil -} diff --git a/pkg/api/presenters/node_pool_test.go b/pkg/api/presenters/node_pool_test.go deleted file mode 100644 index 0aea4277..00000000 --- a/pkg/api/presenters/node_pool_test.go +++ /dev/null @@ -1,450 +0,0 @@ -package presenters - -import ( - "encoding/json" - "testing" - "time" - - . "github.com/onsi/gomega" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" -) - -// Helper function to create test NodePoolCreateRequest -func createTestNodePoolRequest() *openapi.NodePoolCreateRequest { - labels := map[string]string{"env": "test"} - return &openapi.NodePoolCreateRequest{ - Kind: "NodePool", - Name: "test-nodepool", - Spec: map[string]interface{}{ - "replicas": 3, - "instanceType": "n1-standard-4", - }, - Labels: &labels, - } -} - -// TestConvertNodePool_Complete tests conversion with all fields populated -func TestConvertNodePool_Complete(t *testing.T) { - RegisterTestingT(t) - - req := createTestNodePoolRequest() - ownerID := "cluster-owner-123" - - result, err := ConvertNodePool(req, ownerID) - Expect(err).To(BeNil()) - - // Verify basic fields - Expect(result.Kind).To(Equal("NodePool")) - Expect(result.Name).To(Equal("test-nodepool")) - Expect(result.OwnerID).To(Equal("cluster-owner-123")) - Expect(result.OwnerKind).To(Equal("Cluster")) - - // Verify Spec marshaled correctly - var spec map[string]interface{} - err = json.Unmarshal(result.Spec, &spec) - Expect(err).To(BeNil()) - Expect(spec["replicas"]).To(BeNumerically("==", 3)) - Expect(spec["instanceType"]).To(Equal("n1-standard-4")) - - // Verify Labels marshaled correctly - var labels map[string]string - err = json.Unmarshal(result.Labels, &labels) - Expect(err).To(BeNil()) - Expect(labels["env"]).To(Equal("test")) - - // StatusConditions initialization is handled by the service layer on create, not presenters. - Expect(len(result.StatusConditions)).To(Equal(0)) -} - -// TestConvertNodePool_WithKind tests conversion with Kind specified -func TestConvertNodePool_WithKind(t *testing.T) { - RegisterTestingT(t) - - req := &openapi.NodePoolCreateRequest{ - Kind: "CustomNodePool", - Name: "custom-nodepool", - Spec: map[string]interface{}{"test": "spec"}, - Labels: nil, - } - - result, err := ConvertNodePool(req, "cluster-123") - Expect(err).To(BeNil()) - - Expect(result.Kind).To(Equal("CustomNodePool")) -} - -// TestConvertNodePool_WithoutKind tests conversion with nil Kind (uses default) -func TestConvertNodePool_WithoutKind(t *testing.T) { - RegisterTestingT(t) - - req := &openapi.NodePoolCreateRequest{ - Kind: "", // empty Kind falls back to default in ConvertNodePool - Name: "default-kind-nodepool", - Spec: map[string]interface{}{"test": "spec"}, - Labels: nil, - } - - result, err := ConvertNodePool(req, "cluster-456") - Expect(err).To(BeNil()) - - Expect(result.Kind).To(Equal("NodePool")) // Default value -} - -// TestConvertNodePool_WithLabels tests conversion with labels -func TestConvertNodePool_WithLabels(t *testing.T) { - RegisterTestingT(t) - - labels := map[string]string{ - "environment": "production", - "team": "platform", - "region": "us-east", - } - - req := &openapi.NodePoolCreateRequest{ - Name: "labeled-nodepool", - Spec: map[string]interface{}{"test": "spec"}, - Labels: &labels, - } - - result, err := ConvertNodePool(req, "cluster-789") - Expect(err).To(BeNil()) - - var resultLabels map[string]string - err = json.Unmarshal(result.Labels, &resultLabels) - Expect(err).To(BeNil()) - Expect(resultLabels["environment"]).To(Equal("production")) - Expect(resultLabels["team"]).To(Equal("platform")) - Expect(resultLabels["region"]).To(Equal("us-east")) -} - -// TestConvertNodePool_WithoutLabels tests conversion with nil labels -func TestConvertNodePool_WithoutLabels(t *testing.T) { - RegisterTestingT(t) - - req := &openapi.NodePoolCreateRequest{ - Name: "unlabeled-nodepool", - Spec: map[string]interface{}{"test": "spec"}, - Labels: nil, // Nil labels - } - - result, err := ConvertNodePool(req, "cluster-xyz") - Expect(err).To(BeNil()) - - var resultLabels map[string]string - err = json.Unmarshal(result.Labels, &resultLabels) - Expect(err).To(BeNil()) - Expect(len(resultLabels)).To(Equal(0)) // Empty map -} - -// TestPresentNodePool_Complete tests presentation with all fields -func TestPresentNodePool_Complete(t *testing.T) { - RegisterTestingT(t) - - now := time.Now() - reason := "Reconciled" - message := "NodePool is reconciled" - - // Create domain ResourceCondition - conditions := []api.ResourceCondition{ - { - ObservedGeneration: 5, - CreatedTime: now, - LastUpdatedTime: now, - Type: api.ResourceConditionTypeAvailable, - Status: api.ConditionTrue, - Reason: &reason, - Message: &message, - LastTransitionTime: now, - }, - } - conditionsJSON, _ := json.Marshal(conditions) - - spec := map[string]interface{}{"replicas": 5} - specJSON, _ := json.Marshal(spec) - - labels := map[string]string{"env": "staging"} - labelsJSON, _ := json.Marshal(labels) - - nodePool := &api.NodePool{ - Kind: "NodePool", - Href: "/api/hyperfleet/v1/clusters/cluster-abc/nodepools/nodepool-xyz", - Name: "presented-nodepool", - Spec: specJSON, - Labels: labelsJSON, - OwnerID: "cluster-abc", - OwnerKind: "Cluster", - OwnerHref: "/api/hyperfleet/v1/clusters/cluster-abc", - StatusConditions: conditionsJSON, - CreatedBy: "user123@example.com", - UpdatedBy: "user456@example.com", - } - nodePool.ID = "nodepool-xyz" - nodePool.CreatedTime = now - nodePool.UpdatedTime = now - - result, err := PresentNodePool(nodePool) - Expect(err).To(BeNil()) - - // Verify basic fields - Expect(*result.Id).To(Equal("nodepool-xyz")) - Expect(result.Kind).To(Equal("NodePool")) - Expect(*result.Href).To(Equal("/api/hyperfleet/v1/clusters/cluster-abc/nodepools/nodepool-xyz")) - Expect(result.Name).To(Equal("presented-nodepool")) - Expect(result.CreatedBy).To(Equal("user123@example.com")) - Expect(result.UpdatedBy).To(Equal("user456@example.com")) - - // Verify Spec unmarshaled correctly - Expect(result.Spec["replicas"]).To(BeNumerically("==", 5)) - - // Verify Labels unmarshaled correctly - Expect((*result.Labels)["env"]).To(Equal("staging")) - - // Verify OwnerReferences - Expect(*result.OwnerReferences.Id).To(Equal("cluster-abc")) - Expect(result.OwnerReferences.Kind).To(Equal("Cluster")) - Expect(*result.OwnerReferences.Href).To(Equal("/api/hyperfleet/v1/clusters/cluster-abc")) - - // Verify Status - Expect(len(result.Status.Conditions)).To(Equal(1)) - Expect(result.Status.Conditions[0].Type).To(Equal(api.ResourceConditionTypeAvailable)) - Expect(result.Status.Conditions[0].Status).To(Equal(openapi.ResourceConditionStatusTrue)) - - // Verify timestamps - Expect(result.CreatedTime.Unix()).To(Equal(now.Unix())) - Expect(result.UpdatedTime.Unix()).To(Equal(now.Unix())) -} - -// TestPresentNodePool_HrefGeneration tests that Href is generated if not set -func TestPresentNodePool_HrefGeneration(t *testing.T) { - RegisterTestingT(t) - - nodePool := &api.NodePool{ - Kind: "NodePool", - Href: "", // Empty Href - Name: "href-test", - Spec: []byte("{}"), - Labels: []byte("{}"), - OwnerID: "cluster-owner-456", - StatusConditions: []byte("[]"), - } - nodePool.ID = "nodepool-test-123" - - result, err := PresentNodePool(nodePool) - Expect(err).To(BeNil()) - - Expect(*result.Href).To(Equal("/api/hyperfleet/v1/clusters/cluster-owner-456/nodepools/nodepool-test-123")) -} - -// TestPresentNodePool_OwnerHrefGeneration tests that OwnerHref is generated if not set -func TestPresentNodePool_OwnerHrefGeneration(t *testing.T) { - RegisterTestingT(t) - - nodePool := &api.NodePool{ - Kind: "NodePool", - Name: "owner-href-test", - Spec: []byte("{}"), - Labels: []byte("{}"), - OwnerID: "cluster-owner-789", - OwnerHref: "", // Empty OwnerHref - StatusConditions: []byte("[]"), - } - nodePool.ID = "nodepool-owner-test" - - result, err := PresentNodePool(nodePool) - Expect(err).To(BeNil()) - - Expect(*result.OwnerReferences.Href).To(Equal("/api/hyperfleet/v1/clusters/cluster-owner-789")) -} - -// TestPresentNodePool_OwnerReferences tests OwnerReferences are set correctly -func TestPresentNodePool_OwnerReferences(t *testing.T) { - RegisterTestingT(t) - - nodePool := &api.NodePool{ - Kind: "NodePool", - Name: "owner-ref-test", - Spec: []byte("{}"), - Labels: []byte("{}"), - OwnerID: "cluster-ref-123", - OwnerKind: "Cluster", - StatusConditions: []byte("[]"), - } - nodePool.ID = "nodepool-ref-456" - - result, err := PresentNodePool(nodePool) - Expect(err).To(BeNil()) - - Expect(result.OwnerReferences.Id).ToNot(BeNil()) - Expect(*result.OwnerReferences.Id).To(Equal("cluster-ref-123")) - Expect(result.OwnerReferences.Kind).ToNot(BeEmpty()) - Expect(result.OwnerReferences.Kind).To(Equal("Cluster")) - Expect(result.OwnerReferences.Href).ToNot(BeNil()) -} - -// TestPresentNodePool_StatusConditionsConversion tests condition conversion -func TestPresentNodePool_StatusConditionsConversion(t *testing.T) { - RegisterTestingT(t) - - now := time.Now() - reason1 := "Scaling" - message1 := "Scaling in progress" - reason2 := "Healthy" - message2 := "All nodes healthy" - - // Create multiple domain ResourceConditions - conditions := []api.ResourceCondition{ - { - ObservedGeneration: 2, - CreatedTime: now, - LastUpdatedTime: now, - Type: "Progressing", - Status: api.ConditionTrue, - Reason: &reason1, - Message: &message1, - LastTransitionTime: now, - }, - { - ObservedGeneration: 2, - CreatedTime: now, - LastUpdatedTime: now, - Type: api.ResourceConditionTypeHealth, - Status: api.ConditionTrue, - Reason: &reason2, - Message: &message2, - LastTransitionTime: now, - }, - } - conditionsJSON, _ := json.Marshal(conditions) - - nodePool := &api.NodePool{ - Kind: "NodePool", - Name: "multi-conditions-test", - Spec: []byte("{}"), - Labels: []byte("{}"), - OwnerID: "cluster-conditions", - StatusConditions: conditionsJSON, - } - nodePool.ID = "nodepool-multi-conditions" - nodePool.CreatedTime = now - nodePool.UpdatedTime = now - - result, err := PresentNodePool(nodePool) - Expect(err).To(BeNil()) - - // Verify both conditions converted correctly - Expect(len(result.Status.Conditions)).To(Equal(2)) - - // First condition - Expect(result.Status.Conditions[0].Type).To(Equal("Progressing")) - Expect(result.Status.Conditions[0].Status).To(Equal(openapi.ResourceConditionStatusTrue)) - Expect(*result.Status.Conditions[0].Reason).To(Equal("Scaling")) - Expect(*result.Status.Conditions[0].Message).To(Equal("Scaling in progress")) - - // Second condition - Expect(result.Status.Conditions[1].Type).To(Equal(api.ResourceConditionTypeHealth)) - Expect(result.Status.Conditions[1].Status).To(Equal(openapi.ResourceConditionStatusTrue)) - Expect(*result.Status.Conditions[1].Reason).To(Equal("Healthy")) - Expect(*result.Status.Conditions[1].Message).To(Equal("All nodes healthy")) -} - -// TestConvertAndPresentNodePool_RoundTrip tests data integrity through convert and present -func TestConvertAndPresentNodePool_RoundTrip(t *testing.T) { - RegisterTestingT(t) - - originalReq := createTestNodePoolRequest() - ownerID := "cluster-roundtrip-789" - - // Convert from OpenAPI request to domain - nodePool, err := ConvertNodePool(originalReq, ownerID) - Expect(err).To(BeNil()) - - // Simulate database fields (ID, timestamps) - nodePool.ID = "nodepool-roundtrip-123" - now := time.Now() - nodePool.CreatedTime = now - nodePool.UpdatedTime = now - - // Present from domain back to OpenAPI - result, err := PresentNodePool(nodePool) - Expect(err).To(BeNil()) - - // Verify data integrity - Expect(*result.Id).To(Equal("nodepool-roundtrip-123")) - Expect(result.Kind).To(Equal(originalReq.Kind)) - Expect(result.Name).To(Equal(originalReq.Name)) - - // Verify Spec preserved - Expect(result.Spec["replicas"]).To(BeNumerically("==", originalReq.Spec["replicas"])) - Expect(result.Spec["instanceType"]).To(Equal(originalReq.Spec["instanceType"])) - - // Verify Labels preserved - Expect((*result.Labels)["env"]).To(Equal((*originalReq.Labels)["env"])) - - // Verify OwnerReferences set - Expect(*result.OwnerReferences.Id).To(Equal(ownerID)) - Expect(result.OwnerReferences.Kind).To(Equal("Cluster")) - - // Status initialization is handled by the service layer on create, not presenters. - Expect(len(result.Status.Conditions)).To(Equal(0)) -} - -// TestPresentNodePool_MalformedSpec tests error handling for malformed Spec JSON -func TestPresentNodePool_MalformedSpec(t *testing.T) { - RegisterTestingT(t) - - nodePool := &api.NodePool{ - Kind: "NodePool", - Name: "malformed-spec-nodepool", - Spec: []byte("{invalid json}"), // Malformed JSON - Labels: []byte("{}"), - OwnerID: "cluster-123", - StatusConditions: []byte("[]"), - } - nodePool.ID = "nodepool-malformed-spec" - - _, err := PresentNodePool(nodePool) - - Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring("failed to unmarshal nodepool spec")) -} - -// TestPresentNodePool_MalformedLabels tests error handling for malformed Labels JSON -func TestPresentNodePool_MalformedLabels(t *testing.T) { - RegisterTestingT(t) - - nodePool := &api.NodePool{ - Kind: "NodePool", - Name: "malformed-labels-nodepool", - Spec: []byte("{}"), - Labels: []byte("{not valid json"), // Malformed JSON - OwnerID: "cluster-456", - StatusConditions: []byte("[]"), - } - nodePool.ID = "nodepool-malformed-labels" - - _, err := PresentNodePool(nodePool) - - Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring("failed to unmarshal nodepool labels")) -} - -// TestPresentNodePool_MalformedStatusConditions tests error handling for malformed StatusConditions JSON -func TestPresentNodePool_MalformedStatusConditions(t *testing.T) { - RegisterTestingT(t) - - nodePool := &api.NodePool{ - Kind: "NodePool", - Name: "malformed-conditions-nodepool", - Spec: []byte("{}"), - Labels: []byte("{}"), - OwnerID: "cluster-789", - StatusConditions: []byte("[{incomplete"), // Malformed JSON - } - nodePool.ID = "nodepool-malformed-conditions" - - _, err := PresentNodePool(nodePool) - - Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring("failed to unmarshal nodepool status conditions")) -} diff --git a/pkg/api/presenters/presenter_test.go b/pkg/api/presenters/presenter_test.go index 10bb3ad8..90e83007 100644 --- a/pkg/api/presenters/presenter_test.go +++ b/pkg/api/presenters/presenter_test.go @@ -449,8 +449,11 @@ func TestSliceFilter(t *testing.T) { } const ( - testClusterID1 = "cluster-id1" - testNodePoolID1 = "nodepool-id1" + testClusterID1 = "cluster-id1" + testNodePoolID1 = "nodepool-id1" + testMessageComponentsUnavailable = "Some components unavailable" + clusterKind = "Cluster" + nodePoolKind = "NodePool" ) func createTestCluster() openapi.Cluster { diff --git a/pkg/api/presenters/resource.go b/pkg/api/presenters/resource.go index e5d28e6d..49ec63b0 100644 --- a/pkg/api/presenters/resource.go +++ b/pkg/api/presenters/resource.go @@ -4,8 +4,6 @@ import ( "encoding/json" "fmt" - "gorm.io/datatypes" - "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" @@ -19,16 +17,16 @@ func ConvertResource(req *openapi.ResourceCreateRequest) (*api.Resource, error) return nil, fmt.Errorf("failed to marshal spec: %w", err) } - labelsJSON, err := marshalLabels(req.Labels) - if err != nil { - return nil, fmt.Errorf("failed to marshal labels: %w", err) + labels, labelErr := convertLabelsToModel(req.Labels) + if labelErr != nil { + return nil, fmt.Errorf("invalid labels: %w", labelErr) } return &api.Resource{ Kind: req.Kind, Name: req.Name, Spec: specJSON, - Labels: labelsJSON, + Labels: labels, }, nil } @@ -54,7 +52,7 @@ func PresentResource(r *api.Resource) openapi.Resource { } } - labels := unmarshalLabels(r.Labels) + labels := presentLabels(r.Labels) resp := openapi.Resource{ Id: r.ID, @@ -127,27 +125,27 @@ func presentResourceConditions(conditions []api.ResourceCondition) []openapi.Res return result } -func marshalLabels(labels *map[string]string) (datatypes.JSON, error) { - if labels == nil { - return datatypes.JSON("{}"), nil +func convertLabelsToModel(labels *map[string]string) ([]api.ResourceLabel, error) { + if labels == nil || len(*labels) == 0 { + return nil, nil } - b, err := json.Marshal(*labels) - if err != nil { - return nil, err + result := make([]api.ResourceLabel, 0, len(*labels)) + for k, v := range *labels { + if err := api.ValidateLabel(k, v); err != nil { + return nil, err + } + result = append(result, api.ResourceLabel{Key: k, Value: v}) } - return datatypes.JSON(b), nil + return result, nil } -func unmarshalLabels(raw datatypes.JSON) *map[string]string { - if len(raw) == 0 { - return nil - } - var m map[string]string - if err := json.Unmarshal(raw, &m); err != nil { +func presentLabels(labels []api.ResourceLabel) *map[string]string { + if len(labels) == 0 { return nil } - if len(m) == 0 { - return nil + m := make(map[string]string, len(labels)) + for _, l := range labels { + m[l.Key] = l.Value } return &m } diff --git a/pkg/api/presenters/resource_test.go b/pkg/api/presenters/resource_test.go index b52b7215..738d02bb 100644 --- a/pkg/api/presenters/resource_test.go +++ b/pkg/api/presenters/resource_test.go @@ -28,7 +28,9 @@ func TestConvertResource(t *testing.T) { Expect(resource.Kind).To(Equal("Channel")) Expect(resource.Name).To(Equal("stable")) Expect(resource.Spec).NotTo(BeEmpty()) - Expect(string(resource.Labels)).To(ContainSubstring("env")) + Expect(resource.Labels).To(HaveLen(1)) + Expect(resource.Labels[0].Key).To(Equal("env")) + Expect(resource.Labels[0].Value).To(Equal("prod")) } func TestConvertResource_NilLabels(t *testing.T) { @@ -42,7 +44,7 @@ func TestConvertResource_NilLabels(t *testing.T) { resource, err := ConvertResource(req) Expect(err).NotTo(HaveOccurred()) - Expect(string(resource.Labels)).To(Equal("{}")) + Expect(resource.Labels).To(BeEmpty()) } func TestConvertResourceWithOwner(t *testing.T) { @@ -74,7 +76,7 @@ func TestPresentResource(t *testing.T) { Name: "stable", Href: "/api/hyperfleet/v1/channels/test-id", Spec: datatypes.JSON(`{"is_default":true}`), - Labels: datatypes.JSON(`{"env":"prod"}`), + Labels: []api.ResourceLabel{{Key: "env", Value: "prod"}}, Generation: 1, CreatedBy: "user@test.com", UpdatedBy: "user@test.com", diff --git a/pkg/api/resource.go b/pkg/api/resource.go index 75c42da7..fdaee95e 100644 --- a/pkg/api/resource.go +++ b/pkg/api/resource.go @@ -27,7 +27,7 @@ type Resource struct { Href string `json:"href,omitempty" gorm:"size:500"` CreatedBy string `json:"created_by" gorm:"size:255;not null"` UpdatedBy string `json:"updated_by" gorm:"size:255;not null"` - Labels datatypes.JSON `json:"labels,omitempty" gorm:"type:jsonb"` + Labels []ResourceLabel `json:"-" gorm:"foreignKey:ResourceID;references:ID"` Spec datatypes.JSON `json:"spec" gorm:"type:jsonb;not null"` Conditions []ResourceCondition `json:"-" gorm:"foreignKey:ResourceID;references:ID"` Generation int32 `json:"generation" gorm:"default:1;not null"` diff --git a/pkg/api/resource_label.go b/pkg/api/resource_label.go new file mode 100644 index 00000000..90e299af --- /dev/null +++ b/pkg/api/resource_label.go @@ -0,0 +1,31 @@ +package api + +import "fmt" + +const ( + MaxLabelKeyLen = 255 + MaxLabelValueLen = 255 +) + +type ResourceLabel struct { + ResourceID string `json:"-" gorm:"primaryKey;size:255;not null"` + Key string `json:"key" gorm:"primaryKey;size:255;not null"` + Value string `json:"value" gorm:"size:255;not null"` +} + +func (ResourceLabel) TableName() string { + return "resource_labels" +} + +func ValidateLabel(key, value string) error { + if key == "" { + return fmt.Errorf("label key cannot be empty") + } + if len(key) > MaxLabelKeyLen { + return fmt.Errorf("label key %q exceeds maximum length of %d", key, MaxLabelKeyLen) + } + if len(value) > MaxLabelValueLen { + return fmt.Errorf("label value for key %q exceeds maximum length of %d", key, MaxLabelValueLen) + } + return nil +} diff --git a/pkg/config/adapter.go b/pkg/config/adapter.go deleted file mode 100644 index 0171bfc6..00000000 --- a/pkg/config/adapter.go +++ /dev/null @@ -1,39 +0,0 @@ -package config - -// AdapterRequirementsConfig configures which adapters must be ready for resources -// Follows HyperFleet Configuration Standard -type AdapterRequirementsConfig struct { - Required RequiredAdapters `mapstructure:"required" json:"required" validate:"required"` -} - -// RequiredAdapters holds required adapters for different resource types -type RequiredAdapters struct { - Cluster []string `mapstructure:"cluster" json:"cluster" validate:"dive,required"` - Nodepool []string `mapstructure:"nodepool" json:"nodepool" validate:"dive,required"` -} - -// NewAdapterRequirementsConfig returns default AdapterRequirementsConfig values -// Note: This config typically REQUIRES values from environment or config file -// Empty defaults are provided but should be overridden -func NewAdapterRequirementsConfig() *AdapterRequirementsConfig { - return &AdapterRequirementsConfig{ - Required: RequiredAdapters{ - Cluster: []string{}, // Set via config file, env vars (JSON array), or CLI flags - Nodepool: []string{}, // Set via config file, env vars (JSON array), or CLI flags - }, - } -} - -// ============================================================ -// Convenience Accessor Methods -// ============================================================ - -// RequiredClusterAdapters returns cluster adapters -func (a *AdapterRequirementsConfig) RequiredClusterAdapters() []string { - return a.Required.Cluster -} - -// RequiredNodePoolAdapters returns nodepool adapters -func (a *AdapterRequirementsConfig) RequiredNodePoolAdapters() []string { - return a.Required.Nodepool -} diff --git a/pkg/config/adapter_test.go b/pkg/config/adapter_test.go deleted file mode 100644 index 0aadd492..00000000 --- a/pkg/config/adapter_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package config - -import ( - "context" - "testing" - - . "github.com/onsi/gomega" - "github.com/spf13/cobra" -) - -// Test that NewAdapterRequirementsConfig returns default empty config -func TestNewAdapterRequirementsConfig_ReturnsDefaults(t *testing.T) { - RegisterTestingT(t) - - config := NewAdapterRequirementsConfig() - - Expect(config).NotTo(BeNil()) - Expect(config.RequiredClusterAdapters()).To(Equal([]string{})) - Expect(config.RequiredNodePoolAdapters()).To(Equal([]string{})) -} - -// Test loading adapter config via ConfigLoader with valid JSON env vars -func TestConfigLoader_AdaptersFromEnv(t *testing.T) { - RegisterTestingT(t) - - // Set minimal test environment - SetMinimalTestEnv(t) - - // Set environment variables for adapters - t.Setenv("HYPERFLEET_ADAPTERS_REQUIRED_CLUSTER", `["validation","dns"]`) - t.Setenv("HYPERFLEET_ADAPTERS_REQUIRED_NODEPOOL", `["validation","hypershift"]`) - - loader := NewConfigLoader() - cmd := &cobra.Command{} - ctx := context.Background() - - // Load config - appConfig, err := loader.Load(ctx, cmd) - - Expect(err).NotTo(HaveOccurred()) - Expect(appConfig.Adapters).NotTo(BeNil()) - Expect(appConfig.Adapters.RequiredClusterAdapters()).To(Equal([]string{ - "validation", - "dns", - })) - Expect(appConfig.Adapters.RequiredNodePoolAdapters()).To(Equal([]string{ - "validation", - "hypershift", - })) -} - -// Test loading adapter config with single adapter -func TestConfigLoader_AdaptersSingleValue(t *testing.T) { - RegisterTestingT(t) - - SetMinimalTestEnv(t) - - t.Setenv("HYPERFLEET_ADAPTERS_REQUIRED_CLUSTER", `["validation"]`) - t.Setenv("HYPERFLEET_ADAPTERS_REQUIRED_NODEPOOL", `["hypershift"]`) - - loader := NewConfigLoader() - cmd := &cobra.Command{} - ctx := context.Background() - - appConfig, err := loader.Load(ctx, cmd) - - Expect(err).NotTo(HaveOccurred()) - Expect(appConfig.Adapters.RequiredClusterAdapters()).To(Equal([]string{"validation"})) - Expect(appConfig.Adapters.RequiredNodePoolAdapters()).To(Equal([]string{"hypershift"})) -} - -// Test loading adapter config with empty arrays -func TestConfigLoader_AdaptersEmptyArrays(t *testing.T) { - RegisterTestingT(t) - - appConfig, err := LoadTestConfig(t) - - Expect(err).NotTo(HaveOccurred()) - Expect(appConfig.Adapters.RequiredClusterAdapters()).To(Equal([]string{})) - Expect(appConfig.Adapters.RequiredNodePoolAdapters()).To(Equal([]string{})) -} - -// Test loading adapter config with invalid JSON should fail -func TestConfigLoader_AdaptersInvalidJSON(t *testing.T) { - RegisterTestingT(t) - - SetMinimalTestEnv(t) - - t.Setenv("HYPERFLEET_ADAPTERS_REQUIRED_CLUSTER", `not-valid-json`) - - loader := NewConfigLoader() - cmd := &cobra.Command{} - ctx := context.Background() - - _, err := loader.Load(ctx, cmd) - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to parse")) - Expect(err.Error()).To(ContainSubstring("HYPERFLEET_ADAPTERS_REQUIRED_CLUSTER")) -} - -// Test loading adapter config with custom adapter names -func TestConfigLoader_AdaptersCustomNames(t *testing.T) { - RegisterTestingT(t) - - SetMinimalTestEnv(t) - - t.Setenv("HYPERFLEET_ADAPTERS_REQUIRED_CLUSTER", `["custom-adapter-1","custom-adapter-2","test"]`) - t.Setenv("HYPERFLEET_ADAPTERS_REQUIRED_NODEPOOL", `["custom-np-adapter"]`) - - loader := NewConfigLoader() - cmd := &cobra.Command{} - ctx := context.Background() - - appConfig, err := loader.Load(ctx, cmd) - - Expect(err).NotTo(HaveOccurred()) - Expect(appConfig.Adapters.RequiredClusterAdapters()).To(Equal([]string{ - "custom-adapter-1", - "custom-adapter-2", - "test", - })) - Expect(appConfig.Adapters.RequiredNodePoolAdapters()).To(Equal([]string{ - "custom-np-adapter", - })) -} diff --git a/pkg/config/config.go b/pkg/config/config.go index 9e9f1485..e14d6f0a 100755 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -10,7 +10,6 @@ type ApplicationConfig struct { Health *HealthConfig `mapstructure:"health" json:"health" validate:"required"` Database *DatabaseConfig `mapstructure:"database" json:"database" validate:"required"` Logging *LoggingConfig `mapstructure:"logging" json:"logging" validate:"required"` - Adapters *AdapterRequirementsConfig `mapstructure:"adapters" json:"adapters" validate:"required"` Entities []registry.EntityDescriptor `mapstructure:"entities" json:"entities"` } @@ -23,6 +22,5 @@ func NewApplicationConfig() *ApplicationConfig { Health: NewHealthConfig(), Database: NewDatabaseConfig(), Logging: NewLoggingConfig(), - Adapters: NewAdapterRequirementsConfig(), } } diff --git a/pkg/config/dump.go b/pkg/config/dump.go index 4262db6b..fb63b7af 100644 --- a/pkg/config/dump.go +++ b/pkg/config/dump.go @@ -35,9 +35,6 @@ func DumpConfig(config *ApplicationConfig) string { BindAddress: %s Health: BindAddress: %s - Adapters: - ClusterAdapters: %v - NodePoolAdapters: %v Entities: %d registered (kinds: %v) `, config.Server.BindAddress(), @@ -56,8 +53,6 @@ func DumpConfig(config *ApplicationConfig) string { config.Logging.OTel.Enabled, config.Metrics.BindAddress(), config.Health.BindAddress(), - safeAdapterList(config.Adapters, true), - safeAdapterList(config.Adapters, false), len(config.Entities), entityKindNames(config.Entities), ) @@ -89,14 +84,3 @@ func entityKindNames(entities []registry.EntityDescriptor) []string { } return kinds } - -// safeAdapterList safely extracts adapter list, handling nil config -func safeAdapterList(adapters *AdapterRequirementsConfig, cluster bool) []string { - if adapters == nil { - return []string{} - } - if cluster { - return adapters.RequiredClusterAdapters() - } - return adapters.RequiredNodePoolAdapters() -} diff --git a/pkg/config/loader.go b/pkg/config/loader.go index bdc288c0..2f97f1a6 100644 --- a/pkg/config/loader.go +++ b/pkg/config/loader.go @@ -2,7 +2,6 @@ package config import ( "context" - "encoding/json" "fmt" "os" "reflect" @@ -65,11 +64,6 @@ func (l *ConfigLoader) Load(ctx context.Context, cmd *cobra.Command) (*Applicati return nil, err } - // Step 5.5: Handle JSON array environment variables (for adapters) - if err := l.handleJSONArrayEnvVars(ctx); err != nil { - return nil, fmt.Errorf("failed to handle JSON array env vars: %w", err) - } - // Step 6: Unmarshal into ApplicationConfig struct // Start with defaults, then overlay config file/env vars/flags config := NewApplicationConfig() @@ -233,45 +227,6 @@ func (l *ConfigLoader) validateConfig(config *ApplicationConfig) error { return fmt.Errorf("%s", strings.Join(errMessages, "\n")) } -// handleJSONArrayEnvVars processes environment variables containing JSON arrays -// Viper doesn't automatically parse JSON from env vars, so we handle this explicitly -// Used for: HYPERFLEET_ADAPTERS_CLUSTER_ADAPTERS and HYPERFLEET_ADAPTERS_NODEPOOL_ADAPTERS -func (l *ConfigLoader) handleJSONArrayEnvVars(ctx context.Context) error { - // Map of env var name -> viper key - jsonArrayMappings := map[string]string{ - EnvPrefix + "_ADAPTERS_REQUIRED_CLUSTER": "adapters.required.cluster", - EnvPrefix + "_ADAPTERS_REQUIRED_NODEPOOL": "adapters.required.nodepool", - } - - for envVar, viperKey := range jsonArrayMappings { - jsonValue := os.Getenv(envVar) - if jsonValue == "" { - continue - } - - // Parse JSON array - var arrayValue []string - if err := json.Unmarshal([]byte(jsonValue), &arrayValue); err != nil { - return fmt.Errorf("failed to parse %s as JSON array: %w (value: %s)", envVar, err, jsonValue) - } - - // Always set the parsed JSON array value to override Viper's auto-env CSV parsing. - // Viper's AutomaticEnv treats comma-separated strings as arrays, incorrectly parsing - // JSON arrays like '["a","b"]' as ["[\"a\"", "\"b\"]"] instead of ["a", "b"]. - // - // We use Set() to ensure proper JSON parsing overrides Viper's CSV parsing. - // This maintains ENV > Config > Default precedence for adapters. - // - // NOTE: Adapters currently have no CLI flags (see bindFlags line 494). - // If CLI flags are added in the future, this code needs updating to check - // if the value came from a flag before calling Set(). - l.viper.Set(viperKey, arrayValue) - logger.With(ctx, "env_var", envVar, "count", len(arrayValue)).Debug("Parsed JSON array from environment") - } - - return nil -} - // bindEnv wraps viper.BindEnv and tracks the key for validation func (l *ConfigLoader) bindEnv(key string) { l.viper.BindEnv(key) //nolint:errcheck,gosec // BindEnv errors are rare and indicate programming errors @@ -345,10 +300,6 @@ func (l *ConfigLoader) bindAllEnvVars() { l.bindEnv("health.shutdown_timeout") l.bindEnv("health.db_ping_timeout") - // Adapters config - l.bindEnv("adapters.required.cluster") - l.bindEnv("adapters.required.nodepool") - // Entities: config-file-only (complex list-of-struct type). // No env var or CLI flag bindings — loaded exclusively via YAML config. } diff --git a/pkg/config/loader_test.go b/pkg/config/loader_test.go index e00ed6ee..770c64fa 100644 --- a/pkg/config/loader_test.go +++ b/pkg/config/loader_test.go @@ -41,10 +41,6 @@ metrics: health: host: "localhost" port: 8080 -adapters: - required: - cluster: [] - nodepool: [] ` err := os.WriteFile(configPath, []byte(configContent), 0600) Expect(err).NotTo(HaveOccurred()) @@ -181,10 +177,6 @@ metrics: health: host: "localhost" port: 8080 -adapters: - required: - cluster: [] - nodepool: [] ` err := os.WriteFile(configPath, []byte(configContent), 0600) Expect(err).NotTo(HaveOccurred()) @@ -206,45 +198,6 @@ adapters: // JSON Array Parsing Tests // ============================================================== -// TestConfigLoader_JSONArrayParsing tests adapter array parsing -func TestConfigLoader_JSONArrayParsing(t *testing.T) { - RegisterTestingT(t) - - SetMinimalTestEnv(t) - - t.Setenv("HYPERFLEET_ADAPTERS_REQUIRED_CLUSTER", `["validation","dns","networking"]`) - t.Setenv("HYPERFLEET_ADAPTERS_REQUIRED_NODEPOOL", `["validation"]`) - - loader := NewConfigLoader() - cmd := &cobra.Command{} - ctx := context.Background() - - cfg, err := loader.Load(ctx, cmd) - - Expect(err).NotTo(HaveOccurred()) - Expect(cfg.Adapters.RequiredClusterAdapters()).To(Equal([]string{"validation", "dns", "networking"})) - Expect(cfg.Adapters.RequiredNodePoolAdapters()).To(Equal([]string{"validation"})) -} - -// TestConfigLoader_InvalidJSONArray tests error on malformed JSON -func TestConfigLoader_InvalidJSONArray(t *testing.T) { - RegisterTestingT(t) - - SetMinimalTestEnv(t) - - t.Setenv("HYPERFLEET_ADAPTERS_REQUIRED_CLUSTER", `["unclosed`) - - loader := NewConfigLoader() - cmd := &cobra.Command{} - ctx := context.Background() - - _, err := loader.Load(ctx, cmd) - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to parse")) - Expect(err.Error()).To(ContainSubstring("JSON array")) -} - // ============================================================== // Configuration Priority Tests // ============================================================== @@ -284,10 +237,6 @@ metrics: health: host: "localhost" port: 8080 -adapters: - required: - cluster: [] - nodepool: [] ` err := os.WriteFile(configPath, []byte(configContent), 0600) Expect(err).NotTo(HaveOccurred()) diff --git a/pkg/dao/cluster.go b/pkg/dao/cluster.go deleted file mode 100644 index 8fb767cb..00000000 --- a/pkg/dao/cluster.go +++ /dev/null @@ -1,84 +0,0 @@ -package dao - -import ( - "context" - - "gorm.io/gorm/clause" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" -) - -type ClusterDao interface { - Get(ctx context.Context, id string) (*api.Cluster, error) - GetForUpdate(ctx context.Context, id string) (*api.Cluster, error) - Create(ctx context.Context, cluster *api.Cluster) (*api.Cluster, error) - Save(ctx context.Context, cluster *api.Cluster) error - SaveStatusConditions(ctx context.Context, id string, statusConditions []byte) error - Delete(ctx context.Context, id string) error -} - -var _ ClusterDao = &sqlClusterDao{} - -type sqlClusterDao struct { - sessionFactory db.SessionFactory -} - -func NewClusterDao(sessionFactory db.SessionFactory) ClusterDao { - return &sqlClusterDao{sessionFactory: sessionFactory} -} - -func (d *sqlClusterDao) Get(ctx context.Context, id string) (*api.Cluster, error) { - g2 := d.sessionFactory.New(ctx) - var cluster api.Cluster - if err := g2.Take(&cluster, "id = ?", id).Error; err != nil { - return nil, err - } - return &cluster, nil -} - -func (d *sqlClusterDao) GetForUpdate(ctx context.Context, id string) (*api.Cluster, error) { - g2 := d.sessionFactory.New(ctx) - var cluster api.Cluster - if err := g2.Clauses(clause.Locking{Strength: "UPDATE"}).Take(&cluster, "id = ?", id).Error; err != nil { - return nil, err - } - return &cluster, nil -} - -func (d *sqlClusterDao) Create(ctx context.Context, cluster *api.Cluster) (*api.Cluster, error) { - g2 := d.sessionFactory.New(ctx) - if err := g2.Omit(clause.Associations).Create(cluster).Error; err != nil { - db.MarkForRollback(ctx, err) - return nil, err - } - return cluster, nil -} - -func (d *sqlClusterDao) Save(ctx context.Context, cluster *api.Cluster) error { - g2 := d.sessionFactory.New(ctx) - if err := g2.Omit(clause.Associations).Save(cluster).Error; err != nil { - db.MarkForRollback(ctx, err) - return err - } - return nil -} - -func (d *sqlClusterDao) SaveStatusConditions(ctx context.Context, id string, statusConditions []byte) error { - g2 := d.sessionFactory.New(ctx) - result := g2.Model(&api.Cluster{}).Where("id = ?", id).Update("status_conditions", statusConditions) - if result.Error != nil { - db.MarkForRollback(ctx, result.Error) - return result.Error - } - return nil -} - -func (d *sqlClusterDao) Delete(ctx context.Context, id string) error { - g2 := d.sessionFactory.New(ctx) - if err := g2.Omit(clause.Associations).Delete(&api.Cluster{Meta: api.Meta{ID: id}}).Error; err != nil { - db.MarkForRollback(ctx, err) - return err - } - return nil -} diff --git a/pkg/dao/mocks/cluster.go b/pkg/dao/mocks/cluster.go deleted file mode 100644 index bfa2652c..00000000 --- a/pkg/dao/mocks/cluster.go +++ /dev/null @@ -1,54 +0,0 @@ -package mocks - -import ( - "context" - - "gorm.io/gorm" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" -) - -var _ dao.ClusterDao = &clusterDaoMock{} - -type clusterDaoMock struct { - clusters api.ClusterList -} - -func (d *clusterDaoMock) Get(ctx context.Context, id string) (*api.Cluster, error) { - for _, cluster := range d.clusters { - if cluster.ID == id { - return cluster, nil - } - } - return nil, gorm.ErrRecordNotFound -} - -func (d *clusterDaoMock) GetForUpdate(ctx context.Context, id string) (*api.Cluster, error) { - return d.Get(ctx, id) -} - -func (d *clusterDaoMock) Create(ctx context.Context, cluster *api.Cluster) (*api.Cluster, error) { - d.clusters = append(d.clusters, cluster) - return cluster, nil -} - -func (d *clusterDaoMock) Save(ctx context.Context, cluster *api.Cluster) error { - d.clusters = append(d.clusters, cluster) - return nil -} - -func (d *clusterDaoMock) SaveStatusConditions(ctx context.Context, id string, statusConditions []byte) error { - for _, c := range d.clusters { - if c.ID == id { - c.StatusConditions = statusConditions - return nil - } - } - return gorm.ErrRecordNotFound -} - -func (d *clusterDaoMock) Delete(ctx context.Context, id string) error { - return errors.NotImplemented("Cluster").AsError() -} diff --git a/pkg/dao/mocks/node_pool.go b/pkg/dao/mocks/node_pool.go deleted file mode 100644 index ff899617..00000000 --- a/pkg/dao/mocks/node_pool.go +++ /dev/null @@ -1,80 +0,0 @@ -package mocks - -import ( - "context" - - "gorm.io/gorm" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" -) - -var _ dao.NodePoolDao = &nodePoolDaoMock{} - -type nodePoolDaoMock struct { - nodePools api.NodePoolList -} - -func (d *nodePoolDaoMock) Get(ctx context.Context, id string) (*api.NodePool, error) { - for _, nodePool := range d.nodePools { - if nodePool.ID == id { - return nodePool, nil - } - } - return nil, gorm.ErrRecordNotFound -} - -func (d *nodePoolDaoMock) GetByIDAndOwner(ctx context.Context, id string, ownerID string) (*api.NodePool, error) { - for _, nodePool := range d.nodePools { - if nodePool.ID == id && nodePool.OwnerID == ownerID { - return nodePool, nil - } - } - return nil, gorm.ErrRecordNotFound -} - -func (d *nodePoolDaoMock) GetForUpdate(ctx context.Context, id string) (*api.NodePool, error) { - return d.Get(ctx, id) -} - -func (d *nodePoolDaoMock) SaveStatusConditions(ctx context.Context, id string, statusConditions []byte) error { - for _, np := range d.nodePools { - if np.ID == id { - np.StatusConditions = statusConditions - return nil - } - } - return gorm.ErrRecordNotFound -} - -func (d *nodePoolDaoMock) Create(ctx context.Context, nodePool *api.NodePool) (*api.NodePool, error) { - d.nodePools = append(d.nodePools, nodePool) - return nodePool, nil -} - -func (d *nodePoolDaoMock) Save(ctx context.Context, nodePool *api.NodePool) error { - d.nodePools = append(d.nodePools, nodePool) - return nil -} - -func (d *nodePoolDaoMock) Delete(ctx context.Context, id string) error { - return errors.NotImplemented("NodePool").AsError() -} - -func (d *nodePoolDaoMock) FindByOwner(ctx context.Context, ownerID string) (api.NodePoolList, error) { - return nil, errors.NotImplemented("NodePool").AsError() -} - -func (d *nodePoolDaoMock) SaveAll(ctx context.Context, nodePools api.NodePoolList) error { - return errors.NotImplemented("NodePool").AsError() -} - -func (d *nodePoolDaoMock) ExistsByOwner(ctx context.Context, ownerID string) (bool, error) { - for _, np := range d.nodePools { - if np.OwnerID == ownerID { - return true, nil - } - } - return false, nil -} diff --git a/pkg/dao/node_pool.go b/pkg/dao/node_pool.go deleted file mode 100644 index 6446630f..00000000 --- a/pkg/dao/node_pool.go +++ /dev/null @@ -1,127 +0,0 @@ -package dao - -import ( - "context" - - "gorm.io/gorm/clause" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" -) - -type NodePoolDao interface { - Get(ctx context.Context, id string) (*api.NodePool, error) - GetByIDAndOwner(ctx context.Context, id string, ownerID string) (*api.NodePool, error) - GetForUpdate(ctx context.Context, id string) (*api.NodePool, error) - Create(ctx context.Context, nodePool *api.NodePool) (*api.NodePool, error) - Save(ctx context.Context, nodePool *api.NodePool) error - SaveStatusConditions(ctx context.Context, id string, statusConditions []byte) error - Delete(ctx context.Context, id string) error - FindByOwner(ctx context.Context, ownerID string) (api.NodePoolList, error) - SaveAll(ctx context.Context, nodePools api.NodePoolList) error - ExistsByOwner(ctx context.Context, ownerID string) (bool, error) -} - -var _ NodePoolDao = &sqlNodePoolDao{} - -type sqlNodePoolDao struct { - sessionFactory db.SessionFactory -} - -func NewNodePoolDao(sessionFactory db.SessionFactory) NodePoolDao { - return &sqlNodePoolDao{sessionFactory: sessionFactory} -} - -func (d *sqlNodePoolDao) Get(ctx context.Context, id string) (*api.NodePool, error) { - g2 := d.sessionFactory.New(ctx) - var nodePool api.NodePool - if err := g2.Take(&nodePool, "id = ?", id).Error; err != nil { - return nil, err - } - return &nodePool, nil -} - -func (d *sqlNodePoolDao) GetByIDAndOwner(ctx context.Context, id string, ownerID string) (*api.NodePool, error) { - g2 := d.sessionFactory.New(ctx) - var nodePool api.NodePool - if err := g2.Take(&nodePool, "id = ? AND owner_id = ?", id, ownerID).Error; err != nil { - return nil, err - } - return &nodePool, nil -} - -func (d *sqlNodePoolDao) GetForUpdate(ctx context.Context, id string) (*api.NodePool, error) { - g2 := d.sessionFactory.New(ctx) - var nodePool api.NodePool - if err := g2.Clauses(clause.Locking{Strength: "UPDATE"}).Take(&nodePool, "id = ?", id).Error; err != nil { - return nil, err - } - return &nodePool, nil -} - -func (d *sqlNodePoolDao) SaveStatusConditions(ctx context.Context, id string, statusConditions []byte) error { - g2 := d.sessionFactory.New(ctx) - result := g2.Model(&api.NodePool{}).Where("id = ?", id).Update("status_conditions", statusConditions) - if result.Error != nil { - db.MarkForRollback(ctx, result.Error) - return result.Error - } - return nil -} - -func (d *sqlNodePoolDao) Create(ctx context.Context, nodePool *api.NodePool) (*api.NodePool, error) { - g2 := d.sessionFactory.New(ctx) - if err := g2.Omit(clause.Associations).Create(nodePool).Error; err != nil { - db.MarkForRollback(ctx, err) - return nil, err - } - return nodePool, nil -} - -func (d *sqlNodePoolDao) Save(ctx context.Context, nodePool *api.NodePool) error { - g2 := d.sessionFactory.New(ctx) - if err := g2.Omit(clause.Associations).Save(nodePool).Error; err != nil { - db.MarkForRollback(ctx, err) - return err - } - return nil -} - -func (d *sqlNodePoolDao) Delete(ctx context.Context, id string) error { - g2 := d.sessionFactory.New(ctx) - if err := g2.Omit(clause.Associations).Delete(&api.NodePool{Meta: api.Meta{ID: id}}).Error; err != nil { - db.MarkForRollback(ctx, err) - return err - } - return nil -} - -func (d *sqlNodePoolDao) FindByOwner(ctx context.Context, ownerID string) (api.NodePoolList, error) { - g2 := d.sessionFactory.New(ctx) - var nodePools api.NodePoolList - if err := g2.Where("owner_id = ?", ownerID).Find(&nodePools).Error; err != nil { - return nil, err - } - return nodePools, nil -} - -func (d *sqlNodePoolDao) SaveAll(ctx context.Context, nodePools api.NodePoolList) error { - if len(nodePools) == 0 { - return nil - } - g2 := d.sessionFactory.New(ctx) - if err := g2.Omit(clause.Associations).Save(nodePools).Error; err != nil { - db.MarkForRollback(ctx, err) - return err - } - return nil -} - -func (d *sqlNodePoolDao) ExistsByOwner(ctx context.Context, ownerID string) (bool, error) { - g2 := d.sessionFactory.New(ctx) - var count int64 - if err := g2.Model(&api.NodePool{}).Where("owner_id = ?", ownerID).Limit(1).Count(&count).Error; err != nil { - return false, err - } - return count > 0, nil -} diff --git a/pkg/dao/resource.go b/pkg/dao/resource.go index 18db1cdb..d442fe0a 100644 --- a/pkg/dao/resource.go +++ b/pkg/dao/resource.go @@ -38,7 +38,8 @@ func NewResourceDao(sessionFactory db.SessionFactory) ResourceDao { func (d *sqlResourceDao) Get(ctx context.Context, kind, id string) (*api.Resource, error) { g2 := d.sessionFactory.New(ctx) var resource api.Resource - if err := g2.Preload("Conditions").Take(&resource, "kind = ? AND id = ?", kind, id).Error; err != nil { + if err := g2.Preload("Conditions").Preload("Labels"). + Take(&resource, "kind = ? AND id = ?", kind, id).Error; err != nil { return nil, err } return &resource, nil @@ -47,7 +48,7 @@ func (d *sqlResourceDao) Get(ctx context.Context, kind, id string) (*api.Resourc func (d *sqlResourceDao) GetForUpdate(ctx context.Context, kind, id string) (*api.Resource, error) { g2 := d.sessionFactory.New(ctx) var resource api.Resource - if err := g2.Preload("Conditions").Clauses(clause.Locking{Strength: "UPDATE"}).Take( + if err := g2.Preload("Labels").Preload("Conditions").Clauses(clause.Locking{Strength: "UPDATE"}).Take( &resource, "kind = ? AND id = ?", kind, id).Error; err != nil { return nil, err } @@ -57,7 +58,7 @@ func (d *sqlResourceDao) GetForUpdate(ctx context.Context, kind, id string) (*ap func (d *sqlResourceDao) GetByOwner(ctx context.Context, kind, id, ownerID string) (*api.Resource, error) { g2 := d.sessionFactory.New(ctx) var resource api.Resource - if err := g2.Preload("Conditions"). + if err := g2.Preload("Conditions").Preload("Labels"). Take(&resource, "kind = ? AND id = ? AND owner_id = ?", kind, id, ownerID).Error; err != nil { return nil, err } @@ -130,7 +131,7 @@ func (d *sqlResourceDao) ExistsSoftDeletedByOwner(ctx context.Context, kinds []s func (d *sqlResourceDao) FindByKind(ctx context.Context, kind string) (api.ResourceList, error) { g2 := d.sessionFactory.New(ctx) var resources api.ResourceList - if err := g2.Where("kind = ?", kind).Find(&resources).Error; err != nil { + if err := g2.Preload("Labels").Preload("Conditions").Where("kind = ?", kind).Find(&resources).Error; err != nil { return nil, err } return resources, nil @@ -139,7 +140,8 @@ func (d *sqlResourceDao) FindByKind(ctx context.Context, kind string) (api.Resou func (d *sqlResourceDao) FindByKindAndOwner(ctx context.Context, kind, ownerID string) (api.ResourceList, error) { g2 := d.sessionFactory.New(ctx) var resources api.ResourceList - if err := g2.Where("kind = ? AND owner_id = ?", kind, ownerID).Find(&resources).Error; err != nil { + if err := g2.Preload("Labels").Preload("Conditions"). + Where("kind = ? AND owner_id = ?", kind, ownerID).Find(&resources).Error; err != nil { return nil, err } return resources, nil @@ -159,7 +161,7 @@ func (d *sqlResourceDao) FindByKindAndOwnerForUpdate( ) (api.ResourceList, error) { g2 := d.sessionFactory.New(ctx) var resources api.ResourceList - if err := g2.Clauses(clause.Locking{Strength: "UPDATE"}). + if err := g2.Preload("Labels").Preload("Conditions").Clauses(clause.Locking{Strength: "UPDATE"}). Where("kind = ? AND owner_id = ?", kind, ownerID).Find(&resources).Error; err != nil { return nil, err } diff --git a/pkg/dao/resource_label.go b/pkg/dao/resource_label.go new file mode 100644 index 00000000..46b75fb7 --- /dev/null +++ b/pkg/dao/resource_label.go @@ -0,0 +1,49 @@ +package dao + +import ( + "context" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" +) + +//go:generate mockgen-v0.6.0 -source=resource_label.go -package=dao -destination=resource_label_mock.go + +type ResourceLabelDao interface { + ReplaceLabels(ctx context.Context, resourceID string, labels []api.ResourceLabel) error +} + +var _ ResourceLabelDao = &sqlResourceLabelDao{} + +type sqlResourceLabelDao struct { + sessionFactory db.SessionFactory +} + +func NewResourceLabelDao(sessionFactory db.SessionFactory) ResourceLabelDao { + return &sqlResourceLabelDao{sessionFactory: sessionFactory} +} + +func (d *sqlResourceLabelDao) ReplaceLabels( + ctx context.Context, resourceID string, labels []api.ResourceLabel, +) error { + g2 := d.sessionFactory.New(ctx) + + if err := g2.Where("resource_id = ?", resourceID).Delete(&api.ResourceLabel{}).Error; err != nil { + db.MarkForRollback(ctx, err) + return err + } + + if len(labels) > 0 { + rows := make([]api.ResourceLabel, len(labels)) + copy(rows, labels) + for i := range rows { + rows[i].ResourceID = resourceID + } + if err := g2.Create(&rows).Error; err != nil { + db.MarkForRollback(ctx, err) + return err + } + } + + return nil +} diff --git a/pkg/db/migrations/202511111044_add_clusters.go b/pkg/db/migrations/202511111044_add_clusters.go deleted file mode 100644 index cfcfec30..00000000 --- a/pkg/db/migrations/202511111044_add_clusters.go +++ /dev/null @@ -1,66 +0,0 @@ -package migrations - -// Migrations should NEVER use types from other packages. Types can change -// and then migrations run on a _new_ database will fail or behave unexpectedly. -// Instead of importing types, always re-create the type in the migration, as -// is done here, even though the same type is defined in pkg/api - -import ( - "gorm.io/gorm" - - "github.com/go-gormigrate/gormigrate/v2" -) - -func addClusters() *gormigrate.Migration { - return &gormigrate.Migration{ - ID: "202511111044", - Migrate: func(tx *gorm.DB) error { - // Create clusters table - // ClusterStatus is stored as JSONB in status_conditions, and status fields - // are flattened for efficient querying - createTableSQL := ` - CREATE TABLE IF NOT EXISTS clusters ( - id VARCHAR(255) PRIMARY KEY, - created_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), - deleted_at TIMESTAMPTZ NULL, - - -- Core fields - kind VARCHAR(255) NOT NULL DEFAULT 'Cluster', - name VARCHAR(63) NOT NULL, - spec JSONB NOT NULL, - labels JSONB NULL, - href VARCHAR(500), - - -- Version control - generation INTEGER NOT NULL DEFAULT 1, - - -- Status (conditions-only model) - status_conditions JSONB NULL, - - -- Audit fields - created_by VARCHAR(255) NOT NULL, - updated_by VARCHAR(255) NOT NULL - ); - ` - - if err := tx.Exec(createTableSQL).Error; err != nil { - return err - } - - // Create index on deleted_at for soft deletes - if err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_clusters_deleted_at ON clusters(deleted_at);").Error; err != nil { - return err - } - - // Create unique index on name (only for non-deleted records) - createIndexSQL := "CREATE UNIQUE INDEX IF NOT EXISTS idx_clusters_name " + - "ON clusters(name) WHERE deleted_at IS NULL;" - if err := tx.Exec(createIndexSQL).Error; err != nil { - return err - } - - return nil - }, - } -} diff --git a/pkg/db/migrations/202511111055_add_node_pools.go b/pkg/db/migrations/202511111055_add_node_pools.go deleted file mode 100644 index 2c4924e0..00000000 --- a/pkg/db/migrations/202511111055_add_node_pools.go +++ /dev/null @@ -1,87 +0,0 @@ -package migrations - -// Migrations should NEVER use types from other packages. Types can change -// and then migrations run on a _new_ database will fail or behave unexpectedly. -// Instead of importing types, always re-create the type in the migration, as -// is done here, even though the same type is defined in pkg/api - -import ( - "gorm.io/gorm" - - "github.com/go-gormigrate/gormigrate/v2" -) - -func addNodePools() *gormigrate.Migration { - return &gormigrate.Migration{ - ID: "202511111055", - Migrate: func(tx *gorm.DB) error { - // Create node_pools table - createTableSQL := ` - CREATE TABLE IF NOT EXISTS node_pools ( - id VARCHAR(255) PRIMARY KEY, - created_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), - deleted_at TIMESTAMPTZ NULL, - - -- Core fields - kind VARCHAR(255) NOT NULL DEFAULT 'NodePool', - name VARCHAR(255) NOT NULL, - spec JSONB NOT NULL, - labels JSONB NULL, - href VARCHAR(500), - - -- Owner References (flattened) - owner_id VARCHAR(255) NOT NULL, - owner_kind VARCHAR(50) NOT NULL, - owner_href VARCHAR(500) NULL, - - -- Version control - generation INTEGER NOT NULL DEFAULT 1, - - -- Status (conditions-only model) - status_conditions JSONB NULL, - - -- Audit fields - created_by VARCHAR(255) NOT NULL, - updated_by VARCHAR(255) NOT NULL - ); - ` - - if err := tx.Exec(createTableSQL).Error; err != nil { - return err - } - - // Create index on deleted_at for soft deletes - createIdxSQL := "CREATE INDEX IF NOT EXISTS idx_node_pools_deleted_at " + - "ON node_pools(deleted_at);" - if err := tx.Exec(createIdxSQL).Error; err != nil { - return err - } - - // Create index on owner_id for foreign key lookups - if err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_node_pools_owner_id ON node_pools(owner_id);").Error; err != nil { - return err - } - - // Create unique index on (owner_id, name) to prevent duplicate nodepool names within a cluster - createUniqueIdxSQL := "CREATE UNIQUE INDEX IF NOT EXISTS idx_node_pools_owner_name " + - "ON node_pools(owner_id, name) WHERE deleted_at IS NULL;" - if err := tx.Exec(createUniqueIdxSQL).Error; err != nil { - return err - } - - // Add foreign key constraint to clusters - addFKSQL := ` - ALTER TABLE node_pools - ADD CONSTRAINT fk_node_pools_clusters - FOREIGN KEY (owner_id) REFERENCES clusters(id) - ON DELETE RESTRICT ON UPDATE RESTRICT; - ` - if err := tx.Exec(addFKSQL).Error; err != nil { - return err - } - - return nil - }, - } -} diff --git a/pkg/db/migrations/202601210001_add_conditions_gin_index.go b/pkg/db/migrations/202601210001_add_conditions_gin_index.go deleted file mode 100644 index ad7868d5..00000000 --- a/pkg/db/migrations/202601210001_add_conditions_gin_index.go +++ /dev/null @@ -1,37 +0,0 @@ -package migrations - -import ( - "github.com/go-gormigrate/gormigrate/v2" - "gorm.io/gorm" -) - -// addConditionsGinIndex adds expression indexes on the Ready condition -// within status_conditions JSONB columns for efficient lookups. -func addConditionsGinIndex() *gormigrate.Migration { - return &gormigrate.Migration{ - ID: "202601210001", - Migrate: func(tx *gorm.DB) error { - // Create expression index on clusters for Ready condition lookups - if err := tx.Exec(` - CREATE INDEX IF NOT EXISTS idx_clusters_ready_status - ON clusters USING BTREE (( - jsonb_path_query_first(status_conditions, '$[*] ? (@.type == "Ready")') - )); - `).Error; err != nil { - return err - } - - // Create expression index on node_pools for Ready condition lookups - if err := tx.Exec(` - CREATE INDEX IF NOT EXISTS idx_node_pools_ready_status - ON node_pools USING BTREE (( - jsonb_path_query_first(status_conditions, '$[*] ? (@.type == "Ready")') - )); - `).Error; err != nil { - return err - } - - return nil - }, - } -} diff --git a/pkg/db/migrations/202604160001_soft_delete_schema.go b/pkg/db/migrations/202604160001_soft_delete_schema.go index 38d83e5f..202ea238 100644 --- a/pkg/db/migrations/202604160001_soft_delete_schema.go +++ b/pkg/db/migrations/202604160001_soft_delete_schema.go @@ -15,40 +15,6 @@ func addSoftDeleteSchema() *gormigrate.Migration { return &gormigrate.Migration{ ID: "202604160001", Migrate: func(tx *gorm.DB) error { - // clusters: rename deleted_at → deleted_time, add deleted_by, fix partial index - if err := tx.Exec("ALTER TABLE clusters RENAME COLUMN deleted_at TO deleted_time;").Error; err != nil { - return err - } - if err := tx.Exec("ALTER TABLE clusters ADD COLUMN IF NOT EXISTS deleted_by VARCHAR(255) NULL;").Error; err != nil { - return err - } - if err := tx.Exec("DROP INDEX IF EXISTS idx_clusters_deleted_at;").Error; err != nil { - return err - } - if err := tx.Exec("DROP INDEX IF EXISTS idx_clusters_name;").Error; err != nil { - return err - } - if err := tx.Exec("CREATE UNIQUE INDEX idx_clusters_name ON clusters(name) WHERE deleted_time IS NULL;").Error; err != nil { //nolint:lll - return err - } - - // node_pools: rename deleted_at → deleted_time, add deleted_by, fix partial index - if err := tx.Exec("ALTER TABLE node_pools RENAME COLUMN deleted_at TO deleted_time;").Error; err != nil { - return err - } - if err := tx.Exec("ALTER TABLE node_pools ADD COLUMN IF NOT EXISTS deleted_by VARCHAR(255) NULL;").Error; err != nil { //nolint:lll - return err - } - if err := tx.Exec("DROP INDEX IF EXISTS idx_node_pools_deleted_at;").Error; err != nil { - return err - } - if err := tx.Exec("DROP INDEX IF EXISTS idx_node_pools_owner_name;").Error; err != nil { - return err - } - if err := tx.Exec("CREATE UNIQUE INDEX idx_node_pools_owner_name ON node_pools(owner_id, name) WHERE deleted_time IS NULL;").Error; err != nil { //nolint:lll - return err - } - // adapter_statuses: drop unused deleted_at column, make unique index unconditional if err := tx.Exec("ALTER TABLE adapter_statuses DROP COLUMN IF EXISTS deleted_at;").Error; err != nil { return err diff --git a/pkg/db/migrations/202604230001_add_nodepool_owner_deleted_index.go b/pkg/db/migrations/202604230001_add_nodepool_owner_deleted_index.go deleted file mode 100644 index d7b2b422..00000000 --- a/pkg/db/migrations/202604230001_add_nodepool_owner_deleted_index.go +++ /dev/null @@ -1,26 +0,0 @@ -package migrations - -// Migrations should NEVER use types from other packages. Types can change -// and then migrations run on a _new_ database will fail or behave unexpectedly. -// Instead of importing types, always re-create the type in the migration, as -// is done here, even though the same type is defined in pkg/api - -import ( - "gorm.io/gorm" - - "github.com/go-gormigrate/gormigrate/v2" -) - -func addNodePoolOwnerDeletedIndex() *gormigrate.Migration { - return &gormigrate.Migration{ - ID: "202604230001", - Migrate: func(tx *gorm.DB) error { - // Add composite index on (owner_id, deleted_time) for efficient queries - // when fetching soft-deleted nodepools by owner. - if err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_node_pools_owner_deleted ON node_pools(owner_id, deleted_time);").Error; err != nil { //nolint:lll - return err - } - return nil - }, - } -} diff --git a/pkg/db/migrations/202605280001_remove_ready_condition.go b/pkg/db/migrations/202605280001_remove_ready_condition.go deleted file mode 100644 index 1178ffb1..00000000 --- a/pkg/db/migrations/202605280001_remove_ready_condition.go +++ /dev/null @@ -1,58 +0,0 @@ -package migrations - -import ( - "github.com/go-gormigrate/gormigrate/v2" - "gorm.io/gorm" -) - -// removeReadyCondition migrates the deprecated Ready condition out of status_conditions JSONB: -// 1. Backfill: copy Ready → Reconciled for any row that has Ready but not Reconciled. -// 2. Strip: remove all Ready entries from the JSONB arrays. -// 3. Drop the Ready-specific indexes created by 202601210001. -func removeReadyCondition() *gormigrate.Migration { - return &gormigrate.Migration{ - ID: "202605280001", - Migrate: func(tx *gorm.DB) error { - for _, table := range []string{"clusters", "node_pools"} { - // Backfill: if a row has Ready but no Reconciled, copy Ready as Reconciled. - if err := tx.Exec(` - UPDATE ` + table + ` - SET status_conditions = status_conditions || ( - SELECT jsonb_agg( - jsonb_set(elem, '{type}', '"Reconciled"') - ) - FROM jsonb_array_elements(status_conditions) AS elem - WHERE elem->>'type' = 'Ready' - ) - WHERE status_conditions @> '[{"type":"Ready"}]' - AND NOT status_conditions @> '[{"type":"Reconciled"}]' - `).Error; err != nil { - return err - } - - // Strip all Ready entries from the JSONB array. - if err := tx.Exec(` - UPDATE ` + table + ` - SET status_conditions = ( - SELECT COALESCE(jsonb_agg(elem), '[]'::jsonb) - FROM jsonb_array_elements(status_conditions) AS elem - WHERE elem->>'type' != 'Ready' - ) - WHERE status_conditions @> '[{"type":"Ready"}]' - `).Error; err != nil { - return err - } - } - - // Drop the Ready-specific indexes created by 202601210001. - if err := tx.Exec(`DROP INDEX IF EXISTS idx_clusters_ready_status`).Error; err != nil { - return err - } - if err := tx.Exec(`DROP INDEX IF EXISTS idx_node_pools_ready_status`).Error; err != nil { - return err - } - - return nil - }, - } -} diff --git a/pkg/db/migrations/202607010001_add_resource_labels.go b/pkg/db/migrations/202607010001_add_resource_labels.go new file mode 100644 index 00000000..79b39831 --- /dev/null +++ b/pkg/db/migrations/202607010001_add_resource_labels.go @@ -0,0 +1,29 @@ +package migrations + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +func addResourceLabels() *gormigrate.Migration { + return &gormigrate.Migration{ + ID: "202607010001", + Migrate: func(tx *gorm.DB) error { + if err := tx.Exec(`CREATE TABLE IF NOT EXISTS resource_labels ( + resource_id VARCHAR(255) NOT NULL, + key VARCHAR(255) NOT NULL, + value VARCHAR(255) NOT NULL, + PRIMARY KEY (resource_id, key), + FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE + );`).Error; err != nil { + return err + } + + if err := tx.Exec(`ALTER TABLE resources DROP COLUMN IF EXISTS labels;`).Error; err != nil { + return err + } + + return nil + }, + } +} diff --git a/pkg/db/migrations/migration_structs.go b/pkg/db/migrations/migration_structs.go index 1ae15820..495ef1b6 100755 --- a/pkg/db/migrations/migration_structs.go +++ b/pkg/db/migrations/migration_structs.go @@ -26,18 +26,13 @@ import ( // // 4. Create one function in a separate file that returns your Migration. Add that single function call to this list. var MigrationList = []*gormigrate.Migration{ - // addEvents(), // REMOVED: Events table no longer used - no event-driven components - addClusters(), - addNodePools(), addAdapterStatus(), - addConditionsGinIndex(), addSoftDeleteSchema(), addReconciledIndex(), - addNodePoolOwnerDeletedIndex(), addDeletedTimeIndexes(), addResources(), - removeReadyCondition(), addResourceConditions(), + addResourceLabels(), } // Model represents the base model struct. All entities will have this struct embedded. diff --git a/pkg/db/sql_helpers.go b/pkg/db/sql_helpers.go index 2c52ef6e..02c885d2 100755 --- a/pkg/db/sql_helpers.go +++ b/pkg/db/sql_helpers.go @@ -16,18 +16,15 @@ import ( "gorm.io/gorm" ) -// Label key validation pattern: only lowercase letters, digits, and underscores to prevent SQL injection -var labelKeyPattern = regexp.MustCompile(`^[a-z0-9_]+$`) +// jsonbKeyPattern guards keys interpolated into JSONB paths (spec->>'%s', properties->>'%s'). +var jsonbKeyPattern = regexp.MustCompile(`^[a-z0-9_]+$`) -// validateFieldKey validates a JSONB field key to prevent SQL injection through field -// name interpolation. Only allows lowercase letters, digits, and underscores. -// fieldType is used in error messages (e.g. "label key", "spec field segment"). -func validateFieldKey(key, fieldType string) *errors.ServiceError { +func validateJsonbKey(key, fieldType string) *errors.ServiceError { if key == "" { return errors.BadRequest("%s cannot be empty", fieldType) } - if !labelKeyPattern.MatchString(key) { + if !jsonbKeyPattern.MatchString(key) { return errors.BadRequest( "%s '%s' is invalid: must contain only lowercase letters, digits, and underscores", fieldType, key, ) @@ -36,8 +33,9 @@ func validateFieldKey(key, fieldType string) *errors.ServiceError { return nil } -func validateLabelKey(key string) *errors.ServiceError { - return validateFieldKey(key, "label key") +// TODO(HYPERFLEET-1159): remove once Cluster/NodePool are migrated to Resource-kind entities. +func validateLegacyLabelKey(key string) *errors.ServiceError { + return validateJsonbKey(key, "label key") } // Field mapping rules for user-friendly syntax to database columns @@ -55,7 +53,7 @@ func getField(name string, disallowedFields map[string]string) (field string, er return } key := strings.TrimPrefix(trimmedName, "properties.") - if validationErr := validateFieldKey(key, "property key"); validationErr != nil { + if validationErr := validateJsonbKey(key, "property key"); validationErr != nil { err = validationErr return } @@ -76,7 +74,7 @@ func getField(name string, disallowedFields map[string]string) (field string, er parts := strings.Split(strings.TrimPrefix(trimmedName, "spec."), ".") for _, part := range parts { - if validationErr := validateFieldKey(part, "spec field segment"); validationErr != nil { + if validationErr := validateJsonbKey(part, "spec field segment"); validationErr != nil { err = validationErr return } @@ -93,11 +91,16 @@ func getField(name string, disallowedFields map[string]string) (field string, er return } - // Map user-friendly labels.xxx syntax to JSONB query: labels->>'xxx' + // TODO(HYPERFLEET-1159): remove this legacy JSONB branch once Cluster/NodePool + // are migrated to Resource-kind entities with the resource_labels table. if strings.HasPrefix(trimmedName, "labels.") { + if _, disallowed := disallowedFields["labels"]; disallowed { + err = errors.BadRequest("%s is not a valid field name", name) + return + } key := strings.TrimPrefix(trimmedName, "labels.") - if validationErr := validateLabelKey(key); validationErr != nil { + if validationErr := validateLegacyLabelKey(key); validationErr != nil { err = validationErr return } @@ -339,66 +342,74 @@ func conditionSubfieldConverter(n *tsl.Node, conditionType, subfield string) (in // returning the modified tree (with condition nodes replaced) and the extracted conditions. func ExtractConditionQueries(n *tsl.Node) (*tsl.Node, []sq.Sqlizer, *errors.ServiceError) { var conditions []sq.Sqlizer - modifiedTree, err := extractConditionsWalk(n, &conditions) + modifiedTree, err := extractMatchingQueries( + n, hasCondition, conditionsNodeConverter, + "NOT operator is not supported with condition queries; "+ + "use the inverse condition instead (e.g., Reconciled='False')", + "OR operator is not supported with condition queries (status.conditions.*); "+ + "use separate requests or combine conditions with AND", + &conditions, + ) return modifiedTree, conditions, err } -// subtreeHasCondition returns true if any node in the subtree is a condition query -func subtreeHasCondition(n *tsl.Node) bool { +// subtreeHasMatch returns true if any node in the subtree satisfies predicate. +func subtreeHasMatch(n *tsl.Node, predicate func(*tsl.Node) bool) bool { if n == nil { return false } - if hasCondition(n) { + if predicate(n) { return true } - if subtreeHasCondition(n.Left) { + if subtreeHasMatch(n.Left, predicate) { return true } - if subtreeHasCondition(n.Right) { + if subtreeHasMatch(n.Right, predicate) { return true } - return slices.ContainsFunc(n.Children, subtreeHasCondition) + return slices.ContainsFunc(n.Children, func(c *tsl.Node) bool { return subtreeHasMatch(c, predicate) }) } -// extractConditionsWalk recursively walks the tree and extracts condition queries -func extractConditionsWalk(n *tsl.Node, conditions *[]sq.Sqlizer) (*tsl.Node, *errors.ServiceError) { +// extractMatchingQueries recursively walks the tree, replacing every node matching +// predicate with a `1=1` placeholder and collecting converter(node) into exprs. +// NOT and OR are rejected over a subtree containing a match: extracting a match from +// an OR/NOT branch and replacing it with 1=1 changes the query semantics (e.g. +// "name='x' OR condition" becomes "(name='x' OR 1=1) AND condition", which collapses +// the OR to always-true). notMsg/orMsg are the operator-specific error messages. +func extractMatchingQueries( + n *tsl.Node, + predicate func(*tsl.Node) bool, + converter func(*tsl.Node) (any, *errors.ServiceError), + notMsg, orMsg string, + exprs *[]sq.Sqlizer, +) (*tsl.Node, *errors.ServiceError) { if n == nil { return nil, nil } // NOT is unary in v6: child is in Right, Left is nil if n.Kind == tsl.KindUnaryExpr && n.Operator == tsl.OpNot { - if subtreeHasCondition(n.Right) { - return n, errors.BadRequest( - "NOT operator is not supported with condition queries; " + - "use the inverse condition instead (e.g., Reconciled='False')", - ) + if subtreeHasMatch(n.Right, predicate) { + return n, errors.BadRequest("%s", notMsg) } } - // OR with condition queries is not supported: extracting conditions from - // an OR branch and replacing them with 1=1 changes the query semantics - // (e.g. "name='x' OR condition" becomes "(name='x' OR 1=1) AND condition", - // which collapses the OR to always-true). if n.Kind == tsl.KindBinaryExpr && n.Operator == tsl.OpOr { - if subtreeHasCondition(n.Left) || subtreeHasCondition(n.Right) { - return n, errors.BadRequest( - "OR operator is not supported with condition queries (status.conditions.*); " + - "use separate requests or combine conditions with AND", - ) + if subtreeHasMatch(n.Left, predicate) || subtreeHasMatch(n.Right, predicate) { + return n, errors.BadRequest("%s", orMsg) } } - if hasCondition(n) { - expr, err := conditionsNodeConverter(n) + if predicate(n) { + expr, err := converter(n) if err != nil { return n, err } sqlizer, ok := expr.(sq.Sqlizer) if !ok { - return n, errors.GeneralError("unexpected type %T in condition expression", expr) + return n, errors.GeneralError("unexpected type %T in extracted expression", expr) } - *conditions = append(*conditions, sqlizer) + *exprs = append(*exprs, sqlizer) // Replace with a placeholder that always evaluates to true return &tsl.Node{ @@ -409,12 +420,12 @@ func extractConditionsWalk(n *tsl.Node, conditions *[]sq.Sqlizer) (*tsl.Node, *e }, nil } - // For non-condition nodes, recursively process children + // For non-matching nodes, recursively process children var newLeft, newRight *tsl.Node if n.Left != nil { var err *errors.ServiceError - newLeft, err = extractConditionsWalk(n.Left, conditions) + newLeft, err = extractMatchingQueries(n.Left, predicate, converter, notMsg, orMsg, exprs) if err != nil { return n, err } @@ -422,7 +433,7 @@ func extractConditionsWalk(n *tsl.Node, conditions *[]sq.Sqlizer) (*tsl.Node, *e if n.Right != nil { var err *errors.ServiceError - newRight, err = extractConditionsWalk(n.Right, conditions) + newRight, err = extractMatchingQueries(n.Right, predicate, converter, notMsg, orMsg, exprs) if err != nil { return n, err } @@ -430,7 +441,7 @@ func extractConditionsWalk(n *tsl.Node, conditions *[]sq.Sqlizer) (*tsl.Node, *e var newChildren []*tsl.Node for _, child := range n.Children { - newChild, childErr := extractConditionsWalk(child, conditions) + newChild, childErr := extractMatchingQueries(child, predicate, converter, notMsg, orMsg, exprs) if childErr != nil { return n, childErr } @@ -448,6 +459,98 @@ func extractConditionsWalk(n *tsl.Node, conditions *[]sq.Sqlizer) (*tsl.Node, *e }, nil } +// hasLabel returns true if node has a labels. identifier on the left hand side. +func hasLabel(n *tsl.Node) bool { + if n.Left == nil || n.Left.Kind != tsl.KindIdentifier { + return false + } + leftStr, ok := n.Left.Value.(string) + if !ok { + return false + } + return strings.HasPrefix(leftStr, "labels.") +} + +func labelsNodeConverter(n *tsl.Node, resourceTable string) (any, *errors.ServiceError) { + if n.Left == nil || n.Left.Kind != tsl.KindIdentifier { + return nil, errors.BadRequest("invalid label query structure") + } + + leftStr, ok := n.Left.Value.(string) + if !ok { + return nil, errors.BadRequest("expected string for left side of label query") + } + + parts := strings.Split(leftStr, ".") + if len(parts) != 2 || parts[0] != "labels" { + return nil, errors.BadRequest("invalid label field path: %s", leftStr) + } + + key := parts[1] + + if n.Operator != tsl.OpEQ && n.Operator != tsl.OpNE { + return nil, errors.BadRequest( + "operator '%s' is not supported for label queries; use = or !=", n.Operator, + ) + } + sqlOp := comparisonOperators[n.Operator] + + if n.Right == nil || n.Right.Kind != tsl.KindStringLiteral { + return nil, errors.BadRequest("expected string value for label '%s'", key) + } + rightStr, ok := n.Right.Value.(string) + if !ok { + return nil, errors.BadRequest("expected string value for label '%s'", key) + } + + query := fmt.Sprintf( + "EXISTS (SELECT 1 FROM resource_labels WHERE resource_labels.resource_id = %s.id "+ + "AND resource_labels.key = ? AND resource_labels.value %s ?)", + resourceTable, sqlOp, + ) + return sq.Expr(query, key, rightStr), nil +} + +// isLabelIdentifier returns true for a bare labels. identifier node, regardless +// of its position in the tree — unlike hasLabel, which only matches when the +// identifier is the direct left side of a comparison. +func isLabelIdentifier(n *tsl.Node) bool { + if n == nil || n.Kind != tsl.KindIdentifier { + return false + } + s, ok := n.Value.(string) + return ok && strings.HasPrefix(s, "labels.") +} + +func ExtractLabelQueries(n *tsl.Node, resourceTable string) (*tsl.Node, []sq.Sqlizer, *errors.ServiceError) { + var labels []sq.Sqlizer + converter := func(n *tsl.Node) (any, *errors.ServiceError) { + return labelsNodeConverter(n, resourceTable) + } + modifiedTree, err := extractMatchingQueries( + n, hasLabel, converter, + "NOT operator is not supported with label queries", + "OR operator is not supported with label queries (labels.*); "+ + "use separate requests or combine label filters with AND", + &labels, + ) + if err != nil { + return n, nil, err + } + + // "not labels.env='x'" parses as "(NOT labels.env) = 'x'" due to TSL precedence, + // so hasLabel never matches it and it survives into modifiedTree. Without this + // guard it falls through to getField's JSONB branch and hits a missing column. + if subtreeHasMatch(modifiedTree, isLabelIdentifier) { + return n, nil, errors.BadRequest( + "labels. must be used in a direct comparison, e.g. labels.env=\"prod\"; " + + "wrap NOT in parentheses, e.g. not (labels.env=\"prod\")", + ) + } + + return modifiedTree, labels, nil +} + // FieldNameWalk walks the filter tree, maps user-facing field names to SQL columns // via getField, then wraps spec JSONB numeric comparisons in CAST. func FieldNameWalk( diff --git a/pkg/handlers/cluster.go b/pkg/handlers/cluster.go deleted file mode 100644 index 435b3d69..00000000 --- a/pkg/handlers/cluster.go +++ /dev/null @@ -1,212 +0,0 @@ -package handlers - -import ( - "net/http" - - "github.com/gorilla/mux" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/presenters" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" -) - -var _ RestHandler = ClusterHandler{} - -type ClusterHandler struct { - cluster services.ClusterService - generic services.GenericService -} - -func NewClusterHandler(cluster services.ClusterService, generic services.GenericService) *ClusterHandler { - return &ClusterHandler{ - cluster: cluster, - generic: generic, - } -} - -func (h ClusterHandler) Create(w http.ResponseWriter, r *http.Request) { - var req openapi.ClusterCreateRequest - cfg := &handlerConfig{ - MarshalInto: &req, - Validate: []validate{ - validateEmpty(&req, "Id", "id"), - validateName(&req, "Name", "name", 3, 53), - validateKind(&req, "Kind", "kind", "Cluster"), - validateSpec(&req, "Spec", "spec"), - validateLabels(&req, "Labels"), - }, - Action: func() (interface{}, *errors.ServiceError) { - ctx := r.Context() - // Use the presenters.ConvertCluster helper to convert the request - clusterModel, err := presenters.ConvertCluster(&req) - if err != nil { - return nil, errors.GeneralError("Failed to convert cluster: %v", err) - } - clusterModel, svcErr := h.cluster.Create(ctx, clusterModel) - if svcErr != nil { - return nil, svcErr - } - presented, err := presenters.PresentCluster(clusterModel) - if err != nil { - return nil, errors.GeneralError("Failed to present cluster: %v", err) - } - return presented, nil - }, - ErrorHandler: handleError, - } - - handle(w, r, cfg, http.StatusCreated) -} - -func (h ClusterHandler) Patch(w http.ResponseWriter, r *http.Request) { - var req openapi.ClusterPatchRequest - - cfg := &handlerConfig{ - MarshalInto: &req, - StrictUnmarshal: true, - Validate: []validate{ - validatePatchRequest(&req), - validateLabels(&req, "Labels"), - }, - Action: func() (interface{}, *errors.ServiceError) { - ctx := r.Context() - id := mux.Vars(r)["id"] - - patch := convertClusterPatch(&req) - clusterModel, err := h.cluster.Patch(ctx, id, patch) - if err != nil { - return nil, err - } - presented, presErr := presenters.PresentCluster(clusterModel) - if presErr != nil { - return nil, errors.GeneralError("Failed to present cluster: %v", presErr) - } - return presented, nil - }, - ErrorHandler: handleError, - } - - handle(w, r, cfg, http.StatusOK) -} - -func convertClusterPatch(req *openapi.ClusterPatchRequest) *api.ClusterPatch { - patch := &api.ClusterPatch{} - if req.Spec != nil { - patch.Spec = *req.Spec - } - if req.Labels != nil { - patch.Labels = *req.Labels - } - return patch -} - -func (h ClusterHandler) List(w http.ResponseWriter, r *http.Request) { - cfg := &handlerConfig{ - Action: func() (interface{}, *errors.ServiceError) { - ctx := r.Context() - - listArgs, err := services.NewListArguments(r.URL.Query()) - if err != nil { - return nil, err - } - var clusters []api.Cluster - paging, err := h.generic.List(ctx, listArgs, &clusters) - if err != nil { - return nil, err - } - clusterList := openapi.ClusterList{ - Page: int32(paging.Page), - Size: int32(paging.Size), - Total: int32(paging.Total), - Items: []openapi.Cluster{}, - } - - for _, cluster := range clusters { - presented, err := presenters.PresentCluster(&cluster) - if err != nil { - return nil, errors.GeneralError("Failed to present cluster: %v", err) - } - clusterList.Items = append(clusterList.Items, presented) - } - if listArgs.Fields != nil { - filteredItems, err := presenters.SliceFilter(listArgs.Fields, clusterList) - if err != nil { - return nil, err - } - return filteredItems, nil - } - return clusterList, nil - }, - ErrorHandler: handleError, - } - - handleList(w, r, cfg) -} - -func (h ClusterHandler) Get(w http.ResponseWriter, r *http.Request) { - cfg := &handlerConfig{ - Action: func() (interface{}, *errors.ServiceError) { - id := mux.Vars(r)["id"] - ctx := r.Context() - cluster, err := h.cluster.Get(ctx, id) - if err != nil { - return nil, err - } - - presented, presErr := presenters.PresentCluster(cluster) - if presErr != nil { - return nil, errors.GeneralError("Failed to present cluster: %v", presErr) - } - - return applyFieldFilter(r, presented) - }, - ErrorHandler: handleError, - } - - handleGet(w, r, cfg) -} - -func (h ClusterHandler) SoftDelete(w http.ResponseWriter, r *http.Request) { - cfg := &handlerConfig{ - Action: func() (interface{}, *errors.ServiceError) { - id := mux.Vars(r)["id"] - ctx := r.Context() - cluster, err := h.cluster.SoftDelete(ctx, id) - if err != nil { - return nil, err - } - - presented, presErr := presenters.PresentCluster(cluster) - if presErr != nil { - return nil, errors.GeneralError("Failed to present cluster: %v", presErr) - } - - return presented, nil - }, - ErrorHandler: handleError, - } - handleSoftDelete(w, r, cfg) -} - -// ForceDelete permanently removes a cluster that is in Finalizing state. -func (h ClusterHandler) ForceDelete(w http.ResponseWriter, r *http.Request) { - var req openapi.ForceDeleteRequest - cfg := &handlerConfig{ - MarshalInto: &req, - Validate: []validate{ - validateNotEmpty(&req, "Reason", "reason"), - validateMaxLength(&req, "Reason", "reason", maxReasonLength), - }, - Action: func() (interface{}, *errors.ServiceError) { - id := mux.Vars(r)["id"] - ctx := r.Context() - if err := h.cluster.ForceDelete(ctx, id, req.Reason); err != nil { - return nil, err - } - return nil, nil - }, - } - handleForceDelete(w, r, cfg) -} diff --git a/pkg/handlers/cluster_nodepools.go b/pkg/handlers/cluster_nodepools.go deleted file mode 100644 index 9c7ca198..00000000 --- a/pkg/handlers/cluster_nodepools.go +++ /dev/null @@ -1,263 +0,0 @@ -package handlers - -import ( - "net/http" - - "github.com/gorilla/mux" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/presenters" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" -) - -type ClusterNodePoolsHandler struct { - clusterService services.ClusterService - nodePoolService services.NodePoolService -} - -func NewClusterNodePoolsHandler( - clusterService services.ClusterService, - nodePoolService services.NodePoolService, -) *ClusterNodePoolsHandler { - return &ClusterNodePoolsHandler{ - clusterService: clusterService, - nodePoolService: nodePoolService, - } -} - -// List returns all nodepools for a cluster -func (h ClusterNodePoolsHandler) List(w http.ResponseWriter, r *http.Request) { - cfg := &handlerConfig{ - Action: func() (interface{}, *errors.ServiceError) { - ctx := r.Context() - clusterID := mux.Vars(r)["id"] - - if err := validatePathID(clusterID, "cluster id"); err != nil { - return nil, err - } - - listArgs, err := services.NewListArguments(r.URL.Query()) - if err != nil { - return nil, err - } - - nodePools, paging, err := h.nodePoolService.ListByCluster(ctx, clusterID, listArgs) - if err != nil { - return nil, err - } - - items := make([]openapi.NodePool, 0, len(nodePools)) - for _, nodePool := range nodePools { - presented, err := presenters.PresentNodePool(nodePool) - if err != nil { - return nil, errors.GeneralError("Failed to present nodepool: %v", err) - } - items = append(items, presented) - } - - nodePoolList := struct { - Items []openapi.NodePool `json:"items"` - Page int32 `json:"page"` - Size int32 `json:"size"` - Total int32 `json:"total"` - }{ - Page: int32(paging.Page), - Size: int32(paging.Size), - Total: int32(paging.Total), - Items: items, - } - - if listArgs.Fields != nil { - filteredItems, err := presenters.SliceFilter(listArgs.Fields, nodePoolList) - if err != nil { - return nil, err - } - return filteredItems, nil - } - return nodePoolList, nil - }, - ErrorHandler: handleError, - } - - handleList(w, r, cfg) -} - -// Get returns a specific nodepool for a cluster -func (h ClusterNodePoolsHandler) Get(w http.ResponseWriter, r *http.Request) { - cfg := &handlerConfig{ - Action: func() (interface{}, *errors.ServiceError) { - ctx := r.Context() - clusterID := mux.Vars(r)["id"] - nodePoolID := mux.Vars(r)["nodepool_id"] - - nodePool, err := h.nodePoolService.GetByIDAndOwner(ctx, nodePoolID, clusterID) - if err != nil { - return nil, err - } - - presented, presErr := presenters.PresentNodePool(nodePool) - if presErr != nil { - return nil, errors.GeneralError("Failed to present nodepool: %v", presErr) - } - - return applyFieldFilter(r, presented) - }, - ErrorHandler: handleError, - } - - handleGet(w, r, cfg) -} - -// SoftDelete soft-deletes a specific nodepool for a cluster -func (h ClusterNodePoolsHandler) SoftDelete(w http.ResponseWriter, r *http.Request) { - cfg := &handlerConfig{ - Action: func() (interface{}, *errors.ServiceError) { - ctx := r.Context() - clusterID := mux.Vars(r)["id"] - nodePoolID := mux.Vars(r)["nodepool_id"] - - _, err := h.nodePoolService.GetByIDAndOwner(ctx, nodePoolID, clusterID) - if err != nil { - return nil, err - } - - nodePool, err := h.nodePoolService.SoftDelete(ctx, nodePoolID) - if err != nil { - return nil, err - } - - presented, presErr := presenters.PresentNodePool(nodePool) - if presErr != nil { - return nil, errors.GeneralError("Failed to present nodepool: %v", presErr) - } - return presented, nil - }, - ErrorHandler: handleError, - } - - handleSoftDelete(w, r, cfg) -} - -// ForceDelete permanently removes a nodepool that is in Finalizing state. -func (h ClusterNodePoolsHandler) ForceDelete(w http.ResponseWriter, r *http.Request) { - var req openapi.ForceDeleteRequest - cfg := &handlerConfig{ - MarshalInto: &req, - Validate: []validate{ - validateNotEmpty(&req, "Reason", "reason"), - validateMaxLength(&req, "Reason", "reason", maxReasonLength), - }, - Action: func() (interface{}, *errors.ServiceError) { - clusterID := mux.Vars(r)["id"] - nodePoolID := mux.Vars(r)["nodepool_id"] - ctx := r.Context() - if _, err := h.nodePoolService.GetByIDAndOwner(ctx, nodePoolID, clusterID); err != nil { - return nil, err - } - if err := h.nodePoolService.ForceDelete(ctx, nodePoolID, req.Reason); err != nil { - return nil, err - } - return nil, nil - }, - } - handleForceDelete(w, r, cfg) -} - -// Patch patches a specific nodepool for a cluster -func (h ClusterNodePoolsHandler) Patch(w http.ResponseWriter, r *http.Request) { - var req openapi.NodePoolPatchRequest - - cfg := &handlerConfig{ - MarshalInto: &req, - StrictUnmarshal: true, - Validate: []validate{ - validatePatchRequest(&req), - validateLabels(&req, "Labels"), - }, - Action: func() (interface{}, *errors.ServiceError) { - ctx := r.Context() - clusterID := mux.Vars(r)["id"] - nodePoolID := mux.Vars(r)["nodepool_id"] - - _, err := h.nodePoolService.GetByIDAndOwner(ctx, nodePoolID, clusterID) - if err != nil { - return nil, err - } - - patch := convertNodePoolPatch(&req) - found, err := h.nodePoolService.Patch(ctx, nodePoolID, patch) - if err != nil { - return nil, err - } - - presented, presErr := presenters.PresentNodePool(found) - if presErr != nil { - return nil, errors.GeneralError("Failed to present nodepool: %v", presErr) - } - return presented, nil - }, - ErrorHandler: handleError, - } - - handle(w, r, cfg, http.StatusOK) -} - -func convertNodePoolPatch(req *openapi.NodePoolPatchRequest) *api.NodePoolPatch { - patch := &api.NodePoolPatch{} - if req.Spec != nil { - patch.Spec = *req.Spec - } - if req.Labels != nil { - patch.Labels = *req.Labels - } - return patch -} - -// Create creates a new nodepool for a cluster -func (h ClusterNodePoolsHandler) Create(w http.ResponseWriter, r *http.Request) { - var req openapi.NodePoolCreateRequest - cfg := &handlerConfig{ - MarshalInto: &req, - Validate: []validate{ - validateEmpty(&req, "Id", "id"), - validateName(&req, "Name", "name", 3, 15), - validateKind(&req, "Kind", "kind", "NodePool"), - validateSpec(&req, "Spec", "spec"), - validateLabels(&req, "Labels"), - }, - Action: func() (interface{}, *errors.ServiceError) { - ctx := r.Context() - clusterID := mux.Vars(r)["id"] - - cluster, err := h.clusterService.Get(ctx, clusterID) - if err != nil { - return nil, err - } - - if cluster.DeletedTime != nil { - return nil, errors.ConflictState("Cluster '%s' is marked for deletion", clusterID) - } - - nodePoolModel, convErr := presenters.ConvertNodePool(&req, cluster.ID) - if convErr != nil { - return nil, errors.GeneralError("Failed to convert nodepool: %v", convErr) - } - - nodePoolModel, err = h.nodePoolService.Create(ctx, nodePoolModel) - if err != nil { - return nil, err - } - - presented, presErr := presenters.PresentNodePool(nodePoolModel) - if presErr != nil { - return nil, errors.GeneralError("Failed to present nodepool: %v", presErr) - } - return presented, nil - }, - ErrorHandler: handleError, - } - - handle(w, r, cfg, http.StatusCreated) -} diff --git a/pkg/handlers/cluster_nodepools_test.go b/pkg/handlers/cluster_nodepools_test.go deleted file mode 100644 index af0208d3..00000000 --- a/pkg/handlers/cluster_nodepools_test.go +++ /dev/null @@ -1,900 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/google/uuid" - "github.com/gorilla/mux" - . "github.com/onsi/gomega" - "go.uber.org/mock/gomock" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" -) - -const ( - testClusterID = "test-cluster-id" - testNodePoolID = "test-nodepool-id" - testSystemUser = "system@hyperfleet.local" -) - -func TestClusterNodePoolsHandler_List(t *testing.T) { - RegisterTestingT(t) - - now := time.Now() - clusterUUID := uuid.New().String() - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - mockNodePoolSvc.EXPECT().ListByCluster(gomock.Any(), clusterUUID, gomock.Any()). - Return(api.NodePoolList{ - &api.NodePool{ - Meta: api.Meta{ID: "np-1", CreatedTime: now, UpdatedTime: now}, - Name: "test-np", - OwnerID: clusterUUID, - OwnerKind: "Cluster", - Spec: []byte(`{}`), - Labels: []byte(`{}`), - StatusConditions: []byte(`[]`), - CreatedBy: testSystemUser, - UpdatedBy: testSystemUser, - }, - }, &api.PagingMeta{Page: 1, Size: 1, Total: 1}, nil) - - handler := NewClusterNodePoolsHandler(mockClusterSvc, mockNodePoolSvc) - - req := httptest.NewRequest(http.MethodGet, - "/api/hyperfleet/v1/clusters/"+clusterUUID+"/nodepools", nil) - req = mux.SetURLVars(req, map[string]string{"id": clusterUUID}) - rr := httptest.NewRecorder() - - handler.List(rr, req) - Expect(rr.Code).To(Equal(http.StatusOK)) - - var raw map[string]interface{} - Expect(json.Unmarshal(rr.Body.Bytes(), &raw)).To(Succeed()) - Expect(raw).NotTo(HaveKey("kind")) -} - -func TestClusterNodePoolsHandler_Get(t *testing.T) { - RegisterTestingT(t) - - now := time.Now() - clusterID := testClusterID - nodePoolID := testNodePoolID - - tests := []struct { - setupMocks func(ctrl *gomock.Controller) ( - *services.MockClusterService, *services.MockNodePoolService, - ) - name string - clusterID string - nodePoolID string - expectedStatusCode int - expectedError bool - }{ - { - name: "Success - Get nodepool by cluster and nodepool ID", - clusterID: clusterID, - nodePoolID: nodePoolID, - setupMocks: func(ctrl *gomock.Controller) ( - *services.MockClusterService, *services.MockNodePoolService, - ) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - - mockNodePoolSvc.EXPECT().GetByIDAndOwner(gomock.Any(), nodePoolID, clusterID).Return(&api.NodePool{ - Meta: api.Meta{ - ID: nodePoolID, - CreatedTime: now, - UpdatedTime: now, - }, - Kind: "NodePool", - Name: "test-nodepool", - OwnerID: clusterID, - Spec: []byte("{}"), - Labels: []byte("{}"), - StatusConditions: []byte("[]"), - CreatedBy: "user@example.com", - UpdatedBy: "user@example.com", - }, nil) - - return mockClusterSvc, mockNodePoolSvc - }, - expectedStatusCode: http.StatusOK, - expectedError: false, - }, - { - name: "Error - NodePool not found for cluster", - clusterID: clusterID, - nodePoolID: "non-existent", - setupMocks: func(ctrl *gomock.Controller) ( - *services.MockClusterService, *services.MockNodePoolService, - ) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - - mockNodePoolSvc.EXPECT().GetByIDAndOwner(gomock.Any(), "non-existent", clusterID). - Return(nil, errors.NotFound("NodePool not found")) - - return mockClusterSvc, mockNodePoolSvc - }, - expectedStatusCode: http.StatusNotFound, - expectedError: true, - }, - { - name: "Error - NodePool belongs to different cluster", - clusterID: clusterID, - nodePoolID: nodePoolID, - setupMocks: func(ctrl *gomock.Controller) ( - *services.MockClusterService, *services.MockNodePoolService, - ) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - - mockNodePoolSvc.EXPECT().GetByIDAndOwner(gomock.Any(), nodePoolID, clusterID). - Return(nil, errors.NotFound("NodePool '%s' not found for cluster '%s'", nodePoolID, clusterID)) - - return mockClusterSvc, mockNodePoolSvc - }, - expectedStatusCode: http.StatusNotFound, - expectedError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - RegisterTestingT(t) - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClusterSvc, mockNodePoolSvc := tt.setupMocks(ctrl) - handler := NewClusterNodePoolsHandler(mockClusterSvc, mockNodePoolSvc) - - reqURL := "/api/hyperfleet/v1/clusters/" + tt.clusterID + "/nodepools/" + tt.nodePoolID - req := httptest.NewRequest(http.MethodGet, reqURL, nil) - req = mux.SetURLVars(req, map[string]string{ - "id": tt.clusterID, - "nodepool_id": tt.nodePoolID, - }) - - rr := httptest.NewRecorder() - handler.Get(rr, req) - - Expect(rr.Code).To(Equal(tt.expectedStatusCode)) - - if !tt.expectedError { - var response openapi.NodePool - err := json.Unmarshal(rr.Body.Bytes(), &response) - Expect(err).NotTo(HaveOccurred()) - Expect(*response.Id).To(Equal(nodePoolID)) - Expect(response.Kind).NotTo(BeEmpty()) - Expect(response.Kind).To(Equal("NodePool")) - } - }) - } -} - -func TestClusterNodePoolsHandler_SoftDelete(t *testing.T) { - RegisterTestingT(t) - - now := time.Now() - clusterID := testClusterID - nodePoolID := testNodePoolID - - tests := []struct { - setupMocks func(ctrl *gomock.Controller) ( - *services.MockClusterService, *services.MockNodePoolService, - ) - name string - clusterID string - nodePoolID string - expectedStatusCode int - expectedError bool - }{ - { - name: "given cluster exists and nodepool is owned by it, when deleted, then returns 202 with nodepool body", - clusterID: clusterID, - nodePoolID: nodePoolID, - setupMocks: func(ctrl *gomock.Controller) ( - *services.MockClusterService, *services.MockNodePoolService, - ) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - - mockNodePoolSvc.EXPECT().GetByIDAndOwner(gomock.Any(), nodePoolID, clusterID).Return(&api.NodePool{ - Meta: api.Meta{ID: nodePoolID, CreatedTime: now, UpdatedTime: now}, - OwnerID: clusterID, - }, nil) - - deletedTime := now - deletedByUser := testSystemUser - mockNodePoolSvc.EXPECT().SoftDelete(gomock.Any(), nodePoolID).Return(&api.NodePool{ - Meta: api.Meta{ID: nodePoolID, CreatedTime: now, UpdatedTime: now}, - Kind: "NodePool", - Name: "test-nodepool", - OwnerID: clusterID, - DeletedTime: &deletedTime, - DeletedBy: &deletedByUser, - Spec: []byte("{}"), - Labels: []byte("{}"), - StatusConditions: []byte("[]"), - CreatedBy: "user@example.com", - UpdatedBy: "user@example.com", - }, nil) - - return mockClusterSvc, mockNodePoolSvc - }, - expectedStatusCode: http.StatusAccepted, - expectedError: false, - }, - { - name: "given nodepool belongs to a different cluster, when deleted, then returns 404", - clusterID: clusterID, - nodePoolID: nodePoolID, - setupMocks: func(ctrl *gomock.Controller) ( - *services.MockClusterService, *services.MockNodePoolService, - ) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - - mockNodePoolSvc.EXPECT().GetByIDAndOwner(gomock.Any(), nodePoolID, clusterID). - Return(nil, errors.NotFound("NodePool not found for cluster")) - - return mockClusterSvc, mockNodePoolSvc - }, - expectedStatusCode: http.StatusNotFound, - expectedError: true, - }, - { - name: "given nodepool does not exist, when deleted, then returns 404", - clusterID: clusterID, - nodePoolID: "non-existent", - setupMocks: func(ctrl *gomock.Controller) ( - *services.MockClusterService, *services.MockNodePoolService, - ) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - - mockNodePoolSvc.EXPECT().GetByIDAndOwner(gomock.Any(), "non-existent", clusterID). - Return(nil, errors.NotFound("NodePool not found")) - - return mockClusterSvc, mockNodePoolSvc - }, - expectedStatusCode: http.StatusNotFound, - expectedError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - RegisterTestingT(t) - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClusterSvc, mockNodePoolSvc := tt.setupMocks(ctrl) - handler := NewClusterNodePoolsHandler(mockClusterSvc, mockNodePoolSvc) - - reqURL := "/api/hyperfleet/v1/clusters/" + tt.clusterID + "/nodepools/" + tt.nodePoolID - req := httptest.NewRequest(http.MethodDelete, reqURL, nil) - req = mux.SetURLVars(req, map[string]string{ - "id": tt.clusterID, - "nodepool_id": tt.nodePoolID, - }) - - rr := httptest.NewRecorder() - handler.SoftDelete(rr, req) - - Expect(rr.Code).To(Equal(tt.expectedStatusCode)) - - if !tt.expectedError { - var response openapi.NodePool - err := json.Unmarshal(rr.Body.Bytes(), &response) - Expect(err).NotTo(HaveOccurred()) - Expect(*response.Id).To(Equal(nodePoolID)) - Expect(response.DeletedTime).NotTo(BeNil()) - Expect(*response.DeletedBy).To(Equal(testSystemUser)) - } - }) - } -} - -func TestClusterNodePoolsHandler_Create(t *testing.T) { - RegisterTestingT(t) - - now := time.Now() - clusterID := testClusterID - nodePoolID := testNodePoolID - validBody := `{"name":"test-np","kind":"NodePool","spec":{"replicas":1}}` - - tests := []struct { - setupMocks func(ctrl *gomock.Controller) ( - *services.MockClusterService, *services.MockNodePoolService, - ) - name string - clusterID string - expectedStatusCode int - expectedError bool - }{ - { - name: "Success - Create nodepool for active cluster", - clusterID: clusterID, - setupMocks: func(ctrl *gomock.Controller) ( - *services.MockClusterService, *services.MockNodePoolService, - ) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - - mockClusterSvc.EXPECT().Get(gomock.Any(), clusterID).Return(&api.Cluster{ - Meta: api.Meta{ID: clusterID, CreatedTime: now, UpdatedTime: now}, - Name: "test-cluster", - }, nil) - - mockNodePoolSvc.EXPECT().Create(gomock.Any(), gomock.Any()).Return(&api.NodePool{ - Meta: api.Meta{ID: nodePoolID, CreatedTime: now, UpdatedTime: now}, - Kind: "NodePool", - Name: "test-np", - OwnerID: clusterID, - Spec: []byte(`{"replicas":1}`), - Labels: []byte("{}"), - StatusConditions: []byte("[]"), - CreatedBy: testSystemUser, - UpdatedBy: testSystemUser, - }, nil) - - return mockClusterSvc, mockNodePoolSvc - }, - expectedStatusCode: http.StatusCreated, - expectedError: false, - }, - { - name: "Error 409 - Cluster is soft-deleted", - clusterID: clusterID, - setupMocks: func(ctrl *gomock.Controller) ( - *services.MockClusterService, *services.MockNodePoolService, - ) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - - deletedTime := now - mockClusterSvc.EXPECT().Get(gomock.Any(), clusterID).Return(&api.Cluster{ - Meta: api.Meta{ID: clusterID, CreatedTime: now, UpdatedTime: now}, - Name: "test-cluster", - DeletedTime: &deletedTime, - }, nil) - - return mockClusterSvc, mockNodePoolSvc - }, - expectedStatusCode: http.StatusConflict, - expectedError: true, - }, - { - name: "Error 404 - Cluster not found", - clusterID: "non-existent", - setupMocks: func(ctrl *gomock.Controller) ( - *services.MockClusterService, *services.MockNodePoolService, - ) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - - mockClusterSvc.EXPECT().Get(gomock.Any(), "non-existent").Return(nil, errors.NotFound("Cluster not found")) - - return mockClusterSvc, mockNodePoolSvc - }, - expectedStatusCode: http.StatusNotFound, - expectedError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - RegisterTestingT(t) - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClusterSvc, mockNodePoolSvc := tt.setupMocks(ctrl) - handler := NewClusterNodePoolsHandler(mockClusterSvc, mockNodePoolSvc) - - reqURL := "/api/hyperfleet/v1/clusters/" + tt.clusterID + "/nodepools" - req := httptest.NewRequest(http.MethodPost, reqURL, strings.NewReader(validBody)) - req.Header.Set("Content-Type", "application/json") - req = mux.SetURLVars(req, map[string]string{ - "id": tt.clusterID, - }) - - rr := httptest.NewRecorder() - handler.Create(rr, req) - - Expect(rr.Code).To(Equal(tt.expectedStatusCode)) - - if !tt.expectedError { - var response openapi.NodePool - err := json.Unmarshal(rr.Body.Bytes(), &response) - Expect(err).NotTo(HaveOccurred()) - Expect(*response.Id).To(Equal(nodePoolID)) - Expect(response.Kind).NotTo(BeEmpty()) - Expect(response.Kind).To(Equal("NodePool")) - } - - if tt.expectedStatusCode == http.StatusConflict { - var errResp openapi.ProblemDetails - err := json.Unmarshal(rr.Body.Bytes(), &errResp) - Expect(err).NotTo(HaveOccurred()) - Expect(errResp.Type).To(Equal(errors.ErrorTypeConflict)) - Expect(errResp.Status).To(Equal(http.StatusConflict)) - Expect(*errResp.Detail).To(ContainSubstring("marked for deletion")) - Expect(*errResp.Code).To(Equal("HYPERFLEET-CNF-003")) - } - }) - } -} - -func TestClusterNodePoolsHandler_Patch(t *testing.T) { - RegisterTestingT(t) - - now := time.Now() - clusterID := testClusterID - nodePoolID := testNodePoolID - validBody := `{"spec":{"replicas":2}}` - - tests := []struct { - setupMocks func(ctrl *gomock.Controller) ( - *services.MockClusterService, *services.MockNodePoolService, - ) - name string - clusterID string - nodePoolID string - expectedStatusCode int - expectedError bool - }{ - { - name: "Success - Patch nodepool for active cluster", - clusterID: clusterID, - nodePoolID: nodePoolID, - setupMocks: func(ctrl *gomock.Controller) ( - *services.MockClusterService, *services.MockNodePoolService, - ) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - - mockNodePoolSvc.EXPECT().GetByIDAndOwner(gomock.Any(), nodePoolID, clusterID).Return(&api.NodePool{ - Meta: api.Meta{ID: nodePoolID, CreatedTime: now, UpdatedTime: now}, - OwnerID: clusterID, - }, nil) - - mockNodePoolSvc.EXPECT().Patch(gomock.Any(), nodePoolID, gomock.Any()).Return(&api.NodePool{ - Meta: api.Meta{ID: nodePoolID, CreatedTime: now, UpdatedTime: now}, - Kind: "NodePool", - Name: "test-nodepool", - OwnerID: clusterID, - Spec: []byte(`{"replicas":2}`), - Labels: []byte("{}"), - StatusConditions: []byte("[]"), - CreatedBy: "user@example.com", - UpdatedBy: "user@example.com", - }, nil) - - return mockClusterSvc, mockNodePoolSvc - }, - expectedStatusCode: http.StatusOK, - expectedError: false, - }, - { - name: "Error 409 - NodePool is soft-deleted", - clusterID: clusterID, - nodePoolID: nodePoolID, - setupMocks: func(ctrl *gomock.Controller) ( - *services.MockClusterService, *services.MockNodePoolService, - ) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - - mockNodePoolSvc.EXPECT().GetByIDAndOwner(gomock.Any(), nodePoolID, clusterID).Return(&api.NodePool{ - Meta: api.Meta{ID: nodePoolID, CreatedTime: now, UpdatedTime: now}, - OwnerID: clusterID, - }, nil) - - mockNodePoolSvc.EXPECT().Patch(gomock.Any(), nodePoolID, gomock.Any()). - Return(nil, errors.ConflictState("NodePool '%s' is marked for deletion", nodePoolID)) - - return mockClusterSvc, mockNodePoolSvc - }, - expectedStatusCode: http.StatusConflict, - expectedError: true, - }, - { - name: "Error 404 - NodePool not found for cluster", - clusterID: clusterID, - nodePoolID: "non-existent-np", - setupMocks: func(ctrl *gomock.Controller) ( - *services.MockClusterService, *services.MockNodePoolService, - ) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - - mockNodePoolSvc.EXPECT().GetByIDAndOwner(gomock.Any(), "non-existent-np", clusterID). - Return(nil, errors.NotFound("NodePool not found for cluster")) - - return mockClusterSvc, mockNodePoolSvc - }, - expectedStatusCode: http.StatusNotFound, - expectedError: true, - }, - { - name: "Error 404 - NodePool belongs to different cluster", - clusterID: clusterID, - nodePoolID: nodePoolID, - setupMocks: func(ctrl *gomock.Controller) ( - *services.MockClusterService, *services.MockNodePoolService, - ) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - - mockNodePoolSvc.EXPECT().GetByIDAndOwner(gomock.Any(), nodePoolID, clusterID). - Return(nil, errors.NotFound("NodePool '%s' not found for cluster '%s'", nodePoolID, clusterID)) - - return mockClusterSvc, mockNodePoolSvc - }, - expectedStatusCode: http.StatusNotFound, - expectedError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - RegisterTestingT(t) - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClusterSvc, mockNodePoolSvc := tt.setupMocks(ctrl) - handler := NewClusterNodePoolsHandler(mockClusterSvc, mockNodePoolSvc) - - reqURL := "/api/hyperfleet/v1/clusters/" + tt.clusterID + "/nodepools/" + tt.nodePoolID - req := httptest.NewRequest(http.MethodPatch, reqURL, strings.NewReader(validBody)) - req.Header.Set("Content-Type", "application/json") - req = mux.SetURLVars(req, map[string]string{ - "id": tt.clusterID, - "nodepool_id": tt.nodePoolID, - }) - - rr := httptest.NewRecorder() - handler.Patch(rr, req) - - Expect(rr.Code).To(Equal(tt.expectedStatusCode)) - - if !tt.expectedError { - var response openapi.NodePool - err := json.Unmarshal(rr.Body.Bytes(), &response) - Expect(err).NotTo(HaveOccurred()) - Expect(*response.Id).To(Equal(nodePoolID)) - Expect(response.Kind).NotTo(BeEmpty()) - Expect(response.Kind).To(Equal("NodePool")) - } - - if tt.expectedStatusCode == http.StatusConflict { - var errResp openapi.ProblemDetails - err := json.Unmarshal(rr.Body.Bytes(), &errResp) - Expect(err).NotTo(HaveOccurred()) - Expect(errResp.Type).To(Equal(errors.ErrorTypeConflict)) - Expect(errResp.Status).To(Equal(http.StatusConflict)) - Expect(*errResp.Detail).To(ContainSubstring("marked for deletion")) - Expect(*errResp.Code).To(Equal("HYPERFLEET-CNF-003")) - } - }) - } -} - -func TestClusterNodePoolsHandler_Patch_RejectsUnknownFields(t *testing.T) { - RegisterTestingT(t) - - tests := []struct { - name string - body string - }{ - {"rejects name", `{"name":"new-name","spec":{"replicas":2}}`}, - {"rejects id", `{"id":"some-id","spec":{"replicas":2}}`}, - {"rejects generation", `{"generation":5,"spec":{"replicas":2}}`}, - {"rejects status", `{"status":{"phase":"Ready"},"spec":{"replicas":2}}`}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - RegisterTestingT(t) - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - handler := NewClusterNodePoolsHandler(mockClusterSvc, mockNodePoolSvc) - - reqURL := "/api/hyperfleet/v1/clusters/" + testClusterID + "/nodepools/" + testNodePoolID - req := httptest.NewRequest(http.MethodPatch, reqURL, strings.NewReader(tt.body)) - req.Header.Set("Content-Type", "application/json") - req = mux.SetURLVars(req, map[string]string{ - "id": testClusterID, - "nodepool_id": testNodePoolID, - }) - - rr := httptest.NewRecorder() - handler.Patch(rr, req) - - Expect(rr.Code).To(Equal(http.StatusBadRequest)) - - var errResp openapi.ProblemDetails - err := json.Unmarshal(rr.Body.Bytes(), &errResp) - Expect(err).NotTo(HaveOccurred()) - Expect(*errResp.Code).To(Equal(errors.CodeBadRequest)) - }) - } -} - -func TestClusterNodePoolsHandler_ForceDelete(t *testing.T) { - RegisterTestingT(t) - - clusterID := testClusterID - nodePoolID := testNodePoolID - - tests := []struct { - setupMocks func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockNodePoolService) - name string - nodePoolID string - body string - expectedStatusCode int - }{ - { - name: "Success 204 - nodepool force-deleted", - nodePoolID: nodePoolID, - body: `{"reason": "Stuck in finalizing for 2 hours"}`, - setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockNodePoolService) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - mockNodePoolSvc.EXPECT(). - GetByIDAndOwner(gomock.Any(), nodePoolID, clusterID). - Return(&api.NodePool{}, nil) - mockNodePoolSvc.EXPECT(). - ForceDelete(gomock.Any(), nodePoolID, "Stuck in finalizing for 2 hours"). - Return(nil) - return mockClusterSvc, mockNodePoolSvc - }, - expectedStatusCode: http.StatusNoContent, - }, - { - name: "Error 400 - malformed JSON", - nodePoolID: nodePoolID, - body: `not json`, - setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockNodePoolService) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - return mockClusterSvc, mockNodePoolSvc - }, - expectedStatusCode: http.StatusBadRequest, - }, - { - name: "Error 400 - empty reason", - nodePoolID: nodePoolID, - body: `{"reason": ""}`, - setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockNodePoolService) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - return mockClusterSvc, mockNodePoolSvc - }, - expectedStatusCode: http.StatusBadRequest, - }, - { - name: "Error 400 - reason exceeds max length", - nodePoolID: nodePoolID, - body: `{"reason": "` + strings.Repeat("x", maxReasonLength+1) + `"}`, - setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockNodePoolService) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - return mockClusterSvc, mockNodePoolSvc - }, - expectedStatusCode: http.StatusBadRequest, - }, - { - name: "Error 404 - nodepool not found", - nodePoolID: "non-existent-id", - body: `{"reason": "some reason"}`, - setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockNodePoolService) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - mockNodePoolSvc.EXPECT(). - GetByIDAndOwner(gomock.Any(), "non-existent-id", clusterID). - Return(nil, errors.NotFound("NodePool with id='non-existent-id' not found")) - return mockClusterSvc, mockNodePoolSvc - }, - expectedStatusCode: http.StatusNotFound, - }, - { - name: "Error 409 - nodepool not in Finalizing state", - nodePoolID: nodePoolID, - body: `{"reason": "some reason"}`, - setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockNodePoolService) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - mockNodePoolSvc.EXPECT(). - GetByIDAndOwner(gomock.Any(), nodePoolID, clusterID). - Return(&api.NodePool{}, nil) - mockNodePoolSvc.EXPECT(). - ForceDelete(gomock.Any(), nodePoolID, "some reason"). - Return(errors.ConflictState("NodePool '%s' is not in Finalizing state", nodePoolID)) - return mockClusterSvc, mockNodePoolSvc - }, - expectedStatusCode: http.StatusConflict, - }, - { - name: "Error 500 - ownership lookup fails", - nodePoolID: nodePoolID, - body: `{"reason": "some reason"}`, - setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockNodePoolService) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - mockNodePoolSvc.EXPECT(). - GetByIDAndOwner(gomock.Any(), nodePoolID, clusterID). - Return(nil, errors.GeneralError("database connection lost")) - return mockClusterSvc, mockNodePoolSvc - }, - expectedStatusCode: http.StatusInternalServerError, - }, - { - name: "Error 500 - force-delete service error", - nodePoolID: nodePoolID, - body: `{"reason": "some reason"}`, - setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockNodePoolService) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - mockNodePoolSvc.EXPECT(). - GetByIDAndOwner(gomock.Any(), nodePoolID, clusterID). - Return(&api.NodePool{}, nil) - mockNodePoolSvc.EXPECT(). - ForceDelete(gomock.Any(), nodePoolID, "some reason"). - Return(errors.GeneralError("failed to delete adapter statuses")) - return mockClusterSvc, mockNodePoolSvc - }, - expectedStatusCode: http.StatusInternalServerError, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - RegisterTestingT(t) - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClusterSvc, mockNodePoolSvc := tt.setupMocks(ctrl) - handler := NewClusterNodePoolsHandler(mockClusterSvc, mockNodePoolSvc) - - reqURL := "/api/hyperfleet/v1/clusters/" + clusterID + "/nodepools/" + tt.nodePoolID + "/force-delete" - req := httptest.NewRequest(http.MethodPost, reqURL, strings.NewReader(tt.body)) - req.Header.Set("Content-Type", "application/json") - req = mux.SetURLVars(req, map[string]string{ - "id": clusterID, - "nodepool_id": tt.nodePoolID, - }) - - rr := httptest.NewRecorder() - handler.ForceDelete(rr, req) - - Expect(rr.Code).To(Equal(tt.expectedStatusCode)) - - if tt.expectedStatusCode == http.StatusNoContent { - Expect(rr.Body.Len()).To(Equal(0)) - } - - if tt.expectedStatusCode == http.StatusConflict { - var errResp openapi.ProblemDetails - err := json.Unmarshal(rr.Body.Bytes(), &errResp) - Expect(err).NotTo(HaveOccurred()) - Expect(errResp.Type).To(Equal(errors.ErrorTypeConflict)) - Expect(errResp.Status).To(Equal(http.StatusConflict)) - Expect(*errResp.Detail).To(ContainSubstring("not in Finalizing state")) - Expect(*errResp.Code).To(Equal("HYPERFLEET-CNF-003")) - } - }) - } -} - -func TestClusterNodePoolsHandler_Get_WithFieldsFilter(t *testing.T) { - RegisterTestingT(t) - - now := time.Now() - clusterID := testClusterID - nodePoolID := testNodePoolID - - tests := []struct { - setupMocks func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockNodePoolService) - validateResponse func(body []byte) - name string - queryParams string - expectedStatusCode int - }{ - { - name: "Success - Filter with spec and owner_references.id - HYPERFLEET-1142 regression test", - queryParams: "?fields=id,name,labels,spec,owner_references.id", - setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockNodePoolService) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - - mockNodePoolSvc.EXPECT().GetByIDAndOwner(gomock.Any(), nodePoolID, clusterID).Return(&api.NodePool{ - Meta: api.Meta{ID: nodePoolID, CreatedTime: now, UpdatedTime: now}, - Kind: "NodePool", - Name: "worker-pool", - Spec: []byte(`{"replicas": 3, "instanceType": "m5.large"}`), - Labels: []byte(`{"tier": "worker"}`), - StatusConditions: []byte("[]"), - OwnerID: clusterID, - OwnerKind: "Cluster", - OwnerHref: "/api/hyperfleet/v1/clusters/" + clusterID, - Generation: 1, - CreatedBy: testSystemUser, - UpdatedBy: testSystemUser, - }, nil) - - return mockClusterSvc, mockNodePoolSvc - }, - expectedStatusCode: http.StatusOK, - validateResponse: func(body []byte) { - var response map[string]interface{} - err := json.Unmarshal(body, &response) - Expect(err).NotTo(HaveOccurred()) - - // Should have requested fields - Expect(response).To(HaveKey("id")) - Expect(response).To(HaveKey("name")) - Expect(response).To(HaveKey("labels")) - Expect(response).To(HaveKey("spec")) - Expect(response).To(HaveKey("owner_references")) - - // Verify owner_references.id is present - ownerRefs := response["owner_references"].(map[string]interface{}) - Expect(ownerRefs).To(HaveKey("id")) - - // Should NOT have other fields - Expect(response).ToNot(HaveKey("generation")) - Expect(response).ToNot(HaveKey("status")) - Expect(response).ToNot(HaveKey("created_time")) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - RegisterTestingT(t) - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClusterSvc, mockNodePoolSvc := tt.setupMocks(ctrl) - handler := NewClusterNodePoolsHandler(mockClusterSvc, mockNodePoolSvc) - - reqURL := "/api/hyperfleet/v1/clusters/" + clusterID + "/nodepools/" + nodePoolID + tt.queryParams - req := httptest.NewRequest(http.MethodGet, reqURL, nil) - req = mux.SetURLVars(req, map[string]string{ - "id": clusterID, - "nodepool_id": nodePoolID, - }) - - rr := httptest.NewRecorder() - handler.Get(rr, req) - - Expect(rr.Code).To(Equal(tt.expectedStatusCode)) - tt.validateResponse(rr.Body.Bytes()) - }) - } -} diff --git a/pkg/handlers/cluster_status.go b/pkg/handlers/cluster_status.go deleted file mode 100644 index 5dc01b2f..00000000 --- a/pkg/handlers/cluster_status.go +++ /dev/null @@ -1,129 +0,0 @@ -package handlers - -import ( - "net/http" - - "github.com/gorilla/mux" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/presenters" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" -) - -type ClusterStatusHandler struct { - adapterStatusService services.AdapterStatusService - clusterService services.ClusterService -} - -func NewClusterStatusHandler( - adapterStatusService services.AdapterStatusService, - clusterService services.ClusterService, -) *ClusterStatusHandler { - return &ClusterStatusHandler{ - adapterStatusService: adapterStatusService, - clusterService: clusterService, - } -} - -// List returns all adapter statuses for a cluster with pagination -func (h ClusterStatusHandler) List(w http.ResponseWriter, r *http.Request) { - cfg := &handlerConfig{ - Action: func() (interface{}, *errors.ServiceError) { - ctx := r.Context() - clusterID := mux.Vars(r)["id"] - listArgs, err := services.NewListArguments(r.URL.Query()) - if err != nil { - return nil, err - } - - // Verify cluster exists - _, err = h.clusterService.Get(ctx, clusterID) - if err != nil { - return nil, err - } - - // Fetch adapter statuses with pagination - adapterStatuses, total, err := h.adapterStatusService.FindByResourcePaginated( - ctx, api.ResourceTypeCluster, clusterID, listArgs) - if err != nil { - return nil, err - } - - // Convert to OpenAPI models - items := make([]openapi.AdapterStatus, 0, len(adapterStatuses)) - for _, as := range adapterStatuses { - presented, presErr := presenters.PresentAdapterStatus(as) - if presErr != nil { - return nil, errors.GeneralError("Failed to present adapter status: %v", presErr) - } - items = append(items, presented) - } - - // Return list response with pagination metadata - response := openapi.AdapterStatusList{ - Items: items, - Page: int32(listArgs.Page), - Size: int32(len(items)), - Total: int32(total), - } - - return response, nil - }, - ErrorHandler: handleError, - } - - handleList(w, r, cfg) -} - -// Create creates or updates an adapter status for a cluster -func (h ClusterStatusHandler) Create(w http.ResponseWriter, r *http.Request) { - var req openapi.AdapterStatusCreateRequest - - cfg := &handlerConfig{ - MarshalInto: &req, - Validate: []validate{ - validateNotEmpty(&req, "Adapter", "adapter"), - validateObservedGeneration(&req), - validateConditions(&req, "Conditions"), - validateObservedTimeRange(&req.ObservedTime), - }, - Action: func() (interface{}, *errors.ServiceError) { - ctx := r.Context() - clusterID := mux.Vars(r)["id"] - - // Verify cluster exists - _, err := h.clusterService.Get(ctx, clusterID) - if err != nil { - return nil, err - } - - // Create adapter status from request - newStatus, convErr := presenters.ConvertAdapterStatus(api.ResourceTypeCluster, clusterID, &req) - if convErr != nil { - return nil, errors.GeneralError("Failed to convert adapter status: %v", convErr) - } - - // Process adapter status (handles Unknown status and upsert + aggregation) - adapterStatus, err := h.clusterService.ProcessAdapterStatus(ctx, clusterID, newStatus) - if err != nil { - return nil, err - } - - // If result is nil, return nil to signal 204 No Content - if adapterStatus == nil { - return nil, nil - } - - status, presErr := presenters.PresentAdapterStatus(adapterStatus) - if presErr != nil { - return nil, errors.GeneralError("Failed to present adapter status: %v", presErr) - } - return &status, nil - }, - ErrorHandler: handleError, - } - - handleCreateWithNoContent(w, r, cfg) -} diff --git a/pkg/handlers/cluster_test.go b/pkg/handlers/cluster_test.go deleted file mode 100644 index 86d556b8..00000000 --- a/pkg/handlers/cluster_test.go +++ /dev/null @@ -1,469 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/gorilla/mux" - . "github.com/onsi/gomega" - "go.uber.org/mock/gomock" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" -) - -func TestClusterHandler_Patch(t *testing.T) { - RegisterTestingT(t) - - now := time.Now() - clusterID := testClusterID - validBody := `{"spec":{"region":"us-east1"}}` - - tests := []struct { - setupMocks func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockGenericService) - name string - clusterID string - expectedStatusCode int - expectedError bool - }{ - { - name: "Success - Patch active cluster", - clusterID: clusterID, - setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockGenericService) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockGenericSvc := services.NewMockGenericService(ctrl) - - mockClusterSvc.EXPECT().Patch(gomock.Any(), clusterID, gomock.Any()).Return(&api.Cluster{ - Meta: api.Meta{ID: clusterID, CreatedTime: now, UpdatedTime: now}, - Name: "test-cluster", - Spec: []byte(`{"region":"us-east1"}`), - Labels: []byte("{}"), - StatusConditions: []byte("[]"), - CreatedBy: testSystemUser, - UpdatedBy: testSystemUser, - }, nil) - - return mockClusterSvc, mockGenericSvc - }, - expectedStatusCode: http.StatusOK, - expectedError: false, - }, - { - name: "Error 409 - Cluster is soft-deleted", - clusterID: clusterID, - setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockGenericService) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockGenericSvc := services.NewMockGenericService(ctrl) - - mockClusterSvc.EXPECT().Patch(gomock.Any(), clusterID, gomock.Any()). - Return(nil, errors.ConflictState("Cluster '%s' is marked for deletion", clusterID)) - - return mockClusterSvc, mockGenericSvc - }, - expectedStatusCode: http.StatusConflict, - expectedError: true, - }, - { - name: "Error 404 - Cluster not found", - clusterID: "non-existent", - setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockGenericService) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockGenericSvc := services.NewMockGenericService(ctrl) - - mockClusterSvc.EXPECT().Patch(gomock.Any(), "non-existent", gomock.Any()). - Return(nil, errors.NotFound("Cluster not found")) - - return mockClusterSvc, mockGenericSvc - }, - expectedStatusCode: http.StatusNotFound, - expectedError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - RegisterTestingT(t) - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClusterSvc, mockGenericSvc := tt.setupMocks(ctrl) - handler := NewClusterHandler(mockClusterSvc, mockGenericSvc) - - reqURL := "/api/hyperfleet/v1/clusters/" + tt.clusterID - req := httptest.NewRequest(http.MethodPatch, reqURL, strings.NewReader(validBody)) - req.Header.Set("Content-Type", "application/json") - req = mux.SetURLVars(req, map[string]string{ - "id": tt.clusterID, - }) - - rr := httptest.NewRecorder() - handler.Patch(rr, req) - - Expect(rr.Code).To(Equal(tt.expectedStatusCode)) - - if !tt.expectedError { - var response openapi.Cluster - err := json.Unmarshal(rr.Body.Bytes(), &response) - Expect(err).NotTo(HaveOccurred()) - Expect(*response.Id).To(Equal(clusterID)) - } - - if tt.expectedStatusCode == http.StatusConflict { - var errResp openapi.ProblemDetails - err := json.Unmarshal(rr.Body.Bytes(), &errResp) - Expect(err).NotTo(HaveOccurred()) - Expect(errResp.Type).To(Equal(errors.ErrorTypeConflict)) - Expect(errResp.Status).To(Equal(http.StatusConflict)) - Expect(*errResp.Detail).To(ContainSubstring("marked for deletion")) - Expect(*errResp.Code).To(Equal("HYPERFLEET-CNF-003")) - } - }) - } -} - -func TestClusterHandler_Patch_RejectsUnknownFields(t *testing.T) { - RegisterTestingT(t) - - tests := []struct { - name string - body string - }{ - {"rejects name", `{"name":"new-name","labels":{"env":"prod"}}`}, - {"rejects id", `{"id":"some-id","spec":{"region":"us-east1"}}`}, - {"rejects generation", `{"generation":5,"spec":{"region":"us-east1"}}`}, - {"rejects status", `{"status":{"phase":"Ready"},"spec":{"region":"us-east1"}}`}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - RegisterTestingT(t) - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClusterSvc := services.NewMockClusterService(ctrl) - mockGenericSvc := services.NewMockGenericService(ctrl) - handler := NewClusterHandler(mockClusterSvc, mockGenericSvc) - - reqURL := "/api/hyperfleet/v1/clusters/" + testClusterID - req := httptest.NewRequest(http.MethodPatch, reqURL, strings.NewReader(tt.body)) - req.Header.Set("Content-Type", "application/json") - req = mux.SetURLVars(req, map[string]string{"id": testClusterID}) - - rr := httptest.NewRecorder() - handler.Patch(rr, req) - - Expect(rr.Code).To(Equal(http.StatusBadRequest)) - - var errResp openapi.ProblemDetails - err := json.Unmarshal(rr.Body.Bytes(), &errResp) - Expect(err).NotTo(HaveOccurred()) - Expect(*errResp.Code).To(Equal(errors.CodeBadRequest)) - }) - } -} - -func TestClusterHandler_ForceDelete(t *testing.T) { - RegisterTestingT(t) - - clusterID := testClusterID - - tests := []struct { - setupMocks func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockGenericService) - name string - body string - expectedStatusCode int - }{ - { - name: "Success 204 - cluster force-deleted", - body: `{"reason": "Stuck in finalizing for 2 hours"}`, - setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockGenericService) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockGenericSvc := services.NewMockGenericService(ctrl) - mockClusterSvc.EXPECT(). - ForceDelete(gomock.Any(), clusterID, "Stuck in finalizing for 2 hours"). - Return(nil) - return mockClusterSvc, mockGenericSvc - }, - expectedStatusCode: http.StatusNoContent, - }, - { - name: "Error 400 - malformed JSON", - body: `not json`, - setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockGenericService) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockGenericSvc := services.NewMockGenericService(ctrl) - return mockClusterSvc, mockGenericSvc - }, - expectedStatusCode: http.StatusBadRequest, - }, - { - name: "Error 400 - empty reason", - body: `{"reason": ""}`, - setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockGenericService) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockGenericSvc := services.NewMockGenericService(ctrl) - return mockClusterSvc, mockGenericSvc - }, - expectedStatusCode: http.StatusBadRequest, - }, - { - name: "Error 400 - reason exceeds max length", - body: `{"reason": "` + strings.Repeat("x", maxReasonLength+1) + `"}`, - setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockGenericService) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockGenericSvc := services.NewMockGenericService(ctrl) - return mockClusterSvc, mockGenericSvc - }, - expectedStatusCode: http.StatusBadRequest, - }, - { - name: "Error 404 - cluster not found", - body: `{"reason": "some reason"}`, - setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockGenericService) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockGenericSvc := services.NewMockGenericService(ctrl) - mockClusterSvc.EXPECT(). - ForceDelete(gomock.Any(), clusterID, "some reason"). - Return(errors.NotFound("Cluster with id='%s' not found", clusterID)) - return mockClusterSvc, mockGenericSvc - }, - expectedStatusCode: http.StatusNotFound, - }, - { - name: "Error 409 - cluster not in Finalizing state", - body: `{"reason": "some reason"}`, - setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockGenericService) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockGenericSvc := services.NewMockGenericService(ctrl) - mockClusterSvc.EXPECT(). - ForceDelete(gomock.Any(), clusterID, "some reason"). - Return(errors.ConflictState("Cluster '%s' is not in Finalizing state", clusterID)) - return mockClusterSvc, mockGenericSvc - }, - expectedStatusCode: http.StatusConflict, - }, - { - name: "Error 500 - service internal error", - body: `{"reason": "some reason"}`, - setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockGenericService) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockGenericSvc := services.NewMockGenericService(ctrl) - mockClusterSvc.EXPECT(). - ForceDelete(gomock.Any(), clusterID, "some reason"). - Return(errors.GeneralError("database connection lost")) - return mockClusterSvc, mockGenericSvc - }, - expectedStatusCode: http.StatusInternalServerError, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - RegisterTestingT(t) - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClusterSvc, mockGenericSvc := tt.setupMocks(ctrl) - handler := NewClusterHandler(mockClusterSvc, mockGenericSvc) - - reqURL := "/api/hyperfleet/v1/clusters/" + clusterID + "/force-delete" - req := httptest.NewRequest(http.MethodPost, reqURL, strings.NewReader(tt.body)) - req.Header.Set("Content-Type", "application/json") - req = mux.SetURLVars(req, map[string]string{"id": clusterID}) - - rr := httptest.NewRecorder() - handler.ForceDelete(rr, req) - - Expect(rr.Code).To(Equal(tt.expectedStatusCode)) - - if tt.expectedStatusCode == http.StatusNoContent { - Expect(rr.Body.Len()).To(Equal(0)) - } - }) - } -} - -func TestClusterHandler_Get_WithFieldsFilter(t *testing.T) { - RegisterTestingT(t) - - now := time.Now() - clusterID := testClusterID - - tests := []struct { - setupMocks func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockGenericService) - validateResponse func(body []byte) - name string - queryParams string - expectedStatusCode int - }{ - { - name: "Success - No fields filter returns full resource", - queryParams: "", - setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockGenericService) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockGenericSvc := services.NewMockGenericService(ctrl) - - mockClusterSvc.EXPECT().Get(gomock.Any(), clusterID).Return(&api.Cluster{ - Meta: api.Meta{ID: clusterID, CreatedTime: now, UpdatedTime: now}, - Name: "test-cluster", - Generation: 1, - Spec: []byte(`{"region":"us-east-1"}`), - Labels: []byte(`{"env":"prod"}`), - StatusConditions: []byte("[]"), - CreatedBy: testSystemUser, - UpdatedBy: testSystemUser, - }, nil) - - return mockClusterSvc, mockGenericSvc - }, - expectedStatusCode: http.StatusOK, - validateResponse: func(body []byte) { - var response openapi.Cluster - err := json.Unmarshal(body, &response) - Expect(err).NotTo(HaveOccurred()) - Expect(*response.Id).To(Equal(clusterID)) - Expect(response.Name).To(Equal("test-cluster")) - Expect(response.Generation).To(Equal(int32(1))) - Expect(response.Spec).ToNot(BeNil()) - Expect(response.Labels).ToNot(BeNil()) - }, - }, - { - name: "Success - Filter to only id and name", - queryParams: "?fields=id,name", - setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockGenericService) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockGenericSvc := services.NewMockGenericService(ctrl) - - mockClusterSvc.EXPECT().Get(gomock.Any(), clusterID).Return(&api.Cluster{ - Meta: api.Meta{ID: clusterID, CreatedTime: now, UpdatedTime: now}, - Name: "test-cluster", - Generation: 1, - Spec: []byte(`{"region":"us-east-1"}`), - Labels: []byte(`{"env":"prod"}`), - StatusConditions: []byte("[]"), - CreatedBy: testSystemUser, - UpdatedBy: testSystemUser, - }, nil) - - return mockClusterSvc, mockGenericSvc - }, - expectedStatusCode: http.StatusOK, - validateResponse: func(body []byte) { - var response map[string]interface{} - err := json.Unmarshal(body, &response) - Expect(err).NotTo(HaveOccurred()) - - // Should have only id and name - Expect(response).To(HaveKey("id")) - Expect(response).To(HaveKey("name")) - Expect(response["name"]).To(Equal("test-cluster")) - - // Should NOT have other fields - Expect(response).ToNot(HaveKey("generation")) - Expect(response).ToNot(HaveKey("spec")) - Expect(response).ToNot(HaveKey("labels")) - Expect(response).ToNot(HaveKey("created_time")) - }, - }, - { - name: "Success - Filter with kind and generation", - queryParams: "?fields=kind,generation", - setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockGenericService) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockGenericSvc := services.NewMockGenericService(ctrl) - - mockClusterSvc.EXPECT().Get(gomock.Any(), clusterID).Return(&api.Cluster{ - Meta: api.Meta{ID: clusterID, CreatedTime: now, UpdatedTime: now}, - Name: "test-cluster", - Generation: 1, - Spec: []byte(`{"region":"us-east-1"}`), - Labels: []byte(`{"env":"prod"}`), - StatusConditions: []byte("[]"), - CreatedBy: testSystemUser, - UpdatedBy: testSystemUser, - }, nil) - - return mockClusterSvc, mockGenericSvc - }, - expectedStatusCode: http.StatusOK, - validateResponse: func(body []byte) { - var response map[string]interface{} - err := json.Unmarshal(body, &response) - Expect(err).NotTo(HaveOccurred()) - - // id is always included automatically - Expect(response).To(HaveKey("id")) - Expect(response).To(HaveKey("kind")) - Expect(response).To(HaveKey("generation")) - Expect(response["generation"]).To(Equal(float64(1))) - - // Should NOT have other fields - Expect(response).ToNot(HaveKey("name")) - Expect(response).ToNot(HaveKey("spec")) - Expect(response).ToNot(HaveKey("labels")) - }, - }, - { - name: "Error 400 - Invalid field name", - queryParams: "?fields=id,nonexistent_field", - setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockGenericService) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockGenericSvc := services.NewMockGenericService(ctrl) - - mockClusterSvc.EXPECT().Get(gomock.Any(), clusterID).Return(&api.Cluster{ - Meta: api.Meta{ID: clusterID, CreatedTime: now, UpdatedTime: now}, - Name: "test-cluster", - Generation: 1, - Spec: []byte(`{"region":"us-east-1"}`), - Labels: []byte(`{"env":"prod"}`), - StatusConditions: []byte("[]"), - CreatedBy: testSystemUser, - UpdatedBy: testSystemUser, - }, nil) - - return mockClusterSvc, mockGenericSvc - }, - expectedStatusCode: http.StatusBadRequest, - validateResponse: func(body []byte) { - var errResp openapi.ProblemDetails - err := json.Unmarshal(body, &errResp) - Expect(err).NotTo(HaveOccurred()) - Expect(errResp.Status).To(Equal(http.StatusBadRequest)) - Expect(*errResp.Detail).To(ContainSubstring("doesn't exist")) - Expect(*errResp.Detail).To(ContainSubstring("nonexistent_field")) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - RegisterTestingT(t) - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockClusterSvc, mockGenericSvc := tt.setupMocks(ctrl) - handler := NewClusterHandler(mockClusterSvc, mockGenericSvc) - - reqURL := "/api/hyperfleet/v1/clusters/" + clusterID + tt.queryParams - req := httptest.NewRequest(http.MethodGet, reqURL, nil) - req = mux.SetURLVars(req, map[string]string{"id": clusterID}) - - rr := httptest.NewRecorder() - handler.Get(rr, req) - - Expect(rr.Code).To(Equal(tt.expectedStatusCode)) - tt.validateResponse(rr.Body.Bytes()) - }) - } -} diff --git a/pkg/handlers/name_validation_handler_test.go b/pkg/handlers/name_validation_handler_test.go new file mode 100644 index 00000000..5db1e70c --- /dev/null +++ b/pkg/handlers/name_validation_handler_test.go @@ -0,0 +1,187 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" + "gorm.io/datatypes" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" +) + +func newNameValidationHandler( + ctrl *gomock.Controller, + descriptor registry.EntityDescriptor, +) (*ResourceHandler, *services.MockResourceService) { + mockResourceSvc := services.NewMockResourceService(ctrl) + handler := NewResourceHandler(descriptor, mockResourceSvc) + return handler, mockResourceSvc +} + +func TestResourceHandler_Create_NameTooShort(t *testing.T) { + RegisterTestingT(t) + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + descriptor := registry.EntityDescriptor{ + Kind: "TestEntity", + Plural: "testentities", + NameMinLen: 3, + NameMaxLen: 53, + } + handler, _ := newNameValidationHandler(ctrl, descriptor) + + body := `{"kind":"TestEntity","name":"ab","spec":{"key":"value"}}` + req := httptest.NewRequest(http.MethodPost, "/api/hyperfleet/v1/testentities", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + handler.Create(rr, req) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) +} + +func TestResourceHandler_Create_NameTooLong(t *testing.T) { + RegisterTestingT(t) + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + descriptor := registry.EntityDescriptor{ + Kind: "TestEntity", + Plural: "testentities", + NameMinLen: 3, + NameMaxLen: 5, + } + handler, _ := newNameValidationHandler(ctrl, descriptor) + + body := `{"kind":"TestEntity","name":"toolongname","spec":{"key":"value"}}` + req := httptest.NewRequest(http.MethodPost, "/api/hyperfleet/v1/testentities", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + handler.Create(rr, req) + Expect(rr.Code).To(Equal(http.StatusBadRequest)) +} + +func TestResourceHandler_Create_NameAtMinLength(t *testing.T) { + now := time.Now() + + RegisterTestingT(t) + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + descriptor := registry.EntityDescriptor{ + Kind: "TestEntity", + Plural: "testentities", + NameMinLen: 3, + NameMaxLen: 53, + } + handler, mockSvc := newNameValidationHandler(ctrl, descriptor) + + mockSvc.EXPECT().Create(gomock.Any(), "TestEntity", gomock.AssignableToTypeOf(&api.Resource{})).Return(&api.Resource{ + Meta: api.Meta{ID: "test-123", CreatedTime: now, UpdatedTime: now}, + Kind: "TestEntity", + Name: "abc", + Href: "/api/hyperfleet/v1/testentities/test-123", + Spec: datatypes.JSON(`{"key":"value"}`), + Generation: 1, + CreatedBy: "system@hyperfleet.local", + UpdatedBy: "system@hyperfleet.local", + }, nil) + + body := `{"kind":"TestEntity","name":"abc","spec":{"key":"value"}}` + req := httptest.NewRequest(http.MethodPost, "/api/hyperfleet/v1/testentities", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + handler.Create(rr, req) + Expect(rr.Code).To(Equal(http.StatusCreated)) + + var resp openapi.Resource + err := json.Unmarshal(rr.Body.Bytes(), &resp) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.Name).To(Equal("abc")) +} + +func TestResourceHandler_Create_NameAtMaxLength(t *testing.T) { + now := time.Now() + + RegisterTestingT(t) + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + descriptor := registry.EntityDescriptor{ + Kind: "TestEntity", + Plural: "testentities", + NameMinLen: 3, + NameMaxLen: 5, + } + handler, mockSvc := newNameValidationHandler(ctrl, descriptor) + + mockSvc.EXPECT().Create(gomock.Any(), "TestEntity", gomock.AssignableToTypeOf(&api.Resource{})).Return(&api.Resource{ + Meta: api.Meta{ID: "test-456", CreatedTime: now, UpdatedTime: now}, + Kind: "TestEntity", + Name: "abcde", + Href: "/api/hyperfleet/v1/testentities/test-456", + Spec: datatypes.JSON(`{"key":"value"}`), + Generation: 1, + CreatedBy: "system@hyperfleet.local", + UpdatedBy: "system@hyperfleet.local", + }, nil) + + body := `{"kind":"TestEntity","name":"abcde","spec":{"key":"value"}}` + req := httptest.NewRequest(http.MethodPost, "/api/hyperfleet/v1/testentities", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + handler.Create(rr, req) + Expect(rr.Code).To(Equal(http.StatusCreated)) + + var resp openapi.Resource + err := json.Unmarshal(rr.Body.Bytes(), &resp) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.Name).To(Equal("abcde")) +} + +func TestResourceHandler_Create_NoNameValidationWhenZeroLengths(t *testing.T) { + now := time.Now() + + RegisterTestingT(t) + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + descriptor := registry.EntityDescriptor{ + Kind: "TestEntity", + Plural: "testentities", + NameMinLen: 0, + NameMaxLen: 0, + } + handler, mockSvc := newNameValidationHandler(ctrl, descriptor) + + mockSvc.EXPECT().Create(gomock.Any(), "TestEntity", gomock.AssignableToTypeOf(&api.Resource{})).Return(&api.Resource{ + Meta: api.Meta{ID: "test-789", CreatedTime: now, UpdatedTime: now}, + Kind: "TestEntity", + Name: "x", + Href: "/api/hyperfleet/v1/testentities/test-789", + Spec: datatypes.JSON(`{"key":"value"}`), + Generation: 1, + CreatedBy: "system@hyperfleet.local", + UpdatedBy: "system@hyperfleet.local", + }, nil) + + body := `{"kind":"TestEntity","name":"x","spec":{"key":"value"}}` + req := httptest.NewRequest(http.MethodPost, "/api/hyperfleet/v1/testentities", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + handler.Create(rr, req) + Expect(rr.Code).To(Equal(http.StatusCreated)) +} diff --git a/pkg/handlers/node_pool.go b/pkg/handlers/node_pool.go deleted file mode 100644 index cca3b319..00000000 --- a/pkg/handlers/node_pool.go +++ /dev/null @@ -1,74 +0,0 @@ -package handlers - -import ( - "net/http" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/presenters" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" -) - -type NodePoolHandler struct { - nodePool services.NodePoolService - generic services.GenericService -} - -func NewNodePoolHandler(nodePool services.NodePoolService, generic services.GenericService) *NodePoolHandler { - return &NodePoolHandler{ - nodePool: nodePool, - generic: generic, - } -} - -func (h NodePoolHandler) List(w http.ResponseWriter, r *http.Request) { - cfg := &handlerConfig{ - Action: func() (interface{}, *errors.ServiceError) { - ctx := r.Context() - - listArgs, err := services.NewListArguments(r.URL.Query()) - if err != nil { - return nil, err - } - var nodePools []api.NodePool - paging, err := h.generic.List(ctx, listArgs, &nodePools) - if err != nil { - return nil, err - } - // Build list response manually since there's no NodePoolList in OpenAPI - items := make([]openapi.NodePool, 0, len(nodePools)) - - for _, nodePool := range nodePools { - presented, err := presenters.PresentNodePool(&nodePool) - if err != nil { - return nil, errors.GeneralError("Failed to present nodepool: %v", err) - } - items = append(items, presented) - } - - nodePoolList := struct { - Items []openapi.NodePool `json:"items"` - Page int32 `json:"page"` - Size int32 `json:"size"` - Total int32 `json:"total"` - }{ - Page: int32(paging.Page), - Size: int32(paging.Size), - Total: int32(paging.Total), - Items: items, - } - if listArgs.Fields != nil { - filteredItems, err := presenters.SliceFilter(listArgs.Fields, nodePoolList) - if err != nil { - return nil, err - } - return filteredItems, nil - } - return nodePoolList, nil - }, - ErrorHandler: handleError, - } - - handleList(w, r, cfg) -} diff --git a/pkg/handlers/node_pool_test.go b/pkg/handlers/node_pool_test.go deleted file mode 100644 index dce51c20..00000000 --- a/pkg/handlers/node_pool_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - . "github.com/onsi/gomega" - "go.uber.org/mock/gomock" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" -) - -func TestNodePoolHandler_List(t *testing.T) { - RegisterTestingT(t) - - now := time.Now() - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - mockGenericSvc := services.NewMockGenericService(ctrl) - mockGenericSvc.EXPECT().List(gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ interface{}, _ *services.ListArguments, out interface{}) (*api.PagingMeta, *errors.ServiceError) { - nodePools := out.(*[]api.NodePool) - *nodePools = []api.NodePool{ - { - Meta: api.Meta{ID: "np-1", CreatedTime: now, UpdatedTime: now}, - Name: "test-np", - Spec: []byte(`{}`), - Labels: []byte(`{}`), - StatusConditions: []byte(`[]`), - CreatedBy: testSystemUser, - UpdatedBy: testSystemUser, - }, - } - return &api.PagingMeta{Page: 1, Size: 1, Total: 1}, nil - }) - - handler := NewNodePoolHandler(mockNodePoolSvc, mockGenericSvc) - - req := httptest.NewRequest(http.MethodGet, "/api/hyperfleet/v1/nodepools", nil) - rr := httptest.NewRecorder() - - handler.List(rr, req) - Expect(rr.Code).To(Equal(http.StatusOK)) - - var raw map[string]interface{} - Expect(json.Unmarshal(rr.Body.Bytes(), &raw)).To(Succeed()) - Expect(raw).NotTo(HaveKey("kind")) -} diff --git a/pkg/handlers/nodepool_status.go b/pkg/handlers/nodepool_status.go deleted file mode 100644 index c6bd694f..00000000 --- a/pkg/handlers/nodepool_status.go +++ /dev/null @@ -1,124 +0,0 @@ -package handlers - -import ( - "net/http" - - "github.com/gorilla/mux" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/presenters" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" -) - -type NodePoolStatusHandler struct { - adapterStatusService services.AdapterStatusService - nodePoolService services.NodePoolService -} - -func NewNodePoolStatusHandler( - adapterStatusService services.AdapterStatusService, - nodePoolService services.NodePoolService, -) *NodePoolStatusHandler { - return &NodePoolStatusHandler{ - adapterStatusService: adapterStatusService, - nodePoolService: nodePoolService, - } -} - -// List returns all adapter statuses for a nodepool with pagination -func (h NodePoolStatusHandler) List(w http.ResponseWriter, r *http.Request) { - cfg := &handlerConfig{ - Action: func() (interface{}, *errors.ServiceError) { - ctx := r.Context() - nodePoolID := mux.Vars(r)[logger.FieldNodePoolID] - listArgs, err := services.NewListArguments(r.URL.Query()) - if err != nil { - return nil, err - } - - // Fetch adapter statuses with pagination - adapterStatuses, total, err := h.adapterStatusService.FindByResourcePaginated( - ctx, api.ResourceTypeNodePool, nodePoolID, listArgs) - if err != nil { - return nil, err - } - - // Convert to OpenAPI models - items := make([]openapi.AdapterStatus, 0, len(adapterStatuses)) - for _, as := range adapterStatuses { - presented, presErr := presenters.PresentAdapterStatus(as) - if presErr != nil { - return nil, errors.GeneralError("Failed to present adapter status: %v", presErr) - } - items = append(items, presented) - } - - // Return list response with pagination metadata - response := openapi.AdapterStatusList{ - Items: items, - Page: int32(listArgs.Page), - Size: int32(len(items)), - Total: int32(total), - } - - return response, nil - }, - ErrorHandler: handleError, - } - - handleList(w, r, cfg) -} - -// Create creates or updates an adapter status for a nodepool -func (h NodePoolStatusHandler) Create(w http.ResponseWriter, r *http.Request) { - var req openapi.AdapterStatusCreateRequest - - cfg := &handlerConfig{ - MarshalInto: &req, - Validate: []validate{ - validateNotEmpty(&req, "Adapter", "adapter"), - validateObservedGeneration(&req), - validateConditions(&req, "Conditions"), - validateObservedTimeRange(&req.ObservedTime), - }, - Action: func() (interface{}, *errors.ServiceError) { - ctx := r.Context() - nodePoolID := mux.Vars(r)[logger.FieldNodePoolID] - - // Verify nodepool exists - _, err := h.nodePoolService.Get(ctx, nodePoolID) - if err != nil { - return nil, err - } - - // Create adapter status from request - newStatus, convErr := presenters.ConvertAdapterStatus(api.ResourceTypeNodePool, nodePoolID, &req) - if convErr != nil { - return nil, errors.GeneralError("Failed to convert adapter status: %v", convErr) - } - - // Process adapter status (handles Unknown status and upsert + aggregation) - adapterStatus, err := h.nodePoolService.ProcessAdapterStatus(ctx, nodePoolID, newStatus) - if err != nil { - return nil, err - } - - // If result is nil, return nil to signal 204 No Content - if adapterStatus == nil { - return nil, nil - } - - status, presErr := presenters.PresentAdapterStatus(adapterStatus) - if presErr != nil { - return nil, errors.GeneralError("Failed to present adapter status: %v", presErr) - } - return &status, nil - }, - ErrorHandler: handleError, - } - - handleCreateWithNoContent(w, r, cfg) -} diff --git a/pkg/handlers/resource_handler.go b/pkg/handlers/resource_handler.go index e4b90f32..47a3f581 100644 --- a/pkg/handlers/resource_handler.go +++ b/pkg/handlers/resource_handler.go @@ -42,6 +42,7 @@ func (h *ResourceHandler) Create(w http.ResponseWriter, r *http.Request) { MarshalInto: &req, Validate: []validate{ validateKind(&req, "Kind", "kind", h.descriptor.Kind), + validateName(&req, "Name", "name", h.descriptor.NameMinLen, h.descriptor.NameMaxLen), validateSpec(&req, "Spec", "spec"), validateLabels(&req, "Labels"), }, diff --git a/pkg/handlers/resource_handler_test.go b/pkg/handlers/resource_handler_test.go index ef6b3117..bba78980 100644 --- a/pkg/handlers/resource_handler_test.go +++ b/pkg/handlers/resource_handler_test.go @@ -432,7 +432,8 @@ func TestResourceHandler_GetByOwner(t *testing.T) { name: "Success", setupMock: func(mock *services.MockResourceService) { mock.EXPECT().Get(gomock.Any(), "Channel", "ch-1").Return(&api.Resource{ - Meta: api.Meta{ID: "ch-1"}, Kind: "Channel", + Meta: api.Meta{ID: "ch-1", CreatedTime: now, UpdatedTime: now}, + Kind: "Channel", Name: "stable", Spec: datatypes.JSON(`{}`), CreatedBy: "u@t.com", UpdatedBy: "u@t.com", }, nil) mock.EXPECT().GetByOwner(gomock.Any(), "Version", "v-1", "ch-1").Return(&api.Resource{ @@ -443,19 +444,12 @@ func TestResourceHandler_GetByOwner(t *testing.T) { }, expectedStatusCode: http.StatusOK, }, - { - name: "Parent not found", - setupMock: func(mock *services.MockResourceService) { - mock.EXPECT().Get(gomock.Any(), "Channel", "ch-1"). - Return(nil, errors.NotFound("Channel not found")) - }, - expectedStatusCode: http.StatusNotFound, - }, { name: "Child not found", setupMock: func(mock *services.MockResourceService) { mock.EXPECT().Get(gomock.Any(), "Channel", "ch-1").Return(&api.Resource{ - Meta: api.Meta{ID: "ch-1"}, Kind: "Channel", + Meta: api.Meta{ID: "ch-1", CreatedTime: now, UpdatedTime: now}, + Kind: "Channel", Name: "stable", Spec: datatypes.JSON(`{}`), CreatedBy: "u@t.com", UpdatedBy: "u@t.com", }, nil) mock.EXPECT().GetByOwner(gomock.Any(), "Version", "v-1", "ch-1"). @@ -498,7 +492,8 @@ func TestResourceHandler_ListByOwner(t *testing.T) { name: "Success", setupMock: func(mock *services.MockResourceService) { mock.EXPECT().Get(gomock.Any(), "Channel", "ch-1").Return(&api.Resource{ - Meta: api.Meta{ID: "ch-1"}, Kind: "Channel", + Meta: api.Meta{ID: "ch-1", CreatedTime: now, UpdatedTime: now}, + Kind: "Channel", Name: "stable", Spec: datatypes.JSON(`{}`), CreatedBy: "u@t.com", UpdatedBy: "u@t.com", }, nil) mock.EXPECT().ListByOwner(gomock.Any(), "Version", "ch-1", @@ -514,15 +509,6 @@ func TestResourceHandler_ListByOwner(t *testing.T) { expectedStatusCode: http.StatusOK, expectedItems: 1, }, - { - name: "Parent not found", - setupMock: func(mock *services.MockResourceService) { - mock.EXPECT().Get(gomock.Any(), "Channel", "ch-1"). - Return(nil, errors.NotFound("Channel not found")) - }, - expectedStatusCode: http.StatusNotFound, - expectedItems: 0, - }, } for _, tt := range tests { diff --git a/pkg/handlers/validation.go b/pkg/handlers/validation.go index e51cf054..a6609674 100755 --- a/pkg/handlers/validation.go +++ b/pkg/handlers/validation.go @@ -8,8 +8,6 @@ import ( "time" "unicode/utf8" - "github.com/google/uuid" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" ) @@ -63,13 +61,6 @@ func isValidLabelKeyPrefix(s string) bool { return true } -func validatePathID(id, name string) *errors.ServiceError { - if _, err := uuid.Parse(id); err != nil { - return errors.Validation("invalid %s format", name) - } - return nil -} - // Cluster/NodePool name pattern: compliant with Kubernetes DNS Subdomain Names (RFC 1123) // Must start and end with alphanumeric, can contain hyphens in the middle var namePattern = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`) @@ -107,22 +98,6 @@ func validateMaxLength(i interface{}, fieldName string, field string, maxLen int } } -func validateEmpty(i interface{}, fieldName string, field string) validate { - return func() *errors.ServiceError { - value := reflect.ValueOf(i).Elem().FieldByName(fieldName) - if value.Kind() == reflect.Ptr { - if value.IsNil() { - return nil - } - value = value.Elem() - } - if len(value.String()) != 0 { - return errors.Validation("%s must be empty", field) - } - return nil - } -} - // validateName validates that a name field matches the pattern ^[a-z0-9-]+$ and length constraints // //nolint:unparam // fieldName is kept as parameter for flexibility even though currently only "Name" is used @@ -143,13 +118,13 @@ func validateName(i interface{}, fieldName string, field string, minLen, maxLen return errors.Validation("%s is required", field) } - // Check minimum length - if len(name) < minLen { + // Check minimum length (0 = no constraint) + if minLen > 0 && len(name) < minLen { return errors.Validation("%s must be at least %d characters", field, minLen) } - // Check maximum length - if len(name) > maxLen { + // Check maximum length (0 = no constraint) + if maxLen > 0 && len(name) > maxLen { return errors.Validation("%s must be at most %d characters", field, maxLen) } diff --git a/pkg/middleware/schema_validation.go b/pkg/middleware/schema_validation.go index ac905e16..f67bd9fc 100644 --- a/pkg/middleware/schema_validation.go +++ b/pkg/middleware/schema_validation.go @@ -70,24 +70,7 @@ func buildMatchers(specEntities []registry.EntityDescriptor) []specEntityMatcher // SchemaValidationMiddleware validates resource spec fields against OpenAPI schemas for every // registered entity that declares SpecSchemaName. func SchemaValidationMiddleware(validator *validators.SchemaValidator) func(http.Handler) http.Handler { - - // TODO : HYPERFLEET-1159 - Remove this once Cluster and NodePool are registered - specEntities := []registry.EntityDescriptor{ - { - Kind: "Cluster", - Plural: "clusters", - SpecSchemaName: "ClusterSpec", - }, - { - Kind: "NodePool", - Plural: "nodepools", - ParentKind: "Cluster", - SpecSchemaName: "NodePoolSpec", - }, - } - - specEntities = append(specEntities, registry.WithSpecSchema()...) - matchers := buildMatchers(specEntities) + matchers := buildMatchers(registry.WithSpecSchema()) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/middleware/schema_validation_test.go b/pkg/middleware/schema_validation_test.go index e37bf60f..84856302 100644 --- a/pkg/middleware/schema_validation_test.go +++ b/pkg/middleware/schema_validation_test.go @@ -541,6 +541,17 @@ func TestSchemaValidationMiddleware_MalformedJSON(t *testing.T) { func setupTestValidator(t *testing.T) *validators.SchemaValidator { t.Helper() registry.Reset() + registry.Register(registry.EntityDescriptor{ + Kind: "Cluster", + Plural: "clusters", + SpecSchemaName: "ClusterSpec", + }) + registry.Register(registry.EntityDescriptor{ + Kind: "NodePool", + Plural: "nodepools", + ParentKind: "Cluster", + SpecSchemaName: "NodePoolSpec", + }) registry.Register(registry.EntityDescriptor{ Kind: "Foo", Plural: "foos", diff --git a/pkg/registry/descriptor.go b/pkg/registry/descriptor.go index 2df28365..e59532a3 100644 --- a/pkg/registry/descriptor.go +++ b/pkg/registry/descriptor.go @@ -23,7 +23,6 @@ type ReferenceDescriptor struct { // EntityDescriptor defines everything specific to a HyperFleet entity type. // Descriptors are loaded from the application config YAML at startup via LoadDescriptors. -// Cluster and NodePool use legacy typed plugins and are not registered here. type EntityDescriptor struct { // discriminator value stored in Resource.Kind Kind string `mapstructure:"kind" json:"kind"` @@ -43,4 +42,8 @@ type EntityDescriptor struct { References []ReferenceDescriptor `mapstructure:"references" json:"references,omitempty"` // panic at startup if SpecSchemaName missing from spec RequireSpecSchema bool `mapstructure:"require_spec_schema" json:"require_spec_schema,omitempty"` + // minimum name length (0 = no constraint) + NameMinLen int `mapstructure:"name_min_len" json:"name_min_len,omitempty"` + // maximum name length (0 = no constraint) + NameMaxLen int `mapstructure:"name_max_len" json:"name_max_len,omitempty"` } diff --git a/pkg/registry/name_validation_test.go b/pkg/registry/name_validation_test.go new file mode 100644 index 00000000..dd522dd3 --- /dev/null +++ b/pkg/registry/name_validation_test.go @@ -0,0 +1,39 @@ +package registry + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +func TestValidate_NameMaxLenLessThanMinLen_Panics(t *testing.T) { + RegisterTestingT(t) + Reset() + + Register(EntityDescriptor{ + Kind: "TestEntity", + Plural: "testentities", + NameMinLen: 10, + NameMaxLen: 5, + }) + + Expect(func() { + Validate() + }).To(PanicWith(ContainSubstring("NameMaxLen"))) +} + +func TestDescriptorFields_NameMinMaxLen(t *testing.T) { + RegisterTestingT(t) + Reset() + + Register(EntityDescriptor{ + Kind: "Cluster", + Plural: "clusters", + NameMinLen: 3, + NameMaxLen: 53, + }) + + d := MustGet("Cluster") + Expect(d.NameMinLen).To(Equal(3)) + Expect(d.NameMaxLen).To(Equal(53)) +} diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index 2d84a3d4..06f5d801 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -94,6 +94,7 @@ func ChildrenOf(parentKind string) []EntityDescriptor { // - ReferenceDescriptor with TargetKind that doesn't resolve // - duplicate RefType within a single entity's References // - Max < Min (when Max > 0) +// - NameMaxLen > 0 && NameMinLen > NameMaxLen func Validate() { plurals := make(map[string]string, len(descriptors)) @@ -145,6 +146,13 @@ func Validate() { )) } } + + if d.NameMaxLen > 0 && d.NameMinLen > d.NameMaxLen { + panic(fmt.Sprintf( + "entity %q: NameMaxLen (%d) must be >= NameMinLen (%d)", + d.Kind, d.NameMaxLen, d.NameMinLen, + )) + } } } diff --git a/pkg/services/adapter_status_validation.go b/pkg/services/adapter_status_validation.go index 2a3b4c4f..9af1ec97 100644 --- a/pkg/services/adapter_status_validation.go +++ b/pkg/services/adapter_status_validation.go @@ -13,9 +13,8 @@ import ( // aggregation should be triggered. Returns (nil, false, nil) when the update should // be silently discarded. // -// This is the shared implementation used by ClusterService, NodePoolService, and -// ResourceService. Callers provide the resource generation and a pre-built logger -// with entity-specific context fields. +// This is the shared implementation used by ResourceService. Callers provide +// the resource generation and a pre-built logger with entity-specific context fields. func validateAndClassifyAdapterStatus( resourceGeneration int32, adapterStatus *api.AdapterStatus, diff --git a/pkg/services/cluster.go b/pkg/services/cluster.go deleted file mode 100644 index d130e8db..00000000 --- a/pkg/services/cluster.go +++ /dev/null @@ -1,504 +0,0 @@ -package services - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/metrics" -) - -const defaultSystemUser = "system@hyperfleet.local" - -//go:generate mockgen-v0.6.0 -source=cluster.go -package=services -destination=cluster_mock.go - -type ClusterService interface { - Get(ctx context.Context, id string) (*api.Cluster, *errors.ServiceError) - Create(ctx context.Context, cluster *api.Cluster) (*api.Cluster, *errors.ServiceError) - Patch(ctx context.Context, id string, patch *api.ClusterPatch) (*api.Cluster, *errors.ServiceError) - SoftDelete(ctx context.Context, id string) (*api.Cluster, *errors.ServiceError) - UpdateClusterStatusFromAdapters(ctx context.Context, clusterID string) (*api.Cluster, *errors.ServiceError) - ProcessAdapterStatus(ctx context.Context, clusterID string, adapterStatus *api.AdapterStatus) (*api.AdapterStatus, *errors.ServiceError) // nolint:lll - ForceDelete(ctx context.Context, id, reason string) *errors.ServiceError -} - -func NewClusterService( - clusterDao dao.ClusterDao, - nodePoolDao dao.NodePoolDao, - nodePoolService NodePoolService, - adapterStatusDao dao.AdapterStatusDao, - adapterConfig *config.AdapterRequirementsConfig, -) ClusterService { - return &sqlClusterService{ - clusterDao: clusterDao, - nodePoolDao: nodePoolDao, - nodePoolService: nodePoolService, - adapterStatusDao: adapterStatusDao, - adapterConfig: adapterConfig, - } -} - -var _ ClusterService = &sqlClusterService{} - -type sqlClusterService struct { - clusterDao dao.ClusterDao - nodePoolDao dao.NodePoolDao - nodePoolService NodePoolService - adapterStatusDao dao.AdapterStatusDao - adapterConfig *config.AdapterRequirementsConfig -} - -func (s *sqlClusterService) Get(ctx context.Context, id string) (*api.Cluster, *errors.ServiceError) { - cluster, err := s.clusterDao.Get(ctx, id) - if err != nil { - return nil, handleGetError(api.ResourceTypeCluster, "id", id, err) - } - return cluster, nil -} - -func (s *sqlClusterService) Create(ctx context.Context, cluster *api.Cluster) (*api.Cluster, *errors.ServiceError) { - if cluster.CreatedBy == "" { - cluster.CreatedBy = actorFromContext(ctx) - } - if cluster.UpdatedBy == "" { - cluster.UpdatedBy = actorFromContext(ctx) - } - if cluster.Generation == 0 { - cluster.Generation = 1 - } - - cluster, err := s.clusterDao.Create(ctx, cluster) - if err != nil { - return nil, handleCreateError(api.ResourceTypeCluster, err) - } - - updatedCluster, svcErr := s.UpdateClusterStatusFromAdapters(ctx, cluster.ID) - if svcErr != nil { - return nil, svcErr - } - - return updatedCluster, nil -} - -func (s *sqlClusterService) Patch( - ctx context.Context, id string, patch *api.ClusterPatch, -) (*api.Cluster, *errors.ServiceError) { - cluster, err := s.clusterDao.GetForUpdate(ctx, id) - if err != nil { - return nil, handleGetError(api.ResourceTypeCluster, "id", id, err) - } - - if cluster.DeletedTime != nil { - return nil, errors.ConflictState("Cluster '%s' is marked for deletion", id) - } - - oldSpec := cluster.Spec - oldLabels := cluster.Labels - - if applyErr := applyClusterPatch(cluster, patch); applyErr != nil { - return nil, errors.Validation("Invalid patch data: %v", applyErr) - } - - if jsonEqual(oldSpec, cluster.Spec) && jsonEqual(oldLabels, cluster.Labels) { - return cluster, nil - } - - cluster.IncrementGeneration() - cluster.UpdatedBy = actorFromContext(ctx) - - if saveErr := s.clusterDao.Save(ctx, cluster); saveErr != nil { - return nil, handleUpdateError(api.ResourceTypeCluster, saveErr) - } - - updated, svcErr := s.UpdateClusterStatusFromAdapters(ctx, cluster.ID) - if svcErr != nil { - return nil, svcErr - } - return updated, nil -} - -// SoftDelete marks a cluster for deletion by setting DeletedTime and -// DeletedBy to the current time and the caller identity from context. -// If already marked, it returns the cluster unchanged. Cascades the deletion timestamp to all child nodepools. -// Actual removal is handled by adapters detecting the new generation and triggering hard deletion asynchronously. -func (s *sqlClusterService) SoftDelete(ctx context.Context, id string) (*api.Cluster, *errors.ServiceError) { - cluster, err := s.clusterDao.GetForUpdate(ctx, id) - if err != nil { - return nil, handleSoftDeleteError(api.ResourceTypeCluster, err) - } - - // Already marked for deletion — skip cascade to avoid unnecessary DB roundtrips. - if cluster.DeletedTime != nil { - return cluster, nil - } - - deletedTime := time.Now().UTC().Truncate(time.Microsecond) - deletedBy := actorFromContext(ctx) - cluster.MarkDeleted(deletedBy, deletedTime) - cluster.IncrementGeneration() - - if saveErr := s.clusterDao.Save(ctx, cluster); saveErr != nil { - return nil, handleSoftDeleteError(api.ResourceTypeCluster, saveErr) - } - - cluster, svcErr := s.UpdateClusterStatusFromAdapters(ctx, id) - if svcErr != nil { - return nil, svcErr - } - - allNodePools, err := s.nodePoolDao.FindByOwner(ctx, id) - if err != nil { - return nil, errors.GeneralError("Failed to fetch nodepools for cascade delete: %s", err) - } - - if svcErr := s.nodePoolService.CascadeSoftDelete(ctx, allNodePools, deletedBy, deletedTime); svcErr != nil { - return nil, svcErr - } - - return cluster, nil -} - -func applyClusterPatch(cluster *api.Cluster, patch *api.ClusterPatch) error { - if patch.Spec != nil { - specJSON, err := json.Marshal(patch.Spec) - if err != nil { - return fmt.Errorf("failed to marshal cluster spec: %w", err) - } - cluster.Spec = specJSON - } - if patch.Labels != nil { - labelsJSON, err := json.Marshal(patch.Labels) - if err != nil { - return fmt.Errorf("failed to marshal cluster labels: %w", err) - } - cluster.Labels = labelsJSON - } - return nil -} - -func clusterRefTime(c *api.Cluster) time.Time { - if c == nil { - return time.Time{} - } - if !c.UpdatedTime.IsZero() { - return c.UpdatedTime - } - return c.CreatedTime -} - -// UpdateClusterStatusFromAdapters recomputes aggregated Reconciled, Available, and per-adapter conditions -// from stored adapter rows and persists them to the cluster's status. -func (s *sqlClusterService) UpdateClusterStatusFromAdapters( - ctx context.Context, clusterID string, -) (*api.Cluster, *errors.ServiceError) { - cluster, err := s.clusterDao.Get(ctx, clusterID) - if err != nil { - return nil, handleGetError(api.ResourceTypeCluster, "id", clusterID, err) - } - - adapterStatuses, err := s.adapterStatusDao.FindByResource(ctx, api.ResourceTypeCluster, clusterID) - if err != nil { - return nil, errors.GeneralError("Failed to get adapter statuses: %s", err) - } - - return s.recomputeAndSaveClusterStatus(ctx, cluster, adapterStatuses) -} - -// recomputeAndSaveClusterStatus aggregates adapter reports into cluster conditions and -// persists only when the result differs from the current stored value. -func (s *sqlClusterService) recomputeAndSaveClusterStatus( - ctx context.Context, cluster *api.Cluster, adapterStatuses api.AdapterStatusList, -) (*api.Cluster, *errors.ServiceError) { - refTime := clusterRefTime(cluster) - - hasChildResources := false - if cluster.DeletedTime != nil { - exists, err := s.nodePoolDao.ExistsByOwner(ctx, cluster.ID) - if err != nil { - return nil, errors.GeneralError("Failed to check child node pools for status aggregation: %s", err) - } - hasChildResources = exists - } - - prevReconciledStatus := extractPrevReconciledStatus(ctx, cluster.StatusConditions) - - reconciled, lastKnownReconciled, adapterConditions := AggregateResourceStatus(ctx, AggregateResourceStatusInput{ - ResourceGeneration: cluster.Generation, - RefTime: refTime, - DeletedTime: cluster.DeletedTime, - PrevConditionsJSON: cluster.StatusConditions, - RequiredAdapters: s.adapterConfig.RequiredClusterAdapters(), - AdapterStatuses: adapterStatuses, - HasChildResources: hasChildResources, - }) - - allConditions := make([]api.ResourceCondition, 0, fixedConditionCount+len(adapterConditions)) - allConditions = append(allConditions, reconciled, lastKnownReconciled) - allConditions = append(allConditions, adapterConditions...) - - conditionsJSON, err := json.Marshal(allConditions) - if err != nil { - return nil, errors.GeneralError("Failed to marshal conditions: %s", err) - } - - if jsonEqual(cluster.StatusConditions, conditionsJSON) { - return cluster, nil - } - - if err := s.clusterDao.SaveStatusConditions(ctx, cluster.ID, conditionsJSON); err != nil { - return nil, handleUpdateError(api.ResourceTypeCluster, err) - } - - cluster.StatusConditions = conditionsJSON - - if reconciled.Status == api.ConditionFalse && - (prevReconciledStatus == nil || *prevReconciledStatus != api.ConditionFalse) { - metrics.RecordReconciliationStarted("cluster", cluster.DeletedTime != nil) - } - - return cluster, nil -} - -// ProcessAdapterStatus validates mandatory conditions, applies discard rules, upserts adapter -// status, and triggers aggregation when appropriate. -// -// DB call budget (non-deleted happy path): -// 1. GetForUpdate — lock + fetch cluster -// 2. FindByResource — all adapter statuses (existing status found in-memory) -// 3. UpsertWithExisting — write adapter status -// 4. SaveStatusConditions — write updated cluster conditions (skipped when unchanged) -func (s *sqlClusterService) ProcessAdapterStatus( - ctx context.Context, clusterID string, adapterStatus *api.AdapterStatus, -) (*api.AdapterStatus, *errors.ServiceError) { - // 1. Acquire a row-level exclusive lock on the cluster for the duration of this - // transaction. Concurrent adapter status updates for the same cluster are - // serialized here: the second caller blocks until the first commits, ensuring - // the aggregation step always reads a fully up-to-date set of adapter statuses. - cluster, err := s.clusterDao.GetForUpdate(ctx, clusterID) - if err != nil { - return nil, handleGetError(api.ResourceTypeCluster, "id", clusterID, err) - } - - allStatuses, err := s.adapterStatusDao.FindByResource(ctx, api.ResourceTypeCluster, clusterID) - if err != nil { - return nil, errors.GeneralError("Failed to get adapter statuses: %s", err) - } - - existingStatus := findAdapterStatusInList(allStatuses, adapterStatus.Adapter) - - conditions, triggerAggregation, svcErr := s.validateAndClassify( - ctx, clusterID, adapterStatus, cluster, existingStatus, - ) - if svcErr != nil { - return nil, svcErr - } - if conditions == nil && !triggerAggregation { - return nil, nil - } - - adapterStatus.ResourceType = api.ResourceTypeCluster - adapterStatus.ResourceID = clusterID - setConditionTransitionTimes(adapterStatus, existingStatus) - - upsertedStatus, err := s.adapterStatusDao.Upsert(ctx, adapterStatus, existingStatus) - if err != nil { - return nil, handleCreateError("AdapterStatus", err) - } - - // Build the post-upsert snapshot once and reuse it for both the hard-delete check - // and aggregation. Using the pre-upsert allStatuses for the hard-delete check would - // cause allAdaptersFinalized to return false when the current adapter is the last one - // needed to complete the Finalized=True quorum. - updatedStatuses := replaceAdapterStatusInList(allStatuses, upsertedStatus) - - if cluster.DeletedTime != nil { - hardDeleted, hdErr := s.tryHardDeleteCluster(ctx, cluster, conditions, updatedStatuses) - if hdErr != nil { - return nil, hdErr - } - if hardDeleted { - return upsertedStatus, nil - } - } - - // 4. Re-aggregate using data already in memory. - if triggerAggregation { - if _, aggregateErr := s.recomputeAndSaveClusterStatus(ctx, cluster, updatedStatuses); aggregateErr != nil { - return nil, aggregateErr - } - } - - return upsertedStatus, nil -} - -// validateAndClassify delegates to the shared validateAndClassifyAdapterStatus -// with cluster-specific logger context. -func (s *sqlClusterService) validateAndClassify( - ctx context.Context, - clusterID string, - adapterStatus *api.AdapterStatus, - cluster *api.Cluster, - existingStatus *api.AdapterStatus, -) ([]api.AdapterCondition, bool, *errors.ServiceError) { - log := logger.With(logger.WithClusterID(ctx, clusterID), logger.FieldAdapter, adapterStatus.Adapter) - return validateAndClassifyAdapterStatus(cluster.Generation, adapterStatus, existingStatus, log) -} - -// tryHardDeleteCluster checks whether all required adapters have reported Finalized=True for -// a soft-deleted cluster and, when there are no remaining node pools, permanently removes the -// cluster and all its adapter statuses. Returns true when the hard-delete was performed. -func (s *sqlClusterService) tryHardDeleteCluster( - ctx context.Context, - cluster *api.Cluster, - conditions []api.AdapterCondition, - allStatuses api.AdapterStatusList, -) (bool, *errors.ServiceError) { - if !incomingReportedFinalized(conditions) { - return false, nil - } - - if !allAdaptersFinalized(s.adapterConfig.Required.Cluster, allStatuses, cluster.Generation) { - return false, nil - } - - hasNodePools, err := s.nodePoolDao.ExistsByOwner(ctx, cluster.ID) - if err != nil { - return false, errors.GeneralError("Failed to check nodepools during hard-delete: %s", err) - } - if hasNodePools { - return false, nil - } - - if err := s.adapterStatusDao.DeleteByResource(ctx, api.ResourceTypeCluster, cluster.ID); err != nil { - return false, errors.GeneralError("Failed to delete adapter statuses during hard-delete: %s", err) - } - if err := s.clusterDao.Delete(ctx, cluster.ID); err != nil { - return false, errors.GeneralError("Failed to hard-delete cluster: %s", err) - } - - logger.With(logger.WithClusterID(ctx, cluster.ID)). - Info("Hard-deleted cluster after all required adapters reported Finalized=True and no nodepools exist") - - return true, nil -} - -func (s *sqlClusterService) ForceDelete(ctx context.Context, id, reason string) *errors.ServiceError { - cluster, err := s.clusterDao.GetForUpdate(ctx, id) - if err != nil { - return handleGetError(api.ResourceTypeCluster, "id", id, err) - } - - if cluster.DeletedTime == nil { - return errors.ConflictState("Cluster '%s' is not in Finalizing state", id) - } - - clusterStatuses, err := s.adapterStatusDao.FindByResource(ctx, api.ResourceTypeCluster, id) - if err != nil { - logger.With(ctx, "cluster_id", id).WithError(err).Error("Failed to fetch adapter statuses for cluster") - return errors.GeneralError("Failed to fetch adapter statuses for cluster '%s'", id) - } - - nodePools, err := s.nodePoolDao.FindByOwner(ctx, id) - if err != nil { - logger.With(ctx, "cluster_id", id).WithError(err).Error("Failed to fetch nodepools for cluster") - return errors.GeneralError("Failed to fetch nodepools for cluster '%s'", id) - } - - logForceDeleteAudit(ctx, id, reason, clusterStatuses, nodePools) - - return s.cascadeDeleteCluster(ctx, id, nodePools) -} - -func logForceDeleteAudit( - ctx context.Context, clusterID, reason string, - statuses api.AdapterStatusList, nodePools api.NodePoolList, -) { - caller := actorFromContext(ctx) - - nodePoolIDs := make([]string, 0, len(nodePools)) - for _, np := range nodePools { - nodePoolIDs = append(nodePoolIDs, np.ID) - } - - logger.With(ctx, - "cluster_id", clusterID, - "resource_type", api.ResourceTypeCluster, - "caller", caller, - "reason", reason, - "adapter_statuses", buildAdapterSummaries(ctx, statuses), - "child_nodepools", nodePoolIDs, - ).Info("Force-deleting cluster") -} - -func (s *sqlClusterService) cascadeDeleteCluster( - ctx context.Context, id string, nodePools api.NodePoolList, -) *errors.ServiceError { - for _, np := range nodePools { - if err := s.adapterStatusDao.DeleteByResource(ctx, api.ResourceTypeNodePool, np.ID); err != nil { - logger.With(ctx, "cluster_id", id, "nodepool_id", np.ID). - WithError(err).Error("Failed to delete adapter statuses for nodepool") - return errors.GeneralError("Failed to delete adapter statuses for nodepool '%s'", np.ID) - } - if err := s.nodePoolDao.Delete(ctx, np.ID); err != nil { - logger.With(ctx, "cluster_id", id, "nodepool_id", np.ID).WithError(err).Error("Failed to force-delete nodepool") - return handleDeleteError(api.ResourceTypeNodePool, err) - } - } - - if err := s.adapterStatusDao.DeleteByResource(ctx, api.ResourceTypeCluster, id); err != nil { - logger.With(ctx, "cluster_id", id).WithError(err).Error("Failed to delete adapter statuses for cluster") - return errors.GeneralError("Failed to delete adapter statuses for cluster '%s'", id) - } - - if err := s.clusterDao.Delete(ctx, id); err != nil { - logger.With(ctx, "cluster_id", id).WithError(err).Error("Failed to force-delete cluster") - return handleDeleteError(api.ResourceTypeCluster, err) - } - - return nil -} - -func findAdapterStatusInList(statuses api.AdapterStatusList, adapter string) *api.AdapterStatus { - for _, s := range statuses { - if s.Adapter == adapter { - return s - } - } - return nil -} - -// replaceAdapterStatusInList returns a copy of the list with the entry for the given adapter -// replaced (or appended if not present). Used to build an up-to-date snapshot for aggregation -// after an upsert without re-querying. -func replaceAdapterStatusInList(statuses api.AdapterStatusList, updated *api.AdapterStatus) api.AdapterStatusList { - result := make(api.AdapterStatusList, 0, len(statuses)+1) - found := false - for _, s := range statuses { - if s.Adapter == updated.Adapter && s.ResourceType == updated.ResourceType && s.ResourceID == updated.ResourceID { - result = append(result, updated) - found = true - } else { - result = append(result, s) - } - } - if !found { - result = append(result, updated) - } - return result -} - -// incomingReportedFinalized returns true when the adapter conditions contain Finalized=True. -func incomingReportedFinalized(conditions []api.AdapterCondition) bool { - for _, cond := range conditions { - if cond.Type == api.AdapterConditionTypeFinalized && cond.Status == api.AdapterConditionTrue { - return true - } - } - return false -} diff --git a/pkg/services/cluster_test.go b/pkg/services/cluster_test.go deleted file mode 100644 index cd864c29..00000000 --- a/pkg/services/cluster_test.go +++ /dev/null @@ -1,2046 +0,0 @@ -package services - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "testing" - "time" - - . "github.com/onsi/gomega" - "gorm.io/gorm" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" -) - -const ( - testClusterID = "test-cluster-id" - systemActor = "system@hyperfleet.local" -) - -// testAdapterConfig creates a test adapter config with default values -func testAdapterConfig() *config.AdapterRequirementsConfig { - return &config.AdapterRequirementsConfig{ - Required: config.RequiredAdapters{ - Cluster: []string{"validation", "dns", "pullsecret", "hypershift"}, - Nodepool: []string{"validation", "hypershift"}, - }, - } -} - -// Mock implementations for testing ProcessAdapterStatus - -type mockClusterDao struct { - clusters map[string]*api.Cluster - deleteErr error -} - -func newMockClusterDao() *mockClusterDao { - return &mockClusterDao{ - clusters: make(map[string]*api.Cluster), - } -} - -func (d *mockClusterDao) Get(ctx context.Context, id string) (*api.Cluster, error) { - if c, ok := d.clusters[id]; ok { - return c, nil - } - return nil, gorm.ErrRecordNotFound -} - -func (d *mockClusterDao) GetForUpdate(ctx context.Context, id string) (*api.Cluster, error) { - return d.Get(ctx, id) -} - -func (d *mockClusterDao) Create(ctx context.Context, cluster *api.Cluster) (*api.Cluster, error) { - if cluster.CreatedTime.IsZero() { - now := time.Now() - cluster.CreatedTime = now - } - if cluster.UpdatedTime.IsZero() { - cluster.UpdatedTime = cluster.CreatedTime - } - d.clusters[cluster.ID] = cluster - return cluster, nil -} - -func (d *mockClusterDao) Save(ctx context.Context, cluster *api.Cluster) error { - d.clusters[cluster.ID] = cluster - return nil -} - -func (d *mockClusterDao) SaveStatusConditions(ctx context.Context, id string, statusConditions []byte) error { - c, ok := d.clusters[id] - if !ok { - return gorm.ErrRecordNotFound - } - c.StatusConditions = statusConditions - return nil -} - -func (d *mockClusterDao) Delete(ctx context.Context, id string) error { - if d.deleteErr != nil { - return d.deleteErr - } - delete(d.clusters, id) - return nil -} - -func (d *mockClusterDao) FindByIDs(ctx context.Context, ids []string) (api.ClusterList, error) { - var result api.ClusterList - for _, id := range ids { - if c, ok := d.clusters[id]; ok { - result = append(result, c) - } - } - return result, nil -} - -func (d *mockClusterDao) All(ctx context.Context) (api.ClusterList, error) { - var result api.ClusterList - for _, c := range d.clusters { - result = append(result, c) - } - return result, nil -} - -var _ dao.ClusterDao = &mockClusterDao{} - -type mockAdapterStatusDao struct { - statuses map[string]*api.AdapterStatus - findByResourceErr error - deleteByResourceErr error -} - -func newMockAdapterStatusDao() *mockAdapterStatusDao { - return &mockAdapterStatusDao{ - statuses: make(map[string]*api.AdapterStatus), - } -} - -func (d *mockAdapterStatusDao) Get(ctx context.Context, id string) (*api.AdapterStatus, error) { - if s, ok := d.statuses[id]; ok { - return s, nil - } - return nil, errors.NotFound("AdapterStatus").AsError() -} - -func (d *mockAdapterStatusDao) Create(ctx context.Context, status *api.AdapterStatus) (*api.AdapterStatus, error) { - d.statuses[status.ID] = status - return status, nil -} - -func (d *mockAdapterStatusDao) Upsert( - ctx context.Context, status *api.AdapterStatus, existing *api.AdapterStatus, -) (*api.AdapterStatus, error) { - key := status.ResourceType + ":" + status.ResourceID + ":" + status.Adapter - if existing != nil { - isStoredFresherOrEqual := existing.ObservedGeneration > status.ObservedGeneration || - (existing.ObservedGeneration == status.ObservedGeneration && - !existing.LastReportTime.Before(status.LastReportTime)) - if isStoredFresherOrEqual { - return existing, nil - } - - status.ID = existing.ID - if !existing.CreatedTime.IsZero() { - status.CreatedTime = existing.CreatedTime - } - } else { - status.ID = key - } - - d.statuses[key] = status - return status, nil -} - -func (d *mockAdapterStatusDao) Delete(ctx context.Context, id string) error { - delete(d.statuses, id) - return nil -} - -func (d *mockAdapterStatusDao) DeleteByResource(ctx context.Context, resourceType, resourceID string) error { - if d.deleteByResourceErr != nil { - return d.deleteByResourceErr - } - for key, s := range d.statuses { - if s.ResourceType == resourceType && s.ResourceID == resourceID { - delete(d.statuses, key) - } - } - return nil -} - -func (d *mockAdapterStatusDao) FindByResource( - ctx context.Context, - resourceType, resourceID string, -) (api.AdapterStatusList, error) { - if d.findByResourceErr != nil { - return nil, d.findByResourceErr - } - var result api.AdapterStatusList - for _, s := range d.statuses { - if s.ResourceType == resourceType && s.ResourceID == resourceID { - result = append(result, s) - } - } - return result, nil -} - -func (d *mockAdapterStatusDao) FindByResourceIDs( - ctx context.Context, - resourceType string, - resourceIDs []string, -) (api.AdapterStatusList, error) { - var result api.AdapterStatusList - resourceIDSet := make(map[string]bool, len(resourceIDs)) - for _, id := range resourceIDs { - resourceIDSet[id] = true - } - for _, s := range d.statuses { - if s.ResourceType == resourceType && resourceIDSet[s.ResourceID] { - result = append(result, s) - } - } - return result, nil -} - -func (d *mockAdapterStatusDao) FindByResourcePaginated( - ctx context.Context, - resourceType, resourceID string, - offset, limit int, -) (api.AdapterStatusList, int64, error) { - statuses, _ := d.FindByResource(ctx, resourceType, resourceID) - return statuses, int64(len(statuses)), nil -} - -func (d *mockAdapterStatusDao) FindByResourceAndAdapter( - ctx context.Context, - resourceType, resourceID, adapter string, -) (*api.AdapterStatus, error) { - for _, s := range d.statuses { - if s.ResourceType == resourceType && s.ResourceID == resourceID && s.Adapter == adapter { - return s, nil - } - } - return nil, errors.NotFound("AdapterStatus").AsError() -} - -func (d *mockAdapterStatusDao) All(ctx context.Context) (api.AdapterStatusList, error) { - var result api.AdapterStatusList - for _, s := range d.statuses { - result = append(result, s) - } - return result, nil -} - -type mockNodePoolService struct{} - -func newMockNodePoolService() *mockNodePoolService { return &mockNodePoolService{} } - -func (m *mockNodePoolService) Get(context.Context, string) (*api.NodePool, *errors.ServiceError) { - return nil, nil -} - -func (m *mockNodePoolService) Create(context.Context, *api.NodePool) (*api.NodePool, *errors.ServiceError) { - return nil, nil -} - -func (m *mockNodePoolService) Patch( - context.Context, string, *api.NodePoolPatch, -) (*api.NodePool, *errors.ServiceError) { - return nil, nil -} - -func (m *mockNodePoolService) GetByIDAndOwner( - context.Context, string, string, -) (*api.NodePool, *errors.ServiceError) { - return nil, nil -} - -func (m *mockNodePoolService) ListByCluster( - context.Context, string, *ListArguments, -) (api.NodePoolList, *api.PagingMeta, *errors.ServiceError) { - return nil, nil, nil -} - -func (m *mockNodePoolService) SoftDelete(context.Context, string) (*api.NodePool, *errors.ServiceError) { - return nil, nil -} - -func (m *mockNodePoolService) CascadeSoftDelete( - _ context.Context, nodePools api.NodePoolList, deletedBy string, deletedTime time.Time, -) *errors.ServiceError { - if deletedBy == "" { - deletedBy = defaultSystemUser - } - if deletedTime.IsZero() { - deletedTime = time.Now().UTC().Truncate(time.Microsecond) - } - for _, np := range nodePools { - if np.DeletedTime == nil { - np.MarkDeleted(deletedBy, deletedTime) - np.IncrementGeneration() - } - } - return nil -} - -func (m *mockNodePoolService) ForceDelete(context.Context, string, string) *errors.ServiceError { - return nil -} - -func (m *mockNodePoolService) UpdateNodePoolStatusFromAdapters( - context.Context, string, -) (*api.NodePool, *errors.ServiceError) { - return nil, nil -} - -func (m *mockNodePoolService) ProcessAdapterStatus( - context.Context, string, *api.AdapterStatus, -) (*api.AdapterStatus, *errors.ServiceError) { - return nil, nil -} - -var _ dao.AdapterStatusDao = &mockAdapterStatusDao{} - -// TestProcessAdapterStatus_FirstUnknownCondition tests that the first Unknown Available condition is stored -func TestProcessAdapterStatus_FirstUnknownCondition(t *testing.T) { - t.Parallel() - g := NewWithT(t) - - clusterDao := newMockClusterDao() - adapterStatusDao := newMockAdapterStatusDao() - config := testAdapterConfig() - service := NewClusterService(clusterDao, newMockNodePoolDao(), newMockNodePoolService(), adapterStatusDao, config) - - ctx := context.Background() - clusterID := testClusterID - - // Create cluster first - cluster := &api.Cluster{Generation: 1} - cluster.ID = clusterID - _, svcErr := service.Create(ctx, cluster) - g.Expect(svcErr).To(BeNil()) - - // Send first status with Available=Unknown - conditions := []api.AdapterCondition{ - {Type: api.AdapterConditionTypeAvailable, Status: api.AdapterConditionUnknown, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - } - conditionsJSON, _ := json.Marshal(conditions) - now := time.Now() - adapterStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeCluster, - ResourceID: clusterID, - Adapter: "test-adapter", - Conditions: conditionsJSON, - ObservedGeneration: 1, - CreatedTime: now, - LastReportTime: now, - } - - result, err := service.ProcessAdapterStatus(ctx, clusterID, adapterStatus) - - // First report with Unknown should be accepted - g.Expect(err).To(BeNil()) - g.Expect(result).ToNot(BeNil(), "First report with Available=Unknown should be stored") - g.Expect(result.Adapter).To(Equal("test-adapter")) - - // Verify the status was stored - storedStatuses, _ := adapterStatusDao.FindByResource(ctx, api.ResourceTypeCluster, clusterID) - g.Expect(len(storedStatuses)).To(Equal(1), "First Unknown status should be stored") -} - -// TestProcessAdapterStatus_SubsequentUnknownCondition tests that subsequent Unknown Available conditions are discarded -func TestProcessAdapterStatus_SubsequentUnknownCondition(t *testing.T) { - t.Parallel() - g := NewWithT(t) - - clusterDao := newMockClusterDao() - adapterStatusDao := newMockAdapterStatusDao() - - config := testAdapterConfig() - service := NewClusterService(clusterDao, newMockNodePoolDao(), newMockNodePoolService(), adapterStatusDao, config) - - ctx := context.Background() - clusterID := testClusterID - - now := time.Now() - clusterDao.clusters[clusterID] = &api.Cluster{ - Meta: api.Meta{ID: clusterID, CreatedTime: now, UpdatedTime: now}, - Generation: 1, - } - - // Pre-populate an existing adapter status to simulate a previously stored report - conditions := []api.AdapterCondition{ - {Type: api.AdapterConditionTypeAvailable, Status: api.AdapterConditionUnknown, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - } - conditionsJSON, _ := json.Marshal(conditions) - - existingStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeCluster, - ResourceID: clusterID, - Adapter: "test-adapter", - Conditions: conditionsJSON, - ObservedGeneration: 1, - CreatedTime: now, - LastReportTime: now, - } - _, _ = adapterStatusDao.Upsert(ctx, existingStatus, nil) - - // Now send another Unknown status report - newAdapterStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeCluster, - ResourceID: clusterID, - Adapter: "test-adapter", - Conditions: conditionsJSON, - ObservedGeneration: 1, - CreatedTime: now, - LastReportTime: now, - } - - result, err := service.ProcessAdapterStatus(ctx, clusterID, newAdapterStatus) - - g.Expect(err).To(BeNil()) - g.Expect(result).To(BeNil(), "Subsequent Unknown status should be discarded") -} - -// TestProcessAdapterStatus_InvalidStatusReturnsValidationError tests that a non-True/False/Unknown -// Available status is rejected with a validation error. -func TestProcessAdapterStatus_InvalidStatusReturnsValidationError(t *testing.T) { - t.Parallel() - g := NewWithT(t) - - clusterDao := newMockClusterDao() - adapterStatusDao := newMockAdapterStatusDao() - config := testAdapterConfig() - service := NewClusterService(clusterDao, newMockNodePoolDao(), newMockNodePoolService(), adapterStatusDao, config) - - ctx := context.Background() - clusterID := testClusterID - - cluster := &api.Cluster{Generation: 1} - cluster.ID = clusterID - _, svcErr := service.Create(ctx, cluster) - g.Expect(svcErr).To(BeNil()) - - conditions := []api.AdapterCondition{ - {Type: api.AdapterConditionTypeAvailable, Status: "Pending", LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - } - conditionsJSON, _ := json.Marshal(conditions) - now := time.Now() - adapterStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeCluster, - ResourceID: clusterID, - Adapter: "test-adapter", - Conditions: conditionsJSON, - ObservedGeneration: 1, - CreatedTime: now, - LastReportTime: now, - } - - result, err := service.ProcessAdapterStatus(ctx, clusterID, adapterStatus) - - g.Expect(err).ToNot(BeNil(), "Invalid status should return a validation error") - g.Expect(err.HTTPCode).To(Equal(http.StatusBadRequest)) - g.Expect(result).To(BeNil()) -} - -// TestProcessAdapterStatus_EmptyStatusReturnsValidationError tests that an empty Available status -// is rejected with a validation error. -func TestProcessAdapterStatus_EmptyStatusReturnsValidationError(t *testing.T) { - t.Parallel() - g := NewWithT(t) - - clusterDao := newMockClusterDao() - adapterStatusDao := newMockAdapterStatusDao() - config := testAdapterConfig() - service := NewClusterService(clusterDao, newMockNodePoolDao(), newMockNodePoolService(), adapterStatusDao, config) - - ctx := context.Background() - clusterID := testClusterID - - cluster := &api.Cluster{Generation: 1} - cluster.ID = clusterID - _, svcErr := service.Create(ctx, cluster) - g.Expect(svcErr).To(BeNil()) - - conditions := []api.AdapterCondition{ - {Type: api.AdapterConditionTypeAvailable, Status: "", LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - } - conditionsJSON, _ := json.Marshal(conditions) - now := time.Now() - adapterStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeCluster, - ResourceID: clusterID, - Adapter: "test-adapter", - Conditions: conditionsJSON, - ObservedGeneration: 1, - CreatedTime: now, - LastReportTime: now, - } - - result, err := service.ProcessAdapterStatus(ctx, clusterID, adapterStatus) - - g.Expect(err).ToNot(BeNil(), "Empty status should return a validation error") - g.Expect(err.HTTPCode).To(Equal(http.StatusBadRequest)) - g.Expect(result).To(BeNil()) -} - -// TestProcessAdapterStatus_TrueCondition tests that True Available condition upserts and aggregates -func TestProcessAdapterStatus_TrueCondition(t *testing.T) { - t.Parallel() - g := NewWithT(t) - - clusterDao := newMockClusterDao() - adapterStatusDao := newMockAdapterStatusDao() - - config := testAdapterConfig() - service := NewClusterService(clusterDao, newMockNodePoolDao(), newMockNodePoolService(), adapterStatusDao, config) - - ctx := context.Background() - clusterID := testClusterID - - // Create the cluster first - cluster := &api.Cluster{ - Generation: 1, - } - cluster.ID = clusterID - _, svcErr := service.Create(ctx, cluster) - g.Expect(svcErr).To(BeNil()) - - // Create adapter status with all mandatory conditions - conditions := []api.AdapterCondition{ - { - Type: api.AdapterConditionTypeAvailable, - Status: api.AdapterConditionTrue, - LastTransitionTime: time.Now(), - }, - { - Type: api.AdapterConditionTypeApplied, - Status: api.AdapterConditionTrue, - LastTransitionTime: time.Now(), - }, - { - Type: api.AdapterConditionTypeHealth, - Status: api.AdapterConditionTrue, - LastTransitionTime: time.Now(), - }, - } - conditionsJSON, _ := json.Marshal(conditions) - - now := time.Now() - adapterStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeCluster, - ResourceID: clusterID, - Adapter: "test-adapter", - Conditions: conditionsJSON, - CreatedTime: now, - LastReportTime: now, - } - - result, err := service.ProcessAdapterStatus(ctx, clusterID, adapterStatus) - - g.Expect(err).To(BeNil()) - g.Expect(result).ToNot(BeNil(), "ProcessAdapterStatus should return the upserted status") - g.Expect(result.Adapter).To(Equal("test-adapter")) - - // Verify the status was stored - storedStatuses, _ := adapterStatusDao.FindByResource(ctx, api.ResourceTypeCluster, clusterID) - g.Expect(len(storedStatuses)).To(Equal(1), "Status should be stored for True condition") -} - -// TestProcessAdapterStatus_FalseCondition tests that False Available condition upserts and aggregates -func TestProcessAdapterStatus_FalseCondition(t *testing.T) { - t.Parallel() - g := NewWithT(t) - - clusterDao := newMockClusterDao() - adapterStatusDao := newMockAdapterStatusDao() - - config := testAdapterConfig() - service := NewClusterService(clusterDao, newMockNodePoolDao(), newMockNodePoolService(), adapterStatusDao, config) - - ctx := context.Background() - clusterID := testClusterID - - // Create the cluster first - cluster := &api.Cluster{ - Generation: 1, - } - cluster.ID = clusterID - _, svcErr := service.Create(ctx, cluster) - g.Expect(svcErr).To(BeNil()) - - // Create adapter status with all mandatory conditions - conditions := []api.AdapterCondition{ - { - Type: api.AdapterConditionTypeAvailable, - Status: api.AdapterConditionFalse, - LastTransitionTime: time.Now(), - }, - { - Type: api.AdapterConditionTypeApplied, - Status: api.AdapterConditionTrue, - LastTransitionTime: time.Now(), - }, - { - Type: api.AdapterConditionTypeHealth, - Status: api.AdapterConditionFalse, - LastTransitionTime: time.Now(), - }, - } - conditionsJSON, _ := json.Marshal(conditions) - - now := time.Now() - adapterStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeCluster, - ResourceID: clusterID, - Adapter: "test-adapter", - Conditions: conditionsJSON, - CreatedTime: now, - LastReportTime: now, - } - - result, err := service.ProcessAdapterStatus(ctx, clusterID, adapterStatus) - - g.Expect(err).To(BeNil()) - g.Expect(result).ToNot(BeNil(), "ProcessAdapterStatus should return the upserted status") - - // Verify the status was stored - storedStatuses, _ := adapterStatusDao.FindByResource(ctx, api.ResourceTypeCluster, clusterID) - g.Expect(len(storedStatuses)).To(Equal(1), "Status should be stored for False condition") -} - -// TestProcessAdapterStatus_FirstMultipleConditions_AvailableUnknown tests that first reports with -// Available=Unknown are accepted even when other conditions are present -func TestProcessAdapterStatus_FirstMultipleConditions_AvailableUnknown(t *testing.T) { - t.Parallel() - g := NewWithT(t) - - clusterDao := newMockClusterDao() - adapterStatusDao := newMockAdapterStatusDao() - - config := testAdapterConfig() - service := NewClusterService(clusterDao, newMockNodePoolDao(), newMockNodePoolService(), adapterStatusDao, config) - - ctx := context.Background() - clusterID := testClusterID - - // Create cluster first - cluster := &api.Cluster{Generation: 1} - cluster.ID = clusterID - _, svcErr := service.Create(ctx, cluster) - g.Expect(svcErr).To(BeNil()) - - // Create first adapter status with all mandatory conditions but Available=Unknown - conditions := []api.AdapterCondition{ - { - Type: api.AdapterConditionTypeAvailable, - Status: api.AdapterConditionUnknown, - LastTransitionTime: time.Now(), - }, - { - Type: api.AdapterConditionTypeApplied, - Status: api.AdapterConditionTrue, - LastTransitionTime: time.Now(), - }, - { - Type: api.AdapterConditionTypeHealth, - Status: api.AdapterConditionTrue, - LastTransitionTime: time.Now(), - }, - { - Type: api.AdapterConditionTypeReconciled, - Status: api.AdapterConditionTrue, - LastTransitionTime: time.Now(), - }, - } - conditionsJSON, _ := json.Marshal(conditions) - - now := time.Now() - adapterStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeCluster, - ResourceID: clusterID, - Adapter: "test-adapter", - Conditions: conditionsJSON, - CreatedTime: now, - LastReportTime: now, - } - - result, err := service.ProcessAdapterStatus(ctx, clusterID, adapterStatus) - - g.Expect(err).To(BeNil()) - g.Expect(result).ToNot(BeNil(), "First report with Available=Unknown should be accepted") - - // Verify the status was stored - storedStatuses, _ := adapterStatusDao.FindByResource(ctx, api.ResourceTypeCluster, clusterID) - g.Expect(len(storedStatuses)).To(Equal(1), "First status with Available=Unknown should be stored") -} - -// TestProcessAdapterStatus_SubsequentMultipleConditions_AvailableUnknown tests that subsequent reports -// with multiple conditions including Available=Unknown are discarded -func TestProcessAdapterStatus_SubsequentMultipleConditions_AvailableUnknown(t *testing.T) { - t.Parallel() - g := NewWithT(t) - - clusterDao := newMockClusterDao() - adapterStatusDao := newMockAdapterStatusDao() - - config := testAdapterConfig() - service := NewClusterService(clusterDao, newMockNodePoolDao(), newMockNodePoolService(), adapterStatusDao, config) - - ctx := context.Background() - clusterID := testClusterID - - now := time.Now() - clusterDao.clusters[clusterID] = &api.Cluster{ - Meta: api.Meta{ID: clusterID, CreatedTime: now, UpdatedTime: now}, - Generation: 1, - } - - // Pre-populate an existing adapter status - existingConditions := []api.AdapterCondition{ - {Type: api.AdapterConditionTypeAvailable, Status: api.AdapterConditionUnknown, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - } - existingConditionsJSON, _ := json.Marshal(existingConditions) - - existingStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeCluster, - ResourceID: clusterID, - Adapter: "test-adapter", - Conditions: existingConditionsJSON, - ObservedGeneration: 1, - CreatedTime: now, - LastReportTime: now, - } - _, _ = adapterStatusDao.Upsert(ctx, existingStatus, nil) - - // Now send another report with multiple conditions including Available=Unknown - conditions := []api.AdapterCondition{ - {Type: api.AdapterConditionTypeAvailable, Status: api.AdapterConditionUnknown, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeReconciled, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: "Progressing", Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - } - conditionsJSON, _ := json.Marshal(conditions) - - adapterStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeCluster, - ResourceID: clusterID, - Adapter: "test-adapter", - Conditions: conditionsJSON, - ObservedGeneration: 1, - CreatedTime: now, - LastReportTime: now, - } - - result, err := service.ProcessAdapterStatus(ctx, clusterID, adapterStatus) - - g.Expect(err).To(BeNil()) - g.Expect(result).To(BeNil(), "Subsequent Available=Unknown should be discarded") -} - -func TestClusterAvailableReconciledTransitions(t *testing.T) { - t.Parallel() - g := NewWithT(t) - - clusterDao := newMockClusterDao() - adapterStatusDao := newMockAdapterStatusDao() - - adapterConfig := testAdapterConfig() - // Keep this small so we can cover transitions succinctly. - adapterConfig.Required.Cluster = []string{"validation", "dns"} - - service := NewClusterService( - clusterDao, newMockNodePoolDao(), newMockNodePoolService(), adapterStatusDao, adapterConfig) - - ctx := context.Background() - clusterID := testClusterID - - cluster := &api.Cluster{Generation: 1} - cluster.ID = clusterID - _, svcErr := service.Create(ctx, cluster) - g.Expect(svcErr).To(BeNil()) - - getSynth := func() (api.ResourceCondition, api.ResourceCondition) { - stored, getErr := clusterDao.Get(ctx, clusterID) - g.Expect(getErr).To(BeNil()) - - var conds []api.ResourceCondition - g.Expect(json.Unmarshal(stored.StatusConditions, &conds)).To(Succeed()) - g.Expect(len(conds)).To(BeNumerically(">=", 2)) - - var available, reconciled *api.ResourceCondition - for i := range conds { - switch conds[i].Type { - case api.ResourceConditionTypeLastKnownReconciled: - available = &conds[i] - case api.ResourceConditionTypeReconciled: - reconciled = &conds[i] - } - } - g.Expect(reconciled).ToNot(BeNil()) - g.Expect(available).ToNot(BeNil()) - return *available, *reconciled - } - - upsert := func(adapter string, available api.AdapterConditionStatus, observedGen int32) { - conditions := []api.AdapterCondition{ - {Type: api.AdapterConditionTypeAvailable, Status: available, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - } - conditionsJSON, _ := json.Marshal(conditions) - now := time.Now() - - adapterStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeCluster, - ResourceID: clusterID, - Adapter: adapter, - ObservedGeneration: observedGen, - Conditions: conditionsJSON, - CreatedTime: now, - LastReportTime: now, - } - - _, err := service.ProcessAdapterStatus(ctx, clusterID, adapterStatus) - g.Expect(err).To(BeNil()) - } - - // No adapter statuses yet. - _, err := service.UpdateClusterStatusFromAdapters(ctx, clusterID) - g.Expect(err).To(BeNil()) - avail, reconciled := getSynth() - g.Expect(avail.Status).To(Equal(api.ConditionFalse)) - g.Expect(avail.ObservedGeneration).To(Equal(int32(1))) - g.Expect(reconciled.Status).To(Equal(api.ConditionFalse)) - g.Expect(reconciled.ObservedGeneration).To(Equal(int32(1))) - - // Partial adapters: still not Available/Reconciled. - upsert("validation", api.AdapterConditionTrue, 1) - avail, reconciled = getSynth() - g.Expect(avail.Status).To(Equal(api.ConditionFalse)) - g.Expect(reconciled.Status).To(Equal(api.ConditionFalse)) - - // All required adapters available at gen=1 => Available=True, Reconciled=True. - upsert("dns", api.AdapterConditionTrue, 1) - avail, reconciled = getSynth() - g.Expect(avail.Status).To(Equal(api.ConditionTrue)) - g.Expect(avail.ObservedGeneration).To(Equal(int32(1))) - g.Expect(reconciled.Status).To(Equal(api.ConditionTrue)) - g.Expect(reconciled.ObservedGeneration).To(Equal(int32(1))) - - // Bump resource generation => Reconciled flips to False; Available remains True. - clusterDao.clusters[clusterID].Generation = 2 - _, err = service.UpdateClusterStatusFromAdapters(ctx, clusterID) - g.Expect(err).To(BeNil()) - avail, reconciled = getSynth() - g.Expect(avail.Status).To(Equal(api.ConditionTrue)) - g.Expect(avail.ObservedGeneration).To(Equal(int32(1))) - g.Expect(reconciled.Status).To(Equal(api.ConditionFalse)) - g.Expect(reconciled.ObservedGeneration).To(Equal(int32(2))) - - // One adapter updates to gen=2 => Reconciled still False; Available still True (minObservedGeneration still 1). - upsert("validation", api.AdapterConditionTrue, 2) - avail, reconciled = getSynth() - g.Expect(avail.Status).To(Equal(api.ConditionTrue)) - g.Expect(avail.ObservedGeneration).To(Equal(int32(1))) - g.Expect(reconciled.Status).To(Equal(api.ConditionFalse)) - - // One adapter updates to gen=1 => Reconciled still False; Available still True (minObservedGeneration still 1). - // This is an edge case where an adapter reports a gen=1 status after a gen=2 status. - // Since we don't allow downgrading observed generations, we should not overwrite the cluster conditions. - // And Available should remain True, but in reality it should be False. - // This should be an unexpected edge case, since once a resource changes generation, - // all adapters should report a gen=2 status. - // So, while we are keeping Available True for gen=1, - // there should be soon an update to gen=2, which will overwrite the Available condition. - upsert("validation", api.AdapterConditionFalse, 1) - avail, reconciled = getSynth() - g.Expect(avail.Status).To(Equal(api.ConditionTrue)) // <-- this is the edge case - g.Expect(avail.ObservedGeneration).To(Equal(int32(1))) - g.Expect(reconciled.Status).To(Equal(api.ConditionFalse)) - - // All required adapters at gen=2 => Reconciled becomes True, Available minObservedGeneration becomes 2. - upsert("dns", api.AdapterConditionTrue, 2) - avail, reconciled = getSynth() - g.Expect(avail.Status).To(Equal(api.ConditionTrue)) - g.Expect(avail.ObservedGeneration).To(Equal(int32(2))) - g.Expect(reconciled.Status).To(Equal(api.ConditionTrue)) - - // One required adapter goes False => both Available and Reconciled become False. - upsert("dns", api.AdapterConditionFalse, 2) - avail, reconciled = getSynth() - g.Expect(avail.Status).To(Equal(api.ConditionFalse)) - g.Expect(avail.ObservedGeneration).To(Equal(int32(2))) - g.Expect(reconciled.Status).To(Equal(api.ConditionFalse)) - - // Available=Unknown is a no-op (does not store, does not overwrite cluster conditions). - prevStatus := api.Cluster{}.StatusConditions - prevStatus = append(prevStatus, clusterDao.clusters[clusterID].StatusConditions...) - unknownConds := []api.AdapterCondition{ - {Type: api.AdapterConditionTypeAvailable, Status: api.AdapterConditionUnknown, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - } - unknownJSON, _ := json.Marshal(unknownConds) - unknownStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeCluster, - ResourceID: clusterID, - Adapter: "dns", - Conditions: unknownJSON, - } - result, svcErr := service.ProcessAdapterStatus(ctx, clusterID, unknownStatus) - g.Expect(svcErr).To(BeNil()) - g.Expect(result).To(BeNil()) - g.Expect(clusterDao.clusters[clusterID].StatusConditions).To(Equal(prevStatus)) -} - -func TestClusterStaleAdapterStatusUpdatePolicy(t *testing.T) { - t.Parallel() - g := NewWithT(t) - - clusterDao := newMockClusterDao() - adapterStatusDao := newMockAdapterStatusDao() - - adapterConfig := testAdapterConfig() - adapterConfig.Required.Cluster = []string{"validation", "dns"} - - service := NewClusterService( - clusterDao, newMockNodePoolDao(), newMockNodePoolService(), adapterStatusDao, adapterConfig) - - ctx := context.Background() - clusterID := testClusterID - - cluster := &api.Cluster{Generation: 2} - cluster.ID = clusterID - _, svcErr := service.Create(ctx, cluster) - g.Expect(svcErr).To(BeNil()) - - getAvailable := func() api.ResourceCondition { - stored, getErr := clusterDao.Get(ctx, clusterID) - g.Expect(getErr).To(BeNil()) - - var conds []api.ResourceCondition - g.Expect(json.Unmarshal(stored.StatusConditions, &conds)).To(Succeed()) - for i := range conds { - if conds[i].Type == api.ResourceConditionTypeLastKnownReconciled { - return conds[i] - } - } - g.Expect(true).To(BeFalse(), "Available condition not found") - return api.ResourceCondition{} - } - - upsert := func(adapter string, available api.AdapterConditionStatus, observedGen int32) { - conditions := []api.AdapterCondition{ - {Type: api.AdapterConditionTypeAvailable, Status: available, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - } - conditionsJSON, _ := json.Marshal(conditions) - now := time.Now() - - adapterStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeCluster, - ResourceID: clusterID, - Adapter: adapter, - ObservedGeneration: observedGen, - Conditions: conditionsJSON, - CreatedTime: now, - LastReportTime: now, - } - - _, err := service.ProcessAdapterStatus(ctx, clusterID, adapterStatus) - g.Expect(err).To(BeNil()) - } - - // Current generation statuses => Available=True at observed_generation=2. - upsert("validation", api.AdapterConditionTrue, 2) - upsert("dns", api.AdapterConditionTrue, 2) - available := getAvailable() - g.Expect(available.Status).To(Equal(api.ConditionTrue)) - g.Expect(available.ObservedGeneration).To(Equal(int32(2))) - - // Stale True should not override newer True. - upsert("validation", api.AdapterConditionTrue, 1) - available = getAvailable() - g.Expect(available.Status).To(Equal(api.ConditionTrue)) - g.Expect(available.ObservedGeneration).To(Equal(int32(2))) - - // Stale False is more restrictive and should override. - upsert("validation", api.AdapterConditionFalse, 1) - available = getAvailable() - g.Expect(available.Status).To(Equal(api.ConditionTrue)) - g.Expect(available.ObservedGeneration).To(Equal(int32(2))) -} - -func TestClusterSyntheticTimestampsStableWithoutAdapterStatus(t *testing.T) { - t.Parallel() - g := NewWithT(t) - - clusterDao := newMockClusterDao() - adapterStatusDao := newMockAdapterStatusDao() - - adapterConfig := testAdapterConfig() - adapterConfig.Required.Cluster = []string{"validation"} - - service := NewClusterService( - clusterDao, newMockNodePoolDao(), newMockNodePoolService(), adapterStatusDao, adapterConfig) - - ctx := context.Background() - clusterID := testClusterID - - fixedNow := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) - initialConditions := []api.ResourceCondition{ - { - Type: api.ResourceConditionTypeLastKnownReconciled, - Status: api.ConditionFalse, - ObservedGeneration: 1, - LastTransitionTime: fixedNow, - CreatedTime: fixedNow, - LastUpdatedTime: fixedNow, - }, - } - initialConditionsJSON, _ := json.Marshal(initialConditions) - - cluster := &api.Cluster{ - Generation: 1, - StatusConditions: initialConditionsJSON, - } - cluster.ID = clusterID - cluster.CreatedTime = fixedNow - cluster.UpdatedTime = fixedNow - created, svcErr := service.Create(ctx, cluster) - g.Expect(svcErr).To(BeNil()) - - var createdConds []api.ResourceCondition - g.Expect(json.Unmarshal(created.StatusConditions, &createdConds)).To(Succeed()) - g.Expect(len(createdConds)).To(BeNumerically(">=", 2)) - - var createdAvailable, createdReconciled *api.ResourceCondition - for i := range createdConds { - switch createdConds[i].Type { - case api.ResourceConditionTypeLastKnownReconciled: - createdAvailable = &createdConds[i] - case api.ResourceConditionTypeReconciled: - createdReconciled = &createdConds[i] - } - } - g.Expect(createdAvailable).ToNot(BeNil()) - g.Expect(createdReconciled).ToNot(BeNil()) - g.Expect(createdAvailable.CreatedTime).To(Equal(fixedNow)) - g.Expect(createdAvailable.LastTransitionTime).To(Equal(fixedNow)) - g.Expect(createdAvailable.LastUpdatedTime).To(Equal(fixedNow)) - g.Expect(createdReconciled.CreatedTime).To(Equal(fixedNow)) - g.Expect(createdReconciled.LastTransitionTime).To(Equal(fixedNow)) - g.Expect(createdReconciled.LastUpdatedTime).To(Equal(fixedNow)) - - updated, err := service.UpdateClusterStatusFromAdapters(ctx, clusterID) - g.Expect(err).To(BeNil()) - - var updatedConds []api.ResourceCondition - g.Expect(json.Unmarshal(updated.StatusConditions, &updatedConds)).To(Succeed()) - g.Expect(len(updatedConds)).To(BeNumerically(">=", 2)) - - var updatedAvailable, updatedReconciled *api.ResourceCondition - for i := range updatedConds { - switch updatedConds[i].Type { - case api.ResourceConditionTypeLastKnownReconciled: - updatedAvailable = &updatedConds[i] - case api.ResourceConditionTypeReconciled: - updatedReconciled = &updatedConds[i] - } - } - g.Expect(updatedAvailable).ToNot(BeNil()) - g.Expect(updatedReconciled).ToNot(BeNil()) - g.Expect(updatedAvailable.CreatedTime).To(Equal(fixedNow)) - g.Expect(updatedAvailable.LastTransitionTime).To(Equal(fixedNow)) - g.Expect(updatedAvailable.LastUpdatedTime).To(Equal(fixedNow)) - g.Expect(updatedReconciled.CreatedTime).To(Equal(fixedNow)) - g.Expect(updatedReconciled.LastTransitionTime).To(Equal(fixedNow)) - g.Expect(updatedReconciled.LastUpdatedTime).To(Equal(fixedNow)) -} - -// TestProcessAdapterStatus_MissingMandatoryCondition_Available tests that updates missing Available are rejected -func TestProcessAdapterStatus_MissingMandatoryCondition_Available(t *testing.T) { - t.Parallel() - g := NewWithT(t) - - clusterDao := newMockClusterDao() - adapterStatusDao := newMockAdapterStatusDao() - config := testAdapterConfig() - service := NewClusterService(clusterDao, newMockNodePoolDao(), newMockNodePoolService(), adapterStatusDao, config) - - ctx := context.Background() - clusterID := testClusterID - - // Create cluster first - cluster := &api.Cluster{Generation: 1} - cluster.ID = clusterID - _, svcErr := service.Create(ctx, cluster) - g.Expect(svcErr).To(BeNil()) - - // First, send a valid status - validConditions := []api.AdapterCondition{ - {Type: api.AdapterConditionTypeAvailable, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - } - validConditionsJSON, _ := json.Marshal(validConditions) - now := time.Now() - validStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeCluster, - ResourceID: clusterID, - Adapter: "test-adapter", - Conditions: validConditionsJSON, - ObservedGeneration: 1, - CreatedTime: now, - LastReportTime: now, - } - result, err := service.ProcessAdapterStatus(ctx, clusterID, validStatus) - g.Expect(err).To(BeNil()) - g.Expect(result).ToNot(BeNil()) - - // Now send an update without Available condition - invalidConditions := []api.AdapterCondition{ - {Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: "CustomCondition", Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - } - invalidConditionsJSON, _ := json.Marshal(invalidConditions) - invalidStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeCluster, - ResourceID: clusterID, - Adapter: "test-adapter", - Conditions: invalidConditionsJSON, - ObservedGeneration: 1, - CreatedTime: now, - LastReportTime: now, - } - - result, err = service.ProcessAdapterStatus(ctx, clusterID, invalidStatus) - - // Should be rejected with validation error - g.Expect(err).ToNot(BeNil()) - g.Expect(err.HTTPCode).To(Equal(http.StatusBadRequest)) - g.Expect(err.Reason).To(ContainSubstring("missing mandatory condition")) - g.Expect(result).To(BeNil(), "Update missing Available condition should be rejected") - - // Verify old status is preserved - storedStatus, _ := adapterStatusDao.FindByResourceAndAdapter(ctx, api.ResourceTypeCluster, clusterID, "test-adapter") - g.Expect(storedStatus).ToNot(BeNil()) - g.Expect(storedStatus.ObservedGeneration).To(Equal(int32(1)), "Old status should be preserved") - - var storedConditions []api.AdapterCondition - unmarshalErr := json.Unmarshal(storedStatus.Conditions, &storedConditions) - g.Expect(unmarshalErr).To(BeNil()) - g.Expect(len(storedConditions)).To(Equal(3)) - // Verify Available is still there - hasAvailable := false - for _, cond := range storedConditions { - if cond.Type == api.AdapterConditionTypeAvailable { - hasAvailable = true - break - } - } - g.Expect(hasAvailable).To(BeTrue(), "Available condition should be preserved") -} - -// TestProcessAdapterStatus_AllMandatoryConditions_WithCustom tests that valid -// updates with all mandatory conditions are accepted -func TestProcessAdapterStatus_AllMandatoryConditions_WithCustom(t *testing.T) { - t.Parallel() - g := NewWithT(t) - - clusterDao := newMockClusterDao() - adapterStatusDao := newMockAdapterStatusDao() - config := testAdapterConfig() - service := NewClusterService(clusterDao, newMockNodePoolDao(), newMockNodePoolService(), adapterStatusDao, config) - - ctx := context.Background() - clusterID := testClusterID - - // Create cluster first - cluster := &api.Cluster{Generation: 1} - cluster.ID = clusterID - _, svcErr := service.Create(ctx, cluster) - g.Expect(svcErr).To(BeNil()) - - // Send status with all mandatory conditions + custom condition - conditions := []api.AdapterCondition{ - {Type: api.AdapterConditionTypeAvailable, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: "CustomCondition", Status: api.AdapterConditionFalse, LastTransitionTime: time.Now()}, - } - conditionsJSON, _ := json.Marshal(conditions) - now := time.Now() - adapterStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeCluster, - ResourceID: clusterID, - Adapter: "test-adapter", - Conditions: conditionsJSON, - ObservedGeneration: 1, - CreatedTime: now, - LastReportTime: now, - } - - result, err := service.ProcessAdapterStatus(ctx, clusterID, adapterStatus) - - // Should be accepted - g.Expect(err).To(BeNil()) - g.Expect(result).ToNot(BeNil(), "Update with all mandatory conditions should be accepted") - - // Verify status was stored with all 4 conditions - var storedConditions []api.AdapterCondition - unmarshalErr := json.Unmarshal(result.Conditions, &storedConditions) - g.Expect(unmarshalErr).To(BeNil()) - g.Expect(len(storedConditions)).To(Equal(4), "All 4 conditions should be stored") - - // Verify all conditions are present - conditionTypes := make(map[string]bool) - for _, cond := range storedConditions { - conditionTypes[cond.Type] = true - } - g.Expect(conditionTypes[api.AdapterConditionTypeAvailable]).To(BeTrue()) - g.Expect(conditionTypes[api.AdapterConditionTypeApplied]).To(BeTrue()) - g.Expect(conditionTypes[api.AdapterConditionTypeHealth]).To(BeTrue()) - g.Expect(conditionTypes["CustomCondition"]).To(BeTrue()) -} - -// TestProcessAdapterStatus_CustomConditionRemoval tests that custom conditions can be removed -func TestProcessAdapterStatus_CustomConditionRemoval(t *testing.T) { - t.Parallel() - g := NewWithT(t) - - clusterDao := newMockClusterDao() - adapterStatusDao := newMockAdapterStatusDao() - config := testAdapterConfig() - service := NewClusterService(clusterDao, newMockNodePoolDao(), newMockNodePoolService(), adapterStatusDao, config) - - ctx := context.Background() - clusterID := testClusterID - - // Create cluster first - cluster := &api.Cluster{Generation: 1} - cluster.ID = clusterID - _, svcErr := service.Create(ctx, cluster) - g.Expect(svcErr).To(BeNil()) - - // First update: send all mandatory + custom condition - conditions1 := []api.AdapterCondition{ - {Type: api.AdapterConditionTypeAvailable, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: "CustomCondition", Status: api.AdapterConditionFalse, LastTransitionTime: time.Now()}, - } - conditionsJSON1, _ := json.Marshal(conditions1) - now := time.Now() - adapterStatus1 := &api.AdapterStatus{ - ResourceType: api.ResourceTypeCluster, - ResourceID: clusterID, - Adapter: "test-adapter", - Conditions: conditionsJSON1, - ObservedGeneration: 1, - CreatedTime: now, - LastReportTime: now, - } - result1, err := service.ProcessAdapterStatus(ctx, clusterID, adapterStatus1) - g.Expect(err).To(BeNil()) - g.Expect(result1).ToNot(BeNil()) - - var storedConditions1 []api.AdapterCondition - unmarshalErr := json.Unmarshal(result1.Conditions, &storedConditions1) - g.Expect(unmarshalErr).To(BeNil()) - g.Expect(len(storedConditions1)).To(Equal(4)) - - // Allow observed_generation=2 on the adapter report while the cluster is still at generation 1. - clusterDao.clusters[clusterID].Generation = 2 - - // Second update: remove custom condition (only send mandatory conditions) - conditions2 := []api.AdapterCondition{ - {Type: api.AdapterConditionTypeAvailable, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionFalse, LastTransitionTime: time.Now()}, - } - conditionsJSON2, _ := json.Marshal(conditions2) - adapterStatus2 := &api.AdapterStatus{ - ResourceType: api.ResourceTypeCluster, - ResourceID: clusterID, - Adapter: "test-adapter", - Conditions: conditionsJSON2, - ObservedGeneration: 2, - CreatedTime: now, - LastReportTime: now, - } - result2, err := service.ProcessAdapterStatus(ctx, clusterID, adapterStatus2) - g.Expect(err).To(BeNil()) - g.Expect(result2).ToNot(BeNil()) - - // Verify CustomCondition was removed - var storedConditions2 []api.AdapterCondition - unmarshalErr = json.Unmarshal(result2.Conditions, &storedConditions2) - g.Expect(unmarshalErr).To(BeNil()) - g.Expect(len(storedConditions2)).To(Equal(3), "CustomCondition should be removed") - - conditionTypes := make(map[string]bool) - for _, cond := range storedConditions2 { - conditionTypes[cond.Type] = true - } - g.Expect(conditionTypes[api.AdapterConditionTypeAvailable]).To(BeTrue()) - g.Expect(conditionTypes[api.AdapterConditionTypeApplied]).To(BeTrue()) - g.Expect(conditionTypes[api.AdapterConditionTypeHealth]).To(BeTrue()) - g.Expect(conditionTypes["CustomCondition"]).To(BeFalse(), "CustomCondition should not be present") -} - -func TestClusterSoftDelete(t *testing.T) { - t.Run("given a live cluster, when soft-deleted, then deleted_time/deleted_by/generation are set", func(t *testing.T) { - g := NewWithT(t) - // Given: - clusterDao := newMockClusterDao() - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - service := NewClusterService(clusterDao, nodePoolDao, newMockNodePoolService(), adapterStatusDao, testAdapterConfig()) - ctx := context.Background() - clusterID := "live-cluster" - clusterDao.clusters[clusterID] = &api.Cluster{ - Meta: api.Meta{ID: clusterID}, - Generation: 1, - } - // When: - cluster, svcErr := service.SoftDelete(ctx, clusterID) - // Then: - g.Expect(svcErr).To(BeNil()) - g.Expect(cluster.DeletedTime).NotTo(BeNil()) - g.Expect(cluster.DeletedBy).NotTo(BeNil()) - g.Expect(*cluster.DeletedBy).To(Equal(systemActor)) - g.Expect(cluster.Generation).To(Equal(int32(2))) - }) - - t.Run("given a cluster with child nodepools, when soft-deleted, then all nodepools are also soft-deleted", func(t *testing.T) { //nolint:lll - g := NewWithT(t) - // Given: - clusterDao := newMockClusterDao() - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - service := NewClusterService(clusterDao, nodePoolDao, newMockNodePoolService(), adapterStatusDao, testAdapterConfig()) - ctx := context.Background() - clusterID := "cluster-with-pools" - clusterDao.clusters[clusterID] = &api.Cluster{Meta: api.Meta{ID: clusterID}, Generation: 1} - nodePoolDao.nodePools["np-1"] = &api.NodePool{Meta: api.Meta{ID: "np-1"}, OwnerID: clusterID, Generation: 1} - nodePoolDao.nodePools["np-2"] = &api.NodePool{Meta: api.Meta{ID: "np-2"}, OwnerID: clusterID, Generation: 1} - // When: - cluster, svcErr := service.SoftDelete(ctx, clusterID) - // Then: - g.Expect(svcErr).To(BeNil()) - g.Expect(cluster.DeletedTime).NotTo(BeNil()) - g.Expect(nodePoolDao.nodePools["np-1"].DeletedTime).NotTo(BeNil()) - g.Expect(nodePoolDao.nodePools["np-2"].DeletedTime).NotTo(BeNil()) - }) - - t.Run("cascade delete bumps nodepool generation and sets deleted_by", func(t *testing.T) { - g := NewWithT(t) - clusterDao := newMockClusterDao() - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - service := NewClusterService(clusterDao, nodePoolDao, newMockNodePoolService(), adapterStatusDao, testAdapterConfig()) - ctx := context.Background() - clusterID := "cascade-gen" - clusterDao.clusters[clusterID] = &api.Cluster{Meta: api.Meta{ID: clusterID}, Generation: 1} - nodePoolDao.nodePools["np-1"] = &api.NodePool{Meta: api.Meta{ID: "np-1"}, OwnerID: clusterID, Generation: 3} - nodePoolDao.nodePools["np-2"] = &api.NodePool{Meta: api.Meta{ID: "np-2"}, OwnerID: clusterID, Generation: 1} - - _, svcErr := service.SoftDelete(ctx, clusterID) - - g.Expect(svcErr).To(BeNil()) - g.Expect(nodePoolDao.nodePools["np-1"].Generation).To(Equal(int32(4))) - g.Expect(nodePoolDao.nodePools["np-2"].Generation).To(Equal(int32(2))) - g.Expect(nodePoolDao.nodePools["np-1"].DeletedBy).NotTo(BeNil()) - g.Expect(*nodePoolDao.nodePools["np-1"].DeletedBy).To(Equal(systemActor)) - g.Expect(nodePoolDao.nodePools["np-2"].DeletedBy).NotTo(BeNil()) - g.Expect(*nodePoolDao.nodePools["np-2"].DeletedBy).To(Equal(systemActor)) - }) - - t.Run("cascade delete recomputes nodepool status conditions", func(t *testing.T) { - g := NewWithT(t) - clusterDao := newMockClusterDao() - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - adapterConfig := testAdapterConfig() - adapterConfig.Required.Nodepool = []string{"validation"} - adapterConfig.Required.Cluster = []string{} - npService := NewNodePoolService(nodePoolDao, nil, adapterStatusDao, adapterConfig, nil) - service := NewClusterService(clusterDao, nodePoolDao, npService, adapterStatusDao, adapterConfig) - ctx := context.Background() - clusterID := "cascade-conditions" - clusterDao.clusters[clusterID] = &api.Cluster{Meta: api.Meta{ID: clusterID}, Generation: 1} - nodePoolDao.nodePools["np-1"] = &api.NodePool{ - Meta: api.Meta{ID: "np-1"}, OwnerID: clusterID, Generation: 1, - } - - now := time.Now() - adapterConds := []api.AdapterCondition{ - {Type: api.AdapterConditionTypeAvailable, Status: api.AdapterConditionTrue, LastTransitionTime: now}, - {Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue, LastTransitionTime: now}, - {Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue, LastTransitionTime: now}, - } - condJSON, _ := json.Marshal(adapterConds) - adapterStatusDao.statuses["NodePool:np-1:validation"] = &api.AdapterStatus{ - ResourceType: api.ResourceTypeNodePool, ResourceID: "np-1", Adapter: "validation", - ObservedGeneration: 1, Conditions: condJSON, - CreatedTime: now, LastReportTime: now, - } - - _, svcErr := service.SoftDelete(ctx, clusterID) - - g.Expect(svcErr).To(BeNil()) - np := nodePoolDao.nodePools["np-1"] - g.Expect(np.Generation).To(Equal(int32(2))) - g.Expect(np.StatusConditions).NotTo(BeNil()) - - var conds []api.ResourceCondition - g.Expect(json.Unmarshal(np.StatusConditions, &conds)).To(Succeed()) - - var reconciled, lastKnownReconciled *api.ResourceCondition - for i := range conds { - switch conds[i].Type { - case api.ResourceConditionTypeReconciled: - reconciled = &conds[i] - case api.ResourceConditionTypeLastKnownReconciled: - lastKnownReconciled = &conds[i] - } - } - g.Expect(reconciled).NotTo(BeNil()) - g.Expect(reconciled.Status).To(Equal(api.ConditionFalse), - "Reconciled=False: adapter at gen=1, nodepool at gen=2 after MarkDeleted") - g.Expect(lastKnownReconciled).NotTo(BeNil()) - g.Expect(lastKnownReconciled.Status).To(Equal(api.ConditionTrue), - "LastKnownReconciled=True: adapter still reports Available=True") - g.Expect(lastKnownReconciled.ObservedGeneration).To(Equal(int32(1)), - "LastKnownReconciled should remain anchored to the last reconciled adapter generation") - }) - - t.Run("already-deleted nodepools are not re-marked during cascade", func(t *testing.T) { - g := NewWithT(t) - clusterDao := newMockClusterDao() - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - service := NewClusterService(clusterDao, nodePoolDao, newMockNodePoolService(), adapterStatusDao, testAdapterConfig()) - ctx := context.Background() - clusterID := "cascade-mixed" - clusterDao.clusters[clusterID] = &api.Cluster{Meta: api.Meta{ID: clusterID}, Generation: 1} - - originalDeletedTime := time.Now().Add(-time.Hour).UTC().Truncate(time.Microsecond) - originalDeletedBy := "earlier-actor" - nodePoolDao.nodePools["np-live"] = &api.NodePool{ - Meta: api.Meta{ID: "np-live"}, OwnerID: clusterID, Generation: 1, - } - nodePoolDao.nodePools["np-already-deleted"] = &api.NodePool{ - Meta: api.Meta{ID: "np-already-deleted"}, OwnerID: clusterID, Generation: 5, - DeletedTime: &originalDeletedTime, DeletedBy: &originalDeletedBy, - } - - _, svcErr := service.SoftDelete(ctx, clusterID) - - g.Expect(svcErr).To(BeNil()) - live := nodePoolDao.nodePools["np-live"] - g.Expect(live.DeletedTime).NotTo(BeNil()) - g.Expect(live.Generation).To(Equal(int32(2))) - g.Expect(*live.DeletedBy).To(Equal(systemActor)) - - deleted := nodePoolDao.nodePools["np-already-deleted"] - g.Expect(deleted.DeletedTime.Equal(originalDeletedTime)).To(BeTrue(), - "already-deleted nodepool should keep original deleted_time") - g.Expect(*deleted.DeletedBy).To(Equal(originalDeletedBy), - "already-deleted nodepool should keep original deleted_by") - g.Expect(deleted.Generation).To(Equal(int32(5)), - "already-deleted nodepool should keep original generation") - }) - - t.Run("given an already-deleted cluster, when soft-deleted again, then deleted_time and generation are unchanged", func(t *testing.T) { //nolint:lll - g := NewWithT(t) - // Given: - clusterDao := newMockClusterDao() - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - service := NewClusterService(clusterDao, nodePoolDao, newMockNodePoolService(), adapterStatusDao, testAdapterConfig()) - ctx := context.Background() - clusterID := "already-deleted" - originalTime := time.Now().Add(-time.Hour) - clusterDao.clusters[clusterID] = &api.Cluster{ - Meta: api.Meta{ID: clusterID}, DeletedTime: &originalTime, Generation: 2, - } - nodePoolDao.nodePools["np-1"] = &api.NodePool{ - Meta: api.Meta{ID: "np-1"}, OwnerID: clusterID, DeletedTime: &originalTime, Generation: 2, - } - // When: - cluster, svcErr := service.SoftDelete(ctx, clusterID) - // Then: - g.Expect(svcErr).To(BeNil()) - g.Expect(cluster.DeletedTime.Equal(originalTime)).To(BeTrue()) - g.Expect(cluster.Generation).To(Equal(int32(2))) - g.Expect(nodePoolDao.nodePools["np-1"].DeletedTime.Equal(originalTime)).To(BeTrue()) - }) - - t.Run("given a non-existent cluster ID, when soft-deleted, then returns 404 service error", func(t *testing.T) { - g := NewWithT(t) - // Given: - clusterDao := newMockClusterDao() - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - service := NewClusterService(clusterDao, nodePoolDao, newMockNodePoolService(), adapterStatusDao, testAdapterConfig()) - ctx := context.Background() - // When: - _, svcErr := service.SoftDelete(ctx, "nonexistent") - // Then: - g.Expect(svcErr).NotTo(BeNil()) - g.Expect(svcErr.HTTPCode).To(Equal(404)) - }) - - t.Run("given a cluster with Reconciled=True, when soft-deleted, then Reconciled flips to False due to generation bump", func(t *testing.T) { //nolint:lll - g := NewWithT(t) - // Given: - clusterDao := newMockClusterDao() - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - adapterConfig := testAdapterConfig() - adapterConfig.Required.Cluster = []string{"validation"} - service := NewClusterService(clusterDao, nodePoolDao, newMockNodePoolService(), adapterStatusDao, adapterConfig) - ctx := context.Background() - clusterID := "ready-cluster" - - clusterDao.clusters[clusterID] = &api.Cluster{Meta: api.Meta{ID: clusterID}, Generation: 1} - conditions := []api.AdapterCondition{ - {Type: api.AdapterConditionTypeAvailable, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - } - condJSON, _ := json.Marshal(conditions) - now := time.Now() - _, svcErr := service.ProcessAdapterStatus(ctx, clusterID, &api.AdapterStatus{ - ResourceType: api.ResourceTypeCluster, ResourceID: clusterID, Adapter: "validation", - ObservedGeneration: 1, Conditions: condJSON, CreatedTime: now, LastReportTime: now, - }) - g.Expect(svcErr).To(BeNil()) - - // Pre-condition: Reconciled=True before soft-delete - stored, _ := clusterDao.Get(ctx, clusterID) - var preConds []api.ResourceCondition - g.Expect(json.Unmarshal(stored.StatusConditions, &preConds)).To(Succeed()) - var preReconciled *api.ResourceCondition - for i := range preConds { - if preConds[i].Type == api.ResourceConditionTypeReconciled { - preReconciled = &preConds[i] - } - } - g.Expect(preReconciled).NotTo(BeNil()) - g.Expect(preReconciled.Status).To(Equal(api.ConditionTrue)) - - // When: - _, svcErr = service.SoftDelete(ctx, clusterID) - g.Expect(svcErr).To(BeNil()) - - // Then: generation bumped to 2, Reconciled must flip to False - stored, _ = clusterDao.Get(ctx, clusterID) - g.Expect(stored.Generation).To(Equal(int32(2))) - var postConds []api.ResourceCondition - g.Expect(json.Unmarshal(stored.StatusConditions, &postConds)).To(Succeed()) - var postReconciled *api.ResourceCondition - for i := range postConds { - if postConds[i].Type == api.ResourceConditionTypeReconciled { - postReconciled = &postConds[i] - } - } - g.Expect(postReconciled).NotTo(BeNil()) - g.Expect(postReconciled.Status).To(Equal(api.ConditionFalse)) - g.Expect(postReconciled.ObservedGeneration).To(Equal(int32(2))) - }) -} - -func TestReconciled_DuringDeletion_ChildResources(t *testing.T) { - adapterConfig := &config.AdapterRequirementsConfig{ - Required: config.RequiredAdapters{ - Cluster: []string{"validation"}, - Nodepool: []string{"validation"}, - }, - } - - makeClusterWithDeletion := func(clusterID string, gen int32) *api.Cluster { - t := time.Now().UTC().Truncate(time.Microsecond) - return &api.Cluster{ - Meta: api.Meta{ID: clusterID}, - Generation: gen, - DeletedTime: &t, - } - } - - finalizedConditions := func() []byte { - now := time.Now() - conds := []api.AdapterCondition{ - {Type: api.AdapterConditionTypeAvailable, Status: api.AdapterConditionTrue, LastTransitionTime: now}, - {Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue, LastTransitionTime: now}, - {Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue, LastTransitionTime: now}, - {Type: api.AdapterConditionTypeFinalized, Status: api.AdapterConditionTrue, LastTransitionTime: now}, - } - b, _ := json.Marshal(conds) - return b - } - - name := "all adapters finalized, nodepool still exists" + - " → Reconciled=False with WaitingForChildResources" - t.Run(name, func(t *testing.T) { - g := NewWithT(t) - ctx := context.Background() - clusterID := "cluster-with-nodepools" - - clusterDao := newMockClusterDao() - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - service := NewClusterService(clusterDao, nodePoolDao, newMockNodePoolService(), adapterStatusDao, adapterConfig) - - cluster := makeClusterWithDeletion(clusterID, 2) - clusterDao.clusters[clusterID] = cluster - - nodePoolDao.nodePools["np-1"] = &api.NodePool{Meta: api.Meta{ID: "np-1"}, OwnerID: clusterID} - - now := time.Now() - condJSON := finalizedConditions() - adapterStatusDao.statuses["Cluster:"+clusterID+":validation"] = &api.AdapterStatus{ - ResourceType: api.ResourceTypeCluster, ResourceID: clusterID, Adapter: "validation", - ObservedGeneration: 2, Conditions: condJSON, - CreatedTime: now, LastReportTime: now, - } - - updated, svcErr := service.UpdateClusterStatusFromAdapters(ctx, clusterID) - g.Expect(svcErr).To(BeNil()) - - var conds []api.ResourceCondition - g.Expect(json.Unmarshal(updated.StatusConditions, &conds)).To(Succeed()) - - var reconciled *api.ResourceCondition - for i := range conds { - if conds[i].Type == api.ResourceConditionTypeReconciled { - reconciled = &conds[i] - } - } - - g.Expect(reconciled).NotTo(BeNil()) - g.Expect(reconciled.Status).To(Equal(api.ConditionFalse)) - g.Expect(reconciled.Reason).NotTo(BeNil()) - g.Expect(*reconciled.Reason).To(Equal(reasonReconciledWaitingForChildren)) - }) - - t.Run("all adapters finalized, no nodepools → Reconciled=True", func(t *testing.T) { - g := NewWithT(t) - ctx := context.Background() - clusterID := "cluster-no-nodepools" - - clusterDao := newMockClusterDao() - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - service := NewClusterService(clusterDao, nodePoolDao, newMockNodePoolService(), adapterStatusDao, adapterConfig) - - cluster := makeClusterWithDeletion(clusterID, 2) - clusterDao.clusters[clusterID] = cluster - - now := time.Now() - condJSON := finalizedConditions() - adapterStatusDao.statuses["Cluster:"+clusterID+":validation"] = &api.AdapterStatus{ - ResourceType: api.ResourceTypeCluster, ResourceID: clusterID, Adapter: "validation", - ObservedGeneration: 2, Conditions: condJSON, - CreatedTime: now, LastReportTime: now, - } - - updated, svcErr := service.UpdateClusterStatusFromAdapters(ctx, clusterID) - g.Expect(svcErr).To(BeNil()) - - var conds []api.ResourceCondition - g.Expect(json.Unmarshal(updated.StatusConditions, &conds)).To(Succeed()) - - var reconciled *api.ResourceCondition - for i := range conds { - if conds[i].Type == api.ResourceConditionTypeReconciled { - reconciled = &conds[i] - } - } - - g.Expect(reconciled).NotTo(BeNil()) - g.Expect(reconciled.Status).To(Equal(api.ConditionTrue)) - g.Expect(reconciled.Reason).NotTo(BeNil()) - g.Expect(*reconciled.Reason).To(Equal(reasonReconciledAll)) - }) -} - -func TestClusterPatch(t *testing.T) { - t.Parallel() - t.Run("spec changed increments generation", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - clusterDao := newMockClusterDao() - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - adapterConfig := testAdapterConfig() - adapterConfig.Required.Cluster = []string{} - service := NewClusterService(clusterDao, nodePoolDao, newMockNodePoolService(), adapterStatusDao, adapterConfig) - ctx := context.Background() - - clusterDao.clusters["c1"] = &api.Cluster{ - Meta: api.Meta{ID: "c1"}, - Spec: []byte(`{"old":"spec"}`), - Labels: []byte(`{}`), - Generation: 1, - } - - newSpec := map[string]interface{}{"new": "spec"} - result, svcErr := service.Patch(ctx, "c1", &api.ClusterPatch{Spec: newSpec}) - - g.Expect(svcErr).To(BeNil()) - g.Expect(result.Generation).To(Equal(int32(2))) - }) - - t.Run("spec unchanged keeps generation", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - clusterDao := newMockClusterDao() - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - adapterConfig := testAdapterConfig() - adapterConfig.Required.Cluster = []string{} - service := NewClusterService(clusterDao, nodePoolDao, newMockNodePoolService(), adapterStatusDao, adapterConfig) - ctx := context.Background() - - clusterDao.clusters["c1"] = &api.Cluster{ - Meta: api.Meta{ID: "c1"}, - Spec: []byte(`{"key":"value"}`), - Labels: []byte(`{}`), - Generation: 3, - } - - sameSpec := map[string]interface{}{"key": "value"} - result, svcErr := service.Patch(ctx, "c1", &api.ClusterPatch{Spec: sameSpec}) - - g.Expect(svcErr).To(BeNil()) - g.Expect(result.Generation).To(Equal(int32(3))) - }) - - t.Run("labels changed increments generation", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - clusterDao := newMockClusterDao() - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - adapterConfig := testAdapterConfig() - adapterConfig.Required.Cluster = []string{} - service := NewClusterService(clusterDao, nodePoolDao, newMockNodePoolService(), adapterStatusDao, adapterConfig) - ctx := context.Background() - - clusterDao.clusters["c1"] = &api.Cluster{ - Meta: api.Meta{ID: "c1"}, - Spec: []byte(`{}`), - Labels: []byte(`{"env":"dev"}`), - Generation: 1, - } - - newLabels := map[string]string{"env": "prod"} - result, svcErr := service.Patch(ctx, "c1", &api.ClusterPatch{Labels: newLabels}) - - g.Expect(svcErr).To(BeNil()) - g.Expect(result.Generation).To(Equal(int32(2))) - }) - - t.Run("spec unchanged with different key order keeps generation", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - clusterDao := newMockClusterDao() - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - adapterConfig := testAdapterConfig() - adapterConfig.Required.Cluster = []string{} - service := NewClusterService(clusterDao, nodePoolDao, newMockNodePoolService(), adapterStatusDao, adapterConfig) - ctx := context.Background() - - clusterDao.clusters["c1"] = &api.Cluster{ - Meta: api.Meta{ID: "c1"}, - Spec: []byte(`{"z":"last","a":"first","m":"middle"}`), - Labels: []byte(`{}`), - Generation: 5, - } - - sameSpec := map[string]interface{}{"z": "last", "a": "first", "m": "middle"} - result, svcErr := service.Patch(ctx, "c1", &api.ClusterPatch{Spec: sameSpec}) - - g.Expect(svcErr).To(BeNil()) - g.Expect(result.Generation).To(Equal(int32(5))) - }) - - t.Run("labels unchanged with different key order keeps generation", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - clusterDao := newMockClusterDao() - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - adapterConfig := testAdapterConfig() - adapterConfig.Required.Cluster = []string{} - service := NewClusterService(clusterDao, nodePoolDao, newMockNodePoolService(), adapterStatusDao, adapterConfig) - ctx := context.Background() - - clusterDao.clusters["c1"] = &api.Cluster{ - Meta: api.Meta{ID: "c1"}, - Spec: []byte(`{}`), - Labels: []byte(`{"z":"zulu","a":"alpha"}`), - Generation: 4, - } - - sameLabels := map[string]string{"z": "zulu", "a": "alpha"} - result, svcErr := service.Patch(ctx, "c1", &api.ClusterPatch{Labels: sameLabels}) - - g.Expect(svcErr).To(BeNil()) - g.Expect(result.Generation).To(Equal(int32(4))) - }) - - t.Run("not found returns 404", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - clusterDao := newMockClusterDao() - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - service := NewClusterService(clusterDao, nodePoolDao, newMockNodePoolService(), adapterStatusDao, testAdapterConfig()) - ctx := context.Background() - - newSpec := map[string]interface{}{"a": "b"} - _, svcErr := service.Patch(ctx, "nonexistent", &api.ClusterPatch{Spec: newSpec}) - - g.Expect(svcErr).NotTo(BeNil()) - g.Expect(svcErr.HTTPCode).To(Equal(404)) - }) -} - -type forceDeleteEnv struct { - clusterDao *mockClusterDao - nodePoolDao *mockNodePoolDao - adapterStatusDao *mockAdapterStatusDao - service ClusterService -} - -func newForceDeleteEnv() *forceDeleteEnv { - cd := newMockClusterDao() - npd := newMockNodePoolDao() - asd := newMockAdapterStatusDao() - return &forceDeleteEnv{ - clusterDao: cd, - nodePoolDao: npd, - adapterStatusDao: asd, - service: NewClusterService(cd, npd, newMockNodePoolService(), asd, testAdapterConfig()), - } -} - -func TestClusterForceDelete(t *testing.T) { - t.Parallel() - deletedTime := time.Now().Add(-time.Hour) - - t.Run("cluster not found returns 404", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - env := newForceDeleteEnv() - - svcErr := env.service.ForceDelete(context.Background(), "nonexistent", "testing") - - g.Expect(svcErr).NotTo(BeNil()) - g.Expect(svcErr.HTTPCode).To(Equal(http.StatusNotFound)) - }) - - t.Run("cluster not in Finalizing state returns 409", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - env := newForceDeleteEnv() - env.clusterDao.clusters[testClusterID] = &api.Cluster{ - Meta: api.Meta{ID: testClusterID}, - Generation: 1, - } - - svcErr := env.service.ForceDelete(context.Background(), testClusterID, "testing") - - g.Expect(svcErr).NotTo(BeNil()) - g.Expect(svcErr.HTTPCode).To(Equal(http.StatusConflict)) - }) - - t.Run("cluster in Finalizing is removed with adapter statuses", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - env := newForceDeleteEnv() - env.clusterDao.clusters[testClusterID] = &api.Cluster{ - Meta: api.Meta{ID: testClusterID}, - Generation: 2, - DeletedTime: &deletedTime, - } - env.adapterStatusDao.statuses["cs-1"] = &api.AdapterStatus{ - Meta: api.Meta{ID: "cs-1"}, - ResourceType: api.ResourceTypeCluster, - ResourceID: testClusterID, - Adapter: "validation", - } - - svcErr := env.service.ForceDelete(context.Background(), testClusterID, "stuck in finalizing") - - g.Expect(svcErr).To(BeNil()) - _, err := env.clusterDao.Get(context.Background(), testClusterID) - g.Expect(err).To(Equal(gorm.ErrRecordNotFound)) - g.Expect(env.adapterStatusDao.statuses).To(BeEmpty()) - }) - - t.Run("cascade removes child nodepools and their adapter statuses", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - env := newForceDeleteEnv() - env.clusterDao.clusters[testClusterID] = &api.Cluster{ - Meta: api.Meta{ID: testClusterID}, - Generation: 2, - DeletedTime: &deletedTime, - } - env.nodePoolDao.nodePools["np-1"] = &api.NodePool{ - Meta: api.Meta{ID: "np-1"}, OwnerID: testClusterID, Generation: 1, - } - env.nodePoolDao.nodePools["np-2"] = &api.NodePool{ - Meta: api.Meta{ID: "np-2"}, OwnerID: testClusterID, Generation: 1, - } - env.adapterStatusDao.statuses["cs-1"] = &api.AdapterStatus{ - Meta: api.Meta{ID: "cs-1"}, ResourceType: api.ResourceTypeCluster, - ResourceID: testClusterID, Adapter: "validation", - } - env.adapterStatusDao.statuses["ns-1"] = &api.AdapterStatus{ - Meta: api.Meta{ID: "ns-1"}, ResourceType: api.ResourceTypeNodePool, - ResourceID: "np-1", Adapter: "validation", - } - env.adapterStatusDao.statuses["ns-2"] = &api.AdapterStatus{ - Meta: api.Meta{ID: "ns-2"}, ResourceType: api.ResourceTypeNodePool, - ResourceID: "np-2", Adapter: "hypershift", - } - - svcErr := env.service.ForceDelete(context.Background(), testClusterID, "adapter stuck") - - g.Expect(svcErr).To(BeNil()) - _, err := env.clusterDao.Get(context.Background(), testClusterID) - g.Expect(err).To(Equal(gorm.ErrRecordNotFound)) - g.Expect(env.nodePoolDao.nodePools).To(BeEmpty()) - g.Expect(env.adapterStatusDao.statuses).To(BeEmpty()) - }) - - t.Run("adapter status fetch failure returns 500", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - env := newForceDeleteEnv() - env.adapterStatusDao.findByResourceErr = fmt.Errorf("db connection lost") - env.clusterDao.clusters[testClusterID] = &api.Cluster{ - Meta: api.Meta{ID: testClusterID}, - DeletedTime: &deletedTime, - } - - svcErr := env.service.ForceDelete(context.Background(), testClusterID, "testing") - - g.Expect(svcErr).NotTo(BeNil()) - g.Expect(svcErr.HTTPCode).To(Equal(http.StatusInternalServerError)) - }) - - t.Run("nodepool fetch failure returns 500", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - env := newForceDeleteEnv() - env.nodePoolDao.findByOwnerErr = fmt.Errorf("db connection lost") - env.clusterDao.clusters[testClusterID] = &api.Cluster{ - Meta: api.Meta{ID: testClusterID}, - DeletedTime: &deletedTime, - } - - svcErr := env.service.ForceDelete(context.Background(), testClusterID, "testing") - - g.Expect(svcErr).NotTo(BeNil()) - g.Expect(svcErr.HTTPCode).To(Equal(http.StatusInternalServerError)) - }) - - t.Run("cascade adapter status deletion failure returns 500", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - env := newForceDeleteEnv() - env.adapterStatusDao.deleteByResourceErr = fmt.Errorf("db write error") - env.clusterDao.clusters[testClusterID] = &api.Cluster{ - Meta: api.Meta{ID: testClusterID}, - DeletedTime: &deletedTime, - } - env.nodePoolDao.nodePools["np-1"] = &api.NodePool{ - Meta: api.Meta{ID: "np-1"}, OwnerID: testClusterID, Generation: 1, - } - - svcErr := env.service.ForceDelete(context.Background(), testClusterID, "testing") - - g.Expect(svcErr).NotTo(BeNil()) - g.Expect(svcErr.HTTPCode).To(Equal(http.StatusInternalServerError)) - }) - - t.Run("cascade nodepool deletion failure returns 500", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - env := newForceDeleteEnv() - env.nodePoolDao.deleteErr = fmt.Errorf("db write error") - env.clusterDao.clusters[testClusterID] = &api.Cluster{ - Meta: api.Meta{ID: testClusterID}, - DeletedTime: &deletedTime, - } - env.nodePoolDao.nodePools["np-1"] = &api.NodePool{ - Meta: api.Meta{ID: "np-1"}, OwnerID: testClusterID, Generation: 1, - } - - svcErr := env.service.ForceDelete(context.Background(), testClusterID, "testing") - - g.Expect(svcErr).NotTo(BeNil()) - g.Expect(svcErr.HTTPCode).To(Equal(http.StatusInternalServerError)) - }) - - t.Run("cascade cluster adapter status deletion failure returns 500", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - env := newForceDeleteEnv() - env.adapterStatusDao.deleteByResourceErr = fmt.Errorf("db write error") - env.clusterDao.clusters[testClusterID] = &api.Cluster{ - Meta: api.Meta{ID: testClusterID}, - DeletedTime: &deletedTime, - } - - svcErr := env.service.ForceDelete(context.Background(), testClusterID, "testing") - - g.Expect(svcErr).NotTo(BeNil()) - g.Expect(svcErr.HTTPCode).To(Equal(http.StatusInternalServerError)) - }) - - t.Run("cascade cluster deletion failure returns 500", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - env := newForceDeleteEnv() - env.clusterDao.deleteErr = fmt.Errorf("db write error") - env.clusterDao.clusters[testClusterID] = &api.Cluster{ - Meta: api.Meta{ID: testClusterID}, - DeletedTime: &deletedTime, - } - - svcErr := env.service.ForceDelete(context.Background(), testClusterID, "testing") - - g.Expect(svcErr).NotTo(BeNil()) - g.Expect(svcErr.HTTPCode).To(Equal(http.StatusInternalServerError)) - }) -} diff --git a/pkg/services/generic.go b/pkg/services/generic.go index 25db050a..7f47466e 100755 --- a/pkg/services/generic.go +++ b/pkg/services/generic.go @@ -180,6 +180,17 @@ func (s *sqlGenericService) buildSearchValues( return "", nil, serviceErr } + // Extract label queries (labels.xxx) for Resource-kind entities before field name + // mapping — they're backed by a separate resource_labels table, not a JSONB column, + // so they need an EXISTS subquery rather than a simple field substitution. + var labelExprs []squirrel.Sqlizer + if listCtx.resourceType == "Resource" { + tslTree, labelExprs, serviceErr = db.ExtractLabelQueries(tslTree, (*d).GetTableName()) + if serviceErr != nil { + return "", nil, serviceErr + } + } + // apply field name mapping (status.xxx -> status_xxx, labels.xxx -> labels->>'xxx', spec.xxx -> spec->>'xxx') // also wraps spec JSONB fields in CAST(... AS numeric) when compared against a number tslTree, serviceErr = db.FieldNameWalk(tslTree, *listCtx.disallowedFields) @@ -210,20 +221,19 @@ func (s *sqlGenericService) buildSearchValues( return "", nil, errors.GeneralError("%s", err.Error()) } - // Combine the base SQL with condition expressions - if len(conditionExprs) > 0 { - for _, condExpr := range conditionExprs { - condSQL, condValues, err := condExpr.ToSql() - if err != nil { - return "", nil, errors.GeneralError("%s", err.Error()) - } - if sql == "" { - sql = condSQL - } else { - sql = fmt.Sprintf("(%s) AND (%s)", sql, condSQL) - } - values = append(values, condValues...) + // Combine the base SQL with extracted condition and label expressions + extractedExprs := append(append([]squirrel.Sqlizer{}, conditionExprs...), labelExprs...) + for _, expr := range extractedExprs { + exprSQL, exprValues, err := expr.ToSql() + if err != nil { + return "", nil, errors.GeneralError("%s", err.Error()) + } + if sql == "" { + sql = exprSQL + } else { + sql = fmt.Sprintf("(%s) AND (%s)", sql, exprSQL) } + values = append(values, exprValues...) } return sql, values, nil diff --git a/pkg/services/generic_test.go b/pkg/services/generic_test.go index 398f6708..b24d831a 100755 --- a/pkg/services/generic_test.go +++ b/pkg/services/generic_test.go @@ -35,7 +35,7 @@ func TestSQLTranslation(t *testing.T) { }, } for _, test := range errorTests { - var list []api.Cluster + var list []api.Resource search := test["search"].(string) errorMsg := test["error"].(string) listCtx, model, serviceErr := genericService.newListContext( @@ -106,7 +106,7 @@ func TestSQLTranslation(t *testing.T) { }, } for _, test := range sqlTests { - var list []api.Cluster + var list []api.Resource search := test["search"].(string) sqlReal := test["sql"].(string) valuesReal := test["values"].(types.GomegaMatcher) @@ -150,7 +150,7 @@ func TestSQLTranslation(t *testing.T) { }, } for _, test := range jsonbRelatedTableTests { - var list []api.Cluster + var list []api.Resource search := test["search"].(string) sqlReal := test["sql"].(string) valuesReal := test["values"].(types.GomegaMatcher) @@ -163,7 +163,7 @@ func TestSQLTranslation(t *testing.T) { tslTree := tslTreeWrapper.Node tslTree, serviceErr = db.FieldNameWalk(tslTree, *listCtx.disallowedFields) Expect(serviceErr).ToNot(HaveOccurred()) - d := g.GetInstanceDao(context.Background(), &api.Cluster{}) + d := g.GetInstanceDao(context.Background(), &api.Resource{}) tslTree, serviceErr = genericService.treeWalkForRelatedTables(listCtx, tslTree, &d) Expect(serviceErr).ToNot(HaveOccurred(), "JSONB field should not be misclassified as related table: %s", search) tslTree, serviceErr = genericService.treeWalkForAddingTableName(listCtx, tslTree, &d) diff --git a/pkg/services/node_pool.go b/pkg/services/node_pool.go deleted file mode 100644 index e7753186..00000000 --- a/pkg/services/node_pool.go +++ /dev/null @@ -1,460 +0,0 @@ -package services - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/metrics" -) - -//go:generate mockgen-v0.6.0 -source=node_pool.go -package=services -destination=node_pool_mock.go - -type NodePoolService interface { - Get(ctx context.Context, id string) (*api.NodePool, *errors.ServiceError) - Create(ctx context.Context, nodePool *api.NodePool) (*api.NodePool, *errors.ServiceError) - Patch(ctx context.Context, id string, patch *api.NodePoolPatch) (*api.NodePool, *errors.ServiceError) - GetByIDAndOwner(ctx context.Context, id string, ownerID string) (*api.NodePool, *errors.ServiceError) - ListByCluster( - ctx context.Context, clusterID string, args *ListArguments, - ) (api.NodePoolList, *api.PagingMeta, *errors.ServiceError) - SoftDelete(ctx context.Context, id string) (*api.NodePool, *errors.ServiceError) - CascadeSoftDelete( - ctx context.Context, nodePools api.NodePoolList, deletedBy string, deletedTime time.Time, - ) *errors.ServiceError - ForceDelete(ctx context.Context, id string, reason string) *errors.ServiceError - - UpdateNodePoolStatusFromAdapters(ctx context.Context, nodePoolID string) (*api.NodePool, *errors.ServiceError) - - ProcessAdapterStatus( - ctx context.Context, nodePoolID string, adapterStatus *api.AdapterStatus, - ) (*api.AdapterStatus, *errors.ServiceError) -} - -func NewNodePoolService( - nodePoolDao dao.NodePoolDao, - clusterDao dao.ClusterDao, - adapterStatusDao dao.AdapterStatusDao, - adapterConfig *config.AdapterRequirementsConfig, - generic GenericService, -) NodePoolService { - return &sqlNodePoolService{ - nodePoolDao: nodePoolDao, - clusterDao: clusterDao, - adapterStatusDao: adapterStatusDao, - adapterConfig: adapterConfig, - generic: generic, - } -} - -var _ NodePoolService = &sqlNodePoolService{} - -type sqlNodePoolService struct { - nodePoolDao dao.NodePoolDao - clusterDao dao.ClusterDao - adapterStatusDao dao.AdapterStatusDao - adapterConfig *config.AdapterRequirementsConfig - generic GenericService -} - -func (s *sqlNodePoolService) Get(ctx context.Context, id string) (*api.NodePool, *errors.ServiceError) { - nodePool, err := s.nodePoolDao.Get(ctx, id) - if err != nil { - return nil, handleGetError(api.ResourceTypeNodePool, "id", id, err) - } - return nodePool, nil -} - -func (s *sqlNodePoolService) Create(ctx context.Context, nodePool *api.NodePool) (*api.NodePool, *errors.ServiceError) { - if nodePool.CreatedBy == "" { - nodePool.CreatedBy = actorFromContext(ctx) - } - if nodePool.UpdatedBy == "" { - nodePool.UpdatedBy = actorFromContext(ctx) - } - if nodePool.Generation == 0 { - nodePool.Generation = 1 - } - - nodePool, err := s.nodePoolDao.Create(ctx, nodePool) - if err != nil { - return nil, handleCreateError(api.ResourceTypeNodePool, err) - } - - updatedNodePool, svcErr := s.UpdateNodePoolStatusFromAdapters(ctx, nodePool.ID) - if svcErr != nil { - return nil, svcErr - } - - return updatedNodePool, nil -} - -func (s *sqlNodePoolService) Patch( - ctx context.Context, nodePoolID string, patch *api.NodePoolPatch, -) (*api.NodePool, *errors.ServiceError) { - nodePool, err := s.nodePoolDao.GetForUpdate(ctx, nodePoolID) - if err != nil { - return nil, handleGetError(api.ResourceTypeNodePool, "id", nodePoolID, err) - } - - if nodePool.DeletedTime != nil { - return nil, errors.ConflictState("NodePool '%s' is marked for deletion", nodePoolID) - } - - oldSpec := nodePool.Spec - oldLabels := nodePool.Labels - - if applyErr := applyNodePoolPatch(nodePool, patch); applyErr != nil { - return nil, errors.Validation("Invalid patch data: %v", applyErr) - } - - if jsonEqual(oldSpec, nodePool.Spec) && jsonEqual(oldLabels, nodePool.Labels) { - return nodePool, nil - } - - nodePool.IncrementGeneration() - nodePool.UpdatedBy = actorFromContext(ctx) - - if saveErr := s.nodePoolDao.Save(ctx, nodePool); saveErr != nil { - return nil, handleUpdateError(api.ResourceTypeNodePool, saveErr) - } - - updated, svcErr := s.UpdateNodePoolStatusFromAdapters(ctx, nodePool.ID) - if svcErr != nil { - return nil, svcErr - } - return updated, nil -} - -func (s *sqlNodePoolService) GetByIDAndOwner( - ctx context.Context, nodePoolID string, clusterID string, -) (*api.NodePool, *errors.ServiceError) { - nodePool, err := s.nodePoolDao.GetByIDAndOwner(ctx, nodePoolID, clusterID) - if err != nil { - return nil, handleGetError(api.ResourceTypeNodePool, "id", nodePoolID, err) - } - return nodePool, nil -} - -func (s *sqlNodePoolService) ListByCluster( - ctx context.Context, clusterID string, args *ListArguments, -) (api.NodePoolList, *api.PagingMeta, *errors.ServiceError) { - if _, err := s.clusterDao.Get(ctx, clusterID); err != nil { - return nil, nil, handleGetError(api.ResourceTypeCluster, "id", clusterID, err) - } - - if args.Search == "" { - args.Search = "owner_id = '" + clusterID + "'" - } else { - args.Search = "(" + args.Search + ") AND owner_id = '" + clusterID + "'" - } - - var nodePools []api.NodePool - paging, svcErr := s.generic.List(ctx, args, &nodePools) - if svcErr != nil { - return nil, nil, svcErr - } - - result := make(api.NodePoolList, len(nodePools)) - for i := range nodePools { - result[i] = &nodePools[i] - } - return result, paging, nil -} - -func (s *sqlNodePoolService) SoftDelete(ctx context.Context, nodePoolID string) (*api.NodePool, *errors.ServiceError) { - nodePool, err := s.nodePoolDao.GetForUpdate(ctx, nodePoolID) - if err != nil { - return nil, handleSoftDeleteError(api.ResourceTypeNodePool, err) - } - - if nodePool.DeletedTime != nil { - return nodePool, nil - } - - t := time.Now().UTC().Truncate(time.Microsecond) - deletedBy := actorFromContext(ctx) - nodePool.MarkDeleted(deletedBy, t) - nodePool.IncrementGeneration() - - if err := s.nodePoolDao.Save(ctx, nodePool); err != nil { - return nil, handleSoftDeleteError(api.ResourceTypeNodePool, err) - } - - updated, svcErr := s.UpdateNodePoolStatusFromAdapters(ctx, nodePool.ID) - if svcErr != nil { - return nil, svcErr - } - - return updated, nil -} - -func (s *sqlNodePoolService) CascadeSoftDelete( - ctx context.Context, nodePools api.NodePoolList, deletedBy string, deletedTime time.Time, -) *errors.ServiceError { - if deletedBy == "" { - deletedBy = defaultSystemUser - } - if deletedTime.IsZero() { - deletedTime = time.Now().UTC().Truncate(time.Microsecond) - } - - for _, np := range nodePools { - if np.DeletedTime == nil { - np.MarkDeleted(deletedBy, deletedTime) - np.IncrementGeneration() - } - } - - startedCount, svcErr := recomputeNodePoolConditions(ctx, nodePools, s.adapterStatusDao, s.adapterConfig) - if svcErr != nil { - return svcErr - } - - if err := s.nodePoolDao.SaveAll(ctx, nodePools); err != nil { - return handleSoftDeleteError(api.ResourceTypeNodePool, err) - } - - for range startedCount { - metrics.RecordReconciliationStarted("nodepool", true) - } - - return nil -} - -func (s *sqlNodePoolService) ForceDelete(ctx context.Context, id string, reason string) *errors.ServiceError { - nodePool, err := s.nodePoolDao.GetForUpdate(ctx, id) - if err != nil { - return handleGetError(api.ResourceTypeNodePool, "id", id, err) - } - - if nodePool.DeletedTime == nil { - return errors.ConflictState("NodePool '%s' is not in Finalizing state", id) - } - - statuses, err := s.adapterStatusDao.FindByResource(ctx, api.ResourceTypeNodePool, id) - if err != nil { - return errors.GeneralError("Failed to fetch adapter statuses for nodepool '%s': %s", id, err) - } - - caller := actorFromContext(ctx) - - logger.With(ctx, - "nodepool_id", id, - "cluster_id", nodePool.OwnerID, - "resource_type", api.ResourceTypeNodePool, - "caller", caller, - "reason", reason, - "adapter_statuses", buildAdapterSummaries(ctx, statuses), - ).Info("Force-deleting nodepool") - - if err := s.adapterStatusDao.DeleteByResource(ctx, api.ResourceTypeNodePool, id); err != nil { - logger.With(ctx, "nodepool_id", id).WithError(err).Error("Failed to delete adapter statuses for nodepool") - return errors.GeneralError("Failed to delete adapter statuses for nodepool '%s'", id) - } - - if err := s.nodePoolDao.Delete(ctx, id); err != nil { - logger.With(ctx, "nodepool_id", id).WithError(err).Error("Failed to force-delete nodepool") - return handleDeleteError(api.ResourceTypeNodePool, err) - } - - return nil -} - -func applyNodePoolPatch(nodePool *api.NodePool, patch *api.NodePoolPatch) error { - if patch.Spec != nil { - specJSON, err := json.Marshal(patch.Spec) - if err != nil { - return fmt.Errorf("failed to marshal nodepool spec: %w", err) - } - nodePool.Spec = specJSON - } - if patch.Labels != nil { - labelsJSON, err := json.Marshal(patch.Labels) - if err != nil { - return fmt.Errorf("failed to marshal nodepool labels: %w", err) - } - nodePool.Labels = labelsJSON - } - return nil -} - -func nodePoolRefTime(np *api.NodePool) time.Time { - if np == nil { - return time.Time{} - } - if !np.UpdatedTime.IsZero() { - return np.UpdatedTime - } - return np.CreatedTime -} - -// UpdateNodePoolStatusFromAdapters is the public entry point for callers outside -// ProcessAdapterStatus (e.g. Create, Patch, SoftDelete) that don't already hold the node -// pool and adapter statuses. -func (s *sqlNodePoolService) UpdateNodePoolStatusFromAdapters( - ctx context.Context, nodePoolID string, -) (*api.NodePool, *errors.ServiceError) { - return updateNodePoolStatusFromAdapters( - ctx, - nodePoolID, - s.nodePoolDao, - s.adapterStatusDao, - s.adapterConfig, - ) -} - -// recomputeAndSaveNodePoolStatus aggregates adapter reports into node pool conditions and -// persists only when the result differs from the current stored value. Accepts pre-fetched -// data to avoid redundant DB reads. -func (s *sqlNodePoolService) recomputeAndSaveNodePoolStatus( - ctx context.Context, nodePool *api.NodePool, adapterStatuses api.AdapterStatusList, -) (*api.NodePool, *errors.ServiceError) { - conditionsJSON, started, svcErr := computeNodePoolConditionsJSON( - ctx, nodePool, adapterStatuses, s.adapterConfig.RequiredNodePoolAdapters(), - ) - if svcErr != nil { - return nil, svcErr - } - if conditionsJSON == nil { - return nodePool, nil - } - - if err := s.nodePoolDao.SaveStatusConditions(ctx, nodePool.ID, conditionsJSON); err != nil { - return nil, handleUpdateError(api.ResourceTypeNodePool, err) - } - - nodePool.StatusConditions = conditionsJSON - - if started { - metrics.RecordReconciliationStarted("nodepool", nodePool.DeletedTime != nil) - } - - return nodePool, nil -} - -// ProcessAdapterStatus validates mandatory conditions, applies discard rules, upserts adapter -// status, and triggers aggregation when appropriate. -// -// DB call budget (non-deleted happy path): -// 1. GetForUpdate — lock + fetch node pool -// 2. FindByResource — all adapter statuses (existing status found in-memory) -// 3. Upsert — write adapter status -// 4. SaveStatusConditions — write updated node pool conditions (skipped when unchanged) -func (s *sqlNodePoolService) ProcessAdapterStatus( - ctx context.Context, nodePoolID string, adapterStatus *api.AdapterStatus, -) (*api.AdapterStatus, *errors.ServiceError) { - // 1. Acquire a row-level exclusive lock on the node pool for the duration of this - // transaction. Concurrent adapter status updates for the same node pool are - // serialized here: the second caller blocks until the first commits, ensuring - // the aggregation step always reads a fully up-to-date set of adapter statuses. - nodePool, err := s.nodePoolDao.GetForUpdate(ctx, nodePoolID) - if err != nil { - return nil, handleGetError(api.ResourceTypeNodePool, "id", nodePoolID, err) - } - - // 2. Fetch all adapter statuses for this node pool in one query. This replaces the - // individual FindByResourceAndAdapter and later FindByResource calls. - allStatuses, err := s.adapterStatusDao.FindByResource(ctx, api.ResourceTypeNodePool, nodePoolID) - if err != nil { - return nil, errors.GeneralError("Failed to get adapter statuses: %s", err) - } - - existingStatus := findAdapterStatusInList(allStatuses, adapterStatus.Adapter) - - conditions, triggerAggregation, svcErr := s.validateAndClassifyNodePool( - ctx, nodePoolID, adapterStatus, nodePool, existingStatus, - ) - if svcErr != nil { - return nil, svcErr - } - if conditions == nil && !triggerAggregation { - return nil, nil - } - - // 3. Upsert using the already-fetched existing status to skip a redundant lookup. - adapterStatus.ResourceType = api.ResourceTypeNodePool - adapterStatus.ResourceID = nodePoolID - setConditionTransitionTimes(adapterStatus, existingStatus) - - upsertedStatus, err := s.adapterStatusDao.Upsert(ctx, adapterStatus, existingStatus) - if err != nil { - return nil, handleCreateError("AdapterStatus", err) - } - - // Build the post-upsert snapshot once and reuse it for both the hard-delete check - // and aggregation. Using the pre-upsert allStatuses for the hard-delete check would - // cause allAdaptersFinalized to return false when the current adapter is the last one - // needed to complete the Finalized=True quorum. - updatedStatuses := replaceAdapterStatusInList(allStatuses, upsertedStatus) - - if nodePool.DeletedTime != nil { - hardDeleted, hdErr := s.tryHardDeleteNodePool(ctx, nodePool, conditions, updatedStatuses) - if hdErr != nil { - return nil, hdErr - } - if hardDeleted { - return upsertedStatus, nil - } - } - - // 4. Re-aggregate using data already in memory. - if triggerAggregation { - if _, aggregateErr := s.recomputeAndSaveNodePoolStatus(ctx, nodePool, updatedStatuses); aggregateErr != nil { - return nil, aggregateErr - } - } - - return upsertedStatus, nil -} - -// validateAndClassifyNodePool delegates to the shared validateAndClassifyAdapterStatus -// with nodepool-specific logger context. -func (s *sqlNodePoolService) validateAndClassifyNodePool( - ctx context.Context, - nodePoolID string, - adapterStatus *api.AdapterStatus, - nodePool *api.NodePool, - existingStatus *api.AdapterStatus, -) ([]api.AdapterCondition, bool, *errors.ServiceError) { - log := logger.With(ctx, logger.FieldNodePoolID, nodePoolID, logger.FieldAdapter, adapterStatus.Adapter) - return validateAndClassifyAdapterStatus(nodePool.Generation, adapterStatus, existingStatus, log) -} - -// tryHardDeleteNodePool checks whether all required adapters have reported Finalized=True at the current -// resource generation for a soft-deleted node pool and, when so, permanently removes the node pool and all -// its adapter statuses. Unlike clusters, node pools have no child resources to check. Returns true when the -// hard-delete was performed. -// -// Accepts the pre-fetched (post-upsert) adapter statuses list to avoid a redundant FindByResource -// call and to ensure the just-upserted status is visible to allAdaptersFinalized. -func (s *sqlNodePoolService) tryHardDeleteNodePool( - ctx context.Context, nodePool *api.NodePool, conditions []api.AdapterCondition, - allStatuses api.AdapterStatusList, -) (bool, *errors.ServiceError) { - if !incomingReportedFinalized(conditions) { - return false, nil - } - - if !allAdaptersFinalized(s.adapterConfig.Required.Nodepool, allStatuses, nodePool.Generation) { - return false, nil - } - - if err := s.adapterStatusDao.DeleteByResource(ctx, api.ResourceTypeNodePool, nodePool.ID); err != nil { - return false, errors.GeneralError("Failed to delete adapter statuses during hard-delete: %s", err) - } - if err := s.nodePoolDao.Delete(ctx, nodePool.ID); err != nil { - return false, errors.GeneralError("Failed to hard-delete nodepool: %s", err) - } - - logger.With(ctx, logger.FieldNodePoolID, nodePool.ID). - Info("Hard-deleted nodepool after all required adapters reported Finalized=True") - - return true, nil -} diff --git a/pkg/services/node_pool_test.go b/pkg/services/node_pool_test.go deleted file mode 100644 index e417fa48..00000000 --- a/pkg/services/node_pool_test.go +++ /dev/null @@ -1,1399 +0,0 @@ -package services - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "testing" - "time" - - . "github.com/onsi/gomega" - "gorm.io/gorm" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" -) - -const ( - testNodePoolID = "test-nodepool-id" -) - -// testNodePoolAdapterConfig creates a test adapter config with default values -func testNodePoolAdapterConfig() *config.AdapterRequirementsConfig { - return &config.AdapterRequirementsConfig{ - Required: config.RequiredAdapters{ - Cluster: []string{"validation", "dns", "pullsecret", "hypershift"}, - Nodepool: []string{"validation", "hypershift"}, - }, - } -} - -// Mock implementations for testing NodePool ProcessAdapterStatus - -type mockNodePoolDao struct { - nodePools map[string]*api.NodePool - deleteErr error - findByOwnerErr error -} - -func newMockNodePoolDao() *mockNodePoolDao { - return &mockNodePoolDao{ - nodePools: make(map[string]*api.NodePool), - } -} - -func (d *mockNodePoolDao) Get(ctx context.Context, id string) (*api.NodePool, error) { - if np, ok := d.nodePools[id]; ok { - return np, nil - } - return nil, gorm.ErrRecordNotFound -} - -func (d *mockNodePoolDao) GetByIDAndOwner(ctx context.Context, id string, ownerID string) (*api.NodePool, error) { - np, err := d.Get(ctx, id) - if err != nil { - return nil, err - } - if np.OwnerID != ownerID { - return nil, gorm.ErrRecordNotFound - } - return np, nil -} - -func (d *mockNodePoolDao) GetForUpdate(ctx context.Context, id string) (*api.NodePool, error) { - return d.Get(ctx, id) -} - -func (d *mockNodePoolDao) SaveStatusConditions(ctx context.Context, id string, statusConditions []byte) error { - if np, ok := d.nodePools[id]; ok { - np.StatusConditions = statusConditions - return nil - } - return gorm.ErrRecordNotFound -} - -func (d *mockNodePoolDao) Create(ctx context.Context, nodePool *api.NodePool) (*api.NodePool, error) { - if nodePool.CreatedTime.IsZero() { - now := time.Now() - nodePool.CreatedTime = now - } - if nodePool.UpdatedTime.IsZero() { - nodePool.UpdatedTime = nodePool.CreatedTime - } - d.nodePools[nodePool.ID] = nodePool - return nodePool, nil -} - -func (d *mockNodePoolDao) Save(ctx context.Context, nodePool *api.NodePool) error { - d.nodePools[nodePool.ID] = nodePool - return nil -} - -func (d *mockNodePoolDao) Delete(ctx context.Context, id string) error { - if d.deleteErr != nil { - return d.deleteErr - } - delete(d.nodePools, id) - return nil -} - -func (d *mockNodePoolDao) FindByIDs(ctx context.Context, ids []string) (api.NodePoolList, error) { - var result api.NodePoolList - for _, id := range ids { - if np, ok := d.nodePools[id]; ok { - result = append(result, np) - } - } - return result, nil -} - -func (d *mockNodePoolDao) FindByOwner(ctx context.Context, ownerID string) (api.NodePoolList, error) { - if d.findByOwnerErr != nil { - return nil, d.findByOwnerErr - } - var result api.NodePoolList - for _, np := range d.nodePools { - if np.OwnerID == ownerID { - result = append(result, np) - } - } - return result, nil -} - -func (d *mockNodePoolDao) SaveAll(ctx context.Context, nodePools api.NodePoolList) error { - for _, np := range nodePools { - d.nodePools[np.ID] = np - } - return nil -} - -func (d *mockNodePoolDao) ExistsByOwner(ctx context.Context, ownerID string) (bool, error) { - for _, np := range d.nodePools { - if np.OwnerID == ownerID { - return true, nil - } - } - return false, nil -} - -func (d *mockNodePoolDao) All(ctx context.Context) (api.NodePoolList, error) { - var result api.NodePoolList - for _, np := range d.nodePools { - result = append(result, np) - } - return result, nil -} - -var _ dao.NodePoolDao = &mockNodePoolDao{} - -type mockGenericService struct { - err *errors.ServiceError - nodePools []api.NodePool -} - -func (m *mockGenericService) List( - _ context.Context, _ *ListArguments, resourceList interface{}, -) (*api.PagingMeta, *errors.ServiceError) { - if m.err != nil { - return nil, m.err - } - target := resourceList.(*[]api.NodePool) - *target = m.nodePools - return &api.PagingMeta{Page: 1, Size: int64(len(m.nodePools)), Total: int64(len(m.nodePools))}, nil -} - -var _ GenericService = &mockGenericService{} - -// TestNodePoolProcessAdapterStatus_FirstUnknownCondition tests that the first Unknown Available condition is stored -func TestNodePoolProcessAdapterStatus_FirstUnknownCondition(t *testing.T) { - t.Parallel() - g := NewWithT(t) - - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - - config := testNodePoolAdapterConfig() - service := NewNodePoolService(nodePoolDao, nil, adapterStatusDao, config, nil) - - ctx := context.Background() - nodePoolID := testNodePoolID - - now := time.Now() - nodePoolDao.nodePools[nodePoolID] = &api.NodePool{ - Meta: api.Meta{ID: nodePoolID, CreatedTime: now, UpdatedTime: now}, - Generation: 1, - } - - // Create first adapter status with all mandatory conditions but Available=Unknown - conditions := []api.AdapterCondition{ - { - Type: api.AdapterConditionTypeAvailable, - Status: api.AdapterConditionUnknown, - LastTransitionTime: time.Now(), - }, - { - Type: api.AdapterConditionTypeApplied, - Status: api.AdapterConditionTrue, - LastTransitionTime: time.Now(), - }, - { - Type: api.AdapterConditionTypeHealth, - Status: api.AdapterConditionTrue, - LastTransitionTime: time.Now(), - }, - } - conditionsJSON, _ := json.Marshal(conditions) - - adapterStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeNodePool, - ResourceID: nodePoolID, - Adapter: "test-adapter", - Conditions: conditionsJSON, - ObservedGeneration: 1, - CreatedTime: now, - LastReportTime: now, - } - - result, err := service.ProcessAdapterStatus(ctx, nodePoolID, adapterStatus) - - g.Expect(err).To(BeNil()) - g.Expect(result).ToNot(BeNil(), "First report with Available=Unknown should be accepted") - g.Expect(result.Adapter).To(Equal("test-adapter")) - - // Verify the status was stored - storedStatuses, _ := adapterStatusDao.FindByResource(ctx, api.ResourceTypeNodePool, nodePoolID) - g.Expect(len(storedStatuses)).To(Equal(1), "First Unknown status should be stored") -} - -// TestNodePoolProcessAdapterStatus_SubsequentUnknownCondition tests that subsequent Unknown conditions are discarded -func TestNodePoolProcessAdapterStatus_SubsequentUnknownCondition(t *testing.T) { - t.Parallel() - g := NewWithT(t) - - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - - config := testNodePoolAdapterConfig() - service := NewNodePoolService(nodePoolDao, nil, adapterStatusDao, config, nil) - - ctx := context.Background() - nodePoolID := testNodePoolID - - now := time.Now() - nodePoolDao.nodePools[nodePoolID] = &api.NodePool{ - Meta: api.Meta{ID: nodePoolID, CreatedTime: now, UpdatedTime: now}, - Generation: 1, - } - - // Pre-populate an existing adapter status - conditions := []api.AdapterCondition{ - {Type: api.AdapterConditionTypeAvailable, Status: api.AdapterConditionUnknown, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - } - conditionsJSON, _ := json.Marshal(conditions) - - existingStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeNodePool, - ResourceID: nodePoolID, - Adapter: "test-adapter", - Conditions: conditionsJSON, - ObservedGeneration: 1, - CreatedTime: now, - LastReportTime: now, - } - _, _ = adapterStatusDao.Upsert(ctx, existingStatus, nil) - - // Now send another Unknown status report - newAdapterStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeNodePool, - ResourceID: nodePoolID, - Adapter: "test-adapter", - Conditions: conditionsJSON, - ObservedGeneration: 1, - CreatedTime: now, - LastReportTime: now, - } - - result, err := service.ProcessAdapterStatus(ctx, nodePoolID, newAdapterStatus) - - g.Expect(err).To(BeNil()) - g.Expect(result).To(BeNil(), "Subsequent Unknown status should be discarded") -} - -// TestNodePoolProcessAdapterStatus_InvalidStatusReturnsValidationError tests that a non-True/False/Unknown -// Available status is rejected with a validation error. -func TestNodePoolProcessAdapterStatus_InvalidStatusReturnsValidationError(t *testing.T) { - t.Parallel() - g := NewWithT(t) - - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - config := testNodePoolAdapterConfig() - service := NewNodePoolService(nodePoolDao, nil, adapterStatusDao, config, nil) - - ctx := context.Background() - nodePoolID := testNodePoolID - - now := time.Now() - nodePoolDao.nodePools[nodePoolID] = &api.NodePool{ - Meta: api.Meta{ID: nodePoolID, CreatedTime: now, UpdatedTime: now}, - Generation: 1, - } - - conditions := []api.AdapterCondition{ - {Type: api.AdapterConditionTypeAvailable, Status: "Pending", LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - } - conditionsJSON, _ := json.Marshal(conditions) - adapterStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeNodePool, - ResourceID: nodePoolID, - Adapter: "test-adapter", - Conditions: conditionsJSON, - ObservedGeneration: 1, - CreatedTime: now, - LastReportTime: now, - } - - result, err := service.ProcessAdapterStatus(ctx, nodePoolID, adapterStatus) - - g.Expect(err).ToNot(BeNil(), "Invalid status should return a validation error") - g.Expect(err.HTTPCode).To(Equal(http.StatusBadRequest)) - g.Expect(result).To(BeNil()) -} - -// TestNodePoolProcessAdapterStatus_EmptyStatusReturnsValidationError tests that an empty Available -// status is rejected with a validation error. -func TestNodePoolProcessAdapterStatus_EmptyStatusReturnsValidationError(t *testing.T) { - t.Parallel() - g := NewWithT(t) - - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - config := testNodePoolAdapterConfig() - service := NewNodePoolService(nodePoolDao, nil, adapterStatusDao, config, nil) - - ctx := context.Background() - nodePoolID := testNodePoolID - - now := time.Now() - nodePoolDao.nodePools[nodePoolID] = &api.NodePool{ - Meta: api.Meta{ID: nodePoolID, CreatedTime: now, UpdatedTime: now}, - Generation: 1, - } - - conditions := []api.AdapterCondition{ - {Type: api.AdapterConditionTypeAvailable, Status: "", LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - } - conditionsJSON, _ := json.Marshal(conditions) - adapterStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeNodePool, - ResourceID: nodePoolID, - Adapter: "test-adapter", - Conditions: conditionsJSON, - ObservedGeneration: 1, - CreatedTime: now, - LastReportTime: now, - } - - result, err := service.ProcessAdapterStatus(ctx, nodePoolID, adapterStatus) - - g.Expect(err).ToNot(BeNil(), "Empty status should return a validation error") - g.Expect(err.HTTPCode).To(Equal(http.StatusBadRequest)) - g.Expect(result).To(BeNil()) -} - -// TestNodePoolProcessAdapterStatus_TrueCondition tests that True Available condition upserts and aggregates -func TestNodePoolProcessAdapterStatus_TrueCondition(t *testing.T) { - t.Parallel() - g := NewWithT(t) - - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - - config := testNodePoolAdapterConfig() - service := NewNodePoolService(nodePoolDao, nil, adapterStatusDao, config, nil) - - ctx := context.Background() - nodePoolID := testNodePoolID - - // Create the nodepool first - nodePool := &api.NodePool{ - Generation: 1, - } - nodePool.ID = nodePoolID - _, svcErr := service.Create(ctx, nodePool) - g.Expect(svcErr).To(BeNil()) - - // Create adapter status with all mandatory conditions - conditions := []api.AdapterCondition{ - { - Type: api.AdapterConditionTypeAvailable, - Status: api.AdapterConditionTrue, - LastTransitionTime: time.Now(), - }, - { - Type: api.AdapterConditionTypeApplied, - Status: api.AdapterConditionTrue, - LastTransitionTime: time.Now(), - }, - { - Type: api.AdapterConditionTypeHealth, - Status: api.AdapterConditionTrue, - LastTransitionTime: time.Now(), - }, - } - conditionsJSON, _ := json.Marshal(conditions) - - now := time.Now() - adapterStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeNodePool, - ResourceID: nodePoolID, - Adapter: "test-adapter", - Conditions: conditionsJSON, - CreatedTime: now, - LastReportTime: now, - } - - result, err := service.ProcessAdapterStatus(ctx, nodePoolID, adapterStatus) - - g.Expect(err).To(BeNil()) - g.Expect(result).ToNot(BeNil(), "ProcessAdapterStatus should return the upserted status") - g.Expect(result.Adapter).To(Equal("test-adapter")) - - // Verify the status was stored - storedStatuses, _ := adapterStatusDao.FindByResource(ctx, api.ResourceTypeNodePool, nodePoolID) - g.Expect(len(storedStatuses)).To(Equal(1), "Status should be stored for True condition") -} - -// TestNodePoolProcessAdapterStatus_FirstMultipleConditions_AvailableUnknown tests that first reports -// with Available=Unknown are accepted even when other conditions are present -func TestNodePoolProcessAdapterStatus_FirstMultipleConditions_AvailableUnknown(t *testing.T) { - t.Parallel() - g := NewWithT(t) - - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - - config := testNodePoolAdapterConfig() - service := NewNodePoolService(nodePoolDao, nil, adapterStatusDao, config, nil) - - ctx := context.Background() - nodePoolID := testNodePoolID - - now := time.Now() - nodePoolDao.nodePools[nodePoolID] = &api.NodePool{ - Meta: api.Meta{ID: nodePoolID, CreatedTime: now, UpdatedTime: now}, - Generation: 1, - } - - // Create first adapter status with all mandatory conditions but Available=Unknown - conditions := []api.AdapterCondition{ - { - Type: api.AdapterConditionTypeAvailable, - Status: api.AdapterConditionUnknown, - LastTransitionTime: time.Now(), - }, - { - Type: api.AdapterConditionTypeApplied, - Status: api.AdapterConditionTrue, - LastTransitionTime: time.Now(), - }, - { - Type: api.AdapterConditionTypeHealth, - Status: api.AdapterConditionTrue, - LastTransitionTime: time.Now(), - }, - { - Type: api.AdapterConditionTypeReconciled, - Status: api.AdapterConditionTrue, - LastTransitionTime: time.Now(), - }, - } - conditionsJSON, _ := json.Marshal(conditions) - - adapterStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeNodePool, - ResourceID: nodePoolID, - Adapter: "test-adapter", - Conditions: conditionsJSON, - ObservedGeneration: 1, - CreatedTime: now, - LastReportTime: now, - } - - result, err := service.ProcessAdapterStatus(ctx, nodePoolID, adapterStatus) - - g.Expect(err).To(BeNil()) - g.Expect(result).ToNot(BeNil(), "First report with Available=Unknown should be accepted") - - // Verify the status was stored - storedStatuses, _ := adapterStatusDao.FindByResource(ctx, api.ResourceTypeNodePool, nodePoolID) - g.Expect(len(storedStatuses)).To(Equal(1), "First status with Available=Unknown should be stored") -} - -// TestNodePoolProcessAdapterStatus_SubsequentMultipleConditions_AvailableUnknown tests that subsequent -// reports with multiple conditions including Available=Unknown are discarded -func TestNodePoolProcessAdapterStatus_SubsequentMultipleConditions_AvailableUnknown(t *testing.T) { - t.Parallel() - g := NewWithT(t) - - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - - config := testNodePoolAdapterConfig() - service := NewNodePoolService(nodePoolDao, nil, adapterStatusDao, config, nil) - - ctx := context.Background() - nodePoolID := testNodePoolID - - now := time.Now() - nodePoolDao.nodePools[nodePoolID] = &api.NodePool{ - Meta: api.Meta{ID: nodePoolID, CreatedTime: now, UpdatedTime: now}, - Generation: 1, - } - - // Pre-populate an existing adapter status - existingConditions := []api.AdapterCondition{ - {Type: api.AdapterConditionTypeAvailable, Status: api.AdapterConditionUnknown, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - } - existingConditionsJSON, _ := json.Marshal(existingConditions) - - existingStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeNodePool, - ResourceID: nodePoolID, - Adapter: "test-adapter", - Conditions: existingConditionsJSON, - ObservedGeneration: 1, - CreatedTime: now, - LastReportTime: now, - } - _, _ = adapterStatusDao.Upsert(ctx, existingStatus, nil) - - // Now send another report with multiple conditions including Available=Unknown - conditions := []api.AdapterCondition{ - {Type: api.AdapterConditionTypeAvailable, Status: api.AdapterConditionUnknown, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeReconciled, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: "Progressing", Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - } - conditionsJSON, _ := json.Marshal(conditions) - - adapterStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeNodePool, - ResourceID: nodePoolID, - Adapter: "test-adapter", - Conditions: conditionsJSON, - ObservedGeneration: 1, - CreatedTime: now, - LastReportTime: now, - } - - result, err := service.ProcessAdapterStatus(ctx, nodePoolID, adapterStatus) - - g.Expect(err).To(BeNil()) - g.Expect(result).To(BeNil(), "Subsequent Available=Unknown should be discarded") -} - -func TestNodePoolAvailableReconciledTransitions(t *testing.T) { - t.Parallel() - g := NewWithT(t) - - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - - adapterConfig := testNodePoolAdapterConfig() - adapterConfig.Required.Nodepool = []string{"validation", "hypershift"} - - service := NewNodePoolService(nodePoolDao, nil, adapterStatusDao, adapterConfig, nil) - - ctx := context.Background() - nodePoolID := testNodePoolID - - nodePool := &api.NodePool{Generation: 1} - nodePool.ID = nodePoolID - _, svcErr := service.Create(ctx, nodePool) - g.Expect(svcErr).To(BeNil()) - - getSynth := func() (api.ResourceCondition, api.ResourceCondition) { - stored, getErr := nodePoolDao.Get(ctx, nodePoolID) - g.Expect(getErr).To(BeNil()) - - var conds []api.ResourceCondition - g.Expect(json.Unmarshal(stored.StatusConditions, &conds)).To(Succeed()) - g.Expect(len(conds)).To(BeNumerically(">=", 2)) - - var available, reconciled *api.ResourceCondition - for i := range conds { - switch conds[i].Type { - case api.ResourceConditionTypeLastKnownReconciled: - available = &conds[i] - case api.ResourceConditionTypeReconciled: - reconciled = &conds[i] - } - } - g.Expect(reconciled).ToNot(BeNil()) - g.Expect(available).ToNot(BeNil()) - return *available, *reconciled - } - - upsert := func(adapter string, available api.AdapterConditionStatus, observedGen int32) { - conditions := []api.AdapterCondition{ - {Type: api.AdapterConditionTypeAvailable, Status: available, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - } - conditionsJSON, _ := json.Marshal(conditions) - now := time.Now() - - adapterStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeNodePool, - ResourceID: nodePoolID, - Adapter: adapter, - ObservedGeneration: observedGen, - Conditions: conditionsJSON, - CreatedTime: now, - LastReportTime: now, - } - - _, err := service.ProcessAdapterStatus(ctx, nodePoolID, adapterStatus) - g.Expect(err).To(BeNil()) - } - - // No adapter statuses yet. - _, err := service.UpdateNodePoolStatusFromAdapters(ctx, nodePoolID) - g.Expect(err).To(BeNil()) - avail, reconciled := getSynth() - g.Expect(avail.Status).To(Equal(api.ConditionFalse)) - g.Expect(avail.ObservedGeneration).To(Equal(int32(1))) - g.Expect(reconciled.Status).To(Equal(api.ConditionFalse)) - g.Expect(reconciled.ObservedGeneration).To(Equal(int32(1))) - - // Partial adapters: still not Available/Reconciled. - upsert("validation", api.AdapterConditionTrue, 1) - avail, reconciled = getSynth() - g.Expect(avail.Status).To(Equal(api.ConditionFalse)) - g.Expect(reconciled.Status).To(Equal(api.ConditionFalse)) - - // All required adapters available at gen=1 => Available=True, Reconciled=True. - upsert("hypershift", api.AdapterConditionTrue, 1) - avail, reconciled = getSynth() - g.Expect(avail.Status).To(Equal(api.ConditionTrue)) - g.Expect(avail.ObservedGeneration).To(Equal(int32(1))) - g.Expect(reconciled.Status).To(Equal(api.ConditionTrue)) - - // Bump resource generation => Reconciled flips to False; Available remains True. - nodePoolDao.nodePools[nodePoolID].Generation = 2 - _, err = service.UpdateNodePoolStatusFromAdapters(ctx, nodePoolID) - g.Expect(err).To(BeNil()) - avail, reconciled = getSynth() - g.Expect(avail.Status).To(Equal(api.ConditionTrue)) - g.Expect(avail.ObservedGeneration).To(Equal(int32(1))) - g.Expect(reconciled.Status).To(Equal(api.ConditionFalse)) - g.Expect(reconciled.ObservedGeneration).To(Equal(int32(2))) - - // One adapter updates to gen=2 => Reconciled still False; Available still True (minObservedGeneration still 1). - upsert("validation", api.AdapterConditionTrue, 2) - avail, reconciled = getSynth() - g.Expect(avail.Status).To(Equal(api.ConditionTrue)) - g.Expect(avail.ObservedGeneration).To(Equal(int32(1))) - g.Expect(reconciled.Status).To(Equal(api.ConditionFalse)) - - // All required adapters at gen=2 => Reconciled becomes True, Available minObservedGeneration becomes 2. - upsert("hypershift", api.AdapterConditionTrue, 2) - avail, reconciled = getSynth() - g.Expect(avail.Status).To(Equal(api.ConditionTrue)) - g.Expect(avail.ObservedGeneration).To(Equal(int32(2))) - g.Expect(reconciled.Status).To(Equal(api.ConditionTrue)) - - // One required adapter goes False => both Available and Reconciled become False. - upsert("hypershift", api.AdapterConditionFalse, 2) - avail, reconciled = getSynth() - g.Expect(avail.Status).To(Equal(api.ConditionFalse)) - g.Expect(avail.ObservedGeneration).To(Equal(int32(2))) - g.Expect(reconciled.Status).To(Equal(api.ConditionFalse)) - - // Adapter status missing mandatory conditions should be rejected and not overwrite synthetic conditions. - prevStatus := api.NodePool{}.StatusConditions - prevStatus = append(prevStatus, nodePoolDao.nodePools[nodePoolID].StatusConditions...) - nonAvailableConds := []api.AdapterCondition{ - {Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - } - nonAvailableJSON, _ := json.Marshal(nonAvailableConds) - naNow := time.Now() - nonAvailableStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeNodePool, - ResourceID: nodePoolID, - Adapter: "hypershift", - ObservedGeneration: 2, - Conditions: nonAvailableJSON, - CreatedTime: naNow, - LastReportTime: naNow, - } - result, svcErr := service.ProcessAdapterStatus(ctx, nodePoolID, nonAvailableStatus) - g.Expect(svcErr).ToNot(BeNil()) - g.Expect(svcErr.HTTPCode).To(Equal(http.StatusBadRequest)) - g.Expect(svcErr.Reason).To(ContainSubstring("missing mandatory condition")) - g.Expect(result).To(BeNil(), "Update missing mandatory conditions should be rejected") - g.Expect(nodePoolDao.nodePools[nodePoolID].StatusConditions).To(Equal(prevStatus)) - - // Available=Unknown is a no-op (does not store, does not overwrite nodepool conditions). - prevStatus = api.NodePool{}.StatusConditions - prevStatus = append(prevStatus, nodePoolDao.nodePools[nodePoolID].StatusConditions...) - unknownConds := []api.AdapterCondition{ - {Type: api.AdapterConditionTypeAvailable, Status: api.AdapterConditionUnknown, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - } - unknownJSON, _ := json.Marshal(unknownConds) - unknownNow := time.Now() - unknownStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeNodePool, - ResourceID: nodePoolID, - Adapter: "hypershift", - Conditions: unknownJSON, - ObservedGeneration: 2, - CreatedTime: unknownNow, - LastReportTime: unknownNow, - } - result, svcErr = service.ProcessAdapterStatus(ctx, nodePoolID, unknownStatus) - g.Expect(svcErr).To(BeNil()) - g.Expect(result).To(BeNil()) - g.Expect(nodePoolDao.nodePools[nodePoolID].StatusConditions).To(Equal(prevStatus)) -} - -func TestNodePoolStaleAdapterStatusUpdatePolicy(t *testing.T) { - t.Parallel() - g := NewWithT(t) - - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - - adapterConfig := testNodePoolAdapterConfig() - adapterConfig.Required.Nodepool = []string{"validation", "hypershift"} - - service := NewNodePoolService(nodePoolDao, nil, adapterStatusDao, adapterConfig, nil) - - ctx := context.Background() - nodePoolID := testNodePoolID - - nodePool := &api.NodePool{Generation: 2} - nodePool.ID = nodePoolID - _, svcErr := service.Create(ctx, nodePool) - g.Expect(svcErr).To(BeNil()) - - getAvailable := func() api.ResourceCondition { - stored, getErr := nodePoolDao.Get(ctx, nodePoolID) - g.Expect(getErr).To(BeNil()) - - var conds []api.ResourceCondition - g.Expect(json.Unmarshal(stored.StatusConditions, &conds)).To(Succeed()) - for i := range conds { - if conds[i].Type == api.ResourceConditionTypeLastKnownReconciled { - return conds[i] - } - } - g.Expect(true).To(BeFalse(), "Available condition not found") - return api.ResourceCondition{} - } - - upsert := func(adapter string, available api.AdapterConditionStatus, observedGen int32) { - conditions := []api.AdapterCondition{ - {Type: api.AdapterConditionTypeAvailable, Status: available, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - } - conditionsJSON, _ := json.Marshal(conditions) - now := time.Now() - - adapterStatus := &api.AdapterStatus{ - ResourceType: api.ResourceTypeNodePool, - ResourceID: nodePoolID, - Adapter: adapter, - ObservedGeneration: observedGen, - Conditions: conditionsJSON, - CreatedTime: now, - LastReportTime: now, - } - - _, err := service.ProcessAdapterStatus(ctx, nodePoolID, adapterStatus) - g.Expect(err).To(BeNil()) - } - - // Current generation statuses => Available=True at observed_generation=2. - upsert("validation", api.AdapterConditionTrue, 2) - upsert("hypershift", api.AdapterConditionTrue, 2) - available := getAvailable() - g.Expect(available.Status).To(Equal(api.ConditionTrue)) - g.Expect(available.ObservedGeneration).To(Equal(int32(2))) - - // Stale True should not override newer True. - upsert("validation", api.AdapterConditionTrue, 1) - available = getAvailable() - g.Expect(available.Status).To(Equal(api.ConditionTrue)) - g.Expect(available.ObservedGeneration).To(Equal(int32(2))) - - // Stale False is more restrictive and should override but we do not override newer generation responses - upsert("validation", api.AdapterConditionFalse, 1) - available = getAvailable() - g.Expect(available.Status).To(Equal(api.ConditionTrue)) - g.Expect(available.ObservedGeneration).To(Equal(int32(2))) -} - -func TestNodePoolSyntheticTimestampsStableWithoutAdapterStatus(t *testing.T) { - t.Parallel() - g := NewWithT(t) - - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - - adapterConfig := testNodePoolAdapterConfig() - adapterConfig.Required.Nodepool = []string{"validation"} - - service := NewNodePoolService(nodePoolDao, nil, adapterStatusDao, adapterConfig, nil) - - ctx := context.Background() - nodePoolID := testNodePoolID - - fixedNow := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) - initialConditions := []api.ResourceCondition{ - { - Type: api.ResourceConditionTypeLastKnownReconciled, - Status: api.ConditionFalse, - ObservedGeneration: 1, - LastTransitionTime: fixedNow, - CreatedTime: fixedNow, - LastUpdatedTime: fixedNow, - }, - } - initialConditionsJSON, _ := json.Marshal(initialConditions) - - nodePool := &api.NodePool{ - Generation: 1, - StatusConditions: initialConditionsJSON, - } - nodePool.ID = nodePoolID - nodePool.CreatedTime = fixedNow - nodePool.UpdatedTime = fixedNow - created, svcErr := service.Create(ctx, nodePool) - g.Expect(svcErr).To(BeNil()) - - var createdConds []api.ResourceCondition - g.Expect(json.Unmarshal(created.StatusConditions, &createdConds)).To(Succeed()) - g.Expect(len(createdConds)).To(BeNumerically(">=", 2)) - - var createdAvailable, createdReconciled *api.ResourceCondition - for i := range createdConds { - switch createdConds[i].Type { - case api.ResourceConditionTypeLastKnownReconciled: - createdAvailable = &createdConds[i] - case api.ResourceConditionTypeReconciled: - createdReconciled = &createdConds[i] - } - } - g.Expect(createdAvailable).ToNot(BeNil()) - g.Expect(createdReconciled).ToNot(BeNil()) - g.Expect(createdAvailable.CreatedTime).To(Equal(fixedNow)) - g.Expect(createdAvailable.LastTransitionTime).To(Equal(fixedNow)) - g.Expect(createdAvailable.LastUpdatedTime).To(Equal(fixedNow)) - g.Expect(createdReconciled.CreatedTime).To(Equal(fixedNow)) - g.Expect(createdReconciled.LastTransitionTime).To(Equal(fixedNow)) - g.Expect(createdReconciled.LastUpdatedTime).To(Equal(fixedNow)) - - updated, err := service.UpdateNodePoolStatusFromAdapters(ctx, nodePoolID) - g.Expect(err).To(BeNil()) - - var updatedConds []api.ResourceCondition - g.Expect(json.Unmarshal(updated.StatusConditions, &updatedConds)).To(Succeed()) - g.Expect(len(updatedConds)).To(BeNumerically(">=", 2)) - - var updatedAvailable, updatedReconciled *api.ResourceCondition - for i := range updatedConds { - switch updatedConds[i].Type { - case api.ResourceConditionTypeLastKnownReconciled: - updatedAvailable = &updatedConds[i] - case api.ResourceConditionTypeReconciled: - updatedReconciled = &updatedConds[i] - } - } - g.Expect(updatedAvailable).ToNot(BeNil()) - g.Expect(updatedReconciled).ToNot(BeNil()) - g.Expect(updatedAvailable.CreatedTime).To(Equal(fixedNow)) - g.Expect(updatedAvailable.LastTransitionTime).To(Equal(fixedNow)) - g.Expect(updatedAvailable.LastUpdatedTime).To(Equal(fixedNow)) - g.Expect(updatedReconciled.CreatedTime).To(Equal(fixedNow)) - g.Expect(updatedReconciled.LastTransitionTime).To(Equal(fixedNow)) - g.Expect(updatedReconciled.LastUpdatedTime).To(Equal(fixedNow)) -} - -func TestNodePoolSoftDelete(t *testing.T) { - t.Run("given a live nodepool, when soft-deleted, then deleted_time/deleted_by/generation are set", func(t *testing.T) { - g := NewWithT(t) - // Given: - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - service := NewNodePoolService(nodePoolDao, nil, adapterStatusDao, testNodePoolAdapterConfig(), nil) - ctx := context.Background() - nodePoolID := testNodePoolID - nodePoolDao.nodePools[nodePoolID] = &api.NodePool{ - Meta: api.Meta{ID: nodePoolID}, - Generation: 1, - } - // When: - nodePool, svcErr := service.SoftDelete(ctx, nodePoolID) - // Then: - g.Expect(svcErr).To(BeNil()) - g.Expect(nodePool.DeletedTime).NotTo(BeNil()) - g.Expect(nodePool.DeletedBy).NotTo(BeNil()) - g.Expect(*nodePool.DeletedBy).To(Equal(systemActor)) - g.Expect(nodePool.Generation).To(Equal(int32(2))) - }) - - t.Run("given an already-deleted nodepool, when soft-deleted again, then deleted_time and generation are unchanged", func(t *testing.T) { //nolint:lll - g := NewWithT(t) - // Given: - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - service := NewNodePoolService(nodePoolDao, nil, adapterStatusDao, testNodePoolAdapterConfig(), nil) - ctx := context.Background() - nodePoolID := testNodePoolID - originalTime := time.Now().Add(-time.Hour) - nodePoolDao.nodePools[nodePoolID] = &api.NodePool{ - Meta: api.Meta{ID: nodePoolID}, - DeletedTime: &originalTime, - Generation: 3, - } - // When: - nodePool, svcErr := service.SoftDelete(ctx, nodePoolID) - // Then: - g.Expect(svcErr).To(BeNil()) - g.Expect(nodePool.DeletedTime.Equal(originalTime)).To(BeTrue()) - g.Expect(nodePool.Generation).To(Equal(int32(3))) - }) - - t.Run("given a non-existent nodepool ID, when soft-deleted, then returns 404 service error", func(t *testing.T) { - g := NewWithT(t) - // Given: - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - service := NewNodePoolService(nodePoolDao, nil, adapterStatusDao, testNodePoolAdapterConfig(), nil) - ctx := context.Background() - // When: - _, svcErr := service.SoftDelete(ctx, "nonexistent") - // Then: - g.Expect(svcErr).NotTo(BeNil()) - g.Expect(svcErr.HTTPCode).To(Equal(404)) - }) - - t.Run("given a nodepool with Reconciled=True, when soft-deleted, then Reconciled flips to False due to generation bump", func(t *testing.T) { //nolint:lll - g := NewWithT(t) - // Given: - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - adapterConfig := testNodePoolAdapterConfig() - adapterConfig.Required.Nodepool = []string{"validation"} - service := NewNodePoolService(nodePoolDao, nil, adapterStatusDao, adapterConfig, nil) - ctx := context.Background() - nodePoolID := "reconciled-nodepool" - - nodePoolDao.nodePools[nodePoolID] = &api.NodePool{Meta: api.Meta{ID: nodePoolID}, Generation: 1} - conditions := []api.AdapterCondition{ - {Type: api.AdapterConditionTypeAvailable, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeApplied, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - {Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - } - condJSON, _ := json.Marshal(conditions) - now := time.Now() - _, svcErr := service.ProcessAdapterStatus(ctx, nodePoolID, &api.AdapterStatus{ - ResourceType: api.ResourceTypeNodePool, ResourceID: nodePoolID, Adapter: "validation", - ObservedGeneration: 1, Conditions: condJSON, CreatedTime: now, LastReportTime: now, - }) - g.Expect(svcErr).To(BeNil()) - - // Pre-condition: Reconciled=True before soft-delete - stored, _ := nodePoolDao.Get(ctx, nodePoolID) - var preConds []api.ResourceCondition - g.Expect(json.Unmarshal(stored.StatusConditions, &preConds)).To(Succeed()) - var preReconciled *api.ResourceCondition - for i := range preConds { - if preConds[i].Type == api.ResourceConditionTypeReconciled { - preReconciled = &preConds[i] - } - } - g.Expect(preReconciled).NotTo(BeNil()) - g.Expect(preReconciled.Status).To(Equal(api.ConditionTrue)) - - // When: - _, svcErr = service.SoftDelete(ctx, nodePoolID) - g.Expect(svcErr).To(BeNil()) - - // Then: generation bumped to 2, Reconciled must flip to False - stored, _ = nodePoolDao.Get(ctx, nodePoolID) - g.Expect(stored.Generation).To(Equal(int32(2))) - var postConds []api.ResourceCondition - g.Expect(json.Unmarshal(stored.StatusConditions, &postConds)).To(Succeed()) - var postReconciled *api.ResourceCondition - for i := range postConds { - if postConds[i].Type == api.ResourceConditionTypeReconciled { - postReconciled = &postConds[i] - } - } - g.Expect(postReconciled).NotTo(BeNil()) - g.Expect(postReconciled.Status).To(Equal(api.ConditionFalse)) - g.Expect(postReconciled.ObservedGeneration).To(Equal(int32(2))) - }) -} - -type nodePoolTestEnv struct { - nodePoolDao *mockNodePoolDao - adapterStatusDao *mockAdapterStatusDao - service NodePoolService - ctx context.Context -} - -func newNodePoolTestEnv() *nodePoolTestEnv { - npDao := newMockNodePoolDao() - asDao := newMockAdapterStatusDao() - return &nodePoolTestEnv{ - nodePoolDao: npDao, - adapterStatusDao: asDao, - service: NewNodePoolService(npDao, nil, asDao, testNodePoolAdapterConfig(), nil), - ctx: context.Background(), - } -} - -func TestNodePoolForceDelete(t *testing.T) { - t.Parallel() - deletedTime := time.Now().Add(-time.Hour) - - t.Run("nodepool not found returns 404", func(t *testing.T) { - g := NewWithT(t) - env := newNodePoolTestEnv() - - svcErr := env.service.ForceDelete(env.ctx, "nonexistent", "testing") - - g.Expect(svcErr).NotTo(BeNil()) - g.Expect(svcErr.HTTPCode).To(Equal(http.StatusNotFound)) - }) - - t.Run("nodepool not in Finalizing state returns 409", func(t *testing.T) { - g := NewWithT(t) - env := newNodePoolTestEnv() - env.nodePoolDao.nodePools[testNodePoolID] = &api.NodePool{ - Meta: api.Meta{ID: testNodePoolID}, - Generation: 1, - } - - svcErr := env.service.ForceDelete(env.ctx, testNodePoolID, "testing") - - g.Expect(svcErr).NotTo(BeNil()) - g.Expect(svcErr.HTTPCode).To(Equal(http.StatusConflict)) - }) - - t.Run("soft-deleted nodepool is removed along with adapter statuses", func(t *testing.T) { - g := NewWithT(t) - env := newNodePoolTestEnv() - env.nodePoolDao.nodePools[testNodePoolID] = &api.NodePool{ - Meta: api.Meta{ID: testNodePoolID}, - Generation: 2, - DeletedTime: &deletedTime, - } - env.adapterStatusDao.statuses["status-1"] = &api.AdapterStatus{ - Meta: api.Meta{ID: "status-1"}, - ResourceType: api.ResourceTypeNodePool, - ResourceID: testNodePoolID, - Adapter: "validation", - } - - svcErr := env.service.ForceDelete(env.ctx, testNodePoolID, "stuck in finalizing") - - g.Expect(svcErr).To(BeNil()) - _, err := env.nodePoolDao.Get(env.ctx, testNodePoolID) - g.Expect(err).To(Equal(gorm.ErrRecordNotFound)) - g.Expect(env.adapterStatusDao.statuses).To(BeEmpty()) - }) - - t.Run("adapter status fetch failure returns 500", func(t *testing.T) { - g := NewWithT(t) - env := newNodePoolTestEnv() - env.adapterStatusDao.findByResourceErr = fmt.Errorf("db connection lost") - env.nodePoolDao.nodePools[testNodePoolID] = &api.NodePool{ - Meta: api.Meta{ID: testNodePoolID}, - DeletedTime: &deletedTime, - } - - svcErr := env.service.ForceDelete(env.ctx, testNodePoolID, "testing") - - g.Expect(svcErr).NotTo(BeNil()) - g.Expect(svcErr.HTTPCode).To(Equal(http.StatusInternalServerError)) - }) - - t.Run("adapter status deletion failure returns 500", func(t *testing.T) { - g := NewWithT(t) - env := newNodePoolTestEnv() - env.adapterStatusDao.deleteByResourceErr = fmt.Errorf("db connection lost") - env.nodePoolDao.nodePools[testNodePoolID] = &api.NodePool{ - Meta: api.Meta{ID: testNodePoolID}, - DeletedTime: &deletedTime, - } - - svcErr := env.service.ForceDelete(env.ctx, testNodePoolID, "testing") - - g.Expect(svcErr).NotTo(BeNil()) - g.Expect(svcErr.HTTPCode).To(Equal(http.StatusInternalServerError)) - }) - - t.Run("nodepool deletion failure returns 500", func(t *testing.T) { - g := NewWithT(t) - env := newNodePoolTestEnv() - env.nodePoolDao.deleteErr = fmt.Errorf("db connection lost") - env.nodePoolDao.nodePools[testNodePoolID] = &api.NodePool{ - Meta: api.Meta{ID: testNodePoolID}, - DeletedTime: &deletedTime, - } - - svcErr := env.service.ForceDelete(env.ctx, testNodePoolID, "testing") - - g.Expect(svcErr).NotTo(BeNil()) - g.Expect(svcErr.HTTPCode).To(Equal(http.StatusInternalServerError)) - }) -} - -func TestNodePoolPatch(t *testing.T) { - t.Parallel() - t.Run("spec changed increments generation", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - adapterConfig := testNodePoolAdapterConfig() - adapterConfig.Required.Nodepool = []string{} - service := NewNodePoolService(nodePoolDao, nil, adapterStatusDao, adapterConfig, nil) - ctx := context.Background() - - nodePoolDao.nodePools["np1"] = &api.NodePool{ - Meta: api.Meta{ID: "np1"}, - Spec: []byte(`{"old":"spec"}`), - Labels: []byte(`{}`), - Generation: 1, - } - - newSpec := map[string]interface{}{"new": "spec"} - result, svcErr := service.Patch(ctx, "np1", &api.NodePoolPatch{Spec: newSpec}) - - g.Expect(svcErr).To(BeNil()) - g.Expect(result.Generation).To(Equal(int32(2))) - }) - - t.Run("spec unchanged keeps generation", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - adapterConfig := testNodePoolAdapterConfig() - adapterConfig.Required.Nodepool = []string{} - service := NewNodePoolService(nodePoolDao, nil, adapterStatusDao, adapterConfig, nil) - ctx := context.Background() - - nodePoolDao.nodePools["np1"] = &api.NodePool{ - Meta: api.Meta{ID: "np1"}, - Spec: []byte(`{"key":"value"}`), - Labels: []byte(`{}`), - Generation: 3, - } - - sameSpec := map[string]interface{}{"key": "value"} - result, svcErr := service.Patch(ctx, "np1", &api.NodePoolPatch{Spec: sameSpec}) - - g.Expect(svcErr).To(BeNil()) - g.Expect(result.Generation).To(Equal(int32(3))) - }) - - t.Run("labels changed increments generation", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - adapterConfig := testNodePoolAdapterConfig() - adapterConfig.Required.Nodepool = []string{} - service := NewNodePoolService(nodePoolDao, nil, adapterStatusDao, adapterConfig, nil) - ctx := context.Background() - - nodePoolDao.nodePools["np1"] = &api.NodePool{ - Meta: api.Meta{ID: "np1"}, - Spec: []byte(`{}`), - Labels: []byte(`{"env":"dev"}`), - Generation: 1, - } - - newLabels := map[string]string{"env": "prod"} - result, svcErr := service.Patch(ctx, "np1", &api.NodePoolPatch{Labels: newLabels}) - - g.Expect(svcErr).To(BeNil()) - g.Expect(result.Generation).To(Equal(int32(2))) - }) - - t.Run("spec unchanged with different key order keeps generation", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - adapterConfig := testNodePoolAdapterConfig() - adapterConfig.Required.Nodepool = []string{} - service := NewNodePoolService(nodePoolDao, nil, adapterStatusDao, adapterConfig, nil) - ctx := context.Background() - - nodePoolDao.nodePools["np1"] = &api.NodePool{ - Meta: api.Meta{ID: "np1"}, - Spec: []byte(`{"z":"last","a":"first","m":"middle"}`), - Labels: []byte(`{}`), - Generation: 5, - } - - sameSpec := map[string]interface{}{"z": "last", "a": "first", "m": "middle"} - result, svcErr := service.Patch(ctx, "np1", &api.NodePoolPatch{Spec: sameSpec}) - - g.Expect(svcErr).To(BeNil()) - g.Expect(result.Generation).To(Equal(int32(5))) - }) - - t.Run("labels unchanged with different key order keeps generation", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - adapterConfig := testNodePoolAdapterConfig() - adapterConfig.Required.Nodepool = []string{} - service := NewNodePoolService(nodePoolDao, nil, adapterStatusDao, adapterConfig, nil) - ctx := context.Background() - - nodePoolDao.nodePools["np1"] = &api.NodePool{ - Meta: api.Meta{ID: "np1"}, - Spec: []byte(`{}`), - Labels: []byte(`{"z":"zulu","a":"alpha"}`), - Generation: 4, - } - - sameLabels := map[string]string{"z": "zulu", "a": "alpha"} - result, svcErr := service.Patch(ctx, "np1", &api.NodePoolPatch{Labels: sameLabels}) - - g.Expect(svcErr).To(BeNil()) - g.Expect(result.Generation).To(Equal(int32(4))) - }) - - t.Run("not found returns 404", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - service := NewNodePoolService(nodePoolDao, nil, adapterStatusDao, testNodePoolAdapterConfig(), nil) - ctx := context.Background() - - newSpec := map[string]interface{}{"a": "b"} - _, svcErr := service.Patch(ctx, "nonexistent", &api.NodePoolPatch{Spec: newSpec}) - - g.Expect(svcErr).NotTo(BeNil()) - g.Expect(svcErr.HTTPCode).To(Equal(404)) - }) -} - -func TestGetByIDAndOwner(t *testing.T) { - t.Parallel() - t.Run("happy path returns nodepool", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - nodePoolDao := newMockNodePoolDao() - service := NewNodePoolService(nodePoolDao, nil, newMockAdapterStatusDao(), testNodePoolAdapterConfig(), nil) - ctx := context.Background() - - nodePoolDao.nodePools["np1"] = &api.NodePool{ - Meta: api.Meta{ID: "np1"}, - OwnerID: "cluster1", - } - - result, svcErr := service.GetByIDAndOwner(ctx, "np1", "cluster1") - g.Expect(svcErr).To(BeNil()) - g.Expect(result.ID).To(Equal("np1")) - }) - - t.Run("owner mismatch returns 404", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - nodePoolDao := newMockNodePoolDao() - service := NewNodePoolService(nodePoolDao, nil, newMockAdapterStatusDao(), testNodePoolAdapterConfig(), nil) - ctx := context.Background() - - nodePoolDao.nodePools["np1"] = &api.NodePool{ - Meta: api.Meta{ID: "np1"}, - OwnerID: "cluster1", - } - - _, svcErr := service.GetByIDAndOwner(ctx, "np1", "wrong-cluster") - g.Expect(svcErr).NotTo(BeNil()) - g.Expect(svcErr.HTTPCode).To(Equal(404)) - }) - - t.Run("nodepool not found returns 404", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - nodePoolDao := newMockNodePoolDao() - service := NewNodePoolService(nodePoolDao, nil, newMockAdapterStatusDao(), testNodePoolAdapterConfig(), nil) - ctx := context.Background() - - _, svcErr := service.GetByIDAndOwner(ctx, "nonexistent", "cluster1") - g.Expect(svcErr).NotTo(BeNil()) - g.Expect(svcErr.HTTPCode).To(Equal(404)) - }) -} - -func TestListByCluster(t *testing.T) { - t.Parallel() - testClusterUUID := "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11" - - t.Run("happy path returns nodepools for cluster", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - nodePoolDao := newMockNodePoolDao() - clusterDao := newMockClusterDao() - genericSvc := &mockGenericService{ - nodePools: []api.NodePool{ - {Meta: api.Meta{ID: "np1"}, OwnerID: testClusterUUID}, - {Meta: api.Meta{ID: "np2"}, OwnerID: testClusterUUID}, - }, - } - service := NewNodePoolService(nodePoolDao, - clusterDao, - newMockAdapterStatusDao(), - testNodePoolAdapterConfig(), - genericSvc, - ) - ctx := context.Background() - - clusterDao.clusters[testClusterUUID] = &api.Cluster{Meta: api.Meta{ID: testClusterUUID}} - - args := &ListArguments{Page: 1, Size: 100} - result, paging, svcErr := service.ListByCluster(ctx, testClusterUUID, args) - - g.Expect(svcErr).To(BeNil()) - g.Expect(result).To(HaveLen(2)) - g.Expect(result[0].ID).To(Equal("np1")) - g.Expect(result[1].ID).To(Equal("np2")) - g.Expect(paging.Total).To(Equal(int64(2))) - }) - - t.Run("cluster not found returns 404", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - nodePoolDao := newMockNodePoolDao() - clusterDao := newMockClusterDao() - genericSvc := &mockGenericService{} - service := NewNodePoolService(nodePoolDao, - clusterDao, - newMockAdapterStatusDao(), - testNodePoolAdapterConfig(), - genericSvc, - ) - ctx := context.Background() - - nonexistentUUID := "b1ffbc99-9c0b-4ef8-bb6d-6bb9bd380a22" - args := &ListArguments{Page: 1, Size: 100} - _, _, svcErr := service.ListByCluster(ctx, nonexistentUUID, args) - - g.Expect(svcErr).NotTo(BeNil()) - g.Expect(svcErr.HTTPCode).To(Equal(404)) - }) - - t.Run("existing search is preserved and ANDed with owner_id", func(t *testing.T) { - t.Parallel() - g := NewWithT(t) - nodePoolDao := newMockNodePoolDao() - clusterDao := newMockClusterDao() - genericSvc := &mockGenericService{} - service := NewNodePoolService(nodePoolDao, - clusterDao, - newMockAdapterStatusDao(), - testNodePoolAdapterConfig(), - genericSvc, - ) - ctx := context.Background() - - clusterDao.clusters[testClusterUUID] = &api.Cluster{Meta: api.Meta{ID: testClusterUUID}} - - args := &ListArguments{Page: 1, Size: 100, Search: "name = 'test'"} - _, _, svcErr := service.ListByCluster(ctx, testClusterUUID, args) - - g.Expect(svcErr).To(BeNil()) - g.Expect(args.Search).To(ContainSubstring("name = 'test'")) - g.Expect(args.Search).To(ContainSubstring("AND owner_id = '" + testClusterUUID + "'")) - }) -} diff --git a/pkg/services/resource.go b/pkg/services/resource.go index c2c9804e..67a2ffdf 100644 --- a/pkg/services/resource.go +++ b/pkg/services/resource.go @@ -34,12 +34,14 @@ type ResourceService interface { func NewResourceService( resourceDao dao.ResourceDao, + resourceLabelDao dao.ResourceLabelDao, adapterStatusDao dao.AdapterStatusDao, resourceConditionDao dao.ResourceConditionDao, generic GenericService, ) ResourceService { return &sqlResourceService{ resourceDao: resourceDao, + resourceLabelDao: resourceLabelDao, adapterStatusDao: adapterStatusDao, resourceConditionDao: resourceConditionDao, generic: generic, @@ -50,6 +52,7 @@ var _ ResourceService = &sqlResourceService{} type sqlResourceService struct { resourceDao dao.ResourceDao + resourceLabelDao dao.ResourceLabelDao adapterStatusDao dao.AdapterStatusDao resourceConditionDao dao.ResourceConditionDao generic GenericService @@ -67,9 +70,6 @@ func (s *sqlResourceService) Get(ctx context.Context, kind, id string) (*api.Res return resource, nil } -// Create validates name constraints from the EntityDescriptor, sets CreatedBy/UpdatedBy -// from the auth context, and persists a new resource. ID generation, timestamps, href -// computation, and generation initialisation are handled by the GORM BeforeCreate hook. func (s *sqlResourceService) Create( ctx context.Context, kind string, resource *api.Resource, ) (*api.Resource, *errors.ServiceError) { @@ -103,12 +103,16 @@ func (s *sqlResourceService) Create( if err != nil { return nil, handleCreateError(kind, err) } + + if len(resource.Labels) > 0 { + if labelErr := s.resourceLabelDao.ReplaceLabels(ctx, resource.ID, resource.Labels); labelErr != nil { + return nil, handleCreateError(kind, labelErr) + } + } + return resource, nil } -// Patch applies spec/label changes to a resource. Acquires a row-level lock via GetForUpdate -// to prevent concurrent modifications. Increments generation only when spec or labels actually -// change (compared via deep JSON equality). Rejects patches on soft-deleted resources with 409. func (s *sqlResourceService) Patch( ctx context.Context, kind, id string, patch *api.ResourcePatch, ) (*api.Resource, *errors.ServiceError) { @@ -120,37 +124,41 @@ func (s *sqlResourceService) Patch( return nil, handleGetError(kind, "id", id, err) } - // Check if resource is marked for deletion if resource.DeletedTime != nil { return nil, errors.ConflictState("%s '%s' is marked for deletion", kind, id) } - // Snapshot current values before applying the patch. Defensive copy required because - // applyResourcePatch replaces the slice reference, and we need the originals for comparison. oldSpec := append([]byte(nil), resource.Spec...) - oldLabels := append([]byte(nil), resource.Labels...) + oldLabels := resource.Labels if applyErr := applyResourcePatch(resource, patch); applyErr != nil { return nil, errors.Validation("Invalid patch data: %v", applyErr) } - if jsonBytesEqual(oldSpec, resource.Spec) && jsonBytesEqual(oldLabels, resource.Labels) { + specChanged := !jsonBytesEqual(oldSpec, resource.Spec) + labelsChanged := !labelsEqual(oldLabels, resource.Labels) + + if !specChanged && !labelsChanged { return resource, nil } resource.IncrementGeneration() - resource.UpdatedBy = actorFromContext(ctx) if saveErr := s.resourceDao.Save(ctx, resource); saveErr != nil { return nil, handleUpdateError(kind, saveErr) } + if labelsChanged { + if labelErr := s.resourceLabelDao.ReplaceLabels(ctx, resource.ID, resource.Labels); labelErr != nil { + return nil, handleUpdateError(kind, labelErr) + } + } + return resource, nil } -// Delete removes a resource and its cascade subtree. Resources with required -// adapters are soft-deleted; all others are hard-deleted. +// Resources with required adapters are soft-deleted; all others are hard-deleted. func (s *sqlResourceService) Delete(ctx context.Context, kind, id string) (*api.Resource, *errors.ServiceError) { if svcErr := validateKind(kind); svcErr != nil { return nil, svcErr @@ -282,8 +290,7 @@ func (s *sqlResourceService) checkCanDelete( return nil } -// GetByOwner returns a single child resource, validated as belonging to the specified owner. -// Returns 404 if the resource doesn't exist or belongs to a different owner. +// GetByOwner returns a single child resource scoped to the specified owner. Returns 404 if not found. func (s *sqlResourceService) GetByOwner( ctx context.Context, kind, id, ownerID string, ) (*api.Resource, *errors.ServiceError) { @@ -308,6 +315,7 @@ func (s *sqlResourceService) List( args = &ListArguments{Page: 1, Size: 20} } scopedArgs := *args + scopedArgs.Preloads = append(append([]string(nil), scopedArgs.Preloads...), "Labels", "Conditions") kindFilter := fmt.Sprintf("kind = '%s'", kind) if scopedArgs.Search == "" { scopedArgs.Search = kindFilter @@ -324,8 +332,6 @@ func (s *sqlResourceService) List( } // ListByOwner returns child resources of the given owner with pagination, search, and ordering. -// Injects kind and owner_id filters into the TSL search string before delegating to GenericService.List. -// A shallow copy of args is made to avoid mutating the caller's ListArguments. func (s *sqlResourceService) ListByOwner( ctx context.Context, kind, ownerID string, args *ListArguments, ) (api.ResourceList, *api.PagingMeta, *errors.ServiceError) { @@ -336,6 +342,7 @@ func (s *sqlResourceService) ListByOwner( args = &ListArguments{Page: 1, Size: 20} } scopedArgs := *args + scopedArgs.Preloads = append(append([]string(nil), scopedArgs.Preloads...), "Labels", "Conditions") kindFilter := fmt.Sprintf("kind = '%s' AND owner_id = '%s'", kind, ownerID) if scopedArgs.Search == "" { scopedArgs.Search = kindFilter @@ -381,8 +388,7 @@ func (s *sqlResourceService) ListAll( } // ProcessAdapterStatus validates, upserts an adapter status report, and triggers -// status aggregation for a generic resource. Follows the same 4-DB-call pattern -// as ClusterService.ProcessAdapterStatus: +// status aggregation for a generic resource. Follows a 4-DB-call pattern: // 1. GetForUpdate — lock + fetch resource with conditions // 2. FindByResource — all adapter statuses (existing found in-memory) // 3. Upsert — write adapter status @@ -648,8 +654,7 @@ func validateKind(kind string) *errors.ServiceError { return nil } -// validateResourceName checks that the kind is registered and the name is non-empty. -// Name format and length validation is handled by OpenAPI spec validation middleware. +// Name format/length validation is handled by OpenAPI spec validation middleware. func validateResourceName(kind, name string) *errors.ServiceError { if svcErr := validateKind(kind); svcErr != nil { return svcErr @@ -660,10 +665,7 @@ func validateResourceName(kind, name string) *errors.ServiceError { return nil } -// jsonBytesEqual is a nil-safe wrapper around jsonEqual. Returns true if both slices are -// nil/empty, false if only one is, and delegates to jsonEqual for semantic JSON comparison. -// Needed because Resource.Labels is nullable (JSONB NULL), and jsonEqual(nil, nil) returns -// false due to json.Unmarshal(nil) error. +// jsonBytesEqual is a nil-safe wrapper around jsonEqual for comparing JSONB spec fields. func jsonBytesEqual(a, b []byte) bool { if len(a) == 0 && len(b) == 0 { return true @@ -674,7 +676,26 @@ func jsonBytesEqual(a, b []byte) bool { return jsonEqual(a, b) } -// applyResourcePatch merges non-nil patch fields into the resource by marshaling them to JSON. +// labelsEqual compares two label slices by key-value content (order-independent). +func labelsEqual(a, b []api.ResourceLabel) bool { + if len(a) == 0 && len(b) == 0 { + return true + } + if len(a) != len(b) { + return false + } + m := make(map[string]string, len(a)) + for _, l := range a { + m[l.Key] = l.Value + } + for _, l := range b { + if v, ok := m[l.Key]; !ok || v != l.Value { + return false + } + } + return true +} + func applyResourcePatch(resource *api.Resource, patch *api.ResourcePatch) error { if patch.Spec != nil { specJSON, err := json.Marshal(patch.Spec) @@ -684,13 +705,16 @@ func applyResourcePatch(resource *api.Resource, patch *api.ResourcePatch) error resource.Spec = specJSON } if patch.Labels != nil { - labelsJSON, err := json.Marshal(patch.Labels) - if err != nil { - return fmt.Errorf("failed to marshal resource labels: %w", err) + labels := make([]api.ResourceLabel, 0, len(patch.Labels)) + for k, v := range patch.Labels { + if err := api.ValidateLabel(k, v); err != nil { + return err + } + labels = append(labels, api.ResourceLabel{Key: k, Value: v}) } - resource.Labels = labelsJSON + resource.Labels = labels } - // TODO: handle patch.References — three-way semantics (nil=skip, {}=clear, map=replace) + // TODO(HYPERFLEET-1156): handle patch.References — three-way semantics (nil=skip, {}=clear, map=replace) // via dao.ReplaceReferences per generic-resource-registry-design.md §9.2 return nil } diff --git a/pkg/services/resource_test.go b/pkg/services/resource_test.go index 640ec061..712124ca 100644 --- a/pkg/services/resource_test.go +++ b/pkg/services/resource_test.go @@ -164,13 +164,31 @@ func (d *mockResourceDao) GetByID(_ context.Context, id string) (*api.Resource, } return nil, gorm.ErrRecordNotFound } - func (d *mockResourceDao) addResource(r *api.Resource) { d.resources[resourceKey(r.Kind, r.ID)] = r } var _ dao.ResourceDao = &mockResourceDao{} +type mockResourceLabelDao struct { + labels map[string][]api.ResourceLabel + replaceErr error +} + +func newMockResourceLabelDao() *mockResourceLabelDao { + return &mockResourceLabelDao{labels: make(map[string][]api.ResourceLabel)} +} + +func (d *mockResourceLabelDao) ReplaceLabels(_ context.Context, resourceID string, labels []api.ResourceLabel) error { + if d.replaceErr != nil { + return d.replaceErr + } + d.labels[resourceID] = labels + return nil +} + +var _ dao.ResourceLabelDao = &mockResourceLabelDao{} + type resourceGenericMock struct { listErr *errors.ServiceError lastSearch string @@ -215,7 +233,9 @@ var _ dao.ResourceConditionDao = &resourceConditionMock{} func newTestResourceService(mockDao *mockResourceDao) (ResourceService, *mockResourceDao, *resourceGenericMock) { generic := &resourceGenericMock{} - svc := NewResourceService(mockDao, newMockAdapterStatusDao(), newResourceConditionMock(), generic) + svc := NewResourceService( + mockDao, newMockResourceLabelDao(), newMockAdapterStatusDao(), newResourceConditionMock(), generic, + ) return svc, mockDao, generic } @@ -225,7 +245,7 @@ func newTestResourceServiceWithAdapterStatus( asDao := newMockAdapterStatusDao() rcDao := newResourceConditionMock() generic := &resourceGenericMock{} - svc := NewResourceService(mockDao, asDao, rcDao, generic) + svc := NewResourceService(mockDao, newMockResourceLabelDao(), asDao, rcDao, generic) return svc, mockDao, asDao, rcDao } diff --git a/pkg/services/status_helpers.go b/pkg/services/status_helpers.go index 7797f606..15d1c6e9 100644 --- a/pkg/services/status_helpers.go +++ b/pkg/services/status_helpers.go @@ -2,13 +2,8 @@ package services import ( "context" - "encoding/json" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/metrics" ) func extractPrevReconciledStatus(ctx context.Context, raw []byte) *api.ResourceConditionStatus { @@ -18,134 +13,3 @@ func extractPrevReconciledStatus(ctx context.Context, raw []byte) *api.ResourceC } return &prevReconciled.Status } - -// computeNodePoolConditionsJSON aggregates adapter statuses into marshaled conditions JSON. -// Returns (nil, false, nil) if conditions are unchanged relative to np.StatusConditions. -// The reconciliationStarted flag indicates a Reconciled→False transition occurred; -// callers must emit the metric only after the change is persisted. -func computeNodePoolConditionsJSON( - ctx context.Context, - np *api.NodePool, - adapterStatuses []*api.AdapterStatus, - requiredAdapters []string, -) (conditionsJSON []byte, reconciliationStarted bool, svcErr *errors.ServiceError) { - prevReconciledStatus := extractPrevReconciledStatus(ctx, np.StatusConditions) - - reconciled, lastKnownReconciled, adapterConditions := AggregateResourceStatus(ctx, AggregateResourceStatusInput{ - ResourceGeneration: np.Generation, - RefTime: nodePoolRefTime(np), - DeletedTime: np.DeletedTime, - PrevConditionsJSON: np.StatusConditions, - RequiredAdapters: requiredAdapters, - AdapterStatuses: adapterStatuses, - }) - - reconciliationStarted = reconciled.Status == api.ConditionFalse && - (prevReconciledStatus == nil || *prevReconciledStatus != api.ConditionFalse) - - allConditions := make([]api.ResourceCondition, 0, fixedConditionCount+len(adapterConditions)) - allConditions = append(allConditions, reconciled, lastKnownReconciled) - allConditions = append(allConditions, adapterConditions...) - - result, err := json.Marshal(allConditions) - if err != nil { - return nil, false, errors.GeneralError("Failed to marshal conditions: %s", err) - } - - if jsonEqual(np.StatusConditions, result) { - return nil, false, nil - } - - return result, reconciliationStarted, nil -} - -// updateNodePoolStatusFromAdapters fetches a single nodepool by ID, recomputes its status -// conditions from current adapter reports, and persists the result via Save. -// Returns the updated nodepool unchanged if conditions have not changed. -func updateNodePoolStatusFromAdapters( - ctx context.Context, - nodePoolID string, - nodePoolDao dao.NodePoolDao, - adapterStatusDao dao.AdapterStatusDao, - adapterConfig *config.AdapterRequirementsConfig, -) (*api.NodePool, *errors.ServiceError) { - nodePool, err := nodePoolDao.Get(ctx, nodePoolID) - if err != nil { - return nil, handleGetError(api.ResourceTypeNodePool, "id", nodePoolID, err) - } - - adapterStatuses, err := adapterStatusDao.FindByResource(ctx, api.ResourceTypeNodePool, nodePoolID) - if err != nil { - return nil, errors.GeneralError("Failed to get adapter statuses: %s", err) - } - - conditionsJSON, started, svcErr := computeNodePoolConditionsJSON( - ctx, - nodePool, - adapterStatuses, - adapterConfig.RequiredNodePoolAdapters()) - if svcErr != nil { - return nil, svcErr - } - if conditionsJSON == nil { - return nodePool, nil - } - - nodePool.StatusConditions = conditionsJSON - if err = nodePoolDao.Save(ctx, nodePool); err != nil { - return nil, handleUpdateError(api.ResourceTypeNodePool, err) - } - - if started { - metrics.RecordReconciliationStarted("nodepool", nodePool.DeletedTime != nil) - } - - return nodePool, nil -} - -// recomputeNodePoolConditions fetches adapter statuses and recomputes status conditions -// for each nodepool, setting StatusConditions directly on the models. -func recomputeNodePoolConditions( - ctx context.Context, - nodePools []*api.NodePool, - adapterStatusDao dao.AdapterStatusDao, - adapterConfig *config.AdapterRequirementsConfig, -) (reconciliationStartedCount int, svcErr *errors.ServiceError) { - if len(nodePools) == 0 { - return 0, nil - } - - nodePoolIDs := make([]string, len(nodePools)) - for i, np := range nodePools { - nodePoolIDs[i] = np.ID - } - - allStatuses, err := adapterStatusDao.FindByResourceIDs(ctx, api.ResourceTypeNodePool, nodePoolIDs) - if err != nil { - return 0, errors.GeneralError("Failed to get adapter statuses: %s", err) - } - - statusesByResource := make(map[string][]*api.AdapterStatus) - for i := range allStatuses { - s := allStatuses[i] - statusesByResource[s.ResourceID] = append(statusesByResource[s.ResourceID], s) - } - - requiredAdapters := adapterConfig.RequiredNodePoolAdapters() - - var startedCount int - for _, np := range nodePools { - conditionsJSON, started, svcErr := computeNodePoolConditionsJSON(ctx, np, statusesByResource[np.ID], requiredAdapters) - if svcErr != nil { - return startedCount, svcErr - } - if conditionsJSON != nil { - np.StatusConditions = conditionsJSON - } - if started { - startedCount++ - } - } - - return startedCount, nil -} diff --git a/pkg/services/test_helpers_test.go b/pkg/services/test_helpers_test.go new file mode 100644 index 00000000..94dc0d0e --- /dev/null +++ b/pkg/services/test_helpers_test.go @@ -0,0 +1,136 @@ +package services + +import ( + "context" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" +) + +type mockAdapterStatusDao struct { + statuses map[string]*api.AdapterStatus + findByResourceErr error + deleteByResourceErr error +} + +func newMockAdapterStatusDao() *mockAdapterStatusDao { + return &mockAdapterStatusDao{ + statuses: make(map[string]*api.AdapterStatus), + } +} + +func (d *mockAdapterStatusDao) Get(ctx context.Context, id string) (*api.AdapterStatus, error) { + if s, ok := d.statuses[id]; ok { + return s, nil + } + return nil, errors.NotFound("AdapterStatus").AsError() +} + +func (d *mockAdapterStatusDao) Create(ctx context.Context, status *api.AdapterStatus) (*api.AdapterStatus, error) { + d.statuses[status.ID] = status + return status, nil +} + +func (d *mockAdapterStatusDao) Upsert( + ctx context.Context, status *api.AdapterStatus, existing *api.AdapterStatus, +) (*api.AdapterStatus, error) { + key := status.ResourceType + ":" + status.ResourceID + ":" + status.Adapter + if existing != nil { + isStoredFresherOrEqual := existing.ObservedGeneration > status.ObservedGeneration || + (existing.ObservedGeneration == status.ObservedGeneration && + !existing.CreatedTime.Before(status.LastReportTime)) + if isStoredFresherOrEqual { + return existing, nil + } + + status.ID = existing.ID + if !existing.CreatedTime.IsZero() { + status.CreatedTime = existing.CreatedTime + } + } else { + status.ID = key + } + + d.statuses[key] = status + return status, nil +} + +func (d *mockAdapterStatusDao) Delete(ctx context.Context, id string) error { + delete(d.statuses, id) + return nil +} + +func (d *mockAdapterStatusDao) DeleteByResource(ctx context.Context, resourceType, resourceID string) error { + if d.deleteByResourceErr != nil { + return d.deleteByResourceErr + } + for key, s := range d.statuses { + if s.ResourceType == resourceType && s.ResourceID == resourceID { + delete(d.statuses, key) + } + } + return nil +} + +func (d *mockAdapterStatusDao) FindByResource( + ctx context.Context, + resourceType, resourceID string, +) (api.AdapterStatusList, error) { + if d.findByResourceErr != nil { + return nil, d.findByResourceErr + } + var result api.AdapterStatusList + for _, s := range d.statuses { + if s.ResourceType == resourceType && s.ResourceID == resourceID { + result = append(result, s) + } + } + return result, nil +} + +func (d *mockAdapterStatusDao) FindByResourceIDs( + ctx context.Context, + resourceType string, + resourceIDs []string, +) (api.AdapterStatusList, error) { + var result api.AdapterStatusList + resourceIDSet := make(map[string]bool, len(resourceIDs)) + for _, id := range resourceIDs { + resourceIDSet[id] = true + } + for _, s := range d.statuses { + if s.ResourceType == resourceType && resourceIDSet[s.ResourceID] { + result = append(result, s) + } + } + return result, nil +} + +func (d *mockAdapterStatusDao) FindByResourcePaginated( + ctx context.Context, + resourceType, resourceID string, + offset, limit int, +) (api.AdapterStatusList, int64, error) { + statuses, _ := d.FindByResource(ctx, resourceType, resourceID) + return statuses, int64(len(statuses)), nil +} + +func (d *mockAdapterStatusDao) FindByResourceAndAdapter( + ctx context.Context, + resourceType, resourceID, adapter string, +) (*api.AdapterStatus, error) { + for _, s := range d.statuses { + if s.ResourceType == resourceType && s.ResourceID == resourceID && s.Adapter == adapter { + return s, nil + } + } + return nil, errors.NotFound("AdapterStatus").AsError() +} + +func (d *mockAdapterStatusDao) All(ctx context.Context) (api.AdapterStatusList, error) { + var result api.AdapterStatusList + for _, s := range d.statuses { + result = append(result, s) + } + return result, nil +} diff --git a/pkg/services/util.go b/pkg/services/util.go index 1d9a9b06..aa4f63a2 100755 --- a/pkg/services/util.go +++ b/pkg/services/util.go @@ -16,6 +16,8 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" ) +const defaultSystemUser = "system@hyperfleet.local" + func jsonEqual(a, b []byte) bool { if len(a) == 0 && len(b) == 0 { return true @@ -125,3 +127,42 @@ func actorFromContext(ctx context.Context) string { } return defaultSystemUser } + +func findAdapterStatusInList(statuses api.AdapterStatusList, adapter string) *api.AdapterStatus { + for _, s := range statuses { + if s.Adapter == adapter { + return s + } + } + return nil +} + +// replaceAdapterStatusInList returns a copy of the list with the entry for the given adapter +// replaced (or appended if not present). Used to build an up-to-date snapshot for aggregation +// after an upsert without re-querying. +func replaceAdapterStatusInList(statuses api.AdapterStatusList, updated *api.AdapterStatus) api.AdapterStatusList { + result := make(api.AdapterStatusList, 0, len(statuses)+1) + found := false + for _, s := range statuses { + if s.Adapter == updated.Adapter && s.ResourceType == updated.ResourceType && s.ResourceID == updated.ResourceID { + result = append(result, updated) + found = true + } else { + result = append(result, s) + } + } + if !found { + result = append(result, updated) + } + return result +} + +// incomingReportedFinalized returns true when the adapter conditions contain Finalized=True. +func incomingReportedFinalized(conditions []api.AdapterCondition) bool { + for _, cond := range conditions { + if cond.Type == api.AdapterConditionTypeFinalized && cond.Status == api.AdapterConditionTrue { + return true + } + } + return false +} diff --git a/pkg/validators/schema_validator.go b/pkg/validators/schema_validator.go index 54d82624..8a015eb6 100644 --- a/pkg/validators/schema_validator.go +++ b/pkg/validators/schema_validator.go @@ -41,10 +41,7 @@ func NewSchemaValidator(schemaPath string) (*SchemaValidator, error) { return doc.Components.Schemas[name] != nil }) - schemas, err := buildSchemasMap(doc) - if err != nil { - return nil, err - } + schemas := buildSchemasMap(doc) return &SchemaValidator{ doc: doc, @@ -52,7 +49,7 @@ func NewSchemaValidator(schemaPath string) (*SchemaValidator, error) { }, nil } -func buildSchemasMap(doc *openapi3.T) (map[string]*ResourceSchema, error) { +func buildSchemasMap(doc *openapi3.T) map[string]*ResourceSchema { ctx := context.Background() schemas := make(map[string]*ResourceSchema) @@ -72,22 +69,7 @@ func buildSchemasMap(doc *openapi3.T) (map[string]*ResourceSchema, error) { } } - // TODO : HYPERFLEET-1159 - Remove this once Cluster and NodePool are registered - for _, hc := range []struct{ plural, schema string }{ - {"clusters", "ClusterSpec"}, - {"nodepools", "NodePoolSpec"}, - } { - schemaRef := doc.Components.Schemas[hc.schema] - if schemaRef == nil { - return nil, fmt.Errorf("%s schema not found in OpenAPI spec", hc.schema) - } - schemas[hc.plural] = &ResourceSchema{ - TypeName: hc.schema, - Schema: schemaRef, - } - } - - return schemas, nil + return schemas } // HasSchema reports whether a validation schema was loaded for the given resource plural. @@ -106,20 +88,6 @@ func (v *SchemaValidator) Validate(resourcePlural string, spec map[string]interf return v.validateSpec(spec, resourceSchema.Schema, resourceSchema.TypeName) } -// ValidateClusterSpec validates a cluster spec against the ClusterSpec schema -// -// Deprecated: Use Validate("clusters", spec) instead -func (v *SchemaValidator) ValidateClusterSpec(spec map[string]interface{}) error { - return v.Validate("clusters", spec) -} - -// ValidateNodePoolSpec validates a nodepool spec against the NodePoolSpec schema -// -// Deprecated: Use Validate("nodepools", spec) instead -func (v *SchemaValidator) ValidateNodePoolSpec(spec map[string]interface{}) error { - return v.Validate("nodepools", spec) -} - // validateSpec performs the actual validation and converts errors to our error format func (v *SchemaValidator) validateSpec( spec map[string]interface{}, schemaRef *openapi3.SchemaRef, specTypeName string, diff --git a/pkg/validators/schema_validator_test.go b/pkg/validators/schema_validator_test.go index 5ec69844..d4545f7b 100644 --- a/pkg/validators/schema_validator_test.go +++ b/pkg/validators/schema_validator_test.go @@ -120,7 +120,7 @@ func TestNewSchemaValidator_MalformedContent(t *testing.T) { Expect(err.Error()).To(ContainSubstring("failed to load OpenAPI schema")) } -func TestNewSchemaValidator_MissingSchemas(t *testing.T) { +func TestNewSchemaValidator_MissingSchemas_SkipsGracefully(t *testing.T) { RegisterTestingT(t) registerRequiredSpecValidationEntities() @@ -142,9 +142,11 @@ components: err := os.WriteFile(schemaPath, []byte(invalidSchema), 0600) Expect(err).To(BeNil()) - _, err = NewSchemaValidator(schemaPath) - Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring("not found in OpenAPI spec")) + // Non-required entities with missing schemas are skipped with a warning, not an error + validator, err := NewSchemaValidator(schemaPath) + Expect(err).To(BeNil()) + Expect(validator.HasSchema("clusters")).To(BeFalse()) + Expect(validator.HasSchema("nodepools")).To(BeFalse()) } func TestNewSchemaValidator_RegisteredEntityMissingSchema_Panics(t *testing.T) { @@ -258,7 +260,7 @@ func TestNewSchemaValidator_RealOpenAPISpec_BootsWithRegisteredEntities(t *testi Expect(err).To(BeNil()) } -func TestNewSchemaValidator_RequiredEntityMissingOpenAPISchema_Fails(t *testing.T) { +func TestNewSchemaValidator_NonRequiredEntityMissingSchema_SkipsGracefully(t *testing.T) { RegisterTestingT(t) registerRequiredSpecValidationEntities() @@ -283,10 +285,11 @@ components: err := os.WriteFile(schemaPath, []byte(schemaWithoutNodePool), 0600) Expect(err).To(BeNil()) - _, err = NewSchemaValidator(schemaPath) - Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring("NodePoolSpec")) - Expect(err.Error()).To(ContainSubstring("not found")) + // Non-required entities with missing schemas are skipped with a warning + validator, err := NewSchemaValidator(schemaPath) + Expect(err).To(BeNil()) + Expect(validator.HasSchema("clusters")).To(BeTrue()) + Expect(validator.HasSchema("nodepools")).To(BeFalse()) } func TestValidate_WifConfigSpec_Valid(t *testing.T) { @@ -344,7 +347,7 @@ func TestValidateClusterSpec_Valid(t *testing.T) { RegisterTestingT(t) validator := setupTestValidator(t) - err := validator.ValidateClusterSpec(map[string]interface{}{ + err := validator.Validate("clusters", map[string]interface{}{ "region": "us-central1", "provider": "gcp", }) @@ -355,7 +358,7 @@ func TestValidateClusterSpec_ValidWithOptionalFields(t *testing.T) { RegisterTestingT(t) validator := setupTestValidator(t) - err := validator.ValidateClusterSpec(map[string]interface{}{ + err := validator.Validate("clusters", map[string]interface{}{ "region": "europe-west1", "provider": "aws", "network": map[string]interface{}{"vpc_id": "vpc-12345"}, @@ -367,7 +370,7 @@ func TestValidateClusterSpec_MissingRequiredField(t *testing.T) { RegisterTestingT(t) validator := setupTestValidator(t) - err := validator.ValidateClusterSpec(map[string]interface{}{ + err := validator.Validate("clusters", map[string]interface{}{ "region": "us-central1", }) Expect(err).ToNot(BeNil()) @@ -381,7 +384,7 @@ func TestValidateClusterSpec_InvalidEnum(t *testing.T) { RegisterTestingT(t) validator := setupTestValidator(t) - err := validator.ValidateClusterSpec(map[string]interface{}{ + err := validator.Validate("clusters", map[string]interface{}{ "region": "asia-southeast1", "provider": "gcp", }) @@ -397,7 +400,7 @@ func TestValidateClusterSpec_InvalidNestedField(t *testing.T) { RegisterTestingT(t) validator := setupTestValidator(t) - err := validator.ValidateClusterSpec(map[string]interface{}{ + err := validator.Validate("clusters", map[string]interface{}{ "region": "us-central1", "provider": "gcp", "network": map[string]interface{}{"vpc_id": ""}, @@ -412,7 +415,7 @@ func TestValidateNodePoolSpec_Valid(t *testing.T) { RegisterTestingT(t) validator := setupTestValidator(t) - err := validator.ValidateNodePoolSpec(map[string]interface{}{ + err := validator.Validate("nodepools", map[string]interface{}{ "machine_type": "n1-standard-4", "replicas": 3, }) @@ -423,7 +426,7 @@ func TestValidateNodePoolSpec_ValidWithOptional(t *testing.T) { RegisterTestingT(t) validator := setupTestValidator(t) - err := validator.ValidateNodePoolSpec(map[string]interface{}{ + err := validator.Validate("nodepools", map[string]interface{}{ "machine_type": "n1-standard-4", "replicas": 5, "autoscaling": true, @@ -435,7 +438,7 @@ func TestValidateNodePoolSpec_MissingRequiredField(t *testing.T) { RegisterTestingT(t) validator := setupTestValidator(t) - err := validator.ValidateNodePoolSpec(map[string]interface{}{ + err := validator.Validate("nodepools", map[string]interface{}{ "machine_type": "n1-standard-4", }) Expect(err).ToNot(BeNil()) @@ -449,7 +452,7 @@ func TestValidateNodePoolSpec_InvalidType(t *testing.T) { RegisterTestingT(t) validator := setupTestValidator(t) - err := validator.ValidateNodePoolSpec(map[string]interface{}{ + err := validator.Validate("nodepools", map[string]interface{}{ "machine_type": "n1-standard-4", "replicas": "three", }) @@ -461,7 +464,7 @@ func TestValidateNodePoolSpec_OutOfRange(t *testing.T) { RegisterTestingT(t) validator := setupTestValidator(t) - err := validator.ValidateNodePoolSpec(map[string]interface{}{ + err := validator.Validate("nodepools", map[string]interface{}{ "machine_type": "n1-standard-4", "replicas": 150, }) @@ -473,7 +476,7 @@ func TestValidateNodePoolSpec_BelowMinimum(t *testing.T) { RegisterTestingT(t) validator := setupTestValidator(t) - err := validator.ValidateNodePoolSpec(map[string]interface{}{ + err := validator.Validate("nodepools", map[string]interface{}{ "machine_type": "n1-standard-4", "replicas": 0, }) @@ -484,7 +487,7 @@ func TestValidateNodePoolSpec_EmptyMachineType(t *testing.T) { RegisterTestingT(t) validator := setupTestValidator(t) - err := validator.ValidateNodePoolSpec(map[string]interface{}{ + err := validator.Validate("nodepools", map[string]interface{}{ "machine_type": "", "replicas": 3, }) diff --git a/plugins/clusters/plugin.go b/plugins/clusters/plugin.go deleted file mode 100644 index 08bdf411..00000000 --- a/plugins/clusters/plugin.go +++ /dev/null @@ -1,97 +0,0 @@ -package clusters - -import ( - "net/http" - - "github.com/gorilla/mux" - "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/environments" - "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/environments/registry" - "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/server" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/presenters" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/handlers" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" - "github.com/openshift-hyperfleet/hyperfleet-api/plugins/adapterStatus" - "github.com/openshift-hyperfleet/hyperfleet-api/plugins/generic" - "github.com/openshift-hyperfleet/hyperfleet-api/plugins/nodePools" -) - -// ServiceLocator Service Locator -type ServiceLocator func() services.ClusterService - -func NewServiceLocator(env *environments.Env) ServiceLocator { - return func() services.ClusterService { - return services.NewClusterService( - dao.NewClusterDao(env.Database.SessionFactory), - dao.NewNodePoolDao(env.Database.SessionFactory), - nodePools.Service(&env.Services), - dao.NewAdapterStatusDao(env.Database.SessionFactory), - env.Config.Adapters, - ) - } -} - -// Service helper function to get the cluster service from the registry -func Service(s *environments.Services) services.ClusterService { - if s == nil { - return nil - } - if obj := s.GetService("Clusters"); obj != nil { - locator := obj.(ServiceLocator) - return locator() - } - return nil -} - -func init() { - // Service registration - registry.RegisterService("Clusters", func(env interface{}) interface{} { - return NewServiceLocator(env.(*environments.Env)) - }) - - // Routes registration - server.RegisterRoutes("clusters", func(apiV1Router *mux.Router, services server.ServicesInterface) { - envServices := services.(*environments.Services) - clusterHandler := handlers.NewClusterHandler(Service(envServices), generic.Service(envServices)) - - clustersRouter := apiV1Router.PathPrefix("/clusters").Subrouter() - clustersRouter.HandleFunc("", clusterHandler.List).Methods(http.MethodGet) - clustersRouter.HandleFunc("/{id}", clusterHandler.Get).Methods(http.MethodGet) - clustersRouter.HandleFunc("", clusterHandler.Create).Methods(http.MethodPost) - clustersRouter.HandleFunc("/{id}", clusterHandler.Patch).Methods(http.MethodPatch) - clustersRouter.HandleFunc("/{id}", clusterHandler.SoftDelete).Methods(http.MethodDelete) - clustersRouter.HandleFunc("/{id}/force-delete", clusterHandler.ForceDelete).Methods(http.MethodPost) - - // Nested resource: cluster statuses - clusterStatusHandler := handlers.NewClusterStatusHandler(adapterStatus.Service(envServices), Service(envServices)) - clustersRouter.HandleFunc("/{id}/statuses", clusterStatusHandler.List).Methods(http.MethodGet) - clustersRouter.HandleFunc("/{id}/statuses", clusterStatusHandler.Create).Methods(http.MethodPut) - - // Nested resource: cluster nodepools - clusterNodePoolsHandler := handlers.NewClusterNodePoolsHandler( - Service(envServices), - nodePools.Service(envServices), - ) - clustersRouter.HandleFunc("/{id}/nodepools", clusterNodePoolsHandler.List).Methods(http.MethodGet) - clustersRouter.HandleFunc("/{id}/nodepools", clusterNodePoolsHandler.Create).Methods(http.MethodPost) - clustersRouter.HandleFunc("/{id}/nodepools/{nodepool_id}", clusterNodePoolsHandler.Get).Methods(http.MethodGet) - clustersRouter.HandleFunc("/{id}/nodepools/{nodepool_id}", clusterNodePoolsHandler.SoftDelete).Methods(http.MethodDelete) - clustersRouter.HandleFunc("/{id}/nodepools/{nodepool_id}", clusterNodePoolsHandler.Patch).Methods(http.MethodPatch) - clustersRouter.HandleFunc("/{id}/nodepools/{nodepool_id}/force-delete", clusterNodePoolsHandler.ForceDelete).Methods(http.MethodPost) - - // Nested resource: nodepool statuses - nodepoolStatusHandler := handlers.NewNodePoolStatusHandler(adapterStatus.Service(envServices), nodePools.Service(envServices)) - clustersRouter.HandleFunc("/{id}/nodepools/{nodepool_id}/statuses", nodepoolStatusHandler.List).Methods(http.MethodGet) - clustersRouter.HandleFunc("/{id}/nodepools/{nodepool_id}/statuses", nodepoolStatusHandler.Create).Methods(http.MethodPut) - }) - - // REMOVED: Controller registration - Sentinel handles orchestration - // Controllers are no longer run inside the API service - - // Presenter registration - presenters.RegisterPath(api.Cluster{}, "clusters") - presenters.RegisterPath(&api.Cluster{}, "clusters") - presenters.RegisterKind(api.Cluster{}, "Cluster") - presenters.RegisterKind(&api.Cluster{}, "Cluster") -} diff --git a/plugins/entities/plugin.go b/plugins/entities/plugin.go index 817ea0c3..4632300e 100644 --- a/plugins/entities/plugin.go +++ b/plugins/entities/plugin.go @@ -13,7 +13,7 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/validators" - adapterStatus "github.com/openshift-hyperfleet/hyperfleet-api/plugins/adapterStatus" + "github.com/openshift-hyperfleet/hyperfleet-api/plugins/adapterStatus" "github.com/openshift-hyperfleet/hyperfleet-api/plugins/resources" ) diff --git a/plugins/nodePools/plugin.go b/plugins/nodePools/plugin.go deleted file mode 100644 index ca122375..00000000 --- a/plugins/nodePools/plugin.go +++ /dev/null @@ -1,70 +0,0 @@ -package nodePools - -import ( - "net/http" - - "github.com/gorilla/mux" - "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/environments" - "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/environments/registry" - "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/server" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/presenters" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/handlers" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" - "github.com/openshift-hyperfleet/hyperfleet-api/plugins/generic" -) - -// ServiceLocator Service Locator -type ServiceLocator func() services.NodePoolService - -func NewServiceLocator(env *environments.Env) ServiceLocator { - return func() services.NodePoolService { - return services.NewNodePoolService( - dao.NewNodePoolDao(env.Database.SessionFactory), - dao.NewClusterDao(env.Database.SessionFactory), - dao.NewAdapterStatusDao(env.Database.SessionFactory), - env.Config.Adapters, - generic.Service(&env.Services), - ) - } -} - -// Service helper function to get the nodePool service from the registry -func Service(s *environments.Services) services.NodePoolService { - if s == nil { - return nil - } - if obj := s.GetService("NodePools"); obj != nil { - locator := obj.(ServiceLocator) - return locator() - } - return nil -} - -func init() { - // Service registration - registry.RegisterService("NodePools", func(env interface{}) interface{} { - return NewServiceLocator(env.(*environments.Env)) - }) - - // Routes registration - server.RegisterRoutes("nodePools", func(apiV1Router *mux.Router, services server.ServicesInterface) { - envServices := services.(*environments.Services) - nodePoolHandler := handlers.NewNodePoolHandler(Service(envServices), generic.Service(envServices)) - - // Only register routes that are in the OpenAPI spec - // GET /api/hyperfleet/v1/nodepools - List all nodepools - nodePoolsRouter := apiV1Router.PathPrefix("/nodepools").Subrouter() - nodePoolsRouter.HandleFunc("", nodePoolHandler.List).Methods(http.MethodGet) - }) - - // REMOVED: Controller registration - Sentinel handles orchestration - // Controllers are no longer run inside the API service - - // Presenter registration - presenters.RegisterPath(api.NodePool{}, "node_pools") - presenters.RegisterPath(&api.NodePool{}, "node_pools") - presenters.RegisterKind(api.NodePool{}, "NodePool") - presenters.RegisterKind(&api.NodePool{}, "NodePool") -} diff --git a/plugins/resources/plugin.go b/plugins/resources/plugin.go index 4403e5d2..1e159883 100644 --- a/plugins/resources/plugin.go +++ b/plugins/resources/plugin.go @@ -15,6 +15,7 @@ func NewServiceLocator(env *environments.Env) ServiceLocator { return func() services.ResourceService { return services.NewResourceService( dao.NewResourceDao(env.Database.SessionFactory), + dao.NewResourceLabelDao(env.Database.SessionFactory), dao.NewAdapterStatusDao(env.Database.SessionFactory), dao.NewResourceConditionDao(env.Database.SessionFactory), generic.Service(&env.Services), diff --git a/test/factories/clusters.go b/test/factories/clusters.go index 4e06c090..0e59ff0b 100644 --- a/test/factories/clusters.go +++ b/test/factories/clusters.go @@ -3,6 +3,7 @@ package factories import ( "context" "encoding/json" + "fmt" "time" "gorm.io/gorm" @@ -10,208 +11,184 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/environments" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" - "github.com/openshift-hyperfleet/hyperfleet-api/plugins/clusters" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" + "github.com/openshift-hyperfleet/hyperfleet-api/plugins/resources" ) -func (f *Factories) NewCluster(id string) (*api.Cluster, error) { - clusterService := clusters.Service(&environments.Environment().Services) +// resourceService retrieves the ResourceService for creating resources. +func resourceService() services.ResourceService { + return resources.Service(&environments.Environment().Services) +} + +// reloadResource reloads a resource from the database to ensure all fields are current. +func reloadResource(dbSession *gorm.DB, resource *api.Resource) error { + return dbSession.Preload("Conditions").First(resource, "id = ?", resource.ID).Error +} - cluster := &api.Cluster{ - Meta: api.Meta{ID: id}, - Name: "test-cluster-" + id, // Use unique name based on ID - Spec: []byte(`{"test": "spec"}`), - Generation: 42, - CreatedBy: "test@example.com", - UpdatedBy: "test@example.com", +// buildConditions creates Available and Reconciled conditions for a resource. +func buildConditions( + resourceID string, generation int32, isAvailable, isReconciled bool, t time.Time, +) []api.ResourceCondition { + return buildConditionsWithGeneration(resourceID, generation, isAvailable, isReconciled, t) +} + +func buildConditionsWithGeneration( + resourceID string, observedGeneration int32, isAvailable, isReconciled bool, t time.Time, +) []api.ResourceCondition { + availableStatus := api.ConditionFalse + if isAvailable { + availableStatus = api.ConditionTrue + } + reconciledStatus := api.ConditionFalse + if isReconciled { + reconciledStatus = api.ConditionTrue } - sub, err := clusterService.Create(context.Background(), cluster) - if err != nil { - return nil, err + return []api.ResourceCondition{ + { + ResourceID: resourceID, + Type: api.ResourceConditionTypeAvailable, + Status: availableStatus, + ObservedGeneration: observedGeneration, + LastTransitionTime: t, + CreatedTime: t, + LastUpdatedTime: t, + }, + { + ResourceID: resourceID, + Type: api.ResourceConditionTypeReconciled, + Status: reconciledStatus, + ObservedGeneration: observedGeneration, + LastTransitionTime: t, + CreatedTime: t, + LastUpdatedTime: t, + }, + } +} + +func (f *Factories) NewCluster(id string) (*api.Resource, error) { + svc := resourceService() + ctx := context.Background() + + cluster := &api.Resource{ + Kind: "Cluster", + Name: id, + Spec: []byte(`{"region": "us-central1", "provider": "gcp"}`), + CreatedBy: "test@example.com", + UpdatedBy: "test@example.com", } - return sub, nil + result, svcErr := svc.Create(ctx, "Cluster", cluster) + if svcErr != nil { + return nil, fmt.Errorf("create cluster: %w", svcErr) + } + return result, nil } -func (f *Factories) NewClusterList(name string, count int) ([]*api.Cluster, error) { - var Clusters []*api.Cluster - for i := 1; i <= count; i++ { - c, err := f.NewCluster(f.NewID()) +func (f *Factories) NewClusterList(name string, count int) ([]*api.Resource, error) { + result := make([]*api.Resource, 0, count) + for i := range count { + cluster, err := f.NewCluster(fmt.Sprintf("%s-%d", name, i)) if err != nil { return nil, err } - Clusters = append(Clusters, c) + result = append(result, cluster) } - return Clusters, nil + return result, nil } // Aliases for test compatibility -func (f *Factories) NewClusters(id string) (*api.Cluster, error) { +func (f *Factories) NewClusters(id string) (*api.Resource, error) { return f.NewCluster(id) } -func (f *Factories) NewClustersList(name string, count int) ([]*api.Cluster, error) { +func (f *Factories) NewClustersList(name string, count int) ([]*api.Resource, error) { return f.NewClusterList(name, count) } -// reloadCluster reloads a cluster from the database to ensure all fields are current -func reloadCluster(dbSession *gorm.DB, cluster *api.Cluster) error { - return dbSession.First(cluster, "id = ?", cluster.ID).Error -} - // NewClusterWithStatus creates a cluster with specific status conditions using the current time. func NewClusterWithStatus( f *Factories, dbFactory db.SessionFactory, id string, isAvailable, isReconciled bool, -) (*api.Cluster, error) { +) (*api.Resource, error) { return NewClusterWithStatusAtTime(f, dbFactory, id, isAvailable, isReconciled, time.Now()) } // NewClusterWithStatusAtTime creates a cluster with specific status conditions and custom timestamps. -// This is useful for testing time-based condition queries (e.g., last_updated_time < '...') func NewClusterWithStatusAtTime( f *Factories, dbFactory db.SessionFactory, id string, isAvailable, isReconciled bool, conditionTime time.Time, -) (*api.Cluster, error) { +) (*api.Resource, error) { cluster, err := f.NewCluster(id) if err != nil { return nil, err } - availableStatus := api.ConditionFalse - if isAvailable { - availableStatus = api.ConditionTrue - } - reconciledStatus := api.ConditionFalse - if isReconciled { - reconciledStatus = api.ConditionTrue - } - - conditions := []api.ResourceCondition{ - { - Type: api.ResourceConditionTypeAvailable, - Status: availableStatus, - ObservedGeneration: cluster.Generation, - LastTransitionTime: conditionTime, - CreatedTime: conditionTime, - LastUpdatedTime: conditionTime, - }, - { - Type: api.ResourceConditionTypeReconciled, - Status: reconciledStatus, - ObservedGeneration: cluster.Generation, - LastTransitionTime: conditionTime, - CreatedTime: conditionTime, - LastUpdatedTime: conditionTime, - }, - } - - conditionsJSON, err := json.Marshal(conditions) - if err != nil { - return nil, err - } + conditions := buildConditions(cluster.ID, cluster.Generation, isAvailable, isReconciled, conditionTime) dbSession := dbFactory.New(context.Background()) - err = dbSession.Model(cluster).Update("status_conditions", conditionsJSON).Error - if err != nil { + if err := dbSession.Create(&conditions).Error; err != nil { return nil, err } - if err := reloadCluster(dbSession, cluster); err != nil { + if err := reloadResource(dbSession, cluster); err != nil { return nil, err } return cluster, nil } // NewClusterWithObservedGeneration creates a cluster with a specific observed_generation on its conditions. -// This is useful for testing observed_generation-based queries. func NewClusterWithObservedGeneration( f *Factories, dbFactory db.SessionFactory, id string, isAvailable, isReconciled bool, observedGeneration int32, -) (*api.Cluster, error) { +) (*api.Resource, error) { cluster, err := f.NewCluster(id) if err != nil { return nil, err } now := time.Now() - availableStatus := api.ConditionFalse - if isAvailable { - availableStatus = api.ConditionTrue - } - reconciledStatus := api.ConditionFalse - if isReconciled { - reconciledStatus = api.ConditionTrue - } - - conditions := []api.ResourceCondition{ - { - Type: api.ResourceConditionTypeAvailable, - Status: availableStatus, - ObservedGeneration: observedGeneration, - LastTransitionTime: now, - CreatedTime: now, - LastUpdatedTime: now, - }, - { - Type: api.ResourceConditionTypeReconciled, - Status: reconciledStatus, - ObservedGeneration: observedGeneration, - LastTransitionTime: now, - CreatedTime: now, - LastUpdatedTime: now, - }, - } - - conditionsJSON, err := json.Marshal(conditions) - if err != nil { - return nil, err - } + conditions := buildConditionsWithGeneration(cluster.ID, observedGeneration, isAvailable, isReconciled, now) dbSession := dbFactory.New(context.Background()) - err = dbSession.Model(cluster).Update("status_conditions", conditionsJSON).Error - if err != nil { + if err := dbSession.Create(&conditions).Error; err != nil { return nil, err } - if err := reloadCluster(dbSession, cluster); err != nil { + if err := reloadResource(dbSession, cluster); err != nil { return nil, err } return cluster, nil } -// NewClusterWithLabels creates a cluster with specific labels +// NewClusterWithLabels creates a cluster with specific labels. func NewClusterWithLabels( f *Factories, dbFactory db.SessionFactory, id string, labels map[string]string, -) (*api.Cluster, error) { +) (*api.Resource, error) { cluster, err := f.NewCluster(id) if err != nil { return nil, err } - // Convert labels to JSON and update labelsJSON, err := json.Marshal(labels) if err != nil { return nil, err } dbSession := dbFactory.New(context.Background()) - err = dbSession.Model(cluster).Update("labels", labelsJSON).Error - if err != nil { + if err := dbSession.Model(cluster).Update("labels", labelsJSON).Error; err != nil { return nil, err } - // Reload to get updated values - if err := reloadCluster(dbSession, cluster); err != nil { + if err := reloadResource(dbSession, cluster); err != nil { return nil, err } return cluster, nil } // NewClusterWithSpec creates a cluster with a specific spec JSON value. -// The spec map is serialized to JSON and stored as the cluster's spec column. func NewClusterWithSpec( f *Factories, dbFactory db.SessionFactory, id string, spec map[string]interface{}, -) (*api.Cluster, error) { +) (*api.Resource, error) { cluster, err := f.NewCluster(id) if err != nil { return nil, err @@ -223,21 +200,20 @@ func NewClusterWithSpec( } dbSession := dbFactory.New(context.Background()) - err = dbSession.Model(cluster).Update("spec", specJSON).Error - if err != nil { + if err := dbSession.Model(cluster).Update("spec", specJSON).Error; err != nil { return nil, err } - if err := reloadCluster(dbSession, cluster); err != nil { + if err := reloadResource(dbSession, cluster); err != nil { return nil, err } return cluster, nil } -// NewClusterWithStatusAndLabels creates a cluster with both status conditions and labels +// NewClusterWithStatusAndLabels creates a cluster with both status conditions and labels. func NewClusterWithStatusAndLabels( f *Factories, dbFactory db.SessionFactory, id string, isAvailable, isReconciled bool, labels map[string]string, -) (*api.Cluster, error) { +) (*api.Resource, error) { cluster, err := NewClusterWithStatus(f, dbFactory, id, isAvailable, isReconciled) if err != nil { return nil, err @@ -250,12 +226,11 @@ func NewClusterWithStatusAndLabels( } dbSession := dbFactory.New(context.Background()) - err = dbSession.Model(cluster).Update("labels", labelsJSON).Error - if err != nil { + if err := dbSession.Model(cluster).Update("labels", labelsJSON).Error; err != nil { return nil, err } - if err := reloadCluster(dbSession, cluster); err != nil { + if err := reloadResource(dbSession, cluster); err != nil { return nil, err } } diff --git a/test/factories/node_pools.go b/test/factories/node_pools.go index cbaae254..7527a4a9 100644 --- a/test/factories/node_pools.go +++ b/test/factories/node_pools.go @@ -6,83 +6,96 @@ import ( "fmt" "time" - "gorm.io/gorm" - - "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/environments" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" - "github.com/openshift-hyperfleet/hyperfleet-api/plugins/nodePools" ) -func (f *Factories) NewNodePool(id string) (*api.NodePool, error) { - nodePoolService := nodePools.Service(&environments.Environment().Services) - - if nodePoolService == nil { - return nil, fmt.Errorf("nodePoolService is nil - service not initialized") - } +func (f *Factories) NewNodePool(id string) (*api.Resource, error) { + svc := resourceService() + ctx := context.Background() - // Create a parent cluster first to get a valid OwnerID - cluster, err := f.NewCluster(f.NewID()) - if err != nil { - return nil, fmt.Errorf("failed to create parent cluster: %w", err) + // Create parent cluster + cluster := &api.Resource{ + Kind: "Cluster", + Name: fmt.Sprintf("parent-%s", id), + Spec: []byte(`{"region": "us-central1", "provider": "gcp"}`), + CreatedBy: "test@example.com", + UpdatedBy: "test@example.com", } - - if cluster == nil { - return nil, fmt.Errorf("cluster is nil after NewCluster call") + cluster, svcErr := svc.Create(ctx, "Cluster", cluster) + if svcErr != nil { + return nil, fmt.Errorf("create parent cluster: %w", svcErr) } - nodePool := &api.NodePool{ - Meta: api.Meta{ID: id}, - Name: "test-nodepool-" + id, // Use unique name based on ID - Spec: []byte(`{"test": "spec"}`), - OwnerID: cluster.ID, // Use real cluster ID + // Create nodepool + ownerKind := "Cluster" + np := &api.Resource{ + Kind: "NodePool", + Name: id, + OwnerID: &cluster.ID, + OwnerKind: &ownerKind, + Spec: []byte(`{"machine_type": "n1-standard-4", "replicas": 3}`), CreatedBy: "test@example.com", UpdatedBy: "test@example.com", } - - sub, serviceErr := nodePoolService.Create(context.Background(), nodePool) - // Check for real errors (not typed nil) - if serviceErr != nil && serviceErr.RFC9457Code != "" { - return nil, fmt.Errorf("failed to create nodepool: %s (code: %s)", serviceErr.Reason, serviceErr.RFC9457Code) - } - - if sub == nil { - return nil, fmt.Errorf("nodePoolService.Create returned nil without error") + result, svcErr := svc.Create(ctx, "NodePool", np) + if svcErr != nil { + return nil, fmt.Errorf("create nodepool: %w", svcErr) } - - return sub, nil + return result, nil } -func (f *Factories) NewNodePoolList(name string, count int) ([]*api.NodePool, error) { - var NodePools []*api.NodePool - for i := 1; i <= count; i++ { - c, err := f.NewNodePool(f.NewID()) - if err != nil { - return nil, err +func (f *Factories) NewNodePoolList(name string, count int) ([]*api.Resource, error) { + svc := resourceService() + ctx := context.Background() + + // Create shared parent cluster + cluster := &api.Resource{ + Kind: "Cluster", + Name: fmt.Sprintf("parent-%s", name), + Spec: []byte(`{"region": "us-central1", "provider": "gcp"}`), + CreatedBy: "test@example.com", + UpdatedBy: "test@example.com", + } + cluster, svcErr := svc.Create(ctx, "Cluster", cluster) + if svcErr != nil { + return nil, fmt.Errorf("create parent cluster: %w", svcErr) + } + + ownerKind := "Cluster" + result := make([]*api.Resource, 0, count) + for i := range count { + np := &api.Resource{ + Kind: "NodePool", + Name: fmt.Sprintf("%s-%d", name, i), + OwnerID: &cluster.ID, + OwnerKind: &ownerKind, + Spec: []byte(`{"machine_type": "n1-standard-4", "replicas": 3}`), + CreatedBy: "test@example.com", + UpdatedBy: "test@example.com", + } + np, svcErr = svc.Create(ctx, "NodePool", np) + if svcErr != nil { + return nil, fmt.Errorf("create nodepool: %w", svcErr) } - NodePools = append(NodePools, c) + result = append(result, np) } - return NodePools, nil + return result, nil } // Aliases for test compatibility -func (f *Factories) NewNodePools(id string) (*api.NodePool, error) { +func (f *Factories) NewNodePools(id string) (*api.Resource, error) { return f.NewNodePool(id) } -func (f *Factories) NewNodePoolsList(name string, count int) ([]*api.NodePool, error) { +func (f *Factories) NewNodePoolsList(name string, count int) ([]*api.Resource, error) { return f.NewNodePoolList(name, count) } -// reloadNodePool reloads a node pool from the database to ensure all fields are current -func reloadNodePool(dbSession *gorm.DB, nodePool *api.NodePool) error { - return dbSession.First(nodePool, "id = ?", nodePool.ID).Error -} - // NewNodePoolWithStatus creates a node pool with specific status conditions using the current time. func NewNodePoolWithStatus( f *Factories, dbFactory db.SessionFactory, id string, isAvailable, isReconciled bool, -) (*api.NodePool, error) { +) (*api.Resource, error) { return NewNodePoolWithStatusAtTime(f, dbFactory, id, isAvailable, isReconciled, time.Now()) } @@ -90,89 +103,54 @@ func NewNodePoolWithStatus( func NewNodePoolWithStatusAtTime( f *Factories, dbFactory db.SessionFactory, id string, isAvailable, isReconciled bool, conditionTime time.Time, -) (*api.NodePool, error) { +) (*api.Resource, error) { nodePool, err := f.NewNodePool(id) if err != nil { return nil, err } - availableStatus := api.ConditionFalse - if isAvailable { - availableStatus = api.ConditionTrue - } - reconciledStatus := api.ConditionFalse - if isReconciled { - reconciledStatus = api.ConditionTrue - } - - conditions := []api.ResourceCondition{ - { - Type: api.ResourceConditionTypeAvailable, - Status: availableStatus, - ObservedGeneration: nodePool.Generation, - LastTransitionTime: conditionTime, - CreatedTime: conditionTime, - LastUpdatedTime: conditionTime, - }, - { - Type: api.ResourceConditionTypeReconciled, - Status: reconciledStatus, - ObservedGeneration: nodePool.Generation, - LastTransitionTime: conditionTime, - CreatedTime: conditionTime, - LastUpdatedTime: conditionTime, - }, - } - - conditionsJSON, err := json.Marshal(conditions) - if err != nil { - return nil, err - } + conditions := buildConditions(nodePool.ID, nodePool.Generation, isAvailable, isReconciled, conditionTime) dbSession := dbFactory.New(context.Background()) - err = dbSession.Model(nodePool).Update("status_conditions", conditionsJSON).Error - if err != nil { + if err := dbSession.Create(&conditions).Error; err != nil { return nil, err } - if err := reloadNodePool(dbSession, nodePool); err != nil { + if err := reloadResource(dbSession, nodePool); err != nil { return nil, err } return nodePool, nil } -// NewNodePoolWithLabels creates a node pool with specific labels +// NewNodePoolWithLabels creates a node pool with specific labels. func NewNodePoolWithLabels( f *Factories, dbFactory db.SessionFactory, id string, labels map[string]string, -) (*api.NodePool, error) { +) (*api.Resource, error) { nodePool, err := f.NewNodePool(id) if err != nil { return nil, err } - // Convert labels to JSON and update labelsJSON, err := json.Marshal(labels) if err != nil { return nil, err } dbSession := dbFactory.New(context.Background()) - err = dbSession.Model(nodePool).Update("labels", labelsJSON).Error - if err != nil { + if err := dbSession.Model(nodePool).Update("labels", labelsJSON).Error; err != nil { return nil, err } - // Reload to get updated values - if err := reloadNodePool(dbSession, nodePool); err != nil { + if err := reloadResource(dbSession, nodePool); err != nil { return nil, err } return nodePool, nil } -// NewNodePoolWithStatusAndLabels creates a node pool with both status conditions and labels +// NewNodePoolWithStatusAndLabels creates a node pool with both status conditions and labels. func NewNodePoolWithStatusAndLabels( f *Factories, dbFactory db.SessionFactory, id string, isAvailable, isReconciled bool, labels map[string]string, -) (*api.NodePool, error) { +) (*api.Resource, error) { nodePool, err := NewNodePoolWithStatus(f, dbFactory, id, isAvailable, isReconciled) if err != nil { return nil, err @@ -185,12 +163,11 @@ func NewNodePoolWithStatusAndLabels( } dbSession := dbFactory.New(context.Background()) - err = dbSession.Model(nodePool).Update("labels", labelsJSON).Error - if err != nil { + if err := dbSession.Model(nodePool).Update("labels", labelsJSON).Error; err != nil { return nil, err } - if err := reloadNodePool(dbSession, nodePool); err != nil { + if err := reloadResource(dbSession, nodePool); err != nil { return nil, err } } diff --git a/test/integration/adapter_status_test.go b/test/integration/adapter_status_test.go index 9830d6e3..2a5247f5 100644 --- a/test/integration/adapter_status_test.go +++ b/test/integration/adapter_status_test.go @@ -160,7 +160,7 @@ func TestNodePoolStatusPut(t *testing.T) { nodePool, err := h.Factories.NewNodePools(h.NewID()) Expect(err).NotTo(HaveOccurred()) Expect(nodePool).NotTo(BeNil(), "nodePool should not be nil") - Expect(nodePool.OwnerID).NotTo(BeEmpty(), "nodePool.OwnerID should not be empty") + Expect(nodePool.OwnerID).NotTo(BeNil(), "nodePool.OwnerID should not be nil") Expect(nodePool.ID).NotTo(BeEmpty(), "nodePool.ID should not be empty") // Create an adapter status for the nodepool @@ -197,7 +197,7 @@ func TestNodePoolStatusPut(t *testing.T) { // Use nodePool.OwnerID as the cluster_id parameter resp, err := client.PutNodePoolStatusesWithResponse( - ctx, nodePool.OwnerID, nodePool.ID, + ctx, *nodePool.OwnerID, nodePool.ID, openapi.PutNodePoolStatusesJSONRequestBody(statusInput), test.WithAuthToken(ctx), ) Expect(err).NotTo(HaveOccurred(), "Error updating nodepool status: %v", err) @@ -245,14 +245,14 @@ func TestNodePoolStatusGet(t *testing.T) { ) // Use nodePool.OwnerID as the cluster_id parameter _, err = client.PutNodePoolStatusesWithResponse( - ctx, nodePool.OwnerID, nodePool.ID, + ctx, *nodePool.OwnerID, nodePool.ID, openapi.PutNodePoolStatusesJSONRequestBody(statusInput), test.WithAuthToken(ctx), ) Expect(err).NotTo(HaveOccurred()) } // Get all statuses for the nodepool - resp, err := client.GetNodePoolsStatusesWithResponse(ctx, nodePool.OwnerID, nodePool.ID, nil, test.WithAuthToken(ctx)) + resp, err := client.GetNodePoolsStatusesWithResponse(ctx, *nodePool.OwnerID, nodePool.ID, nil, test.WithAuthToken(ctx)) Expect(err).NotTo(HaveOccurred(), "Error getting nodepool statuses: %v", err) Expect(resp.StatusCode()).To(Equal(http.StatusOK)) Expect(resp.JSON200).NotTo(BeNil()) @@ -539,7 +539,7 @@ func TestNodePoolStatusPut_FirstUnknownAccepted(t *testing.T) { // First report with Unknown Available condition: should be accepted resp, err := client.PutNodePoolStatusesWithResponse( - ctx, nodePool.OwnerID, nodePool.ID, + ctx, *nodePool.OwnerID, nodePool.ID, openapi.PutNodePoolStatusesJSONRequestBody(statusInput), test.WithAuthToken(ctx), ) Expect(err).NotTo(HaveOccurred(), "Error updating nodepool status: %v", err) @@ -548,7 +548,7 @@ func TestNodePoolStatusPut_FirstUnknownAccepted(t *testing.T) { // Verify status was stored listResp, err := client.GetNodePoolsStatusesWithResponse( - ctx, nodePool.OwnerID, nodePool.ID, nil, test.WithAuthToken(ctx), + ctx, *nodePool.OwnerID, nodePool.ID, nil, test.WithAuthToken(ctx), ) Expect(err).NotTo(HaveOccurred()) Expect(listResp.JSON200).NotTo(BeNil()) @@ -564,7 +564,7 @@ func TestNodePoolStatusPut_FirstUnknownAccepted(t *testing.T) { // Subsequent report with same adapter: should be rejected (204 No Content) resp2, err := client.PutNodePoolStatusesWithResponse( - ctx, nodePool.OwnerID, nodePool.ID, + ctx, *nodePool.OwnerID, nodePool.ID, openapi.PutNodePoolStatusesJSONRequestBody(statusInput), test.WithAuthToken(ctx), ) Expect(err).NotTo(HaveOccurred(), "Error updating nodepool status: %v", err) @@ -1215,7 +1215,7 @@ func TestNodePoolStatusPut_FutureObservedTimeRejected(t *testing.T) { } resp, err := client.PutNodePoolStatusesWithResponse( - ctx, nodePool.OwnerID, nodePool.ID, + ctx, *nodePool.OwnerID, nodePool.ID, openapi.PutNodePoolStatusesJSONRequestBody(statusInput), test.WithAuthToken(ctx), ) Expect(err).NotTo(HaveOccurred()) @@ -1258,7 +1258,7 @@ func TestNodePoolStatusPut_InvalidStatusRejected(t *testing.T) { ) resp, err := client.PutNodePoolStatusesWithResponse( - ctx, nodePool.OwnerID, nodePool.ID, + ctx, *nodePool.OwnerID, nodePool.ID, openapi.PutNodePoolStatusesJSONRequestBody(statusInput), test.WithAuthToken(ctx), ) Expect(err).NotTo(HaveOccurred(), "Test case: "+tc.name) diff --git a/test/integration/channels_test.go b/test/integration/channels_test.go index 7e3441c8..f0a77e01 100644 --- a/test/integration/channels_test.go +++ b/test/integration/channels_test.go @@ -55,25 +55,19 @@ func TestChannelCreate(t *testing.T) { channelName := fmt.Sprintf("channel-with-labels-%s", uuid.NewString()[:8]) channel := newChannelResource(channelName) - labels := map[string]string{ - "environment": "test", - "team": "platform", + channel.Labels = []api.ResourceLabel{ + {Key: "environment", Value: "test"}, + {Key: "team", Value: "platform"}, } - var err error - channel.Labels, err = json.Marshal(labels) - Expect(err).To(BeNil(), "should marshal labels") createdChannel, svcErr := svc.Create(t.Context(), "Channel", channel) Expect(svcErr).To(BeNil()) - Expect(createdChannel.Labels).NotTo(BeNil()) + Expect(createdChannel.Labels).NotTo(BeEmpty()) - // Get the resource and verify labels persisted retrieved, getErr := svc.Get(t.Context(), "Channel", createdChannel.ID) Expect(getErr).To(BeNil(), "should retrieve channel") - var retrievedLabels map[string]string - err = json.Unmarshal(retrieved.Labels, &retrievedLabels) - Expect(err).To(BeNil(), "should unmarshal retrieved labels") - Expect(retrievedLabels).To(Equal(labels), "retrieved labels should match") + retrievedLabels := labelsToMap(retrieved.Labels) + Expect(retrievedLabels).To(Equal(map[string]string{"environment": "test", "team": "platform"})) }) t.Run("SetsTimestamps", func(t *testing.T) { @@ -337,8 +331,7 @@ func TestChannelPatch(t *testing.T) { Expect(patched.Generation).To(Equal(int32(2)), "generation should increment to 2") // Verify labels - var patchedLabels map[string]string - json.Unmarshal(patched.Labels, &patchedLabels) + patchedLabels := labelsToMap(patched.Labels) Expect(patchedLabels).To(Equal(newLabels)) }) diff --git a/test/integration/clusters_test.go b/test/integration/clusters_test.go index 22d4becf..99c1dee8 100644 --- a/test/integration/clusters_test.go +++ b/test/integration/clusters_test.go @@ -992,7 +992,7 @@ func TestClusterSoftDelete(t *testing.T) { Expect(*delResp.JSON202.DeletedBy).To(Equal(account.Email)) // Verify cascade via direct DB query dbSession := h.DBFactory.New(ctx) - var nodePool api.NodePool + var nodePool api.Resource Expect(dbSession.First(&nodePool, "id = ?", nodePoolID).Error).NotTo(HaveOccurred()) Expect(nodePool.DeletedTime).NotTo(BeNil(), "nodepool should be soft-deleted after cluster cascade") Expect(nodePool.DeletedBy).NotTo(BeNil(), "nodepool deleted_by should be set after cluster cascade") @@ -1058,17 +1058,14 @@ func TestClusterSoftDelete(t *testing.T) { Expect(clusterReconciledCond.Status).To(Equal(openapi.ResourceConditionStatusFalse), "Cluster Reconciled should be False after soft-delete") // Then: Verify cascaded nodepool generation and Reconciled status - var npAfterDelete api.NodePool - Expect(dbSession.First(&npAfterDelete, "id = ?", nodePool.ID).Error).NotTo(HaveOccurred()) + var npAfterDelete api.Resource + Expect(dbSession.Preload("Conditions").First(&npAfterDelete, "id = ?", nodePool.ID).Error).NotTo(HaveOccurred()) Expect(npAfterDelete.Generation).To(Equal(initialNodePoolGeneration+1), "NodePool generation should be incremented after cascade soft-delete") - var conditions []api.ResourceCondition - err = json.Unmarshal(npAfterDelete.StatusConditions, &conditions) - Expect(err).NotTo(HaveOccurred(), "should be able to unmarshal nodepool status conditions") var reconciledCond *api.ResourceCondition - for i := range conditions { - if conditions[i].Type == api.ResourceConditionTypeReconciled { - reconciledCond = &conditions[i] + for i := range npAfterDelete.Conditions { + if npAfterDelete.Conditions[i].Type == api.ResourceConditionTypeReconciled { + reconciledCond = &npAfterDelete.Conditions[i] break } } @@ -1104,7 +1101,7 @@ func TestClusterSoftDelete(t *testing.T) { Expect(firstDeletedTime).NotTo(BeNil()) // Capture nodepool state after first delete dbSession := h.DBFactory.New(ctx) - var npAfterFirst api.NodePool + var npAfterFirst api.Resource Expect(dbSession.First(&npAfterFirst, "id = ?", nodePoolID).Error).NotTo(HaveOccurred()) npGenerationAfterFirst := npAfterFirst.Generation // When: second delete (idempotent) @@ -1114,7 +1111,7 @@ func TestClusterSoftDelete(t *testing.T) { Expect(resp2.StatusCode()).To(Equal(http.StatusAccepted)) Expect(resp2.JSON202.DeletedTime.Equal(*firstDeletedTime)).To(BeTrue(), "cluster deleted_time should not change on repeated delete") - var npAfterSecond api.NodePool + var npAfterSecond api.Resource Expect(dbSession.First(&npAfterSecond, "id = ?", nodePoolID).Error).NotTo(HaveOccurred()) Expect(npAfterSecond.DeletedTime.Equal(*npAfterFirst.DeletedTime)).To(BeTrue(), "nodepool deleted_time should not change on repeated delete") @@ -1178,7 +1175,7 @@ func TestClusterHardDelete(t *testing.T) { Expect(statusResp.StatusCode()).To(Equal(http.StatusCreated)) } - var clusterCheck api.Cluster + var clusterCheck api.Resource dbErr := dbSession.First(&clusterCheck, "id = ?", cluster.ID).Error Expect(dbErr).To(HaveOccurred(), "Cluster should be hard-deleted from DB") Expect(dbErr.Error()).To(ContainSubstring("record not found")) @@ -1248,7 +1245,7 @@ func TestClusterHardDelete(t *testing.T) { api.ResourceTypeCluster, cluster.ID, "validation").First(&adapterStatus).Error Expect(err).NotTo(HaveOccurred()) - var clusterCheck api.Cluster + var clusterCheck api.Resource err = dbSession.First(&clusterCheck, "id = ?", cluster.ID).Error Expect(err).NotTo(HaveOccurred()) }) @@ -1274,7 +1271,7 @@ func TestClusterHardDelete(t *testing.T) { Expect(delResp.StatusCode()).To(Equal(http.StatusAccepted)) clusterNewGeneration := delResp.JSON202.Generation - var nodePoolAfterDelete api.NodePool + var nodePoolAfterDelete api.Resource err = dbSession.First(&nodePoolAfterDelete, "id = ?", nodePool.ID).Error Expect(err).NotTo(HaveOccurred()) nodePoolNewGeneration := nodePoolAfterDelete.Generation @@ -1319,7 +1316,7 @@ func TestClusterHardDelete(t *testing.T) { Expect(statusResp.StatusCode()).To(Equal(http.StatusCreated)) } - var clusterCheck api.Cluster + var clusterCheck api.Resource err = dbSession.First(&clusterCheck, "id = ?", cluster.ID).Error Expect(err).NotTo(HaveOccurred()) Expect(clusterCheck.DeletedTime).NotTo(BeNil(), "Cluster should still be soft-deleted") @@ -1361,7 +1358,7 @@ func TestClusterHardDelete(t *testing.T) { Expect(loopErr).NotTo(HaveOccurred()) } - var nodePoolCheck api.NodePool + var nodePoolCheck api.Resource nodePoolErr := dbSession.First(&nodePoolCheck, "id = ?", nodePool.ID).Error Expect(nodePoolErr).To(HaveOccurred()) Expect(nodePoolErr.Error()).To(ContainSubstring("record not found")) @@ -1480,13 +1477,13 @@ func TestClusterForceDelete(t *testing.T) { Expect(forceDeleteResp.StatusCode()).To(Equal(http.StatusNoContent)) // Verify cluster is gone - var clusterCheck api.Cluster + var clusterCheck api.Resource dbErr := dbSession.First(&clusterCheck, "id = ?", cluster.ID).Error Expect(dbErr).To(HaveOccurred()) Expect(dbErr.Error()).To(ContainSubstring("record not found")) // Verify nodepools are gone - var nodePoolCheck api.NodePool + var nodePoolCheck api.Resource dbErr = dbSession.First(&nodePoolCheck, "id = ?", nodePool.ID).Error Expect(dbErr).To(HaveOccurred()) Expect(dbErr.Error()).To(ContainSubstring("record not found")) diff --git a/test/integration/node_pools_test.go b/test/integration/node_pools_test.go index 8f47c516..b07927b3 100644 --- a/test/integration/node_pools_test.go +++ b/test/integration/node_pools_test.go @@ -200,7 +200,7 @@ func TestNodePoolPatch_SetReconciledFalse(t *testing.T) { newLabels := map[string]string{"env": "staging"} patchResp, err := client.PatchNodePoolByIdWithResponse( - ctx, nodePool.OwnerID, nodePool.ID, + ctx, *nodePool.OwnerID, nodePool.ID, openapi.PatchNodePoolByIdJSONRequestBody{Labels: &newLabels}, test.WithAuthToken(ctx), ) @@ -767,7 +767,7 @@ func TestNodePoolHardDelete(t *testing.T) { Expect(statusResp.StatusCode()).To(Equal(http.StatusCreated)) } - var nodePoolCheck api.NodePool + var nodePoolCheck api.Resource dbErr := dbSession.First(&nodePoolCheck, "id = ?", nodePool.ID).Error Expect(dbErr).To(HaveOccurred(), "Nodepool should be hard-deleted from DB") Expect(dbErr.Error()).To(ContainSubstring("record not found")) @@ -778,7 +778,7 @@ func TestNodePoolHardDelete(t *testing.T) { Expect(err).NotTo(HaveOccurred()) Expect(adapterStatuses).To(BeEmpty(), "Adapter statuses should be hard-deleted") - var clusterCheck api.Cluster + var clusterCheck api.Resource err = dbSession.First(&clusterCheck, "id = ?", cluster.ID).Error Expect(err).NotTo(HaveOccurred()) }) @@ -832,7 +832,7 @@ func TestNodePoolHardDelete(t *testing.T) { Expect(err).NotTo(HaveOccurred()) Expect(statusResp.StatusCode()).To(Equal(http.StatusCreated)) - var nodePoolCheck api.NodePool + var nodePoolCheck api.Resource err = dbSession.First(&nodePoolCheck, "id = ?", nodePool.ID).Error Expect(err).NotTo(HaveOccurred()) Expect(nodePoolCheck.DeletedTime).NotTo(BeNil(), "Nodepool should still be soft-deleted") @@ -891,7 +891,7 @@ func TestNodePoolHardDelete(t *testing.T) { Expect(err).NotTo(HaveOccurred()) Expect(statusResp.StatusCode()).To(Equal(http.StatusCreated)) - var nodePoolCheck api.NodePool + var nodePoolCheck api.Resource err = dbSession.First(&nodePoolCheck, "id = ?", nodePool.ID).Error Expect(err).NotTo(HaveOccurred()) @@ -949,7 +949,7 @@ func TestNodePoolHardDelete(t *testing.T) { Expect(statusResp.StatusCode()).To(Equal(http.StatusCreated)) } - var nodePoolCheck api.NodePool + var nodePoolCheck api.Resource err = dbSession.First(&nodePoolCheck, "id = ?", nodePool.ID).Error Expect(err).NotTo(HaveOccurred()) Expect(nodePoolCheck.DeletedTime).To(BeNil(), "Nodepool should not be soft-deleted") @@ -1024,7 +1024,7 @@ func TestNodePoolForceDelete(t *testing.T) { Expect(forceDeleteResp.StatusCode()).To(Equal(http.StatusNoContent)) // Verify nodepool is gone - var nodePoolCheck api.NodePool + var nodePoolCheck api.Resource dbErr := dbSession.First(&nodePoolCheck, "id = ?", nodePool.ID).Error Expect(dbErr).To(HaveOccurred(), "Nodepool should be hard-deleted from DB after force-delete") Expect(dbErr.Error()).To(ContainSubstring("record not found")) @@ -1037,7 +1037,7 @@ func TestNodePoolForceDelete(t *testing.T) { Expect(adapterStatuses).To(BeEmpty()) // Verify parent cluster still exists - var clusterCheck api.Cluster + var clusterCheck api.Resource err = dbSession.First(&clusterCheck, "id = ?", cluster.ID).Error Expect(err).NotTo(HaveOccurred()) }) diff --git a/test/integration/resource_delete_test.go b/test/integration/resource_delete_test.go index 9735a045..f063721d 100644 --- a/test/integration/resource_delete_test.go +++ b/test/integration/resource_delete_test.go @@ -170,6 +170,55 @@ func TestResourceDelete_ParentChildWithRequiredAdapters(t *testing.T) { }) } +func TestExistsSoftDeletedByOwner(t *testing.T) { + t.Run("DetectsSoftDeletedChild", func(t *testing.T) { + RegisterTestingT(t) + svc, h := setupResourceTest(t) + + registry.UpdateDescriptor("Version", func(d *registry.EntityDescriptor) { + d.RequiredAdapters = []string{"test-adapter"} + }) + t.Cleanup(func() { + registry.UpdateDescriptor("Version", func(d *registry.EntityDescriptor) { + d.RequiredAdapters = nil + }) + }) + + channel := createTestChannel(t, svc) + version := createTestVersion(t, svc, fmt.Sprintf("v-%s", uuid.NewString()[:8]), channel.ID) + + // Soft-delete the version (RequiredAdapters forces soft-delete) + _, svcErr := svc.Delete(t.Context(), "Version", version.ID) + Expect(svcErr).To(BeNil()) + + // ExistsSoftDeletedByOwner raw SQL: kind IN (?) AND owner_id = ? AND deleted_time IS NOT NULL + dbSession := h.DBFactory.New(t.Context()) + var exists bool + err := dbSession.Raw( + "SELECT EXISTS(SELECT 1 FROM resources WHERE kind IN (?) AND owner_id = ? AND deleted_time IS NOT NULL)", + []string{"Version"}, channel.ID, + ).Scan(&exists).Error + Expect(err).To(BeNil()) + Expect(exists).To(BeTrue(), "should detect soft-deleted child via raw SQL") + + // Also verify with multiple kinds (the slice binding path) + err = dbSession.Raw( + "SELECT EXISTS(SELECT 1 FROM resources WHERE kind IN (?) AND owner_id = ? AND deleted_time IS NOT NULL)", + []string{"Version", "NonexistentKind"}, channel.ID, + ).Scan(&exists).Error + Expect(err).To(BeNil()) + Expect(exists).To(BeTrue(), "should detect with multi-kind IN clause") + + // Negative case: no soft-deleted children for a random owner + err = dbSession.Raw( + "SELECT EXISTS(SELECT 1 FROM resources WHERE kind IN (?) AND owner_id = ? AND deleted_time IS NOT NULL)", + []string{"Version"}, "nonexistent-owner", + ).Scan(&exists).Error + Expect(err).To(BeNil()) + Expect(exists).To(BeFalse(), "should not detect for nonexistent owner") + }) +} + // TestResourceDelete_WithoutRequiredAdapters tests delete behavior when Version // does NOT have RequiredAdapters configured (hard-delete scenario). func TestResourceDelete_WithoutRequiredAdapters(t *testing.T) { diff --git a/test/integration/resource_helpers.go b/test/integration/resource_helpers.go index ad1cf908..abb239ef 100644 --- a/test/integration/resource_helpers.go +++ b/test/integration/resource_helpers.go @@ -31,6 +31,14 @@ func checkResourceCount(ctx context.Context, h *test.Helper, ids []string, expec return nil } +func labelsToMap(labels []api.ResourceLabel) map[string]string { + m := make(map[string]string, len(labels)) + for _, l := range labels { + m[l.Key] = l.Value + } + return m +} + // newChannelResource creates a Channel resource struct with default spec. // Does NOT persist to database - use svc.Create() to persist. func newChannelResource(name string) *api.Resource { diff --git a/test/integration/transaction_test.go b/test/integration/transaction_test.go index 7af0b785..32fbd709 100644 --- a/test/integration/transaction_test.go +++ b/test/integration/transaction_test.go @@ -42,10 +42,11 @@ func TestTransactionRollbackWithDAO(t *testing.T) { // Step 2: Create a cluster using DAO // This simulates what happens in a real POST request handler - cluster := &api.Cluster{ + cluster := &api.Resource{ Meta: api.Meta{ ID: h.NewID(), }, + Kind: "Cluster", Name: "transaction-test-cluster", CreatedBy: "test-user", UpdatedBy: "test-user", @@ -58,7 +59,7 @@ func TestTransactionRollbackWithDAO(t *testing.T) { Expect(err).NotTo(HaveOccurred(), "Failed to create cluster via GORM") // Step 3: Verify cluster was inserted (in-transaction visibility) - var inTransactionCluster api.Cluster + var inTransactionCluster api.Resource err = g2.Where("id = ?", cluster.ID).First(&inTransactionCluster).Error Expect(err).NotTo(HaveOccurred(), "Should see cluster within transaction") @@ -74,7 +75,7 @@ func TestTransactionRollbackWithDAO(t *testing.T) { freshCtx := context.Background() freshSession := h.DBFactory.New(freshCtx) - var afterRollbackCluster api.Cluster + var afterRollbackCluster api.Resource err = freshSession.Where("id = ?", cluster.ID).First(&afterRollbackCluster).Error // THE MOMENT OF TRUTH @@ -104,10 +105,11 @@ func TestTransactionCommitWithDAO(t *testing.T) { Expect(err).NotTo(HaveOccurred(), "Failed to create transaction context") // Step 2: Create a cluster using DAO - cluster := &api.Cluster{ + cluster := &api.Resource{ Meta: api.Meta{ ID: h.NewID(), }, + Kind: "Cluster", Name: "transaction-commit-test", CreatedBy: "test-user", UpdatedBy: "test-user", @@ -126,7 +128,7 @@ func TestTransactionCommitWithDAO(t *testing.T) { freshCtx := context.Background() freshSession := h.DBFactory.New(freshCtx) - var committedCluster api.Cluster + var committedCluster api.Resource err = freshSession.Where("id = ?", cluster.ID).First(&committedCluster).Error // Should find the cluster @@ -146,16 +148,18 @@ func TestMultipleDAOOperationsInTransaction(t *testing.T) { Expect(err).NotTo(HaveOccurred(), "Failed to create transaction context") // Create two clusters in the same transaction - cluster1 := &api.Cluster{ + cluster1 := &api.Resource{ Meta: api.Meta{ID: h.NewID()}, + Kind: "Cluster", Name: "multi-op-cluster-1", CreatedBy: "test-user", UpdatedBy: "test-user", Spec: []byte(`{"op": 1}`), } - cluster2 := &api.Cluster{ + cluster2 := &api.Resource{ Meta: api.Meta{ID: h.NewID()}, + Kind: "Cluster", Name: "multi-op-cluster-2", CreatedBy: "test-user", UpdatedBy: "test-user", @@ -182,10 +186,10 @@ func TestMultipleDAOOperationsInTransaction(t *testing.T) { freshCtx := context.Background() freshSession := h.DBFactory.New(freshCtx) - var check1 api.Cluster + var check1 api.Resource err1 := freshSession.Where("id = ?", cluster1.ID).First(&check1).Error - var check2 api.Cluster + var check2 api.Resource err2 := freshSession.Where("id = ?", cluster2.ID).First(&check2).Error // Both should NOT exist if transactions work diff --git a/test/integration/versions_test.go b/test/integration/versions_test.go index b0a81a9b..27636b2f 100644 --- a/test/integration/versions_test.go +++ b/test/integration/versions_test.go @@ -113,25 +113,19 @@ func TestVersionCreate(t *testing.T) { channel := createTestChannel(t, svc) version := newVersionResource("version-with-labels", channel.ID) - labels := map[string]string{ - "environment": "test", - "team": "platform", + version.Labels = []api.ResourceLabel{ + {Key: "environment", Value: "test"}, + {Key: "team", Value: "platform"}, } - var err error - version.Labels, err = json.Marshal(labels) - Expect(err).To(BeNil(), "should marshal labels") createdVersion, svcErr := svc.Create(t.Context(), "Version", version) Expect(svcErr).To(BeNil()) - Expect(createdVersion.Labels).NotTo(BeNil()) + Expect(createdVersion.Labels).NotTo(BeEmpty()) - // Get the resource and verify labels persisted retrieved, getErr := svc.Get(t.Context(), "Version", createdVersion.ID) Expect(getErr).To(BeNil(), "should retrieve version") - var retrievedLabels map[string]string - jsonErr := json.Unmarshal(retrieved.Labels, &retrievedLabels) - Expect(jsonErr).To(BeNil(), "should unmarshal retrieved labels") - Expect(retrievedLabels).To(Equal(labels), "retrieved labels should match") + retrievedLabels := labelsToMap(retrieved.Labels) + Expect(retrievedLabels).To(Equal(map[string]string{"environment": "test", "team": "platform"})) }) t.Run("SetsTimestamps", func(t *testing.T) { @@ -302,23 +296,17 @@ func TestVersionList(t *testing.T) { // Create version with unique label version1 := newVersionResource("version-with-label-1", channel.ID) - labels := map[string]string{ - "environment": uniqueLabel, - } - var err error - version1.Labels, err = json.Marshal(labels) - Expect(err).To(BeNil(), "should marshal labels") + version1.Labels = []api.ResourceLabel{{Key: "environment", Value: uniqueLabel}} created1, svcErr := svc.Create(t.Context(), "Version", version1) Expect(svcErr).To(BeNil()) - Expect(created1.Labels).NotTo(BeNil()) + Expect(created1.Labels).NotTo(BeEmpty()) // Create another version with the same label version2 := newVersionResource("version-with-label-2", channel.ID) - version2.Labels, err = json.Marshal(labels) - Expect(err).To(BeNil(), "should marshal labels") + version2.Labels = []api.ResourceLabel{{Key: "environment", Value: uniqueLabel}} created2, svcErr := svc.Create(t.Context(), "Version", version2) Expect(svcErr).To(BeNil()) - Expect(created2.Labels).NotTo(BeNil()) + Expect(created2.Labels).NotTo(BeEmpty()) // Create version without the label version3 := createTestVersion(t, svc, "version-no-label", channel.ID) @@ -333,11 +321,8 @@ func TestVersionList(t *testing.T) { Expect(svcErr).To(BeNil(), "list by label should succeed") Expect(len(list)).To(BeNumerically(">=", 2), "should find at least 2 versions with the label") - // Verify all returned versions have the label for _, item := range list { - var itemLabels map[string]string - err := json.Unmarshal(item.Labels, &itemLabels) - Expect(err).To(BeNil()) + itemLabels := labelsToMap(item.Labels) Expect(itemLabels["environment"]).To(Equal(uniqueLabel)) } @@ -521,11 +506,8 @@ func TestVersionPatch(t *testing.T) { retrieved, getErr := svc.Get(t.Context(), "Version", version.ID) Expect(getErr).To(BeNil()) - // Verify updated version is incremented Expect(retrieved.Generation).To(Equal(int32(2))) - var retrievedLabelValues map[string]string - jsonErr := json.Unmarshal(retrieved.Labels, &retrievedLabelValues) - Expect(jsonErr).To(BeNil()) + retrievedLabelValues := labelsToMap(retrieved.Labels) Expect(retrievedLabelValues).To(Equal(newLabels), "patched labels should match retrieved labels") })