Skip to content
Merged
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
32 changes: 28 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ HyperFleet API - Simple REST API for cluster lifecycle management. Provides CRUD

### Technology Stack

- **Language**: Go 1.24.9
- **Language**: Go 1.24 or higher
- **API Definition**: TypeSpec → OpenAPI 3.0.3
- **Code Generation**: openapi-generator-cli v7.16.0
- **Database**: PostgreSQL with GORM ORM
Expand All @@ -30,7 +30,7 @@ HyperFleet API - Simple REST API for cluster lifecycle management. Provides CRUD

```
hyperfleet-api/
├── cmd/hyperfleet/ # Application entry point
├── cmd/hyperfleet-api/ # Application entry point
├── pkg/
│ ├── api/ # API models and handlers
│ │ ├── openapi/ # Generated Go models from OpenAPI
Expand Down Expand Up @@ -181,8 +181,24 @@ All list endpoints return consistent pagination metadata:
- `?page=N` - Page number (default: 1)
- `?pageSize=N` - Items per page (default: 100)

**Search Parameters (clusters only):**
- `?search=name='cluster-name'` - Filter by name
**Search Parameters:**
- Uses TSL (Tree Search Language) query syntax
- Supported fields: `name`, `status.phase`, `labels.<key>`
- Supported operators: `=`, `in`, `and`, `or`
- Examples:
```bash
# Simple query
curl -G http://localhost:8000/api/hyperfleet/v1/clusters \
--data-urlencode "search=name='my-cluster'"

# AND query
curl -G http://localhost:8000/api/hyperfleet/v1/clusters \
--data-urlencode "search=status.phase='Ready' and labels.env='production'"

# OR query
curl -G http://localhost:8000/api/hyperfleet/v1/clusters \
--data-urlencode "search=labels.env='dev' or labels.env='staging'"
```

## Development Workflow

Expand Down Expand Up @@ -541,6 +557,14 @@ curl -X POST http://localhost:8000/api/hyperfleet/v1/clusters/$CLUSTER_ID/status

# 4. Get cluster with aggregated status
curl http://localhost:8000/api/hyperfleet/v1/clusters/$CLUSTER_ID | jq

# 5. Search with AND condition
curl -G http://localhost:8000/api/hyperfleet/v1/clusters \
--data-urlencode "search=status.phase='Ready' and labels.env='production'" | jq

# 6. Search with OR condition
curl -G http://localhost:8000/api/hyperfleet/v1/clusters \
--data-urlencode "search=labels.env='dev' or labels.env='staging'" | jq
```

## License
Expand Down
6 changes: 5 additions & 1 deletion openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,10 @@ components:
schema:
type: string
explode: false
description: |
Filter results using TSL (Tree Search Language) query syntax.

Examples: `status.phase='NotReady'`, `name in ('c1','c2')`, `labels.region='us-east'`
schemas:
APIResource:
type: object
Expand Down Expand Up @@ -711,7 +715,7 @@ components:
type: string
minLength: 3
maxLength: 63
pattern: ^[a-z0-9-]+$
pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
description: Cluster name (unique)
spec:
allOf:
Expand Down
19 changes: 17 additions & 2 deletions pkg/api/resource_id.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
package api

import "github.com/segmentio/ksuid"
import (
"encoding/base32"

"github.com/segmentio/ksuid"
)

// NewID generates a new unique identifier using KSUID with lowercase Base32 encoding.
// The resulting 32-character lowercase string is compatible with Kubernetes DNS-1123
// subdomain naming requirements, making it suitable for use as Kubernetes resource names
// and labels. The KSUID provides time-based ordering (second precision) and global
// uniqueness without requiring a central server.
func NewID() string {
return ksuid.New().String()
return uidEncoding.EncodeToString(ksuid.New().Bytes())
}

// uidAlphabet is the lowercase alphabet used to encode unique identifiers.
const uidAlphabet = "0123456789abcdefghijklmnopqrstuv"

// uidEncoding is the lowercase variant of Base32 used to encode unique identifiers.
var uidEncoding = base32.NewEncoding(uidAlphabet).WithPadding(base32.NoPadding)
67 changes: 67 additions & 0 deletions pkg/api/resource_id_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package api

import (
"regexp"
"testing"
)

func TestNewID(t *testing.T) {
// Generate multiple IDs to test uniqueness, format, and length
ids := make(map[string]bool)
for i := 0; i < 100; i++ {
id := NewID()

// Verify length is 32 characters
if len(id) != 32 {
t.Errorf("Expected ID length 32, got %d: %s", len(id), id)
}

// Verify only lowercase letters (a-v) and digits (0-9)
if !regexp.MustCompile(`^[0-9a-v]{32}$`).MatchString(id) {
t.Errorf("ID contains invalid characters (should be lowercase 0-9a-v): %s", id)
}

// Verify uniqueness
if ids[id] {
t.Errorf("Duplicate ID generated: %s", id)
}
ids[id] = true
}
}

