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
23 changes: 23 additions & 0 deletions pkg/api/presenters/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"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/util"
)

Expand Down Expand Up @@ -90,6 +91,11 @@ func PresentResource(r *api.Resource) openapi.Resource {
}
}

if len(r.References) > 0 {
refs := presentResourceReferences(r.References)
resp.References = &refs
}

return resp
}

Expand All @@ -107,6 +113,23 @@ func PresentResourceList(resources api.ResourceList, paging *api.PagingMeta) ope
}
}

func presentResourceReferences(refs []api.ResourceReference) api.ReferenceMap {
result := make(api.ReferenceMap)
for _, ref := range refs {
id := ref.TargetID
objRef := openapi.ObjectReference{
Id: &id,
Kind: ref.TargetKind,
}
if targetDesc, ok := registry.Get(ref.TargetKind); ok {
href := fmt.Sprintf("/api/hyperfleet/v1/%s/%s", targetDesc.Plural, ref.TargetID)
objRef.Href = &href
}
result[ref.RefType] = append(result[ref.RefType], objRef)
}
return result
}

func presentResourceConditions(conditions []api.ResourceCondition) []openapi.ResourceCondition {
if len(conditions) == 0 {
return []openapi.ResourceCondition{}
Expand Down
45 changes: 45 additions & 0 deletions pkg/api/presenters/resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"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"
)

func TestConvertResource(t *testing.T) {
Expand Down Expand Up @@ -217,6 +218,50 @@ func TestPresentResource_WithEmptyConditions(t *testing.T) {
Expect(string(body)).To(ContainSubstring(`"status":{"conditions":[]}`))
}

func TestPresentResource_WithReferences(t *testing.T) {
RegisterTestingT(t)

registry.Reset()
registry.Register(registry.EntityDescriptor{
Kind: "WifConfig",
Plural: "wifconfigs",
})

now := time.Now()
resource := &api.Resource{
Meta: api.Meta{ID: "res-1", CreatedTime: now, UpdatedTime: now},
Kind: "Cluster",
Name: "my-cluster",
Spec: datatypes.JSON(`{}`),
CreatedBy: "user@test.com",
UpdatedBy: "user@test.com",
References: []api.ResourceReference{
{
SourceID: "res-1",
RefType: "wif_config",
TargetID: "wif-1",
TargetKind: "WifConfig",
},
{
SourceID: "res-1",
RefType: "wif_config",
TargetID: "wif-2",
TargetKind: "WifConfig",
},
},
}

resp := PresentResource(resource)
Expect(resp.References).ToNot(BeNil())
refs := *resp.References
Expect(refs).To(HaveKey("wif_config"))
Expect(refs["wif_config"]).To(HaveLen(2))
Expect(*refs["wif_config"][0].Id).To(Equal("wif-1"))
Expect(refs["wif_config"][0].Kind).To(Equal("WifConfig"))
Expect(*refs["wif_config"][0].Href).To(Equal("/api/hyperfleet/v1/wifconfigs/wif-1"))
Expect(*refs["wif_config"][1].Id).To(Equal("wif-2"))
}

func TestPresentResourceList(t *testing.T) {
RegisterTestingT(t)

Expand Down
7 changes: 6 additions & 1 deletion pkg/api/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,18 @@ type Resource struct {
Labels datatypes.JSON `json:"labels,omitempty" gorm:"type:jsonb"`
Spec datatypes.JSON `json:"spec" gorm:"type:jsonb;not null"`
Conditions []ResourceCondition `json:"-" gorm:"foreignKey:ResourceID;references:ID"`
References []ResourceReference `json:"-" gorm:"foreignKey:SourceID;references:ID"`
Generation int32 `json:"generation" gorm:"default:1;not null"`
}

// ReferenceMap is the API-level representation of resource references,
// keyed by ref type (e.g. "wif_config") with a list of object references per type.
type ReferenceMap = map[string][]openapi.ObjectReference

type ResourcePatch struct {
Spec map[string]interface{}
Labels map[string]string
References map[string][]openapi.ObjectReference
References ReferenceMap
}

type ResourceList []*Resource
Expand Down
20 changes: 20 additions & 0 deletions pkg/api/resource_reference.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package api

// ResourceReference stores a single non-ownership association between two resources.
// Table: resource_references. Natural composite PK (source_id, ref_type, target_id).
type ResourceReference struct {
SourceID string `json:"source_id" gorm:"primaryKey;column:source_id;size:255"`
RefType string `json:"ref_type" gorm:"primaryKey;column:ref_type;size:255"`
TargetID string `json:"target_id" gorm:"primaryKey;column:target_id;size:255"`
TargetKind string `json:"target_kind" gorm:"column:target_kind;size:100;not null"`
}

func (ResourceReference) TableName() string {
return "resource_references"
}

// ResourceSummary carries kind + name for error messages (e.g. FindReferencer 409).
type ResourceSummary struct {
Kind string
Name string
}
16 changes: 16 additions & 0 deletions pkg/dao/mocks/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,19 @@ func (d *resourceDaoMock) GetByID(_ context.Context, id string) (*api.Resource,
}
return nil, gorm.ErrRecordNotFound
}

func (d *resourceDaoMock) ReplaceReferences(_ context.Context, _ string, _ []api.ResourceReference) error {
return nil
}

func (d *resourceDaoMock) FindReferencer(_ context.Context, _ string) (*api.ResourceSummary, error) {
return nil, nil
}

func (d *resourceDaoMock) ClearTargetReferences(_ context.Context, _ string) error {
return nil
}

func (d *resourceDaoMock) FindSourceIDsByRef(_ context.Context, _, _ string) ([]string, error) {
return nil, nil
}
83 changes: 78 additions & 5 deletions pkg/dao/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ type ResourceDao interface {
FindByKindAndOwner(ctx context.Context, kind, ownerID string) (api.ResourceList, error)
FindByKindAndOwnerForUpdate(ctx context.Context, kind, ownerID string) (api.ResourceList, error)
GetByID(ctx context.Context, id string) (*api.Resource, error)
ReplaceReferences(ctx context.Context, sourceID string, refs []api.ResourceReference) error
FindReferencer(ctx context.Context, targetID string) (*api.ResourceSummary, error)
ClearTargetReferences(ctx context.Context, targetID string) error
FindSourceIDsByRef(ctx context.Context, refType, targetID string) ([]string, error)
}

var _ ResourceDao = &sqlResourceDao{}
Expand All @@ -38,7 +42,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("References").
Take(&resource, "kind = ? AND id = ?", kind, id).Error; err != nil {
return nil, err
}
return &resource, nil
Expand All @@ -47,8 +52,9 @@ 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(
&resource, "kind = ? AND id = ?", kind, id).Error; err != nil {
if err := g2.Clauses(clause.Locking{Strength: "UPDATE"}).
Preload("Conditions").Preload("References").
Take(&resource, "kind = ? AND id = ?", kind, id).Error; err != nil {
return nil, err
}
return &resource, nil
Expand All @@ -57,7 +63,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("References").
Take(&resource, "kind = ? AND id = ? AND owner_id = ?", kind, id, ownerID).Error; err != nil {
return nil, err
}
Expand Down Expand Up @@ -148,7 +154,7 @@ func (d *sqlResourceDao) FindByKindAndOwner(ctx context.Context, kind, ownerID s
func (d *sqlResourceDao) GetByID(ctx context.Context, id string) (*api.Resource, error) {
g2 := d.sessionFactory.New(ctx)
var resource api.Resource
if err := g2.Preload("Conditions").Take(&resource, "id = ?", id).Error; err != nil {
if err := g2.Preload("Conditions").Preload("References").Take(&resource, "id = ?", id).Error; err != nil {
return nil, err
}
return &resource, nil
Expand All @@ -165,3 +171,70 @@ func (d *sqlResourceDao) FindByKindAndOwnerForUpdate(
}
return resources, nil
}

func (d *sqlResourceDao) ReplaceReferences(
ctx context.Context, sourceID string, refs []api.ResourceReference,
) error {
g2 := d.sessionFactory.New(ctx)
if err := g2.Where("source_id = ?", sourceID).Delete(&api.ResourceReference{}).Error; err != nil {
db.MarkForRollback(ctx, err)
return err
}
for i := range refs {
refs[i].SourceID = sourceID
}
if len(refs) > 0 {
if err := g2.Create(&refs).Error; err != nil {
db.MarkForRollback(ctx, err)
return err
}
}
return nil
}

// FindReferencer returns the first non-deleted resource that references targetID,
// or nil if none exists. Used as an existence check for 409 conflict responses.
func (d *sqlResourceDao) FindReferencer(
ctx context.Context, targetID string,
) (*api.ResourceSummary, error) {
g2 := d.sessionFactory.New(ctx)
var summary api.ResourceSummary
err := g2.Model(&api.ResourceReference{}).
Select("resources.kind, resources.name").
Joins("JOIN resources ON resource_references.source_id = resources.id").
Where("resource_references.target_id = ? AND resources.deleted_time IS NULL", targetID).
Limit(1).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the Limit(1) intentional? As the Function name is FindReferencer(s).

Scan(&summary).Error
if err != nil {
return nil, err
}
if summary.Kind == "" {
return nil, nil
}
return &summary, nil
}

// ClearTargetReferences removes all inbound references pointing at targetID.
// Called by forceDeleteResourceTree before hard-deleting a referenced target,
// because the target_id FK uses ON DELETE RESTRICT.
func (d *sqlResourceDao) ClearTargetReferences(ctx context.Context, targetID string) error {
g2 := d.sessionFactory.New(ctx)
if err := g2.Where("target_id = ?", targetID).Delete(&api.ResourceReference{}).Error; err != nil {
db.MarkForRollback(ctx, err)
return err
}
return nil
}

func (d *sqlResourceDao) FindSourceIDsByRef(
ctx context.Context, refType, targetID string,
) ([]string, error) {
g2 := d.sessionFactory.New(ctx)
var ids []string
if err := g2.Model(&api.ResourceReference{}).
Where("ref_type = ? AND target_id = ?", refType, targetID).
Pluck("source_id", &ids).Error; err != nil {
return nil, err
}
return ids, nil
}
36 changes: 36 additions & 0 deletions pkg/db/migrations/202607021925_add_resource_references.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package migrations

import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)

func addResourceReferences() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "202607021925",
Migrate: func(tx *gorm.DB) error {
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS resource_references (
source_id VARCHAR(255) NOT NULL,
ref_type VARCHAR(255) NOT NULL,
target_id VARCHAR(255) NOT NULL,
target_kind VARCHAR(100) NOT NULL,
PRIMARY KEY (source_id, ref_type, target_id),
FOREIGN KEY (source_id) REFERENCES resources(id) ON DELETE CASCADE,
FOREIGN KEY (target_id) REFERENCES resources(id) ON DELETE RESTRICT
);`).Error; err != nil {
return err
}

if err := tx.Exec(
`CREATE INDEX IF NOT EXISTS idx_resource_references_target ON resource_references (target_id);`,
).Error; err != nil {
return err
}

return nil
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec(`DROP TABLE IF EXISTS resource_references`).Error
},
}
}
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(),
addResourceReferences(),
}

// Model represents the base model struct. All entities will have this struct embedded.
Expand Down
12 changes: 11 additions & 1 deletion pkg/handlers/resource_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ func (h *ResourceHandler) Create(w http.ResponseWriter, r *http.Request) {
return nil, errors.GeneralError("failed to convert resource: %v", err)
}

resource, svcErr := h.service.Create(ctx, h.descriptor.Kind, resource)
refs := extractReferences(req.References)
resource, svcErr := h.service.Create(ctx, h.descriptor.Kind, resource, refs)
if svcErr != nil {
return nil, svcErr
}
Expand Down Expand Up @@ -207,6 +208,15 @@ func convertResourcePatch(req *openapi.ResourcePatchRequest) *api.ResourcePatch
return patch
}

// extractReferences unwraps the optional references pointer from an API request.
// Returns nil when no references are supplied (nil pointer), or the map value.
func extractReferences(refs *api.ReferenceMap) api.ReferenceMap {
if refs == nil {
return nil
}
return *refs
}

func (h *ResourceHandler) ForceDelete(w http.ResponseWriter, r *http.Request) {
var req openapi.ForceDeleteRequest
cfg := &handlerConfig{
Expand Down
8 changes: 5 additions & 3 deletions pkg/handlers/resource_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ func TestResourceHandler_Create(t *testing.T) {
name: "Success - creates channel",
body: `{"kind":"Channel","name":"stable","spec":{"is_default":true}}`,
setupMock: func(mock *services.MockResourceService) {
mock.EXPECT().Create(gomock.Any(), "Channel", gomock.AssignableToTypeOf(&api.Resource{})).Return(&api.Resource{
mock.EXPECT().Create(
gomock.Any(), "Channel", gomock.AssignableToTypeOf(&api.Resource{}), gomock.Any(),
).Return(&api.Resource{
Meta: api.Meta{ID: "ch-123", CreatedTime: now, UpdatedTime: now},
Kind: "Channel",
Name: "stable",
Expand Down Expand Up @@ -96,7 +98,7 @@ func TestResourceHandler_Create(t *testing.T) {
name: "Error 409 - duplicate name",
body: `{"kind":"Channel","name":"stable","spec":{"is_default":true}}`,
setupMock: func(mock *services.MockResourceService) {
mock.EXPECT().Create(gomock.Any(), "Channel", gomock.AssignableToTypeOf(&api.Resource{})).
mock.EXPECT().Create(gomock.Any(), "Channel", gomock.AssignableToTypeOf(&api.Resource{}), gomock.Any()).
Return(nil, errors.Conflict("Channel with name 'stable' already exists"))
},
expectedStatusCode: http.StatusConflict,
Expand Down Expand Up @@ -378,7 +380,7 @@ func TestResourceHandler_CreateWithOwner(t *testing.T) {
Href: "/api/hyperfleet/v1/channels/ch-1",
Spec: datatypes.JSON(`{}`), CreatedBy: "u@t.com", UpdatedBy: "u@t.com",
}, nil)
mock.EXPECT().Create(gomock.Any(), "Version", gomock.AssignableToTypeOf(&api.Resource{})).
mock.EXPECT().Create(gomock.Any(), "Version", gomock.AssignableToTypeOf(&api.Resource{}), gomock.Any()).
Return(&api.Resource{
Meta: api.Meta{ID: "v-1", CreatedTime: now, UpdatedTime: now},
Kind: "Version", Name: "4-17-3",
Expand Down
3 changes: 2 additions & 1 deletion pkg/handlers/root_resource_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ func (h *RootResourceHandler) Create(w http.ResponseWriter, r *http.Request) {
if convErr != nil {
return nil, errors.GeneralError("failed to convert resource: %v", convErr)
}
resource, svcErr := h.service.Create(r.Context(), descriptor.Kind, resource)
refs := extractReferences(req.References)
resource, svcErr := h.service.Create(r.Context(), descriptor.Kind, resource, refs)
if svcErr != nil {
return nil, svcErr
}
Expand Down
Loading