Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement org deletion job endpoint #2638

Merged
merged 1 commit into from
Jun 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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