Skip to content

Commit c81607e

Browse files
Merge pull request #180 from tirthct/hyperfleet-1084
HYPERFLEET-1084 - feat: add generic resource data layer with entity registry
2 parents 40e6a31 + dfacfe8 commit c81607e

11 files changed

Lines changed: 1086 additions & 0 deletions

File tree

pkg/api/resource.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package api
2+
3+
import (
4+
"fmt"
5+
"time"
6+
7+
"gorm.io/datatypes"
8+
"gorm.io/gorm"
9+
10+
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry"
11+
)
12+
13+
// Resource is the generic GORM model for entity types managed by the entity
14+
// registry (Channel, Version, WIF Config, etc.). Entity kinds are
15+
// differentiated by the Kind field. Existing Cluster and NodePool types
16+
// are NOT migrated to this model.
17+
type Resource struct {
18+
Meta
19+
Kind string `json:"kind" gorm:"size:100;not null"`
20+
Name string `json:"name" gorm:"size:100;not null"`
21+
Href string `json:"href,omitempty" gorm:"size:500"`
22+
CreatedBy string `json:"created_by" gorm:"size:255;not null"`
23+
UpdatedBy string `json:"updated_by" gorm:"size:255;not null"`
24+
DeletedBy *string `json:"deleted_by,omitempty" gorm:"size:255"`
25+
DeletedTime *time.Time `json:"deleted_time,omitempty"`
26+
OwnerID *string `json:"owner_id,omitempty" gorm:"size:255"`
27+
OwnerKind *string `json:"owner_kind,omitempty" gorm:"size:100"`
28+
OwnerHref *string `json:"owner_href,omitempty" gorm:"size:500"`
29+
Spec datatypes.JSON `json:"spec" gorm:"type:jsonb;not null"`
30+
Labels datatypes.JSON `json:"labels,omitempty" gorm:"type:jsonb"`
31+
Generation int32 `json:"generation" gorm:"default:1;not null"`
32+
}
33+
34+
type (
35+
ResourceList []*Resource
36+
ResourceIndex map[string]*Resource
37+
)
38+
39+
// TODO: Evaluate the need for this method as part of
40+
// https://redhat.atlassian.net/browse/HYPERFLEET-1085 and remove if not needed
41+
func (l ResourceList) Index() ResourceIndex {
42+
index := ResourceIndex{}
43+
for _, o := range l {
44+
index[o.ID] = o
45+
}
46+
return index
47+
}
48+
49+
func (r Resource) TableName() string {
50+
return "resources"
51+
}
52+
53+
// BeforeCreate TODO: Validate the necessity for this as part of https://redhat.atlassian.net/browse/HYPERFLEET-1085
54+
func (r *Resource) BeforeCreate(tx *gorm.DB) error {
55+
if r.ID == "" {
56+
id, err := NewID()
57+
if err != nil {
58+
return fmt.Errorf("failed to generate resource ID: %w", err)
59+
}
60+
r.ID = id
61+
}
62+
63+
now := time.Now()
64+
if r.CreatedTime.IsZero() {
65+
r.CreatedTime = now
66+
}
67+
r.UpdatedTime = now
68+
if r.Generation == 0 {
69+
r.Generation = 1
70+
}
71+
72+
if r.Href == "" {
73+
desc := registry.MustGet(r.Kind)
74+
if r.OwnerID != nil && *r.OwnerID != "" {
75+
if r.OwnerKind == nil || *r.OwnerKind == "" {
76+
return fmt.Errorf("owner_kind is required when owner_id is set")
77+
}
78+
if r.OwnerHref == nil {
79+
parentDesc := registry.MustGet(*r.OwnerKind)
80+
ownerHref := fmt.Sprintf("/api/hyperfleet/v1/%s/%s",
81+
parentDesc.Plural, *r.OwnerID)
82+
r.OwnerHref = &ownerHref
83+
}
84+
r.Href = fmt.Sprintf("%s/%s/%s", *r.OwnerHref, desc.Plural, r.ID)
85+
} else {
86+
r.Href = fmt.Sprintf("/api/hyperfleet/v1/%s/%s", desc.Plural, r.ID)
87+
}
88+
}
89+
90+
return nil
91+
}
92+
93+
func (r *Resource) BeforeUpdate(tx *gorm.DB) error {
94+
r.UpdatedTime = time.Now()
95+
return nil
96+
}
97+
98+
func (r *Resource) MarkDeleted(by string, t time.Time) {
99+
r.DeletedTime = &t
100+
r.DeletedBy = &by
101+
}
102+
103+
func (r *Resource) IncrementGeneration() {
104+
r.Generation++
105+
}