func TestNewID_TimeOrdering(t *testing.T) {
// KSUID uses second-level timestamps, so IDs generated within the same second
// will have the same timestamp prefix. Time ordering is only guaranteed for IDs
// generated in different seconds.
id1 := NewID()

// For practical testing, we verify consistency and uniqueness within the same second
// rather than waiting for the next second (which would slow down tests significantly).
// In production, most ID generations will be more than 1 second apart.
id2 := NewID()

if len(id1) != 32 || len(id2) != 32 {
t.Errorf("IDs should have consistent length of 32")
}

// Verify ID uniqueness even within the same second
if id1 == id2 {
t.Errorf("IDs should be unique even within the same second: %s == %s", id1, id2)
}
}

func TestNewID_K8sCompatible(t *testing.T) {
id := NewID()

// Verify DNS-1123 subdomain compatibility:
// - Must contain only lowercase letters, digits, '-', and '.'
// - Must start and end with alphanumeric characters
// - Maximum length is 253 characters
//
// Our IDs contain only lowercase letters (a-v) and digits (0-9), with a fixed
// length of 32 characters, so they are fully compatible.
dns1123Pattern := regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`)
if !dns1123Pattern.MatchString(id) {
t.Errorf("ID is not DNS-1123 subdomain compatible: %s", id)
}
}
9 changes: 9 additions & 0 deletions pkg/db/migrations/202511111044_add_clusters.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,19 @@ func addClusters() *gormigrate.Migration {
return err
}

// Create index on status_last_updated_time for search optimization
// Sentinel queries frequently filter by this field to find stale resources
if err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_clusters_status_last_updated_time ON clusters(status_last_updated_time);").Error; err != nil {
return err
}

return nil
},
Rollback: func(tx *gorm.DB) error {
// Drop indexes first
if err := tx.Exec("DROP INDEX IF EXISTS idx_clusters_status_last_updated_time;").Error; err != nil {
return err
}
if err := tx.Exec("DROP INDEX IF EXISTS idx_clusters_status_phase;").Error; err != nil {
return err
}
Expand Down
9 changes: 9 additions & 0 deletions pkg/db/migrations/202511111055_add_node_pools.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ func addNodePools() *gormigrate.Migration {
return err
}

// Create index on status_last_updated_time for search optimization
// Sentinel queries frequently filter by this field to find stale resources
if err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_node_pools_status_last_updated_time ON node_pools(status_last_updated_time);").Error; err != nil {
return err
}

// Add foreign key constraint to clusters
addFKSQL := `
ALTER TABLE node_pools
Expand All @@ -90,6 +96,9 @@ func addNodePools() *gormigrate.Migration {
}

// Drop indexes
if err := tx.Exec("DROP INDEX IF EXISTS idx_node_pools_status_last_updated_time;").Error; err != nil {
return err
}
if err := tx.Exec("DROP INDEX IF EXISTS idx_node_pools_status_phase;").Error; err != nil {
return err
}
Expand Down
48 changes: 47 additions & 1 deletion pkg/db/sql_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package db
import (
"fmt"
"reflect"
"regexp"
"strings"

"github.com/jinzhu/inflection"
Expand All @@ -11,6 +12,23 @@ 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_]+$`)

// validateLabelKey validates a label key to prevent SQL injection
// through field name interpolation. Only allows lowercase letters, digits, and underscores.
func validateLabelKey(key string) *errors.ServiceError {
if key == "" {
return errors.BadRequest("label key cannot be empty")
}

if !labelKeyPattern.MatchString(key) {
return errors.BadRequest("label key '%s' is invalid: must contain only lowercase letters, digits, and underscores", key)
}

return nil
}

// Check if a field name starts with properties.
func startsWithProperties(s string) bool {
return strings.HasPrefix(s, "properties.")
Expand All @@ -33,6 +51,15 @@ func hasProperty(n tsl.Node) bool {
return true
}

// Field mapping rules for user-friendly syntax to database columns
var statusFieldMappings = map[string]string{
"status.last_updated_time": "status_last_updated_time",
"status.last_transition_time": "status_last_transition_time",
"status.phase": "status_phase",
"status.observed_generation": "status_observed_generation",
"status.conditions": "status_conditions",
}

// getField gets the sql field associated with a name.
func getField(name string, disallowedFields map[string]string) (field string, err *errors.ServiceError) {
// We want to accept names with trailing and leading spaces
Expand All @@ -44,6 +71,25 @@ func getField(name string, disallowedFields map[string]string) (field string, er
return
}

// Map user-friendly labels.xxx syntax to JSONB query: labels->>'xxx'
if strings.HasPrefix(trimmedName, "labels.") {
key := strings.TrimPrefix(trimmedName, "labels.")

// Validate label key to prevent SQL injection
if validationErr := validateLabelKey(key); validationErr != nil {
err = validationErr
return
}

field = fmt.Sprintf("labels->>'%s'", key)
return
}

// Map user-friendly status.xxx syntax to database columns
if mapped, ok := statusFieldMappings[trimmedName]; ok {
trimmedName = mapped
}

// Check for nested field, e.g., subscription_labels.key
checkName := trimmedName
fieldParts := strings.Split(trimmedName, ".")
Expand All @@ -55,7 +101,7 @@ func getField(name string, disallowedFields map[string]string) (field string, er
checkName = fieldParts[1]
}

// Check for allowed fields
// Check for disallowed fields
_, ok := disallowedFields[checkName]
if ok {
err = errors.BadRequest("%s is not a valid field name", name)
Expand Down
7 changes: 4 additions & 3 deletions pkg/handlers/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import (
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors"
)

// Cluster/NodePool name pattern: lowercase alphanumeric and hyphens
var namePattern = regexp.MustCompile(`^[a-z0-9-]+$`)
// 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])?$`)

