Skip to content

Commit caf60fe

Browse files
Merge pull request #156 from pnguyen44/HYPERFLEET-1041/nodepool-force-delete
HYPERFLEET-1041 - feat: add force-delete endpoint for nodepools
2 parents cd75e21 + 190c369 commit caf60fe

8 files changed

Lines changed: 379 additions & 1 deletion

File tree

pkg/api/force_delete_types.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package api
2+
3+
// TODO(HYPERFLEET-1075): Move to hyperfleet-api-spec and generate via oapi-codegen.
4+
// ForceDeleteRequest is the request body for force-delete endpoints.
5+
type ForceDeleteRequest struct {
6+
Reason string `json:"reason"`
7+
}

pkg/handlers/cluster_nodepools.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,30 @@ func (h ClusterNodePoolsHandler) SoftDelete(w http.ResponseWriter, r *http.Reque
138138
handleSoftDelete(w, r, cfg, http.StatusAccepted)
139139
}
140140

141+
// ForceDelete permanently removes a nodepool that is in Finalizing state.
142+
func (h ClusterNodePoolsHandler) ForceDelete(w http.ResponseWriter, r *http.Request) {
143+
var req api.ForceDeleteRequest
144+
cfg := &handlerConfig{
145+
MarshalInto: &req,
146+
Validate: []validate{
147+
validateNotEmpty(&req, "Reason", "reason"),
148+
},
149+
Action: func() (interface{}, *errors.ServiceError) {
150+
clusterID := mux.Vars(r)["id"]
151+
nodePoolID := mux.Vars(r)["nodepool_id"]
152+
ctx := r.Context()
153+
if _, err := h.nodePoolService.GetByIDAndOwner(ctx, nodePoolID, clusterID); err != nil {
154+
return nil, err
155+
}
156+
if err := h.nodePoolService.ForceDelete(ctx, nodePoolID, req.Reason); err != nil {
157+
return nil, err
158+
}
159+
return nil, nil
160+
},
161+
}
162+
handleForceDelete(w, r, cfg)
163+
}
164+
141165
// Patch patches a specific nodepool for a cluster
142166
func (h ClusterNodePoolsHandler) Patch(w http.ResponseWriter, r *http.Request) {
143167
var patch api.NodePoolPatchRequest

pkg/handlers/cluster_nodepools_test.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,3 +553,125 @@ func TestClusterNodePoolsHandler_Patch(t *testing.T) {
553553
})
554554
}
555555
}
556+
557+
func TestClusterNodePoolsHandler_ForceDelete(t *testing.T) {
558+
RegisterTestingT(t)
559+
560+
clusterID := testClusterID
561+
nodePoolID := testNodePoolID
562+
563+
tests := []struct {
564+
setupMocks func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockNodePoolService)
565+
name string
566+
nodePoolID string
567+
body string
568+
expectedStatusCode int
569+
}{
570+
{
571+
name: "Success 204 - nodepool force-deleted",
572+
nodePoolID: nodePoolID,
573+
body: `{"reason": "Stuck in finalizing for 2 hours"}`,
574+
setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockNodePoolService) {
575+
mockClusterSvc := services.NewMockClusterService(ctrl)
576+
mockNodePoolSvc := services.NewMockNodePoolService(ctrl)
577+
mockNodePoolSvc.EXPECT().
578+
GetByIDAndOwner(gomock.Any(), nodePoolID, clusterID).
579+
Return(&api.NodePool{}, nil)
580+
mockNodePoolSvc.EXPECT().
581+
ForceDelete(gomock.Any(), nodePoolID, "Stuck in finalizing for 2 hours").
582+
Return(nil)
583+
return mockClusterSvc, mockNodePoolSvc
584+
},
585+
expectedStatusCode: http.StatusNoContent,
586+
},
587+
{
588+
name: "Error 404 - nodepool not found",
589+
nodePoolID: "non-existent-id",
590+
body: `{"reason": "some reason"}`,
591+
setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockNodePoolService) {
592+
mockClusterSvc := services.NewMockClusterService(ctrl)
593+
mockNodePoolSvc := services.NewMockNodePoolService(ctrl)
594+
mockNodePoolSvc.EXPECT().
595+
GetByIDAndOwner(gomock.Any(), "non-existent-id", clusterID).
596+
Return(nil, errors.NotFound("NodePool with id='non-existent-id' not found"))
597+
return mockClusterSvc, mockNodePoolSvc
598+
},
599+
expectedStatusCode: http.StatusNotFound,
600+
},
601+
{
602+
name: "Error 409 - nodepool not in Finalizing state",
603+
nodePoolID: nodePoolID,
604+
body: `{"reason": "some reason"}`,
605+
setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockNodePoolService) {
606+
mockClusterSvc := services.NewMockClusterService(ctrl)
607+
mockNodePoolSvc := services.NewMockNodePoolService(ctrl)
608+
mockNodePoolSvc.EXPECT().
609+
GetByIDAndOwner(gomock.Any(), nodePoolID, clusterID).
610+
Return(&api.NodePool{}, nil)
611+
mockNodePoolSvc.EXPECT().
612+
ForceDelete(gomock.Any(), nodePoolID, "some reason").
613+
Return(errors.ConflictState("NodePool '%s' is not in Finalizing state", nodePoolID))
614+
return mockClusterSvc, mockNodePoolSvc
615+
},
616+
expectedStatusCode: http.StatusConflict,
617+
},
618+
{
619+
name: "Error 400 - empty reason",
620+
nodePoolID: nodePoolID,
621+
body: `{"reason": ""}`,
622+
setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockNodePoolService) {
623+
mockClusterSvc := services.NewMockClusterService(ctrl)
624+
mockNodePoolSvc := services.NewMockNodePoolService(ctrl)
625+
return mockClusterSvc, mockNodePoolSvc
626+
},
627+
expectedStatusCode: http.StatusBadRequest,
628+
},
629+
{
630+
name: "Error 400 - malformed JSON",
631+
nodePoolID: nodePoolID,
632+
body: `not json`,
633+
setupMocks: func(ctrl *gomock.Controller) (*services.MockClusterService, *services.MockNodePoolService) {
634+
mockClusterSvc := services.NewMockClusterService(ctrl)
635+
mockNodePoolSvc := services.NewMockNodePoolService(ctrl)
636+
return mockClusterSvc, mockNodePoolSvc
637+
},
638+
expectedStatusCode: http.StatusBadRequest,
639+
},
640+
}
641+
642+
for _, tt := range tests {
643+
t.Run(tt.name, func(t *testing.T) {
644+
RegisterTestingT(t)
645+
646+
ctrl := gomock.NewController(t)
647+
defer ctrl.Finish()
648+
649+
mockClusterSvc, mockNodePoolSvc := tt.setupMocks(ctrl)
650+
handler := NewClusterNodePoolsHandler(mockClusterSvc, mockNodePoolSvc)
651+
652+
reqURL := "/api/hyperfleet/v1/clusters/" + clusterID + "/nodepools/" + tt.nodePoolID + "/force-delete"
653+
req := httptest.NewRequest(http.MethodPost, reqURL, strings.NewReader(tt.body))
654+
req.Header.Set("Content-Type", "application/json")
655+
req = mux.SetURLVars(req, map[string]string{
656+
"id": clusterID,
657+
"nodepool_id": tt.nodePoolID,
658+
})
659+
660+
rr := httptest.NewRecorder()
661+
handler.ForceDelete(rr, req)
662+
663+
Expect(rr.Code).To(Equal(tt.expectedStatusCode))
664+
665+
if tt.expectedStatusCode == http.StatusNoContent {
666+
Expect(rr.Body.Len()).To(Equal(0))
667+
}
668+
669+
if tt.expectedStatusCode == http.StatusConflict {
670+
var errResp openapi.Error
671+
err := json.Unmarshal(rr.Body.Bytes(), &errResp)
672+
Expect(err).NotTo(HaveOccurred())
673+
Expect(*errResp.Detail).To(ContainSubstring("not in Finalizing state"))
674+
}
675+
})
676+
}
677+
}

