Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion docs/api-operator-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
28 changes: 27 additions & 1 deletion openapi/openapi.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions pkg/api/cluster_types.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package api

import (
"fmt"
"time"

"github.com/google/uuid"
"gorm.io/datatypes"
"gorm.io/gorm"
)
Expand All @@ -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"`
Expand All @@ -34,15 +37,21 @@ 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 {
c.Generation = 1
}
// 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
}
Expand Down
94 changes: 94 additions & 0 deletions pkg/api/cluster_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package api
import (
"testing"

"github.com/google/uuid"
. "github.com/onsi/gomega"
)

Expand Down Expand Up @@ -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())
}
12 changes: 10 additions & 2 deletions pkg/api/node_pool_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"time"

"github.com/google/uuid"
"gorm.io/datatypes"
"gorm.io/gorm"
)
Expand All @@ -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"`
Expand All @@ -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 {
Expand All @@ -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
}
Expand Down
Loading