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
22 changes: 21 additions & 1 deletion pkg/api/presenters/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ func PresentResource(r *api.Resource) openapi.Resource {
UpdatedBy: openapi_types.Email(r.UpdatedBy),
DeletedTime: r.DeletedTime,
Status: openapi.ResourceStatus{
Conditions: []openapi.ResourceCondition{},
Conditions: presentResourceConditions(r.Conditions),
},
}

Expand Down Expand Up @@ -109,6 +109,26 @@ func PresentResourceList(resources api.ResourceList, paging *api.PagingMeta) ope
}
}

func presentResourceConditions(conditions []api.ResourceCondition) []openapi.ResourceCondition {
if len(conditions) == 0 {
return []openapi.ResourceCondition{}
}
result := make([]openapi.ResourceCondition, len(conditions))
for i, c := range conditions {
result[i] = openapi.ResourceCondition{
Type: c.Type,
Status: openapi.ResourceConditionStatus(c.Status),
Reason: c.Reason,
Message: c.Message,
ObservedGeneration: c.ObservedGeneration,
CreatedTime: c.CreatedTime,
LastUpdatedTime: c.LastUpdatedTime,
LastTransitionTime: c.LastTransitionTime,
}
}
return result
}

func marshalLabels(labels *map[string]string) (datatypes.JSON, error) {
if labels == nil {
return datatypes.JSON("{}"), nil
Expand Down
62 changes: 62 additions & 0 deletions pkg/api/presenters/resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,68 @@ func TestPresentResource_EmptySpec(t *testing.T) {
Expect(resp.Spec).To(BeEmpty())
}

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

now := time.Now().Truncate(time.Microsecond)
reason := "AllAdaptersReporting"
message := "All adapters are available"
resource := &api.Resource{
Meta: api.Meta{ID: "cond-id", CreatedTime: now, UpdatedTime: now},
Kind: "Channel",
Name: "test",
Spec: datatypes.JSON(`{}`),
CreatedBy: "user@test.com",
UpdatedBy: "user@test.com",
Conditions: []api.ResourceCondition{
{
Type: "Available",
Status: api.ConditionTrue,
Reason: &reason,
Message: &message,
ObservedGeneration: 3,
CreatedTime: now,
LastUpdatedTime: now,
LastTransitionTime: now,
},
},
}

resp := PresentResource(resource)
Expect(resp.Status.Conditions).To(HaveLen(1))

cond := resp.Status.Conditions[0]
Expect(cond.Type).To(Equal("Available"))
Expect(cond.Status).To(Equal(openapi.ResourceConditionStatusTrue))
Expect(*cond.Reason).To(Equal("AllAdaptersReporting"))
Expect(*cond.Message).To(Equal("All adapters are available"))
Expect(cond.ObservedGeneration).To(Equal(int32(3)))
Expect(cond.CreatedTime).To(BeTemporally("==", now))
Expect(cond.LastTransitionTime).To(BeTemporally("==", now))
}

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

now := time.Now()
resource := &api.Resource{
Meta: api.Meta{ID: "empty-cond-id", CreatedTime: now, UpdatedTime: now},
Kind: "Channel",
Name: "test",
Spec: datatypes.JSON(`{}`),
CreatedBy: "user@test.com",
UpdatedBy: "user@test.com",
Conditions: []api.ResourceCondition{},
}

resp := PresentResource(resource)
body, err := json.Marshal(resp)
Expect(err).NotTo(HaveOccurred())
Expect(resp.Status.Conditions).NotTo(BeNil())
Expect(resp.Status.Conditions).To(BeEmpty())
Expect(string(body)).To(ContainSubstring(`"status":{"conditions":[]}`))
}

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