pkg/api/resource_test.go

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
package api
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
. "github.com/onsi/gomega"
8+
9+
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry"
10+
)
11+
12+
func setupTestRegistry() {
13+
registry.Reset()
14+
registry.Register(registry.EntityDescriptor{
15+
Kind: "Channel",
16+
Plural: "channels",
17+
})
18+
registry.Register(registry.EntityDescriptor{
19+
Kind: "Version",
20+
Plural: "versions",
21+
ParentKind: "Channel",
22+
})
23+
}
24+
25+
func strPtr(s string) *string {
26+
return &s
27+
}
28+
29+
func TestResourceList_Index(t *testing.T) {
30+
RegisterTestingT(t)
31+
32+
emptyList := ResourceList{}
33+
emptyIndex := emptyList.Index()
34+
Expect(len(emptyIndex)).To(Equal(0))
35+
36+
r1 := &Resource{}
37+
r1.ID = "res-1"
38+
r1.Name = "test-resource-1"
39+
40+
r2 := &Resource{}
41+
r2.ID = "res-2"
42+
r2.Name = "test-resource-2"
43+
44+
multiList := ResourceList{r1, r2}
45+
multiIndex := multiList.Index()
46+
Expect(len(multiIndex)).To(Equal(2))
47+
Expect(multiIndex["res-1"]).To(Equal(r1))
48+
Expect(multiIndex["res-2"]).To(Equal(r2))
49+
50+
r1Dup := &Resource{}
51+
r1Dup.ID = "res-1"
52+
r1Dup.Name = "duplicate"
53+
54+
dupList := ResourceList{r1, r1Dup}
55+
dupIndex := dupList.Index()
56+
Expect(len(dupIndex)).To(Equal(1))
57+
Expect(dupIndex["res-1"].Name).To(Equal("duplicate"))
58+
}
59+
60+
func TestResource_BeforeCreate_IDGeneration(t *testing.T) {
61+
RegisterTestingT(t)
62+
setupTestRegistry()
63+
64+
r := &Resource{Name: "test", Kind: "Channel"}
65+
66+
err := r.BeforeCreate(nil)
67+
Expect(err).To(BeNil())
68+
Expect(r.ID).ToNot(BeEmpty())
69+
}
70+
71+
func TestResource_BeforeCreate_IDPreservation(t *testing.T) {
72+
RegisterTestingT(t)
73+
setupTestRegistry()
74+
75+
r := &Resource{Name: "test", Kind: "Channel"}
76+
r.ID = "pre-set-id"
77+
78+
err := r.BeforeCreate(nil)
79+
Expect(err).To(BeNil())
80+
Expect(r.ID).To(Equal("pre-set-id"))
81+
}
82+
83+
func TestResource_BeforeCreate_GenerationDefault(t *testing.T) {
84+
RegisterTestingT(t)
85+
setupTestRegistry()
86+
87+
r := &Resource{Name: "test", Kind: "Channel"}
88+
89+
err := r.BeforeCreate(nil)
90+
Expect(err).To(BeNil())
91+
Expect(r.Generation).To(Equal(int32(1)))
92+
}
93+
94+
func TestResource_BeforeCreate_GenerationPreserved(t *testing.T) {
95+
RegisterTestingT(t)
96+
setupTestRegistry()
97+
98+
r := &Resource{Name: "test", Kind: "Channel", Generation: 5}
99+
100+
err := r.BeforeCreate(nil)
101+
Expect(err).To(BeNil())
102+
Expect(r.Generation).To(Equal(int32(5)))
103+
}
104+
105+
func TestResource_BeforeCreate_Timestamps(t *testing.T) {
106+
RegisterTestingT(t)
107+
setupTestRegistry()
108+
109+
before := time.Now()
110+
r := &Resource{Name: "test", Kind: "Channel"}
111+
112+
err := r.BeforeCreate(nil)
113+
Expect(err).To(BeNil())
114+
115+
Expect(r.CreatedTime).ToNot(BeZero())
116+
Expect(r.UpdatedTime).ToNot(BeZero())
117+
Expect(r.CreatedTime.After(before) || r.CreatedTime.Equal(before)).To(BeTrue())
118+
Expect(r.UpdatedTime.After(before) || r.UpdatedTime.Equal(before)).To(BeTrue())
119+
}
120+
121+
func TestResource_BeforeCreate_CreatedTimePreserved(t *testing.T) {
122+
RegisterTestingT(t)
123+
setupTestRegistry()
124+
125+
fixedTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
126+
r := &Resource{Name: "test", Kind: "Channel"}
127+
r.CreatedTime = fixedTime
128+
129+
err := r.BeforeCreate(nil)
130+
Expect(err).To(BeNil())
131+
Expect(r.CreatedTime).To(Equal(fixedTime))
132+
}
133+
134+
func TestResource_BeforeCreate_HrefTopLevel(t *testing.T) {
135+
RegisterTestingT(t)
136+
setupTestRegistry()
137+
138+
r := &Resource{Name: "stable", Kind: "Channel"}
139+
err := r.BeforeCreate(nil)
140+
Expect(err).To(BeNil())
141+
Expect(r.Href).To(Equal("/api/hyperfleet/v1/channels/" + r.ID))
142+
}
143+
144+
func TestResource_BeforeCreate_HrefChild(t *testing.T) {
145+
RegisterTestingT(t)
146+
setupTestRegistry()
147+
148+
r := &Resource{
149+
Name: "4-17-12",
150+
Kind: "Version",
151+
OwnerID: strPtr("ch-1"),
152+
OwnerKind: strPtr("Channel"),
153+
}
154+
err := r.BeforeCreate(nil)
155+
Expect(err).To(BeNil())
156+
Expect(r.Href).To(Equal("/api/hyperfleet/v1/channels/ch-1/versions/" + r.ID))
157+
Expect(*r.OwnerHref).To(Equal("/api/hyperfleet/v1/channels/ch-1"))
158+
}
159+
160+
func TestResource_BeforeCreate_OwnerKindMissing(t *testing.T) {
161+
RegisterTestingT(t)
162+
setupTestRegistry()
163+
164+
r := &Resource{
165+
Name: "4-17-12",
166+
Kind: "Version",
167+
OwnerID: strPtr("ch-1"),
168+
}
169+
err := r.BeforeCreate(nil)
170+
Expect(err).ToNot(BeNil())
171+
Expect(err.Error()).To(ContainSubstring("owner_kind is required"))
172+
}
173+
174+
func TestResource_BeforeCreate_OwnerKindEmpty(t *testing.T) {
175+
RegisterTestingT(t)
176+
setupTestRegistry()
177+
178+
r := &Resource{
179+
Name: "4-17-12",
180+
Kind: "Version",
181+
OwnerID: strPtr("ch-1"),
182+
OwnerKind: strPtr(""),
183+
}
184+
err := r.BeforeCreate(nil)
185+
Expect(err).ToNot(BeNil())
186+
Expect(err.Error()).To(ContainSubstring("owner_kind is required"))
187+
}
188+
189+
func TestResource_BeforeCreate_HrefChildWithPresetOwnerHref(t *testing.T) {
190+
RegisterTestingT(t)
191+
setupTestRegistry()
192+
193+
r := &Resource{
194+
Name: "some-label",
195+
Kind: "Version",
196+
OwnerID: strPtr("v-1"),
197+
OwnerKind: strPtr("Version"),
198+
OwnerHref: strPtr("/api/hyperfleet/v1/channels/ch-1/versions/v-1"),
199+
}
200+
err := r.BeforeCreate(nil)
201+
Expect(err).To(BeNil())
202+
Expect(r.Href).To(Equal("/api/hyperfleet/v1/channels/ch-1/versions/v-1/versions/" + r.ID))
203+
Expect(*r.OwnerHref).To(Equal("/api/hyperfleet/v1/channels/ch-1/versions/v-1"))
204+
}
205+
206+
func TestResource_BeforeCreate_HrefPreserved(t *testing.T) {
207+
RegisterTestingT(t)
208+
setupTestRegistry()
209+
210+
r := &Resource{Name: "test", Kind: "Channel", Href: "/custom/href"}
211+
err := r.BeforeCreate(nil)
212+
Expect(err).To(BeNil())
213+
Expect(r.Href).To(Equal("/custom/href"))
214+
}
215+
216+
func TestResource_BeforeUpdate_UpdatesTimestamp(t *testing.T) {
217+
RegisterTestingT(t)
218+
219+
r := &Resource{Name: "test", Kind: "Channel"}
220+
r.UpdatedTime = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
221+
222+
before := time.Now()
223+
err := r.BeforeUpdate(nil)
224+
Expect(err).To(BeNil())
225+
Expect(r.UpdatedTime.After(before) || r.UpdatedTime.Equal(before)).To(BeTrue())
226+
}
227+
228+
func TestResource_MarkDeleted(t *testing.T) {
229+
RegisterTestingT(t)
230+
231+
r := &Resource{Name: "test", Kind: "Channel"}
232+
now := time.Now()
233+
234+
r.MarkDeleted("admin", now)
235+
236+
Expect(r.DeletedTime).ToNot(BeNil())
237+
Expect(*r.DeletedTime).To(Equal(now))
238+
Expect(r.DeletedBy).ToNot(BeNil())
239+
Expect(*r.DeletedBy).To(Equal("admin"))
240+
}
241+
242+
func TestResource_IncrementGeneration(t *testing.T) {
243+
RegisterTestingT(t)
244+
245+
r := &Resource{Name: "test", Kind: "Channel", Generation: 1}
246+
r.IncrementGeneration()
247+
Expect(r.Generation).To(Equal(int32(2)))
248+
249+
r.IncrementGeneration()
250+
Expect(r.Generation).To(Equal(int32(3)))
251+
}
252+
253+
func TestResource_TableName(t *testing.T) {
254+
RegisterTestingT(t)
255+
256+
r := Resource{}
257+
Expect(r.TableName()).To(Equal("resources"))
258+
}

0 commit comments

Comments
 (0)