pkg/handlers/framework.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,42 @@ func handleList(w http.ResponseWriter, r *http.Request, cfg *handlerConfig) {
135135
writeJSONResponse(w, r, http.StatusOK, results)
136136
}
137137

138+
// handleForceDelete reads the request body, unmarshals it into cfg.MarshalInto,
139+
// runs validators, executes the action, and returns 204 No Content on success.
140+
func handleForceDelete(w http.ResponseWriter, r *http.Request, cfg *handlerConfig) {
141+
if cfg.ErrorHandler == nil {
142+
cfg.ErrorHandler = handleError
143+
}
144+
145+
bytes, err := io.ReadAll(r.Body)
146+
if err != nil {
147+
handleError(r, w, errors.MalformedRequest("Unable to read request body: %s", err))
148+
return
149+
}
150+
151+
err = json.Unmarshal(bytes, &cfg.MarshalInto)
152+
if err != nil {
153+
handleError(r, w, errors.MalformedRequest("Invalid request format: %s", err))
154+
return
155+
}
156+
157+
for _, v := range cfg.Validate {
158+
err := v()
159+
if err != nil {
160+
cfg.ErrorHandler(r, w, err)
161+
return
162+
}
163+
}
164+
165+
_, serviceErr := cfg.Action()
166+
if serviceErr != nil {
167+
cfg.ErrorHandler(r, w, serviceErr)
168+
return
169+
}
170+
171+
w.WriteHeader(http.StatusNoContent)
172+
}
173+
138174
// handleCreateWithNoContent handles create requests that may return 204 No Content
139175
// If action returns (nil, nil), it means a no-op and returns 204 No Content
140176
// Otherwise, it returns 201 Created with the result

