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
42 changes: 20 additions & 22 deletions pkg/api/presenters/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
}

Expand All @@ -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,
Expand Down Expand Up @@ -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
}
8 changes: 5 additions & 3 deletions pkg/api/presenters/resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion pkg/api/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
31 changes: 31 additions & 0 deletions pkg/api/resource_label.go
Original file line number Diff line number Diff line change
@@ -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
}
12 changes: 7 additions & 5 deletions pkg/dao/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,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
Expand All @@ -45,7 +46,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.Clauses(clause.Locking{Strength: "UPDATE"}).Take(
if err := g2.Preload("Labels").Clauses(clause.Locking{Strength: "UPDATE"}).Take(
&resource, "kind = ? AND id = ?", kind, id).Error; err != nil {
return nil, err
}
Expand All @@ -55,7 +56,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
}
Expand Down Expand Up @@ -114,7 +115,7 @@ func (d *sqlResourceDao) ExistsByOwner(ctx context.Context, kind, ownerID string
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
Expand All @@ -123,7 +124,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
Expand Down
49 changes: 49 additions & 0 deletions pkg/dao/resource_label.go
Original file line number Diff line number Diff line change
@@ -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
}
29 changes: 29 additions & 0 deletions pkg/db/migrations/202607010001_add_resource_labels.go
Original file line number Diff line number Diff line change
@@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return nil
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
1 change: 1 addition & 0 deletions pkg/db/migrations/migration_structs.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ var MigrationList = []*gormigrate.Migration{
addResources(),
removeReadyCondition(),
addResourceConditions(),
addResourceLabels(),
}

// Model represents the base model struct. All entities will have this struct embedded.
Expand Down
7 changes: 6 additions & 1 deletion pkg/db/sql_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,13 @@ 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-1160): Resource labels moved to resource_labels table.
// Replace JSONB mapping below with JOIN-based query for Resource entities.
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 {
Expand Down
Loading