Skip to content

Commit 9798ec3

Browse files
Merge pull request #119 from mliptak0/hyperfleet-api-854
HYPERFLEET-854 - feat: implement hard deletion for clusters and nodepools
2 parents 2ad08e8 + 6e3acf7 commit 9798ec3

17 files changed

Lines changed: 1344 additions & 251 deletions

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Hard deletion for Clusters and NodePools: resources and their adapter statuses are permanently removed from the database once all required adapters report `Finalized=True` and no child resources remain ([#119](https://github.com/openshift-hyperfleet/hyperfleet-api/pull/119))
13+
- `Finalized` condition aggregation with `WaitingForChildResources` intermediate state when all adapters are finalized but child node pools still exist ([#119](https://github.com/openshift-hyperfleet/hyperfleet-api/pull/119))
1214
- Soft deletion for Clusters and NodePools with `deleted_time` and `deleted_by` fields for tracking deletion requests ([#106](https://github.com/openshift-hyperfleet/hyperfleet-api/pull/106))
1315
- Aggregation logic for resource data ([#91](https://github.com/openshift-hyperfleet/hyperfleet-api/pull/91))
1416
- Version subcommand to CLI ([#84](https://github.com/openshift-hyperfleet/hyperfleet-api/pull/84))
@@ -26,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2628

2729
### Changed
2830

31+
- Refactored `AdapterStatusDao.Upsert()` to accept a pre-fetched existing record, moving lookup and `LastTransitionTime` preservation logic to the service layer ([#119](https://github.com/openshift-hyperfleet/hyperfleet-api/pull/119))
2932
- Refactored DAO methods to remove Unscoped calls for fetching Clusters and NodePools ([#106](https://github.com/openshift-hyperfleet/hyperfleet-api/pull/106))
3033
- Bumped oapi-codegen version to fix missing `omitempty` on generated response objects ([#106](https://github.com/openshift-hyperfleet/hyperfleet-api/pull/106))
3134
- Updated OpenAPI spec with examples for Cluster and NodePool schemas ([#106](https://github.com/openshift-hyperfleet/hyperfleet-api/pull/106))

pkg/api/adapter_status_types.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package api
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"time"
67

@@ -52,3 +53,16 @@ func (as *AdapterStatus) BeforeCreate(tx *gorm.DB) error {
5253
as.ID = id
5354
return nil
5455
}
56+
57+
func (as *AdapterStatus) IsFinalized() bool {
58+
var conditions []AdapterCondition
59+
if err := json.Unmarshal(as.Conditions, &conditions); err != nil {
60+
return false
61+
}
62+
for _, cond := range conditions {
63+
if cond.Type == ConditionTypeFinalized && cond.Status == AdapterConditionTrue {
64+
return true
65+
}
66+
}
67+
return false
68+
}

pkg/dao/adapter_status.go

Lines changed: 15 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,20 @@ package dao
22

33
import (
44
"context"
5-
"encoding/json"
6-
"errors"
7-
"time"
85

9-
"gorm.io/datatypes"
10-
"gorm.io/gorm"
116
"gorm.io/gorm/clause"
127

138
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/api"
14-
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi"
159
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/db"
1610
)
1711

1812
type AdapterStatusDao interface {
1913
Get(ctx context.Context, id string) (*api.AdapterStatus, error)
2014
Create(ctx context.Context, adapterStatus *api.AdapterStatus) (*api.AdapterStatus, error)
2115
Replace(ctx context.Context, adapterStatus *api.AdapterStatus) (*api.AdapterStatus, error)
22-
Upsert(ctx context.Context, adapterStatus *api.AdapterStatus) (*api.AdapterStatus, error)
16+
Upsert(ctx context.Context, adapterStatus *api.AdapterStatus, existing *api.AdapterStatus) (*api.AdapterStatus, error)
2317
Delete(ctx context.Context, id string) error
18+
DeleteByResource(ctx context.Context, resourceType, resourceID string) error
2419
FindByResource(ctx context.Context, resourceType, resourceID string) (api.AdapterStatusList, error)
2520
FindByResourceIDs(ctx context.Context, resourceType string, resourceIDs []string) (api.AdapterStatusList, error)
2621
FindByResourcePaginated(
@@ -73,30 +68,12 @@ func (d *sqlAdapterStatusDao) Replace(
7368
return adapterStatus, nil
7469
}
7570

76-
// Upsert creates or updates an adapter status based on resource_type, resource_id, and adapter
77-
// This implements the upsert semantic required by the new API spec
7871
func (d *sqlAdapterStatusDao) Upsert(
79-
ctx context.Context, adapterStatus *api.AdapterStatus,
72+
ctx context.Context, adapterStatus *api.AdapterStatus, existing *api.AdapterStatus,
8073
) (*api.AdapterStatus, error) {
8174
g2 := (*d.sessionFactory).New(ctx)
8275

83-
// Keep deterministic observed time from the incoming report when provided (observed_time).
84-
if adapterStatus.LastReportTime.IsZero() {
85-
adapterStatus.LastReportTime = time.Now()
86-
}
87-
88-
existing, err := d.FindByResourceAndAdapter(
89-
ctx, adapterStatus.ResourceType, adapterStatus.ResourceID, adapterStatus.Adapter,
90-
)
91-
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
92-
db.MarkForRollback(ctx, err)
93-
return nil, err
94-
}
95-
96-
if err == nil && existing != nil {
97-
// Preserve LastTransitionTime for conditions whose status hasn't changed.
98-
adapterStatus.Conditions = preserveLastTransitionTime(existing.Conditions, adapterStatus.Conditions)
99-
76+
if existing != nil {
10077
updateResult := g2.Model(&api.AdapterStatus{}).
10178
Where("resource_type = ? AND resource_id = ? AND adapter = ?",
10279
adapterStatus.ResourceType, adapterStatus.ResourceID, adapterStatus.Adapter).
@@ -118,9 +95,8 @@ func (d *sqlAdapterStatusDao) Upsert(
11895
return nil, updateResult.Error
11996
}
12097

121-
// No-op when the stored row is fresher or equal.
12298
if updateResult.RowsAffected == 0 {
123-
return d.FindByResourceAndAdapter(ctx, adapterStatus.ResourceType, adapterStatus.ResourceID, adapterStatus.Adapter)
99+
return existing, nil
124100
}
125101

126102
return d.FindByResourceAndAdapter(ctx, adapterStatus.ResourceType, adapterStatus.ResourceID, adapterStatus.Adapter)
@@ -135,7 +111,6 @@ func (d *sqlAdapterStatusDao) Upsert(
135111
return adapterStatus, nil
136112
}
137113

138-
// A row was inserted concurrently; return the latest stored row without overwriting it.
139114
return d.FindByResourceAndAdapter(ctx, adapterStatus.ResourceType, adapterStatus.ResourceID, adapterStatus.Adapter)
140115
}
141116

@@ -150,6 +125,16 @@ func (d *sqlAdapterStatusDao) Delete(ctx context.Context, id string) error {
150125
return nil
151126
}
152127

128+
func (d *sqlAdapterStatusDao) DeleteByResource(ctx context.Context, resourceType, resourceID string) error {
129+
g2 := (*d.sessionFactory).New(ctx)
130+
if err := g2.Where("resource_type = ? AND resource_id = ?", resourceType, resourceID).
131+
Delete(&api.AdapterStatus{}).Error; err != nil {
132+
db.MarkForRollback(ctx, err)
133+
return err
134+
}
135+
return nil
136+
}
137+
153138
func (d *sqlAdapterStatusDao) FindByResource(
154139
ctx context.Context, resourceType, resourceID string,
155140
) (api.AdapterStatusList, error) {
@@ -220,52 +205,3 @@ func (d *sqlAdapterStatusDao) All(ctx context.Context) (api.AdapterStatusList, e
220205
}
221206
return statuses, nil
222207
}
223-
224-
// preserveLastTransitionTime preserves LastTransitionTime for conditions whose status hasn't changed
225-
// This implements the Kubernetes condition semantic where LastTransitionTime is only updated when status changes
226-
func preserveLastTransitionTime(oldConditionsJSON, newConditionsJSON datatypes.JSON) datatypes.JSON {
227-
// Unmarshal old conditions
228-
var oldConditions []openapi.AdapterCondition
229-
if len(oldConditionsJSON) > 0 {
230-
if err := json.Unmarshal(oldConditionsJSON, &oldConditions); err != nil {
231-
// If we can't unmarshal old conditions, return new conditions as-is
232-
return newConditionsJSON
233-
}
234-
}
235-
236-
// Unmarshal new conditions
237-
var newConditions []openapi.AdapterCondition
238-
if len(newConditionsJSON) > 0 {
239-
if err := json.Unmarshal(newConditionsJSON, &newConditions); err != nil {
240-
// If we can't unmarshal new conditions, return new conditions as-is
241-
return newConditionsJSON
242-
}
243-
}
244-
245-
// Build a map of old conditions by type for quick lookup
246-
oldConditionsMap := make(map[string]openapi.AdapterCondition)
247-
for _, oldCond := range oldConditions {
248-
oldConditionsMap[oldCond.Type] = oldCond
249-
}
250-
251-
// Update new conditions: preserve LastTransitionTime if status hasn't changed
252-
for i := range newConditions {
253-
if oldCond, exists := oldConditionsMap[newConditions[i].Type]; exists {
254-
// If status hasn't changed, preserve the old LastTransitionTime
255-
if oldCond.Status == newConditions[i].Status {
256-
newConditions[i].LastTransitionTime = oldCond.LastTransitionTime
257-
}
258-
// If status changed, keep the new LastTransitionTime (already set to now)
259-
}
260-
// If this is a new condition type, keep the new LastTransitionTime
261-
}
262-
263-
// Marshal back to JSON
264-
updatedJSON, err := json.Marshal(newConditions)
265-
if err != nil {
266-
// If we can't marshal, return new conditions as-is
267-
return newConditionsJSON
268-
}
269-
270-
return updatedJSON
271-
}

pkg/dao/cluster.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ import (
1212

1313
type ClusterDao interface {
1414
Get(ctx context.Context, id string) (*api.Cluster, error)
15+
GetForUpdate(ctx context.Context, id string) (*api.Cluster, error)
1516
Create(ctx context.Context, cluster *api.Cluster) (*api.Cluster, error)
1617
Replace(ctx context.Context, cluster *api.Cluster) (*api.Cluster, error)
1718
Save(ctx context.Context, cluster *api.Cluster) error
19+
SaveStatusConditions(ctx context.Context, id string, statusConditions []byte) error
1820
Delete(ctx context.Context, id string) error
1921
FindByIDs(ctx context.Context, ids []string) (api.ClusterList, error)
2022
All(ctx context.Context) (api.ClusterList, error)
@@ -39,6 +41,15 @@ func (d *sqlClusterDao) Get(ctx context.Context, id string) (*api.Cluster, error
3941
return &cluster, nil
4042
}
4143

44+
func (d *sqlClusterDao) GetForUpdate(ctx context.Context, id string) (*api.Cluster, error) {
45+
g2 := (*d.sessionFactory).New(ctx)
46+
var cluster api.Cluster
47+
if err := g2.Clauses(clause.Locking{Strength: "UPDATE"}).Take(&cluster, "id = ?", id).Error; err != nil {
48+
return nil, err
49+
}
50+
return &cluster, nil
51+
}
52+
4253
func (d *sqlClusterDao) Create(ctx context.Context, cluster *api.Cluster) (*api.Cluster, error) {
4354
g2 := (*d.sessionFactory).New(ctx)
4455
if err := g2.Omit(clause.Associations).Create(cluster).Error; err != nil {
@@ -83,6 +94,16 @@ func (d *sqlClusterDao) Save(ctx context.Context, cluster *api.Cluster) error {
8394
return nil
8495
}
8596

97+
func (d *sqlClusterDao) SaveStatusConditions(ctx context.Context, id string, statusConditions []byte) error {
98+
g2 := (*d.sessionFactory).New(ctx)
99+
result := g2.Model(&api.Cluster{}).Where("id = ?", id).Update("status_conditions", statusConditions)
100+
if result.Error != nil {
101+
db.MarkForRollback(ctx, result.Error)
102+
return result.Error
103+
}
104+
return nil
105+
}
106+
86107
func (d *sqlClusterDao) Delete(ctx context.Context, id string) error {
87108
g2 := (*d.sessionFactory).New(ctx)
88109
if err := g2.Omit(clause.Associations).Delete(&api.Cluster{Meta: api.Meta{ID: id}}).Error; err != nil {

pkg/dao/mocks/cluster.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ func (d *clusterDaoMock) Get(ctx context.Context, id string) (*api.Cluster, erro
2525
return nil, gorm.ErrRecordNotFound
2626
}
2727

28+
func (d *clusterDaoMock) GetForUpdate(ctx context.Context, id string) (*api.Cluster, error) {
29+
return d.Get(ctx, id)
30+
}
31+
2832
func (d *clusterDaoMock) Create(ctx context.Context, cluster *api.Cluster) (*api.Cluster, error) {
2933
d.clusters = append(d.clusters, cluster)
3034
return cluster, nil
@@ -39,6 +43,16 @@ func (d *clusterDaoMock) Save(ctx context.Context, cluster *api.Cluster) error {
3943
return nil
4044
}
4145

46+
func (d *clusterDaoMock) SaveStatusConditions(ctx context.Context, id string, statusConditions []byte) error {
47+
for _, c := range d.clusters {
48+
if c.ID == id {
49+
c.StatusConditions = statusConditions
50+
return nil
51+
}
52+
}
53+
return gorm.ErrRecordNotFound
54+
}
55+
4256
func (d *clusterDaoMock) Delete(ctx context.Context, id string) error {
4357
return errors.NotImplemented("Cluster").AsError()
4458
}

pkg/dao/mocks/node_pool.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,20 @@ func (d *nodePoolDaoMock) Get(ctx context.Context, id string) (*api.NodePool, er
2626
return nil, gorm.ErrRecordNotFound
2727
}
2828

29+
func (d *nodePoolDaoMock) GetForUpdate(ctx context.Context, id string) (*api.NodePool, error) {
30+
return d.Get(ctx, id)
31+
}
32+
33+
func (d *nodePoolDaoMock) SaveStatusConditions(ctx context.Context, id string, statusConditions []byte) error {
34+
for _, np := range d.nodePools {
35+
if np.ID == id {
36+
np.StatusConditions = statusConditions
37+
return nil
38+
}
39+
}
40+
return gorm.ErrRecordNotFound
41+
}
42+
2943
func (d *nodePoolDaoMock) Create(ctx context.Context, nodePool *api.NodePool) (*api.NodePool, error) {
3044
d.nodePools = append(d.nodePools, nodePool)
3145
return nodePool, nil
@@ -56,10 +70,23 @@ func (d *nodePoolDaoMock) FindByIDs(ctx context.Context, ids []string) (api.Node
5670
return nil, errors.NotImplemented("NodePool").AsError()
5771
}
5872

73+
func (d *nodePoolDaoMock) FindByOwner(ctx context.Context, ownerID string) (api.NodePoolList, error) {
74+
return nil, errors.NotImplemented("NodePool").AsError()
75+
}
76+
5977
func (d *nodePoolDaoMock) UpdateStatusConditionsByIDs(ctx context.Context, updates map[string][]byte) error {
6078
return errors.NotImplemented("NodePool").AsError()
6179
}
6280

81+
func (d *nodePoolDaoMock) ExistsByOwner(ctx context.Context, ownerID string) (bool, error) {
82+
for _, np := range d.nodePools {
83+
if np.OwnerID == ownerID {
84+
return true, nil
85+
}
86+
}
87+
return false, nil
88+
}
89+
6390
func (d *nodePoolDaoMock) All(ctx context.Context) (api.NodePoolList, error) {
6491
return d.nodePools, nil
6592
}

pkg/dao/node_pool.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,18 @@ import (
1414

1515
type NodePoolDao interface {
1616
Get(ctx context.Context, id string) (*api.NodePool, error)
17+
GetForUpdate(ctx context.Context, id string) (*api.NodePool, error)
1718
Create(ctx context.Context, nodePool *api.NodePool) (*api.NodePool, error)
1819
Replace(ctx context.Context, nodePool *api.NodePool) (*api.NodePool, error)
1920
Save(ctx context.Context, nodePool *api.NodePool) error
21+
SaveStatusConditions(ctx context.Context, id string, statusConditions []byte) error
2022
Delete(ctx context.Context, id string) error
2123
FindByIDs(ctx context.Context, ids []string) (api.NodePoolList, error)
24+
FindByOwner(ctx context.Context, ownerID string) (api.NodePoolList, error)
2225
FindSoftDeletedByOwner(ctx context.Context, ownerID string) (api.NodePoolList, error)
2326
SoftDeleteByOwner(ctx context.Context, ownerID string, t time.Time, deletedBy string) error
2427
UpdateStatusConditionsByIDs(ctx context.Context, updates map[string][]byte) error
28+
ExistsByOwner(ctx context.Context, ownerID string) (bool, error)
2529
All(ctx context.Context) (api.NodePoolList, error)
2630
}
2731

@@ -44,6 +48,25 @@ func (d *sqlNodePoolDao) Get(ctx context.Context, id string) (*api.NodePool, err
4448
return &nodePool, nil
4549
}
4650

51+
func (d *sqlNodePoolDao) GetForUpdate(ctx context.Context, id string) (*api.NodePool, error) {
52+
g2 := (*d.sessionFactory).New(ctx)
53+
var nodePool api.NodePool
54+
if err := g2.Clauses(clause.Locking{Strength: "UPDATE"}).Take(&nodePool, "id = ?", id).Error; err != nil {
55+
return nil, err
56+
}
57+
return &nodePool, nil
58+
}
59+
60+
func (d *sqlNodePoolDao) SaveStatusConditions(ctx context.Context, id string, statusConditions []byte) error {
61+
g2 := (*d.sessionFactory).New(ctx)
62+
result := g2.Model(&api.NodePool{}).Where("id = ?", id).Update("status_conditions", statusConditions)
63+
if result.Error != nil {
64+
db.MarkForRollback(ctx, result.Error)
65+
return result.Error
66+
}
67+
return nil
68+
}
69+
4770
func (d *sqlNodePoolDao) Create(ctx context.Context, nodePool *api.NodePool) (*api.NodePool, error) {
4871
g2 := (*d.sessionFactory).New(ctx)
4972
if err := g2.Omit(clause.Associations).Create(nodePool).Error; err != nil {
@@ -131,6 +154,15 @@ func (d *sqlNodePoolDao) FindByIDs(ctx context.Context, ids []string) (api.NodeP
131154
return nodePools, nil
132155
}
133156

157+
func (d *sqlNodePoolDao) FindByOwner(ctx context.Context, ownerID string) (api.NodePoolList, error) {
158+
g2 := (*d.sessionFactory).New(ctx)
159+
var nodePools api.NodePoolList
160+
if err := g2.Where("owner_id = ?", ownerID).Find(&nodePools).Error; err != nil {
161+
return nil, err
162+
}
163+
return nodePools, nil
164+
}
165+
134166
func (d *sqlNodePoolDao) UpdateStatusConditionsByIDs(ctx context.Context, updates map[string][]byte) error {
135167
g2 := (*d.sessionFactory).New(ctx)
136168
if len(updates) == 0 {
@@ -149,6 +181,15 @@ func (d *sqlNodePoolDao) UpdateStatusConditionsByIDs(ctx context.Context, update
149181
return nil
150182
}
151183

184+
func (d *sqlNodePoolDao) ExistsByOwner(ctx context.Context, ownerID string) (bool, error) {
185+
g2 := (*d.sessionFactory).New(ctx)
186+
var count int64
187+
if err := g2.Model(&api.NodePool{}).Where("owner_id = ?", ownerID).Limit(1).Count(&count).Error; err != nil {
188+
return false, err
189+
}
190+
return count > 0, nil
191+
}
192+
152193
func (d *sqlNodePoolDao) All(ctx context.Context) (api.NodePoolList, error) {
153194
g2 := (*d.sessionFactory).New(ctx)
154195
nodePools := api.NodePoolList{}

0 commit comments

Comments
 (0)