func validateNotEmpty(i interface{}, fieldName string, field string) validate {
return func() *errors.ServiceError {
Expand Down Expand Up @@ -72,7 +73,7 @@ func validateName(i interface{}, fieldName string, field string, minLen, maxLen

// Check pattern: lowercase alphanumeric and hyphens only
if !namePattern.MatchString(name) {
return errors.Validation("%s must contain only lowercase letters, numbers, and hyphens", field)
return errors.Validation("%s must start and end with lowercase letter or number, and contain only lowercase letters, numbers, and hyphens", field)
}

return nil
Expand Down
3 changes: 3 additions & 0 deletions pkg/handlers/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ func TestValidateName_InvalidCharacters(t *testing.T) {
"test@cluster", // special char
"test/cluster", // slash
"test\\cluster", // backslash
"-test", // starts with hyphen
"test-", // ends with hyphen
"-test-", // starts and ends with hyphen
}

for _, name := range invalidNames {
Expand Down
26 changes: 18 additions & 8 deletions pkg/services/generic.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,15 @@ type sqlGenericService struct {
}

var (
SearchDisallowedFields = map[string]map[string]string{}
allFieldsAllowed = map[string]string{}
SearchDisallowedFields = map[string]map[string]string{
"Cluster": {
"spec": "spec", // Provider-specific field, not searchable
},
"NodePool": {
"spec": "spec", // Provider-specific field, not searchable
},
}
allFieldsAllowed = map[string]string{}
)

// wrap all needed pieces for the LIST function
Expand Down Expand Up @@ -161,8 +168,14 @@ func (s *sqlGenericService) buildSearchValues(listCtx *listContext, d *dao.Gener
if err != nil {
return "", nil, errors.BadRequest("Failed to parse search query: %s", listCtx.args.Search)
}
// apply field name mapping first (status.xxx -> status_xxx, labels.xxx -> labels->>'xxx')
// this must happen before treeWalkForRelatedTables to prevent treating "status" and "labels" as related resources
tslTree, serviceErr := db.FieldNameWalk(tslTree, *listCtx.disallowedFields)
if serviceErr != nil {
return "", nil, serviceErr
}
// find all related tables
tslTree, serviceErr := s.treeWalkForRelatedTables(listCtx, tslTree, d)
tslTree, serviceErr = s.treeWalkForRelatedTables(listCtx, tslTree, d)
if serviceErr != nil {
return "", nil, serviceErr
}
Expand Down Expand Up @@ -332,11 +345,8 @@ func (s *sqlGenericService) treeWalkForAddingTableName(listCtx *listContext, tsl
}

func (s *sqlGenericService) treeWalkForSqlizer(listCtx *listContext, tslTree tsl.Node) (squirrel.Sqlizer, *errors.ServiceError) {
// Check field names in tree
tslTree, serviceErr := db.FieldNameWalk(tslTree, *listCtx.disallowedFields)
if serviceErr != nil {
return nil, serviceErr
}
// Note: FieldNameWalk is now called earlier in buildSearchValues to ensure field mapping
// happens before related table detection. No need to call it again here.

// Convert the search tree into SQL [Squirrel] filter
sqlizer, err := sqlFilter.Walk(tslTree)
Expand Down
Loading