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 89b4a4d
Show file tree
Hide file tree
Showing 6 changed files with 151 additions and 21 deletions.
38 changes: 35 additions & 3 deletions api/handlers/job.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import (
"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"
Expand All @@ -22,21 +25,26 @@ 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) {
authInfo, _ := authorization.InfoFromContext(r.Context())
logger := logr.FromContextOrDiscard(r.Context()).WithName("handlers.job.get")

jobGUID := routing.URLParam(r, "guid")
Expand All @@ -56,8 +64,32 @@ func (h *Job) get(r *http.Request) (*routing.Response, error) {
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:
org, err := h.orgRepo.GetOrg(r.Context(), authInfo, resourceGUID)
if err != nil {
if _, ok := err.(apierrors.NotFoundError); !ok {
return nil, apierrors.LogAndReturn(logger, apierrors.ForbiddenAsNotFound(err), "Failed to fetch org from Kubernetes", "OrgGUID", resourceGUID)
}
jobResponse = presenter.ForJob(jobGUID, []presenter.JobResponseError{}, presenter.StateComplete, jobType, h.serverURL)
break
}

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

if time.Since(deletionTime).Seconds() < JobTimeoutDuration {
jobResponse = presenter.ForJob(jobGUID, []presenter.JobResponseError{}, presenter.StateProcessing, jobType, h.serverURL)
} else {
jobResponse = presenter.ForJob(jobGUID, []presenter.JobResponseError{{
Detail: fmt.Sprintf("CFOrg deletion timed out. Check for lingering resources in the %q namespace", org.GUID),
Title: "CF-UnprocessableEntity",
Code: 10008,
}}, presenter.StateFailed, jobType, h.serverURL)
}
default:
return nil, apierrors.LogAndReturn(
logger,
Expand Down
82 changes: 79 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,74 @@ var _ = Describe("Job", func() {
expectNotFoundError("Job")
})
})

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

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

When("org delete 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("org delete completed", 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("org delete 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",
})),
)))
})
})
})
})
})
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
16 changes: 13 additions & 3 deletions api/presenter/job_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ var _ = Describe("", func() {
It("renders the job", func() {
Expect(output).To(MatchJSON(`{
"created_at": "",
"errors": null,
"errors": [],
"guid": "the-job-guid",
"links": {
"self": {
Expand All @@ -52,7 +52,11 @@ var _ = Describe("", func() {

Describe("ForDeleteJob", func() {
JustBeforeEach(func() {
response := presenter.ForJob("the-job-guid", "the.operation", *baseURL)
response := presenter.ForJob("the-job-guid", []presenter.JobResponseError{{
Detail: "error detail",
Title: "CF-JobErrorTitle",
Code: 12345,
}}, "COMPLETE", "the.operation", *baseURL)
var err error
output, err = json.Marshal(response)
Expect(err).NotTo(HaveOccurred())
Expand All @@ -61,7 +65,13 @@ var _ = Describe("", func() {
It("renders the job", func() {
Expect(output).To(MatchJSON(`{
"created_at": "",
"errors": null,
"errors": [
{
"code": 12345,
"detail": "error detail",
"title": "CF-JobErrorTitle"
}
],
"guid": "the-job-guid",
"links": {
"self": {
Expand Down
1 change: 1 addition & 0 deletions api/repositories/org_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ type OrgRecord struct {
Annotations map[string]string
CreatedAt string
UpdatedAt string
DeletedAt string
}

type OrgRepo struct {
Expand Down

0 comments on commit 89b4a4d

Please sign in to comment.