diff --git a/api/handlers/job.go b/api/handlers/job.go index 885e5f8c5..248506936 100644 --- a/api/handlers/job.go +++ b/api/handlers/job.go @@ -11,6 +11,7 @@ import ( "code.cloudfoundry.org/korifi/api/authorization" apierrors "code.cloudfoundry.org/korifi/api/errors" "code.cloudfoundry.org/korifi/api/presenter" + "code.cloudfoundry.org/korifi/api/repositories" "code.cloudfoundry.org/korifi/api/routing" "github.com/go-logr/logr" @@ -34,12 +35,14 @@ const JobResourceType = "Job" type Job struct { serverURL url.URL orgRepo CFOrgRepository + spaceRepo CFSpaceRepository } -func NewJob(serverURL url.URL, orgRepo CFOrgRepository) *Job { +func NewJob(serverURL url.URL, orgRepo CFOrgRepository, spaceRepo CFSpaceRepository) *Job { return &Job{ serverURL: serverURL, orgRepo: orgRepo, + spaceRepo: spaceRepo, } } @@ -66,10 +69,10 @@ func (h *Job) get(r *http.Request) (*routing.Response, error) { switch jobType { case syncSpacePrefix: jobResponse = presenter.ForManifestApplyJob(jobGUID, resourceGUID, h.serverURL) - case appDeletePrefix, spaceDeletePrefix, routeDeletePrefix, domainDeletePrefix, roleDeletePrefix: + case appDeletePrefix, routeDeletePrefix, domainDeletePrefix, roleDeletePrefix: jobResponse = presenter.ForJob(jobGUID, []presenter.JobResponseError{}, presenter.StateComplete, jobType, h.serverURL) - case orgDeletePrefix: - jobResponse, err = h.handleOrgDelete(r.Context(), resourceGUID, jobGUID) + case orgDeletePrefix, spaceDeletePrefix: + jobResponse, err = h.handleDeleteJob(r.Context(), resourceGUID, jobGUID, jobType) if err != nil { return nil, err } @@ -84,11 +87,32 @@ func (h *Job) get(r *http.Request) (*routing.Response, error) { return routing.NewResponse(http.StatusOK).WithBody(jobResponse), nil } -func (h *Job) handleOrgDelete(ctx context.Context, resourceGUID, jobGUID string) (presenter.JobResponse, error) { +func (h *Job) handleDeleteJob(ctx context.Context, resourceGUID, jobGUID, jobType string) (presenter.JobResponse, error) { authInfo, _ := authorization.InfoFromContext(ctx) - log := logr.FromContextOrDiscard(ctx).WithName("handlers.job.get.handleOrgDelete") + log := logr.FromContextOrDiscard(ctx).WithName("handlers.job.get.handleDeleteJob") + + var ( + org repositories.OrgRecord + space repositories.SpaceRecord + err error + resourceName string + resourceType string + deletedAt string + ) + + switch jobType { + case orgDeletePrefix: + org, err = h.orgRepo.GetOrg(ctx, authInfo, resourceGUID) + resourceName = org.Name + resourceType = "Org" + deletedAt = org.DeletedAt + case spaceDeletePrefix: + space, err = h.spaceRepo.GetSpace(ctx, authInfo, resourceGUID) + resourceName = space.Name + resourceType = "Space" + deletedAt = space.DeletedAt + } - org, err := h.orgRepo.GetOrg(ctx, authInfo, resourceGUID) if err != nil { switch err.(type) { case apierrors.NotFoundError, apierrors.ForbiddenError: @@ -96,37 +120,36 @@ func (h *Job) handleOrgDelete(ctx context.Context, resourceGUID, jobGUID string) jobGUID, []presenter.JobResponseError{}, presenter.StateComplete, - orgDeletePrefix, + jobType, h.serverURL, ), nil default: return presenter.JobResponse{}, apierrors.LogAndReturn( log, - apierrors.ForbiddenAsNotFound(err), - "failed to fetch org from Kubernetes", - "OrgGUID", resourceGUID, + err, + "failed to fetch "+resourceType+" from Kubernetes", + resourceType+"GUID", resourceGUID, ) } } - // This logic can be refactored into a generic helper for all resource types. - if org.DeletedAt == "" { + if deletedAt == "" { return presenter.JobResponse{}, apierrors.LogAndReturn( log, apierrors.NewNotFoundError(fmt.Errorf("job %q not found", jobGUID), JobResourceType), - "org not marked for deletion", - "OrgGUID", resourceGUID, + resourceType+" not marked for deletion", + resourceType+"GUID", resourceGUID, ) } - deletionTime, err := time.Parse(time.RFC3339Nano, org.DeletedAt) + deletionTime, err := time.Parse(time.RFC3339Nano, deletedAt) if err != nil { return presenter.JobResponse{}, apierrors.LogAndReturn( log, err, - "failed to parse org deletion time", - "name", org.Name, - "timestamp", org.DeletedAt, + "failed to parse "+resourceType+" deletion time", + "name", resourceName, + "timestamp", deletedAt, ) } @@ -135,7 +158,7 @@ func (h *Job) handleOrgDelete(ctx context.Context, resourceGUID, jobGUID string) jobGUID, []presenter.JobResponseError{}, presenter.StateProcessing, - orgDeletePrefix, + jobType, h.serverURL, ), nil } else { @@ -143,11 +166,11 @@ func (h *Job) handleOrgDelete(ctx context.Context, resourceGUID, jobGUID string) jobGUID, []presenter.JobResponseError{{ Code: 10008, - Detail: fmt.Sprintf("CFOrg deletion timed out. Check for lingering resources in the %q namespace", org.GUID), + Detail: fmt.Sprintf("%s deletion timed out. Check for remaining resources in the %q namespace", resourceType, resourceGUID), Title: "CF-UnprocessableEntity", }}, presenter.StateFailed, - orgDeletePrefix, + jobType, h.serverURL, ), nil } diff --git a/api/handlers/job_test.go b/api/handlers/job_test.go index 0e6dc66ea..9dbff7cdf 100644 --- a/api/handlers/job_test.go +++ b/api/handlers/job_test.go @@ -24,13 +24,15 @@ var _ = Describe("Job", func() { jobGUID string req *http.Request orgRepo *fake.OrgRepository + spaceRepo *fake.SpaceRepository ) BeforeEach(func() { spaceGUID = uuid.NewString() orgRepo = new(fake.OrgRepository) - apiHandler := handlers.NewJob(*serverURL, orgRepo) + spaceRepo = new(fake.SpaceRepository) + apiHandler := handlers.NewJob(*serverURL, orgRepo, spaceRepo) routerBuilder.LoadRoutes(apiHandler) }) @@ -73,7 +75,6 @@ var _ = Describe("Job", func() { ))) }, Entry("app delete", "app.delete", "cf-app-guid"), - Entry("space delete", "space.delete", "cf-space-guid"), Entry("route delete", "route.delete", "cf-route-guid"), Entry("domain delete", "domain.delete", "cf-domain-guid"), Entry("role delete", "role.delete", "cf-role-guid"), @@ -150,7 +151,7 @@ var _ = Describe("Job", func() { MatchJSONPath("$.state", "FAILED"), MatchJSONPath("$.errors", ConsistOf(map[string]interface{}{ "code": float64(10008), - "detail": fmt.Sprintf("CFOrg deletion timed out. Check for lingering resources in the %q namespace", resourceGUID), + "detail": fmt.Sprintf("Org deletion timed out. Check for remaining resources in the %q namespace", resourceGUID), "title": "CF-UnprocessableEntity", })), ))) @@ -190,5 +191,107 @@ var _ = Describe("Job", func() { }) }) }) + + Describe("space delete", func() { + const ( + operation = "space.delete" + resourceGUID = "cf-space-guid" + ) + + BeforeEach(func() { + jobGUID = operation + "~" + resourceGUID + }) + + When("the space deletion is in progress", func() { + BeforeEach(func() { + spaceRepo.GetSpaceReturns(repositories.SpaceRecord{ + GUID: "cf-space-guid", + DeletedAt: time.Now().Format(time.RFC3339Nano), + }, nil) + }) + + It("returns a processing status", func() { + Expect(rr).To(HaveHTTPBody(SatisfyAll( + MatchJSONPath("$.guid", jobGUID), + MatchJSONPath("$.links.self.href", defaultServerURL+"/v3/jobs/"+jobGUID), + MatchJSONPath("$.operation", operation), + MatchJSONPath("$.state", "PROCESSING"), + MatchJSONPath("$.errors", BeEmpty()), + ))) + }) + }) + + When("the space does not exist", func() { + BeforeEach(func() { + spaceRepo.GetSpaceReturns(repositories.SpaceRecord{}, apierrors.NewNotFoundError(nil, repositories.SpaceResourceType)) + }) + + It("returns a complete status", func() { + Expect(rr).To(HaveHTTPBody(SatisfyAll( + MatchJSONPath("$.guid", jobGUID), + MatchJSONPath("$.links.self.href", defaultServerURL+"/v3/jobs/"+jobGUID), + MatchJSONPath("$.operation", operation), + MatchJSONPath("$.state", "COMPLETE"), + MatchJSONPath("$.errors", BeEmpty()), + ))) + }) + }) + + When("the space deletion times out", func() { + BeforeEach(func() { + spaceRepo.GetSpaceReturns(repositories.SpaceRecord{ + GUID: "cf-space-guid", + DeletedAt: (time.Now().Add(-180 * time.Second)).Format(time.RFC3339Nano), + }, nil) + }) + + It("returns a failed status", func() { + Expect(rr).To(HaveHTTPBody(SatisfyAll( + MatchJSONPath("$.guid", jobGUID), + MatchJSONPath("$.links.self.href", defaultServerURL+"/v3/jobs/"+jobGUID), + MatchJSONPath("$.operation", operation), + MatchJSONPath("$.state", "FAILED"), + MatchJSONPath("$.errors", ConsistOf(map[string]interface{}{ + "code": float64(10008), + "detail": fmt.Sprintf("Space deletion timed out. Check for remaining resources in the %q namespace", resourceGUID), + "title": "CF-UnprocessableEntity", + })), + ))) + }) + }) + + When("the user does not have permission to see the space", func() { + BeforeEach(func() { + spaceRepo.GetSpaceReturns(repositories.SpaceRecord{}, apierrors.NewForbiddenError(nil, repositories.SpaceResourceType)) + }) + + It("returns a complete status", func() { + Expect(rr).To(HaveHTTPBody(SatisfyAll( + MatchJSONPath("$.guid", jobGUID), + MatchJSONPath("$.links.self.href", defaultServerURL+"/v3/jobs/"+jobGUID), + MatchJSONPath("$.operation", operation), + MatchJSONPath("$.state", "COMPLETE"), + MatchJSONPath("$.errors", BeEmpty()), + ))) + }) + }) + + When("the space has not been marked for deletion", func() { + BeforeEach(func() { + spaceRepo.GetSpaceReturns(repositories.SpaceRecord{ + GUID: resourceGUID, + }, nil) + }) + + It("returns a not found error", func() { + Expect(rr).To(HaveHTTPStatus(http.StatusNotFound)) + Expect(rr).To(HaveHTTPBody(SatisfyAll( + MatchJSONPath("$.errors[0].code", float64(10010)), + MatchJSONPath("$.errors[0].detail", "Job not found. Ensure it exists and you have access to it."), + MatchJSONPath("$.errors[0].title", "CF-ResourceNotFound"), + ))) + }) + }) + }) }) }) diff --git a/api/main.go b/api/main.go index 8ef783b45..344e6ec57 100644 --- a/api/main.go +++ b/api/main.go @@ -328,6 +328,7 @@ func main() { handlers.NewJob( *serverURL, orgRepo, + spaceRepo, ), handlers.NewLogCache( appRepo, diff --git a/api/repositories/space_repository.go b/api/repositories/space_repository.go index e6e7abdd7..5130413e1 100644 --- a/api/repositories/space_repository.go +++ b/api/repositories/space_repository.go @@ -53,6 +53,7 @@ type SpaceRecord struct { Annotations map[string]string CreatedAt string UpdatedAt string + DeletedAt string } type SpaceRepo struct { @@ -236,6 +237,11 @@ func (r *SpaceRepo) GetSpace(ctx context.Context, info authorization.Info, space func cfSpaceToSpaceRecord(cfSpace korifiv1alpha1.CFSpace) SpaceRecord { updatedAtTime, _ := getTimeLastUpdatedTimestamp(&cfSpace.ObjectMeta) + deletedAtTime := "" + if cfSpace.DeletionTimestamp != nil { + deletedAtTime = formatTimestamp(*cfSpace.DeletionTimestamp) + } + return SpaceRecord{ Name: cfSpace.Spec.DisplayName, GUID: cfSpace.Name, @@ -244,6 +250,7 @@ func cfSpaceToSpaceRecord(cfSpace korifiv1alpha1.CFSpace) SpaceRecord { Labels: cfSpace.Labels, CreatedAt: formatTimestamp(cfSpace.CreationTimestamp), UpdatedAt: updatedAtTime, + DeletedAt: deletedAtTime, } }