pkg/services/cluster_test.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,9 @@ func (d *mockClusterDao) All(ctx context.Context) (api.ClusterList, error) {
106106
var _ dao.ClusterDao = &mockClusterDao{}
107107

108108
type mockAdapterStatusDao struct {
109-
statuses map[string]*api.AdapterStatus
109+
statuses map[string]*api.AdapterStatus
110+
findByResourceErr error
111+
deleteByResourceErr error
110112
}
111113

112114
func newMockAdapterStatusDao() *mockAdapterStatusDao {
@@ -157,6 +159,9 @@ func (d *mockAdapterStatusDao) Delete(ctx context.Context, id string) error {
157159
}
158160

159161
func (d *mockAdapterStatusDao) DeleteByResource(ctx context.Context, resourceType, resourceID string) error {
162+
if d.deleteByResourceErr != nil {
163+
return d.deleteByResourceErr
164+
}
160165
for key, s := range d.statuses {
161166
if s.ResourceType == resourceType && s.ResourceID == resourceID {
162167
delete(d.statuses, key)
@@ -169,6 +174,9 @@ func (d *mockAdapterStatusDao) FindByResource(
169174
ctx context.Context,
170175
resourceType, resourceID string,
171176
) (api.AdapterStatusList, error) {
177+
if d.findByResourceErr != nil {
178+
return nil, d.findByResourceErr
179+
}
172180
var result api.AdapterStatusList
173181
for _, s := range d.statuses {
174182
if s.ResourceType == resourceType && s.ResourceID == resourceID {
@@ -278,6 +286,9 @@ func (m *mockNodePoolService) CascadeSoftDelete(
278286
}
279287

280288
func (m *mockNodePoolService) Delete(context.Context, string) *errors.ServiceError { return nil }
289+
func (m *mockNodePoolService) ForceDelete(context.Context, string, string) *errors.ServiceError {
290+
return nil
291+
}
281292

282293
func (m *mockNodePoolService) All(context.Context) (api.NodePoolList, *errors.ServiceError) {
283294
return nil, nil

pkg/services/node_pool.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"time"
88

99
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/api"
10+
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/auth"
1011
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/config"
1112
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao"
1213
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors"
@@ -29,6 +30,7 @@ type NodePoolService interface {
2930
ctx context.Context, nodePools api.NodePoolList, deletedBy string, deletedTime time.Time,
3031
) *errors.ServiceError
3132
Delete(ctx context.Context, id string) *errors.ServiceError
33+
ForceDelete(ctx context.Context, id string, reason string) *errors.ServiceError
3234
All(ctx context.Context) (api.NodePoolList, *errors.ServiceError)
3335

3436
FindByIDs(ctx context.Context, ids []string) (api.NodePoolList, *errors.ServiceError)
@@ -244,6 +246,61 @@ func (s *sqlNodePoolService) Delete(ctx context.Context, id string) *errors.Serv
244246
return nil
245247
}
246248

249+
func (s *sqlNodePoolService) ForceDelete(ctx context.Context, id string, reason string) *errors.ServiceError {
250+
nodePool, err := s.nodePoolDao.GetForUpdate(ctx, id)
251+
if err != nil {
252+
return handleGetError("NodePool", "id", id, err)
253+
}
254+
255+
if nodePool.DeletedTime == nil {
256+
return errors.ConflictState("NodePool '%s' is not in Finalizing state", id)
257+
}
258+
259+
statuses, err := s.adapterStatusDao.FindByResource(ctx, "NodePool", id)
260+
if err != nil {
261+
return errors.GeneralError("Failed to fetch adapter statuses for nodepool '%s': %s", id, err)
262+
}
263+
264+
caller := auth.GetUsernameFromContext(ctx)
265+
if caller == "" {
266+
caller = "unknown"
267+
}
268+
269+
type adapterSummary struct {
270+
Conditions map[string]string `json:"conditions"`
271+
Adapter string `json:"adapter"`
272+
}
273+
summaries := make([]adapterSummary, 0, len(statuses))
274+
for _, st := range statuses {
275+
conds := make(map[string]string)
276+
var parsed []api.AdapterCondition
277+
if err := json.Unmarshal(st.Conditions, &parsed); err == nil {
278+
for _, c := range parsed {
279+
conds[c.Type] = string(c.Status)
280+
}
281+
}
282+
summaries = append(summaries, adapterSummary{Adapter: st.Adapter, Conditions: conds})
283+
}
284+
285+
logger.With(ctx,
286+
"nodepool_id", id,
287+
"resource_type", "NodePool",
288+
"caller", caller,
289+
"reason", reason,
290+
"adapter_statuses", summaries,
291+
).Info("Force-deleting nodepool")
292+
293+
if err := s.adapterStatusDao.DeleteByResource(ctx, "NodePool", id); err != nil {
294+
return errors.GeneralError("Failed to delete adapter statuses for nodepool '%s': %s", id, err)
295+
}
296+
297+
if err := s.nodePoolDao.Delete(ctx, id); err != nil {
298+
return errors.GeneralError("Failed to force-delete nodepool '%s': %s", id, err)
299+
}
300+
301+
return nil
302+
}
303+
247304
func (s *sqlNodePoolService) FindByIDs(ctx context.Context, ids []string) (api.NodePoolList, *errors.ServiceError) {
248305
nodePools, err := s.nodePoolDao.FindByIDs(ctx, ids)
249306
if err != nil {

0 commit comments

Comments
 (0)