Skip to content

Commit

Permalink
Implement org deletion job
Browse files Browse the repository at this point in the history
- Add check for org deletion timeout with message
- Calculate status for deletion based on presence of CRD and its timestamp

[#2605]

Co-authored-by: Dave Walter <[email protected]>
  • Loading branch information
acosta11 and davewalter committed Jun 28, 2023
1 parent 7a7729a commit 8ff1a02
Show file tree
Hide file tree
Showing 6 changed files with 240 additions and 25 deletions.
98 changes: 91 additions & 7 deletions api/handlers/job.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package handlers

import (
"context"
"fmt"
"net/http"
"net/url"
"regexp"
"time"

"code.cloudfoundry.org/korifi/api/authorization"
apierrors "code.cloudfoundry.org/korifi/api/errors"
"code.cloudfoundry.org/korifi/api/presenter"
"code.cloudfoundry.org/korifi/api/routing"
Expand All @@ -22,45 +25,57 @@ const (
spaceDeletePrefix = "space.delete"
domainDeletePrefix = "domain.delete"
roleDeletePrefix = "role.delete"

JobTimeoutDuration = 120.0
)

const JobResourceType = "Job"

type Job struct {
serverURL url.URL
orgRepo CFOrgRepository
}

func NewJob(serverURL url.URL) *Job {
func NewJob(serverURL url.URL, orgRepo CFOrgRepository) *Job {
return &Job{
serverURL: serverURL,
orgRepo: orgRepo,
}
}

func (h *Job) get(r *http.Request) (*routing.Response, error) {
logger := logr.FromContextOrDiscard(r.Context()).WithName("handlers.job.get")
log := logr.FromContextOrDiscard(r.Context()).WithName("handlers.job.get")

jobGUID := routing.URLParam(r, "guid")

jobType, resourceGUID, match := parseJobGUID(jobGUID)

if !match {
return nil, apierrors.LogAndReturn(
logger,
log,
apierrors.NewNotFoundError(fmt.Errorf("invalid job guid: %s", jobGUID), JobResourceType),
"Invalid Job GUID",
)
}

var jobResponse presenter.JobResponse
var (
err error
jobResponse presenter.JobResponse
)

switch jobType {
case syncSpacePrefix:
jobResponse = presenter.ForManifestApplyJob(jobGUID, resourceGUID, h.serverURL)
case appDeletePrefix, orgDeletePrefix, spaceDeletePrefix, routeDeletePrefix, domainDeletePrefix, roleDeletePrefix:
jobResponse = presenter.ForJob(jobGUID, jobType, h.serverURL)
case appDeletePrefix, spaceDeletePrefix, routeDeletePrefix, domainDeletePrefix, roleDeletePrefix:
jobResponse = presenter.ForJob(jobGUID, []presenter.JobResponseError{}, presenter.StateComplete, jobType, h.serverURL)
case orgDeletePrefix:
jobResponse, err = h.handleOrgDelete(r.Context(), resourceGUID, jobGUID)
if err != nil {
return nil, err
}
default:
return nil, apierrors.LogAndReturn(
logger,
log,
apierrors.NewNotFoundError(fmt.Errorf("invalid job type: %s", jobType), JobResourceType),
fmt.Sprintf("Invalid Job type: %s", jobType),
)
Expand All @@ -69,6 +84,75 @@ 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) {
authInfo, _ := authorization.InfoFromContext(ctx)
log := logr.FromContextOrDiscard(ctx).WithName("handlers.job.get.handleOrgDelete")

org, err := h.orgRepo.GetOrg(ctx, authInfo, resourceGUID)
if err != nil {
switch err.(type) {
case apierrors.NotFoundError, apierrors.ForbiddenError:
return presenter.ForJob(
jobGUID,
[]presenter.JobResponseError{},
presenter.StateComplete,
orgDeletePrefix,
h.serverURL,
), nil
default:
return presenter.JobResponse{}, apierrors.LogAndReturn(
log,
apierrors.ForbiddenAsNotFound(err),
"failed to fetch org from Kubernetes",
"OrgGUID", resourceGUID,
)
}
}

// This logic can be refactored into a generic helper for all resource types.
if org.DeletedAt == "" {
return presenter.JobResponse{}, apierrors.LogAndReturn(
log,
apierrors.NewNotFoundError(fmt.Errorf("job %q not found", jobGUID), JobResourceType),
"org not marked for deletion",
"OrgGUID", resourceGUID,
)
}

deletionTime, err := time.Parse(time.RFC3339Nano, org.DeletedAt)
if err != nil {
return presenter.JobResponse{}, apierrors.LogAndReturn(
log,
err,
"failed to parse org deletion time",
"name", org.Name,
"timestamp", org.DeletedAt,
)
}

if time.Since(deletionTime).Seconds() < JobTimeoutDuration {
return presenter.ForJob(
jobGUID,
[]presenter.JobResponseError{},
presenter.StateProcessing,
orgDeletePrefix,
h.serverURL,
), nil
} else {
return presenter.ForJob(
jobGUID,
[]presenter.JobResponseError{{
Code: 10008,
Detail: fmt.Sprintf("CFOrg deletion timed out. Check for lingering resources in the %q namespace", org.GUID),
Title: "CF-UnprocessableEntity",
}},
presenter.StateFailed,
orgDeletePrefix,
h.serverURL,
), nil
}
}

func (h *Job) UnauthenticatedRoutes() []routing.Route {
return nil
}
Expand Down
115 changes: 112 additions & 3 deletions api/handlers/job_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
package handlers_test

import (
"fmt"
"net/http"
"net/http/httptest"
"time"

apierrors "code.cloudfoundry.org/korifi/api/errors"
"code.cloudfoundry.org/korifi/api/handlers"
"code.cloudfoundry.org/korifi/api/handlers/fake"
"code.cloudfoundry.org/korifi/api/repositories"
. "code.cloudfoundry.org/korifi/tests/matchers"

"github.com/google/uuid"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
Expand All @@ -17,11 +23,14 @@ var _ = Describe("Job", func() {
spaceGUID string
jobGUID string
req *http.Request
orgRepo *fake.OrgRepository
)

BeforeEach(func() {
spaceGUID = uuid.NewString()
apiHandler := handlers.NewJob(*serverURL)

orgRepo = new(fake.OrgRepository)
apiHandler := handlers.NewJob(*serverURL, orgRepo)
routerBuilder.LoadRoutes(apiHandler)
})

Expand Down Expand Up @@ -63,9 +72,7 @@ var _ = Describe("Job", func() {
MatchJSONPath("$.operation", operation),
)))
},

Entry("app delete", "app.delete", "cf-app-guid"),
Entry("org delete", "org.delete", "cf-org-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"),
Expand All @@ -81,5 +88,107 @@ var _ = Describe("Job", func() {
expectNotFoundError("Job")
})
})

Describe("org delete", func() {
const (
operation = "org.delete"
resourceGUID = "cf-org-guid"
)

BeforeEach(func() {
jobGUID = operation + "~" + resourceGUID
})

When("the org deletion is in progress", func() {
BeforeEach(func() {
orgRepo.GetOrgReturns(repositories.OrgRecord{
GUID: "cf-org-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 org does not exist", func() {
BeforeEach(func() {
orgRepo.GetOrgReturns(repositories.OrgRecord{}, apierrors.NewNotFoundError(nil, repositories.OrgResourceType))
})

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 org deletion times out", func() {
BeforeEach(func() {
orgRepo.GetOrgReturns(repositories.OrgRecord{
GUID: "cf-org-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("CFOrg deletion timed out. Check for lingering resources in the %q namespace", resourceGUID),
"title": "CF-UnprocessableEntity",
})),
)))
})
})

When("the user does not have permission to see the org", func() {
BeforeEach(func() {
orgRepo.GetOrgReturns(repositories.OrgRecord{}, apierrors.NewForbiddenError(nil, repositories.OrgResourceType))
})

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 org has not been marked for deletion", func() {
BeforeEach(func() {
orgRepo.GetOrgReturns(repositories.OrgRecord{
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"),
)))
})
})
})
})
})
1 change: 1 addition & 0 deletions api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ func main() {
),
handlers.NewJob(
*serverURL,
orgRepo,
),
handlers.NewLogCache(
appRepo,
Expand Down
34 changes: 22 additions & 12 deletions api/presenter/job.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import (
const (
JobGUIDDelimiter = "~"

StateComplete = "COMPLETE"
StateFailed = "FAILED"
StateProcessing = "PROCESSING"

AppDeleteOperation = "app.delete"
OrgDeleteOperation = "org.delete"
RouteDeleteOperation = "route.delete"
Expand All @@ -17,15 +21,21 @@ const (
RoleDeleteOperation = "role.delete"
)

type JobResponseError struct {
Detail string `json:"detail"`
Title string `json:"title"`
Code int `json:"code"`
}

type JobResponse struct {
GUID string `json:"guid"`
Errors *string `json:"errors"`
Warnings *string `json:"warnings"`
Operation string `json:"operation"`
State string `json:"state"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Links JobLinks `json:"links"`
GUID string `json:"guid"`
Errors []JobResponseError `json:"errors"`
Warnings *string `json:"warnings"`
Operation string `json:"operation"`
State string `json:"state"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Links JobLinks `json:"links"`
}

type JobLinks struct {
Expand All @@ -34,20 +44,20 @@ type JobLinks struct {
}

func ForManifestApplyJob(jobGUID string, spaceGUID string, baseURL url.URL) JobResponse {
response := ForJob(jobGUID, SpaceApplyManifestOperation, baseURL)
response := ForJob(jobGUID, []JobResponseError{}, StateComplete, SpaceApplyManifestOperation, baseURL)
response.Links.Space = &Link{
HRef: buildURL(baseURL).appendPath("/v3/spaces", spaceGUID).build(),
}
return response
}

func ForJob(jobGUID string, operation string, baseURL url.URL) JobResponse {
func ForJob(jobGUID string, errors []JobResponseError, state string, operation string, baseURL url.URL) JobResponse {
return JobResponse{
GUID: jobGUID,
Errors: nil,
Errors: errors,
Warnings: nil,
Operation: operation,
State: "COMPLETE",
State: state,
CreatedAt: "",
UpdatedAt: "",
Links: JobLinks{
Expand Down
Loading

0 comments on commit 8ff1a02

Please sign in to comment.