From 7afed2daaf72ca6b0f9b1d2dd6ca9c12706aa8c5 Mon Sep 17 00:00:00 2001 From: Danail Branekov Date: Fri, 13 Sep 2024 15:37:24 +0000 Subject: [PATCH] Implement `GET /v3/deployments` Supported query parameters: * `app_guids` * `status_values` * `order_by` issue #3459 Co-authored-by: Georgi Sabev --- api/handlers/deployment.go | 19 +++ api/handlers/deployment_test.go | 47 ++++++ api/handlers/fake/cfdeployment_repository.go | 83 +++++++++++ api/main.go | 2 + api/payloads/deployment.go | 72 +++++++++- api/payloads/deployment_test.go | 48 +++++++ api/payloads/package.go | 8 +- api/presenter/deployment.go | 4 + api/presenter/deployment_test.go | 6 + api/repositories/compare/sort.go | 43 ++++++ api/repositories/compare/sort_test.go | 50 +++++++ api/repositories/compare/suite_test.go | 13 ++ api/repositories/deployment_repository.go | 104 ++++++++++++-- .../deployment_repository_test.go | 136 +++++++++++++++++- api/repositories/fake/deployment_sorter.go | 118 +++++++++++++++ tests/e2e/deployments_test.go | 29 ++++ tools/compare.go | 16 +++ tools/compare_test.go | 33 +++++ 18 files changed, 807 insertions(+), 24 deletions(-) create mode 100644 api/repositories/compare/sort.go create mode 100644 api/repositories/compare/sort_test.go create mode 100644 api/repositories/compare/suite_test.go create mode 100644 api/repositories/fake/deployment_sorter.go create mode 100644 tools/compare.go create mode 100644 tools/compare_test.go diff --git a/api/handlers/deployment.go b/api/handlers/deployment.go index e1ed2e16f..f4f417181 100644 --- a/api/handlers/deployment.go +++ b/api/handlers/deployment.go @@ -27,6 +27,7 @@ const ( type CFDeploymentRepository interface { GetDeployment(context.Context, authorization.Info, string) (repositories.DeploymentRecord, error) CreateDeployment(context.Context, authorization.Info, repositories.CreateDeploymentMessage) (repositories.DeploymentRecord, error) + ListDeployments(context.Context, authorization.Info, repositories.ListDeploymentsMessage) ([]repositories.DeploymentRecord, error) } //counterfeiter:generate -o fake -fake-name RunnerInfoRepository . RunnerInfoRepository @@ -106,6 +107,23 @@ func (h *Deployment) get(r *http.Request) (*routing.Response, error) { return routing.NewResponse(http.StatusOK).WithBody(presenter.ForDeployment(deployment, h.serverURL)), nil } +func (h *Deployment) list(r *http.Request) (*routing.Response, error) { + authInfo, _ := authorization.InfoFromContext(r.Context()) + logger := logr.FromContextOrDiscard(r.Context()).WithName("handlers.deployment.list") + + payload := new(payloads.DeploymentList) + if err := h.requestValidator.DecodeAndValidateURLValues(r, payload); err != nil { + return nil, apierrors.LogAndReturn(logger, err, "Unable to decode request query parameters") + } + + deployments, err := h.deploymentRepo.ListDeployments(r.Context(), authInfo, payload.ToMessage()) + if err != nil { + return nil, apierrors.LogAndReturn(logger, err, "Failed to fetch deployments from Kubernetes") + } + + return routing.NewResponse(http.StatusOK).WithBody(presenter.ForList(presenter.ForDeployment, deployments, h.serverURL, *r.URL)), nil +} + func (h *Deployment) UnauthenticatedRoutes() []routing.Route { return nil } @@ -114,5 +132,6 @@ func (h *Deployment) AuthenticatedRoutes() []routing.Route { return []routing.Route{ {Method: "GET", Pattern: DeploymentPath, Handler: h.get}, {Method: "POST", Pattern: DeploymentsPath, Handler: h.create}, + {Method: "GET", Pattern: DeploymentsPath, Handler: h.list}, } } diff --git a/api/handlers/deployment_test.go b/api/handlers/deployment_test.go index 989c12e7b..6988a5c5d 100644 --- a/api/handlers/deployment_test.go +++ b/api/handlers/deployment_test.go @@ -213,4 +213,51 @@ var _ = Describe("Deployment", func() { }) }) }) + + Describe("GET /v3/deployments", func() { + var deploymentRecord repositories.DeploymentRecord + + BeforeEach(func() { + deploymentRecord = repositories.DeploymentRecord{ + GUID: "deployment-guid", + } + deploymentsRepo.ListDeploymentsReturns([]repositories.DeploymentRecord{deploymentRecord}, nil) + + payload := &payloads.DeploymentList{AppGUIDs: "bob,alice"} + requestValidator.DecodeAndValidateURLValuesStub = decodeAndValidateURLValuesStub(payload) + + var err error + req, err = http.NewRequestWithContext(ctx, "GET", "/v3/deployments", nil) + Expect(err).NotTo(HaveOccurred()) + }) + + It("returns the list of deployments", func() { + Expect(requestValidator.DecodeAndValidateURLValuesCallCount()).To(Equal(1)) + actualReq, _ := requestValidator.DecodeAndValidateURLValuesArgsForCall(0) + Expect(actualReq.URL).To(Equal(req.URL)) + + Expect(deploymentsRepo.ListDeploymentsCallCount()).To(Equal(1)) + _, _, listMessage := deploymentsRepo.ListDeploymentsArgsForCall(0) + Expect(listMessage).To(Equal(repositories.ListDeploymentsMessage{ + AppGUIDs: []string{"bob", "alice"}, + })) + + Expect(rr).To(HaveHTTPStatus(http.StatusOK)) + Expect(rr).To(HaveHTTPHeaderWithValue("Content-Type", "application/json")) + Expect(rr).To(HaveHTTPBody(SatisfyAll( + MatchJSONPath("$.resources", HaveLen(1)), + MatchJSONPath("$.resources[0].guid", "deployment-guid"), + ))) + }) + + When("there is an error listing deployments", func() { + BeforeEach(func() { + deploymentsRepo.ListDeploymentsReturns(nil, errors.New("unexpected error!")) + }) + + It("returns an error", func() { + expectUnknownError() + }) + }) + }) }) diff --git a/api/handlers/fake/cfdeployment_repository.go b/api/handlers/fake/cfdeployment_repository.go index f72743962..b60525bed 100644 --- a/api/handlers/fake/cfdeployment_repository.go +++ b/api/handlers/fake/cfdeployment_repository.go @@ -41,6 +41,21 @@ type CFDeploymentRepository struct { result1 repositories.DeploymentRecord result2 error } + ListDeploymentsStub func(context.Context, authorization.Info, repositories.ListDeploymentsMessage) ([]repositories.DeploymentRecord, error) + listDeploymentsMutex sync.RWMutex + listDeploymentsArgsForCall []struct { + arg1 context.Context + arg2 authorization.Info + arg3 repositories.ListDeploymentsMessage + } + listDeploymentsReturns struct { + result1 []repositories.DeploymentRecord + result2 error + } + listDeploymentsReturnsOnCall map[int]struct { + result1 []repositories.DeploymentRecord + result2 error + } invocations map[string][][]interface{} invocationsMutex sync.RWMutex } @@ -177,6 +192,72 @@ func (fake *CFDeploymentRepository) GetDeploymentReturnsOnCall(i int, result1 re }{result1, result2} } +func (fake *CFDeploymentRepository) ListDeployments(arg1 context.Context, arg2 authorization.Info, arg3 repositories.ListDeploymentsMessage) ([]repositories.DeploymentRecord, error) { + fake.listDeploymentsMutex.Lock() + ret, specificReturn := fake.listDeploymentsReturnsOnCall[len(fake.listDeploymentsArgsForCall)] + fake.listDeploymentsArgsForCall = append(fake.listDeploymentsArgsForCall, struct { + arg1 context.Context + arg2 authorization.Info + arg3 repositories.ListDeploymentsMessage + }{arg1, arg2, arg3}) + stub := fake.ListDeploymentsStub + fakeReturns := fake.listDeploymentsReturns + fake.recordInvocation("ListDeployments", []interface{}{arg1, arg2, arg3}) + fake.listDeploymentsMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *CFDeploymentRepository) ListDeploymentsCallCount() int { + fake.listDeploymentsMutex.RLock() + defer fake.listDeploymentsMutex.RUnlock() + return len(fake.listDeploymentsArgsForCall) +} + +func (fake *CFDeploymentRepository) ListDeploymentsCalls(stub func(context.Context, authorization.Info, repositories.ListDeploymentsMessage) ([]repositories.DeploymentRecord, error)) { + fake.listDeploymentsMutex.Lock() + defer fake.listDeploymentsMutex.Unlock() + fake.ListDeploymentsStub = stub +} + +func (fake *CFDeploymentRepository) ListDeploymentsArgsForCall(i int) (context.Context, authorization.Info, repositories.ListDeploymentsMessage) { + fake.listDeploymentsMutex.RLock() + defer fake.listDeploymentsMutex.RUnlock() + argsForCall := fake.listDeploymentsArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 +} + +func (fake *CFDeploymentRepository) ListDeploymentsReturns(result1 []repositories.DeploymentRecord, result2 error) { + fake.listDeploymentsMutex.Lock() + defer fake.listDeploymentsMutex.Unlock() + fake.ListDeploymentsStub = nil + fake.listDeploymentsReturns = struct { + result1 []repositories.DeploymentRecord + result2 error + }{result1, result2} +} + +func (fake *CFDeploymentRepository) ListDeploymentsReturnsOnCall(i int, result1 []repositories.DeploymentRecord, result2 error) { + fake.listDeploymentsMutex.Lock() + defer fake.listDeploymentsMutex.Unlock() + fake.ListDeploymentsStub = nil + if fake.listDeploymentsReturnsOnCall == nil { + fake.listDeploymentsReturnsOnCall = make(map[int]struct { + result1 []repositories.DeploymentRecord + result2 error + }) + } + fake.listDeploymentsReturnsOnCall[i] = struct { + result1 []repositories.DeploymentRecord + result2 error + }{result1, result2} +} + func (fake *CFDeploymentRepository) Invocations() map[string][][]interface{} { fake.invocationsMutex.RLock() defer fake.invocationsMutex.RUnlock() @@ -184,6 +265,8 @@ func (fake *CFDeploymentRepository) Invocations() map[string][][]interface{} { defer fake.createDeploymentMutex.RUnlock() fake.getDeploymentMutex.RLock() defer fake.getDeploymentMutex.RUnlock() + fake.listDeploymentsMutex.RLock() + defer fake.listDeploymentsMutex.RUnlock() copiedInvocations := map[string][][]interface{}{} for key, value := range fake.invocations { copiedInvocations[key] = value diff --git a/api/main.go b/api/main.go index 07b5a27ef..4bfe95775 100644 --- a/api/main.go +++ b/api/main.go @@ -169,6 +169,8 @@ func main() { deploymentRepo := repositories.NewDeploymentRepo( userClientFactory, namespaceRetriever, + nsPermissions, + repositories.NewDeploymentSorter(), ) buildRepo := repositories.NewBuildRepo( namespaceRetriever, diff --git a/api/payloads/deployment.go b/api/payloads/deployment.go index bf9495d37..678528c75 100644 --- a/api/payloads/deployment.go +++ b/api/payloads/deployment.go @@ -1,8 +1,16 @@ package payloads import ( + "fmt" + "net/url" + "regexp" + "slices" + + "code.cloudfoundry.org/korifi/api/payloads/parse" + "code.cloudfoundry.org/korifi/api/payloads/validation" "code.cloudfoundry.org/korifi/api/repositories" - "github.com/jellydator/validation" + "github.com/BooleanCat/go-functional/v2/it" + jellidation "github.com/jellydator/validation" ) type DropletGUID struct { @@ -15,8 +23,8 @@ type DeploymentCreate struct { } func (c DeploymentCreate) Validate() error { - return validation.ValidateStruct(&c, - validation.Field(&c.Relationships, validation.NotNil)) + return jellidation.ValidateStruct(&c, + jellidation.Field(&c.Relationships, jellidation.NotNil)) } func (c *DeploymentCreate) ToMessage() repositories.CreateDeploymentMessage { @@ -31,6 +39,60 @@ type DeploymentRelationships struct { } func (r DeploymentRelationships) Validate() error { - return validation.ValidateStruct(&r, - validation.Field(&r.App, validation.NotNil)) + return jellidation.ValidateStruct(&r, + jellidation.Field(&r.App, jellidation.NotNil)) +} + +type DeploymentList struct { + AppGUIDs string `json:"app_guids"` + OrderBy string `json:"order_by"` + StatusValues string `json:"status_values"` +} + +func (d *DeploymentList) SupportedKeys() []string { + return []string{"app_guids", "status_values", "order_by"} +} + +func (d *DeploymentList) IgnoredKeys() []*regexp.Regexp { + return []*regexp.Regexp{ + regexp.MustCompile("page"), + regexp.MustCompile("per_page"), + } +} + +func (d *DeploymentList) DecodeFromURLValues(values url.Values) error { + d.AppGUIDs = values.Get("app_guids") + d.OrderBy = values.Get("order_by") + d.StatusValues = values.Get("status_values") + + return nil +} + +func (d DeploymentList) Validate() error { + return jellidation.ValidateStruct(&d, + jellidation.Field(&d.OrderBy, validation.OneOfOrderBy("created_at", "updated_at")), + jellidation.Field(&d.StatusValues, jellidation.By(func(value any) error { + statusValues, ok := value.(string) + if !ok { + return fmt.Errorf("%T is not supported, string is expected", value) + } + + return jellidation.Each(validation.OneOf( + "ACTIVE", + "FINALIZED", + )).Validate(parse.ArrayParam(statusValues)) + })), + ) +} + +func (d *DeploymentList) ToMessage() repositories.ListDeploymentsMessage { + statusValues := slices.Collect(it.Map(slices.Values(parse.ArrayParam(d.StatusValues)), func(v string) repositories.DeploymentStatusValue { + return repositories.DeploymentStatusValue(v) + })) + + return repositories.ListDeploymentsMessage{ + AppGUIDs: parse.ArrayParam(d.AppGUIDs), + StatusValues: statusValues, + OrderBy: d.OrderBy, + } } diff --git a/api/payloads/deployment_test.go b/api/payloads/deployment_test.go index 129492deb..3499ded3c 100644 --- a/api/payloads/deployment_test.go +++ b/api/payloads/deployment_test.go @@ -103,3 +103,51 @@ var _ = Describe("DeploymentCreate", func() { }) }) }) + +var _ = Describe("DeploymentList", func() { + Describe("Validation", func() { + DescribeTable("valid query", + func(query string, expectedDeploymentList payloads.DeploymentList) { + actualDeploymentList, decodeErr := decodeQuery[payloads.DeploymentList](query) + + Expect(decodeErr).NotTo(HaveOccurred()) + Expect(*actualDeploymentList).To(Equal(expectedDeploymentList)) + }, + + Entry("app_guids", "app_guids=app_guid", payloads.DeploymentList{AppGUIDs: "app_guid"}), + Entry("status_values ACTIVE", "status_values=ACTIVE", payloads.DeploymentList{StatusValues: "ACTIVE"}), + Entry("status_values FINALIZED", "status_values=FINALIZED", payloads.DeploymentList{StatusValues: "FINALIZED"}), + Entry("order_by created_at", "order_by=created_at", payloads.DeploymentList{OrderBy: "created_at"}), + Entry("order_by -created_at", "order_by=-created_at", payloads.DeploymentList{OrderBy: "-created_at"}), + Entry("order_by updated_at", "order_by=updated_at", payloads.DeploymentList{OrderBy: "updated_at"}), + Entry("order_by -updated_at", "order_by=-updated_at", payloads.DeploymentList{OrderBy: "-updated_at"}), + ) + + DescribeTable("invalid query", + func(query string, expectedErrMsg string) { + _, decodeErr := decodeQuery[payloads.DeploymentList](query) + Expect(decodeErr).To(MatchError(ContainSubstring(expectedErrMsg))) + }, + Entry("invalid order_by", "order_by=foo", "value must be one of"), + Entry("invalid status_values", "status_values=foo", "value must be one of"), + ) + }) + + Describe("ToMessage", func() { + It("translates to repository message", func() { + deploymentList := payloads.DeploymentList{ + AppGUIDs: "app-guid1,app-guid2", + StatusValues: "ACTIVE,FINALIZED", + OrderBy: "created_at", + } + Expect(deploymentList.ToMessage()).To(Equal(repositories.ListDeploymentsMessage{ + AppGUIDs: []string{"app-guid1", "app-guid2"}, + StatusValues: []repositories.DeploymentStatusValue{ + repositories.DeploymentStatusValueActive, + repositories.DeploymentStatusValueFinalized, + }, + OrderBy: "created_at", + })) + }) + }) +}) diff --git a/api/payloads/package.go b/api/payloads/package.go index 1488d0912..a42788117 100644 --- a/api/payloads/package.go +++ b/api/payloads/package.go @@ -117,14 +117,8 @@ func (p *PackageList) DecodeFromURLValues(values url.Values) error { } func (p PackageList) Validate() error { - validOrderBys := []string{"created_at", "updated_at"} - var allowed []any - for _, a := range validOrderBys { - allowed = append(allowed, a, "-"+a) - } - return jellidation.ValidateStruct(&p, - jellidation.Field(&p.OrderBy, validation.OneOf(allowed...)), + jellidation.Field(&p.OrderBy, validation.OneOfOrderBy("created_at", "updated_at")), ) } diff --git a/api/presenter/deployment.go b/api/presenter/deployment.go index e6e64b902..e45488daa 100644 --- a/api/presenter/deployment.go +++ b/api/presenter/deployment.go @@ -25,6 +25,8 @@ type DeploymentResponse struct { Droplet DropletGUID `json:"droplet"` Relationships map[string]model.ToOneRelationship `json:"relationships"` Links DeploymentLinks `json:"links"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } type DeploymentLinks struct { @@ -43,6 +45,8 @@ func ForDeployment(responseDeployment repositories.DeploymentRecord, baseURL url Guid: responseDeployment.DropletGUID, }, Relationships: ForRelationships(responseDeployment.Relationships()), + CreatedAt: formatTimestamp(&responseDeployment.CreatedAt), + UpdatedAt: formatTimestamp(responseDeployment.UpdatedAt), Links: DeploymentLinks{ Self: Link{ HRef: buildURL(baseURL).appendPath(deploymentsBase, responseDeployment.GUID).build(), diff --git a/api/presenter/deployment_test.go b/api/presenter/deployment_test.go index fcf86ecfc..35d9e0d91 100644 --- a/api/presenter/deployment_test.go +++ b/api/presenter/deployment_test.go @@ -3,9 +3,11 @@ package presenter_test import ( "encoding/json" "net/url" + "time" "code.cloudfoundry.org/korifi/api/presenter" "code.cloudfoundry.org/korifi/api/repositories" + "code.cloudfoundry.org/korifi/tools" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -25,6 +27,8 @@ var _ = Describe("Deployments", func() { record = repositories.DeploymentRecord{ GUID: "app-guid", DropletGUID: "droplet-guid", + CreatedAt: time.UnixMilli(1000), + UpdatedAt: tools.PtrTo(time.UnixMilli(2000)), Status: repositories.DeploymentStatus{ Value: "deployment-status-value", Reason: "deployment-status-reason", @@ -56,6 +60,8 @@ var _ = Describe("Deployments", func() { } } }, + "created_at": "1970-01-01T00:00:01Z", + "updated_at": "1970-01-01T00:00:02Z", "links": { "self": { "href": "https://api.example.org/v3/deployments/app-guid" diff --git a/api/repositories/compare/sort.go b/api/repositories/compare/sort.go new file mode 100644 index 000000000..b920004fb --- /dev/null +++ b/api/repositories/compare/sort.go @@ -0,0 +1,43 @@ +package compare + +import ( + "slices" + "strings" +) + +type SortOrder int + +const ( + Ascending SortOrder = 1 + Descending SortOrder = -1 +) + +type Sorter[T any] struct { + comparatorFactory func(string) func(T, T) int +} + +func NewSorter[T any](comparatorFactory func(string) func(T, T) int) *Sorter[T] { + return &Sorter[T]{comparatorFactory: comparatorFactory} +} + +func (s Sorter[T]) Sort(records []T, orderBy string) []T { + field, isDescending := strings.CutPrefix(orderBy, "-") + + sortOrder := Ascending + if isDescending { + sortOrder = Descending + } + + comparator := orderedComparator(sortOrder, s.comparatorFactory(field)) + slices.SortFunc(records, comparator) + return records +} + +func orderedComparator[T any]( + order SortOrder, + comparator func(T, T) int, +) func(T, T) int { + return func(t1, t2 T) int { + return int(order) * comparator(t1, t2) + } +} diff --git a/api/repositories/compare/sort_test.go b/api/repositories/compare/sort_test.go new file mode 100644 index 000000000..022d691b7 --- /dev/null +++ b/api/repositories/compare/sort_test.go @@ -0,0 +1,50 @@ +package compare_test + +import ( + "code.cloudfoundry.org/korifi/api/repositories/compare" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type sortable struct { + sortField int +} + +var _ = Describe("Sort", func() { + var ( + s1, s2, s3 sortable + sorter *compare.Sorter[sortable] + orderBy string + sorted []sortable + ) + + BeforeEach(func() { + s1 = sortable{sortField: 1} + s2 = sortable{sortField: 2} + s3 = sortable{sortField: 3} + orderBy = "sortable_field" + sorter = compare.NewSorter(func(string) func(s1, s2 sortable) int { + return func(s1, s2 sortable) int { + return s1.sortField - s2.sortField + } + }) + }) + + JustBeforeEach(func() { + sorted = sorter.Sort([]sortable{s1, s3, s2}, orderBy) + }) + + It("sorts ascending", func() { + Expect(sorted).To(Equal([]sortable{s1, s2, s3})) + }) + + When("orderBy is descending", func() { + BeforeEach(func() { + orderBy = "-sortable_field" + }) + + It("sorts descending", func() { + Expect(sorted).To(Equal([]sortable{s3, s2, s1})) + }) + }) +}) diff --git a/api/repositories/compare/suite_test.go b/api/repositories/compare/suite_test.go new file mode 100644 index 000000000..6f969f2a2 --- /dev/null +++ b/api/repositories/compare/suite_test.go @@ -0,0 +1,13 @@ +package compare_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCompare(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Compare Suite") +} diff --git a/api/repositories/deployment_repository.go b/api/repositories/deployment_repository.go index 0f9bc5ec6..ff93f52cb 100644 --- a/api/repositories/deployment_repository.go +++ b/api/repositories/deployment_repository.go @@ -3,16 +3,22 @@ package repositories import ( "context" "fmt" + "slices" "strconv" "time" "code.cloudfoundry.org/korifi/api/authorization" apierrors "code.cloudfoundry.org/korifi/api/errors" + "code.cloudfoundry.org/korifi/api/repositories/compare" korifiv1alpha1 "code.cloudfoundry.org/korifi/controllers/api/v1alpha1" + "code.cloudfoundry.org/korifi/tools" "code.cloudfoundry.org/korifi/tools/k8s" "code.cloudfoundry.org/korifi/version" + "github.com/BooleanCat/go-functional/v2/it" + "github.com/BooleanCat/go-functional/v2/it/itx" "github.com/go-logr/logr" + k8serrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -20,8 +26,10 @@ import ( const DeploymentResourceType = "Deployment" type DeploymentRepo struct { - userClientFactory authorization.UserK8sClientFactory - namespaceRetriever NamespaceRetriever + userClientFactory authorization.UserK8sClientFactory + namespaceRetriever NamespaceRetriever + namespacePermissions *authorization.NamespacePermissions + sorter DeploymentSorter } type DeploymentRecord struct { @@ -38,6 +46,37 @@ func (r DeploymentRecord) Relationships() map[string]string { } } +//counterfeiter:generate -o fake -fake-name DeploymentSorter . DeploymentSorter +type DeploymentSorter interface { + Sort(records []DeploymentRecord, order string) []DeploymentRecord +} + +type deploymentSorter struct { + sorter *compare.Sorter[DeploymentRecord] +} + +func NewDeploymentSorter() *deploymentSorter { + return &deploymentSorter{ + sorter: compare.NewSorter(DeploymentComparator), + } +} + +func (s *deploymentSorter) Sort(records []DeploymentRecord, order string) []DeploymentRecord { + return s.sorter.Sort(records, order) +} + +func DeploymentComparator(fieldName string) func(DeploymentRecord, DeploymentRecord) int { + return func(d1, d2 DeploymentRecord) int { + switch fieldName { + case "created_at": + return tools.CompareTimePtr(&d1.CreatedAt, &d2.CreatedAt) + case "updated_at": + return tools.CompareTimePtr(d1.UpdatedAt, d2.UpdatedAt) + } + return 0 + } +} + type DeploymentStatusValue string const ( @@ -62,13 +101,31 @@ type CreateDeploymentMessage struct { DropletGUID string } +type ListDeploymentsMessage struct { + AppGUIDs []string + StatusValues []DeploymentStatusValue + OrderBy string +} + +func (m ListDeploymentsMessage) matchesApp(app korifiv1alpha1.CFApp) bool { + return tools.EmptyOrContains(m.AppGUIDs, app.Name) +} + +func (m ListDeploymentsMessage) matchesStatusValue(deployment DeploymentRecord) bool { + return tools.EmptyOrContains(m.StatusValues, deployment.Status.Value) +} + func NewDeploymentRepo( userClientFactory authorization.UserK8sClientFactory, namespaceRetriever NamespaceRetriever, + namespacePermissions *authorization.NamespacePermissions, + sorter DeploymentSorter, ) *DeploymentRepo { return &DeploymentRepo{ - userClientFactory: userClientFactory, - namespaceRetriever: namespaceRetriever, + userClientFactory: userClientFactory, + namespaceRetriever: namespaceRetriever, + namespacePermissions: namespacePermissions, + sorter: sorter, } } @@ -89,7 +146,7 @@ func (r *DeploymentRepo) GetDeployment(ctx context.Context, authInfo authorizati return DeploymentRecord{}, apierrors.FromK8sError(err, DeploymentResourceType) } - return appToDeploymentRecord(app), nil + return appToDeploymentRecord(*app), nil } func (r *DeploymentRepo) CreateDeployment(ctx context.Context, authInfo authorization.Info, message CreateDeploymentMessage) (DeploymentRecord, error) { @@ -136,7 +193,38 @@ func (r *DeploymentRepo) CreateDeployment(ctx context.Context, authInfo authoriz return DeploymentRecord{}, apierrors.FromK8sError(err, DeploymentResourceType) } - return appToDeploymentRecord(app), nil + return appToDeploymentRecord(*app), nil +} + +func (r *DeploymentRepo) ListDeployments(ctx context.Context, authInfo authorization.Info, message ListDeploymentsMessage) ([]DeploymentRecord, error) { + userClient, err := r.userClientFactory.BuildClient(authInfo) + if err != nil { + return nil, fmt.Errorf("failed to create user client: %w", err) + } + + authorisedSpaceNamespaces, err := authorizedSpaceNamespaces(ctx, authInfo, r.namespacePermissions) + if err != nil { + return nil, fmt.Errorf("failed to get namespaces for spaces with user role bindings: %w", err) + } + + var apps []korifiv1alpha1.CFApp + for _, ns := range authorisedSpaceNamespaces.Collect() { + appList := &korifiv1alpha1.CFAppList{} + err := userClient.List(ctx, appList, client.InNamespace(ns)) + if k8serrors.IsForbidden(err) { + continue + } + if err != nil { + return nil, fmt.Errorf("failed to list apps in namespace %s: %w", ns, apierrors.FromK8sError(err, AppResourceType)) + } + + apps = append(apps, appList.Items...) + } + + deploymentRecords := it.Map(itx.FromSlice(apps).Filter(message.matchesApp), appToDeploymentRecord) + deploymentRecords = it.Filter(deploymentRecords, message.matchesStatusValue) + + return r.sorter.Sort(slices.Collect(deploymentRecords), message.OrderBy), nil } func bumpAppRev(appRev string) (string, error) { @@ -148,11 +236,11 @@ func bumpAppRev(appRev string) (string, error) { return strconv.Itoa(r + 1), nil } -func appToDeploymentRecord(cfApp *korifiv1alpha1.CFApp) DeploymentRecord { +func appToDeploymentRecord(cfApp korifiv1alpha1.CFApp) DeploymentRecord { deploymentRecord := DeploymentRecord{ GUID: cfApp.Name, CreatedAt: cfApp.CreationTimestamp.Time, - UpdatedAt: getLastUpdatedTime(cfApp), + UpdatedAt: getLastUpdatedTime(&cfApp), DropletGUID: cfApp.Spec.CurrentDropletRef.Name, Status: DeploymentStatus{ Value: DeploymentStatusValueActive, diff --git a/api/repositories/deployment_repository_test.go b/api/repositories/deployment_repository_test.go index b52a34792..25d2754e7 100644 --- a/api/repositories/deployment_repository_test.go +++ b/api/repositories/deployment_repository_test.go @@ -5,8 +5,10 @@ import ( apierrors "code.cloudfoundry.org/korifi/api/errors" "code.cloudfoundry.org/korifi/api/repositories" + "code.cloudfoundry.org/korifi/api/repositories/fake" korifiv1alpha1 "code.cloudfoundry.org/korifi/controllers/api/v1alpha1" "code.cloudfoundry.org/korifi/tests/matchers" + "code.cloudfoundry.org/korifi/tools" "code.cloudfoundry.org/korifi/tools/k8s" "code.cloudfoundry.org/korifi/version" "k8s.io/apimachinery/pkg/api/meta" @@ -16,7 +18,8 @@ import ( "github.com/google/uuid" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/onsi/gomega/gstruct" + . "github.com/onsi/gomega/gstruct" + "github.com/onsi/gomega/types" ) var _ = Describe("DeploymentRepository", func() { @@ -25,12 +28,18 @@ var _ = Describe("DeploymentRepository", func() { cfOrg *korifiv1alpha1.CFOrg cfSpace *korifiv1alpha1.CFSpace cfApp *korifiv1alpha1.CFApp + sorter *fake.DeploymentSorter ) BeforeEach(func() { cfOrg = createOrgWithCleanup(ctx, prefixedGUID("org")) cfSpace = createSpaceWithCleanup(ctx, cfOrg.Name, prefixedGUID("space1")) cfApp = createApp(cfSpace.Name) + sorter = new(fake.DeploymentSorter) + sorter.SortStub = func(records []repositories.DeploymentRecord, _ string) []repositories.DeploymentRecord { + return records + } + Expect(k8sClient.Create(ctx, &korifiv1alpha1.AppWorkload{ ObjectMeta: metav1.ObjectMeta{ Namespace: cfApp.Namespace, @@ -44,7 +53,7 @@ var _ = Describe("DeploymentRepository", func() { }, })).To(Succeed()) - deploymentRepo = repositories.NewDeploymentRepo(userClientFactory, namespaceRetriever) + deploymentRepo = repositories.NewDeploymentRepo(userClientFactory, namespaceRetriever, nsPerms, sorter) }) Describe("GetDeployment", func() { @@ -80,7 +89,7 @@ var _ = Describe("DeploymentRepository", func() { Expect(deployment.Status.Value).To(Equal(repositories.DeploymentStatusValueActive)) Expect(deployment.Status.Reason).To(Equal(repositories.DeploymentStatusReasonDeploying)) Expect(deployment.CreatedAt).To(BeTemporally("~", time.Now(), timeCheckThreshold)) - Expect(deployment.UpdatedAt).To(gstruct.PointTo(BeTemporally("~", time.Now(), timeCheckThreshold))) + Expect(deployment.UpdatedAt).To(PointTo(BeTemporally("~", time.Now(), timeCheckThreshold))) Expect(deployment.Relationships()).To(Equal(map[string]string{ "app": cfApp.Name, @@ -153,7 +162,7 @@ var _ = Describe("DeploymentRepository", func() { Expect(deployment.Status.Value).To(Equal(repositories.DeploymentStatusValueActive)) Expect(deployment.Status.Reason).To(Equal(repositories.DeploymentStatusReasonDeploying)) Expect(deployment.CreatedAt).To(BeTemporally("~", time.Now(), timeCheckThreshold)) - Expect(deployment.UpdatedAt).To(gstruct.PointTo(BeTemporally("~", time.Now(), timeCheckThreshold))) + Expect(deployment.UpdatedAt).To(PointTo(BeTemporally("~", time.Now(), timeCheckThreshold))) }) It("bumps the app-rev annotation on the app", func() { @@ -242,4 +251,123 @@ var _ = Describe("DeploymentRepository", func() { }) }) }) + + Describe("ListDeployments", func() { + var ( + message repositories.ListDeploymentsMessage + deployments []repositories.DeploymentRecord + anotherApp *korifiv1alpha1.CFApp + ) + + BeforeEach(func() { + unauthorisedSpace := createSpaceWithCleanup(ctx, cfOrg.Name, prefixedGUID("another-space")) + createApp(unauthorisedSpace.Name) + + anotherApp = createApp(cfSpace.Name) + message = repositories.ListDeploymentsMessage{} + }) + + JustBeforeEach(func() { + var err error + deployments, err = deploymentRepo.ListDeployments(ctx, authInfo, message) + Expect(err).NotTo(HaveOccurred()) + }) + + It("returns an empty list", func() { + Expect(deployments).To(BeEmpty()) + }) + + When("the user is authorized in a space", func() { + BeforeEach(func() { + createRoleBinding(ctx, userName, spaceDeveloperRole.Name, cfSpace.Name) + }) + + It("returns the deployments from that namespace", func() { + Expect(deployments).To(ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "GUID": Equal(cfApp.Name), + }), + MatchFields(IgnoreExtras, Fields{ + "GUID": Equal(anotherApp.Name), + }), + )) + }) + + Describe("ordering", func() { + BeforeEach(func() { + message.OrderBy = "foo" + }) + + It("sorts the deployments", func() { + Expect(sorter.SortCallCount()).To(Equal(1)) + sortedDeployments, field := sorter.SortArgsForCall(0) + Expect(field).To(Equal("foo")) + Expect(sortedDeployments).To(ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "GUID": Equal(cfApp.Name), + }), + MatchFields(IgnoreExtras, Fields{ + "GUID": Equal(anotherApp.Name), + }), + )) + }) + }) + + Describe("filtering", func() { + Describe("by app guid", func() { + BeforeEach(func() { + message = repositories.ListDeploymentsMessage{ + AppGUIDs: []string{cfApp.Name}, + } + }) + + It("filters by app guids", func() { + Expect(deployments).To(ConsistOf(MatchFields(IgnoreExtras, Fields{ + "GUID": Equal(cfApp.Name), + }))) + }) + }) + + Describe("by status", func() { + BeforeEach(func() { + Expect(k8s.Patch(ctx, k8sClient, cfApp, func() { + meta.SetStatusCondition(&cfApp.Status.Conditions, metav1.Condition{ + Type: korifiv1alpha1.StatusConditionReady, + Status: metav1.ConditionTrue, + Reason: "ready", + }) + })).To(Succeed()) + + message = repositories.ListDeploymentsMessage{ + StatusValues: []repositories.DeploymentStatusValue{repositories.DeploymentStatusValueFinalized}, + } + }) + + It("filters by status", func() { + Expect(deployments).To(ConsistOf(MatchFields(IgnoreExtras, Fields{ + "GUID": Equal(cfApp.Name), + }))) + }) + }) + }) + }) + }) }) + +var _ = DescribeTable("DeploymentSorter", + func(d1, d2 repositories.DeploymentRecord, field string, match types.GomegaMatcher) { + Expect(repositories.DeploymentComparator(field)(d1, d2)).To(match) + }, + Entry("created_at", + repositories.DeploymentRecord{CreatedAt: time.UnixMilli(1)}, + repositories.DeploymentRecord{CreatedAt: time.UnixMilli(2)}, + "created_at", + BeNumerically("<", 0), + ), + Entry("updated_at", + repositories.DeploymentRecord{UpdatedAt: tools.PtrTo(time.UnixMilli(1))}, + repositories.DeploymentRecord{UpdatedAt: tools.PtrTo(time.UnixMilli(2))}, + "updated_at", + BeNumerically("<", 0), + ), +) diff --git a/api/repositories/fake/deployment_sorter.go b/api/repositories/fake/deployment_sorter.go new file mode 100644 index 000000000..30b7c7857 --- /dev/null +++ b/api/repositories/fake/deployment_sorter.go @@ -0,0 +1,118 @@ +// Code generated by counterfeiter. DO NOT EDIT. +package fake + +import ( + "sync" + + "code.cloudfoundry.org/korifi/api/repositories" +) + +type DeploymentSorter struct { + SortStub func([]repositories.DeploymentRecord, string) []repositories.DeploymentRecord + sortMutex sync.RWMutex + sortArgsForCall []struct { + arg1 []repositories.DeploymentRecord + arg2 string + } + sortReturns struct { + result1 []repositories.DeploymentRecord + } + sortReturnsOnCall map[int]struct { + result1 []repositories.DeploymentRecord + } + invocations map[string][][]interface{} + invocationsMutex sync.RWMutex +} + +func (fake *DeploymentSorter) Sort(arg1 []repositories.DeploymentRecord, arg2 string) []repositories.DeploymentRecord { + var arg1Copy []repositories.DeploymentRecord + if arg1 != nil { + arg1Copy = make([]repositories.DeploymentRecord, len(arg1)) + copy(arg1Copy, arg1) + } + fake.sortMutex.Lock() + ret, specificReturn := fake.sortReturnsOnCall[len(fake.sortArgsForCall)] + fake.sortArgsForCall = append(fake.sortArgsForCall, struct { + arg1 []repositories.DeploymentRecord + arg2 string + }{arg1Copy, arg2}) + stub := fake.SortStub + fakeReturns := fake.sortReturns + fake.recordInvocation("Sort", []interface{}{arg1Copy, arg2}) + fake.sortMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *DeploymentSorter) SortCallCount() int { + fake.sortMutex.RLock() + defer fake.sortMutex.RUnlock() + return len(fake.sortArgsForCall) +} + +func (fake *DeploymentSorter) SortCalls(stub func([]repositories.DeploymentRecord, string) []repositories.DeploymentRecord) { + fake.sortMutex.Lock() + defer fake.sortMutex.Unlock() + fake.SortStub = stub +} + +func (fake *DeploymentSorter) SortArgsForCall(i int) ([]repositories.DeploymentRecord, string) { + fake.sortMutex.RLock() + defer fake.sortMutex.RUnlock() + argsForCall := fake.sortArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *DeploymentSorter) SortReturns(result1 []repositories.DeploymentRecord) { + fake.sortMutex.Lock() + defer fake.sortMutex.Unlock() + fake.SortStub = nil + fake.sortReturns = struct { + result1 []repositories.DeploymentRecord + }{result1} +} + +func (fake *DeploymentSorter) SortReturnsOnCall(i int, result1 []repositories.DeploymentRecord) { + fake.sortMutex.Lock() + defer fake.sortMutex.Unlock() + fake.SortStub = nil + if fake.sortReturnsOnCall == nil { + fake.sortReturnsOnCall = make(map[int]struct { + result1 []repositories.DeploymentRecord + }) + } + fake.sortReturnsOnCall[i] = struct { + result1 []repositories.DeploymentRecord + }{result1} +} + +func (fake *DeploymentSorter) Invocations() map[string][][]interface{} { + fake.invocationsMutex.RLock() + defer fake.invocationsMutex.RUnlock() + fake.sortMutex.RLock() + defer fake.sortMutex.RUnlock() + copiedInvocations := map[string][][]interface{}{} + for key, value := range fake.invocations { + copiedInvocations[key] = value + } + return copiedInvocations +} + +func (fake *DeploymentSorter) recordInvocation(key string, args []interface{}) { + fake.invocationsMutex.Lock() + defer fake.invocationsMutex.Unlock() + if fake.invocations == nil { + fake.invocations = map[string][][]interface{}{} + } + if fake.invocations[key] == nil { + fake.invocations[key] = [][]interface{}{} + } + fake.invocations[key] = append(fake.invocations[key], args) +} + +var _ repositories.DeploymentSorter = new(DeploymentSorter) diff --git a/tests/e2e/deployments_test.go b/tests/e2e/deployments_test.go index 3e345d149..06a751d39 100644 --- a/tests/e2e/deployments_test.go +++ b/tests/e2e/deployments_test.go @@ -3,6 +3,8 @@ package e2e_test import ( "net/http" + . "github.com/onsi/gomega/gstruct" + "github.com/go-resty/resty/v2" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -76,4 +78,31 @@ var _ = Describe("Deployments", func() { Expect(deploymentResource.GUID).NotTo(BeEmpty()) }) }) + + Describe("List", func() { + var ( + deploymentGUID string + listedDeployments resourceList[responseResource] + ) + + BeforeEach(func() { + listedDeployments = resourceList[responseResource]{} + deploymentGUID = createDeployment(appGUID) + }) + + JustBeforeEach(func() { + var err error + resp, err = adminClient.R(). + SetResult(&listedDeployments). + Get("/v3/deployments/") + Expect(err).NotTo(HaveOccurred()) + }) + + It("lists deployment", func() { + Expect(resp).To(HaveRestyStatusCode(http.StatusOK)) + Expect(listedDeployments.Resources).To(ContainElement( + MatchFields(IgnoreExtras, Fields{"GUID": Equal(deploymentGUID)}), + )) + }) + }) }) diff --git a/tools/compare.go b/tools/compare.go new file mode 100644 index 000000000..affff8cc3 --- /dev/null +++ b/tools/compare.go @@ -0,0 +1,16 @@ +package tools + +import "time" + +func CompareTimePtr(t1, t2 *time.Time) int { + return ZeroIfNil(t1).Compare(ZeroIfNil(t2)) +} + +func ZeroIfNil[T any, PT *T](value PT) T { + if value != nil { + return *value + } + + var result T + return result +} diff --git a/tools/compare_test.go b/tools/compare_test.go new file mode 100644 index 000000000..e1c9c9b7f --- /dev/null +++ b/tools/compare_test.go @@ -0,0 +1,33 @@ +package tools_test + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/onsi/gomega/types" + + "code.cloudfoundry.org/korifi/tools" +) + +var _ = Describe("Compare", func() { + DescribeTable("ZeroIfNil", + func(value *time.Time, match types.GomegaMatcher) { + Expect(tools.ZeroIfNil(value)).To(match) + }, + Entry("nil", nil, BeZero()), + Entry("not nil", tools.PtrTo(time.UnixMilli(1)), Equal(time.UnixMilli(1))), + ) + + DescribeTable("CompareTimePtr", + func(t1, t2 *time.Time, match types.GomegaMatcher) { + Expect(tools.CompareTimePtr(t1, t2)).To(match) + }, + Entry("nils", nil, nil, BeZero()), + Entry("nil, not-nil", nil, tools.PtrTo(time.UnixMilli(1)), BeNumerically("<", 0)), + Entry("not-nil, nil", tools.PtrTo(time.UnixMilli(1)), nil, BeNumerically(">", 0)), + Entry("not-nil < not-nil", tools.PtrTo(time.UnixMilli(1)), tools.PtrTo(time.UnixMilli(2)), BeNumerically("<", 0)), + Entry("not-nil > not-nil", tools.PtrTo(time.UnixMilli(2)), tools.PtrTo(time.UnixMilli(1)), BeNumerically(">", 0)), + Entry("not-nil == not-nil", tools.PtrTo(time.UnixMilli(1)), tools.PtrTo(time.UnixMilli(1)), BeZero()), + ) +})