diff --git a/pkg/dao/mocks/resource.go b/pkg/dao/mocks/resource.go index 4a31cfae..b1add509 100644 --- a/pkg/dao/mocks/resource.go +++ b/pkg/dao/mocks/resource.go @@ -97,3 +97,12 @@ func (d *resourceDaoMock) FindByKindAndOwnerForUpdate( ) (api.ResourceList, error) { return d.FindByKindAndOwner(ctx, kind, ownerID) } + +func (d *resourceDaoMock) GetByID(_ context.Context, id string) (*api.Resource, error) { + for _, r := range d.resources { + if r.ID == id { + return r, nil + } + } + return nil, gorm.ErrRecordNotFound +} diff --git a/pkg/dao/resource.go b/pkg/dao/resource.go index d0c69a25..2011272f 100644 --- a/pkg/dao/resource.go +++ b/pkg/dao/resource.go @@ -21,6 +21,7 @@ type ResourceDao interface { FindByKind(ctx context.Context, kind string) (api.ResourceList, error) 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) } var _ ResourceDao = &sqlResourceDao{} @@ -129,6 +130,15 @@ func (d *sqlResourceDao) FindByKindAndOwner(ctx context.Context, kind, ownerID s return resources, nil } +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 { + return nil, err + } + return &resource, nil +} + func (d *sqlResourceDao) FindByKindAndOwnerForUpdate( ctx context.Context, kind, ownerID string, ) (api.ResourceList, error) { diff --git a/pkg/handlers/resource_handler.go b/pkg/handlers/resource_handler.go index 151fdced..997cb553 100644 --- a/pkg/handlers/resource_handler.go +++ b/pkg/handlers/resource_handler.go @@ -275,3 +275,47 @@ func (h *ResourceHandler) DeleteByOwner(w http.ResponseWriter, r *http.Request) } handleSoftDelete(w, r, cfg) } + +func (h *ResourceHandler) ForceDelete(w http.ResponseWriter, r *http.Request) { + var req openapi.ForceDeleteRequest + cfg := &handlerConfig{ + MarshalInto: &req, + Validate: []validate{ + validateNotEmpty(&req, "Reason", "reason"), + validateMaxLength(&req, "Reason", "reason", maxReasonLength), + }, + Action: func() (interface{}, *errors.ServiceError) { + id := mux.Vars(r)["id"] + if err := h.service.ForceDelete(r.Context(), h.descriptor.Kind, id, req.Reason); err != nil { + return nil, err + } + return nil, nil + }, + } + handleForceDelete(w, r, cfg) +} + +func (h *ResourceHandler) ForceDeleteByOwner(w http.ResponseWriter, r *http.Request) { + var req openapi.ForceDeleteRequest + cfg := &handlerConfig{ + MarshalInto: &req, + Validate: []validate{ + validateNotEmpty(&req, "Reason", "reason"), + validateMaxLength(&req, "Reason", "reason", maxReasonLength), + }, + Action: func() (interface{}, *errors.ServiceError) { + vars := mux.Vars(r) + parentID, id := vars["parent_id"], vars["id"] + + if _, err := h.service.GetByOwner(r.Context(), h.descriptor.Kind, id, parentID); err != nil { + return nil, err + } + + if err := h.service.ForceDelete(r.Context(), h.descriptor.Kind, id, req.Reason); err != nil { + return nil, err + } + return nil, nil + }, + } + handleForceDelete(w, r, cfg) +} diff --git a/pkg/handlers/resource_handler_test.go b/pkg/handlers/resource_handler_test.go index 22ed9ec2..ca823b91 100644 --- a/pkg/handlers/resource_handler_test.go +++ b/pkg/handlers/resource_handler_test.go @@ -650,3 +650,175 @@ func TestResourceHandler_DeleteByOwner(t *testing.T) { }) } } + +func TestResourceHandler_ForceDelete(t *testing.T) { + RegisterTestingT(t) + + resourceID := "ch-123" + + tests := []struct { + setupMock func(mock *services.MockResourceService) + name string + body string + expectedStatusCode int + }{ + { + name: "Success 204 - resource force-deleted", + body: `{"reason": "Stuck in finalizing for 2 hours"}`, + setupMock: func(mock *services.MockResourceService) { + mock.EXPECT(). + ForceDelete(gomock.Any(), "Channel", resourceID, "Stuck in finalizing for 2 hours"). + Return(nil) + }, + expectedStatusCode: http.StatusNoContent, + }, + { + name: "Error 400 - malformed JSON", + body: `not json`, + setupMock: func(mock *services.MockResourceService) { + }, + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "Error 400 - empty reason", + body: `{"reason": ""}`, + setupMock: func(mock *services.MockResourceService) { + }, + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "Error 400 - reason exceeds max length", + body: `{"reason": "` + strings.Repeat("x", maxReasonLength+1) + `"}`, + setupMock: func(mock *services.MockResourceService) { + }, + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "Error 404 - resource not found", + body: `{"reason": "some reason"}`, + setupMock: func(mock *services.MockResourceService) { + mock.EXPECT(). + ForceDelete(gomock.Any(), "Channel", resourceID, "some reason"). + Return(errors.NotFound("Channel with id='%s' not found", resourceID)) + }, + expectedStatusCode: http.StatusNotFound, + }, + { + name: "Error 409 - resource not in Finalizing state", + body: `{"reason": "some reason"}`, + setupMock: func(mock *services.MockResourceService) { + mock.EXPECT(). + ForceDelete(gomock.Any(), "Channel", resourceID, "some reason"). + Return(errors.ConflictState("Channel '%s' is not in Finalizing state", resourceID)) + }, + expectedStatusCode: http.StatusConflict, + }, + { + name: "Error 500 - service internal error", + body: `{"reason": "some reason"}`, + setupMock: func(mock *services.MockResourceService) { + mock.EXPECT(). + ForceDelete(gomock.Any(), "Channel", resourceID, "some reason"). + Return(errors.GeneralError("database connection lost")) + }, + expectedStatusCode: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + RegisterTestingT(t) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + handler, mockSvc := newTestResourceHandler(ctrl) + tt.setupMock(mockSvc) + + reqURL := "/api/hyperfleet/v1/channels/" + resourceID + "/force-delete" + req := httptest.NewRequest(http.MethodPost, reqURL, strings.NewReader(tt.body)) + req.Header.Set("Content-Type", "application/json") + req = mux.SetURLVars(req, map[string]string{"id": resourceID}) + + rr := httptest.NewRecorder() + handler.ForceDelete(rr, req) + + Expect(rr.Code).To(Equal(tt.expectedStatusCode)) + + if tt.expectedStatusCode == http.StatusNoContent { + Expect(rr.Body.Len()).To(Equal(0)) + } + }) + } +} + +func TestResourceHandler_ForceDeleteByOwner(t *testing.T) { + RegisterTestingT(t) + + parentID := "ch-1" + versionID := "v-1" + + tests := []struct { + setupMock func(mock *services.MockResourceService) + name string + body string + expectedStatusCode int + }{ + { + name: "Success 204 - nested resource force-deleted", + body: `{"reason": "Stuck in finalizing"}`, + setupMock: func(mock *services.MockResourceService) { + mock.EXPECT(). + GetByOwner(gomock.Any(), "Version", versionID, parentID). + Return(&api.Resource{Meta: api.Meta{ID: versionID}, Kind: "Version"}, nil) + mock.EXPECT(). + ForceDelete(gomock.Any(), "Version", versionID, "Stuck in finalizing"). + Return(nil) + }, + expectedStatusCode: http.StatusNoContent, + }, + { + name: "Error 404 - ownership mismatch", + body: `{"reason": "some reason"}`, + setupMock: func(mock *services.MockResourceService) { + mock.EXPECT(). + GetByOwner(gomock.Any(), "Version", versionID, parentID). + Return(nil, errors.NotFound("Version with id='%s' not found for owner '%s'", versionID, parentID)) + }, + expectedStatusCode: http.StatusNotFound, + }, + { + name: "Error 400 - empty reason", + body: `{"reason": ""}`, + setupMock: func(mock *services.MockResourceService) { + }, + expectedStatusCode: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + RegisterTestingT(t) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + handler, mockSvc := newTestVersionHandler(ctrl) + tt.setupMock(mockSvc) + + reqURL := "/api/hyperfleet/v1/channels/" + parentID + "/versions/" + versionID + "/force-delete" + req := httptest.NewRequest(http.MethodPost, reqURL, strings.NewReader(tt.body)) + req.Header.Set("Content-Type", "application/json") + req = mux.SetURLVars(req, map[string]string{"parent_id": parentID, "id": versionID}) + + rr := httptest.NewRecorder() + handler.ForceDeleteByOwner(rr, req) + + Expect(rr.Code).To(Equal(tt.expectedStatusCode)) + + if tt.expectedStatusCode == http.StatusNoContent { + Expect(rr.Body.Len()).To(Equal(0)) + } + }) + } +} diff --git a/pkg/handlers/root_resource_handler.go b/pkg/handlers/root_resource_handler.go new file mode 100644 index 00000000..75349cfc --- /dev/null +++ b/pkg/handlers/root_resource_handler.go @@ -0,0 +1,196 @@ +package handlers + +import ( + "fmt" + "net/http" + + "github.com/gorilla/mux" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/presenters" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/validators" +) + +type RootResourceHandler struct { + service services.ResourceService + validator *validators.SchemaValidator +} + +func NewRootResourceHandler( + service services.ResourceService, + validator *validators.SchemaValidator, +) *RootResourceHandler { + return &RootResourceHandler{service: service, validator: validator} +} + +func (h *RootResourceHandler) List(w http.ResponseWriter, r *http.Request) { + cfg := &handlerConfig{ + Action: func() (interface{}, *errors.ServiceError) { + listArgs, err := services.NewListArguments(r.URL.Query()) + if err != nil { + return nil, err + } + + if kind := r.URL.Query().Get("kind"); kind != "" { + descriptor, ok := registry.Get(kind) + if !ok { + return nil, errors.Validation("Unknown entity kind: %s", kind) + } + kindFilter := fmt.Sprintf("kind = '%s'", descriptor.Kind) + if listArgs.Search == "" { + listArgs.Search = kindFilter + } else { + listArgs.Search = "(" + listArgs.Search + ") AND " + kindFilter + } + } + + resources, paging, err := h.service.ListAll(r.Context(), listArgs) + if err != nil { + return nil, err + } + result := presenters.PresentResourceList(resources, paging) + if listArgs.Fields != nil { + return presenters.SliceFilter(listArgs.Fields, result) + } + return result, nil + }, + } + handleList(w, r, cfg) +} + +func (h *RootResourceHandler) Get(w http.ResponseWriter, r *http.Request) { + cfg := &handlerConfig{ + Action: func() (interface{}, *errors.ServiceError) { + id := mux.Vars(r)["id"] + resource, err := h.service.GetByID(r.Context(), id) + if err != nil { + return nil, err + } + presented := presenters.PresentResource(resource) + return applyFieldFilter(r, presented) + }, + } + handleGet(w, r, cfg) +} + +func (h *RootResourceHandler) Create(w http.ResponseWriter, r *http.Request) { + var req openapi.ResourceCreateRequest + cfg := &handlerConfig{ + MarshalInto: &req, + Validate: []validate{ + validateSpec(&req, "Spec", "spec"), + validateLabels(&req, "Labels"), + }, + Action: func() (interface{}, *errors.ServiceError) { + descriptor, ok := registry.Get(req.Kind) + if !ok { + return nil, errors.Validation("Unknown entity kind: %s", req.Kind) + } + if descriptor.ParentKind != "" { + return nil, ChildCreateRejection(descriptor) + } + + resource, convErr := presenters.ConvertResource(&req) + if convErr != nil { + return nil, errors.GeneralError("failed to convert resource: %v", convErr) + } + resource, svcErr := h.service.Create(r.Context(), descriptor.Kind, resource) + if svcErr != nil { + return nil, svcErr + } + return presenters.PresentResource(resource), nil + }, + } + handle(w, r, cfg, http.StatusCreated) +} + +func (h *RootResourceHandler) Patch(w http.ResponseWriter, r *http.Request) { + var req openapi.ResourcePatchRequest + cfg := &handlerConfig{ + MarshalInto: &req, + StrictUnmarshal: true, + Validate: []validate{ + validatePatchRequest(&req), + validateLabels(&req, "Labels"), + }, + Action: func() (interface{}, *errors.ServiceError) { + id := mux.Vars(r)["id"] + resource, err := h.service.GetByID(r.Context(), id) + if err != nil { + return nil, err + } + + if req.Spec != nil && h.validator != nil { + descriptor := registry.MustGet(resource.Kind) + if validationErr := h.validator.Validate(descriptor.Plural, *req.Spec); validationErr != nil { + if svcErr, ok := validationErr.(*errors.ServiceError); ok { + return nil, svcErr + } + return nil, errors.Validation("Spec validation failed: %v", validationErr) + } + } + + patch := convertResourcePatch(&req) + resource, err = h.service.Patch(r.Context(), resource.Kind, id, patch) + if err != nil { + return nil, err + } + return presenters.PresentResource(resource), nil + }, + } + handle(w, r, cfg, http.StatusOK) +} + +func (h *RootResourceHandler) ForceDelete(w http.ResponseWriter, r *http.Request) { + var req openapi.ForceDeleteRequest + cfg := &handlerConfig{ + MarshalInto: &req, + Validate: []validate{ + validateNotEmpty(&req, "Reason", "reason"), + validateMaxLength(&req, "Reason", "reason", maxReasonLength), + }, + Action: func() (interface{}, *errors.ServiceError) { + id := mux.Vars(r)["id"] + resource, err := h.service.GetByID(r.Context(), id) + if err != nil { + return nil, err + } + if err := h.service.ForceDelete(r.Context(), resource.Kind, id, req.Reason); err != nil { + return nil, err + } + return nil, nil + }, + } + handleForceDelete(w, r, cfg) +} + +func (h *RootResourceHandler) Delete(w http.ResponseWriter, r *http.Request) { + cfg := &handlerConfig{ + Action: func() (interface{}, *errors.ServiceError) { + id := mux.Vars(r)["id"] + resource, err := h.service.GetByID(r.Context(), id) + if err != nil { + return nil, err + } + resource, svcErr := h.service.Delete(r.Context(), resource.Kind, id) + if svcErr != nil { + return nil, svcErr + } + return presenters.PresentResource(resource), nil + }, + } + handleSoftDelete(w, r, cfg) +} + +func ChildCreateRejection(descriptor registry.EntityDescriptor) *errors.ServiceError { + parent := registry.MustGet(descriptor.ParentKind) + svcErr := errors.Validation( + "Cannot create %s here. Use POST /%s/{%s_id}/%s", + descriptor.Kind, parent.Plural, parent.Kind, descriptor.Plural, + ) + svcErr.HTTPCode = http.StatusUnprocessableEntity + return svcErr +} diff --git a/pkg/middleware/schema_validation.go b/pkg/middleware/schema_validation.go index 8f0c9786..ac905e16 100644 --- a/pkg/middleware/schema_validation.go +++ b/pkg/middleware/schema_validation.go @@ -119,6 +119,15 @@ func SchemaValidationMiddleware(validator *validators.SchemaValidator) func(http return } + // For root /resources endpoint, resolve the entity plural from body's kind field + if resourcePlural == "" { + resourcePlural = resolveRootResourcePlural(requestData) + if resourcePlural == "" { + next.ServeHTTP(w, r) + return + } + } + // Extract spec field spec, ok := requestData["spec"] if !ok { @@ -156,11 +165,19 @@ func SchemaValidationMiddleware(validator *validators.SchemaValidator) func(http } } +// rootResourcePattern matches the /resources root endpoint (with or without a trailing UUID). +var rootResourcePattern = regexp.MustCompile( + `/resources(?:/?|/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$`, +) + // shouldValidateRequest determines if the request requires spec validation. // // Each matcher's regex anchors at the end of the path, so when a path contains // multiple registered plurals (e.g. /clusters/{id}/nodepools), only the // rightmost (deepest) segment can match, and that match wins automatically. +// +// The root /resources endpoint is also matched; the caller must resolve the +// entity plural from the request body's "kind" field (see resolveRootResourcePlural). func shouldValidateRequest( method, path string, matchers []specEntityMatcher, ) (shouldValidate bool, resourcePlural string) { @@ -168,14 +185,29 @@ func shouldValidateRequest( return false, "" } - shouldValidate = false for _, m := range matchers { - shouldValidate = m.re.MatchString(path) - if shouldValidate { - resourcePlural = m.plural - break + if m.re.MatchString(path) { + return true, m.plural } } - return shouldValidate, resourcePlural + if rootResourcePattern.MatchString(path) { + return true, "" + } + + return false, "" +} + +// resolveRootResourcePlural extracts the entity kind from a parsed request body +// and maps it to the descriptor's Plural for schema lookup. +func resolveRootResourcePlural(requestData map[string]interface{}) string { + kind, ok := requestData["kind"].(string) + if !ok || kind == "" { + return "" + } + descriptor, found := registry.Get(kind) + if !found { + return "" + } + return descriptor.Plural } diff --git a/pkg/services/resource.go b/pkg/services/resource.go index 64f03cf7..db200804 100644 --- a/pkg/services/resource.go +++ b/pkg/services/resource.go @@ -10,6 +10,7 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/util" ) @@ -24,6 +25,9 @@ type ResourceService interface { List(ctx context.Context, kind string, args *ListArguments) (api.ResourceList, *api.PagingMeta, *errors.ServiceError) GetByOwner(ctx context.Context, kind, id, ownerID string) (*api.Resource, *errors.ServiceError) ListByOwner(ctx context.Context, kind, ownerID string, args *ListArguments) (api.ResourceList, *api.PagingMeta, *errors.ServiceError) // nolint:lll + ForceDelete(ctx context.Context, kind, id, reason string) *errors.ServiceError + GetByID(ctx context.Context, id string) (*api.Resource, *errors.ServiceError) + ListAll(ctx context.Context, args *ListArguments) (api.ResourceList, *api.PagingMeta, *errors.ServiceError) } func NewResourceService(resourceDao dao.ResourceDao, generic GenericService) ResourceService { @@ -300,6 +304,30 @@ func (s *sqlResourceService) ListByOwner( return result, paging, nil } +func (s *sqlResourceService) GetByID(ctx context.Context, id string) (*api.Resource, *errors.ServiceError) { + resource, err := s.resourceDao.GetByID(ctx, id) + if err != nil { + return nil, handleGetError("Resource", "id", id, err) + } + return resource, nil +} + +func (s *sqlResourceService) ListAll( + ctx context.Context, args *ListArguments, +) (api.ResourceList, *api.PagingMeta, *errors.ServiceError) { + var resources []api.Resource + paging, svcErr := s.generic.List(ctx, args, &resources) + if svcErr != nil { + return nil, nil, svcErr + } + + result := make(api.ResourceList, len(resources)) + for i := range resources { + result[i] = &resources[i] + } + return result, paging, nil +} + // validateKind checks that the kind is a registered entity type. // Returns 400 if the kind is unknown, preventing invalid kinds from reaching the DAO. func validateKind(kind string) *errors.ServiceError { @@ -355,3 +383,70 @@ func applyResourcePatch(resource *api.Resource, patch *api.ResourcePatch) error // via dao.ReplaceReferences per generic-resource-registry-design.md §9.2 return nil } + +func (s *sqlResourceService) ForceDelete(ctx context.Context, kind, id, reason string) *errors.ServiceError { + if svcErr := validateKind(kind); svcErr != nil { + return svcErr + } + + resource, err := s.resourceDao.GetForUpdate(ctx, kind, id) + if err != nil { + return handleGetError(kind, "id", id, err) + } + + if resource.DeletedTime == nil { + return errors.ConflictState("%s '%s' is not in Finalizing state", kind, id) + } + + caller := actorFromContext(ctx) + if svcErr := s.forceDeleteResourceTree(ctx, resource, caller, reason); svcErr != nil { + db.MarkForRollback(ctx, svcErr) + return svcErr + } + return nil +} + +func (s *sqlResourceService) forceDeleteResourceTree( + ctx context.Context, resource *api.Resource, caller, reason string, +) *errors.ServiceError { + desc := registry.MustGet(resource.Kind) + if len(desc.RequiredAdapters) > 0 { + return errors.GeneralError( + "force-delete not implemented for resources with required adapters (kind=%s)"+ + " — adapter_status cleanup needed, see HYPERFLEET-1154", + resource.Kind, + ) + } + + children := registry.ChildrenOf(resource.Kind) + + childIDs := make([]string, 0) + for _, child := range children { + items, err := s.resourceDao.FindByKindAndOwnerForUpdate(ctx, child.Kind, resource.ID) + if err != nil { + logger.With(ctx, "resource_id", resource.ID, "child_kind", child.Kind). + WithError(err).Error("Failed to find children for force-delete") + return errors.GeneralError("Unable to find %s children for force-delete", child.Kind) + } + for _, item := range items { + childIDs = append(childIDs, item.ID) + if svcErr := s.forceDeleteResourceTree(ctx, item, caller, reason); svcErr != nil { + return svcErr + } + } + } + + logger.With(ctx, + "resource_kind", resource.Kind, + "resource_id", resource.ID, + "caller", caller, + "reason", reason, + "child_resource_ids", childIDs, + ).Info("Force-deleting resource") + + if err := s.resourceDao.Delete(ctx, resource.Kind, resource.ID); err != nil { + return handleDeleteError(resource.Kind, err) + } + + return nil +} diff --git a/pkg/services/resource_test.go b/pkg/services/resource_test.go index 42126fb7..c642137c 100644 --- a/pkg/services/resource_test.go +++ b/pkg/services/resource_test.go @@ -17,6 +17,11 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" ) +const ( + testDeletedBy = "someone" + testChannelID = "ch-1" +) + func setupTestDescriptors() { registry.Reset() registry.Register(registry.EntityDescriptor{ @@ -131,6 +136,15 @@ func (d *mockResourceDao) FindByKindAndOwnerForUpdate( return d.FindByKindAndOwner(ctx, kind, ownerID) } +func (d *mockResourceDao) GetByID(_ context.Context, id string) (*api.Resource, error) { + for _, r := range d.resources { + if r.ID == id { + return r, nil + } + } + return nil, gorm.ErrRecordNotFound +} + func (d *mockResourceDao) FindByIDs(_ context.Context, kind string, ids []string) (api.ResourceList, error) { idSet := make(map[string]bool, len(ids)) for _, id := range ids { @@ -562,7 +576,7 @@ func TestResourceService_Delete_AlreadyDeleted_Idempotent(t *testing.T) { now := time.Now() existing := testResource("Channel", "ch-1", "stable") existing.DeletedTime = &now - deletedBy := "someone" + deletedBy := testDeletedBy existing.DeletedBy = &deletedBy existing.Generation = 3 mockDao.addResource(existing) @@ -1119,3 +1133,240 @@ func TestResourceService_ListByOwner_UnknownKind(t *testing.T) { Expect(svcErr).ToNot(BeNil()) Expect(svcErr.HTTPCode).To(Equal(400)) } + +// --- ForceDelete --- + +func TestResourceService_ForceDelete_HappyPath_NoChildren(t *testing.T) { + RegisterTestingT(t) + setupTestDescriptors() + + mockDao := newMockResourceDao() + svc, _, _ := newTestResourceService(mockDao) + + now := time.Now() + existing := testResource("Channel", testChannelID, "stable") + existing.DeletedTime = &now + deletedBy := testDeletedBy + existing.DeletedBy = &deletedBy + mockDao.addResource(existing) + + svcErr := svc.ForceDelete(context.Background(), "Channel", testChannelID, "Stuck in finalizing") + Expect(svcErr).To(BeNil()) + + _, exists := mockDao.resources[resourceKey("Channel", testChannelID)] + Expect(exists).To(BeFalse()) +} + +func TestResourceService_ForceDelete_CascadesAllChildren(t *testing.T) { + RegisterTestingT(t) + setupTestDescriptors() + + mockDao := newMockResourceDao() + svc, _, _ := newTestResourceService(mockDao) + + now := time.Now() + channel := testResource("Channel", testChannelID, "stable") + channel.DeletedTime = &now + deletedBy := testDeletedBy + channel.DeletedBy = &deletedBy + mockDao.addResource(channel) + + chID := testChannelID + v1 := testResource("Version", "v-1", "v1.0") + v1.OwnerID = &chID + mockDao.addResource(v1) + + v2 := testResource("Version", "v-2", "v2.0") + v2.OwnerID = &chID + mockDao.addResource(v2) + + svcErr := svc.ForceDelete(context.Background(), "Channel", testChannelID, "stuck") + Expect(svcErr).To(BeNil()) + + _, chExists := mockDao.resources[resourceKey("Channel", testChannelID)] + Expect(chExists).To(BeFalse()) + _, v1Exists := mockDao.resources[resourceKey("Version", "v-1")] + Expect(v1Exists).To(BeFalse()) + _, v2Exists := mockDao.resources[resourceKey("Version", "v-2")] + Expect(v2Exists).To(BeFalse()) +} + +func TestResourceService_ForceDelete_BypassesRestrict(t *testing.T) { + RegisterTestingT(t) + setupTestDescriptors() + + mockDao := newMockResourceDao() + svc, _, _ := newTestResourceService(mockDao) + + channel := testResource("Channel", testChannelID, "stable") + mockDao.addResource(channel) + + chID := testChannelID + version := testResource("Version", "v-1", "v1.0") + version.OwnerID = &chID + mockDao.addResource(version) + + // Normal delete blocked by Restrict policy (active children) + _, normalDeleteErr := svc.Delete(context.Background(), "Channel", testChannelID) + Expect(normalDeleteErr).ToNot(BeNil()) + Expect(normalDeleteErr.HTTPCode).To(Equal(409)) + + // Simulate reaching Finalizing state (e.g., via admin override) + now := time.Now() + channel.DeletedTime = &now + deletedBy := "admin" + channel.DeletedBy = &deletedBy + + // Force-delete bypasses Restrict and cascades everything + svcErr := svc.ForceDelete(context.Background(), "Channel", testChannelID, "bypass restrict") + Expect(svcErr).To(BeNil()) + + _, chExists := mockDao.resources[resourceKey("Channel", testChannelID)] + Expect(chExists).To(BeFalse()) + _, vExists := mockDao.resources[resourceKey("Version", "v-1")] + Expect(vExists).To(BeFalse()) +} + +func TestResourceService_ForceDelete_NotInFinalizingState(t *testing.T) { + RegisterTestingT(t) + setupTestDescriptors() + + mockDao := newMockResourceDao() + svc, _, _ := newTestResourceService(mockDao) + + existing := testResource("Channel", testChannelID, "stable") + mockDao.addResource(existing) + + svcErr := svc.ForceDelete(context.Background(), "Channel", testChannelID, "some reason") + Expect(svcErr).ToNot(BeNil()) + Expect(svcErr.HTTPCode).To(Equal(409)) +} + +func TestResourceService_ForceDelete_NotFound(t *testing.T) { + RegisterTestingT(t) + setupTestDescriptors() + + mockDao := newMockResourceDao() + svc, _, _ := newTestResourceService(mockDao) + + svcErr := svc.ForceDelete(context.Background(), "Channel", "nonexistent", "some reason") + Expect(svcErr).ToNot(BeNil()) + Expect(svcErr.HTTPCode).To(Equal(404)) +} + +func TestResourceService_ForceDelete_RecursiveGrandchildren(t *testing.T) { + RegisterTestingT(t) + registry.Reset() + registry.Register(registry.EntityDescriptor{Kind: "Root", Plural: "roots"}) + registry.Register(registry.EntityDescriptor{ + Kind: "Child", Plural: "children", ParentKind: "Root", + OnParentDelete: registry.OnParentDeleteCascade, + }) + registry.Register(registry.EntityDescriptor{ + Kind: "Grandchild", Plural: "grandchildren", ParentKind: "Child", + OnParentDelete: registry.OnParentDeleteRestrict, + }) + + mockDao := newMockResourceDao() + svc, _, _ := newTestResourceService(mockDao) + + now := time.Now() + root := testResource("Root", "r-1", "root") + root.DeletedTime = &now + deletedBy := testDeletedBy + root.DeletedBy = &deletedBy + mockDao.addResource(root) + + rootID := "r-1" + child := testResource("Child", "c-1", "child") + child.OwnerID = &rootID + mockDao.addResource(child) + + childID := "c-1" + grandchild := testResource("Grandchild", "gc-1", "grandchild") + grandchild.OwnerID = &childID + mockDao.addResource(grandchild) + + svcErr := svc.ForceDelete(context.Background(), "Root", "r-1", "force all") + Expect(svcErr).To(BeNil()) + + Expect(mockDao.resources).To(HaveLen(0)) +} + +func TestResourceService_ForceDelete_RequiredAdaptersBlocked(t *testing.T) { + RegisterTestingT(t) + setupManagedDescriptor() + + mockDao := newMockResourceDao() + svc, _, _ := newTestResourceService(mockDao) + + now := time.Now() + existing := testResource("Managed", "m-1", "managed-1") + existing.DeletedTime = &now + deletedBy := testDeletedBy + existing.DeletedBy = &deletedBy + mockDao.addResource(existing) + + svcErr := svc.ForceDelete(context.Background(), "Managed", "m-1", "some reason") + Expect(svcErr).ToNot(BeNil()) + Expect(svcErr.HTTPCode).To(Equal(500)) +} + +func TestResourceService_ForceDelete_ChildWithRequiredAdapters(t *testing.T) { + RegisterTestingT(t) + registry.Reset() + registry.Register(registry.EntityDescriptor{Kind: "Parent", Plural: "parents"}) + registry.Register(registry.EntityDescriptor{ + Kind: "ManagedChild", Plural: "managedchildren", ParentKind: "Parent", + OnParentDelete: registry.OnParentDeleteCascade, + RequiredAdapters: []string{"provisioner"}, + }) + + mockDao := newMockResourceDao() + svc, _, _ := newTestResourceService(mockDao) + + now := time.Now() + parent := testResource("Parent", "p-1", "parent") + parent.DeletedTime = &now + deletedBy := testDeletedBy + parent.DeletedBy = &deletedBy + mockDao.addResource(parent) + + childID := "p-1" + child := testResource("ManagedChild", "mc-1", "managed-child") + child.OwnerID = &childID + mockDao.addResource(child) + + svcErr := svc.ForceDelete(context.Background(), "Parent", "p-1", "force it") + Expect(svcErr).ToNot(BeNil()) + Expect(svcErr.HTTPCode).To(Equal(500)) + + _, parentExists := mockDao.resources[resourceKey("Parent", "p-1")] + Expect(parentExists).To(BeTrue(), "parent should NOT be deleted when child cascade fails") + _, childExists := mockDao.resources[resourceKey("ManagedChild", "mc-1")] + Expect(childExists).To(BeTrue(), "child should NOT be deleted when guard fires") +} + +func TestResourceService_ForceDelete_InvalidKind(t *testing.T) { + RegisterTestingT(t) + setupTestDescriptors() + + mockDao := newMockResourceDao() + svc, _, _ := newTestResourceService(mockDao) + + svcErr := svc.ForceDelete(context.Background(), "Bogus", testChannelID, "some reason") + Expect(svcErr).ToNot(BeNil()) + Expect(svcErr.HTTPCode).To(Equal(400)) +} + +func TestAllGenericDescriptors_HaveNoRequiredAdapters(t *testing.T) { + RegisterTestingT(t) + setupTestDescriptors() + + for _, d := range registry.All() { + Expect(d.RequiredAdapters).To(BeEmpty(), + "Descriptor %q has RequiredAdapters=%v. "+ + "ForceDelete does not yet handle adapter_status cleanup. "+ + "See HYPERFLEET-1154.", d.Kind, d.RequiredAdapters) + } +} diff --git a/plugins/entities/plugin.go b/plugins/entities/plugin.go index 575682a6..1dc8edfb 100644 --- a/plugins/entities/plugin.go +++ b/plugins/entities/plugin.go @@ -1,6 +1,7 @@ package entities import ( + "fmt" "net/http" "sort" @@ -8,9 +9,12 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/environments" "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/server" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/response" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/handlers" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/validators" "github.com/openshift-hyperfleet/hyperfleet-api/plugins/resources" ) @@ -19,7 +23,17 @@ func init() { envServices := svc.(*environments.Services) resourceService := resources.Service(envServices) - RegisterEntityRoutes(apiV1Router, resourceService) + schemaPath := environments.Environment().Config.Server.OpenAPISchemaPath + var schemaValidator *validators.SchemaValidator + if schemaPath != "" { + var err error + schemaValidator, err = validators.NewSchemaValidator(schemaPath) + if err != nil { + schemaValidator = nil + } + } + + RegisterEntityRoutes(apiV1Router, resourceService, schemaValidator) }) } @@ -29,13 +43,23 @@ func init() { // // Top-level entities get routes at /{plural}. Child entities (ParentKind != "") // get nested routes only, under /{parent_plural}/{parent_id}/{plural}. -func RegisterEntityRoutes(apiV1Router *mux.Router, resourceService services.ResourceService) { +func RegisterEntityRoutes( + apiV1Router *mux.Router, + resourceService services.ResourceService, + schemaValidator *validators.SchemaValidator, +) { descriptors := registry.All() sort.Slice(descriptors, func(i, j int) bool { return descriptors[i].Kind < descriptors[j].Kind }) for _, descriptor := range descriptors { + if descriptor.Plural == "resources" { + panic(fmt.Sprintf( + "entity kind %q uses reserved plural %q which would shadow /resources root endpoint", + descriptor.Kind, descriptor.Plural, + )) + } h := handlers.NewResourceHandler(descriptor, resourceService) if descriptor.ParentKind == "" { @@ -45,6 +69,7 @@ func RegisterEntityRoutes(apiV1Router *mux.Router, resourceService services.Reso r.HandleFunc("/{id}", h.Get).Methods(http.MethodGet) r.HandleFunc("/{id}", h.Patch).Methods(http.MethodPatch) r.HandleFunc("/{id}", h.Delete).Methods(http.MethodDelete) + r.HandleFunc("/{id}/force-delete", h.ForceDelete).Methods(http.MethodPost) } else { parent := registry.MustGet(descriptor.ParentKind) pr := apiV1Router.PathPrefix("/" + parent.Plural + "/{parent_id}/" + descriptor.Plural).Subrouter() @@ -53,6 +78,40 @@ func RegisterEntityRoutes(apiV1Router *mux.Router, resourceService services.Reso pr.HandleFunc("/{id}", h.GetByOwner).Methods(http.MethodGet) pr.HandleFunc("/{id}", h.PatchByOwner).Methods(http.MethodPatch) pr.HandleFunc("/{id}", h.DeleteByOwner).Methods(http.MethodDelete) + pr.HandleFunc("/{id}/force-delete", h.ForceDeleteByOwner).Methods(http.MethodPost) + + fr := apiV1Router.PathPrefix("/" + descriptor.Plural).Subrouter() + fr.HandleFunc("", h.List).Methods(http.MethodGet) + fr.HandleFunc("", rejectChildCreate(descriptor)).Methods(http.MethodPost) + fr.HandleFunc("/{id}", h.Get).Methods(http.MethodGet) + fr.HandleFunc("/{id}", h.Patch).Methods(http.MethodPatch) + fr.HandleFunc("/{id}", h.Delete).Methods(http.MethodDelete) + fr.HandleFunc("/{id}/force-delete", h.ForceDelete).Methods(http.MethodPost) } } + + rootHandler := handlers.NewRootResourceHandler(resourceService, schemaValidator) + rootRouter := apiV1Router.PathPrefix("/resources").Subrouter() + rootRouter.HandleFunc("", rootHandler.List).Methods(http.MethodGet) + rootRouter.HandleFunc("", rootHandler.Create).Methods(http.MethodPost) + rootRouter.HandleFunc("/{id}", rootHandler.Get).Methods(http.MethodGet) + rootRouter.HandleFunc("/{id}", rootHandler.Patch).Methods(http.MethodPatch) + rootRouter.HandleFunc("/{id}", rootHandler.Delete).Methods(http.MethodDelete) + rootRouter.HandleFunc("/{id}/force-delete", rootHandler.ForceDelete).Methods(http.MethodPost) + // TODO: HYPERFLEET-1154 — wire /{id}/statuses GET and PUT once ResourceStatusHandler exists +} + +func rejectChildCreate(descriptor registry.EntityDescriptor) http.HandlerFunc { + svcErr := handlers.ChildCreateRejection(descriptor) + return func(w http.ResponseWriter, r *http.Request) { + traceID, _ := logger.GetRequestID(r.Context()) + logger.With(r.Context(), + "code", svcErr.RFC9457Code, + "http_code", svcErr.HTTPCode, + "reason", svcErr.Reason).Info("Client error response") + response.WriteProblemDetailsResponse( + w, r, svcErr.HTTPCode, + svcErr.AsProblemDetails(r.URL.Path, traceID), + ) + } } diff --git a/plugins/entities/plugin_test.go b/plugins/entities/plugin_test.go index 189f0591..1633e1a3 100644 --- a/plugins/entities/plugin_test.go +++ b/plugins/entities/plugin_test.go @@ -21,7 +21,7 @@ func TestRegisterEntityRoutes_TopLevelEntity(t *testing.T) { router := mux.NewRouter() apiV1 := router.PathPrefix("/api/hyperfleet/v1").Subrouter() - RegisterEntityRoutes(apiV1, nil) + RegisterEntityRoutes(apiV1, nil, nil) assertRouteMatches(t, router, "GET", "/api/hyperfleet/v1/channels") assertRouteMatches(t, router, "POST", "/api/hyperfleet/v1/channels") @@ -42,7 +42,7 @@ func TestRegisterEntityRoutes_ChildEntity_NestedOnly(t *testing.T) { router := mux.NewRouter() apiV1 := router.PathPrefix("/api/hyperfleet/v1").Subrouter() - RegisterEntityRoutes(apiV1, nil) + RegisterEntityRoutes(apiV1, nil, nil) parentID := "00000000-0000-0000-0000-000000000001" childID := "00000000-0000-0000-0000-000000000002" @@ -66,7 +66,7 @@ func TestRegisterEntityRoutes_EmptyRegistry(t *testing.T) { apiV1 := router.PathPrefix("/api/hyperfleet/v1").Subrouter() Expect(func() { - RegisterEntityRoutes(apiV1, nil) + RegisterEntityRoutes(apiV1, nil, nil) }).ToNot(Panic()) } diff --git a/test/integration/resource_force_delete_test.go b/test/integration/resource_force_delete_test.go new file mode 100644 index 00000000..95b6f096 --- /dev/null +++ b/test/integration/resource_force_delete_test.go @@ -0,0 +1,275 @@ +package integration + +import ( + "fmt" + "net/http" + "testing" + + "github.com/google/uuid" + . "github.com/onsi/gomega" + "gopkg.in/resty.v1" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" + "github.com/openshift-hyperfleet/hyperfleet-api/test" +) + +func createVersionForChannel( + t *testing.T, svc services.ResourceService, channelID, name string, +) { + t.Helper() + version := newVersionResource(name, channelID) + _, err := svc.Create(t.Context(), "Version", version) + if err != nil { + t.Fatalf("Failed to create version: %v", err) + } +} + +func markFinalizing(t *testing.T, h *test.Helper, resourceID string) { + t.Helper() + dbSession := h.DBFactory.New(t.Context()) + err := dbSession.Exec( + "UPDATE resources SET deleted_time = NOW(), deleted_by = 'admin' WHERE id = ?", + resourceID, + ).Error + Expect(err).ToNot(HaveOccurred()) +} + +func TestResourceForceDelete(t *testing.T) { + t.Run("ChannelWithRestrictedChildren", func(t *testing.T) { + RegisterTestingT(t) + svc, h := setupResourceTest(t) + prefix := uuid.NewString()[:8] + + channel := createChannel(t, svc, fmt.Sprintf("fd-ch-%s", prefix)) + createVersionForChannel(t, svc, channel.ID, fmt.Sprintf("fd-v1-%s", prefix)) + createVersionForChannel(t, svc, channel.ID, fmt.Sprintf("fd-v2-%s", prefix)) + + args := &services.ListArguments{Page: 1, Size: 10} + versions, _, listErr := svc.ListByOwner( + t.Context(), "Version", channel.ID, args, + ) + Expect(listErr).To(BeNil()) + Expect(versions).To(HaveLen(2)) + versionIDs := []string{versions[0].ID, versions[1].ID} + + _, deleteErr := svc.Delete(t.Context(), "Channel", channel.ID) + Expect(deleteErr).ToNot(BeNil()) + Expect(deleteErr.HTTPCode).To(Equal(409)) + + markFinalizing(t, h, channel.ID) + + forceErr := svc.ForceDelete( + t.Context(), "Channel", channel.ID, "stuck in finalizing", + ) + Expect(forceErr).To(BeNil()) + + allIDs := append([]string{channel.ID}, versionIDs...) + err := checkResourceCount(t.Context(), h, allIDs, 0) + Expect(err).ToNot(HaveOccurred()) + }) + + t.Run("ChannelNoChildren", func(t *testing.T) { + RegisterTestingT(t) + svc, h := setupResourceTest(t) + prefix := uuid.NewString()[:8] + + channel := createChannel(t, svc, fmt.Sprintf("fd-solo-%s", prefix)) + markFinalizing(t, h, channel.ID) + + forceErr := svc.ForceDelete( + t.Context(), "Channel", channel.ID, "cleanup", + ) + Expect(forceErr).To(BeNil()) + + err := checkResourceCount(t.Context(), h, []string{channel.ID}, 0) + Expect(err).ToNot(HaveOccurred()) + }) + + t.Run("NotInFinalizingState_Returns409", func(t *testing.T) { + RegisterTestingT(t) + svc, _ := setupResourceTest(t) + prefix := uuid.NewString()[:8] + + channel := createChannel(t, svc, fmt.Sprintf("fd-active-%s", prefix)) + + forceErr := svc.ForceDelete( + t.Context(), "Channel", channel.ID, "should fail", + ) + Expect(forceErr).ToNot(BeNil()) + Expect(forceErr.HTTPCode).To(Equal(409)) + }) + + t.Run("NotFound_Returns404", func(t *testing.T) { + RegisterTestingT(t) + svc, _ := setupResourceTest(t) + + forceErr := svc.ForceDelete( + t.Context(), "Channel", "nonexistent-id", "should fail", + ) + Expect(forceErr).ToNot(BeNil()) + Expect(forceErr.HTTPCode).To(Equal(404)) + }) + + t.Run("NestedVersionForceDelete", func(t *testing.T) { + RegisterTestingT(t) + svc, h := setupResourceTest(t) + prefix := uuid.NewString()[:8] + + channel := createChannel(t, svc, fmt.Sprintf("fd-parent-%s", prefix)) + versionName := fmt.Sprintf("fd-ver-%s", prefix) + createVersionForChannel(t, svc, channel.ID, versionName) + + args := &services.ListArguments{Page: 1, Size: 10} + versions, _, listErr := svc.ListByOwner( + t.Context(), "Version", channel.ID, args, + ) + Expect(listErr).To(BeNil()) + Expect(versions).To(HaveLen(1)) + versionID := versions[0].ID + + markFinalizing(t, h, versionID) + + forceErr := svc.ForceDelete( + t.Context(), "Version", versionID, "cleanup version", + ) + Expect(forceErr).To(BeNil()) + + err := checkResourceCount(t.Context(), h, []string{versionID}, 0) + Expect(err).ToNot(HaveOccurred()) + + err = checkResourceCount(t.Context(), h, []string{channel.ID}, 1) + Expect(err).ToNot(HaveOccurred()) + }) +} + +func TestResourceForceDeleteHTTP(t *testing.T) { + t.Run("HappyPath_204", func(t *testing.T) { + RegisterTestingT(t) + h, _ := test.RegisterIntegration(t) + svc, _ := setupResourceTest(t) + prefix := uuid.NewString()[:8] + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + token := test.GetAccessTokenFromContext(ctx) + + channel := createChannel(t, svc, fmt.Sprintf("fd-http-%s", prefix)) + markFinalizing(t, h, channel.ID) + + resp, err := resty.R(). + SetHeader("Content-Type", "application/json"). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", token)). + SetBody(`{"reason": "admin cleanup"}`). + Post(h.RestURL(fmt.Sprintf("/channels/%s/force-delete", channel.ID))) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusNoContent)) + + err = checkResourceCount(t.Context(), h, []string{channel.ID}, 0) + Expect(err).ToNot(HaveOccurred()) + }) + + t.Run("NestedVersion_204", func(t *testing.T) { + RegisterTestingT(t) + h, _ := test.RegisterIntegration(t) + svc, _ := setupResourceTest(t) + prefix := uuid.NewString()[:8] + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + token := test.GetAccessTokenFromContext(ctx) + + channel := createChannel(t, svc, fmt.Sprintf("fd-nested-%s", prefix)) + createVersionForChannel(t, svc, channel.ID, fmt.Sprintf("fd-nv-%s", prefix)) + + args := &services.ListArguments{Page: 1, Size: 10} + versions, _, listErr := svc.ListByOwner( + t.Context(), "Version", channel.ID, args, + ) + Expect(listErr).To(BeNil()) + Expect(versions).To(HaveLen(1)) + versionID := versions[0].ID + + markFinalizing(t, h, versionID) + + url := fmt.Sprintf( + "/channels/%s/versions/%s/force-delete", + channel.ID, versionID, + ) + resp, err := resty.R(). + SetHeader("Content-Type", "application/json"). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", token)). + SetBody(`{"reason": "nested cleanup"}`). + Post(h.RestURL(url)) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusNoContent)) + + err = checkResourceCount(t.Context(), h, []string{versionID}, 0) + Expect(err).ToNot(HaveOccurred()) + + err = checkResourceCount(t.Context(), h, []string{channel.ID}, 1) + Expect(err).ToNot(HaveOccurred()) + }) + + t.Run("WifConfig_204", func(t *testing.T) { + RegisterTestingT(t) + h, _ := test.RegisterIntegration(t) + svc, _ := setupResourceTest(t) + prefix := uuid.NewString()[:8] + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + token := test.GetAccessTokenFromContext(ctx) + + wifConfig := newWifConfigResource(fmt.Sprintf("fd-wif-%s", prefix)) + created, createErr := svc.Create(t.Context(), "WifConfig", wifConfig) + Expect(createErr).To(BeNil()) + + markFinalizing(t, h, created.ID) + + resp, err := resty.R(). + SetHeader("Content-Type", "application/json"). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", token)). + SetBody(`{"reason": "wifconfig cleanup"}`). + Post(h.RestURL(fmt.Sprintf("/wifconfigs/%s/force-delete", created.ID))) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusNoContent)) + + err = checkResourceCount(t.Context(), h, []string{created.ID}, 0) + Expect(err).ToNot(HaveOccurred()) + }) + + t.Run("EmptyReason_400", func(t *testing.T) { + RegisterTestingT(t) + h, _ := test.RegisterIntegration(t) + svc, _ := setupResourceTest(t) + prefix := uuid.NewString()[:8] + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + token := test.GetAccessTokenFromContext(ctx) + + channel := createChannel(t, svc, fmt.Sprintf("fd-noreason-%s", prefix)) + markFinalizing(t, h, channel.ID) + + resp, err := resty.R(). + SetHeader("Content-Type", "application/json"). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", token)). + SetBody(`{"reason": ""}`). + Post(h.RestURL(fmt.Sprintf("/channels/%s/force-delete", channel.ID))) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusBadRequest)) + }) +} + +func TestGenericDescriptors_HaveNoRequiredAdapters(t *testing.T) { + RegisterTestingT(t) + _, _ = setupResourceTest(t) + + for _, d := range registry.All() { + Expect(d.RequiredAdapters).To(BeEmpty(), + "Descriptor %q has RequiredAdapters=%v. "+ + "ForceDelete does not yet handle adapter_status cleanup. "+ + "See HYPERFLEET-1154.", d.Kind, d.RequiredAdapters) + } +} diff --git a/test/integration/resource_helpers.go b/test/integration/resource_helpers.go index c8ed12ed..aa8cb1c9 100644 --- a/test/integration/resource_helpers.go +++ b/test/integration/resource_helpers.go @@ -58,3 +58,13 @@ func newVersionResource(name, channelID string) *api.Resource { UpdatedBy: "test@example.com", } } + +func newWifConfigResource(name string) *api.Resource { + return &api.Resource{ + Kind: "WifConfig", + Name: name, + Spec: []byte(`{"project_id": "test-project", "pool_id": "test-pool"}`), + CreatedBy: "test@example.com", + UpdatedBy: "test@example.com", + } +} diff --git a/test/integration/root_resources_test.go b/test/integration/root_resources_test.go new file mode 100644 index 00000000..77059d5f --- /dev/null +++ b/test/integration/root_resources_test.go @@ -0,0 +1,395 @@ +package integration + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/google/uuid" + . "github.com/onsi/gomega" + "gopkg.in/resty.v1" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" + "github.com/openshift-hyperfleet/hyperfleet-api/test" +) + +const resourcesPath = "/resources" + +func rootResourceRequest(ctx context.Context) *resty.Request { + jwtToken := test.GetAccessTokenFromContext(ctx) + return resty.R(). + SetHeader("Content-Type", "application/json"). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", jwtToken)) +} + +func TestRootResourceList(t *testing.T) { + RegisterTestingT(t) + svc, h := setupResourceTest(t) + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + + ch1 := createChannel(t, svc, fmt.Sprintf("list-ch1-%s", uuid.NewString()[:8])) + ch2 := createChannel(t, svc, fmt.Sprintf("list-ch2-%s", uuid.NewString()[:8])) + + t.Run("ListsAllKinds", func(t *testing.T) { + RegisterTestingT(t) + resp, err := rootResourceRequest(ctx). + Get(h.RestURL(resourcesPath)) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusOK)) + + var result openapi.ResourceList + Expect(json.Unmarshal(resp.Body(), &result)).To(Succeed()) + Expect(result.Size).To(BeNumerically(">=", 2)) + + ids := make(map[string]bool) + for _, item := range result.Items { + ids[item.Id] = true + } + Expect(ids).To(HaveKey(ch1.ID)) + Expect(ids).To(HaveKey(ch2.ID)) + }) + + t.Run("FiltersByKind", func(t *testing.T) { + RegisterTestingT(t) + resp, err := rootResourceRequest(ctx). + Get(h.RestURL(resourcesPath + "?kind=Channel")) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusOK)) + + var result openapi.ResourceList + Expect(json.Unmarshal(resp.Body(), &result)).To(Succeed()) + for _, item := range result.Items { + Expect(item.Kind).To(Equal("Channel")) + } + }) + + t.Run("RejectsUnknownKind", func(t *testing.T) { + RegisterTestingT(t) + resp, err := rootResourceRequest(ctx). + Get(h.RestURL(resourcesPath + "?kind=NonExistent")) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusBadRequest)) + }) +} + +func TestRootResourceGetByID(t *testing.T) { + RegisterTestingT(t) + svc, h := setupResourceTest(t) + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + + channel := createChannel(t, svc, fmt.Sprintf("get-ch-%s", uuid.NewString()[:8])) + + t.Run("FetchesByID", func(t *testing.T) { + RegisterTestingT(t) + resp, err := rootResourceRequest(ctx). + Get(h.RestURL(fmt.Sprintf("%s/%s", resourcesPath, channel.ID))) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusOK)) + + var resource openapi.Resource + Expect(json.Unmarshal(resp.Body(), &resource)).To(Succeed()) + Expect(resource.Id).To(Equal(channel.ID)) + Expect(resource.Kind).To(Equal("Channel")) + }) + + t.Run("NotFoundReturns404", func(t *testing.T) { + RegisterTestingT(t) + resp, err := rootResourceRequest(ctx). + Get(h.RestURL(fmt.Sprintf("%s/%s", resourcesPath, uuid.NewString()))) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusNotFound)) + }) +} + +func TestRootResourceCreate(t *testing.T) { + RegisterTestingT(t) + _, h := setupResourceTest(t) + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + + t.Run("CreatesTopLevelEntity", func(t *testing.T) { + RegisterTestingT(t) + input := openapi.ResourceCreateRequest{ + Kind: "Channel", + Name: fmt.Sprintf("root-create-%s", uuid.NewString()[:8]), + Spec: map[string]interface{}{ + "is_default": false, + "enabled_regex": ".*", + }, + } + body, err := json.Marshal(input) + Expect(err).NotTo(HaveOccurred()) + + resp, err := rootResourceRequest(ctx). + SetBody(body). + Post(h.RestURL(resourcesPath)) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusCreated)) + + var created map[string]interface{} + Expect(json.Unmarshal(resp.Body(), &created)).To(Succeed()) + Expect(created["id"]).NotTo(BeEmpty()) + Expect(created["kind"]).To(Equal("Channel")) + Expect(created["name"]).To(HavePrefix("root-create-")) + }) +} + +func TestRootResourceCreateChildKindReturns422(t *testing.T) { + RegisterTestingT(t) + _, h := setupResourceTest(t) + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + + input := openapi.ResourceCreateRequest{ + Kind: "Version", + Name: fmt.Sprintf("child-reject-%s", uuid.NewString()[:8]), + Spec: map[string]interface{}{ + "raw_version": "4.17.0", + "enabled": true, + "is_default": false, + "release_image": "quay.io/openshift-release-dev/ocp-release:4.17.0", + }, + } + body, err := json.Marshal(input) + Expect(err).NotTo(HaveOccurred()) + + resp, err := rootResourceRequest(ctx). + SetBody(body). + Post(h.RestURL(resourcesPath)) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusUnprocessableEntity)) + + var problem openapi.ProblemDetails + Expect(json.Unmarshal(resp.Body(), &problem)).To(Succeed()) + Expect(*problem.Detail).To(ContainSubstring("channels")) +} + +func TestRootResourcePatch(t *testing.T) { + RegisterTestingT(t) + svc, h := setupResourceTest(t) + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + + channel := createChannel(t, svc, fmt.Sprintf("patch-ch-%s", uuid.NewString()[:8])) + + patchBody := `{"spec": {"is_default": true, "enabled_regex": ".*"}}` + resp, err := rootResourceRequest(ctx). + SetBody(patchBody). + Patch(h.RestURL(fmt.Sprintf("%s/%s", resourcesPath, channel.ID))) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusOK)) + + var patched map[string]interface{} + Expect(json.Unmarshal(resp.Body(), &patched)).To(Succeed()) + spec := patched["spec"].(map[string]interface{}) + Expect(spec["is_default"]).To(BeTrue()) +} + +func TestRootResourceDelete(t *testing.T) { + RegisterTestingT(t) + svc, h := setupResourceTest(t) + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + + channel := createChannel(t, svc, fmt.Sprintf("delete-ch-%s", uuid.NewString()[:8])) + + resp, err := rootResourceRequest(ctx). + Delete(h.RestURL(fmt.Sprintf("%s/%s", resourcesPath, channel.ID))) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusAccepted)) +} + +func TestRootResourcePatchSoftDeletedReturns409(t *testing.T) { + RegisterTestingT(t) + svc, h := setupResourceTest(t) + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + + channel := createChannel(t, svc, fmt.Sprintf("del409-ch-%s", uuid.NewString()[:8])) + _, delErr := svc.Delete(t.Context(), "Channel", channel.ID) + Expect(delErr).To(BeNil()) + + patchBody := `{"spec": {"is_default": true, "enabled_regex": ".*"}}` + resp, err := rootResourceRequest(ctx). + SetBody(patchBody). + Patch(h.RestURL(fmt.Sprintf("%s/%s", resourcesPath, channel.ID))) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusNotFound)) +} + +func TestRootResourceForceDeleteRoute(t *testing.T) { + RegisterTestingT(t) + svc, h := setupResourceTest(t) + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + + channel := createChannel(t, svc, fmt.Sprintf("fdel-ch-%s", uuid.NewString()[:8])) + + t.Run("MissingReasonReturns400", func(t *testing.T) { + RegisterTestingT(t) + resp, err := rootResourceRequest(ctx). + SetBody(`{}`). + Post(h.RestURL(fmt.Sprintf("%s/%s/force-delete", resourcesPath, channel.ID))) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusBadRequest)) + }) + + t.Run("ReturnsExpectedStatus", func(t *testing.T) { + RegisterTestingT(t) + resp, err := rootResourceRequest(ctx). + SetBody(`{"reason": "cleanup"}`). + Post(h.RestURL(fmt.Sprintf("%s/%s/force-delete", resourcesPath, channel.ID))) + Expect(err).NotTo(HaveOccurred()) + // 409 because Channel has no required adapters (never soft-deleted/finalizing) + // Confirms the route is wired and reaches ForceDelete service method + Expect(resp.StatusCode()).To(Equal(http.StatusConflict)) + }) +} + +func TestFlatChildRouteList(t *testing.T) { + RegisterTestingT(t) + svc, h := setupResourceTest(t) + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + + ch := createChannel(t, svc, fmt.Sprintf("flat-parent-%s", uuid.NewString()[:8])) + v1Name := fmt.Sprintf("flat-v1-%s", uuid.NewString()[:8]) + v1 := newVersionResource(v1Name, ch.ID) + created1, svcErr := svc.Create(t.Context(), "Version", v1) + Expect(svcErr).To(BeNil()) + + resp, err := rootResourceRequest(ctx). + Get(h.RestURL("/versions")) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusOK)) + + var result openapi.ResourceList + Expect(json.Unmarshal(resp.Body(), &result)).To(Succeed()) + + ids := make(map[string]bool) + for _, item := range result.Items { + ids[item.Id] = true + Expect(item.Kind).To(Equal("Version")) + } + Expect(ids).To(HaveKey(created1.ID)) +} + +func TestFlatChildRouteGetByID(t *testing.T) { + RegisterTestingT(t) + svc, h := setupResourceTest(t) + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + + ch := createChannel(t, svc, fmt.Sprintf("flat-get-parent-%s", uuid.NewString()[:8])) + v := newVersionResource(fmt.Sprintf("flat-get-v-%s", uuid.NewString()[:8]), ch.ID) + created, svcErr := svc.Create(t.Context(), "Version", v) + Expect(svcErr).To(BeNil()) + + resp, err := rootResourceRequest(ctx). + Get(h.RestURL(fmt.Sprintf("/versions/%s", created.ID))) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusOK)) + + var resource openapi.Resource + Expect(json.Unmarshal(resp.Body(), &resource)).To(Succeed()) + Expect(resource.Id).To(Equal(created.ID)) + Expect(resource.Kind).To(Equal("Version")) +} + +func TestFlatChildRoutePatch(t *testing.T) { + RegisterTestingT(t) + svc, h := setupResourceTest(t) + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + + ch := createChannel(t, svc, fmt.Sprintf("flat-patch-parent-%s", uuid.NewString()[:8])) + v := newVersionResource(fmt.Sprintf("flat-patch-v-%s", uuid.NewString()[:8]), ch.ID) + created, svcErr := svc.Create(t.Context(), "Version", v) + Expect(svcErr).To(BeNil()) + + patchSpec := map[string]interface{}{ + "raw_version": "4.18.0", + "enabled": true, + "is_default": false, + "release_image": "quay.io/ocp-release:4.18.0", + } + patchBody, marshalErr := json.Marshal(map[string]interface{}{"spec": patchSpec}) + Expect(marshalErr).NotTo(HaveOccurred()) + resp, err := rootResourceRequest(ctx). + SetBody(patchBody). + Patch(h.RestURL(fmt.Sprintf("/versions/%s", created.ID))) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusOK)) + + var patched map[string]interface{} + Expect(json.Unmarshal(resp.Body(), &patched)).To(Succeed()) + spec := patched["spec"].(map[string]interface{}) + Expect(spec["raw_version"]).To(Equal("4.18.0")) +} + +func TestFlatChildRouteDelete(t *testing.T) { + RegisterTestingT(t) + svc, h := setupResourceTest(t) + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + + ch := createChannel(t, svc, fmt.Sprintf("flat-del-parent-%s", uuid.NewString()[:8])) + v := newVersionResource(fmt.Sprintf("flat-del-v-%s", uuid.NewString()[:8]), ch.ID) + _, svcErr := svc.Create(t.Context(), "Version", v) + Expect(svcErr).To(BeNil()) + + retrieved, getErr := svc.Get(t.Context(), "Version", v.ID) + Expect(getErr).To(BeNil()) + + resp, err := rootResourceRequest(ctx). + Delete(h.RestURL(fmt.Sprintf("/versions/%s", retrieved.ID))) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusAccepted)) +} + +func TestFlatChildRoutePostReturns422(t *testing.T) { + RegisterTestingT(t) + _, h := setupResourceTest(t) + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + + input := openapi.ResourceCreateRequest{ + Kind: "Version", + Name: fmt.Sprintf("flat-post-%s", uuid.NewString()[:8]), + Spec: map[string]interface{}{ + "raw_version": "4.17.0", + "enabled": true, + "is_default": false, + "release_image": "quay.io/openshift-release-dev/ocp-release:4.17.0", + }, + } + body, err := json.Marshal(input) + Expect(err).NotTo(HaveOccurred()) + + resp, err := rootResourceRequest(ctx). + SetBody(body). + Post(h.RestURL("/versions")) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusUnprocessableEntity)) + + var problem openapi.ProblemDetails + Expect(json.Unmarshal(resp.Body(), &problem)).To(Succeed()) + Expect(*problem.Detail).To(ContainSubstring("channels")) +}