Expand Down
27 changes: 14 additions & 13 deletions pkg/api/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,21 @@ import (
// differentiated by the Kind field. Existing Cluster and NodePool types
// are NOT migrated to this model.
type Resource struct {
OwnerID *string `json:"owner_id,omitempty" gorm:"size:255"`
OwnerHref *string `json:"owner_href,omitempty" gorm:"size:500"`
OwnerKind *string `json:"owner_kind,omitempty" gorm:"size:100"`
DeletedBy *string `json:"deleted_by,omitempty" gorm:"size:255"`
DeletedTime *time.Time `json:"deleted_time,omitempty"`
Meta
Kind string `json:"kind" gorm:"size:100;not null"`
Name string `json:"name" gorm:"size:100;not null"`
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"`
DeletedBy *string `json:"deleted_by,omitempty" gorm:"size:255"`
DeletedTime *time.Time `json:"deleted_time,omitempty"`
OwnerID *string `json:"owner_id,omitempty" gorm:"size:255"`
OwnerKind *string `json:"owner_kind,omitempty" gorm:"size:100"`
OwnerHref *string `json:"owner_href,omitempty" gorm:"size:500"`
Spec datatypes.JSON `json:"spec" gorm:"type:jsonb;not null"`
Labels datatypes.JSON `json:"labels,omitempty" gorm:"type:jsonb"`
Generation int32 `json:"generation" gorm:"default:1;not null"`
Kind string `json:"kind" gorm:"size:100;not null"`
Name string `json:"name" gorm:"size:100;not null"`
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"`
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"`
}

type ResourcePatch struct {
Expand Down
28 changes: 17 additions & 11 deletions pkg/api/status_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,18 +50,24 @@ const (
ResourceConditionTypeLastKnownReconciled = "LastKnownReconciled"
)

// ResourceCondition represents a condition of a resource
// Domain equivalent of openapi.ResourceCondition
// JSON tags match database JSONB structure
// ResourceCondition represents a condition of a resource.
// Dual-use: GORM model for the resource_conditions table (generic resources)
// and JSON-deserializable struct for JSONB columns (clusters/node pools).
// ResourceID is excluded from JSON (json:"-") to preserve JSONB backward compat.
type ResourceCondition struct {
CreatedTime time.Time `json:"created_time"`
LastUpdatedTime time.Time `json:"last_updated_time"`
LastTransitionTime time.Time `json:"last_transition_time"`
Reason *string `json:"reason,omitempty"`
Message *string `json:"message,omitempty"`
Type string `json:"type"`
Status ResourceConditionStatus `json:"status"`
ObservedGeneration int32 `json:"observed_generation"`
CreatedTime time.Time `json:"created_time" gorm:"not null"`
LastUpdatedTime time.Time `json:"last_updated_time" gorm:"not null"`
LastTransitionTime time.Time `json:"last_transition_time" gorm:"not null"`
Reason *string `json:"reason,omitempty" gorm:"type:text"`
Message *string `json:"message,omitempty" gorm:"type:text"`
ResourceID string `json:"-" gorm:"primaryKey;size:255;not null"`
Type string `json:"type" gorm:"primaryKey;size:100;not null"`
Status ResourceConditionStatus `json:"status" gorm:"size:10;not null"`
ObservedGeneration int32 `json:"observed_generation" gorm:"default:0;not null"`
}

func (ResourceCondition) TableName() string {
return "resource_conditions"
}

// AdapterCondition represents a condition of an adapter
Expand Down
5 changes: 3 additions & 2 deletions pkg/dao/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ 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.Take(&resource, "kind = ? AND id = ?", kind, id).Error; err != nil {
if err := g2.Preload("Conditions").Take(&resource, "kind = ? AND id = ?", kind, id).Error; err != nil {
return nil, err
}
return &resource, nil
Expand All @@ -55,7 +55,8 @@ 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.Take(&resource, "kind = ? AND id = ? AND owner_id = ?", kind, id, ownerID).Error; err != nil {
if err := g2.Preload("Conditions").
Take(&resource, "kind = ? AND id = ? AND owner_id = ?", kind, id, ownerID).Error; err != nil {
return nil, err
}
return &resource, nil
Expand Down
66 changes: 66 additions & 0 deletions pkg/dao/resource_condition.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package dao

import (
"context"

"github.com/openshift-hyperfleet/hyperfleet-api/pkg/api"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/db"
)

type ResourceConditionDao interface {
// UpdateConditions must be called within a write transaction — it performs
// delete+insert which is not atomic without one. Per ADR-0008, this is called
// from the adapter status upsert path (PUT), which has a transaction from
// TransactionMiddleware.
UpdateConditions(ctx context.Context, resourceID string, conditions []api.ResourceCondition) error
}

var _ ResourceConditionDao = &sqlResourceConditionDao{}

type sqlResourceConditionDao struct {
sessionFactory db.SessionFactory
}

func NewResourceConditionDao(sessionFactory db.SessionFactory) ResourceConditionDao {
return &sqlResourceConditionDao{sessionFactory: sessionFactory}
}

func (d *sqlResourceConditionDao) UpdateConditions(
ctx context.Context, resourceID string, conditions []api.ResourceCondition,
) error {
g2 := d.sessionFactory.New(ctx)

var existing []api.ResourceCondition
if err := g2.Where("resource_id = ?", resourceID).Find(&existing).Error; err != nil {
return err
Comment on lines +33 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Mark the initial query failure for rollback.

Line 34 returns the Find(&existing) error without db.MarkForRollback(ctx, err). In this write DAO, that can commit earlier mutations in the same request transaction while condition replacement fails, leaving the resource row and resource_conditions out of sync.

As per path instructions, pkg/dao/**: "On any write error, mark the transaction for rollback" and pkg/db/**: "Without this, the middleware will commit a partially-failed transaction."

Proposed fix
 	var existing []api.ResourceCondition
 	if err := g2.Where("resource_id = ?", resourceID).Find(&existing).Error; err != nil {
+		db.MarkForRollback(ctx, err)
 		return err
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var existing []api.ResourceCondition
if err := g2.Where("resource_id = ?", resourceID).Find(&existing).Error; err != nil {
return err
var existing []api.ResourceCondition
if err := g2.Where("resource_id = ?", resourceID).Find(&existing).Error; err != nil {
db.MarkForRollback(ctx, err)
return err
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/dao/resource_condition.go` around lines 33 - 35, The initial
`Find(&existing)` error path in the `resource_condition` DAO currently returns
without marking the request transaction for rollback, which can leave earlier
writes committed. Update the write flow that loads existing conditions to call
`db.MarkForRollback(ctx, err)` before returning the `Find` error, following the
same pattern used for other write failures in `pkg/dao`. Keep the fix localized
to the resource condition replacement path so any failure in this method
prevents a partial commit.

Source: Path instructions

}

prevByType := make(map[string]api.ResourceCondition, len(existing))
for _, c := range existing {
prevByType[c.Type] = c
}

for i := range conditions {
conditions[i].ResourceID = resourceID
if prev, ok := prevByType[conditions[i].Type]; ok {
if prev.Status == conditions[i].Status {
conditions[i].LastTransitionTime = prev.LastTransitionTime
}
conditions[i].CreatedTime = prev.CreatedTime
}
}

if err := g2.Where("resource_id = ?", resourceID).Delete(&api.ResourceCondition{}).Error; err != nil {
db.MarkForRollback(ctx, err)
return err
}

if len(conditions) > 0 {
if err := g2.Create(&conditions).Error; err != nil {
db.MarkForRollback(ctx, err)
return err
}
}

return nil
}
38 changes: 38 additions & 0 deletions pkg/db/migrations/202606290001_add_resource_conditions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package migrations

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

func addResourceConditions() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "202606290001",
Migrate: func(tx *gorm.DB) error {
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS resource_conditions (
resource_id VARCHAR(255) NOT NULL,
type VARCHAR(100) NOT NULL,
status VARCHAR(10) NOT NULL,
reason TEXT,
message TEXT,
observed_generation INTEGER NOT NULL DEFAULT 0,
created_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_transition_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (resource_id, type),
FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE
);`).Error; err != nil {
Comment on lines +12 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fail on schema drift instead of masking it.

UpdateConditions depends on this table having the exact PK/FK/defaults described in the PR. CREATE TABLE IF NOT EXISTS will still return success if a partial/manual resource_conditions table already exists, and gormigrate will then record this migration as applied against the wrong schema.

Suggested change
-			if err := tx.Exec(`CREATE TABLE IF NOT EXISTS resource_conditions (
+			if err := tx.Exec(`CREATE TABLE resource_conditions (
 				resource_id			VARCHAR(255) NOT NULL,
 				type				VARCHAR(100) NOT NULL,
 				status				VARCHAR(10) NOT NULL,
 				reason				TEXT,
 				message				TEXT,
 				observed_generation	INTEGER NOT NULL DEFAULT 0,
 				created_time		TIMESTAMPTZ NOT NULL DEFAULT NOW(),
 				last_updated_time	TIMESTAMPTZ NOT NULL DEFAULT NOW(),
 				last_transition_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
 				PRIMARY KEY (resource_id, type),
 				FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE
 			);`).Error; err != nil {
 				return err
 			}

As per path instructions, migrations must be reviewed for backward compatibility and incompatible-schema risks.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS resource_conditions (
resource_id VARCHAR(255) NOT NULL,
type VARCHAR(100) NOT NULL,
status VARCHAR(10) NOT NULL,
reason TEXT,
message TEXT,
observed_generation INTEGER NOT NULL DEFAULT 0,
created_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_transition_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (resource_id, type),
FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE
);`).Error; err != nil {
if err := tx.Exec(`CREATE TABLE resource_conditions (
resource_id VARCHAR(255) NOT NULL,
type VARCHAR(100) NOT NULL,
status VARCHAR(10) NOT NULL,
reason TEXT,
message TEXT,
observed_generation INTEGER NOT NULL DEFAULT 0,
created_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_transition_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (resource_id, type),
FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE
);`).Error; err != nil {
return err
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/db/migrations/202606290001_add_resource_conditions.go` around lines 12 -
24, The `UpdateConditions` migration currently uses `resource_conditions` with
`CREATE TABLE IF NOT EXISTS`, which can silently accept an existing table with
the wrong PK/FK/defaults and mask schema drift. Replace this in the migration
with a schema validation or fail-fast check in the migration logic so
`gormigrate` does not mark it applied unless `resource_conditions` matches the
expected definition; use the `UpdateConditions` migration block and the
`resource_conditions` table definition to enforce exact structure.

Source: Path instructions

return err
}

if err := tx.Exec(
"CREATE INDEX IF NOT EXISTS idx_resource_conditions_resource_id " +
"ON resource_conditions (resource_id);",
).Error; err != nil {
return err
}

return nil
},
}
}
1 change: 1 addition & 0 deletions pkg/db/migrations/migration_structs.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ var MigrationList = []*gormigrate.Migration{
addDeletedTimeIndexes(),
addResources(),
removeReadyCondition(),
addResourceConditions(),
}

// Model represents the base model struct. All entities will have this struct embedded.
Expand Down
Loading