diff --git a/docs/api-operator-guide.md b/docs/api-operator-guide.md index 831c77f..042b8ae 100644 --- a/docs/api-operator-guide.md +++ b/docs/api-operator-guide.md @@ -71,7 +71,8 @@ Every resource (cluster, nodepool) has these key fields: ``` Resource (e.g., Cluster) -├── id (32-character unique identifier, auto-generated) +├── id (32-character KSUID identifier, auto-generated) +├── uuid (RFC4122 UUID identifier, auto-generated) ├── kind (Resource "Cluster" or "NodePool") ├── name (Unique identifier) ├── spec (JSONB - desired state, provider-specific) @@ -89,11 +90,23 @@ Resource (e.g., Cluster) | Field | Type | Purpose | Managed By | |-------|------|---------|------------| +| **id** | string | Primary identifier (KSUID format: 27-character Base32, time-sortable, DNS-safe) | API (auto-generated at creation) | +| **uuid** | string | RFC4122 UUID identifier (36-character hyphenated format) for platform integrations requiring standard UUID format | API (auto-generated at creation) | | **spec** | JSONB | Desired state - what you want the resource to look like | User (via API) | | **status** | JSONB | Observed state - what adapters report about the resource | API (aggregated from adapter reports) | | **generation** | int32 | Version counter that increments when spec changes | API (automatic) | | **labels** | JSONB | Key-value pairs for filtering and organization | User (via API) | +**Dual Identifier System:** + +HyperFleet resources use two auto-generated identifiers: + +- **id** (KSUID): The primary identifier used in API paths and internal references. KSUID (K-Sortable Unique ID) is a 27-character time-sortable identifier that's DNS-safe and compatible with Kubernetes resource naming. Format example: `2p5dtahuv0svfdsc28i309rn3rq6ucqi` (no hyphens). + +- **uuid** (RFC4122 UUID): A standards-compliant UUID for platform integrations that require UUID format (e.g., Hypershift spec.clusterID). Format example: `550e8400-e29b-41d4-a716-446655440000` (hyphenated). + +Both identifiers are immutable (assigned at creation and never change), but serve different purposes. Use `id` for HyperFleet API operations and `uuid` for external platform integrations requiring RFC4122 UUIDs. + **How the Resource Model Works:** 1. **Desired State (spec)**: When you create or update a resource, you provide a `spec` containing the desired configuration (e.g., cluster region, version, node count). The API stores this without business-logic interpretation, but validates it against the OpenAPI schema when a schema is configured. @@ -126,6 +139,7 @@ POST /api/hyperfleet/v1/clusters GET /api/hyperfleet/v1/clusters/{id} { "id": "2opdkciuv7itslp5guihuhckhp8da0uo", + "uuid": "550e8400-e29b-41d4-a716-446655440000", "kind": "Cluster", "name": "my-cluster", "generation": 1, diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 24e44b0..349482a 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.0.0 info: title: HyperFleet API - version: 1.0.4 + version: 1.0.5 contact: name: HyperFleet Team license: @@ -745,6 +745,14 @@ components: updated_by: type: string format: email + uuid: + type: string + format: uuid + description: |- + RFC4122 UUID identifier for the cluster (immutable, assigned at creation). + This provides a stable, standards-compliant identifier for platform integrations + that require UUID format (e.g., Hypershift spec.clusterID). + example: 550e8400-e29b-41d4-a716-446655440000 generation: type: integer format: int32 @@ -756,6 +764,7 @@ components: kind: Cluster id: cluster-123 href: https://api.hyperfleet.com/v1/clusters/cluster-123 + uuid: 550e8400-e29b-41d4-a716-446655440000 name: cluster-123 labels: environment: production @@ -1002,6 +1011,14 @@ components: updated_by: type: string format: email + uuid: + type: string + format: uuid + description: |- + RFC4122 UUID identifier for the nodepool (immutable, assigned at creation). + This provides a stable, standards-compliant identifier for platform integrations + that require UUID format. + example: 7c9e6679-7425-40de-944b-e07fc1f90ae7 generation: type: integer format: int32 @@ -1015,6 +1032,7 @@ components: kind: NodePool id: nodepool-123 href: https://api.hyperfleet.com/v1/nodepools/nodepool-123 + uuid: 7c9e6679-7425-40de-944b-e07fc1f90ae7 name: worker-pool-1 labels: environment: production @@ -1144,6 +1162,14 @@ components: updated_by: type: string format: email + uuid: + type: string + format: uuid + description: |- + RFC4122 UUID identifier for the nodepool (immutable, assigned at creation). + This provides a stable, standards-compliant identifier for platform integrations + that require UUID format. + example: 7c9e6679-7425-40de-944b-e07fc1f90ae7 generation: type: integer format: int32 diff --git a/pkg/api/cluster_types.go b/pkg/api/cluster_types.go index bed22eb..d676d9d 100644 --- a/pkg/api/cluster_types.go +++ b/pkg/api/cluster_types.go @@ -1,8 +1,10 @@ package api import ( + "fmt" "time" + "github.com/google/uuid" "gorm.io/datatypes" "gorm.io/gorm" ) @@ -11,6 +13,7 @@ import ( type Cluster struct { Meta Kind string `json:"kind" gorm:"default:'Cluster'"` + UUID string `json:"uuid" gorm:"uniqueIndex;size:36;not null"` 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"` @@ -34,7 +37,13 @@ func (l ClusterList) Index() ClusterIndex { func (c *Cluster) BeforeCreate(tx *gorm.DB) error { now := time.Now() - c.ID = NewID() + // Only generate if not already set (idempotent) + if c.ID == "" { + c.ID = NewID() + } + if c.UUID == "" { + c.UUID = uuid.New().String() + } c.CreatedTime = now c.UpdatedTime = now if c.Generation == 0 { @@ -42,7 +51,7 @@ func (c *Cluster) BeforeCreate(tx *gorm.DB) error { } // Set Href if not already set if c.Href == "" { - c.Href = "/api/hyperfleet/v1/clusters/" + c.ID + c.Href = fmt.Sprintf("/api/hyperfleet/v1/clusters/%s", c.ID) } return nil } diff --git a/pkg/api/cluster_types_test.go b/pkg/api/cluster_types_test.go index a5cbd8a..8754f52 100644 --- a/pkg/api/cluster_types_test.go +++ b/pkg/api/cluster_types_test.go @@ -3,6 +3,7 @@ package api import ( "testing" + "github.com/google/uuid" . "github.com/onsi/gomega" ) @@ -173,3 +174,96 @@ func TestCluster_BeforeCreate_Complete(t *testing.T) { Expect(cluster.Generation).To(Equal(int32(1))) Expect(cluster.Href).To(Equal("/api/hyperfleet/v1/clusters/" + cluster.ID)) } + +// TestCluster_BeforeCreate_UUIDGeneration tests UUID auto-generation +func TestCluster_BeforeCreate_UUIDGeneration(t *testing.T) { + RegisterTestingT(t) + + cluster := &Cluster{ + Name: "test-cluster", + } + + err := cluster.BeforeCreate(nil) + Expect(err).To(BeNil()) + Expect(cluster.UUID).ToNot(BeEmpty()) + + // Verify UUID format (RFC4122 v4) + parsedUUID, err := uuid.Parse(cluster.UUID) + Expect(err).To(BeNil()) + Expect(parsedUUID.String()).To(Equal(cluster.UUID)) +} + +// TestCluster_BeforeCreate_UUIDUniqueness tests that each cluster gets a unique UUID +func TestCluster_BeforeCreate_UUIDUniqueness(t *testing.T) { + RegisterTestingT(t) + + cluster1 := &Cluster{Name: "cluster-1"} + cluster2 := &Cluster{Name: "cluster-2"} + cluster3 := &Cluster{Name: "cluster-3"} + + _ = cluster1.BeforeCreate(nil) + _ = cluster2.BeforeCreate(nil) + _ = cluster3.BeforeCreate(nil) + + // All UUIDs should be different + Expect(cluster1.UUID).ToNot(Equal(cluster2.UUID)) + Expect(cluster1.UUID).ToNot(Equal(cluster3.UUID)) + Expect(cluster2.UUID).ToNot(Equal(cluster3.UUID)) + + // All should be valid UUIDs + _, err1 := uuid.Parse(cluster1.UUID) + _, err2 := uuid.Parse(cluster2.UUID) + _, err3 := uuid.Parse(cluster3.UUID) + Expect(err1).To(BeNil()) + Expect(err2).To(BeNil()) + Expect(err3).To(BeNil()) +} + +// TestCluster_BeforeCreate_UUIDAndIDDifferent tests that UUID and ID are independent +func TestCluster_BeforeCreate_UUIDAndIDDifferent(t *testing.T) { + RegisterTestingT(t) + + cluster := &Cluster{Name: "test-cluster"} + + err := cluster.BeforeCreate(nil) + Expect(err).To(BeNil()) + + // UUID and ID should both be set + Expect(cluster.UUID).ToNot(BeEmpty()) + Expect(cluster.ID).ToNot(BeEmpty()) + + // UUID and ID should be different (UUID is hyphenated, ID is KSUID) + Expect(cluster.UUID).ToNot(Equal(cluster.ID)) + + // UUID should contain hyphens (RFC4122 format) + Expect(cluster.UUID).To(ContainSubstring("-")) + + // ID should not contain hyphens (KSUID format) + Expect(cluster.ID).ToNot(ContainSubstring("-")) +} + +// TestCluster_BeforeCreate_UUIDImmutable tests UUID is set once and preserved on subsequent calls +func TestCluster_BeforeCreate_UUIDImmutable(t *testing.T) { + RegisterTestingT(t) + + cluster := &Cluster{Name: "test-cluster"} + + // First BeforeCreate call + err := cluster.BeforeCreate(nil) + Expect(err).To(BeNil()) + firstUUID := cluster.UUID + firstID := cluster.ID + + // Second BeforeCreate call (simulating accidental double-call) + err = cluster.BeforeCreate(nil) + Expect(err).To(BeNil()) + + // UUID and ID should be preserved (idempotent behavior) + // This prevents data corruption if BeforeCreate is accidentally called multiple times + Expect(cluster.UUID).To(Equal(firstUUID)) + Expect(cluster.ID).To(Equal(firstID)) + + // UUID should still be valid + _, err1 := uuid.Parse(cluster.UUID) + Expect(err1).To(BeNil()) +} diff --git a/pkg/api/node_pool_types.go b/pkg/api/node_pool_types.go index ca15870..8a5c9e3 100644 --- a/pkg/api/node_pool_types.go +++ b/pkg/api/node_pool_types.go @@ -4,6 +4,7 @@ import ( "fmt" "time" + "github.com/google/uuid" "gorm.io/datatypes" "gorm.io/gorm" ) @@ -13,6 +14,7 @@ type NodePool struct { Cluster *Cluster `gorm:"foreignKey:OwnerID;references:ID"` Meta Kind string `json:"kind" gorm:"default:'NodePool'"` + UUID string `json:"uuid" gorm:"uniqueIndex;size:36;not null"` Name string `json:"name" gorm:"size:15;not null"` UpdatedBy string `json:"updated_by" gorm:"size:255;not null"` Href string `json:"href,omitempty" gorm:"size:500"` @@ -39,7 +41,13 @@ func (l NodePoolList) Index() NodePoolIndex { func (np *NodePool) BeforeCreate(tx *gorm.DB) error { now := time.Now() - np.ID = NewID() + // Only generate if not already set (idempotent) + if np.ID == "" { + np.ID = NewID() + } + if np.UUID == "" { + np.UUID = uuid.New().String() + } np.CreatedTime = now np.UpdatedTime = now if np.Generation == 0 { @@ -54,7 +62,7 @@ func (np *NodePool) BeforeCreate(tx *gorm.DB) error { } // Set OwnerHref if not already set if np.OwnerHref == "" { - np.OwnerHref = "/api/hyperfleet/v1/clusters/" + np.OwnerID + np.OwnerHref = fmt.Sprintf("/api/hyperfleet/v1/clusters/%s", np.OwnerID) } return nil } diff --git a/pkg/api/node_pool_types_test.go b/pkg/api/node_pool_types_test.go index 97f23c4..cc7e175 100644 --- a/pkg/api/node_pool_types_test.go +++ b/pkg/api/node_pool_types_test.go @@ -3,6 +3,7 @@ package api import ( "testing" + "github.com/google/uuid" . "github.com/onsi/gomega" ) @@ -213,3 +214,97 @@ func TestNodePool_BeforeCreate_Complete(t *testing.T) { 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")) } + +// TestNodePool_BeforeCreate_UUIDGeneration tests UUID auto-generation +func TestNodePool_BeforeCreate_UUIDGeneration(t *testing.T) { + RegisterTestingT(t) + + nodepool := &NodePool{ + Name: "test-nodepool", + OwnerID: "cluster-123", + } + + err := nodepool.BeforeCreate(nil) + Expect(err).To(BeNil()) + Expect(nodepool.UUID).ToNot(BeEmpty()) + + // Verify UUID format (RFC4122 v4) + parsedUUID, err := uuid.Parse(nodepool.UUID) + Expect(err).To(BeNil()) + Expect(parsedUUID.String()).To(Equal(nodepool.UUID)) +} + +// TestNodePool_BeforeCreate_UUIDUniqueness tests that each nodepool gets a unique UUID +func TestNodePool_BeforeCreate_UUIDUniqueness(t *testing.T) { + RegisterTestingT(t) + + nodepool1 := &NodePool{Name: "nodepool-1", OwnerID: "cluster-123"} + nodepool2 := &NodePool{Name: "nodepool-2", OwnerID: "cluster-123"} + nodepool3 := &NodePool{Name: "nodepool-3", OwnerID: "cluster-123"} + + _ = nodepool1.BeforeCreate(nil) + _ = nodepool2.BeforeCreate(nil) + _ = nodepool3.BeforeCreate(nil) + + // All UUIDs should be different + Expect(nodepool1.UUID).ToNot(Equal(nodepool2.UUID)) + Expect(nodepool1.UUID).ToNot(Equal(nodepool3.UUID)) + Expect(nodepool2.UUID).ToNot(Equal(nodepool3.UUID)) + + // All should be valid UUIDs + _, err1 := uuid.Parse(nodepool1.UUID) + _, err2 := uuid.Parse(nodepool2.UUID) + _, err3 := uuid.Parse(nodepool3.UUID) + Expect(err1).To(BeNil()) + Expect(err2).To(BeNil()) + Expect(err3).To(BeNil()) +} + +// TestNodePool_BeforeCreate_UUIDAndIDDifferent tests that UUID and ID are independent +func TestNodePool_BeforeCreate_UUIDAndIDDifferent(t *testing.T) { + RegisterTestingT(t) + + nodepool := &NodePool{Name: "test-nodepool", OwnerID: "cluster-123"} + + err := nodepool.BeforeCreate(nil) + Expect(err).To(BeNil()) + + // UUID and ID should both be set + Expect(nodepool.UUID).ToNot(BeEmpty()) + Expect(nodepool.ID).ToNot(BeEmpty()) + + // UUID and ID should be different (UUID is hyphenated, ID is KSUID) + Expect(nodepool.UUID).ToNot(Equal(nodepool.ID)) + + // UUID should contain hyphens (RFC4122 format) + Expect(nodepool.UUID).To(ContainSubstring("-")) + + // ID should not contain hyphens (KSUID format) + Expect(nodepool.ID).ToNot(ContainSubstring("-")) +} + +// TestNodePool_BeforeCreate_UUIDImmutable tests UUID is set once and preserved on subsequent calls +func TestNodePool_BeforeCreate_UUIDImmutable(t *testing.T) { + RegisterTestingT(t) + + nodepool := &NodePool{Name: "test-nodepool", OwnerID: "cluster-123"} + + // First BeforeCreate call + err := nodepool.BeforeCreate(nil) + Expect(err).To(BeNil()) + firstUUID := nodepool.UUID + firstID := nodepool.ID + + // Second BeforeCreate call (simulating accidental double-call) + err = nodepool.BeforeCreate(nil) + Expect(err).To(BeNil()) + + // UUID and ID should be preserved (idempotent behavior) + // This prevents data corruption if BeforeCreate is accidentally called multiple times + Expect(nodepool.UUID).To(Equal(firstUUID)) + Expect(nodepool.ID).To(Equal(firstID)) + + // UUID should still be valid + _, err1 := uuid.Parse(nodepool.UUID) + Expect(err1).To(BeNil()) +} diff --git a/pkg/api/presenters/cluster.go b/pkg/api/presenters/cluster.go index c4a3178..24b32e4 100644 --- a/pkg/api/presenters/cluster.go +++ b/pkg/api/presenters/cluster.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" + "github.com/google/uuid" openapi_types "github.com/oapi-codegen/runtime/types" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" @@ -97,6 +98,16 @@ func PresentCluster(cluster *api.Cluster) (openapi.Cluster, error) { } } + // Parse UUID from string to openapi_types.UUID + var uuidPtr *openapi_types.UUID + if cluster.UUID != "" { + parsedUUID, err := uuid.Parse(cluster.UUID) + if err != nil { + return openapi.Cluster{}, fmt.Errorf("invalid UUID format in database: %w", err) + } + uuidPtr = &parsedUUID + } + result := openapi.Cluster{ CreatedBy: toEmail(cluster.CreatedBy), CreatedTime: cluster.CreatedTime, @@ -112,6 +123,7 @@ func PresentCluster(cluster *api.Cluster) (openapi.Cluster, error) { }, UpdatedBy: toEmail(cluster.UpdatedBy), UpdatedTime: cluster.UpdatedTime, + Uuid: uuidPtr, } return result, nil diff --git a/pkg/api/presenters/cluster_test.go b/pkg/api/presenters/cluster_test.go index 3a61f3f..d6a9380 100644 --- a/pkg/api/presenters/cluster_test.go +++ b/pkg/api/presenters/cluster_test.go @@ -409,3 +409,23 @@ func TestPresentCluster_MalformedStatusConditions(t *testing.T) { Expect(err).ToNot(BeNil()) Expect(err.Error()).To(ContainSubstring("failed to unmarshal cluster status conditions")) } + +// TestPresentCluster_MalformedUUID tests error handling for malformed UUID in database +func TestPresentCluster_MalformedUUID(t *testing.T) { + RegisterTestingT(t) + + cluster := &api.Cluster{ + Kind: "Cluster", + Name: "malformed-uuid-cluster", + UUID: "not-a-valid-uuid", // Invalid UUID format + Spec: []byte("{}"), + Labels: []byte("{}"), + StatusConditions: []byte("[]"), + } + cluster.ID = "cluster-malformed-uuid" + + _, err := PresentCluster(cluster) + + Expect(err).ToNot(BeNil()) + Expect(err.Error()).To(ContainSubstring("invalid UUID format in database")) +} diff --git a/pkg/api/presenters/node_pool.go b/pkg/api/presenters/node_pool.go index 5864bac..e14769f 100644 --- a/pkg/api/presenters/node_pool.go +++ b/pkg/api/presenters/node_pool.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" + "github.com/google/uuid" + openapi_types "github.com/oapi-codegen/runtime/types" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" ) @@ -96,6 +98,16 @@ func PresentNodePool(nodePool *api.NodePool) (openapi.NodePool, error) { } } + // Parse UUID from string to openapi_types.UUID + var uuidPtr *openapi_types.UUID + if nodePool.UUID != "" { + parsedUUID, err := uuid.Parse(nodePool.UUID) + if err != nil { + return openapi.NodePool{}, fmt.Errorf("invalid UUID format in database: %w", err) + } + uuidPtr = &parsedUUID + } + kind := nodePool.Kind result := openapi.NodePool{ CreatedBy: toEmail(nodePool.CreatedBy), @@ -117,6 +129,7 @@ func PresentNodePool(nodePool *api.NodePool) (openapi.NodePool, error) { }, UpdatedBy: toEmail(nodePool.UpdatedBy), UpdatedTime: nodePool.UpdatedTime, + Uuid: uuidPtr, } return result, nil diff --git a/pkg/api/presenters/node_pool_test.go b/pkg/api/presenters/node_pool_test.go index c6a4b1b..b4c6ba0 100644 --- a/pkg/api/presenters/node_pool_test.go +++ b/pkg/api/presenters/node_pool_test.go @@ -458,3 +458,24 @@ func TestPresentNodePool_MalformedStatusConditions(t *testing.T) { Expect(err).ToNot(BeNil()) Expect(err.Error()).To(ContainSubstring("failed to unmarshal nodepool status conditions")) } + +// TestPresentNodePool_MalformedUUID tests error handling for malformed UUID in database +func TestPresentNodePool_MalformedUUID(t *testing.T) { + RegisterTestingT(t) + + nodePool := &api.NodePool{ + Kind: "NodePool", + Name: "malformed-uuid-nodepool", + UUID: "not-a-valid-uuid", // Invalid UUID format + Spec: []byte("{}"), + Labels: []byte("{}"), + OwnerID: "cluster-123", + StatusConditions: []byte("[]"), + } + nodePool.ID = "nodepool-malformed-uuid" + + _, err := PresentNodePool(nodePool) + + Expect(err).ToNot(BeNil()) + Expect(err.Error()).To(ContainSubstring("invalid UUID format in database")) +} diff --git a/pkg/db/migrations/202603230001_add_cluster_uuid.go b/pkg/db/migrations/202603230001_add_cluster_uuid.go new file mode 100644 index 0000000..66148b3 --- /dev/null +++ b/pkg/db/migrations/202603230001_add_cluster_uuid.go @@ -0,0 +1,63 @@ +package migrations + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// addClusterUUID adds RFC4122 UUID field to clusters table. +// UUIDs are immutable identifiers for platform integrations requiring standard UUID format. +func addClusterUUID() *gormigrate.Migration { + return &gormigrate.Migration{ + ID: "202603230001", + Migrate: func(tx *gorm.DB) error { + // Step 1: Add uuid column (nullable initially for backfill) + if err := tx.Exec(` + ALTER TABLE clusters + ADD COLUMN uuid VARCHAR(36); + `).Error; err != nil { + return err + } + + // Step 2: Backfill UUIDs for existing clusters using PostgreSQL's gen_random_uuid() + if err := tx.Exec(` + UPDATE clusters + SET uuid = gen_random_uuid()::text + WHERE uuid IS NULL; + `).Error; err != nil { + return err + } + + // Step 3: Make column NOT NULL after backfill + if err := tx.Exec(` + ALTER TABLE clusters + ALTER COLUMN uuid SET NOT NULL; + `).Error; err != nil { + return err + } + + // Step 4: Add unique constraint (only for non-deleted records) + if err := tx.Exec(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_clusters_uuid + ON clusters(uuid) WHERE deleted_at IS NULL; + `).Error; err != nil { + return err + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + // Drop index first + if err := tx.Exec("DROP INDEX IF EXISTS idx_clusters_uuid;").Error; err != nil { + return err + } + + // Drop column + if err := tx.Exec("ALTER TABLE clusters DROP COLUMN IF EXISTS uuid;").Error; err != nil { + return err + } + + return nil + }, + } +} diff --git a/pkg/db/migrations/202603230002_add_nodepool_uuid.go b/pkg/db/migrations/202603230002_add_nodepool_uuid.go new file mode 100644 index 0000000..c0de944 --- /dev/null +++ b/pkg/db/migrations/202603230002_add_nodepool_uuid.go @@ -0,0 +1,63 @@ +package migrations + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// addNodePoolUUID adds RFC4122 UUID field to node_pools table. +// UUIDs are immutable identifiers for platform integrations requiring standard UUID format. +func addNodePoolUUID() *gormigrate.Migration { + return &gormigrate.Migration{ + ID: "202603230002", + Migrate: func(tx *gorm.DB) error { + // Step 1: Add uuid column (nullable initially for backfill) + if err := tx.Exec(` + ALTER TABLE node_pools + ADD COLUMN uuid VARCHAR(36); + `).Error; err != nil { + return err + } + + // Step 2: Backfill UUIDs for existing nodepools using PostgreSQL's gen_random_uuid() + if err := tx.Exec(` + UPDATE node_pools + SET uuid = gen_random_uuid()::text + WHERE uuid IS NULL; + `).Error; err != nil { + return err + } + + // Step 3: Make column NOT NULL after backfill + if err := tx.Exec(` + ALTER TABLE node_pools + ALTER COLUMN uuid SET NOT NULL; + `).Error; err != nil { + return err + } + + // Step 4: Add unique constraint (only for non-deleted records) + if err := tx.Exec(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_node_pools_uuid + ON node_pools(uuid) WHERE deleted_at IS NULL; + `).Error; err != nil { + return err + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + // Drop index first + if err := tx.Exec("DROP INDEX IF EXISTS idx_node_pools_uuid;").Error; err != nil { + return err + } + + // Drop column + if err := tx.Exec("ALTER TABLE node_pools DROP COLUMN IF EXISTS uuid;").Error; err != nil { + return err + } + + return nil + }, + } +} diff --git a/pkg/db/migrations/migration_structs.go b/pkg/db/migrations/migration_structs.go index 00fe82e..64707ad 100755 --- a/pkg/db/migrations/migration_structs.go +++ b/pkg/db/migrations/migration_structs.go @@ -32,6 +32,8 @@ var MigrationList = []*gormigrate.Migration{ addNodePools(), addAdapterStatus(), addConditionsGinIndex(), + addClusterUUID(), + addNodePoolUUID(), } // Model represents the base model struct. All entities will have this struct embedded. diff --git a/test/integration/clusters_test.go b/test/integration/clusters_test.go index b20b632..bb1f078 100644 --- a/test/integration/clusters_test.go +++ b/test/integration/clusters_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/google/uuid" . "github.com/onsi/gomega" "gopkg.in/resty.v1" @@ -851,3 +852,80 @@ func TestClusterPost_MissingSpec(t *testing.T) { Expect(ok).To(BeTrue()) Expect(detail).To(ContainSubstring("spec is required")) } + +// TestClusterUUID tests that Cluster UUID is generated and returned in API responses +func TestClusterUUID(t *testing.T) { + h, client := test.RegisterIntegration(t) + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + + // Test 1: POST creates cluster with UUID + clusterInput := openapi.ClusterCreateRequest{ + Kind: util.PtrString("Cluster"), + Name: "test-uuid-cluster", + Spec: map[string]interface{}{"test": "spec"}, + } + + postResp, err := client.PostClusterWithResponse( + ctx, openapi.PostClusterJSONRequestBody(clusterInput), test.WithAuthToken(ctx), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(postResp.StatusCode()).To(Equal(http.StatusCreated)) + + clusterOutput := postResp.JSON201 + Expect(clusterOutput).NotTo(BeNil()) + Expect(clusterOutput.Uuid).NotTo(BeNil(), "UUID should be set on POST response") + + // Convert openapi_types.UUID to string + uuidStr := clusterOutput.Uuid.String() + Expect(uuidStr).NotTo(BeEmpty(), "UUID should not be empty") + + // Test 2: Verify UUID is valid RFC4122 format + parsedUUID, err := uuid.Parse(uuidStr) + Expect(err).To(BeNil(), "UUID should be valid RFC4122 format") + Expect(parsedUUID.String()).To(Equal(uuidStr), "UUID should match parsed value") + + // Test 3: Verify UUID contains hyphens (RFC4122 format) + Expect(uuidStr).To(ContainSubstring("-"), "UUID should contain hyphens") + + // Test 4: Verify UUID is different from ID (KSUID) + Expect(uuidStr).NotTo(Equal(*clusterOutput.Id), "UUID should differ from ID") + Expect(*clusterOutput.Id).NotTo(ContainSubstring("-"), "ID (KSUID) should not contain hyphens") + + // Test 5: GET returns the same UUID + getResp, err := client.GetClusterByIdWithResponse(ctx, *clusterOutput.Id, nil, test.WithAuthToken(ctx)) + Expect(err).NotTo(HaveOccurred()) + Expect(getResp.StatusCode()).To(Equal(http.StatusOK)) + + getCluster := getResp.JSON200 + Expect(getCluster).NotTo(BeNil()) + Expect(getCluster.Uuid).NotTo(BeNil(), "UUID should be present in GET response") + + getUUIDStr := getCluster.Uuid.String() + Expect(getUUIDStr).To(Equal(uuidStr), "UUID should be consistent between POST and GET") + + // Test 6: Create another cluster and verify UUID is unique + clusterInput2 := openapi.ClusterCreateRequest{ + Kind: util.PtrString("Cluster"), + Name: "test-uuid-cluster-2", + Spec: map[string]interface{}{"test": "spec"}, + } + + postResp2, err := client.PostClusterWithResponse( + ctx, openapi.PostClusterJSONRequestBody(clusterInput2), test.WithAuthToken(ctx), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(postResp2.StatusCode()).To(Equal(http.StatusCreated)) + + clusterOutput2 := postResp2.JSON201 + Expect(clusterOutput2.Uuid).NotTo(BeNil()) + + uuid2Str := clusterOutput2.Uuid.String() + Expect(uuid2Str).NotTo(Equal(uuidStr), "Each cluster should have a unique UUID") + + // Test 7: Verify second UUID is also valid RFC4122 + parsedUUID2, err := uuid.Parse(uuid2Str) + Expect(err).To(BeNil(), "Second UUID should also be valid RFC4122 format") + Expect(parsedUUID2.String()).To(Equal(uuid2Str)) +} diff --git a/test/integration/node_pools_test.go b/test/integration/node_pools_test.go index 9f6f1a1..e8ddfb2 100644 --- a/test/integration/node_pools_test.go +++ b/test/integration/node_pools_test.go @@ -6,6 +6,7 @@ import ( "net/http" "testing" + "github.com/google/uuid" . "github.com/onsi/gomega" "gopkg.in/resty.v1" @@ -447,3 +448,73 @@ func TestNodePoolPost_MissingSpec(t *testing.T) { Expect(ok).To(BeTrue()) Expect(detail).To(ContainSubstring("spec is required")) } + +// TestNodePoolUUID tests that NodePool UUID is generated and returned in API responses +func TestNodePoolUUID(t *testing.T) { + h, client := test.RegisterIntegration(t) + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + + // Create a parent cluster first + cluster, err := h.Factories.NewClusters(h.NewID()) + Expect(err).NotTo(HaveOccurred()) + + // Test 1: POST creates nodepool with UUID + kind := kindNodePool + nodePoolInput := openapi.NodePoolCreateRequest{ + Kind: &kind, + Name: "test-uuid-np", + Spec: map[string]interface{}{"test": "spec"}, + } + + postResp, err := client.CreateNodePoolWithResponse( + ctx, cluster.ID, openapi.CreateNodePoolJSONRequestBody(nodePoolInput), test.WithAuthToken(ctx), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(postResp.StatusCode()).To(Equal(http.StatusCreated)) + + nodePoolOutput := postResp.JSON201 + Expect(nodePoolOutput).NotTo(BeNil()) + Expect(nodePoolOutput.Uuid).NotTo(BeNil(), "UUID should be set on POST response") + + // Convert openapi_types.UUID to string + uuidStr := nodePoolOutput.Uuid.String() + Expect(uuidStr).NotTo(BeEmpty(), "UUID should not be empty") + + // Test 2: Verify UUID is valid RFC4122 format + parsedUUID, err := uuid.Parse(uuidStr) + Expect(err).To(BeNil(), "UUID should be valid RFC4122 format") + Expect(parsedUUID.String()).To(Equal(uuidStr), "UUID should match parsed value") + + // Test 3: Verify UUID contains hyphens (RFC4122 format) + Expect(uuidStr).To(ContainSubstring("-"), "UUID should contain hyphens") + + // Test 4: Verify UUID is different from ID (KSUID) + Expect(uuidStr).NotTo(Equal(*nodePoolOutput.Id), "UUID should differ from ID") + Expect(*nodePoolOutput.Id).NotTo(ContainSubstring("-"), "ID (KSUID) should not contain hyphens") + + // Test 5: Create another nodepool and verify UUID is unique + nodePoolInput2 := openapi.NodePoolCreateRequest{ + Kind: &kind, + Name: "test-uuid-np-2", + Spec: map[string]interface{}{"test": "spec"}, + } + + postResp2, err := client.CreateNodePoolWithResponse( + ctx, cluster.ID, openapi.CreateNodePoolJSONRequestBody(nodePoolInput2), test.WithAuthToken(ctx), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(postResp2.StatusCode()).To(Equal(http.StatusCreated)) + + nodePoolOutput2 := postResp2.JSON201 + Expect(nodePoolOutput2.Uuid).NotTo(BeNil()) + + uuid2Str := nodePoolOutput2.Uuid.String() + Expect(uuid2Str).NotTo(Equal(uuidStr), "Each nodepool should have a unique UUID") + + // Test 6: Verify second UUID is also valid RFC4122 + parsedUUID2, err := uuid.Parse(uuid2Str) + Expect(err).To(BeNil(), "Second UUID should also be valid RFC4122 format") + Expect(parsedUUID2.String()).To(Equal(uuid2Str)) +}