From a729656450f5296120876fd40614f7f436dd735f Mon Sep 17 00:00:00 2001 From: Kiril Kabakchiev Date: Thu, 16 Jan 2020 14:41:19 +0200 Subject: [PATCH 01/15] Sm as a platform (#390) --- api/api.go | 4 +- api/filters/protected_sm_platform_filter.go | 43 +++++++++++++++ pkg/sm/sm.go | 28 +++++++++- pkg/types/platform.go | 1 + test/common/common.go | 10 +++- test/common/test_context.go | 14 +++-- test/interceptors_test/interceptors_test.go | 28 +++++++--- test/platform_test/platform_test.go | 16 +++--- test/sm_test/sm_test.go | 61 +++++++++++++++++++++ 9 files changed, 181 insertions(+), 24 deletions(-) create mode 100644 api/filters/protected_sm_platform_filter.go diff --git a/api/api.go b/api/api.go index 4f6f9b723..9610fe63c 100644 --- a/api/api.go +++ b/api/api.go @@ -20,9 +20,10 @@ package api import ( "context" "fmt" + "sync" + "github.com/Peripli/service-manager/operations" "github.com/Peripli/service-manager/pkg/env" - "sync" "github.com/Peripli/service-manager/api/configuration" @@ -127,6 +128,7 @@ func New(ctx context.Context, e env.Environment, options *Options) (*web.API, er &filters.Logging{}, &filters.SelectionCriteria{}, filters.NewProtectedLabelsFilter(options.APISettings.ProtectedLabels), + &filters.ProtectedSMPlatformFilter{}, &filters.PlatformAwareVisibilityFilter{}, &filters.PatchOnlyLabelsFilter{}, filters.NewPlansFilterByVisibility(options.Repository), diff --git a/api/filters/protected_sm_platform_filter.go b/api/filters/protected_sm_platform_filter.go new file mode 100644 index 000000000..65f259709 --- /dev/null +++ b/api/filters/protected_sm_platform_filter.go @@ -0,0 +1,43 @@ +package filters + +import ( + "net/http" + + "github.com/Peripli/service-manager/pkg/types" + + "github.com/Peripli/service-manager/pkg/query" + "github.com/Peripli/service-manager/pkg/web" +) + +const ProtectedSMPlatformFilterName = "ProtectedSMPlatformFilter" + +// ProtectedSMPlatformFilter disallows patching and deleting of the service manager platform +type ProtectedSMPlatformFilter struct { +} + +func (f *ProtectedSMPlatformFilter) Name() string { + return ProtectedSMPlatformFilterName +} + +func (f *ProtectedSMPlatformFilter) Run(req *web.Request, next web.Handler) (*web.Response, error) { + ctx := req.Context() + byName := query.ByField(query.NotEqualsOperator, "name", types.SMPlatform) + ctx, err := query.AddCriteria(ctx, byName) + if err != nil { + return nil, err + } + req.Request = req.WithContext(ctx) + + return next.Handle(req) +} + +func (f *ProtectedSMPlatformFilter) FilterMatchers() []web.FilterMatcher { + return []web.FilterMatcher{ + { + Matchers: []web.Matcher{ + web.Path(web.PlatformsURL + "/*"), + web.Methods(http.MethodPatch, http.MethodDelete), + }, + }, + } +} diff --git a/pkg/sm/sm.go b/pkg/sm/sm.go index c79e711f8..59cca3598 100644 --- a/pkg/sm/sm.go +++ b/pkg/sm/sm.go @@ -20,10 +20,11 @@ import ( "context" "database/sql" "fmt" - "github.com/Peripli/service-manager/operations" "net/http" "sync" + "time" + "github.com/Peripli/service-manager/operations" secFilters "github.com/Peripli/service-manager/pkg/security/filters" "github.com/Peripli/service-manager/pkg/env" @@ -217,6 +218,10 @@ func (smb *ServiceManagerBuilder) Build() *ServiceManager { // start the operation maintainer smb.OperationMaintainer.Run() + if err := smb.registerSMPlatform(); err != nil { + log.C(smb.ctx).Panic(err) + } + return &ServiceManager{ ctx: smb.ctx, wg: smb.wg, @@ -226,6 +231,27 @@ func (smb *ServiceManagerBuilder) Build() *ServiceManager { } } +func (smb *ServiceManagerBuilder) registerSMPlatform() error { + if _, err := smb.Storage.Create(smb.ctx, &types.Platform{ + Base: types.Base{ + ID: types.SMPlatform, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Labels: make(map[string][]string), + }, + Type: types.SMPlatform, + Name: types.SMPlatform, + }); err != nil { + if err == util.ErrAlreadyExistsInStorage { + log.C(smb.ctx).Infof("platform %s already exists in SMDB...", "service-manager") + return nil + } + return fmt.Errorf("could register service-manager platform during bootstrap: %s", err) + } + + return nil +} + func (smb *ServiceManagerBuilder) installHealth() error { healthz, thresholds, err := health.Configure(smb.ctx, smb.HealthIndicators, smb.cfg.Health) if err != nil { diff --git a/pkg/types/platform.go b/pkg/types/platform.go index 01a974f9c..d30dd56de 100644 --- a/pkg/types/platform.go +++ b/pkg/types/platform.go @@ -26,6 +26,7 @@ import ( ) const K8sPlatformType string = "kubernetes" +const SMPlatform = "service-manager" //go:generate smgen api Platform // Platform platform struct diff --git a/test/common/common.go b/test/common/common.go index 3355e8417..35f7c6273 100644 --- a/test/common/common.go +++ b/test/common/common.go @@ -220,16 +220,20 @@ func RemoveAllBrokers(SM *SMExpect) { } func RemoveAllPlatforms(SM *SMExpect) { - removeAll(SM, "platforms", web.PlatformsURL) + removeAll(SM, "platforms", web.PlatformsURL, fmt.Sprintf("fieldQuery=name ne '%s'", types.SMPlatform)) } func RemoveAllVisibilities(SM *SMExpect) { removeAll(SM, "visibilities", web.VisibilitiesURL) } -func removeAll(SM *SMExpect, entity, rootURLPath string) { +func removeAll(SM *SMExpect, entity, rootURLPath string, queries ...string) { By("removing all " + entity) - SM.DELETE(rootURLPath).Expect() + deleteCall := SM.DELETE(rootURLPath) + for _, query := range queries { + deleteCall.WithQueryString(query) + } + deleteCall.Expect() } func RegisterBrokerInSM(brokerJSON Object, SM *SMExpect, headers map[string]string) Object { diff --git a/test/common/test_context.go b/test/common/test_context.go index e6e8c7759..60ac26165 100644 --- a/test/common/test_context.go +++ b/test/common/test_context.go @@ -524,6 +524,14 @@ func (ctx *TestContext) Cleanup() { ctx.wg.Wait() } +func (ctx *TestContext) CleanupPlatforms() { + if ctx.TestPlatform != nil { + ctx.SMWithOAuth.DELETE(web.PlatformsURL).WithQuery("fieldQuery", fmt.Sprintf("id notin ('%s', '%s')", ctx.TestPlatform.ID, types.SMPlatform)).Expect() + } else { + ctx.SMWithOAuth.DELETE(web.PlatformsURL).WithQuery("fieldQuery", fmt.Sprintf("id ne '%s'", types.SMPlatform)).Expect() + } +} + func (ctx *TestContext) CleanupAdditionalResources() { if ctx == nil { return @@ -542,11 +550,7 @@ func (ctx *TestContext) CleanupAdditionalResources() { ctx.SMWithOAuth.DELETE(web.ServiceBrokersURL).Expect() - if ctx.TestPlatform != nil { - ctx.SMWithOAuth.DELETE(web.PlatformsURL).WithQuery("fieldQuery", fmt.Sprintf("id ne '%s'", ctx.TestPlatform.ID)).Expect() - } else { - ctx.SMWithOAuth.DELETE(web.PlatformsURL).Expect() - } + ctx.CleanupPlatforms() var smServer FakeServer for serverName, server := range ctx.Servers { if serverName == SMServer { diff --git a/test/interceptors_test/interceptors_test.go b/test/interceptors_test/interceptors_test.go index c24281f4d..5be25f562 100644 --- a/test/interceptors_test/interceptors_test.go +++ b/test/interceptors_test/interceptors_test.go @@ -282,9 +282,16 @@ var _ = Describe("Interceptors", func() { deleteModificationInterceptors[types.PlatformType].OnTxDeleteStub = func(f storage.InterceptDeleteOnTxFunc) storage.InterceptDeleteOnTxFunc { return func(ctx context.Context, txStorage storage.Repository, objects types.ObjectList, deletionCriteria ...query.Criterion) error { - Expect(deletionCriteria).To(HaveLen(1)) - Expect(deletionCriteria[0].LeftOp).To(Equal("id")) - Expect(deletionCriteria[0].RightOp[0]).To(Equal(platform2.ID)) + Expect(len(deletionCriteria)).Should(BeNumerically(">=", 1)) + found := false + for _, deleteCriteria := range deletionCriteria { + if deleteCriteria.LeftOp == "id" && deleteCriteria.RightOp[0] == platform2.ID { + found = true + } + } + if !found { + Fail("Could not find id criteria") + } return f(ctx, txStorage, objects, deletionCriteria...) } } @@ -295,9 +302,16 @@ var _ = Describe("Interceptors", func() { deleteModificationInterceptors[types.PlatformType].OnTxDeleteStub = func(f storage.InterceptDeleteOnTxFunc) storage.InterceptDeleteOnTxFunc { return func(ctx context.Context, txStorage storage.Repository, objects types.ObjectList, deletionCriteria ...query.Criterion) error { - Expect(deletionCriteria).To(HaveLen(1)) - Expect(deletionCriteria[0].LeftOp).To(Equal("id")) - Expect(deletionCriteria[0].RightOp[0]).To(Equal(platform1.ID)) + Expect(len(deletionCriteria)).Should(BeNumerically(">=", 1)) + found := false + for _, deleteCriteria := range deletionCriteria { + if deleteCriteria.LeftOp == "id" && deleteCriteria.RightOp[0] == platform1.ID { + found = true + } + } + if !found { + Fail("Could not find id criteria") + } return f(ctx, txStorage, objects, deletionCriteria...) } } @@ -308,7 +322,7 @@ var _ = Describe("Interceptors", func() { By("should be left with the created platform and the test one only") ctx.SMWithOAuth.List(web.PlatformsURL). - Length().Equal(2) + Length().Ge(2) }) }) }) diff --git a/test/platform_test/platform_test.go b/test/platform_test/platform_test.go index 03be41bf7..0f5d812d2 100644 --- a/test/platform_test/platform_test.go +++ b/test/platform_test/platform_test.go @@ -19,6 +19,7 @@ package platform_test import ( "context" "net/http" + "sort" "testing" "github.com/Peripli/service-manager/test/testutil/service_instance" @@ -69,30 +70,31 @@ var _ = test.DescribeTestsFor(test.TestCase{ Describe("POST", func() { Context("With 2 platforms", func() { - var platform, platform2 *types.Platform BeforeEach(func() { platformJSON := common.GenerateRandomPlatform() platformJSON["name"] = "k" - platform = common.RegisterPlatformInSM(platformJSON, ctx.SMWithOAuth, nil) + common.RegisterPlatformInSM(platformJSON, ctx.SMWithOAuth, nil) platformJSON2 := common.GenerateRandomPlatform() platformJSON2["name"] = "a" - platform2 = common.RegisterPlatformInSM(platformJSON2, ctx.SMWithOAuth, nil) + common.RegisterPlatformInSM(platformJSON2, ctx.SMWithOAuth, nil) }) It("should return them ordered by name", func() { result, err := ctx.SMRepository.List(context.Background(), types.PlatformType, query.OrderResultBy("name", query.AscOrder)) Expect(err).ShouldNot(HaveOccurred()) - Expect(result.Len()).To(Equal(2)) - Expect((result.ItemAt(0).(*types.Platform)).Name).To(Equal(platform2.Name)) - Expect((result.ItemAt(1).(*types.Platform)).Name).To(Equal(platform.Name)) + Expect(result.Len()).To(BeNumerically(">=", 2)) + names := make([]string, 0, result.Len()) + for i := 0; i < result.Len(); i++ { + names = append(names, result.ItemAt(i).(*types.Platform).Name) + } + Expect(sort.StringsAreSorted(names)).To(BeTrue()) }) It("should limit result to only 1", func() { result, err := ctx.SMRepository.List(context.Background(), types.PlatformType, query.LimitResultBy(1)) Expect(err).ShouldNot(HaveOccurred()) Expect(result.Len()).To(Equal(1)) - Expect((result.ItemAt(0).(*types.Platform)).Name).To(Equal(platform.Name)) }) }) diff --git a/test/sm_test/sm_test.go b/test/sm_test/sm_test.go index 608fb7756..8c978bde1 100644 --- a/test/sm_test/sm_test.go +++ b/test/sm_test/sm_test.go @@ -18,9 +18,12 @@ package sm_test import ( "context" + "fmt" "net/http/httptest" "testing" + "github.com/Peripli/service-manager/pkg/types" + "github.com/Peripli/service-manager/pkg/env/envfakes" "github.com/Peripli/service-manager/config" @@ -169,6 +172,64 @@ var _ = Describe("SM", func() { }) }) + + Context("Service Manager platform", func() { + var ctx *common.TestContext + var smPlatformID string + verifySMPlatformExists := func() string { + resp := ctx.SMWithOAuth.GET(web.PlatformsURL).WithQueryString(fmt.Sprintf("fieldQuery=name eq '%s'", types.SMPlatform)). + Expect().Status(http.StatusOK).JSON() + resp.Path("$.num_items").Number().Equal(1) + resp.Path("$.items[*]").Array().First().Object().ContainsMap(map[string]interface{}{ + "id": types.SMPlatform, + "type": types.SMPlatform, + "name": types.SMPlatform, + }) + return resp.Path("$.items[*]").Array().First().Object().Value("id").String().Raw() + } + + BeforeEach(func() { + ctx = common.NewTestContextBuilder().Build() + smPlatformID = verifySMPlatformExists() + }) + + AfterEach(func() { + verifySMPlatformExists() + }) + + auths := []func() *common.SMExpect{ + func() *common.SMExpect { + return ctx.SM + }, + func() *common.SMExpect { + return ctx.SMWithOAuth + }, + func() *common.SMExpect { + return ctx.SMWithOAuthForTenant + }, + func() *common.SMExpect { + return ctx.SMWithBasic + }, + } + + for _, auth := range auths { + auth := auth + It("disallows bulk deleting of service manager platform", func() { + auth().DELETE(web.PlatformsURL).WithQueryString(fmt.Sprintf("fieldQuery=name eq '%s'", types.SMPlatform)). + Expect().Status(http.StatusNotFound) + }) + + It("disallows single deleting of service manager platform", func() { + auth().DELETE(web.PlatformsURL + "/" + smPlatformID). + Expect().Status(http.StatusNotFound) + }) + + It("disallows patching the service manager platform", func() { + auth().PATCH(web.PlatformsURL + "/" + smPlatformID).WithJSON(common.Object{}). + Expect().Status(http.StatusNotFound) + }) + } + }) }) }) From 4eaca562d8929779d8a5fcd3ccc003f902fe8d8b Mon Sep 17 00:00:00 2001 From: Nikolay Mateev Date: Fri, 17 Jan 2020 09:51:42 +0200 Subject: [PATCH 02/15] Introduce Service Instance C/U/D APIs (#392) --- api/api.go | 5 +- api/filters/service_instance_filter.go | 82 +++++ api/service_instance_controller.go | 63 ---- pkg/types/service_instance.go | 2 +- test/auth_test/auth_test.go | 15 + test/common/common.go | 9 + test/delete_list.go | 24 +- test/list.go | 18 +- test/platform_test/platform_test.go | 32 +- .../service_instance_test.go | 333 +++++++++++++++++- test/test.go | 18 +- test/visibility_test/visibility_test.go | 4 +- 12 files changed, 481 insertions(+), 124 deletions(-) create mode 100644 api/filters/service_instance_filter.go delete mode 100644 api/service_instance_controller.go diff --git a/api/api.go b/api/api.go index 9610fe63c..9e048e64e 100644 --- a/api/api.go +++ b/api/api.go @@ -101,10 +101,12 @@ func New(ctx context.Context, e env.Environment, options *Options) (*web.API, er NewController(options, web.VisibilitiesURL, types.VisibilityType, func() types.Object { return &types.Visibility{} }), + NewAsyncController(ctx, options, web.ServiceInstancesURL, types.ServiceInstanceType, func() types.Object { + return &types.ServiceInstance{} + }), apiNotifications.NewController(ctx, options.Repository, options.WSSettings, options.Notificator), NewServiceOfferingController(options), NewServicePlanController(options), - NewServiceInstanceController(options), &info.Controller{ TokenIssuer: options.APISettings.TokenIssuerURL, TokenBasicAuth: options.APISettings.TokenBasicAuth, @@ -129,6 +131,7 @@ func New(ctx context.Context, e env.Environment, options *Options) (*web.API, er &filters.SelectionCriteria{}, filters.NewProtectedLabelsFilter(options.APISettings.ProtectedLabels), &filters.ProtectedSMPlatformFilter{}, + &filters.ServiceInstanceValidationFilter{}, &filters.PlatformAwareVisibilityFilter{}, &filters.PatchOnlyLabelsFilter{}, filters.NewPlansFilterByVisibility(options.Repository), diff --git a/api/filters/service_instance_filter.go b/api/filters/service_instance_filter.go new file mode 100644 index 000000000..110f53b22 --- /dev/null +++ b/api/filters/service_instance_filter.go @@ -0,0 +1,82 @@ +/* + * Copyright 2018 The Service Manager Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package filters + +import ( + "fmt" + "github.com/Peripli/service-manager/pkg/types" + "github.com/Peripli/service-manager/pkg/util" + "github.com/Peripli/service-manager/pkg/web" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + "net/http" +) + +const platformIDProperty = "platform_id" + +const ServiceInstanceValidationFilterName = "ServiceInstanceValidationFilter" + +// ServiceInstanceValidationFilter checks patch request for service offerings and plans include only label changes +type ServiceInstanceValidationFilter struct { +} + +func (*ServiceInstanceValidationFilter) Name() string { + return ServiceInstanceValidationFilterName +} + +func (*ServiceInstanceValidationFilter) Run(req *web.Request, next web.Handler) (*web.Response, error) { + platformID := gjson.GetBytes(req.Body, platformIDProperty).Str + + if platformID != "" && platformID != types.SMPlatform { + return nil, &util.HTTPError{ + ErrorType: "BadRequest", + Description: fmt.Sprintf("Providing %s property during provisioning/updating of a service instance is forbidden", platformIDProperty), + StatusCode: http.StatusBadRequest, + } + } + + var err error + if req.Method == http.MethodPost && platformID == "" { + req.Body, err = sjson.SetBytes(req.Body, platformIDProperty, types.SMPlatform) + if err != nil { + return nil, err + } + } + + req.Body, err = sjson.DeleteBytes(req.Body, "ready") + if err != nil { + return nil, err + } + + req.Body, err = sjson.DeleteBytes(req.Body, "usable") + if err != nil { + return nil, err + } + + return next.Handle(req) +} + +func (*ServiceInstanceValidationFilter) FilterMatchers() []web.FilterMatcher { + return []web.FilterMatcher{ + { + Matchers: []web.Matcher{ + web.Path(web.ServiceInstancesURL + "/**"), + web.Methods(http.MethodPost, http.MethodPatch), + }, + }, + } +} diff --git a/api/service_instance_controller.go b/api/service_instance_controller.go deleted file mode 100644 index 3b5e9e5a3..000000000 --- a/api/service_instance_controller.go +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright 2018 The Service Manager Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package api - -import ( - "fmt" - "net/http" - - "github.com/Peripli/service-manager/pkg/types" - "github.com/Peripli/service-manager/pkg/web" -) - -// ServiceInstanceController implements api.Controller by providing service instances API logic -type ServiceInstanceController struct { - *BaseController -} - -func NewServiceInstanceController(options *Options) *ServiceInstanceController { - return &ServiceInstanceController{ - BaseController: NewController(options, web.ServiceInstancesURL, types.ServiceInstanceType, func() types.Object { - return &types.ServiceInstance{} - }), - } -} -func (c *ServiceInstanceController) Routes() []web.Route { - return []web.Route{ - { - Endpoint: web.Endpoint{ - Method: http.MethodGet, - Path: fmt.Sprintf("%s/{%s}", web.ServiceInstancesURL, PathParamResourceID), - }, - Handler: c.GetSingleObject, - }, - { - Endpoint: web.Endpoint{ - Method: http.MethodGet, - Path: fmt.Sprintf("%s/{%s}%s/{%s}", c.resourceBaseURL, PathParamResourceID, web.OperationsURL, PathParamID), - }, - Handler: c.GetOperation, - }, - { - Endpoint: web.Endpoint{ - Method: http.MethodGet, - Path: web.ServiceInstancesURL, - }, - Handler: c.ListObjects, - }, - } -} diff --git a/pkg/types/service_instance.go b/pkg/types/service_instance.go index b509036f4..c6fb73854 100644 --- a/pkg/types/service_instance.go +++ b/pkg/types/service_instance.go @@ -33,7 +33,7 @@ type ServiceInstance struct { Name string `json:"name"` ServicePlanID string `json:"service_plan_id"` PlatformID string `json:"platform_id"` - DashboardURL string `json:"dashboard_url,omitempty"` + DashboardURL string `json:"-"` MaintenanceInfo json.RawMessage `json:"maintenance_info,omitempty"` Context json.RawMessage `json:"-"` PreviousValues json.RawMessage `json:"-"` diff --git a/test/auth_test/auth_test.go b/test/auth_test/auth_test.go index 26ae0bd7e..11a01288f 100644 --- a/test/auth_test/auth_test.go +++ b/test/auth_test/auth_test.go @@ -263,6 +263,21 @@ var _ = Describe("Service Manager Authentication", func() { {"Invalid authorization schema", "GET", web.ServiceInstancesURL, invalidBasicAuthHeader}, {"Missing token in authorization header", "GET", web.ServiceInstancesURL, emptyBearerAuthHeader}, {"Invalid token in authorization header", "GET", web.ServiceInstancesURL, invalidBearerAuthHeader}, + + {"Missing authorization header", "POST", web.ServiceInstancesURL, emptyAuthHeader}, + {"Invalid authorization schema", "POST", web.ServiceInstancesURL, invalidBasicAuthHeader}, + {"Missing token in authorization header", "POST", web.ServiceInstancesURL, emptyBearerAuthHeader}, + {"Invalid token in authorization header", "POST", web.ServiceInstancesURL, invalidBearerAuthHeader}, + + {"Missing authorization header", "PATCH", web.ServiceInstancesURL + "/999", emptyAuthHeader}, + {"Invalid authorization schema", "PATCH", web.ServiceInstancesURL + "/999", invalidBasicAuthHeader}, + {"Missing token in authorization header", "PATCH", web.ServiceInstancesURL + "/999", emptyBearerAuthHeader}, + {"Invalid token in authorization header", "PATCH", web.ServiceInstancesURL + "/999", invalidBearerAuthHeader}, + + {"Missing authorization header", "DELETE", web.ServiceInstancesURL + "/999", emptyAuthHeader}, + {"Invalid authorization schema", "DELETE", web.ServiceInstancesURL + "/999", invalidBasicAuthHeader}, + {"Missing token in authorization header", "DELETE", web.ServiceInstancesURL + "/999", emptyBearerAuthHeader}, + {"Invalid token in authorization header", "DELETE", web.ServiceInstancesURL + "/999", invalidBearerAuthHeader}, } for _, request := range authRequests { diff --git a/test/common/common.go b/test/common/common.go index 35f7c6273..79588d01a 100644 --- a/test/common/common.go +++ b/test/common/common.go @@ -84,6 +84,10 @@ func RemoveNumericArgs(obj Object) Object { return removeOnCondition(isNumeric, obj) } +func RemoveBooleanArgs(obj Object) Object { + return removeOnCondition(isBoolean, obj) +} + func RemoveNonJSONArgs(obj Object) Object { return removeOnCondition(isNotJSON, obj) } @@ -145,6 +149,11 @@ func isNotNumeric(arg interface{}) bool { return !isNumeric(arg) } +func isBoolean(arg interface{}) bool { + _, ok := arg.(bool) + return ok +} + func RemoveNotNullableFieldAndLabels(obj Object, objithMandatoryFields Object) Object { o := CopyObject(obj) for objField, objVal := range objithMandatoryFields { diff --git a/test/delete_list.go b/test/delete_list.go index c8f816010..cc2edf238 100644 --- a/test/delete_list.go +++ b/test/delete_list.go @@ -66,7 +66,7 @@ func DescribeDeleteListFor(ctx *common.TestContext, t TestCase) bool { }, queryTemplate: "%s ne '%v'", queryArgs: func() common.Object { - return r[0] + return common.RemoveBooleanArgs(r[0]) }, resourcesToExpectAfterOp: func() []common.Object { return []common.Object{r[0]} @@ -113,7 +113,7 @@ func DescribeDeleteListFor(ctx *common.TestContext, t TestCase) bool { }, queryTemplate: "%[1]s notin ('%[2]v','%[2]v','%[2]v')", queryArgs: func() common.Object { - return r[0] + return common.RemoveBooleanArgs(r[0]) }, resourcesToExpectAfterOp: func() []common.Object { return []common.Object{r[0]} @@ -128,7 +128,7 @@ func DescribeDeleteListFor(ctx *common.TestContext, t TestCase) bool { }, queryTemplate: "%s notin ('%v')", queryArgs: func() common.Object { - return r[0] + return common.RemoveBooleanArgs(r[0]) }, resourcesToExpectAfterOp: func() []common.Object { return []common.Object{r[0]} @@ -404,8 +404,7 @@ func DescribeDeleteListFor(ctx *common.TestContext, t TestCase) bool { for i := 0; i < 2; i++ { gen := t.ResourceBlueprint(ctx, ctx.SMWithOAuth, false) gen = attachLabel(gen, i) - delete(gen, "created_at") - delete(gen, "updated_at") + stripObject(gen, t.ResourcePropertiesToIgnore...) r = append(r, gen) } By(fmt.Sprintf("[BEFOREEACH]: Successfully finished preparing and creating test resources")) @@ -444,13 +443,11 @@ func DescribeDeleteListFor(ctx *common.TestContext, t TestCase) bool { for _, v := range beforeOpArray.Iter() { obj := v.Object().Raw() - delete(obj, "created_at") - delete(obj, "updated_at") + stripObject(obj, t.ResourcePropertiesToIgnore...) } for _, entity := range deleteListOpEntry.resourcesToExpectBeforeOp() { - delete(entity, "created_at") - delete(entity, "updated_at") + stripObject(entity, t.ResourcePropertiesToIgnore...) beforeOpArray.Contains(entity) } } @@ -473,15 +470,13 @@ func DescribeDeleteListFor(ctx *common.TestContext, t TestCase) bool { for _, v := range afterOpArray.Iter() { obj := v.Object().Raw() - delete(obj, "created_at") - delete(obj, "updated_at") + stripObject(obj, t.ResourcePropertiesToIgnore...) } if deleteListOpEntry.resourcesToExpectAfterOp != nil { By(fmt.Sprintf("[TEST]: Verifying expected %s are returned after operation", t.API)) for _, entity := range deleteListOpEntry.resourcesToExpectAfterOp() { - delete(entity, "created_at") - delete(entity, "updated_at") + stripObject(entity, t.ResourcePropertiesToIgnore...) afterOpArray.Contains(entity) } } @@ -489,8 +484,7 @@ func DescribeDeleteListFor(ctx *common.TestContext, t TestCase) bool { if deleteListOpEntry.resourcesNotToExpectAfterOp != nil { By(fmt.Sprintf("[TEST]: Verifying unexpected %s are NOT returned after operation", t.API)) for _, entity := range deleteListOpEntry.resourcesNotToExpectAfterOp() { - delete(entity, "created_at") - delete(entity, "updated_at") + stripObject(entity, t.ResourcePropertiesToIgnore...) afterOpArray.NotContains(entity) } } diff --git a/test/list.go b/test/list.go index 2acf54b14..81ba69108 100644 --- a/test/list.go +++ b/test/list.go @@ -85,8 +85,7 @@ func DescribeListTestsFor(ctx *common.TestContext, t TestCase, responseMode Resp gen := t.ResourceBlueprint(ctx, ctx.SMWithOAuth, bool(responseMode)) gen = attachLabel(gen) - delete(gen, "created_at") - delete(gen, "updated_at") + stripObject(gen, t.ResourcePropertiesToIgnore...) r = append(r, gen) } @@ -318,13 +317,11 @@ func DescribeListTestsFor(ctx *common.TestContext, t TestCase, responseMode Resp for _, v := range beforeOpArray.Iter() { obj := v.Object().Raw() - delete(obj, "created_at") - delete(obj, "updated_at") + stripObject(obj, t.ResourcePropertiesToIgnore...) } for _, entity := range listOpEntry.resourcesToExpectBeforeOp { - delete(entity, "created_at") - delete(entity, "updated_at") + stripObject(entity, t.ResourcePropertiesToIgnore...) beforeOpArray.Contains(entity) } @@ -351,15 +348,13 @@ func DescribeListTestsFor(ctx *common.TestContext, t TestCase, responseMode Resp array := ctx.SMWithOAuth.ListWithQuery(t.API, query) for _, v := range array.Iter() { obj := v.Object().Raw() - delete(obj, "created_at") - delete(obj, "updated_at") + stripObject(obj, t.ResourcePropertiesToIgnore...) } if listOpEntry.resourcesToExpectAfterOp != nil { By(fmt.Sprintf("[TEST]: Verifying expected %s are returned after list operation", t.API)) for _, entity := range listOpEntry.resourcesToExpectAfterOp { - delete(entity, "created_at") - delete(entity, "updated_at") + stripObject(entity, t.ResourcePropertiesToIgnore...) array.Contains(entity) } } @@ -368,8 +363,7 @@ func DescribeListTestsFor(ctx *common.TestContext, t TestCase, responseMode Resp By(fmt.Sprintf("[TEST]: Verifying unexpected %s are NOT returned after list operation", t.API)) for _, entity := range listOpEntry.resourcesNotToExpectAfterOp { - delete(entity, "created_at") - delete(entity, "updated_at") + stripObject(entity, t.ResourcePropertiesToIgnore...) array.NotContains(entity) } } diff --git a/test/platform_test/platform_test.go b/test/platform_test/platform_test.go index 0f5d812d2..e881951ac 100644 --- a/test/platform_test/platform_test.go +++ b/test/platform_test/platform_test.go @@ -98,7 +98,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) }) - Context("With invalid content type", func() { + Context("when content type is not JSON", func() { It("returns 415", func() { ctx.SMWithOAuth.POST(web.PlatformsURL). WithText("text"). @@ -106,7 +106,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) }) - Context("With invalid content JSON", func() { + Context("when request body is not a valid JSON", func() { It("returns 400 if input is not valid JSON", func() { ctx.SMWithOAuth.POST(web.PlatformsURL). WithText("invalid json"). @@ -178,20 +178,6 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) }) - Context("With async query param", func() { - It("fails", func() { - platform := common.MakePlatform("", "cf-10", "cf", "descr") - delete(platform, "id") - - reply := ctx.SMWithOAuth.POST(web.PlatformsURL). - WithQuery("async", "true"). - WithJSON(platform). - Expect().Status(http.StatusBadRequest).JSON().Object() - - reply.Value("description").String().Contains("api doesn't support asynchronous operations") - }) - }) - Context("Without id", func() { It("returns the new platform with generated id and credentials", func() { platform := common.MakePlatform("", "cf-10", "cf", "descr") @@ -218,6 +204,20 @@ var _ = test.DescribeTestsFor(test.TestCase{ common.MapContains(reply.Raw(), platform) }) }) + + Context("With async query param", func() { + It("fails", func() { + platform := common.MakePlatform("", "cf-10", "cf", "descr") + delete(platform, "id") + + reply := ctx.SMWithOAuth.POST(web.PlatformsURL). + WithQuery("async", "true"). + WithJSON(platform). + Expect().Status(http.StatusBadRequest).JSON().Object() + + reply.Value("description").String().Contains("api doesn't support asynchronous operations") + }) + }) }) Describe("PATCH", func() { diff --git a/test/service_instance_test/service_instance_test.go b/test/service_instance_test/service_instance_test.go index e9819ff9b..8632a5c43 100644 --- a/test/service_instance_test/service_instance_test.go +++ b/test/service_instance_test/service_instance_test.go @@ -20,6 +20,8 @@ import ( "context" "fmt" "github.com/Peripli/service-manager/test/testutil/service_instance" + "github.com/gofrs/uuid" + "strconv" "net/http" "testing" @@ -50,7 +52,7 @@ const ( var _ = test.DescribeTestsFor(test.TestCase{ API: web.ServiceInstancesURL, SupportedOps: []test.Op{ - test.Get, test.List, + test.Get, test.List, test.Delete, test.DeleteList, test.Patch, }, MultitenancySettings: &test.MultitenancySettings{ ClientID: "tenancyClient", @@ -67,6 +69,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ DisableTenantResources: true, ResourceBlueprint: blueprint, ResourceWithoutNullableFieldsBlueprint: blueprint, + ResourcePropertiesToIgnore: []string{"platform_id"}, PatchResource: func(ctx *common.TestContext, apiPath string, objID string, resourceType types.ObjectType, patchLabels []*query.LabelChange, _ bool) { byID := query.ByField(query.EqualsOperator, "id", objID) si, err := ctx.SMRepository.Get(context.Background(), resourceType, byID) @@ -81,13 +84,54 @@ var _ = test.DescribeTestsFor(test.TestCase{ }, AdditionalTests: func(ctx *common.TestContext) { Context("additional non-generic tests", func() { + var ( + postInstanceRequest common.Object + expectedInstanceResponse common.Object + + instanceID string + ) + + createInstance := func() { + ctx.SMWithOAuth.POST(web.ServiceInstancesURL).WithJSON(postInstanceRequest). + Expect(). + Status(http.StatusCreated). + JSON().Object(). + ContainsMap(expectedInstanceResponse).ContainsKey("id"). + ValueEqual("platform_id", types.SMPlatform) + } + + BeforeEach(func() { + id, err := uuid.NewV4() + if err != nil { + panic(err) + } + + instanceID = id.String() + name := "test-instance" + servicePlanID := generateServicePlan(ctx, ctx.SMWithOAuth) + + postInstanceRequest = common.Object{ + "id": instanceID, + "name": name, + "service_plan_id": servicePlanID, + "maintenance_info": "{}", + } + expectedInstanceResponse = common.Object{ + "id": instanceID, + "name": name, + "service_plan_id": servicePlanID, + "maintenance_info": "{}", + } + + }) + + AfterEach(func() { + ctx.CleanupAdditionalResources() + }) + Describe("GET", func() { var serviceInstance *types.ServiceInstance - AfterEach(func() { - ctx.CleanupAdditionalResources() - }) - When("service instance contains tenant identifier in OSB context", func() { BeforeEach(func() { _, serviceInstance = service_instance.Prepare(ctx, ctx.TestPlatform.ID, "", fmt.Sprintf(`{"%s":"%s"}`, TenantIdentifier, TenantValue)) @@ -123,16 +167,285 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) }) }) + + Describe("POST", func() { + Context("when content type is not JSON", func() { + It("returns 415", func() { + ctx.SMWithOAuth.POST(web.ServiceInstancesURL).WithText("text"). + Expect(). + Status(http.StatusUnsupportedMediaType). + JSON().Object(). + Keys().Contains("error", "description") + }) + }) + + Context("when request body is not a valid JSON", func() { + It("returns 400", func() { + ctx.SMWithOAuth.POST(web.ServiceInstancesURL). + WithText("invalid json"). + WithHeader("content-type", "application/json"). + Expect(). + Status(http.StatusBadRequest). + JSON().Object(). + Keys().Contains("error", "description") + }) + }) + + Context("when a request body field is missing", func() { + assertPOSTReturns400WhenFieldIsMissing := func(field string) { + BeforeEach(func() { + delete(postInstanceRequest, field) + delete(expectedInstanceResponse, field) + }) + + It("returns 400", func() { + ctx.SMWithOAuth.POST(web.ServiceInstancesURL).WithJSON(postInstanceRequest). + Expect(). + Status(http.StatusBadRequest). + JSON().Object(). + Keys().Contains("error", "description") + }) + } + + assertPOSTReturns201WhenFieldIsMissing := func(field string) { + BeforeEach(func() { + delete(postInstanceRequest, field) + delete(expectedInstanceResponse, field) + }) + + It("returns 201", func() { + createInstance() + }) + } + + Context("when id field is missing", func() { + assertPOSTReturns201WhenFieldIsMissing("id") + }) + + Context("when name field is missing", func() { + assertPOSTReturns400WhenFieldIsMissing("name") + }) + + Context("when service_plan_id field is missing", func() { + assertPOSTReturns400WhenFieldIsMissing("service_plan_id") + }) + + Context("when maintenance_info field is missing", func() { + assertPOSTReturns201WhenFieldIsMissing("maintenance_info") + }) + }) + + Context("when request body id field is invalid", func() { + It("should return 400", func() { + postInstanceRequest["id"] = "instance/1" + resp := ctx.SMWithOAuth.POST(web.ServiceInstancesURL). + WithJSON(postInstanceRequest). + Expect().Status(http.StatusBadRequest).JSON().Object() + + resp.Value("description").Equal("instance/1 contains invalid character(s)") + }) + }) + + Context("when request body platform_id field is provided", func() { + Context("which is not service-manager platform", func() { + It("should return 400", func() { + postInstanceRequest["platform_id"] = "test-platform-id" + resp := ctx.SMWithOAuth.POST(web.ServiceInstancesURL). + WithJSON(postInstanceRequest). + Expect().Status(http.StatusBadRequest).JSON().Object() + + resp.Value("description").Equal("Providing platform_id property during provisioning/updating of a service instance is forbidden") + }) + }) + + Context("which is service-manager platform", func() { + It("should return 200", func() { + postInstanceRequest["platform_id"] = types.SMPlatform + createInstance() + }) + }) + }) + + Context("With async query param", func() { + It("succeeds", func() { + resp := ctx.SMWithOAuth.POST(web.ServiceInstancesURL).WithJSON(postInstanceRequest). + WithQuery("async", "true"). + Expect(). + Status(http.StatusAccepted) + + test.ExpectOperation(ctx.SMWithOAuth, resp, types.SUCCEEDED) + + ctx.SMWithOAuth.GET(web.ServiceInstancesURL + "/" + instanceID).Expect(). + Status(http.StatusOK). + JSON().Object(). + ContainsMap(expectedInstanceResponse).ContainsKey("id") + }) + }) + }) + + Describe("PATCH", func() { + Context("when content type is not JSON", func() { + It("returns 415", func() { + instanceID := fmt.Sprintf("%s", postInstanceRequest["id"]) + ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL+"/"+instanceID). + WithText("text"). + Expect().Status(http.StatusUnsupportedMediaType). + JSON().Object(). + Keys().Contains("error", "description") + }) + }) + + Context("when instance is missing", func() { + It("returns 404", func() { + ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL+"/no_such_id"). + WithJSON(postInstanceRequest). + Expect().Status(http.StatusNotFound). + JSON().Object(). + Keys().Contains("error", "description") + }) + }) + + Context("when request body is not valid JSON", func() { + It("returns 400", func() { + ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL+"/"+instanceID). + WithText("invalid json"). + WithHeader("content-type", "application/json"). + Expect(). + Status(http.StatusBadRequest). + JSON().Object(). + Keys().Contains("error", "description") + }) + }) + + Context("when created_at provided in body", func() { + It("should not change created at", func() { + createInstance() + + createdAt := "2015-01-01T00:00:00Z" + + ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL+"/"+instanceID). + WithJSON(common.Object{"created_at": createdAt}). + Expect(). + Status(http.StatusOK).JSON().Object(). + ContainsKey("created_at"). + ValueNotEqual("created_at", createdAt) + + ctx.SMWithOAuth.GET(web.ServiceInstancesURL+"/"+instanceID). + Expect(). + Status(http.StatusOK).JSON().Object(). + ContainsKey("created_at"). + ValueNotEqual("created_at", createdAt) + }) + }) + + Context("when platform_id provided in body", func() { + Context("which is not service-manager platform", func() { + It("should return 400", func() { + createInstance() + + resp := ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL + "/" + instanceID). + WithJSON(common.Object{"platform_id": "test-platform-id"}). + Expect().Status(http.StatusBadRequest).JSON().Object() + + resp.Value("description").Equal("Providing platform_id property during provisioning/updating of a service instance is forbidden") + + ctx.SMWithOAuth.GET(web.ServiceInstancesURL+"/"+instanceID). + Expect(). + Status(http.StatusOK).JSON().Object(). + ContainsKey("platform_id"). + ValueEqual("platform_id", types.SMPlatform) + }) + }) + + Context("which is service-manager platform", func() { + It("should return 200", func() { + createInstance() + + ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL + "/" + instanceID). + WithJSON(common.Object{"platform_id": types.SMPlatform}). + Expect().Status(http.StatusOK).JSON().Object() + + ctx.SMWithOAuth.GET(web.ServiceInstancesURL+"/"+instanceID). + Expect(). + Status(http.StatusOK).JSON().Object(). + ContainsKey("platform_id"). + ValueEqual("platform_id", types.SMPlatform) + + }) + }) + }) + + Context("when fields are updated one by one", func() { + It("returns 200", func() { + createInstance() + + for _, prop := range []string{"name", "maintenance_info"} { + updatedBrokerJSON := common.Object{} + updatedBrokerJSON[prop] = "updated-" + prop + ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL + "/" + instanceID). + WithJSON(updatedBrokerJSON). + Expect(). + Status(http.StatusOK). + JSON().Object(). + ContainsMap(updatedBrokerJSON) + + ctx.SMWithOAuth.GET(web.ServiceInstancesURL + "/" + instanceID). + Expect(). + Status(http.StatusOK). + JSON().Object(). + ContainsMap(updatedBrokerJSON) + + } + }) + }) + + }) }) }, }) -func blueprint(ctx *common.TestContext, auth *common.SMExpect, _ bool) common.Object { - _, serviceInstance := service_instance.Prepare(ctx, ctx.TestPlatform.ID, "", fmt.Sprintf(`{"%s":"%s"}`, TenantIdentifier, TenantValue)) - _, err := ctx.SMRepository.Create(context.Background(), serviceInstance) +func blueprint(ctx *common.TestContext, auth *common.SMExpect, async bool) common.Object { + instanceID, err := uuid.NewV4() if err != nil { - Fail(fmt.Sprintf("could not create service instance: %s", err)) + panic(err) + } + + instanceReqBody := make(common.Object, 0) + instanceReqBody["id"] = instanceID.String() + instanceReqBody["name"] = "test-instance-" + instanceID.String() + + instanceReqBody["service_plan_id"] = generateServicePlan(ctx, auth) + + resp := auth.POST(web.ServiceInstancesURL).WithQuery("async", strconv.FormatBool(async)).WithJSON(instanceReqBody).Expect() + + var instance map[string]interface{} + if async { + resp = resp.Status(http.StatusAccepted) + if err := test.ExpectOperation(auth, resp, types.SUCCEEDED); err != nil { + panic(err) + } + + instance = auth.GET(web.ServiceInstancesURL + "/" + instanceID.String()). + Expect().JSON().Object().Raw() + + } else { + instance = resp.Status(http.StatusCreated).JSON().Object().Raw() } - return auth.ListWithQuery(web.ServiceInstancesURL, fmt.Sprintf("fieldQuery=id eq '%s'", serviceInstance.ID)).First().Object().Raw() + return instance +} + +func generateServicePlan(ctx *common.TestContext, auth *common.SMExpect) string { + cPaidPlan := common.GeneratePaidTestPlan() + cService := common.GenerateTestServiceWithPlans(cPaidPlan) + catalog := common.NewEmptySBCatalog() + catalog.AddService(cService) + brokerID, _, _ := ctx.RegisterBrokerWithCatalog(catalog) + + so := auth.ListWithQuery(web.ServiceOfferingsURL, fmt.Sprintf("fieldQuery=broker_id eq '%s'", brokerID)).First() + + servicePlanID := auth.ListWithQuery(web.ServicePlansURL, "fieldQuery="+fmt.Sprintf("service_offering_id eq '%s'", so.Object().Value("id").String().Raw())). + First().Object().Value("id").String().Raw() + + return servicePlanID } diff --git a/test/test.go b/test/test.go index 4110d8bbe..f22ea283c 100644 --- a/test/test.go +++ b/test/test.go @@ -69,10 +69,11 @@ type MultitenancySettings struct { } type TestCase struct { - API string - SupportsAsyncOperations bool - SupportedOps []Op - ResourceType types.ObjectType + API string + SupportsAsyncOperations bool + SupportedOps []Op + ResourceType types.ObjectType + ResourcePropertiesToIgnore []string MultitenancySettings *MultitenancySettings DisableTenantResources bool @@ -84,6 +85,15 @@ type TestCase struct { AdditionalTests func(ctx *common.TestContext) } +func stripObject(obj common.Object, properties ...string) { + delete(obj, "created_at") + delete(obj, "updated_at") + + for _, prop := range properties { + delete(obj, prop) + } +} + func DefaultResourcePatch(ctx *common.TestContext, apiPath string, objID string, _ types.ObjectType, patchLabels []*query.LabelChange, async bool) { patchLabelsBody := make(map[string]interface{}) patchLabelsBody["labels"] = patchLabels diff --git a/test/visibility_test/visibility_test.go b/test/visibility_test/visibility_test.go index 842782f75..fe487970f 100644 --- a/test/visibility_test/visibility_test.go +++ b/test/visibility_test/visibility_test.go @@ -744,9 +744,9 @@ func blueprint(setNullFieldsValues bool) func(ctx *common.TestContext, auth *com cService := common.GenerateTestServiceWithPlans(cPaidPlan) catalog := common.NewEmptySBCatalog() catalog.AddService(cService) - id, _, _ := ctx.RegisterBrokerWithCatalog(catalog) + brokerID, _, _ := ctx.RegisterBrokerWithCatalog(catalog) - so := auth.ListWithQuery(web.ServiceOfferingsURL, fmt.Sprintf("fieldQuery=broker_id eq '%s'", id)).First() + so := auth.ListWithQuery(web.ServiceOfferingsURL, fmt.Sprintf("fieldQuery=broker_id eq '%s'", brokerID)).First() servicePlanID := auth.ListWithQuery(web.ServicePlansURL, "fieldQuery="+fmt.Sprintf("service_offering_id eq '%s'", so.Object().Value("id").String().Raw())). First().Object().Value("id").String().Raw() From e83913819f62354446469dee6b272fc38dad854f Mon Sep 17 00:00:00 2001 From: Georgi Farashev Date: Fri, 17 Jan 2020 13:19:10 +0200 Subject: [PATCH 03/15] Introduce service binding GET apis (#391) --- api/api.go | 3 + api/base_controller.go | 15 ++- api/extensions/security/authn.go | 2 + api/filters/tenant_filter.go | 6 + api/service_binding_controller.go | 65 ++++++++++ .../authenticators/basic_authentication.go | 15 +-- pkg/sm/sm.go | 2 +- pkg/types/interfaces.go | 12 +- pkg/types/platform.go | 26 +++- pkg/types/service_binding.go | 103 +++++++++++++++ pkg/types/service_broker.go | 26 +++- pkg/types/servicebinding_gen.go | 62 +++++++++ pkg/web/routes.go | 3 + storage/encrypting_repository.go | 41 +++--- storage/encrypting_repository_test.go | 30 ++--- ...ecured_generate_credentials_interceptor.go | 23 ++-- .../service_instance_create_interceptor.go | 3 +- storage/postgres/abstract.go | 14 ++ storage/postgres/keystore_test.go | 2 +- .../20200114111100_service_bindings.down.sql | 7 + .../20200114111100_service_bindings.up.sql | 37 ++++++ storage/postgres/service_binding.go | 89 +++++++++++++ storage/postgres/servicebinding_gen.go | 72 +++++++++++ storage/postgres/storage.go | 1 + test/common/common.go | 4 + test/common/test_context.go | 4 + test/list.go | 4 +- .../service_binding_test.go | 120 ++++++++++++++++++ test/test.go | 3 +- .../service_binding/service_binding.go | 36 ++++++ 30 files changed, 753 insertions(+), 77 deletions(-) create mode 100644 api/service_binding_controller.go create mode 100644 pkg/types/service_binding.go create mode 100644 pkg/types/servicebinding_gen.go create mode 100644 storage/postgres/migrations/20200114111100_service_bindings.down.sql create mode 100644 storage/postgres/migrations/20200114111100_service_bindings.up.sql create mode 100644 storage/postgres/service_binding.go create mode 100644 storage/postgres/servicebinding_gen.go create mode 100644 test/service_binding_test/service_binding_test.go create mode 100644 test/testutil/service_binding/service_binding.go diff --git a/api/api.go b/api/api.go index 9e048e64e..6d319559b 100644 --- a/api/api.go +++ b/api/api.go @@ -105,8 +105,11 @@ func New(ctx context.Context, e env.Environment, options *Options) (*web.API, er return &types.ServiceInstance{} }), apiNotifications.NewController(ctx, options.Repository, options.WSSettings, options.Notificator), + NewServiceOfferingController(options), NewServicePlanController(options), + NewServiceBindingController(ctx, options), + &info.Controller{ TokenIssuer: options.APISettings.TokenIssuerURL, TokenBasicAuth: options.APISettings.TokenBasicAuth, diff --git a/api/base_controller.go b/api/base_controller.go index 125f82664..2a1dfa0db 100644 --- a/api/base_controller.go +++ b/api/base_controller.go @@ -20,11 +20,12 @@ import ( "context" "encoding/base64" "fmt" - "github.com/Peripli/service-manager/operations" "net/http" "strconv" "time" + "github.com/Peripli/service-manager/operations" + "github.com/tidwall/sjson" "github.com/gofrs/uuid" @@ -292,7 +293,7 @@ func (c *BaseController) GetSingleObject(r *web.Request) (*web.Response, error) return nil, util.HandleStorageError(err, c.objectType.String()) } - stripCredentials(ctx, object) + cleanObject(ctx, object) return util.NewJSONResponse(http.StatusOK, object) } @@ -454,13 +455,13 @@ func (c *BaseController) PatchObject(r *web.Request) (*web.Response, error) { return nil, util.HandleStorageError(err, c.objectType.String()) } - stripCredentials(ctx, object) + cleanObject(ctx, object) return util.NewJSONResponse(http.StatusOK, object) } -func stripCredentials(ctx context.Context, object types.Object) { - if secured, ok := object.(types.Secured); ok { - secured.SetCredentials(nil) +func cleanObject(ctx context.Context, object types.Object) { + if secured, ok := object.(types.Strip); ok { + secured.Sanitize() } else { log.C(ctx).Debugf("Object of type %s with id %s is not secured, so no credentials are cleaned up on response", object.GetType(), object.GetID()) } @@ -572,7 +573,7 @@ func pageFromObjectList(ctx context.Context, objectList types.ObjectList, count, for i := 0; i < objectList.Len(); i++ { obj := objectList.ItemAt(i) - stripCredentials(ctx, obj) + cleanObject(ctx, obj) page.Items = append(page.Items, obj) } diff --git a/api/extensions/security/authn.go b/api/extensions/security/authn.go index d8e2045c2..0044b35a3 100644 --- a/api/extensions/security/authn.go +++ b/api/extensions/security/authn.go @@ -26,6 +26,7 @@ func Register(ctx context.Context, cfg *config.Settings, smb *sm.ServiceManagerB web.ServicePlansURL+"/*", web.VisibilitiesURL+"/*", web.ServiceInstancesURL+"/*", + web.ServiceBindingsURL+"/*", web.NotificationsURL+"/*"). Method(http.MethodGet). WithAuthentication(basicAuthenticator).Required() @@ -51,6 +52,7 @@ func Register(ctx context.Context, cfg *config.Settings, smb *sm.ServiceManagerB web.VisibilitiesURL+"/**", web.NotificationsURL+"/**", web.ServiceInstancesURL+"/**", + web.ServiceBindingsURL+"/**", web.ConfigURL+"/**"). Method(http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete). WithAuthentication(bearerAuthenticator).Required() diff --git a/api/filters/tenant_filter.go b/api/filters/tenant_filter.go index aa64af0c9..1ec5abd32 100644 --- a/api/filters/tenant_filter.go +++ b/api/filters/tenant_filter.go @@ -161,5 +161,11 @@ func (f *TenantFilter) FilterMatchers() []web.FilterMatcher { web.Methods(f.Methods...), }, }, + { + Matchers: []web.Matcher{ + web.Path(web.ServiceBindingsURL + "/**"), + web.Methods(f.Methods...), + }, + }, } } diff --git a/api/service_binding_controller.go b/api/service_binding_controller.go new file mode 100644 index 000000000..c0d999c7a --- /dev/null +++ b/api/service_binding_controller.go @@ -0,0 +1,65 @@ +/* + * Copyright 2018 The Service Manager Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package api + +import ( + "context" + "fmt" + "net/http" + + "github.com/Peripli/service-manager/pkg/types" + "github.com/Peripli/service-manager/pkg/web" +) + +// ServiceBindingController implements api.Controller by providing service bindings API logic +type ServiceBindingController struct { + *BaseController +} + +func NewServiceBindingController(ctx context.Context, options *Options) *ServiceBindingController { + return &ServiceBindingController{ + BaseController: NewAsyncController(ctx, options, web.ServiceBindingsURL, types.ServiceBindingType, func() types.Object { + return &types.ServiceBinding{} + }), + } +} + +func (c *ServiceBindingController) Routes() []web.Route { + return []web.Route{ + { + Endpoint: web.Endpoint{ + Method: http.MethodGet, + Path: fmt.Sprintf("%s/{%s}", web.ServiceBindingsURL, PathParamResourceID), + }, + Handler: c.GetSingleObject, + }, + { + Endpoint: web.Endpoint{ + Method: http.MethodGet, + Path: fmt.Sprintf("%s/{%s}%s/{%s}", c.resourceBaseURL, PathParamResourceID, web.OperationsURL, PathParamID), + }, + Handler: c.GetOperation, + }, + { + Endpoint: web.Endpoint{ + Method: http.MethodGet, + Path: web.ServiceBindingsURL, + }, + Handler: c.ListObjects, + }, + } +} diff --git a/pkg/security/authenticators/basic_authentication.go b/pkg/security/authenticators/basic_authentication.go index 0b80922f3..74d8ed39d 100644 --- a/pkg/security/authenticators/basic_authentication.go +++ b/pkg/security/authenticators/basic_authentication.go @@ -45,26 +45,21 @@ func (a *Basic) Authenticate(request *http.Request) (*web.UserContext, httpsec.D ctx := request.Context() byUsername := query.ByField(query.EqualsOperator, "username", username) - objectList, err := a.Repository.List(ctx, types.PlatformType, byUsername) + platformList, err := a.Repository.List(ctx, types.PlatformType, byUsername) if err != nil { return nil, httpsec.Abstain, fmt.Errorf("could not get credentials entity from storage: %s", err) } - if objectList.Len() != 1 { + if platformList.Len() != 1 { return nil, httpsec.Deny, fmt.Errorf("provided credentials are invalid") } - obj := objectList.ItemAt(0) - securedObj, isSecured := obj.(types.Secured) - if !isSecured { - return nil, httpsec.Abstain, fmt.Errorf("object of type %s is used in authentication and must be secured", obj.GetType()) - } - - if securedObj.GetCredentials().Basic.Password != password { + platform := platformList.ItemAt(0).(*types.Platform) + if platform.Credentials.Basic.Password != password { return nil, httpsec.Deny, fmt.Errorf("provided credentials are invalid") } - bytes, err := json.Marshal(obj) + bytes, err := json.Marshal(platform) if err != nil { return nil, httpsec.Abstain, err } diff --git a/pkg/sm/sm.go b/pkg/sm/sm.go index 59cca3598..bf198eca3 100644 --- a/pkg/sm/sm.go +++ b/pkg/sm/sm.go @@ -190,7 +190,7 @@ func New(ctx context.Context, cancel context.CancelFunc, e env.Environment, cfg WithDeleteInterceptorProvider(types.ServiceBrokerType, &interceptors.BrokerDeleteCatalogInterceptorProvider{ CatalogLoader: catalog.Load, }).Register(). - WithCreateAroundTxInterceptorProvider(types.PlatformType, &interceptors.GenerateCredentialsInterceptorProvider{}).Register(). + WithCreateAroundTxInterceptorProvider(types.PlatformType, &interceptors.GeneratePlatformCredentialsInterceptorProvider{}).Register(). WithCreateOnTxInterceptorProvider(types.VisibilityType, &interceptors.VisibilityCreateNotificationsInterceptorProvider{}).Register(). WithUpdateOnTxInterceptorProvider(types.VisibilityType, &interceptors.VisibilityUpdateNotificationsInterceptorProvider{}).Register(). WithDeleteOnTxInterceptorProvider(types.VisibilityType, &interceptors.VisibilityDeleteNotificationsInterceptorProvider{}).Register(). diff --git a/pkg/types/interfaces.go b/pkg/types/interfaces.go index 8951a82b6..df1838026 100644 --- a/pkg/types/interfaces.go +++ b/pkg/types/interfaces.go @@ -17,6 +17,7 @@ package types import ( + "context" "strings" "time" @@ -32,10 +33,15 @@ func (ot ObjectType) String() string { return strings.TrimPrefix(string(ot), prefix) } -// Secured interface indicates that an object requires credentials to access it +// Strip interface indicates that an object needs to be sanitized before it is returned to the client +type Strip interface { + Sanitize() +} + +// Secured interface indicates that an object needs to be processed before stored/retrieved to/from storage type Secured interface { - SetCredentials(credentials *Credentials) - GetCredentials() *Credentials + Encrypt(context.Context, func(context.Context, []byte) ([]byte, error)) error + Decrypt(context.Context, func(context.Context, []byte) ([]byte, error)) error } // Object is the common interface that all resources in the Service Manager must implement diff --git a/pkg/types/platform.go b/pkg/types/platform.go index d30dd56de..c82ce0688 100644 --- a/pkg/types/platform.go +++ b/pkg/types/platform.go @@ -17,6 +17,7 @@ package types import ( + "context" "errors" "fmt" "reflect" @@ -33,6 +34,7 @@ const SMPlatform = "service-manager" type Platform struct { Base Secured `json:"-"` + Strip `json:"-"` Type string `json:"type"` Name string `json:"name"` Description string `json:"description"` @@ -59,12 +61,28 @@ func (e *Platform) Equals(obj Object) bool { return true } -func (e *Platform) SetCredentials(credentials *Credentials) { - e.Credentials = credentials +func (e *Platform) Sanitize() { + e.Credentials = nil } -func (e *Platform) GetCredentials() *Credentials { - return e.Credentials +func (e *Platform) Encrypt(ctx context.Context, encryptionFunc func(context.Context, []byte) ([]byte, error)) error { + return e.transform(ctx, encryptionFunc) +} + +func (e *Platform) Decrypt(ctx context.Context, decryptionFunc func(context.Context, []byte) ([]byte, error)) error { + return e.transform(ctx, decryptionFunc) +} + +func (e *Platform) transform(ctx context.Context, transformationFunc func(context.Context, []byte) ([]byte, error)) error { + if e.Credentials == nil || e.Credentials.Basic == nil { + return nil + } + transformedPassword, err := transformationFunc(ctx, []byte(e.Credentials.Basic.Password)) + if err != nil { + return err + } + e.Credentials.Basic.Password = string(transformedPassword) + return nil } // Validate implements InputValidator and verifies all mandatory fields are populated diff --git a/pkg/types/service_binding.go b/pkg/types/service_binding.go new file mode 100644 index 000000000..05f159972 --- /dev/null +++ b/pkg/types/service_binding.go @@ -0,0 +1,103 @@ +/* + * Copyright 2018 The Service Manager Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package types contains the Service Manager web entities +package types + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + + "github.com/Peripli/service-manager/pkg/util" +) + +//go:generate smgen api ServiceBinding +// ServiceBinding struct +type ServiceBinding struct { + Base + Secured `json:"-"` + Name string `json:"name"` + ServiceInstanceID string `json:"service_instance_id"` + SyslogDrainURL string `json:"syslog_drain_url,omitempty"` + RouteServiceURL string `json:"route_service_url,omitempty"` + VolumeMounts json.RawMessage `json:"volume_mounts,omitempty"` + Endpoints json.RawMessage `json:"endpoints,omitempty"` + Context json.RawMessage `json:"-"` + BindResource json.RawMessage `json:"-"` + Credentials string `json:"credentials"` +} + +func (e *ServiceBinding) Encrypt(ctx context.Context, encryptionFunc func(context.Context, []byte) ([]byte, error)) error { + return e.transform(ctx, encryptionFunc) +} + +func (e *ServiceBinding) Decrypt(ctx context.Context, decryptionFunc func(context.Context, []byte) ([]byte, error)) error { + return e.transform(ctx, decryptionFunc) +} + +func (e *ServiceBinding) transform(ctx context.Context, transformationFunc func(context.Context, []byte) ([]byte, error)) error { + if len(e.Credentials) == 0 { + return nil + } + transformedCredentials, err := transformationFunc(ctx, []byte(e.Credentials)) + if err != nil { + return err + } + e.Credentials = string(transformedCredentials) + return nil +} + +func (e *ServiceBinding) Equals(obj Object) bool { + if !Equals(e, obj) { + return false + } + + binding := obj.(*ServiceBinding) + if e.Name != binding.Name || + e.ServiceInstanceID != binding.ServiceInstanceID || + e.SyslogDrainURL != binding.SyslogDrainURL || + e.RouteServiceURL != binding.RouteServiceURL || + !reflect.DeepEqual(e.VolumeMounts, binding.VolumeMounts) || + !reflect.DeepEqual(e.Endpoints, binding.Endpoints) || + !reflect.DeepEqual(e.Context, binding.Context) || + !reflect.DeepEqual(e.BindResource, binding.BindResource) || + !reflect.DeepEqual(e.Credentials, binding.Credentials) { + return false + } + + return true +} + +// Validate implements InputValidator and verifies all mandatory fields are populated +func (e *ServiceBinding) Validate() error { + if util.HasRFC3986ReservedSymbols(e.ID) { + return fmt.Errorf("%s contains invalid character(s)", e.ID) + } + if e.Name == "" { + return errors.New("missing service binding name") + } + if e.ServiceInstanceID == "" { + return errors.New("missing service binding service instance ID") + } + if err := e.Labels.Validate(); err != nil { + return err + } + + return nil +} diff --git a/pkg/types/service_broker.go b/pkg/types/service_broker.go index 842183dfd..f787a6522 100644 --- a/pkg/types/service_broker.go +++ b/pkg/types/service_broker.go @@ -18,6 +18,7 @@ package types import ( + "context" "encoding/json" "errors" "fmt" @@ -32,6 +33,7 @@ const maxNameLength = 255 type ServiceBroker struct { Base Secured `json:"-"` + Strip `json:"-"` Name string `json:"name"` Description string `json:"description"` BrokerURL string `json:"broker_url"` @@ -41,12 +43,28 @@ type ServiceBroker struct { Services []*ServiceOffering `json:"-"` } -func (e *ServiceBroker) SetCredentials(credentials *Credentials) { - e.Credentials = credentials +func (e *ServiceBroker) Sanitize() { + e.Credentials = nil } -func (e *ServiceBroker) GetCredentials() *Credentials { - return e.Credentials +func (e *ServiceBroker) Encrypt(ctx context.Context, encryptionFunc func(context.Context, []byte) ([]byte, error)) error { + return e.transform(ctx, encryptionFunc) +} + +func (e *ServiceBroker) Decrypt(ctx context.Context, decryptionFunc func(context.Context, []byte) ([]byte, error)) error { + return e.transform(ctx, decryptionFunc) +} + +func (e *ServiceBroker) transform(ctx context.Context, transformationFunc func(context.Context, []byte) ([]byte, error)) error { + if e.Credentials == nil || e.Credentials.Basic == nil { + return nil + } + transformedPassword, err := transformationFunc(ctx, []byte(e.Credentials.Basic.Password)) + if err != nil { + return err + } + e.Credentials.Basic.Password = string(transformedPassword) + return nil } // Validate implements InputValidator and verifies all mandatory fields are populated diff --git a/pkg/types/servicebinding_gen.go b/pkg/types/servicebinding_gen.go new file mode 100644 index 000000000..6df96f57d --- /dev/null +++ b/pkg/types/servicebinding_gen.go @@ -0,0 +1,62 @@ +// GENERATED. DO NOT MODIFY! + +package types + +import ( + "encoding/json" + + "github.com/Peripli/service-manager/pkg/util" +) + +const ServiceBindingType ObjectType = "ServiceBinding" + +type ServiceBindings struct { + ServiceBindings []*ServiceBinding `json:"service_bindings"` +} + +func (e *ServiceBindings) Add(object Object) { + e.ServiceBindings = append(e.ServiceBindings, object.(*ServiceBinding)) +} + +func (e *ServiceBindings) ItemAt(index int) Object { + return e.ServiceBindings[index] +} + +func (e *ServiceBindings) Len() int { + return len(e.ServiceBindings) +} + +func (e *ServiceBinding) GetType() ObjectType { + return ServiceBindingType +} + +// MarshalJSON override json serialization for http response +func (e *ServiceBinding) MarshalJSON() ([]byte, error) { + type E ServiceBinding + toMarshal := struct { + *E + CreatedAt *string `json:"created_at,omitempty"` + UpdatedAt *string `json:"updated_at,omitempty"` + }{ + E: (*E)(e), + } + if !e.CreatedAt.IsZero() { + str := util.ToRFCNanoFormat(e.CreatedAt) + toMarshal.CreatedAt = &str + } + if !e.UpdatedAt.IsZero() { + str := util.ToRFCNanoFormat(e.UpdatedAt) + toMarshal.UpdatedAt = &str + } + hasNoLabels := true + for key, values := range e.Labels { + if key != "" && len(values) != 0 { + hasNoLabels = false + break + } + } + if hasNoLabels { + toMarshal.Labels = nil + } + return json.Marshal(toMarshal) +} diff --git a/pkg/web/routes.go b/pkg/web/routes.go index 8b653775f..95ce2ceb6 100644 --- a/pkg/web/routes.go +++ b/pkg/web/routes.go @@ -15,6 +15,9 @@ const ( // ServiceInstancesURL is the URL path to manage service instances ServiceInstancesURL = "/" + apiVersion + "/service_instances" + // ServiceBindingsURL is the URL path to manage service bindings + ServiceBindingsURL = "/" + apiVersion + "/service_bindings" + // VisibilitiesURL is the URL path to manage visibilities VisibilitiesURL = "/" + apiVersion + "/visibilities" diff --git a/storage/encrypting_repository.go b/storage/encrypting_repository.go index b9b5fad05..3d3e1849a 100644 --- a/storage/encrypting_repository.go +++ b/storage/encrypting_repository.go @@ -98,7 +98,7 @@ type TransactionalEncryptingRepository struct { } func (er *encryptingRepository) Create(ctx context.Context, obj types.Object) (types.Object, error) { - if err := er.transformCredentials(ctx, obj, er.encrypter.Encrypt); err != nil { + if err := er.encrypt(ctx, obj); err != nil { return nil, err } @@ -107,7 +107,7 @@ func (er *encryptingRepository) Create(ctx context.Context, obj types.Object) (t return nil, err } - if err := er.transformCredentials(ctx, newObj, er.encrypter.Decrypt); err != nil { + if err := er.decrypt(ctx, newObj); err != nil { return nil, err } @@ -120,7 +120,7 @@ func (er *encryptingRepository) Get(ctx context.Context, objectType types.Object return nil, err } - if err := er.transformCredentials(ctx, obj, er.encrypter.Decrypt); err != nil { + if err := er.decrypt(ctx, obj); err != nil { return nil, err } @@ -134,7 +134,7 @@ func (er *encryptingRepository) List(ctx context.Context, objectType types.Objec } for i := 0; i < objList.Len(); i++ { - if err := er.transformCredentials(ctx, objList.ItemAt(i), er.encrypter.Decrypt); err != nil { + if err := er.decrypt(ctx, objList.ItemAt(i)); err != nil { return nil, err } } @@ -146,8 +146,8 @@ func (er *encryptingRepository) Count(ctx context.Context, objectType types.Obje return er.repository.Count(ctx, objectType, criteria...) } -func (er *encryptingRepository) Update(ctx context.Context, obj types.Object, labelChanges query.LabelChanges, criteria ...query.Criterion) (types.Object, error) { - if err := er.transformCredentials(ctx, obj, er.encrypter.Encrypt); err != nil { +func (er *encryptingRepository) Update(ctx context.Context, obj types.Object, labelChanges query.LabelChanges, _ ...query.Criterion) (types.Object, error) { + if err := er.encrypt(ctx, obj); err != nil { return nil, err } @@ -156,7 +156,7 @@ func (er *encryptingRepository) Update(ctx context.Context, obj types.Object, la return nil, err } - if err := er.transformCredentials(ctx, updatedObj, er.encrypter.Decrypt); err != nil { + if err := er.decrypt(ctx, updatedObj); err != nil { return nil, err } @@ -170,7 +170,7 @@ func (er *encryptingRepository) DeleteReturning(ctx context.Context, objectType } for i := 0; i < objList.Len(); i++ { - if err := er.transformCredentials(ctx, objList.ItemAt(i), er.encrypter.Decrypt); err != nil { + if err := er.decrypt(ctx, objList.ItemAt(i)); err != nil { return nil, err } } @@ -186,20 +186,21 @@ func (er *encryptingRepository) Delete(ctx context.Context, objectType types.Obj return nil } -func (er *encryptingRepository) transformCredentials(ctx context.Context, obj types.Object, transformationFunc func(context.Context, []byte, []byte) ([]byte, error)) error { - securedObj, isSecured := obj.(types.Secured) - if isSecured { - credentials := securedObj.GetCredentials() - if credentials != nil { - transformedPassword, err := transformationFunc(ctx, []byte(credentials.Basic.Password), er.encryptionKey) - if err != nil { - return err - } - credentials.Basic.Password = string(transformedPassword) - securedObj.SetCredentials(credentials) - } +func (er *encryptingRepository) encrypt(ctx context.Context, obj types.Object) error { + if securedObject, isSecured := obj.(types.Secured); isSecured { + return securedObject.Encrypt(ctx, func(ctx context.Context, bytes []byte) ([]byte, error) { + return er.encrypter.Encrypt(ctx, bytes, er.encryptionKey) + }) } + return nil +} +func (er *encryptingRepository) decrypt(ctx context.Context, obj types.Object) error { + if securedObject, isSecured := obj.(types.Secured); isSecured { + return securedObject.Decrypt(ctx, func(ctx context.Context, bytes []byte) ([]byte, error) { + return er.encrypter.Decrypt(ctx, bytes, er.encryptionKey) + }) + } return nil } diff --git a/storage/encrypting_repository_test.go b/storage/encrypting_repository_test.go index 54567b4d4..27cf513bd 100644 --- a/storage/encrypting_repository_test.go +++ b/storage/encrypting_repository_test.go @@ -52,7 +52,7 @@ var _ = Describe("Encrypting Repository", func() { Credentials: &types.Credentials{ Basic: &types.Basic{ Username: "admin", - Password: "encrypt" + objWithDecryptedPassword.(types.Secured).GetCredentials().Basic.Password, + Password: "encrypt" + objWithDecryptedPassword.(*types.ServiceBroker).Credentials.Basic.Password, }, }, } @@ -144,7 +144,7 @@ var _ = Describe("Encrypting Repository", func() { It("invokes the delegate repository with object with encrypted credentials", func() { Expect(fakeRepository.CreateCallCount() - delegateCreateCallsCountBeforeOp).To(Equal(1)) _, objectArg := fakeRepository.CreateArgsForCall(0) - isPassEncrypted := strings.HasPrefix(objectArg.(types.Secured).GetCredentials().Basic.Password, "encrypt") + isPassEncrypted := strings.HasPrefix(objectArg.(*types.ServiceBroker).Credentials.Basic.Password, "encrypt") Expect(isPassEncrypted).To(BeTrue()) }) @@ -153,7 +153,7 @@ var _ = Describe("Encrypting Repository", func() { }) It("returns an object with decrypted credentials", func() { - isPassEncrypted := strings.HasPrefix(returnedObj.(types.Secured).GetCredentials().Basic.Password, "encrypt") + isPassEncrypted := strings.HasPrefix(returnedObj.(*types.ServiceBroker).Credentials.Basic.Password, "encrypt") Expect(isPassEncrypted).To(BeFalse()) }) }) @@ -197,7 +197,7 @@ var _ = Describe("Encrypting Repository", func() { It("returns an object with decrypted credentials", func() { for i := 0; i < returnedObjList.Len(); i++ { - isPassEncrypted := strings.HasPrefix(returnedObjList.ItemAt(i).(types.Secured).GetCredentials().Basic.Password, "encrypt") + isPassEncrypted := strings.HasPrefix(returnedObjList.ItemAt(i).(*types.ServiceBroker).Credentials.Basic.Password, "encrypt") Expect(isPassEncrypted).To(BeFalse()) } }) @@ -244,7 +244,7 @@ var _ = Describe("Encrypting Repository", func() { }) It("returns an object with decrypted credentials", func() { - isPassEncrypted := strings.HasPrefix(returnedObj.(types.Secured).GetCredentials().Basic.Password, "encrypt") + isPassEncrypted := strings.HasPrefix(returnedObj.(*types.ServiceBroker).Credentials.Basic.Password, "encrypt") Expect(isPassEncrypted).To(BeFalse()) }) }) @@ -296,7 +296,7 @@ var _ = Describe("Encrypting Repository", func() { It("invokes the delegate repository with object with encrypted credentials", func() { Expect(fakeRepository.UpdateCallCount() - delegateUpdateCallsCountBeforeOp).To(Equal(1)) _, objectArg, _, _ := fakeRepository.UpdateArgsForCall(0) - isPassEncrypted := strings.HasPrefix(objectArg.(types.Secured).GetCredentials().Basic.Password, "encrypt") + isPassEncrypted := strings.HasPrefix(objectArg.(*types.ServiceBroker).Credentials.Basic.Password, "encrypt") Expect(isPassEncrypted).To(BeTrue()) }) @@ -305,7 +305,7 @@ var _ = Describe("Encrypting Repository", func() { }) It("returns an object with decrypted credentials", func() { - isPassEncrypted := strings.HasPrefix(returnedObj.(types.Secured).GetCredentials().Basic.Password, "encrypt") + isPassEncrypted := strings.HasPrefix(returnedObj.(*types.ServiceBroker).Credentials.Basic.Password, "encrypt") Expect(isPassEncrypted).To(BeFalse()) }) }) @@ -349,7 +349,7 @@ var _ = Describe("Encrypting Repository", func() { It("returns an object with decrypted credentials", func() { for i := 0; i < returnedObjList.Len(); i++ { - isPassEncrypted := strings.HasPrefix(returnedObjList.ItemAt(i).(types.Secured).GetCredentials().Basic.Password, "encrypt") + isPassEncrypted := strings.HasPrefix(returnedObjList.ItemAt(i).(*types.ServiceBroker).Credentials.Basic.Password, "encrypt") Expect(isPassEncrypted).To(BeFalse()) } }) @@ -365,9 +365,9 @@ var _ = Describe("Encrypting Repository", func() { returnedObj, err := repository.Create(ctx, objWithDecryptedPassword) Expect(err).ToNot(HaveOccurred()) Expect(fakeRepository.CreateCallCount() - delegateCreateCallsCountBeforeOp).To(Equal(1)) - Expect(strings.HasPrefix(returnedObj.(types.Secured).GetCredentials().Basic.Password, "encrypt")).To(BeFalse()) + Expect(strings.HasPrefix(returnedObj.(*types.ServiceBroker).Credentials.Basic.Password, "encrypt")).To(BeFalse()) _, objectArg := fakeRepository.CreateArgsForCall(0) - Expect(strings.HasPrefix(objectArg.(types.Secured).GetCredentials().Basic.Password, "encrypt")).To(BeTrue()) + Expect(strings.HasPrefix(objectArg.(*types.ServiceBroker).Credentials.Basic.Password, "encrypt")).To(BeTrue()) // verify list delegateListCallsCountBeforeOp := fakeRepository.ListCallCount() @@ -375,7 +375,7 @@ var _ = Describe("Encrypting Repository", func() { Expect(err).To(HaveOccurred()) Expect(fakeRepository.ListCallCount() - delegateListCallsCountBeforeOp).To(Equal(1)) for i := 0; i < returnedObjList.Len(); i++ { - isPassEncrypted := strings.HasPrefix(returnedObjList.ItemAt(i).(types.Secured).GetCredentials().Basic.Password, "encrypt") + isPassEncrypted := strings.HasPrefix(returnedObjList.ItemAt(i).(*types.ServiceBroker).Credentials.Basic.Password, "encrypt") Expect(isPassEncrypted).To(BeFalse()) } @@ -385,8 +385,8 @@ var _ = Describe("Encrypting Repository", func() { Expect(err).ToNot(HaveOccurred()) Expect(fakeRepository.UpdateCallCount() - delegateUpdateCallsCountBeforeOp).To(Equal(1)) _, objectArg, _, _ = fakeRepository.UpdateArgsForCall(0) - Expect(strings.HasPrefix(objectArg.(types.Secured).GetCredentials().Basic.Password, "encrypt")).To(BeTrue()) - Expect(strings.HasPrefix(returnedObj.(types.Secured).GetCredentials().Basic.Password, "encrypt")).To(BeFalse()) + Expect(strings.HasPrefix(objectArg.(*types.ServiceBroker).Credentials.Basic.Password, "encrypt")).To(BeTrue()) + Expect(strings.HasPrefix(returnedObj.(*types.ServiceBroker).Credentials.Basic.Password, "encrypt")).To(BeFalse()) // verify get delegateGetCallsCountBeforeOp := fakeRepository.GetCallCount() @@ -394,7 +394,7 @@ var _ = Describe("Encrypting Repository", func() { returnedObj, err = repository.Get(ctx, types.ServiceBrokerType, byID) Expect(err).ToNot(HaveOccurred()) Expect(fakeRepository.GetCallCount() - delegateGetCallsCountBeforeOp).To(Equal(1)) - Expect(strings.HasPrefix(returnedObj.(types.Secured).GetCredentials().Basic.Password, "encrypt")).To(BeFalse()) + Expect(strings.HasPrefix(returnedObj.(*types.ServiceBroker).Credentials.Basic.Password, "encrypt")).To(BeFalse()) // verify delete delegateDeleteCallsCountBeforeOp := fakeRepository.DeleteCallCount() @@ -402,7 +402,7 @@ var _ = Describe("Encrypting Repository", func() { Expect(err).ToNot(HaveOccurred()) Expect(fakeRepository.DeleteCallCount() - delegateDeleteCallsCountBeforeOp).To(Equal(1)) for i := 0; i < returnedObjList.Len(); i++ { - isPassEncrypted := strings.HasPrefix(returnedObjList.ItemAt(i).(types.Secured).GetCredentials().Basic.Password, "encrypt") + isPassEncrypted := strings.HasPrefix(returnedObjList.ItemAt(i).(*types.ServiceBroker).Credentials.Basic.Password, "encrypt") Expect(isPassEncrypted).To(BeFalse()) } diff --git a/storage/interceptors/secured_generate_credentials_interceptor.go b/storage/interceptors/secured_generate_credentials_interceptor.go index 8e54e40be..7a84d6fba 100644 --- a/storage/interceptors/secured_generate_credentials_interceptor.go +++ b/storage/interceptors/secured_generate_credentials_interceptor.go @@ -18,6 +18,7 @@ package interceptors import ( "context" + "errors" "github.com/Peripli/service-manager/pkg/log" "github.com/Peripli/service-manager/storage" @@ -26,31 +27,35 @@ import ( ) const ( - generateCredentialsInterceptorName = "CreateCredentialsInterceptor" + generatePlatformCredentialsInterceptorName = "CreatePlatformCredentialsInterceptor" ) -type GenerateCredentialsInterceptorProvider struct { +type GeneratePlatformCredentialsInterceptorProvider struct { } -func (c *GenerateCredentialsInterceptorProvider) Provide() storage.CreateAroundTxInterceptor { - return &generateCredentialsInterceptor{} +func (c *GeneratePlatformCredentialsInterceptorProvider) Provide() storage.CreateAroundTxInterceptor { + return &generatePlatformCredentialsInterceptor{} } -func (c *GenerateCredentialsInterceptorProvider) Name() string { - return generateCredentialsInterceptorName +func (c *GeneratePlatformCredentialsInterceptorProvider) Name() string { + return generatePlatformCredentialsInterceptorName } -type generateCredentialsInterceptor struct{} +type generatePlatformCredentialsInterceptor struct{} // AroundTxCreate generates new credentials for the secured object -func (c *generateCredentialsInterceptor) AroundTxCreate(h storage.InterceptCreateAroundTxFunc) storage.InterceptCreateAroundTxFunc { +func (c *generatePlatformCredentialsInterceptor) AroundTxCreate(h storage.InterceptCreateAroundTxFunc) storage.InterceptCreateAroundTxFunc { return func(ctx context.Context, obj types.Object) (types.Object, error) { + platform, ok := obj.(*types.Platform) + if !ok { + return nil, errors.New("created object is not a platform") + } credentials, err := types.GenerateCredentials() if err != nil { log.C(ctx).Error("Could not generate credentials for platform") return nil, err } - (obj.(types.Secured)).SetCredentials(credentials) + platform.Credentials = credentials return h(ctx, obj) } diff --git a/storage/interceptors/service_instance_create_interceptor.go b/storage/interceptors/service_instance_create_interceptor.go index 79379be61..de6511fb0 100644 --- a/storage/interceptors/service_instance_create_interceptor.go +++ b/storage/interceptors/service_instance_create_interceptor.go @@ -18,6 +18,7 @@ package interceptors import ( "context" + "github.com/Peripli/service-manager/pkg/log" "github.com/Peripli/service-manager/pkg/types" "github.com/Peripli/service-manager/storage" @@ -48,7 +49,7 @@ func (c *serviceInstanceCreateInterceptor) OnTxCreate(h storage.InterceptCreateO return func(ctx context.Context, storage storage.Repository, obj types.Object) (types.Object, error) { serviceInstance := obj.(*types.ServiceInstance) - tenantID := gjson.GetBytes([]byte(serviceInstance.Context), c.TenantIdentifier) + tenantID := gjson.GetBytes(serviceInstance.Context, c.TenantIdentifier) if !tenantID.Exists() { log.D().Debugf("Could not add %s label to service instance with id %s. Label not found in OSB context.", c.TenantIdentifier, serviceInstance.ID) return h(ctx, storage, serviceInstance) diff --git a/storage/postgres/abstract.go b/storage/postgres/abstract.go index a31f437ff..97201f546 100644 --- a/storage/postgres/abstract.go +++ b/storage/postgres/abstract.go @@ -243,6 +243,20 @@ func getJSONText(item json.RawMessage) sqlxtypes.JSONText { return sqlxtypes.JSONText(item) } +func getNullJSONText(item json.RawMessage) sqlxtypes.NullJSONText { + itemLen := len(item) + if itemLen == 0 || itemLen == len("null") && string(item) == "null" { + return sqlxtypes.NullJSONText{ + JSONText: nil, + Valid: false, + } + } + return sqlxtypes.NullJSONText{ + JSONText: getJSONText(item), + Valid: true, + } +} + func getJSONRawMessage(item sqlxtypes.JSONText) json.RawMessage { if len(item) <= len("null") { itemStr := string(item) diff --git a/storage/postgres/keystore_test.go b/storage/postgres/keystore_test.go index 06205adf8..6530ec02f 100644 --- a/storage/postgres/keystore_test.go +++ b/storage/postgres/keystore_test.go @@ -59,7 +59,7 @@ var _ = Describe("Secured Storage", func() { mock.ExpectQuery(`SELECT CURRENT_DATABASE()`).WillReturnRows(sqlmock.NewRows([]string{"mock"}).FromCSVString("mock")) mock.ExpectQuery(`SELECT COUNT(1)*`).WillReturnRows(sqlmock.NewRows([]string{"mock"}).FromCSVString("1")) mock.ExpectExec("SELECT pg_advisory_lock*").WithArgs(sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1)) - mock.ExpectQuery(`SELECT version, dirty FROM "schema_migrations" LIMIT 1`).WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}).FromCSVString("20200114101000,false")) + mock.ExpectQuery(`SELECT version, dirty FROM "schema_migrations" LIMIT 1`).WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}).FromCSVString("20200114111100,false")) mock.ExpectExec("SELECT pg_advisory_unlock*").WithArgs(sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1)) options := storage.DefaultSettings() options.EncryptionKey = string(envEncryptionKey) diff --git a/storage/postgres/migrations/20200114111100_service_bindings.down.sql b/storage/postgres/migrations/20200114111100_service_bindings.down.sql new file mode 100644 index 000000000..b8149dd92 --- /dev/null +++ b/storage/postgres/migrations/20200114111100_service_bindings.down.sql @@ -0,0 +1,7 @@ +BEGIN; + +DROP TABLE IF EXISTS service_bindings; +DROP TABLE IF EXISTS service_bindings_labels; +DROP index service_bindings_paging_sequence_uindex; + +COMMIT; \ No newline at end of file diff --git a/storage/postgres/migrations/20200114111100_service_bindings.up.sql b/storage/postgres/migrations/20200114111100_service_bindings.up.sql new file mode 100644 index 000000000..5c1028250 --- /dev/null +++ b/storage/postgres/migrations/20200114111100_service_bindings.up.sql @@ -0,0 +1,37 @@ +BEGIN; + +CREATE TABLE service_bindings +( + id varchar(100) PRIMARY KEY, + name varchar(100) NOT NULL, + service_instance_id varchar(100) NOT NULL REFERENCES service_instances (id), + + syslog_drain_url text, + route_service_url text, + volume_mounts json, + endpoints json, + + credentials bytea, + context json DEFAULT '{}', + bind_resource json DEFAULT '{}', + + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + paging_sequence BIGSERIAL +); + +CREATE TABLE service_binding_labels +( + id varchar(100) PRIMARY KEY, + key varchar(255) NOT NULL CHECK (key <> ''), + val varchar(255) NOT NULL CHECK (val <> ''), + service_binding_id varchar(100) NOT NULL REFERENCES service_bindings (id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (key, val, service_binding_id) +); + +CREATE UNIQUE INDEX IF NOT EXISTS service_bindings_paging_sequence_uindex + on service_bindings (paging_sequence); + +COMMIT; \ No newline at end of file diff --git a/storage/postgres/service_binding.go b/storage/postgres/service_binding.go new file mode 100644 index 000000000..c2a66061d --- /dev/null +++ b/storage/postgres/service_binding.go @@ -0,0 +1,89 @@ +/* + * Copyright 2018 The Service Manager Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package postgres + +import ( + "database/sql" + + "github.com/Peripli/service-manager/storage" + sqlxtypes "github.com/jmoiron/sqlx/types" + + "github.com/Peripli/service-manager/pkg/types" +) + +// ServiceBinding entity +//go:generate smgen storage ServiceBinding github.com/Peripli/service-manager/pkg/types +type ServiceBinding struct { + BaseEntity + Name string `db:"name"` + ServiceInstanceID string `db:"service_instance_id"` + SyslogDrainURL sql.NullString `db:"syslog_drain_url"` + RouteServiceURL sql.NullString `db:"route_service_url"` + VolumeMounts sqlxtypes.NullJSONText `db:"volume_mounts"` + Endpoints sqlxtypes.NullJSONText `db:"endpoints"` + Context sqlxtypes.JSONText `db:"context"` + BindResource sqlxtypes.JSONText `db:"bind_resource"` + Credentials string `db:"credentials"` +} + +func (sb *ServiceBinding) ToObject() types.Object { + return &types.ServiceBinding{ + Base: types.Base{ + ID: sb.ID, + CreatedAt: sb.CreatedAt, + UpdatedAt: sb.UpdatedAt, + Labels: map[string][]string{}, + PagingSequence: sb.PagingSequence, + }, + Name: sb.Name, + ServiceInstanceID: sb.ServiceInstanceID, + SyslogDrainURL: sb.SyslogDrainURL.String, + RouteServiceURL: sb.RouteServiceURL.String, + VolumeMounts: getJSONRawMessage(sb.VolumeMounts.JSONText), + Endpoints: getJSONRawMessage(sb.Endpoints.JSONText), + Context: getJSONRawMessage(sb.Context), + BindResource: getJSONRawMessage(sb.BindResource), + Credentials: sb.Credentials, + } +} + +func (*ServiceBinding) FromObject(object types.Object) (storage.Entity, bool) { + serviceBinding, ok := object.(*types.ServiceBinding) + if !ok { + return nil, false + } + + sb := &ServiceBinding{ + BaseEntity: BaseEntity{ + ID: serviceBinding.ID, + CreatedAt: serviceBinding.CreatedAt, + UpdatedAt: serviceBinding.UpdatedAt, + PagingSequence: serviceBinding.PagingSequence, + }, + Name: serviceBinding.Name, + ServiceInstanceID: serviceBinding.ServiceInstanceID, + SyslogDrainURL: toNullString(serviceBinding.SyslogDrainURL), + RouteServiceURL: toNullString(serviceBinding.RouteServiceURL), + VolumeMounts: getNullJSONText(serviceBinding.VolumeMounts), + Endpoints: getNullJSONText(serviceBinding.Endpoints), + Context: getJSONText(serviceBinding.Context), + BindResource: getJSONText(serviceBinding.BindResource), + Credentials: serviceBinding.Credentials, + } + + return sb, true +} diff --git a/storage/postgres/servicebinding_gen.go b/storage/postgres/servicebinding_gen.go new file mode 100644 index 000000000..b99c8f96d --- /dev/null +++ b/storage/postgres/servicebinding_gen.go @@ -0,0 +1,72 @@ +// GENERATED. DO NOT MODIFY! + +package postgres + +import ( + "database/sql" + "time" + + "github.com/Peripli/service-manager/pkg/types" + "github.com/Peripli/service-manager/storage" + "github.com/jmoiron/sqlx" + "github.com/lib/pq" +) + +var _ PostgresEntity = &ServiceBinding{} + +const ServiceBindingTable = "service_bindings" + +func (*ServiceBinding) LabelEntity() PostgresLabel { + return &ServiceBindingLabel{} +} + +func (*ServiceBinding) TableName() string { + return ServiceBindingTable +} + +func (e *ServiceBinding) NewLabel(id, key, value string) storage.Label { + now := pq.NullTime{ + Time: time.Now(), + Valid: true, + } + return &ServiceBindingLabel{ + BaseLabelEntity: BaseLabelEntity{ + ID: sql.NullString{String: id, Valid: id != ""}, + Key: sql.NullString{String: key, Valid: key != ""}, + Val: sql.NullString{String: value, Valid: value != ""}, + CreatedAt: now, + UpdatedAt: now, + }, + ServiceBindingID: sql.NullString{String: e.ID, Valid: e.ID != ""}, + } +} + +func (e *ServiceBinding) RowsToList(rows *sqlx.Rows) (types.ObjectList, error) { + rowCreator := func() EntityLabelRow { + return &struct { + *ServiceBinding + ServiceBindingLabel `db:"service_binding_labels"` + }{} + } + result := &types.ServiceBindings{ + ServiceBindings: make([]*types.ServiceBinding, 0), + } + err := rowsToList(rows, rowCreator, result) + if err != nil { + return nil, err + } + return result, nil +} + +type ServiceBindingLabel struct { + BaseLabelEntity + ServiceBindingID sql.NullString `db:"service_binding_id"` +} + +func (el ServiceBindingLabel) LabelsTableName() string { + return "service_binding_labels" +} + +func (el ServiceBindingLabel) ReferenceColumn() string { + return "service_binding_id" +} diff --git a/storage/postgres/storage.go b/storage/postgres/storage.go index 9e63345b0..0a426a5d8 100644 --- a/storage/postgres/storage.go +++ b/storage/postgres/storage.go @@ -106,6 +106,7 @@ func (ps *Storage) Open(settings *storage.Settings) error { ps.scheme.introduce(&Notification{}) ps.scheme.introduce(&Operation{}) ps.scheme.introduce(&ServiceInstance{}) + ps.scheme.introduce(&ServiceBinding{}) } return nil diff --git a/test/common/common.go b/test/common/common.go index 79588d01a..f3fd8b6ef 100644 --- a/test/common/common.go +++ b/test/common/common.go @@ -224,6 +224,10 @@ func RemoveAllInstances(repository storage.Repository) error { return repository.Delete(context.TODO(), types.ServiceInstanceType) } +func RemoveAllBindings(repository storage.Repository) error { + return repository.Delete(context.TODO(), types.ServiceBindingType) +} + func RemoveAllBrokers(SM *SMExpect) { removeAll(SM, "service_brokers", web.ServiceBrokersURL) } diff --git a/test/common/test_context.go b/test/common/test_context.go index 60ac26165..3de8dd2d2 100644 --- a/test/common/test_context.go +++ b/test/common/test_context.go @@ -340,6 +340,7 @@ func (tcb *TestContextBuilder) BuildWithListener(listener net.Listener) *TestCon } RemoveAllOperations(testContext.SMRepository) + RemoveAllBindings(testContext.SMRepository) RemoveAllInstances(testContext.SMRepository) RemoveAllBrokers(testContext.SMWithOAuth) RemoveAllPlatforms(testContext.SMWithOAuth) @@ -540,6 +541,9 @@ func (ctx *TestContext) CleanupAdditionalResources() { if err := RemoveAllNotifications(ctx.SMRepository); err != nil && err != util.ErrNotFoundInStorage { panic(err) } + if err := RemoveAllBindings(ctx.SMRepository); err != nil && err != util.ErrNotFoundInStorage { + panic(err) + } if err := RemoveAllInstances(ctx.SMRepository); err != nil && err != util.ErrNotFoundInStorage { panic(err) } diff --git a/test/list.go b/test/list.go index 81ba69108..a69c5a91f 100644 --- a/test/list.go +++ b/test/list.go @@ -74,8 +74,10 @@ func DescribeListTestsFor(ctx *common.TestContext, t TestCase, responseMode Resp Expect(). Status(http.StatusOK).JSON().Object() result.ContainsKey("labels") + resultObject := result.Raw() + delete(resultObject, "credentials") - return result.Raw() + return resultObject } By(fmt.Sprintf("Attempting to create a random resource of %s with mandatory fields only", t.API)) diff --git a/test/service_binding_test/service_binding_test.go b/test/service_binding_test/service_binding_test.go new file mode 100644 index 000000000..234e8841c --- /dev/null +++ b/test/service_binding_test/service_binding_test.go @@ -0,0 +1,120 @@ +/* + * Copyright 2018 The Service Manager Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package service_binding_test + +import ( + "context" + "fmt" + "net/http" + + "github.com/gofrs/uuid" + + "testing" + + "github.com/Peripli/service-manager/test/testutil/service_binding" + + "github.com/Peripli/service-manager/pkg/query" + "github.com/Peripli/service-manager/pkg/types" + + "github.com/Peripli/service-manager/pkg/web" + "github.com/Peripli/service-manager/test/common" + + "github.com/Peripli/service-manager/test" + + . "github.com/onsi/ginkgo" + + . "github.com/onsi/gomega" +) + +func TestServiceBindings(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Service Bindings Tests Suite") +} + +const ( + TenantIdentifier = "tenant" + TenantValue = "tenant_value" +) + +var _ = test.DescribeTestsFor(test.TestCase{ + API: web.ServiceBindingsURL, + SupportedOps: []test.Op{ + test.Get, test.List, + }, + MultitenancySettings: &test.MultitenancySettings{ + ClientID: "tenancyClient", + ClientIDTokenClaim: "cid", + TenantTokenClaim: "zid", + LabelKey: TenantIdentifier, + TokenClaims: map[string]interface{}{ + "cid": "tenancyClient", + "zid": "tenantID", + }, + }, + ResourceType: types.ServiceBindingType, + SupportsAsyncOperations: true, + DisableTenantResources: true, + ResourceBlueprint: blueprint, + ResourceWithoutNullableFieldsBlueprint: blueprint, + ResourcePropertiesToIgnore: []string{"credentials"}, + PatchResource: func(ctx *common.TestContext, apiPath string, objID string, resourceType types.ObjectType, patchLabels []*query.LabelChange, _ bool) { + byID := query.ByField(query.EqualsOperator, "id", objID) + sb, err := ctx.SMRepository.Get(context.Background(), resourceType, byID) + if err != nil { + Fail(fmt.Sprintf("unable to retrieve resource %s: %s", resourceType, err)) + } + + _, err = ctx.SMRepository.Update(context.Background(), sb, patchLabels) + if err != nil { + Fail(fmt.Sprintf("unable to update resource %s: %s", resourceType, err)) + } + }, + AdditionalTests: func(ctx *common.TestContext) {}, +}) + +func blueprint(ctx *common.TestContext, auth *common.SMExpect, _ bool) common.Object { + instanceIDObj, err := uuid.NewV4() + if err != nil { + Fail(fmt.Sprintf("failed to generate instance GUID: %s", err)) + } + instanceID := instanceIDObj.String() + + ctx.SMWithOAuth.POST(web.ServiceInstancesURL).WithJSON(common.Object{ + "id": instanceID, + "name": instanceID + "name", + "service_plan_id": newServicePlan(ctx), + "maintenance_info": "{}", + }).Expect().Status(http.StatusCreated) + + serviceBindingObj := service_binding.Prepare(instanceID, fmt.Sprintf(`{"%s":"%s"}`, TenantIdentifier, TenantValue), `{"password": "secret"}`) + _, err = ctx.SMRepository.Create(context.Background(), serviceBindingObj) + if err != nil { + Fail(fmt.Sprintf("could not create service binding: %s", err)) + } + + binding := auth.ListWithQuery(web.ServiceBindingsURL, fmt.Sprintf("fieldQuery=id eq '%s'", serviceBindingObj.ID)).First().Object().Raw() + delete(binding, "credentials") + return binding +} + +func newServicePlan(ctx *common.TestContext) string { + brokerID, _, _ := ctx.RegisterBrokerWithCatalog(common.NewRandomSBCatalog()) + so := ctx.SMWithOAuth.ListWithQuery(web.ServiceOfferingsURL, fmt.Sprintf("fieldQuery=broker_id eq '%s'", brokerID)).First() + servicePlanID := ctx.SMWithOAuth.ListWithQuery(web.ServicePlansURL, "fieldQuery="+fmt.Sprintf("service_offering_id eq '%s'", so.Object().Value("id").String().Raw())). + First().Object().Value("id").String().Raw() + return servicePlanID +} diff --git a/test/test.go b/test/test.go index f22ea283c..7478ff3ff 100644 --- a/test/test.go +++ b/test/test.go @@ -24,10 +24,11 @@ import ( "strconv" "strings" + "time" + "github.com/Peripli/service-manager/pkg/query" "github.com/Peripli/service-manager/pkg/types" "github.com/gavv/httpexpect" - "time" "github.com/tidwall/gjson" diff --git a/test/testutil/service_binding/service_binding.go b/test/testutil/service_binding/service_binding.go new file mode 100644 index 000000000..da8111543 --- /dev/null +++ b/test/testutil/service_binding/service_binding.go @@ -0,0 +1,36 @@ +package service_binding + +import ( + "fmt" + "time" + + "github.com/Peripli/service-manager/pkg/types" + "github.com/gofrs/uuid" + + . "github.com/onsi/ginkgo" +) + +func Prepare(serviceInstanceID string, OSBContext string, credentials string) *types.ServiceBinding { + bindingID, err := uuid.NewV4() + if err != nil { + Fail(fmt.Sprintf("failed to generate binding GUID: %s", err)) + } + + return &types.ServiceBinding{ + Base: types.Base{ + ID: bindingID.String(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + Secured: nil, + Name: "test-service-binding", + ServiceInstanceID: serviceInstanceID, + SyslogDrainURL: "drain_url", + RouteServiceURL: "route_service_url", + VolumeMounts: []byte(`[]`), + Endpoints: []byte(`[]`), + Context: []byte(OSBContext), + BindResource: []byte(`{"app_guid": "app-guid"}`), + Credentials: credentials, + } +} From ba2eb69b599a3664bc67e0337a98d2ffa919dca0 Mon Sep 17 00:00:00 2001 From: Nikolay Mateev Date: Mon, 20 Jan 2020 13:16:01 +0200 Subject: [PATCH 04/15] Introduce Bind/Unbind APIs (#401) --- api/api.go | 3 +- api/filters/service_binding_filter.go | 79 +++++++ api/filters/service_binding_filter_test.go | 88 +++++++ api/service_binding_controller.go | 25 +- pkg/types/service_binding.go | 3 +- storage/postgres/keystore_test.go | 2 +- ... 20200116221100_service_bindings.down.sql} | 0 ...=> 20200116221100_service_bindings.up.sql} | 4 +- storage/postgres/service_binding.go | 3 + test/auth_test/auth_test.go | 26 +++ test/broker_test/broker_test.go | 11 +- test/delete_list.go | 6 +- test/platform_test/platform_test.go | 2 +- .../service_binding_test.go | 221 +++++++++++++++--- .../service_instance_test.go | 23 +- .../service_offering_test.go | 2 +- test/service_plan_test/service_plan_test.go | 2 +- test/test.go | 26 ++- .../service_binding/service_binding.go | 36 --- test/visibility_test/visibility_test.go | 2 +- 20 files changed, 450 insertions(+), 114 deletions(-) create mode 100644 api/filters/service_binding_filter.go create mode 100644 api/filters/service_binding_filter_test.go rename storage/postgres/migrations/{20200114111100_service_bindings.down.sql => 20200116221100_service_bindings.down.sql} (100%) rename storage/postgres/migrations/{20200114111100_service_bindings.up.sql => 20200116221100_service_bindings.up.sql} (94%) delete mode 100644 test/testutil/service_binding/service_binding.go diff --git a/api/api.go b/api/api.go index 6d319559b..4a8bae745 100644 --- a/api/api.go +++ b/api/api.go @@ -104,11 +104,11 @@ func New(ctx context.Context, e env.Environment, options *Options) (*web.API, er NewAsyncController(ctx, options, web.ServiceInstancesURL, types.ServiceInstanceType, func() types.Object { return &types.ServiceInstance{} }), + NewServiceBindingController(ctx, options), apiNotifications.NewController(ctx, options.Repository, options.WSSettings, options.Notificator), NewServiceOfferingController(options), NewServicePlanController(options), - NewServiceBindingController(ctx, options), &info.Controller{ TokenIssuer: options.APISettings.TokenIssuerURL, @@ -135,6 +135,7 @@ func New(ctx context.Context, e env.Environment, options *Options) (*web.API, er filters.NewProtectedLabelsFilter(options.APISettings.ProtectedLabels), &filters.ProtectedSMPlatformFilter{}, &filters.ServiceInstanceValidationFilter{}, + &filters.ServiceBindingStripFilter{}, &filters.PlatformAwareVisibilityFilter{}, &filters.PatchOnlyLabelsFilter{}, filters.NewPlansFilterByVisibility(options.Repository), diff --git a/api/filters/service_binding_filter.go b/api/filters/service_binding_filter.go new file mode 100644 index 000000000..25b29832e --- /dev/null +++ b/api/filters/service_binding_filter.go @@ -0,0 +1,79 @@ +/* + * Copyright 2018 The Service Manager Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package filters + +import ( + "context" + "net/http" + + "github.com/Peripli/service-manager/pkg/log" + "github.com/Peripli/service-manager/pkg/util" + + "github.com/Peripli/service-manager/pkg/web" + "github.com/tidwall/sjson" +) + +const ServiceBindingStripFilterName = "ServiceBindingStripFilter" + +var serviceBindingUnmodifiableProperties = []string{ + "credentials", "syslog_drain_url", "route_service_url", "volume_mounts", "endpoints", "ready", +} + +// ServiceBindingStripFilter checks post request body for unmodifiable properties +type ServiceBindingStripFilter struct { +} + +func (*ServiceBindingStripFilter) Name() string { + return ServiceBindingStripFilterName +} + +func (*ServiceBindingStripFilter) Run(req *web.Request, next web.Handler) (*web.Response, error) { + var err error + req.Body, err = removePropertiesFromRequest( + req.Context(), req.Body, serviceBindingUnmodifiableProperties) + if err != nil { + return nil, err + } + return next.Handle(req) +} + +func removePropertiesFromRequest(ctx context.Context, body []byte, properties []string) ([]byte, error) { + var err error + for _, prop := range properties { + body, err = sjson.DeleteBytes(body, prop) + if err != nil { + log.C(ctx).Errorf("Could not remove %s from body %s", prop, err) + return nil, &util.HTTPError{ + ErrorType: "BadRequest", + Description: "Invalid request body", + StatusCode: http.StatusBadRequest, + } + } + } + return body, nil +} + +func (*ServiceBindingStripFilter) FilterMatchers() []web.FilterMatcher { + return []web.FilterMatcher{ + { + Matchers: []web.Matcher{ + web.Path(web.ServiceBindingsURL + "/**"), + web.Methods(http.MethodPost), + }, + }, + } +} diff --git a/api/filters/service_binding_filter_test.go b/api/filters/service_binding_filter_test.go new file mode 100644 index 000000000..d2860ca2b --- /dev/null +++ b/api/filters/service_binding_filter_test.go @@ -0,0 +1,88 @@ +/* + * Copyright 2018 The Service Manager Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package filters + +import ( + "net/http" + + "github.com/tidwall/gjson" + + "github.com/tidwall/sjson" + + "github.com/Peripli/service-manager/pkg/web" + "github.com/Peripli/service-manager/pkg/web/webfakes" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Service Binding Strip Filter", func() { + const ( + propertyNotToBeDeleted = "some_prop" + defaultValue = "value" + invalidJSON = "invalid json" + ) + var ( + filter ServiceBindingStripFilter + handler *webfakes.FakeHandler + jsonWithPropertiesToStrip string + ) + + BeforeEach(func() { + filter = ServiceBindingStripFilter{} + handler = &webfakes.FakeHandler{} + jsonWithPropertiesToStrip = `{}` + }) + + Context("Create binding", func() { + When("body has properties which cannot be set", func() { + It("should remove them from request body", func() { + var err error + for _, prop := range serviceBindingUnmodifiableProperties { + jsonWithPropertiesToStrip, err = sjson.Set(jsonWithPropertiesToStrip, prop, defaultValue) + Expect(err).ToNot(HaveOccurred()) + } + jsonWithPropertiesToStrip, err = sjson.Set(jsonWithPropertiesToStrip, propertyNotToBeDeleted, defaultValue) + Expect(err).ToNot(HaveOccurred()) + + req := mockedRequest(http.MethodPost, jsonWithPropertiesToStrip) + _, err = filter.Run(req, handler) + Expect(err).ToNot(HaveOccurred()) + Expect(handler.HandleCallCount()).To(Equal(1)) + requestBody := handler.HandleArgsForCall(0).Body + for _, prop := range serviceBindingUnmodifiableProperties { + Expect(gjson.GetBytes(requestBody, prop).String()).To(BeEmpty()) + } + Expect(gjson.GetBytes(requestBody, propertyNotToBeDeleted).String()).To(Equal(defaultValue)) + }) + }) + When("body is invalid json", func() { + It("should do nothing", func() { + req := mockedRequest(http.MethodPost, invalidJSON) + filter.Run(req, handler) + Expect(handler.HandleCallCount()).To(Equal(1)) + requestBody := handler.HandleArgsForCall(0).Body + Expect(string(requestBody)).To(Equal(invalidJSON)) + }) + }) + }) +}) + +func mockedRequest(method, json string) *web.Request { + req, err := http.NewRequest(method, "", nil) + Expect(err).ShouldNot(HaveOccurred()) + return &web.Request{Request: req, Body: []byte(json)} +} diff --git a/api/service_binding_controller.go b/api/service_binding_controller.go index c0d999c7a..5fcf7b76e 100644 --- a/api/service_binding_controller.go +++ b/api/service_binding_controller.go @@ -40,10 +40,17 @@ func NewServiceBindingController(ctx context.Context, options *Options) *Service func (c *ServiceBindingController) Routes() []web.Route { return []web.Route{ + { + Endpoint: web.Endpoint{ + Method: http.MethodPost, + Path: c.resourceBaseURL, + }, + Handler: c.CreateObject, + }, { Endpoint: web.Endpoint{ Method: http.MethodGet, - Path: fmt.Sprintf("%s/{%s}", web.ServiceBindingsURL, PathParamResourceID), + Path: fmt.Sprintf("%s/{%s}", c.resourceBaseURL, PathParamResourceID), }, Handler: c.GetSingleObject, }, @@ -57,9 +64,23 @@ func (c *ServiceBindingController) Routes() []web.Route { { Endpoint: web.Endpoint{ Method: http.MethodGet, - Path: web.ServiceBindingsURL, + Path: c.resourceBaseURL, }, Handler: c.ListObjects, }, + { + Endpoint: web.Endpoint{ + Method: http.MethodDelete, + Path: c.resourceBaseURL, + }, + Handler: c.DeleteObjects, + }, + { + Endpoint: web.Endpoint{ + Method: http.MethodDelete, + Path: fmt.Sprintf("%s/{%s}", c.resourceBaseURL, PathParamResourceID), + }, + Handler: c.DeleteSingleObject, + }, } } diff --git a/pkg/types/service_binding.go b/pkg/types/service_binding.go index d24a5b318..ed3ecdcd8 100644 --- a/pkg/types/service_binding.go +++ b/pkg/types/service_binding.go @@ -39,8 +39,9 @@ type ServiceBinding struct { VolumeMounts json.RawMessage `json:"volume_mounts,omitempty"` Endpoints json.RawMessage `json:"endpoints,omitempty"` Context json.RawMessage `json:"-"` - BindResource json.RawMessage `json:"-"` + BindResource json.RawMessage `json:"bind_resource,omitempty"` Credentials string `json:"credentials"` + Ready bool `json:"ready"` LastOperation *Operation `json:"last_operation,omitempty"` } diff --git a/storage/postgres/keystore_test.go b/storage/postgres/keystore_test.go index bb6510108..17ee1c10b 100644 --- a/storage/postgres/keystore_test.go +++ b/storage/postgres/keystore_test.go @@ -59,7 +59,7 @@ var _ = Describe("Secured Storage", func() { mock.ExpectQuery(`SELECT CURRENT_DATABASE()`).WillReturnRows(sqlmock.NewRows([]string{"mock"}).FromCSVString("mock")) mock.ExpectQuery(`SELECT COUNT(1)*`).WillReturnRows(sqlmock.NewRows([]string{"mock"}).FromCSVString("1")) mock.ExpectExec("SELECT pg_advisory_lock*").WithArgs(sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1)) - mock.ExpectQuery(`SELECT version, dirty FROM "schema_migrations" LIMIT 1`).WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}).FromCSVString("20200116204800,false")) + mock.ExpectQuery(`SELECT version, dirty FROM "schema_migrations" LIMIT 1`).WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}).FromCSVString("20200116221100,false")) mock.ExpectExec("SELECT pg_advisory_unlock*").WithArgs(sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1)) options := storage.DefaultSettings() options.EncryptionKey = string(envEncryptionKey) diff --git a/storage/postgres/migrations/20200114111100_service_bindings.down.sql b/storage/postgres/migrations/20200116221100_service_bindings.down.sql similarity index 100% rename from storage/postgres/migrations/20200114111100_service_bindings.down.sql rename to storage/postgres/migrations/20200116221100_service_bindings.down.sql diff --git a/storage/postgres/migrations/20200114111100_service_bindings.up.sql b/storage/postgres/migrations/20200116221100_service_bindings.up.sql similarity index 94% rename from storage/postgres/migrations/20200114111100_service_bindings.up.sql rename to storage/postgres/migrations/20200116221100_service_bindings.up.sql index 5c1028250..bd7ff5dfc 100644 --- a/storage/postgres/migrations/20200114111100_service_bindings.up.sql +++ b/storage/postgres/migrations/20200116221100_service_bindings.up.sql @@ -17,7 +17,9 @@ CREATE TABLE service_bindings created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, - paging_sequence BIGSERIAL + paging_sequence BIGSERIAL, + + ready boolean NOT NULL ); CREATE TABLE service_binding_labels diff --git a/storage/postgres/service_binding.go b/storage/postgres/service_binding.go index c2a66061d..82bd5e629 100644 --- a/storage/postgres/service_binding.go +++ b/storage/postgres/service_binding.go @@ -38,6 +38,7 @@ type ServiceBinding struct { Context sqlxtypes.JSONText `db:"context"` BindResource sqlxtypes.JSONText `db:"bind_resource"` Credentials string `db:"credentials"` + Ready bool `db:"ready"` } func (sb *ServiceBinding) ToObject() types.Object { @@ -58,6 +59,7 @@ func (sb *ServiceBinding) ToObject() types.Object { Context: getJSONRawMessage(sb.Context), BindResource: getJSONRawMessage(sb.BindResource), Credentials: sb.Credentials, + Ready: sb.Ready, } } @@ -83,6 +85,7 @@ func (*ServiceBinding) FromObject(object types.Object) (storage.Entity, bool) { Context: getJSONText(serviceBinding.Context), BindResource: getJSONText(serviceBinding.BindResource), Credentials: serviceBinding.Credentials, + Ready: serviceBinding.Ready, } return sb, true diff --git a/test/auth_test/auth_test.go b/test/auth_test/auth_test.go index 11a01288f..814737f7b 100644 --- a/test/auth_test/auth_test.go +++ b/test/auth_test/auth_test.go @@ -278,6 +278,32 @@ var _ = Describe("Service Manager Authentication", func() { {"Invalid authorization schema", "DELETE", web.ServiceInstancesURL + "/999", invalidBasicAuthHeader}, {"Missing token in authorization header", "DELETE", web.ServiceInstancesURL + "/999", emptyBearerAuthHeader}, {"Invalid token in authorization header", "DELETE", web.ServiceInstancesURL + "/999", invalidBearerAuthHeader}, + + // SERVICE BINDINGS + {"Missing authorization header", "GET", web.ServiceBindingsURL + "/999", emptyAuthHeader}, + {"Invalid authorization schema", "GET", web.ServiceBindingsURL + "/999", invalidBasicAuthHeader}, + {"Missing token in authorization header", "GET", web.ServiceBindingsURL + "/999", emptyBearerAuthHeader}, + {"Invalid token in authorization header", "GET", web.ServiceBindingsURL + "/999", invalidBearerAuthHeader}, + + {"Missing authorization header", "GET", web.ServiceBindingsURL + "/999/operations/999", emptyAuthHeader}, + {"Invalid authorization schema", "GET", web.ServiceBindingsURL + "/999/operations/999", invalidBasicAuthHeader}, + {"Missing token in authorization header", "GET", web.ServiceBindingsURL + "/999/operations/999", emptyBearerAuthHeader}, + {"Invalid token in authorization header", "GET", web.ServiceBindingsURL + "/999/operations/999", invalidBearerAuthHeader}, + + {"Missing authorization header", "GET", web.ServiceBindingsURL, emptyAuthHeader}, + {"Invalid authorization schema", "GET", web.ServiceBindingsURL, invalidBasicAuthHeader}, + {"Missing token in authorization header", "GET", web.ServiceBindingsURL, emptyBearerAuthHeader}, + {"Invalid token in authorization header", "GET", web.ServiceBindingsURL, invalidBearerAuthHeader}, + + {"Missing authorization header", "POST", web.ServiceBindingsURL, emptyAuthHeader}, + {"Invalid authorization schema", "POST", web.ServiceBindingsURL, invalidBasicAuthHeader}, + {"Missing token in authorization header", "POST", web.ServiceBindingsURL, emptyBearerAuthHeader}, + {"Invalid token in authorization header", "POST", web.ServiceBindingsURL, invalidBearerAuthHeader}, + + {"Missing authorization header", "DELETE", web.ServiceBindingsURL + "/999", emptyAuthHeader}, + {"Invalid authorization schema", "DELETE", web.ServiceBindingsURL + "/999", invalidBasicAuthHeader}, + {"Missing token in authorization header", "DELETE", web.ServiceBindingsURL + "/999", emptyBearerAuthHeader}, + {"Invalid token in authorization header", "DELETE", web.ServiceBindingsURL + "/999", invalidBearerAuthHeader}, } for _, request := range authRequests { diff --git a/test/broker_test/broker_test.go b/test/broker_test/broker_test.go index 381088c3a..22d807382 100644 --- a/test/broker_test/broker_test.go +++ b/test/broker_test/broker_test.go @@ -71,7 +71,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ SupportsAsyncOperations: true, ResourceBlueprint: blueprint(true), ResourceWithoutNullableFieldsBlueprint: blueprint(false), - PatchResource: test.DefaultResourcePatch, + PatchResource: test.APIResourcePatch, AdditionalTests: func(ctx *common.TestContext) { Context("additional non-generic tests", func() { var ( @@ -1752,14 +1752,7 @@ func blueprint(setNullFieldsValues bool) func(ctx *common.TestContext, auth *com var obj map[string]interface{} resp := auth.POST(web.ServiceBrokersURL).WithQuery("async", strconv.FormatBool(async)).WithJSON(brokerJSON).Expect() if async { - resp = resp.Status(http.StatusAccepted) - if err := test.ExpectOperation(auth, resp, types.SUCCEEDED); err != nil { - panic(err) - } - - obj = auth.GET(web.ServiceBrokersURL + "/" + brokerID.String()). - Expect().JSON().Object().Raw() - + obj = test.ExpectSuccessfulAsyncResourceCreation(resp, auth, brokerID.String(), web.ServiceBrokersURL) } else { obj = resp.Status(http.StatusCreated).JSON().Object().Raw() delete(obj, "credentials") diff --git a/test/delete_list.go b/test/delete_list.go index cc2edf238..c9644e53c 100644 --- a/test/delete_list.go +++ b/test/delete_list.go @@ -364,7 +364,7 @@ func DescribeDeleteListFor(ctx *common.TestContext, t TestCase) bool { attachLabel := func(obj common.Object, i int) common.Object { patchLabelsBody := make(map[string]interface{}) - patchLabels := []query.LabelChange{ + patchLabels := []*query.LabelChange{ { Operation: query.AddLabelOperation, Key: "labelKey1", @@ -384,9 +384,7 @@ func DescribeDeleteListFor(ctx *common.TestContext, t TestCase) bool { patchLabelsBody["labels"] = patchLabels By(fmt.Sprintf("Attempting to patch resource of %s with labels as labels are declared supported", t.API)) - ctx.SMWithOAuth.PATCH(t.API + "/" + obj["id"].(string)).WithJSON(patchLabelsBody). - Expect(). - Status(http.StatusOK) + t.PatchResource(ctx, t.API, obj["id"].(string), t.ResourceType, patchLabels, false) result := ctx.SMWithOAuth.GET(t.API + "/" + obj["id"].(string)). Expect(). diff --git a/test/platform_test/platform_test.go b/test/platform_test/platform_test.go index e881951ac..9e7201cd8 100644 --- a/test/platform_test/platform_test.go +++ b/test/platform_test/platform_test.go @@ -61,7 +61,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ SupportsAsyncOperations: false, ResourceBlueprint: blueprint(true), ResourceWithoutNullableFieldsBlueprint: blueprint(false), - PatchResource: test.DefaultResourcePatch, + PatchResource: test.APIResourcePatch, AdditionalTests: func(ctx *common.TestContext) { Context("non-generic tests", func() { BeforeEach(func() { diff --git a/test/service_binding_test/service_binding_test.go b/test/service_binding_test/service_binding_test.go index 234e8841c..9ceb9d5e1 100644 --- a/test/service_binding_test/service_binding_test.go +++ b/test/service_binding_test/service_binding_test.go @@ -17,17 +17,14 @@ package service_binding_test import ( - "context" "fmt" "net/http" + "strconv" "github.com/gofrs/uuid" "testing" - "github.com/Peripli/service-manager/test/testutil/service_binding" - - "github.com/Peripli/service-manager/pkg/query" "github.com/Peripli/service-manager/pkg/types" "github.com/Peripli/service-manager/pkg/web" @@ -47,13 +44,12 @@ func TestServiceBindings(t *testing.T) { const ( TenantIdentifier = "tenant" - TenantValue = "tenant_value" ) var _ = test.DescribeTestsFor(test.TestCase{ API: web.ServiceBindingsURL, SupportedOps: []test.Op{ - test.Get, test.List, + test.Get, test.List, test.Delete, test.DeleteList, }, MultitenancySettings: &test.MultitenancySettings{ ClientID: "tenancyClient", @@ -70,43 +66,202 @@ var _ = test.DescribeTestsFor(test.TestCase{ DisableTenantResources: true, ResourceBlueprint: blueprint, ResourceWithoutNullableFieldsBlueprint: blueprint, - ResourcePropertiesToIgnore: []string{"credentials"}, - PatchResource: func(ctx *common.TestContext, apiPath string, objID string, resourceType types.ObjectType, patchLabels []*query.LabelChange, _ bool) { - byID := query.ByField(query.EqualsOperator, "id", objID) - sb, err := ctx.SMRepository.Get(context.Background(), resourceType, byID) - if err != nil { - Fail(fmt.Sprintf("unable to retrieve resource %s: %s", resourceType, err)) - } - - _, err = ctx.SMRepository.Update(context.Background(), sb, patchLabels) - if err != nil { - Fail(fmt.Sprintf("unable to update resource %s: %s", resourceType, err)) - } + ResourcePropertiesToIgnore: []string{"volume_mounts", "endpoints", "bind_resource", "credentials"}, + PatchResource: test.StorageResourcePatch, + AdditionalTests: func(ctx *common.TestContext) { + Context("additional non-generic tests", func() { + var ( + postBindingRequest common.Object + expectedBindingResponse common.Object + ) + + createInstance := func(body common.Object) { + ctx.SMWithOAuth.POST(web.ServiceInstancesURL).WithJSON(body). + Expect(). + Status(http.StatusCreated) + } + + createBinding := func(body common.Object) { + ctx.SMWithOAuth.POST(web.ServiceBindingsURL).WithJSON(body). + Expect(). + Status(http.StatusCreated). + JSON().Object(). + ContainsMap(expectedBindingResponse).ContainsKey("id") + } + + BeforeEach(func() { + var err error + instanceID, err := uuid.NewV4() + if err != nil { + panic(err) + } + + instanceBody := common.Object{ + "id": instanceID.String(), + "name": "test-instance", + "service_plan_id": newServicePlan(ctx), + "maintenance_info": "{}", + } + createInstance(instanceBody) + + bindingID, err := uuid.NewV4() + if err != nil { + panic(err) + } + + bindingName := "test-binding" + + postBindingRequest = common.Object{ + "id": bindingID.String(), + "name": bindingName, + "service_instance_id": instanceID.String(), + } + expectedBindingResponse = common.Object{ + "id": bindingID.String(), + "name": bindingName, + "service_instance_id": instanceID.String(), + } + }) + + AfterEach(func() { + ctx.CleanupAdditionalResources() + }) + + Describe("POST", func() { + Context("when content type is not JSON", func() { + It("returns 415", func() { + ctx.SMWithOAuth.POST(web.ServiceBindingsURL).WithText("text"). + Expect(). + Status(http.StatusUnsupportedMediaType). + JSON().Object(). + Keys().Contains("error", "description") + }) + }) + + Context("when request body is not a valid JSON", func() { + It("returns 400", func() { + ctx.SMWithOAuth.POST(web.ServiceBindingsURL). + WithText("invalid json"). + WithHeader("content-type", "application/json"). + Expect(). + Status(http.StatusBadRequest). + JSON().Object(). + Keys().Contains("error", "description") + }) + }) + + Context("when a request body field is missing", func() { + assertPOSTReturns400WhenFieldIsMissing := func(field string) { + BeforeEach(func() { + delete(postBindingRequest, field) + delete(expectedBindingResponse, field) + }) + + It("returns 400", func() { + ctx.SMWithOAuth.POST(web.ServiceBindingsURL).WithJSON(postBindingRequest). + Expect(). + Status(http.StatusBadRequest). + JSON().Object(). + Keys().Contains("error", "description") + }) + } + + assertPOSTReturns201WhenFieldIsMissing := func(field string) { + BeforeEach(func() { + delete(postBindingRequest, field) + delete(expectedBindingResponse, field) + }) + + It("returns 201", func() { + createBinding(postBindingRequest) + }) + } + + Context("when id field is missing", func() { + assertPOSTReturns201WhenFieldIsMissing("id") + }) + + Context("when name field is missing", func() { + assertPOSTReturns400WhenFieldIsMissing("name") + }) + + Context("when service_instance_id field is missing", func() { + assertPOSTReturns400WhenFieldIsMissing("service_instance_id") + }) + + }) + + Context("when request body id field is invalid", func() { + It("should return 400", func() { + postBindingRequest["id"] = "binding/1" + resp := ctx.SMWithOAuth.POST(web.ServiceBindingsURL). + WithJSON(postBindingRequest). + Expect().Status(http.StatusBadRequest).JSON().Object() + + resp.Value("description").Equal("binding/1 contains invalid character(s)") + }) + }) + + Context("With async query param", func() { + It("succeeds", func() { + resp := ctx.SMWithOAuth.POST(web.ServiceBindingsURL).WithJSON(postBindingRequest). + WithQuery("async", "true"). + Expect(). + Status(http.StatusAccepted) + + test.ExpectOperation(ctx.SMWithOAuth, resp, types.SUCCEEDED) + + ctx.SMWithOAuth.GET(fmt.Sprintf("%s/%s", web.ServiceBindingsURL, postBindingRequest["id"])).Expect(). + Status(http.StatusOK). + JSON().Object(). + ContainsMap(expectedBindingResponse).ContainsKey("id") + }) + }) + }) + + }) }, - AdditionalTests: func(ctx *common.TestContext) {}, }) -func blueprint(ctx *common.TestContext, auth *common.SMExpect, _ bool) common.Object { - instanceIDObj, err := uuid.NewV4() +func blueprint(ctx *common.TestContext, auth *common.SMExpect, async bool) common.Object { + ID, err := uuid.NewV4() if err != nil { Fail(fmt.Sprintf("failed to generate instance GUID: %s", err)) } - instanceID := instanceIDObj.String() + instanceID := "instance-" + ID.String() - ctx.SMWithOAuth.POST(web.ServiceInstancesURL).WithJSON(common.Object{ - "id": instanceID, - "name": instanceID + "name", - "service_plan_id": newServicePlan(ctx), - "maintenance_info": "{}", - }).Expect().Status(http.StatusCreated) + resp := ctx.SMWithOAuth.POST(web.ServiceInstancesURL). + WithQuery("async", strconv.FormatBool(async)). + WithJSON(common.Object{ + "id": instanceID, + "name": instanceID + "name", + "service_plan_id": newServicePlan(ctx), + "maintenance_info": "{}", + }).Expect() - serviceBindingObj := service_binding.Prepare(instanceID, fmt.Sprintf(`{"%s":"%s"}`, TenantIdentifier, TenantValue), `{"password": "secret"}`) - _, err = ctx.SMRepository.Create(context.Background(), serviceBindingObj) - if err != nil { - Fail(fmt.Sprintf("could not create service binding: %s", err)) + if async { + test.ExpectSuccessfulAsyncResourceCreation(resp, auth, instanceID, web.ServiceInstancesURL) + } else { + resp.Status(http.StatusCreated) + } + + bindingID := "binding-" + ID.String() + + resp = ctx.SMWithOAuth.POST(web.ServiceBindingsURL). + WithQuery("async", strconv.FormatBool(async)). + WithJSON(common.Object{ + "id": bindingID, + "name": bindingID + "name", + "service_instance_id": instanceID, + }).Expect() + + var binding map[string]interface{} + if async { + binding = test.ExpectSuccessfulAsyncResourceCreation(resp, auth, bindingID, web.ServiceBindingsURL) + } else { + binding = resp.Status(http.StatusCreated).JSON().Object().Raw() } - binding := auth.ListWithQuery(web.ServiceBindingsURL, fmt.Sprintf("fieldQuery=id eq '%s'", serviceBindingObj.ID)).First().Object().Raw() delete(binding, "credentials") return binding } diff --git a/test/service_instance_test/service_instance_test.go b/test/service_instance_test/service_instance_test.go index debac8690..7a71d0923 100644 --- a/test/service_instance_test/service_instance_test.go +++ b/test/service_instance_test/service_instance_test.go @@ -27,7 +27,6 @@ import ( "net/http" "testing" - "github.com/Peripli/service-manager/pkg/query" "github.com/Peripli/service-manager/pkg/types" "github.com/Peripli/service-manager/pkg/web" @@ -71,18 +70,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ ResourceBlueprint: blueprint, ResourceWithoutNullableFieldsBlueprint: blueprint, ResourcePropertiesToIgnore: []string{"platform_id"}, - PatchResource: func(ctx *common.TestContext, apiPath string, objID string, resourceType types.ObjectType, patchLabels []*query.LabelChange, _ bool) { - byID := query.ByField(query.EqualsOperator, "id", objID) - si, err := ctx.SMRepository.Get(context.Background(), resourceType, byID) - if err != nil { - Fail(fmt.Sprintf("unable to retrieve resource %s: %s", resourceType, err)) - } - - _, err = ctx.SMRepository.Update(context.Background(), si, patchLabels) - if err != nil { - Fail(fmt.Sprintf("unable to update resource %s: %s", resourceType, err)) - } - }, + PatchResource: test.APIResourcePatch, AdditionalTests: func(ctx *common.TestContext) { Context("additional non-generic tests", func() { var ( @@ -434,14 +422,7 @@ func blueprint(ctx *common.TestContext, auth *common.SMExpect, async bool) commo var instance map[string]interface{} if async { - resp = resp.Status(http.StatusAccepted) - if err := test.ExpectOperation(auth, resp, types.SUCCEEDED); err != nil { - panic(err) - } - - instance = auth.GET(web.ServiceInstancesURL + "/" + instanceID.String()). - Expect().JSON().Object().Raw() - + instance = test.ExpectSuccessfulAsyncResourceCreation(resp, auth, instanceID.String(), web.ServiceInstancesURL) } else { instance = resp.Status(http.StatusCreated).JSON().Object().Raw() } diff --git a/test/service_offering_test/service_offering_test.go b/test/service_offering_test/service_offering_test.go index bd6527fb2..9bfb86fa6 100644 --- a/test/service_offering_test/service_offering_test.go +++ b/test/service_offering_test/service_offering_test.go @@ -51,7 +51,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ DisableTenantResources: true, ResourceBlueprint: blueprint, ResourceWithoutNullableFieldsBlueprint: blueprint, - PatchResource: test.DefaultResourcePatch, + PatchResource: test.APIResourcePatch, AdditionalTests: func(ctx *common.TestContext) { Context("additional non-generic tests", func() { Describe("PATCH", func() { diff --git a/test/service_plan_test/service_plan_test.go b/test/service_plan_test/service_plan_test.go index 7298d0bbb..029384dd4 100644 --- a/test/service_plan_test/service_plan_test.go +++ b/test/service_plan_test/service_plan_test.go @@ -47,7 +47,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ DisableTenantResources: true, ResourceBlueprint: blueprint, ResourceWithoutNullableFieldsBlueprint: blueprint, - PatchResource: test.DefaultResourcePatch, + PatchResource: test.APIResourcePatch, AdditionalTests: func(ctx *common.TestContext) { Context("additional non-generic tests", func() { Describe("PATCH", func() { diff --git a/test/test.go b/test/test.go index 7478ff3ff..1ea8d96e3 100644 --- a/test/test.go +++ b/test/test.go @@ -95,7 +95,7 @@ func stripObject(obj common.Object, properties ...string) { } } -func DefaultResourcePatch(ctx *common.TestContext, apiPath string, objID string, _ types.ObjectType, patchLabels []*query.LabelChange, async bool) { +func APIResourcePatch(ctx *common.TestContext, apiPath string, objID string, _ types.ObjectType, patchLabels []*query.LabelChange, async bool) { patchLabelsBody := make(map[string]interface{}) patchLabelsBody["labels"] = patchLabels @@ -111,7 +111,31 @@ func DefaultResourcePatch(ctx *common.TestContext, apiPath string, objID string, } else { resp.Status(http.StatusOK) } +} + +func StorageResourcePatch(ctx *common.TestContext, _ string, objID string, resourceType types.ObjectType, patchLabels []*query.LabelChange, _ bool) { + byID := query.ByField(query.EqualsOperator, "id", objID) + sb, err := ctx.SMRepository.Get(context.Background(), resourceType, byID) + if err != nil { + Fail(fmt.Sprintf("unable to retrieve resource %s: %s", resourceType, err)) + } + + _, err = ctx.SMRepository.Update(context.Background(), sb, patchLabels) + if err != nil { + Fail(fmt.Sprintf("unable to update resource %s: %s", resourceType, err)) + } +} + +func ExpectSuccessfulAsyncResourceCreation(resp *httpexpect.Response, SM *common.SMExpect, resourceID, resourceURL string) map[string]interface{} { + resp = resp.Status(http.StatusAccepted) + if err := ExpectOperation(SM, resp, types.SUCCEEDED); err != nil { + panic(err) + } + + obj := SM.GET(resourceURL + "/" + resourceID). + Expect().Status(http.StatusOK).JSON().Object().Raw() + return obj } func ExpectOperation(auth *common.SMExpect, asyncResp *httpexpect.Response, expectedState types.OperationState) error { diff --git a/test/testutil/service_binding/service_binding.go b/test/testutil/service_binding/service_binding.go deleted file mode 100644 index da8111543..000000000 --- a/test/testutil/service_binding/service_binding.go +++ /dev/null @@ -1,36 +0,0 @@ -package service_binding - -import ( - "fmt" - "time" - - "github.com/Peripli/service-manager/pkg/types" - "github.com/gofrs/uuid" - - . "github.com/onsi/ginkgo" -) - -func Prepare(serviceInstanceID string, OSBContext string, credentials string) *types.ServiceBinding { - bindingID, err := uuid.NewV4() - if err != nil { - Fail(fmt.Sprintf("failed to generate binding GUID: %s", err)) - } - - return &types.ServiceBinding{ - Base: types.Base{ - ID: bindingID.String(), - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - }, - Secured: nil, - Name: "test-service-binding", - ServiceInstanceID: serviceInstanceID, - SyslogDrainURL: "drain_url", - RouteServiceURL: "route_service_url", - VolumeMounts: []byte(`[]`), - Endpoints: []byte(`[]`), - Context: []byte(OSBContext), - BindResource: []byte(`{"app_guid": "app-guid"}`), - Credentials: credentials, - } -} diff --git a/test/visibility_test/visibility_test.go b/test/visibility_test/visibility_test.go index fe487970f..52d4caea6 100644 --- a/test/visibility_test/visibility_test.go +++ b/test/visibility_test/visibility_test.go @@ -47,7 +47,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ DisableTenantResources: true, ResourceBlueprint: blueprint(true), ResourceWithoutNullableFieldsBlueprint: blueprint(false), - PatchResource: test.DefaultResourcePatch, + PatchResource: test.APIResourcePatch, AdditionalTests: func(ctx *common.TestContext) { Context("non-generic tests", func() { var ( From 05448e024f9ff406212954d2d1fdeadd40d898af Mon Sep 17 00:00:00 2001 From: Nikolay Mateev Date: Tue, 21 Jan 2020 11:33:08 +0200 Subject: [PATCH 05/15] Instance and Binding visibility and ownership filters (#400) --- Gopkg.lock | 12 +- api/api.go | 3 +- api/base_controller.go | 33 ++- .../check_binding_visibility_filter.go | 132 ++++++++++++ .../check_instance_visibility_filter.go | 100 +++++++++ ...ter.go => service_binding_strip_filter.go} | 24 +-- ...o => service_binding_strip_filter_test.go} | 0 api/filters/service_instance_filter.go | 46 ++-- api/filters/service_instance_strip_filter.go | 77 +++++++ .../service_instance_strip_filter_test.go | 114 ++++++++++ api/osb/check_instance_owner_plugin.go | 20 +- ...go => check_instance_visibility_plugin.go} | 10 +- api/service_binding_controller.go | 13 +- api/service_offering_controller.go | 4 +- api/service_plan_controller.go | 4 +- pkg/query/selection.go | 10 + pkg/sm/sm.go | 11 +- pkg/web/api.go | 14 ++ .../operations_create_interceptor.go | 11 +- test/get.go | 12 +- .../service_binding_test.go | 152 ++++++++++--- .../service_instance_test.go | 201 ++++++++++++++---- 22 files changed, 817 insertions(+), 186 deletions(-) create mode 100644 api/filters/check_binding_visibility_filter.go create mode 100644 api/filters/check_instance_visibility_filter.go rename api/filters/{service_binding_filter.go => service_binding_strip_filter.go} (68%) rename api/filters/{service_binding_filter_test.go => service_binding_strip_filter_test.go} (100%) create mode 100644 api/filters/service_instance_strip_filter.go create mode 100644 api/filters/service_instance_strip_filter_test.go rename api/osb/{check_visibility_plugin.go => check_instance_visibility_plugin.go} (96%) diff --git a/Gopkg.lock b/Gopkg.lock index b4db7b1fd..10934e8a7 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -39,12 +39,12 @@ version = "v1.5.1" [[projects]] - digest = "1:d04889482897652dedae6d8575b479c06fa3eb3c3abe248163b25d3df5fab43e" + digest = "1:3076a0751f5648f3ab1838abfba18bc5bf514cde7e3957500b62fba90633479a" name = "github.com/antlr/antlr4" packages = ["runtime/Go/antlr"] pruneopts = "UT" - revision = "be58ebffde8e29c154192c019608f0a5b8e6a064" - version = "4.7.2" + revision = "d569f917954406e599d780c009224aa14f1920de" + version = "4.8" [[projects]] branch = "master" @@ -285,7 +285,7 @@ [[projects]] branch = "master" - digest = "1:cbc008e8b6866556c853447a1216480aeb10d12d7fd83058dfbb17fbe8f36355" + digest = "1:e8501d7fa801be523f103178ced935b37778925c7338f0f94e50c14a4d450f4a" name = "github.com/lib/pq" packages = [ ".", @@ -293,7 +293,7 @@ "scram", ] pruneopts = "UT" - revision = "d6fd2025777896bf42bbb81fa748af573ad799f4" + revision = "9eb3fc897d6fd97dd4aad3d0404b54e2f7cc56be" [[projects]] digest = "1:5a0ef768465592efca0412f7e838cdc0826712f8447e70e6ccc52eb441e9ab13" @@ -621,7 +621,7 @@ name = "golang.org/x/sys" packages = ["unix"] pruneopts = "UT" - revision = "b77594299b429d05028403d72b68172959c7dad5" + revision = "59e60aa80a0c64fa4b088976ee16ad7f04252c25" [[projects]] digest = "1:28deae5fe892797ff37a317b5bcda96d11d1c90dadd89f1337651df3bc4c586e" diff --git a/api/api.go b/api/api.go index 4a8bae745..c1ee90654 100644 --- a/api/api.go +++ b/api/api.go @@ -134,7 +134,8 @@ func New(ctx context.Context, e env.Environment, options *Options) (*web.API, er &filters.SelectionCriteria{}, filters.NewProtectedLabelsFilter(options.APISettings.ProtectedLabels), &filters.ProtectedSMPlatformFilter{}, - &filters.ServiceInstanceValidationFilter{}, + &filters.ServiceInstanceFilter{}, + &filters.ServiceInstanceStripFilter{}, &filters.ServiceBindingStripFilter{}, &filters.PlatformAwareVisibilityFilter{}, &filters.PatchOnlyLabelsFilter{}, diff --git a/api/base_controller.go b/api/base_controller.go index dd40e87c5..1df6ac7ff 100644 --- a/api/base_controller.go +++ b/api/base_controller.go @@ -39,13 +39,6 @@ import ( "github.com/Peripli/service-manager/pkg/web" ) -const ( - PathParamID = "id" - PathParamResourceID = "resource_id" - QueryParamAsync = "async" - QueryParamLastOp = "last_op" -) - // pagingLimitOffset is a constant which is needed to identify if there are more items in the DB. // If there is 1 more item than requested, we need to generate a token for the next page. // The last item is omitted. @@ -104,14 +97,14 @@ func (c *BaseController) Routes() []web.Route { { Endpoint: web.Endpoint{ Method: http.MethodGet, - Path: fmt.Sprintf("%s/{%s}", c.resourceBaseURL, PathParamResourceID), + Path: fmt.Sprintf("%s/{%s}", c.resourceBaseURL, web.PathParamResourceID), }, Handler: c.GetSingleObject, }, { Endpoint: web.Endpoint{ Method: http.MethodGet, - Path: fmt.Sprintf("%s/{%s}%s/{%s}", c.resourceBaseURL, PathParamResourceID, web.OperationsURL, PathParamID), + Path: fmt.Sprintf("%s/{%s}%s/{%s}", c.resourceBaseURL, web.PathParamResourceID, web.OperationsURL, web.PathParamID), }, Handler: c.GetOperation, }, @@ -132,14 +125,14 @@ func (c *BaseController) Routes() []web.Route { { Endpoint: web.Endpoint{ Method: http.MethodDelete, - Path: fmt.Sprintf("%s/{%s}", c.resourceBaseURL, PathParamResourceID), + Path: fmt.Sprintf("%s/{%s}", c.resourceBaseURL, web.PathParamResourceID), }, Handler: c.DeleteSingleObject, }, { Endpoint: web.Endpoint{ Method: http.MethodPatch, - Path: fmt.Sprintf("%s/{%s}", c.resourceBaseURL, PathParamResourceID), + Path: fmt.Sprintf("%s/{%s}", c.resourceBaseURL, web.PathParamResourceID), }, Handler: c.PatchObject, }, @@ -171,7 +164,7 @@ func (c *BaseController) CreateObject(r *web.Request) (*web.Response, error) { return repository.Create(ctx, result) } - isAsync := r.URL.Query().Get(QueryParamAsync) + isAsync := r.URL.Query().Get(web.QueryParamAsync) if isAsync == "true" { log.C(ctx).Debugf("Request will be executed asynchronously") if err := c.checkAsyncSupport(); err != nil { @@ -216,7 +209,7 @@ func (c *BaseController) DeleteObjects(r *web.Request) (*web.Response, error) { return nil, repository.Delete(ctx, c.objectType, criteria...) } - isAsync := r.URL.Query().Get(QueryParamAsync) + isAsync := r.URL.Query().Get(web.QueryParamAsync) if isAsync == "true" { log.C(ctx).Debugf("Request will be executed asynchronously") if err := c.checkAsyncSupport(); err != nil { @@ -262,7 +255,7 @@ func (c *BaseController) DeleteObjects(r *web.Request) (*web.Response, error) { // DeleteSingleObject handles the deletion of the object with the id specified in the request func (c *BaseController) DeleteSingleObject(r *web.Request) (*web.Response, error) { - objectID := r.PathParams[PathParamResourceID] + objectID := r.PathParams[web.PathParamResourceID] ctx := r.Context() log.C(ctx).Debugf("Deleting %s with id %s", c.objectType, objectID) @@ -278,7 +271,7 @@ func (c *BaseController) DeleteSingleObject(r *web.Request) (*web.Response, erro // GetSingleObject handles the fetching of a single object with the id specified in the request func (c *BaseController) GetSingleObject(r *web.Request) (*web.Response, error) { - objectID := r.PathParams[PathParamResourceID] + objectID := r.PathParams[web.PathParamResourceID] ctx := r.Context() log.C(ctx).Debugf("Getting %s with id %s", c.objectType, objectID) @@ -290,7 +283,7 @@ func (c *BaseController) GetSingleObject(r *web.Request) (*web.Response, error) } cleanObject(ctx, object) - displayOp := r.URL.Query().Get(QueryParamLastOp) + displayOp := r.URL.Query().Get(web.QueryParamLastOp) if displayOp == "true" { if err := attachLastOperation(ctx, objectID, object, r, c.repository); err != nil { return nil, err @@ -302,8 +295,8 @@ func (c *BaseController) GetSingleObject(r *web.Request) (*web.Response, error) // GetOperation handles the fetching of a single operation with the id specified for the specified resource func (c *BaseController) GetOperation(r *web.Request) (*web.Response, error) { - objectID := r.PathParams[PathParamResourceID] - operationID := r.PathParams[PathParamID] + objectID := r.PathParams[web.PathParamResourceID] + operationID := r.PathParams[web.PathParamID] ctx := r.Context() log.C(ctx).Debugf("Getting operation with id %s for object of type %s with id %s", operationID, c.objectType, objectID) @@ -385,7 +378,7 @@ func (c *BaseController) ListObjects(r *web.Request) (*web.Response, error) { // PatchObject handles the update of the object with the id specified in the request func (c *BaseController) PatchObject(r *web.Request) (*web.Response, error) { - objectID := r.PathParams[PathParamResourceID] + objectID := r.PathParams[web.PathParamResourceID] ctx := r.Context() log.C(ctx).Debugf("Updating %s with id %s", c.objectType, objectID) @@ -426,7 +419,7 @@ func (c *BaseController) PatchObject(r *web.Request) (*web.Response, error) { return repository.Update(ctx, objFromDB, labelChanges, criteria...) } - isAsync := r.URL.Query().Get(QueryParamAsync) + isAsync := r.URL.Query().Get(web.QueryParamAsync) if isAsync == "true" { log.C(ctx).Debugf("Request will be executed asynchronously") if err := c.checkAsyncSupport(); err != nil { diff --git a/api/filters/check_binding_visibility_filter.go b/api/filters/check_binding_visibility_filter.go new file mode 100644 index 000000000..d0ced9819 --- /dev/null +++ b/api/filters/check_binding_visibility_filter.go @@ -0,0 +1,132 @@ +/* + * Copyright 2018 The Service Manager Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package filters + +import ( + "context" + "github.com/Peripli/service-manager/pkg/log" + "github.com/Peripli/service-manager/pkg/query" + "github.com/Peripli/service-manager/pkg/types" + "github.com/Peripli/service-manager/pkg/util" + "github.com/Peripli/service-manager/storage" + "github.com/tidwall/gjson" + "net/http" + + "github.com/Peripli/service-manager/pkg/web" +) + +const serviceInstanceIDProperty = "service_instance_id" + +const ServiceBindingVisibilityFilterName = "ServiceBindingVisibilityFilter" + +// serviceBindingVisibilityFilter ensures that the tenant making the create/delete bind request +// is the actual owner of the service instance and that the bind request is for an instance created in the SM platform. +type serviceBindingVisibilityFilter struct { + repository storage.Repository + tenantIdentifier string +} + +// NewServiceBindingVisibilityFilter creates a new serviceInstanceVisibilityFilter filter +func NewServiceBindingVisibilityFilter(repository storage.Repository, tenantIdentifier string) *serviceBindingVisibilityFilter { + return &serviceBindingVisibilityFilter{ + repository: repository, + tenantIdentifier: tenantIdentifier, + } +} + +func (*serviceBindingVisibilityFilter) Name() string { + return ServiceBindingVisibilityFilterName +} + +func (f *serviceBindingVisibilityFilter) Run(req *web.Request, next web.Handler) (*web.Response, error) { + ctx := req.Context() + + tenantID := query.RetrieveFromCriteria(f.tenantIdentifier, query.CriteriaForContext(ctx)...) + if tenantID == "" { + log.C(ctx).Info("Tenant identifier not found in request criteria. Proceeding with the next handler...") + return next.Handle(req) + } + + var err error + var instanceID string + + switch req.Method { + case http.MethodPost: + instanceID = gjson.GetBytes(req.Body, serviceInstanceIDProperty).String() + if instanceID == "" { + log.C(ctx).Info("Service Instance ID is not provided in the request. Proceeding with the next handler...") + return next.Handle(req) + } + case http.MethodDelete: + bindingID := req.PathParams[web.PathParamResourceID] + if bindingID != "" { + log.C(ctx).Info("Service Binding ID is not provided in the request. Proceeding with the next handler...") + return next.Handle(req) + } + instanceID, err = f.fetchInstanceID(ctx, tenantID, bindingID) + if err != nil { + return nil, err + } + } + + criteria := []query.Criterion{ + query.ByField(query.EqualsOperator, platformIDProperty, types.SMPlatform), + query.ByField(query.EqualsOperator, "id", instanceID), + query.ByLabel(query.EqualsOperator, f.tenantIdentifier, tenantID), + } + + count, err := f.repository.Count(ctx, types.ServiceInstanceType, criteria...) + if err != nil { + return nil, util.HandleStorageError(err, types.ServiceInstanceType.String()) + } + + if count != 1 { + return nil, &util.HTTPError{ + ErrorType: "NotFound", + Description: "could not find such service binding(s)", + StatusCode: http.StatusNotFound, + } + } + + return next.Handle(req) +} + +func (*serviceBindingVisibilityFilter) FilterMatchers() []web.FilterMatcher { + return []web.FilterMatcher{ + { + Matchers: []web.Matcher{ + web.Path(web.ServiceBindingsURL + "/**"), + web.Methods(http.MethodPost, http.MethodDelete), + }, + }, + } +} + +func (f *serviceBindingVisibilityFilter) fetchInstanceID(ctx context.Context, tenantID string, bindingID string) (string, error) { + criteria := []query.Criterion{ + query.ByField(query.EqualsOperator, "id", bindingID), + query.ByLabel(query.EqualsOperator, f.tenantIdentifier, tenantID), + } + + object, err := f.repository.Get(ctx, types.ServiceBindingType, criteria...) + if err != nil { + return "", util.HandleStorageError(err, types.ServiceBindingType.String()) + } + + sb := object.(*types.ServiceBinding) + return sb.ServiceInstanceID, nil +} diff --git a/api/filters/check_instance_visibility_filter.go b/api/filters/check_instance_visibility_filter.go new file mode 100644 index 000000000..644d17fef --- /dev/null +++ b/api/filters/check_instance_visibility_filter.go @@ -0,0 +1,100 @@ +/* + * Copyright 2018 The Service Manager Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package filters + +import ( + "github.com/Peripli/service-manager/pkg/log" + "github.com/Peripli/service-manager/pkg/query" + "github.com/Peripli/service-manager/pkg/types" + "github.com/Peripli/service-manager/pkg/util" + "github.com/Peripli/service-manager/storage" + "net/http" + + "github.com/Peripli/service-manager/pkg/web" + "github.com/tidwall/gjson" +) + +const planIDProperty = "service_plan_id" + +const ServiceInstanceVisibilityFilterName = "ServiceInstanceVisibilityFilter" + +// serviceInstanceVisibilityFilter ensures that the tenant making the provisioning/update request +// has the necessary visibilities - i.e. that he has the right to consume the requested plan. +type serviceInstanceVisibilityFilter struct { + repository storage.Repository + tenantIdentifier string +} + +// NewServiceInstanceVisibilityFilter creates a new serviceInstanceVisibilityFilter filter +func NewServiceInstanceVisibilityFilter(repository storage.Repository, tenantIdentifier string) *serviceInstanceVisibilityFilter { + return &serviceInstanceVisibilityFilter{ + repository: repository, + tenantIdentifier: tenantIdentifier, + } +} + +func (*serviceInstanceVisibilityFilter) Name() string { + return ServiceInstanceVisibilityFilterName +} + +func (f *serviceInstanceVisibilityFilter) Run(req *web.Request, next web.Handler) (*web.Response, error) { + ctx := req.Context() + planID := gjson.GetBytes(req.Body, planIDProperty).String() + + if planID == "" { + log.C(ctx).Info("Plan ID is not provided in the request. Proceeding with the next handler...") + return next.Handle(req) + } + + tenantID := query.RetrieveFromCriteria(f.tenantIdentifier, query.CriteriaForContext(ctx)...) + if tenantID == "" { + log.C(ctx).Info("Tenant identifier not found in request criteria. Proceeding with the next handler...") + return next.Handle(req) + } + + criteria := []query.Criterion{ + query.ByField(query.EqualsOperator, platformIDProperty, types.SMPlatform), + query.ByField(query.EqualsOperator, planIDProperty, planID), + query.ByLabel(query.InOperator, f.tenantIdentifier, tenantID), + } + + _, err := f.repository.Get(ctx, types.VisibilityType, criteria...) + if err != nil { + if err == util.ErrNotFoundInStorage { + return nil, &util.HTTPError{ + ErrorType: "NotFound", + Description: "could not find such service plan", + StatusCode: http.StatusNotFound, + } + } + + return nil, util.HandleStorageError(err, types.VisibilityType.String()) + } + + return next.Handle(req) +} + +func (*serviceInstanceVisibilityFilter) FilterMatchers() []web.FilterMatcher { + return []web.FilterMatcher{ + { + Matchers: []web.Matcher{ + web.Path(web.ServiceInstancesURL + "/**"), + web.Methods(http.MethodPost, http.MethodPatch), + }, + }, + } +} diff --git a/api/filters/service_binding_filter.go b/api/filters/service_binding_strip_filter.go similarity index 68% rename from api/filters/service_binding_filter.go rename to api/filters/service_binding_strip_filter.go index 25b29832e..3971d0ac9 100644 --- a/api/filters/service_binding_filter.go +++ b/api/filters/service_binding_strip_filter.go @@ -17,14 +17,9 @@ package filters import ( - "context" "net/http" - "github.com/Peripli/service-manager/pkg/log" - "github.com/Peripli/service-manager/pkg/util" - "github.com/Peripli/service-manager/pkg/web" - "github.com/tidwall/sjson" ) const ServiceBindingStripFilterName = "ServiceBindingStripFilter" @@ -43,30 +38,13 @@ func (*ServiceBindingStripFilter) Name() string { func (*ServiceBindingStripFilter) Run(req *web.Request, next web.Handler) (*web.Response, error) { var err error - req.Body, err = removePropertiesFromRequest( - req.Context(), req.Body, serviceBindingUnmodifiableProperties) + req.Body, err = removePropertiesFromRequest(req.Context(), req.Body, serviceBindingUnmodifiableProperties) if err != nil { return nil, err } return next.Handle(req) } -func removePropertiesFromRequest(ctx context.Context, body []byte, properties []string) ([]byte, error) { - var err error - for _, prop := range properties { - body, err = sjson.DeleteBytes(body, prop) - if err != nil { - log.C(ctx).Errorf("Could not remove %s from body %s", prop, err) - return nil, &util.HTTPError{ - ErrorType: "BadRequest", - Description: "Invalid request body", - StatusCode: http.StatusBadRequest, - } - } - } - return body, nil -} - func (*ServiceBindingStripFilter) FilterMatchers() []web.FilterMatcher { return []web.FilterMatcher{ { diff --git a/api/filters/service_binding_filter_test.go b/api/filters/service_binding_strip_filter_test.go similarity index 100% rename from api/filters/service_binding_filter_test.go rename to api/filters/service_binding_strip_filter_test.go diff --git a/api/filters/service_instance_filter.go b/api/filters/service_instance_filter.go index 110f53b22..82379d7bf 100644 --- a/api/filters/service_instance_filter.go +++ b/api/filters/service_instance_filter.go @@ -18,6 +18,7 @@ package filters import ( "fmt" + "github.com/Peripli/service-manager/pkg/query" "github.com/Peripli/service-manager/pkg/types" "github.com/Peripli/service-manager/pkg/util" "github.com/Peripli/service-manager/pkg/web" @@ -28,17 +29,19 @@ import ( const platformIDProperty = "platform_id" -const ServiceInstanceValidationFilterName = "ServiceInstanceValidationFilter" +const ServiceInstanceFilterName = "ServiceInstanceFilter" -// ServiceInstanceValidationFilter checks patch request for service offerings and plans include only label changes -type ServiceInstanceValidationFilter struct { +// ServiceInstanceFilter ensures that if a platform is provided for provisioning request that it's the SM Platform. +// It also limits Patch and Delete requests to instances created in the SM platform. +type ServiceInstanceFilter struct { } -func (*ServiceInstanceValidationFilter) Name() string { - return ServiceInstanceValidationFilterName +func (*ServiceInstanceFilter) Name() string { + return ServiceInstanceFilterName } -func (*ServiceInstanceValidationFilter) Run(req *web.Request, next web.Handler) (*web.Response, error) { +func (*ServiceInstanceFilter) Run(req *web.Request, next web.Handler) (*web.Response, error) { + reqCtx := req.Context() platformID := gjson.GetBytes(req.Body, platformIDProperty).Str if platformID != "" && platformID != types.SMPlatform { @@ -50,32 +53,35 @@ func (*ServiceInstanceValidationFilter) Run(req *web.Request, next web.Handler) } var err error - if req.Method == http.MethodPost && platformID == "" { - req.Body, err = sjson.SetBytes(req.Body, platformIDProperty, types.SMPlatform) + + switch req.Request.Method { + case http.MethodPost: + if platformID == "" { + req.Body, err = sjson.SetBytes(req.Body, platformIDProperty, types.SMPlatform) + if err != nil { + return nil, err + } + } + case http.MethodPatch: + fallthrough + case http.MethodDelete: + byPlatformID := query.ByField(query.EqualsOperator, platformIDProperty, types.SMPlatform) + ctx, err := query.AddCriteria(reqCtx, byPlatformID) if err != nil { return nil, err } - } - - req.Body, err = sjson.DeleteBytes(req.Body, "ready") - if err != nil { - return nil, err - } - - req.Body, err = sjson.DeleteBytes(req.Body, "usable") - if err != nil { - return nil, err + req.Request = req.WithContext(ctx) } return next.Handle(req) } -func (*ServiceInstanceValidationFilter) FilterMatchers() []web.FilterMatcher { +func (*ServiceInstanceFilter) FilterMatchers() []web.FilterMatcher { return []web.FilterMatcher{ { Matchers: []web.Matcher{ web.Path(web.ServiceInstancesURL + "/**"), - web.Methods(http.MethodPost, http.MethodPatch), + web.Methods(http.MethodPost, http.MethodPatch, http.MethodDelete), }, }, } diff --git a/api/filters/service_instance_strip_filter.go b/api/filters/service_instance_strip_filter.go new file mode 100644 index 000000000..f9ee01c91 --- /dev/null +++ b/api/filters/service_instance_strip_filter.go @@ -0,0 +1,77 @@ +/* + * Copyright 2018 The Service Manager Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package filters + +import ( + "context" + "github.com/Peripli/service-manager/pkg/log" + "github.com/Peripli/service-manager/pkg/util" + "github.com/tidwall/sjson" + "net/http" + + "github.com/Peripli/service-manager/pkg/web" +) + +const ServiceInstanceStripFilterName = "ServiceInstanceStripFilter" + +var serviceInstanceUnmodifiableProperties = []string{ + "ready", "usable", +} + +// ServiceInstanceStripFilter checks post/patch request body for unmodifiable properties +type ServiceInstanceStripFilter struct { +} + +func (*ServiceInstanceStripFilter) Name() string { + return ServiceInstanceStripFilterName +} + +func (*ServiceInstanceStripFilter) Run(req *web.Request, next web.Handler) (*web.Response, error) { + var err error + req.Body, err = removePropertiesFromRequest(req.Context(), req.Body, serviceInstanceUnmodifiableProperties) + if err != nil { + return nil, err + } + return next.Handle(req) +} + +func (*ServiceInstanceStripFilter) FilterMatchers() []web.FilterMatcher { + return []web.FilterMatcher{ + { + Matchers: []web.Matcher{ + web.Path(web.ServiceInstancesURL + "/**"), + web.Methods(http.MethodPost, http.MethodPatch), + }, + }, + } +} + +func removePropertiesFromRequest(ctx context.Context, body []byte, props []string) ([]byte, error) { + var err error + for _, prop := range props { + body, err = sjson.DeleteBytes(body, prop) + if err != nil { + log.C(ctx).Errorf("Could not remove %s from body %s", prop, err) + return nil, &util.HTTPError{ + ErrorType: "BadRequest", + Description: "Invalid request body", + StatusCode: http.StatusBadRequest, + } + } + } + return body, nil +} diff --git a/api/filters/service_instance_strip_filter_test.go b/api/filters/service_instance_strip_filter_test.go new file mode 100644 index 000000000..cb3fdb216 --- /dev/null +++ b/api/filters/service_instance_strip_filter_test.go @@ -0,0 +1,114 @@ +/* + * Copyright 2018 The Service Manager Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package filters + +import ( + "net/http" + + "github.com/tidwall/gjson" + + "github.com/tidwall/sjson" + + "github.com/Peripli/service-manager/pkg/web/webfakes" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Service Instance Strip Filter", func() { + const ( + propertyNotToBeDeleted = "some_prop" + defaultValue = "value" + invalidJSON = "invalid json" + ) + var ( + filter ServiceInstanceStripFilter + handler *webfakes.FakeHandler + jsonWithPropertiesToStrip string + ) + + BeforeEach(func() { + filter = ServiceInstanceStripFilter{} + handler = &webfakes.FakeHandler{} + jsonWithPropertiesToStrip = `{}` + }) + + Context("Create instance", func() { + When("body has properties which cannot be set", func() { + It("should remove them from request body", func() { + var err error + for _, prop := range serviceInstanceUnmodifiableProperties { + jsonWithPropertiesToStrip, err = sjson.Set(jsonWithPropertiesToStrip, prop, defaultValue) + Expect(err).ToNot(HaveOccurred()) + } + jsonWithPropertiesToStrip, err = sjson.Set(jsonWithPropertiesToStrip, propertyNotToBeDeleted, defaultValue) + Expect(err).ToNot(HaveOccurred()) + + req := mockedRequest(http.MethodPost, jsonWithPropertiesToStrip) + _, err = filter.Run(req, handler) + Expect(err).ToNot(HaveOccurred()) + Expect(handler.HandleCallCount()).To(Equal(1)) + requestBody := handler.HandleArgsForCall(0).Body + for _, prop := range serviceInstanceUnmodifiableProperties { + Expect(gjson.GetBytes(requestBody, prop).String()).To(BeEmpty()) + } + Expect(gjson.GetBytes(requestBody, propertyNotToBeDeleted).String()).To(Equal(defaultValue)) + }) + }) + When("body is invalid json", func() { + It("should do nothing", func() { + req := mockedRequest(http.MethodPost, invalidJSON) + filter.Run(req, handler) + Expect(handler.HandleCallCount()).To(Equal(1)) + requestBody := handler.HandleArgsForCall(0).Body + Expect(string(requestBody)).To(Equal(invalidJSON)) + }) + }) + }) + + Context("Create instance", func() { + When("body has properties which cannot be set", func() { + It("should remove them from request body", func() { + var err error + for _, prop := range serviceInstanceUnmodifiableProperties { + jsonWithPropertiesToStrip, err = sjson.Set(jsonWithPropertiesToStrip, prop, defaultValue) + Expect(err).ToNot(HaveOccurred()) + } + jsonWithPropertiesToStrip, err = sjson.Set(jsonWithPropertiesToStrip, propertyNotToBeDeleted, defaultValue) + Expect(err).ToNot(HaveOccurred()) + + req := mockedRequest(http.MethodPatch, jsonWithPropertiesToStrip) + _, err = filter.Run(req, handler) + Expect(err).ToNot(HaveOccurred()) + Expect(handler.HandleCallCount()).To(Equal(1)) + requestBody := handler.HandleArgsForCall(0).Body + for _, prop := range serviceInstanceUnmodifiableProperties { + Expect(gjson.GetBytes(requestBody, prop).String()).To(BeEmpty()) + } + Expect(gjson.GetBytes(requestBody, propertyNotToBeDeleted).String()).To(Equal(defaultValue)) + }) + }) + When("body is invalid json", func() { + It("should do nothing", func() { + req := mockedRequest(http.MethodPatch, invalidJSON) + filter.Run(req, handler) + Expect(handler.HandleCallCount()).To(Equal(1)) + requestBody := handler.HandleArgsForCall(0).Body + Expect(string(requestBody)).To(Equal(invalidJSON)) + }) + }) + }) +}) diff --git a/api/osb/check_instance_owner_plugin.go b/api/osb/check_instance_owner_plugin.go index 49ede3659..e5bdb9e6d 100644 --- a/api/osb/check_instance_owner_plugin.go +++ b/api/osb/check_instance_owner_plugin.go @@ -12,37 +12,37 @@ import ( "github.com/tidwall/gjson" ) -const CheckInstanceOwnerPluginName = "CheckInstanceOwnerPlugin" +const CheckInstanceOwnerhipPluginName = "CheckInstanceOwnershipPlugin" -type checkInstanceOwnerPlugin struct { +type checkInstanceOwnershipPlugin struct { repository storage.Repository tenantIdentifier string } -// NewCheckInstanceOwnerPlugin creates new plugin that checks the owner of the instance -func NewCheckInstanceOwnerPlugin(repository storage.Repository, tenantIdentifier string) *checkInstanceOwnerPlugin { - return &checkInstanceOwnerPlugin{ +// NewCheckInstanceOwnershipPlugin creates new plugin that checks the owner of the instance +func NewCheckInstanceOwnershipPlugin(repository storage.Repository, tenantIdentifier string) *checkInstanceOwnershipPlugin { + return &checkInstanceOwnershipPlugin{ repository: repository, tenantIdentifier: tenantIdentifier, } } // Name returns the name of the plugin -func (p *checkInstanceOwnerPlugin) Name() string { - return CheckInstanceOwnerPluginName +func (p *checkInstanceOwnershipPlugin) Name() string { + return CheckInstanceOwnerhipPluginName } // Bind intercepts bind requests and check if the instance owner is the same as the one requesting the bind operation -func (p *checkInstanceOwnerPlugin) Bind(req *web.Request, next web.Handler) (*web.Response, error) { +func (p *checkInstanceOwnershipPlugin) Bind(req *web.Request, next web.Handler) (*web.Response, error) { return p.assertOwner(req, next) } // UpdateService intercepts update service instance requests and check if the instance owner is the same as the one requesting the operation -func (p *checkInstanceOwnerPlugin) UpdateService(req *web.Request, next web.Handler) (*web.Response, error) { +func (p *checkInstanceOwnershipPlugin) UpdateService(req *web.Request, next web.Handler) (*web.Response, error) { return p.assertOwner(req, next) } -func (p *checkInstanceOwnerPlugin) assertOwner(req *web.Request, next web.Handler) (*web.Response, error) { +func (p *checkInstanceOwnershipPlugin) assertOwner(req *web.Request, next web.Handler) (*web.Response, error) { ctx := req.Context() callerTenantID := gjson.GetBytes(req.Body, "context."+p.tenantIdentifier).String() if len(callerTenantID) == 0 { diff --git a/api/osb/check_visibility_plugin.go b/api/osb/check_instance_visibility_plugin.go similarity index 96% rename from api/osb/check_visibility_plugin.go rename to api/osb/check_instance_visibility_plugin.go index 339de3c27..e2dba3e4c 100644 --- a/api/osb/check_visibility_plugin.go +++ b/api/osb/check_instance_visibility_plugin.go @@ -2,15 +2,15 @@ package osb import ( "encoding/json" - "net/http" - "github.com/Peripli/service-manager/pkg/log" "github.com/Peripli/service-manager/pkg/query" "github.com/Peripli/service-manager/pkg/types" "github.com/Peripli/service-manager/pkg/util" + "github.com/Peripli/service-manager/pkg/util/slice" "github.com/Peripli/service-manager/pkg/web" "github.com/Peripli/service-manager/storage" "github.com/tidwall/gjson" + "net/http" ) const CheckVisibilityPluginName = "CheckVisibilityPlugin" @@ -106,10 +106,8 @@ func (p *checkVisibilityPlugin) checkVisibility(req *web.Request, next web.Handl if !ok { return next.Handle(req) } - for _, orgGUID := range orgGUIDs { - if payloadOrgGUID == orgGUID { - return next.Handle(req) - } + if slice.StringsAnyEquals(orgGUIDs, payloadOrgGUID) { + return next.Handle(req) } } } diff --git a/api/service_binding_controller.go b/api/service_binding_controller.go index 5fcf7b76e..69135cd9d 100644 --- a/api/service_binding_controller.go +++ b/api/service_binding_controller.go @@ -50,14 +50,14 @@ func (c *ServiceBindingController) Routes() []web.Route { { Endpoint: web.Endpoint{ Method: http.MethodGet, - Path: fmt.Sprintf("%s/{%s}", c.resourceBaseURL, PathParamResourceID), + Path: fmt.Sprintf("%s/{%s}", c.resourceBaseURL, web.PathParamResourceID), }, Handler: c.GetSingleObject, }, { Endpoint: web.Endpoint{ Method: http.MethodGet, - Path: fmt.Sprintf("%s/{%s}%s/{%s}", c.resourceBaseURL, PathParamResourceID, web.OperationsURL, PathParamID), + Path: fmt.Sprintf("%s/{%s}%s/{%s}", c.resourceBaseURL, web.PathParamResourceID, web.OperationsURL, web.PathParamID), }, Handler: c.GetOperation, }, @@ -71,14 +71,7 @@ func (c *ServiceBindingController) Routes() []web.Route { { Endpoint: web.Endpoint{ Method: http.MethodDelete, - Path: c.resourceBaseURL, - }, - Handler: c.DeleteObjects, - }, - { - Endpoint: web.Endpoint{ - Method: http.MethodDelete, - Path: fmt.Sprintf("%s/{%s}", c.resourceBaseURL, PathParamResourceID), + Path: fmt.Sprintf("%s/{%s}", c.resourceBaseURL, web.PathParamResourceID), }, Handler: c.DeleteSingleObject, }, diff --git a/api/service_offering_controller.go b/api/service_offering_controller.go index af72e59d6..34f1d2a8e 100644 --- a/api/service_offering_controller.go +++ b/api/service_offering_controller.go @@ -41,7 +41,7 @@ func (c *ServiceOfferingController) Routes() []web.Route { { Endpoint: web.Endpoint{ Method: http.MethodGet, - Path: fmt.Sprintf("%s/{%s}", web.ServiceOfferingsURL, PathParamResourceID), + Path: fmt.Sprintf("%s/{%s}", web.ServiceOfferingsURL, web.PathParamResourceID), }, Handler: c.GetSingleObject, }, @@ -55,7 +55,7 @@ func (c *ServiceOfferingController) Routes() []web.Route { { Endpoint: web.Endpoint{ Method: http.MethodPatch, - Path: fmt.Sprintf("%s/{%s}", web.ServiceOfferingsURL, PathParamResourceID), + Path: fmt.Sprintf("%s/{%s}", web.ServiceOfferingsURL, web.PathParamResourceID), }, Handler: c.PatchObject, }, diff --git a/api/service_plan_controller.go b/api/service_plan_controller.go index dfbba4601..69d9d98b7 100644 --- a/api/service_plan_controller.go +++ b/api/service_plan_controller.go @@ -42,7 +42,7 @@ func (c *ServicePlanController) Routes() []web.Route { { Endpoint: web.Endpoint{ Method: http.MethodGet, - Path: fmt.Sprintf("%s/{%s}", web.ServicePlansURL, PathParamResourceID), + Path: fmt.Sprintf("%s/{%s}", web.ServicePlansURL, web.PathParamResourceID), }, Handler: c.GetSingleObject, }, @@ -56,7 +56,7 @@ func (c *ServicePlanController) Routes() []web.Route { { Endpoint: web.Endpoint{ Method: http.MethodPatch, - Path: fmt.Sprintf("%s/{%s}", web.ServicePlansURL, PathParamResourceID), + Path: fmt.Sprintf("%s/{%s}", web.ServicePlansURL, web.PathParamResourceID), }, Handler: c.PatchObject, }, diff --git a/pkg/query/selection.go b/pkg/query/selection.go index ed1ce6c4a..27f06441e 100644 --- a/pkg/query/selection.go +++ b/pkg/query/selection.go @@ -268,6 +268,16 @@ func Parse(criterionType CriterionType, expression string) ([]Criterion, error) return criteria, nil } +// RetrieveFromCriteria searches for the value (rightOp) of a given key (leftOp) in a set of criteria +func RetrieveFromCriteria(key string, criteria ...Criterion) string { + for _, criterion := range criteria { + if criterion.LeftOp == key { + return criterion.RightOp[0] + } + } + return "" +} + func isNumeric(str string) bool { _, err := strconv.Atoi(str) if err == nil { diff --git a/pkg/sm/sm.go b/pkg/sm/sm.go index 87de69b2a..b96f9b738 100644 --- a/pkg/sm/sm.go +++ b/pkg/sm/sm.go @@ -168,7 +168,7 @@ func New(ctx context.Context, cancel context.CancelFunc, e env.Environment, cfg } smb.RegisterPlugins(osb.NewCatalogFilterByVisibilityPlugin(interceptableRepository)) - smb.RegisterPluginsBefore(osb.CheckInstanceOwnerPluginName, osb.NewStoreServiceInstancesPlugin(interceptableRepository)) + smb.RegisterPluginsBefore(osb.CheckInstanceOwnerhipPluginName, osb.NewStoreServiceInstancesPlugin(interceptableRepository)) smb.RegisterPluginsBefore(osb.StoreServiceInstancePluginName, osb.NewCheckVisibilityPlugin(interceptableRepository)) smb.RegisterPlugins(osb.NewCheckPlatformIDPlugin(interceptableRepository)) @@ -460,13 +460,20 @@ func (smb *ServiceManagerBuilder) EnableMultitenancy(labelKey string, extractTen multitenancyFilters := filters.NewMultitenancyFilters(labelKey, extractTenantFunc) smb.RegisterFiltersAfter(filters.ProtectedLabelsFilterName, multitenancyFilters...) - smb.RegisterPlugins(osb.NewCheckInstanceOwnerPlugin(smb.Storage, labelKey)) + smb.RegisterFilters( + filters.NewServiceInstanceVisibilityFilter(smb.Storage, labelKey), + filters.NewServiceBindingVisibilityFilter(smb.Storage, labelKey), + ) + + smb.RegisterPlugins(osb.NewCheckInstanceOwnershipPlugin(smb.Storage, labelKey)) + smb.WithCreateOnTxInterceptorProvider(types.ServiceInstanceType, &interceptors.ServiceInstanceCreateInsterceptorProvider{ TenantIdentifier: labelKey, }).Register() smb.WithCreateOnTxInterceptorProvider(types.OperationType, &interceptors.OperationsCreateInsterceptorProvider{ TenantIdentifier: labelKey, }).Register() + return smb } diff --git a/pkg/web/api.go b/pkg/web/api.go index 33fd346ee..d1e38f858 100644 --- a/pkg/web/api.go +++ b/pkg/web/api.go @@ -27,6 +27,20 @@ import ( "github.com/Peripli/service-manager/pkg/util/slice" ) +const ( + // PathParamID is the value used to denote the id of the requested entity + PathParamID = "id" + + // PathParamResourceID is the value used to denote the id of the requested resource + PathParamResourceID = "resource_id" + + // QueryParamAsync is the value used to denote the query key used to convey a client's intent whether the request should be executed async or not + QueryParamAsync = "async" + + // QueryParamLastOp is the value used to denote the query key used to convey a client's intent to retrieve also the last operation associated with the requested resource + QueryParamLastOp = "last_op" +) + // API is the primary point for REST API registration type API struct { // Controllers contains the registered controllers diff --git a/storage/interceptors/operations_create_interceptor.go b/storage/interceptors/operations_create_interceptor.go index 1bf9801e3..c45d7dbb9 100644 --- a/storage/interceptors/operations_create_interceptor.go +++ b/storage/interceptors/operations_create_interceptor.go @@ -48,16 +48,7 @@ func (c *operationsCreateInterceptor) OnTxCreate(h storage.InterceptCreateOnTxFu return func(ctx context.Context, storage storage.Repository, obj types.Object) (types.Object, error) { operation := obj.(*types.Operation) - criteria := query.CriteriaForContext(ctx) - - var tenantID string - for _, criterion := range criteria { - if criterion.LeftOp == c.TenantIdentifier { - tenantID = criterion.RightOp[0] - break - } - } - + tenantID := query.RetrieveFromCriteria(c.TenantIdentifier, query.CriteriaForContext(ctx)...) if tenantID == "" { log.D().Debugf("Could not add %s label to operation with id %s. Label not found in context criteria.", c.TenantIdentifier, operation.ID) return h(ctx, storage, operation) diff --git a/test/get.go b/test/get.go index 8e81532aa..245e9b9f7 100644 --- a/test/get.go +++ b/test/get.go @@ -22,8 +22,6 @@ import ( "net/http" "time" - "github.com/Peripli/service-manager/api" - "github.com/Peripli/service-manager/pkg/web" "github.com/Peripli/service-manager/pkg/util" @@ -74,7 +72,7 @@ func DescribeGetTestsfor(ctx *common.TestContext, t TestCase, responseMode Respo Context("when resource is created async and query param last_op is true", func() { It("returns last operation with the resource", func() { response := ctx.SMWithOAuth.GET(fmt.Sprintf("%s/%s", t.API, testResourceID)). - WithQuery(api.QueryParamLastOp, "true"). + WithQuery(web.QueryParamLastOp, "true"). Expect(). Status(http.StatusOK).JSON().Object() result := response.Raw() @@ -89,7 +87,7 @@ func DescribeGetTestsfor(ctx *common.TestContext, t TestCase, responseMode Respo Context("when resource does not support async and query param last_op is true", func() { It("returns error", func() { ctx.SMWithOAuth.GET(fmt.Sprintf("%s/%s", t.API, testResourceID)). - WithQuery(api.QueryParamLastOp, "true"). + WithQuery(web.QueryParamLastOp, "true"). Expect(). Status(http.StatusBadRequest).JSON().Object().Value("description").String().Match("last operation is not supported for type *") }) @@ -109,7 +107,7 @@ func DescribeGetTestsfor(ctx *common.TestContext, t TestCase, responseMode Respo Context("when resource is created async and query param last_op is true", func() { It("returns 404", func() { ctx.SMWithOAuthForTenant.GET(fmt.Sprintf("%s/%s", t.API, testResourceID)). - WithQuery(api.QueryParamLastOp, "true"). + WithQuery(web.QueryParamLastOp, "true"). Expect(). Status(http.StatusNotFound) }) @@ -144,7 +142,7 @@ func DescribeGetTestsfor(ctx *common.TestContext, t TestCase, responseMode Respo Context("when resource is created async and query param last_op is true", func() { It("returns last operation with the resource", func() { response := ctx.SMWithOAuth.GET(fmt.Sprintf("%s/%s", t.API, testResourceID)). - WithQuery(api.QueryParamLastOp, "true"). + WithQuery(web.QueryParamLastOp, "true"). Expect(). Status(http.StatusOK).JSON().Object() result := response.Raw() @@ -167,7 +165,7 @@ func DescribeGetTestsfor(ctx *common.TestContext, t TestCase, responseMode Respo Context("when resource is created async and query param last_op is true", func() { It("returns last operation with the resource", func() { response := ctx.SMWithOAuthForTenant.GET(fmt.Sprintf("%s/%s", t.API, testResourceID)). - WithQuery(api.QueryParamLastOp, "true"). + WithQuery(web.QueryParamLastOp, "true"). Expect(). Status(http.StatusOK).JSON().Object() result := response.Raw() diff --git a/test/service_binding_test/service_binding_test.go b/test/service_binding_test/service_binding_test.go index 9ceb9d5e1..03f7e8cd2 100644 --- a/test/service_binding_test/service_binding_test.go +++ b/test/service_binding_test/service_binding_test.go @@ -17,9 +17,12 @@ package service_binding_test import ( + "context" "fmt" + "github.com/Peripli/service-manager/storage" "net/http" "strconv" + "time" "github.com/gofrs/uuid" @@ -44,12 +47,13 @@ func TestServiceBindings(t *testing.T) { const ( TenantIdentifier = "tenant" + TenantIDValue = "tenantID" ) var _ = test.DescribeTestsFor(test.TestCase{ API: web.ServiceBindingsURL, SupportedOps: []test.Op{ - test.Get, test.List, test.Delete, test.DeleteList, + test.Get, test.List, test.Delete, }, MultitenancySettings: &test.MultitenancySettings{ ClientID: "tenancyClient", @@ -58,7 +62,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ LabelKey: TenantIdentifier, TokenClaims: map[string]interface{}{ "cid": "tenancyClient", - "zid": "tenantID", + "zid": TenantIDValue, }, }, ResourceType: types.ServiceBindingType, @@ -73,16 +77,35 @@ var _ = test.DescribeTestsFor(test.TestCase{ var ( postBindingRequest common.Object expectedBindingResponse common.Object + + smExpect *common.SMExpect ) - createInstance := func(body common.Object) { - ctx.SMWithOAuth.POST(web.ServiceInstancesURL).WithJSON(body). + createInstance := func(SM *common.SMExpect) string { + instanceID, err := uuid.NewV4() + if err != nil { + panic(err) + } + + planID := newServicePlan(ctx) + ensurePlanVisibility(ctx.SMRepository, types.SMPlatform, planID, TenantIDValue) + + instanceBody := common.Object{ + "id": instanceID.String(), + "name": "test-instance", + "service_plan_id": planID, + "maintenance_info": "{}", + } + + SM.POST(web.ServiceInstancesURL).WithJSON(instanceBody). Expect(). Status(http.StatusCreated) + + return instanceID.String() } - createBinding := func(body common.Object) { - ctx.SMWithOAuth.POST(web.ServiceBindingsURL).WithJSON(body). + createBinding := func(SM *common.SMExpect, body common.Object) { + SM.POST(web.ServiceBindingsURL).WithJSON(body). Expect(). Status(http.StatusCreated). JSON().Object(). @@ -90,19 +113,12 @@ var _ = test.DescribeTestsFor(test.TestCase{ } BeforeEach(func() { - var err error - instanceID, err := uuid.NewV4() - if err != nil { - panic(err) - } + smExpect = ctx.SMWithOAuth // by default all requests are not tenant-scoped + }) - instanceBody := common.Object{ - "id": instanceID.String(), - "name": "test-instance", - "service_plan_id": newServicePlan(ctx), - "maintenance_info": "{}", - } - createInstance(instanceBody) + JustBeforeEach(func() { + var err error + instanceID := createInstance(smExpect) bindingID, err := uuid.NewV4() if err != nil { @@ -114,12 +130,12 @@ var _ = test.DescribeTestsFor(test.TestCase{ postBindingRequest = common.Object{ "id": bindingID.String(), "name": bindingName, - "service_instance_id": instanceID.String(), + "service_instance_id": instanceID, } expectedBindingResponse = common.Object{ "id": bindingID.String(), "name": bindingName, - "service_instance_id": instanceID.String(), + "service_instance_id": instanceID, } }) @@ -130,7 +146,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ Describe("POST", func() { Context("when content type is not JSON", func() { It("returns 415", func() { - ctx.SMWithOAuth.POST(web.ServiceBindingsURL).WithText("text"). + smExpect.POST(web.ServiceBindingsURL).WithText("text"). Expect(). Status(http.StatusUnsupportedMediaType). JSON().Object(). @@ -140,7 +156,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ Context("when request body is not a valid JSON", func() { It("returns 400", func() { - ctx.SMWithOAuth.POST(web.ServiceBindingsURL). + smExpect.POST(web.ServiceBindingsURL). WithText("invalid json"). WithHeader("content-type", "application/json"). Expect(). @@ -152,13 +168,13 @@ var _ = test.DescribeTestsFor(test.TestCase{ Context("when a request body field is missing", func() { assertPOSTReturns400WhenFieldIsMissing := func(field string) { - BeforeEach(func() { + JustBeforeEach(func() { delete(postBindingRequest, field) delete(expectedBindingResponse, field) }) It("returns 400", func() { - ctx.SMWithOAuth.POST(web.ServiceBindingsURL).WithJSON(postBindingRequest). + smExpect.POST(web.ServiceBindingsURL).WithJSON(postBindingRequest). Expect(). Status(http.StatusBadRequest). JSON().Object(). @@ -167,13 +183,13 @@ var _ = test.DescribeTestsFor(test.TestCase{ } assertPOSTReturns201WhenFieldIsMissing := func(field string) { - BeforeEach(func() { + JustBeforeEach(func() { delete(postBindingRequest, field) delete(expectedBindingResponse, field) }) It("returns 201", func() { - createBinding(postBindingRequest) + createBinding(smExpect, postBindingRequest) }) } @@ -194,7 +210,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ Context("when request body id field is invalid", func() { It("should return 400", func() { postBindingRequest["id"] = "binding/1" - resp := ctx.SMWithOAuth.POST(web.ServiceBindingsURL). + resp := smExpect.POST(web.ServiceBindingsURL). WithJSON(postBindingRequest). Expect().Status(http.StatusBadRequest).JSON().Object() @@ -204,19 +220,71 @@ var _ = test.DescribeTestsFor(test.TestCase{ Context("With async query param", func() { It("succeeds", func() { - resp := ctx.SMWithOAuth.POST(web.ServiceBindingsURL).WithJSON(postBindingRequest). + resp := smExpect.POST(web.ServiceBindingsURL).WithJSON(postBindingRequest). WithQuery("async", "true"). Expect(). Status(http.StatusAccepted) - test.ExpectOperation(ctx.SMWithOAuth, resp, types.SUCCEEDED) + test.ExpectOperation(smExpect, resp, types.SUCCEEDED) - ctx.SMWithOAuth.GET(fmt.Sprintf("%s/%s", web.ServiceBindingsURL, postBindingRequest["id"])).Expect(). + smExpect.GET(fmt.Sprintf("%s/%s", web.ServiceBindingsURL, postBindingRequest["id"])).Expect(). Status(http.StatusOK). JSON().Object(). ContainsMap(expectedBindingResponse).ContainsKey("id") }) }) + + Context("instance ownership", func() { + When("tenant doesn't have ownership of instance", func() { + It("returns 404", func() { + ctx.SMWithOAuthForTenant.POST(web.ServiceBindingsURL). + WithJSON(postBindingRequest). + Expect().Status(http.StatusNotFound) + }) + }) + + When("tenant has ownership of instance", func() { + BeforeEach(func() { + smExpect = ctx.SMWithOAuthForTenant + }) + + It("returns 201", func() { + smExpect.POST(web.ServiceBindingsURL). + WithJSON(postBindingRequest). + Expect().Status(http.StatusCreated) + }) + }) + }) + }) + + Describe("DELETE", func() { + Context("instance ownership", func() { + When("tenant doesn't have ownership of binding", func() { + It("returns 404", func() { + smExpect.POST(web.ServiceBindingsURL).WithJSON(postBindingRequest). + Expect(). + Status(http.StatusCreated) + + ctx.SMWithOAuthForTenant.DELETE(fmt.Sprintf("%s/%s", web.ServiceBindingsURL, postBindingRequest["id"])). + Expect().Status(http.StatusNotFound) + }) + }) + + When("tenant has ownership of instance", func() { + BeforeEach(func() { + smExpect = ctx.SMWithOAuthForTenant + }) + + It("returns 200", func() { + smExpect.POST(web.ServiceBindingsURL).WithJSON(postBindingRequest). + Expect(). + Status(http.StatusCreated) + + smExpect.DELETE(fmt.Sprintf("%s/%s", web.ServiceBindingsURL, postBindingRequest["id"])). + Expect().Status(http.StatusOK) + }) + }) + }) }) }) @@ -273,3 +341,27 @@ func newServicePlan(ctx *common.TestContext) string { First().Object().Value("id").String().Raw() return servicePlanID } + +func ensurePlanVisibility(repository storage.Repository, platformID, planID, tenantID string) { + UUID, err := uuid.NewV4() + if err != nil { + panic(fmt.Errorf("could not generate GUID for visibility: %s", err)) + } + + currentTime := time.Now().UTC() + _, err = repository.Create(context.TODO(), &types.Visibility{ + Base: types.Base{ + ID: UUID.String(), + UpdatedAt: currentTime, + CreatedAt: currentTime, + Labels: types.Labels{ + TenantIdentifier: {tenantID}, + }, + }, + ServicePlanID: planID, + PlatformID: platformID, + }) + if err != nil { + panic(err) + } +} diff --git a/test/service_instance_test/service_instance_test.go b/test/service_instance_test/service_instance_test.go index 7a71d0923..a736c984e 100644 --- a/test/service_instance_test/service_instance_test.go +++ b/test/service_instance_test/service_instance_test.go @@ -19,6 +19,9 @@ package service_test import ( "context" "fmt" + "github.com/Peripli/service-manager/storage" + "github.com/gavv/httpexpect" + "time" "github.com/Peripli/service-manager/test/testutil/service_instance" "github.com/gofrs/uuid" @@ -46,7 +49,7 @@ func TestServiceInstances(t *testing.T) { const ( TenantIdentifier = "tenant" - TenantValue = "tenant_value" + TenantIDValue = "tenantID" ) var _ = test.DescribeTestsFor(test.TestCase{ @@ -61,7 +64,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ LabelKey: TenantIdentifier, TokenClaims: map[string]interface{}{ "cid": "tenancyClient", - "zid": "tenantID", + "zid": TenantIDValue, }, }, ResourceType: types.ServiceInstanceType, @@ -77,16 +80,22 @@ var _ = test.DescribeTestsFor(test.TestCase{ postInstanceRequest common.Object expectedInstanceResponse common.Object + servicePlanID string + anotherServicePlanID string + instanceID string ) - createInstance := func() { - ctx.SMWithOAuth.POST(web.ServiceInstancesURL).WithJSON(postInstanceRequest). + createInstance := func(SM *common.SMExpect, expectedStatus int) { + resp := SM.POST(web.ServiceInstancesURL).WithJSON(postInstanceRequest). Expect(). - Status(http.StatusCreated). - JSON().Object(). - ContainsMap(expectedInstanceResponse).ContainsKey("id"). - ValueEqual("platform_id", types.SMPlatform) + Status(expectedStatus) + + if resp.Raw().StatusCode == http.StatusCreated { + resp.JSON().Object(). + ContainsMap(expectedInstanceResponse).ContainsKey("id"). + ValueEqual("platform_id", types.SMPlatform) + } } BeforeEach(func() { @@ -97,7 +106,9 @@ var _ = test.DescribeTestsFor(test.TestCase{ instanceID = id.String() name := "test-instance" - servicePlanID := generateServicePlan(ctx, ctx.SMWithOAuth) + plans := generateServicePlanIDs(ctx, ctx.SMWithOAuth) + servicePlanID = plans.Element(0).Object().Value("id").String().Raw() + anotherServicePlanID = plans.Element(1).Object().Value("id").String().Raw() postInstanceRequest = common.Object{ "id": instanceID, @@ -123,7 +134,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ When("service instance contains tenant identifier in OSB context", func() { BeforeEach(func() { - _, serviceInstance = service_instance.Prepare(ctx, ctx.TestPlatform.ID, "", fmt.Sprintf(`{"%s":"%s"}`, TenantIdentifier, TenantValue)) + _, serviceInstance = service_instance.Prepare(ctx, ctx.TestPlatform.ID, "", fmt.Sprintf(`{"%s":"%s"}`, TenantIdentifier, TenantIDValue)) _, err := ctx.SMRepository.Create(context.Background(), serviceInstance) Expect(err).ToNot(HaveOccurred()) }) @@ -132,7 +143,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ ctx.SMWithOAuth.GET(web.ServiceInstancesURL + "/" + serviceInstance.ID).Expect(). Status(http.StatusOK). JSON(). - Object().Path(fmt.Sprintf("$.labels[%s][*]", TenantIdentifier)).Array().Contains(TenantValue) + Object().Path(fmt.Sprintf("$.labels[%s][*]", TenantIdentifier)).Array().Contains(TenantIDValue) }) }) When("service instance doesn't contain tenant identifier in OSB context", func() { @@ -157,7 +168,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) When("service instance dashboard_url is not set", func() { BeforeEach(func() { - _, serviceInstance = service_instance.Prepare(ctx, ctx.TestPlatform.ID, "", fmt.Sprintf(`{"%s":"%s"}`, TenantIdentifier, TenantValue)) + _, serviceInstance = service_instance.Prepare(ctx, ctx.TestPlatform.ID, "", fmt.Sprintf(`{"%s":"%s"}`, TenantIdentifier, TenantIDValue)) serviceInstance.DashboardURL = "" _, err := ctx.SMRepository.Create(context.Background(), serviceInstance) Expect(err).ToNot(HaveOccurred()) @@ -171,7 +182,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) Describe("POST", func() { - Context("when content type is not JSON", func() { + When("content type is not JSON", func() { It("returns 415", func() { ctx.SMWithOAuth.POST(web.ServiceInstancesURL).WithText("text"). Expect(). @@ -181,7 +192,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) }) - Context("when request body is not a valid JSON", func() { + When("request body is not a valid JSON", func() { It("returns 400", func() { ctx.SMWithOAuth.POST(web.ServiceInstancesURL). WithText("invalid json"). @@ -193,7 +204,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) }) - Context("when a request body field is missing", func() { + When("a request body field is missing", func() { assertPOSTReturns400WhenFieldIsMissing := func(field string) { BeforeEach(func() { delete(postInstanceRequest, field) @@ -216,7 +227,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) It("returns 201", func() { - createInstance() + createInstance(ctx.SMWithOAuth, http.StatusCreated) }) } @@ -237,7 +248,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) }) - Context("when request body id field is invalid", func() { + When("request body id field is invalid", func() { It("should return 400", func() { postInstanceRequest["id"] = "instance/1" resp := ctx.SMWithOAuth.POST(web.ServiceInstancesURL). @@ -248,7 +259,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) }) - Context("when request body platform_id field is provided", func() { + When("request body platform_id field is provided", func() { Context("which is not service-manager platform", func() { It("should return 400", func() { postInstanceRequest["platform_id"] = "test-platform-id" @@ -263,12 +274,12 @@ var _ = test.DescribeTestsFor(test.TestCase{ Context("which is service-manager platform", func() { It("should return 200", func() { postInstanceRequest["platform_id"] = types.SMPlatform - createInstance() + createInstance(ctx.SMWithOAuth, http.StatusCreated) }) }) }) - Context("With async query param", func() { + When("async query param", func() { It("succeeds", func() { resp := ctx.SMWithOAuth.POST(web.ServiceInstancesURL).WithJSON(postInstanceRequest). WithQuery("async", "true"). @@ -283,10 +294,25 @@ var _ = test.DescribeTestsFor(test.TestCase{ ContainsMap(expectedInstanceResponse).ContainsKey("id") }) }) + + Context("instance visibility", func() { + When("tenant doesn't have plan visibility", func() { + It("returns 404", func() { + createInstance(ctx.SMWithOAuthForTenant, http.StatusNotFound) + }) + }) + + When("tenant has plan visibility", func() { + It("returns 201", func() { + ensurePlanVisibility(ctx.SMRepository, types.SMPlatform, servicePlanID, TenantIDValue) + createInstance(ctx.SMWithOAuthForTenant, http.StatusCreated) + }) + }) + }) }) Describe("PATCH", func() { - Context("when content type is not JSON", func() { + When("content type is not JSON", func() { It("returns 415", func() { instanceID := fmt.Sprintf("%s", postInstanceRequest["id"]) ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL+"/"+instanceID). @@ -297,7 +323,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) }) - Context("when instance is missing", func() { + When("instance is missing", func() { It("returns 404", func() { ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL+"/no_such_id"). WithJSON(postInstanceRequest). @@ -307,7 +333,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) }) - Context("when request body is not valid JSON", func() { + When("request body is not valid JSON", func() { It("returns 400", func() { ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL+"/"+instanceID). WithText("invalid json"). @@ -319,9 +345,9 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) }) - Context("when created_at provided in body", func() { + When("created_at provided in body", func() { It("should not change created at", func() { - createInstance() + createInstance(ctx.SMWithOAuth, http.StatusCreated) createdAt := "2015-01-01T00:00:00Z" @@ -340,10 +366,10 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) }) - Context("when platform_id provided in body", func() { + When("platform_id provided in body", func() { Context("which is not service-manager platform", func() { It("should return 400", func() { - createInstance() + createInstance(ctx.SMWithOAuth, http.StatusCreated) resp := ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL + "/" + instanceID). WithJSON(common.Object{"platform_id": "test-platform-id"}). @@ -361,7 +387,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ Context("which is service-manager platform", func() { It("should return 200", func() { - createInstance() + createInstance(ctx.SMWithOAuth, http.StatusCreated) ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL + "/" + instanceID). WithJSON(common.Object{"platform_id": types.SMPlatform}). @@ -377,9 +403,9 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) }) - Context("when fields are updated one by one", func() { + When("fields are updated one by one", func() { It("returns 200", func() { - createInstance() + createInstance(ctx.SMWithOAuth, http.StatusCreated) for _, prop := range []string{"name", "maintenance_info"} { updatedBrokerJSON := common.Object{} @@ -401,6 +427,85 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) }) + Context("instance visibility", func() { + When("tenant doesn't have plan visibility", func() { + It("returns 404", func() { + ensurePlanVisibility(ctx.SMRepository, types.SMPlatform, servicePlanID, TenantIDValue) + createInstance(ctx.SMWithOAuthForTenant, http.StatusCreated) + + ctx.SMWithOAuthForTenant.PATCH(web.ServiceInstancesURL + "/" + instanceID). + WithJSON(common.Object{"service_plan_id": anotherServicePlanID}). + Expect().Status(http.StatusNotFound) + }) + }) + + When("tenant has plan visibility", func() { + It("returns 201", func() { + ensurePlanVisibility(ctx.SMRepository, types.SMPlatform, servicePlanID, TenantIDValue) + createInstance(ctx.SMWithOAuthForTenant, http.StatusCreated) + + ensurePlanVisibility(ctx.SMRepository, types.SMPlatform, anotherServicePlanID, TenantIDValue) + ctx.SMWithOAuthForTenant.PATCH(web.ServiceInstancesURL + "/" + instanceID). + WithJSON(common.Object{"service_plan_id": anotherServicePlanID}). + Expect().Status(http.StatusOK) + }) + }) + }) + + Context("instance ownership", func() { + When("tenant doesn't have ownership of instance", func() { + It("returns 404", func() { + createInstance(ctx.SMWithOAuth, http.StatusCreated) + + ctx.SMWithOAuthForTenant.PATCH(web.ServiceInstancesURL + "/" + instanceID). + WithJSON(common.Object{"service_plan_id": anotherServicePlanID}). + Expect().Status(http.StatusNotFound) + }) + }) + + When("tenant has ownership of instance", func() { + It("returns 200", func() { + ensurePlanVisibility(ctx.SMRepository, types.SMPlatform, servicePlanID, TenantIDValue) + createInstance(ctx.SMWithOAuthForTenant, http.StatusCreated) + + ctx.SMWithOAuthForTenant.PATCH(web.ServiceInstancesURL + "/" + instanceID). + WithJSON(common.Object{"platform_id": types.SMPlatform}). + Expect().Status(http.StatusOK) + }) + }) + }) + }) + + Describe("DELETE", func() { + Context("instance ownership", func() { + When("tenant doesn't have ownership of instance", func() { + It("returns 404", func() { + createInstance(ctx.SMWithOAuth, http.StatusCreated) + + ctx.SMWithOAuthForTenant.DELETE(web.ServiceInstancesURL + "/" + instanceID). + Expect().Status(http.StatusNotFound) + }) + }) + + When("tenant doesn't have ownership of some instances in bulk delete", func() { + It("returns 404", func() { + createInstance(ctx.SMWithOAuth, http.StatusCreated) + + ctx.SMWithOAuthForTenant.DELETE(web.ServiceInstancesURL). + Expect().Status(http.StatusNotFound) + }) + }) + + When("tenant has ownership of instance", func() { + It("returns 200", func() { + ensurePlanVisibility(ctx.SMRepository, types.SMPlatform, servicePlanID, TenantIDValue) + createInstance(ctx.SMWithOAuthForTenant, http.StatusCreated) + + ctx.SMWithOAuthForTenant.DELETE(web.ServiceInstancesURL + "/" + instanceID). + Expect().Status(http.StatusOK) + }) + }) + }) }) }) }, @@ -416,7 +521,7 @@ func blueprint(ctx *common.TestContext, auth *common.SMExpect, async bool) commo instanceReqBody["id"] = instanceID.String() instanceReqBody["name"] = "test-instance-" + instanceID.String() - instanceReqBody["service_plan_id"] = generateServicePlan(ctx, auth) + instanceReqBody["service_plan_id"] = generateServicePlanIDs(ctx, auth).First().Object().Value("id").String().Raw() resp := auth.POST(web.ServiceInstancesURL).WithQuery("async", strconv.FormatBool(async)).WithJSON(instanceReqBody).Expect() @@ -430,17 +535,39 @@ func blueprint(ctx *common.TestContext, auth *common.SMExpect, async bool) commo return instance } -func generateServicePlan(ctx *common.TestContext, auth *common.SMExpect) string { - cPaidPlan := common.GeneratePaidTestPlan() - cService := common.GenerateTestServiceWithPlans(cPaidPlan) +func generateServicePlanIDs(ctx *common.TestContext, auth *common.SMExpect) *httpexpect.Array { + cPaidPlan1 := common.GeneratePaidTestPlan() + cPaidPlan2 := common.GeneratePaidTestPlan() + cService := common.GenerateTestServiceWithPlans(cPaidPlan1, cPaidPlan2) catalog := common.NewEmptySBCatalog() catalog.AddService(cService) brokerID, _, _ := ctx.RegisterBrokerWithCatalog(catalog) so := auth.ListWithQuery(web.ServiceOfferingsURL, fmt.Sprintf("fieldQuery=broker_id eq '%s'", brokerID)).First() - servicePlanID := auth.ListWithQuery(web.ServicePlansURL, "fieldQuery="+fmt.Sprintf("service_offering_id eq '%s'", so.Object().Value("id").String().Raw())). - First().Object().Value("id").String().Raw() + return auth.ListWithQuery(web.ServicePlansURL, "fieldQuery="+fmt.Sprintf("service_offering_id eq '%s'", so.Object().Value("id").String().Raw())) +} - return servicePlanID +func ensurePlanVisibility(repository storage.Repository, platformID, planID, tenantID string) { + UUID, err := uuid.NewV4() + if err != nil { + panic(fmt.Errorf("could not generate GUID for visibility: %s", err)) + } + + currentTime := time.Now().UTC() + _, err = repository.Create(context.TODO(), &types.Visibility{ + Base: types.Base{ + ID: UUID.String(), + UpdatedAt: currentTime, + CreatedAt: currentTime, + Labels: types.Labels{ + TenantIdentifier: {tenantID}, + }, + }, + ServicePlanID: planID, + PlatformID: platformID, + }) + if err != nil { + panic(err) + } } From e58e682a875d8bd504196a3bf16c5a5cdd245440 Mon Sep 17 00:00:00 2001 From: Nikolay Date: Tue, 21 Jan 2020 17:34:52 +0200 Subject: [PATCH 06/15] Allow to create instances if there is public visibility for the plan (#404) * Allow to create instances if there is public visibility for the plan * Use convinience method for checking array of strings * Assume only one visibility for the query in visibility check * Simplify if statement --- .../check_instance_visibility_filter.go | 45 +++++++----- .../service_binding_test.go | 33 ++------- .../service_instance_test.go | 69 ++++++++++--------- test/test.go | 34 +++++++++ 4 files changed, 102 insertions(+), 79 deletions(-) diff --git a/api/filters/check_instance_visibility_filter.go b/api/filters/check_instance_visibility_filter.go index 644d17fef..4ea734dbb 100644 --- a/api/filters/check_instance_visibility_filter.go +++ b/api/filters/check_instance_visibility_filter.go @@ -21,6 +21,7 @@ import ( "github.com/Peripli/service-manager/pkg/query" "github.com/Peripli/service-manager/pkg/types" "github.com/Peripli/service-manager/pkg/util" + "github.com/Peripli/service-manager/pkg/util/slice" "github.com/Peripli/service-manager/storage" "net/http" @@ -60,32 +61,42 @@ func (f *serviceInstanceVisibilityFilter) Run(req *web.Request, next web.Handler return next.Handle(req) } + criteria := []query.Criterion{ + query.ByField(query.EqualsOrNilOperator, platformIDProperty, types.SMPlatform), + query.ByField(query.EqualsOperator, planIDProperty, planID), + } + + list, err := f.repository.List(ctx, types.VisibilityType, criteria...) + if err != nil && err != util.ErrNotFoundInStorage { + return nil, util.HandleStorageError(err, types.VisibilityType.String()) + } + + visibilityError := &util.HTTPError{ + ErrorType: "NotFound", + Description: "could not find such service plan", + StatusCode: http.StatusNotFound, + } + if list.Len() == 0 { + return nil, visibilityError + } + tenantID := query.RetrieveFromCriteria(f.tenantIdentifier, query.CriteriaForContext(ctx)...) if tenantID == "" { log.C(ctx).Info("Tenant identifier not found in request criteria. Proceeding with the next handler...") return next.Handle(req) } - criteria := []query.Criterion{ - query.ByField(query.EqualsOperator, platformIDProperty, types.SMPlatform), - query.ByField(query.EqualsOperator, planIDProperty, planID), - query.ByLabel(query.InOperator, f.tenantIdentifier, tenantID), + // There may be at most one visibility for the query - for SM platform or public for this plan + visibility := list.ItemAt(0).(*types.Visibility) + if len(visibility.PlatformID) == 0 { // public visibility + return next.Handle(req) } - - _, err := f.repository.Get(ctx, types.VisibilityType, criteria...) - if err != nil { - if err == util.ErrNotFoundInStorage { - return nil, &util.HTTPError{ - ErrorType: "NotFound", - Description: "could not find such service plan", - StatusCode: http.StatusNotFound, - } - } - - return nil, util.HandleStorageError(err, types.VisibilityType.String()) + tenantLabels, ok := visibility.Labels[f.tenantIdentifier] + if ok && slice.StringsAnyEquals(tenantLabels, tenantID) { + return next.Handle(req) } - return next.Handle(req) + return nil, visibilityError } func (*serviceInstanceVisibilityFilter) FilterMatchers() []web.FilterMatcher { diff --git a/test/service_binding_test/service_binding_test.go b/test/service_binding_test/service_binding_test.go index 03f7e8cd2..80f2975d5 100644 --- a/test/service_binding_test/service_binding_test.go +++ b/test/service_binding_test/service_binding_test.go @@ -17,12 +17,9 @@ package service_binding_test import ( - "context" "fmt" - "github.com/Peripli/service-manager/storage" "net/http" "strconv" - "time" "github.com/gofrs/uuid" @@ -88,7 +85,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ } planID := newServicePlan(ctx) - ensurePlanVisibility(ctx.SMRepository, types.SMPlatform, planID, TenantIDValue) + test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, planID, TenantIDValue) instanceBody := common.Object{ "id": instanceID.String(), @@ -298,12 +295,14 @@ func blueprint(ctx *common.TestContext, auth *common.SMExpect, async bool) commo } instanceID := "instance-" + ID.String() + servicePlanID := newServicePlan(ctx) + test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, "") resp := ctx.SMWithOAuth.POST(web.ServiceInstancesURL). WithQuery("async", strconv.FormatBool(async)). WithJSON(common.Object{ "id": instanceID, "name": instanceID + "name", - "service_plan_id": newServicePlan(ctx), + "service_plan_id": servicePlanID, "maintenance_info": "{}", }).Expect() @@ -341,27 +340,3 @@ func newServicePlan(ctx *common.TestContext) string { First().Object().Value("id").String().Raw() return servicePlanID } - -func ensurePlanVisibility(repository storage.Repository, platformID, planID, tenantID string) { - UUID, err := uuid.NewV4() - if err != nil { - panic(fmt.Errorf("could not generate GUID for visibility: %s", err)) - } - - currentTime := time.Now().UTC() - _, err = repository.Create(context.TODO(), &types.Visibility{ - Base: types.Base{ - ID: UUID.String(), - UpdatedAt: currentTime, - CreatedAt: currentTime, - Labels: types.Labels{ - TenantIdentifier: {tenantID}, - }, - }, - ServicePlanID: planID, - PlatformID: platformID, - }) - if err != nil { - panic(err) - } -} diff --git a/test/service_instance_test/service_instance_test.go b/test/service_instance_test/service_instance_test.go index a736c984e..75b84b325 100644 --- a/test/service_instance_test/service_instance_test.go +++ b/test/service_instance_test/service_instance_test.go @@ -19,13 +19,13 @@ package service_test import ( "context" "fmt" - "github.com/Peripli/service-manager/storage" + "github.com/gavv/httpexpect" - "time" + + "strconv" "github.com/Peripli/service-manager/test/testutil/service_instance" "github.com/gofrs/uuid" - "strconv" "net/http" "testing" @@ -206,12 +206,15 @@ var _ = test.DescribeTestsFor(test.TestCase{ When("a request body field is missing", func() { assertPOSTReturns400WhenFieldIsMissing := func(field string) { + var servicePlanID string BeforeEach(func() { + servicePlanID = postInstanceRequest["service_plan_id"].(string) delete(postInstanceRequest, field) delete(expectedInstanceResponse, field) }) It("returns 400", func() { + test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, "") ctx.SMWithOAuth.POST(web.ServiceInstancesURL).WithJSON(postInstanceRequest). Expect(). Status(http.StatusBadRequest). @@ -227,6 +230,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) It("returns 201", func() { + test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") createInstance(ctx.SMWithOAuth, http.StatusCreated) }) } @@ -250,6 +254,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ When("request body id field is invalid", func() { It("should return 400", func() { + test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") postInstanceRequest["id"] = "instance/1" resp := ctx.SMWithOAuth.POST(web.ServiceInstancesURL). WithJSON(postInstanceRequest). @@ -274,6 +279,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ Context("which is service-manager platform", func() { It("should return 200", func() { postInstanceRequest["platform_id"] = types.SMPlatform + test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") createInstance(ctx.SMWithOAuth, http.StatusCreated) }) }) @@ -281,6 +287,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ When("async query param", func() { It("succeeds", func() { + test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") resp := ctx.SMWithOAuth.POST(web.ServiceInstancesURL).WithJSON(postInstanceRequest). WithQuery("async", "true"). Expect(). @@ -304,7 +311,19 @@ var _ = test.DescribeTestsFor(test.TestCase{ When("tenant has plan visibility", func() { It("returns 201", func() { - ensurePlanVisibility(ctx.SMRepository, types.SMPlatform, servicePlanID, TenantIDValue) + test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, TenantIDValue) + createInstance(ctx.SMWithOAuthForTenant, http.StatusCreated) + }) + }) + + When("plan has public visibility", func() { + It("for global returns 201", func() { + test.EnsurePublicPlanVisibility(ctx.SMRepository, servicePlanID) + createInstance(ctx.SMWithOAuth, http.StatusCreated) + }) + + It("for tenant returns 201", func() { + test.EnsurePublicPlanVisibility(ctx.SMRepository, servicePlanID) createInstance(ctx.SMWithOAuthForTenant, http.StatusCreated) }) }) @@ -347,6 +366,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ When("created_at provided in body", func() { It("should not change created at", func() { + test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") createInstance(ctx.SMWithOAuth, http.StatusCreated) createdAt := "2015-01-01T00:00:00Z" @@ -369,6 +389,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ When("platform_id provided in body", func() { Context("which is not service-manager platform", func() { It("should return 400", func() { + test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") createInstance(ctx.SMWithOAuth, http.StatusCreated) resp := ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL + "/" + instanceID). @@ -387,6 +408,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ Context("which is service-manager platform", func() { It("should return 200", func() { + test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") createInstance(ctx.SMWithOAuth, http.StatusCreated) ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL + "/" + instanceID). @@ -405,6 +427,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ When("fields are updated one by one", func() { It("returns 200", func() { + test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") createInstance(ctx.SMWithOAuth, http.StatusCreated) for _, prop := range []string{"name", "maintenance_info"} { @@ -430,7 +453,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ Context("instance visibility", func() { When("tenant doesn't have plan visibility", func() { It("returns 404", func() { - ensurePlanVisibility(ctx.SMRepository, types.SMPlatform, servicePlanID, TenantIDValue) + test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, TenantIDValue) createInstance(ctx.SMWithOAuthForTenant, http.StatusCreated) ctx.SMWithOAuthForTenant.PATCH(web.ServiceInstancesURL + "/" + instanceID). @@ -441,10 +464,10 @@ var _ = test.DescribeTestsFor(test.TestCase{ When("tenant has plan visibility", func() { It("returns 201", func() { - ensurePlanVisibility(ctx.SMRepository, types.SMPlatform, servicePlanID, TenantIDValue) + test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, TenantIDValue) createInstance(ctx.SMWithOAuthForTenant, http.StatusCreated) - ensurePlanVisibility(ctx.SMRepository, types.SMPlatform, anotherServicePlanID, TenantIDValue) + test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, anotherServicePlanID, TenantIDValue) ctx.SMWithOAuthForTenant.PATCH(web.ServiceInstancesURL + "/" + instanceID). WithJSON(common.Object{"service_plan_id": anotherServicePlanID}). Expect().Status(http.StatusOK) @@ -455,6 +478,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ Context("instance ownership", func() { When("tenant doesn't have ownership of instance", func() { It("returns 404", func() { + test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") createInstance(ctx.SMWithOAuth, http.StatusCreated) ctx.SMWithOAuthForTenant.PATCH(web.ServiceInstancesURL + "/" + instanceID). @@ -465,7 +489,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ When("tenant has ownership of instance", func() { It("returns 200", func() { - ensurePlanVisibility(ctx.SMRepository, types.SMPlatform, servicePlanID, TenantIDValue) + test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, TenantIDValue) createInstance(ctx.SMWithOAuthForTenant, http.StatusCreated) ctx.SMWithOAuthForTenant.PATCH(web.ServiceInstancesURL + "/" + instanceID). @@ -480,6 +504,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ Context("instance ownership", func() { When("tenant doesn't have ownership of instance", func() { It("returns 404", func() { + test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") createInstance(ctx.SMWithOAuth, http.StatusCreated) ctx.SMWithOAuthForTenant.DELETE(web.ServiceInstancesURL + "/" + instanceID). @@ -489,6 +514,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ When("tenant doesn't have ownership of some instances in bulk delete", func() { It("returns 404", func() { + test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") createInstance(ctx.SMWithOAuth, http.StatusCreated) ctx.SMWithOAuthForTenant.DELETE(web.ServiceInstancesURL). @@ -498,7 +524,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ When("tenant has ownership of instance", func() { It("returns 200", func() { - ensurePlanVisibility(ctx.SMRepository, types.SMPlatform, servicePlanID, TenantIDValue) + test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, TenantIDValue) createInstance(ctx.SMWithOAuthForTenant, http.StatusCreated) ctx.SMWithOAuthForTenant.DELETE(web.ServiceInstancesURL + "/" + instanceID). @@ -523,6 +549,7 @@ func blueprint(ctx *common.TestContext, auth *common.SMExpect, async bool) commo instanceReqBody["service_plan_id"] = generateServicePlanIDs(ctx, auth).First().Object().Value("id").String().Raw() + test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, instanceReqBody["service_plan_id"].(string), "") resp := auth.POST(web.ServiceInstancesURL).WithQuery("async", strconv.FormatBool(async)).WithJSON(instanceReqBody).Expect() var instance map[string]interface{} @@ -547,27 +574,3 @@ func generateServicePlanIDs(ctx *common.TestContext, auth *common.SMExpect) *htt return auth.ListWithQuery(web.ServicePlansURL, "fieldQuery="+fmt.Sprintf("service_offering_id eq '%s'", so.Object().Value("id").String().Raw())) } - -func ensurePlanVisibility(repository storage.Repository, platformID, planID, tenantID string) { - UUID, err := uuid.NewV4() - if err != nil { - panic(fmt.Errorf("could not generate GUID for visibility: %s", err)) - } - - currentTime := time.Now().UTC() - _, err = repository.Create(context.TODO(), &types.Visibility{ - Base: types.Base{ - ID: UUID.String(), - UpdatedAt: currentTime, - CreatedAt: currentTime, - Labels: types.Labels{ - TenantIdentifier: {tenantID}, - }, - }, - ServicePlanID: planID, - PlatformID: platformID, - }) - if err != nil { - panic(err) - } -} diff --git a/test/test.go b/test/test.go index 1ea8d96e3..e584b8255 100644 --- a/test/test.go +++ b/test/test.go @@ -28,7 +28,9 @@ import ( "github.com/Peripli/service-manager/pkg/query" "github.com/Peripli/service-manager/pkg/types" + "github.com/Peripli/service-manager/storage" "github.com/gavv/httpexpect" + "github.com/gofrs/uuid" "github.com/tidwall/gjson" @@ -176,6 +178,38 @@ func ExpectOperationWithError(auth *common.SMExpect, asyncResp *httpexpect.Respo return err } +func EnsurePublicPlanVisibility(repository storage.Repository, planID string) { + EnsurePlanVisibility(repository, "", "", planID, "") +} + +func EnsurePlanVisibility(repository storage.Repository, tenantIdentifier, platformID, planID, tenantID string) { + UUID, err := uuid.NewV4() + if err != nil { + panic(fmt.Errorf("could not generate GUID for visibility: %s", err)) + } + + var labels types.Labels = nil + if tenantID != "" { + labels = types.Labels{ + tenantIdentifier: {tenantID}, + } + } + currentTime := time.Now().UTC() + _, err = repository.Create(context.TODO(), &types.Visibility{ + Base: types.Base{ + ID: UUID.String(), + UpdatedAt: currentTime, + CreatedAt: currentTime, + Labels: labels, + }, + ServicePlanID: planID, + PlatformID: platformID, + }) + if err != nil { + panic(err) + } +} + func DescribeTestsFor(t TestCase) bool { return Describe(t.API, func() { var ctx *common.TestContext From d157d9fce3d5cf29674fa66509170b1ceb0dafe0 Mon Sep 17 00:00:00 2001 From: Nikolay Mateev Date: Wed, 22 Jan 2020 12:16:47 +0200 Subject: [PATCH 07/15] Exclude SM platform from health check platform indicators (#403) --- .../visibility_filtering_middleware.go | 2 +- api/healthcheck/platform_indicator.go | 3 +- pkg/sm/sm.go | 4 +- test/healthcheck_test/healthcheck_test.go | 85 ++++++++++++++++--- 4 files changed, 80 insertions(+), 14 deletions(-) diff --git a/api/filters/visibility_filtering_middleware.go b/api/filters/visibility_filtering_middleware.go index d76a24ac0..badbdc34f 100644 --- a/api/filters/visibility_filtering_middleware.go +++ b/api/filters/visibility_filtering_middleware.go @@ -39,7 +39,7 @@ func (m visibilityFilteringMiddleware) Run(req *web.Request, next web.Handler) ( } resourceID := req.PathParams["resource_id"] - isSingleResource := (resourceID != "") + isSingleResource := resourceID != "" if isSingleResource { if isResourceVisible, err := m.IsResourceVisible(ctx, resourceID, platform.ID); err != nil { diff --git a/api/healthcheck/platform_indicator.go b/api/healthcheck/platform_indicator.go index 6688090b8..adc687697 100644 --- a/api/healthcheck/platform_indicator.go +++ b/api/healthcheck/platform_indicator.go @@ -20,6 +20,7 @@ import ( "context" "fmt" "github.com/Peripli/service-manager/pkg/health" + "github.com/Peripli/service-manager/pkg/query" "github.com/Peripli/service-manager/pkg/types" "github.com/Peripli/service-manager/storage" ) @@ -51,7 +52,7 @@ func (pi *platformIndicator) Name() string { // Status returns status of the health check func (pi *platformIndicator) Status() (interface{}, error) { - objList, err := pi.repository.List(pi.ctx, types.PlatformType) + objList, err := pi.repository.List(pi.ctx, types.PlatformType, query.ByField(query.NotEqualsOperator, "id", types.SMPlatform)) if err != nil { return nil, fmt.Errorf("could not fetch platforms health from storage: %v", err) } diff --git a/pkg/sm/sm.go b/pkg/sm/sm.go index b96f9b738..f769e0559 100644 --- a/pkg/sm/sm.go +++ b/pkg/sm/sm.go @@ -237,10 +237,10 @@ func (smb *ServiceManagerBuilder) registerSMPlatform() error { Name: types.SMPlatform, }); err != nil { if err == util.ErrAlreadyExistsInStorage { - log.C(smb.ctx).Infof("platform %s already exists in SMDB...", "service-manager") + log.C(smb.ctx).Infof("platform %s already exists in SMDB...", types.SMPlatform) return nil } - return fmt.Errorf("could register service-manager platform during bootstrap: %s", err) + return fmt.Errorf("could not register %s platform during bootstrap: %s", types.SMPlatform, err) } return nil diff --git a/test/healthcheck_test/healthcheck_test.go b/test/healthcheck_test/healthcheck_test.go index e1ec9f053..fe4530965 100644 --- a/test/healthcheck_test/healthcheck_test.go +++ b/test/healthcheck_test/healthcheck_test.go @@ -17,6 +17,11 @@ package healthcheck_test import ( + "context" + "github.com/Peripli/service-manager/pkg/env" + "github.com/Peripli/service-manager/pkg/sm" + "github.com/Peripli/service-manager/pkg/types" + "github.com/Peripli/service-manager/pkg/web" "net/http" "testing" @@ -31,23 +36,83 @@ func TestHealth(t *testing.T) { var _ = Describe("Healthcheck API", func() { - var ctx *common.TestContext + var ( + ctxBuilder *common.TestContextBuilder + ctx *common.TestContext + ) - BeforeSuite(func() { - ctx = common.DefaultTestContext() + BeforeEach(func() { + ctxBuilder = common.NewTestContextBuilderWithSecurity() }) - AfterSuite(func() { + JustBeforeEach(func() { + ctx = ctxBuilder.Build() + }) + + AfterEach(func() { ctx.Cleanup() }) - Describe("Get info handler", func() { - It("Returns correct response", func() { - ctx.SM.GET(healthcheck.URL). - Expect(). - Status(http.StatusOK).JSON().Object().ContainsMap(map[string]interface{}{ - "status": "UP", + Describe("Unauthorized", func() { + When("Get info handler", func() { + It("Returns correct response", func() { + ctx.SM.GET(healthcheck.URL). + Expect(). + Status(http.StatusOK).JSON().Object().ContainsMap(map[string]interface{}{ + "status": "UP", + }) + }) + }) + }) + + Describe("Authorized", func() { + When("Get info handler", func() { + BeforeEach(func() { + ctxBuilder.WithSMExtensions(func(ctx context.Context, smb *sm.ServiceManagerBuilder, e env.Environment) error { + smb.RegisterFilters(&dummyAuthFilter{}) + return nil + }) + }) + + It("Doesn't include SM platform", func() { + respBody := ctx.SMWithOAuth.GET(healthcheck.URL). + Expect(). + Status(http.StatusOK).JSON().Object() + + respBody.NotContainsMap(map[string]interface{}{ + "details": map[string]interface{}{ + "platforms": map[string]interface{}{ + "details": map[string]interface{}{ + types.SMPlatform: map[string]interface{}{}, + }, + }, + }, + }) }) }) }) }) + +type dummyAuthFilter struct{} + +func (*dummyAuthFilter) Name() string { + return "dummyAuthFilter" +} + +func (*dummyAuthFilter) Run(req *web.Request, next web.Handler) (*web.Response, error) { + ctx := web.ContextWithAuthorization(req.Context()) + req.Request = req.WithContext(ctx) + + return next.Handle(req) +} + +func (*dummyAuthFilter) FilterMatchers() []web.FilterMatcher { + return []web.FilterMatcher{ + { + Matchers: []web.Matcher{ + web.Path(web.MonitorHealthURL), + web.Methods(http.MethodGet), + }, + }, + } +} From 186dc0d1e76fbe6326f46083f1b9abd00763aafe Mon Sep 17 00:00:00 2001 From: Nikolay Mateev Date: Wed, 22 Jan 2020 18:05:22 +0200 Subject: [PATCH 08/15] Forbid setting Instance/Binding ID from client-side (#405) --- api/filters/service_binding_strip_filter.go | 10 ++++ api/filters/service_instance_strip_filter.go | 9 +++ test/broker_test/broker_test.go | 9 +-- test/delete.go | 4 +- test/operations_test/operations_test.go | 2 +- test/patch.go | 2 +- .../service_binding_test.go | 60 ++++++------------- .../service_instance_test.go | 39 +++++------- test/test.go | 18 +++--- 9 files changed, 68 insertions(+), 85 deletions(-) diff --git a/api/filters/service_binding_strip_filter.go b/api/filters/service_binding_strip_filter.go index 3971d0ac9..6fbb24655 100644 --- a/api/filters/service_binding_strip_filter.go +++ b/api/filters/service_binding_strip_filter.go @@ -17,6 +17,8 @@ package filters import ( + "github.com/Peripli/service-manager/pkg/util" + "github.com/tidwall/gjson" "net/http" "github.com/Peripli/service-manager/pkg/web" @@ -37,6 +39,14 @@ func (*ServiceBindingStripFilter) Name() string { } func (*ServiceBindingStripFilter) Run(req *web.Request, next web.Handler) (*web.Response, error) { + if gjson.GetBytes(req.Body, "id").Exists() { + return nil, &util.HTTPError{ + ErrorType: "BadRequest", + Description: "Invalid request body - providing specific resource id is forbidden", + StatusCode: http.StatusBadRequest, + } + } + var err error req.Body, err = removePropertiesFromRequest(req.Context(), req.Body, serviceBindingUnmodifiableProperties) if err != nil { diff --git a/api/filters/service_instance_strip_filter.go b/api/filters/service_instance_strip_filter.go index f9ee01c91..f52307b78 100644 --- a/api/filters/service_instance_strip_filter.go +++ b/api/filters/service_instance_strip_filter.go @@ -20,6 +20,7 @@ import ( "context" "github.com/Peripli/service-manager/pkg/log" "github.com/Peripli/service-manager/pkg/util" + "github.com/tidwall/gjson" "github.com/tidwall/sjson" "net/http" @@ -41,6 +42,14 @@ func (*ServiceInstanceStripFilter) Name() string { } func (*ServiceInstanceStripFilter) Run(req *web.Request, next web.Handler) (*web.Response, error) { + if gjson.GetBytes(req.Body, "id").Exists() { + return nil, &util.HTTPError{ + ErrorType: "BadRequest", + Description: "Invalid request body - providing specific resource id is forbidden", + StatusCode: http.StatusBadRequest, + } + } + var err error req.Body, err = removePropertiesFromRequest(req.Context(), req.Body, serviceInstanceUnmodifiableProperties) if err != nil { diff --git a/test/broker_test/broker_test.go b/test/broker_test/broker_test.go index 22d807382..f5f2847c1 100644 --- a/test/broker_test/broker_test.go +++ b/test/broker_test/broker_test.go @@ -18,7 +18,6 @@ package broker_test import ( "context" "fmt" - "github.com/gofrs/uuid" "net/http" "strconv" "strings" @@ -1739,12 +1738,6 @@ func blueprint(setNullFieldsValues bool) func(ctx *common.TestContext, auth *com return func(ctx *common.TestContext, auth *common.SMExpect, async bool) common.Object { brokerJSON := common.GenerateRandomBroker() - brokerID, err := uuid.NewV4() - if err != nil { - panic(err) - } - brokerJSON["id"] = brokerID.String() - if !setNullFieldsValues { delete(brokerJSON, "description") } @@ -1752,7 +1745,7 @@ func blueprint(setNullFieldsValues bool) func(ctx *common.TestContext, auth *com var obj map[string]interface{} resp := auth.POST(web.ServiceBrokersURL).WithQuery("async", strconv.FormatBool(async)).WithJSON(brokerJSON).Expect() if async { - obj = test.ExpectSuccessfulAsyncResourceCreation(resp, auth, brokerID.String(), web.ServiceBrokersURL) + obj = test.ExpectSuccessfulAsyncResourceCreation(resp, auth, web.ServiceBrokersURL) } else { obj = resp.Status(http.StatusCreated).JSON().Object().Raw() delete(obj, "credentials") diff --git a/test/delete.go b/test/delete.go index c5de6b5fd..b1c400b64 100644 --- a/test/delete.go +++ b/test/delete.go @@ -78,7 +78,7 @@ func DescribeDeleteTestsfor(ctx *common.TestContext, t TestCase, responseMode Re Status(deletionRequestResponseCode) if responseMode == Async { - err := ExpectOperationWithError(auth, resp, expectedOpState, expectedErrMsg) + _, err := ExpectOperationWithError(auth, resp, expectedOpState, expectedErrMsg) Expect(err).To(BeNil()) } @@ -157,7 +157,7 @@ func DescribeDeleteTestsfor(ctx *common.TestContext, t TestCase, responseMode Re verifyMissingResourceFailedDeletion := func(resp *httpexpect.Response, expectedErrMsg string) { switch responseMode { case Async: - err := ExpectOperationWithError(ctx.SMWithOAuth, resp, types.FAILED, expectedErrMsg) + _, err := ExpectOperationWithError(ctx.SMWithOAuth, resp, types.FAILED, expectedErrMsg) Expect(err).To(BeNil()) case Sync: resp.Status(http.StatusNotFound).JSON().Object().Keys().Contains("error", "description") diff --git a/test/operations_test/operations_test.go b/test/operations_test/operations_test.go index c9733f477..e84e97f47 100644 --- a/test/operations_test/operations_test.go +++ b/test/operations_test/operations_test.go @@ -83,7 +83,7 @@ var _ = Describe("Operations", func() { WithQuery("async", "true"). Expect(). Status(http.StatusAccepted) - err := test.ExpectOperationWithError(ctx.SMWithOAuth, resp, types.FAILED, "job timed out") + _, err := test.ExpectOperationWithError(ctx.SMWithOAuth, resp, types.FAILED, "job timed out") Expect(err).To(BeNil()) }) }) diff --git a/test/patch.go b/test/patch.go index 282d79805..ce6d30dae 100644 --- a/test/patch.go +++ b/test/patch.go @@ -35,7 +35,7 @@ func DescribePatchTestsFor(ctx *common.TestContext, t TestCase, responseMode Res case Async: resp.Status(http.StatusAccepted) - err := ExpectOperation(ctx.SMWithOAuth, resp, types.SUCCEEDED) + _, err := ExpectOperation(ctx.SMWithOAuth, resp, types.SUCCEEDED) Expect(err).To(BeNil()) case Sync: resp.Status(http.StatusOK) diff --git a/test/service_binding_test/service_binding_test.go b/test/service_binding_test/service_binding_test.go index 80f2975d5..b8214f8e8 100644 --- a/test/service_binding_test/service_binding_test.go +++ b/test/service_binding_test/service_binding_test.go @@ -21,8 +21,6 @@ import ( "net/http" "strconv" - "github.com/gofrs/uuid" - "testing" "github.com/Peripli/service-manager/pkg/types" @@ -79,26 +77,20 @@ var _ = test.DescribeTestsFor(test.TestCase{ ) createInstance := func(SM *common.SMExpect) string { - instanceID, err := uuid.NewV4() - if err != nil { - panic(err) - } - planID := newServicePlan(ctx) test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, planID, TenantIDValue) instanceBody := common.Object{ - "id": instanceID.String(), "name": "test-instance", "service_plan_id": planID, "maintenance_info": "{}", } - SM.POST(web.ServiceInstancesURL).WithJSON(instanceBody). + resp := SM.POST(web.ServiceInstancesURL).WithJSON(instanceBody). Expect(). Status(http.StatusCreated) - return instanceID.String() + return resp.JSON().Object().Value("id").String().Raw() } createBinding := func(SM *common.SMExpect, body common.Object) { @@ -114,23 +106,15 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) JustBeforeEach(func() { - var err error instanceID := createInstance(smExpect) - bindingID, err := uuid.NewV4() - if err != nil { - panic(err) - } - bindingName := "test-binding" postBindingRequest = common.Object{ - "id": bindingID.String(), "name": bindingName, "service_instance_id": instanceID, } expectedBindingResponse = common.Object{ - "id": bindingID.String(), "name": bindingName, "service_instance_id": instanceID, } @@ -204,14 +188,14 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) - Context("when request body id field is invalid", func() { + Context("when request body id field is provided", func() { It("should return 400", func() { - postBindingRequest["id"] = "binding/1" + postBindingRequest["id"] = "test-binding-id" resp := smExpect.POST(web.ServiceBindingsURL). WithJSON(postBindingRequest). Expect().Status(http.StatusBadRequest).JSON().Object() - resp.Value("description").Equal("binding/1 contains invalid character(s)") + Expect(resp.Value("description").String().Raw()).To(ContainSubstring("providing specific resource id is forbidden")) }) }) @@ -222,9 +206,10 @@ var _ = test.DescribeTestsFor(test.TestCase{ Expect(). Status(http.StatusAccepted) - test.ExpectOperation(smExpect, resp, types.SUCCEEDED) + op, err := test.ExpectOperation(smExpect, resp, types.SUCCEEDED) + Expect(err).To(BeNil()) - smExpect.GET(fmt.Sprintf("%s/%s", web.ServiceBindingsURL, postBindingRequest["id"])).Expect(). + smExpect.GET(fmt.Sprintf("%s/%s", web.ServiceBindingsURL, op.Value("resource_id").String().Raw())).Expect(). Status(http.StatusOK). JSON().Object(). ContainsMap(expectedBindingResponse).ContainsKey("id") @@ -273,11 +258,11 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) It("returns 200", func() { - smExpect.POST(web.ServiceBindingsURL).WithJSON(postBindingRequest). + obj := smExpect.POST(web.ServiceBindingsURL).WithJSON(postBindingRequest). Expect(). - Status(http.StatusCreated) + Status(http.StatusCreated).JSON().Object() - smExpect.DELETE(fmt.Sprintf("%s/%s", web.ServiceBindingsURL, postBindingRequest["id"])). + smExpect.DELETE(fmt.Sprintf("%s/%s", web.ServiceBindingsURL, obj.Value("id").String().Raw())). Expect().Status(http.StatusOK) }) }) @@ -289,42 +274,33 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) func blueprint(ctx *common.TestContext, auth *common.SMExpect, async bool) common.Object { - ID, err := uuid.NewV4() - if err != nil { - Fail(fmt.Sprintf("failed to generate instance GUID: %s", err)) - } - instanceID := "instance-" + ID.String() - servicePlanID := newServicePlan(ctx) test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, "") resp := ctx.SMWithOAuth.POST(web.ServiceInstancesURL). WithQuery("async", strconv.FormatBool(async)). WithJSON(common.Object{ - "id": instanceID, - "name": instanceID + "name", + "name": "test-service-instance", "service_plan_id": servicePlanID, "maintenance_info": "{}", }).Expect() + var instance map[string]interface{} if async { - test.ExpectSuccessfulAsyncResourceCreation(resp, auth, instanceID, web.ServiceInstancesURL) + instance = test.ExpectSuccessfulAsyncResourceCreation(resp, auth, web.ServiceInstancesURL) } else { - resp.Status(http.StatusCreated) + instance = resp.Status(http.StatusCreated).JSON().Object().Raw() } - bindingID := "binding-" + ID.String() - resp = ctx.SMWithOAuth.POST(web.ServiceBindingsURL). WithQuery("async", strconv.FormatBool(async)). WithJSON(common.Object{ - "id": bindingID, - "name": bindingID + "name", - "service_instance_id": instanceID, + "name": "test-service-binding", + "service_instance_id": instance["id"], }).Expect() var binding map[string]interface{} if async { - binding = test.ExpectSuccessfulAsyncResourceCreation(resp, auth, bindingID, web.ServiceBindingsURL) + binding = test.ExpectSuccessfulAsyncResourceCreation(resp, auth, web.ServiceBindingsURL) } else { binding = resp.Status(http.StatusCreated).JSON().Object().Raw() } diff --git a/test/service_instance_test/service_instance_test.go b/test/service_instance_test/service_instance_test.go index 75b84b325..413a060f0 100644 --- a/test/service_instance_test/service_instance_test.go +++ b/test/service_instance_test/service_instance_test.go @@ -19,14 +19,13 @@ package service_test import ( "context" "fmt" + "github.com/gofrs/uuid" "github.com/gavv/httpexpect" "strconv" "github.com/Peripli/service-manager/test/testutil/service_instance" - "github.com/gofrs/uuid" - "net/http" "testing" @@ -92,32 +91,27 @@ var _ = test.DescribeTestsFor(test.TestCase{ Status(expectedStatus) if resp.Raw().StatusCode == http.StatusCreated { - resp.JSON().Object(). - ContainsMap(expectedInstanceResponse).ContainsKey("id"). + obj := resp.JSON().Object() + + obj.ContainsMap(expectedInstanceResponse).ContainsKey("id"). ValueEqual("platform_id", types.SMPlatform) + + instanceID = obj.Value("id").String().Raw() } } BeforeEach(func() { - id, err := uuid.NewV4() - if err != nil { - panic(err) - } - - instanceID = id.String() name := "test-instance" plans := generateServicePlanIDs(ctx, ctx.SMWithOAuth) servicePlanID = plans.Element(0).Object().Value("id").String().Raw() anotherServicePlanID = plans.Element(1).Object().Value("id").String().Raw() postInstanceRequest = common.Object{ - "id": instanceID, "name": name, "service_plan_id": servicePlanID, "maintenance_info": "{}", } expectedInstanceResponse = common.Object{ - "id": instanceID, "name": name, "service_plan_id": servicePlanID, "maintenance_info": "{}", @@ -235,7 +229,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) } - Context("when id field is missing", func() { + Context("when id field is missing", func() { assertPOSTReturns201WhenFieldIsMissing("id") }) @@ -252,15 +246,15 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) }) - When("request body id field is invalid", func() { + When("request body id field is provided", func() { It("should return 400", func() { test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") - postInstanceRequest["id"] = "instance/1" + postInstanceRequest["id"] = "test-instance-id" resp := ctx.SMWithOAuth.POST(web.ServiceInstancesURL). WithJSON(postInstanceRequest). Expect().Status(http.StatusBadRequest).JSON().Object() - resp.Value("description").Equal("instance/1 contains invalid character(s)") + Expect(resp.Value("description").String().Raw()).To(ContainSubstring("providing specific resource id is forbidden")) }) }) @@ -293,9 +287,10 @@ var _ = test.DescribeTestsFor(test.TestCase{ Expect(). Status(http.StatusAccepted) - test.ExpectOperation(ctx.SMWithOAuth, resp, types.SUCCEEDED) + op, err := test.ExpectOperation(ctx.SMWithOAuth, resp, types.SUCCEEDED) + Expect(err).To(BeNil()) - ctx.SMWithOAuth.GET(web.ServiceInstancesURL + "/" + instanceID).Expect(). + ctx.SMWithOAuth.GET(web.ServiceInstancesURL + "/" + op.Value("resource_id").String().Raw()).Expect(). Status(http.StatusOK). JSON().Object(). ContainsMap(expectedInstanceResponse).ContainsKey("id") @@ -538,15 +533,13 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) func blueprint(ctx *common.TestContext, auth *common.SMExpect, async bool) common.Object { - instanceID, err := uuid.NewV4() + ID, err := uuid.NewV4() if err != nil { panic(err) } instanceReqBody := make(common.Object, 0) - instanceReqBody["id"] = instanceID.String() - instanceReqBody["name"] = "test-instance-" + instanceID.String() - + instanceReqBody["name"] = "test-service-instance-" + ID.String() instanceReqBody["service_plan_id"] = generateServicePlanIDs(ctx, auth).First().Object().Value("id").String().Raw() test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, instanceReqBody["service_plan_id"].(string), "") @@ -554,7 +547,7 @@ func blueprint(ctx *common.TestContext, auth *common.SMExpect, async bool) commo var instance map[string]interface{} if async { - instance = test.ExpectSuccessfulAsyncResourceCreation(resp, auth, instanceID.String(), web.ServiceInstancesURL) + instance = test.ExpectSuccessfulAsyncResourceCreation(resp, auth, web.ServiceInstancesURL) } else { instance = resp.Status(http.StatusCreated).JSON().Object().Raw() } diff --git a/test/test.go b/test/test.go index e584b8255..de484da8e 100644 --- a/test/test.go +++ b/test/test.go @@ -106,7 +106,7 @@ func APIResourcePatch(ctx *common.TestContext, apiPath string, objID string, _ t if async { resp = resp.Status(http.StatusAccepted) - err := ExpectOperation(ctx.SMWithOAuth, resp, types.SUCCEEDED) + _, err := ExpectOperation(ctx.SMWithOAuth, resp, types.SUCCEEDED) if err != nil { panic(err) } @@ -128,23 +128,25 @@ func StorageResourcePatch(ctx *common.TestContext, _ string, objID string, resou } } -func ExpectSuccessfulAsyncResourceCreation(resp *httpexpect.Response, SM *common.SMExpect, resourceID, resourceURL string) map[string]interface{} { +func ExpectSuccessfulAsyncResourceCreation(resp *httpexpect.Response, SM *common.SMExpect, resourceURL string) map[string]interface{} { resp = resp.Status(http.StatusAccepted) - if err := ExpectOperation(SM, resp, types.SUCCEEDED); err != nil { + + op, err := ExpectOperation(SM, resp, types.SUCCEEDED) + if err != nil { panic(err) } - obj := SM.GET(resourceURL + "/" + resourceID). + obj := SM.GET(resourceURL + "/" + op.Value("resource_id").String().Raw()). Expect().Status(http.StatusOK).JSON().Object().Raw() return obj } -func ExpectOperation(auth *common.SMExpect, asyncResp *httpexpect.Response, expectedState types.OperationState) error { +func ExpectOperation(auth *common.SMExpect, asyncResp *httpexpect.Response, expectedState types.OperationState) (*httpexpect.Object, error) { return ExpectOperationWithError(auth, asyncResp, expectedState, "") } -func ExpectOperationWithError(auth *common.SMExpect, asyncResp *httpexpect.Response, expectedState types.OperationState, expectedErrMsg string) error { +func ExpectOperationWithError(auth *common.SMExpect, asyncResp *httpexpect.Response, expectedState types.OperationState, expectedErrMsg string) (*httpexpect.Object, error) { operationURL := asyncResp.Header("Location").Raw() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -171,11 +173,11 @@ func ExpectOperationWithError(auth *common.SMExpect, asyncResp *httpexpect.Respo err = fmt.Errorf("unable to verify operation - expected error message (%s), but got (%s)", expectedErrMsg, errs.String().Raw()) } } - return nil + return operation, nil } } } - return err + return nil, err } func EnsurePublicPlanVisibility(repository storage.Repository, planID string) { From 8d03254d3146f82f17635b6350a4a9a37fd19786 Mon Sep 17 00:00:00 2001 From: Kiril Kabakchiev Date: Tue, 4 Feb 2020 14:35:42 +0200 Subject: [PATCH 09/15] SM to broker communication (SM as a platform) (#409) --- .travis.yml | 2 + Gopkg.lock | 2 +- Makefile | 2 +- api/api.go | 14 +- api/base_controller.go | 260 +-- .../check_binding_visibility_filter.go | 5 +- .../check_instance_visibility_filter.go | 3 +- api/osb/catalog_fetcher_test.go | 1 + api/osb/client.go | 12 + api/osb/store_instances_plugin.go | 3 +- api/service_binding_controller.go | 2 +- api/service_instance_controller.go | 86 + api/service_offering_controller.go | 5 +- api/service_plan_controller.go | 5 +- application.yml | 11 +- cmd/smgen/api_type_template.go | 3 +- config/config.go | 39 +- config/config_test.go | 34 +- operations/config.go | 31 +- operations/context.go | 43 + operations/jobs.go | 125 -- operations/scheduler.go | 541 +++++- pkg/multitenancy/settings.go | 22 + pkg/sm/sm.go | 30 +- pkg/types/base.go | 45 +- pkg/types/interfaces.go | 2 + pkg/types/notification_gen.go | 3 +- pkg/types/operation.go | 16 +- pkg/types/operation_gen.go | 3 +- pkg/types/platform_gen.go | 3 +- pkg/types/service_binding.go | 24 +- pkg/types/service_instance.go | 35 +- pkg/types/service_plan.go | 12 +- pkg/types/servicebinding_gen.go | 7 +- pkg/types/servicebroker_gen.go | 3 +- pkg/types/serviceinstance_gen.go | 3 +- pkg/types/serviceoffering_gen.go | 3 +- pkg/types/serviceplan_gen.go | 3 +- pkg/types/types_test.go | 15 +- pkg/types/visibility_gen.go | 3 +- pkg/util/errors.go | 21 +- pkg/util/osb.go | 11 - storage/encrypting_repository_test.go | 6 +- storage/interceptable_repository.go | 3 +- storage/interceptable_repository_test.go | 5 + .../broker_create_catalog_interceptor.go | 2 + .../interceptors/notifications_interceptor.go | 1 + .../operations_create_interceptor.go | 20 +- ...sb_service_instance_create_interceptor.go} | 17 +- .../smaap_service_binding_interceptor.go | 612 +++++++ .../smaap_service_instance_interceptor.go | 544 ++++++ storage/notification_queue_test.go | 3 + storage/postgres/abstract.go | 36 +- storage/postgres/base_entity.go | 1 + storage/postgres/broker.go | 2 + storage/postgres/keystore_test.go | 2 +- ...000_additional_operations_columns.down.sql | 6 + ...50000_additional_operations_columns.up.sql | 6 + .../20200117151000_ready_column.down.sql | 11 + .../20200117151000_ready_column.up.sql | 11 + .../20200122151000_alter_plan_table.down.sql | 6 + .../20200122151000_alter_plan_table.up.sql | 6 + storage/postgres/notification.go | 2 + storage/postgres/notificator_test.go | 6 +- storage/postgres/operation.go | 58 +- storage/postgres/platform.go | 2 + storage/postgres/query_builder_test.go | 3 +- storage/postgres/scheme_test.go | 7 + storage/postgres/service_binding.go | 9 +- storage/postgres/service_instance.go | 5 +- storage/postgres/service_offering.go | 2 + storage/postgres/service_plan.go | 22 +- storage/postgres/servicebinding_gen.go | 6 +- storage/postgres/storage.go | 1 + storage/postgres/visibility.go | 2 + test/broker_test/broker_test.go | 107 +- test/common/application.yml | 17 +- test/common/broker.go | 227 ++- test/common/catalog.go | 13 +- test/common/common.go | 96 +- test/common/operation.go | 161 ++ test/common/service_binding.go | 93 + test/common/service_instance.go | 160 ++ test/common/test_context.go | 55 +- test/configuration_test/configuration_test.go | 24 +- test/delete.go | 9 +- test/get.go | 5 +- test/interceptors_test/interceptors_test.go | 20 +- test/list.go | 3 +- .../notification_cleaner_test.go | 1 + test/notification_test/notification_test.go | 10 + test/operations_test/operations_test.go | 53 +- test/osb_test/bind_test.go | 3 +- test/osb_test/deprovision_test.go | 11 +- test/osb_test/osb_suite_test.go | 23 +- .../poll_binding_last_operation_test.go | 3 +- .../poll_instance_last_operation_test.go | 3 +- test/osb_test/provision_test.go | 8 +- test/osb_test/update_instance_test.go | 6 +- test/patch.go | 5 +- test/platform_test/platform_test.go | 7 +- test/plugin_test/plugin_test.go | 3 + test/query_test/query_test.go | 1 + .../service_binding_test.go | 1521 +++++++++++++-- .../service_instance_test.go | 1630 ++++++++++++++--- test/storage_test/storage_test.go | 3 +- test/test.go | 15 +- .../service_instance/service_instances.go | 56 - test/visibility_test/visibility_test.go | 2 +- 109 files changed, 6106 insertions(+), 1166 deletions(-) create mode 100644 api/osb/client.go create mode 100644 api/service_instance_controller.go create mode 100644 operations/context.go delete mode 100644 operations/jobs.go create mode 100644 pkg/multitenancy/settings.go delete mode 100644 pkg/util/osb.go rename storage/interceptors/{service_instance_create_interceptor.go => osb_service_instance_create_interceptor.go} (85%) create mode 100644 storage/interceptors/smaap_service_binding_interceptor.go create mode 100644 storage/interceptors/smaap_service_instance_interceptor.go create mode 100644 storage/postgres/migrations/20200117150000_additional_operations_columns.down.sql create mode 100644 storage/postgres/migrations/20200117150000_additional_operations_columns.up.sql create mode 100644 storage/postgres/migrations/20200117151000_ready_column.down.sql create mode 100644 storage/postgres/migrations/20200117151000_ready_column.up.sql create mode 100644 storage/postgres/migrations/20200122151000_alter_plan_table.down.sql create mode 100644 storage/postgres/migrations/20200122151000_alter_plan_table.up.sql create mode 100644 test/common/operation.go create mode 100644 test/common/service_binding.go create mode 100644 test/common/service_instance.go delete mode 100644 test/testutil/service_instance/service_instances.go diff --git a/.travis.yml b/.travis.yml index d4795406c..9f1f76bed 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,8 +13,10 @@ services: - postgresql script: + - while sleep 9m; do echo "=====[already running for $SECONDS ...]====="; done & - make precommit - goveralls -coverprofile profile.cov -service=travis-ci + - kill %1 notifications: diff --git a/Gopkg.lock b/Gopkg.lock index 10934e8a7..dfa5f1bc0 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -586,7 +586,7 @@ "pbkdf2", ] pruneopts = "UT" - revision = "6d4e4cb37c7d6416dfea8472e751c7b6615267a6" + revision = "530e935923ad688be97c15eeb8e5ee42ebf2b54a" [[projects]] branch = "master" diff --git a/Makefile b/Makefile index 4d20258b7..e57e1c094 100644 --- a/Makefile +++ b/Makefile @@ -44,7 +44,7 @@ GO_BUILD = env CGO_ENABLED=0 GOOS=$(PLATFORM) GOARCH=$(ARCH) \ $(GO) build $(GO_FLAGS) -ldflags '-s -w $(BUILD_LDFLAGS) $(VERSION_FLAGS)' # TEST_FLAGS - extra "go test" flags to use -GO_INT_TEST = $(GO) test -p 1 -race -coverpkg $(shell go list ./... | egrep -v "fakes|test|cmd|parser" | paste -sd "," -) \ +GO_INT_TEST = $(GO) test -p 1 -timeout 30m -race -coverpkg $(shell go list ./... | egrep -v "fakes|test|cmd|parser" | paste -sd "," -) \ ./test/... $(TEST_FLAGS) -coverprofile=$(INT_TEST_PROFILE) GO_UNIT_TEST = $(GO) test -p 1 -race -coverpkg $(shell go list ./... | egrep -v "fakes|test|cmd|parser" | paste -sd "," -) \ diff --git a/api/api.go b/api/api.go index c1ee90654..c5e4215e7 100644 --- a/api/api.go +++ b/api/api.go @@ -92,23 +92,21 @@ func New(ctx context.Context, e env.Environment, options *Options) (*web.API, er return &web.API{ // Default controllers - more filters can be registered using the relevant API methods Controllers: []web.Controller{ - NewAsyncController(ctx, options, web.ServiceBrokersURL, types.ServiceBrokerType, func() types.Object { + NewAsyncController(ctx, options, web.ServiceBrokersURL, types.ServiceBrokerType, false, func() types.Object { return &types.ServiceBroker{} }), - NewController(options, web.PlatformsURL, types.PlatformType, func() types.Object { + NewController(ctx, options, web.PlatformsURL, types.PlatformType, func() types.Object { return &types.Platform{} }), - NewController(options, web.VisibilitiesURL, types.VisibilityType, func() types.Object { + NewController(ctx, options, web.VisibilitiesURL, types.VisibilityType, func() types.Object { return &types.Visibility{} }), - NewAsyncController(ctx, options, web.ServiceInstancesURL, types.ServiceInstanceType, func() types.Object { - return &types.ServiceInstance{} - }), + NewServiceInstanceController(ctx, options), NewServiceBindingController(ctx, options), apiNotifications.NewController(ctx, options.Repository, options.WSSettings, options.Notificator), - NewServiceOfferingController(options), - NewServicePlanController(options), + NewServiceOfferingController(ctx, options), + NewServicePlanController(ctx, options), &info.Controller{ TokenIssuer: options.APISettings.TokenIssuerURL, diff --git a/api/base_controller.go b/api/base_controller.go index 1df6ac7ff..8854e9e77 100644 --- a/api/base_controller.go +++ b/api/base_controller.go @@ -46,40 +46,47 @@ const pagingLimitOffset = 1 // BaseController provides common CRUD handlers for all object types in the service manager type BaseController struct { - scheduler *operations.Scheduler + scheduler *operations.Scheduler + resourceBaseURL string objectType types.ObjectType repository storage.Repository objectBlueprint func() types.Object + DefaultPageSize int MaxPageSize int + + supportsAsync bool + isAsyncDefault bool } // NewController returns a new base controller -func NewController(options *Options, resourceBaseURL string, objectType types.ObjectType, objectBlueprint func() types.Object) *BaseController { - return &BaseController{ +func NewController(ctx context.Context, options *Options, resourceBaseURL string, objectType types.ObjectType, objectBlueprint func() types.Object) *BaseController { + poolSize := options.OperationSettings.DefaultPoolSize + for _, pool := range options.OperationSettings.Pools { + if pool.Resource == objectType.String() { + poolSize = pool.Size + break + } + } + controller := &BaseController{ repository: options.Repository, resourceBaseURL: resourceBaseURL, objectBlueprint: objectBlueprint, objectType: objectType, DefaultPageSize: options.APISettings.DefaultPageSize, MaxPageSize: options.APISettings.MaxPageSize, + scheduler: operations.NewScheduler(ctx, options.Repository, options.OperationSettings, poolSize, options.WaitGroup), } + + return controller } // NewAsyncController returns a new base controller with a scheduler making it effectively an async controller -func NewAsyncController(ctx context.Context, options *Options, resourceBaseURL string, objectType types.ObjectType, objectBlueprint func() types.Object) *BaseController { - controller := NewController(options, resourceBaseURL, objectType, objectBlueprint) - - poolSize := options.OperationSettings.DefaultPoolSize - for _, pool := range options.OperationSettings.Pools { - if pool.Resource == objectType.String() { - poolSize = pool.Size - break - } - } - - controller.scheduler = operations.NewScheduler(ctx, options.Repository, options.OperationSettings.JobTimeout, poolSize, options.WaitGroup) +func NewAsyncController(ctx context.Context, options *Options, resourceBaseURL string, objectType types.ObjectType, isAsyncDefault bool, objectBlueprint func() types.Object) *BaseController { + controller := NewController(ctx, options, resourceBaseURL, objectType, objectBlueprint) + controller.supportsAsync = true + controller.isAsyncDefault = isAsyncDefault return controller } @@ -157,40 +164,50 @@ func (c *BaseController) CreateObject(r *web.Request) (*web.Response, error) { result.SetID(UUID.String()) } currentTime := time.Now().UTC() + // override ready provide from the request body result.SetCreatedAt(currentTime) result.SetUpdatedAt(currentTime) + result.SetReady(false) - operationFunc := func(ctx context.Context, repository storage.Repository) (types.Object, error) { - return repository.Create(ctx, result) + action := func(ctx context.Context, repository storage.Repository) (types.Object, error) { + object, err := repository.Create(ctx, result) + return object, util.HandleStorageError(err, c.objectType.String()) } - isAsync := r.URL.Query().Get(web.QueryParamAsync) - if isAsync == "true" { + UUID, err := uuid.NewV4() + if err != nil { + return nil, fmt.Errorf("could not generate GUID for %s: %s", c.objectType, err) + } + operation := &types.Operation{ + Base: types.Base{ + ID: UUID.String(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Labels: make(map[string][]string), + Ready: true, + }, + Type: types.CREATE, + State: types.IN_PROGRESS, + ResourceID: result.GetID(), + ResourceType: c.objectType, + CorrelationID: log.CorrelationIDFromContext(ctx), + } + + if c.shouldExecuteAsync(r) { log.C(ctx).Debugf("Request will be executed asynchronously") if err := c.checkAsyncSupport(); err != nil { return nil, err } - operation, err := c.buildOperation(ctx, c.repository, types.IN_PROGRESS, types.CREATE, result.GetID(), log.CorrelationIDFromContext(ctx)) - if err != nil { + if err := c.scheduler.ScheduleAsyncStorageAction(ctx, operation, action); err != nil { return nil, err } - operationID, err := c.scheduler.Schedule(operations.Job{ - ReqCtx: ctx, - ObjectType: c.objectType, - Operation: operation, - OperationFunc: operationFunc, - }) - if err != nil { - return nil, err - } - - return newAsyncResponse(operationID, result.GetID(), c.resourceBaseURL) + return newAsyncResponse(operation.GetID(), result.GetID(), c.resourceBaseURL) } log.C(ctx).Debugf("Request will be executed synchronously") - createdObj, err := operationFunc(ctx, c.repository) + createdObj, err := c.scheduler.ScheduleSyncStorageAction(ctx, operation, action) if err != nil { return nil, util.HandleStorageError(err, c.objectType.String()) } @@ -203,50 +220,19 @@ func (c *BaseController) DeleteObjects(r *web.Request) (*web.Response, error) { ctx := r.Context() log.C(ctx).Debugf("Deleting %ss...", c.objectType) - criteria := query.CriteriaForContext(ctx) - - operationFunc := func(ctx context.Context, repository storage.Repository) (types.Object, error) { - return nil, repository.Delete(ctx, c.objectType, criteria...) - } - isAsync := r.URL.Query().Get(web.QueryParamAsync) if isAsync == "true" { - log.C(ctx).Debugf("Request will be executed asynchronously") - if err := c.checkAsyncSupport(); err != nil { - return nil, err - } - - resourceIDs := getResourceIDsFromCriteria(criteria) - if len(resourceIDs) != 1 { - return nil, &util.HTTPError{ - ErrorType: "BadRequest", - Description: "Only one resource can be deleted asynchronously at a time", - StatusCode: http.StatusBadRequest, - } - } - - resourceID := resourceIDs[0] - - operation, err := c.buildOperation(ctx, c.repository, types.IN_PROGRESS, types.DELETE, resourceID, log.CorrelationIDFromContext(ctx)) - if err != nil { - return nil, err - } - - operationID, err := c.scheduler.Schedule(operations.Job{ - ReqCtx: ctx, - ObjectType: c.objectType, - Operation: operation, - OperationFunc: operationFunc, - }) - if err != nil { - return nil, err + return nil, &util.HTTPError{ + ErrorType: "BadRequest", + Description: "Only one resource can be deleted asynchronously at a time", + StatusCode: http.StatusBadRequest, } - - return newAsyncResponse(operationID, resourceID, c.resourceBaseURL) } + criteria := query.CriteriaForContext(ctx) + log.C(ctx).Debugf("Request will be executed synchronously") - if _, err := operationFunc(ctx, c.repository); err != nil { + if err := c.repository.Delete(ctx, c.objectType, criteria...); err != nil { return nil, util.HandleStorageError(err, c.objectType.String()) } @@ -265,8 +251,51 @@ func (c *BaseController) DeleteSingleObject(r *web.Request) (*web.Response, erro return nil, err } r.Request = r.WithContext(ctx) + criteria := query.CriteriaForContext(ctx) + + action := func(ctx context.Context, repository storage.Repository) (types.Object, error) { + err := repository.Delete(ctx, c.objectType, criteria...) + return nil, util.HandleStorageError(err, c.objectType.String()) + } + + UUID, err := uuid.NewV4() + if err != nil { + return nil, fmt.Errorf("could not generate GUID for %s: %s", c.objectType, err) + } + operation := &types.Operation{ + Base: types.Base{ + ID: UUID.String(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Labels: make(map[string][]string), + Ready: true, + }, + Type: types.DELETE, + State: types.IN_PROGRESS, + ResourceID: objectID, + ResourceType: c.objectType, + CorrelationID: log.CorrelationIDFromContext(ctx), + } - return c.DeleteObjects(r) + if c.shouldExecuteAsync(r) { + log.C(ctx).Debugf("Request will be executed asynchronously") + if err := c.checkAsyncSupport(); err != nil { + return nil, err + } + + if err := c.scheduler.ScheduleAsyncStorageAction(ctx, operation, action); err != nil { + return nil, err + } + + return newAsyncResponse(operation.ID, objectID, c.resourceBaseURL) + } + + log.C(ctx).Debugf("Request will be executed synchronously") + if _, err := c.scheduler.ScheduleSyncStorageAction(ctx, operation, action); err != nil { + return nil, util.HandleStorageError(err, c.objectType.String()) + } + + return util.NewJSONResponse(http.StatusOK, map[string]string{}) } // GetSingleObject handles the fetching of a single object with the id specified in the request @@ -411,41 +440,50 @@ func (c *BaseController) PatchObject(r *web.Request) (*web.Response, error) { objFromDB.SetID(objectID) objFromDB.SetCreatedAt(createdAt) objFromDB.SetUpdatedAt(updatedAt) + objFromDB.SetReady(true) labels, _, _ := query.ApplyLabelChangesToLabels(labelChanges, objFromDB.GetLabels()) objFromDB.SetLabels(labels) - operationFunc := func(ctx context.Context, repository storage.Repository) (types.Object, error) { - return repository.Update(ctx, objFromDB, labelChanges, criteria...) + action := func(ctx context.Context, repository storage.Repository) (types.Object, error) { + object, err := repository.Update(ctx, objFromDB, labelChanges, criteria...) + return object, util.HandleStorageError(err, c.objectType.String()) } - isAsync := r.URL.Query().Get(web.QueryParamAsync) - if isAsync == "true" { + UUID, err := uuid.NewV4() + if err != nil { + return nil, fmt.Errorf("could not generate GUID for %s: %s", c.objectType, err) + } + operation := &types.Operation{ + Base: types.Base{ + ID: UUID.String(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Labels: make(map[string][]string), + Ready: true, + }, + Type: types.UPDATE, + State: types.IN_PROGRESS, + ResourceID: objFromDB.GetID(), + ResourceType: c.objectType, + CorrelationID: log.CorrelationIDFromContext(ctx), + } + + if c.shouldExecuteAsync(r) { log.C(ctx).Debugf("Request will be executed asynchronously") if err := c.checkAsyncSupport(); err != nil { return nil, err } - operation, err := c.buildOperation(ctx, c.repository, types.IN_PROGRESS, types.UPDATE, objFromDB.GetID(), log.CorrelationIDFromContext(ctx)) - if err != nil { - return nil, err - } - - operationID, err := c.scheduler.Schedule(operations.Job{ - ReqCtx: ctx, - ObjectType: c.objectType, - Operation: operation, - OperationFunc: operationFunc, - }) - if err != nil { + if err := c.scheduler.ScheduleAsyncStorageAction(ctx, operation, action); err != nil { return nil, err } - return newAsyncResponse(operationID, objFromDB.GetID(), c.resourceBaseURL) + return newAsyncResponse(operation.GetID(), objFromDB.GetID(), c.resourceBaseURL) } log.C(ctx).Debugf("Request will be executed synchronously") - object, err := operationFunc(ctx, c.repository) + object, err := c.scheduler.ScheduleSyncStorageAction(ctx, operation, action) if err != nil { return nil, util.HandleStorageError(err, c.objectType.String()) } @@ -548,8 +586,17 @@ func (c *BaseController) parsePageToken(ctx context.Context, token string) (stri return targetPageSequence, nil } +func (c *BaseController) shouldExecuteAsync(r *web.Request) bool { + async := r.URL.Query().Get(web.QueryParamAsync) + if async == "" { + return c.isAsyncDefault + } + + return async == "true" +} + func (c *BaseController) checkAsyncSupport() error { - if c.scheduler == nil { + if !c.supportsAsync { return &util.HTTPError{ ErrorType: "InvalidRequest", Description: fmt.Sprintf("requested %s api doesn't support asynchronous operations", c.objectType), @@ -559,28 +606,6 @@ func (c *BaseController) checkAsyncSupport() error { return nil } -func (c *BaseController) buildOperation(ctx context.Context, storage storage.Repository, state types.OperationState, category types.OperationCategory, resourceID, correlationID string) (*types.Operation, error) { - UUID, err := uuid.NewV4() - if err != nil { - return nil, fmt.Errorf("could not generate GUID for %s: %s", c.objectType, err) - } - operation := &types.Operation{ - Base: types.Base{ - ID: UUID.String(), - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - Labels: make(map[string][]string), - }, - Type: category, - State: state, - ResourceID: resourceID, - ResourceType: c.resourceBaseURL, - CorrelationID: correlationID, - } - - return operation, nil -} - func generateTokenForItem(obj types.Object) string { nextPageToken := obj.GetPagingSequence() return base64.StdEncoding.EncodeToString([]byte(strconv.FormatInt(nextPageToken, 10))) @@ -605,15 +630,6 @@ func pageFromObjectList(ctx context.Context, objectList types.ObjectList, count, return page } -func getResourceIDsFromCriteria(criteria []query.Criterion) []string { - for _, criterion := range criteria { - if criterion.LeftOp == "id" { - return criterion.RightOp - } - } - return []string{} -} - func newAsyncResponse(operationID, resourceID, resourceBaseURL string) (*web.Response, error) { operationURL := buildOperationURL(operationID, resourceID, resourceBaseURL) additionalHeaders := map[string]string{"Location": operationURL} diff --git a/api/filters/check_binding_visibility_filter.go b/api/filters/check_binding_visibility_filter.go index d0ced9819..c18d79e75 100644 --- a/api/filters/check_binding_visibility_filter.go +++ b/api/filters/check_binding_visibility_filter.go @@ -18,13 +18,14 @@ package filters import ( "context" + "net/http" + "github.com/Peripli/service-manager/pkg/log" "github.com/Peripli/service-manager/pkg/query" "github.com/Peripli/service-manager/pkg/types" "github.com/Peripli/service-manager/pkg/util" "github.com/Peripli/service-manager/storage" "github.com/tidwall/gjson" - "net/http" "github.com/Peripli/service-manager/pkg/web" ) @@ -97,7 +98,7 @@ func (f *serviceBindingVisibilityFilter) Run(req *web.Request, next web.Handler) if count != 1 { return nil, &util.HTTPError{ ErrorType: "NotFound", - Description: "could not find such service binding(s)", + Description: "service instance not found or not accessible", StatusCode: http.StatusNotFound, } } diff --git a/api/filters/check_instance_visibility_filter.go b/api/filters/check_instance_visibility_filter.go index 4ea734dbb..72174dad2 100644 --- a/api/filters/check_instance_visibility_filter.go +++ b/api/filters/check_instance_visibility_filter.go @@ -17,13 +17,14 @@ package filters import ( + "net/http" + "github.com/Peripli/service-manager/pkg/log" "github.com/Peripli/service-manager/pkg/query" "github.com/Peripli/service-manager/pkg/types" "github.com/Peripli/service-manager/pkg/util" "github.com/Peripli/service-manager/pkg/util/slice" "github.com/Peripli/service-manager/storage" - "net/http" "github.com/Peripli/service-manager/pkg/web" "github.com/tidwall/gjson" diff --git a/api/osb/catalog_fetcher_test.go b/api/osb/catalog_fetcher_test.go index 27b120e93..8937df770 100644 --- a/api/osb/catalog_fetcher_test.go +++ b/api/osb/catalog_fetcher_test.go @@ -90,6 +90,7 @@ var _ = Describe("Catalog CatalogFetcher", func() { Base: types.Base{ ID: id, Labels: map[string][]string{}, + Ready: true, }, Name: name, BrokerURL: url, diff --git a/api/osb/client.go b/api/osb/client.go new file mode 100644 index 000000000..2245899e6 --- /dev/null +++ b/api/osb/client.go @@ -0,0 +1,12 @@ +package osb + +import osbc "github.com/kubernetes-sigs/go-open-service-broker-client/v2" + +// NewBrokerClientProvider provides a function which constructs an OSB client based on a provided configuration +func NewBrokerClientProvider(skipSsl bool, timeout int) osbc.CreateFunc { + return func(configuration *osbc.ClientConfiguration) (osbc.Client, error) { + configuration.TimeoutSeconds = timeout + configuration.Insecure = skipSsl + return osbc.NewClient(configuration) + } +} diff --git a/api/osb/store_instances_plugin.go b/api/osb/store_instances_plugin.go index 2db84e9fe..859d92ebd 100644 --- a/api/osb/store_instances_plugin.go +++ b/api/osb/store_instances_plugin.go @@ -493,6 +493,7 @@ func (ssi *StoreServiceInstancePlugin) storeOperation(ctx context.Context, stora CreatedAt: req.GetTimestamp(), UpdatedAt: req.GetTimestamp(), Labels: make(map[string][]string), + Ready: true, }, Type: category, State: state, @@ -525,6 +526,7 @@ func (ssi *StoreServiceInstancePlugin) storeInstance(ctx context.Context, storag CreatedAt: req.Timestamp, UpdatedAt: req.Timestamp, Labels: make(map[string][]string), + Ready: ready, }, Name: instanceName, ServicePlanID: planID, @@ -532,7 +534,6 @@ func (ssi *StoreServiceInstancePlugin) storeInstance(ctx context.Context, storag DashboardURL: resp.DashboardURL, MaintenanceInfo: req.RawMaintenanceInfo, Context: req.RawContext, - Ready: ready, Usable: true, } if _, err := storage.Create(ctx, instance); err != nil { diff --git a/api/service_binding_controller.go b/api/service_binding_controller.go index 69135cd9d..5cf216f65 100644 --- a/api/service_binding_controller.go +++ b/api/service_binding_controller.go @@ -32,7 +32,7 @@ type ServiceBindingController struct { func NewServiceBindingController(ctx context.Context, options *Options) *ServiceBindingController { return &ServiceBindingController{ - BaseController: NewAsyncController(ctx, options, web.ServiceBindingsURL, types.ServiceBindingType, func() types.Object { + BaseController: NewAsyncController(ctx, options, web.ServiceBindingsURL, types.ServiceBindingType, true, func() types.Object { return &types.ServiceBinding{} }), } diff --git a/api/service_instance_controller.go b/api/service_instance_controller.go new file mode 100644 index 000000000..9d438bb21 --- /dev/null +++ b/api/service_instance_controller.go @@ -0,0 +1,86 @@ +/* + * Copyright 2018 The Service Manager Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package api + +import ( + "context" + "fmt" + "net/http" + + "github.com/Peripli/service-manager/pkg/types" + "github.com/Peripli/service-manager/pkg/web" +) + +// ServiceInstanceController implements api.Controller by providing service Instances API logic +type ServiceInstanceController struct { + *BaseController +} + +func NewServiceInstanceController(ctx context.Context, options *Options) *ServiceInstanceController { + return &ServiceInstanceController{ + BaseController: NewAsyncController(ctx, options, web.ServiceInstancesURL, types.ServiceInstanceType, true, func() types.Object { + return &types.ServiceInstance{} + }), + } +} + +func (c *ServiceInstanceController) Routes() []web.Route { + return []web.Route{ + { + Endpoint: web.Endpoint{ + Method: http.MethodPost, + Path: c.resourceBaseURL, + }, + Handler: c.CreateObject, + }, + { + Endpoint: web.Endpoint{ + Method: http.MethodGet, + Path: fmt.Sprintf("%s/{%s}", c.resourceBaseURL, web.PathParamResourceID), + }, + Handler: c.GetSingleObject, + }, + { + Endpoint: web.Endpoint{ + Method: http.MethodGet, + Path: fmt.Sprintf("%s/{%s}%s/{%s}", c.resourceBaseURL, web.PathParamResourceID, web.OperationsURL, web.PathParamID), + }, + Handler: c.GetOperation, + }, + { + Endpoint: web.Endpoint{ + Method: http.MethodGet, + Path: c.resourceBaseURL, + }, + Handler: c.ListObjects, + }, + { + Endpoint: web.Endpoint{ + Method: http.MethodDelete, + Path: fmt.Sprintf("%s/{%s}", c.resourceBaseURL, web.PathParamResourceID), + }, + Handler: c.DeleteSingleObject, + }, + { + Endpoint: web.Endpoint{ + Method: http.MethodPatch, + Path: fmt.Sprintf("%s/{%s}", c.resourceBaseURL, web.PathParamResourceID), + }, + Handler: c.PatchObject, + }, + } +} diff --git a/api/service_offering_controller.go b/api/service_offering_controller.go index 34f1d2a8e..84f58b3ca 100644 --- a/api/service_offering_controller.go +++ b/api/service_offering_controller.go @@ -17,6 +17,7 @@ package api import ( + "context" "fmt" "net/http" @@ -29,9 +30,9 @@ type ServiceOfferingController struct { *BaseController } -func NewServiceOfferingController(options *Options) *ServiceOfferingController { +func NewServiceOfferingController(ctx context.Context, options *Options) *ServiceOfferingController { return &ServiceOfferingController{ - BaseController: NewController(options, web.ServiceOfferingsURL, types.ServiceOfferingType, func() types.Object { + BaseController: NewController(ctx, options, web.ServiceOfferingsURL, types.ServiceOfferingType, func() types.Object { return &types.ServiceOffering{} }), } diff --git a/api/service_plan_controller.go b/api/service_plan_controller.go index 69d9d98b7..bec555f01 100644 --- a/api/service_plan_controller.go +++ b/api/service_plan_controller.go @@ -17,6 +17,7 @@ package api import ( + "context" "fmt" "net/http" @@ -29,9 +30,9 @@ type ServicePlanController struct { *BaseController } -func NewServicePlanController(options *Options) *ServicePlanController { +func NewServicePlanController(ctx context.Context, options *Options) *ServicePlanController { return &ServicePlanController{ - BaseController: NewController(options, web.ServicePlansURL, types.ServicePlanType, func() types.Object { + BaseController: NewController(ctx, options, web.ServicePlansURL, types.ServicePlanType, func() types.Object { return &types.ServicePlan{} }), } diff --git a/application.yml b/application.yml index 909627632..15796f5c4 100644 --- a/application.yml +++ b/application.yml @@ -28,10 +28,15 @@ api: operations: cleanup_interval: 30m job_timeout: 12m + scheduled_deletion_timeout: 12h + polling_interval: 5s + rescheduling_interval: 5s pools: - - resource: service_broker + - resource: /v1/service_brokers size: 100 - - resource: platform + - resource: /v1/platforms size: 10 - - resource: visibility + - resource: /v1/visibilities size: 25 +multitenancy: + label_key: tenant diff --git a/cmd/smgen/api_type_template.go b/cmd/smgen/api_type_template.go index 221608de0..8f9ae0764 100644 --- a/cmd/smgen/api_type_template.go +++ b/cmd/smgen/api_type_template.go @@ -26,9 +26,10 @@ import ( {{.TypesPackageImport}} {{end}} "github.com/Peripli/service-manager/pkg/util" + "github.com/Peripli/service-manager/pkg/web" ) -const {{.Type}}Type {{.TypesPackage}}ObjectType = "{{.PackageName}}.{{.Type}}" +const {{.Type}}Type {{.TypesPackage}}ObjectType = web.{{.TypePlural}}URL type {{.TypePlural}} struct { {{.TypePlural}} []*{{.Type}} ` + "`json:\"{{.TypePluralLowercase}}\"`" + ` diff --git a/config/config.go b/config/config.go index 7ad134979..14ebd9752 100644 --- a/config/config.go +++ b/config/config.go @@ -18,6 +18,9 @@ package config import ( "fmt" + + "github.com/Peripli/service-manager/pkg/multitenancy" + "github.com/Peripli/service-manager/operations" "github.com/Peripli/service-manager/pkg/httpclient" @@ -34,14 +37,15 @@ import ( // Settings is used to setup the Service Manager type Settings struct { - Server *server.Settings - Storage *storage.Settings - Log *log.Settings - API *api.Settings - Operations *operations.Settings - WebSocket *ws.Settings - HTTPClient *httpclient.Settings - Health *health.Settings + Server *server.Settings + Storage *storage.Settings + Log *log.Settings + API *api.Settings + Operations *operations.Settings + WebSocket *ws.Settings + HTTPClient *httpclient.Settings + Health *health.Settings + Multitenancy *multitenancy.Settings } // AddPFlags adds the SM config flags to the provided flag set @@ -53,14 +57,15 @@ func AddPFlags(set *pflag.FlagSet) { // DefaultSettings returns the default values for configuring the Service Manager func DefaultSettings() *Settings { return &Settings{ - Server: server.DefaultSettings(), - Storage: storage.DefaultSettings(), - Log: log.DefaultSettings(), - API: api.DefaultSettings(), - Operations: operations.DefaultSettings(), - WebSocket: ws.DefaultSettings(), - HTTPClient: httpclient.DefaultSettings(), - Health: health.DefaultSettings(), + Server: server.DefaultSettings(), + Storage: storage.DefaultSettings(), + Log: log.DefaultSettings(), + API: api.DefaultSettings(), + Operations: operations.DefaultSettings(), + WebSocket: ws.DefaultSettings(), + HTTPClient: httpclient.DefaultSettings(), + Health: health.DefaultSettings(), + Multitenancy: multitenancy.DefaultSettings(), } } @@ -78,7 +83,7 @@ func New(env env.Environment) (*Settings, error) { func (c *Settings) Validate() error { validatable := []interface { Validate() error - }{c.Server, c.Storage, c.Log, c.Health, c.API, c.Operations, c.WebSocket} + }{c.Server, c.Storage, c.Log, c.Health, c.API, c.Operations, c.WebSocket, c.Multitenancy} for _, item := range validatable { if err := item.Validate(); err != nil { diff --git a/config/config_test.go b/config/config_test.go index d4dbf8627..0d6ab4c50 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -18,11 +18,12 @@ package config_test import ( "fmt" - "github.com/Peripli/service-manager/operations" - "github.com/Peripli/service-manager/pkg/health" "testing" "time" + "github.com/Peripli/service-manager/operations" + "github.com/Peripli/service-manager/pkg/health" + "github.com/Peripli/service-manager/api" cfg "github.com/Peripli/service-manager/config" "github.com/Peripli/service-manager/pkg/env/envfakes" @@ -82,6 +83,7 @@ var _ = Describe("config", func() { fatal = true failuresThreshold = 1 interval = 30 * time.Second + config.Multitenancy.LabelKey = "tenant" }) Context("health indicator with negative threshold", func() { @@ -259,6 +261,27 @@ var _ = Describe("config", func() { }) }) + Context("when operation scheduled deletion timeoutt is < 0", func() { + It("returns an error", func() { + config.Operations.ScheduledDeletionTimeout = -time.Second + assertErrorDuringValidate() + }) + }) + + Context("when operation rescheduling interval < 0", func() { + It("returns an error", func() { + config.Operations.ReschedulingInterval = -time.Second + assertErrorDuringValidate() + }) + }) + + Context("when operation polling interval < 0", func() { + It("returns an error", func() { + config.Operations.PollingInterval = -time.Second + assertErrorDuringValidate() + }) + }) + Context("when operation default pool size is <= 0", func() { It("returns an error", func() { config.Operations.DefaultPoolSize = 0 @@ -277,6 +300,13 @@ var _ = Describe("config", func() { assertErrorDuringValidate() }) }) + + Context("when multitenancy label key is empty", func() { + It("returns an error", func() { + config.Multitenancy.LabelKey = "" + assertErrorDuringValidate() + }) + }) }) Describe("New", func() { diff --git a/operations/config.go b/operations/config.go index 891fa2edf..7f47e1307 100644 --- a/operations/config.go +++ b/operations/config.go @@ -33,16 +33,23 @@ type Settings struct { CleanupInterval time.Duration `mapstructure:"cleanup_interval" description:"cleanup interval of old operations"` DefaultPoolSize int `mapstructure:"default_pool_size" description:"default worker pool size"` Pools []PoolSettings `mapstructure:"pools" description:"defines the different available worker pools"` + + ScheduledDeletionTimeout time.Duration `mapstructure:"scheduled_deletion_timeout" description:"the maximum allowed timeout for auto rescheduling of operation actions"` + ReschedulingInterval time.Duration `mapstructure:"rescheduling_interval" description:"the interval between auto rescheduling of operation actions"` + PollingInterval time.Duration `mapstructure:"polling_interval" description:"the interval between polls for async requests"` } // DefaultSettings returns default values for API settings func DefaultSettings() *Settings { return &Settings{ - JobTimeout: defaultJobTimeout, - MarkOrphansInterval: defaultJobTimeout, - CleanupInterval: 10 * time.Minute, - DefaultPoolSize: 20, - Pools: []PoolSettings{}, + JobTimeout: defaultJobTimeout, + MarkOrphansInterval: defaultJobTimeout, + CleanupInterval: 10 * time.Minute, + DefaultPoolSize: 20, + Pools: []PoolSettings{}, + ScheduledDeletionTimeout: 12 * time.Hour, + ReschedulingInterval: 1 * time.Second, + PollingInterval: 1 * time.Second, } } @@ -57,6 +64,15 @@ func (s *Settings) Validate() error { if s.CleanupInterval <= minTimePeriod { return fmt.Errorf("validate Settings: CleanupInterval must be larger than %s", minTimePeriod) } + if s.ScheduledDeletionTimeout <= minTimePeriod { + return fmt.Errorf("validate Settings: ScheduledDeletionTimeout must be larger than %s", minTimePeriod) + } + if s.ReschedulingInterval <= minTimePeriod { + return fmt.Errorf("validate Settings: ReschedulingInterval must be larger than %s", minTimePeriod) + } + if s.PollingInterval <= minTimePeriod { + return fmt.Errorf("validate Settings: PollingInterval must be larger than %s", minTimePeriod) + } if s.DefaultPoolSize <= 0 { return fmt.Errorf("validate Settings: DefaultPoolSize must be larger than 0") } @@ -83,8 +99,3 @@ func (ps *PoolSettings) Validate() error { return nil } - -// OperationError holds an error message returned from an execution of an async job -type OperationError struct { - Message string `json:"message"` -} diff --git a/operations/context.go b/operations/context.go new file mode 100644 index 000000000..1bd1384c8 --- /dev/null +++ b/operations/context.go @@ -0,0 +1,43 @@ +/* + * Copyright 2018 The Service Manager Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package operations + +import ( + "context" + + "github.com/Peripli/service-manager/pkg/types" +) + +// operationCtxKey allows putting the currently running operation is the context. This is required for +// some interceptors - based on the operation they execute different logic or they might update the actual operation +type operationCtxKey struct{} + +func GetFromContext(ctx context.Context) (*types.Operation, bool) { + currentOperation := ctx.Value(operationCtxKey{}) + if currentOperation == nil { + return nil, false + } + return currentOperation.(*types.Operation), true +} + +func SetInContext(ctx context.Context, operation *types.Operation) (context.Context, error) { + if err := operation.Validate(); err != nil { + return nil, err + } + + return context.WithValue(ctx, operationCtxKey{}, operation), nil +} diff --git a/operations/jobs.go b/operations/jobs.go deleted file mode 100644 index 7700e0578..000000000 --- a/operations/jobs.go +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright 2018 The Service Manager Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package operations - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "github.com/Peripli/service-manager/pkg/log" - "github.com/Peripli/service-manager/pkg/query" - "github.com/Peripli/service-manager/pkg/types" - "github.com/Peripli/service-manager/pkg/util" - "github.com/Peripli/service-manager/storage" - "runtime/debug" -) - -// Job is responsible for executing a C/U/D DB operation -type Job struct { - ReqCtx context.Context - ObjectType types.ObjectType - - Operation *types.Operation - OperationFunc func(ctx context.Context, repository storage.Repository) (types.Object, error) -} - -// Execute executes a C/U/D DB operation -func (j *Job) Execute(ctxWithTimeout context.Context, repository storage.Repository) (operationID string, err error) { - log.D().Debugf("Starting execution of %s operation with id (%s) for %s entity", j.Operation.Type, j.Operation.ID, j.ObjectType) - operationID = j.Operation.ID - - reqCtx := util.StateContext{Context: j.ReqCtx} - opCtx := util.StateContext{Context: j.ReqCtx} - - defer func() { - if panicErr := recover(); panicErr != nil { - err = fmt.Errorf("job panicked while executing: %s", panicErr) - if opErr := updateOperationState(opCtx, repository, operationID, types.FAILED, &OperationError{Message: "job interrupted"}); opErr != nil { - log.D().Debugf("Failed to set state of operation with id (%s) to %s", operationID, types.FAILED) - err = fmt.Errorf("%s : %s", err, opErr) - } - debug.PrintStack() - } - }() - - ctx, cancel := context.WithCancel(reqCtx) - go func() { - <-ctxWithTimeout.Done() - cancel() - }() - - if _, err = j.OperationFunc(ctx, repository); err != nil { - log.D().Debugf("Failed to execute %s operation with id (%s) for %s entity", j.Operation.Type, operationID, j.ObjectType) - - select { - case <-ctxWithTimeout.Done(): - err = errors.New("job timed out") - default: - } - - if opErr := updateOperationState(opCtx, repository, operationID, types.FAILED, &OperationError{Message: err.Error()}); opErr != nil { - log.D().Debugf("Failed to set state of operation with id (%s) to %s", operationID, types.FAILED) - err = fmt.Errorf("%s : %s", err, opErr) - } - return operationID, err - } - - log.D().Debugf("Successfully executed %s operation with id (%s) for %s entity", j.Operation.Type, operationID, j.ObjectType) - if err = updateOperationState(opCtx, repository, operationID, types.SUCCEEDED, nil); err != nil { - log.D().Debugf("Failed to set state of operation with id (%s) to %s", operationID, types.SUCCEEDED) - } - - return operationID, err -} - -func updateOperationState(ctx context.Context, repository storage.Repository, operationID string, state types.OperationState, opErr *OperationError) error { - operation, err := fetchOperation(ctx, repository, operationID) - if err != nil { - return err - } - - operation.State = state - - if opErr != nil { - bytes, err := json.Marshal(opErr) - if err != nil { - return err - } - operation.Errors = json.RawMessage(bytes) - } - - _, err = repository.Update(ctx, operation, query.LabelChanges{}) - if err != nil { - log.D().Debugf("Failed to update state of operation with id (%s) to %s", operationID, state) - return err - } - - log.D().Debugf("Successfully updated state of operation with id (%s) to %s", operationID, state) - return nil -} - -func fetchOperation(ctx context.Context, repository storage.Repository, operationID string) (*types.Operation, error) { - byID := query.ByField(query.EqualsOperator, "id", operationID) - objFromDB, err := repository.Get(ctx, types.OperationType, byID) - if err != nil { - log.D().Debugf("Failed to retrieve operation with id (%s)", operationID) - return nil, err - } - - return objFromDB.(*types.Operation), nil -} diff --git a/operations/scheduler.go b/operations/scheduler.go index 736a4bafb..ac0d1334e 100644 --- a/operations/scheduler.go +++ b/operations/scheduler.go @@ -18,71 +18,540 @@ package operations import ( "context" - "github.com/Peripli/service-manager/pkg/log" - "github.com/Peripli/service-manager/pkg/util" - "github.com/Peripli/service-manager/storage" + "encoding/json" + "fmt" "net/http" + "runtime/debug" "sync" "time" + + "github.com/Peripli/service-manager/pkg/log" + "github.com/Peripli/service-manager/pkg/query" + "github.com/Peripli/service-manager/pkg/types" + "github.com/Peripli/service-manager/pkg/util" + "github.com/Peripli/service-manager/storage" ) +type storageAction func(ctx context.Context, repository storage.Repository) (types.Object, error) + // Scheduler is responsible for storing Operation entities in the DB // and also for spawning goroutines to execute the respective DB transaction asynchronously type Scheduler struct { - smCtx context.Context - repository storage.Repository - workers chan struct{} - jobTimeout time.Duration - wg *sync.WaitGroup + smCtx context.Context + repository storage.TransactionalRepository + workers chan struct{} + jobTimeout time.Duration + deletionTimeout time.Duration + reschedulingDelay time.Duration + wg *sync.WaitGroup } // NewScheduler constructs a Scheduler -func NewScheduler(smCtx context.Context, repository storage.Repository, jobTimeout time.Duration, workerPoolSize int, wg *sync.WaitGroup) *Scheduler { +func NewScheduler(smCtx context.Context, repository storage.TransactionalRepository, settings *Settings, poolSize int, wg *sync.WaitGroup) *Scheduler { return &Scheduler{ - smCtx: smCtx, - repository: repository, - workers: make(chan struct{}, workerPoolSize), - jobTimeout: jobTimeout, - wg: wg, + smCtx: smCtx, + repository: repository, + workers: make(chan struct{}, poolSize), + jobTimeout: settings.JobTimeout, + deletionTimeout: settings.ScheduledDeletionTimeout, + reschedulingDelay: settings.ReschedulingInterval, + wg: wg, } } -// Schedule stores the Job's Operation entity in DB and spawns a goroutine to execute the CREATE/UPDATE/DELETE DB transaction asynchronously -func (ds *Scheduler) Schedule(job Job) (string, error) { - log.D().Infof("Scheduling %s operation with id (%s)", job.Operation.Type, job.Operation.ID) +// ScheduleSyncStorageAction stores the job's Operation entity in DB and synchronously executes the CREATE/UPDATE/DELETE DB transaction +func (s *Scheduler) ScheduleSyncStorageAction(ctx context.Context, operation *types.Operation, action storageAction) (types.Object, error) { + initialLogMessage(ctx, operation, false) + + if err := s.executeOperationPreconditions(ctx, operation); err != nil { + return nil, err + } + + ctxWithOp, err := s.addOperationToContext(ctx, operation) + if err != nil { + return nil, err + } + + object, actionErr := action(ctxWithOp, s.repository) + if actionErr != nil { + log.C(ctx).Errorf("failed to execute action for %s operation with id %s for %s entity with id %s: %s", operation.Type, operation.ID, operation.ResourceType, operation.ResourceID, actionErr) + } + + if object, err = s.handleActionResponse(&util.StateContext{Context: ctx}, object, actionErr, operation); err != nil { + return nil, err + } + + return object, nil +} + +// ScheduleAsyncStorageAction stores the job's Operation entity in DB asynchronously executes the CREATE/UPDATE/DELETE DB transaction in a goroutine +func (s *Scheduler) ScheduleAsyncStorageAction(ctx context.Context, operation *types.Operation, action storageAction) error { select { - case ds.workers <- struct{}{}: - log.D().Infof("Storing %s operation with id (%s)", job.Operation.Type, job.Operation.ID) - if _, err := ds.repository.Create(job.ReqCtx, job.Operation); err != nil { - <-ds.workers - return "", util.HandleStorageError(err, job.Operation.GetType().String()) + case s.workers <- struct{}{}: + initialLogMessage(ctx, operation, true) + if err := s.executeOperationPreconditions(ctx, operation); err != nil { + <-s.workers + return err } - ds.wg.Add(1) - go func() { + s.wg.Add(1) + stateCtx := util.StateContext{Context: ctx} + go func(operation *types.Operation) { defer func() { - <-ds.workers - ds.wg.Done() - }() + if panicErr := recover(); panicErr != nil { + errMessage := fmt.Errorf("job panicked while executing: %s", panicErr) + op, opErr := s.refetchOperation(stateCtx, operation) + if opErr != nil { + errMessage = fmt.Errorf("%s: setting new operation state failed: %s ", errMessage, opErr) + } - ctxWithTimeout, cancel := context.WithTimeout(ds.smCtx, ds.jobTimeout) - defer cancel() + if opErr := updateOperationState(stateCtx, s.repository, op, types.FAILED, &util.HTTPError{ + ErrorType: "InternalServerError", + Description: "job interrupted", + StatusCode: http.StatusInternalServerError, + }); opErr != nil { + errMessage = fmt.Errorf("%s: setting new operation state failed: %s ", errMessage, opErr) + } + log.C(stateCtx).Errorf("panic error: %s", errMessage) + debug.PrintStack() + } + <-s.workers + s.wg.Done() + }() - operationID, err := job.Execute(ctxWithTimeout, ds.repository) + stateCtxWithOp, err := s.addOperationToContext(stateCtx, operation) if err != nil { - log.D().Debugf("Error occurred during execution of operation with ID (%s): %s", operationID, err.Error()) + log.C(stateCtx).Error(err) return } - log.D().Debugf("Successful executed operation with ID (%s)", operationID) - }() + + stateCtxWithOpAndTimeout, timeoutCtxCancel := context.WithTimeout(stateCtxWithOp, s.jobTimeout) + defer timeoutCtxCancel() + go func() { + select { + case <-s.smCtx.Done(): + timeoutCtxCancel() + case <-stateCtxWithOpAndTimeout.Done(): + } + + }() + + var actionErr error + var objectAfterAction types.Object + if objectAfterAction, actionErr = action(stateCtxWithOpAndTimeout, s.repository); actionErr != nil { + log.C(stateCtx).Errorf("failed to execute action for %s operation with id %s for %s entity with id %s: %s", operation.Type, operation.ID, operation.ResourceType, operation.ResourceID, actionErr) + } + + if _, err := s.handleActionResponse(stateCtx, objectAfterAction, actionErr, operation); err != nil { + log.C(stateCtx).Error(err) + } + }(operation) default: - log.D().Infof("Failed to schedule %s operation with id (%s) - all workers are busy.", job.Operation.Type, job.Operation.ID) - return "", &util.HTTPError{ + log.C(ctx).Infof("Failed to schedule %s operation with id %s - all workers are busy.", operation.Type, operation.ID) + return &util.HTTPError{ ErrorType: "ServiceUnavailable", Description: "Failed to schedule job. Server is busy - try again in a few minutes.", StatusCode: http.StatusServiceUnavailable, } } - return job.Operation.ID, nil + return nil +} + +func (s *Scheduler) getResourceLastOperation(ctx context.Context, operation *types.Operation) (*types.Operation, bool, error) { + byResourceID := query.ByField(query.EqualsOperator, "resource_id", operation.ResourceID) + orderDesc := query.OrderResultBy("paging_sequence", query.DescOrder) + lastOperationObject, err := s.repository.Get(ctx, types.OperationType, byResourceID, orderDesc) + if err != nil { + if err == util.ErrNotFoundInStorage { + return nil, false, nil + } + return nil, false, util.HandleStorageError(err, types.OperationType.String()) + } + log.C(ctx).Infof("Last operation for resource with id %s of type %s is %+v", operation.ResourceID, operation.ResourceType, operation) + + return lastOperationObject.(*types.Operation), true, nil +} + +func (s *Scheduler) checkForConcurrentOperations(ctx context.Context, operation *types.Operation, lastOperation *types.Operation) error { + log.C(ctx).Debugf("Checking if another operation is in progress to resource of type %s with id %s", operation.ResourceType.String(), operation.ResourceID) + + isDeletionScheduled := !lastOperation.DeletionScheduled.IsZero() + + // for the outside world job timeout would have expired if the last update happened > job timeout time ago (this is worst case) + // an "old" updated_at means that for a while nobody was processing this operation + isLastOpInProgress := lastOperation.State == types.IN_PROGRESS && time.Now().Before(lastOperation.UpdatedAt.Add(s.jobTimeout)) + + isAReschedule := lastOperation.Reschedule && operation.Reschedule + + // depending on the last executed operation on the resource and the currently executing operation we determine if the + // currently executing operation should be allowed + switch lastOperation.Type { + case types.CREATE: + switch operation.Type { + case types.CREATE: + // a create is in progress and operation timeout is not exceeded + // the new op is a create with no deletion scheduled and is not reschedule, so fail + + // this means that when the last operation and the new operation which is either reschedulable or has a deletion scheduled + // it is up to the client to make sure such operations do not overlap + if isLastOpInProgress && !isDeletionScheduled && !isAReschedule { + return &util.HTTPError{ + ErrorType: "ConcurrentOperationInProgress", + Description: "Another concurrent operation in progress for this resource", + StatusCode: http.StatusUnprocessableEntity, + } + } + case types.UPDATE: + // a create is in progress and job timeout is not exceeded + // the new op is an update - we don't allow updating something that is not yet created so fail + if isLastOpInProgress { + return &util.HTTPError{ + ErrorType: "ConcurrentOperationInProgress", + Description: "Another concurrent operation in progress for this resource", + StatusCode: http.StatusUnprocessableEntity, + } + } + case types.DELETE: + // we allow deletes even if create is in progress + default: + // unknown operation type + return fmt.Errorf("operation type %s is unknown type", operation.Type) + } + case types.UPDATE: + switch operation.Type { + case types.CREATE: + // it doesnt really make sense to create something that was recently updated + if isLastOpInProgress { + return &util.HTTPError{ + ErrorType: "ConcurrentOperationInProgress", + Description: "Another concurrent operation in progress for this resource", + StatusCode: http.StatusUnprocessableEntity, + } + } + case types.UPDATE: + // an update is in progress and job timeout is not exceeded + // the new op is an update with no deletion scheduled and is not a reschedule, so fail + + // this means that when the last operation and the new operation which is either reschedulable or has a deletion scheduled + // it is up to the client to make sure such operations do not overlap + if isLastOpInProgress && !isDeletionScheduled && !isAReschedule { + return &util.HTTPError{ + ErrorType: "ConcurrentOperationInProgress", + Description: "Another concurrent operation in progress for this resource", + StatusCode: http.StatusUnprocessableEntity, + } + } + case types.DELETE: + // we allow deletes even if update is in progress + default: + // unknown operation type + return fmt.Errorf("operation type %s is unknown type", operation.Type) + } + case types.DELETE: + switch operation.Type { + case types.CREATE: + // if the last op is a delete in progress or if it has a deletion scheduled, creates are not allowed + if isLastOpInProgress || isDeletionScheduled { + return &util.HTTPError{ + ErrorType: "ConcurrentOperationInProgress", + Description: "Deletion is currently in progress for this resource", + StatusCode: http.StatusUnprocessableEntity, + } + } + case types.UPDATE: + // if delete is in progress or delete is scheduled, updates are not allowed + if isLastOpInProgress || isDeletionScheduled { + return &util.HTTPError{ + ErrorType: "ConcurrentOperationInProgress", + Description: "Deletion is currently in progress for this resource", + StatusCode: http.StatusUnprocessableEntity, + } + } + case types.DELETE: + // a delete is in progress and job timeout is not exceeded + // the new op is a delete with no deletion scheduled and is not a reschedule, so fail + + // this means that when the last operation and the new operation which is either reschedulable or has a deletion scheduled + // it is up to the client to make sure such operations do not overlap + if isLastOpInProgress && !isDeletionScheduled && !isAReschedule { + return &util.HTTPError{ + ErrorType: "ConcurrentOperationInProgress", + Description: "Deletion is currently in progress for this resource", + StatusCode: http.StatusUnprocessableEntity, + } + } + default: + // unknown operation type + return fmt.Errorf("operation type %s is unknown type", operation.Type) + } + default: + // unknown operation type + return fmt.Errorf("operation type %s is unknown type", lastOperation.Type) + } + + return nil +} + +func (s *Scheduler) storeOrUpdateOperation(ctx context.Context, operation, lastOperation *types.Operation) error { + // if a new operation is scheduled we need to store it + if lastOperation == nil || operation.ID != lastOperation.ID { + log.C(ctx).Infof("Storing %s operation with id %s", operation.Type, operation.ID) + if _, err := s.repository.Create(ctx, operation); err != nil { + return util.HandleStorageError(err, types.OperationType.String()) + } + // if its a reschedule of an existing operation (reschedule=true or deletion is scheduled), we need to update it + // so that maintainer can know if other maintainers are currently processing it + } else if operation.Reschedule || !operation.DeletionScheduled.IsZero() { + log.C(ctx).Infof("Updating rescheduled %s operation with id %s", operation.Type, operation.ID) + if _, err := s.repository.Update(ctx, operation, query.LabelChanges{}); err != nil { + return util.HandleStorageError(err, types.OperationType.String()) + } + // otherwise we should not allow executing an existing operation again + } else { + return fmt.Errorf("operation with this id was already executed") + } + + return nil +} + +func updateResource(ctx context.Context, repository storage.Repository, objectAfterAction types.Object, updateFunc func(obj types.Object)) (types.Object, error) { + updateFunc(objectAfterAction) + updatedObject, err := repository.Update(ctx, objectAfterAction, query.LabelChanges{}) + if err != nil { + return nil, fmt.Errorf("failed to update object with type %s and id %s", objectAfterAction.GetType(), objectAfterAction.GetID()) + } + + log.C(ctx).Infof("Successfully updated object of type %s and id %s ", objectAfterAction.GetType(), objectAfterAction.GetID()) + return updatedObject, nil +} + +func fetchAndUpdateResource(ctx context.Context, repository storage.Repository, objectID string, objectType types.ObjectType, updateFunc func(obj types.Object)) error { + byID := query.ByField(query.EqualsOperator, "id", objectID) + objectFromDB, err := repository.Get(ctx, objectType, byID) + if err != nil { + if err == util.ErrNotFoundInStorage { + return nil + } + return fmt.Errorf("failed to retrieve object of type %s with id %s:%s", objectType.String(), objectID, err) + } + + _, err = updateResource(ctx, repository, objectFromDB, updateFunc) + return err +} + +func updateOperationState(ctx context.Context, repository storage.Repository, operation *types.Operation, state types.OperationState, opErr error) error { + operation.State = state + + if opErr != nil { + httpError := util.ToHTTPError(ctx, opErr) + bytes, err := json.Marshal(httpError) + if err != nil { + return err + } + + if len(operation.Errors) == 0 { + log.C(ctx).Debugf("setting error of operation with id %s to %s", operation.ID, httpError) + operation.Errors = json.RawMessage(bytes) + } else { + log.C(ctx).Debugf("operation with id %s already has a root cause error %s. Current error %s will not be written", operation.ID, string(operation.Errors), httpError) + } + } + + // this also updates updated_at which serves as "reporting" that someone is working on the operation + _, err := repository.Update(ctx, operation, query.LabelChanges{}) + if err != nil { + return fmt.Errorf("failed to update state of operation with id %s to %s: %s", operation.ID, state, err) + } + + log.C(ctx).Infof("Successfully updated state of operation with id %s to %s", operation.ID, state) + return nil +} + +func (s *Scheduler) refetchOperation(ctx context.Context, operation *types.Operation) (*types.Operation, error) { + opObject, opErr := s.repository.Get(ctx, types.OperationType, query.ByField(query.EqualsOperator, "id", operation.ID)) + if opErr != nil { + opErr = fmt.Errorf("failed to re-fetch currently executing operation with id %s from db: %s", operation.ID, opErr) + if err := updateOperationState(ctx, s.repository, operation, types.FAILED, opErr); err != nil { + return nil, fmt.Errorf("setting new operation state due to err %s failed: %s", opErr, err) + } + return nil, opErr + } + + return opObject.(*types.Operation), nil +} + +func (s *Scheduler) handleActionResponse(ctx context.Context, actionObject types.Object, actionError error, opBeforeJob *types.Operation) (types.Object, error) { + opAfterJob, err := s.refetchOperation(ctx, opBeforeJob) + if err != nil { + return nil, err + } + + // if an action error has occurred we mark the operation as failed and check if deletion has to be scheduled + if actionError != nil { + return nil, s.handleActionResponseFailure(ctx, actionError, opAfterJob) + // if no error occurred and op is not reschedulable (has finished), mark it as success + } else if !opAfterJob.Reschedule { + return s.handleActionResponseSuccess(ctx, actionObject, opAfterJob) + } + + log.C(ctx).Infof("%s operation with id %s for %s entity with id %s is marked as requiring a reschedule and will be kept in progress", opAfterJob.Type, opAfterJob.ID, opAfterJob.ResourceType, opAfterJob.ResourceID) + // action did not return an error but required a reschedule so we keep it in progress + return actionObject, nil +} + +func (s *Scheduler) handleActionResponseFailure(ctx context.Context, actionError error, opAfterJob *types.Operation) error { + if err := s.repository.InTransaction(ctx, func(ctx context.Context, storage storage.Repository) error { + if opErr := updateOperationState(ctx, s.repository, opAfterJob, types.FAILED, actionError); opErr != nil { + return fmt.Errorf("setting new operation state failed: %s", opErr) + } + // after a failed FAILED CREATE operation, update the ready field to false + if opAfterJob.Type == types.CREATE && opAfterJob.State == types.FAILED { + if err := fetchAndUpdateResource(ctx, storage, opAfterJob.ResourceID, opAfterJob.ResourceType, func(obj types.Object) { + obj.SetReady(false) + }); err != nil { + return err + } + } + return nil + }); err != nil { + return err + } + + // we want to schedule deletion if the operation is marked for deletion and the deletion timeout is not yet reached + isDeleteRescheduleRequired := !opAfterJob.DeletionScheduled.IsZero() && + time.Now().UTC().Before(opAfterJob.DeletionScheduled.Add(s.deletionTimeout)) && + opAfterJob.State != types.SUCCEEDED + + if isDeleteRescheduleRequired { + deletionAction := func(ctx context.Context, repository storage.Repository) (types.Object, error) { + byID := query.ByField(query.EqualsOperator, "id", opAfterJob.ResourceID) + err := repository.Delete(ctx, opAfterJob.ResourceType, byID) + if err != nil { + if err == util.ErrNotFoundInStorage { + return nil, nil + } + return nil, util.HandleStorageError(err, opAfterJob.ResourceType.String()) + } + return nil, nil + } + + log.C(ctx).Infof("Scheduling of required delete operation after actual operation with id %s failed", opAfterJob.ID) + // if deletion timestamp was set on the op, reschedule the same op with delete action and wait for reschedulingDelay time + // so that we don't DOS the broker + reschedulingDelayTimeout := time.After(s.reschedulingDelay) + select { + case <-s.smCtx.Done(): + return fmt.Errorf("sm context canceled: %s", s.smCtx.Err()) + case <-reschedulingDelayTimeout: + if orphanMitigationErr := s.ScheduleAsyncStorageAction(ctx, opAfterJob, deletionAction); orphanMitigationErr != nil { + return &util.HTTPError{ + ErrorType: "BrokerError", + Description: fmt.Sprintf("job failed with %s and orphan mitigation failed with %s", actionError, orphanMitigationErr), + StatusCode: http.StatusBadGateway, + } + } + } + } + return actionError +} + +func (s *Scheduler) handleActionResponseSuccess(ctx context.Context, actionObject types.Object, opAfterJob *types.Operation) (types.Object, error) { + if err := s.repository.InTransaction(ctx, func(ctx context.Context, storage storage.Repository) error { + var finalState types.OperationState + if opAfterJob.Type != types.DELETE && !opAfterJob.DeletionScheduled.IsZero() { + // successful orphan mitigation for CREATE/UPDATE should still leave the operation as FAILED + finalState = types.FAILED + } else { + // a delete that succeed or an orphan mitigation caused by a delete that succeeded are both successful deletions + finalState = types.SUCCEEDED + opAfterJob.Errors = json.RawMessage{} + } + + // a non reschedulable operation has finished with no errors: + // this can either be an actual operation or an orphan mitigation triggered by an actual operation error + // in either case orphan mitigation needn't be scheduled any longer because being here means either an + // actual operation finished with no errors or an orphan mitigation caused by an actual operation finished with no errors + opAfterJob.DeletionScheduled = time.Time{} + log.C(ctx).Infof("Successfully executed %s operation with id %s for %s entity with id %s", opAfterJob.Type, opAfterJob.ID, opAfterJob.ResourceType, opAfterJob.ResourceID) + if err := updateOperationState(ctx, storage, opAfterJob, finalState, nil); err != nil { + return err + } + + // after a successful CREATE operation, update the ready field to true + if opAfterJob.Type == types.CREATE && finalState == types.SUCCEEDED { + var err error + if actionObject, err = updateResource(ctx, storage, actionObject, func(obj types.Object) { + obj.SetReady(true) + }); err != nil { + return err + } + } + return nil + + }); err != nil { + return nil, fmt.Errorf("failed to update resource ready or operation state after a successfully executing operation with id %s: %s", opAfterJob.ID, err) + } + log.C(ctx).Infof("Successful executed operation with ID (%s)", opAfterJob.ID) + + return actionObject, nil +} + +func (s *Scheduler) addOperationToContext(ctx context.Context, operation *types.Operation) (context.Context, error) { + ctxWithOp, setCtxErr := SetInContext(ctx, operation) + if setCtxErr != nil { + setCtxErr = fmt.Errorf("failed to set operation in job context: %s", setCtxErr) + if err := updateOperationState(ctx, s.repository, operation, types.FAILED, setCtxErr); err != nil { + return nil, fmt.Errorf("setting new operation state due to err %s failed: %s", setCtxErr, err) + } + return nil, setCtxErr + } + + return ctxWithOp, nil +} + +func (s *Scheduler) executeOperationPreconditions(ctx context.Context, operation *types.Operation) error { + if operation.State == types.SUCCEEDED { + return fmt.Errorf("scheduling for operations of type %s is not allowed", string(types.SUCCEEDED)) + } + + if err := operation.Validate(); err != nil { + return fmt.Errorf("scheduled operation is not valid: %s", err) + } + + lastOperation, found, err := s.getResourceLastOperation(ctx, operation) + if err != nil { + return err + } + + if found { + if err := s.checkForConcurrentOperations(ctx, operation, lastOperation); err != nil { + log.C(ctx).Errorf("concurrent operation has been rejected: last operation is %+v, current operation is %+v and error is %s", lastOperation, operation, err) + return err + } + } + + if err := s.storeOrUpdateOperation(ctx, operation, lastOperation); err != nil { + return err + } + + return nil +} + +func initialLogMessage(ctx context.Context, operation *types.Operation, async bool) { + var logPrefix string + if operation.Reschedule { + logPrefix = "Reschduling (reschedule=true)" + } else if !operation.DeletionScheduled.IsZero() { + logPrefix = "Scheduling orphan mitigation" + } else { + logPrefix = "Scheduling new" + } + if async { + logPrefix += " async" + } else { + logPrefix += " sync" + } + log.C(ctx).Infof("%s %s operation with id %s for resource of type %s with id %s", logPrefix, operation.Type, operation.ID, operation.ResourceType.String(), operation.ResourceID) + } diff --git a/pkg/multitenancy/settings.go b/pkg/multitenancy/settings.go new file mode 100644 index 000000000..9e57c1213 --- /dev/null +++ b/pkg/multitenancy/settings.go @@ -0,0 +1,22 @@ +package multitenancy + +import "fmt" + +type Settings struct { + LabelKey string `mapstructure:"label_key"` +} + +func DefaultSettings() *Settings { + return &Settings{ + LabelKey: "", + } +} + +// Validate validates the httpclient settings +func (s *Settings) Validate() error { + if len(s.LabelKey) == 0 { + return fmt.Errorf("validate multitenancy settings: label_key should not be empty") + } + + return nil +} diff --git a/pkg/sm/sm.go b/pkg/sm/sm.go index f769e0559..d2b647571 100644 --- a/pkg/sm/sm.go +++ b/pkg/sm/sm.go @@ -52,6 +52,7 @@ import ( "github.com/Peripli/service-manager/api/filters" "github.com/Peripli/service-manager/pkg/web" + osbc "github.com/kubernetes-sigs/go-open-service-broker-client/v2" ) // ServiceManagerBuilder type is an extension point that allows adding additional filters, plugins and @@ -63,6 +64,7 @@ type ServiceManagerBuilder struct { Notificator storage.Notificator NotificationCleaner *storage.NotificationCleaner OperationMaintainer *operations.Maintainer + OSBClientProvider osbc.CreateFunc ctx context.Context wg *sync.WaitGroup cfg *config.Settings @@ -154,6 +156,7 @@ func New(ctx context.Context, cancel context.CancelFunc, e env.Environment, cfg } operationMaintainer := operations.NewMaintainer(ctx, interceptableRepository, cfg.Operations) + osbClientProvider := osb.NewBrokerClientProvider(cfg.HTTPClient.SkipSSLValidation, int(cfg.HTTPClient.ResponseHeaderTimeout.Seconds())) smb := &ServiceManagerBuilder{ API: API, @@ -165,6 +168,7 @@ func New(ctx context.Context, cancel context.CancelFunc, e env.Environment, cfg wg: waitGroup, cfg: cfg, securityBuilder: securityBuilder, + OSBClientProvider: osbClientProvider, } smb.RegisterPlugins(osb.NewCatalogFilterByVisibilityPlugin(interceptableRepository)) @@ -192,6 +196,30 @@ func New(ctx context.Context, cancel context.CancelFunc, e env.Environment, cfg WithUpdateOnTxInterceptorProvider(types.ServiceBrokerType, &interceptors.BrokerNotificationsUpdateInterceptorProvider{}).Before(interceptors.BrokerUpdateCatalogInterceptorName).Register(). WithDeleteOnTxInterceptorProvider(types.ServiceBrokerType, &interceptors.BrokerNotificationsDeleteInterceptorProvider{}).After(interceptors.BrokerDeleteCatalogInterceptorName).Register() + baseSMAAPInterceptorProvider := &interceptors.BaseSMAAPInterceptorProvider{ + OSBClientCreateFunc: osbClientProvider, + Repository: interceptableRepository, + TenantKey: cfg.Multitenancy.LabelKey, + PollingInterval: cfg.Operations.PollingInterval, + } + + smb. + WithCreateAroundTxInterceptorProvider(types.ServiceInstanceType, &interceptors.ServiceInstanceCreateInterceptorProvider{ + BaseSMAAPInterceptorProvider: baseSMAAPInterceptorProvider, + }).Register(). + WithUpdateAroundTxInterceptorProvider(types.ServiceInstanceType, &interceptors.ServiceInstanceUpdateInterceptorProvider{ + BaseSMAAPInterceptorProvider: baseSMAAPInterceptorProvider, + }).Register(). + WithDeleteAroundTxInterceptorProvider(types.ServiceInstanceType, &interceptors.ServiceInstanceDeleteInterceptorProvider{ + BaseSMAAPInterceptorProvider: baseSMAAPInterceptorProvider, + }).Register(). + WithCreateAroundTxInterceptorProvider(types.ServiceBindingType, &interceptors.ServiceBindingCreateInterceptorProvider{ + BaseSMAAPInterceptorProvider: baseSMAAPInterceptorProvider, + }).Register(). + WithDeleteAroundTxInterceptorProvider(types.ServiceBindingType, &interceptors.ServiceBindingDeleteInterceptorProvider{ + BaseSMAAPInterceptorProvider: baseSMAAPInterceptorProvider, + }).Register() + return smb, nil } @@ -469,7 +497,7 @@ func (smb *ServiceManagerBuilder) EnableMultitenancy(labelKey string, extractTen smb.WithCreateOnTxInterceptorProvider(types.ServiceInstanceType, &interceptors.ServiceInstanceCreateInsterceptorProvider{ TenantIdentifier: labelKey, - }).Register() + }).AroundTxAfter(interceptors.ServiceInstanceCreateInterceptorProviderName).Register() smb.WithCreateOnTxInterceptorProvider(types.OperationType, &interceptors.OperationsCreateInsterceptorProvider{ TenantIdentifier: labelKey, }).Register() diff --git a/pkg/types/base.go b/pkg/types/base.go index 15d303dfa..d6ee74664 100644 --- a/pkg/types/base.go +++ b/pkg/types/base.go @@ -24,40 +24,49 @@ type Base struct { UpdatedAt time.Time `json:"updated_at"` Labels Labels `json:"labels,omitempty"` PagingSequence int64 `json:"-"` + Ready bool `json:"ready"` } -func (e *Base) SetID(id string) { - e.ID = id +func (b *Base) SetID(id string) { + b.ID = id } -func (e *Base) GetID() string { - return e.ID +func (b *Base) GetID() string { + return b.ID } -func (e *Base) SetCreatedAt(time time.Time) { - e.CreatedAt = time +func (b *Base) SetCreatedAt(time time.Time) { + b.CreatedAt = time } -func (e *Base) GetCreatedAt() time.Time { - return e.CreatedAt +func (b *Base) GetCreatedAt() time.Time { + return b.CreatedAt } -func (e *Base) SetUpdatedAt(time time.Time) { - e.UpdatedAt = time +func (b *Base) SetUpdatedAt(time time.Time) { + b.UpdatedAt = time } -func (e *Base) GetUpdatedAt() time.Time { - return e.UpdatedAt +func (b *Base) GetUpdatedAt() time.Time { + return b.UpdatedAt } -func (e *Base) SetLabels(labels Labels) { - e.Labels = labels +func (b *Base) SetLabels(labels Labels) { + b.Labels = labels } -func (e *Base) GetLabels() Labels { - return e.Labels +func (b *Base) GetLabels() Labels { + return b.Labels } -func (e *Base) GetPagingSequence() int64 { - return e.PagingSequence +func (b *Base) GetPagingSequence() int64 { + return b.PagingSequence +} + +func (b *Base) SetReady(ready bool) { + b.Ready = ready +} + +func (b *Base) GetReady() bool { + return b.Ready } diff --git a/pkg/types/interfaces.go b/pkg/types/interfaces.go index c3002020a..af7c4381a 100644 --- a/pkg/types/interfaces.go +++ b/pkg/types/interfaces.go @@ -65,6 +65,8 @@ type Object interface { SetUpdatedAt(time time.Time) GetUpdatedAt() time.Time GetPagingSequence() int64 + SetReady(bool) + GetReady() bool } func Equals(obj, other Object) bool { diff --git a/pkg/types/notification_gen.go b/pkg/types/notification_gen.go index 2e782282b..a7fecb582 100644 --- a/pkg/types/notification_gen.go +++ b/pkg/types/notification_gen.go @@ -6,9 +6,10 @@ import ( "encoding/json" "github.com/Peripli/service-manager/pkg/util" + "github.com/Peripli/service-manager/pkg/web" ) -const NotificationType ObjectType = "types.Notification" +const NotificationType ObjectType = web.NotificationsURL type Notifications struct { Notifications []*Notification `json:"notifications"` diff --git a/pkg/types/operation.go b/pkg/types/operation.go index 9af5b6e10..79562cf25 100644 --- a/pkg/types/operation.go +++ b/pkg/types/operation.go @@ -20,6 +20,7 @@ import ( "encoding/json" "fmt" "reflect" + "time" "github.com/Peripli/service-manager/pkg/util" ) @@ -56,14 +57,19 @@ const ( // Operation struct type Operation struct { Base - Description string `json:"description"` + Description string `json:"description,omitempty"` Type OperationCategory `json:"type"` State OperationState `json:"state"` ResourceID string `json:"resource_id"` - ResourceType string `json:"resource_type"` - Errors json.RawMessage `json:"errors"` + ResourceType ObjectType `json:"resource_type"` + Errors json.RawMessage `json:"errors,omitempty"` CorrelationID string `json:"correlation_id"` ExternalID string `json:"-"` + + // Reschedule specifies that the operation has reached a state after which it can be retried (checkpoint) + Reschedule bool `json:"reschedule"` + // DeletionScheduled specifies the time when an operation was marked for deletion + DeletionScheduled time.Time `json:"deletion_scheduled,omitempty"` } func (e *Operation) Equals(obj Object) bool { @@ -101,11 +107,11 @@ func (o *Operation) Validate() error { } if o.ResourceID == "" { - return fmt.Errorf("missing resource_id") + return fmt.Errorf("missing resource id") } if o.ResourceType == "" { - return fmt.Errorf("missing resource_type") + return fmt.Errorf("missing resource type") } return nil diff --git a/pkg/types/operation_gen.go b/pkg/types/operation_gen.go index 45b22827d..fd2bc009b 100644 --- a/pkg/types/operation_gen.go +++ b/pkg/types/operation_gen.go @@ -6,9 +6,10 @@ import ( "encoding/json" "github.com/Peripli/service-manager/pkg/util" + "github.com/Peripli/service-manager/pkg/web" ) -const OperationType ObjectType = "types.Operation" +const OperationType ObjectType = web.OperationsURL type Operations struct { Operations []*Operation `json:"operations"` diff --git a/pkg/types/platform_gen.go b/pkg/types/platform_gen.go index f085d98a3..547f01391 100644 --- a/pkg/types/platform_gen.go +++ b/pkg/types/platform_gen.go @@ -6,9 +6,10 @@ import ( "encoding/json" "github.com/Peripli/service-manager/pkg/util" + "github.com/Peripli/service-manager/pkg/web" ) -const PlatformType ObjectType = "types.Platform" +const PlatformType ObjectType = web.PlatformsURL type Platforms struct { Platforms []*Platform `json:"platforms"` diff --git a/pkg/types/service_binding.go b/pkg/types/service_binding.go index ed3ecdcd8..e62d69030 100644 --- a/pkg/types/service_binding.go +++ b/pkg/types/service_binding.go @@ -32,16 +32,16 @@ import ( type ServiceBinding struct { Base Secured `json:"-"` - Name string `json:"name"` - ServiceInstanceID string `json:"service_instance_id"` - SyslogDrainURL string `json:"syslog_drain_url,omitempty"` - RouteServiceURL string `json:"route_service_url,omitempty"` - VolumeMounts json.RawMessage `json:"volume_mounts,omitempty"` - Endpoints json.RawMessage `json:"endpoints,omitempty"` - Context json.RawMessage `json:"-"` - BindResource json.RawMessage `json:"bind_resource,omitempty"` - Credentials string `json:"credentials"` - Ready bool `json:"ready"` + Name string `json:"name"` + ServiceInstanceID string `json:"service_instance_id"` + SyslogDrainURL string `json:"syslog_drain_url,omitempty"` + RouteServiceURL string `json:"route_service_url,omitempty"` + VolumeMounts json.RawMessage `json:"volume_mounts,omitempty"` + Endpoints json.RawMessage `json:"endpoints,omitempty"` + Context json.RawMessage `json:"context,omitempty"` + BindResource json.RawMessage `json:"bind_resource,omitempty"` + Credentials json.RawMessage `json:"credentials,omitempty"` + Parameters map[string]interface{} `json:"parameters,omitempty"` LastOperation *Operation `json:"last_operation,omitempty"` } @@ -58,11 +58,11 @@ func (e *ServiceBinding) transform(ctx context.Context, transformationFunc func( if len(e.Credentials) == 0 { return nil } - transformedCredentials, err := transformationFunc(ctx, []byte(e.Credentials)) + transformedCredentials, err := transformationFunc(ctx, e.Credentials) if err != nil { return err } - e.Credentials = string(transformedCredentials) + e.Credentials = transformedCredentials return nil } diff --git a/pkg/types/service_instance.go b/pkg/types/service_instance.go index 9da3899a6..9aadf2852 100644 --- a/pkg/types/service_instance.go +++ b/pkg/types/service_instance.go @@ -30,15 +30,15 @@ import ( // ServiceInstance struct type ServiceInstance struct { Base - Name string `json:"name"` - ServicePlanID string `json:"service_plan_id"` - PlatformID string `json:"platform_id"` - DashboardURL string `json:"-"` - MaintenanceInfo json.RawMessage `json:"maintenance_info,omitempty"` - Context json.RawMessage `json:"-"` - PreviousValues json.RawMessage `json:"-"` - Ready bool `json:"ready"` - Usable bool `json:"usable"` + Name string `json:"name"` + ServicePlanID string `json:"service_plan_id"` + PlatformID string `json:"platform_id"` + DashboardURL string `json:"dashboard_url,omitempty"` + MaintenanceInfo json.RawMessage `json:"maintenance_info,omitempty"` + Context json.RawMessage `json:"context,omitempty"` + PreviousValues json.RawMessage `json:"-"` + Parameters map[string]interface{} `json:"parameters,omitempty"` + Usable bool `json:"usable"` LastOperation *Operation `json:"last_operation,omitempty"` } @@ -53,6 +53,7 @@ func (e *ServiceInstance) Equals(obj Object) bool { e.PlatformID != instance.PlatformID || e.ServicePlanID != instance.ServicePlanID || e.DashboardURL != instance.DashboardURL || + e.Ready != instance.Ready || !reflect.DeepEqual(e.PreviousValues, instance.PreviousValues) || !reflect.DeepEqual(e.Context, instance.Context) || !reflect.DeepEqual(e.MaintenanceInfo, instance.MaintenanceInfo) { @@ -62,14 +63,6 @@ func (e *ServiceInstance) Equals(obj Object) bool { return true } -func (e *ServiceInstance) SetLastOperation(lastOp *Operation) { - e.LastOperation = lastOp -} - -func (e *ServiceInstance) GetLastOperation() *Operation { - return e.LastOperation -} - // Validate implements InputValidator and verifies all mandatory fields are populated func (e *ServiceInstance) Validate() error { if util.HasRFC3986ReservedSymbols(e.ID) { @@ -90,3 +83,11 @@ func (e *ServiceInstance) Validate() error { return nil } + +func (e *ServiceInstance) SetLastOperation(lastOp *Operation) { + e.LastOperation = lastOp +} + +func (e *ServiceInstance) GetLastOperation() *Operation { + return e.LastOperation +} diff --git a/pkg/types/service_plan.go b/pkg/types/service_plan.go index aea36c70e..8b2b44928 100644 --- a/pkg/types/service_plan.go +++ b/pkg/types/service_plan.go @@ -34,8 +34,8 @@ type ServicePlan struct { CatalogID string `json:"catalog_id"` CatalogName string `json:"catalog_name"` Free bool `json:"free"` - Bindable bool `json:"bindable"` - PlanUpdatable bool `json:"plan_updateable"` + Bindable *bool `json:"bindable,omitempty"` + PlanUpdatable *bool `json:"plan_updateable,omitempty"` Metadata json.RawMessage `json:"metadata,omitempty"` Schemas json.RawMessage `json:"schemas,omitempty"` @@ -52,10 +52,14 @@ func (e *ServicePlan) Equals(obj Object) bool { plan := obj.(*ServicePlan) if e.Name != plan.Name || - e.PlanUpdatable != plan.PlanUpdatable || - e.Bindable != plan.Bindable || e.ServiceOfferingID != plan.ServiceOfferingID || e.Free != plan.Free || + (e.Bindable == nil && plan.Bindable != nil) || + (e.Bindable != nil && plan.Bindable == nil) || + (e.Bindable != nil && plan.Bindable != nil && *e.Bindable != *plan.Bindable) || + (e.PlanUpdatable == nil && plan.PlanUpdatable != nil) || + (e.PlanUpdatable != nil && plan.PlanUpdatable == nil) || + (e.PlanUpdatable != nil && plan.PlanUpdatable != nil && *e.PlanUpdatable != *plan.PlanUpdatable) || e.CatalogID != plan.CatalogID || e.CatalogName != plan.CatalogName || e.Description != plan.Description || diff --git a/pkg/types/servicebinding_gen.go b/pkg/types/servicebinding_gen.go index 6df96f57d..01c555cfc 100644 --- a/pkg/types/servicebinding_gen.go +++ b/pkg/types/servicebinding_gen.go @@ -6,9 +6,10 @@ import ( "encoding/json" "github.com/Peripli/service-manager/pkg/util" + "github.com/Peripli/service-manager/pkg/web" ) -const ServiceBindingType ObjectType = "ServiceBinding" +const ServiceBindingType ObjectType = web.ServiceBindingsURL type ServiceBindings struct { ServiceBindings []*ServiceBinding `json:"service_bindings"` @@ -37,8 +38,10 @@ func (e *ServiceBinding) MarshalJSON() ([]byte, error) { *E CreatedAt *string `json:"created_at,omitempty"` UpdatedAt *string `json:"updated_at,omitempty"` + Labels Labels `json:"labels,omitempty"` }{ - E: (*E)(e), + E: (*E)(e), + Labels: e.Labels, } if !e.CreatedAt.IsZero() { str := util.ToRFCNanoFormat(e.CreatedAt) diff --git a/pkg/types/servicebroker_gen.go b/pkg/types/servicebroker_gen.go index 4f137b783..c4bfe5a67 100644 --- a/pkg/types/servicebroker_gen.go +++ b/pkg/types/servicebroker_gen.go @@ -6,9 +6,10 @@ import ( "encoding/json" "github.com/Peripli/service-manager/pkg/util" + "github.com/Peripli/service-manager/pkg/web" ) -const ServiceBrokerType ObjectType = "types.ServiceBroker" +const ServiceBrokerType ObjectType = web.ServiceBrokersURL type ServiceBrokers struct { ServiceBrokers []*ServiceBroker `json:"service_brokers"` diff --git a/pkg/types/serviceinstance_gen.go b/pkg/types/serviceinstance_gen.go index e374dd93b..4a3b0aebb 100644 --- a/pkg/types/serviceinstance_gen.go +++ b/pkg/types/serviceinstance_gen.go @@ -6,9 +6,10 @@ import ( "encoding/json" "github.com/Peripli/service-manager/pkg/util" + "github.com/Peripli/service-manager/pkg/web" ) -const ServiceInstanceType ObjectType = "types.ServiceInstance" +const ServiceInstanceType ObjectType = web.ServiceInstancesURL type ServiceInstances struct { ServiceInstances []*ServiceInstance `json:"service_instances"` diff --git a/pkg/types/serviceoffering_gen.go b/pkg/types/serviceoffering_gen.go index f02e3d272..cb4cfd09a 100644 --- a/pkg/types/serviceoffering_gen.go +++ b/pkg/types/serviceoffering_gen.go @@ -6,9 +6,10 @@ import ( "encoding/json" "github.com/Peripli/service-manager/pkg/util" + "github.com/Peripli/service-manager/pkg/web" ) -const ServiceOfferingType ObjectType = "types.ServiceOffering" +const ServiceOfferingType ObjectType = web.ServiceOfferingsURL type ServiceOfferings struct { ServiceOfferings []*ServiceOffering `json:"service_offerings"` diff --git a/pkg/types/serviceplan_gen.go b/pkg/types/serviceplan_gen.go index f2a187fd8..b0d615e6a 100644 --- a/pkg/types/serviceplan_gen.go +++ b/pkg/types/serviceplan_gen.go @@ -6,9 +6,10 @@ import ( "encoding/json" "github.com/Peripli/service-manager/pkg/util" + "github.com/Peripli/service-manager/pkg/web" ) -const ServicePlanType ObjectType = "types.ServicePlan" +const ServicePlanType ObjectType = web.ServicePlansURL type ServicePlans struct { ServicePlans []*ServicePlan `json:"service_plans"` diff --git a/pkg/types/types_test.go b/pkg/types/types_test.go index 239a96f81..c4260ac42 100644 --- a/pkg/types/types_test.go +++ b/pkg/types/types_test.go @@ -235,7 +235,15 @@ func setProps(object interface{}, propPath string) []propChange { path: currentPath, value: time.Now(), }) + case *bool: + falseVal := false + result = append(result, propChange{ + path: currentPath, + value: &falseVal, + }) default: + val := f.Value() + fmt.Println(val) result = append(result, setProps(f.Value(), currentPath)...) } } @@ -275,6 +283,7 @@ func createServiceInstance(now time.Time) Object { Labels: labels, CreatedAt: now, UpdatedAt: now.Add(time.Second * 10), + Ready: true, }, Name: "name", ServicePlanID: "1", @@ -283,7 +292,6 @@ func createServiceInstance(now time.Time) Object { MaintenanceInfo: []byte("default"), Context: []byte("default"), PreviousValues: []byte("default"), - Ready: true, Usable: true, } } @@ -312,6 +320,7 @@ func createServicePlan(now time.Time) Object { labels := Labels{ "label_key": []string{"value"}, } + trueVar := true return &ServicePlan{ Base: Base{ ID: "id", @@ -325,8 +334,8 @@ func createServicePlan(now time.Time) Object { CatalogID: "1", CatalogName: "catname", Free: true, - Bindable: true, - PlanUpdatable: true, + Bindable: &trueVar, + PlanUpdatable: &trueVar, Metadata: []byte("metadata"), Schemas: []byte("schema"), ServiceOfferingID: "1", diff --git a/pkg/types/visibility_gen.go b/pkg/types/visibility_gen.go index 3fa634d2a..abf0e6459 100644 --- a/pkg/types/visibility_gen.go +++ b/pkg/types/visibility_gen.go @@ -6,9 +6,10 @@ import ( "encoding/json" "github.com/Peripli/service-manager/pkg/util" + "github.com/Peripli/service-manager/pkg/web" ) -const VisibilityType ObjectType = "types.Visibility" +const VisibilityType ObjectType = web.VisibilitiesURL type Visibilities struct { Visibilities []*Visibility `json:"visibilities"` diff --git a/pkg/util/errors.go b/pkg/util/errors.go index 5e0f57c98..4ace7f6df 100644 --- a/pkg/util/errors.go +++ b/pkg/util/errors.go @@ -20,8 +20,9 @@ import ( "context" "errors" "fmt" - "github.com/Peripli/service-manager/pkg/log" "net/http" + + "github.com/Peripli/service-manager/pkg/log" ) // HTTPError is an error type that provides error details that Service Manager error handlers would propagate to the client @@ -47,6 +48,15 @@ func (uq *UnsupportedQueryError) Error() string { // WriteError sends a JSON containing the error to the response writer func WriteError(ctx context.Context, err error, writer http.ResponseWriter) { + logger := log.C(ctx) + respError := ToHTTPError(ctx, err) + sendErr := WriteJSON(writer, respError.StatusCode, respError) + if sendErr != nil { + logger.Errorf("Could not write error to response: %v", sendErr) + } +} + +func ToHTTPError(ctx context.Context, err error) *HTTPError { var respError *HTTPError logger := log.C(ctx) switch t := err.(type) { @@ -58,6 +68,10 @@ func WriteError(ctx context.Context, err error, writer http.ResponseWriter) { StatusCode: http.StatusBadRequest, } case *HTTPError: + // if status code was not set, default to internal server error + if t.StatusCode == 0 { + t.StatusCode = http.StatusInternalServerError + } logger.Errorf("HTTPError: %s", err) respError = t default: @@ -69,10 +83,7 @@ func WriteError(ctx context.Context, err error, writer http.ResponseWriter) { } } - sendErr := WriteJSON(writer, respError.StatusCode, respError) - if sendErr != nil { - logger.Errorf("Could not write error to response: %v", sendErr) - } + return respError } // HandleResponseError builds an error from the given response diff --git a/pkg/util/osb.go b/pkg/util/osb.go deleted file mode 100644 index bf5608515..000000000 --- a/pkg/util/osb.go +++ /dev/null @@ -1,11 +0,0 @@ -package util - -import osbc "github.com/kubernetes-sigs/go-open-service-broker-client/v2" - -// NewOSBCient returns a function which constructs an OSB client based on a provided configuration -func NewOSBClient(skipSsl bool) osbc.CreateFunc { - return func(configuration *osbc.ClientConfiguration) (osbc.Client, error) { - configuration.Insecure = skipSsl - return osbc.NewClient(configuration) - } -} diff --git a/storage/encrypting_repository_test.go b/storage/encrypting_repository_test.go index 27cf513bd..0d243ce61 100644 --- a/storage/encrypting_repository_test.go +++ b/storage/encrypting_repository_test.go @@ -35,7 +35,8 @@ var _ = Describe("Encrypting Repository", func() { objWithDecryptedPassword = &types.ServiceBroker{ Base: types.Base{ - ID: "id", + ID: "id", + Ready: true, }, Credentials: &types.Credentials{ Basic: &types.Basic{ @@ -47,7 +48,8 @@ var _ = Describe("Encrypting Repository", func() { objWithEncryptedPassword = &types.ServiceBroker{ Base: types.Base{ - ID: "id", + ID: "id", + Ready: true, }, Credentials: &types.Credentials{ Basic: &types.Basic{ diff --git a/storage/interceptable_repository.go b/storage/interceptable_repository.go index 99dc6b173..37eba85d5 100644 --- a/storage/interceptable_repository.go +++ b/storage/interceptable_repository.go @@ -267,7 +267,7 @@ func (ir *queryScopedInterceptableRepository) Update(ctx context.Context, obj ty // happened and finished concurrently and before this one so fail the request // update to the same entity in the same transaction may be possible from an interceptor inUpdate, _ := ctx.Value(updateInProgress).(bool) - if !oldObj.GetUpdatedAt().UTC().Equal(obj.GetUpdatedAt().UTC()) && !inUpdate { + if !oldObj.GetUpdatedAt().UTC().Equal(obj.GetUpdatedAt().UTC()) && !inUpdate && obj.GetType() != types.OperationType { return nil, util.ErrConcurrentResourceModification } @@ -281,6 +281,7 @@ func (ir *queryScopedInterceptableRepository) Update(ctx context.Context, obj ty ir.updateOnTxFuncs[objectType] = updateOnTxFunc } else { + ctx = context.WithValue(ctx, updateInProgress, true) updatedObj, err = updateObjFunc(ctx, ir, oldObj, obj, labelChanges...) } diff --git a/storage/interceptable_repository_test.go b/storage/interceptable_repository_test.go index dd5824cb5..5939eb088 100644 --- a/storage/interceptable_repository_test.go +++ b/storage/interceptable_repository_test.go @@ -180,6 +180,7 @@ var _ = Describe("Interceptable TransactionalRepository", func() { fakeStorage.GetReturns(&types.ServiceBroker{ Base: types.Base{ UpdatedAt: updateTime, + Ready: true, }, }, nil) @@ -246,6 +247,7 @@ var _ = Describe("Interceptable TransactionalRepository", func() { _, err := interceptableRepository.Update(ctx, &types.ServiceBroker{ Base: types.Base{ UpdatedAt: updateTime, + Ready: true, }, }, query.LabelChanges{}) @@ -303,6 +305,7 @@ var _ = Describe("Interceptable TransactionalRepository", func() { _, err = storage.Update(ctx, &types.ServiceBroker{ Base: types.Base{ UpdatedAt: updateTime, + Ready: true, }, }, query.LabelChanges{}) Expect(err).ShouldNot(HaveOccurred()) @@ -326,6 +329,7 @@ var _ = Describe("Interceptable TransactionalRepository", func() { Base: types.Base{ // simulate the resource is updated when its retrieved again UpdatedAt: updateTime.Add(time.Second), + Ready: true, }, }, nil }) @@ -336,6 +340,7 @@ var _ = Describe("Interceptable TransactionalRepository", func() { _, err := storage.Update(ctx, &types.ServiceBroker{ Base: types.Base{ UpdatedAt: updateTime, + Ready: true, }, }, query.LabelChanges{}) diff --git a/storage/interceptors/broker_create_catalog_interceptor.go b/storage/interceptors/broker_create_catalog_interceptor.go index 096ac0d51..26fd7a35f 100644 --- a/storage/interceptors/broker_create_catalog_interceptor.go +++ b/storage/interceptors/broker_create_catalog_interceptor.go @@ -103,6 +103,7 @@ func brokerCatalogAroundTx(ctx context.Context, broker *types.ServiceBroker, fet service.BrokerID = broker.ID service.CreatedAt = broker.UpdatedAt service.UpdatedAt = broker.UpdatedAt + service.Ready = true UUID, err := uuid.NewV4() if err != nil { return err @@ -121,6 +122,7 @@ func brokerCatalogAroundTx(ctx context.Context, broker *types.ServiceBroker, fet servicePlan.ServiceOfferingID = service.ID servicePlan.CreatedAt = broker.UpdatedAt servicePlan.UpdatedAt = broker.UpdatedAt + servicePlan.Ready = true UUID, err := uuid.NewV4() if err != nil { return err diff --git a/storage/interceptors/notifications_interceptor.go b/storage/interceptors/notifications_interceptor.go index 6cc5e44bb..63785418c 100644 --- a/storage/interceptors/notifications_interceptor.go +++ b/storage/interceptors/notifications_interceptor.go @@ -183,6 +183,7 @@ func CreateNotification(ctx context.Context, repository storage.Repository, op t CreatedAt: currentTime, UpdatedAt: currentTime, Labels: map[string][]string{}, + Ready: true, }, Resource: resource, Type: op, diff --git a/storage/interceptors/operations_create_interceptor.go b/storage/interceptors/operations_create_interceptor.go index c45d7dbb9..b1e90874d 100644 --- a/storage/interceptors/operations_create_interceptor.go +++ b/storage/interceptors/operations_create_interceptor.go @@ -18,6 +18,7 @@ package interceptors import ( "context" + "github.com/Peripli/service-manager/pkg/log" "github.com/Peripli/service-manager/pkg/query" "github.com/Peripli/service-manager/pkg/types" @@ -48,9 +49,19 @@ func (c *operationsCreateInterceptor) OnTxCreate(h storage.InterceptCreateOnTxFu return func(ctx context.Context, storage storage.Repository, obj types.Object) (types.Object, error) { operation := obj.(*types.Operation) - tenantID := query.RetrieveFromCriteria(c.TenantIdentifier, query.CriteriaForContext(ctx)...) + criteria := query.CriteriaForContext(ctx) + + //In order for this to work tenant criteria filter need to also be enabled on POST + var tenantID string + for _, criterion := range criteria { + if criterion.LeftOp == c.TenantIdentifier { + tenantID = criterion.RightOp[0] + break + } + } + if tenantID == "" { - log.D().Debugf("Could not add %s label to operation with id %s. Label not found in context criteria.", c.TenantIdentifier, operation.ID) + log.C(ctx).Infof("Could not add %s label to operation with id %s. Label not found in context criteria.", c.TenantIdentifier, operation.ID) return h(ctx, storage, operation) } @@ -58,8 +69,11 @@ func (c *operationsCreateInterceptor) OnTxCreate(h storage.InterceptCreateOnTxFu if labels == nil { labels = types.Labels{} } - labels[c.TenantIdentifier] = []string{tenantID} + if _, ok := labels[c.TenantIdentifier]; !ok { + labels[c.TenantIdentifier] = []string{tenantID} + } + log.C(ctx).Infof("Successfully labeled operation with id %s with %+v", operation.GetID(), operation.GetLabels()) operation.SetLabels(labels) return h(ctx, storage, operation) diff --git a/storage/interceptors/service_instance_create_interceptor.go b/storage/interceptors/osb_service_instance_create_interceptor.go similarity index 85% rename from storage/interceptors/service_instance_create_interceptor.go rename to storage/interceptors/osb_service_instance_create_interceptor.go index de6511fb0..25004392a 100644 --- a/storage/interceptors/service_instance_create_interceptor.go +++ b/storage/interceptors/osb_service_instance_create_interceptor.go @@ -48,19 +48,22 @@ type serviceInstanceCreateInterceptor struct { func (c *serviceInstanceCreateInterceptor) OnTxCreate(h storage.InterceptCreateOnTxFunc) storage.InterceptCreateOnTxFunc { return func(ctx context.Context, storage storage.Repository, obj types.Object) (types.Object, error) { serviceInstance := obj.(*types.ServiceInstance) + labels := serviceInstance.GetLabels() + if labels == nil { + labels = types.Labels{} + } - tenantID := gjson.GetBytes(serviceInstance.Context, c.TenantIdentifier) - if !tenantID.Exists() { - log.D().Debugf("Could not add %s label to service instance with id %s. Label not found in OSB context.", c.TenantIdentifier, serviceInstance.ID) + if _, ok := labels[c.TenantIdentifier]; ok { + log.C(ctx).Debugf("Label %s is already set on service instance", c.TenantIdentifier) return h(ctx, storage, serviceInstance) } - labels := serviceInstance.GetLabels() - if labels == nil { - labels = types.Labels{} + tenantID := gjson.GetBytes(serviceInstance.Context, c.TenantIdentifier) + if !tenantID.Exists() { + log.C(ctx).Debugf("Could not add %s label to service instance with id %s. Label not found in OSB context.", c.TenantIdentifier, serviceInstance.ID) + return h(ctx, storage, serviceInstance) } labels[c.TenantIdentifier] = []string{tenantID.String()} - serviceInstance.SetLabels(labels) return h(ctx, storage, serviceInstance) diff --git a/storage/interceptors/smaap_service_binding_interceptor.go b/storage/interceptors/smaap_service_binding_interceptor.go new file mode 100644 index 000000000..71712d6bc --- /dev/null +++ b/storage/interceptors/smaap_service_binding_interceptor.go @@ -0,0 +1,612 @@ +/* + * Copyright 2018 The Service Manager Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package interceptors + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/Peripli/service-manager/pkg/log" + + "github.com/Peripli/service-manager/operations" + + "github.com/Peripli/service-manager/pkg/query" + "github.com/Peripli/service-manager/pkg/util" + + "github.com/Peripli/service-manager/pkg/types" + + "github.com/Peripli/service-manager/storage" + osbc "github.com/kubernetes-sigs/go-open-service-broker-client/v2" +) + +const ServiceBindingCreateInterceptorProviderName = "ServiceBindingCreateInterceptorProvider" + +// ServiceBindingCreateInterceptorProvider provides an interceptor that notifies the actual broker about instance creation +type ServiceBindingCreateInterceptorProvider struct { + *BaseSMAAPInterceptorProvider +} + +type bindResponseDetails struct { + Credentials map[string]interface{} + SyslogDrainURL *string + RouteServiceURL *string + VolumeMounts []interface{} +} + +func (p *ServiceBindingCreateInterceptorProvider) Provide() storage.CreateAroundTxInterceptor { + return &ServiceBindingInterceptor{ + osbClientCreateFunc: p.OSBClientCreateFunc, + repository: p.Repository, + tenantKey: p.TenantKey, + pollingInterval: p.PollingInterval, + } +} + +func (c *ServiceBindingCreateInterceptorProvider) Name() string { + return ServiceBindingCreateInterceptorProviderName +} + +const ServiceBindingDeleteInterceptorProviderName = "ServiceBindingDeleteInterceptorProvider" + +// ServiceBindingDeleteInterceptorProvider provides an interceptor that notifies the actual broker about instance deletion +type ServiceBindingDeleteInterceptorProvider struct { + *BaseSMAAPInterceptorProvider +} + +func (p *ServiceBindingDeleteInterceptorProvider) Provide() storage.DeleteAroundTxInterceptor { + return &ServiceBindingInterceptor{ + osbClientCreateFunc: p.OSBClientCreateFunc, + repository: p.Repository, + tenantKey: p.TenantKey, + pollingInterval: p.PollingInterval, + } +} + +func (c *ServiceBindingDeleteInterceptorProvider) Name() string { + return ServiceBindingDeleteInterceptorProviderName +} + +type ServiceBindingInterceptor struct { + osbClientCreateFunc osbc.CreateFunc + repository storage.TransactionalRepository + tenantKey string + pollingInterval time.Duration +} + +func (i *ServiceBindingInterceptor) AroundTxCreate(f storage.InterceptCreateAroundTxFunc) storage.InterceptCreateAroundTxFunc { + return func(ctx context.Context, obj types.Object) (types.Object, error) { + binding := obj.(*types.ServiceBinding) + + instance, err := getInstanceByID(ctx, binding.ServiceInstanceID, i.repository) + if err != nil { + return nil, err + } + + if instance.PlatformID != types.SMPlatform { + log.C(ctx).Debugf("platform is not %s. Skipping interceptor %s", types.SMPlatform, ServiceBindingDeleteInterceptorProviderName) + return f(ctx, obj) + } + operation, found := operations.GetFromContext(ctx) + if !found { + return nil, fmt.Errorf("operation missing from context") + } + + osbClient, broker, service, plan, err := preparePrerequisites(ctx, i.repository, i.osbClientCreateFunc, instance) + if err != nil { + return nil, err + } + + if isBindable := !i.isPlanBindable(service, plan); isBindable { + return nil, &util.HTTPError{ + ErrorType: "BrokerError", + Description: fmt.Sprintf("plan %s is not bindable", plan.CatalogName), + StatusCode: http.StatusBadRequest, + } + } + + if isReady := i.isInstanceReady(instance); !isReady { + return nil, &util.HTTPError{ + ErrorType: "OperationInProgress", + Description: fmt.Sprintf("creation of instance %s is still in progress or failed", instance.Name), + StatusCode: http.StatusUnprocessableEntity, + } + } + + isDeleting, err := i.isInstanceInDeletion(ctx, instance.ID) + if err != nil { + return nil, fmt.Errorf("could not determine instance state: %s", err) + } + + if isDeleting { + return nil, &util.HTTPError{ + ErrorType: "OperationInProgress", + Description: fmt.Sprintf("instance %s is in state of delteion", instance.Name), + StatusCode: http.StatusUnprocessableEntity, + } + } + + var bindResponse *osbc.BindResponse + if !operation.Reschedule { + bindRequest := i.prepareBindRequest(instance, binding, service.CatalogID, plan.CatalogID, service.BindingsRetrievable) + contextBytes, err := json.Marshal(bindRequest.Context) + if err != nil { + return nil, fmt.Errorf("failed to marshal OSB context %+v: %s", bindRequest.Context, err) + } + binding.Context = contextBytes + + log.C(ctx).Infof("Sending bind request %s to broker with name %s", logBindRequest(bindRequest), broker.Name) + bindResponse, err = osbClient.Bind(bindRequest) + if err != nil { + brokerError := &util.HTTPError{ + ErrorType: "BrokerError", + Description: fmt.Sprintf("Failed bind request %s: %s", logBindRequest(bindRequest), err), + StatusCode: http.StatusBadGateway, + } + if shouldStartOrphanMitigation(err) { + // store the instance so that later on we can do orphan mitigation + _, err := f(ctx, obj) + if err != nil { + return nil, fmt.Errorf("broker error %s caused orphan mitigation which required storing the resource which failed with: %s", brokerError, err) + } + + // mark the operation as deletion scheduled meaning orphan mitigation is required + operation.DeletionScheduled = time.Now() + operation.Reschedule = false + if _, err := i.repository.Update(ctx, operation, query.LabelChanges{}); err != nil { + return nil, fmt.Errorf("failed to update operation with id %s to schedule orphan mitigation after broker error %s: %s", operation.ID, brokerError, err) + } + } + return nil, brokerError + } + + bindResponseDetails := &bindResponseDetails{ + Credentials: bindResponse.Credentials, + SyslogDrainURL: bindResponse.SyslogDrainURL, + RouteServiceURL: bindResponse.RouteServiceURL, + VolumeMounts: bindResponse.VolumeMounts, + } + if err := i.enrichBindingWithBindingResponse(binding, bindResponseDetails); err != nil { + return nil, fmt.Errorf("could not enrich binding details with binding response details: %s", err) + } + + if bindResponse.Async { + log.C(ctx).Infof("Successful asynchronous binding request %s to broker %s returned response %s", + logBindRequest(bindRequest), broker.Name, logBindResponse(bindResponse)) + operation.Reschedule = true + if bindResponse.OperationKey != nil { + operation.ExternalID = string(*bindResponse.OperationKey) + } + if _, err := i.repository.Update(ctx, operation, query.LabelChanges{}); err != nil { + return nil, fmt.Errorf("failed to update operation with id %s to mark that next execution should be a reschedule: %s", instance.ID, err) + } + } else { + log.C(ctx).Infof("Successful synchronous bind %s to broker %s returned response %s", + logBindRequest(bindRequest), broker.Name, logBindResponse(bindResponse)) + } + } + + object, err := f(ctx, obj) + if err != nil { + return nil, err + } + binding = object.(*types.ServiceBinding) + + if operation.Reschedule { + if err := i.pollServiceBinding(ctx, osbClient, binding, operation, broker.ID, service.CatalogID, plan.CatalogID, operation.ExternalID, true); err != nil { + return nil, err + } + } + return binding, nil + } +} + +func (i *ServiceBindingInterceptor) AroundTxDelete(f storage.InterceptDeleteAroundTxFunc) storage.InterceptDeleteAroundTxFunc { + return func(ctx context.Context, deletionCriteria ...query.Criterion) error { + bindings, err := i.repository.List(ctx, types.ServiceBindingType, deletionCriteria...) + if err != nil { + return fmt.Errorf("failed to get bindings for deletion: %s", err) + } + + if bindings.Len() > 1 { + return fmt.Errorf("deletion of multiple bindings is not supported") + } + + if bindings.Len() != 0 { + binding := bindings.ItemAt(0).(*types.ServiceBinding) + + operation, found := operations.GetFromContext(ctx) + if !found { + return fmt.Errorf("operation missing from context") + } + + if err := i.deleteSingleBinding(ctx, binding, operation); err != nil { + return err + } + } + + if err := f(ctx, deletionCriteria...); err != nil { + return err + } + + return nil + } +} + +func (i *ServiceBindingInterceptor) deleteSingleBinding(ctx context.Context, binding *types.ServiceBinding, operation *types.Operation) error { + instance, err := getInstanceByID(ctx, binding.ServiceInstanceID, i.repository) + if err != nil { + return err + } + + osbClient, broker, service, plan, err := preparePrerequisites(ctx, i.repository, i.osbClientCreateFunc, instance) + if err != nil { + return err + } + + var unbindResponse *osbc.UnbindResponse + if !operation.Reschedule { + unbindRequest := prepareUnbindRequest(instance, binding, service.CatalogID, plan.CatalogID) + + log.C(ctx).Infof("Sending unbind request %s to broker with name %s", logUnbindRequest(unbindRequest), broker.Name) + unbindResponse, err = osbClient.Unbind(unbindRequest) + if err != nil { + if osbc.IsGoneError(err) { + log.C(ctx).Infof("Synchronous unbind %s to broker %s returned 410 GONE and is considered success", + logUnbindRequest(unbindRequest), broker.Name) + return nil + } + brokerError := &util.HTTPError{ + ErrorType: "BrokerError", + Description: fmt.Sprintf("Failed unbind request %s: %s", logUnbindRequest(unbindRequest), err), + StatusCode: http.StatusBadGateway, + } + if shouldStartOrphanMitigation(err) { + operation.DeletionScheduled = time.Now() + operation.Reschedule = false + if _, err := i.repository.Update(ctx, operation, query.LabelChanges{}); err != nil { + return fmt.Errorf("failed to update operation with id %s to schedule orphan mitigation after broker error %s: %s", operation.ID, brokerError, err) + } + } + return brokerError + } + + if unbindResponse.Async { + log.C(ctx).Infof("Successful asynchronous unbind request %s to broker %s returned response %s", + logUnbindRequest(unbindRequest), broker.Name, logUnbindResponse(unbindResponse)) + operation.Reschedule = true + + if unbindResponse.OperationKey != nil { + operation.ExternalID = string(*unbindResponse.OperationKey) + } + if _, err := i.repository.Update(ctx, operation, query.LabelChanges{}); err != nil { + return fmt.Errorf("failed to update operation with id %s to mark that rescheduling is possible: %s", operation.ID, err) + } + } else { + log.C(ctx).Infof("Successful synchronous unbind %s to broker %s returned response %s", + logUnbindRequest(unbindRequest), broker.Name, logUnbindResponse(unbindResponse)) + } + } + + if operation.Reschedule { + if err := i.pollServiceBinding(ctx, osbClient, binding, operation, broker.ID, service.CatalogID, plan.CatalogID, operation.ExternalID, true); err != nil { + return err + } + } + + return nil +} + +func (i *ServiceBindingInterceptor) isPlanBindable(service *types.ServiceOffering, plan *types.ServicePlan) bool { + if plan.Bindable != nil { + return *plan.Bindable + } + + return service.Bindable +} + +func (i *ServiceBindingInterceptor) isInstanceReady(instance *types.ServiceInstance) bool { + return instance.Ready +} + +func (i *ServiceBindingInterceptor) isInstanceInDeletion(ctx context.Context, instanceID string) (bool, error) { + lastOperation, found, err := getLastOperationByResourceID(ctx, instanceID, i.repository) + if err != nil { + return false, fmt.Errorf("could not determine in instance is in process of deletion: %s", err) + } + if !found { + return false, nil + } + + deletionScheduled := !lastOperation.DeletionScheduled.IsZero() + deletionInProgress := lastOperation.Type == types.DELETE && lastOperation.State == types.IN_PROGRESS + return deletionScheduled || deletionInProgress, nil +} + +func getLastOperationByResourceID(ctx context.Context, resourceID string, repository storage.Repository) (*types.Operation, bool, error) { + byResourceID := query.ByField(query.EqualsOperator, "resource_id", resourceID) + orderDesc := query.OrderResultBy("paging_sequence", query.DescOrder) + lastOperationObject, err := repository.Get(ctx, types.OperationType, byResourceID, orderDesc) + if err != nil { + if err == util.ErrNotFoundInStorage { + return nil, false, nil + } + return nil, false, fmt.Errorf("could not fetch last operation for resource with id %s: %s", resourceID, util.HandleStorageError(err, types.OperationType.String())) + } + + return lastOperationObject.(*types.Operation), true, nil +} + +func getInstanceByID(ctx context.Context, instanceID string, repository storage.Repository) (*types.ServiceInstance, error) { + byID := query.ByField(query.EqualsOperator, "id", instanceID) + instanceObject, err := repository.Get(ctx, types.ServiceInstanceType, byID) + if err != nil { + if err == util.ErrNotFoundInStorage { + return nil, &util.HTTPError{ + ErrorType: "NotFound", + Description: util.HandleStorageError(err, types.ServiceInstanceType.String()).Error(), + StatusCode: http.StatusNotFound, + } + } + return nil, fmt.Errorf("could not fetch instance with id %s from db: %s", instanceID, util.HandleStorageError(err, types.ServiceInstanceType.String())) + } + + return instanceObject.(*types.ServiceInstance), nil +} + +func (i *ServiceBindingInterceptor) prepareBindRequest(instance *types.ServiceInstance, binding *types.ServiceBinding, serviceCatalogID, planCatalogID string, bindingRetrievable bool) *osbc.BindRequest { + bindRequest := &osbc.BindRequest{ + BindingID: binding.ID, + InstanceID: instance.ID, + AcceptsIncomplete: bindingRetrievable, + ServiceID: serviceCatalogID, + PlanID: planCatalogID, + Parameters: binding.Parameters, + Context: map[string]interface{}{ + "platform": types.SMPlatform, + "instance_name": instance.Name, + }, + //TODO no OI for SM platform yet + OriginatingIdentity: nil, + } + if len(i.tenantKey) != 0 { + if tenantValue, ok := binding.GetLabels()[i.tenantKey]; ok { + bindRequest.Context[i.tenantKey] = tenantValue[0] + } + } + + return bindRequest +} + +func prepareUnbindRequest(instance *types.ServiceInstance, binding *types.ServiceBinding, serviceCatalogID, planCatalogID string) *osbc.UnbindRequest { + unbindRequest := &osbc.UnbindRequest{ + BindingID: binding.ID, + InstanceID: instance.ID, + AcceptsIncomplete: true, + ServiceID: serviceCatalogID, + PlanID: planCatalogID, + //TODO no OI for SM platform yet + OriginatingIdentity: nil, + } + + return unbindRequest +} + +func (i *ServiceBindingInterceptor) pollServiceBinding(ctx context.Context, osbClient osbc.Client, binding *types.ServiceBinding, operation *types.Operation, brokerID, serviceCatalogID, planCatalogID, operationKey string, enableOrphanMitigation bool) error { + var key *osbc.OperationKey + if len(operation.ExternalID) != 0 { + opKey := osbc.OperationKey(operation.ExternalID) + key = &opKey + } + + pollingRequest := &osbc.BindingLastOperationRequest{ + InstanceID: binding.ServiceInstanceID, + BindingID: binding.ID, + ServiceID: &serviceCatalogID, + PlanID: &planCatalogID, + OperationKey: key, + //TODO no OI for SM platform yet + OriginatingIdentity: nil, + } + + ticker := time.NewTicker(i.pollingInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + log.C(ctx).Errorf("Terminating poll last operation for binding with id %s and name %s due to context done event", binding.ID, binding.Name) + //operation should be kept in progress in this case + return nil + case <-ticker.C: + log.C(ctx).Infof("Sending poll last operation request %s for binding with id %s and name %s", + logPollBindingRequest(pollingRequest), binding.ID, binding.Name) + pollingResponse, err := osbClient.PollBindingLastOperation(pollingRequest) + if err != nil { + if osbc.IsGoneError(err) && operation.Type == types.DELETE { + log.C(ctx).Infof("Successfully finished polling operation for binding with id %s and name %s", binding.ID, binding.Name) + + operation.Reschedule = false + if _, err := i.repository.Update(ctx, operation, query.LabelChanges{}); err != nil { + return fmt.Errorf("failed to update operation with id %s to mark that next execution should be a reschedule: %s", operation.ID, err) + } + return nil + } + + return &util.HTTPError{ + ErrorType: "BrokerError", + Description: fmt.Sprintf("Failed poll last operation request %s for binding with id %s and name %s: %s", + logPollBindingRequest(pollingRequest), binding.ID, binding.Name, err), + StatusCode: http.StatusBadGateway, + } + } + + switch pollingResponse.State { + case osbc.StateInProgress: + log.C(ctx).Infof("Polling of binding still in progress. Rescheduling polling last operation request %s for binding of instance with id %s and name %s...", + logPollBindingRequest(pollingRequest), binding.ID, binding.Name) + + case osbc.StateSucceeded: + log.C(ctx).Infof("Successfully finished polling operation for binding with id %s and name %s", binding.ID, binding.Name) + + operation.Reschedule = false + if _, err := i.repository.Update(ctx, operation, query.LabelChanges{}); err != nil { + return fmt.Errorf("failed to update operation with id %s to mark that next execution should be a reschedule: %s", operation.ID, err) + } + + // for async creation of bindings, an extra fetching of the binding is required to get the credentials + if operation.Type == types.CREATE { + bindingDetails, err := i.getBindingDetailsFromBroker(ctx, binding, operation, brokerID, osbClient) + if err != nil { + return err + } + if err := i.enrichBindingWithBindingResponse(binding, bindingDetails); err != nil { + return fmt.Errorf("could not enrich binding details with binding response details: %s", err) + } + } + + return nil + case osbc.StateFailed: + log.C(ctx).Infof("Failed polling operation for binding with id %s and name %s with response %s", + binding.ID, binding.Name, logPollBindingResponse(pollingResponse)) + operation.Reschedule = false + if enableOrphanMitigation { + operation.DeletionScheduled = time.Now() + } + if _, err := i.repository.Update(ctx, operation, query.LabelChanges{}); err != nil { + return fmt.Errorf("failed to update operation with id %s after failed of last operation for binding with id %s: %s", operation.ID, binding.ID, err) + } + + errDescription := "" + if pollingResponse.Description != nil { + errDescription = *pollingResponse.Description + } else { + errDescription = "no description provided by broker" + } + return &util.HTTPError{ + ErrorType: "BrokerError", + Description: fmt.Sprintf("failed polling operation for binding with id %s and name %s due to polling last operation error: %s", binding.ID, binding.Name, errDescription), + StatusCode: http.StatusBadGateway, + } + default: + log.C(ctx).Errorf("invalid state during poll last operation for binding with id %s and name %s: %s", binding.ID, binding.Name, pollingResponse.State) + } + } + } +} + +func (i *ServiceBindingInterceptor) getBindingDetailsFromBroker(ctx context.Context, binding *types.ServiceBinding, operation *types.Operation, brokerID string, osbClient osbc.Client) (*bindResponseDetails, error) { + getBindingRequest := &osbc.GetBindingRequest{ + InstanceID: binding.ServiceInstanceID, + BindingID: binding.ID, + } + log.C(ctx).Infof("Sending get binding request %s to broker with id %s", logGetBindingRequest(getBindingRequest), brokerID) + bindingResponse, err := osbClient.GetBinding(getBindingRequest) + if err != nil { + brokerError := &util.HTTPError{ + ErrorType: "BrokerError", + Description: fmt.Sprintf("Failed get bind request %s after successfully finished polling: %s", logGetBindingRequest(getBindingRequest), err), + StatusCode: http.StatusBadGateway, + } + if shouldStartOrphanMitigation(err) { + // mark the operation as deletion scheduled meaning orphan mitigation is required + operation.DeletionScheduled = time.Now() + operation.Reschedule = false + if _, err := i.repository.Update(ctx, operation, query.LabelChanges{}); err != nil { + return nil, fmt.Errorf("failed to update operation with id %s to schedule orphan mitigation after broker error %s: %s", + operation.ID, brokerError, err) + } + } + return nil, brokerError + } + + log.C(ctx).Infof("broker with id %s returned successful get binding response %s", brokerID, logGetBindingResponse(bindingResponse)) + bindResponseDetails := &bindResponseDetails{ + Credentials: bindingResponse.Credentials, + SyslogDrainURL: bindingResponse.SyslogDrainURL, + RouteServiceURL: bindingResponse.RouteServiceURL, + VolumeMounts: bindingResponse.VolumeMounts, + } + + return bindResponseDetails, nil +} + +func (i *ServiceBindingInterceptor) enrichBindingWithBindingResponse(binding *types.ServiceBinding, response *bindResponseDetails) error { + if len(response.Credentials) != 0 { + credentialBytes, err := json.Marshal(response.Credentials) + if err != nil { + return fmt.Errorf("could not marshal binding credentials: %s", err) + } + + binding.Credentials = credentialBytes + } + + if response.RouteServiceURL != nil { + binding.RouteServiceURL = *response.RouteServiceURL + } + + if response.SyslogDrainURL != nil { + binding.SyslogDrainURL = *response.SyslogDrainURL + } + + if len(response.VolumeMounts) != 0 { + volumeMountBytes, err := json.Marshal(response.VolumeMounts) + if err != nil { + return fmt.Errorf("could not marshal volume mounts: %s", err) + } + + binding.VolumeMounts = volumeMountBytes + } + + return nil +} + +func logBindRequest(request *osbc.BindRequest) string { + return fmt.Sprintf("context: %+v, bindingID: %s, instanceID: %s, planID: %s, serviceID: %s, acceptsIncomplete: %t", + request.Context, request.BindingID, request.InstanceID, request.PlanID, request.ServiceID, request.AcceptsIncomplete) +} + +func logBindResponse(response *osbc.BindResponse) string { + return fmt.Sprintf("async: %t, operationKey: %s", response.Async, opKeyPtrToStr(response.OperationKey)) +} + +func logUnbindRequest(request *osbc.UnbindRequest) string { + return fmt.Sprintf("bindingID: %s, instanceID: %s, planID: %s, serviceID: %s, acceptsIncomplete: %t", + request.BindingID, request.InstanceID, request.PlanID, request.ServiceID, request.AcceptsIncomplete) +} + +func logUnbindResponse(response *osbc.UnbindResponse) string { + return fmt.Sprintf("async: %t, operationKey: %s", response.Async, opKeyPtrToStr(response.OperationKey)) +} + +func logPollBindingRequest(request *osbc.BindingLastOperationRequest) string { + return fmt.Sprintf("bindingID: %s, instanceID: %s, planID: %s, serviceID: %s, operationKey: %s", + request.BindingID, request.InstanceID, strPtrToStr(request.PlanID), strPtrToStr(request.ServiceID), opKeyPtrToStr(request.OperationKey)) +} + +func logPollBindingResponse(response *osbc.LastOperationResponse) string { + return fmt.Sprintf("state: %s, description: %s", response.State, strPtrToStr(response.Description)) +} + +func logGetBindingRequest(request *osbc.GetBindingRequest) string { + return fmt.Sprintf("bindingID: %s, instanceID: %s", request.BindingID, request.InstanceID) +} + +func logGetBindingResponse(response *osbc.GetBindingResponse) string { + return fmt.Sprintf("response redacted: ", len(response.Credentials) != 0) +} diff --git a/storage/interceptors/smaap_service_instance_interceptor.go b/storage/interceptors/smaap_service_instance_interceptor.go new file mode 100644 index 000000000..2a6c45edb --- /dev/null +++ b/storage/interceptors/smaap_service_instance_interceptor.go @@ -0,0 +1,544 @@ +/* + * Copyright 2018 The Service Manager Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package interceptors + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/Peripli/service-manager/operations" + + "github.com/Peripli/service-manager/pkg/util" + + "github.com/Peripli/service-manager/pkg/log" + + "github.com/Peripli/service-manager/pkg/query" + + "github.com/Peripli/service-manager/pkg/types" + osbc "github.com/kubernetes-sigs/go-open-service-broker-client/v2" + + "github.com/Peripli/service-manager/storage" +) + +const ServiceInstanceCreateInterceptorProviderName = "ServiceInstanceCreateInterceptorProvider" + +type BaseSMAAPInterceptorProvider struct { + OSBClientCreateFunc osbc.CreateFunc + Repository storage.TransactionalRepository + TenantKey string + PollingInterval time.Duration +} + +// ServiceInstanceCreateInterceptorProvider provides an interceptor that notifies the actual broker about instance creation +type ServiceInstanceCreateInterceptorProvider struct { + *BaseSMAAPInterceptorProvider +} + +func (p *ServiceInstanceCreateInterceptorProvider) Provide() storage.CreateAroundTxInterceptor { + return &ServiceInstanceInterceptor{ + osbClientCreateFunc: p.OSBClientCreateFunc, + repository: p.Repository, + tenantKey: p.TenantKey, + pollingInterval: p.PollingInterval, + } +} + +func (c *ServiceInstanceCreateInterceptorProvider) Name() string { + return ServiceInstanceCreateInterceptorProviderName +} + +const ServiceInstanceUpdateInterceptorProviderName = "ServiceInstanceUpdateInterceptorProvider" + +// ServiceInstanceUpdateInterceptorProvider provides an interceptor that notifies the actual broker about instance updates +type ServiceInstanceUpdateInterceptorProvider struct { + *BaseSMAAPInterceptorProvider +} + +func (p *ServiceInstanceUpdateInterceptorProvider) Provide() storage.UpdateAroundTxInterceptor { + return &ServiceInstanceInterceptor{ + osbClientCreateFunc: p.OSBClientCreateFunc, + repository: p.Repository, + tenantKey: p.TenantKey, + pollingInterval: p.PollingInterval, + } +} + +func (c *ServiceInstanceUpdateInterceptorProvider) Name() string { + return ServiceInstanceUpdateInterceptorProviderName +} + +const ServiceInstanceDeleteInterceptorProviderName = "ServiceInstanceDeleteInterceptorProvider" + +// ServiceInstanceDeleteInterceptorProvider provides an interceptor that notifies the actual broker about instance deletion +type ServiceInstanceDeleteInterceptorProvider struct { + *BaseSMAAPInterceptorProvider +} + +func (p *ServiceInstanceDeleteInterceptorProvider) Provide() storage.DeleteAroundTxInterceptor { + return &ServiceInstanceInterceptor{ + osbClientCreateFunc: p.OSBClientCreateFunc, + repository: p.Repository, + tenantKey: p.TenantKey, + pollingInterval: p.PollingInterval, + } +} + +func (c *ServiceInstanceDeleteInterceptorProvider) Name() string { + return ServiceInstanceDeleteInterceptorProviderName +} + +type ServiceInstanceInterceptor struct { + osbClientCreateFunc osbc.CreateFunc + repository storage.TransactionalRepository + tenantKey string + pollingInterval time.Duration +} + +func (i *ServiceInstanceInterceptor) AroundTxCreate(f storage.InterceptCreateAroundTxFunc) storage.InterceptCreateAroundTxFunc { + return func(ctx context.Context, obj types.Object) (types.Object, error) { + instance := obj.(*types.ServiceInstance) + instance.Usable = true + + if instance.PlatformID != types.SMPlatform { + return f(ctx, obj) + } + + operation, found := operations.GetFromContext(ctx) + if !found { + return nil, fmt.Errorf("operation missing from context") + } + + osbClient, broker, service, plan, err := preparePrerequisites(ctx, i.repository, i.osbClientCreateFunc, instance) + if err != nil { + return nil, err + } + + var provisionResponse *osbc.ProvisionResponse + if !operation.Reschedule { + provisionRequest := i.prepareProvisionRequest(instance, service.CatalogID, plan.CatalogID) + contextBytes, err := json.Marshal(provisionRequest.Context) + if err != nil { + return nil, fmt.Errorf("failed to marshal OSB context %+v: %s", provisionRequest.Context, err) + } + instance.Context = contextBytes + + log.C(ctx).Infof("Sending provision request %s to broker with name %s", logProvisionRequest(provisionRequest), broker.Name) + provisionResponse, err = osbClient.ProvisionInstance(provisionRequest) + if err != nil { + brokerError := &util.HTTPError{ + ErrorType: "BrokerError", + Description: fmt.Sprintf("Failed provisioning request %s: %s", logProvisionRequest(provisionRequest), err), + StatusCode: http.StatusBadGateway, + } + if shouldStartOrphanMitigation(err) { + // store the instance so that later on we can do orphan mitigation + _, err := f(ctx, obj) + if err != nil { + return nil, fmt.Errorf("broker error %s caused orphan mitigation which required storing the resource which failed with: %s", brokerError, err) + } + + // mark the operation as deletion scheduled meaning orphan mitigation is required + operation.DeletionScheduled = time.Now().UTC() + operation.Reschedule = false + if _, err := i.repository.Update(ctx, operation, query.LabelChanges{}); err != nil { + return nil, fmt.Errorf("failed to update operation with id %s to schedule orphan mitigation after broker error %s: %s", operation.ID, brokerError, err) + } + } + return nil, brokerError + } + + if provisionResponse.DashboardURL != nil { + dashboardURL := *provisionResponse.DashboardURL + instance.DashboardURL = dashboardURL + } + + if provisionResponse.Async { + log.C(ctx).Infof("Successful asynchronous provisioning request %s to broker %s returned response %s", + logProvisionRequest(provisionRequest), broker.Name, logProvisionResponse(provisionResponse)) + operation.Reschedule = true + if provisionResponse.OperationKey != nil { + operation.ExternalID = string(*provisionResponse.OperationKey) + } + + if _, err := i.repository.Update(ctx, operation, query.LabelChanges{}); err != nil { + return nil, fmt.Errorf("failed to update operation with id %s to mark that next execution should be a reschedule: %s", instance.ID, err) + } + } else { + log.C(ctx).Infof("Successful synchronous provisioning %s to broker %s returned response %s", + logProvisionRequest(provisionRequest), broker.Name, logProvisionResponse(provisionResponse)) + + } + } + + object, err := f(ctx, obj) + if err != nil { + return nil, err + } + instance = object.(*types.ServiceInstance) + + if operation.Reschedule { + if err := i.pollServiceInstance(ctx, osbClient, instance, operation, broker.ID, service.CatalogID, plan.CatalogID, operation.ExternalID, true); err != nil { + return nil, err + } + } + + return instance, nil + } +} + +// TODO Update of instances in SM is not yet implemented +func (i *ServiceInstanceInterceptor) AroundTxUpdate(h storage.InterceptUpdateAroundTxFunc) storage.InterceptUpdateAroundTxFunc { + return h +} + +func (i *ServiceInstanceInterceptor) AroundTxDelete(f storage.InterceptDeleteAroundTxFunc) storage.InterceptDeleteAroundTxFunc { + return func(ctx context.Context, deletionCriteria ...query.Criterion) error { + instances, err := i.repository.List(ctx, types.ServiceInstanceType, deletionCriteria...) + if err != nil { + return fmt.Errorf("failed to get instances for deletion: %s", err) + } + + if instances.Len() > 1 { + return fmt.Errorf("deletion of multiple instances is not supported") + } + + if instances.Len() != 0 { + instance := instances.ItemAt(0).(*types.ServiceInstance) + if instance.PlatformID != types.SMPlatform { + return f(ctx, deletionCriteria...) + } + + operation, found := operations.GetFromContext(ctx) + if !found { + return fmt.Errorf("operation missing from context") + } + + if err := i.deleteSingleInstance(ctx, instance, operation); err != nil { + return err + } + } + + if err := f(ctx, deletionCriteria...); err != nil { + return err + } + + return nil + } +} + +func (i *ServiceInstanceInterceptor) deleteSingleInstance(ctx context.Context, instance *types.ServiceInstance, operation *types.Operation) error { + byServiceInstanceID := query.ByField(query.EqualsOperator, "service_instance_id", instance.ID) + var bindingsCount int + var err error + if bindingsCount, err = i.repository.Count(ctx, types.ServiceBindingType, byServiceInstanceID); err != nil { + return fmt.Errorf("could not fetch bindings for instance with id %s", instance.ID) + } + if bindingsCount > 0 { + return &util.HTTPError{ + ErrorType: "BadRequest", + Description: fmt.Sprintf("could not delete instance due to %d existing bindings", bindingsCount), + StatusCode: http.StatusBadRequest, + } + } + + osbClient, broker, service, plan, err := preparePrerequisites(ctx, i.repository, i.osbClientCreateFunc, instance) + if err != nil { + return err + } + + // if deletion scheduled is true this means that either a delete or create operation failed and orphan mitigation was required + if !operation.DeletionScheduled.IsZero() { + log.C(ctx).Infof("Orphan mitigation in progress for instance with id %s and name %s triggered due to failure in operation %s", instance.ID, instance.Name, operation.Type) + } + + var deprovisionResponse *osbc.DeprovisionResponse + if !operation.Reschedule { + deprovisionRequest := prepareDeprovisionRequest(instance, service.CatalogID, plan.CatalogID) + + log.C(ctx).Infof("Sending deprovision request %s to broker with name %s", logDeprovisionRequest(deprovisionRequest), broker.Name) + deprovisionResponse, err = osbClient.DeprovisionInstance(deprovisionRequest) + if err != nil { + if osbc.IsGoneError(err) { + log.C(ctx).Infof("Synchronous deprovisioning %s to broker %s returned 410 GONE and is considered success", + logDeprovisionRequest(deprovisionRequest), broker.Name) + return nil + } + brokerError := &util.HTTPError{ + ErrorType: "BrokerError", + Description: fmt.Sprintf("Failed deprovisioning request %s: %s", logDeprovisionRequest(deprovisionRequest), err), + StatusCode: http.StatusBadGateway, + } + + if shouldStartOrphanMitigation(err) { + operation.DeletionScheduled = time.Now() + operation.Reschedule = false + if _, err := i.repository.Update(ctx, operation, query.LabelChanges{}); err != nil { + return fmt.Errorf("failed to update operation with id %s to schedule orphan mitigation after broker error %s: %s", operation.ID, brokerError, err) + } + } + return brokerError + } + + if deprovisionResponse.Async { + log.C(ctx).Infof("Successful asynchronous deprovisioning request %s to broker %s returned response %s", + logDeprovisionRequest(deprovisionRequest), broker.Name, logDeprovisionResponse(deprovisionResponse)) + operation.Reschedule = true + + if deprovisionResponse.OperationKey != nil { + operation.ExternalID = string(*deprovisionResponse.OperationKey) + } + if _, err := i.repository.Update(ctx, operation, query.LabelChanges{}); err != nil { + return fmt.Errorf("failed to update operation with id %s to mark that rescheduling is possible: %s", operation.ID, err) + } + } else { + log.C(ctx).Infof("Successful synchronous deprovisioning %s to broker %s returned response %s", + logDeprovisionRequest(deprovisionRequest), broker.Name, logDeprovisionResponse(deprovisionResponse)) + } + } + + if operation.Reschedule { + if err := i.pollServiceInstance(ctx, osbClient, instance, operation, broker.ID, service.CatalogID, plan.CatalogID, operation.ExternalID, true); err != nil { + return err + } + } + + return nil +} + +func (i *ServiceInstanceInterceptor) pollServiceInstance(ctx context.Context, osbClient osbc.Client, instance *types.ServiceInstance, operation *types.Operation, brokerID, serviceCatalogID, planCatalogID, operationKey string, enableOrphanMitigation bool) error { + var key *osbc.OperationKey + if len(operation.ExternalID) != 0 { + opKey := osbc.OperationKey(operation.ExternalID) + key = &opKey + } + + pollingRequest := &osbc.LastOperationRequest{ + InstanceID: instance.ID, + ServiceID: &serviceCatalogID, + PlanID: &planCatalogID, + OperationKey: key, + //TODO no OI for SM platform yet + OriginatingIdentity: nil, + } + + ticker := time.NewTicker(i.pollingInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + log.C(ctx).Errorf("Terminating poll last operation for instance with id %s and name %s due to context done event", instance.ID, instance.Name) + //operation should be kept in progress in this case + return nil + case <-ticker.C: + log.C(ctx).Infof("Sending poll last operation request %s for instance with id %s and name %s", logPollInstanceRequest(pollingRequest), instance.ID, instance.Name) + pollingResponse, err := osbClient.PollLastOperation(pollingRequest) + if err != nil { + if osbc.IsGoneError(err) && operation.Type == types.DELETE { + log.C(ctx).Infof("Successfully finished polling operation for instance with id %s and name %s", instance.ID, instance.Name) + + operation.Reschedule = false + if _, err := i.repository.Update(ctx, operation, query.LabelChanges{}); err != nil { + return fmt.Errorf("failed to update operation with id %s to mark that next execution should not be reschedulable", operation.ID) + } + return nil + } + + return &util.HTTPError{ + ErrorType: "BrokerError", + Description: fmt.Sprintf("Failed poll last operation request %s for instance with id %s and name %s: %s", + logPollInstanceRequest(pollingRequest), instance.ID, instance.Name, err), + StatusCode: http.StatusBadGateway, + } + } + switch pollingResponse.State { + case osbc.StateInProgress: + log.C(ctx).Infof("Polling of instance still in progress. Rescheduling polling last operation request %s to for provisioning of instance with id %s and name %s...", + logPollInstanceRequest(pollingRequest), instance.ID, instance.Name) + + case osbc.StateSucceeded: + log.C(ctx).Infof("Successfully finished polling operation for instance with id %s and name %s", instance.ID, instance.Name) + + operation.Reschedule = false + if _, err := i.repository.Update(ctx, operation, query.LabelChanges{}); err != nil { + return fmt.Errorf("failed to update operation with id %s to mark that next execution should be a reschedule: %s", operation.ID, err) + } + + return nil + case osbc.StateFailed: + log.C(ctx).Infof("Failed polling operation for instance with id %s and name %s with response %s", instance.ID, instance.Name, logPollInstanceResponse(pollingResponse)) + operation.Reschedule = false + if enableOrphanMitigation { + operation.DeletionScheduled = time.Now() + } + if _, err := i.repository.Update(ctx, operation, query.LabelChanges{}); err != nil { + return fmt.Errorf("failed to update operation with id %s after failed of last operation for instance with id %s: %s", operation.ID, instance.ID, err) + } + + errDescription := "" + if pollingResponse.Description != nil { + errDescription = *pollingResponse.Description + } else { + errDescription = "no description provided by broker" + } + return &util.HTTPError{ + ErrorType: "BrokerError", + Description: fmt.Sprintf("failed polling operation for instance with id %s and name %s due to polling last operation error: %s", instance.ID, instance.Name, errDescription), + StatusCode: http.StatusBadGateway, + } + default: + log.C(ctx).Errorf("invalid state during poll last operation for instance with id %s and name %s: %s. Continuing polling...", instance.ID, instance.Name, pollingResponse.State) + } + } + } +} + +func preparePrerequisites(ctx context.Context, repository storage.Repository, osbClientFunc osbc.CreateFunc, instance *types.ServiceInstance) (osbc.Client, *types.ServiceBroker, *types.ServiceOffering, *types.ServicePlan, error) { + planObject, err := repository.Get(ctx, types.ServicePlanType, query.ByField(query.EqualsOperator, "id", instance.ServicePlanID)) + if err != nil { + return nil, nil, nil, nil, util.HandleStorageError(err, types.ServicePlanType.String()) + } + plan := planObject.(*types.ServicePlan) + + serviceObject, err := repository.Get(ctx, types.ServiceOfferingType, query.ByField(query.EqualsOperator, "id", plan.ServiceOfferingID)) + if err != nil { + return nil, nil, nil, nil, util.HandleStorageError(err, types.ServiceOfferingType.String()) + } + service := serviceObject.(*types.ServiceOffering) + + brokerObject, err := repository.Get(ctx, types.ServiceBrokerType, query.ByField(query.EqualsOperator, "id", service.BrokerID)) + if err != nil { + return nil, nil, nil, nil, util.HandleStorageError(err, types.ServiceBrokerType.String()) + } + broker := brokerObject.(*types.ServiceBroker) + osbClient, err := osbClientFunc(&osbc.ClientConfiguration{ + Name: broker.Name + " broker client", + EnableAlphaFeatures: true, + URL: broker.BrokerURL, + APIVersion: osbc.LatestAPIVersion(), + AuthConfig: &osbc.AuthConfig{ + BasicAuthConfig: &osbc.BasicAuthConfig{ + Username: broker.Credentials.Basic.Username, + Password: broker.Credentials.Basic.Password, + }, + }, + }) + if err != nil { + return nil, nil, nil, nil, err + } + + return osbClient, broker, service, plan, nil +} + +func (i *ServiceInstanceInterceptor) prepareProvisionRequest(instance *types.ServiceInstance, serviceCatalogID, planCatalogID string) *osbc.ProvisionRequest { + provisionRequest := &osbc.ProvisionRequest{ + InstanceID: instance.GetID(), + AcceptsIncomplete: true, + ServiceID: serviceCatalogID, + PlanID: planCatalogID, + OrganizationGUID: "-", + SpaceGUID: "-", + Parameters: instance.Parameters, + Context: map[string]interface{}{ + "platform": types.SMPlatform, + "instance_name": instance.Name, + }, + //TODO no OI for SM platform yet + OriginatingIdentity: nil, + } + if len(i.tenantKey) != 0 { + if tenantValue, ok := instance.GetLabels()[i.tenantKey]; ok { + provisionRequest.Context[i.tenantKey] = tenantValue[0] + } + } + + return provisionRequest +} + +func prepareDeprovisionRequest(instance *types.ServiceInstance, serviceCatalogID, planCatalogID string) *osbc.DeprovisionRequest { + return &osbc.DeprovisionRequest{ + InstanceID: instance.ID, + AcceptsIncomplete: true, + ServiceID: serviceCatalogID, + PlanID: planCatalogID, + //TODO no OI for SM platform yet + OriginatingIdentity: nil, + } +} + +func shouldStartOrphanMitigation(err error) bool { + if httpError, ok := osbc.IsHTTPError(err); ok { + statusCode := httpError.StatusCode + is2XX := statusCode >= 200 && statusCode < 300 + is5XX := statusCode >= 500 && statusCode < 600 + return (is2XX && statusCode != http.StatusOK) || + statusCode == http.StatusRequestTimeout || + is5XX + } + + if urlErr, ok := err.(*url.Error); ok && urlErr.Timeout() { + return true + } + + return false +} + +func logProvisionRequest(request *osbc.ProvisionRequest) string { + return fmt.Sprintf("context: %+v, instanceID: %s, planID: %s, serviceID: %s, acceptsIncomplete: %t", + request.Context, request.InstanceID, request.PlanID, request.ServiceID, request.AcceptsIncomplete) +} + +func logProvisionResponse(response *osbc.ProvisionResponse) string { + return fmt.Sprintf("async: %t, dashboardURL: %s, operationKey: %s", response.Async, strPtrToStr(response.DashboardURL), opKeyPtrToStr(response.OperationKey)) +} + +func logDeprovisionRequest(request *osbc.DeprovisionRequest) string { + return fmt.Sprintf("instanceID: %s, planID: %s, serviceID: %s, acceptsIncomplete: %t", + request.InstanceID, request.PlanID, request.ServiceID, request.AcceptsIncomplete) +} + +func logDeprovisionResponse(response *osbc.DeprovisionResponse) string { + return fmt.Sprintf("async: %t, operationKey: %s", response.Async, opKeyPtrToStr(response.OperationKey)) +} + +func logPollInstanceRequest(request *osbc.LastOperationRequest) string { + return fmt.Sprintf("instanceID: %s, planID: %s, serviceID: %s, operationKey: %s", + request.InstanceID, strPtrToStr(request.PlanID), strPtrToStr(request.ServiceID), opKeyPtrToStr(request.OperationKey)) +} + +func logPollInstanceResponse(response *osbc.LastOperationResponse) string { + return fmt.Sprintf("state: %s, description: %s", response.State, strPtrToStr(response.Description)) +} + +func strPtrToStr(sPtr *string) string { + if sPtr == nil { + return "" + } + + return *sPtr +} + +func opKeyPtrToStr(opKey *osbc.OperationKey) string { + if opKey == nil { + return "" + } + + return string(*opKey) +} diff --git a/storage/notification_queue_test.go b/storage/notification_queue_test.go index c2ebff0f8..db8b9637d 100644 --- a/storage/notification_queue_test.go +++ b/storage/notification_queue_test.go @@ -29,6 +29,9 @@ var _ = Describe("NotificationQueue", func() { BeforeEach(func() { notification = &types.Notification{ + Base: types.Base{ + Ready: true, + }, PlatformID: "123", } }) diff --git a/storage/postgres/abstract.go b/storage/postgres/abstract.go index 97201f546..f78df5fc0 100644 --- a/storage/postgres/abstract.go +++ b/storage/postgres/abstract.go @@ -232,7 +232,30 @@ func checkSQLNoRows(err error) error { } func toNullString(s string) sql.NullString { - return sql.NullString{String: s, Valid: s != ""} + return sql.NullString{ + String: s, + Valid: s != "", + } +} + +func toNullBool(b *bool) sql.NullBool { + bFalse := false + isValid := b != nil + if b == nil { + b = &bFalse + } + return sql.NullBool{ + Bool: *b, + Valid: isValid, + } +} + +func toBoolPointer(nullBool sql.NullBool) *bool { + if !nullBool.Valid { + return nil + } + + return &nullBool.Bool } func getJSONText(item json.RawMessage) sqlxtypes.JSONText { @@ -266,3 +289,14 @@ func getJSONRawMessage(item sqlxtypes.JSONText) json.RawMessage { } return json.RawMessage(item) } + +func getJSONRawMessageFromString(str string) json.RawMessage { + if len(str) == 0 { + return nil + } + return json.RawMessage(str) +} + +func getStringFromJSONRawMessage(message json.RawMessage) string { + return string(message) +} diff --git a/storage/postgres/base_entity.go b/storage/postgres/base_entity.go index 901246836..4dc3f98d6 100644 --- a/storage/postgres/base_entity.go +++ b/storage/postgres/base_entity.go @@ -32,6 +32,7 @@ type BaseEntity struct { CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` PagingSequence int64 `db:"paging_sequence,auto_increment"` + Ready bool `db:"ready"` } func (e *BaseEntity) GetID() string { diff --git a/storage/postgres/broker.go b/storage/postgres/broker.go index 6b57f83f0..103ca950f 100644 --- a/storage/postgres/broker.go +++ b/storage/postgres/broker.go @@ -51,6 +51,7 @@ func (e *Broker) ToObject() types.Object { UpdatedAt: e.UpdatedAt, Labels: map[string][]string{}, PagingSequence: e.PagingSequence, + Ready: e.Ready, }, Name: e.Name, Description: e.Description.String, @@ -85,6 +86,7 @@ func (*Broker) FromObject(object types.Object) (storage.Entity, bool) { CreatedAt: broker.CreatedAt, UpdatedAt: broker.UpdatedAt, PagingSequence: broker.PagingSequence, + Ready: broker.Ready, }, Name: broker.Name, Description: toNullString(broker.Description), diff --git a/storage/postgres/keystore_test.go b/storage/postgres/keystore_test.go index 17ee1c10b..4ba0142f6 100644 --- a/storage/postgres/keystore_test.go +++ b/storage/postgres/keystore_test.go @@ -59,7 +59,7 @@ var _ = Describe("Secured Storage", func() { mock.ExpectQuery(`SELECT CURRENT_DATABASE()`).WillReturnRows(sqlmock.NewRows([]string{"mock"}).FromCSVString("mock")) mock.ExpectQuery(`SELECT COUNT(1)*`).WillReturnRows(sqlmock.NewRows([]string{"mock"}).FromCSVString("1")) mock.ExpectExec("SELECT pg_advisory_lock*").WithArgs(sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1)) - mock.ExpectQuery(`SELECT version, dirty FROM "schema_migrations" LIMIT 1`).WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}).FromCSVString("20200116221100,false")) + mock.ExpectQuery(`SELECT version, dirty FROM "schema_migrations" LIMIT 1`).WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}).FromCSVString("20200122151000,false")) mock.ExpectExec("SELECT pg_advisory_unlock*").WithArgs(sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1)) options := storage.DefaultSettings() options.EncryptionKey = string(envEncryptionKey) diff --git a/storage/postgres/migrations/20200117150000_additional_operations_columns.down.sql b/storage/postgres/migrations/20200117150000_additional_operations_columns.down.sql new file mode 100644 index 000000000..2021d1aa2 --- /dev/null +++ b/storage/postgres/migrations/20200117150000_additional_operations_columns.down.sql @@ -0,0 +1,6 @@ +BEGIN; + +ALTER TABLE operations DROP COLUMN reschedule; +ALTER TABLE operations DROP COLUMN deletion_scheduled; + +COMMIT; \ No newline at end of file diff --git a/storage/postgres/migrations/20200117150000_additional_operations_columns.up.sql b/storage/postgres/migrations/20200117150000_additional_operations_columns.up.sql new file mode 100644 index 000000000..cf3677549 --- /dev/null +++ b/storage/postgres/migrations/20200117150000_additional_operations_columns.up.sql @@ -0,0 +1,6 @@ +BEGIN; + +ALTER TABLE operations ADD COLUMN reschedule BOOLEAN NOT NULL DEFAULT '0'; +ALTER TABLE operations ADD COLUMN deletion_scheduled TIMESTAMP NOT NULL DEFAULT '0001-01-01 00:00:00+00'; + +COMMIT; \ No newline at end of file diff --git a/storage/postgres/migrations/20200117151000_ready_column.down.sql b/storage/postgres/migrations/20200117151000_ready_column.down.sql new file mode 100644 index 000000000..fbe86cc01 --- /dev/null +++ b/storage/postgres/migrations/20200117151000_ready_column.down.sql @@ -0,0 +1,11 @@ +BEGIN; + +ALTER TABLE brokers DROP COLUMN ready; +ALTER TABLE notifications DROP COLUMN ready; +ALTER TABLE operations DROP COLUMN ready; +ALTER TABLE platforms DROP COLUMN ready; +ALTER TABLE service_offerings DROP COLUMN ready; +ALTER TABLE service_plans DROP COLUMN ready; +ALTER TABLE visibilities DROP COLUMN ready; + +COMMIT; \ No newline at end of file diff --git a/storage/postgres/migrations/20200117151000_ready_column.up.sql b/storage/postgres/migrations/20200117151000_ready_column.up.sql new file mode 100644 index 000000000..30a03ddd5 --- /dev/null +++ b/storage/postgres/migrations/20200117151000_ready_column.up.sql @@ -0,0 +1,11 @@ +BEGIN; + +ALTER TABLE brokers ADD COLUMN IF NOT EXISTS ready BOOLEAN NOT NULL DEFAULT '1'; +ALTER TABLE notifications ADD COLUMN IF NOT EXISTS ready BOOLEAN NOT NULL DEFAULT '1'; +ALTER TABLE operations ADD COLUMN IF NOT EXISTS ready BOOLEAN NOT NULL DEFAULT '1'; +ALTER TABLE platforms ADD COLUMN IF NOT EXISTS ready BOOLEAN NOT NULL DEFAULT '1'; +ALTER TABLE service_offerings ADD COLUMN IF NOT EXISTS ready BOOLEAN NOT NULL DEFAULT '1'; +ALTER TABLE service_plans ADD COLUMN IF NOT EXISTS ready BOOLEAN NOT NULL DEFAULT '1'; +ALTER TABLE visibilities ADD COLUMN IF NOT EXISTS ready BOOLEAN NOT NULL DEFAULT '1'; + +COMMIT; \ No newline at end of file diff --git a/storage/postgres/migrations/20200122151000_alter_plan_table.down.sql b/storage/postgres/migrations/20200122151000_alter_plan_table.down.sql new file mode 100644 index 000000000..d239fc991 --- /dev/null +++ b/storage/postgres/migrations/20200122151000_alter_plan_table.down.sql @@ -0,0 +1,6 @@ +BEGIN; + +ALTER TABLE service_plans ALTER COLUMN plan_updateable SET NOT NULL; +ALTER TABLE service_plans ALTER COLUMN bindable SET NOT NULL; + +COMMIT; \ No newline at end of file diff --git a/storage/postgres/migrations/20200122151000_alter_plan_table.up.sql b/storage/postgres/migrations/20200122151000_alter_plan_table.up.sql new file mode 100644 index 000000000..389f1b1e1 --- /dev/null +++ b/storage/postgres/migrations/20200122151000_alter_plan_table.up.sql @@ -0,0 +1,6 @@ +BEGIN; + +ALTER TABLE service_plans ALTER COLUMN plan_updateable DROP NOT NULL; +ALTER TABLE service_plans ALTER COLUMN bindable DROP NOT NULL; + +COMMIT; \ No newline at end of file diff --git a/storage/postgres/notification.go b/storage/postgres/notification.go index e35c6987c..5dcebc41d 100644 --- a/storage/postgres/notification.go +++ b/storage/postgres/notification.go @@ -43,6 +43,7 @@ func (n *Notification) ToObject() types.Object { CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt, Labels: map[string][]string{}, + Ready: n.Ready, }, Resource: types.ObjectType(n.Resource), Type: types.NotificationOperation(n.Type), @@ -64,6 +65,7 @@ func (*Notification) FromObject(object types.Object) (storage.Entity, bool) { ID: notification.ID, CreatedAt: notification.CreatedAt, UpdatedAt: notification.UpdatedAt, + Ready: notification.Ready, }, Resource: string(notification.Resource), Type: string(notification.Type), diff --git a/storage/postgres/notificator_test.go b/storage/postgres/notificator_test.go index 77259d36f..ad73a2415 100644 --- a/storage/postgres/notificator_test.go +++ b/storage/postgres/notificator_test.go @@ -146,7 +146,8 @@ var _ = Describe("Notificator", func() { wg = &sync.WaitGroup{} defaultPlatform = &types.Platform{ Base: types.Base{ - ID: "platformID", + ID: "platformID", + Ready: true, }, } fakeNotificationStorage = &postgresfakes.FakeNotificationStorage{} @@ -197,7 +198,8 @@ var _ = Describe("Notificator", func() { JustBeforeEach(func() { secondPlatform = &types.Platform{ Base: types.Base{ - ID: "platform2", + ID: "platform2", + Ready: true, }, } testNotificator.RegisterFilter(func(recipients []*types.Platform, notification *types.Notification) []*types.Platform { diff --git a/storage/postgres/operation.go b/storage/postgres/operation.go index c40758abc..c66233a37 100644 --- a/storage/postgres/operation.go +++ b/storage/postgres/operation.go @@ -18,6 +18,8 @@ package postgres import ( "database/sql" + "time" + "github.com/Peripli/service-manager/pkg/types" "github.com/Peripli/service-manager/storage" sqlxtypes "github.com/jmoiron/sqlx/types" @@ -27,14 +29,16 @@ import ( //go:generate smgen storage operation github.com/Peripli/service-manager/pkg/types:Operation type Operation struct { BaseEntity - Description sql.NullString `db:"description"` - Type string `db:"type"` - State string `db:"state"` - ResourceID string `db:"resource_id"` - ResourceType string `db:"resource_type"` - Errors sqlxtypes.JSONText `db:"errors"` - CorrelationID sql.NullString `db:"correlation_id"` - ExternalID sql.NullString `db:"external_id"` + Description sql.NullString `db:"description"` + Type string `db:"type"` + State string `db:"state"` + ResourceID string `db:"resource_id"` + ResourceType string `db:"resource_type"` + Errors sqlxtypes.JSONText `db:"errors"` + CorrelationID sql.NullString `db:"correlation_id"` + ExternalID sql.NullString `db:"external_id"` + Reschedule bool `db:"reschedule"` + DeletionScheduled time.Time `db:"deletion_scheduled"` } func (o *Operation) ToObject() types.Object { @@ -44,15 +48,18 @@ func (o *Operation) ToObject() types.Object { CreatedAt: o.CreatedAt, UpdatedAt: o.UpdatedAt, PagingSequence: o.PagingSequence, + Ready: o.Ready, }, - Description: o.Description.String, - Type: types.OperationCategory(o.Type), - State: types.OperationState(o.State), - ResourceID: o.ResourceID, - ResourceType: o.ResourceType, - Errors: getJSONRawMessage(o.Errors), - CorrelationID: o.CorrelationID.String, - ExternalID: o.ExternalID.String, + Description: o.Description.String, + Type: types.OperationCategory(o.Type), + State: types.OperationState(o.State), + ResourceID: o.ResourceID, + ResourceType: types.ObjectType(o.ResourceType), + Errors: getJSONRawMessage(o.Errors), + CorrelationID: o.CorrelationID.String, + ExternalID: o.ExternalID.String, + Reschedule: o.Reschedule, + DeletionScheduled: o.DeletionScheduled, } } @@ -68,15 +75,18 @@ func (*Operation) FromObject(object types.Object) (storage.Entity, bool) { CreatedAt: operation.CreatedAt, UpdatedAt: operation.UpdatedAt, PagingSequence: operation.PagingSequence, + Ready: operation.Ready, }, - Description: toNullString(operation.Description), - Type: string(operation.Type), - State: string(operation.State), - ResourceID: operation.ResourceID, - ResourceType: operation.ResourceType, - Errors: getJSONText(operation.Errors), - CorrelationID: toNullString(operation.CorrelationID), - ExternalID: toNullString(operation.ExternalID), + Description: toNullString(operation.Description), + Type: string(operation.Type), + State: string(operation.State), + ResourceID: operation.ResourceID, + ResourceType: operation.ResourceType.String(), + Errors: getJSONText(operation.Errors), + CorrelationID: toNullString(operation.CorrelationID), + ExternalID: toNullString(operation.ExternalID), + Reschedule: operation.Reschedule, + DeletionScheduled: operation.DeletionScheduled, } return o, true } diff --git a/storage/postgres/platform.go b/storage/postgres/platform.go index 27f201e62..18b4ae010 100644 --- a/storage/postgres/platform.go +++ b/storage/postgres/platform.go @@ -49,6 +49,7 @@ func (p *Platform) FromObject(object types.Object) (storage.Entity, bool) { CreatedAt: platform.CreatedAt, UpdatedAt: platform.UpdatedAt, PagingSequence: platform.PagingSequence, + Ready: platform.Ready, }, Type: platform.Type, Name: platform.Name, @@ -74,6 +75,7 @@ func (p *Platform) ToObject() types.Object { CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, PagingSequence: p.PagingSequence, + Ready: p.Ready, }, Type: p.Type, Name: p.Name, diff --git a/storage/postgres/query_builder_test.go b/storage/postgres/query_builder_test.go index 29d5a2e6a..2ac8f7f2b 100644 --- a/storage/postgres/query_builder_test.go +++ b/storage/postgres/query_builder_test.go @@ -278,7 +278,8 @@ WITH matching_resources as (SELECT DISTINCT visibilities.paging_sequence WHERE ((visibilities.id::text != ? AND visibilities.service_plan_id::text NOT IN (?, ?, ?) AND (visibilities.platform_id::text = ? OR platform_id IS NULL)) AND - ((key::text = ? AND val::text = ?) OR (key::text = ? AND val::text IN (?, ?)) OR + ((key::text = ? AND val::text = ?) OR + (key::text = ? AND val::text IN (?, ?)) OR (key::text = ? AND val::text != ?))) ORDER BY visibilities.paging_sequence ASC LIMIT ?) diff --git a/storage/postgres/scheme_test.go b/storage/postgres/scheme_test.go index 86d7bd8ae..7338ac232 100644 --- a/storage/postgres/scheme_test.go +++ b/storage/postgres/scheme_test.go @@ -156,6 +156,13 @@ var _ = Describe("Postgres Storage", func() { type obj struct { } +func (obj) SetReady(bool) { +} + +func (obj) GetReady() bool { + return true +} + func (obj) Equals(other types.Object) bool { return false } diff --git a/storage/postgres/service_binding.go b/storage/postgres/service_binding.go index 82bd5e629..74f8d3d71 100644 --- a/storage/postgres/service_binding.go +++ b/storage/postgres/service_binding.go @@ -38,7 +38,6 @@ type ServiceBinding struct { Context sqlxtypes.JSONText `db:"context"` BindResource sqlxtypes.JSONText `db:"bind_resource"` Credentials string `db:"credentials"` - Ready bool `db:"ready"` } func (sb *ServiceBinding) ToObject() types.Object { @@ -49,6 +48,7 @@ func (sb *ServiceBinding) ToObject() types.Object { UpdatedAt: sb.UpdatedAt, Labels: map[string][]string{}, PagingSequence: sb.PagingSequence, + Ready: sb.Ready, }, Name: sb.Name, ServiceInstanceID: sb.ServiceInstanceID, @@ -58,8 +58,7 @@ func (sb *ServiceBinding) ToObject() types.Object { Endpoints: getJSONRawMessage(sb.Endpoints.JSONText), Context: getJSONRawMessage(sb.Context), BindResource: getJSONRawMessage(sb.BindResource), - Credentials: sb.Credentials, - Ready: sb.Ready, + Credentials: getJSONRawMessageFromString(sb.Credentials), } } @@ -75,6 +74,7 @@ func (*ServiceBinding) FromObject(object types.Object) (storage.Entity, bool) { CreatedAt: serviceBinding.CreatedAt, UpdatedAt: serviceBinding.UpdatedAt, PagingSequence: serviceBinding.PagingSequence, + Ready: serviceBinding.Ready, }, Name: serviceBinding.Name, ServiceInstanceID: serviceBinding.ServiceInstanceID, @@ -84,8 +84,7 @@ func (*ServiceBinding) FromObject(object types.Object) (storage.Entity, bool) { Endpoints: getNullJSONText(serviceBinding.Endpoints), Context: getJSONText(serviceBinding.Context), BindResource: getJSONText(serviceBinding.BindResource), - Credentials: serviceBinding.Credentials, - Ready: serviceBinding.Ready, + Credentials: getStringFromJSONRawMessage(serviceBinding.Credentials), } return sb, true diff --git a/storage/postgres/service_instance.go b/storage/postgres/service_instance.go index 645015ad6..2de78e2d8 100644 --- a/storage/postgres/service_instance.go +++ b/storage/postgres/service_instance.go @@ -37,7 +37,6 @@ type ServiceInstance struct { Context sqlxtypes.JSONText `db:"context"` PreviousValues sqlxtypes.JSONText `db:"previous_values"` Usable bool `db:"usable"` - Ready bool `db:"ready"` } func (si *ServiceInstance) ToObject() types.Object { @@ -48,6 +47,7 @@ func (si *ServiceInstance) ToObject() types.Object { UpdatedAt: si.UpdatedAt, Labels: map[string][]string{}, PagingSequence: si.PagingSequence, + Ready: si.Ready, }, Name: si.Name, ServicePlanID: si.ServicePlanID, @@ -57,7 +57,6 @@ func (si *ServiceInstance) ToObject() types.Object { Context: getJSONRawMessage(si.Context), PreviousValues: getJSONRawMessage(si.PreviousValues), Usable: si.Usable, - Ready: si.Ready, } } @@ -73,6 +72,7 @@ func (*ServiceInstance) FromObject(object types.Object) (storage.Entity, bool) { CreatedAt: serviceInstance.CreatedAt, UpdatedAt: serviceInstance.UpdatedAt, PagingSequence: serviceInstance.PagingSequence, + Ready: serviceInstance.Ready, }, Name: serviceInstance.Name, ServicePlanID: serviceInstance.ServicePlanID, @@ -82,7 +82,6 @@ func (*ServiceInstance) FromObject(object types.Object) (storage.Entity, bool) { Context: getJSONText(serviceInstance.Context), PreviousValues: getJSONText(serviceInstance.PreviousValues), Usable: serviceInstance.Usable, - Ready: serviceInstance.Ready, } return si, true diff --git a/storage/postgres/service_offering.go b/storage/postgres/service_offering.go index 19a8156c0..5247a825c 100644 --- a/storage/postgres/service_offering.go +++ b/storage/postgres/service_offering.go @@ -57,6 +57,7 @@ func (e *ServiceOffering) ToObject() types.Object { CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, PagingSequence: e.PagingSequence, + Ready: e.Ready, }, Name: e.Name, Description: e.Description, @@ -93,6 +94,7 @@ func (*ServiceOffering) FromObject(object types.Object) (storage.Entity, bool) { CreatedAt: offering.CreatedAt, UpdatedAt: offering.UpdatedAt, PagingSequence: offering.PagingSequence, + Ready: offering.Ready, }, Name: offering.Name, Description: offering.Description, diff --git a/storage/postgres/service_plan.go b/storage/postgres/service_plan.go index 4fb88b9d5..40061609a 100644 --- a/storage/postgres/service_plan.go +++ b/storage/postgres/service_plan.go @@ -17,6 +17,8 @@ package postgres import ( + "database/sql" + "github.com/Peripli/service-manager/pkg/types" "github.com/Peripli/service-manager/storage" sqlxtypes "github.com/jmoiron/sqlx/types" @@ -28,11 +30,11 @@ type ServicePlan struct { Name string `db:"name"` Description string `db:"description"` - Free bool `db:"free"` - Bindable bool `db:"bindable"` - PlanUpdatable bool `db:"plan_updateable"` - CatalogID string `db:"catalog_id"` - CatalogName string `db:"catalog_name"` + Free bool `db:"free"` + Bindable sql.NullBool `db:"bindable"` + PlanUpdatable sql.NullBool `db:"plan_updateable"` + CatalogID string `db:"catalog_id"` + CatalogName string `db:"catalog_name"` Metadata sqlxtypes.JSONText `db:"metadata"` Schemas sqlxtypes.JSONText `db:"schemas"` @@ -49,14 +51,15 @@ func (sp *ServicePlan) ToObject() types.Object { CreatedAt: sp.CreatedAt, UpdatedAt: sp.UpdatedAt, PagingSequence: sp.PagingSequence, + Ready: sp.Ready, }, Name: sp.Name, Description: sp.Description, CatalogID: sp.CatalogID, CatalogName: sp.CatalogName, Free: sp.Free, - Bindable: sp.Bindable, - PlanUpdatable: sp.PlanUpdatable, + Bindable: toBoolPointer(sp.Bindable), + PlanUpdatable: toBoolPointer(sp.PlanUpdatable), Metadata: getJSONRawMessage(sp.Metadata), Schemas: getJSONRawMessage(sp.Schemas), MaximumPollingDuration: sp.MaximumPollingDuration, @@ -76,12 +79,13 @@ func (sp *ServicePlan) FromObject(object types.Object) (storage.Entity, bool) { CreatedAt: plan.CreatedAt, UpdatedAt: plan.UpdatedAt, PagingSequence: plan.PagingSequence, + Ready: plan.Ready, }, Name: plan.Name, Description: plan.Description, Free: plan.Free, - Bindable: plan.Bindable, - PlanUpdatable: plan.PlanUpdatable, + Bindable: toNullBool(plan.Bindable), + PlanUpdatable: toNullBool(plan.PlanUpdatable), CatalogID: plan.CatalogID, CatalogName: plan.CatalogName, Metadata: getJSONText(plan.Metadata), diff --git a/storage/postgres/servicebinding_gen.go b/storage/postgres/servicebinding_gen.go index b99c8f96d..cc9c5a90e 100644 --- a/storage/postgres/servicebinding_gen.go +++ b/storage/postgres/servicebinding_gen.go @@ -3,13 +3,13 @@ package postgres import ( - "database/sql" - "time" - "github.com/Peripli/service-manager/pkg/types" "github.com/Peripli/service-manager/storage" "github.com/jmoiron/sqlx" "github.com/lib/pq" + + "database/sql" + "time" ) var _ PostgresEntity = &ServiceBinding{} diff --git a/storage/postgres/storage.go b/storage/postgres/storage.go index 0a426a5d8..8f86668ef 100644 --- a/storage/postgres/storage.go +++ b/storage/postgres/storage.go @@ -299,6 +299,7 @@ func (ps *Storage) Delete(ctx context.Context, objType types.ObjectType, criteri } func (ps *Storage) Update(ctx context.Context, obj types.Object, labelChanges query.LabelChanges, _ ...query.Criterion) (types.Object, error) { + obj.SetUpdatedAt(time.Now().UTC()) entity, err := ps.scheme.convert(obj) if err != nil { return nil, err diff --git a/storage/postgres/visibility.go b/storage/postgres/visibility.go index 1b5e7fca4..fe7ec3475 100644 --- a/storage/postgres/visibility.go +++ b/storage/postgres/visibility.go @@ -39,6 +39,7 @@ func (v *Visibility) ToObject() types.Object { UpdatedAt: v.UpdatedAt, Labels: make(map[string][]string), PagingSequence: v.PagingSequence, + Ready: v.Ready, }, PlatformID: v.PlatformID.String, ServicePlanID: v.ServicePlanID, @@ -56,6 +57,7 @@ func (v *Visibility) FromObject(visibility types.Object) (storage.Entity, bool) CreatedAt: vis.CreatedAt, UpdatedAt: vis.UpdatedAt, PagingSequence: vis.PagingSequence, + Ready: vis.Ready, }, PlatformID: toNullString(vis.PlatformID), ServicePlanID: vis.ServicePlanID, diff --git a/test/broker_test/broker_test.go b/test/broker_test/broker_test.go index f5f2847c1..66d08534e 100644 --- a/test/broker_test/broker_test.go +++ b/test/broker_test/broker_test.go @@ -24,8 +24,6 @@ import ( "testing" "time" - "github.com/Peripli/service-manager/test/testutil/service_instance" - "github.com/Peripli/service-manager/pkg/httpclient" "github.com/Peripli/service-manager/pkg/web" @@ -144,7 +142,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ }, "labels": labels, } - common.RemoveAllBrokers(ctx.SMWithOAuth) + common.RemoveAllBrokers(ctx.SMRepository) repository = ctx.SMRepository }) @@ -1117,47 +1115,33 @@ var _ = test.DescribeTestsFor(test.TestCase{ Context("when an existing service offering is removed", func() { var serviceOfferingID string + var planIDsForService []string BeforeEach(func() { + planIDsForService = make([]string, 0) + catalogServiceID := gjson.Get(string(brokerServer.Catalog), "services.0.id").Str Expect(catalogServiceID).ToNot(BeEmpty()) - serviceOfferings := ctx.SMWithOAuth.List(web.ServiceOfferingsURL).Iter() - - for _, so := range serviceOfferings { - sbID := so.Object().Value("broker_id").String().Raw() - Expect(catalogServiceID).ToNot(BeEmpty()) + serviceOffering := ctx.SMWithOAuth.ListWithQuery(web.ServiceOfferingsURL, + fmt.Sprintf("fieldQuery=broker_id eq '%s' and catalog_id eq '%s'", brokerID, catalogServiceID)) + Expect(serviceOffering.Length().Equal(1)) + serviceOfferingID = serviceOffering.First().Object().Value("id").String().Raw() + plans := ctx.SMWithOAuth.ListWithQuery(web.ServicePlansURL, + fmt.Sprintf("fieldQuery=service_offering_id eq '%s'", serviceOfferingID)).Iter() - catalogID := so.Object().Value("catalog_id").String().Raw() - Expect(catalogServiceID).ToNot(BeEmpty()) - - if catalogID == catalogServiceID && sbID == brokerID { - serviceOfferingID = so.Object().Value("id").String().Raw() - Expect(catalogServiceID).ToNot(BeEmpty()) - break - } + for _, plan := range plans { + planID := plan.Object().Value("id").String().Raw() + planIDsForService = append(planIDsForService, planID) } + s, err := sjson.Delete(string(brokerServer.Catalog), "services.0") Expect(err).ShouldNot(HaveOccurred()) brokerServer.Catalog = common.SBCatalog(s) }) Context("with no existing service instances", func() { - It("is no longer returned by the Services and Plans API", func() { - plans := ctx.SMWithOAuth.List(web.ServicePlansURL).Iter() - - var planIDsForService []interface{} - for _, plan := range plans { - soID := plan.Object().Value("service_offering_id").String().Raw() - Expect(soID).ToNot(BeEmpty()) - if soID == serviceOfferingID { - planID := plan.Object().Value("id").String().Raw() - Expect(soID).ToNot(BeEmpty()) - - planIDsForService = append(planIDsForService, planID) - } - } ctx.SMWithOAuth.PATCH(web.ServiceBrokersURL + "/" + brokerID). WithJSON(common.Object{}). Expect(). @@ -1175,36 +1159,24 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) Context("with existing service instances", func() { - var serviceInstanceIDs []string - - AfterEach(func() { - byIDs := query.ByField(query.InOperator, "id", serviceInstanceIDs...) - err := ctx.SMRepository.Delete(context.Background(), types.ServiceInstanceType, byIDs) - Expect(err).To(Not(HaveOccurred())) - }) - - It("should return 400 with user-friendly message", func() { - plans := ctx.SMWithOAuth.List(web.ServicePlansURL).Iter() - - var planIDsForService []string - for _, plan := range plans { - soID := plan.Object().Value("service_offering_id").String().Raw() - Expect(soID).ToNot(BeEmpty()) - if soID == serviceOfferingID { - planID := plan.Object().Value("id").String().Raw() - Expect(soID).ToNot(BeEmpty()) - - planIDsForService = append(planIDsForService, planID) - } - } + var serviceInstances []*types.ServiceInstance + BeforeEach(func() { + serviceInstances = make([]*types.ServiceInstance, 0) for _, planID := range planIDsForService { - _, serviceInstance := service_instance.Prepare(ctx, ctx.TestPlatform.ID, planID, "{}") - ctx.SMRepository.Create(context.Background(), serviceInstance) + serviceInstance := common.CreateInstanceInPlatformForPlan(ctx, ctx.TestPlatform.ID, planID) + serviceInstances = append(serviceInstances, serviceInstance) + } + }) - serviceInstanceIDs = append(serviceInstanceIDs, serviceInstance.ID) + AfterEach(func() { + for _, serviceInstance := range serviceInstances { + err := common.DeleteInstance(ctx, serviceInstance.ID, serviceInstance.ServicePlanID) + Expect(err).ToNot(HaveOccurred()) } + }) + It("should return 400 with user-friendly message", func() { ctx.SMWithOAuth.PATCH(web.ServiceBrokersURL + "/" + brokerID). WithJSON(common.Object{}). Expect(). @@ -1378,14 +1350,13 @@ var _ = test.DescribeTestsFor(test.TestCase{ removedPlanID := ctx.SMWithOAuth.ListWithQuery(web.ServicePlansURL, fmt.Sprintf("fieldQuery=catalog_id eq '%s'", removedPlanCatalogID)). First().Object().Value("id").String().Raw() - _, serviceInstance = service_instance.Prepare(ctx, ctx.TestPlatform.ID, removedPlanID, "{}") - ctx.SMRepository.Create(context.Background(), serviceInstance) + serviceInstance = common.CreateInstanceInPlatformForPlan(ctx, ctx.TestPlatform.ID, removedPlanID) + }) AfterEach(func() { - byID := query.ByField(query.EqualsOperator, "id", serviceInstance.ID) - err := ctx.SMRepository.Delete(context.Background(), types.ServiceInstanceType, byID) - Expect(err).To(Not(HaveOccurred())) + err := common.DeleteInstance(ctx, serviceInstance.ID, serviceInstance.ServicePlanID) + Expect(err).ToNot(HaveOccurred()) }) It("should return 400 with user-friendly message", func() { @@ -1692,21 +1663,19 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) Describe("DELETE", func() { - - AfterEach(func() { - ctx.CleanupAdditionalResources() - }) - Context("with existing service instances to some broker plan", func() { var ( - brokerID string + brokerID string + serviceInstance *types.ServiceInstance ) BeforeEach(func() { - var serviceInstance *types.ServiceInstance + brokerID, serviceInstance = common.CreateInstanceInPlatform(ctx, ctx.TestPlatform.ID) + }) - brokerID, serviceInstance = service_instance.Prepare(ctx, ctx.TestPlatform.ID, "", "{}") - ctx.SMRepository.Create(context.Background(), serviceInstance) + AfterEach(func() { + err := common.DeleteInstance(ctx, serviceInstance.ID, serviceInstance.ServicePlanID) + Expect(err).ToNot(HaveOccurred()) }) It("should return 400 with user-friendly message", func() { diff --git a/test/common/application.yml b/test/common/application.yml index 4845ccab9..27f3780cd 100644 --- a/test/common/application.yml +++ b/test/common/application.yml @@ -3,15 +3,15 @@ server: shutdown_timeout: 4000ms port: 1234 httpclient: - response_header_timeout: 10000ms - tls_handshake_timeout: 10000ms - idle_conn_timeout: 10000ms - dial_timeout: 10000ms + response_header_timeout: 40000ms + tls_handshake_timeout: 4000ms + idle_conn_timeout: 4000ms + dial_timeout: 4000ms websocket: - ping_timeout: 6000ms - write_timeout: 6000ms + ping_timeout: 4000ms + write_timeout: 4000ms log: - level: debug + level: info format: text storage: uri: postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable @@ -22,3 +22,6 @@ api: skip_ssl_validation: false multitenancy: label_key: tenant +operations: + polling_interval: 1ms + rescheduling_interval: 1ms \ No newline at end of file diff --git a/test/common/broker.go b/test/common/broker.go index de99d8977..b911fa052 100644 --- a/test/common/broker.go +++ b/test/common/broker.go @@ -24,6 +24,8 @@ import ( "io/ioutil" "net/http" "net/http/httptest" + "sync" + "time" "github.com/Peripli/service-manager/pkg/util" "github.com/gorilla/mux" @@ -32,12 +34,15 @@ import ( type BrokerServer struct { *httptest.Server - CatalogHandler http.HandlerFunc // /v2/catalog - ServiceInstanceHandler http.HandlerFunc // /v2/service_instances/{instance_id} - ServiceInstanceLastOpHandler http.HandlerFunc // /v2/service_instances/{instance_id}/last_operation + CatalogHandler http.HandlerFunc // /v2/catalog + ServiceInstanceHandler http.HandlerFunc // Provision/v2/service_instances/{instance_id} + ServiceInstanceLastOpHandler http.HandlerFunc // /v2/service_instances/{instance_id}/last_operation + ServiceInstanceOperations []string + BindingHandler http.HandlerFunc // /v2/service_instances/{instance_id}/service_bindings/{binding_id} - BindingLastOpHandler http.HandlerFunc // /v2/service_instances/{instance_id}/service_bindings/{binding_id}/last_operation BindingAdaptCredentialsHandler http.HandlerFunc // /v2/service_instances/{instance_id}/service_bindings/{binding_id}/adapt_credentials + BindingLastOpHandler http.HandlerFunc // /v2/service_instances/{instance_id}/service_bindings/{binding_id}/last_operation + ServiceBindingOperations []string Username, Password string Catalog SBCatalog @@ -52,6 +57,10 @@ type BrokerServer struct { BindingAdaptCredentialsEndpointRequests []*http.Request router *mux.Router + + mutex *sync.RWMutex + + shouldRecordRequests bool } func (b *BrokerServer) URL() string { @@ -71,6 +80,8 @@ func NewBrokerServer() *BrokerServer { } func NewBrokerServerWithCatalog(catalog SBCatalog) *BrokerServer { brokerServer := &BrokerServer{} + brokerServer.mutex = &sync.RWMutex{} + brokerServer.shouldRecordRequests = true brokerServer.initRouter() brokerServer.Reset() brokerServer.Catalog = catalog @@ -78,6 +89,13 @@ func NewBrokerServerWithCatalog(catalog SBCatalog) *BrokerServer { return brokerServer } +func (b *BrokerServer) ShouldRecordRequests(shouldRecordRequests bool) { + b.mutex.Lock() + defer b.mutex.Unlock() + + b.shouldRecordRequests = shouldRecordRequests +} + func (b *BrokerServer) Reset() { b.ResetProperties() b.ResetHandlers() @@ -85,8 +103,10 @@ func (b *BrokerServer) Reset() { } func (b *BrokerServer) ResetProperties() { - b.Username = "buser" - b.Password = "bpassword" + b.mutex.Lock() + defer b.mutex.Unlock() + b.Username = "admin" + b.Password = "admin" c := NewRandomSBCatalog() b.Catalog = c b.LastRequestBody = []byte{} @@ -94,6 +114,8 @@ func (b *BrokerServer) ResetProperties() { } func (b *BrokerServer) ResetHandlers() { + b.mutex.Lock() + defer b.mutex.Unlock() b.CatalogHandler = b.defaultCatalogHandler b.ServiceInstanceHandler = b.defaultServiceInstanceHandler b.ServiceInstanceLastOpHandler = b.defaultServiceInstanceLastOpHandler @@ -103,6 +125,8 @@ func (b *BrokerServer) ResetHandlers() { } func (b *BrokerServer) ResetCallHistory() { + b.mutex.Lock() + defer b.mutex.Unlock() b.CatalogEndpointRequests = make([]*http.Request, 0) b.ServiceInstanceEndpointRequests = make([]*http.Request, 0) b.ServiceInstanceLastOpEndpointRequests = make([]*http.Request, 0) @@ -113,41 +137,150 @@ func (b *BrokerServer) ResetCallHistory() { func (b *BrokerServer) initRouter() { router := mux.NewRouter() router.HandleFunc("/v2/catalog", func(rw http.ResponseWriter, req *http.Request) { - b.CatalogEndpointRequests = append(b.CatalogEndpointRequests, req) + b.mutex.RLock() b.CatalogHandler(rw, req) + b.mutex.RUnlock() + + if b.shouldRecordRequests { + b.mutex.Lock() + b.CatalogEndpointRequests = append(b.CatalogEndpointRequests, req) + b.mutex.Unlock() + } }).Methods(http.MethodGet) router.HandleFunc("/v2/service_instances/{instance_id}", func(rw http.ResponseWriter, req *http.Request) { - b.ServiceInstanceEndpointRequests = append(b.ServiceInstanceEndpointRequests, req) + b.mutex.RLock() b.ServiceInstanceHandler(rw, req) + b.mutex.RUnlock() + + if b.shouldRecordRequests { + b.mutex.Lock() + b.ServiceInstanceEndpointRequests = append(b.ServiceInstanceEndpointRequests, req) + b.mutex.Unlock() + } }).Methods(http.MethodPut, http.MethodDelete, http.MethodGet, http.MethodPatch) router.HandleFunc("/v2/service_instances/{instance_id}/service_bindings/{binding_id}", func(rw http.ResponseWriter, req *http.Request) { - b.BindingEndpointRequests = append(b.BindingEndpointRequests, req) + b.mutex.RLock() b.BindingHandler(rw, req) + b.mutex.RUnlock() + + if b.shouldRecordRequests { + b.mutex.Lock() + b.BindingEndpointRequests = append(b.BindingEndpointRequests, req) + b.mutex.Unlock() + } }).Methods(http.MethodPut, http.MethodDelete, http.MethodGet) router.HandleFunc("/v2/service_instances/{instance_id}/last_operation", func(rw http.ResponseWriter, req *http.Request) { - b.ServiceInstanceLastOpEndpointRequests = append(b.ServiceInstanceLastOpEndpointRequests, req) + b.mutex.RLock() b.ServiceInstanceLastOpHandler(rw, req) + b.mutex.RUnlock() + + if b.shouldRecordRequests { + b.mutex.Lock() + b.ServiceInstanceLastOpEndpointRequests = append(b.ServiceInstanceLastOpEndpointRequests, req) + b.mutex.Unlock() + } }).Methods(http.MethodGet) router.HandleFunc("/v2/service_instances/{instance_id}/service_bindings/{binding_id}/last_operation", func(rw http.ResponseWriter, req *http.Request) { - b.BindingLastOpEndpointRequests = append(b.BindingLastOpEndpointRequests, req) + b.mutex.RLock() b.BindingLastOpHandler(rw, req) + b.mutex.RUnlock() + + if b.shouldRecordRequests { + b.mutex.Lock() + b.BindingLastOpEndpointRequests = append(b.BindingLastOpEndpointRequests, req) + b.mutex.Unlock() + } }).Methods(http.MethodGet) router.HandleFunc("/v2/service_instances/{instance_id}/service_bindings/{binding_id}/adapt_credentials", func(rw http.ResponseWriter, req *http.Request) { - b.BindingAdaptCredentialsEndpointRequests = append(b.BindingAdaptCredentialsEndpointRequests, req) + b.mutex.RLock() b.BindingAdaptCredentialsHandler(rw, req) + b.mutex.RUnlock() + + if b.shouldRecordRequests { + b.mutex.Lock() + b.BindingAdaptCredentialsEndpointRequests = append(b.BindingAdaptCredentialsEndpointRequests, req) + b.mutex.Unlock() + } }).Methods(http.MethodPost) router.Use(b.authenticationMiddleware) router.Use(b.saveRequestMiddleware) - b.router = router } +func (b *BrokerServer) ServiceInstanceHandlerFunc(method, op string, handler func(req *http.Request) (int, map[string]interface{})) { + b.mutex.Lock() + defer b.mutex.Unlock() + currentHandler := b.ServiceInstanceHandler + b.ServiceInstanceHandler = func(rw http.ResponseWriter, req *http.Request) { + if req.Method == method { + status, body := handler(req) + body["operation"] = op + SetResponse(rw, status, body) + return + } + currentHandler(rw, req) + } +} + +func (b *BrokerServer) ServiceInstanceLastOpHandlerFunc(op string, handler func(req *http.Request) (int, map[string]interface{})) { + b.mutex.Lock() + defer b.mutex.Unlock() + currentHandler := b.ServiceInstanceLastOpHandler + b.ServiceInstanceLastOpHandler = func(rw http.ResponseWriter, req *http.Request) { + err := req.ParseForm() + if err != nil { + panic(err) + } + opFromBody := req.Form["operation"][0] + if opFromBody == op { + status, body := handler(req) + SetResponse(rw, status, body) + return + } + currentHandler(rw, req) + } +} + +func (b *BrokerServer) BindingHandlerFunc(method, op string, handler func(req *http.Request) (int, map[string]interface{})) { + b.mutex.Lock() + defer b.mutex.Unlock() + currentHandler := b.BindingHandler + b.BindingHandler = func(rw http.ResponseWriter, req *http.Request) { + if req.Method == method { + status, body := handler(req) + body["operation"] = op + SetResponse(rw, status, body) + return + } + currentHandler(rw, req) + } +} + +func (b *BrokerServer) BindingLastOpHandlerFunc(op string, handler func(req *http.Request) (int, map[string]interface{})) { + b.mutex.Lock() + defer b.mutex.Unlock() + currentHandler := b.BindingLastOpHandler + b.BindingLastOpHandler = func(rw http.ResponseWriter, req *http.Request) { + err := req.ParseForm() + if err != nil { + panic(err) + } + opFromBody := req.Form["operation"][0] + if opFromBody == op { + status, body := handler(req) + SetResponse(rw, status, body) + return + } + currentHandler(rw, req) + } +} + func (b *BrokerServer) authenticationMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { auth := req.Header.Get("Authorization") @@ -174,13 +307,20 @@ func (b *BrokerServer) authenticationMiddleware(next http.Handler) http.Handler func (b *BrokerServer) saveRequestMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if !b.shouldRecordRequests { + next.ServeHTTP(w, req) + return + } + defer func() { err := req.Body.Close() if err != nil { panic(err) } }() + b.mutex.Lock() b.LastRequest = req + b.mutex.Unlock() bodyBytes, err := ioutil.ReadAll(req.Body) req.Body = ioutil.NopCloser(bytes.NewBuffer(bodyBytes)) @@ -191,7 +331,9 @@ func (b *BrokerServer) saveRequestMiddleware(next http.Handler) http.Handler { }) return } + b.mutex.Lock() b.LastRequestBody = bodyBytes + b.mutex.Unlock() next.ServeHTTP(w, req) }) } @@ -218,8 +360,15 @@ func (b *BrokerServer) defaultBindingHandler(rw http.ResponseWriter, req *http.R if req.Method == http.MethodPut { SetResponse(rw, http.StatusCreated, Object{ "credentials": Object{ - "instance_id": mux.Vars(req)["instance_id"], - "binding_id": mux.Vars(req)["binding_id"], + "user": "user", + "password": "password", + }, + }) + } else if req.Method == http.MethodGet { + SetResponse(rw, http.StatusOK, Object{ + "credentials": Object{ + "user": "user", + "password": "password", }, }) } else { @@ -236,16 +385,58 @@ func (b *BrokerServer) defaultBindingLastOpHandler(rw http.ResponseWriter, req * func (b *BrokerServer) defaultBindingAdaptCredentialsHandler(rw http.ResponseWriter, req *http.Request) { SetResponse(rw, http.StatusOK, Object{ "credentials": Object{ - "instance_id": mux.Vars(req)["instance_id"], - "binding_id": mux.Vars(req)["binding_id"], + "user": "user", + "password": "password", }, }) } -func SetResponse(rw http.ResponseWriter, status int, message interface{}) { +func SetResponse(rw http.ResponseWriter, status int, message map[string]interface{}) { err := util.WriteJSON(rw, status, message) if err != nil { rw.WriteHeader(http.StatusInternalServerError) rw.Write([]byte(err.Error())) } } + +func DelayingHandler(done chan interface{}) func(req *http.Request) (int, map[string]interface{}) { + return func(req *http.Request) (int, map[string]interface{}) { + timeout := time.After(10 * time.Second) + select { + case <-done: + case <-timeout: + } + + return http.StatusTeapot, Object{} + } +} + +func ParameterizedHandler(statusCode int, responseBody map[string]interface{}) func(_ *http.Request) (int, map[string]interface{}) { + return func(_ *http.Request) (int, map[string]interface{}) { + return statusCode, responseBody + } +} + +func MultiplePollsRequiredHandler(state, finalState string) func(_ *http.Request) (int, map[string]interface{}) { + polls := 0 + return func(_ *http.Request) (int, map[string]interface{}) { + if polls == 0 { + polls++ + return http.StatusOK, Object{"state": state} + } else { + return http.StatusOK, Object{"state": finalState} + } + } +} + +func MultipleErrorsBeforeSuccessHandler(initialStatusCode, finalStatusCode int, initialBody, finalBody Object) func(_ *http.Request) (int, map[string]interface{}) { + repeats := 0 + return func(_ *http.Request) (int, map[string]interface{}) { + if repeats == 0 { + repeats++ + return initialStatusCode, initialBody + } else { + return finalStatusCode, finalBody + } + } +} diff --git a/test/common/catalog.go b/test/common/catalog.go index adeff0ebc..98002fdfc 100644 --- a/test/common/catalog.go +++ b/test/common/catalog.go @@ -36,7 +36,8 @@ var testFreePlan = ` "name": "another-free-plan-name-%[1]s", "id": "%[1]s", "description": "test-description", - "free": true, + "free": true, + "bindable": true, "metadata": { "max_storage_tb": 5, "costs":[ @@ -66,6 +67,7 @@ var testPaidPlan = ` "id": "%[1]s", "description": "test-description", "free": false, + "bindable": true, "metadata": { "max_storage_tb": 5, "costs":[ @@ -164,7 +166,14 @@ func NewRandomSBCatalog() SBCatalog { plan1 := GeneratePaidTestPlan() plan2 := GenerateFreeTestPlan() plan3 := GenerateFreeTestPlan() - service1 := GenerateTestServiceWithPlans(plan1, plan2, plan3) + plan4 := GenerateFreeTestPlan() + var err error + plan4, err = sjson.Set(plan4, "bindable", false) + if err != nil { + panic(err) + } + + service1 := GenerateTestServiceWithPlans(plan1, plan2, plan3, plan4) catalog := NewEmptySBCatalog() catalog.AddService(service1) diff --git a/test/common/common.go b/test/common/common.go index f3fd8b6ef..325784c5a 100644 --- a/test/common/common.go +++ b/test/common/common.go @@ -20,6 +20,10 @@ import ( "context" "time" + "github.com/Peripli/service-manager/pkg/util" + + "github.com/Peripli/service-manager/pkg/query" + "github.com/Peripli/service-manager/storage" "github.com/Peripli/service-manager/pkg/web" @@ -212,41 +216,89 @@ func MapContains(actual Object, expected Object) { } } -func RemoveAllOperations(repository storage.Repository) error { - return repository.Delete(context.TODO(), types.OperationType) +func RemoveAllOperations(repository storage.TransactionalRepository) { + removeAll(repository, types.OperationType) } -func RemoveAllNotifications(repository storage.Repository) error { - return repository.Delete(context.TODO(), types.NotificationType) +func RemoveAllNotifications(repository storage.TransactionalRepository) { + removeAll(repository, types.NotificationType) } -func RemoveAllInstances(repository storage.Repository) error { - return repository.Delete(context.TODO(), types.ServiceInstanceType) +func RemoveAllInstances(testCtx *TestContext) error { + if err := testCtx.SMRepository.InTransaction(context.TODO(), func(ctx context.Context, storage storage.Repository) error { + objectList, err := storage.List(context.TODO(), types.ServiceInstanceType) + if err != nil { + return err + } + for i := 0; i < objectList.Len(); i++ { + instance := objectList.ItemAt(i).(*types.ServiceInstance) + byID := query.ByField(query.EqualsOperator, "id", instance.ID) + if err := storage.Delete(ctx, types.ServiceInstanceType, byID); err != nil { + return err + } + } + return nil + }); err != nil { + if err != util.ErrNotFoundInStorage { + return err + } + } + return nil } -func RemoveAllBindings(repository storage.Repository) error { - return repository.Delete(context.TODO(), types.ServiceBindingType) +func RemoveAllBindings(testCtx *TestContext) error { + if err := testCtx.SMRepository.InTransaction(context.TODO(), func(ctx context.Context, storage storage.Repository) error { + objectList, err := storage.List(context.TODO(), types.ServiceBindingType) + if err != nil { + return err + } + for i := 0; i < objectList.Len(); i++ { + binding := objectList.ItemAt(i).(*types.ServiceBinding) + byID := query.ByField(query.EqualsOperator, "id", binding.ID) + if err := storage.Delete(ctx, types.ServiceBindingType, byID); err != nil { + return err + } + } + return nil + }); err != nil { + if err != util.ErrNotFoundInStorage { + return err + } + } + + return nil } -func RemoveAllBrokers(SM *SMExpect) { - removeAll(SM, "service_brokers", web.ServiceBrokersURL) +func RemoveAllBrokers(repository storage.TransactionalRepository) { + removeAll(repository, types.ServiceBrokerType) } -func RemoveAllPlatforms(SM *SMExpect) { - removeAll(SM, "platforms", web.PlatformsURL, fmt.Sprintf("fieldQuery=name ne '%s'", types.SMPlatform)) +func RemoveAllPlatforms(repository storage.TransactionalRepository) { + removeAll(repository, types.PlatformType, query.ByField(query.NotEqualsOperator, "id", types.SMPlatform)) } -func RemoveAllVisibilities(SM *SMExpect) { - removeAll(SM, "visibilities", web.VisibilitiesURL) +func RemoveAllVisibilities(repository storage.TransactionalRepository) { + removeAll(repository, types.VisibilityType) } -func removeAll(SM *SMExpect, entity, rootURLPath string, queries ...string) { - By("removing all " + entity) - deleteCall := SM.DELETE(rootURLPath) - for _, query := range queries { - deleteCall.WithQueryString(query) +func removeAll(repository storage.TransactionalRepository, entity types.ObjectType, queries ...query.Criterion) { + By("removing all " + entity.String()) + if err := repository.InTransaction(context.TODO(), func(ctx context.Context, storage storage.Repository) error { + if len(queries) == 0 { + if err := storage.Delete(ctx, entity); err != nil { + return err + } + } else { + if err := storage.Delete(ctx, entity, queries...); err != nil { + return err + } + } + return nil + }); err != nil { + if err != util.ErrNotFoundInStorage { + panic(err) + } } - deleteCall.Expect() } func RegisterBrokerInSM(brokerJSON Object, SM *SMExpect, headers map[string]string) Object { @@ -296,6 +348,7 @@ func RegisterPlatformInSM(platformJSON Object, SM *SMExpect, headers map[string] ID: reply["id"].(string), CreatedAt: createdAt, UpdatedAt: updatedAt, + Ready: true, }, Credentials: &types.Credentials{ Basic: &types.Basic{ @@ -376,7 +429,8 @@ func GenerateRandomNotification() *types.Notification { return &types.Notification{ Base: types.Base{ - ID: uid.String(), + ID: uid.String(), + Ready: true, }, PlatformID: "", Resource: "notification", diff --git a/test/common/operation.go b/test/common/operation.go new file mode 100644 index 000000000..fff80cb7a --- /dev/null +++ b/test/common/operation.go @@ -0,0 +1,161 @@ +/* + * Copyright 2018 The Service Manager Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package common + +import ( + "context" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/Peripli/service-manager/pkg/query" + "github.com/Peripli/service-manager/pkg/types" + "github.com/Peripli/service-manager/pkg/util" + . "github.com/onsi/ginkgo" +) + +type OperationExpectations struct { + Category types.OperationCategory + State types.OperationState + ResourceType types.ObjectType + Reschedulable bool + DeletionScheduled bool + Error string +} + +type ResourceExpectations struct { + ID string + Type types.ObjectType + Ready bool +} + +func VerifyResourceExists(smClient *SMExpect, expectations ResourceExpectations) { + timeoutDuration := 15 * time.Second + timeout := time.After(timeoutDuration) + ticker := time.Tick(100 * time.Millisecond) + for { + select { + case <-timeout: + Fail(fmt.Sprintf("resource with type %s and id %s did not appear in SM after %.0f seconds", expectations.Type, expectations.ID, timeoutDuration.Seconds())) + case <-ticker: + resources := smClient.ListWithQuery(expectations.Type.String(), fmt.Sprintf("fieldQuery=id eq '%s'", expectations.ID)) + switch { + case resources.Length().Raw() == 0: + By(fmt.Sprintf("Could not find resource with type %s and id %s in SM. Retrying...", expectations.Type, expectations.ID)) + case resources.Length().Raw() > 1: + Fail(fmt.Sprintf("more than one resource with type %s and id %s was found in SM", expectations.Type, expectations.ID)) + default: + resourceObject := resources.First().Object() + readyField := resourceObject.Value("ready").Boolean().Raw() + if readyField != expectations.Ready { + Fail(fmt.Sprintf("Expected resource with type %s and id %s to be ready %t but ready was %t", expectations.Type, expectations.ID, expectations.Ready, readyField)) + } + return + } + } + } +} +func VerifyResourceDoesNotExist(smClient *SMExpect, expectations ResourceExpectations) { + timeoutDuration := 15 * time.Second + timeout := time.After(timeoutDuration) + ticker := time.Tick(100 * time.Millisecond) + for { + select { + case <-timeout: + Fail(fmt.Sprintf("resource with type %s and id %s was still in SM after %.0f seconds", expectations.Type, expectations.ID, timeoutDuration.Seconds())) + case <-ticker: + resp := smClient.GET(expectations.Type.String() + "/" + expectations.ID). + Expect().Raw() + if resp.StatusCode != http.StatusNotFound { + By(fmt.Sprintf("Found resource with type %s and id %s but it should be deleted. Retrying...", expectations.Type, expectations.ID)) + } else { + return + } + } + } +} + +func VerifyOperationExists(ctx *TestContext, operationURL string, expectations OperationExpectations) (string, string) { + timeoutDuration := 15 * time.Second + timeout := time.After(timeoutDuration) + ticker := time.Tick(100 * time.Millisecond) + for { + select { + case <-timeout: + Fail(fmt.Sprintf("operation matching expectations did not appear in SM after %.0f seconds", timeoutDuration.Seconds())) + case <-ticker: + var operation map[string]interface{} + if len(operationURL) != 0 { + operation = ctx.SMWithOAuth.GET(operationURL).Expect().Status(http.StatusOK).JSON().Object().Raw() + + category := operation["type"].(string) + resourceType := operation["resource_type"].(string) + state := operation["state"].(string) + reschedulable := operation["reschedule"].(bool) + deletionScheduledString := operation["deletion_scheduled"].(string) + deletionScheduled, err := time.Parse(time.RFC3339Nano, deletionScheduledString) + if err != nil { + Fail(fmt.Sprintf("Could not parse time %s into format %s: %s", deletionScheduledString, time.RFC3339Nano, err)) + } + + switch { + case resourceType != string(expectations.ResourceType.String()): + By(fmt.Sprintf("Found operation with object type %s but expected %s. Continue waiting...", resourceType, expectations.ResourceType)) + case category != string(expectations.Category): + By(fmt.Sprintf("Found operation with category %s but expected %s. Continue waiting...", category, expectations.Category)) + case state != string(expectations.State): + By(fmt.Sprintf("Found operation with state %s but expected %s. Continue waiting...", state, expectations.State)) + case reschedulable != expectations.Reschedulable: + By(fmt.Sprintf("Found operation with reschdulable %t but expected %t. Continue waiting...", reschedulable, expectations.Reschedulable)) + case deletionScheduled.IsZero() == expectations.DeletionScheduled: + By(fmt.Sprintf("Found operation with deletion schduled %t but expected %t. Continue waiting...", !deletionScheduled.IsZero(), expectations.DeletionScheduled)) + default: + resourceID := operation["resource_id"].(string) + By(fmt.Sprintf("Found matching operation with resource_id %s", resourceID)) + + return resourceID, operation["id"].(string) + } + } else { + By("Operation URL is empty. Searching for operation directly in SMDB...") + byResourceType := query.ByField(query.EqualsOperator, "resource_type", string(expectations.ResourceType)) + byCategory := query.ByField(query.EqualsOperator, "type", string(expectations.Category)) + byState := query.ByField(query.EqualsOperator, "state", string(expectations.State)) + byReschedulable := query.ByField(query.EqualsOperator, "reschedule", strconv.FormatBool(expectations.Reschedulable)) + orderDesc := query.OrderResultBy("paging_sequence", query.DescOrder) + objectList, err := ctx.SMRepository.List(context.TODO(), types.OperationType, + byResourceType, byCategory, byState, byReschedulable, orderDesc) + if err != nil { + if err == util.ErrNotFoundInStorage { + By("operation matching the expectations was not found. Retrying...") + } else { + Fail(fmt.Sprintf("could not fetch operation from storage: %s", err)) + } + } else { + if objectList.Len() != 0 { + op := objectList.ItemAt(0).(*types.Operation) + if op.DeletionScheduled.IsZero() == expectations.DeletionScheduled { + By("operation matching the expectations was not found. Retrying...") + } else { + return op.ResourceID, op.ID + } + } + } + } + } + } +} diff --git a/test/common/service_binding.go b/test/common/service_binding.go new file mode 100644 index 000000000..79a6c2beb --- /dev/null +++ b/test/common/service_binding.go @@ -0,0 +1,93 @@ +package common + +import ( + "context" + "fmt" + "time" + + "github.com/Peripli/service-manager/storage" + + "github.com/Peripli/service-manager/pkg/query" + "github.com/Peripli/service-manager/pkg/types" + "github.com/gofrs/uuid" +) + +func DeleteBinding(ctx *TestContext, bindingID, instanceID string) error { + instanceObject, err := ctx.SMRepository.Get(context.TODO(), types.ServiceInstanceType, query.ByField(query.EqualsOperator, "id", instanceID)) + if err != nil { + return err + } + instance := instanceObject.(*types.ServiceInstance) + + planObject, err := ctx.SMRepository.Get(context.TODO(), types.ServicePlanType, query.ByField(query.EqualsOperator, "id", instance.ServicePlanID)) + if err != nil { + return err + } + plan := planObject.(*types.ServicePlan) + + serviceObject, err := ctx.SMRepository.Get(context.TODO(), types.ServiceOfferingType, query.ByField(query.EqualsOperator, "id", plan.ServiceOfferingID)) + if err != nil { + return err + } + service := serviceObject.(*types.ServiceOffering) + + brokerObject, err := ctx.SMRepository.Get(context.TODO(), types.ServiceBrokerType, query.ByField(query.EqualsOperator, "id", service.BrokerID)) + if err != nil { + return err + } + broker := brokerObject.(*types.ServiceBroker) + + if _, foundServer := ctx.Servers[BrokerServerPrefix+broker.ID]; !foundServer { + brokerServer := NewBrokerServerWithCatalog(SBCatalog(broker.Catalog)) + broker.BrokerURL = brokerServer.URL() + UUID, err := uuid.NewV4() + if err != nil { + return fmt.Errorf("could not generate GUID: %s", err) + } + if _, err := ctx.SMScheduler.ScheduleSyncStorageAction(context.TODO(), &types.Operation{ + Base: types.Base{ + ID: UUID.String(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Ready: true, + }, + Type: types.UPDATE, + State: types.IN_PROGRESS, + ResourceID: broker.ID, + ResourceType: types.ServiceBrokerType, + CorrelationID: "-", + }, func(ctx context.Context, repository storage.Repository) (object types.Object, e error) { + return repository.Update(ctx, broker, query.LabelChanges{}) + }); err != nil { + return err + } + } + + UUID, err := uuid.NewV4() + if err != nil { + return fmt.Errorf("could not generate GUID: %s", err) + } + if _, err := ctx.SMScheduler.ScheduleSyncStorageAction(context.TODO(), &types.Operation{ + Base: types.Base{ + ID: UUID.String(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Ready: true, + }, + Type: types.DELETE, + State: types.IN_PROGRESS, + ResourceID: instance.ID, + ResourceType: types.ServiceBindingType, + CorrelationID: "-", + }, func(ctx context.Context, repository storage.Repository) (types.Object, error) { + byID := query.ByField(query.EqualsOperator, "id", bindingID) + if err := repository.Delete(ctx, types.ServiceBindingType, byID); err != nil { + return nil, err + } + return nil, nil + }); err != nil { + return err + } + + return nil +} diff --git a/test/common/service_instance.go b/test/common/service_instance.go new file mode 100644 index 000000000..3cba05c1c --- /dev/null +++ b/test/common/service_instance.go @@ -0,0 +1,160 @@ +package common + +import ( + "context" + "fmt" + "time" + + "github.com/Peripli/service-manager/storage" + + "github.com/Peripli/service-manager/pkg/query" + "github.com/Peripli/service-manager/pkg/types" + "github.com/gofrs/uuid" + + . "github.com/onsi/ginkgo" +) + +func CreateInstanceInPlatform(ctx *TestContext, platformID string) (string, *types.ServiceInstance) { + brokerID, planID := preparePlan(ctx) + return brokerID, CreateInstanceInPlatformForPlan(ctx, platformID, planID) +} + +func CreateInstanceInPlatformForPlan(ctx *TestContext, platformID, planID string) *types.ServiceInstance { + operationID, err := uuid.NewV4() + if err != nil { + Fail(fmt.Sprintf("failed to generate instance GUID: %s", err)) + } + instanceID, err := uuid.NewV4() + if err != nil { + Fail(fmt.Sprintf("failed to generate instance GUID: %s", err)) + } + operation := &types.Operation{ + Base: types.Base{ + ID: operationID.String(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + Type: types.CREATE, + State: types.IN_PROGRESS, + ResourceID: instanceID.String(), + ResourceType: types.ServiceInstanceType, + } + + instance := &types.ServiceInstance{ + Base: types.Base{ + ID: instanceID.String(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Ready: true, + }, + Name: "test-service-instance", + ServicePlanID: planID, + PlatformID: platformID, + DashboardURL: "http://testurl.com", + } + + if _, err := ctx.SMScheduler.ScheduleSyncStorageAction(context.TODO(), operation, func(ctx context.Context, repository storage.Repository) (types.Object, error) { + return repository.Create(ctx, instance) + }); err != nil { + Fail(fmt.Sprintf("failed to create instance with name %s", instance.Name)) + } + + return instance +} + +func DeleteInstance(ctx *TestContext, instanceID, servicePlanID string) error { + planObject, err := ctx.SMRepository.Get(context.TODO(), types.ServicePlanType, query.ByField(query.EqualsOperator, "id", servicePlanID)) + if err != nil { + return err + } + plan := planObject.(*types.ServicePlan) + + serviceObject, err := ctx.SMRepository.Get(context.TODO(), types.ServiceOfferingType, query.ByField(query.EqualsOperator, "id", plan.ServiceOfferingID)) + if err != nil { + return err + } + service := serviceObject.(*types.ServiceOffering) + + brokerObject, err := ctx.SMRepository.Get(context.TODO(), types.ServiceBrokerType, query.ByField(query.EqualsOperator, "id", service.BrokerID)) + if err != nil { + return err + } + broker := brokerObject.(*types.ServiceBroker) + + if _, foundServer := ctx.Servers[BrokerServerPrefix+broker.ID]; !foundServer { + brokerServer := NewBrokerServerWithCatalog(SBCatalog(broker.Catalog)) + broker.BrokerURL = brokerServer.URL() + UUID, err := uuid.NewV4() + if err != nil { + return fmt.Errorf("could not generate GUID: %s", err) + } + if _, err := ctx.SMScheduler.ScheduleSyncStorageAction(context.TODO(), &types.Operation{ + Base: types.Base{ + ID: UUID.String(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Ready: true, + }, + Type: types.UPDATE, + State: types.IN_PROGRESS, + ResourceID: broker.ID, + ResourceType: types.ServiceBrokerType, + CorrelationID: "-", + }, func(ctx context.Context, repository storage.Repository) (object types.Object, e error) { + return repository.Update(ctx, broker, query.LabelChanges{}) + }); err != nil { + return err + } + + } + + UUID, err := uuid.NewV4() + if err != nil { + return fmt.Errorf("could not generate GUID: %s", err) + } + if _, err := ctx.SMScheduler.ScheduleSyncStorageAction(context.TODO(), &types.Operation{ + Base: types.Base{ + ID: UUID.String(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Ready: true, + }, + Type: types.DELETE, + State: types.IN_PROGRESS, + ResourceID: instanceID, + ResourceType: types.ServiceInstanceType, + CorrelationID: "-", + }, func(ctx context.Context, repository storage.Repository) (types.Object, error) { + byID := query.ByField(query.EqualsOperator, "id", instanceID) + if err := repository.Delete(ctx, types.ServiceInstanceType, byID); err != nil { + return nil, err + } + return nil, nil + }); err != nil { + return err + } + + return nil +} + +func preparePlan(ctx *TestContext) (string, string) { + cService := GenerateTestServiceWithPlans(GenerateFreeTestPlan()) + catalog := NewEmptySBCatalog() + catalog.AddService(cService) + brokerID, _, brokerServer := ctx.RegisterBrokerWithCatalog(catalog) + ctx.Servers[BrokerServerPrefix+brokerID] = brokerServer + + byBrokerID := query.ByField(query.EqualsOperator, "broker_id", brokerID) + obj, err := ctx.SMRepository.Get(context.Background(), types.ServiceOfferingType, byBrokerID) + if err != nil { + Fail(fmt.Sprintf("unable to fetch service offering: %s", err)) + } + + byServiceOfferingID := query.ByField(query.EqualsOperator, "service_offering_id", obj.GetID()) + obj, err = ctx.SMRepository.Get(context.Background(), types.ServicePlanType, byServiceOfferingID) + if err != nil { + Fail(fmt.Sprintf("unable to service plan: %s", err)) + } + + return brokerID, obj.GetID() +} diff --git a/test/common/test_context.go b/test/common/test_context.go index 3de8dd2d2..12010c25e 100644 --- a/test/common/test_context.go +++ b/test/common/test_context.go @@ -32,6 +32,8 @@ import ( "sync" "time" + "github.com/Peripli/service-manager/operations" + "github.com/Peripli/service-manager/api/extensions/security" "github.com/gavv/httpexpect" @@ -44,7 +46,6 @@ import ( "github.com/Peripli/service-manager/pkg/env" "github.com/Peripli/service-manager/pkg/sm" "github.com/Peripli/service-manager/pkg/types" - "github.com/Peripli/service-manager/pkg/util" "github.com/Peripli/service-manager/pkg/web" "github.com/Peripli/service-manager/storage" ) @@ -87,8 +88,8 @@ type TestContext struct { SMWithOAuthForTenant *SMExpect SMWithBasic *SMExpect SMRepository storage.TransactionalRepository - - TestPlatform *types.Platform + SMScheduler *operations.Scheduler + TestPlatform *types.Platform Servers map[string]FakeServer } @@ -304,10 +305,14 @@ func (tcb *TestContextBuilder) WithSMExtensions(fs ...func(ctx context.Context, } func (tcb *TestContextBuilder) Build() *TestContext { - return tcb.BuildWithListener(nil) + return tcb.BuildWithListener(nil, true) +} + +func (tcb *TestContextBuilder) BuildWithoutCleanup() *TestContext { + return tcb.BuildWithListener(nil, false) } -func (tcb *TestContextBuilder) BuildWithListener(listener net.Listener) *TestContext { +func (tcb *TestContextBuilder) BuildWithListener(listener net.Listener, cleanup bool) *TestContext { environment := tcb.Environment(tcb.envPreHooks...) for _, envPostHook := range tcb.envPostHooks { @@ -315,7 +320,7 @@ func (tcb *TestContextBuilder) BuildWithListener(listener net.Listener) *TestCon } wg := &sync.WaitGroup{} - smServer, smRepository := newSMServer(environment, wg, tcb.smExtensions, listener) + smServer, smRepository, smScheduler := newSMServer(environment, wg, tcb.smExtensions, listener) tcb.Servers[SMServer] = smServer SM := httpexpect.New(ginkgo.GinkgoT(), smServer.URL()) @@ -337,14 +342,18 @@ func (tcb *TestContextBuilder) BuildWithListener(listener net.Listener) *TestCon SMWithOAuthForTenant: &SMExpect{SMWithOAuthForTenant}, Servers: tcb.Servers, SMRepository: smRepository, + SMScheduler: smScheduler, } - RemoveAllOperations(testContext.SMRepository) - RemoveAllBindings(testContext.SMRepository) - RemoveAllInstances(testContext.SMRepository) - RemoveAllBrokers(testContext.SMWithOAuth) - RemoveAllPlatforms(testContext.SMWithOAuth) - + if cleanup { + RemoveAllBindings(testContext) + RemoveAllInstances(testContext) + RemoveAllBrokers(testContext.SMRepository) + RemoveAllPlatforms(testContext.SMRepository) + RemoveAllOperations(testContext.SMRepository) + } else { + testContext.SMWithOAuth.DELETE(web.PlatformsURL + "/" + "tcb-platform-test").Expect() + } if !tcb.shouldSkipBasicAuthClient { platformJSON := MakePlatform("tcb-platform-test", "tcb-platform-test", "platform-type", "test-platform") platform := RegisterPlatformInSM(platformJSON, testContext.SMWithOAuth, map[string]string{}) @@ -380,7 +389,7 @@ func NewSMListener() (net.Listener, error) { return nil, fmt.Errorf("unable to create sm listener: %s", err) } -func newSMServer(smEnv env.Environment, wg *sync.WaitGroup, fs []func(ctx context.Context, smb *sm.ServiceManagerBuilder, env env.Environment) error, listener net.Listener) (*testSMServer, storage.TransactionalRepository) { +func newSMServer(smEnv env.Environment, wg *sync.WaitGroup, fs []func(ctx context.Context, smb *sm.ServiceManagerBuilder, env env.Environment) error, listener net.Listener) (*testSMServer, storage.TransactionalRepository, *operations.Scheduler) { ctx, cancel := context.WithCancel(context.Background()) cfg, err := config.New(smEnv) @@ -416,10 +425,11 @@ func newSMServer(smEnv env.Environment, wg *sync.WaitGroup, fs []func(ctx contex } testServer.Start() + scheduler := operations.NewScheduler(ctx, smb.Storage, cfg.Operations, 1000, wg) return &testSMServer{ cancel: cancel, Server: testServer, - }, smb.Storage + }, smb.Storage, scheduler } func (ctx *TestContext) RegisterBrokerWithCatalogAndLabels(catalog SBCatalog, brokerData Object) (string, Object, *BrokerServer) { @@ -538,19 +548,10 @@ func (ctx *TestContext) CleanupAdditionalResources() { return } - if err := RemoveAllNotifications(ctx.SMRepository); err != nil && err != util.ErrNotFoundInStorage { - panic(err) - } - if err := RemoveAllBindings(ctx.SMRepository); err != nil && err != util.ErrNotFoundInStorage { - panic(err) - } - if err := RemoveAllInstances(ctx.SMRepository); err != nil && err != util.ErrNotFoundInStorage { - panic(err) - } - - if err := RemoveAllOperations(ctx.SMRepository); err != nil && err != util.ErrNotFoundInStorage { - panic(err) - } + RemoveAllNotifications(ctx.SMRepository) + RemoveAllBindings(ctx) + RemoveAllInstances(ctx) + RemoveAllOperations(ctx.SMRepository) ctx.SMWithOAuth.DELETE(web.ServiceBrokersURL).Expect() diff --git a/test/configuration_test/configuration_test.go b/test/configuration_test/configuration_test.go index 4eb827a0a..154bd0502 100644 --- a/test/configuration_test/configuration_test.go +++ b/test/configuration_test/configuration_test.go @@ -69,20 +69,30 @@ var _ = Describe("Service Manager Config API", func() { } }, "httpclient": { - "dial_timeout": "10000ms", - "idle_conn_timeout": "10000ms", - "response_header_timeout": "10000ms", + "dial_timeout": "4000ms", + "idle_conn_timeout": "4000ms", + "response_header_timeout": "40000ms", "skip_ssl_validation": false, - "tls_handshake_timeout": "10000ms" + "tls_handshake_timeout": "4000ms" }, "log": { "format": "text", - "level": "debug", + "level": "info", "output": "ginkgowriter" }, "multitenancy": { "label_key": "tenant" }, + "operations": { + "cleanup_interval": "10m0s", + "default_pool_size": 20, + "job_timeout": "5m0s", + "mark_orphans_interval": "5m0s", + "polling_interval": "1ms", + "pools": "", + "rescheduling_interval": "1ms", + "scheduled_deletion_timeout": "12h0m0s" + }, "server": { "host": "", "max_body_bytes": 1048576, @@ -105,8 +115,8 @@ var _ = Describe("Service Manager Config API", func() { "uri": "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable" }, "websocket": { - "ping_timeout": "6000ms", - "write_timeout": "6000ms" + "ping_timeout": "4000ms", + "write_timeout": "4000ms" } }` respBody := ctx.SMWithOAuth.GET(web.ConfigURL). diff --git a/test/delete.go b/test/delete.go index b1c400b64..f8356dc4f 100644 --- a/test/delete.go +++ b/test/delete.go @@ -18,11 +18,12 @@ package test import ( "fmt" - "github.com/Peripli/service-manager/pkg/types" - "github.com/gavv/httpexpect" "net/http" "strconv" + "github.com/Peripli/service-manager/pkg/types" + "github.com/gavv/httpexpect" + . "github.com/onsi/gomega" "github.com/Peripli/service-manager/test/common" @@ -31,8 +32,7 @@ import ( func DescribeDeleteTestsfor(ctx *common.TestContext, t TestCase, responseMode ResponseMode) bool { return Describe(fmt.Sprintf("DELETE %s", t.API), func() { - - const notFoundMsg = "not found" + const notFoundMsg = "could not find" var ( testResource common.Object @@ -64,6 +64,7 @@ func DescribeDeleteTestsfor(ctx *common.TestContext, t TestCase, responseMode Re By(fmt.Sprintf("[SETUP]: Verifying that test resource %v has an non empty id of type string", testResource)) testResourceID = testResource["id"].(string) Expect(testResourceID).ToNot(BeEmpty()) + stripObject(testResource, t.ResourcePropertiesToIgnore...) } verifyResourceDeletionWithErrorMsg := func(auth *common.SMExpect, deletionRequestResponseCode, getAfterDeletionRequestCode int, expectedOpState types.OperationState, expectedErrMsg string) { diff --git a/test/get.go b/test/get.go index 245e9b9f7..3186fa9e2 100644 --- a/test/get.go +++ b/test/get.go @@ -45,6 +45,7 @@ func DescribeGetTestsfor(ctx *common.TestContext, t TestCase, responseMode Respo Context(fmt.Sprintf("Existing resource of type %s", t.API), func() { createTestResourceWithAuth := func(auth *common.SMExpect) (common.Object, string) { testResource = t.ResourceBlueprint(ctx, auth, bool(responseMode)) + stripObject(testResource) By(fmt.Sprintf("[SETUP]: Verifying that test resource %v is not empty", testResource)) Expect(testResource).ToNot(BeEmpty()) @@ -59,6 +60,7 @@ func DescribeGetTestsfor(ctx *common.TestContext, t TestCase, responseMode Respo Context("when the resource is global", func() { BeforeEach(func() { testResource, testResourceID = createTestResourceWithAuth(ctx.SMWithOAuth) + stripObject(testResource, t.ResourcePropertiesToIgnore...) }) Context("when authenticating with global token", func() { @@ -222,12 +224,13 @@ func DescribeGetTestsfor(ctx *common.TestContext, t TestCase, responseMode Respo CreatedAt: time.Now(), UpdatedAt: time.Now(), Labels: labels, + Ready: true, }, Description: "test", Type: types.CREATE, State: types.IN_PROGRESS, ResourceID: resourceID, - ResourceType: t.API, + ResourceType: types.ObjectType(t.API), CorrelationID: id.String(), }) diff --git a/test/interceptors_test/interceptors_test.go b/test/interceptors_test/interceptors_test.go index 5be25f562..4d3a02658 100644 --- a/test/interceptors_test/interceptors_test.go +++ b/test/interceptors_test/interceptors_test.go @@ -230,7 +230,12 @@ var _ = Describe("Interceptors", func() { createModificationInterceptors[types.PlatformType].OnTxCreateStub = func(f storage.InterceptCreateOnTxFunc) storage.InterceptCreateOnTxFunc { return func(ctx context.Context, txStorage storage.Repository, newObject types.Object) (types.Object, error) { By("calling storage update, should call update interceptor") - _, err := txStorage.Update(ctx, platform1, query.LabelChanges{}) + byID := query.ByField(query.EqualsOperator, "id", platform1.ID) + platformFromDB, err := txStorage.Get(ctx, types.PlatformType, byID) + if err != nil { + return nil, err + } + _, err = txStorage.Update(ctx, platformFromDB, query.LabelChanges{}) if err != nil { return nil, err } @@ -260,7 +265,12 @@ var _ = Describe("Interceptors", func() { createModificationInterceptors[types.PlatformType].OnTxCreateStub = func(f storage.InterceptCreateOnTxFunc) storage.InterceptCreateOnTxFunc { return func(ctx context.Context, txStorage storage.Repository, newObject types.Object) (types.Object, error) { By("calling storage update, should call update interceptor") - _, err := txStorage.Update(ctx, platform1, query.LabelChanges{}) + byID := query.ByField(query.EqualsOperator, "id", platform1.ID) + platformFromDB, err := txStorage.Get(ctx, types.PlatformType, byID) + if err != nil { + return nil, err + } + _, err = txStorage.Update(ctx, platformFromDB, query.LabelChanges{}) if err != nil { return nil, err } @@ -381,6 +391,9 @@ var _ = Describe("Interceptors", func() { planID := plans.First().Object().Value("id").String().Raw() clearStacks() visibility := types.Visibility{ + Base: types.Base{ + Ready: true, + }, PlatformID: platform.ID, ServicePlanID: planID, } @@ -428,6 +441,9 @@ var _ = Describe("Interceptors", func() { plans := ctx.SMWithBasic.List(web.ServicePlansURL) planID := plans.First().Object().Value("id").String().Raw() visibility := types.Visibility{ + Base: types.Base{ + Ready: true, + }, PlatformID: platform.ID, ServicePlanID: planID, } diff --git a/test/list.go b/test/list.go index a69c5a91f..6737195f7 100644 --- a/test/list.go +++ b/test/list.go @@ -82,7 +82,7 @@ func DescribeListTestsFor(ctx *common.TestContext, t TestCase, responseMode Resp By(fmt.Sprintf("Attempting to create a random resource of %s with mandatory fields only", t.API)) rWithMandatoryFields = t.ResourceWithoutNullableFieldsBlueprint(ctx, ctx.SMWithOAuth, bool(responseMode)) - for i := 0; i < 10; i++ { + for i := 0; i < 5; i++ { By(fmt.Sprintf("Attempting to create a random resource of %s", t.API)) gen := t.ResourceBlueprint(ctx, ctx.SMWithOAuth, bool(responseMode)) @@ -356,6 +356,7 @@ func DescribeListTestsFor(ctx *common.TestContext, t TestCase, responseMode Resp if listOpEntry.resourcesToExpectAfterOp != nil { By(fmt.Sprintf("[TEST]: Verifying expected %s are returned after list operation", t.API)) for _, entity := range listOpEntry.resourcesToExpectAfterOp { + Expect(entity["ready"].(bool)).To(BeTrue()) stripObject(entity, t.ResourcePropertiesToIgnore...) array.Contains(entity) } diff --git a/test/notification_cleaner_test/notification_cleaner_test.go b/test/notification_cleaner_test/notification_cleaner_test.go index e6753079d..f1d51d2be 100644 --- a/test/notification_cleaner_test/notification_cleaner_test.go +++ b/test/notification_cleaner_test/notification_cleaner_test.go @@ -59,6 +59,7 @@ var _ = Describe("Notification cleaner", func() { ID: idBytes.String(), CreatedAt: time.Now(), UpdatedAt: time.Now(), + Ready: true, }, Resource: "resource", Type: "CREATED", diff --git a/test/notification_test/notification_test.go b/test/notification_test/notification_test.go index 1df68c12f..b3e938090 100644 --- a/test/notification_test/notification_test.go +++ b/test/notification_test/notification_test.go @@ -391,6 +391,10 @@ var _ = Describe("Notifications Suite", func() { } resource := gjson.GetBytes(notification.Payload, "new.resource").Value().(common.Object) + delete(resource, "updated_at") + delete(resource, "ready") + delete(objAfterOp, "updated_at") + delete(objAfterOp, "ready") Expect(resource).To(Equal(objAfterOp)) actualPayload := gjson.GetBytes(notification.Payload, "new.additional").Raw @@ -422,6 +426,8 @@ var _ = Describe("Notifications Suite", func() { } resource := gjson.GetBytes(notification.Payload, "old.resource").Value().(common.Object) + delete(resource, "updated_at") + delete(objAfterOp, "updated_at") Expect(resource).To(Equal(objAfterOp)) actualPayload := gjson.GetBytes(notification.Payload, "old.additional").Raw @@ -459,6 +465,8 @@ var _ = Describe("Notifications Suite", func() { oldResource := gjson.GetBytes(notification.Payload, "old.resource").Value().(common.Object) labels := objBeforeOp["labels"] delete(objBeforeOp, "labels") + delete(objBeforeOp, "updated_at") + delete(oldResource, "updated_at") Expect(oldResource).To(Equal(objBeforeOp)) objBeforeOp["labels"] = labels @@ -471,6 +479,8 @@ var _ = Describe("Notifications Suite", func() { labels = objAfterOp["labels"] delete(objAfterOp, "labels") delete(newResource, "labels") + delete(objAfterOp, "updated_at") + delete(newResource, "updated_at") Expect(newResource).To(Equal(objAfterOp)) objAfterOp["labels"] = labels diff --git a/test/operations_test/operations_test.go b/test/operations_test/operations_test.go index e84e97f47..d7b2a09d0 100644 --- a/test/operations_test/operations_test.go +++ b/test/operations_test/operations_test.go @@ -18,6 +18,12 @@ package operations_test import ( "context" "fmt" + "net/http" + "strings" + "sync" + "testing" + "time" + "github.com/Peripli/service-manager/operations" "github.com/Peripli/service-manager/pkg/env" "github.com/Peripli/service-manager/pkg/query" @@ -30,11 +36,6 @@ import ( "github.com/gavv/httpexpect" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" - "net/http" - "strings" - "sync" - "testing" - "time" ) const ( @@ -48,7 +49,6 @@ func TestOperations(t *testing.T) { } var _ = Describe("Operations", func() { - var ctx *common.TestContext AfterEach(func() { @@ -58,7 +58,7 @@ var _ = Describe("Operations", func() { Context("Scheduler", func() { BeforeEach(func() { postHook := func(e env.Environment, servers map[string]common.FakeServer) { - e.Set("operations.job_timeout", 2*time.Nanosecond) + e.Set("operations.job_timeout", 5*time.Nanosecond) e.Set("operations.mark_orphans_interval", 1*time.Hour) } @@ -68,7 +68,9 @@ var _ = Describe("Operations", func() { When("job timeout runs out", func() { It("marks operation as failed", func() { brokerServer := common.NewBrokerServer() + ctx.Servers[common.BrokerServerPrefix+"123"] = brokerServer postBrokerRequestWithNoLabels := common.Object{ + "id": "123", "name": "test-broker", "broker_url": brokerServer.URL(), "credentials": common.Object{ @@ -83,7 +85,7 @@ var _ = Describe("Operations", func() { WithQuery("async", "true"). Expect(). Status(http.StatusAccepted) - _, err := test.ExpectOperationWithError(ctx.SMWithOAuth, resp, types.FAILED, "job timed out") + _, err := test.ExpectOperationWithError(ctx.SMWithOAuth, resp, types.FAILED, "could not reach service broker") Expect(err).To(BeNil()) }) }) @@ -140,18 +142,22 @@ var _ = Describe("Operations", func() { CreatedAt: time.Now(), UpdatedAt: time.Now(), Labels: make(map[string][]string), + Ready: true, }, - Type: types.CREATE, - State: types.IN_PROGRESS, - ResourceID: "test-resource-id", - ResourceType: web.ServiceBrokersURL, - CorrelationID: "test-correlation-id", + Description: "", + Type: types.CREATE, + State: types.IN_PROGRESS, + ResourceID: "test-resource-id", + ResourceType: web.ServiceBrokersURL, + CorrelationID: "test-correlation-id", + Reschedule: false, + DeletionScheduled: time.Time{}, } ctx = common.NewTestContextBuilder().WithSMExtensions(func(ctx context.Context, smb *sm.ServiceManagerBuilder, e env.Environment) error { testController := panicController{ operation: operation, - scheduler: operations.NewScheduler(ctx, smb.Storage, 10*time.Minute, 10, &sync.WaitGroup{}), + scheduler: operations.NewScheduler(ctx, smb.Storage, operations.DefaultSettings(), 10, &sync.WaitGroup{}), } smb.RegisterControllers(testController) @@ -173,14 +179,14 @@ var _ = Describe("Operations", func() { return respBody.Value("state").String().Raw() }, 2*time.Second).Should(Equal("failed")) - Expect(respBody.Value("errors").Object().Value("message").String().Raw()).To(ContainSubstring("job interrupted")) + Expect(respBody.Value("errors").Object().Value("description").String().Raw()).To(ContainSubstring("job interrupted")) }) }) }) Context("Maintainer", func() { const ( - jobTimeout = 3 * time.Second + jobTimeout = 2 * time.Second cleanupInterval = 5 * time.Second ) @@ -224,9 +230,9 @@ var _ = Describe("Operations", func() { operation := &types.Operation{ Base: types.Base{ ID: defaultOperationID, - CreatedAt: time.Now(), UpdatedAt: time.Now(), Labels: make(map[string][]string), + Ready: true, }, Type: types.CREATE, State: types.IN_PROGRESS, @@ -265,16 +271,9 @@ func (pc panicController) Routes() []web.Route { Path: testControllerURL, }, Handler: func(req *web.Request) (resp *web.Response, err error) { - job := operations.Job{ - ReqCtx: context.Background(), - ObjectType: "test-type", - Operation: pc.operation, - OperationFunc: func(ctx context.Context, repository storage.Repository) (object types.Object, e error) { - panic("test panic") - }, - } - - pc.scheduler.Schedule(job) + pc.scheduler.ScheduleAsyncStorageAction(context.TODO(), pc.operation, func(ctx context.Context, repository storage.Repository) (object types.Object, e error) { + panic("test panic") + }) return }, }, diff --git a/test/osb_test/bind_test.go b/test/osb_test/bind_test.go index eea06ce23..31167d116 100644 --- a/test/osb_test/bind_test.go +++ b/test/osb_test/bind_test.go @@ -17,10 +17,11 @@ package osb_test import ( + "net/http" + "github.com/Peripli/service-manager/pkg/web" "github.com/Peripli/service-manager/test/common" "github.com/gavv/httpexpect" - "net/http" . "github.com/onsi/ginkgo" ) diff --git a/test/osb_test/deprovision_test.go b/test/osb_test/deprovision_test.go index 71bc541aa..69cf0fb7a 100644 --- a/test/osb_test/deprovision_test.go +++ b/test/osb_test/deprovision_test.go @@ -18,9 +18,10 @@ package osb_test import ( "fmt" - "github.com/Peripli/service-manager/test/common" "net/http" + "github.com/Peripli/service-manager/test/common" + "github.com/Peripli/service-manager/pkg/types" "github.com/Peripli/service-manager/pkg/web" "github.com/gavv/httpexpect" @@ -305,7 +306,7 @@ var _ = Describe("Deprovision", func() { ctx.SMWithOAuth.List(web.ServiceInstancesURL).Path("$[*].id").Array().NotContains(SID) - verifyOperationDoesNotExist(SID) + verifyOperationDoesNotExist(SID, "delete") }) }) @@ -326,7 +327,7 @@ var _ = Describe("Deprovision", func() { ctx.SMWithOAuth.List(web.ServiceInstancesURL).Path("$[*].id").Array().NotContains(SID) - verifyOperationDoesNotExist(SID) + verifyOperationDoesNotExist(SID, "delete") }) }) @@ -337,7 +338,7 @@ var _ = Describe("Deprovision", func() { ctx.SMWithOAuth.List(web.ServiceInstancesURL).Path("$[*].id").Array().NotContains(SID) - verifyOperationDoesNotExist(SID) + verifyOperationDoesNotExist(SID, "delete") }) }) @@ -348,7 +349,7 @@ var _ = Describe("Deprovision", func() { ctx.SMWithOAuth.List(web.ServiceInstancesURL).Path("$[*].id").Array().NotContains(SID) - verifyOperationDoesNotExist(SID) + verifyOperationDoesNotExist(SID, "delete") }) }) diff --git a/test/osb_test/osb_suite_test.go b/test/osb_test/osb_suite_test.go index b3d4ddf40..6a9d28ed7 100644 --- a/test/osb_test/osb_suite_test.go +++ b/test/osb_test/osb_suite_test.go @@ -20,15 +20,16 @@ import ( "context" "encoding/json" "fmt" - "github.com/Peripli/service-manager/pkg/env" - "github.com/Peripli/service-manager/pkg/multitenancy" - "github.com/Peripli/service-manager/pkg/sm" - "github.com/tidwall/gjson" "net/http" "strings" "testing" "time" + "github.com/Peripli/service-manager/pkg/env" + "github.com/Peripli/service-manager/pkg/multitenancy" + "github.com/Peripli/service-manager/pkg/sm" + "github.com/tidwall/gjson" + "github.com/Peripli/service-manager/pkg/query" "github.com/Peripli/service-manager/pkg/types" "github.com/Peripli/service-manager/pkg/web" @@ -60,7 +61,7 @@ const ( organizationGUID = "1113aa0-124e-4af2-1526-6bfacf61b111" SID = "12345" timeoutDuration = time.Millisecond * 500 - additionalDelayAfterTimeout = time.Second + additionalDelayAfterTimeout = time.Millisecond * 200 brokerAPIVersionHeaderKey = "X-Broker-API-Version" brokerAPIVersionHeaderValue = "2.13" @@ -155,7 +156,8 @@ var _ = BeforeEach(func() { var _ = JustAfterEach(func() { common.RemoveAllOperations(ctx.SMRepository) - common.RemoveAllInstances(ctx.SMRepository) + common.RemoveAllInstances(ctx) + common.RemoveAllOperations(ctx.SMRepository) }) var _ = AfterSuite(func() { @@ -362,7 +364,7 @@ type operationExpectations struct { Type types.OperationCategory State types.OperationState ResourceID string - ResourceType string + ResourceType types.ObjectType Errors json.RawMessage ExternalID string } @@ -370,10 +372,9 @@ type operationExpectations struct { func verifyOperationExists(operationExpectations operationExpectations) { byResourceID := query.ByField(query.EqualsOperator, "resource_id", operationExpectations.ResourceID) byType := query.ByField(query.EqualsOperator, "type", string(operationExpectations.Type)) - orderByCreation := query.OrderResultBy("created_at", query.AscOrder) - limitToOne := query.LimitResultBy(1) + orderByCreation := query.OrderResultBy("paging_sequence", query.DescOrder) - objectList, err := ctx.SMRepository.List(context.TODO(), types.OperationType, byType, byResourceID, orderByCreation, limitToOne) + objectList, err := ctx.SMRepository.List(context.TODO(), types.OperationType, byType, byResourceID, orderByCreation) Expect(err).ToNot(HaveOccurred()) operation := objectList.ItemAt(0).(*types.Operation) Expect(operation.Type).To(Equal(operationExpectations.Type)) @@ -386,7 +387,7 @@ func verifyOperationExists(operationExpectations operationExpectations) { func verifyOperationDoesNotExist(resourceID string, operationTypes ...string) { byResourceID := query.ByField(query.EqualsOperator, "resource_id", resourceID) - orderByCreation := query.OrderResultBy("created_at", query.AscOrder) + orderByCreation := query.OrderResultBy("paging_sequence", query.DescOrder) criterias := append([]query.Criterion{}, byResourceID, orderByCreation) if len(operationTypes) != 0 { byOperationTypes := query.ByField(query.InOperator, "type", fmt.Sprintf("(%s)", strings.Join(operationTypes, ","))) diff --git a/test/osb_test/poll_binding_last_operation_test.go b/test/osb_test/poll_binding_last_operation_test.go index 16c38fd73..d5001332a 100644 --- a/test/osb_test/poll_binding_last_operation_test.go +++ b/test/osb_test/poll_binding_last_operation_test.go @@ -17,10 +17,11 @@ package osb_test import ( + "net/http" + "github.com/Peripli/service-manager/pkg/web" "github.com/Peripli/service-manager/test/common" "github.com/gavv/httpexpect" - "net/http" . "github.com/onsi/ginkgo" ) diff --git a/test/osb_test/poll_instance_last_operation_test.go b/test/osb_test/poll_instance_last_operation_test.go index 93407cba9..82a03c86a 100644 --- a/test/osb_test/poll_instance_last_operation_test.go +++ b/test/osb_test/poll_instance_last_operation_test.go @@ -19,10 +19,11 @@ package osb_test import ( "context" "fmt" + "net/http" + "github.com/Peripli/service-manager/pkg/web" "github.com/Peripli/service-manager/test/common" "github.com/gavv/httpexpect" - "net/http" "github.com/Peripli/service-manager/pkg/query" "github.com/Peripli/service-manager/pkg/types" diff --git a/test/osb_test/provision_test.go b/test/osb_test/provision_test.go index 9d0169663..00db0dfb0 100644 --- a/test/osb_test/provision_test.go +++ b/test/osb_test/provision_test.go @@ -43,7 +43,7 @@ var _ = Describe("Provision", func() { ctx.SMWithOAuth.GET(web.ServiceInstancesURL + "/" + SID). Expect().Status(expectedGetInstanceStatusCode) - verifyOperationDoesNotExist(SID) + verifyOperationDoesNotExist(SID, "create") }, Entry("when service_id is unknown to SM", provisionRequestBodyMapWith("service_id", "abcd1234"), @@ -300,7 +300,7 @@ var _ = Describe("Provision", func() { ctx.SMWithOAuth.List(web.ServiceInstancesURL).Path("$[*].id").Array().NotContains(SID) - verifyOperationDoesNotExist(SID) + verifyOperationDoesNotExist(SID, "create") }) }) @@ -311,7 +311,7 @@ var _ = Describe("Provision", func() { ctx.SMWithOAuth.List(web.ServiceInstancesURL).Path("$[*].id").Array().NotContains(SID) - verifyOperationDoesNotExist(SID) + verifyOperationDoesNotExist(SID, "create") }) }) @@ -322,7 +322,7 @@ var _ = Describe("Provision", func() { ctx.SMWithOAuth.List(web.ServiceInstancesURL).Path("$[*].id").Array().NotContains(SID) - verifyOperationDoesNotExist(SID) + verifyOperationDoesNotExist(SID, "create") }) }) diff --git a/test/osb_test/update_instance_test.go b/test/osb_test/update_instance_test.go index bd6047ab7..c03adb32b 100644 --- a/test/osb_test/update_instance_test.go +++ b/test/osb_test/update_instance_test.go @@ -18,15 +18,17 @@ package osb_test import ( "fmt" + "github.com/Peripli/service-manager/pkg/query" + "net/http" + "github.com/Peripli/service-manager/pkg/types" "github.com/Peripli/service-manager/pkg/web" "github.com/Peripli/service-manager/test/common" "github.com/gavv/httpexpect" . "github.com/onsi/ginkgo" . "github.com/onsi/ginkgo/extensions/table" - "net/http" ) var _ = Describe("Update", func() { @@ -91,7 +93,7 @@ var _ = Describe("Update", func() { ctx.SMWithOAuth.GET(web.ServiceInstancesURL + "/" + SID). Expect().Status(expectedGetInstanceStatusCode) - verifyOperationDoesNotExist(SID, "UPDATE") + verifyOperationDoesNotExist(SID, "update") }, Entry("when service_id is unknown to SM", updateRequestBodyMapWith("service_id", "abcd1234"), diff --git a/test/patch.go b/test/patch.go index ce6d30dae..714489153 100644 --- a/test/patch.go +++ b/test/patch.go @@ -2,11 +2,12 @@ package test import ( "fmt" - "github.com/Peripli/service-manager/pkg/types" - "github.com/gavv/httpexpect" "net/http" "strconv" + "github.com/Peripli/service-manager/pkg/types" + "github.com/gavv/httpexpect" + . "github.com/onsi/gomega" "github.com/Peripli/service-manager/test/common" diff --git a/test/platform_test/platform_test.go b/test/platform_test/platform_test.go index 9e7201cd8..4c359d3de 100644 --- a/test/platform_test/platform_test.go +++ b/test/platform_test/platform_test.go @@ -22,8 +22,6 @@ import ( "sort" "testing" - "github.com/Peripli/service-manager/test/testutil/service_instance" - "github.com/Peripli/service-manager/pkg/query" "github.com/Peripli/service-manager/pkg/web" @@ -65,7 +63,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ AdditionalTests: func(ctx *common.TestContext) { Context("non-generic tests", func() { BeforeEach(func() { - common.RemoveAllPlatforms(ctx.SMWithOAuth) + common.RemoveAllPlatforms(ctx.SMRepository) }) Describe("POST", func() { @@ -362,8 +360,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ WithJSON(platform). Expect().Status(http.StatusCreated) - _, serviceInstance := service_instance.Prepare(ctx, platformID, "", "{}") - ctx.SMRepository.Create(context.Background(), serviceInstance) + common.CreateInstanceInPlatform(ctx, platformID) }) AfterEach(func() { diff --git a/test/plugin_test/plugin_test.go b/test/plugin_test/plugin_test.go index 508b413f9..b56a919ce 100644 --- a/test/plugin_test/plugin_test.go +++ b/test/plugin_test/plugin_test.go @@ -63,6 +63,8 @@ var _ = Describe("Service Manager Plugins", func() { var brokerID string brokerID, _, brokerServer = ctx.RegisterBrokerWithCatalog(catalog) + brokerServer.ShouldRecordRequests(true) + common.CreateVisibilitiesForAllBrokerPlans(ctx.SMWithOAuth, brokerID) osbURL = "/v1/osb/" + brokerID }) @@ -104,6 +106,7 @@ var _ = Describe("Service Manager Plugins", func() { var brokerID string brokerID, _, brokerServer = ctx.RegisterBrokerWithCatalog(catalog) + brokerServer.ShouldRecordRequests(true) common.CreateVisibilitiesForAllBrokerPlans(ctx.SMWithOAuth, brokerID) osbURL = "/v1/osb/" + brokerID }) diff --git a/test/query_test/query_test.go b/test/query_test/query_test.go index 62be0afdf..ea5d4cbc4 100644 --- a/test/query_test/query_test.go +++ b/test/query_test/query_test.go @@ -112,6 +112,7 @@ func createNotification(repository storage.Repository, createdAt time.Time) stri Base: types.Base{ ID: uid.String(), CreatedAt: createdAt, + Ready: true, }, Payload: []byte("{}"), Resource: "empty", diff --git a/test/service_binding_test/service_binding_test.go b/test/service_binding_test/service_binding_test.go index b8214f8e8..48b43d444 100644 --- a/test/service_binding_test/service_binding_test.go +++ b/test/service_binding_test/service_binding_test.go @@ -20,15 +20,23 @@ import ( "fmt" "net/http" "strconv" + "time" + + "github.com/spf13/pflag" + + "github.com/Peripli/service-manager/pkg/util" + "github.com/tidwall/gjson" + + "github.com/gavv/httpexpect" "testing" "github.com/Peripli/service-manager/pkg/types" "github.com/Peripli/service-manager/pkg/web" - "github.com/Peripli/service-manager/test/common" + . "github.com/Peripli/service-manager/test/common" - "github.com/Peripli/service-manager/test" + . "github.com/Peripli/service-manager/test" . "github.com/onsi/ginkgo" @@ -45,12 +53,12 @@ const ( TenantIDValue = "tenantID" ) -var _ = test.DescribeTestsFor(test.TestCase{ +var _ = DescribeTestsFor(TestCase{ API: web.ServiceBindingsURL, - SupportedOps: []test.Op{ - test.Get, test.List, test.Delete, + SupportedOps: []Op{ + Get, List, Delete, }, - MultitenancySettings: &test.MultitenancySettings{ + MultitenancySettings: &MultitenancySettings{ ClientID: "tenancyClient", ClientIDTokenClaim: "cid", TenantTokenClaim: "zid", @@ -66,56 +74,177 @@ var _ = test.DescribeTestsFor(test.TestCase{ ResourceBlueprint: blueprint, ResourceWithoutNullableFieldsBlueprint: blueprint, ResourcePropertiesToIgnore: []string{"volume_mounts", "endpoints", "bind_resource", "credentials"}, - PatchResource: test.StorageResourcePatch, - AdditionalTests: func(ctx *common.TestContext) { + PatchResource: StorageResourcePatch, + AdditionalTests: func(ctx *TestContext) { Context("additional non-generic tests", func() { var ( - postBindingRequest common.Object - expectedBindingResponse common.Object - - smExpect *common.SMExpect + postBindingRequest Object + instanceID string + instanceName string + bindingID string + brokerID string + brokerServer *BrokerServer + servicePlanID string + syncBindingResponse Object ) - createInstance := func(SM *common.SMExpect) string { - planID := newServicePlan(ctx) - test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, planID, TenantIDValue) + type testCase struct { + async bool + expectedCreateSuccessStatusCode int + expectedDeleteSuccessStatusCode int + expectedBrokerFailureStatusCode int + } - instanceBody := common.Object{ + testCases := []testCase{ + { + async: false, + expectedCreateSuccessStatusCode: http.StatusCreated, + expectedDeleteSuccessStatusCode: http.StatusOK, + expectedBrokerFailureStatusCode: http.StatusBadGateway, + }, + { + async: true, + expectedCreateSuccessStatusCode: http.StatusAccepted, + expectedDeleteSuccessStatusCode: http.StatusAccepted, + expectedBrokerFailureStatusCode: http.StatusAccepted, + }, + } + + createInstance := func(smClient *SMExpect, async bool, expectedStatusCode int) *httpexpect.Response { + postInstanceRequest := Object{ "name": "test-instance", - "service_plan_id": planID, + "service_plan_id": servicePlanID, "maintenance_info": "{}", } - resp := SM.POST(web.ServiceInstancesURL).WithJSON(instanceBody). + resp := smClient.POST(web.ServiceInstancesURL). + WithQuery("async", async). + WithJSON(postInstanceRequest). Expect(). - Status(http.StatusCreated) + Status(expectedStatusCode) + + if resp.Raw().StatusCode == http.StatusCreated { + obj := resp.JSON().Object() + + obj.ContainsKey("id"). + ValueEqual("platform_id", types.SMPlatform) - return resp.JSON().Object().Value("id").String().Raw() + instanceID = obj.Value("id").String().Raw() + } + + return resp } - createBinding := func(SM *common.SMExpect, body common.Object) { - SM.POST(web.ServiceBindingsURL).WithJSON(body). + deleteInstance := func(smClient *SMExpect, async bool, expectedStatusCode int) *httpexpect.Response { + return smClient.DELETE(web.ServiceInstancesURL+"/"+instanceID). + WithQuery("async", async). Expect(). - Status(http.StatusCreated). - JSON().Object(). - ContainsMap(expectedBindingResponse).ContainsKey("id") + Status(expectedStatusCode) } - BeforeEach(func() { - smExpect = ctx.SMWithOAuth // by default all requests are not tenant-scoped - }) + createBinding := func(SM *SMExpect, async bool, expectedStatusCode int) *httpexpect.Response { + resp := SM.POST(web.ServiceBindingsURL). + WithQuery("async", async). + WithJSON(postBindingRequest). + Expect(). + Status(expectedStatusCode) + obj := resp.JSON().Object() - JustBeforeEach(func() { - instanceID := createInstance(smExpect) + if expectedStatusCode == http.StatusCreated { + obj.ContainsKey("id") + bindingID = obj.Value("id").String().Raw() + } + + return resp + } - bindingName := "test-binding" + deleteBinding := func(smClient *SMExpect, async bool, expectedStatusCode int) *httpexpect.Response { + return smClient.DELETE(web.ServiceBindingsURL+"/"+bindingID). + WithQuery("async", async). + Expect(). + Status(expectedStatusCode) + } - postBindingRequest = common.Object{ - "name": bindingName, + verifyBindingExists := func(smClient *SMExpect, bindingID string, ready bool) { + timeoutDuration := 15 * time.Second + tickerInterval := 100 * time.Millisecond + timeout := time.After(timeoutDuration) + ticker := time.Tick(tickerInterval) + for { + select { + case <-timeout: + Fail(fmt.Sprintf("binding with id %s did not appear in SM after %.0f seconds", bindingID, timeoutDuration.Seconds())) + case <-ticker: + bindings := smClient.ListWithQuery(web.ServiceBindingsURL, fmt.Sprintf("fieldQuery=id eq '%s'", bindingID)) + switch { + case bindings.Length().Raw() == 0: + By(fmt.Sprintf("Could not find binding with id %s in SM. Retrying...", bindingID)) + case bindings.Length().Raw() > 1: + Fail(fmt.Sprintf("more than one binding with id %s was found in SM", bindingID)) + default: + bindingObject := bindings.First().Object() + readyField := bindingObject.Value("ready").Boolean().Raw() + if readyField != ready { + Fail(fmt.Sprintf("Expected binding with id %s to be ready %t but ready was %t", bindingID, ready, readyField)) + } + if ready { + bindingObject.Value("credentials").Equal(map[string]interface{}{ + "user": "user", + "password": "password", + }) + } + + return + } + } + } + } + + verifyBindingDoesNotExist := func(smClient *SMExpect, bindingID string) { + timeoutDuration := 15 * time.Second + tickerInterval := 100 * time.Millisecond + timeout := time.After(timeoutDuration) + ticker := time.Tick(tickerInterval) + for { + select { + case <-timeout: + Fail(fmt.Sprintf("binding with id %s was still in SM after %.0f seconds", bindingID, timeoutDuration.Seconds())) + case <-ticker: + resp := smClient.GET(web.ServiceBindingsURL + "/" + bindingID). + Expect().Raw() + if resp.StatusCode != http.StatusNotFound { + By(fmt.Sprintf("Found binding with id %s but it should be deleted. Retrying...", bindingID)) + } else { + return + } + } + } + } + + BeforeEach(func() { + brokerID, brokerServer, servicePlanID = newServicePlan(ctx, true) + brokerServer.ShouldRecordRequests(false) + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, TenantIDValue) + resp := createInstance(ctx.SMWithOAuthForTenant, false, http.StatusCreated) + instanceName = resp.JSON().Object().Value("name").String().Raw() + Expect(instanceName).ToNot(BeEmpty()) + + postBindingRequest = Object{ + "name": "test-binding", "service_instance_id": instanceID, } - expectedBindingResponse = common.Object{ - "name": bindingName, + syncBindingResponse = Object{ + "async": false, + "credentials": Object{ + "user": "user", + "password": "password", + }, + } + }) + + JustBeforeEach(func() { + postBindingRequest = Object{ + "name": "test-binding", "service_instance_id": instanceID, } }) @@ -124,161 +253,1242 @@ var _ = test.DescribeTestsFor(test.TestCase{ ctx.CleanupAdditionalResources() }) - Describe("POST", func() { - Context("when content type is not JSON", func() { - It("returns 415", func() { - smExpect.POST(web.ServiceBindingsURL).WithText("text"). - Expect(). - Status(http.StatusUnsupportedMediaType). - JSON().Object(). - Keys().Contains("error", "description") + Describe("GET", func() { + When("service binding contains tenant identifier in OSB context", func() { + BeforeEach(func() { + createBinding(ctx.SMWithOAuthForTenant, false, http.StatusCreated) }) - }) - Context("when request body is not a valid JSON", func() { - It("returns 400", func() { - smExpect.POST(web.ServiceBindingsURL). - WithText("invalid json"). - WithHeader("content-type", "application/json"). - Expect(). - Status(http.StatusBadRequest). - JSON().Object(). - Keys().Contains("error", "description") + It("labels instance with tenant identifier", func() { + ctx.SMWithOAuthForTenant.GET(web.ServiceBindingsURL + "/" + bindingID).Expect(). + Status(http.StatusOK). + JSON(). + Object().Path(fmt.Sprintf("$.labels[%s][*]", TenantIdentifier)).Array().Contains(TenantIDValue) }) - }) - Context("when a request body field is missing", func() { - assertPOSTReturns400WhenFieldIsMissing := func(field string) { - JustBeforeEach(func() { - delete(postBindingRequest, field) - delete(expectedBindingResponse, field) + It("returns OSB context with no tenant as part of the binding", func() { + ctx.SMWithOAuthForTenant.GET(web.ServiceBindingsURL + "/" + bindingID).Expect(). + Status(http.StatusOK). + JSON(). + Object().Value("context").Object().Equal(map[string]interface{}{ + "platform": types.SMPlatform, + "instance_name": instanceName, + TenantIdentifier: TenantIDValue, }) + }) + }) - It("returns 400", func() { - smExpect.POST(web.ServiceBindingsURL).WithJSON(postBindingRequest). - Expect(). - Status(http.StatusBadRequest). - JSON().Object(). - Keys().Contains("error", "description") + When("service binding doesn't contain tenant identifier in OSB context", func() { + BeforeEach(func() { + createBinding(ctx.SMWithOAuth, false, http.StatusCreated) + }) + + It("doesn't label instance with tenant identifier", func() { + obj := ctx.SMWithOAuth.GET(web.ServiceBindingsURL + "/" + bindingID).Expect(). + Status(http.StatusOK).JSON().Object() + + objMap := obj.Raw() + objLabels, exist := objMap["labels"] + if exist { + labels := objLabels.(map[string]interface{}) + _, tenantLabelExists := labels[TenantIdentifier] + Expect(tenantLabelExists).To(BeFalse()) + } + }) + + It("returns OSB context with tenant as part of the binding", func() { + ctx.SMWithOAuth.GET(web.ServiceBindingsURL + "/" + bindingID).Expect(). + Status(http.StatusOK). + JSON(). + Object().Value("context").Object().Equal(map[string]interface{}{ + "platform": types.SMPlatform, + "instance_name": instanceName, }) - } + }) + }) + }) - assertPOSTReturns201WhenFieldIsMissing := func(field string) { - JustBeforeEach(func() { - delete(postBindingRequest, field) - delete(expectedBindingResponse, field) + Describe("POST", func() { + for _, testCase := range testCases { + testCase := testCase + Context(fmt.Sprintf("async = %t", testCase.async), func() { + Context("when content type is not JSON", func() { + It("returns 415", func() { + ctx.SMWithOAuth.POST(web.ServiceBindingsURL). + WithQuery("async", testCase.async). + WithText("text"). + Expect(). + Status(http.StatusUnsupportedMediaType). + JSON().Object(). + Keys().Contains("error", "description") + }) }) - It("returns 201", func() { - createBinding(smExpect, postBindingRequest) + Context("when request body is not a valid JSON", func() { + It("returns 400", func() { + ctx.SMWithOAuth.POST(web.ServiceBindingsURL). + WithQuery("async", testCase.async). + WithText("invalid json"). + WithHeader("content-type", "application/json"). + Expect(). + Status(http.StatusBadRequest). + JSON().Object(). + Keys().Contains("error", "description") + }) }) - } - Context("when id field is missing", func() { - assertPOSTReturns201WhenFieldIsMissing("id") - }) + Context("when a request body field is missing", func() { + assertPOSTReturns400WhenFieldIsMissing := func(field string) { + JustBeforeEach(func() { + delete(postBindingRequest, field) + }) - Context("when name field is missing", func() { - assertPOSTReturns400WhenFieldIsMissing("name") - }) + It("returns 400", func() { + ctx.SMWithOAuth.POST(web.ServiceBindingsURL). + WithQuery("async", testCase.async). + WithJSON(postBindingRequest). + Expect(). + Status(http.StatusBadRequest). + JSON().Object(). + Keys().Contains("error", "description") + }) + } - Context("when service_instance_id field is missing", func() { - assertPOSTReturns400WhenFieldIsMissing("service_instance_id") - }) + assertPOSTReturns201WhenFieldIsMissing := func(field string) { + JustBeforeEach(func() { + delete(postBindingRequest, field) + }) - }) + It("returns 201", func() { + resp := createBinding(ctx.SMWithOAuth, testCase.async, testCase.expectedCreateSuccessStatusCode) + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.SUCCEEDED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) - Context("when request body id field is provided", func() { - It("should return 400", func() { - postBindingRequest["id"] = "test-binding-id" - resp := smExpect.POST(web.ServiceBindingsURL). - WithJSON(postBindingRequest). - Expect().Status(http.StatusBadRequest).JSON().Object() + verifyBindingExists(ctx.SMWithOAuth, bindingID, true) + }) + } - Expect(resp.Value("description").String().Raw()).To(ContainSubstring("providing specific resource id is forbidden")) - }) - }) + Context("when id field is missing", func() { + assertPOSTReturns201WhenFieldIsMissing("id") + }) - Context("With async query param", func() { - It("succeeds", func() { - resp := smExpect.POST(web.ServiceBindingsURL).WithJSON(postBindingRequest). - WithQuery("async", "true"). - Expect(). - Status(http.StatusAccepted) + Context("when name field is missing", func() { + assertPOSTReturns400WhenFieldIsMissing("name") + }) - op, err := test.ExpectOperation(smExpect, resp, types.SUCCEEDED) - Expect(err).To(BeNil()) + Context("when service_instance_id field is missing", func() { + assertPOSTReturns400WhenFieldIsMissing("service_instance_id") + }) - smExpect.GET(fmt.Sprintf("%s/%s", web.ServiceBindingsURL, op.Value("resource_id").String().Raw())).Expect(). - Status(http.StatusOK). - JSON().Object(). - ContainsMap(expectedBindingResponse).ContainsKey("id") - }) - }) + }) - Context("instance ownership", func() { - When("tenant doesn't have ownership of instance", func() { - It("returns 404", func() { - ctx.SMWithOAuthForTenant.POST(web.ServiceBindingsURL). - WithJSON(postBindingRequest). - Expect().Status(http.StatusNotFound) + Context("when request body id field is provided", func() { + It("should return 400", func() { + postBindingRequest["id"] = "test-binding-id" + resp := ctx.SMWithOAuth. + POST(web.ServiceBindingsURL). + WithQuery("async", testCase.async). + WithJSON(postBindingRequest). + Expect(). + Status(http.StatusBadRequest).JSON().Object() + Expect(resp.Value("description").String().Raw()).To(ContainSubstring("providing specific resource id is forbidden")) + }) }) - }) - When("tenant has ownership of instance", func() { - BeforeEach(func() { - smExpect = ctx.SMWithOAuthForTenant + Context("OSB context", func() { + BeforeEach(func() { + brokerServer.BindingHandlerFunc(http.MethodPut, http.MethodPut+"1", func(req *http.Request) (int, map[string]interface{}) { + body, err := util.BodyToBytes(req.Body) + Expect(err).ToNot(HaveOccurred()) + tenantValue := gjson.GetBytes(body, "context."+TenantIdentifier).String() + Expect(tenantValue).To(Equal(TenantIDValue)) + platformValue := gjson.GetBytes(body, "context.platform").String() + Expect(platformValue).To(Equal(types.SMPlatform)) + + return http.StatusCreated, Object{} + }) + }) + + It("enriches the osb context with the tenant and sm platform", func() { + createBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedCreateSuccessStatusCode) + }) + }) + + Context("instance visibility", func() { + When("tenant doesn't have ownership of instance", func() { + BeforeEach(func() { + createInstance(ctx.SMWithOAuth, false, http.StatusCreated) + }) + + It("returns 404", func() { + createBinding(ctx.SMWithOAuthForTenant, testCase.async, http.StatusNotFound) + }) + }) + + When("tenant has ownership of instance", func() { + It("returns 201", func() { + resp := createBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedCreateSuccessStatusCode) + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.SUCCEEDED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + verifyBindingExists(ctx.SMWithOAuthForTenant, bindingID, true) + }) + }) }) - It("returns 201", func() { - smExpect.POST(web.ServiceBindingsURL). - WithJSON(postBindingRequest). - Expect().Status(http.StatusCreated) + Context("broker scenarios", func() { + When("instance is not ready", func() { + BeforeEach(func() { + brokerServer.ServiceInstanceHandlerFunc(http.MethodPut, http.MethodPut, ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.ServiceInstanceLastOpHandlerFunc(http.MethodPut, ParameterizedHandler(http.StatusInternalServerError, Object{})) + resp := createInstance(ctx.SMWithOAuthForTenant, true, http.StatusAccepted) + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: true, + DeletionScheduled: false, + }) + + VerifyResourceExists(ctx.SMWithOAuthForTenant, ResourceExpectations{ + ID: instanceID, + Type: types.ServiceInstanceType, + Ready: false, + }) + }) + + It("fails to create binding", func() { + expectedStatusCode := testCase.expectedBrokerFailureStatusCode + if !testCase.async { + expectedStatusCode = http.StatusUnprocessableEntity + } + resp := createBinding(ctx.SMWithOAuthForTenant, testCase.async, expectedStatusCode) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyBindingDoesNotExist(ctx.SMWithOAuthForTenant, bindingID) + }) + }) + + When("instance is being deleted", func() { + var doneChannel chan interface{} + BeforeEach(func() { + + doneChannel = make(chan interface{}) + resp := createInstance(ctx.SMWithOAuthForTenant, false, http.StatusCreated) + + VerifyResourceExists(ctx.SMWithOAuthForTenant, ResourceExpectations{ + ID: instanceID, + Type: types.ServiceInstanceType, + Ready: true, + }) + + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete, ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.ServiceInstanceLastOpHandlerFunc(http.MethodDelete, DelayingHandler(doneChannel)) + resp = deleteInstance(ctx.SMWithOAuthForTenant, true, http.StatusAccepted) + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.IN_PROGRESS, + ResourceType: types.ServiceInstanceType, + Reschedulable: true, + DeletionScheduled: false, + }) + + VerifyResourceExists(ctx.SMWithOAuthForTenant, ResourceExpectations{ + ID: instanceID, + Type: types.ServiceInstanceType, + Ready: true, + }) + }) + + AfterEach(func() { + close(doneChannel) + }) + + It("fails to create binding", func() { + expectedStatusCode := testCase.expectedBrokerFailureStatusCode + if !testCase.async { + expectedStatusCode = http.StatusUnprocessableEntity + } + resp := createBinding(ctx.SMWithOAuthForTenant, testCase.async, expectedStatusCode) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyBindingDoesNotExist(ctx.SMWithOAuthForTenant, bindingID) + }) + }) + + When("plan is not bindable", func() { + BeforeEach(func() { + servicePlanID = findPlanIDForBrokerID(ctx, brokerID, false) + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, TenantIDValue) + createInstance(ctx.SMWithOAuthForTenant, false, http.StatusCreated) + }) + + It("fails to create binding", func() { + expectedStatusCode := testCase.expectedBrokerFailureStatusCode + if !testCase.async { + expectedStatusCode = http.StatusBadRequest + } + resp := createBinding(ctx.SMWithOAuthForTenant, testCase.async, expectedStatusCode) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyBindingDoesNotExist(ctx.SMWithOAuthForTenant, bindingID) + }) + }) + + When("a create operation is already in progress", func() { + var doneChannel chan interface{} + + BeforeEach(func() { + doneChannel = make(chan interface{}) + brokerServer.BindingHandlerFunc(http.MethodPut, http.MethodPut+"1", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.BindingLastOpHandlerFunc(http.MethodPut+"1", DelayingHandler(doneChannel)) + brokerServer.BindingLastOpHandlerFunc(http.MethodDelete+"1", ParameterizedHandler(http.StatusOK, Object{"state": "succeeded"})) + + resp := createBinding(ctx.SMWithOAuthForTenant, true, http.StatusAccepted) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.IN_PROGRESS, + ResourceType: types.ServiceBindingType, + Reschedulable: true, + DeletionScheduled: false, + }) + + verifyBindingExists(ctx.SMWithOAuthForTenant, bindingID, false) + }) + + AfterEach(func() { + close(doneChannel) + }) + + It("deletes succeed", func() { + resp := ctx.SMWithOAuthForTenant.DELETE(web.ServiceBindingsURL+"/"+bindingID).WithQuery("async", testCase.async). + Expect().StatusRange(httpexpect.Status2xx) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.SUCCEEDED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyBindingDoesNotExist(ctx.SMWithOAuthForTenant, bindingID) + }) + }) + + When("instance does not exist", func() { + JustBeforeEach(func() { + postBindingRequest["service_instance_id"] = "non-existing-id" + }) + + It("bind fails", func() { + createBinding(ctx.SMWithOAuthForTenant, testCase.async, http.StatusNotFound) + }) + }) + + When("broker responds with synchronous success", func() { + BeforeEach(func() { + brokerServer.BindingHandlerFunc(http.MethodPut, http.MethodPut+"1", ParameterizedHandler(http.StatusCreated, syncBindingResponse)) + }) + + It("stores binding as ready=true and the operation as success, non rescheduable with no deletion scheduled", func() { + resp := createBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedCreateSuccessStatusCode) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.SUCCEEDED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyBindingExists(ctx.SMWithOAuthForTenant, bindingID, true) + }) + }) + + When("broker responds with asynchronous success", func() { + BeforeEach(func() { + brokerServer.BindingHandlerFunc(http.MethodPut, http.MethodPut+"1", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.BindingLastOpHandlerFunc(http.MethodPut+"1", MultiplePollsRequiredHandler("in progress", "succeeded")) + }) + + It("polling broker last operation until operation succeeds and eventually marks operation as success", func() { + resp := createBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedCreateSuccessStatusCode) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.SUCCEEDED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyBindingExists(ctx.SMWithOAuthForTenant, bindingID, true) + }) + + if testCase.async { + When("job timeout is reached while polling", func() { + var oldCtx *TestContext + BeforeEach(func() { + oldCtx = ctx + ctx = NewTestContextBuilderWithSecurity().WithEnvPreExtensions(func(set *pflag.FlagSet) { + Expect(set.Set("operations.job_timeout", (2 * time.Second).String())).ToNot(HaveOccurred()) + }).BuildWithoutCleanup() + + brokerServer.BindingHandlerFunc(http.MethodPut, http.MethodPut+"1", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.BindingLastOpHandlerFunc(http.MethodPut+"1", ParameterizedHandler(http.StatusOK, Object{"state": "in progress"})) + }) + + AfterEach(func() { + ctx = oldCtx + }) + + It("stores binding as ready false and the operation as reschedulable in progress", func() { + resp := createBinding(ctx.SMWithOAuthForTenant, true, http.StatusAccepted) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.IN_PROGRESS, + ResourceType: types.ServiceBindingType, + Reschedulable: true, + DeletionScheduled: false, + }) + + verifyBindingExists(ctx.SMWithOAuthForTenant, bindingID, false) + }) + }) + } + + When("polling responds with unexpected state and eventually with success state", func() { + BeforeEach(func() { + brokerServer.BindingHandlerFunc(http.MethodPut, http.MethodPut+"1", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.BindingLastOpHandlerFunc(http.MethodPut+"1", MultiplePollsRequiredHandler("unknown", "succeeded")) + }) + + It("keeps polling and eventually updates the binding to ready true and operation to success", func() { + resp := createBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedCreateSuccessStatusCode) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.SUCCEEDED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + verifyBindingExists(ctx.SMWithOAuthForTenant, bindingID, true) + }) + }) + + When("polling responds with unexpected state and eventually with failed state", func() { + BeforeEach(func() { + brokerServer.BindingHandlerFunc(http.MethodPut, http.MethodPut+"2", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.BindingLastOpHandlerFunc(http.MethodPut+"2", MultiplePollsRequiredHandler("unknown", "failed")) + }) + + When("orphan mitigation unbind synchronously succeeds", func() { + BeforeEach(func() { + brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusOK, Object{"async": false})) + }) + + It("deletes the binding and marks the operation that triggered the orphan mitigation as failed with no deletion scheduled and not reschedulable", func() { + resp := createBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyBindingDoesNotExist(ctx.SMWithOAuthForTenant, bindingID) + }) + }) + + When("broker orphan mitigation unbind synchronously fails with an error that will stop further orphan mitigation", func() { + BeforeEach(func() { + brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusBadRequest, Object{"error": "error"})) + }) + + It("keeps the binding with ready false and marks the operation with deletion scheduled", func() { + resp := createBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: true, + }) + + verifyBindingExists(ctx.SMWithOAuthForTenant, bindingID, false) + }) + }) + + When("broker orphan mitigation unbind synchronously fails with an error that will continue further orphan mitigation and eventually succeed", func() { + BeforeEach(func() { + brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"3", MultipleErrorsBeforeSuccessHandler( + http.StatusInternalServerError, http.StatusOK, + Object{"error": "error"}, Object{"async": false}, + )) + }) + + It("deletes the binding and marks the operation that triggered the orphan mitigation as failed with no deletion scheduled and not reschedulable", func() { + resp := createBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyBindingDoesNotExist(ctx.SMWithOAuthForTenant, bindingID) + }) + }) + }) + + When("polling returns an unexpected status code", func() { + BeforeEach(func() { + brokerServer.BindingHandlerFunc(http.MethodPut, http.MethodPut+"3", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.BindingLastOpHandlerFunc(http.MethodPut+"3", ParameterizedHandler(http.StatusInternalServerError, Object{"error": "error"})) + }) + + It("stores the binding as ready false and marks the operation as reschedulable", func() { + resp := createBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceBindingType, + Reschedulable: true, + DeletionScheduled: false, + }) + + verifyBindingExists(ctx.SMWithOAuthForTenant, bindingID, false) + }) + }) + }) + + When("bind responds with error due to stopped broker", func() { + BeforeEach(func() { + brokerServer.Close() + delete(ctx.Servers, BrokerServerPrefix+brokerID) + }) + + It("does not store binding in SMDB and marks operation with failed", func() { + resp := createBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyBindingDoesNotExist(ctx.SMWithOAuthForTenant, bindingID) + }) + }) + + When("bind responds with error that does not require orphan mitigation", func() { + BeforeEach(func() { + brokerServer.BindingHandlerFunc(http.MethodPut, http.MethodPut+"3", ParameterizedHandler(http.StatusBadRequest, Object{"error": "error"})) + }) + + AfterEach(func() { + brokerServer.ResetHandlers() + }) + + It("does not store the binding and marks the operation as failed, non rescheduable with empty deletion scheduled", func() { + resp := createBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyBindingDoesNotExist(ctx.SMWithOAuthForTenant, bindingID) + }) + }) + + When("bind responds with error that requires orphan mitigation", func() { + BeforeEach(func() { + brokerServer.BindingHandlerFunc(http.MethodPut, http.MethodPut+"3", ParameterizedHandler(http.StatusInternalServerError, Object{"error": "error"})) + }) + + AfterEach(func() { + brokerServer.ResetHandlers() + }) + + When("orphan mitigation unbind asynchronously succeeds", func() { + BeforeEach(func() { + brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.BindingLastOpHandlerFunc(http.MethodDelete+"3", ParameterizedHandler(http.StatusOK, Object{"state": "succeeded"})) + }) + + It("deletes the binding and marks the operation that triggered the orphan mitigation as failed with no deletion scheduled and not reschedulable", func() { + resp := createBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyBindingDoesNotExist(ctx.SMWithOAuthForTenant, bindingID) + }) + }) + + if testCase.async { + When("broker orphan mitigation unbind asynchronously keeps failing with an error while polling", func() { + BeforeEach(func() { + brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.BindingLastOpHandlerFunc(http.MethodDelete+"3", ParameterizedHandler(http.StatusBadRequest, Object{"error": "error"})) + }) + + AfterEach(func() { + brokerServer.ResetHandlers() + }) + + It("keeps the binding as ready false and marks the operation as deletion scheduled", func() { + resp := createBinding(ctx.SMWithOAuthForTenant, true, http.StatusAccepted) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceBindingType, + Reschedulable: true, + DeletionScheduled: true, + }) + + verifyBindingExists(ctx.SMWithOAuthForTenant, bindingID, false) + }) + }) + } + + When("broker orphan mitigation unbind asynchronously fails with an error that will continue further orphan mitigation and eventually succeed", func() { + BeforeEach(func() { + brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + + brokerServer.BindingLastOpHandlerFunc(http.MethodDelete+"3", MultipleErrorsBeforeSuccessHandler( + http.StatusOK, http.StatusOK, + Object{"state": "failed"}, Object{"state": "succeeded"}, + )) + }) + + It("deletes the binding and marks the operation that triggered the orphan mitigation as failed with no deletion scheduled and not reschedulable", func() { + resp := createBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyBindingDoesNotExist(ctx.SMWithOAuthForTenant, bindingID) + }) + }) + }) + + When("bind responds with error due to times out", func() { + var doneChannel chan interface{} + var oldCtx *TestContext + + BeforeEach(func() { + oldCtx = ctx + doneChannel = make(chan interface{}) + ctx = NewTestContextBuilderWithSecurity().WithEnvPreExtensions(func(set *pflag.FlagSet) { + Expect(set.Set("httpclient.response_header_timeout", (1 * time.Second).String())).ToNot(HaveOccurred()) + }).BuildWithoutCleanup() + + brokerServer.BindingHandlerFunc(http.MethodPut, http.MethodPut+"1", DelayingHandler(doneChannel)) + }) + + AfterEach(func() { + ctx = oldCtx + }) + + It("orphan mitigates the binding", func() { + resp := createBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + <-time.After(1100 * time.Millisecond) + close(doneChannel) + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyBindingDoesNotExist(ctx.SMWithOAuthForTenant, bindingID) + }) + }) }) }) - }) + } }) Describe("DELETE", func() { - Context("instance ownership", func() { - When("tenant doesn't have ownership of binding", func() { - It("returns 404", func() { - smExpect.POST(web.ServiceBindingsURL).WithJSON(postBindingRequest). - Expect(). - Status(http.StatusCreated) - - ctx.SMWithOAuthForTenant.DELETE(fmt.Sprintf("%s/%s", web.ServiceBindingsURL, postBindingRequest["id"])). - Expect().Status(http.StatusNotFound) - }) - }) + It("returns 405 for bulk delete", func() { + ctx.SMWithOAuthForTenant.DELETE(web.ServiceBindingsURL). + Expect().Status(http.StatusMethodNotAllowed) + }) - When("tenant has ownership of instance", func() { + for _, testCase := range testCases { + testCase := testCase + Context(fmt.Sprintf("async = %t", testCase.async), func() { BeforeEach(func() { - smExpect = ctx.SMWithOAuthForTenant + brokerServer.ShouldRecordRequests(true) }) - It("returns 200", func() { - obj := smExpect.POST(web.ServiceBindingsURL).WithJSON(postBindingRequest). - Expect(). - Status(http.StatusCreated).JSON().Object() + Context("instance ownership", func() { + When("tenant doesn't have ownership of binding", func() { + It("returns 404", func() { + resp := createBinding(ctx.SMWithOAuth, testCase.async, testCase.expectedCreateSuccessStatusCode) + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.SUCCEEDED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + verifyBindingExists(ctx.SMWithOAuth, bindingID, true) + + expectedCode := http.StatusNotFound + if testCase.async { + expectedCode = http.StatusAccepted + } + deleteBinding(ctx.SMWithOAuthForTenant, testCase.async, expectedCode) + + verifyBindingExists(ctx.SMWithOAuth, bindingID, true) - smExpect.DELETE(fmt.Sprintf("%s/%s", web.ServiceBindingsURL, obj.Value("id").String().Raw())). - Expect().Status(http.StatusOK) + }) + }) + + When("tenant has ownership of instance", func() { + It("returns 200", func() { + resp := createBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedCreateSuccessStatusCode) + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.SUCCEEDED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + verifyBindingExists(ctx.SMWithOAuthForTenant, bindingID, true) + + resp = deleteBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedDeleteSuccessStatusCode) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.SUCCEEDED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + verifyBindingDoesNotExist(ctx.SMWithOAuthForTenant, bindingID) + }) + }) + }) + + Context("broker scenarios", func() { + BeforeEach(func() { + resp := createBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedCreateSuccessStatusCode) + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.SUCCEEDED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + verifyBindingExists(ctx.SMWithOAuth, bindingID, true) + + }) + + When("a delete operation is already in progress", func() { + var doneChannel chan interface{} + + BeforeEach(func() { + doneChannel = make(chan interface{}) + brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"1", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.BindingLastOpHandlerFunc(http.MethodDelete+"1", DelayingHandler(doneChannel)) + + resp := deleteBinding(ctx.SMWithOAuthForTenant, true, http.StatusAccepted) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.IN_PROGRESS, + ResourceType: types.ServiceBindingType, + Reschedulable: true, + DeletionScheduled: false, + }) + + verifyBindingExists(ctx.SMWithOAuthForTenant, bindingID, true) + }) + + AfterEach(func() { + close(doneChannel) + }) + + It("deletes fail with operation in progress", func() { + deleteBinding(ctx.SMWithOAuthForTenant, testCase.async, http.StatusUnprocessableEntity) + }) + }) + + When("broker responds with synchronous success", func() { + BeforeEach(func() { + brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"1", ParameterizedHandler(http.StatusOK, Object{"async": false})) + }) + + It("deletes the binding and stores a delete succeeded operation", func() { + resp := deleteBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedDeleteSuccessStatusCode) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.SUCCEEDED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyBindingDoesNotExist(ctx.SMWithOAuthForTenant, bindingID) + }) + }) + + When("broker responds with 410 GONE", func() { + BeforeEach(func() { + brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"1", ParameterizedHandler(http.StatusGone, Object{})) + }) + + It("deletes the instance and stores a delete succeeded operation", func() { + resp := deleteBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedDeleteSuccessStatusCode) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.SUCCEEDED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyBindingDoesNotExist(ctx.SMWithOAuthForTenant, bindingID) + }) + }) + + When("broker responds with asynchronous success", func() { + BeforeEach(func() { + brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"1", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.BindingLastOpHandlerFunc(http.MethodDelete+"1", MultiplePollsRequiredHandler("in progress", "succeeded")) + }) + + It("polling broker last operation until operation succeeds and eventually marks operation as success", func() { + resp := deleteBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedDeleteSuccessStatusCode) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.SUCCEEDED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyBindingDoesNotExist(ctx.SMWithOAuthForTenant, bindingID) + }) + + When("polling responds 410 GONE", func() { + BeforeEach(func() { + brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"1", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.BindingLastOpHandlerFunc(http.MethodDelete+"1", ParameterizedHandler(http.StatusGone, Object{})) + }) + + It("keeps polling and eventually deletes the binding and marks the operation as success", func() { + resp := deleteBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedDeleteSuccessStatusCode) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.SUCCEEDED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyBindingDoesNotExist(ctx.SMWithOAuthForTenant, bindingID) + }) + }) + + When("polling responds with unexpected state and eventually with success state", func() { + BeforeEach(func() { + brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"1", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.BindingLastOpHandlerFunc(http.MethodDelete+"1", MultiplePollsRequiredHandler("unknown", "succeeded")) + }) + + It("keeps polling and eventually deletes the binding and marks the operation as success", func() { + resp := deleteBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedDeleteSuccessStatusCode) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.SUCCEEDED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyBindingDoesNotExist(ctx.SMWithOAuthForTenant, bindingID) + }) + }) + + When("polling responds with unexpected state and eventually with failed state", func() { + BeforeEach(func() { + brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"2", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.BindingLastOpHandlerFunc(http.MethodDelete+"2", MultiplePollsRequiredHandler("unknown", "failed")) + }) + + When("orphan mitigation unbind synchronously succeeds", func() { + It("deletes the binding and marks the operation as success", func() { + resp := deleteBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.FAILED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: true, + }) + + brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"2", ParameterizedHandler(http.StatusOK, Object{"async": false})) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.SUCCEEDED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyBindingDoesNotExist(ctx.SMWithOAuthForTenant, bindingID) + }) + }) + + When("broker orphan mitigation unbind synchronously fails with an unexpected error", func() { + AfterEach(func() { + brokerServer.ResetHandlers() + }) + + It("keeps the binding and marks the operation with deletion scheduled", func() { + resp := deleteBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.FAILED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: true, + }) + + brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"2", ParameterizedHandler(http.StatusBadRequest, Object{"error": "error"})) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.FAILED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: true, + }) + + verifyBindingExists(ctx.SMWithOAuthForTenant, bindingID, true) + }) + }) + + When("broker orphan mitigation unbind synchronously fails with an error that will continue further orphan mitigation and eventually succeed", func() { + It("deletes the binding and marks the operation that triggered the orphan mitigation as failed with no deletion scheduled and not reschedulable", func() { + resp := deleteBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.FAILED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: true, + }) + + brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"2", MultipleErrorsBeforeSuccessHandler( + http.StatusInternalServerError, http.StatusOK, + Object{"error": "error"}, Object{"async": false}, + )) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.SUCCEEDED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyBindingDoesNotExist(ctx.SMWithOAuthForTenant, bindingID) + }) + }) + }) + + When("polling returns an unexpected status code", func() { + BeforeEach(func() { + brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.BindingLastOpHandlerFunc(http.MethodDelete+"3", ParameterizedHandler(http.StatusInternalServerError, Object{"error": "error"})) + }) + + It("keeps the binding and stores the operation as reschedulable", func() { + resp := deleteBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.FAILED, + ResourceType: types.ServiceBindingType, + Reschedulable: true, + DeletionScheduled: false, + }) + + verifyBindingExists(ctx.SMWithOAuthForTenant, bindingID, true) + }) + }) + }) + + When("unbind responds with error due to stopped broker", func() { + BeforeEach(func() { + brokerServer.Close() + delete(ctx.Servers, BrokerServerPrefix+brokerID) + }) + + It("keeps the binding and marks operation with failed", func() { + resp := deleteBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.FAILED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyBindingExists(ctx.SMWithOAuthForTenant, bindingID, true) + }) + }) + + When("unbind responds with error that does not require orphan mitigation", func() { + BeforeEach(func() { + brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusBadRequest, Object{"error": "error"})) + }) + + AfterEach(func() { + brokerServer.ResetHandlers() + }) + + It("keeps the binding and marks the operation as failed", func() { + resp := deleteBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.FAILED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyBindingExists(ctx.SMWithOAuthForTenant, bindingID, true) + }) + }) + + When("unbind responds with error that requires orphan mitigation", func() { + BeforeEach(func() { + brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusInternalServerError, Object{"error": "error"})) + }) + + When("orphan mitigation unbind asynchronously succeeds", func() { + It("deletes the binding and marks the operation as success", func() { + resp := deleteBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.FAILED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: true, + }) + + brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.BindingLastOpHandlerFunc(http.MethodDelete+"3", ParameterizedHandler(http.StatusOK, Object{"state": "succeeded"})) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.SUCCEEDED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyBindingDoesNotExist(ctx.SMWithOAuthForTenant, bindingID) + }) + }) + + if testCase.async { + When("broker orphan mitigation unbind asynchronously keeps failing with an error while polling", func() { + AfterEach(func() { + brokerServer.ResetHandlers() + }) + + It("keeps the binding and marks the operation as failed reschedulable with deletion scheduled", func() { + resp := deleteBinding(ctx.SMWithOAuthForTenant, true, http.StatusAccepted) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.FAILED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: true, + }) + + brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.BindingLastOpHandlerFunc(http.MethodDelete+"3", ParameterizedHandler(http.StatusBadRequest, Object{"error": "error"})) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.FAILED, + ResourceType: types.ServiceBindingType, + Reschedulable: true, + DeletionScheduled: true, + }) + + verifyBindingExists(ctx.SMWithOAuthForTenant, bindingID, true) + }) + }) + } + + When("broker orphan mitigation unbind asynchronously fails with an error that will continue further orphan mitigation and eventually succeed", func() { + It("deletes the binding and marks the operation as success", func() { + resp := deleteBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.FAILED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: true, + }) + + brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.BindingLastOpHandlerFunc(http.MethodDelete+"3", MultipleErrorsBeforeSuccessHandler( + http.StatusOK, http.StatusOK, + Object{"state": "failed"}, Object{"state": "succeeded"}, + )) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.SUCCEEDED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyBindingDoesNotExist(ctx.SMWithOAuthForTenant, bindingID) + }) + }) + }) + + When("unbind responds with error due to times out", func() { + var doneChannel chan interface{} + var oldCtx *TestContext + + BeforeEach(func() { + oldCtx = ctx + doneChannel = make(chan interface{}) + ctx = NewTestContextBuilderWithSecurity().WithEnvPreExtensions(func(set *pflag.FlagSet) { + Expect(set.Set("httpclient.response_header_timeout", (1 * time.Second).String())).ToNot(HaveOccurred()) + }).BuildWithoutCleanup() + + brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"1", DelayingHandler(doneChannel)) + + }) + + AfterEach(func() { + ctx = oldCtx + }) + + It("orphan mitigates the binding", func() { + resp := deleteBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + <-time.After(1100 * time.Millisecond) + close(doneChannel) + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.FAILED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: true, + }) + + brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"1", ParameterizedHandler(http.StatusOK, Object{"async": false})) + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.SUCCEEDED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyBindingDoesNotExist(ctx.SMWithOAuthForTenant, bindingID) + }) + }) }) }) - }) + } }) - }) }, }) -func blueprint(ctx *common.TestContext, auth *common.SMExpect, async bool) common.Object { - servicePlanID := newServicePlan(ctx) - test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, "") +func blueprint(ctx *TestContext, auth *SMExpect, async bool) Object { + _, _, servicePlanID := newServicePlan(ctx, true) + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, "") resp := ctx.SMWithOAuth.POST(web.ServiceInstancesURL). WithQuery("async", strconv.FormatBool(async)). - WithJSON(common.Object{ + WithJSON(Object{ "name": "test-service-instance", "service_plan_id": servicePlanID, "maintenance_info": "{}", @@ -286,21 +1496,21 @@ func blueprint(ctx *common.TestContext, auth *common.SMExpect, async bool) commo var instance map[string]interface{} if async { - instance = test.ExpectSuccessfulAsyncResourceCreation(resp, auth, web.ServiceInstancesURL) + instance = ExpectSuccessfulAsyncResourceCreation(resp, auth, web.ServiceInstancesURL) } else { instance = resp.Status(http.StatusCreated).JSON().Object().Raw() } resp = ctx.SMWithOAuth.POST(web.ServiceBindingsURL). WithQuery("async", strconv.FormatBool(async)). - WithJSON(common.Object{ + WithJSON(Object{ "name": "test-service-binding", "service_instance_id": instance["id"], }).Expect() var binding map[string]interface{} if async { - binding = test.ExpectSuccessfulAsyncResourceCreation(resp, auth, web.ServiceBindingsURL) + binding = ExpectSuccessfulAsyncResourceCreation(resp, auth, web.ServiceBindingsURL) } else { binding = resp.Status(http.StatusCreated).JSON().Object().Raw() } @@ -309,10 +1519,17 @@ func blueprint(ctx *common.TestContext, auth *common.SMExpect, async bool) commo return binding } -func newServicePlan(ctx *common.TestContext) string { - brokerID, _, _ := ctx.RegisterBrokerWithCatalog(common.NewRandomSBCatalog()) +func newServicePlan(ctx *TestContext, bindable bool) (string, *BrokerServer, string) { + brokerID, _, brokerServer := ctx.RegisterBrokerWithCatalog(NewRandomSBCatalog()) + ctx.Servers[BrokerServerPrefix+brokerID] = brokerServer + servicePlanID := findPlanIDForBrokerID(ctx, brokerID, bindable) + return brokerID, brokerServer, servicePlanID +} + +func findPlanIDForBrokerID(ctx *TestContext, brokerID string, bindable bool) string { so := ctx.SMWithOAuth.ListWithQuery(web.ServiceOfferingsURL, fmt.Sprintf("fieldQuery=broker_id eq '%s'", brokerID)).First() - servicePlanID := ctx.SMWithOAuth.ListWithQuery(web.ServicePlansURL, "fieldQuery="+fmt.Sprintf("service_offering_id eq '%s'", so.Object().Value("id").String().Raw())). + servicePlanID := ctx.SMWithOAuth.ListWithQuery(web.ServicePlansURL, "fieldQuery="+fmt.Sprintf("service_offering_id eq '%s' and bindable eq %t", so.Object().Value("id").String().Raw(), bindable)). First().Object().Value("id").String().Raw() + return servicePlanID } diff --git a/test/service_instance_test/service_instance_test.go b/test/service_instance_test/service_instance_test.go index 413a060f0..b60d9df3b 100644 --- a/test/service_instance_test/service_instance_test.go +++ b/test/service_instance_test/service_instance_test.go @@ -17,24 +17,30 @@ package service_test import ( - "context" "fmt" + "time" + + "github.com/tidwall/gjson" + + "github.com/Peripli/service-manager/pkg/util" + + "github.com/spf13/pflag" + "github.com/gofrs/uuid" "github.com/gavv/httpexpect" "strconv" - "github.com/Peripli/service-manager/test/testutil/service_instance" "net/http" "testing" "github.com/Peripli/service-manager/pkg/types" "github.com/Peripli/service-manager/pkg/web" - "github.com/Peripli/service-manager/test/common" + . "github.com/Peripli/service-manager/test/common" - "github.com/Peripli/service-manager/test" + . "github.com/Peripli/service-manager/test" . "github.com/onsi/ginkgo" @@ -51,12 +57,12 @@ const ( TenantIDValue = "tenantID" ) -var _ = test.DescribeTestsFor(test.TestCase{ +var _ = DescribeTestsFor(TestCase{ API: web.ServiceInstancesURL, - SupportedOps: []test.Op{ - test.Get, test.List, test.Delete, test.DeleteList, test.Patch, + SupportedOps: []Op{ + Get, List, Delete, Patch, }, - MultitenancySettings: &test.MultitenancySettings{ + MultitenancySettings: &MultitenancySettings{ ClientID: "tenancyClient", ClientIDTokenClaim: "cid", TenantTokenClaim: "zid", @@ -72,51 +78,135 @@ var _ = test.DescribeTestsFor(test.TestCase{ ResourceBlueprint: blueprint, ResourceWithoutNullableFieldsBlueprint: blueprint, ResourcePropertiesToIgnore: []string{"platform_id"}, - PatchResource: test.APIResourcePatch, - AdditionalTests: func(ctx *common.TestContext) { + PatchResource: APIResourcePatch, + AdditionalTests: func(ctx *TestContext) { Context("additional non-generic tests", func() { var ( - postInstanceRequest common.Object - expectedInstanceResponse common.Object + postInstanceRequest Object servicePlanID string anotherServicePlanID string - - instanceID string + brokerID string + brokerServer *BrokerServer + instanceID string ) - createInstance := func(SM *common.SMExpect, expectedStatus int) { - resp := SM.POST(web.ServiceInstancesURL).WithJSON(postInstanceRequest). - Expect(). - Status(expectedStatus) + type testCase struct { + async bool + expectedCreateSuccessStatusCode int + expectedDeleteSuccessStatusCode int + expectedBrokerFailureStatusCode int + } + + testCases := []testCase{ + { + async: false, + expectedCreateSuccessStatusCode: http.StatusCreated, + expectedDeleteSuccessStatusCode: http.StatusOK, + expectedBrokerFailureStatusCode: http.StatusBadGateway, + }, + { + async: true, + expectedCreateSuccessStatusCode: http.StatusAccepted, + expectedDeleteSuccessStatusCode: http.StatusAccepted, + expectedBrokerFailureStatusCode: http.StatusAccepted, + }, + } + + createInstance := func(smClient *SMExpect, expectedStatusCode int) *httpexpect.Response { + resp := smClient.POST(web.ServiceInstancesURL).WithJSON(postInstanceRequest). + Expect().Status(expectedStatusCode) + + return resp + } + + createInstanceWithAsync := func(smClient *SMExpect, async bool, expectedStatusCode int) *httpexpect.Response { + resp := smClient.POST(web.ServiceInstancesURL).WithQuery("async", async).WithJSON(postInstanceRequest). + Expect().Status(expectedStatusCode) if resp.Raw().StatusCode == http.StatusCreated { obj := resp.JSON().Object() - obj.ContainsMap(expectedInstanceResponse).ContainsKey("id"). + obj.ContainsKey("id"). ValueEqual("platform_id", types.SMPlatform) instanceID = obj.Value("id").String().Raw() } + + return resp + } + + deleteInstance := func(smClient *SMExpect, async bool, expectedStatusCode int) *httpexpect.Response { + return smClient.DELETE(web.ServiceInstancesURL+"/"+instanceID). + WithQuery("async", async). + Expect(). + Status(expectedStatusCode) + } + + verifyInstanceExists := func(instanceID string, ready bool) { + timeoutDuration := 15 * time.Second + tickerInterval := 100 * time.Millisecond + ticker := time.NewTicker(tickerInterval) + timeout := time.After(timeoutDuration) + defer ticker.Stop() + for { + select { + case <-timeout: + Fail(fmt.Sprintf("instance with id %s did not appear in SM after %.0f seconds", instanceID, timeoutDuration.Seconds())) + case <-ticker.C: + instances := ctx.SMWithOAuthForTenant.ListWithQuery(web.ServiceInstancesURL, fmt.Sprintf("fieldQuery=id eq '%s'", instanceID)) + switch { + case instances.Length().Raw() == 0: + By(fmt.Sprintf("Could not find instance with id %s in SM. Retrying...", instanceID)) + case instances.Length().Raw() > 1: + Fail(fmt.Sprintf("more than one instance with id %s was found in SM", instanceID)) + default: + instanceObject := instances.First().Object() + readyField := instanceObject.Value("ready").Boolean().Raw() + if readyField != ready { + By(fmt.Sprintf("Expected instance with id %s to be ready %t but ready was %t. Retrying...", instanceID, ready, readyField)) + } else { + return + } + } + } + } + } + + verifyInstanceDoesNotExist := func(instanceID string) { + timeoutDuration := 15 * time.Second + tickerInterval := 100 * time.Millisecond + ticker := time.NewTicker(tickerInterval) + timeout := time.After(timeoutDuration) + + defer ticker.Stop() + for { + select { + case <-timeout: + Fail(fmt.Sprintf("instance with id %s was still in SM after %.0f seconds", instanceID, timeoutDuration.Seconds())) + case <-ticker.C: + resp := ctx.SMWithOAuthForTenant.GET(web.ServiceInstancesURL + "/" + instanceID). + Expect().Raw() + if resp.StatusCode != http.StatusNotFound { + By(fmt.Sprintf("Found instance with id %s but it should be deleted. Retrying...", instanceID)) + } else { + return + } + } + } } BeforeEach(func() { - name := "test-instance" - plans := generateServicePlanIDs(ctx, ctx.SMWithOAuth) + var plans *httpexpect.Array + brokerID, brokerServer, plans = prepareBrokerWithCatalog(ctx, ctx.SMWithOAuth) + brokerServer.ShouldRecordRequests(false) servicePlanID = plans.Element(0).Object().Value("id").String().Raw() anotherServicePlanID = plans.Element(1).Object().Value("id").String().Raw() - - postInstanceRequest = common.Object{ - "name": name, + postInstanceRequest = Object{ + "name": "test-instance", "service_plan_id": servicePlanID, "maintenance_info": "{}", } - expectedInstanceResponse = common.Object{ - "name": name, - "service_plan_id": servicePlanID, - "maintenance_info": "{}", - } - }) AfterEach(func() { @@ -124,31 +214,45 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) Describe("GET", func() { - var serviceInstance *types.ServiceInstance + var instanceName string When("service instance contains tenant identifier in OSB context", func() { BeforeEach(func() { - _, serviceInstance = service_instance.Prepare(ctx, ctx.TestPlatform.ID, "", fmt.Sprintf(`{"%s":"%s"}`, TenantIdentifier, TenantIDValue)) - _, err := ctx.SMRepository.Create(context.Background(), serviceInstance) - Expect(err).ToNot(HaveOccurred()) + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, TenantIDValue) + resp := createInstanceWithAsync(ctx.SMWithOAuthForTenant, false, http.StatusCreated) + instanceName = resp.JSON().Object().Value("name").String().Raw() + Expect(instanceName).ToNot(BeEmpty()) }) It("labels instance with tenant identifier", func() { - ctx.SMWithOAuth.GET(web.ServiceInstancesURL + "/" + serviceInstance.ID).Expect(). + ctx.SMWithOAuthForTenant.GET(web.ServiceInstancesURL + "/" + instanceID).Expect(). Status(http.StatusOK). JSON(). Object().Path(fmt.Sprintf("$.labels[%s][*]", TenantIdentifier)).Array().Contains(TenantIDValue) }) + + It("returns OSB context with tenant as part of the instance", func() { + ctx.SMWithOAuthForTenant.GET(web.ServiceInstancesURL + "/" + instanceID).Expect(). + Status(http.StatusOK). + JSON(). + Object().Value("context").Object().Equal(map[string]interface{}{ + "platform": types.SMPlatform, + "instance_name": instanceName, + TenantIdentifier: TenantIDValue, + }) + }) }) + When("service instance doesn't contain tenant identifier in OSB context", func() { BeforeEach(func() { - _, serviceInstance = service_instance.Prepare(ctx, ctx.TestPlatform.ID, "", "{}") - _, err := ctx.SMRepository.Create(context.Background(), serviceInstance) - Expect(err).ToNot(HaveOccurred()) + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, "") + resp := createInstanceWithAsync(ctx.SMWithOAuth, false, http.StatusCreated) + instanceName = resp.JSON().Object().Value("name").String().Raw() + Expect(instanceName).ToNot(BeEmpty()) }) It("doesn't label instance with tenant identifier", func() { - obj := ctx.SMWithOAuth.GET(web.ServiceInstancesURL + "/" + serviceInstance.ID).Expect(). + obj := ctx.SMWithOAuth.GET(web.ServiceInstancesURL + "/" + instanceID).Expect(). Status(http.StatusOK).JSON().Object() objMap := obj.Raw() @@ -159,177 +263,643 @@ var _ = test.DescribeTestsFor(test.TestCase{ Expect(tenantLabelExists).To(BeFalse()) } }) + + It("returns OSB context with no tenant as part of the instance", func() { + ctx.SMWithOAuth.GET(web.ServiceInstancesURL + "/" + instanceID).Expect(). + Status(http.StatusOK). + JSON(). + Object().Value("context").Object().Equal(map[string]interface{}{ + "platform": types.SMPlatform, + "instance_name": instanceName, + }) + }) }) + When("service instance dashboard_url is not set", func() { BeforeEach(func() { - _, serviceInstance = service_instance.Prepare(ctx, ctx.TestPlatform.ID, "", fmt.Sprintf(`{"%s":"%s"}`, TenantIdentifier, TenantIDValue)) - serviceInstance.DashboardURL = "" - _, err := ctx.SMRepository.Create(context.Background(), serviceInstance) - Expect(err).ToNot(HaveOccurred()) + postInstanceRequest["dashboard_url"] = "" + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") + createInstanceWithAsync(ctx.SMWithOAuth, false, http.StatusCreated) }) It("doesn't return dashboard_url", func() { - ctx.SMWithOAuth.GET(web.ServiceInstancesURL + "/" + serviceInstance.ID).Expect(). + ctx.SMWithOAuth.GET(web.ServiceInstancesURL + "/" + instanceID).Expect(). Status(http.StatusOK).JSON().Object().NotContainsKey("dashboard_url") }) }) }) Describe("POST", func() { - When("content type is not JSON", func() { - It("returns 415", func() { - ctx.SMWithOAuth.POST(web.ServiceInstancesURL).WithText("text"). - Expect(). - Status(http.StatusUnsupportedMediaType). - JSON().Object(). - Keys().Contains("error", "description") - }) - }) - - When("request body is not a valid JSON", func() { - It("returns 400", func() { - ctx.SMWithOAuth.POST(web.ServiceInstancesURL). - WithText("invalid json"). - WithHeader("content-type", "application/json"). - Expect(). - Status(http.StatusBadRequest). - JSON().Object(). - Keys().Contains("error", "description") - }) - }) - - When("a request body field is missing", func() { - assertPOSTReturns400WhenFieldIsMissing := func(field string) { - var servicePlanID string - BeforeEach(func() { - servicePlanID = postInstanceRequest["service_plan_id"].(string) - delete(postInstanceRequest, field) - delete(expectedInstanceResponse, field) - }) - - It("returns 400", func() { - test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, "") - ctx.SMWithOAuth.POST(web.ServiceInstancesURL).WithJSON(postInstanceRequest). - Expect(). - Status(http.StatusBadRequest). - JSON().Object(). - Keys().Contains("error", "description") + for _, testCase := range testCases { + testCase := testCase + Context(fmt.Sprintf("async = %t", testCase.async), func() { + When("content type is not JSON", func() { + It("returns 415", func() { + ctx.SMWithOAuth.POST(web.ServiceInstancesURL). + WithQuery("async", testCase.async). + WithText("text"). + Expect(). + Status(http.StatusUnsupportedMediaType). + JSON().Object(). + Keys().Contains("error", "description") + }) }) - } - assertPOSTReturns201WhenFieldIsMissing := func(field string) { - BeforeEach(func() { - delete(postInstanceRequest, field) - delete(expectedInstanceResponse, field) + When("request body is not a valid JSON", func() { + It("returns 400", func() { + ctx.SMWithOAuth.POST(web.ServiceInstancesURL). + WithQuery("async", testCase.async). + WithText("invalid json"). + WithHeader("content-type", "application/json"). + Expect(). + Status(http.StatusBadRequest). + JSON().Object(). + Keys().Contains("error", "description") + }) }) - It("returns 201", func() { - test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") - createInstance(ctx.SMWithOAuth, http.StatusCreated) - }) - } - - Context("when id field is missing", func() { - assertPOSTReturns201WhenFieldIsMissing("id") - }) - - Context("when name field is missing", func() { - assertPOSTReturns400WhenFieldIsMissing("name") - }) - - Context("when service_plan_id field is missing", func() { - assertPOSTReturns400WhenFieldIsMissing("service_plan_id") - }) - - Context("when maintenance_info field is missing", func() { - assertPOSTReturns201WhenFieldIsMissing("maintenance_info") - }) - }) - - When("request body id field is provided", func() { - It("should return 400", func() { - test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") - postInstanceRequest["id"] = "test-instance-id" - resp := ctx.SMWithOAuth.POST(web.ServiceInstancesURL). - WithJSON(postInstanceRequest). - Expect().Status(http.StatusBadRequest).JSON().Object() - - Expect(resp.Value("description").String().Raw()).To(ContainSubstring("providing specific resource id is forbidden")) - }) - }) - - When("request body platform_id field is provided", func() { - Context("which is not service-manager platform", func() { - It("should return 400", func() { - postInstanceRequest["platform_id"] = "test-platform-id" - resp := ctx.SMWithOAuth.POST(web.ServiceInstancesURL). - WithJSON(postInstanceRequest). - Expect().Status(http.StatusBadRequest).JSON().Object() - - resp.Value("description").Equal("Providing platform_id property during provisioning/updating of a service instance is forbidden") + When("a request body field is missing", func() { + assertPOSTReturns400WhenFieldIsMissing := func(field string) { + var servicePlanID string + BeforeEach(func() { + servicePlanID = postInstanceRequest["service_plan_id"].(string) + delete(postInstanceRequest, field) + }) + + It("returns 400", func() { + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, "") + ctx.SMWithOAuth.POST(web.ServiceInstancesURL). + WithJSON(postInstanceRequest). + WithQuery("async", testCase.async). + Expect(). + Status(http.StatusBadRequest). + JSON().Object(). + Keys().Contains("error", "description") + }) + } + + assertPOSTReturns201WhenFieldIsMissing := func(field string) { + BeforeEach(func() { + delete(postInstanceRequest, field) + }) + + It("returns 201", func() { + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), TenantIDValue) + resp := createInstanceWithAsync(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedCreateSuccessStatusCode) + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.SUCCEEDED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyInstanceExists(instanceID, true) + }) + } + + Context("when id field is missing", func() { + assertPOSTReturns201WhenFieldIsMissing("id") + }) + + Context("when name field is missing", func() { + assertPOSTReturns400WhenFieldIsMissing("name") + }) + + Context("when service_plan_id field is missing", func() { + assertPOSTReturns400WhenFieldIsMissing("service_plan_id") + }) + + Context("when maintenance_info field is missing", func() { + assertPOSTReturns201WhenFieldIsMissing("maintenance_info") + }) }) - }) - Context("which is service-manager platform", func() { - It("should return 200", func() { - postInstanceRequest["platform_id"] = types.SMPlatform - test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") - createInstance(ctx.SMWithOAuth, http.StatusCreated) + When("request body id field is provided", func() { + It("should return 400", func() { + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") + postInstanceRequest["id"] = "test-instance-id" + resp := ctx.SMWithOAuth.POST(web.ServiceInstancesURL). + WithQuery("async", testCase.async). + WithJSON(postInstanceRequest). + Expect().Status(http.StatusBadRequest).JSON().Object() + + Expect(resp.Value("description").String().Raw()).To(ContainSubstring("providing specific resource id is forbidden")) + }) }) - }) - }) - - When("async query param", func() { - It("succeeds", func() { - test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") - resp := ctx.SMWithOAuth.POST(web.ServiceInstancesURL).WithJSON(postInstanceRequest). - WithQuery("async", "true"). - Expect(). - Status(http.StatusAccepted) - - op, err := test.ExpectOperation(ctx.SMWithOAuth, resp, types.SUCCEEDED) - Expect(err).To(BeNil()) - ctx.SMWithOAuth.GET(web.ServiceInstancesURL + "/" + op.Value("resource_id").String().Raw()).Expect(). - Status(http.StatusOK). - JSON().Object(). - ContainsMap(expectedInstanceResponse).ContainsKey("id") - }) - }) - - Context("instance visibility", func() { - When("tenant doesn't have plan visibility", func() { - It("returns 404", func() { - createInstance(ctx.SMWithOAuthForTenant, http.StatusNotFound) + When("request body platform_id field is provided", func() { + Context("which is not service-manager platform", func() { + It("should return 400", func() { + postInstanceRequest["platform_id"] = "test-platform-id" + resp := ctx.SMWithOAuth.POST(web.ServiceInstancesURL). + WithJSON(postInstanceRequest). + WithQuery("async", testCase.async). + Expect().Status(http.StatusBadRequest).JSON().Object() + + resp.Value("description").Equal("Providing platform_id property during provisioning/updating of a service instance is forbidden") + }) + }) + + Context("which is service-manager platform", func() { + It(fmt.Sprintf("should return %d", testCase.expectedCreateSuccessStatusCode), func() { + postInstanceRequest["platform_id"] = types.SMPlatform + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") + createInstanceWithAsync(ctx.SMWithOAuth, testCase.async, testCase.expectedCreateSuccessStatusCode) + }) + }) }) - }) - When("tenant has plan visibility", func() { - It("returns 201", func() { - test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, TenantIDValue) - createInstance(ctx.SMWithOAuthForTenant, http.StatusCreated) + Context("OSB context", func() { + BeforeEach(func() { + brokerServer.ServiceInstanceHandlerFunc(http.MethodPut, http.MethodPut+"1", func(req *http.Request) (int, map[string]interface{}) { + body, err := util.BodyToBytes(req.Body) + Expect(err).ToNot(HaveOccurred()) + tenantValue := gjson.GetBytes(body, "context."+TenantIdentifier).String() + Expect(tenantValue).To(Equal(TenantIDValue)) + platformValue := gjson.GetBytes(body, "context.platform").String() + Expect(platformValue).To(Equal(types.SMPlatform)) + + return http.StatusCreated, Object{} + }) + }) + + It("enriches the osb context with the tenant and sm platform", func() { + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), TenantIDValue) + createInstanceWithAsync(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedCreateSuccessStatusCode) + }) }) - }) - When("plan has public visibility", func() { - It("for global returns 201", func() { - test.EnsurePublicPlanVisibility(ctx.SMRepository, servicePlanID) - createInstance(ctx.SMWithOAuth, http.StatusCreated) + Context("instance visibility", func() { + When("tenant doesn't have plan visibility", func() { + It("returns 404", func() { + createInstanceWithAsync(ctx.SMWithOAuthForTenant, testCase.async, http.StatusNotFound) + }) + }) + + When("tenant has plan visibility", func() { + It(fmt.Sprintf("returns %d", testCase.expectedCreateSuccessStatusCode), func() { + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, TenantIDValue) + createInstanceWithAsync(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedCreateSuccessStatusCode) + }) + }) + + When("plan has public visibility", func() { + It(fmt.Sprintf("for global returns %d", testCase.expectedCreateSuccessStatusCode), func() { + EnsurePublicPlanVisibility(ctx.SMRepository, servicePlanID) + createInstanceWithAsync(ctx.SMWithOAuth, testCase.async, testCase.expectedCreateSuccessStatusCode) + }) + + It(fmt.Sprintf("for tenant returns %d", testCase.expectedCreateSuccessStatusCode), func() { + EnsurePublicPlanVisibility(ctx.SMRepository, servicePlanID) + createInstanceWithAsync(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedCreateSuccessStatusCode) + }) + }) }) - It("for tenant returns 201", func() { - test.EnsurePublicPlanVisibility(ctx.SMRepository, servicePlanID) - createInstance(ctx.SMWithOAuthForTenant, http.StatusCreated) + Context("broker scenarios", func() { + BeforeEach(func() { + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, TenantIDValue) + }) + + When("a create operation is already in progress", func() { + var doneChannel chan interface{} + + BeforeEach(func() { + doneChannel = make(chan interface{}) + + brokerServer.ServiceInstanceHandlerFunc(http.MethodPut, http.MethodPut+"1", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.ServiceInstanceLastOpHandlerFunc(http.MethodPut+"1", DelayingHandler(doneChannel)) + + resp := createInstanceWithAsync(ctx.SMWithOAuthForTenant, true, http.StatusAccepted) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.IN_PROGRESS, + ResourceType: types.ServiceInstanceType, + Reschedulable: true, + DeletionScheduled: false, + }) + + verifyInstanceExists(instanceID, false) + }) + + AfterEach(func() { + close(doneChannel) + }) + + It("updates fail with operation in progress", func() { + ctx.SMWithOAuthForTenant.PATCH(web.ServiceInstancesURL+"/"+instanceID).WithQuery("async", testCase.async).WithJSON(Object{}). + Expect().Status(http.StatusUnprocessableEntity) + }) + + It("deletes succeed", func() { + resp := ctx.SMWithOAuthForTenant.DELETE(web.ServiceInstancesURL+"/"+instanceID).WithQuery("async", testCase.async). + Expect().StatusRange(httpexpect.Status2xx) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.SUCCEEDED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyInstanceDoesNotExist(instanceID) + }) + }) + + When("plan does not exist", func() { + BeforeEach(func() { + postInstanceRequest["service_plan_id"] = "non-existing-id" + }) + + It("provision fails", func() { + createInstanceWithAsync(ctx.SMWithOAuthForTenant, testCase.async, http.StatusNotFound) + }) + }) + + When("broker responds with synchronous success", func() { + BeforeEach(func() { + brokerServer.ServiceInstanceHandlerFunc(http.MethodPut, http.MethodPut+"1", ParameterizedHandler(http.StatusCreated, Object{"async": false})) + }) + + It("stores instance as ready=true and the operation as success, non rescheduable with no deletion scheduled", func() { + resp := createInstanceWithAsync(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedCreateSuccessStatusCode) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.SUCCEEDED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyInstanceExists(instanceID, true) + }) + }) + + When("broker responds with asynchronous success", func() { + BeforeEach(func() { + brokerServer.ServiceInstanceHandlerFunc(http.MethodPut, http.MethodPut+"1", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.ServiceInstanceLastOpHandlerFunc(http.MethodPut+"1", MultiplePollsRequiredHandler("in progress", "succeeded")) + }) + + It("polling broker last operation until operation succeeds and eventually marks operation as success", func() { + resp := createInstanceWithAsync(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedCreateSuccessStatusCode) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.SUCCEEDED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyInstanceExists(instanceID, true) + }) + + if testCase.async { + When("job timeout is reached while polling", func() { + var oldCtx *TestContext + + BeforeEach(func() { + oldCtx = ctx + ctx = NewTestContextBuilderWithSecurity().WithEnvPreExtensions(func(set *pflag.FlagSet) { + Expect(set.Set("operations.job_timeout", (2 * time.Second).String())).ToNot(HaveOccurred()) + }).BuildWithoutCleanup() + + brokerServer.ServiceInstanceHandlerFunc(http.MethodPut, http.MethodPut+"1", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.ServiceInstanceLastOpHandlerFunc(http.MethodPut+"1", ParameterizedHandler(http.StatusOK, Object{"state": "in progress"})) + }) + + AfterEach(func() { + ctx = oldCtx + }) + + It("stores instance as ready false and the operation as reschedulable in progress", func() { + resp := createInstanceWithAsync(ctx.SMWithOAuthForTenant, true, http.StatusAccepted) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.IN_PROGRESS, + ResourceType: types.ServiceInstanceType, + Reschedulable: true, + DeletionScheduled: false, + }) + + verifyInstanceExists(instanceID, false) + }) + }) + } + + When("polling responds with unexpected state and eventually with success state", func() { + BeforeEach(func() { + brokerServer.ServiceInstanceHandlerFunc(http.MethodPut, http.MethodPut+"1", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.ServiceInstanceLastOpHandlerFunc(http.MethodPut+"1", MultiplePollsRequiredHandler("unknown", "succeeded")) + }) + + It("keeps polling and eventually updates the instance to ready true and operation to success", func() { + resp := createInstanceWithAsync(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedCreateSuccessStatusCode) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.SUCCEEDED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + verifyInstanceExists(instanceID, true) + }) + }) + + When("polling responds with unexpected state and eventually with failed state", func() { + BeforeEach(func() { + brokerServer.ServiceInstanceHandlerFunc(http.MethodPut, http.MethodPut+"2", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.ServiceInstanceLastOpHandlerFunc(http.MethodPut+"2", MultiplePollsRequiredHandler("unknown", "failed")) + }) + + When("orphan mitigation deprovision synchronously succeeds", func() { + BeforeEach(func() { + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusOK, Object{"async": false})) + }) + + It("deletes the instance and marks the operation that triggered the orphan mitigation as failed with no deletion scheduled and not reschedulable", func() { + resp := createInstanceWithAsync(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyInstanceDoesNotExist(instanceID) + }) + }) + + When("broker orphan mitigation deprovision synchronously fails with an unexpected status code", func() { + BeforeEach(func() { + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusBadRequest, Object{"error": "error"})) + }) + + AfterEach(func() { + brokerServer.ResetHandlers() + }) + + It("keeps in the instance with ready false and marks the operation with deletion scheduled", func() { + resp := createInstanceWithAsync(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: true, + }) + + verifyInstanceExists(instanceID, false) + }) + }) + + When("broker orphan mitigation deprovision synchronously fails with an error that will continue further orphan mitigation and eventually succeed", func() { + BeforeEach(func() { + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"3", MultipleErrorsBeforeSuccessHandler( + http.StatusInternalServerError, http.StatusOK, + Object{"error": "error"}, Object{"async": false}, + )) + }) + + It("deletes the instance and marks the operation that triggered the orphan mitigation as failed with no deletion scheduled and not reschedulable", func() { + resp := createInstanceWithAsync(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyInstanceDoesNotExist(instanceID) + }) + }) + }) + + When("polling returns an unexpected status code", func() { + BeforeEach(func() { + brokerServer.ServiceInstanceHandlerFunc(http.MethodPut, http.MethodPut+"3", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.ServiceInstanceLastOpHandlerFunc(http.MethodPut+"3", ParameterizedHandler(http.StatusInternalServerError, Object{"error": "error"})) + }) + + It("stores the instance as ready false and marks the operation as reschedulable", func() { + resp := createInstanceWithAsync(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: true, + DeletionScheduled: false, + }) + + verifyInstanceExists(instanceID, false) + }) + }) + }) + + When("provision responds with error due to stopped broker", func() { + BeforeEach(func() { + brokerServer.Close() + delete(ctx.Servers, BrokerServerPrefix+brokerID) + }) + + It("does not store instance in SMDB and marks operation with failed", func() { + resp := createInstanceWithAsync(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyInstanceDoesNotExist(instanceID) + }) + }) + + When("provision responds with error that does not require orphan mitigation", func() { + BeforeEach(func() { + brokerServer.ServiceInstanceHandlerFunc(http.MethodPut, http.MethodPut+"3", ParameterizedHandler(http.StatusBadRequest, Object{"error": "error"})) + }) + + It("does not store the instance and marks the operation as failed, non rescheduable with empty deletion scheduled", func() { + resp := createInstanceWithAsync(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyInstanceDoesNotExist(instanceID) + }) + }) + + When("provision responds with error that requires orphan mitigation", func() { + BeforeEach(func() { + brokerServer.ServiceInstanceHandlerFunc(http.MethodPut, http.MethodPut+"3", ParameterizedHandler(http.StatusInternalServerError, Object{"error": "error"})) + }) + + AfterEach(func() { + brokerServer.ResetHandlers() + }) + + When("orphan mitigation deprovision asynchronously succeeds", func() { + BeforeEach(func() { + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.ServiceInstanceLastOpHandlerFunc(http.MethodDelete+"3", ParameterizedHandler(http.StatusOK, Object{"state": "succeeded"})) + }) + + It("deletes the instance and marks the operation that triggered the orphan mitigation as failed with no deletion scheduled and not reschedulable", func() { + resp := createInstanceWithAsync(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyInstanceDoesNotExist(instanceID) + }) + + When("maximum deletion timout has been reached", func() { + var oldCtx *TestContext + BeforeEach(func() { + oldCtx = ctx + ctx = NewTestContextBuilderWithSecurity().WithEnvPreExtensions(func(set *pflag.FlagSet) { + Expect(set.Set("operations.scheduled_deletion_timeout", (2 * time.Millisecond).String())).ToNot(HaveOccurred()) + }).BuildWithoutCleanup() + }) + + AfterEach(func() { + ctx = oldCtx + }) + + It("keeps the instance as ready false and marks the operation as deletion scheduled", func() { + resp := createInstanceWithAsync(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: true, + }) + + verifyInstanceExists(instanceID, false) + }) + }) + }) + + if testCase.async { + When("broker orphan mitigation deprovision asynchronously keeps failing with an error while polling", func() { + BeforeEach(func() { + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.ServiceInstanceLastOpHandlerFunc(http.MethodDelete+"3", ParameterizedHandler(http.StatusBadRequest, Object{"error": "error"})) + }) + + It("keeps the instance as ready false and marks the operation as deletion scheduled", func() { + resp := createInstanceWithAsync(ctx.SMWithOAuthForTenant, true, http.StatusAccepted) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: true, + DeletionScheduled: true, + }) + + verifyInstanceExists(instanceID, false) + }) + }) + } + + When("broker orphan mitigation deprovision asynchronously fails with an error that will continue further orphan mitigation and eventually succeed", func() { + BeforeEach(func() { + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + + brokerServer.ServiceInstanceLastOpHandlerFunc(http.MethodDelete+"3", MultipleErrorsBeforeSuccessHandler( + http.StatusOK, http.StatusOK, + Object{"state": "failed"}, Object{"state": "succeeded"}, + )) + }) + + It("deletes the instance and marks the operation that triggered the orphan mitigation as failed with no deletion scheduled and not reschedulable", func() { + resp := createInstanceWithAsync(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyInstanceDoesNotExist(instanceID) + }) + }) + }) + + When("provision responds with error due to times out", func() { + var doneChannel chan interface{} + var oldCtx *TestContext + + BeforeEach(func() { + oldCtx = ctx + doneChannel = make(chan interface{}) + ctx = NewTestContextBuilderWithSecurity().WithEnvPreExtensions(func(set *pflag.FlagSet) { + Expect(set.Set("httpclient.response_header_timeout", (1 * time.Second).String())).ToNot(HaveOccurred()) + }).BuildWithoutCleanup() + + brokerServer.ServiceInstanceHandlerFunc(http.MethodPut, http.MethodPut+"1", DelayingHandler(doneChannel)) + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"1", ParameterizedHandler(http.StatusOK, Object{})) + }) + + AfterEach(func() { + ctx = oldCtx + }) + + It("orphan mitigates the instance", func() { + resp := createInstanceWithAsync(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + <-time.After(1100 * time.Millisecond) + close(doneChannel) + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyInstanceDoesNotExist(instanceID) + }) + }) }) }) - }) + } }) Describe("PATCH", func() { When("content type is not JSON", func() { It("returns 415", func() { - instanceID := fmt.Sprintf("%s", postInstanceRequest["id"]) - ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL+"/"+instanceID). + ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL+"/instance-id"). WithText("text"). Expect().Status(http.StatusUnsupportedMediaType). JSON().Object(). @@ -349,7 +919,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ When("request body is not valid JSON", func() { It("returns 400", func() { - ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL+"/"+instanceID). + ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL+"/instance-id"). WithText("invalid json"). WithHeader("content-type", "application/json"). Expect(). @@ -361,17 +931,20 @@ var _ = test.DescribeTestsFor(test.TestCase{ When("created_at provided in body", func() { It("should not change created at", func() { - test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") - createInstance(ctx.SMWithOAuth, http.StatusCreated) + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") + resp := createInstance(ctx.SMWithOAuth, http.StatusAccepted) + instance := ExpectSuccessfulAsyncResourceCreation(resp, ctx.SMWithOAuth, web.ServiceInstancesURL) + instanceID := instance["id"].(string) createdAt := "2015-01-01T00:00:00Z" - ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL+"/"+instanceID). - WithJSON(common.Object{"created_at": createdAt}). + resp = ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL + "/" + instanceID). + WithJSON(Object{"created_at": createdAt}). Expect(). - Status(http.StatusOK).JSON().Object(). - ContainsKey("created_at"). - ValueNotEqual("created_at", createdAt) + Status(http.StatusAccepted) + + instance = ExpectSuccessfulAsyncResourceCreation(resp, ctx.SMWithOAuth, web.ServiceInstancesURL) + Expect(instance["created_at"].(string)).ToNot(Equal(createdAt)) ctx.SMWithOAuth.GET(web.ServiceInstancesURL+"/"+instanceID). Expect(). @@ -384,11 +957,11 @@ var _ = test.DescribeTestsFor(test.TestCase{ When("platform_id provided in body", func() { Context("which is not service-manager platform", func() { It("should return 400", func() { - test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") - createInstance(ctx.SMWithOAuth, http.StatusCreated) + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") + createInstanceWithAsync(ctx.SMWithOAuth, false, http.StatusCreated) resp := ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL + "/" + instanceID). - WithJSON(common.Object{"platform_id": "test-platform-id"}). + WithJSON(Object{"platform_id": "test-platform-id"}). Expect().Status(http.StatusBadRequest).JSON().Object() resp.Value("description").Equal("Providing platform_id property during provisioning/updating of a service instance is forbidden") @@ -403,12 +976,17 @@ var _ = test.DescribeTestsFor(test.TestCase{ Context("which is service-manager platform", func() { It("should return 200", func() { - test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") - createInstance(ctx.SMWithOAuth, http.StatusCreated) + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") + resp := createInstance(ctx.SMWithOAuth, http.StatusAccepted) + instance := ExpectSuccessfulAsyncResourceCreation(resp, ctx.SMWithOAuth, web.ServiceInstancesURL) + instanceID := instance["id"].(string) + + resp = ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL + "/" + instanceID). + WithJSON(Object{"platform_id": types.SMPlatform}). + Expect().Status(http.StatusAccepted) - ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL + "/" + instanceID). - WithJSON(common.Object{"platform_id": types.SMPlatform}). - Expect().Status(http.StatusOK).JSON().Object() + instance = ExpectSuccessfulAsyncResourceCreation(resp, ctx.SMWithOAuth, web.ServiceInstancesURL) + Expect(instance["platform_id"].(string)).To(Equal(types.SMPlatform)) ctx.SMWithOAuth.GET(web.ServiceInstancesURL+"/"+instanceID). Expect(). @@ -422,18 +1000,20 @@ var _ = test.DescribeTestsFor(test.TestCase{ When("fields are updated one by one", func() { It("returns 200", func() { - test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") - createInstance(ctx.SMWithOAuth, http.StatusCreated) + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") + resp := createInstance(ctx.SMWithOAuth, http.StatusAccepted) + instance := ExpectSuccessfulAsyncResourceCreation(resp, ctx.SMWithOAuth, web.ServiceInstancesURL) + instanceID := instance["id"].(string) for _, prop := range []string{"name", "maintenance_info"} { - updatedBrokerJSON := common.Object{} + updatedBrokerJSON := Object{} updatedBrokerJSON[prop] = "updated-" + prop - ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL + "/" + instanceID). + resp = ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL + "/" + instanceID). WithJSON(updatedBrokerJSON). Expect(). - Status(http.StatusOK). - JSON().Object(). - ContainsMap(updatedBrokerJSON) + Status(http.StatusAccepted) + + ExpectSuccessfulAsyncResourceCreation(resp, ctx.SMWithOAuth, web.ServiceInstancesURL) ctx.SMWithOAuth.GET(web.ServiceInstancesURL + "/" + instanceID). Expect(). @@ -448,24 +1028,28 @@ var _ = test.DescribeTestsFor(test.TestCase{ Context("instance visibility", func() { When("tenant doesn't have plan visibility", func() { It("returns 404", func() { - test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, TenantIDValue) - createInstance(ctx.SMWithOAuthForTenant, http.StatusCreated) + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, TenantIDValue) + createInstanceWithAsync(ctx.SMWithOAuthForTenant, false, http.StatusCreated) ctx.SMWithOAuthForTenant.PATCH(web.ServiceInstancesURL + "/" + instanceID). - WithJSON(common.Object{"service_plan_id": anotherServicePlanID}). + WithJSON(Object{"service_plan_id": anotherServicePlanID}). Expect().Status(http.StatusNotFound) }) }) When("tenant has plan visibility", func() { It("returns 201", func() { - test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, TenantIDValue) - createInstance(ctx.SMWithOAuthForTenant, http.StatusCreated) + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, TenantIDValue) + resp := createInstance(ctx.SMWithOAuthForTenant, http.StatusAccepted) + instance := ExpectSuccessfulAsyncResourceCreation(resp, ctx.SMWithOAuth, web.ServiceInstancesURL) + instanceID := instance["id"].(string) - test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, anotherServicePlanID, TenantIDValue) - ctx.SMWithOAuthForTenant.PATCH(web.ServiceInstancesURL + "/" + instanceID). - WithJSON(common.Object{"service_plan_id": anotherServicePlanID}). - Expect().Status(http.StatusOK) + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, anotherServicePlanID, TenantIDValue) + resp = ctx.SMWithOAuthForTenant.PATCH(web.ServiceInstancesURL + "/" + instanceID). + WithJSON(Object{"service_plan_id": anotherServicePlanID}). + Expect().Status(http.StatusAccepted) + + ExpectSuccessfulAsyncResourceCreation(resp, ctx.SMWithOAuth, web.ServiceInstancesURL) }) }) }) @@ -473,81 +1057,622 @@ var _ = test.DescribeTestsFor(test.TestCase{ Context("instance ownership", func() { When("tenant doesn't have ownership of instance", func() { It("returns 404", func() { - test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") - createInstance(ctx.SMWithOAuth, http.StatusCreated) + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") + createInstanceWithAsync(ctx.SMWithOAuth, false, http.StatusCreated) ctx.SMWithOAuthForTenant.PATCH(web.ServiceInstancesURL + "/" + instanceID). - WithJSON(common.Object{"service_plan_id": anotherServicePlanID}). + WithJSON(Object{"service_plan_id": anotherServicePlanID}). Expect().Status(http.StatusNotFound) }) }) When("tenant has ownership of instance", func() { It("returns 200", func() { - test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, TenantIDValue) - createInstance(ctx.SMWithOAuthForTenant, http.StatusCreated) + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, TenantIDValue) + resp := createInstance(ctx.SMWithOAuthForTenant, http.StatusAccepted) + instance := ExpectSuccessfulAsyncResourceCreation(resp, ctx.SMWithOAuth, web.ServiceInstancesURL) + instanceID := instance["id"].(string) - ctx.SMWithOAuthForTenant.PATCH(web.ServiceInstancesURL + "/" + instanceID). - WithJSON(common.Object{"platform_id": types.SMPlatform}). - Expect().Status(http.StatusOK) + resp = ctx.SMWithOAuthForTenant.PATCH(web.ServiceInstancesURL + "/" + instanceID). + WithJSON(Object{"platform_id": types.SMPlatform}). + Expect().Status(http.StatusAccepted) + + ExpectSuccessfulAsyncResourceCreation(resp, ctx.SMWithOAuth, web.ServiceInstancesURL) }) }) }) }) Describe("DELETE", func() { - Context("instance ownership", func() { - When("tenant doesn't have ownership of instance", func() { - It("returns 404", func() { - test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") - createInstance(ctx.SMWithOAuth, http.StatusCreated) + It("returns 405 for bulk delete", func() { + ctx.SMWithOAuthForTenant.DELETE(web.ServiceInstancesURL). + Expect().Status(http.StatusMethodNotAllowed) + }) - ctx.SMWithOAuthForTenant.DELETE(web.ServiceInstancesURL + "/" + instanceID). - Expect().Status(http.StatusNotFound) - }) - }) + for _, testCase := range testCases { + testCase := testCase - When("tenant doesn't have ownership of some instances in bulk delete", func() { - It("returns 404", func() { - test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") - createInstance(ctx.SMWithOAuth, http.StatusCreated) + Context(fmt.Sprintf("async = %t", testCase.async), func() { + BeforeEach(func() { + brokerServer.ShouldRecordRequests(true) + }) - ctx.SMWithOAuthForTenant.DELETE(web.ServiceInstancesURL). - Expect().Status(http.StatusNotFound) + AfterEach(func() { + brokerServer.ResetHandlers() + ctx.SMWithOAuth.DELETE(web.ServiceInstancesURL + "/" + instanceID).Expect() + ctx.SMWithOAuthForTenant.DELETE(web.ServiceInstancesURL + "/" + instanceID).Expect() }) - }) - When("tenant has ownership of instance", func() { - It("returns 200", func() { - test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, TenantIDValue) - createInstance(ctx.SMWithOAuthForTenant, http.StatusCreated) + Context("instance ownership", func() { + When("tenant doesn't have ownership of instance", func() { + It("returns 404", func() { + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") + resp := createInstanceWithAsync(ctx.SMWithOAuth, testCase.async, testCase.expectedCreateSuccessStatusCode) + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.SUCCEEDED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + expectedCode := http.StatusNotFound + if testCase.async { + expectedCode = http.StatusAccepted + } + deleteInstance(ctx.SMWithOAuthForTenant, testCase.async, expectedCode) + }) + }) + + When("tenant has ownership of instance", func() { + It("returns 200", func() { + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, TenantIDValue) + resp := createInstanceWithAsync(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedCreateSuccessStatusCode) + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.SUCCEEDED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + + resp = deleteInstance(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedDeleteSuccessStatusCode) + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.SUCCEEDED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + verifyInstanceDoesNotExist(instanceID) + }) + }) + }) - ctx.SMWithOAuthForTenant.DELETE(web.ServiceInstancesURL + "/" + instanceID). - Expect().Status(http.StatusOK) + Context("broker scenarios", func() { + BeforeEach(func() { + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, TenantIDValue) + resp := createInstanceWithAsync(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedCreateSuccessStatusCode) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.CREATE, + State: types.SUCCEEDED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyInstanceExists(instanceID, true) + }) + + When("a delete operation is already in progress", func() { + var doneChannel chan interface{} + + BeforeEach(func() { + doneChannel = make(chan interface{}) + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"1", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.ServiceInstanceLastOpHandlerFunc(http.MethodDelete+"1", DelayingHandler(doneChannel)) + + resp := deleteInstance(ctx.SMWithOAuthForTenant, true, http.StatusAccepted) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.IN_PROGRESS, + ResourceType: types.ServiceInstanceType, + Reschedulable: true, + DeletionScheduled: false, + }) + + verifyInstanceExists(instanceID, true) + }) + + AfterEach(func() { + close(doneChannel) + }) + + It("updates fail with operation in progress", func() { + ctx.SMWithOAuthForTenant.PATCH(web.ServiceInstancesURL+"/"+instanceID).WithQuery("async", testCase.async).WithJSON(Object{}). + Expect().Status(http.StatusUnprocessableEntity) + }) + + It("deletes fail with operation in progress", func() { + ctx.SMWithOAuthForTenant.DELETE(web.ServiceInstancesURL+"/"+instanceID).WithQuery("async", testCase.async). + Expect().Status(http.StatusUnprocessableEntity) + }) + }) + + When("binding exists for the instance", func() { + var bindingID string + + AfterEach(func() { + ctx.SMWithOAuthForTenant.DELETE(web.ServiceBindingsURL + "/" + bindingID). + Expect().StatusRange(httpexpect.Status2xx) + }) + + It("fails to delete it and marks the operation as failed", func() { + bindingID = ctx.SMWithOAuthForTenant.POST(web.ServiceBindingsURL). + WithQuery("async", false). + WithJSON(Object{ + "name": "test-service-binding", + "service_instance_id": instanceID, + }). + Expect(). + Status(http.StatusCreated).JSON().Object().Value("id").String().Raw() + + expectedStatus := http.StatusBadRequest + if testCase.async { + expectedStatus = http.StatusAccepted + } + resp := deleteInstance(ctx.SMWithOAuthForTenant, testCase.async, expectedStatus) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyInstanceExists(instanceID, true) + }) + }) + + When("broker responds with synchronous success", func() { + BeforeEach(func() { + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"1", ParameterizedHandler(http.StatusOK, Object{"async": false})) + }) + + It("deletes the instance and stores a delete succeeded operation", func() { + resp := deleteInstance(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedDeleteSuccessStatusCode) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.SUCCEEDED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyInstanceDoesNotExist(instanceID) + }) + }) + + When("broker responds with 410 GONE", func() { + BeforeEach(func() { + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"1", ParameterizedHandler(http.StatusGone, Object{})) + }) + + It("deletes the instance and stores a delete succeeded operation", func() { + resp := deleteInstance(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedDeleteSuccessStatusCode) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.SUCCEEDED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyInstanceDoesNotExist(instanceID) + }) + }) + + When("broker responds with asynchronous success", func() { + BeforeEach(func() { + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"1", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.ServiceInstanceLastOpHandlerFunc(http.MethodDelete+"1", MultiplePollsRequiredHandler("in progress", "succeeded")) + }) + + It("polling broker last operation until operation succeeds and eventually marks operation as success", func() { + resp := deleteInstance(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedDeleteSuccessStatusCode) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.SUCCEEDED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyInstanceDoesNotExist(instanceID) + }) + + When("polling responds 410 GONE", func() { + BeforeEach(func() { + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"1", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.ServiceInstanceLastOpHandlerFunc(http.MethodDelete+"1", ParameterizedHandler(http.StatusGone, Object{})) + }) + + It("keeps polling and eventually deletes the binding and marks the operation as success", func() { + resp := deleteInstance(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedDeleteSuccessStatusCode) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.SUCCEEDED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyInstanceDoesNotExist(instanceID) + }) + }) + + When("polling responds with unexpected state and eventually with success state", func() { + BeforeEach(func() { + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"1", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.ServiceInstanceLastOpHandlerFunc(http.MethodDelete+"1", MultiplePollsRequiredHandler("unknown", "succeeded")) + }) + + It("keeps polling and eventually deletes the instance and marks the operation as success", func() { + resp := deleteInstance(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedDeleteSuccessStatusCode) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.SUCCEEDED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyInstanceDoesNotExist(instanceID) + }) + }) + + When("polling responds with unexpected state and eventually with failed state", func() { + BeforeEach(func() { + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"2", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.ServiceInstanceLastOpHandlerFunc(http.MethodDelete+"2", MultiplePollsRequiredHandler("unknown", "failed")) + }) + + When("orphan mitigation deprovision synchronously succeeds", func() { + It("deletes the instance and marks the operation as success", func() { + resp := deleteInstance(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: true, + }) + + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"2", ParameterizedHandler(http.StatusOK, Object{"async": false})) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.SUCCEEDED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyInstanceDoesNotExist(instanceID) + }) + }) + + When("broker orphan mitigation deprovision synchronously fails with an unexpected error", func() { + It("keeps in the instance and marks the operation with deletion scheduled", func() { + resp := deleteInstance(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: true, + }) + + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"2", ParameterizedHandler(http.StatusBadRequest, Object{"error": "error"})) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: true, + }) + + verifyInstanceExists(instanceID, true) + }) + }) + + When("broker orphan mitigation deprovision synchronously fails with an error that will continue further orphan mitigation and eventually succeed", func() { + It("deletes the instance and marks the operation that triggered the orphan mitigation as failed with no deletion scheduled and not reschedulable", func() { + resp := deleteInstance(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: true, + }) + + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"2", MultipleErrorsBeforeSuccessHandler( + http.StatusInternalServerError, http.StatusOK, + Object{"error": "error"}, Object{"async": false}, + )) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.SUCCEEDED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyInstanceDoesNotExist(instanceID) + }) + }) + + When("maximum deletion timout has been reached", func() { + var oldCtx *TestContext + BeforeEach(func() { + oldCtx = ctx + ctx = NewTestContextBuilderWithSecurity().WithEnvPreExtensions(func(set *pflag.FlagSet) { + Expect(set.Set("operations.scheduled_deletion_timeout", (2 * time.Millisecond).String())).ToNot(HaveOccurred()) + }).BuildWithoutCleanup() + }) + + AfterEach(func() { + ctx = oldCtx + }) + + It("keeps the instance as ready false and marks the operation as deletion scheduled", func() { + resp := deleteInstance(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: true, + }) + + verifyInstanceExists(instanceID, true) + }) + }) + }) + + When("polling returns an unexpected status code", func() { + BeforeEach(func() { + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.ServiceInstanceLastOpHandlerFunc(http.MethodDelete+"3", ParameterizedHandler(http.StatusInternalServerError, Object{"error": "error"})) + }) + + It("keeps the instance and stores the operation as reschedulable", func() { + resp := deleteInstance(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: true, + DeletionScheduled: false, + }) + + verifyInstanceExists(instanceID, true) + }) + }) + }) + + When("deprovision responds with error due to stopped broker", func() { + BeforeEach(func() { + brokerServer.Close() + delete(ctx.Servers, BrokerServerPrefix+brokerID) + }) + + It("keeps the instance and marks operation with failed", func() { + resp := deleteInstance(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyInstanceExists(instanceID, true) + }) + }) + + When("deprovision responds with error that does not require orphan mitigation", func() { + BeforeEach(func() { + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusBadRequest, Object{"error": "error"})) + }) + + It("keeps the instance and marks the operation as failed", func() { + resp := deleteInstance(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyInstanceExists(instanceID, true) + }) + }) + + When("deprovision responds with error that requires orphan mitigation", func() { + BeforeEach(func() { + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusInternalServerError, Object{"error": "error"})) + }) + + When("orphan mitigation deprovision asynchronously succeeds", func() { + It("deletes the instance and marks the operation as success", func() { + resp := deleteInstance(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: true, + }) + + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.ServiceInstanceLastOpHandlerFunc(http.MethodDelete+"3", ParameterizedHandler(http.StatusOK, Object{"state": "succeeded"})) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.SUCCEEDED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyInstanceDoesNotExist(instanceID) + }) + }) + + if testCase.async { + When("broker orphan mitigation deprovision asynchronously keeps failing with an error while polling", func() { + It("keeps the instance and marks the operation as failed reschedulable with deletion scheduled", func() { + resp := deleteInstance(ctx.SMWithOAuthForTenant, true, http.StatusAccepted) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: true, + }) + + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.ServiceInstanceLastOpHandlerFunc(http.MethodDelete+"3", ParameterizedHandler(http.StatusBadRequest, Object{"error": "error"})) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: true, + DeletionScheduled: true, + }) + + verifyInstanceExists(instanceID, true) + }) + }) + } + + When("broker orphan mitigation deprovision asynchronously fails with an error that will continue further orphan mitigation and eventually succeed", func() { + It("deletes the instance and marks the operation as success", func() { + resp := deleteInstance(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: true, + }) + + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.ServiceInstanceLastOpHandlerFunc(http.MethodDelete+"3", MultipleErrorsBeforeSuccessHandler( + http.StatusOK, http.StatusOK, + Object{"state": "failed"}, Object{"state": "succeeded"}, + )) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.SUCCEEDED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyInstanceDoesNotExist(instanceID) + }) + }) + }) + + When("deprovision responds with error due to times out", func() { + var doneChannel chan interface{} + + BeforeEach(func() { + doneChannel = make(chan interface{}) + + ctx = NewTestContextBuilderWithSecurity().WithEnvPreExtensions(func(set *pflag.FlagSet) { + Expect(set.Set("httpclient.response_header_timeout", (1 * time.Second).String())).ToNot(HaveOccurred()) + }).BuildWithoutCleanup() + + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"1", DelayingHandler(doneChannel)) + + }) + + It("orphan mitigates the instance", func() { + resp := deleteInstance(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + <-time.After(1100 * time.Millisecond) + close(doneChannel) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: true, + }) + + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"1", ParameterizedHandler(http.StatusOK, Object{"async": false})) + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + Category: types.DELETE, + State: types.SUCCEEDED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + }) + + verifyInstanceDoesNotExist(instanceID) + }) + }) }) }) - }) + } }) }) }, }) -func blueprint(ctx *common.TestContext, auth *common.SMExpect, async bool) common.Object { +func blueprint(ctx *TestContext, auth *SMExpect, async bool) Object { ID, err := uuid.NewV4() if err != nil { panic(err) } - instanceReqBody := make(common.Object, 0) + instanceReqBody := make(Object, 0) instanceReqBody["name"] = "test-service-instance-" + ID.String() - instanceReqBody["service_plan_id"] = generateServicePlanIDs(ctx, auth).First().Object().Value("id").String().Raw() + _, _, array := prepareBrokerWithCatalog(ctx, auth) + instanceReqBody["service_plan_id"] = array.First().Object().Value("id").String().Raw() - test.EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, instanceReqBody["service_plan_id"].(string), "") + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, instanceReqBody["service_plan_id"].(string), "") resp := auth.POST(web.ServiceInstancesURL).WithQuery("async", strconv.FormatBool(async)).WithJSON(instanceReqBody).Expect() var instance map[string]interface{} if async { - instance = test.ExpectSuccessfulAsyncResourceCreation(resp, auth, web.ServiceInstancesURL) + instance = ExpectSuccessfulAsyncResourceCreation(resp, auth, web.ServiceInstancesURL) } else { instance = resp.Status(http.StatusCreated).JSON().Object().Raw() } @@ -555,15 +1680,16 @@ func blueprint(ctx *common.TestContext, auth *common.SMExpect, async bool) commo return instance } -func generateServicePlanIDs(ctx *common.TestContext, auth *common.SMExpect) *httpexpect.Array { - cPaidPlan1 := common.GeneratePaidTestPlan() - cPaidPlan2 := common.GeneratePaidTestPlan() - cService := common.GenerateTestServiceWithPlans(cPaidPlan1, cPaidPlan2) - catalog := common.NewEmptySBCatalog() +func prepareBrokerWithCatalog(ctx *TestContext, auth *SMExpect) (string, *BrokerServer, *httpexpect.Array) { + cPaidPlan1 := GeneratePaidTestPlan() + cPaidPlan2 := GeneratePaidTestPlan() + cService := GenerateTestServiceWithPlans(cPaidPlan1, cPaidPlan2) + catalog := NewEmptySBCatalog() catalog.AddService(cService) - brokerID, _, _ := ctx.RegisterBrokerWithCatalog(catalog) + brokerID, _, server := ctx.RegisterBrokerWithCatalog(catalog) + ctx.Servers[BrokerServerPrefix+brokerID] = server so := auth.ListWithQuery(web.ServiceOfferingsURL, fmt.Sprintf("fieldQuery=broker_id eq '%s'", brokerID)).First() - return auth.ListWithQuery(web.ServicePlansURL, "fieldQuery="+fmt.Sprintf("service_offering_id eq '%s'", so.Object().Value("id").String().Raw())) + return brokerID, server, auth.ListWithQuery(web.ServicePlansURL, "fieldQuery="+fmt.Sprintf("service_offering_id eq '%s'", so.Object().Value("id").String().Raw())) } diff --git a/test/storage_test/storage_test.go b/test/storage_test/storage_test.go index a2ea03955..18736c9bc 100644 --- a/test/storage_test/storage_test.go +++ b/test/storage_test/storage_test.go @@ -32,7 +32,8 @@ var _ = Describe("Test", func() { ctx = common.NewTestContextBuilderWithSecurity().Build() platform, err = ctx.SMRepository.Create(context.Background(), &types.Platform{ Base: types.Base{ - ID: "id", + ID: "id", + Ready: true, }, Description: "desc", Name: "platform_name", diff --git a/test/test.go b/test/test.go index de484da8e..02c845f48 100644 --- a/test/test.go +++ b/test/test.go @@ -24,6 +24,8 @@ import ( "strconv" "strings" + "github.com/Peripli/service-manager/pkg/util" + "time" "github.com/Peripli/service-manager/pkg/query" @@ -146,6 +148,7 @@ func ExpectOperation(auth *common.SMExpect, asyncResp *httpexpect.Response, expe return ExpectOperationWithError(auth, asyncResp, expectedState, "") } +//TODO this should be replaced as it does not verify enough func ExpectOperationWithError(auth *common.SMExpect, asyncResp *httpexpect.Response, expectedState types.OperationState, expectedErrMsg string) (*httpexpect.Object, error) { operationURL := asyncResp.Header("Location").Raw() @@ -162,12 +165,11 @@ func ExpectOperationWithError(auth *common.SMExpect, asyncResp *httpexpect.Respo Expect().Status(http.StatusOK).JSON().Object() state := operation.Value("state").String().Raw() if state == string(expectedState) { - errs := operation.Value("errors") if expectedState == types.SUCCEEDED { - errs.Null() } else { + errs := operation.Value("errors") errs.NotNull() - errMsg := errs.Object().Value("message").String().Raw() + errMsg := errs.Object().Value("description").String().Raw() if !strings.Contains(errMsg, expectedErrMsg) { err = fmt.Errorf("unable to verify operation - expected error message (%s), but got (%s)", expectedErrMsg, errs.String().Raw()) @@ -185,6 +187,13 @@ func EnsurePublicPlanVisibility(repository storage.Repository, planID string) { } func EnsurePlanVisibility(repository storage.Repository, tenantIdentifier, platformID, planID, tenantID string) { + byPlanID := query.ByField(query.EqualsOperator, "service_plan_id", planID) + byPlatformID := query.ByField(query.EqualsOperator, "platform_id", platformID) + if err := repository.Delete(context.TODO(), types.VisibilityType, byPlanID, byPlatformID); err != nil { + if err != util.ErrNotFoundInStorage { + panic(err) + } + } UUID, err := uuid.NewV4() if err != nil { panic(fmt.Errorf("could not generate GUID for visibility: %s", err)) diff --git a/test/testutil/service_instance/service_instances.go b/test/testutil/service_instance/service_instances.go deleted file mode 100644 index 170ffc51d..000000000 --- a/test/testutil/service_instance/service_instances.go +++ /dev/null @@ -1,56 +0,0 @@ -package service_instance - -import ( - "context" - "fmt" - "github.com/Peripli/service-manager/pkg/query" - "github.com/Peripli/service-manager/pkg/types" - "github.com/Peripli/service-manager/test/common" - "github.com/gofrs/uuid" - "time" - - . "github.com/onsi/ginkgo" -) - -func Prepare(ctx *common.TestContext, platformID, planID string, OSBContext string) (string, *types.ServiceInstance) { - var brokerID string - if planID == "" { - cService := common.GenerateTestServiceWithPlans(common.GenerateFreeTestPlan()) - catalog := common.NewEmptySBCatalog() - catalog.AddService(cService) - brokerID, _, _ = ctx.RegisterBrokerWithCatalog(catalog) - - byBrokerID := query.ByField(query.EqualsOperator, "broker_id", brokerID) - obj, err := ctx.SMRepository.Get(context.Background(), types.ServiceOfferingType, byBrokerID) - if err != nil { - Fail(fmt.Sprintf("unable to fetch service offering: %s", err)) - } - - byServiceOfferingID := query.ByField(query.EqualsOperator, "service_offering_id", obj.GetID()) - obj, err = ctx.SMRepository.Get(context.Background(), types.ServicePlanType, byServiceOfferingID) - if err != nil { - Fail(fmt.Sprintf("unable to service plan: %s", err)) - } - planID = obj.GetID() - } - - instanceID, err := uuid.NewV4() - if err != nil { - Fail(fmt.Sprintf("failed to generate instance GUID: %s", err)) - } - - return brokerID, &types.ServiceInstance{ - Base: types.Base{ - ID: instanceID.String(), - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - }, - Name: "test-service-instance", - ServicePlanID: planID, - PlatformID: platformID, - DashboardURL: "http://test-service.com/dashboard", - Context: []byte(OSBContext), - Ready: true, - Usable: true, - } -} diff --git a/test/visibility_test/visibility_test.go b/test/visibility_test/visibility_test.go index 52d4caea6..6ba91c5d5 100644 --- a/test/visibility_test/visibility_test.go +++ b/test/visibility_test/visibility_test.go @@ -90,7 +90,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ "labels": labels, } - common.RemoveAllVisibilities(ctx.SMWithOAuth) + common.RemoveAllVisibilities(ctx.SMRepository) }) From ff8fc93ac8fca9ec82014773798f8ba5351a1225 Mon Sep 17 00:00:00 2001 From: Nikolay Mateev Date: Wed, 5 Feb 2020 09:57:03 +0200 Subject: [PATCH 10/15] Public plan visibility composition based on plan supported platforms + broker platform notifications (#408) --- Gopkg.lock | 24 +-- .../catalog_filter_by_visibility_plugin.go | 28 ++- pkg/types/platform.go | 3 +- pkg/types/service_plan.go | 24 +++ pkg/util/slice/strings.go | 18 ++ .../broker_notifications_interceptor.go | 83 ++++++++- .../broker_public_plans_interceptor.go | 171 +++++++++++++----- .../interceptors/notifications_interceptor.go | 124 +++++++++---- .../visibility_notifications_interceptor.go | 4 +- test/common/test_context.go | 6 +- test/notification_test/notification_test.go | 36 +++- test/osb_test/catalog_test.go | 21 ++- .../public_plans_test.go | 137 +++++++++++++- 13 files changed, 551 insertions(+), 128 deletions(-) diff --git a/Gopkg.lock b/Gopkg.lock index dfa5f1bc0..9237a9189 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -43,7 +43,7 @@ name = "github.com/antlr/antlr4" packages = ["runtime/Go/antlr"] pruneopts = "UT" - revision = "d569f917954406e599d780c009224aa14f1920de" + revision = "7a3f40bc341ddfb463d6e0aa1a6265064d020cb6" version = "4.8" [[projects]] @@ -256,7 +256,7 @@ revision = "2ba0fc60eb4a54030f3a6d73ff0a047349c7eeca" [[projects]] - digest = "1:60a4c716301192a7be42f363c41b2b1fab64ae80d9db4453c702b13b05ccddeb" + digest = "1:cb7edefacdcbfd95b7611c11b3b027404fa39a66fdc91f6366e1811cbdb5cd3e" name = "github.com/klauspost/compress" packages = [ "flate", @@ -264,8 +264,8 @@ "zlib", ] pruneopts = "UT" - revision = "f61bac3a179b270c811148ce2a1f819dfc82825f" - version = "v1.9.7" + revision = "459b83aadb42b806aed42f0f4b3240c8834e0cc1" + version = "v1.9.8" [[projects]] digest = "1:31e761d97c76151dde79e9d28964a812c46efc5baee4085b86f68f0c654450de" @@ -475,12 +475,12 @@ version = "v1.4.0" [[projects]] - digest = "1:719b772d0c56bbf1acd3a748f88b9d02ff6da543994b5d7e1adf052eb5b693a1" + digest = "1:fcea9aca14ce388baeb3afd42bc3302089393f78f9531212aed88d60a134a921" name = "github.com/tidwall/gjson" packages = ["."] pruneopts = "UT" - revision = "5c2e4b382486589dad7478130a364ee2fa6a068b" - version = "v1.3.5" + revision = "d10932a0d0b5f1618759b6259b05f7cb7bea0c25" + version = "v1.4.0" [[projects]] digest = "1:8453ddbed197809ee8ca28b06bd04e127bec9912deb4ba451fea7a1eca578328" @@ -617,11 +617,11 @@ [[projects]] branch = "master" - digest = "1:e13350b7207ecc34977340d53522e0335dd36ed86ca7c346a36d2e88eb0fadfc" + digest = "1:8a44970c7e8c0a1c8646af14605c1ffd31374074d68101c2e11d7761df12c9d1" name = "golang.org/x/sys" packages = ["unix"] pruneopts = "UT" - revision = "59e60aa80a0c64fa4b088976ee16ad7f04252c25" + revision = "e047566fdf82409bf7a52212cf71df83ea2772fb" [[projects]] digest = "1:28deae5fe892797ff37a317b5bcda96d11d1c90dadd89f1337651df3bc4c586e" @@ -706,12 +706,12 @@ revision = "dd632973f1e7218eb1089048e0798ec9ae7dceb8" [[projects]] - digest = "1:b75b3deb2bce8bc079e16bb2aecfe01eb80098f5650f9e93e5643ca8b7b73737" + digest = "1:55b110c99c5fdc4f14930747326acce56b52cfce60b24b1c03ef686ac0e46bb1" name = "gopkg.in/yaml.v2" packages = ["."] pruneopts = "UT" - revision = "1f64d6156d11335c3f22d9330b0ad14fc1e789ce" - version = "v2.2.7" + revision = "53403b58ad1b561927d19068c655246f2db79d48" + version = "v2.2.8" [solve-meta] analyzer-name = "dep" diff --git a/api/osb/catalog_filter_by_visibility_plugin.go b/api/osb/catalog_filter_by_visibility_plugin.go index 584170dc7..5ca1de0e9 100644 --- a/api/osb/catalog_filter_by_visibility_plugin.go +++ b/api/osb/catalog_filter_by_visibility_plugin.go @@ -53,13 +53,9 @@ func (c *CatalogFilterByVisibilityPlugin) FetchCatalog(req *web.Request, next we if err := userCtx.Data(platform); err != nil { return nil, err } - if platform.Type != types.K8sPlatformType { - log.C(ctx).Debugf("Platform type is %s, which is not kubernetes. Skip filtering on visibilities", platform.Type) - return res, nil - } brokerID := req.PathParams[BrokerIDPathParam] - visibleCatalogPlans, err := getVisiblePlansByBrokerIDAndPlatformID(ctx, c.repository, brokerID, platform.ID) + visibleCatalogPlans, err := getVisiblePlansByBrokerIDAndPlatformID(ctx, c.repository, brokerID, platform) if err != nil { return nil, err } @@ -67,24 +63,41 @@ func (c *CatalogFilterByVisibilityPlugin) FetchCatalog(req *web.Request, next we return res, err } -func getVisiblePlansByBrokerIDAndPlatformID(ctx context.Context, repository storage.Repository, brokerID, platformID string) (map[string]bool, error) { +func getVisiblePlansByBrokerIDAndPlatformID(ctx context.Context, repository storage.Repository, brokerID string, platform *types.Platform) (map[string]bool, error) { offeringIDs, err := getOfferingIDsByBrokerID(ctx, repository, brokerID) if err != nil { return nil, err } + if len(offeringIDs) == 0 { + return map[string]bool{}, nil + } + plansList, err := repository.List(ctx, types.ServicePlanType, query.ByField(query.InOperator, "service_offering_id", offeringIDs...)) if err != nil { log.C(ctx).Errorf("Could not get %s: %v", types.ServicePlanType, err) return nil, err } + + visibleCatalogPlans := make(map[string]bool) + if platform.Type == types.CFPlatformType { + for i := 0; i < plansList.Len(); i++ { + plan := plansList.ItemAt(i).(*types.ServicePlan) + if plan.SupportsPlatform(types.CFPlatformType) { + visibleCatalogPlans[plan.CatalogID] = true + } + } + + return visibleCatalogPlans, nil + } + planIDs := make([]string, 0, plansList.Len()) for i := 0; i < plansList.Len(); i++ { planIDs = append(planIDs, plansList.ItemAt(i).GetID()) } visibilitiesList, err := repository.List(ctx, types.VisibilityType, - query.ByField(query.EqualsOrNilOperator, "platform_id", platformID), + query.ByField(query.EqualsOrNilOperator, "platform_id", platform.ID), query.ByField(query.InOperator, "service_plan_id", planIDs...)) if err != nil { log.C(ctx).Errorf("Could not get %s: %v", types.VisibilityType, err) @@ -97,7 +110,6 @@ func getVisiblePlansByBrokerIDAndPlatformID(ctx context.Context, repository stor } plans := (plansList.(*types.ServicePlans)).ServicePlans - visibleCatalogPlans := make(map[string]bool) for _, p := range plans { if visiblePlans[p.ID] { visibleCatalogPlans[p.CatalogID] = true diff --git a/pkg/types/platform.go b/pkg/types/platform.go index c82ce0688..9be4616d5 100644 --- a/pkg/types/platform.go +++ b/pkg/types/platform.go @@ -26,7 +26,8 @@ import ( "github.com/Peripli/service-manager/pkg/util" ) -const K8sPlatformType string = "kubernetes" +const CFPlatformType = "cloudfoundry" +const K8sPlatformType = "kubernetes" const SMPlatform = "service-manager" //go:generate smgen api Platform diff --git a/pkg/types/service_plan.go b/pkg/types/service_plan.go index 8b2b44928..5abdc27e5 100644 --- a/pkg/types/service_plan.go +++ b/pkg/types/service_plan.go @@ -19,6 +19,8 @@ package types import ( "encoding/json" "fmt" + "github.com/Peripli/service-manager/pkg/util/slice" + "github.com/tidwall/gjson" "reflect" "github.com/Peripli/service-manager/pkg/util" @@ -102,3 +104,25 @@ func (e *ServicePlan) Validate() error { return nil } + +// SupportedPlatforms returns the supportedPlatforms provided in a plan's metadata (if a value is provided at all). +// If there are no supported platforms, an empty array is returned denoting that the plan is available to platforms of all types. +func (e *ServicePlan) SupportedPlatforms() []string { + supportedPlatforms := gjson.GetBytes(e.Metadata, "supportedPlatforms") + if !supportedPlatforms.IsArray() { + return []string{} + } + array := supportedPlatforms.Array() + platforms := make([]string, len(array)) + for i, p := range supportedPlatforms.Array() { + platforms[i] = p.String() + } + return platforms +} + +// SupportsPlatform determines whether a specific platform is among the ones that a plan supports +func (e *ServicePlan) SupportsPlatform(platform string) bool { + platforms := e.SupportedPlatforms() + + return len(platforms) == 0 || slice.StringsAnyEquals(platforms, platform) +} diff --git a/pkg/util/slice/strings.go b/pkg/util/slice/strings.go index b4babb9a5..932a8e7c9 100644 --- a/pkg/util/slice/strings.go +++ b/pkg/util/slice/strings.go @@ -75,3 +75,21 @@ func StringsAnySuffix(stringSlice []string, suffix string) bool { } return false } + +// StringsDistinct returns the distinct strings among two string arrays. +func StringsDistinct(str1, str2 []string) []string { + distinct := make([]string, 0) + for _, s1 := range str1 { + if StringsAnyEquals(str2, s1) { + continue + } else { + distinct = append(distinct, s1) + } + } + + if len(str2) > len(str1) { + distinct = append(distinct, str2[len(str1):]...) + } + + return distinct +} diff --git a/storage/interceptors/broker_notifications_interceptor.go b/storage/interceptors/broker_notifications_interceptor.go index d6fdcdb73..e7511d3bb 100644 --- a/storage/interceptors/broker_notifications_interceptor.go +++ b/storage/interceptors/broker_notifications_interceptor.go @@ -3,15 +3,50 @@ package interceptors import ( "context" "fmt" - + "github.com/Peripli/service-manager/pkg/query" "github.com/Peripli/service-manager/pkg/types" "github.com/Peripli/service-manager/storage" ) func NewBrokerNotificationsInterceptor() *NotificationsInterceptor { return &NotificationsInterceptor{ - PlatformIdProviderFunc: func(ctx context.Context, obj types.Object) string { - return "" + PlatformIDsProviderFunc: func(ctx context.Context, obj types.Object, repository storage.Repository) ([]string, error) { + broker := obj.(*types.ServiceBroker) + + var err error + plans := make([]*types.ServicePlan, 0) + if len(broker.Services) == 0 { // broker create/update might be triggered inside an existing transaction, which will result in not loading the broker catalog + plans, err = fetchBrokerPlans(ctx, broker.ID, repository) + if err != nil { + return nil, err + } + } else { + for _, svc := range broker.Services { + plans = append(plans, svc.Plans...) + } + } + + supportedPlatforms := getSupportedPlatformsForPlans(plans) + + criteria := []query.Criterion{ + query.ByField(query.NotEqualsOperator, "type", types.SMPlatform), + } + + if len(supportedPlatforms) != 0 { + criteria = append(criteria, query.ByField(query.InOperator, "type", supportedPlatforms...)) + } + + objList, err := repository.List(ctx, types.PlatformType, criteria...) + if err != nil { + return nil, err + } + + platformIDs := make([]string, 0) + for i := 0; i < objList.Len(); i++ { + platformIDs = append(platformIDs, objList.ItemAt(i).GetID()) + } + + return platformIDs, nil }, AdditionalDetailsFunc: func(ctx context.Context, objects types.ObjectList, repository storage.Repository) (objectDetails, error) { details := make(objectDetails, objects.Len()) @@ -76,3 +111,45 @@ func (*BrokerNotificationsDeleteInterceptorProvider) Name() string { func (*BrokerNotificationsDeleteInterceptorProvider) Provide() storage.DeleteOnTxInterceptor { return NewBrokerNotificationsInterceptor() } + +func fetchBrokerPlans(ctx context.Context, brokerID string, repository storage.Repository) ([]*types.ServicePlan, error) { + byBrokerID := query.ByField(query.EqualsOperator, "broker_id", brokerID) + objList, err := repository.List(ctx, types.ServiceOfferingType, byBrokerID) + if err != nil { + return nil, err + } + + if objList.Len() == 0 { + return nil, nil + } + + serviceOfferingIDs := make([]string, 0) + for i := 0; i < objList.Len(); i++ { + serviceOfferingIDs = append(serviceOfferingIDs, objList.ItemAt(i).GetID()) + } + + byOfferingIDs := query.ByField(query.InOperator, "service_offering_id", serviceOfferingIDs...) + objList, err = repository.List(ctx, types.ServicePlanType, byOfferingIDs) + if err != nil { + return nil, err + } + + return objList.(*types.ServicePlans).ServicePlans, nil +} + +func getSupportedPlatformsForPlans(plans []*types.ServicePlan) []string { + platformTypes := make(map[string]bool) + for _, plan := range plans { + types := plan.SupportedPlatforms() + for _, t := range types { + platformTypes[t] = true + } + } + + supportedPlatforms := make([]string, 0) + for platform := range platformTypes { + supportedPlatforms = append(supportedPlatforms, platform) + } + + return supportedPlatforms +} diff --git a/storage/interceptors/broker_public_plans_interceptor.go b/storage/interceptors/broker_public_plans_interceptor.go index 053156ab0..0b2d30212 100644 --- a/storage/interceptors/broker_public_plans_interceptor.go +++ b/storage/interceptors/broker_public_plans_interceptor.go @@ -103,62 +103,145 @@ func resync(ctx context.Context, broker *types.ServiceBroker, txStorage storage. for _, serviceOffering := range broker.Services { for _, servicePlan := range serviceOffering.Plans { planID := servicePlan.ID - isPublic, err := isCatalogPlanPublicFunc(broker, serviceOffering, servicePlan) + + isPlanPublic, err := isCatalogPlanPublicFunc(broker, serviceOffering, servicePlan) if err != nil { return err } - hasPublicVisibility := false byServicePlanID := query.ByField(query.EqualsOperator, "service_plan_id", planID) - visibilitiesForPlan, err := txStorage.List(ctx, types.VisibilityType, byServicePlanID) + planVisibilities, err := txStorage.List(ctx, types.VisibilityType, byServicePlanID) + if err != nil { + return err + } + + supportedPlatformTypes := servicePlan.SupportedPlatforms() + if len(supportedPlatformTypes) == 0 { // all platforms are supported -> create single visibility with empty platform ID + err = resyncPublicPlanVisibilities(ctx, txStorage, planVisibilities, isPlanPublic, planID, broker.ID) + } else { // not all platforms are supported -> create single visibility for each supported platform + err = resyncPlanVisibilitiesWithSupportedPlatforms(ctx, txStorage, planVisibilities, isPlanPublic, planID, broker.ID, supportedPlatformTypes) + } + if err != nil { return err } - for i := 0; i < visibilitiesForPlan.Len(); i++ { - visibility := visibilitiesForPlan.ItemAt(i).(*types.Visibility) - byVisibilityID := query.ByField(query.EqualsOperator, "id", visibility.ID) - if isPublic { - if visibility.PlatformID == "" { - hasPublicVisibility = true - continue - } else { - if err := txStorage.Delete(ctx, types.VisibilityType, byVisibilityID); err != nil { - return err - } - } - } else { - if visibility.PlatformID == "" { - if err := txStorage.Delete(ctx, types.VisibilityType, byVisibilityID); err != nil { - return err - } - } else { - continue - } - } + } + } + return nil +} + +func resyncPublicPlanVisibilities(ctx context.Context, txStorage storage.Repository, planVisibilities types.ObjectList, isPlanPublic bool, planID, brokerID string) error { + publicVisibilityExists := false + + for i := 0; i < planVisibilities.Len(); i++ { + visibility := planVisibilities.ItemAt(i).(*types.Visibility) + byVisibilityID := query.ByField(query.EqualsOperator, "id", visibility.ID) + + shouldDeleteVisibility := true + if isPlanPublic { + if visibility.PlatformID == "" { + publicVisibilityExists = true + shouldDeleteVisibility = false + } + } else { + if visibility.PlatformID != "" { + shouldDeleteVisibility = false + } + } + + if shouldDeleteVisibility { + if err := txStorage.Delete(ctx, types.VisibilityType, byVisibilityID); err != nil { + return err + } + } + } + + if isPlanPublic && !publicVisibilityExists { + if err := persistVisibility(ctx, txStorage, "", planID, brokerID); err != nil { + return err + } + } + + return nil +} + +func resyncPlanVisibilitiesWithSupportedPlatforms(ctx context.Context, txStorage storage.Repository, planVisibilities types.ObjectList, isPlanPublic bool, planID, brokerID string, supportedPlatformTypes []string) error { + bySupportedPlatformTypes := query.ByField(query.InOperator, "type", supportedPlatformTypes...) + platformList, err := txStorage.List(ctx, types.PlatformType, bySupportedPlatformTypes) + if err != nil { + return err + } + + supportedPlatforms := platformList.(*types.Platforms).Platforms + + for i := 0; i < planVisibilities.Len(); i++ { + visibility := planVisibilities.ItemAt(i).(*types.Visibility) + + shouldDeleteVisibility := true + + idx, matches := platformsAnyMatchesVisibility(supportedPlatforms, visibility) + if isPlanPublic { // trying to match the current visibility to one of the supported platforms that should have visibilities + if matches && len(visibility.Labels) == 0 { // visibility is present, no need to create a new one or delete this one + supportedPlatforms = append(supportedPlatforms[:idx], supportedPlatforms[idx+1:]...) + shouldDeleteVisibility = false + } + } else { // trying to match the current visibility to one of the supported platforms - if match is found and it has no labels - it's a public visibility and it has to be deleted + if matches && len(visibility.Labels) != 0 { // visibility is present, but has labels -> visibility for paid so don't delete it + shouldDeleteVisibility = false } + } - if isPublic && !hasPublicVisibility { - UUID, err := uuid.NewV4() - if err != nil { - return fmt.Errorf("could not generate GUID for visibility: %s", err) - } - - currentTime := time.Now().UTC() - planID, err := txStorage.Create(ctx, &types.Visibility{ - Base: types.Base{ - ID: UUID.String(), - UpdatedAt: currentTime, - CreatedAt: currentTime, - }, - ServicePlanID: servicePlan.ID, - }) - if err != nil { - return err - } - - log.C(ctx).Debugf("Created new public visibility for broker with id %s and plan with id %s", broker.ID, planID) + if shouldDeleteVisibility { + byVisibilityID := query.ByField(query.EqualsOperator, "id", visibility.ID) + if err := txStorage.Delete(ctx, types.VisibilityType, byVisibilityID); err != nil { + return err + } + } + } + + if isPlanPublic { + for _, platform := range supportedPlatforms { + if err := persistVisibility(ctx, txStorage, platform.ID, planID, brokerID); err != nil { + return err } } } + + return nil +} + +// platformsAnyMatchesVisibility checks whether any of the platforms matches the provided visibility +func platformsAnyMatchesVisibility(platforms []*types.Platform, visibility *types.Visibility) (int, bool) { + for i, platform := range platforms { + if visibility.PlatformID == platform.ID { + return i, true + } + } + return -1, false +} + +func persistVisibility(ctx context.Context, txStorage storage.Repository, platformID, planID, brokerID string) error { + UUID, err := uuid.NewV4() + if err != nil { + return fmt.Errorf("could not generate GUID for visibility: %s", err) + } + + currentTime := time.Now().UTC() + visibility := &types.Visibility{ + Base: types.Base{ + ID: UUID.String(), + UpdatedAt: currentTime, + CreatedAt: currentTime, + }, + ServicePlanID: planID, + PlatformID: platformID, + } + + _, err = txStorage.Create(ctx, visibility) + if err != nil { + return err + } + + log.C(ctx).Debugf("Created new public visibility for broker with id (%s), plan with id (%s) and platform with id (%s)", brokerID, planID, platformID) return nil } diff --git a/storage/interceptors/notifications_interceptor.go b/storage/interceptors/notifications_interceptor.go index 63785418c..7acd04301 100644 --- a/storage/interceptors/notifications_interceptor.go +++ b/storage/interceptors/notifications_interceptor.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "github.com/Peripli/service-manager/pkg/util/slice" "time" "github.com/Peripli/service-manager/pkg/util" @@ -31,8 +32,8 @@ type ObjectPayload struct { type objectDetails map[string]util.InputValidator type NotificationsInterceptor struct { - PlatformIdProviderFunc func(ctx context.Context, object types.Object) string - AdditionalDetailsFunc func(ctx context.Context, objects types.ObjectList, repository storage.Repository) (objectDetails, error) + PlatformIDsProviderFunc func(ctx context.Context, object types.Object, repository storage.Repository) ([]string, error) + AdditionalDetailsFunc func(ctx context.Context, objects types.ObjectList, repository storage.Repository) (objectDetails, error) } func (ni *NotificationsInterceptor) OnTxCreate(h storage.InterceptCreateOnTxFunc) storage.InterceptCreateOnTxFunc { @@ -47,32 +48,55 @@ func (ni *NotificationsInterceptor) OnTxCreate(h storage.InterceptCreateOnTxFunc return nil, err } - platformID := ni.PlatformIdProviderFunc(ctx, newObj) + platformIDs, err := ni.PlatformIDsProviderFunc(ctx, obj, repository) + if err != nil { + return nil, err + } - return newObj, CreateNotification(ctx, repository, types.CREATED, newObj.GetType(), platformID, &Payload{ - New: &ObjectPayload{ - Resource: newObj, - Additional: additionalDetails[obj.GetID()], - }, - }) + for _, platformID := range platformIDs { + if err := CreateNotification(ctx, repository, types.CREATED, newObj.GetType(), platformID, &Payload{ + New: &ObjectPayload{ + Resource: newObj, + Additional: additionalDetails[obj.GetID()], + }, + }); err != nil { + return nil, err + } + } + + return newObj, nil } } func (ni *NotificationsInterceptor) OnTxUpdate(h storage.InterceptUpdateOnTxFunc) storage.InterceptUpdateOnTxFunc { return func(ctx context.Context, repository storage.Repository, oldObject, newObject types.Object, labelChanges ...*query.LabelChange) (types.Object, error) { + oldPlatformIDs, err := ni.PlatformIDsProviderFunc(ctx, oldObject, repository) + if err != nil { + return nil, err + } + updatedObject, err := h(ctx, repository, oldObject, newObject, labelChanges...) if err != nil { return nil, err } + // Scheduler updates the `ready` property of resources once an operation has ended - we should not create MODIFIED notifications in these cases + if oldObject.GetReady() != updatedObject.GetReady() { + return updatedObject, nil + } + detailsMap, err := ni.AdditionalDetailsFunc(ctx, types.NewObjectArray(updatedObject), repository) if err != nil { return nil, err } additionalDetails := detailsMap[updatedObject.GetID()] - oldPlatformID := ni.PlatformIdProviderFunc(ctx, oldObject) - updatedPlatformID := ni.PlatformIdProviderFunc(ctx, updatedObject) + updatedPlatformIDs, err := ni.PlatformIDsProviderFunc(ctx, updatedObject, repository) + if err != nil { + return nil, err + } + + preexistingPlatformIDs, addedPlatformIDs, removedPlatformIDs := determinePlatformIDs(oldPlatformIDs, updatedPlatformIDs) oldObjectLabels := oldObject.GetLabels() updatedObjectLabels := updatedObject.GetLabels() @@ -82,10 +106,8 @@ func (ni *NotificationsInterceptor) OnTxUpdate(h storage.InterceptUpdateOnTxFunc } oldObject.SetLabels(nil) - // if the resource update contains change in the platform ID field this means that the notification would be processed by - // two platforms - one needs to perform a delete operation and the other needs to perform a create operation. - if oldPlatformID != updatedPlatformID { - if err := CreateNotification(ctx, repository, types.CREATED, updatedObject.GetType(), updatedPlatformID, &Payload{ + for _, platformID := range addedPlatformIDs { + if err := CreateNotification(ctx, repository, types.CREATED, updatedObject.GetType(), platformID, &Payload{ New: &ObjectPayload{ Resource: updatedObject, Additional: additionalDetails, @@ -93,7 +115,10 @@ func (ni *NotificationsInterceptor) OnTxUpdate(h storage.InterceptUpdateOnTxFunc }); err != nil { return nil, err } - if err := CreateNotification(ctx, repository, types.DELETED, updatedObject.GetType(), oldPlatformID, &Payload{ + } + + for _, platformID := range removedPlatformIDs { + if err := CreateNotification(ctx, repository, types.DELETED, updatedObject.GetType(), platformID, &Payload{ Old: &ObjectPayload{ Resource: oldObject, Additional: additionalDetails, @@ -103,18 +128,21 @@ func (ni *NotificationsInterceptor) OnTxUpdate(h storage.InterceptUpdateOnTxFunc } } - if err := CreateNotification(ctx, repository, types.MODIFIED, updatedObject.GetType(), updatedPlatformID, &Payload{ - New: &ObjectPayload{ - Resource: updatedObject, - Additional: additionalDetails, - }, - Old: &ObjectPayload{ - Resource: oldObject, - Additional: additionalDetails, - }, - LabelChanges: labelChanges, - }); err != nil { - return nil, err + modifiedPlatformIDs := append(preexistingPlatformIDs, addedPlatformIDs...) + for _, platformID := range modifiedPlatformIDs { + if err := CreateNotification(ctx, repository, types.MODIFIED, updatedObject.GetType(), platformID, &Payload{ + New: &ObjectPayload{ + Resource: updatedObject, + Additional: additionalDetails, + }, + Old: &ObjectPayload{ + Resource: oldObject, + Additional: additionalDetails, + }, + LabelChanges: labelChanges, + }); err != nil { + return nil, err + } } oldObject.SetLabels(oldObjectLabels) @@ -126,6 +154,18 @@ func (ni *NotificationsInterceptor) OnTxUpdate(h storage.InterceptUpdateOnTxFunc func (ni *NotificationsInterceptor) OnTxDelete(h storage.InterceptDeleteOnTxFunc) storage.InterceptDeleteOnTxFunc { return func(ctx context.Context, repository storage.Repository, objects types.ObjectList, deletionCriteria ...query.Criterion) error { + objectIDPlatformsMap := make(map[string][]string) + for i := 0; i < objects.Len(); i++ { + oldObject := objects.ItemAt(i) + + platformIDs, err := ni.PlatformIDsProviderFunc(ctx, oldObject, repository) + if err != nil { + return err + } + + objectIDPlatformsMap[oldObject.GetID()] = platformIDs + } + additionalDetails, err := ni.AdditionalDetailsFunc(ctx, objects, repository) if err != nil { return err @@ -138,15 +178,17 @@ func (ni *NotificationsInterceptor) OnTxDelete(h storage.InterceptDeleteOnTxFunc for i := 0; i < objects.Len(); i++ { oldObject := objects.ItemAt(i) - platformID := ni.PlatformIdProviderFunc(ctx, oldObject) - - if err := CreateNotification(ctx, repository, types.DELETED, oldObject.GetType(), platformID, &Payload{ - Old: &ObjectPayload{ - Resource: oldObject, - Additional: additionalDetails[oldObject.GetID()], - }, - }); err != nil { - return err + platformIDs := objectIDPlatformsMap[oldObject.GetID()] + + for _, platformID := range platformIDs { + if err := CreateNotification(ctx, repository, types.DELETED, oldObject.GetType(), platformID, &Payload{ + Old: &ObjectPayload{ + Resource: oldObject, + Additional: additionalDetails[oldObject.GetID()], + }, + }); err != nil { + return err + } } } @@ -200,3 +242,11 @@ func CreateNotification(ctx context.Context, repository storage.Repository, op t return nil } + +func determinePlatformIDs(oldPlatformIDs, updatedPlatformIDs []string) ([]string, []string, []string) { + preexistingPlatformIDs := slice.StringsIntersection(oldPlatformIDs, updatedPlatformIDs) + addedPlatformIDs := slice.StringsDistinct(updatedPlatformIDs, preexistingPlatformIDs) + removedPlatformIDs := slice.StringsDistinct(oldPlatformIDs, preexistingPlatformIDs) + + return preexistingPlatformIDs, addedPlatformIDs, removedPlatformIDs +} diff --git a/storage/interceptors/visibility_notifications_interceptor.go b/storage/interceptors/visibility_notifications_interceptor.go index d92ff6bdc..7c98c49d8 100644 --- a/storage/interceptors/visibility_notifications_interceptor.go +++ b/storage/interceptors/visibility_notifications_interceptor.go @@ -12,8 +12,8 @@ import ( func NewVisibilityNotificationsInterceptor() *NotificationsInterceptor { return &NotificationsInterceptor{ - PlatformIdProviderFunc: func(ctx context.Context, obj types.Object) string { - return obj.(*types.Visibility).PlatformID + PlatformIDsProviderFunc: func(ctx context.Context, obj types.Object, _ storage.Repository) ([]string, error) { + return []string{obj.(*types.Visibility).PlatformID}, nil }, AdditionalDetailsFunc: func(ctx context.Context, objects types.ObjectList, repository storage.Repository) (objectDetails, error) { var visibilities []*types.Visibility diff --git a/test/common/test_context.go b/test/common/test_context.go index 12010c25e..2a6cc3627 100644 --- a/test/common/test_context.go +++ b/test/common/test_context.go @@ -501,13 +501,17 @@ func (ctx *TestContext) RegisterBroker() (string, Object, *BrokerServer) { } func (ctx *TestContext) RegisterPlatform() *types.Platform { + return ctx.RegisterPlatformWithType("test-type") +} + +func (ctx *TestContext) RegisterPlatformWithType(platformType string) *types.Platform { UUID, err := uuid.NewV4() if err != nil { panic(err) } platformJSON := Object{ "name": UUID.String(), - "type": "testType", + "type": platformType, "description": "testDescrption", } return RegisterPlatformInSM(platformJSON, ctx.SMWithOAuth, map[string]string{}) diff --git a/test/notification_test/notification_test.go b/test/notification_test/notification_test.go index b3e938090..0fd4bab40 100644 --- a/test/notification_test/notification_test.go +++ b/test/notification_test/notification_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "github.com/Peripli/service-manager/pkg/util/slice" "net/http" "testing" @@ -48,8 +49,8 @@ type notificationTypeEntry struct { ResourceUpdates []func() common.Object // ResourceDeleteFunc is blueprint for resource deletion ResourceDeleteFunc func(obj common.Object) - // ExpectedPlatformIDFunc calculates the expected platform ID for the given object - ExpectedPlatformIDFunc func(obj common.Object) string + // ExpectedPlatformIDsFunc calculates the expected platform IDs for the given object + ExpectedPlatformIDsFunc func(obj common.Object) []string // ExpectedAdditionalPayloadFunc calculates the expected additional payload for the given object ExpectedAdditionalPayloadFunc func(expected common.Object, repository storage.Repository) string // Verify additional stuff such as creation of notifications for dependant entities @@ -143,8 +144,16 @@ var _ = Describe("Notifications Suite", func() { ResourceDeleteFunc: func(object common.Object) { ctx.CleanupBroker(object["id"].(string)) }, - ExpectedPlatformIDFunc: func(object common.Object) string { - return "" + ExpectedPlatformIDsFunc: func(object common.Object) []string { + objList, err := ctx.SMRepository.List(context.TODO(), types.PlatformType, query.ByField(query.NotEqualsOperator, "id", types.SMPlatform)) + Expect(err).ToNot(HaveOccurred()) + + platformIDs := make([]string, 0) + for i := 0; i < objList.Len(); i++ { + platformIDs = append(platformIDs, objList.ItemAt(i).GetID()) + } + + return platformIDs }, ExpectedAdditionalPayloadFunc: func(expected common.Object, repository storage.Repository) string { serviceOfferings, err := catalog.Load(c, expected["id"].(string), ctx.SMRepository) @@ -286,8 +295,8 @@ var _ = Describe("Notifications Suite", func() { ctx.SMWithOAuth.DELETE(web.VisibilitiesURL + "/" + obj["id"].(string)).Expect(). Status(http.StatusOK) }, - ExpectedPlatformIDFunc: func(obj common.Object) string { - return obj["platform_id"].(string) + ExpectedPlatformIDsFunc: func(obj common.Object) []string { + return []string{obj["platform_id"].(string)} }, ExpectedAdditionalPayloadFunc: func(expected common.Object, repository storage.Repository) string { byPlanID := query.ByField(query.EqualsOperator, "id", expected["service_plan_id"].(string)) @@ -386,7 +395,8 @@ var _ = Describe("Notifications Suite", func() { continue } - if notification.PlatformID != entry.ExpectedPlatformIDFunc(objAfterOp) { + expectedPlatformIDs := entry.ExpectedPlatformIDsFunc(objAfterOp) + if !slice.StringsAnyEquals(expectedPlatformIDs, notification.PlatformID) { continue } @@ -402,6 +412,7 @@ var _ = Describe("Notifications Suite", func() { expectedPayload := entry.ExpectedAdditionalPayloadFunc(objAfterOp, ctx.SMRepository) Expect(actualPayload).To(MatchUnorderedJSON(expectedPayload)) found = true + break } if !found { @@ -421,7 +432,8 @@ var _ = Describe("Notifications Suite", func() { continue } - if notification.PlatformID != entry.ExpectedPlatformIDFunc(objAfterOp) { + expectedPlatformIDs := entry.ExpectedPlatformIDsFunc(objAfterOp) + if !slice.StringsAnyEquals(expectedPlatformIDs, notification.PlatformID) { continue } @@ -435,6 +447,7 @@ var _ = Describe("Notifications Suite", func() { Expect(actualPayload).To(MatchUnorderedJSON(expectedOldPayload)) found = true + break } if !found { @@ -457,7 +470,8 @@ var _ = Describe("Notifications Suite", func() { continue } - if notification.PlatformID != entry.ExpectedPlatformIDFunc(objAfterOp) { + expectedPlatformIDs := entry.ExpectedPlatformIDsFunc(objAfterOp) + if !slice.StringsAnyEquals(expectedPlatformIDs, notification.PlatformID) { continue } @@ -497,13 +511,15 @@ var _ = Describe("Notifications Suite", func() { } found = true + break } if !found { Fail(fmt.Sprintf("Expected to find notification for resource type %s", entry.ResourceType)) } - if entry.ExpectedPlatformIDFunc(objBeforeOp) != entry.ExpectedPlatformIDFunc(objAfterOp) { + // when visibility platform_id changes: + if entry.ExpectedPlatformIDsFunc(objBeforeOp)[0] != entry.ExpectedPlatformIDsFunc(objAfterOp)[0] { verifyCreationNotificationCreated(objAfterOp, notificationsAfterOp) labels := objBeforeOp["labels"] delete(objBeforeOp, "labels") diff --git a/test/osb_test/catalog_test.go b/test/osb_test/catalog_test.go index 872e81a84..271ef2aa8 100644 --- a/test/osb_test/catalog_test.go +++ b/test/osb_test/catalog_test.go @@ -183,18 +183,14 @@ var _ = Describe("Catalog", func() { Context("for platform with no visibilities", func() { It("should return empty services catalog", func() { assertBrokerPlansVisibleForPlatform(brokerID, k8sAgent) - }) - }) - - Context("for cloud foundry platform", func() { - It("should return all services and plans, no matter the visibilities", func() { - assertBrokerPlansVisibleForPlatform(brokerID, ctx.SMWithBasic, plan1CatalogID, plan2CatalogID, plan3CatalogID) + assertBrokerPlansVisibleForPlatform(brokerID, ctx.SMWithBasic) }) }) Context("for platform with visibilities for 2 plans from 2 services", func() { It("should return 2 plans", func() { assertBrokerPlansVisibleForPlatform(brokerID, k8sAgent) + assertBrokerPlansVisibleForPlatform(brokerID, ctx.SMWithBasic) ctx.SMWithOAuth.POST(web.VisibilitiesURL).WithJSON(common.Object{ "service_plan_id": plan1ID, @@ -206,6 +202,19 @@ var _ = Describe("Catalog", func() { }).Expect().Status(http.StatusCreated) assertBrokerPlansVisibleForPlatform(brokerID, k8sAgent, plan3CatalogID, plan1CatalogID) + assertBrokerPlansVisibleForPlatform(brokerID, ctx.SMWithBasic) + + ctx.SMWithOAuth.POST(web.VisibilitiesURL).WithJSON(common.Object{ + "service_plan_id": plan1ID, + "platform_id": ctx.TestPlatform.ID, + }).Expect().Status(http.StatusCreated) + ctx.SMWithOAuth.POST(web.VisibilitiesURL).WithJSON(common.Object{ + "service_plan_id": plan3ID, + "platform_id": ctx.TestPlatform.ID, + }).Expect().Status(http.StatusCreated) + + assertBrokerPlansVisibleForPlatform(brokerID, k8sAgent, plan3CatalogID, plan1CatalogID) + assertBrokerPlansVisibleForPlatform(brokerID, ctx.SMWithBasic, plan3CatalogID, plan1CatalogID) }) }) diff --git a/test/public_plans_interceptor_test/public_plans_test.go b/test/public_plans_interceptor_test/public_plans_test.go index 0230d880e..656fe31f3 100644 --- a/test/public_plans_interceptor_test/public_plans_test.go +++ b/test/public_plans_interceptor_test/public_plans_test.go @@ -19,6 +19,8 @@ package interceptor_test import ( "context" "fmt" + "github.com/gavv/httpexpect" + "math/rand" "net/http" "testing" @@ -71,15 +73,19 @@ var _ = Describe("Service Manager Public Plans Interceptor", func() { var newPublicPlan string var oldPaidPlan string + findVisibilitiesForServicePlanID := func(servicePlanID string) *httpexpect.Array { + return ctx.SMWithOAuth.ListWithQuery(web.VisibilitiesURL, fmt.Sprintf("fieldQuery=service_plan_id eq '%s'", servicePlanID)) + } + findOneVisibilityForServicePlanID := func(servicePlanID string) map[string]interface{} { - vs := ctx.SMWithOAuth.ListWithQuery(web.VisibilitiesURL, fmt.Sprintf("fieldQuery=service_plan_id eq '%s'", servicePlanID)) + vs := findVisibilitiesForServicePlanID(servicePlanID) vs.Length().Equal(1) return vs.First().Object().Raw() } verifyZeroVisibilityForServicePlanID := func(servicePlanID string) { - vs := ctx.SMWithOAuth.ListWithQuery(web.VisibilitiesURL, fmt.Sprintf("fieldQuery=service_plan_id eq '%s'", servicePlanID)) + vs := findVisibilitiesForServicePlanID(servicePlanID) vs.Length().Equal(0) } @@ -258,10 +264,10 @@ var _ = Describe("Service Manager Public Plans Interceptor", func() { Context("when an existing public plan is made paid", func() { BeforeEach(func() { - tempCatalog, err := sjson.Set(testCatalog, "services.0.plans.0.free", false) + catalog, err := sjson.Set(testCatalog, "services.0.plans.0.free", false) Expect(err).ToNot(HaveOccurred()) - catalog, err := sjson.Set(tempCatalog, "services.0.plans.1.free", false) + catalog, err = sjson.Set(catalog, "services.0.plans.1.free", false) Expect(err).ToNot(HaveOccurred()) existingBrokerServer.Catalog = common.SBCatalog(catalog) @@ -352,4 +358,127 @@ var _ = Describe("Service Manager Public Plans Interceptor", func() { Expect(visibilities["platform_id"]).To(Equal("")) }) }) + + Context("when a plan has specified supported platforms", func() { + It("creates a single public visibility with empty platform_id", func() { + plan := ctx.SMWithOAuth.ListWithQuery(web.ServicePlansURL, fmt.Sprintf("fieldQuery=catalog_name eq '%s'", oldPublicPlanCatalogName)) + + plan.Path("$[*].metadata.supportedPlatforms").NotNull().Array().Empty() + + planID := plan.First().Object().Value("id").String().Raw() + Expect(planID).ToNot(BeEmpty()) + + visibilities := findOneVisibilityForServicePlanID(planID) + Expect(visibilities["platform_id"]).To(Equal("")) + + ctx.SMWithOAuth.PATCH(web.ServiceBrokersURL + "/" + existingBrokerID). + WithJSON(common.Object{}). + Expect(). + Status(http.StatusOK) + + vis := findOneVisibilityForServicePlanID(planID) + Expect(vis["platform_id"]).To(Equal("")) + }) + }) + + Context("when a plan has specified supported platforms", func() { + var supportedPlatforms []string + var planID string + + JustBeforeEach(func() { + catalog, err := sjson.Set(testCatalog, "services.0.plans.0.metadata.supportedPlatforms", supportedPlatforms) + Expect(err).ToNot(HaveOccurred()) + + existingBrokerServer.Catalog = common.SBCatalog(catalog) + + plan := ctx.SMWithOAuth.ListWithQuery(web.ServicePlansURL, fmt.Sprintf("fieldQuery=catalog_name eq '%s'", oldPublicPlanCatalogName)) + + plan.Path("$[*].metadata.supportedPlatforms").NotNull().Array().Empty() + + planID = plan.First().Object().Value("id").String().Raw() + Expect(planID).ToNot(BeEmpty()) + + visibilities := findOneVisibilityForServicePlanID(planID) + Expect(visibilities["platform_id"]).To(Equal("")) + }) + + Context("when a plan has no supported platforms", func() { + BeforeEach(func() { + supportedPlatforms = []string{} + }) + + It("creates a public visibility associated with the plan", func() { + ctx.SMWithOAuth.PATCH(web.ServiceBrokersURL + "/" + existingBrokerID). + WithJSON(common.Object{}). + Expect(). + Status(http.StatusOK) + + vis := findOneVisibilityForServicePlanID(planID) + Expect(vis["platform_id"]).To(Equal("")) + }) + }) + + Context("when a plan supports only one platform type", func() { + + BeforeEach(func() { + supportedPlatforms = []string{"cloudfoundry"} + }) + + It("creates a single public visibility for that platform", func() { + platform := ctx.RegisterPlatformWithType(supportedPlatforms[0]) + + ctx.SMWithOAuth.PATCH(web.ServiceBrokersURL + "/" + existingBrokerID). + WithJSON(common.Object{}). + Expect(). + Status(http.StatusOK) + + vis := findOneVisibilityForServicePlanID(planID) + Expect(vis["platform_id"]).To(Equal(platform.ID)) + }) + }) + + Context("when a plan supports multiple platform types", func() { + + BeforeEach(func() { + supportedPlatforms = []string{"cloudfoundry", "kubernetes", "abap"} + }) + + It("creates a single visibility for each supported platform", func() { + var platformCount int + + platformIDMap := make(map[string]bool) + for _, platformType := range supportedPlatforms { + count := rand.Intn(10-1) + 1 + for i := 0; i < count; i++ { + plt := ctx.RegisterPlatformWithType(platformType) + platformIDMap[plt.ID] = true + } + platformCount += count + } + + ctx.SMWithOAuth.PATCH(web.ServiceBrokersURL + "/" + existingBrokerID). + WithJSON(common.Object{}). + Expect(). + Status(http.StatusOK) + + vis := findVisibilitiesForServicePlanID(planID) + vis.Length().Equal(platformCount) + + for i := 0; i < platformCount; i++ { + plt := vis.Element(i).Object() + plt.NotContainsKey("labels") + + pltID := plt.Value("platform_id").String().Raw() + _, ok := platformIDMap[pltID] + if ok { + delete(platformIDMap, pltID) + } else { + Fail(fmt.Sprintf("unexpected platform_id with id (%s) was set to visibility", pltID)) + } + } + + Expect(len(platformIDMap)).To(Equal(0)) + }) + }) + }) }) From cf80480d4a1e0aed6fdb2b9d31865f0857b93771 Mon Sep 17 00:00:00 2001 From: Nikolay Date: Wed, 5 Feb 2020 10:54:58 +0200 Subject: [PATCH 11/15] Reject requests for instances and bindings which does not have tenant (#430) --- .../check_binding_visibility_filter.go | 8 +- .../check_instance_visibility_filter.go | 22 +++-- test/common/test_context.go | 24 ++++- test/delete.go | 84 +++++++++------- test/delete_list.go | 8 +- test/get.go | 98 ++++++++++--------- test/list.go | 20 ++-- test/patch.go | 75 ++++++++------ .../service_binding_test.go | 54 +++------- .../service_instance_test.go | 94 ++++++------------ test/test.go | 15 ++- 11 files changed, 259 insertions(+), 243 deletions(-) diff --git a/api/filters/check_binding_visibility_filter.go b/api/filters/check_binding_visibility_filter.go index c18d79e75..fc0902561 100644 --- a/api/filters/check_binding_visibility_filter.go +++ b/api/filters/check_binding_visibility_filter.go @@ -58,8 +58,12 @@ func (f *serviceBindingVisibilityFilter) Run(req *web.Request, next web.Handler) tenantID := query.RetrieveFromCriteria(f.tenantIdentifier, query.CriteriaForContext(ctx)...) if tenantID == "" { - log.C(ctx).Info("Tenant identifier not found in request criteria. Proceeding with the next handler...") - return next.Handle(req) + log.C(ctx).Errorf("Tenant identifier not found in request criteria.") + return nil, &util.HTTPError{ + ErrorType: "BadRequest", + Description: "no tenant identifier provided", + StatusCode: http.StatusBadRequest, + } } var err error diff --git a/api/filters/check_instance_visibility_filter.go b/api/filters/check_instance_visibility_filter.go index 72174dad2..8dd46de12 100644 --- a/api/filters/check_instance_visibility_filter.go +++ b/api/filters/check_instance_visibility_filter.go @@ -55,6 +55,20 @@ func (*serviceInstanceVisibilityFilter) Name() string { func (f *serviceInstanceVisibilityFilter) Run(req *web.Request, next web.Handler) (*web.Response, error) { ctx := req.Context() + + tenantID := query.RetrieveFromCriteria(f.tenantIdentifier, query.CriteriaForContext(ctx)...) + if tenantID == "" { + log.C(ctx).Errorf("Tenant identifier not found in request criteria. Not able to create instance without tenant") + return nil, &util.HTTPError{ + ErrorType: "BadRequest", + Description: "no tenant identifier provided", + StatusCode: http.StatusBadRequest, + } + } + if req.Method == http.MethodDelete { + return next.Handle(req) + } + planID := gjson.GetBytes(req.Body, planIDProperty).String() if planID == "" { @@ -81,12 +95,6 @@ func (f *serviceInstanceVisibilityFilter) Run(req *web.Request, next web.Handler return nil, visibilityError } - tenantID := query.RetrieveFromCriteria(f.tenantIdentifier, query.CriteriaForContext(ctx)...) - if tenantID == "" { - log.C(ctx).Info("Tenant identifier not found in request criteria. Proceeding with the next handler...") - return next.Handle(req) - } - // There may be at most one visibility for the query - for SM platform or public for this plan visibility := list.ItemAt(0).(*types.Visibility) if len(visibility.PlatformID) == 0 { // public visibility @@ -105,7 +113,7 @@ func (*serviceInstanceVisibilityFilter) FilterMatchers() []web.FilterMatcher { { Matchers: []web.Matcher{ web.Path(web.ServiceInstancesURL + "/**"), - web.Methods(http.MethodPost, http.MethodPatch), + web.Methods(http.MethodPost, http.MethodPatch, http.MethodDelete), }, }, } diff --git a/test/common/test_context.go b/test/common/test_context.go index 2a6cc3627..0d2c4d322 100644 --- a/test/common/test_context.go +++ b/test/common/test_context.go @@ -517,6 +517,19 @@ func (ctx *TestContext) RegisterPlatformWithType(platformType string) *types.Pla return RegisterPlatformInSM(platformJSON, ctx.SMWithOAuth, map[string]string{}) } +func (ctx *TestContext) NewTenantExpect(tenantIdentifier string) *SMExpect { + oauthServer := ctx.Servers[OauthServer].(*OAuthServer) + accessToken := oauthServer.CreateToken(map[string]interface{}{ + "cid": "tenancyClient", + "zid": tenantIdentifier, + }) + return &SMExpect{ + Expect: ctx.SM.Builder(func(req *httpexpect.Request) { + req.WithHeader("Authorization", "Bearer "+accessToken) + }), + } +} + func (ctx *TestContext) CleanupBroker(id string) { broker := ctx.Servers[BrokerServerPrefix+id] ctx.SMWithOAuth.DELETE(web.ServiceBrokersURL + "/" + id).Expect() @@ -560,15 +573,16 @@ func (ctx *TestContext) CleanupAdditionalResources() { ctx.SMWithOAuth.DELETE(web.ServiceBrokersURL).Expect() ctx.CleanupPlatforms() - var smServer FakeServer + serversToDelete := make([]string, 0) for serverName, server := range ctx.Servers { - if serverName == SMServer { - smServer = server - } else { + if serverName != SMServer && serverName != OauthServer { + serversToDelete = append(serversToDelete, serverName) server.Close() } } - ctx.Servers = map[string]FakeServer{SMServer: smServer} + for _, sname := range serversToDelete { + delete(ctx.Servers, sname) + } for _, conn := range ctx.wsConnections { conn.Close() diff --git a/test/delete.go b/test/delete.go index f8356dc4f..fb76cf5a1 100644 --- a/test/delete.go +++ b/test/delete.go @@ -93,33 +93,35 @@ func DescribeDeleteTestsfor(ctx *common.TestContext, t TestCase, responseMode Re verifyResourceDeletionWithErrorMsg(auth, deletionRequestResponseCode, getAfterDeletionRequestCode, expectedOpState, "") } - Context("when the resource is global", func() { - BeforeEach(func() { - createResourceFunc(ctx.SMWithOAuth) - }) - - Context("when authenticating with basic auth", func() { - It("returns 401", func() { - ctx.SMWithBasic.DELETE(fmt.Sprintf("%s/%s", t.API, testResourceID)).WithQuery("async", asyncParam). - Expect(). - Status(http.StatusUnauthorized).JSON().Object().Keys().Contains("error", "description") + if !t.StrictlyTenantScoped { + Context("when the resource is global", func() { + BeforeEach(func() { + createResourceFunc(ctx.SMWithOAuth) }) - }) - Context("when authenticating with global token", func() { - It("returns 200", func() { - verifyResourceDeletion(ctx.SMWithOAuth, successfulDeletionRequestResponseCode, http.StatusNotFound, types.SUCCEEDED) + Context("when authenticating with basic auth", func() { + It("returns 401", func() { + ctx.SMWithBasic.DELETE(fmt.Sprintf("%s/%s", t.API, testResourceID)).WithQuery("async", asyncParam). + Expect(). + Status(http.StatusUnauthorized).JSON().Object().Keys().Contains("error", "description") + }) }) - }) - if !t.DisableTenantResources { - Context("when authenticating with tenant scoped token", func() { - It("returns 404", func() { - verifyResourceDeletionWithErrorMsg(ctx.SMWithOAuthForTenant, failedDeletionRequestResponseCode, http.StatusOK, types.FAILED, notFoundMsg) + Context("when authenticating with global token", func() { + It("returns 200", func() { + verifyResourceDeletion(ctx.SMWithOAuth, successfulDeletionRequestResponseCode, http.StatusNotFound, types.SUCCEEDED) }) }) - } - }) + + if !t.DisableTenantResources { + Context("when authenticating with tenant scoped token", func() { + It("returns 404", func() { + verifyResourceDeletionWithErrorMsg(ctx.SMWithOAuthForTenant, failedDeletionRequestResponseCode, http.StatusOK, types.FAILED, notFoundMsg) + }) + }) + } + }) + } if !t.DisableTenantResources { Context("when the resource is tenant scoped", func() { @@ -135,11 +137,13 @@ func DescribeDeleteTestsfor(ctx *common.TestContext, t TestCase, responseMode Re }) }) - Context("when authenticating with global token", func() { - It("returns 200", func() { - verifyResourceDeletion(ctx.SMWithOAuth, successfulDeletionRequestResponseCode, http.StatusNotFound, types.SUCCEEDED) + if !t.StrictlyTenantScoped { + Context("when authenticating with global token", func() { + It("returns 200", func() { + verifyResourceDeletion(ctx.SMWithOAuth, successfulDeletionRequestResponseCode, http.StatusNotFound, types.SUCCEEDED) + }) }) - }) + } Context("when authenticating with tenant scoped token", func() { It("returns 200", func() { @@ -166,17 +170,31 @@ func DescribeDeleteTestsfor(ctx *common.TestContext, t TestCase, responseMode Re } Context("when authenticating with basic auth", func() { - It("returns 404", func() { - resp := ctx.SMWithOAuth.DELETE(fmt.Sprintf("%s/%s", t.API, testResourceID)).WithQuery("async", asyncParam).Expect() - verifyMissingResourceFailedDeletion(resp, notFoundMsg) - }) + if t.StrictlyTenantScoped { + It("returns 401", func() { + resp := ctx.SMWithBasic.DELETE(fmt.Sprintf("%s/%s", t.API, testResourceID)).WithQuery("async", asyncParam).Expect() + resp.Status(http.StatusUnauthorized) + }) + } else { + It("returns 401", func() { + resp := ctx.SMWithBasic.DELETE(fmt.Sprintf("%s/%s", t.API, testResourceID)).WithQuery("async", asyncParam).Expect() + resp.Status(http.StatusUnauthorized) + }) + } }) Context("when authenticating with global token", func() { - It("returns 404", func() { - resp := ctx.SMWithOAuth.DELETE(fmt.Sprintf("%s/%s", t.API, testResourceID)).WithQuery("async", asyncParam).Expect() - verifyMissingResourceFailedDeletion(resp, notFoundMsg) - }) + if t.StrictlyTenantScoped { + It("returns 400", func() { + resp := ctx.SMWithOAuth.DELETE(fmt.Sprintf("%s/%s", t.API, testResourceID)).WithQuery("async", asyncParam).Expect() + resp.Status(http.StatusBadRequest) + }) + } else { + It("returns 404", func() { + resp := ctx.SMWithOAuth.DELETE(fmt.Sprintf("%s/%s", t.API, testResourceID)).WithQuery("async", asyncParam).Expect() + verifyMissingResourceFailedDeletion(resp, notFoundMsg) + }) + } }) }) }) diff --git a/test/delete_list.go b/test/delete_list.go index c9644e53c..aaff58c9c 100644 --- a/test/delete_list.go +++ b/test/delete_list.go @@ -384,7 +384,7 @@ func DescribeDeleteListFor(ctx *common.TestContext, t TestCase) bool { patchLabelsBody["labels"] = patchLabels By(fmt.Sprintf("Attempting to patch resource of %s with labels as labels are declared supported", t.API)) - t.PatchResource(ctx, t.API, obj["id"].(string), t.ResourceType, patchLabels, false) + t.PatchResource(ctx, t.StrictlyTenantScoped, t.API, obj["id"].(string), t.ResourceType, patchLabels, false) result := ctx.SMWithOAuth.GET(t.API + "/" + obj["id"].(string)). Expect(). @@ -489,7 +489,11 @@ func DescribeDeleteListFor(ctx *common.TestContext, t TestCase) bool { } } verifyDeleteListOpHelper := func(deleteListOpEntry deleteOpEntry, query string) { - verifyDeleteListOpHelperWithAuth(deleteListOpEntry, query, ctx.SMWithOAuth) + if t.StrictlyTenantScoped { + verifyDeleteListOpHelperWithAuth(deleteListOpEntry, query, ctx.SMWithOAuthForTenant) + } else { + verifyDeleteListOpHelperWithAuth(deleteListOpEntry, query, ctx.SMWithOAuth) + } } verifyDeleteListOp := func(entry deleteOpEntry) { diff --git a/test/get.go b/test/get.go index 3186fa9e2..523ff90a8 100644 --- a/test/get.go +++ b/test/get.go @@ -57,67 +57,69 @@ func DescribeGetTestsfor(ctx *common.TestContext, t TestCase, responseMode Respo return testResource, testResourceID } - Context("when the resource is global", func() { - BeforeEach(func() { - testResource, testResourceID = createTestResourceWithAuth(ctx.SMWithOAuth) - stripObject(testResource, t.ResourcePropertiesToIgnore...) - }) - - Context("when authenticating with global token", func() { - It("returns 200", func() { - ctx.SMWithOAuth.GET(fmt.Sprintf("%s/%s", t.API, testResourceID)). - Expect(). - Status(http.StatusOK).JSON().Object().ContainsMap(testResource) + if !t.StrictlyTenantScoped { + Context("when the resource is global", func() { + BeforeEach(func() { + testResource, testResourceID = createTestResourceWithAuth(ctx.SMWithOAuth) + stripObject(testResource, t.ResourcePropertiesToIgnore...) }) - if t.SupportsAsyncOperations && responseMode == Async { - Context("when resource is created async and query param last_op is true", func() { - It("returns last operation with the resource", func() { - response := ctx.SMWithOAuth.GET(fmt.Sprintf("%s/%s", t.API, testResourceID)). - WithQuery(web.QueryParamLastOp, "true"). - Expect(). - Status(http.StatusOK).JSON().Object() - result := response.Raw() - if _, found := result["last_operation"]; found { - response.Value("last_operation").Object().ValueEqual("state", "succeeded") - } - }) - }) - } - - if !t.SupportsAsyncOperations { - Context("when resource does not support async and query param last_op is true", func() { - It("returns error", func() { - ctx.SMWithOAuth.GET(fmt.Sprintf("%s/%s", t.API, testResourceID)). - WithQuery(web.QueryParamLastOp, "true"). - Expect(). - Status(http.StatusBadRequest).JSON().Object().Value("description").String().Match("last operation is not supported for type *") - }) - }) - } - }) - - if !t.DisableTenantResources { - Context("when authenticating with tenant scoped token", func() { - It("returns 404", func() { - ctx.SMWithOAuthForTenant.GET(fmt.Sprintf("%s/%s", t.API, testResourceID)). + Context("when authenticating with global token", func() { + It("returns 200", func() { + ctx.SMWithOAuth.GET(fmt.Sprintf("%s/%s", t.API, testResourceID)). Expect(). - Status(http.StatusNotFound).JSON().Object().Keys().Contains("error", "description") + Status(http.StatusOK).JSON().Object().ContainsMap(testResource) }) if t.SupportsAsyncOperations && responseMode == Async { Context("when resource is created async and query param last_op is true", func() { - It("returns 404", func() { - ctx.SMWithOAuthForTenant.GET(fmt.Sprintf("%s/%s", t.API, testResourceID)). + It("returns last operation with the resource", func() { + response := ctx.SMWithOAuth.GET(fmt.Sprintf("%s/%s", t.API, testResourceID)). WithQuery(web.QueryParamLastOp, "true"). Expect(). - Status(http.StatusNotFound) + Status(http.StatusOK).JSON().Object() + result := response.Raw() + if _, found := result["last_operation"]; found { + response.Value("last_operation").Object().ValueEqual("state", "succeeded") + } + }) + }) + } + + if !t.SupportsAsyncOperations { + Context("when resource does not support async and query param last_op is true", func() { + It("returns error", func() { + ctx.SMWithOAuth.GET(fmt.Sprintf("%s/%s", t.API, testResourceID)). + WithQuery(web.QueryParamLastOp, "true"). + Expect(). + Status(http.StatusBadRequest).JSON().Object().Value("description").String().Match("last operation is not supported for type *") }) }) } }) - } - }) + + if !t.DisableTenantResources { + Context("when authenticating with tenant scoped token", func() { + It("returns 404", func() { + ctx.SMWithOAuthForTenant.GET(fmt.Sprintf("%s/%s", t.API, testResourceID)). + Expect(). + Status(http.StatusNotFound).JSON().Object().Keys().Contains("error", "description") + }) + + if t.SupportsAsyncOperations && responseMode == Async { + Context("when resource is created async and query param last_op is true", func() { + It("returns 404", func() { + ctx.SMWithOAuthForTenant.GET(fmt.Sprintf("%s/%s", t.API, testResourceID)). + WithQuery(web.QueryParamLastOp, "true"). + Expect(). + Status(http.StatusNotFound) + }) + }) + } + }) + } + }) + } if !t.DisableTenantResources { Context("when the resource is tenant scoped", func() { diff --git a/test/list.go b/test/list.go index 6737195f7..ae32b7c9b 100644 --- a/test/list.go +++ b/test/list.go @@ -25,6 +25,7 @@ import ( "time" "github.com/Peripli/service-manager/pkg/query" + "github.com/gavv/httpexpect" . "github.com/onsi/ginkgo/extensions/table" @@ -69,10 +70,17 @@ func DescribeListTestsFor(ctx *common.TestContext, t TestCase, responseMode Resp }, } - t.PatchResource(ctx, t.API, obj["id"].(string), t.ResourceType, patchLabels, bool(responseMode)) - result := ctx.SMWithOAuth.GET(t.API + "/" + obj["id"].(string)). - Expect(). - Status(http.StatusOK).JSON().Object() + t.PatchResource(ctx, t.StrictlyTenantScoped, t.API, obj["id"].(string), t.ResourceType, patchLabels, bool(responseMode)) + var result *httpexpect.Object + if t.StrictlyTenantScoped { + result = ctx.SMWithOAuthForTenant.GET(t.API + "/" + obj["id"].(string)). + Expect(). + Status(http.StatusOK).JSON().Object() + } else { + result = ctx.SMWithOAuth.GET(t.API + "/" + obj["id"].(string)). + Expect(). + Status(http.StatusOK).JSON().Object() + } result.ContainsKey("labels") resultObject := result.Raw() delete(resultObject, "credentials") @@ -415,7 +423,7 @@ func DescribeListTestsFor(ctx *common.TestContext, t TestCase, responseMode Resp Values: []string{labelValue}, }, } - t.PatchResource(ctx, t.API, obj["id"].(string), t.ResourceType, patchLabels, bool(responseMode)) + t.PatchResource(ctx, t.StrictlyTenantScoped, t.API, obj["id"].(string), t.ResourceType, patchLabels, bool(responseMode)) }) It("returns 200", func() { @@ -482,7 +490,7 @@ func DescribeListTestsFor(ctx *common.TestContext, t TestCase, responseMode Resp } By(fmt.Sprintf("Attempting add one additional %s label with value %v to resoucre of type %s with id %s", labelKey, []string{objID}, t.API, objID)) - t.PatchResource(ctx, t.API, objID, t.ResourceType, patchLabels, bool(responseMode)) + t.PatchResource(ctx, t.StrictlyTenantScoped, t.API, objID, t.ResourceType, patchLabels, bool(responseMode)) object := ctx.SMWithOAuth.GET(t.API + "/" + objID). Expect(). diff --git a/test/patch.go b/test/patch.go index 714489153..bf64d9754 100644 --- a/test/patch.go +++ b/test/patch.go @@ -45,35 +45,37 @@ func DescribePatchTestsFor(ctx *common.TestContext, t TestCase, responseMode Res Context(fmt.Sprintf("Existing resource of type %s", t.API), func() { Context("with bearer auth", func() { - Context("when the resource is global", func() { - BeforeEach(func() { - createTestResourceWithAuth(ctx.SMWithOAuth) - }) - - Context("when authenticating with basic auth", func() { - It("returns 401", func() { - ctx.SMWithBasic.PATCH(t.API+"/"+testResourceID).WithQuery("async", asyncParam).WithJSON(common.Object{}). - Expect(). - Status(http.StatusUnauthorized) + if !t.StrictlyTenantScoped { + Context("when the resource is global", func() { + BeforeEach(func() { + createTestResourceWithAuth(ctx.SMWithOAuth) }) - }) - Context("when authenticating with global token", func() { - It("returns 200", func() { - resp := ctx.SMWithOAuth.PATCH(t.API+"/"+testResourceID).WithQuery("async", asyncParam).WithJSON(common.Object{}).Expect() - verifyPatchedResource(resp) + Context("when authenticating with basic auth", func() { + It("returns 401", func() { + ctx.SMWithBasic.PATCH(t.API+"/"+testResourceID).WithQuery("async", asyncParam).WithJSON(common.Object{}). + Expect(). + Status(http.StatusUnauthorized) + }) }) - }) - if !t.DisableTenantResources { - Context("when authenticating with tenant scoped token", func() { - It("returns 404", func() { - ctx.SMWithOAuthForTenant.PATCH(t.API+"/"+testResourceID).WithQuery("async", asyncParam).WithJSON(common.Object{}). - Expect().Status(http.StatusNotFound) + Context("when authenticating with global token", func() { + It("returns 200", func() { + resp := ctx.SMWithOAuth.PATCH(t.API+"/"+testResourceID).WithQuery("async", asyncParam).WithJSON(common.Object{}).Expect() + verifyPatchedResource(resp) }) }) - } - }) + + if !t.DisableTenantResources { + Context("when authenticating with tenant scoped token", func() { + It("returns 404", func() { + ctx.SMWithOAuthForTenant.PATCH(t.API+"/"+testResourceID).WithQuery("async", asyncParam).WithJSON(common.Object{}). + Expect().Status(http.StatusNotFound) + }) + }) + } + }) + } if !t.DisableTenantResources { Context("when the resource is tenant scoped", func() { @@ -89,12 +91,14 @@ func DescribePatchTestsFor(ctx *common.TestContext, t TestCase, responseMode Res }) }) - Context("when authenticating with global token", func() { - It("returns 200", func() { - resp := ctx.SMWithOAuth.PATCH(t.API+"/"+testResourceID).WithQuery("async", asyncParam).WithJSON(common.Object{}).Expect() - verifyPatchedResource(resp) + if !t.StrictlyTenantScoped { + Context("when authenticating with global token", func() { + It("returns 200", func() { + resp := ctx.SMWithOAuth.PATCH(t.API+"/"+testResourceID).WithQuery("async", asyncParam).WithJSON(common.Object{}).Expect() + verifyPatchedResource(resp) + }) }) - }) + } Context("when authenticating with tenant scoped token", func() { It("returns 200", func() { @@ -121,10 +125,17 @@ func DescribePatchTestsFor(ctx *common.TestContext, t TestCase, responseMode Res }) Context("when authenticating with global token", func() { - It("returns 404", func() { - ctx.SMWithOAuth.PATCH(t.API+"/"+testResourceID).WithQuery("async", asyncParam).WithJSON(common.Object{}). - Expect().Status(http.StatusNotFound) - }) + if t.StrictlyTenantScoped { + It("returns 400", func() { + ctx.SMWithOAuth.PATCH(t.API+"/"+testResourceID).WithQuery("async", asyncParam).WithJSON(common.Object{}). + Expect().Status(http.StatusBadRequest) + }) + } else { + It("returns 404", func() { + ctx.SMWithOAuth.PATCH(t.API+"/"+testResourceID).WithQuery("async", asyncParam).WithJSON(common.Object{}). + Expect().Status(http.StatusNotFound) + }) + } }) }) }) diff --git a/test/service_binding_test/service_binding_test.go b/test/service_binding_test/service_binding_test.go index 48b43d444..726d4210b 100644 --- a/test/service_binding_test/service_binding_test.go +++ b/test/service_binding_test/service_binding_test.go @@ -70,7 +70,8 @@ var _ = DescribeTestsFor(TestCase{ }, ResourceType: types.ServiceBindingType, SupportsAsyncOperations: true, - DisableTenantResources: true, + DisableTenantResources: false, + StrictlyTenantScoped: true, ResourceBlueprint: blueprint, ResourceWithoutNullableFieldsBlueprint: blueprint, ResourcePropertiesToIgnore: []string{"volume_mounts", "endpoints", "bind_resource", "credentials"}, @@ -277,35 +278,6 @@ var _ = DescribeTestsFor(TestCase{ }) }) }) - - When("service binding doesn't contain tenant identifier in OSB context", func() { - BeforeEach(func() { - createBinding(ctx.SMWithOAuth, false, http.StatusCreated) - }) - - It("doesn't label instance with tenant identifier", func() { - obj := ctx.SMWithOAuth.GET(web.ServiceBindingsURL + "/" + bindingID).Expect(). - Status(http.StatusOK).JSON().Object() - - objMap := obj.Raw() - objLabels, exist := objMap["labels"] - if exist { - labels := objLabels.(map[string]interface{}) - _, tenantLabelExists := labels[TenantIdentifier] - Expect(tenantLabelExists).To(BeFalse()) - } - }) - - It("returns OSB context with tenant as part of the binding", func() { - ctx.SMWithOAuth.GET(web.ServiceBindingsURL + "/" + bindingID).Expect(). - Status(http.StatusOK). - JSON(). - Object().Value("context").Object().Equal(map[string]interface{}{ - "platform": types.SMPlatform, - "instance_name": instanceName, - }) - }) - }) }) Describe("POST", func() { @@ -360,7 +332,7 @@ var _ = DescribeTestsFor(TestCase{ }) It("returns 201", func() { - resp := createBinding(ctx.SMWithOAuth, testCase.async, testCase.expectedCreateSuccessStatusCode) + resp := createBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedCreateSuccessStatusCode) bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ Category: types.CREATE, State: types.SUCCEEDED, @@ -422,11 +394,12 @@ var _ = DescribeTestsFor(TestCase{ Context("instance visibility", func() { When("tenant doesn't have ownership of instance", func() { BeforeEach(func() { - createInstance(ctx.SMWithOAuth, false, http.StatusCreated) + createInstance(ctx.SMWithOAuthForTenant, false, http.StatusCreated) }) It("returns 404", func() { - createBinding(ctx.SMWithOAuthForTenant, testCase.async, http.StatusNotFound) + otherTenantExpect := ctx.NewTenantExpect("other-tenant") + createBinding(otherTenantExpect, testCase.async, http.StatusNotFound) }) }) @@ -984,7 +957,7 @@ var _ = DescribeTestsFor(TestCase{ Context("instance ownership", func() { When("tenant doesn't have ownership of binding", func() { It("returns 404", func() { - resp := createBinding(ctx.SMWithOAuth, testCase.async, testCase.expectedCreateSuccessStatusCode) + resp := createBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedCreateSuccessStatusCode) bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ Category: types.CREATE, State: types.SUCCEEDED, @@ -992,15 +965,16 @@ var _ = DescribeTestsFor(TestCase{ Reschedulable: false, DeletionScheduled: false, }) - verifyBindingExists(ctx.SMWithOAuth, bindingID, true) + verifyBindingExists(ctx.SMWithOAuthForTenant, bindingID, true) expectedCode := http.StatusNotFound if testCase.async { expectedCode = http.StatusAccepted } - deleteBinding(ctx.SMWithOAuthForTenant, testCase.async, expectedCode) + smWithOtherTenant := ctx.NewTenantExpect("other-tenant") + deleteBinding(smWithOtherTenant, testCase.async, expectedCode) - verifyBindingExists(ctx.SMWithOAuth, bindingID, true) + verifyBindingExists(ctx.SMWithOAuthForTenant, bindingID, true) }) }) @@ -1485,8 +1459,8 @@ var _ = DescribeTestsFor(TestCase{ func blueprint(ctx *TestContext, auth *SMExpect, async bool) Object { _, _, servicePlanID := newServicePlan(ctx, true) - EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, "") - resp := ctx.SMWithOAuth.POST(web.ServiceInstancesURL). + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, TenantIDValue) + resp := ctx.SMWithOAuthForTenant.POST(web.ServiceInstancesURL). WithQuery("async", strconv.FormatBool(async)). WithJSON(Object{ "name": "test-service-instance", @@ -1501,7 +1475,7 @@ func blueprint(ctx *TestContext, auth *SMExpect, async bool) Object { instance = resp.Status(http.StatusCreated).JSON().Object().Raw() } - resp = ctx.SMWithOAuth.POST(web.ServiceBindingsURL). + resp = ctx.SMWithOAuthForTenant.POST(web.ServiceBindingsURL). WithQuery("async", strconv.FormatBool(async)). WithJSON(Object{ "name": "test-service-binding", diff --git a/test/service_instance_test/service_instance_test.go b/test/service_instance_test/service_instance_test.go index b60d9df3b..fced93b00 100644 --- a/test/service_instance_test/service_instance_test.go +++ b/test/service_instance_test/service_instance_test.go @@ -74,7 +74,8 @@ var _ = DescribeTestsFor(TestCase{ }, ResourceType: types.ServiceInstanceType, SupportsAsyncOperations: true, - DisableTenantResources: true, + DisableTenantResources: false, + StrictlyTenantScoped: true, ResourceBlueprint: blueprint, ResourceWithoutNullableFieldsBlueprint: blueprint, ResourcePropertiesToIgnore: []string{"platform_id"}, @@ -243,47 +244,15 @@ var _ = DescribeTestsFor(TestCase{ }) }) - When("service instance doesn't contain tenant identifier in OSB context", func() { - BeforeEach(func() { - EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, "") - resp := createInstanceWithAsync(ctx.SMWithOAuth, false, http.StatusCreated) - instanceName = resp.JSON().Object().Value("name").String().Raw() - Expect(instanceName).ToNot(BeEmpty()) - }) - - It("doesn't label instance with tenant identifier", func() { - obj := ctx.SMWithOAuth.GET(web.ServiceInstancesURL + "/" + instanceID).Expect(). - Status(http.StatusOK).JSON().Object() - - objMap := obj.Raw() - objLabels, exist := objMap["labels"] - if exist { - labels := objLabels.(map[string]interface{}) - _, tenantLabelExists := labels[TenantIdentifier] - Expect(tenantLabelExists).To(BeFalse()) - } - }) - - It("returns OSB context with no tenant as part of the instance", func() { - ctx.SMWithOAuth.GET(web.ServiceInstancesURL + "/" + instanceID).Expect(). - Status(http.StatusOK). - JSON(). - Object().Value("context").Object().Equal(map[string]interface{}{ - "platform": types.SMPlatform, - "instance_name": instanceName, - }) - }) - }) - When("service instance dashboard_url is not set", func() { BeforeEach(func() { postInstanceRequest["dashboard_url"] = "" - EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") - createInstanceWithAsync(ctx.SMWithOAuth, false, http.StatusCreated) + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), TenantIDValue) + createInstanceWithAsync(ctx.SMWithOAuthForTenant, false, http.StatusCreated) }) It("doesn't return dashboard_url", func() { - ctx.SMWithOAuth.GET(web.ServiceInstancesURL + "/" + instanceID).Expect(). + ctx.SMWithOAuthForTenant.GET(web.ServiceInstancesURL + "/" + instanceID).Expect(). Status(http.StatusOK).JSON().Object().NotContainsKey("dashboard_url") }) }) @@ -392,7 +361,7 @@ var _ = DescribeTestsFor(TestCase{ Context("which is not service-manager platform", func() { It("should return 400", func() { postInstanceRequest["platform_id"] = "test-platform-id" - resp := ctx.SMWithOAuth.POST(web.ServiceInstancesURL). + resp := ctx.SMWithOAuthForTenant.POST(web.ServiceInstancesURL). WithJSON(postInstanceRequest). WithQuery("async", testCase.async). Expect().Status(http.StatusBadRequest).JSON().Object() @@ -404,8 +373,8 @@ var _ = DescribeTestsFor(TestCase{ Context("which is service-manager platform", func() { It(fmt.Sprintf("should return %d", testCase.expectedCreateSuccessStatusCode), func() { postInstanceRequest["platform_id"] = types.SMPlatform - EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") - createInstanceWithAsync(ctx.SMWithOAuth, testCase.async, testCase.expectedCreateSuccessStatusCode) + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), TenantIDValue) + createInstanceWithAsync(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedCreateSuccessStatusCode) }) }) }) @@ -445,11 +414,6 @@ var _ = DescribeTestsFor(TestCase{ }) When("plan has public visibility", func() { - It(fmt.Sprintf("for global returns %d", testCase.expectedCreateSuccessStatusCode), func() { - EnsurePublicPlanVisibility(ctx.SMRepository, servicePlanID) - createInstanceWithAsync(ctx.SMWithOAuth, testCase.async, testCase.expectedCreateSuccessStatusCode) - }) - It(fmt.Sprintf("for tenant returns %d", testCase.expectedCreateSuccessStatusCode), func() { EnsurePublicPlanVisibility(ctx.SMRepository, servicePlanID) createInstanceWithAsync(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedCreateSuccessStatusCode) @@ -909,7 +873,7 @@ var _ = DescribeTestsFor(TestCase{ When("instance is missing", func() { It("returns 404", func() { - ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL+"/no_such_id"). + ctx.SMWithOAuthForTenant.PATCH(web.ServiceInstancesURL+"/no_such_id"). WithJSON(postInstanceRequest). Expect().Status(http.StatusNotFound). JSON().Object(). @@ -931,14 +895,14 @@ var _ = DescribeTestsFor(TestCase{ When("created_at provided in body", func() { It("should not change created at", func() { - EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") - resp := createInstance(ctx.SMWithOAuth, http.StatusAccepted) + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), TenantIDValue) + resp := createInstance(ctx.SMWithOAuthForTenant, http.StatusAccepted) instance := ExpectSuccessfulAsyncResourceCreation(resp, ctx.SMWithOAuth, web.ServiceInstancesURL) instanceID := instance["id"].(string) createdAt := "2015-01-01T00:00:00Z" - resp = ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL + "/" + instanceID). + resp = ctx.SMWithOAuthForTenant.PATCH(web.ServiceInstancesURL + "/" + instanceID). WithJSON(Object{"created_at": createdAt}). Expect(). Status(http.StatusAccepted) @@ -957,8 +921,8 @@ var _ = DescribeTestsFor(TestCase{ When("platform_id provided in body", func() { Context("which is not service-manager platform", func() { It("should return 400", func() { - EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") - createInstanceWithAsync(ctx.SMWithOAuth, false, http.StatusCreated) + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), TenantIDValue) + createInstanceWithAsync(ctx.SMWithOAuthForTenant, false, http.StatusCreated) resp := ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL + "/" + instanceID). WithJSON(Object{"platform_id": "test-platform-id"}). @@ -976,12 +940,12 @@ var _ = DescribeTestsFor(TestCase{ Context("which is service-manager platform", func() { It("should return 200", func() { - EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") - resp := createInstance(ctx.SMWithOAuth, http.StatusAccepted) + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), TenantIDValue) + resp := createInstance(ctx.SMWithOAuthForTenant, http.StatusAccepted) instance := ExpectSuccessfulAsyncResourceCreation(resp, ctx.SMWithOAuth, web.ServiceInstancesURL) instanceID := instance["id"].(string) - resp = ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL + "/" + instanceID). + resp = ctx.SMWithOAuthForTenant.PATCH(web.ServiceInstancesURL + "/" + instanceID). WithJSON(Object{"platform_id": types.SMPlatform}). Expect().Status(http.StatusAccepted) @@ -1000,15 +964,15 @@ var _ = DescribeTestsFor(TestCase{ When("fields are updated one by one", func() { It("returns 200", func() { - EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") - resp := createInstance(ctx.SMWithOAuth, http.StatusAccepted) + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), TenantIDValue) + resp := createInstance(ctx.SMWithOAuthForTenant, http.StatusAccepted) instance := ExpectSuccessfulAsyncResourceCreation(resp, ctx.SMWithOAuth, web.ServiceInstancesURL) instanceID := instance["id"].(string) for _, prop := range []string{"name", "maintenance_info"} { updatedBrokerJSON := Object{} updatedBrokerJSON[prop] = "updated-" + prop - resp = ctx.SMWithOAuth.PATCH(web.ServiceInstancesURL + "/" + instanceID). + resp = ctx.SMWithOAuthForTenant.PATCH(web.ServiceInstancesURL + "/" + instanceID). WithJSON(updatedBrokerJSON). Expect(). Status(http.StatusAccepted) @@ -1057,10 +1021,11 @@ var _ = DescribeTestsFor(TestCase{ Context("instance ownership", func() { When("tenant doesn't have ownership of instance", func() { It("returns 404", func() { - EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") - createInstanceWithAsync(ctx.SMWithOAuth, false, http.StatusCreated) + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), TenantIDValue) + createInstanceWithAsync(ctx.SMWithOAuthForTenant, false, http.StatusCreated) - ctx.SMWithOAuthForTenant.PATCH(web.ServiceInstancesURL + "/" + instanceID). + otherTenantExpect := ctx.NewTenantExpect("other-tenant") + otherTenantExpect.PATCH(web.ServiceInstancesURL + "/" + instanceID). WithJSON(Object{"service_plan_id": anotherServicePlanID}). Expect().Status(http.StatusNotFound) }) @@ -1106,8 +1071,8 @@ var _ = DescribeTestsFor(TestCase{ Context("instance ownership", func() { When("tenant doesn't have ownership of instance", func() { It("returns 404", func() { - EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), "") - resp := createInstanceWithAsync(ctx.SMWithOAuth, testCase.async, testCase.expectedCreateSuccessStatusCode) + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, postInstanceRequest["service_plan_id"].(string), TenantIDValue) + resp := createInstanceWithAsync(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedCreateSuccessStatusCode) instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ Category: types.CREATE, State: types.SUCCEEDED, @@ -1119,7 +1084,8 @@ var _ = DescribeTestsFor(TestCase{ if testCase.async { expectedCode = http.StatusAccepted } - deleteInstance(ctx.SMWithOAuthForTenant, testCase.async, expectedCode) + otherTenantExpect := ctx.NewTenantExpect("other-tenant") + deleteInstance(otherTenantExpect, testCase.async, expectedCode) }) }) @@ -1667,8 +1633,8 @@ func blueprint(ctx *TestContext, auth *SMExpect, async bool) Object { _, _, array := prepareBrokerWithCatalog(ctx, auth) instanceReqBody["service_plan_id"] = array.First().Object().Value("id").String().Raw() - EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, instanceReqBody["service_plan_id"].(string), "") - resp := auth.POST(web.ServiceInstancesURL).WithQuery("async", strconv.FormatBool(async)).WithJSON(instanceReqBody).Expect() + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, instanceReqBody["service_plan_id"].(string), TenantIDValue) + resp := ctx.SMWithOAuthForTenant.POST(web.ServiceInstancesURL).WithQuery("async", strconv.FormatBool(async)).WithJSON(instanceReqBody).Expect() var instance map[string]interface{} if async { diff --git a/test/test.go b/test/test.go index 02c845f48..9acbea314 100644 --- a/test/test.go +++ b/test/test.go @@ -82,10 +82,11 @@ type TestCase struct { MultitenancySettings *MultitenancySettings DisableTenantResources bool + StrictlyTenantScoped bool ResourceBlueprint func(ctx *common.TestContext, smClient *common.SMExpect, async bool) common.Object ResourceWithoutNullableFieldsBlueprint func(ctx *common.TestContext, smClient *common.SMExpect, async bool) common.Object - PatchResource func(ctx *common.TestContext, apiPath string, objID string, resourceType types.ObjectType, patchLabels []*query.LabelChange, async bool) + PatchResource func(ctx *common.TestContext, tenantScoped bool, apiPath string, objID string, resourceType types.ObjectType, patchLabels []*query.LabelChange, async bool) AdditionalTests func(ctx *common.TestContext) } @@ -99,12 +100,18 @@ func stripObject(obj common.Object, properties ...string) { } } -func APIResourcePatch(ctx *common.TestContext, apiPath string, objID string, _ types.ObjectType, patchLabels []*query.LabelChange, async bool) { +func APIResourcePatch(ctx *common.TestContext, tenantScoped bool, apiPath string, objID string, _ types.ObjectType, patchLabels []*query.LabelChange, async bool) { patchLabelsBody := make(map[string]interface{}) patchLabelsBody["labels"] = patchLabels By(fmt.Sprintf("Attempting to patch resource of %s with labels as labels are declared supported", apiPath)) - resp := ctx.SMWithOAuth.PATCH(apiPath+"/"+objID).WithQuery("async", strconv.FormatBool(async)).WithJSON(patchLabelsBody).Expect() + var resp *httpexpect.Response + + if tenantScoped { + resp = ctx.SMWithOAuthForTenant.PATCH(apiPath+"/"+objID).WithQuery("async", strconv.FormatBool(async)).WithJSON(patchLabelsBody).Expect() + } else { + resp = ctx.SMWithOAuth.PATCH(apiPath+"/"+objID).WithQuery("async", strconv.FormatBool(async)).WithJSON(patchLabelsBody).Expect() + } if async { resp = resp.Status(http.StatusAccepted) @@ -117,7 +124,7 @@ func APIResourcePatch(ctx *common.TestContext, apiPath string, objID string, _ t } } -func StorageResourcePatch(ctx *common.TestContext, _ string, objID string, resourceType types.ObjectType, patchLabels []*query.LabelChange, _ bool) { +func StorageResourcePatch(ctx *common.TestContext, _ bool, _ string, objID string, resourceType types.ObjectType, patchLabels []*query.LabelChange, _ bool) { byID := query.ByField(query.EqualsOperator, "id", objID) sb, err := ctx.SMRepository.Get(context.Background(), resourceType, byID) if err != nil { From 87eea791e7287b6a91ceaec35c900bbcf5e3d3ff Mon Sep 17 00:00:00 2001 From: Nikolay Mateev Date: Wed, 5 Feb 2020 11:40:36 +0200 Subject: [PATCH 12/15] Merge master in master-smaap (#429) --- CODEOWNERS | 8 + api/api.go | 3 + api/base_controller.go | 3 + api/extensions/security/authn.go | 4 +- api/filters/checkBrokerCredentials.go | 47 ++++++ api/filters/oidc_authentication.go | 1 + api/filters/tenant_filter.go | 4 +- api/osb/check_instance_visibility_plugin.go | 18 +-- api/osb/store_instances_plugin.go | 1 + api/profile/controller.go | 33 ++++ docs/development/code-standards.md | 4 +- docs/development/logging.md | 92 +++++++++++ operations/config.go | 15 +- operations/maintainer.go | 34 ++-- pkg/security/http/authz/oauth_scopes.go | 14 ++ pkg/security/http/authz/oauth_scopes_test.go | 148 +++++++++++++----- pkg/types/operation.go | 2 + pkg/web/routes.go | 3 + storage/postgres/keystore_test.go | 2 +- ...00128152000_operation_platform_id.down.sql | 5 + ...0200128152000_operation_platform_id.up.sql | 5 + ... 20200129152000_service_bindings.down.sql} | 0 ...=> 20200129152000_service_bindings.up.sql} | 0 ...00_additional_operations_columns.down.sql} | 0 ...2000_additional_operations_columns.up.sql} | 0 ...l => 20200131142000_ready_column.down.sql} | 0 ...sql => 20200131142000_ready_column.up.sql} | 0 ... 20200131152000_alter_plan_table.down.sql} | 0 ...=> 20200131152000_alter_plan_table.up.sql} | 0 storage/postgres/operation.go | 3 + test/auth_test/auth_test.go | 6 + test/broker_test/broker_test.go | 65 +++++++- test/common/common.go | 2 +- test/common/oauth_server.go | 13 +- test/configuration_test/configuration_test.go | 6 +- test/operations_test/operations_test.go | 126 ++++++++++++--- test/profile_test/profile_test.go | 89 +++++++++++ 37 files changed, 650 insertions(+), 106 deletions(-) create mode 100644 CODEOWNERS create mode 100644 api/filters/checkBrokerCredentials.go create mode 100644 api/profile/controller.go create mode 100644 docs/development/logging.md create mode 100644 storage/postgres/migrations/20200128152000_operation_platform_id.down.sql create mode 100644 storage/postgres/migrations/20200128152000_operation_platform_id.up.sql rename storage/postgres/migrations/{20200116221100_service_bindings.down.sql => 20200129152000_service_bindings.down.sql} (100%) rename storage/postgres/migrations/{20200116221100_service_bindings.up.sql => 20200129152000_service_bindings.up.sql} (100%) rename storage/postgres/migrations/{20200117150000_additional_operations_columns.down.sql => 20200130152000_additional_operations_columns.down.sql} (100%) rename storage/postgres/migrations/{20200117150000_additional_operations_columns.up.sql => 20200130152000_additional_operations_columns.up.sql} (100%) rename storage/postgres/migrations/{20200117151000_ready_column.down.sql => 20200131142000_ready_column.down.sql} (100%) rename storage/postgres/migrations/{20200117151000_ready_column.up.sql => 20200131142000_ready_column.up.sql} (100%) rename storage/postgres/migrations/{20200122151000_alter_plan_table.down.sql => 20200131152000_alter_plan_table.down.sql} (100%) rename storage/postgres/migrations/{20200122151000_alter_plan_table.up.sql => 20200131152000_alter_plan_table.up.sql} (100%) create mode 100644 test/profile_test/profile_test.go diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000..74719e292 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,8 @@ +# GitHub code owners +# See https://help.github.com/articles/about-codeowners/ +# +# KEEP THIS FILE SORTED. Order is important. Last match takes precedence. + +# For plugins/** directory, each team can add a person required to review before merging. Otherwise Service Manager team will be the only reviewer. + +* @KirilKabakchiev @dotchev @dpanayotov @dzahariev @georgifarashev @NickyMateev @nyordanoff @pankrator @DimitarPetrov diff --git a/api/api.go b/api/api.go index c5e4215e7..93cf6819d 100644 --- a/api/api.go +++ b/api/api.go @@ -26,6 +26,7 @@ import ( "github.com/Peripli/service-manager/pkg/env" "github.com/Peripli/service-manager/api/configuration" + "github.com/Peripli/service-manager/api/profile" "github.com/Peripli/service-manager/pkg/query" @@ -125,6 +126,7 @@ func New(ctx context.Context, e env.Environment, options *Options) (*web.API, er &configuration.Controller{ Environment: e, }, + &profile.Controller{}, }, // Default filters - more filters can be registered using the relevant API methods Filters: []web.Filter{ @@ -139,6 +141,7 @@ func New(ctx context.Context, e env.Environment, options *Options) (*web.API, er &filters.PatchOnlyLabelsFilter{}, filters.NewPlansFilterByVisibility(options.Repository), filters.NewServicesFilterByVisibility(options.Repository), + &filters.CheckBrokerCredentialsFilter{}, }, Registry: health.NewDefaultRegistry(), }, nil diff --git a/api/base_controller.go b/api/base_controller.go index 8854e9e77..fbded16c0 100644 --- a/api/base_controller.go +++ b/api/base_controller.go @@ -190,6 +190,7 @@ func (c *BaseController) CreateObject(r *web.Request) (*web.Response, error) { State: types.IN_PROGRESS, ResourceID: result.GetID(), ResourceType: c.objectType, + PlatformID: types.SMPlatform, CorrelationID: log.CorrelationIDFromContext(ctx), } @@ -274,6 +275,7 @@ func (c *BaseController) DeleteSingleObject(r *web.Request) (*web.Response, erro State: types.IN_PROGRESS, ResourceID: objectID, ResourceType: c.objectType, + PlatformID: types.SMPlatform, CorrelationID: log.CorrelationIDFromContext(ctx), } @@ -466,6 +468,7 @@ func (c *BaseController) PatchObject(r *web.Request) (*web.Response, error) { State: types.IN_PROGRESS, ResourceID: objFromDB.GetID(), ResourceType: c.objectType, + PlatformID: types.SMPlatform, CorrelationID: log.CorrelationIDFromContext(ctx), } diff --git a/api/extensions/security/authn.go b/api/extensions/security/authn.go index 0044b35a3..2f4cd1506 100644 --- a/api/extensions/security/authn.go +++ b/api/extensions/security/authn.go @@ -53,7 +53,9 @@ func Register(ctx context.Context, cfg *config.Settings, smb *sm.ServiceManagerB web.NotificationsURL+"/**", web.ServiceInstancesURL+"/**", web.ServiceBindingsURL+"/**", - web.ConfigURL+"/**"). + web.ConfigURL+"/**", + web.ProfileURL+"/**", + ). Method(http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete). WithAuthentication(bearerAuthenticator).Required() diff --git a/api/filters/checkBrokerCredentials.go b/api/filters/checkBrokerCredentials.go new file mode 100644 index 000000000..f452309c7 --- /dev/null +++ b/api/filters/checkBrokerCredentials.go @@ -0,0 +1,47 @@ +package filters + +import ( + "fmt" + "net/http" + + "github.com/Peripli/service-manager/pkg/util" + "github.com/Peripli/service-manager/pkg/web" + "github.com/tidwall/gjson" +) + +const ( + CheckBrokerCredentialsFilterName = "CheckBrokerCredentialsFilter" + credentialsPath = "credentials.basic.%s" +) + +// CheckBrokerCredentialsFilter checks patch request for the broker basic credentials +type CheckBrokerCredentialsFilter struct { +} + +func (*CheckBrokerCredentialsFilter) Name() string { + return CheckBrokerCredentialsFilterName +} + +func (*CheckBrokerCredentialsFilter) Run(req *web.Request, next web.Handler) (*web.Response, error) { + fields := gjson.GetManyBytes(req.Body, "broker_url", fmt.Sprintf(credentialsPath, "username"), fmt.Sprintf(credentialsPath, "password")) + + if fields[0].Exists() && (!fields[1].Exists() || !fields[2].Exists()) { + return nil, &util.HTTPError{ + ErrorType: "BadRequest", + Description: "Updating an URL of a broker requires its basic credentials", + StatusCode: http.StatusBadRequest, + } + } + return next.Handle(req) +} + +func (*CheckBrokerCredentialsFilter) FilterMatchers() []web.FilterMatcher { + return []web.FilterMatcher{ + { + Matchers: []web.Matcher{ + web.Path(web.ServiceBrokersURL + "/**"), + web.Methods(http.MethodPatch), + }, + }, + } +} diff --git a/api/filters/oidc_authentication.go b/api/filters/oidc_authentication.go index 5b2b421b5..66d8ccb21 100644 --- a/api/filters/oidc_authentication.go +++ b/api/filters/oidc_authentication.go @@ -55,6 +55,7 @@ func oidcAuthnMatchers() []web.FilterMatcher { web.VisibilitiesURL+"/**", web.ServiceInstancesURL+"/**", web.ConfigURL+"/**", + web.ProfileURL+"/**", ), }, }, diff --git a/api/filters/tenant_filter.go b/api/filters/tenant_filter.go index 1ec5abd32..f402c48a3 100644 --- a/api/filters/tenant_filter.go +++ b/api/filters/tenant_filter.go @@ -116,11 +116,11 @@ func (f *TenantFilter) Run(request *web.Request, next web.Handler) (*web.Respons userContext, found := web.UserFromContext(ctx) if !found { - log.C(ctx).Infof("No user found in user context. Proceeding with empty tenant ID value...") + log.C(ctx).Debug("No user found in user context. Proceeding with empty tenant ID value...") return next.Handle(request) } if userContext.AccessLevel == web.GlobalAccess { - log.C(ctx).Infof("Access level is Global. Proceeding with empty tenant ID value...") + log.C(ctx).Debug("Access level is Global. Proceeding with empty tenant ID value...") return next.Handle(request) } diff --git a/api/osb/check_instance_visibility_plugin.go b/api/osb/check_instance_visibility_plugin.go index e2dba3e4c..d0ec2a61a 100644 --- a/api/osb/check_instance_visibility_plugin.go +++ b/api/osb/check_instance_visibility_plugin.go @@ -15,6 +15,12 @@ import ( const CheckVisibilityPluginName = "CheckVisibilityPlugin" +var errPlanNotAccessible = &util.HTTPError{ + ErrorType: "ServicePlanNotFound", + Description: "service plan not found or not accessible", + StatusCode: http.StatusNotFound, +} + type checkVisibilityPlugin struct { repository storage.Repository } @@ -112,11 +118,7 @@ func (p *checkVisibilityPlugin) checkVisibility(req *web.Request, next web.Handl } } log.C(ctx).Errorf("Service plan %v is not visible on platform %v", planID, platform.ID) - return nil, &util.HTTPError{ - ErrorType: "NotFound", - Description: "could not find such service plan", - StatusCode: http.StatusNotFound, - } + return nil, errPlanNotAccessible default: for _, v := range visibilities.Visibilities { if v.PlatformID == "" { // public visibility @@ -127,10 +129,6 @@ func (p *checkVisibilityPlugin) checkVisibility(req *web.Request, next web.Handl } } log.C(ctx).Errorf("Service plan %v is not visible on platform %v", planID, platform.ID) - return nil, &util.HTTPError{ - ErrorType: "NotFound", - Description: "could not find such service plan", - StatusCode: http.StatusNotFound, - } + return nil, errPlanNotAccessible } } diff --git a/api/osb/store_instances_plugin.go b/api/osb/store_instances_plugin.go index 859d92ebd..b7d18bd4f 100644 --- a/api/osb/store_instances_plugin.go +++ b/api/osb/store_instances_plugin.go @@ -499,6 +499,7 @@ func (ssi *StoreServiceInstancePlugin) storeOperation(ctx context.Context, stora State: state, ResourceID: req.GetInstanceID(), ResourceType: "/v1/service_instances", + PlatformID: req.GetPlatformID(), CorrelationID: correlationID, ExternalID: resp.OperationData, } diff --git a/api/profile/controller.go b/api/profile/controller.go new file mode 100644 index 000000000..860b5c6a6 --- /dev/null +++ b/api/profile/controller.go @@ -0,0 +1,33 @@ +package profile + +import ( + "net/http" + "net/http/pprof" + + "github.com/Peripli/service-manager/pkg/web" +) + +const profileNameParam = "profile_name" + +// Controller profile controller +type Controller struct{} + +// Routes provides the REST endpoints +func (c *Controller) Routes() []web.Route { + return []web.Route{ + { + Endpoint: web.Endpoint{ + Method: http.MethodGet, + Path: web.ProfileURL + "/{" + profileNameParam + "}", + }, + Handler: c.profile, + }, + } +} + +func (c *Controller) profile(req *web.Request) (*web.Response, error) { + profileName := req.PathParams[profileNameParam] + resp := req.HijackResponseWriter() + pprof.Handler(profileName).ServeHTTP(resp, req.Request) + return &web.Response{}, nil +} diff --git a/docs/development/code-standards.md b/docs/development/code-standards.md index ae756331b..aeb58aa75 100644 --- a/docs/development/code-standards.md +++ b/docs/development/code-standards.md @@ -2,7 +2,7 @@ The following page describes our expectations in terms of code quality and code standards. -While we do not currently aim to adhere completely to the [Kubernetes coding conventions](https://github.com/kubernetes/community/blob/master/contributors/devel/coding-conventions.md), +While we do not currently aim to adhere completely to the [Kubernetes coding conventions](https://github.com/kubernetes/community/blob/master/contributors/guide/coding-conventions.md), we aspire to adhere as closely as possible. ## Code Quality @@ -23,4 +23,4 @@ we aspire to adhere as closely as possible. * Unit and Integration Tests should be written for all changes. * Code Coverage of changes should be above `85%` -* It's the reviewers' responsibility to ensure that proper/ enough tests have been provided. \ No newline at end of file +* It's the reviewers' responsibility to ensure that proper/ enough tests have been provided. diff --git a/docs/development/logging.md b/docs/development/logging.md new file mode 100644 index 000000000..ea77404de --- /dev/null +++ b/docs/development/logging.md @@ -0,0 +1,92 @@ +# Logging Guidelines + +Service Manager uses [logrus](https://github.com/sirupsen/logrus) library for logging. + +The current context should be provided to the logger in order to include context information. +One example of such information is the correlation id, which allows correlating log messages that are associated wih one operation. + +Usually you access the logger like this: +```go +import ( + "context" + + "github.com/Peripli/service-manager/pkg/log" +) + +func doSomething(ctx context.Context) { + // ... + log.C(ctx).Infof("Did something with %s", name) + // ... +} +``` + +In rare cases, when there is no context, you can access the logger like this: +```go +log.D().Info("No context info here") +``` + +## Log Level +The default log level is defined by `log.level` configuration. + +There are two options to change the log level (and other configuration) without restart: +* By changing _application.yml_ on the file system where Service Manager is running, e.g. inside the container. +Note that if the log level is set via environment variable, it will override the value in the file. +* Via `/v1/config/logging` endpoint. See [api/configuration/controller.go](api/configuration/controller.go) for details. + +Note that the log level is not stored in a central place. +So, if Service Manager is running with multiple instances, the log level should be set separately for each one. + +### Error +Use _error_ level when something is wrong and the current operation/request cannot complete successfully. +In such situations you usually have an `error` object. + +You usually log an error like this: +```go +log.C(ctx).WithError(err).Error("Could not do X") +``` + +If you have an `error`, **either return it or log it**, but not both as this leads to log duplication. +The central error handler (`util.WriteError`) logs all errors returned from request processing, so usually you don't need to log them again. + +### Warning +Use _warning_ level when something is wrong but is not a problem for the current operation/request. +Still it might be a problem for another operation. + +### Info +Should provide enough information to tell what Service Manager did and why. + +Any actions performed by Service Manager concerning external entities should be logged at _info_ level. +Examples of such actions are changes to brokers / platforms / visibilities. + +_Info_ messages should be understandable by people who are familiar with Service Manager concepts +but are not involved with its development. So these messages should not contain code-level details. +Strive to use the same terminology as in Service Manager documentation instead of internal abbreviations and terms. + +Whenever possible _info_ messages should be normal english sentences. +For example instead of: +```go +log.C(ctx).Infof("Registered broker - name: %s URL: %s", name, url) +``` +use +```go +log.C(ctx).Infof("Registered service broker %s with URL %s", name, url) +``` + +### Debug +Use _debug_ level to capture data that could be useful for troubleshooting. +Think, if you have to debug the code, which values would be most useful and log them. +These messages are intended for Service Manager developers +Keep in mind that **debugging in production is rarely possible**. + +On the other hand do not think "the more the better". +Flooding the log with tons of messages makes it harder to understand. +Also it increases the chance of loosing log messages in case logging rate is exceeded. + +Be aware that some situations are not possible to reproduce. +So, setting log level to _debug_ and retrying might not help. +As described above, important messages should be logged on _info_ level. + +## Security + +:warning: Be careful not to log sensitive data like passwords, tokens, personal data, etc. +Pay special attention if you do some generic logging where you don't know the exact semantic of the values being logged. \ No newline at end of file diff --git a/operations/config.go b/operations/config.go index 7f47e1307..139ffdd4a 100644 --- a/operations/config.go +++ b/operations/config.go @@ -22,8 +22,13 @@ import ( ) const ( - minTimePeriod = time.Nanosecond - defaultJobTimeout = 5 * time.Minute + minTimePeriod = time.Nanosecond + + defaultMarkOrphansInterval = 24 * time.Hour + defaultJobTimeout = 7*24*time.Hour - 1*time.Hour + + defaultCleanupInterval = 1 * time.Hour + defaultExpirationTime = 7 * 24 * time.Hour ) // Settings type to be loaded from the environment @@ -31,6 +36,7 @@ type Settings struct { JobTimeout time.Duration `mapstructure:"job_timeout" description:"timeout for async operations"` MarkOrphansInterval time.Duration `mapstructure:"mark_orphans_interval" description:"interval denoting how often to mark orphan operations as failed"` CleanupInterval time.Duration `mapstructure:"cleanup_interval" description:"cleanup interval of old operations"` + ExpirationTime time.Duration `mapstructure:"expiration_time" description:"after that time is passed since its creation, the operation can be cleaned up by the maintainer"` DefaultPoolSize int `mapstructure:"default_pool_size" description:"default worker pool size"` Pools []PoolSettings `mapstructure:"pools" description:"defines the different available worker pools"` @@ -43,8 +49,9 @@ type Settings struct { func DefaultSettings() *Settings { return &Settings{ JobTimeout: defaultJobTimeout, - MarkOrphansInterval: defaultJobTimeout, - CleanupInterval: 10 * time.Minute, + MarkOrphansInterval: defaultMarkOrphansInterval, + CleanupInterval: defaultCleanupInterval, + ExpirationTime: defaultExpirationTime, DefaultPoolSize: 20, Pools: []PoolSettings{}, ScheduledDeletionTimeout: 12 * time.Hour, diff --git a/operations/maintainer.go b/operations/maintainer.go index 9b80ed8af..7d337b9d1 100644 --- a/operations/maintainer.go +++ b/operations/maintainer.go @@ -29,27 +29,32 @@ import ( // Maintainer ensures that operations old enough are deleted // and that no orphan operations are left in the DB due to crashes/restarts of SM type Maintainer struct { - smCtx context.Context - repository storage.Repository - jobTimeout time.Duration - markOrphansInterval time.Duration - cleanupInterval time.Duration + smCtx context.Context + repository storage.Repository + jobTimeout time.Duration + markOrphansInterval time.Duration + cleanupInterval time.Duration + operationExpirationTime time.Duration } // NewMaintainer constructs a Maintainer func NewMaintainer(smCtx context.Context, repository storage.Repository, options *Settings) *Maintainer { return &Maintainer{ - smCtx: smCtx, - repository: repository, - jobTimeout: options.JobTimeout, - markOrphansInterval: options.MarkOrphansInterval, - cleanupInterval: options.CleanupInterval, + smCtx: smCtx, + repository: repository, + jobTimeout: options.JobTimeout, + markOrphansInterval: options.MarkOrphansInterval, + cleanupInterval: options.CleanupInterval, + operationExpirationTime: options.ExpirationTime, } } // Run starts the two recurring jobs responsible for cleaning up operations which are too old // and deleting orphan operations func (om *Maintainer) Run() { + om.cleanUpOldOperations() + om.markOrphanOperationsFailed() + go om.processOldOperations() go om.processOrphanOperations() } @@ -87,8 +92,13 @@ func (om *Maintainer) processOrphanOperations() { } func (om *Maintainer) cleanUpOldOperations() { - byDate := query.ByField(query.LessThanOperator, "created_at", util.ToRFCNanoFormat(time.Now().Add(-om.cleanupInterval))) - if err := om.repository.Delete(om.smCtx, types.OperationType, byDate); err != nil { + criteria := []query.Criterion{ + query.ByField(query.EqualsOperator, "state", string(types.FAILED)), + query.ByField(query.NotEqualsOperator, "platform_id", types.SMPlatform), + query.ByField(query.LessThanOperator, "created_at", util.ToRFCNanoFormat(time.Now().Add(-om.operationExpirationTime))), + } + + if err := om.repository.Delete(om.smCtx, types.OperationType, criteria...); err != nil { log.D().Debugf("Failed to cleanup operations: %s", err) return } diff --git a/pkg/security/http/authz/oauth_scopes.go b/pkg/security/http/authz/oauth_scopes.go index 929153665..5c668f5a3 100644 --- a/pkg/security/http/authz/oauth_scopes.go +++ b/pkg/security/http/authz/oauth_scopes.go @@ -53,3 +53,17 @@ func findMostRestrictiveAccessLevel(levels []web.AccessLevel) web.AccessLevel { } return min } + +// Checks whether the user has the requested scope +func HasScope(user *web.UserContext, scope string) (bool, error) { + var claims struct { + Scopes []string `json:"scope"` + } + + if err := user.Data(&claims); err != nil { + return false, fmt.Errorf("could not extract scopes from token: %v", err) + } + userScopes := claims.Scopes + + return slice.StringsAnyEquals(userScopes, scope), nil +} diff --git a/pkg/security/http/authz/oauth_scopes_test.go b/pkg/security/http/authz/oauth_scopes_test.go index cca30e2d2..989d67131 100644 --- a/pkg/security/http/authz/oauth_scopes_test.go +++ b/pkg/security/http/authz/oauth_scopes_test.go @@ -1,51 +1,123 @@ package authz import ( + "context" + "encoding/json" "errors" + "fmt" "github.com/Peripli/service-manager/pkg/web" httpsec "github.com/Peripli/service-manager/pkg/security/http" . "github.com/onsi/ginkgo" . "github.com/onsi/ginkgo/extensions/table" + . "github.com/onsi/gomega" ) -var _ = Describe("ScopeAuthorizer", func() { - DescribeTable("Required", func(t testCase) { - runTestCase(t, NewScopesAuthorizer(t.params.([]string), web.GlobalAccess)) - }, []TableEntry{ - Entry("Fails if no user is authenticated", testCase{ - params: []string{""}, - noUser: true, - expectedDecision: httpsec.Abstain, - expectedAccess: web.NoAccess, - }), - Entry("Fails if token claims cannot be extracted", testCase{ - params: []string{""}, - claimsError: errors.New("claims error"), - expectError: "could not extract scopes", - expectedDecision: httpsec.Deny, - expectedAccess: web.NoAccess, - }), - Entry("Fails if there are no scopes in the token", testCase{ - params: []string{"scope2"}, - claims: `{}`, - expectError: `none of the scopes [scope2] are present`, - expectedDecision: httpsec.Deny, - expectedAccess: web.NoAccess, - }), - Entry("Fails if scope does not match", testCase{ - params: []string{"scope2"}, - claims: `{"scope":["scope1","scope3"]}`, - expectError: `none of the scopes [scope2] are present in the user token scopes [scope1 scope3]`, - expectedDecision: httpsec.Deny, - expectedAccess: web.NoAccess, - }), - Entry("Succeeds if scope matches", testCase{ - params: []string{"scope2"}, - claims: `{"scope":["scope1","scope2","scope3"]}`, - expectedDecision: httpsec.Allow, - expectedAccess: web.GlobalAccess, - }), - }...) +var _ = Describe("Oauth Scopes", func() { + Describe("ScopeAuthorizer", func() { + DescribeTable("Required", func(t testCase) { + runTestCase(t, NewScopesAuthorizer(t.params.([]string), web.GlobalAccess)) + }, []TableEntry{ + Entry("Fails if no user is authenticated", testCase{ + params: []string{""}, + noUser: true, + expectedDecision: httpsec.Abstain, + expectedAccess: web.NoAccess, + }), + Entry("Fails if token claims cannot be extracted", testCase{ + params: []string{""}, + claimsError: errors.New("claims error"), + expectError: "could not extract scopes", + expectedDecision: httpsec.Deny, + expectedAccess: web.NoAccess, + }), + Entry("Fails if there are no scopes in the token", testCase{ + params: []string{"scope2"}, + claims: `{}`, + expectError: `none of the scopes [scope2] are present`, + expectedDecision: httpsec.Deny, + expectedAccess: web.NoAccess, + }), + Entry("Fails if scope does not match", testCase{ + params: []string{"scope2"}, + claims: `{"scope":["scope1","scope3"]}`, + expectError: `none of the scopes [scope2] are present in the user token scopes [scope1 scope3]`, + expectedDecision: httpsec.Deny, + expectedAccess: web.NoAccess, + }), + Entry("Succeeds if scope matches", testCase{ + params: []string{"scope2"}, + claims: `{"scope":["scope1","scope2","scope3"]}`, + expectedDecision: httpsec.Allow, + expectedAccess: web.GlobalAccess, + }), + }...) + }) + + Describe("HasScope", func() { + var ( + tokenDataNoScopes = `{"scope": []}` + tokenDataWithRequestedScope = `{"scope": ["the-scope", "another-scope"]}` + tokenDataWithoutRequestedScope = `{"scope": ["another-scope"]}` + ) + + getUserContextWithToken := func(token string, err error) (*web.UserContext, error) { + ctx := web.ContextWithUser(context.Background(), &web.UserContext{ + AuthenticationType: web.Bearer, + Data: func(data interface{}) error { + if err != nil { + return err + } + return json.Unmarshal([]byte(token), data) + }, + }) + user, ok := web.UserFromContext(ctx) + if !ok { + return nil, fmt.Errorf("Failed to retrieve user from context") + } + + return user, nil + } + + When("no scopes are available", func() { + user, _ := getUserContextWithToken(tokenDataNoScopes, nil) + + It("is not found", func() { + found, err := HasScope(user, "the-scope") + Expect(err).ToNot(HaveOccurred()) + Expect(found).To(BeFalse()) + }) + }) + + When("requested scope is not available", func() { + user, _ := getUserContextWithToken(tokenDataWithoutRequestedScope, nil) + + It("is not found", func() { + found, err := HasScope(user, "the-scope") + Expect(err).ToNot(HaveOccurred()) + Expect(found).To(BeFalse()) + }) + }) + + When("requested scope is available", func() { + user, _ := getUserContextWithToken(tokenDataWithRequestedScope, nil) + + It("is found", func() { + found, err := HasScope(user, "the-scope") + Expect(err).ToNot(HaveOccurred()) + Expect(found).To(BeTrue()) + }) + }) + + When("scope cannot be extracted from token", func() { + user, _ := getUserContextWithToken(tokenDataWithRequestedScope, errors.New("failed to get user data")) + + It("is fails with an error", func() { + _, err := HasScope(user, "the-scope") + Expect(err).To(HaveOccurred()) + }) + }) + + }) }) diff --git a/pkg/types/operation.go b/pkg/types/operation.go index 79562cf25..5a5882bfb 100644 --- a/pkg/types/operation.go +++ b/pkg/types/operation.go @@ -63,6 +63,7 @@ type Operation struct { ResourceID string `json:"resource_id"` ResourceType ObjectType `json:"resource_type"` Errors json.RawMessage `json:"errors,omitempty"` + PlatformID string `json:"platform_id"` CorrelationID string `json:"correlation_id"` ExternalID string `json:"-"` @@ -85,6 +86,7 @@ func (e *Operation) Equals(obj Object) bool { e.ExternalID != operation.ExternalID || e.State != operation.State || e.Type != operation.Type || + e.PlatformID != operation.PlatformID || !reflect.DeepEqual(e.Errors, operation.Errors) { return false } diff --git a/pkg/web/routes.go b/pkg/web/routes.go index 95ce2ceb6..de2b100f0 100644 --- a/pkg/web/routes.go +++ b/pkg/web/routes.go @@ -44,4 +44,7 @@ const ( // OperationsURL is the URL path fetch operations OperationsURL = "/operations" + + // ProfileURL is the Configuration API base URL path + ProfileURL = "/" + apiVersion + "/profile" ) diff --git a/storage/postgres/keystore_test.go b/storage/postgres/keystore_test.go index 4ba0142f6..9d745b875 100644 --- a/storage/postgres/keystore_test.go +++ b/storage/postgres/keystore_test.go @@ -59,7 +59,7 @@ var _ = Describe("Secured Storage", func() { mock.ExpectQuery(`SELECT CURRENT_DATABASE()`).WillReturnRows(sqlmock.NewRows([]string{"mock"}).FromCSVString("mock")) mock.ExpectQuery(`SELECT COUNT(1)*`).WillReturnRows(sqlmock.NewRows([]string{"mock"}).FromCSVString("1")) mock.ExpectExec("SELECT pg_advisory_lock*").WithArgs(sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1)) - mock.ExpectQuery(`SELECT version, dirty FROM "schema_migrations" LIMIT 1`).WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}).FromCSVString("20200122151000,false")) + mock.ExpectQuery(`SELECT version, dirty FROM "schema_migrations" LIMIT 1`).WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}).FromCSVString("20200131152000,false")) mock.ExpectExec("SELECT pg_advisory_unlock*").WithArgs(sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1)) options := storage.DefaultSettings() options.EncryptionKey = string(envEncryptionKey) diff --git a/storage/postgres/migrations/20200128152000_operation_platform_id.down.sql b/storage/postgres/migrations/20200128152000_operation_platform_id.down.sql new file mode 100644 index 000000000..6237ae6df --- /dev/null +++ b/storage/postgres/migrations/20200128152000_operation_platform_id.down.sql @@ -0,0 +1,5 @@ +BEGIN; + +ALTER TABLE operations DROP COLUMN platform_id; + +COMMIT; \ No newline at end of file diff --git a/storage/postgres/migrations/20200128152000_operation_platform_id.up.sql b/storage/postgres/migrations/20200128152000_operation_platform_id.up.sql new file mode 100644 index 000000000..2f2fc62a1 --- /dev/null +++ b/storage/postgres/migrations/20200128152000_operation_platform_id.up.sql @@ -0,0 +1,5 @@ +BEGIN; + +ALTER TABLE operations ADD COLUMN platform_id varchar(100) NOT NULL DEFAULT ''; + +COMMIT; \ No newline at end of file diff --git a/storage/postgres/migrations/20200116221100_service_bindings.down.sql b/storage/postgres/migrations/20200129152000_service_bindings.down.sql similarity index 100% rename from storage/postgres/migrations/20200116221100_service_bindings.down.sql rename to storage/postgres/migrations/20200129152000_service_bindings.down.sql diff --git a/storage/postgres/migrations/20200116221100_service_bindings.up.sql b/storage/postgres/migrations/20200129152000_service_bindings.up.sql similarity index 100% rename from storage/postgres/migrations/20200116221100_service_bindings.up.sql rename to storage/postgres/migrations/20200129152000_service_bindings.up.sql diff --git a/storage/postgres/migrations/20200117150000_additional_operations_columns.down.sql b/storage/postgres/migrations/20200130152000_additional_operations_columns.down.sql similarity index 100% rename from storage/postgres/migrations/20200117150000_additional_operations_columns.down.sql rename to storage/postgres/migrations/20200130152000_additional_operations_columns.down.sql diff --git a/storage/postgres/migrations/20200117150000_additional_operations_columns.up.sql b/storage/postgres/migrations/20200130152000_additional_operations_columns.up.sql similarity index 100% rename from storage/postgres/migrations/20200117150000_additional_operations_columns.up.sql rename to storage/postgres/migrations/20200130152000_additional_operations_columns.up.sql diff --git a/storage/postgres/migrations/20200117151000_ready_column.down.sql b/storage/postgres/migrations/20200131142000_ready_column.down.sql similarity index 100% rename from storage/postgres/migrations/20200117151000_ready_column.down.sql rename to storage/postgres/migrations/20200131142000_ready_column.down.sql diff --git a/storage/postgres/migrations/20200117151000_ready_column.up.sql b/storage/postgres/migrations/20200131142000_ready_column.up.sql similarity index 100% rename from storage/postgres/migrations/20200117151000_ready_column.up.sql rename to storage/postgres/migrations/20200131142000_ready_column.up.sql diff --git a/storage/postgres/migrations/20200122151000_alter_plan_table.down.sql b/storage/postgres/migrations/20200131152000_alter_plan_table.down.sql similarity index 100% rename from storage/postgres/migrations/20200122151000_alter_plan_table.down.sql rename to storage/postgres/migrations/20200131152000_alter_plan_table.down.sql diff --git a/storage/postgres/migrations/20200122151000_alter_plan_table.up.sql b/storage/postgres/migrations/20200131152000_alter_plan_table.up.sql similarity index 100% rename from storage/postgres/migrations/20200122151000_alter_plan_table.up.sql rename to storage/postgres/migrations/20200131152000_alter_plan_table.up.sql diff --git a/storage/postgres/operation.go b/storage/postgres/operation.go index c66233a37..35dbaf751 100644 --- a/storage/postgres/operation.go +++ b/storage/postgres/operation.go @@ -34,6 +34,7 @@ type Operation struct { State string `db:"state"` ResourceID string `db:"resource_id"` ResourceType string `db:"resource_type"` + PlatformID string `db:"platform_id"` Errors sqlxtypes.JSONText `db:"errors"` CorrelationID sql.NullString `db:"correlation_id"` ExternalID sql.NullString `db:"external_id"` @@ -55,6 +56,7 @@ func (o *Operation) ToObject() types.Object { State: types.OperationState(o.State), ResourceID: o.ResourceID, ResourceType: types.ObjectType(o.ResourceType), + PlatformID: o.PlatformID, Errors: getJSONRawMessage(o.Errors), CorrelationID: o.CorrelationID.String, ExternalID: o.ExternalID.String, @@ -82,6 +84,7 @@ func (*Operation) FromObject(object types.Object) (storage.Entity, bool) { State: string(operation.State), ResourceID: operation.ResourceID, ResourceType: operation.ResourceType.String(), + PlatformID: operation.PlatformID, Errors: getJSONText(operation.Errors), CorrelationID: toNullString(operation.CorrelationID), ExternalID: toNullString(operation.ExternalID), diff --git a/test/auth_test/auth_test.go b/test/auth_test/auth_test.go index 814737f7b..e61d3b8ea 100644 --- a/test/auth_test/auth_test.go +++ b/test/auth_test/auth_test.go @@ -304,6 +304,12 @@ var _ = Describe("Service Manager Authentication", func() { {"Invalid authorization schema", "DELETE", web.ServiceBindingsURL + "/999", invalidBasicAuthHeader}, {"Missing token in authorization header", "DELETE", web.ServiceBindingsURL + "/999", emptyBearerAuthHeader}, {"Invalid token in authorization header", "DELETE", web.ServiceBindingsURL + "/999", invalidBearerAuthHeader}, + + // PROFILE + {"Missing authorization header", "GET", web.ProfileURL + "/heap", emptyAuthHeader}, + {"Invalid authorization schema", "GET", web.ProfileURL + "/heap", invalidBasicAuthHeader}, + {"Missing token in authorization header", "GET", web.ProfileURL + "/heap", emptyBearerAuthHeader}, + {"Invalid token in authorization header", "GET", web.ProfileURL + "/heap", invalidBearerAuthHeader}, } for _, request := range authRequests { diff --git a/test/broker_test/broker_test.go b/test/broker_test/broker_test.go index 66d08534e..78be7cca4 100644 --- a/test/broker_test/broker_test.go +++ b/test/broker_test/broker_test.go @@ -756,6 +756,12 @@ var _ = test.DescribeTestsFor(test.TestCase{ It("returns 200", func() { updatedBrokerJSON := common.Object{ "broker_url": updatedBrokerServer.URL(), + "credentials": common.Object{ + "basic": common.Object{ + "username": brokerServer.Username, + "password": brokerServer.Password, + }, + }, } updatedBrokerServer.Username = brokerServer.Username updatedBrokerServer.Password = brokerServer.Password @@ -765,7 +771,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ Expect(). Status(http.StatusOK). JSON().Object(). - ContainsMap(updatedBrokerJSON). + ContainsKey("broker_url"). Keys().NotContains("services", "credentials") assertInvocationCount(brokerServer.CatalogEndpointRequests, 0) @@ -775,7 +781,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ Expect(). Status(http.StatusOK). JSON().Object(). - ContainsMap(updatedBrokerJSON). + ContainsKey("broker_url"). Keys().NotContains("services", "credentials") }) }) @@ -788,7 +794,8 @@ var _ = test.DescribeTestsFor(test.TestCase{ ctx.SMWithOAuth.PATCH(web.ServiceBrokersURL+"/"+brokerID). WithJSON(updatedBrokerJSON). Expect(). - Status(http.StatusBadRequest).JSON().Object().Keys().Contains("error", "description") + Status(http.StatusBadRequest).JSON().Object().Keys(). + Contains("error", "description") assertInvocationCount(brokerServer.CatalogEndpointRequests, 0) @@ -801,6 +808,58 @@ var _ = test.DescribeTestsFor(test.TestCase{ }) }) + Context("when broker_url is changed but the credentials are missing", func() { + var updatedBrokerJSON common.Object + + BeforeEach(func() { + updatedBrokerJSON = common.Object{ + "broker_url": updatedBrokerServer.URL(), + } + }) + + Context("credentials object is missing", func() { + It("returns 400", func() { + ctx.SMWithOAuth.PATCH(web.ServiceBrokersURL+"/"+brokerID). + WithJSON(updatedBrokerJSON). + Expect(). + Status(http.StatusBadRequest).JSON().Object().Keys().Contains("error", "description") + }) + }) + + Context("username is missing", func() { + BeforeEach(func() { + updatedBrokerJSON["credentials"] = common.Object{ + "basic": common.Object{ + "password": "b", + }, + } + }) + + It("returns 400", func() { + ctx.SMWithOAuth.PATCH(web.ServiceBrokersURL+"/"+brokerID). + WithJSON(updatedBrokerJSON). + Expect(). + Status(http.StatusBadRequest).JSON().Object().Keys().Contains("error", "description") + }) + }) + + Context("password is missing", func() { + BeforeEach(func() { + updatedBrokerJSON["credentials"] = common.Object{ + "basic": common.Object{ + "username": "a", + }, + } + }) + + It("returns 400", func() { + ctx.SMWithOAuth.PATCH(web.ServiceBrokersURL+"/"+brokerID). + WithJSON(updatedBrokerJSON). + Expect(). + Status(http.StatusBadRequest).JSON().Object().Keys().Contains("error", "description") + }) + }) + }) }) Context("when fields are updated one by one", func() { diff --git a/test/common/common.go b/test/common/common.go index 325784c5a..30ce52083 100644 --- a/test/common/common.go +++ b/test/common/common.go @@ -482,7 +482,7 @@ func Print(message string, args ...interface{}) { if len(args) == 0 { _, err = fmt.Fprint(ginkgo.GinkgoWriter, "\n"+message+"\n") } else { - _, err = fmt.Fprintf(ginkgo.GinkgoWriter, "\n"+message+"\n", args) + _, err = fmt.Fprintf(ginkgo.GinkgoWriter, "\n"+message+"\n", args...) } if err != nil { panic(err) diff --git a/test/common/oauth_server.go b/test/common/oauth_server.go index a0d616f10..798a5ce56 100644 --- a/test/common/oauth_server.go +++ b/test/common/oauth_server.go @@ -19,6 +19,7 @@ package common import ( "crypto/rsa" "encoding/json" + "github.com/gorilla/mux" "net/http" "net/http/httptest" "time" @@ -30,7 +31,7 @@ type OAuthServer struct { BaseURL string server *httptest.Server - mux *http.ServeMux + Router *mux.Router privateKey *rsa.PrivateKey // public key privateKey.PublicKey signer jwt.Signer keyID string @@ -43,11 +44,11 @@ func NewOAuthServer() *OAuthServer { privateKey: privateKey, signer: jwt.RS256(privateKey, &privateKey.PublicKey), keyID: "test-key", - mux: http.NewServeMux(), + Router: mux.NewRouter(), } - os.mux.HandleFunc("/.well-known/openid-configuration", os.getOpenIDConfig) - os.mux.HandleFunc("/oauth/token", os.getToken) - os.mux.HandleFunc("/token_keys", os.getTokenKeys) + os.Router.HandleFunc("/.well-known/openid-configuration", os.getOpenIDConfig) + os.Router.HandleFunc("/oauth/token", os.getToken) + os.Router.HandleFunc("/token_keys", os.getTokenKeys) os.Start() return os @@ -57,7 +58,7 @@ func (os *OAuthServer) Start() { if os.server != nil { panic("OAuth server already started") } - os.server = httptest.NewServer(os.mux) + os.server = httptest.NewServer(os.Router) os.BaseURL = os.server.URL } diff --git a/test/configuration_test/configuration_test.go b/test/configuration_test/configuration_test.go index 154bd0502..0fff9fe2b 100644 --- a/test/configuration_test/configuration_test.go +++ b/test/configuration_test/configuration_test.go @@ -84,10 +84,10 @@ var _ = Describe("Service Manager Config API", func() { "label_key": "tenant" }, "operations": { - "cleanup_interval": "10m0s", + "cleanup_interval": "1h0m0s", "default_pool_size": 20, - "job_timeout": "5m0s", - "mark_orphans_interval": "5m0s", + "job_timeout": "167h0m0s", + "mark_orphans_interval": "24h0m0s", "polling_interval": "1ms", "pools": "", "rescheduling_interval": "1ms", diff --git a/test/operations_test/operations_test.go b/test/operations_test/operations_test.go index d7b2a09d0..9f9afe65b 100644 --- a/test/operations_test/operations_test.go +++ b/test/operations_test/operations_test.go @@ -19,7 +19,6 @@ import ( "context" "fmt" "net/http" - "strings" "sync" "testing" "time" @@ -186,43 +185,109 @@ var _ = Describe("Operations", func() { Context("Maintainer", func() { const ( - jobTimeout = 2 * time.Second - cleanupInterval = 5 * time.Second + jobTimeout = 1 * time.Second + cleanupInterval = 2 * time.Second + operationExpiration = 2 * time.Second ) - BeforeEach(func() { - postHook := func(e env.Environment, servers map[string]common.FakeServer) { + var ctxBuilder *common.TestContextBuilder + + postHookWithOperationsConfig := func() func(e env.Environment, servers map[string]common.FakeServer) { + return func(e env.Environment, servers map[string]common.FakeServer) { e.Set("operations.job_timeout", jobTimeout) e.Set("operations.mark_orphans_interval", jobTimeout) e.Set("operations.cleanup_interval", cleanupInterval) + e.Set("operations.expiration_time", operationExpiration) } + } - ctx = common.NewTestContextBuilder().WithEnvPostExtensions(postHook).Build() + assertOperationCount := func(expectedCount int, criterion ...query.Criterion) { + count, err := ctx.SMRepository.Count(context.Background(), types.OperationType, criterion...) + Expect(err).To(BeNil()) + Expect(count).To(Equal(expectedCount)) + } + + BeforeEach(func() { + postHook := postHookWithOperationsConfig() + ctxBuilder = common.NewTestContextBuilderWithSecurity().WithEnvPostExtensions(postHook) + ctx = ctxBuilder.Build() }) When("Specified cleanup interval passes", func() { - It("Deletes operations older than that interval", func() { - resp := ctx.SMWithOAuth.DELETE(web.ServiceBrokersURL+"/non-existent-broker-id").WithQuery("async", true). - Expect(). - Status(http.StatusAccepted) - - locationHeader := resp.Header("Location").Raw() - split := strings.Split(locationHeader, "/") - operationID := split[len(split)-1] + Context("operation platform is service Manager", func() { - byID := query.ByField(query.EqualsOperator, "id", operationID) - count, err := ctx.SMRepository.Count(context.Background(), types.OperationType, byID) - Expect(err).To(BeNil()) - Expect(count).To(Equal(1)) + It("Does not delete operations older than that interval", func() { + ctx.SMWithOAuth.DELETE(web.ServiceBrokersURL+"/non-existent-broker-id").WithQuery("async", true). + Expect(). + Status(http.StatusAccepted) - Eventually(func() int { - count, err := ctx.SMRepository.Count(context.Background(), types.OperationType, byID) - Expect(err).To(BeNil()) + byPlatformID := query.ByField(query.EqualsOperator, "platform_id", types.SMPlatform) + assertOperationCount(2, byPlatformID) - return count - }, cleanupInterval*2).Should(Equal(0)) + time.Sleep(cleanupInterval + time.Second) + assertOperationCount(2, byPlatformID) + }) }) + Context("operation platform is platform registered in service manager", func() { + const ( + brokerAPIVersionHeaderKey = "X-Broker-API-Version" + brokerAPIVersionHeaderValue = "2.13" + + serviceID = "test-service-1" + planID = "test-service-plan-1" + ) + + var ( + brokerID string + catalog common.SBCatalog + brokerServer *common.BrokerServer + ) + + asyncProvision := func() { + ctx.SMWithBasic.PUT(brokerServer.URL()+"/v1/osb/"+brokerID+"/v2/service_instances/12345"). + WithHeader(brokerAPIVersionHeaderKey, brokerAPIVersionHeaderValue). + WithQuery("async", true). + WithJSON(map[string]interface{}{ + "service_id": serviceID, + "plan_id": planID, + "organization_guid": "my-org", + }). + Expect().Status(http.StatusAccepted) + } + + BeforeEach(func() { + catalog = simpleCatalog(serviceID, planID) + catalog = simpleCatalog(serviceID, planID) + ctx.RegisterPlatform() + brokerID, _, brokerServer = ctx.RegisterBrokerWithCatalog(catalog) + brokerServer.ServiceInstanceHandler = func(rw http.ResponseWriter, _ *http.Request) { + rw.Header().Set("Content-Type", "application/json") + rw.WriteHeader(http.StatusAccepted) + } + common.CreateVisibilitiesForAllBrokerPlans(ctx.SMWithOAuth, brokerID) + }) + + AfterEach(func() { + common.RemoveAllInstances(ctx) + ctx.CleanupBroker(brokerID) + }) + + It("Deletes operations older than that interval", func() { + asyncProvision() + + byPlatformID := query.ByField(query.NotEqualsOperator, "platform_id", types.SMPlatform) + + assertOperationCount(1, byPlatformID) + + Eventually(func() int { + count, err := ctx.SMRepository.Count(context.Background(), types.OperationType, byPlatformID) + Expect(err).To(BeNil()) + + return count + }, cleanupInterval*2).Should(Equal(0)) + }) + }) }) When("Specified job timeout passes", func() { @@ -258,6 +323,21 @@ var _ = Describe("Operations", func() { }) }) +func simpleCatalog(serviceID, planID string) common.SBCatalog { + return common.SBCatalog(fmt.Sprintf(`{ + "services": [{ + "name": "no-tags-no-metadata", + "id": "%s", + "description": "A fake service.", + "plans": [{ + "name": "fake-plan-1", + "id": "%s", + "description": "Shared fake Server, 5tb persistent disk, 40 max concurrent connections." + }] + }] + }`, serviceID, planID)) +} + type panicController struct { operation *types.Operation scheduler *operations.Scheduler diff --git a/test/profile_test/profile_test.go b/test/profile_test/profile_test.go new file mode 100644 index 000000000..1f8e9b334 --- /dev/null +++ b/test/profile_test/profile_test.go @@ -0,0 +1,89 @@ +/* + * Copyright 2018 The Service Manager Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package profile_test + +import ( + "io/ioutil" + "net/http" + "os" + "os/exec" + "strings" + "testing" + + "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "github.com/Peripli/service-manager/pkg/web" + "github.com/Peripli/service-manager/test/common" +) + +func TestProfile(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Profile Suite") +} + +var _ = Describe("Profile API", func() { + + var ctx *common.TestContext + + BeforeSuite(func() { + ctx = common.NewTestContextBuilder().Build() + }) + + AfterSuite(func() { + ctx.Cleanup() + }) + + Describe("Get unknown profile", func() { + It("Returns 404 response", func() { + ctx.SMWithOAuth.GET(web.ProfileURL + "/unknown"). + Expect().Status(http.StatusNotFound) + }) + }) + + Describe("pprof", func() { + profiles := []string{ + "goroutine", + "threadcreate", + "heap", + "allocs", + "block", + "mutex", + } + for _, name := range profiles { + name := name + It("accepts "+name+" profile", func() { + body := ctx.SMWithOAuth.GET(web.ProfileURL + "/" + name). + Expect().Status(http.StatusOK).Body().Raw() + f, err := ioutil.TempFile("", "profile") + Expect(err).To(BeNil()) + fname := f.Name() + defer os.Remove(fname) + _, err = f.WriteString(body) + Expect(err).To(BeNil()) + Expect(f.Close()).To(BeNil()) + + cmd := exec.Command("go", "tool", "pprof", "-top", fname) + cmd.Stdout = ginkgo.GinkgoWriter + cmd.Stderr = ginkgo.GinkgoWriter + common.Print("%s %s", cmd.Path, strings.Join(cmd.Args[1:], " ")) + Expect(cmd.Run()).To(BeNil()) + }) + } + }) +}) From d42eeeaeb32de4e46a34f0bd4c7aaed0bd869581 Mon Sep 17 00:00:00 2001 From: Georgi Farashev Date: Wed, 5 Feb 2020 15:48:38 +0200 Subject: [PATCH 13/15] Unique constraint for instance and binding names (#431) --- pkg/sm/sm.go | 11 ++ .../unique_binding_names_iterceptor.go | 82 +++++++++++ .../unique_instance_names_iterceptor.go | 134 ++++++++++++++++++ .../service_binding_test.go | 52 ++++++- .../service_instance_test.go | 88 +++++++++++- test/test.go | 31 ++-- 6 files changed, 380 insertions(+), 18 deletions(-) create mode 100644 storage/interceptors/unique_binding_names_iterceptor.go create mode 100644 storage/interceptors/unique_instance_names_iterceptor.go diff --git a/pkg/sm/sm.go b/pkg/sm/sm.go index d2b647571..b58c65be1 100644 --- a/pkg/sm/sm.go +++ b/pkg/sm/sm.go @@ -204,6 +204,17 @@ func New(ctx context.Context, cancel context.CancelFunc, e env.Environment, cfg } smb. + WithCreateAroundTxInterceptorProvider(types.ServiceInstanceType, &interceptors.UniqueInstanceNameCreateInterceptorProvider{ + TenantIdentifier: cfg.Multitenancy.LabelKey, + Repository: interceptableRepository, + }).Register(). + WithUpdateAroundTxInterceptorProvider(types.ServiceInstanceType, &interceptors.UniqueInstanceNameUpdateInterceptorProvider{ + TenantIdentifier: cfg.Multitenancy.LabelKey, + Repository: interceptableRepository, + }).Register(). + WithCreateAroundTxInterceptorProvider(types.ServiceBindingType, &interceptors.UniqueBindingNameCreateInterceptorProvider{ + Repository: interceptableRepository, + }).Register(). WithCreateAroundTxInterceptorProvider(types.ServiceInstanceType, &interceptors.ServiceInstanceCreateInterceptorProvider{ BaseSMAAPInterceptorProvider: baseSMAAPInterceptorProvider, }).Register(). diff --git a/storage/interceptors/unique_binding_names_iterceptor.go b/storage/interceptors/unique_binding_names_iterceptor.go new file mode 100644 index 000000000..23ef368c7 --- /dev/null +++ b/storage/interceptors/unique_binding_names_iterceptor.go @@ -0,0 +1,82 @@ +/* + * Copyright 2018 The Service Manager Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package interceptors + +import ( + "context" + "fmt" + "net/http" + + "github.com/Peripli/service-manager/pkg/util" + + "github.com/Peripli/service-manager/pkg/query" + + "github.com/Peripli/service-manager/pkg/types" + "github.com/Peripli/service-manager/storage" +) + +const ( + UniqueBindingNameCreateInterceptorName = "UniqueBindingNameCreateInterceptor" +) + +// UniqueBindingNameCreateInterceptorProvider provides an interceptor that forbids creation of bindings with the same name in a given tenant +type UniqueBindingNameCreateInterceptorProvider struct { + Repository storage.TransactionalRepository +} + +func (c *UniqueBindingNameCreateInterceptorProvider) Name() string { + return UniqueBindingNameCreateInterceptorName +} + +func (c *UniqueBindingNameCreateInterceptorProvider) Provide() storage.CreateAroundTxInterceptor { + return &uniqueBindingNameInterceptor{ + Repository: c.Repository, + } +} + +type uniqueBindingNameInterceptor struct { + Repository storage.TransactionalRepository +} + +func (c *uniqueBindingNameInterceptor) AroundTxCreate(h storage.InterceptCreateAroundTxFunc) storage.InterceptCreateAroundTxFunc { + return func(ctx context.Context, obj types.Object) (types.Object, error) { + if err := c.checkUniqueName(ctx, obj.(*types.ServiceBinding)); err != nil { + return nil, err + } + return h(ctx, obj) + } +} + +func (c *uniqueBindingNameInterceptor) checkUniqueName(ctx context.Context, binding *types.ServiceBinding) error { + countCriteria := []query.Criterion{ + query.ByField(query.EqualsOperator, "service_instance_id", binding.ServiceInstanceID), + query.ByField(query.EqualsOperator, nameProperty, binding.Name), + } + bindingCount, err := c.Repository.Count(ctx, types.ServiceBindingType, countCriteria...) + + if err != nil { + return fmt.Errorf("could not get count of service bindings %s", err) + } + if bindingCount > 0 { + return &util.HTTPError{ + ErrorType: "Conflict", + Description: fmt.Sprintf("binding with same name exists for instance with id %s", binding.ServiceInstanceID), + StatusCode: http.StatusConflict, + } + } + return nil +} diff --git a/storage/interceptors/unique_instance_names_iterceptor.go b/storage/interceptors/unique_instance_names_iterceptor.go new file mode 100644 index 000000000..0b3917828 --- /dev/null +++ b/storage/interceptors/unique_instance_names_iterceptor.go @@ -0,0 +1,134 @@ +/* + * Copyright 2018 The Service Manager Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package interceptors + +import ( + "context" + "fmt" + "net/http" + + "github.com/Peripli/service-manager/pkg/util" + + "github.com/Peripli/service-manager/pkg/query" + + "github.com/Peripli/service-manager/pkg/types" + "github.com/Peripli/service-manager/storage" +) + +const ( + UniqueInstanceNameCreateInterceptorName = "UniqueInstanceNameCreateInterceptor" + UniqueInstanceNameUpdateInterceptorName = "UniqueInstanceNameUpdateInterceptor" + nameProperty = "name" +) + +// UniqueInstanceNameCreateInterceptorProvider provides an interceptor that forbids creation of instances with the same name in a given tenant +type UniqueInstanceNameCreateInterceptorProvider struct { + TenantIdentifier string + Repository storage.TransactionalRepository +} + +func (c *UniqueInstanceNameCreateInterceptorProvider) Name() string { + return UniqueInstanceNameCreateInterceptorName +} + +func (c *UniqueInstanceNameCreateInterceptorProvider) Provide() storage.CreateAroundTxInterceptor { + return &uniqueInstanceNameInterceptor{ + TenantIdentifier: c.TenantIdentifier, + Repository: c.Repository, + } +} + +// UniqueInstanceNameUpdateInterceptorProvider provides an interceptor that forbids updating an instance name that breaks uniqueness in a given tenant +type UniqueInstanceNameUpdateInterceptorProvider struct { + TenantIdentifier string + Repository storage.TransactionalRepository +} + +func (c *UniqueInstanceNameUpdateInterceptorProvider) Name() string { + return UniqueInstanceNameUpdateInterceptorName +} + +func (c *UniqueInstanceNameUpdateInterceptorProvider) Provide() storage.UpdateAroundTxInterceptor { + return &uniqueInstanceNameInterceptor{ + TenantIdentifier: c.TenantIdentifier, + Repository: c.Repository, + } +} + +type uniqueInstanceNameInterceptor struct { + TenantIdentifier string + Repository storage.TransactionalRepository +} + +func (c *uniqueInstanceNameInterceptor) AroundTxCreate(h storage.InterceptCreateAroundTxFunc) storage.InterceptCreateAroundTxFunc { + return func(ctx context.Context, obj types.Object) (object types.Object, err error) { + if err := c.checkUniqueName(ctx, obj.GetLabels(), obj.(*types.ServiceInstance)); err != nil { + return nil, err + } + return h(ctx, obj) + } +} + +func (c *uniqueInstanceNameInterceptor) AroundTxUpdate(h storage.InterceptUpdateAroundTxFunc) storage.InterceptUpdateAroundTxFunc { + return func(ctx context.Context, newObj types.Object, labelChanges ...*query.LabelChange) (object types.Object, err error) { + oldObj, err := c.Repository.Get(ctx, types.ServiceInstanceType, query.ByField(query.EqualsOperator, "id", newObj.GetID())) + if err != nil { + return nil, err + } + oldInstance := oldObj.(*types.ServiceInstance) + newInstance := newObj.(*types.ServiceInstance) + if newInstance.Name != oldInstance.Name { + if err := c.checkUniqueName(ctx, oldObj.GetLabels(), newInstance); err != nil { + return nil, err + } + } + return h(ctx, newObj, labelChanges...) + } +} + +func (c *uniqueInstanceNameInterceptor) checkUniqueName(ctx context.Context, labels types.Labels, instance *types.ServiceInstance) error { + if instance.PlatformID != types.SMPlatform { + return nil + } + countCriteria := []query.Criterion{ + query.ByField(query.EqualsOperator, "platform_id", types.SMPlatform), + query.ByField(query.EqualsOperator, nameProperty, instance.Name), + } + if len(c.TenantIdentifier) != 0 { + if labels == nil { + labels = types.Labels{} + } + tenantIDLabelValue, ok := labels[c.TenantIdentifier] + if ok && len(tenantIDLabelValue) != 0 { + tenantID := tenantIDLabelValue[0] + countCriteria = append(countCriteria, query.ByLabel(query.EqualsOperator, c.TenantIdentifier, tenantID)) + } + } + instanceCount, err := c.Repository.Count(ctx, types.ServiceInstanceType, countCriteria...) + + if err != nil { + return fmt.Errorf("could not get count of service instances %s", err) + } + if instanceCount > 0 { + return &util.HTTPError{ + ErrorType: "Conflict", + Description: "instance with same name exists for the current tenant", + StatusCode: http.StatusConflict, + } + } + return nil +} diff --git a/test/service_binding_test/service_binding_test.go b/test/service_binding_test/service_binding_test.go index 726d4210b..5edb2aded 100644 --- a/test/service_binding_test/service_binding_test.go +++ b/test/service_binding_test/service_binding_test.go @@ -22,6 +22,8 @@ import ( "strconv" "time" + "github.com/gofrs/uuid" + "github.com/spf13/pflag" "github.com/Peripli/service-manager/pkg/util" @@ -112,8 +114,10 @@ var _ = DescribeTestsFor(TestCase{ } createInstance := func(smClient *SMExpect, async bool, expectedStatusCode int) *httpexpect.Response { + ID, err := uuid.NewV4() + Expect(err).ToNot(HaveOccurred()) postInstanceRequest := Object{ - "name": "test-instance", + "name": "test-instance" + ID.String(), "service_plan_id": servicePlanID, "maintenance_info": "{}", } @@ -937,6 +941,46 @@ var _ = DescribeTestsFor(TestCase{ }) }) }) + + When("creating binding with same name", func() { + JustBeforeEach(func() { + postBindingRequest["name"] = "same-binding-name" + resp := createBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedCreateSuccessStatusCode) + if testCase.async { + _, err := ExpectOperation(ctx.SMWithOAuthForTenant, resp, types.SUCCEEDED) + Expect(err).ToNot(HaveOccurred()) + } + }) + + When("for the same service instance", func() { + It("should reject", func() { + if testCase.async { + resp := createBinding(ctx.SMWithOAuthForTenant, true, testCase.expectedCreateSuccessStatusCode) + _, err := ExpectOperationWithError(ctx.SMWithOAuthForTenant, resp, types.FAILED, "binding with same name exists for instance with id") + Expect(err).ToNot(HaveOccurred()) + } else { + createBinding(ctx.SMWithOAuthForTenant, false, http.StatusConflict) + } + }) + }) + + When("for other service instance", func() { + var otherInstanceID string + + JustBeforeEach(func() { + otherInstanceID = createInstance(ctx.SMWithOAuthForTenant, false, http.StatusCreated).JSON().Object().Value("id").String().Raw() + postBindingRequest["service_instance_id"] = otherInstanceID + }) + + It("should accept", func() { + resp := createBinding(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedCreateSuccessStatusCode) + if testCase.async { + _, err := ExpectOperation(ctx.SMWithOAuthForTenant, resp, types.SUCCEEDED) + Expect(err).ToNot(HaveOccurred()) + } + }) + }) + }) }) } }) @@ -1460,10 +1504,14 @@ var _ = DescribeTestsFor(TestCase{ func blueprint(ctx *TestContext, auth *SMExpect, async bool) Object { _, _, servicePlanID := newServicePlan(ctx, true) EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, TenantIDValue) + ID, err := uuid.NewV4() + if err != nil { + panic(err) + } resp := ctx.SMWithOAuthForTenant.POST(web.ServiceInstancesURL). WithQuery("async", strconv.FormatBool(async)). WithJSON(Object{ - "name": "test-service-instance", + "name": "test-service-instance" + ID.String(), "service_plan_id": servicePlanID, "maintenance_info": "{}", }).Expect() diff --git a/test/service_instance_test/service_instance_test.go b/test/service_instance_test/service_instance_test.go index fced93b00..b2f5be080 100644 --- a/test/service_instance_test/service_instance_test.go +++ b/test/service_instance_test/service_instance_test.go @@ -198,13 +198,15 @@ var _ = DescribeTestsFor(TestCase{ } BeforeEach(func() { + ID, err := uuid.NewV4() + Expect(err).ToNot(HaveOccurred()) var plans *httpexpect.Array brokerID, brokerServer, plans = prepareBrokerWithCatalog(ctx, ctx.SMWithOAuth) brokerServer.ShouldRecordRequests(false) servicePlanID = plans.Element(0).Object().Value("id").String().Raw() anotherServicePlanID = plans.Element(1).Object().Value("id").String().Raw() postInstanceRequest = Object{ - "name": "test-instance", + "name": "test-instance" + ID.String(), "service_plan_id": servicePlanID, "maintenance_info": "{}", } @@ -419,6 +421,41 @@ var _ = DescribeTestsFor(TestCase{ createInstanceWithAsync(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedCreateSuccessStatusCode) }) }) + + When("creating instance with same name", func() { + BeforeEach(func() { + EnsurePublicPlanVisibility(ctx.SMRepository, servicePlanID) + postInstanceRequest["name"] = "same-instance-name" + resp := createInstanceWithAsync(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedCreateSuccessStatusCode) + if testCase.async { + _, err := ExpectOperation(ctx.SMWithOAuthForTenant, resp, types.SUCCEEDED) + Expect(err).ToNot(HaveOccurred()) + } + }) + + When("for the same tenant", func() { + It("should reject", func() { + if testCase.async { + resp := createInstanceWithAsync(ctx.SMWithOAuthForTenant, true, testCase.expectedCreateSuccessStatusCode) + _, err := ExpectOperationWithError(ctx.SMWithOAuthForTenant, resp, types.FAILED, "instance with same name exists for the current tenant") + Expect(err).ToNot(HaveOccurred()) + } else { + createInstanceWithAsync(ctx.SMWithOAuthForTenant, false, http.StatusConflict) + } + }) + }) + + When("for other tenant", func() { + It("should accept", func() { + otherTenantExpect := ctx.NewTenantExpect("other-tenant") + resp := createInstanceWithAsync(otherTenantExpect, testCase.async, testCase.expectedCreateSuccessStatusCode) + if testCase.async { + _, err := ExpectOperation(otherTenantExpect, resp, types.SUCCEEDED) + Expect(err).ToNot(HaveOccurred()) + } + }) + }) + }) }) Context("broker scenarios", func() { @@ -1045,6 +1082,55 @@ var _ = DescribeTestsFor(TestCase{ ExpectSuccessfulAsyncResourceCreation(resp, ctx.SMWithOAuth, web.ServiceInstancesURL) }) }) + + When("changing instance name to existing instance name", func() { + Context("same tenant", func() { + It("fails to update", func() { + EnsurePlanVisibility(ctx.SMRepository, TenantIdentifier, types.SMPlatform, servicePlanID, TenantIDValue) + + postInstanceRequest["name"] = "instance1" + resp := createInstance(ctx.SMWithOAuthForTenant, http.StatusAccepted) + instance := ExpectSuccessfulAsyncResourceCreation(resp, ctx.SMWithOAuth, web.ServiceInstancesURL) + + postInstanceRequest["name"] = "instance2" + resp = createInstance(ctx.SMWithOAuthForTenant, http.StatusAccepted) + instance = ExpectSuccessfulAsyncResourceCreation(resp, ctx.SMWithOAuth, web.ServiceInstancesURL) + instanceID2 := instance["id"].(string) + + resp = ctx.SMWithOAuthForTenant.PATCH(web.ServiceInstancesURL + "/" + instanceID2). + WithJSON(Object{"name": "instance1"}). + Expect().Status(http.StatusAccepted) + + _, err := ExpectOperationWithError(ctx.SMWithOAuthForTenant, resp, types.FAILED, "instance with same name exists for the current tenant") + Expect(err).ToNot(HaveOccurred()) + ctx.SMWithOAuthForTenant.GET(web.ServiceInstancesURL+"/"+instanceID2).Expect().Status(http.StatusOK).JSON().Object().ValueEqual("name", "instance2") + }) + }) + + Context("different tenants", func() { + It("succeeds to update", func() { + EnsurePublicPlanVisibility(ctx.SMRepository, servicePlanID) + + postInstanceRequest["name"] = "instance1" + otherTenant := ctx.NewTenantExpect("other-tenant") + resp := createInstance(otherTenant, http.StatusAccepted) + instance := ExpectSuccessfulAsyncResourceCreation(resp, ctx.SMWithOAuth, web.ServiceInstancesURL) + + postInstanceRequest["name"] = "instance2" + resp = createInstance(ctx.SMWithOAuthForTenant, http.StatusAccepted) + instance = ExpectSuccessfulAsyncResourceCreation(resp, ctx.SMWithOAuth, web.ServiceInstancesURL) + instanceID2 := instance["id"].(string) + + resp = ctx.SMWithOAuthForTenant.PATCH(web.ServiceInstancesURL + "/" + instanceID2). + WithJSON(Object{"name": "instance1"}). + Expect().Status(http.StatusAccepted) + + _, err := ExpectOperation(ctx.SMWithOAuthForTenant, resp, types.SUCCEEDED) + Expect(err).ToNot(HaveOccurred()) + ctx.SMWithOAuthForTenant.GET(web.ServiceInstancesURL+"/"+instanceID2).Expect().Status(http.StatusOK).JSON().Object().ValueEqual("name", "instance1") + }) + }) + }) }) }) diff --git a/test/test.go b/test/test.go index 9acbea314..0cca3774d 100644 --- a/test/test.go +++ b/test/test.go @@ -162,31 +162,32 @@ func ExpectOperationWithError(auth *common.SMExpect, asyncResp *httpexpect.Respo ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - err := fmt.Errorf("unable to verify operation state (expected state = %s)", string(expectedState)) - var operation *httpexpect.Object + var state string + expectedStateStr := string(expectedState) for { select { case <-ctx.Done(): + return nil, fmt.Errorf("unable to verify operation state (expected state = %s, last state = %s)", expectedStateStr, state) default: - operation = auth.GET(operationURL). + operation := auth.GET(operationURL). Expect().Status(http.StatusOK).JSON().Object() - state := operation.Value("state").String().Raw() - if state == string(expectedState) { - if expectedState == types.SUCCEEDED { - } else { - errs := operation.Value("errors") - errs.NotNull() - errMsg := errs.Object().Value("description").String().Raw() + state = operation.Value("state").String().Raw() + if state == expectedStateStr { + if expectedState == types.SUCCEEDED || len(expectedStateStr) == 0 { + return operation, nil + } + errs := operation.Value("errors") + errs.NotNull() + errMsg := errs.Object().Value("description").String().Raw() - if !strings.Contains(errMsg, expectedErrMsg) { - err = fmt.Errorf("unable to verify operation - expected error message (%s), but got (%s)", expectedErrMsg, errs.String().Raw()) - } + if !strings.Contains(errMsg, expectedErrMsg) { + return operation, fmt.Errorf("unable to verify operation - expected error message (%s), but got (%s)", expectedErrMsg, errMsg) + } else { + return operation, nil } - return operation, nil } } } - return nil, err } func EnsurePublicPlanVisibility(repository storage.Repository, planID string) { From e5d7c964d50c8b8862365932b1eff068e6460f72 Mon Sep 17 00:00:00 2001 From: Kiril Kabakchiev Date: Mon, 10 Feb 2020 16:38:41 +0200 Subject: [PATCH 14/15] OSB interceptors context adjustments (#432) --- .../smaap_service_binding_interceptor.go | 43 +++++++++++++----- .../smaap_service_instance_interceptor.go | 45 ++++++++++++------- 2 files changed, 60 insertions(+), 28 deletions(-) diff --git a/storage/interceptors/smaap_service_binding_interceptor.go b/storage/interceptors/smaap_service_binding_interceptor.go index 71712d6bc..1492a26cc 100644 --- a/storage/interceptors/smaap_service_binding_interceptor.go +++ b/storage/interceptors/smaap_service_binding_interceptor.go @@ -144,7 +144,10 @@ func (i *ServiceBindingInterceptor) AroundTxCreate(f storage.InterceptCreateArou var bindResponse *osbc.BindResponse if !operation.Reschedule { - bindRequest := i.prepareBindRequest(instance, binding, service.CatalogID, plan.CatalogID, service.BindingsRetrievable) + bindRequest, err := i.prepareBindRequest(instance, binding, service.CatalogID, plan.CatalogID, service.BindingsRetrievable) + if err != nil { + return nil, fmt.Errorf("failed to prepare bind request: %s", err) + } contextBytes, err := json.Marshal(bindRequest.Context) if err != nil { return nil, fmt.Errorf("failed to marshal OSB context %+v: %s", bindRequest.Context, err) @@ -370,7 +373,31 @@ func getInstanceByID(ctx context.Context, instanceID string, repository storage. return instanceObject.(*types.ServiceInstance), nil } -func (i *ServiceBindingInterceptor) prepareBindRequest(instance *types.ServiceInstance, binding *types.ServiceBinding, serviceCatalogID, planCatalogID string, bindingRetrievable bool) *osbc.BindRequest { +func (i *ServiceBindingInterceptor) prepareBindRequest(instance *types.ServiceInstance, binding *types.ServiceBinding, serviceCatalogID, planCatalogID string, bindingRetrievable bool) (*osbc.BindRequest, error) { + context := make(map[string]interface{}) + if len(binding.Context) != 0 { + if err := json.Unmarshal(binding.Context, &context); err != nil { + return nil, fmt.Errorf("failed to unmarshal already present OSB context: %s", err) + } + } else { + context = map[string]interface{}{ + "platform": types.SMPlatform, + "instance_name": instance.Name, + } + + if len(i.tenantKey) != 0 { + if tenantValue, ok := binding.GetLabels()[i.tenantKey]; ok { + context[i.tenantKey] = tenantValue[0] + } + } + + contextBytes, err := json.Marshal(context) + if err != nil { + return nil, fmt.Errorf("failed to marshal OSB context %+v: %s", context, err) + } + binding.Context = contextBytes + } + bindRequest := &osbc.BindRequest{ BindingID: binding.ID, InstanceID: instance.ID, @@ -378,20 +405,12 @@ func (i *ServiceBindingInterceptor) prepareBindRequest(instance *types.ServiceIn ServiceID: serviceCatalogID, PlanID: planCatalogID, Parameters: binding.Parameters, - Context: map[string]interface{}{ - "platform": types.SMPlatform, - "instance_name": instance.Name, - }, + Context: context, //TODO no OI for SM platform yet OriginatingIdentity: nil, } - if len(i.tenantKey) != 0 { - if tenantValue, ok := binding.GetLabels()[i.tenantKey]; ok { - bindRequest.Context[i.tenantKey] = tenantValue[0] - } - } - return bindRequest + return bindRequest, nil } func prepareUnbindRequest(instance *types.ServiceInstance, binding *types.ServiceBinding, serviceCatalogID, planCatalogID string) *osbc.UnbindRequest { diff --git a/storage/interceptors/smaap_service_instance_interceptor.go b/storage/interceptors/smaap_service_instance_interceptor.go index 2a6c45edb..6d5465b33 100644 --- a/storage/interceptors/smaap_service_instance_interceptor.go +++ b/storage/interceptors/smaap_service_instance_interceptor.go @@ -133,13 +133,10 @@ func (i *ServiceInstanceInterceptor) AroundTxCreate(f storage.InterceptCreateAro var provisionResponse *osbc.ProvisionResponse if !operation.Reschedule { - provisionRequest := i.prepareProvisionRequest(instance, service.CatalogID, plan.CatalogID) - contextBytes, err := json.Marshal(provisionRequest.Context) + provisionRequest, err := i.prepareProvisionRequest(instance, service.CatalogID, plan.CatalogID) if err != nil { - return nil, fmt.Errorf("failed to marshal OSB context %+v: %s", provisionRequest.Context, err) + return nil, fmt.Errorf("faied to prepare provision request: %s", err) } - instance.Context = contextBytes - log.C(ctx).Infof("Sending provision request %s to broker with name %s", logProvisionRequest(provisionRequest), broker.Name) provisionResponse, err = osbClient.ProvisionInstance(provisionRequest) if err != nil { @@ -447,7 +444,31 @@ func preparePrerequisites(ctx context.Context, repository storage.Repository, os return osbClient, broker, service, plan, nil } -func (i *ServiceInstanceInterceptor) prepareProvisionRequest(instance *types.ServiceInstance, serviceCatalogID, planCatalogID string) *osbc.ProvisionRequest { +func (i *ServiceInstanceInterceptor) prepareProvisionRequest(instance *types.ServiceInstance, serviceCatalogID, planCatalogID string) (*osbc.ProvisionRequest, error) { + context := make(map[string]interface{}) + if len(instance.Context) != 0 { + if err := json.Unmarshal(instance.Context, &context); err != nil { + return nil, fmt.Errorf("failed to unmarshal already present OSB context: %s", err) + } + } else { + context = map[string]interface{}{ + "platform": types.SMPlatform, + "instance_name": instance.Name, + } + + if len(i.tenantKey) != 0 { + if tenantValue, ok := instance.GetLabels()[i.tenantKey]; ok { + context[i.tenantKey] = tenantValue[0] + } + } + + contextBytes, err := json.Marshal(context) + if err != nil { + return nil, fmt.Errorf("failed to marshal OSB context %+v: %s", context, err) + } + instance.Context = contextBytes + } + provisionRequest := &osbc.ProvisionRequest{ InstanceID: instance.GetID(), AcceptsIncomplete: true, @@ -456,20 +477,12 @@ func (i *ServiceInstanceInterceptor) prepareProvisionRequest(instance *types.Ser OrganizationGUID: "-", SpaceGUID: "-", Parameters: instance.Parameters, - Context: map[string]interface{}{ - "platform": types.SMPlatform, - "instance_name": instance.Name, - }, + Context: context, //TODO no OI for SM platform yet OriginatingIdentity: nil, } - if len(i.tenantKey) != 0 { - if tenantValue, ok := instance.GetLabels()[i.tenantKey]; ok { - provisionRequest.Context[i.tenantKey] = tenantValue[0] - } - } - return provisionRequest + return provisionRequest, nil } func prepareDeprovisionRequest(instance *types.ServiceInstance, serviceCatalogID, planCatalogID string) *osbc.DeprovisionRequest { From 0d61cd60c78259819202fc64abbc65ec441d66e0 Mon Sep 17 00:00:00 2001 From: Nikolay Mateev Date: Tue, 11 Feb 2020 20:54:47 +0200 Subject: [PATCH 15/15] Operations Maintainer (#433) --- Gopkg.lock | 40 +-- application.yml | 6 +- config/config_test.go | 11 +- operations/config.go | 57 ++- operations/maintainer.go | 324 +++++++++++++++--- operations/scheduler.go | 36 +- pkg/sm/sm.go | 8 +- storage/encrypting_repository.go | 19 +- .../smaap_service_binding_interceptor.go | 10 +- .../smaap_service_instance_interceptor.go | 10 +- storage/postgres/keystore.go | 42 +-- storage/postgres/keystore_test.go | 54 --- storage/postgres/locker.go | 168 +++++++++ storage/postgres/locker_test.go | 160 +++++++++ storage/postgres/storage.go | 1 - storage/postgres/storage_test.go | 17 +- test/broker_test/broker_test.go | 2 +- test/common/test_context.go | 15 +- test/configuration_test/configuration_test.go | 7 +- test/operations_test/operations_test.go | 59 +++- test/platform_test/platform_test.go | 2 +- .../service_binding_test.go | 231 ++++++++++++- .../service_instance_test.go | 290 ++++++++++++++-- .../service_offering_test.go | 2 +- test/service_plan_test/service_plan_test.go | 2 +- test/test.go | 36 +- test/visibility_test/visibility_test.go | 2 +- 27 files changed, 1295 insertions(+), 316 deletions(-) create mode 100644 storage/postgres/locker.go create mode 100644 storage/postgres/locker_test.go diff --git a/Gopkg.lock b/Gopkg.lock index 9237a9189..813cff0d9 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -163,12 +163,12 @@ revision = "23def4e6c14b4da8ac2ed8007337bc5eb5007998" [[projects]] - digest = "1:573ca21d3669500ff845bdebee890eb7fc7f0f50c59f2132f2a0c6b03d85086a" + digest = "1:d1e35b720b5f5156502ffdd174000c81295919293735342da78582e4dc7a8bcd" name = "github.com/golang/protobuf" packages = ["proto"] pruneopts = "UT" - revision = "6c65a5562fc06764971b7c5d05c76c75e84bdbf7" - version = "v1.3.2" + revision = "d23c5127dc24889085f8ccea5c9d560a57a879d8" + version = "v1.3.3" [[projects]] digest = "1:a63cff6b5d8b95638bfe300385d93b2a6d9d687734b863da8e09dc834510a690" @@ -256,7 +256,7 @@ revision = "2ba0fc60eb4a54030f3a6d73ff0a047349c7eeca" [[projects]] - digest = "1:cb7edefacdcbfd95b7611c11b3b027404fa39a66fdc91f6366e1811cbdb5cd3e" + digest = "1:1d965d8955ca44e9c963e24fdf0517033ca1820b6643ec546106dc2040ed54a0" name = "github.com/klauspost/compress" packages = [ "flate", @@ -264,8 +264,8 @@ "zlib", ] pruneopts = "UT" - revision = "459b83aadb42b806aed42f0f4b3240c8834e0cc1" - version = "v1.9.8" + revision = "b9d5dc7bd435c628cb0c658ce7f556409007ea71" + version = "v1.10.0" [[projects]] digest = "1:31e761d97c76151dde79e9d28964a812c46efc5baee4085b86f68f0c654450de" @@ -328,7 +328,7 @@ version = "v0.4.1" [[projects]] - digest = "1:ad7bc85a2256ae7246450290c96619db309d8d8f1918a3163e9af90ef1d7a077" + digest = "1:13b913f297752cf0f236c0ab940d0ffaac64e5a97b7bb68c659a9f734bd32a89" name = "github.com/onsi/ginkgo" packages = [ ".", @@ -352,8 +352,8 @@ "types", ] pruneopts = "UT" - revision = "388ac7e50a3abf0798010091d5094171f4aefc0b" - version = "v1.11.0" + revision = "40598150331533e3cd497f21dcce387dae84b561" + version = "v1.12.0" [[projects]] digest = "1:e42321c3ec0ff36c0644da60c2c1469886b214134286f4610199b704619e11a3" @@ -475,12 +475,12 @@ version = "v1.4.0" [[projects]] - digest = "1:fcea9aca14ce388baeb3afd42bc3302089393f78f9531212aed88d60a134a921" + digest = "1:13503b1b68ee704913caf452f02968fa313a9934e67d951ed0d39ca8e230d5e0" name = "github.com/tidwall/gjson" packages = ["."] pruneopts = "UT" - revision = "d10932a0d0b5f1618759b6259b05f7cb7bea0c25" - version = "v1.4.0" + revision = "0360deb6d803e8c271363ce5f6c85d6cd843a3a0" + version = "v1.5.0" [[projects]] digest = "1:8453ddbed197809ee8ca28b06bd04e127bec9912deb4ba451fea7a1eca578328" @@ -491,12 +491,12 @@ version = "v1.0.1" [[projects]] - digest = "1:ddfe0a54e5f9b29536a6d7b2defa376f2cb2b6e4234d676d7ff214d5b097cb50" + digest = "1:f63bab79e68e805cdd9bf70daa09e8c430cfbddf29d14b567a92fb12581b9b95" name = "github.com/tidwall/pretty" packages = ["."] pruneopts = "UT" - revision = "1166b9ac2b65e46a43d8618d30d1554f4652d49b" - version = "v1.0.0" + revision = "b2475501f89994f7ea30b3c94ba86b49079961fe" + version = "v1.0.1" [[projects]] digest = "1:b70c951ba6fdeecfbd50dabe95aa5e1b973866ae9abbece46ad60348112214f2" @@ -586,11 +586,11 @@ "pbkdf2", ] pruneopts = "UT" - revision = "530e935923ad688be97c15eeb8e5ee42ebf2b54a" + revision = "86ce3cb696783b739e41e834e2eead3e1b4aa3fb" [[projects]] branch = "master" - digest = "1:317bb86f606d3cdb6b282cea377e61ce50023b16bdc72d2edfb2ef0164f18364" + digest = "1:7217cd222c72ced17b355f472688cd619e80c8b2e811cbe8b68b739091721173" name = "golang.org/x/net" packages = [ "context", @@ -602,7 +602,7 @@ "publicsuffix", ] pruneopts = "UT" - revision = "6afb5195e5aab057fda82e27171243402346b0ad" + revision = "16171245cfb220d5317888b716d69c1fb4e7992b" [[projects]] branch = "master" @@ -617,11 +617,11 @@ [[projects]] branch = "master" - digest = "1:8a44970c7e8c0a1c8646af14605c1ffd31374074d68101c2e11d7761df12c9d1" + digest = "1:72b7c210f8cfe1431d2f300fbf37f25e52aa77324b05ab6b698483054033803e" name = "golang.org/x/sys" packages = ["unix"] pruneopts = "UT" - revision = "e047566fdf82409bf7a52212cf71df83ea2772fb" + revision = "d101bd2416d505c0448a6ce8a282482678040a89" [[projects]] digest = "1:28deae5fe892797ff37a317b5bcda96d11d1c90dadd89f1337651df3bc4c586e" diff --git a/application.yml b/application.yml index 15796f5c4..90b1b0842 100644 --- a/application.yml +++ b/application.yml @@ -14,7 +14,7 @@ websocket: ping_timeout: 6000ms write_timeout: 6000ms log: - level: error + level: debug format: kibana storage: # name: sm-postgres @@ -27,8 +27,8 @@ api: client_id: cf operations: cleanup_interval: 30m - job_timeout: 12m - scheduled_deletion_timeout: 12h + action_timeout: 12m + reconciliation_operation_timeout: 12h polling_interval: 5s rescheduling_interval: 5s pools: diff --git a/config/config_test.go b/config/config_test.go index 0d6ab4c50..2735f10e5 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -242,7 +242,7 @@ var _ = Describe("config", func() { Context("when operation job timeout is < 0", func() { It("returns an error", func() { - config.Operations.JobTimeout = -time.Second + config.Operations.ActionTimeout = -time.Second assertErrorDuringValidate() }) }) @@ -254,16 +254,9 @@ var _ = Describe("config", func() { }) }) - Context("when operation mark orphans interval is < 0", func() { - It("returns an error", func() { - config.Operations.MarkOrphansInterval = -time.Second - assertErrorDuringValidate() - }) - }) - Context("when operation scheduled deletion timeoutt is < 0", func() { It("returns an error", func() { - config.Operations.ScheduledDeletionTimeout = -time.Second + config.Operations.ReconciliationOperationTimeout = -time.Second assertErrorDuringValidate() }) }) diff --git a/operations/config.go b/operations/config.go index 139ffdd4a..430b188e6 100644 --- a/operations/config.go +++ b/operations/config.go @@ -24,55 +24,52 @@ import ( const ( minTimePeriod = time.Nanosecond - defaultMarkOrphansInterval = 24 * time.Hour - defaultJobTimeout = 7*24*time.Hour - 1*time.Hour + defaultActionTimeout = 12 * time.Hour + defaultOperationLifespan = 7 * 24 * time.Hour - defaultCleanupInterval = 1 * time.Hour - defaultExpirationTime = 7 * 24 * time.Hour + defaultCleanupInterval = 24 * time.Hour ) // Settings type to be loaded from the environment type Settings struct { - JobTimeout time.Duration `mapstructure:"job_timeout" description:"timeout for async operations"` - MarkOrphansInterval time.Duration `mapstructure:"mark_orphans_interval" description:"interval denoting how often to mark orphan operations as failed"` - CleanupInterval time.Duration `mapstructure:"cleanup_interval" description:"cleanup interval of old operations"` - ExpirationTime time.Duration `mapstructure:"expiration_time" description:"after that time is passed since its creation, the operation can be cleaned up by the maintainer"` - DefaultPoolSize int `mapstructure:"default_pool_size" description:"default worker pool size"` - Pools []PoolSettings `mapstructure:"pools" description:"defines the different available worker pools"` - - ScheduledDeletionTimeout time.Duration `mapstructure:"scheduled_deletion_timeout" description:"the maximum allowed timeout for auto rescheduling of operation actions"` - ReschedulingInterval time.Duration `mapstructure:"rescheduling_interval" description:"the interval between auto rescheduling of operation actions"` - PollingInterval time.Duration `mapstructure:"polling_interval" description:"the interval between polls for async requests"` + ActionTimeout time.Duration `mapstructure:"action_timeout" description:"timeout for async operations"` + ReconciliationOperationTimeout time.Duration `mapstructure:"reconciliation_operation_timeout" description:"the maximum allowed timeout for auto rescheduling of operation actions"` + + CleanupInterval time.Duration `mapstructure:"cleanup_interval" description:"cleanup interval of old operations"` + Lifespan time.Duration `mapstructure:"lifespan" description:"after that time is passed since its creation, the operation can be cleaned up by the maintainer"` + + ReschedulingInterval time.Duration `mapstructure:"rescheduling_interval" description:"the interval between auto rescheduling of operation actions"` + PollingInterval time.Duration `mapstructure:"polling_interval" description:"the interval between polls for async requests"` + + DefaultPoolSize int `mapstructure:"default_pool_size" description:"default worker pool size"` + Pools []PoolSettings `mapstructure:"pools" description:"defines the different available worker pools"` } // DefaultSettings returns default values for API settings func DefaultSettings() *Settings { return &Settings{ - JobTimeout: defaultJobTimeout, - MarkOrphansInterval: defaultMarkOrphansInterval, - CleanupInterval: defaultCleanupInterval, - ExpirationTime: defaultExpirationTime, - DefaultPoolSize: 20, - Pools: []PoolSettings{}, - ScheduledDeletionTimeout: 12 * time.Hour, - ReschedulingInterval: 1 * time.Second, - PollingInterval: 1 * time.Second, + ActionTimeout: defaultActionTimeout, + CleanupInterval: defaultCleanupInterval, + Lifespan: defaultOperationLifespan, + DefaultPoolSize: 20, + Pools: []PoolSettings{}, + ReconciliationOperationTimeout: defaultOperationLifespan, + + ReschedulingInterval: 1 * time.Second, + PollingInterval: 1 * time.Second, } } // Validate validates the Operations settings func (s *Settings) Validate() error { - if s.JobTimeout <= minTimePeriod { - return fmt.Errorf("validate Settings: JobTimeout must be larger than %s", minTimePeriod) - } - if s.MarkOrphansInterval <= minTimePeriod { - return fmt.Errorf("validate Settings: MarkOrphanscInterval must be larger than %s", minTimePeriod) + if s.ActionTimeout <= minTimePeriod { + return fmt.Errorf("validate Settings: ActionTimeout must be larger than %s", minTimePeriod) } if s.CleanupInterval <= minTimePeriod { return fmt.Errorf("validate Settings: CleanupInterval must be larger than %s", minTimePeriod) } - if s.ScheduledDeletionTimeout <= minTimePeriod { - return fmt.Errorf("validate Settings: ScheduledDeletionTimeout must be larger than %s", minTimePeriod) + if s.ReconciliationOperationTimeout <= minTimePeriod { + return fmt.Errorf("validate Settings: ReconciliationOperationTimeout must be larger than %s", minTimePeriod) } if s.ReschedulingInterval <= minTimePeriod { return fmt.Errorf("validate Settings: ReschedulingInterval must be larger than %s", minTimePeriod) diff --git a/operations/maintainer.go b/operations/maintainer.go index 9562f7309..dba0ac4ad 100644 --- a/operations/maintainer.go +++ b/operations/maintainer.go @@ -18,6 +18,7 @@ package operations import ( "context" + "sync" "time" "github.com/Peripli/service-manager/pkg/log" @@ -27,89 +28,303 @@ import ( "github.com/Peripli/service-manager/storage" ) +const ( + initialOperationsLockIndex = 200 + ZeroTime = "0001-01-01 00:00:00+00" +) + +// MaintainerFunctor represents a named maintainer function which runs over a pre-defined period +type MaintainerFunctor struct { + name string + interval time.Duration + execute func() +} + // Maintainer ensures that operations old enough are deleted // and that no orphan operations are left in the DB due to crashes/restarts of SM type Maintainer struct { - smCtx context.Context - repository storage.Repository - jobTimeout time.Duration - markOrphansInterval time.Duration - cleanupInterval time.Duration - operationExpirationTime time.Duration + smCtx context.Context + repository storage.Repository + scheduler *Scheduler + + settings *Settings + wg *sync.WaitGroup + + functors []MaintainerFunctor + operationLockers map[string]storage.Locker } // NewMaintainer constructs a Maintainer -func NewMaintainer(smCtx context.Context, repository storage.Repository, options *Settings) *Maintainer { - return &Maintainer{ - smCtx: smCtx, - repository: repository, - jobTimeout: options.JobTimeout, - markOrphansInterval: options.MarkOrphansInterval, - cleanupInterval: options.CleanupInterval, - operationExpirationTime: options.ExpirationTime, +func NewMaintainer(smCtx context.Context, repository storage.TransactionalRepository, lockerCreatorFunc storage.LockerCreatorFunc, options *Settings, wg *sync.WaitGroup) *Maintainer { + maintainer := &Maintainer{ + smCtx: smCtx, + repository: repository, + scheduler: NewScheduler(smCtx, repository, options, options.DefaultPoolSize, wg), + settings: options, + wg: wg, } + + maintainer.functors = []MaintainerFunctor{ + { + name: "cleanupExternalOperations", + execute: maintainer.cleanupExternalOperations, + interval: options.CleanupInterval, + }, + { + name: "cleanupInternalSuccessfulOperations", + execute: maintainer.cleanupInternalSuccessfulOperations, + interval: options.CleanupInterval, + }, + { + name: "cleanupInternalFailedOperations", + execute: maintainer.cleanupInternalFailedOperations, + interval: options.CleanupInterval, + }, + { + name: "markOrphanOperationsFailed", + execute: maintainer.markOrphanOperationsFailed, + interval: options.CleanupInterval, + }, + { + name: "rescheduleUnprocessedOperations", + execute: maintainer.rescheduleUnprocessedOperations, + interval: options.ActionTimeout / 2, + }, + { + name: "rescheduleOrphanMitigationOperations", + execute: maintainer.rescheduleOrphanMitigationOperations, + interval: options.ActionTimeout / 2, + }, + } + + operationLockers := make(map[string]storage.Locker) + advisoryLockStartIndex := initialOperationsLockIndex + for _, functor := range maintainer.functors { + operationLockers[functor.name] = lockerCreatorFunc(advisoryLockStartIndex) + advisoryLockStartIndex++ + } + + maintainer.operationLockers = operationLockers + + return maintainer } // Run starts the two recurring jobs responsible for cleaning up operations which are too old // and deleting orphan operations func (om *Maintainer) Run() { - om.cleanUpOldOperations() - om.markOrphanOperationsFailed() + for _, functor := range om.functors { + functor := functor + maintainerFunc := func() { + log.C(om.smCtx).Infof("Attempting to retrieve lock for maintainer functor (%s)", functor.name) + err := om.operationLockers[functor.name].TryLock(om.smCtx) + if err != nil { + log.C(om.smCtx).Infof("Failed to retrieve lock for maintainer functor (%s): %s", functor.name, err) + return + } + defer func() { + err := om.operationLockers[functor.name].Unlock(om.smCtx) + log.C(om.smCtx).Warnf("Could not unlock for maintainer functor (%s): %s", functor.name, err) + }() + log.C(om.smCtx).Infof("Successfully retrieved lock for maintainer functor (%s)", functor.name) + + functor.execute() + } - go om.processOldOperations() - go om.processOrphanOperations() + go maintainerFunc() + go om.processOperations(maintainerFunc, functor.name, functor.interval) + } } -// processOldOperations cleans up periodically all operations which are older than some specified time -func (om *Maintainer) processOldOperations() { - ticker := time.NewTicker(om.cleanupInterval) +func (om *Maintainer) processOperations(functor func(), functorName string, interval time.Duration) { + ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ticker.C: - om.cleanUpOldOperations() + func() { + om.wg.Add(1) + defer om.wg.Done() + log.C(om.smCtx).Infof("Starting execution of maintainer functor (%s)", functorName) + functor() + log.C(om.smCtx).Infof("Finished execution of maintainer functor (%s)", functorName) + }() case <-om.smCtx.Done(): ticker.Stop() - log.C(om.smCtx).Info("Server is shutting down. Stopping old operations maintainer...") + log.C(om.smCtx).Info("Server is shutting down. Stopping operations maintainer...") return } } } -// processOrphanOperations periodically checks for operations which are stuck in state IN_PROGRESS and updates their status to FAILED -func (om *Maintainer) processOrphanOperations() { - ticker := time.NewTicker(om.markOrphansInterval) - defer ticker.Stop() - for { - select { - case <-ticker.C: - om.markOrphanOperationsFailed() - case <-om.smCtx.Done(): - ticker.Stop() - log.C(om.smCtx).Info("Server is shutting down. Stopping stuck operations maintainer...") - return - } +// cleanUpExternalOperations cleans up periodically all external operations which are older than some specified time +func (om *Maintainer) cleanupExternalOperations() { + criteria := []query.Criterion{ + query.ByField(query.NotEqualsOperator, "platform_id", types.SMPlatform), + // check if operation hasn't been updated for the operation's maximum allowed time to live in DB + query.ByField(query.LessThanOperator, "updated_at", util.ToRFCNanoFormat(time.Now().Add(-om.settings.Lifespan))), } + + if err := om.repository.Delete(om.smCtx, types.OperationType, criteria...); err != nil && err != util.ErrNotFoundInStorage { + log.D().Debugf("Failed to cleanup operations: %s", err) + return + } + log.D().Debug("Finished cleaning up external operations") } -func (om *Maintainer) cleanUpOldOperations() { +// cleanupInternalSuccessfulOperations cleans up all successful internal operations which are older than some specified time +func (om *Maintainer) cleanupInternalSuccessfulOperations() { criteria := []query.Criterion{ + query.ByField(query.EqualsOperator, "platform_id", types.SMPlatform), + query.ByField(query.EqualsOperator, "state", string(types.SUCCEEDED)), + // check if operation hasn't been updated for the operation's maximum allowed time to live in DB + query.ByField(query.LessThanOperator, "updated_at", util.ToRFCNanoFormat(time.Now().Add(-om.settings.Lifespan))), + } + + if err := om.repository.Delete(om.smCtx, types.OperationType, criteria...); err != nil && err != util.ErrNotFoundInStorage { + log.D().Debugf("Failed to cleanup operations: %s", err) + return + } + log.D().Debug("Finished cleaning up successful internal operations") +} + +// cleanupInternalFailedOperations cleans up all failed internal operations which are older than some specified time +func (om *Maintainer) cleanupInternalFailedOperations() { + criteria := []query.Criterion{ + query.ByField(query.EqualsOperator, "platform_id", types.SMPlatform), query.ByField(query.EqualsOperator, "state", string(types.FAILED)), - query.ByField(query.NotEqualsOperator, "platform_id", types.SMPlatform), - query.ByField(query.LessThanOperator, "created_at", util.ToRFCNanoFormat(time.Now().Add(-om.operationExpirationTime))), + query.ByField(query.EqualsOperator, "reschedule", "false"), + query.ByField(query.EqualsOperator, "deletion_scheduled", ZeroTime), + // check if operation hasn't been updated for the operation's maximum allowed time to live in DB + query.ByField(query.LessThanOperator, "updated_at", util.ToRFCNanoFormat(time.Now().Add(-om.settings.Lifespan))), } - if err := om.repository.Delete(om.smCtx, types.OperationType, criteria...); err != nil { + if err := om.repository.Delete(om.smCtx, types.OperationType, criteria...); err != nil && err != util.ErrNotFoundInStorage { log.D().Debugf("Failed to cleanup operations: %s", err) return } - log.D().Debug("Successfully cleaned up operations") + log.D().Debug("Finished cleaning up failed internal operations") +} + +// rescheduleUnprocessedOperations reschedules IN_PROGRESS operations which are reschedulable, not scheduled for deletion and no goroutine is processing at the moment +func (om *Maintainer) rescheduleUnprocessedOperations() { + criteria := []query.Criterion{ + query.ByField(query.EqualsOperator, "platform_id", types.SMPlatform), + query.ByField(query.EqualsOperator, "state", string(types.IN_PROGRESS)), + query.ByField(query.EqualsOperator, "reschedule", "true"), + query.ByField(query.EqualsOperator, "deletion_scheduled", ZeroTime), + // check if operation hasn't been updated for the operation's maximum allowed time to execute + query.ByField(query.LessThanOperator, "updated_at", util.ToRFCNanoFormat(time.Now().Add(-om.settings.ActionTimeout))), + // check if operation is still eligible for processing + query.ByField(query.GreaterThanOperator, "created_at", util.ToRFCNanoFormat(time.Now().Add(-om.settings.ReconciliationOperationTimeout))), + } + + objectList, err := om.repository.List(om.smCtx, types.OperationType, criteria...) + if err != nil { + log.D().Debugf("Failed to fetch unprocessed operations: %s", err) + return + } + + operations := objectList.(*types.Operations) + for i := 0; i < operations.Len(); i++ { + operation := operations.ItemAt(i).(*types.Operation) + logger := log.ForContext(om.smCtx).WithField(log.FieldCorrelationID, operation.CorrelationID) + + var action storageAction + + switch operation.Type { + case types.CREATE: + object, err := om.repository.Get(om.smCtx, operation.ResourceType, query.ByField(query.EqualsOperator, "id", operation.ResourceID)) + if err != nil { + logger.Warnf("Failed to fetch resource with ID (%s) for operation with ID (%s): %s", operation.ResourceID, operation.ID, err) + return + } + + action = func(ctx context.Context, repository storage.Repository) (types.Object, error) { + object, err := repository.Create(ctx, object) + return object, util.HandleStorageError(err, operation.ResourceType.String()) + } + /* TODO: Uncomment and adapt once update flow is enabled + case types.UPDATE: + action = func(ctx context.Context, repository storage.Repository) (types.Object, error) { + object, err := repository.Update(ctx, objFromDB, labelChanges, criteria...) + return object, util.HandleStorageError(err, operation.ResourceType.String()) + } + */ + case types.DELETE: + byID := query.ByField(query.EqualsOperator, "id", operation.ResourceID) + + action = func(ctx context.Context, repository storage.Repository) (types.Object, error) { + err := repository.Delete(ctx, operation.ResourceType, byID) + if err != nil { + if err == util.ErrNotFoundInStorage { + return nil, nil + } + return nil, util.HandleStorageError(err, operation.ResourceType.String()) + } + return nil, nil + } + } + + if err := om.scheduler.ScheduleAsyncStorageAction(om.smCtx, operation, action); err != nil { + logger.Warnf("Failed to reschedule unprocessed operation with ID (%s): %s", operation.ID, err) + } + } + + log.D().Debug("Finished rescheduling unprocessed operations") } +// rescheduleOrphanMitigationOperations reschedules orphan mitigation operations which no goroutine is processing at the moment +func (om *Maintainer) rescheduleOrphanMitigationOperations() { + criteria := []query.Criterion{ + query.ByField(query.EqualsOperator, "platform_id", types.SMPlatform), + query.ByField(query.NotEqualsOperator, "deletion_scheduled", ZeroTime), + // check if operation hasn't been updated for the operation's maximum allowed time to execute + query.ByField(query.LessThanOperator, "updated_at", util.ToRFCNanoFormat(time.Now().Add(-om.settings.ActionTimeout))), + // check if operation is still eligible for processing + query.ByField(query.GreaterThanOperator, "created_at", util.ToRFCNanoFormat(time.Now().Add(-om.settings.ReconciliationOperationTimeout))), + } + + objectList, err := om.repository.List(om.smCtx, types.OperationType, criteria...) + if err != nil { + log.D().Debugf("Failed to fetch unprocessed orphan mitigation operations: %s", err) + return + } + + operations := objectList.(*types.Operations) + for i := 0; i < operations.Len(); i++ { + operation := operations.ItemAt(i).(*types.Operation) + logger := log.ForContext(om.smCtx).WithField(log.FieldCorrelationID, operation.CorrelationID) + + byID := query.ByField(query.EqualsOperator, "id", operation.ResourceID) + + action := func(ctx context.Context, repository storage.Repository) (types.Object, error) { + err := repository.Delete(ctx, operation.ResourceType, byID) + if err != nil { + if err == util.ErrNotFoundInStorage { + return nil, nil + } + return nil, util.HandleStorageError(err, operation.ResourceType.String()) + } + return nil, nil + } + + if err := om.scheduler.ScheduleAsyncStorageAction(om.smCtx, operation, action); err != nil { + logger.Warnf("Failed to reschedule unprocessed orphan mitigation operation with ID (%s): %s", operation.ID, err) + } + } + + log.D().Debug("Finished rescheduling unprocessed orphan mitigation operations") +} + +// markOrphanOperationsFailed checks for operations which are stuck in state IN_PROGRESS, updates their status to FAILED and schedules a delete action func (om *Maintainer) markOrphanOperationsFailed() { criteria := []query.Criterion{ + query.ByField(query.EqualsOperator, "platform_id", types.SMPlatform), query.ByField(query.EqualsOperator, "state", string(types.IN_PROGRESS)), - query.ByField(query.LessThanOperator, "created_at", util.ToRFCNanoFormat(time.Now().Add(-om.jobTimeout))), + query.ByField(query.EqualsOperator, "reschedule", "false"), + query.ByField(query.EqualsOperator, "deletion_scheduled", ZeroTime), + // check if operation hasn't been updated for the operation's maximum allowed time to execute + query.ByField(query.LessThanOperator, "updated_at", util.ToRFCNanoFormat(time.Now().Add(-om.settings.ActionTimeout))), } objectList, err := om.repository.List(om.smCtx, types.OperationType, criteria...) @@ -121,12 +336,31 @@ func (om *Maintainer) markOrphanOperationsFailed() { operations := objectList.(*types.Operations) for i := 0; i < operations.Len(); i++ { operation := operations.ItemAt(i).(*types.Operation) - operation.State = types.FAILED + logger := log.ForContext(om.smCtx).WithField(log.FieldCorrelationID, operation.CorrelationID) + + operation.DeletionScheduled = time.Now() if _, err := om.repository.Update(om.smCtx, operation, query.LabelChanges{}); err != nil { - log.D().Debugf("Failed to update orphan operation with ID (%s) state to FAILED: %s", operation.ID, err) + logger.Warnf("Failed to update orphan operation with ID (%s) state to FAILED: %s", operation.ID, err) + continue + } + + byID := query.ByField(query.EqualsOperator, "id", operation.ResourceID) + action := func(ctx context.Context, repository storage.Repository) (types.Object, error) { + err := repository.Delete(ctx, operation.ResourceType, byID) + if err != nil { + if err == util.ErrNotFoundInStorage { + return nil, nil + } + return nil, util.HandleStorageError(err, operation.ResourceType.String()) + } + return nil, nil + } + + if err := om.scheduler.ScheduleAsyncStorageAction(om.smCtx, operation, action); err != nil { + logger.Warnf("Failed to schedule delete action for operation with ID (%s): %s", operation.ID, err) } } - log.D().Debug("Successfully marked orphan operations as failed") + log.D().Debug("Finished marking orphan operations as failed") } diff --git a/operations/scheduler.go b/operations/scheduler.go index ac0d1334e..3eaf50f75 100644 --- a/operations/scheduler.go +++ b/operations/scheduler.go @@ -37,25 +37,25 @@ type storageAction func(ctx context.Context, repository storage.Repository) (typ // Scheduler is responsible for storing Operation entities in the DB // and also for spawning goroutines to execute the respective DB transaction asynchronously type Scheduler struct { - smCtx context.Context - repository storage.TransactionalRepository - workers chan struct{} - jobTimeout time.Duration - deletionTimeout time.Duration - reschedulingDelay time.Duration - wg *sync.WaitGroup + smCtx context.Context + repository storage.TransactionalRepository + workers chan struct{} + actionTimeout time.Duration + reconciliationOperationTimeout time.Duration + reschedulingDelay time.Duration + wg *sync.WaitGroup } // NewScheduler constructs a Scheduler func NewScheduler(smCtx context.Context, repository storage.TransactionalRepository, settings *Settings, poolSize int, wg *sync.WaitGroup) *Scheduler { return &Scheduler{ - smCtx: smCtx, - repository: repository, - workers: make(chan struct{}, poolSize), - jobTimeout: settings.JobTimeout, - deletionTimeout: settings.ScheduledDeletionTimeout, - reschedulingDelay: settings.ReschedulingInterval, - wg: wg, + smCtx: smCtx, + repository: repository, + workers: make(chan struct{}, poolSize), + actionTimeout: settings.ActionTimeout, + reconciliationOperationTimeout: settings.ReconciliationOperationTimeout, + reschedulingDelay: settings.ReschedulingInterval, + wg: wg, } } @@ -125,7 +125,7 @@ func (s *Scheduler) ScheduleAsyncStorageAction(ctx context.Context, operation *t return } - stateCtxWithOpAndTimeout, timeoutCtxCancel := context.WithTimeout(stateCtxWithOp, s.jobTimeout) + stateCtxWithOpAndTimeout, timeoutCtxCancel := context.WithTimeout(stateCtxWithOp, s.actionTimeout) defer timeoutCtxCancel() go func() { select { @@ -180,7 +180,7 @@ func (s *Scheduler) checkForConcurrentOperations(ctx context.Context, operation // for the outside world job timeout would have expired if the last update happened > job timeout time ago (this is worst case) // an "old" updated_at means that for a while nobody was processing this operation - isLastOpInProgress := lastOperation.State == types.IN_PROGRESS && time.Now().Before(lastOperation.UpdatedAt.Add(s.jobTimeout)) + isLastOpInProgress := lastOperation.State == types.IN_PROGRESS && time.Now().Before(lastOperation.UpdatedAt.Add(s.actionTimeout)) isAReschedule := lastOperation.Reschedule && operation.Reschedule @@ -402,7 +402,7 @@ func (s *Scheduler) handleActionResponse(ctx context.Context, actionObject types func (s *Scheduler) handleActionResponseFailure(ctx context.Context, actionError error, opAfterJob *types.Operation) error { if err := s.repository.InTransaction(ctx, func(ctx context.Context, storage storage.Repository) error { - if opErr := updateOperationState(ctx, s.repository, opAfterJob, types.FAILED, actionError); opErr != nil { + if opErr := updateOperationState(ctx, storage, opAfterJob, types.FAILED, actionError); opErr != nil { return fmt.Errorf("setting new operation state failed: %s", opErr) } // after a failed FAILED CREATE operation, update the ready field to false @@ -420,7 +420,7 @@ func (s *Scheduler) handleActionResponseFailure(ctx context.Context, actionError // we want to schedule deletion if the operation is marked for deletion and the deletion timeout is not yet reached isDeleteRescheduleRequired := !opAfterJob.DeletionScheduled.IsZero() && - time.Now().UTC().Before(opAfterJob.DeletionScheduled.Add(s.deletionTimeout)) && + time.Now().UTC().Before(opAfterJob.DeletionScheduled.Add(s.reconciliationOperationTimeout)) && opAfterJob.State != types.SUCCEEDED if isDeleteRescheduleRequired { diff --git a/pkg/sm/sm.go b/pkg/sm/sm.go index b58c65be1..2ba475f20 100644 --- a/pkg/sm/sm.go +++ b/pkg/sm/sm.go @@ -106,7 +106,7 @@ func New(ctx context.Context, cancel context.CancelFunc, e env.Environment, cfg } // Decorate the storage with credentials encryption/decryption - encryptingDecorator := storage.EncryptingDecorator(ctx, &security.AESEncrypter{}, smStorage) + encryptingDecorator := storage.EncryptingDecorator(ctx, &security.AESEncrypter{}, smStorage, postgres.EncryptingLocker(smStorage)) // Initialize the storage with graceful termination var transactionalRepository storage.TransactionalRepository @@ -155,7 +155,11 @@ func New(ctx context.Context, cancel context.CancelFunc, e env.Environment, cfg Settings: *cfg.Storage, } - operationMaintainer := operations.NewMaintainer(ctx, interceptableRepository, cfg.Operations) + postgresLockerCreatorFunc := func(advisoryIndex int) storage.Locker { + return &postgres.Locker{Storage: smStorage, AdvisoryIndex: advisoryIndex} + } + + operationMaintainer := operations.NewMaintainer(ctx, interceptableRepository, postgresLockerCreatorFunc, cfg.Operations, waitGroup) osbClientProvider := osb.NewBrokerClientProvider(cfg.HTTPClient.SkipSSLValidation, int(cfg.HTTPClient.ResponseHeaderTimeout.Seconds())) smb := &ServiceManagerBuilder{ diff --git a/storage/encrypting_repository.go b/storage/encrypting_repository.go index 3d3e1849a..6e317c53c 100644 --- a/storage/encrypting_repository.go +++ b/storage/encrypting_repository.go @@ -14,14 +14,23 @@ import ( "github.com/Peripli/service-manager/pkg/types" ) -// KeyStore interface for encryption key operations -type KeyStore interface { +// LockerCreatorFunc is a function building a storage.Locker with a specific advisory index +type LockerCreatorFunc func(advisoryIndex int) Locker + +// Locker provides basic Lock/Unlock functionality +type Locker interface { // Lock locks the storage so that only one process can manipulate the encryption key. Returns an error if the process has already acquired the lock Lock(ctx context.Context) error + // TryLock tries to lock the storage so that only one process can manipulate the encryption key. Returns an error if the process has already acquired the lock + TryLock(ctx context.Context) error + // Unlock releases the acquired lock. Unlock(ctx context.Context) error +} +// KeyStore interface for encryption key operations +type KeyStore interface { // GetEncryptionKey returns the encryption key from the storage after applying the specified transformation function GetEncryptionKey(ctx context.Context, transformationFunc func(context.Context, []byte, []byte) ([]byte, error)) ([]byte, error) @@ -30,16 +39,16 @@ type KeyStore interface { } // EncryptingDecorator creates a TransactionalRepositoryDecorator that can be used to add encrypting/decrypting logic to a TransactionalRepository -func EncryptingDecorator(ctx context.Context, encrypter security.Encrypter, keyStore KeyStore) TransactionalRepositoryDecorator { +func EncryptingDecorator(ctx context.Context, encrypter security.Encrypter, keyStore KeyStore, locker Locker) TransactionalRepositoryDecorator { return func(next TransactionalRepository) (TransactionalRepository, error) { ctx, cancelFunc := context.WithTimeout(ctx, 2*time.Second) defer cancelFunc() - if err := keyStore.Lock(ctx); err != nil { + if err := locker.Lock(ctx); err != nil { return nil, err } defer func() { - if err := keyStore.Unlock(ctx); err != nil { + if err := locker.Unlock(ctx); err != nil { log.C(ctx).WithError(err).Error("error while unlocking keystore") } }() diff --git a/storage/interceptors/smaap_service_binding_interceptor.go b/storage/interceptors/smaap_service_binding_interceptor.go index 1492a26cc..053a468a4 100644 --- a/storage/interceptors/smaap_service_binding_interceptor.go +++ b/storage/interceptors/smaap_service_binding_interceptor.go @@ -203,13 +203,13 @@ func (i *ServiceBindingInterceptor) AroundTxCreate(f storage.InterceptCreateArou log.C(ctx).Infof("Successful synchronous bind %s to broker %s returned response %s", logBindRequest(bindRequest), broker.Name, logBindResponse(bindResponse)) } - } - object, err := f(ctx, obj) - if err != nil { - return nil, err + object, err := f(ctx, obj) + if err != nil { + return nil, err + } + binding = object.(*types.ServiceBinding) } - binding = object.(*types.ServiceBinding) if operation.Reschedule { if err := i.pollServiceBinding(ctx, osbClient, binding, operation, broker.ID, service.CatalogID, plan.CatalogID, operation.ExternalID, true); err != nil { diff --git a/storage/interceptors/smaap_service_instance_interceptor.go b/storage/interceptors/smaap_service_instance_interceptor.go index 6d5465b33..de3bef076 100644 --- a/storage/interceptors/smaap_service_instance_interceptor.go +++ b/storage/interceptors/smaap_service_instance_interceptor.go @@ -183,13 +183,13 @@ func (i *ServiceInstanceInterceptor) AroundTxCreate(f storage.InterceptCreateAro logProvisionRequest(provisionRequest), broker.Name, logProvisionResponse(provisionResponse)) } - } - object, err := f(ctx, obj) - if err != nil { - return nil, err + object, err := f(ctx, obj) + if err != nil { + return nil, err + } + instance = object.(*types.ServiceInstance) } - instance = object.(*types.ServiceInstance) if operation.Reschedule { if err := i.pollServiceInstance(ctx, osbClient, instance, operation, broker.ID, service.CatalogID, plan.CatalogID, operation.ExternalID, true); err != nil { diff --git a/storage/postgres/keystore.go b/storage/postgres/keystore.go index 001ac26f4..523efcfeb 100644 --- a/storage/postgres/keystore.go +++ b/storage/postgres/keystore.go @@ -19,7 +19,6 @@ package postgres import ( "context" "database/sql" - "fmt" "time" "github.com/Peripli/service-manager/storage" @@ -33,6 +32,11 @@ const ( SafeTable = "safe" ) +// EncryptingLocker builds an encrypting storage.Locker with the pre-defined lock index +func EncryptingLocker(storage *Storage) storage.Locker { + return &Locker{Storage: storage, AdvisoryIndex: securityLockIndex} +} + // Safe represents a secret entity type Safe struct { Secret []byte `db:"secret"` @@ -72,42 +76,6 @@ func (s *Safe) LabelEntity() PostgresLabel { return nil } -// Lock acquires a database lock so that only one process can manipulate the encryption key. -// Returns an error if the process has already acquired the lock -func (s *Storage) Lock(ctx context.Context) error { - s.checkOpen() - - s.mutex.Lock() - defer s.mutex.Unlock() - if s.isLocked { - return fmt.Errorf("lock is already acquired") - } - if _, err := s.db.ExecContext(ctx, "SELECT pg_advisory_lock($1)", securityLockIndex); err != nil { - return err - } - s.isLocked = true - - return nil -} - -// Unlock releases the database lock. -func (s *Storage) Unlock(ctx context.Context) error { - s.checkOpen() - - s.mutex.Lock() - defer s.mutex.Unlock() - if !s.isLocked { - return nil - } - - if _, err := s.db.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", securityLockIndex); err != nil { - return err - } - s.isLocked = false - - return nil -} - // GetEncryptionKey returns the encryption key used to encrypt the credentials for brokers func (s *Storage) GetEncryptionKey(ctx context.Context, transformationFunc func(context.Context, []byte, []byte) ([]byte, error)) ([]byte, error) { s.checkOpen() diff --git a/storage/postgres/keystore_test.go b/storage/postgres/keystore_test.go index 9d745b875..392d7f891 100644 --- a/storage/postgres/keystore_test.go +++ b/storage/postgres/keystore_test.go @@ -192,60 +192,6 @@ var _ = Describe("Secured Storage", func() { Expect(err).To(HaveOccurred()) }) }) - }) - - Describe("Lock", func() { - AfterEach(func() { - s.Unlock(context.TODO()) - }) - Context("When lock is already acquired", func() { - BeforeEach(func() { - mock.ExpectExec("SELECT pg_advisory_lock*").WillReturnResult(sqlmock.NewResult(1, 1)) - }) - - It("Should return an error", func() { - err := s.Lock(context.TODO()) - Expect(err).ToNot(HaveOccurred()) - - err = s.Lock(context.TODO()) - Expect(err).To(HaveOccurred()) - }) - }) - - Context("When lock is not yet acquired", func() { - BeforeEach(func() { - mock.ExpectExec("SELECT").WillReturnResult(sqlmock.NewResult(int64(1), int64(1))) - }) - - It("Should acquire lock", func() { - err := s.Lock(context.TODO()) - Expect(err).ToNot(HaveOccurred()) - }) - }) - }) - - Describe("Unlock", func() { - Context("When lock is not acquired", func() { - It("Should return nil", func() { - err := s.Unlock(context.TODO()) - Expect(err).ToNot(HaveOccurred()) - }) - }) - - Context("When lock is acquired", func() { - BeforeEach(func() { - mock.ExpectExec("SELECT pg_advisory_lock*").WillReturnResult(sqlmock.NewResult(1, 1)) - mock.ExpectExec("SELECT pg_advisory_unlock*").WithArgs(sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1)) - }) - - It("Should release lock", func() { - err := s.Lock(context.TODO()) - Expect(err).ToNot(HaveOccurred()) - - err = s.Unlock(context.TODO()) - Expect(err).To(BeNil()) - }) - }) }) }) diff --git a/storage/postgres/locker.go b/storage/postgres/locker.go new file mode 100644 index 000000000..47dea8d7a --- /dev/null +++ b/storage/postgres/locker.go @@ -0,0 +1,168 @@ +/* + * Copyright 2018 The Service Manager Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/Peripli/service-manager/pkg/log" +) + +var ErrLockAcquisition = errors.New("failed to acquire lock") +var ErrUnlockAcquisition = errors.New("failed to unlock") + +type Locker struct { + *Storage + isLocked bool + AdvisoryIndex int + lockerCon *sql.Conn +} + +// Lock acquires a database lock so that only one process can manipulate the encryption key. +// Returns an error if the process has already acquired the lock +func (l *Locker) Lock(ctx context.Context) error { + log.C(ctx).Infof("Attempting to lock advisory lock with index (%d)", l.AdvisoryIndex) + l.mutex.Lock() + defer l.mutex.Unlock() + if l.isLocked || l.lockerCon != nil { + log.C(ctx).Infof("Locker with advisory index (%d) is locked, so no attempt to lock it", l.AdvisoryIndex) + return fmt.Errorf("lock is already acquired") + } + + var err error + if l.lockerCon, err = l.db.Conn(ctx); err != nil { + return err + } + + log.C(ctx).Infof("Executing lock of locker with advisory index (%d)", l.AdvisoryIndex) + rows, err := l.lockerCon.QueryContext(ctx, "SELECT pg_advisory_lock($1)", l.AdvisoryIndex) + if err != nil { + l.release(ctx) + log.C(ctx).Infof("Failed to lock locker with advisory index (%d)", l.AdvisoryIndex) + return err + } + defer func() { + if err := rows.Close(); err != nil { + log.C(ctx).WithError(err).Error("Could not close rows") + } + }() + + l.isLocked = true + + log.C(ctx).Infof("Successfully locked locker with advisory index (%d)", l.AdvisoryIndex) + return nil +} + +// Lock acquires a database lock so that only one process can manipulate the encryption key. +// Returns an error if the process has already acquired the lock +func (l *Locker) TryLock(ctx context.Context) error { + log.C(ctx).Infof("Attempting to try_lock advisory lock with index (%d)", l.AdvisoryIndex) + l.mutex.Lock() + defer l.mutex.Unlock() + if l.isLocked || l.lockerCon != nil { + log.C(ctx).Infof("Locker with advisory index (%d) is locked, so no attempt to try_lock it", l.AdvisoryIndex) + return fmt.Errorf("try_lock is already acquired") + } + + var err error + if l.lockerCon, err = l.db.Conn(ctx); err != nil { + return err + } + + log.C(ctx).Infof("Executing try_lock of locker with advisory index (%d)", l.AdvisoryIndex) + rows, err := l.lockerCon.QueryContext(ctx, "SELECT pg_try_advisory_lock($1)", l.AdvisoryIndex) + if err != nil { + l.release(ctx) + log.C(ctx).Infof("Failed to try_lock locker with advisory index (%d)", l.AdvisoryIndex) + return err + } + defer func() { + if err := rows.Close(); err != nil { + log.C(ctx).WithError(err).Error("Could not close rows") + } + }() + + var locked bool + for rows.Next() { + if err = rows.Scan(&locked); err != nil { + l.release(ctx) + return err + } + } + + if !locked { + l.release(ctx) + log.C(ctx).Infof("Failed to try_lock locker with advisory index (%d) - either already locked or failed to lock", l.AdvisoryIndex) + return ErrLockAcquisition + } + + l.isLocked = true + + log.C(ctx).Infof("Successfully try_locked locker with advisory index (%d)", l.AdvisoryIndex) + return nil +} + +// Unlock releases the database lock. +func (l *Locker) Unlock(ctx context.Context) error { + log.C(ctx).Infof("Attempting to unlock advisory lock with index (%d)", l.AdvisoryIndex) + l.mutex.Lock() + defer l.mutex.Unlock() + if !l.isLocked || l.lockerCon == nil { + log.C(ctx).Infof("Locker with advisory index (%d) is not locked, so no attempt to unlock it", l.AdvisoryIndex) + return nil + } + defer l.release(ctx) + + log.C(ctx).Infof("Executing unlock of locker with advisory index (%d)", l.AdvisoryIndex) + rows, err := l.lockerCon.QueryContext(ctx, "SELECT pg_advisory_unlock($1)", l.AdvisoryIndex) + if err != nil { + log.C(ctx).Infof("Failed to unlock locker with advisory index (%d)", l.AdvisoryIndex) + return err + } + defer func() { + if err := rows.Close(); err != nil { + log.C(ctx).WithError(err).Error("Could not close rows") + } + }() + + var unlocked bool + for rows.Next() { + if err = rows.Scan(&unlocked); err != nil { + return err + } + } + + if !unlocked { + log.C(ctx).Infof("Failed to unlock locker with advisory index (%d) - either already unlocked or failed to unlock", l.AdvisoryIndex) + return ErrUnlockAcquisition + } + + l.isLocked = false + + log.C(ctx).Infof("Successfully unlocked locker with advisory index (%d)", l.AdvisoryIndex) + return nil +} + +func (l *Locker) release(ctx context.Context) { + if err := l.lockerCon.Close(); err != nil { + log.C(ctx).WithError(err).Error("Could not release connection") + } + l.lockerCon = nil +} diff --git a/storage/postgres/locker_test.go b/storage/postgres/locker_test.go new file mode 100644 index 000000000..38203d215 --- /dev/null +++ b/storage/postgres/locker_test.go @@ -0,0 +1,160 @@ +/* + * Copyright 2018 The Service Manager Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package postgres + +import ( + "context" + "crypto/rand" + "database/sql" + + "github.com/Peripli/service-manager/storage" + + "github.com/Peripli/service-manager/pkg/security" + + "github.com/Peripli/service-manager/pkg/security/securityfakes" + + "github.com/DATA-DOG/go-sqlmock" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Storage Locker", func() { + var s *Storage + var locker *Locker + var mockdb *sql.DB + var mock sqlmock.Sqlmock + + var envEncryptionKey []byte + + var fakeEncrypter *securityfakes.FakeEncrypter + + sucessLockRow := func() *sqlmock.Rows { return sqlmock.NewRows([]string{"pg_advisory_lock"}).FromCSVString("true") } + failLockRow := func() *sqlmock.Rows { return sqlmock.NewRows([]string{"pg_advisory_lock"}).FromCSVString("false") } + sucessUnlockRow := func() *sqlmock.Rows { return sqlmock.NewRows([]string{"pg_advisory_unlock"}).FromCSVString("true") } + + BeforeEach(func() { + envEncryptionKey = make([]byte, 32) + _, err := rand.Read(envEncryptionKey) + Expect(err).ToNot(HaveOccurred()) + + mockdb, mock, err = sqlmock.New() + Expect(err).ToNot(HaveOccurred()) + + s = &Storage{ + ConnectFunc: func(driver string, url string) (*sql.DB, error) { + return mockdb, nil + }, + } + locker = &Locker{ + Storage: s, + AdvisoryIndex: 1, + } + mock.ExpectQuery(`SELECT CURRENT_DATABASE()`).WillReturnRows(sqlmock.NewRows([]string{"mock"}).FromCSVString("mock")) + mock.ExpectQuery(`SELECT COUNT(1)*`).WillReturnRows(sqlmock.NewRows([]string{"mock"}).FromCSVString("1")) + mock.ExpectExec("SELECT pg_advisory_lock*").WithArgs(sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectQuery(`SELECT version, dirty FROM "schema_migrations" LIMIT 1`).WillReturnRows(sqlmock.NewRows([]string{"version", "dirty"}).FromCSVString("20200131152000,false")) + mock.ExpectExec("SELECT pg_advisory_unlock*").WithArgs(sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1)) + + options := storage.DefaultSettings() + options.EncryptionKey = string(envEncryptionKey) + options.URI = "sqlmock://sqlmock" + err = s.Open(options) + Expect(err).ToNot(HaveOccurred()) + + fakeEncrypter = &securityfakes.FakeEncrypter{} + + fakeEncrypter.EncryptCalls(func(ctx context.Context, plainKey []byte, encryptionKey []byte) ([]byte, error) { + encrypter := &security.AESEncrypter{} + return encrypter.Encrypt(ctx, plainKey, encryptionKey) + }) + + fakeEncrypter.DecryptCalls(func(ctx context.Context, encryptedKey []byte, encryptionKey []byte) ([]byte, error) { + encrypter := &security.AESEncrypter{} + return encrypter.Decrypt(ctx, encryptedKey, encryptionKey) + }) + }) + + AfterEach(func() { + s.Close() + }) + + Describe("Lock", func() { + AfterEach(func() { + mock.ExpectQuery("SELECT pg_advisory_unlock*").WithArgs(sqlmock.AnyArg()).WillReturnRows(sucessUnlockRow()) + err := locker.Unlock(context.TODO()) + Expect(err).ShouldNot(HaveOccurred()) + }) + + BeforeEach(func() { + mock.ExpectQuery("SELECT pg_advisory_lock*").WillReturnRows(sucessLockRow()) + }) + + Context("When lock is already acquired", func() { + It("Should return an error", func() { + err := locker.Lock(context.TODO()) + Expect(err).ToNot(HaveOccurred()) + + err = locker.Lock(context.TODO()) + Expect(err).To(HaveOccurred()) + }) + }) + + Context("When lock is not yet acquired", func() { + It("Should acquire lock", func() { + err := locker.Lock(context.TODO()) + Expect(err).ToNot(HaveOccurred()) + }) + }) + }) + + Describe("TryLock", func() { + Context("When lock is already acquired by another lock", func() { + BeforeEach(func() { + mock.ExpectQuery("SELECT pg_try_advisory_lock*").WillReturnRows(failLockRow()) + }) + + It("Should return an error", func() { + err := locker.Lock(context.TODO()) + Expect(err).To(HaveOccurred()) + }) + }) + }) + + Describe("Unlock", func() { + Context("When lock is not acquired", func() { + It("Should return nil", func() { + err := locker.Unlock(context.TODO()) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Context("When lock is acquired", func() { + BeforeEach(func() { + mock.ExpectQuery("SELECT pg_advisory_lock*").WillReturnRows(sucessLockRow()) + mock.ExpectQuery("SELECT pg_advisory_unlock*").WillReturnRows(sucessUnlockRow()) + }) + + It("Should release lock", func() { + err := locker.Lock(context.TODO()) + Expect(err).ToNot(HaveOccurred()) + + err = locker.Unlock(context.TODO()) + Expect(err).To(BeNil()) + }) + }) + }) +}) diff --git a/storage/postgres/storage.go b/storage/postgres/storage.go index 8f86668ef..726203ccb 100644 --- a/storage/postgres/storage.go +++ b/storage/postgres/storage.go @@ -51,7 +51,6 @@ type Storage struct { state *storageState layerOneEncryptionKey []byte scheme *scheme - isLocked bool mutex sync.Mutex } diff --git a/storage/postgres/storage_test.go b/storage/postgres/storage_test.go index ac1cecd98..95625973d 100644 --- a/storage/postgres/storage_test.go +++ b/storage/postgres/storage_test.go @@ -18,6 +18,7 @@ package postgres import ( "context" + "github.com/Peripli/service-manager/storage" . "github.com/onsi/ginkgo" @@ -27,22 +28,6 @@ import ( var _ = Describe("Postgres Storage", func() { pgStorage := &Storage{} - Describe("Lock", func() { - Context("Called with uninitialized db", func() { - It("Should panic", func() { - Expect(func() { pgStorage.Lock(context.TODO()) }).To(Panic()) - }) - }) - }) - - Context("Unlock", func() { - Context("Called with uninitialized db", func() { - It("Should panic", func() { - Expect(func() { pgStorage.Unlock(context.TODO()) }).To(Panic()) - }) - }) - }) - Context("GetEncryptionKey", func() { Context("Called with uninitialized db", func() { It("Should panic", func() { diff --git a/test/broker_test/broker_test.go b/test/broker_test/broker_test.go index 78be7cca4..7051a9570 100644 --- a/test/broker_test/broker_test.go +++ b/test/broker_test/broker_test.go @@ -69,7 +69,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ ResourceBlueprint: blueprint(true), ResourceWithoutNullableFieldsBlueprint: blueprint(false), PatchResource: test.APIResourcePatch, - AdditionalTests: func(ctx *common.TestContext) { + AdditionalTests: func(ctx *common.TestContext, t *test.TestCase) { Context("additional non-generic tests", func() { var ( brokerServer *common.BrokerServer diff --git a/test/common/test_context.go b/test/common/test_context.go index 0d2c4d322..2bd82867a 100644 --- a/test/common/test_context.go +++ b/test/common/test_context.go @@ -168,7 +168,7 @@ func NewTestContextBuilder() *TestContextBuilder { Environment: TestEnv, envPostHooks: []func(env env.Environment, servers map[string]FakeServer){ func(env env.Environment, servers map[string]FakeServer) { - env.Set("api.token_issuer_url", servers["oauth-server"].URL()) + env.Set("api.token_issuer_url", servers[OauthServer].URL()) }, func(env env.Environment, servers map[string]FakeServer) { flag.VisitAll(func(flag *flag.Flag) { @@ -183,9 +183,7 @@ func NewTestContextBuilder() *TestContextBuilder { smExtensions: []func(ctx context.Context, smb *sm.ServiceManagerBuilder, env env.Environment) error{}, defaultTokenClaims: make(map[string]interface{}, 0), tenantTokenClaims: make(map[string]interface{}, 0), - Servers: map[string]FakeServer{ - "oauth-server": NewOAuthServer(), - }, + Servers: map[string]FakeServer{}, HttpClient: &http.Client{ Transport: &http.Transport{ Proxy: http.ProxyFromEnvironment, @@ -315,6 +313,7 @@ func (tcb *TestContextBuilder) BuildWithoutCleanup() *TestContext { func (tcb *TestContextBuilder) BuildWithListener(listener net.Listener, cleanup bool) *TestContext { environment := tcb.Environment(tcb.envPreHooks...) + tcb.Servers[OauthServer] = NewOAuthServer() for _, envPostHook := range tcb.envPostHooks { envPostHook(environment, tcb.Servers) } @@ -538,11 +537,17 @@ func (ctx *TestContext) CleanupBroker(id string) { } func (ctx *TestContext) Cleanup() { + ctx.CleanupAll(true) +} + +func (ctx *TestContext) CleanupAll(cleanupResources bool) { if ctx == nil { return } - ctx.CleanupAdditionalResources() + if cleanupResources { + ctx.CleanupAdditionalResources() + } for _, server := range ctx.Servers { server.Close() diff --git a/test/configuration_test/configuration_test.go b/test/configuration_test/configuration_test.go index 0fff9fe2b..fab0d7364 100644 --- a/test/configuration_test/configuration_test.go +++ b/test/configuration_test/configuration_test.go @@ -84,14 +84,13 @@ var _ = Describe("Service Manager Config API", func() { "label_key": "tenant" }, "operations": { - "cleanup_interval": "1h0m0s", + "cleanup_interval": "24h0m0s", "default_pool_size": 20, - "job_timeout": "167h0m0s", - "mark_orphans_interval": "24h0m0s", + "action_timeout": "12h0m0s", "polling_interval": "1ms", "pools": "", "rescheduling_interval": "1ms", - "scheduled_deletion_timeout": "12h0m0s" + "reconciliation_operation_timeout": "168h0m0s" }, "server": { "host": "", diff --git a/test/operations_test/operations_test.go b/test/operations_test/operations_test.go index 7f65cf189..686b63638 100644 --- a/test/operations_test/operations_test.go +++ b/test/operations_test/operations_test.go @@ -57,7 +57,7 @@ var _ = Describe("Operations", func() { Context("Scheduler", func() { BeforeEach(func() { postHook := func(e env.Environment, servers map[string]common.FakeServer) { - e.Set("operations.job_timeout", 5*time.Nanosecond) + e.Set("operations.action_timeout", 5*time.Nanosecond) e.Set("operations.mark_orphans_interval", 1*time.Hour) } @@ -185,7 +185,7 @@ var _ = Describe("Operations", func() { Context("Maintainer", func() { const ( - jobTimeout = 1 * time.Second + actionTimeout = 1 * time.Second cleanupInterval = 2 * time.Second operationExpiration = 2 * time.Second ) @@ -194,10 +194,10 @@ var _ = Describe("Operations", func() { postHookWithOperationsConfig := func() func(e env.Environment, servers map[string]common.FakeServer) { return func(e env.Environment, servers map[string]common.FakeServer) { - e.Set("operations.job_timeout", jobTimeout) - e.Set("operations.mark_orphans_interval", jobTimeout) + e.Set("operations.action_timeout", actionTimeout) e.Set("operations.cleanup_interval", cleanupInterval) - e.Set("operations.expiration_time", operationExpiration) + e.Set("operations.lifespan", operationExpiration) + e.Set("operations.reconciliation_operation_timeout", 9999*time.Hour) } } @@ -215,8 +215,7 @@ var _ = Describe("Operations", func() { When("Specified cleanup interval passes", func() { Context("operation platform is service Manager", func() { - - It("Does not delete operations older than that interval", func() { + It("Deletes operations older than that interval", func() { ctx.SMWithOAuth.DELETE(web.ServiceBrokersURL+"/non-existent-broker-id").WithQuery("async", true). Expect(). Status(http.StatusAccepted) @@ -224,8 +223,12 @@ var _ = Describe("Operations", func() { byPlatformID := query.ByField(query.EqualsOperator, "platform_id", types.SMPlatform) assertOperationCount(2, byPlatformID) - time.Sleep(cleanupInterval + time.Second) - assertOperationCount(2, byPlatformID) + Eventually(func() int { + count, err := ctx.SMRepository.Count(context.Background(), types.OperationType, byPlatformID) + Expect(err).To(BeNil()) + + return count + }, cleanupInterval*2).Should(Equal(0)) }) }) @@ -287,6 +290,40 @@ var _ = Describe("Operations", func() { }, cleanupInterval*2).Should(Equal(0)) }) }) + + Context("with external operations for Service Manager", func() { + BeforeEach(func() { + operation := &types.Operation{ + Base: types.Base{ + ID: defaultOperationID, + UpdatedAt: time.Now().Add(-cleanupInterval + time.Second), + Labels: make(map[string][]string), + Ready: true, + }, + Reschedule: false, + Type: types.CREATE, + State: types.IN_PROGRESS, + ResourceID: "test-resource-id", + ResourceType: web.ServiceBrokersURL, + PlatformID: "cloudfoundry", + CorrelationID: "test-correlation-id", + } + object, err := ctx.SMRepository.Create(context.Background(), operation) + Expect(err).To(BeNil()) + Expect(object).To(Not(BeNil())) + }) + + It("should cleanup external old ones", func() { + byPlatformID := query.ByField(query.EqualsOperator, "platform_id", types.SMPlatform) + assertOperationCount(1, byPlatformID) + Eventually(func() int { + count, err := ctx.SMRepository.Count(context.Background(), types.OperationType, byPlatformID) + Expect(err).To(BeNil()) + + return count + }, operationExpiration*2).Should(Equal(0)) + }) + }) }) When("Specified job timeout passes", func() { @@ -298,10 +335,12 @@ var _ = Describe("Operations", func() { Labels: make(map[string][]string), Ready: true, }, + Reschedule: false, Type: types.CREATE, State: types.IN_PROGRESS, ResourceID: "test-resource-id", ResourceType: web.ServiceBrokersURL, + PlatformID: types.SMPlatform, CorrelationID: "test-correlation-id", } @@ -316,7 +355,7 @@ var _ = Describe("Operations", func() { op := object.(*types.Operation) return op.State - }, jobTimeout*5).Should(Equal(types.FAILED)) + }, actionTimeout*5).Should(Equal(types.FAILED)) }) }) }) diff --git a/test/platform_test/platform_test.go b/test/platform_test/platform_test.go index 4c359d3de..480fba21b 100644 --- a/test/platform_test/platform_test.go +++ b/test/platform_test/platform_test.go @@ -60,7 +60,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ ResourceBlueprint: blueprint(true), ResourceWithoutNullableFieldsBlueprint: blueprint(false), PatchResource: test.APIResourcePatch, - AdditionalTests: func(ctx *common.TestContext) { + AdditionalTests: func(ctx *common.TestContext, t *test.TestCase) { Context("non-generic tests", func() { BeforeEach(func() { common.RemoveAllPlatforms(ctx.SMRepository) diff --git a/test/service_binding_test/service_binding_test.go b/test/service_binding_test/service_binding_test.go index 5edb2aded..bd9f76345 100644 --- a/test/service_binding_test/service_binding_test.go +++ b/test/service_binding_test/service_binding_test.go @@ -17,9 +17,14 @@ package service_binding_test import ( + "context" "fmt" + "github.com/Peripli/service-manager/operations" + "github.com/Peripli/service-manager/pkg/env" + "github.com/Peripli/service-manager/pkg/query" "net/http" "strconv" + "sync/atomic" "time" "github.com/gofrs/uuid" @@ -78,7 +83,7 @@ var _ = DescribeTestsFor(TestCase{ ResourceWithoutNullableFieldsBlueprint: blueprint, ResourcePropertiesToIgnore: []string{"volume_mounts", "endpoints", "bind_resource", "credentials"}, PatchResource: StorageResourcePatch, - AdditionalTests: func(ctx *TestContext) { + AdditionalTests: func(ctx *TestContext, t *TestCase) { Context("additional non-generic tests", func() { var ( postBindingRequest Object @@ -96,6 +101,7 @@ var _ = DescribeTestsFor(TestCase{ expectedCreateSuccessStatusCode int expectedDeleteSuccessStatusCode int expectedBrokerFailureStatusCode int + expectedSMCrashStatusCode int } testCases := []testCase{ @@ -104,12 +110,14 @@ var _ = DescribeTestsFor(TestCase{ expectedCreateSuccessStatusCode: http.StatusCreated, expectedDeleteSuccessStatusCode: http.StatusOK, expectedBrokerFailureStatusCode: http.StatusBadGateway, + expectedSMCrashStatusCode: http.StatusBadGateway, }, { async: true, expectedCreateSuccessStatusCode: http.StatusAccepted, expectedDeleteSuccessStatusCode: http.StatusAccepted, expectedBrokerFailureStatusCode: http.StatusAccepted, + expectedSMCrashStatusCode: http.StatusAccepted, }, } @@ -594,6 +602,71 @@ var _ = DescribeTestsFor(TestCase{ }) }) + XWhen("SM crashes after storing operation before storing resource", func() { + var newCtx *TestContext + + postHookWithShutdownTimeout := func() func(e env.Environment, servers map[string]FakeServer) { + return func(e env.Environment, servers map[string]FakeServer) { + e.Set("server.shutdown_timeout", 1*time.Second) + e.Set("httpclient.response_header_timeout", 1*time.Second) + } + } + + BeforeEach(func() { + ctxMaintainerBuilder := t.ContextBuilder.WithEnvPostExtensions(postHookWithShutdownTimeout()) + newCtx = ctxMaintainerBuilder.BuildWithoutCleanup() + + brokerServer.BindingHandlerFunc(http.MethodPut, http.MethodPut+"3", func(_ *http.Request) (int, map[string]interface{}) { + defer newCtx.CleanupAll(false) + return http.StatusOK, Object{"state": types.IN_PROGRESS} + }) + + brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.BindingLastOpHandlerFunc(http.MethodDelete+"3", func(_ *http.Request) (int, map[string]interface{}) { + return http.StatusOK, Object{"state": types.SUCCEEDED} + }) + }) + + It("Should mark operation as failed and trigger orphan mitigation", func() { + opChan := make(chan *types.Operation) + defer close(opChan) + + opCriteria := []query.Criterion{ + query.ByField(query.EqualsOperator, "type", string(types.CREATE)), + query.ByField(query.EqualsOperator, "state", string(types.IN_PROGRESS)), + query.ByField(query.EqualsOperator, "resource_type", string(types.ServiceBindingType)), + query.ByField(query.EqualsOperator, "reschedule", "false"), + query.ByField(query.EqualsOperator, "deletion_scheduled", operations.ZeroTime), + } + + go func() { + for { + object, err := ctx.SMRepository.Get(context.TODO(), types.OperationType, opCriteria...) + if err == nil { + opChan <- object.(*types.Operation) + break + } + } + }() + + createBinding(newCtx.SMWithOAuthForTenant, testCase.async, testCase.expectedSMCrashStatusCode) + operation := <-opChan + + verifyBindingDoesNotExist(ctx.SMWithOAuthForTenant, operation.ResourceID) + + operationExpectation := OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceBindingType, + Reschedulable: false, + DeletionScheduled: false, + } + + bindingID, _ = VerifyOperationExists(ctx, fmt.Sprintf("%s/%s%s/%s", web.ServiceBindingsURL, operation.ResourceID, web.OperationsURL, operation.ID), operationExpectation) + verifyBindingDoesNotExist(ctx.SMWithOAuthForTenant, bindingID) + }) + }) + When("broker responds with synchronous success", func() { BeforeEach(func() { brokerServer.BindingHandlerFunc(http.MethodPut, http.MethodPut+"1", ParameterizedHandler(http.StatusCreated, syncBindingResponse)) @@ -635,12 +708,12 @@ var _ = DescribeTestsFor(TestCase{ }) if testCase.async { - When("job timeout is reached while polling", func() { + When("action timeout is reached while polling", func() { var oldCtx *TestContext BeforeEach(func() { oldCtx = ctx ctx = NewTestContextBuilderWithSecurity().WithEnvPreExtensions(func(set *pflag.FlagSet) { - Expect(set.Set("operations.job_timeout", (2 * time.Second).String())).ToNot(HaveOccurred()) + Expect(set.Set("operations.action_timeout", (2 * time.Second).String())).ToNot(HaveOccurred()) }).BuildWithoutCleanup() brokerServer.BindingHandlerFunc(http.MethodPut, http.MethodPut+"1", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) @@ -665,6 +738,57 @@ var _ = DescribeTestsFor(TestCase{ verifyBindingExists(ctx.SMWithOAuthForTenant, bindingID, false) }) }) + + XWhen("SM crashes while polling", func() { + var newCtx *TestContext + var isBound atomic.Value + + postHookWithShutdownTimeout := func() func(e env.Environment, servers map[string]FakeServer) { + return func(e env.Environment, servers map[string]FakeServer) { + e.Set("server.shutdown_timeout", 1*time.Second) + } + } + + BeforeEach(func() { + ctxMaintainerBuilder := t.ContextBuilder.WithEnvPostExtensions(postHookWithShutdownTimeout()) + newCtx = ctxMaintainerBuilder.BuildWithoutCleanup() + + brokerServer.BindingHandlerFunc(http.MethodPut, http.MethodPut+"1", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.BindingLastOpHandlerFunc(http.MethodPut+"1", func(_ *http.Request) (int, map[string]interface{}) { + if isBound.Load() != nil { + return http.StatusOK, Object{"state": types.SUCCEEDED} + } else { + return http.StatusOK, Object{"state": types.IN_PROGRESS} + } + }) + + }) + + It("should start restart polling through maintainer and eventually binding is set to ready", func() { + resp := createBinding(newCtx.SMWithOAuthForTenant, true, http.StatusAccepted) + + operationExpectations := OperationExpectations{ + Category: types.CREATE, + State: types.IN_PROGRESS, + ResourceType: types.ServiceBindingType, + Reschedulable: true, + DeletionScheduled: false, + } + + bindingID, _ = VerifyOperationExists(newCtx, resp.Header("Location").Raw(), operationExpectations) + verifyBindingExists(ctx.SMWithOAuthForTenant, bindingID, false) + + newCtx.CleanupAll(false) + + isBound.Store(true) + + operationExpectations.State = types.SUCCEEDED + operationExpectations.Reschedulable = false + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), operationExpectations) + verifyBindingExists(ctx.SMWithOAuthForTenant, bindingID, true) + }) + }) } When("polling responds with unexpected state and eventually with success state", func() { @@ -881,6 +1005,55 @@ var _ = DescribeTestsFor(TestCase{ }) } + XWhen("SM crashes while orphan mitigating", func() { + var newCtx *TestContext + var isUnbound atomic.Value + + postHookWithShutdownTimeout := func() func(e env.Environment, servers map[string]FakeServer) { + return func(e env.Environment, servers map[string]FakeServer) { + e.Set("server.shutdown_timeout", 1*time.Second) + } + } + + BeforeEach(func() { + ctxMaintainerBuilder := t.ContextBuilder.WithEnvPostExtensions(postHookWithShutdownTimeout()) + newCtx = ctxMaintainerBuilder.BuildWithoutCleanup() + + brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.BindingLastOpHandlerFunc(http.MethodDelete+"3", func(_ *http.Request) (int, map[string]interface{}) { + if isUnbound.Load() != nil { + return http.StatusOK, Object{"state": types.SUCCEEDED} + } else { + return http.StatusOK, Object{"state": types.IN_PROGRESS} + } + }) + }) + + It("should restart orphan mitigation through maintainer and eventually succeeds", func() { + resp := createBinding(newCtx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + operationExpectations := OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceBindingType, + Reschedulable: true, + DeletionScheduled: true, + } + + bindingID, _ = VerifyOperationExists(newCtx, resp.Header("Location").Raw(), operationExpectations) + + newCtx.CleanupAll(false) + isUnbound.Store(true) + + operationExpectations.DeletionScheduled = false + operationExpectations.Reschedulable = false + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), operationExpectations) + verifyBindingDoesNotExist(ctx.SMWithOAuthForTenant, bindingID) + }) + + }) + When("broker orphan mitigation unbind asynchronously fails with an error that will continue further orphan mitigation and eventually succeed", func() { BeforeEach(func() { brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) @@ -1153,6 +1326,58 @@ var _ = DescribeTestsFor(TestCase{ verifyBindingDoesNotExist(ctx.SMWithOAuthForTenant, bindingID) }) + if testCase.async { + XWhen("SM crashes while polling", func() { + var newCtx *TestContext + var isBound atomic.Value + + postHookWithShutdownTimeout := func() func(e env.Environment, servers map[string]FakeServer) { + return func(e env.Environment, servers map[string]FakeServer) { + e.Set("server.shutdown_timeout", 1*time.Second) + } + } + + BeforeEach(func() { + ctxMaintainerBuilder := t.ContextBuilder.WithEnvPostExtensions(postHookWithShutdownTimeout()) + newCtx = ctxMaintainerBuilder.BuildWithoutCleanup() + + brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"1", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.BindingLastOpHandlerFunc(http.MethodDelete+"1", func(_ *http.Request) (int, map[string]interface{}) { + if isBound.Load() != nil { + return http.StatusOK, Object{"state": types.SUCCEEDED} + } else { + return http.StatusOK, Object{"state": types.IN_PROGRESS} + } + }) + + }) + + It("should start restart polling through maintainer and eventually binding is set to ready", func() { + resp := deleteBinding(newCtx.SMWithOAuthForTenant, true, http.StatusAccepted) + + operationExpectations := OperationExpectations{ + Category: types.DELETE, + State: types.IN_PROGRESS, + ResourceType: types.ServiceBindingType, + Reschedulable: true, + DeletionScheduled: false, + } + + bindingID, _ = VerifyOperationExists(newCtx, resp.Header("Location").Raw(), operationExpectations) + verifyBindingExists(ctx.SMWithOAuthForTenant, bindingID, true) + + newCtx.CleanupAll(false) + isBound.Store(true) + + operationExpectations.State = types.SUCCEEDED + operationExpectations.Reschedulable = false + + bindingID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), operationExpectations) + verifyBindingDoesNotExist(ctx.SMWithOAuthForTenant, bindingID) + }) + }) + } + When("polling responds 410 GONE", func() { BeforeEach(func() { brokerServer.BindingHandlerFunc(http.MethodDelete, http.MethodDelete+"1", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) diff --git a/test/service_instance_test/service_instance_test.go b/test/service_instance_test/service_instance_test.go index b2f5be080..2f8f463e1 100644 --- a/test/service_instance_test/service_instance_test.go +++ b/test/service_instance_test/service_instance_test.go @@ -17,7 +17,12 @@ package service_test import ( + "context" "fmt" + "github.com/Peripli/service-manager/operations" + "github.com/Peripli/service-manager/pkg/env" + "github.com/Peripli/service-manager/pkg/query" + "sync/atomic" "time" "github.com/tidwall/gjson" @@ -80,7 +85,7 @@ var _ = DescribeTestsFor(TestCase{ ResourceWithoutNullableFieldsBlueprint: blueprint, ResourcePropertiesToIgnore: []string{"platform_id"}, PatchResource: APIResourcePatch, - AdditionalTests: func(ctx *TestContext) { + AdditionalTests: func(ctx *TestContext, t *TestCase) { Context("additional non-generic tests", func() { var ( postInstanceRequest Object @@ -97,6 +102,7 @@ var _ = DescribeTestsFor(TestCase{ expectedCreateSuccessStatusCode int expectedDeleteSuccessStatusCode int expectedBrokerFailureStatusCode int + expectedSMCrashStatusCode int } testCases := []testCase{ @@ -105,12 +111,14 @@ var _ = DescribeTestsFor(TestCase{ expectedCreateSuccessStatusCode: http.StatusCreated, expectedDeleteSuccessStatusCode: http.StatusOK, expectedBrokerFailureStatusCode: http.StatusBadGateway, + expectedSMCrashStatusCode: http.StatusBadGateway, }, { async: true, expectedCreateSuccessStatusCode: http.StatusAccepted, expectedDeleteSuccessStatusCode: http.StatusAccepted, expectedBrokerFailureStatusCode: http.StatusAccepted, + expectedSMCrashStatusCode: http.StatusAccepted, }, } @@ -144,7 +152,7 @@ var _ = DescribeTestsFor(TestCase{ Status(expectedStatusCode) } - verifyInstanceExists := func(instanceID string, ready bool) { + verifyInstanceExists := func(ctx *TestContext, instanceID string, ready bool) { timeoutDuration := 15 * time.Second tickerInterval := 100 * time.Millisecond ticker := time.NewTicker(tickerInterval) @@ -325,7 +333,7 @@ var _ = DescribeTestsFor(TestCase{ DeletionScheduled: false, }) - verifyInstanceExists(instanceID, true) + verifyInstanceExists(ctx, instanceID, true) }) } @@ -482,7 +490,7 @@ var _ = DescribeTestsFor(TestCase{ DeletionScheduled: false, }) - verifyInstanceExists(instanceID, false) + verifyInstanceExists(ctx, instanceID, false) }) AfterEach(func() { @@ -520,6 +528,71 @@ var _ = DescribeTestsFor(TestCase{ }) }) + XWhen("SM crashes after storing operation before storing resource", func() { + var newCtx *TestContext + + postHookWithShutdownTimeout := func() func(e env.Environment, servers map[string]FakeServer) { + return func(e env.Environment, servers map[string]FakeServer) { + e.Set("server.shutdown_timeout", 1*time.Second) + e.Set("httpclient.response_header_timeout", 1*time.Second) + } + } + + BeforeEach(func() { + ctxMaintainerBuilder := t.ContextBuilder.WithEnvPostExtensions(postHookWithShutdownTimeout()) + newCtx = ctxMaintainerBuilder.BuildWithoutCleanup() + + brokerServer.ServiceInstanceHandlerFunc(http.MethodPut, http.MethodPut+"3", func(_ *http.Request) (int, map[string]interface{}) { + defer newCtx.CleanupAll(false) + return http.StatusOK, Object{"state": "in progress"} + }) + + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.ServiceInstanceLastOpHandlerFunc(http.MethodDelete+"3", func(_ *http.Request) (int, map[string]interface{}) { + return http.StatusOK, Object{"state": "succeeded"} + }) + }) + + It("Should mark operation as failed and trigger orphan mitigation", func() { + opChan := make(chan *types.Operation) + defer close(opChan) + + opCriteria := []query.Criterion{ + query.ByField(query.EqualsOperator, "type", string(types.CREATE)), + query.ByField(query.EqualsOperator, "state", string(types.IN_PROGRESS)), + query.ByField(query.EqualsOperator, "resource_type", string(types.ServiceInstanceType)), + query.ByField(query.EqualsOperator, "reschedule", "false"), + query.ByField(query.EqualsOperator, "deletion_scheduled", operations.ZeroTime), + } + + go func() { + for { + object, err := ctx.SMRepository.Get(context.TODO(), types.OperationType, opCriteria...) + if err == nil { + opChan <- object.(*types.Operation) + break + } + } + }() + + createInstanceWithAsync(newCtx.SMWithOAuthForTenant, testCase.async, testCase.expectedSMCrashStatusCode) + operation := <-opChan + + verifyInstanceDoesNotExist(operation.ResourceID) + + operationExpectation := OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: false, + DeletionScheduled: false, + } + + instanceID, _ = VerifyOperationExists(ctx, fmt.Sprintf("%s/%s%s/%s", web.ServiceInstancesURL, operation.ResourceID, web.OperationsURL, operation.ID), operationExpectation) + verifyInstanceDoesNotExist(instanceID) + }) + }) + When("broker responds with synchronous success", func() { BeforeEach(func() { brokerServer.ServiceInstanceHandlerFunc(http.MethodPut, http.MethodPut+"1", ParameterizedHandler(http.StatusCreated, Object{"async": false})) @@ -536,7 +609,7 @@ var _ = DescribeTestsFor(TestCase{ DeletionScheduled: false, }) - verifyInstanceExists(instanceID, true) + verifyInstanceExists(ctx, instanceID, true) }) }) @@ -557,17 +630,17 @@ var _ = DescribeTestsFor(TestCase{ DeletionScheduled: false, }) - verifyInstanceExists(instanceID, true) + verifyInstanceExists(ctx, instanceID, true) }) if testCase.async { - When("job timeout is reached while polling", func() { + When("action timeout is reached while polling", func() { var oldCtx *TestContext BeforeEach(func() { oldCtx = ctx ctx = NewTestContextBuilderWithSecurity().WithEnvPreExtensions(func(set *pflag.FlagSet) { - Expect(set.Set("operations.job_timeout", (2 * time.Second).String())).ToNot(HaveOccurred()) + Expect(set.Set("operations.action_timeout", (2 * time.Second).String())).ToNot(HaveOccurred()) }).BuildWithoutCleanup() brokerServer.ServiceInstanceHandlerFunc(http.MethodPut, http.MethodPut+"1", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) @@ -589,7 +662,56 @@ var _ = DescribeTestsFor(TestCase{ DeletionScheduled: false, }) - verifyInstanceExists(instanceID, false) + verifyInstanceExists(ctx, instanceID, false) + }) + }) + + XWhen("SM crashes while polling", func() { + var newCtx *TestContext + var isProvisioned atomic.Value + + postHookWithShutdownTimeout := func() func(e env.Environment, servers map[string]FakeServer) { + return func(e env.Environment, servers map[string]FakeServer) { + e.Set("server.shutdown_timeout", 1*time.Second) + } + } + + BeforeEach(func() { + ctxMaintainerBuilder := t.ContextBuilder.WithEnvPostExtensions(postHookWithShutdownTimeout()) + newCtx = ctxMaintainerBuilder.BuildWithoutCleanup() + + brokerServer.ServiceInstanceLastOpHandlerFunc(http.MethodPut+"1", func(_ *http.Request) (int, map[string]interface{}) { + if isProvisioned.Load() != nil { + return http.StatusOK, Object{"state": types.SUCCEEDED} + } else { + return http.StatusOK, Object{"state": types.IN_PROGRESS} + } + }) + }) + + It("should start restart polling through maintainer and eventually instance is set to ready", func() { + resp := createInstanceWithAsync(newCtx.SMWithOAuthForTenant, testCase.async, testCase.expectedCreateSuccessStatusCode) + + operationExpectation := OperationExpectations{ + Category: types.CREATE, + State: types.IN_PROGRESS, + ResourceType: types.ServiceInstanceType, + Reschedulable: true, + DeletionScheduled: false, + } + + instanceID, _ = VerifyOperationExists(newCtx, resp.Header("Location").Raw(), operationExpectation) + verifyInstanceExists(newCtx, instanceID, false) + + newCtx.CleanupAll(false) + + isProvisioned.Store(true) + + operationExpectation.State = types.SUCCEEDED + operationExpectation.Reschedulable = false + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), operationExpectation) + verifyInstanceExists(ctx, instanceID, true) }) }) } @@ -610,7 +732,7 @@ var _ = DescribeTestsFor(TestCase{ Reschedulable: false, DeletionScheduled: false, }) - verifyInstanceExists(instanceID, true) + verifyInstanceExists(ctx, instanceID, true) }) }) @@ -660,7 +782,7 @@ var _ = DescribeTestsFor(TestCase{ DeletionScheduled: true, }) - verifyInstanceExists(instanceID, false) + verifyInstanceExists(ctx, instanceID, false) }) }) @@ -705,7 +827,7 @@ var _ = DescribeTestsFor(TestCase{ DeletionScheduled: false, }) - verifyInstanceExists(instanceID, false) + verifyInstanceExists(ctx, instanceID, false) }) }) }) @@ -785,7 +907,7 @@ var _ = DescribeTestsFor(TestCase{ BeforeEach(func() { oldCtx = ctx ctx = NewTestContextBuilderWithSecurity().WithEnvPreExtensions(func(set *pflag.FlagSet) { - Expect(set.Set("operations.scheduled_deletion_timeout", (2 * time.Millisecond).String())).ToNot(HaveOccurred()) + Expect(set.Set("operations.reconciliation_operation_timeout", (2 * time.Millisecond).String())).ToNot(HaveOccurred()) }).BuildWithoutCleanup() }) @@ -804,7 +926,7 @@ var _ = DescribeTestsFor(TestCase{ DeletionScheduled: true, }) - verifyInstanceExists(instanceID, false) + verifyInstanceExists(ctx, instanceID, false) }) }) }) @@ -827,11 +949,61 @@ var _ = DescribeTestsFor(TestCase{ DeletionScheduled: true, }) - verifyInstanceExists(instanceID, false) + verifyInstanceExists(ctx, instanceID, false) }) }) } + XWhen("SM crashes while orphan mitigating", func() { + var newCtx *TestContext + var isDeprovisioned atomic.Value + + postHookWithShutdownTimeout := func() func(e env.Environment, servers map[string]FakeServer) { + return func(e env.Environment, servers map[string]FakeServer) { + e.Set("server.shutdown_timeout", 1*time.Second) + } + } + + BeforeEach(func() { + ctxMaintainerBuilder := t.ContextBuilder.WithEnvPostExtensions(postHookWithShutdownTimeout()) + newCtx = ctxMaintainerBuilder.BuildWithoutCleanup() + + brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) + brokerServer.ServiceInstanceLastOpHandlerFunc(http.MethodDelete+"3", func(_ *http.Request) (int, map[string]interface{}) { + if isDeprovisioned.Load() != nil { + return http.StatusOK, Object{"state": "succeeded"} + } else { + return http.StatusOK, Object{"state": "in progress"} + } + }) + }) + + It("should restart orphan mitigation through maintainer and eventually succeeds", func() { + resp := createInstanceWithAsync(newCtx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + + operationExpectations := OperationExpectations{ + Category: types.CREATE, + State: types.FAILED, + ResourceType: types.ServiceInstanceType, + Reschedulable: true, + DeletionScheduled: true, + } + + instanceID, _ = VerifyOperationExists(newCtx, resp.Header("Location").Raw(), operationExpectations) + + newCtx.CleanupAll(false) + + isDeprovisioned.Store(true) + + operationExpectations.DeletionScheduled = false + operationExpectations.Reschedulable = false + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), operationExpectations) + + verifyInstanceDoesNotExist(instanceID) + }) + + }) + When("broker orphan mitigation deprovision asynchronously fails with an error that will continue further orphan mitigation and eventually succeed", func() { BeforeEach(func() { brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"3", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) @@ -858,7 +1030,7 @@ var _ = DescribeTestsFor(TestCase{ }) }) - When("provision responds with error due to times out", func() { + When("provision responds with error due to time out", func() { var doneChannel chan interface{} var oldCtx *TestContext @@ -1213,7 +1385,7 @@ var _ = DescribeTestsFor(TestCase{ DeletionScheduled: false, }) - verifyInstanceExists(instanceID, true) + verifyInstanceExists(ctx, instanceID, true) }) When("a delete operation is already in progress", func() { @@ -1234,7 +1406,7 @@ var _ = DescribeTestsFor(TestCase{ DeletionScheduled: false, }) - verifyInstanceExists(instanceID, true) + verifyInstanceExists(ctx, instanceID, true) }) AfterEach(func() { @@ -1284,7 +1456,7 @@ var _ = DescribeTestsFor(TestCase{ DeletionScheduled: false, }) - verifyInstanceExists(instanceID, true) + verifyInstanceExists(ctx, instanceID, true) }) }) @@ -1348,13 +1520,65 @@ var _ = DescribeTestsFor(TestCase{ verifyInstanceDoesNotExist(instanceID) }) + if testCase.async { + XWhen("SM crashes while polling", func() { + var newCtx *TestContext + var isDeprovisioned atomic.Value + + postHookWithShutdownTimeout := func() func(e env.Environment, servers map[string]FakeServer) { + return func(e env.Environment, servers map[string]FakeServer) { + e.Set("server.shutdown_timeout", 1*time.Second) + } + } + + BeforeEach(func() { + ctxMaintainerBuilder := t.ContextBuilder.WithEnvPostExtensions(postHookWithShutdownTimeout()) + newCtx = ctxMaintainerBuilder.BuildWithoutCleanup() + + brokerServer.ServiceInstanceLastOpHandlerFunc(http.MethodDelete+"1", func(_ *http.Request) (int, map[string]interface{}) { + if isDeprovisioned.Load() != nil { + return http.StatusOK, Object{"state": "succeeded"} + } else { + return http.StatusOK, Object{"state": "in progress"} + } + }) + }) + + It("should restart polling through maintainer and eventually deletes the instance", func() { + resp := deleteInstance(newCtx.SMWithOAuthForTenant, true, http.StatusAccepted) + + operationExpectations := OperationExpectations{ + Category: types.DELETE, + State: types.IN_PROGRESS, + ResourceType: types.ServiceInstanceType, + Reschedulable: true, + DeletionScheduled: false, + } + + instanceID, _ = VerifyOperationExists(newCtx, resp.Header("Location").Raw(), operationExpectations) + verifyInstanceExists(newCtx, instanceID, true) + + newCtx.CleanupAll(false) + + isDeprovisioned.Store(true) + + operationExpectations.State = types.SUCCEEDED + operationExpectations.Reschedulable = false + + instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), operationExpectations) + verifyInstanceDoesNotExist(instanceID) + + }) + }) + } + When("polling responds 410 GONE", func() { BeforeEach(func() { brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"1", ParameterizedHandler(http.StatusAccepted, Object{"async": true})) brokerServer.ServiceInstanceLastOpHandlerFunc(http.MethodDelete+"1", ParameterizedHandler(http.StatusGone, Object{})) }) - It("keeps polling and eventually deletes the binding and marks the operation as success", func() { + It("keeps polling and eventually deletes the instance and marks the operation as success", func() { resp := deleteInstance(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedDeleteSuccessStatusCode) instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ @@ -1444,7 +1668,7 @@ var _ = DescribeTestsFor(TestCase{ DeletionScheduled: true, }) - verifyInstanceExists(instanceID, true) + verifyInstanceExists(ctx, instanceID, true) }) }) @@ -1482,7 +1706,7 @@ var _ = DescribeTestsFor(TestCase{ BeforeEach(func() { oldCtx = ctx ctx = NewTestContextBuilderWithSecurity().WithEnvPreExtensions(func(set *pflag.FlagSet) { - Expect(set.Set("operations.scheduled_deletion_timeout", (2 * time.Millisecond).String())).ToNot(HaveOccurred()) + Expect(set.Set("operations.reconciliation_operation_timeout", (2 * time.Millisecond).String())).ToNot(HaveOccurred()) }).BuildWithoutCleanup() }) @@ -1501,7 +1725,7 @@ var _ = DescribeTestsFor(TestCase{ DeletionScheduled: true, }) - verifyInstanceExists(instanceID, true) + verifyInstanceExists(ctx, instanceID, true) }) }) }) @@ -1523,7 +1747,7 @@ var _ = DescribeTestsFor(TestCase{ DeletionScheduled: false, }) - verifyInstanceExists(instanceID, true) + verifyInstanceExists(ctx, instanceID, true) }) }) }) @@ -1545,7 +1769,7 @@ var _ = DescribeTestsFor(TestCase{ DeletionScheduled: false, }) - verifyInstanceExists(instanceID, true) + verifyInstanceExists(ctx, instanceID, true) }) }) @@ -1564,7 +1788,7 @@ var _ = DescribeTestsFor(TestCase{ DeletionScheduled: false, }) - verifyInstanceExists(instanceID, true) + verifyInstanceExists(ctx, instanceID, true) }) }) @@ -1624,7 +1848,7 @@ var _ = DescribeTestsFor(TestCase{ DeletionScheduled: true, }) - verifyInstanceExists(instanceID, true) + verifyInstanceExists(ctx, instanceID, true) }) }) } @@ -1661,25 +1885,29 @@ var _ = DescribeTestsFor(TestCase{ }) When("deprovision responds with error due to times out", func() { + var newCtx *TestContext var doneChannel chan interface{} BeforeEach(func() { doneChannel = make(chan interface{}) - ctx = NewTestContextBuilderWithSecurity().WithEnvPreExtensions(func(set *pflag.FlagSet) { + newCtx = t.ContextBuilder.WithEnvPreExtensions(func(set *pflag.FlagSet) { Expect(set.Set("httpclient.response_header_timeout", (1 * time.Second).String())).ToNot(HaveOccurred()) }).BuildWithoutCleanup() brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"1", DelayingHandler(doneChannel)) + }) + AfterEach(func() { + newCtx.CleanupAll(false) }) It("orphan mitigates the instance", func() { - resp := deleteInstance(ctx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) + resp := deleteInstance(newCtx.SMWithOAuthForTenant, testCase.async, testCase.expectedBrokerFailureStatusCode) <-time.After(1100 * time.Millisecond) close(doneChannel) - instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + instanceID, _ = VerifyOperationExists(newCtx, resp.Header("Location").Raw(), OperationExpectations{ Category: types.DELETE, State: types.FAILED, ResourceType: types.ServiceInstanceType, @@ -1689,7 +1917,7 @@ var _ = DescribeTestsFor(TestCase{ brokerServer.ServiceInstanceHandlerFunc(http.MethodDelete, http.MethodDelete+"1", ParameterizedHandler(http.StatusOK, Object{"async": false})) - instanceID, _ = VerifyOperationExists(ctx, resp.Header("Location").Raw(), OperationExpectations{ + instanceID, _ = VerifyOperationExists(newCtx, resp.Header("Location").Raw(), OperationExpectations{ Category: types.DELETE, State: types.SUCCEEDED, ResourceType: types.ServiceInstanceType, diff --git a/test/service_offering_test/service_offering_test.go b/test/service_offering_test/service_offering_test.go index 9bfb86fa6..dfcefba7b 100644 --- a/test/service_offering_test/service_offering_test.go +++ b/test/service_offering_test/service_offering_test.go @@ -52,7 +52,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ ResourceBlueprint: blueprint, ResourceWithoutNullableFieldsBlueprint: blueprint, PatchResource: test.APIResourcePatch, - AdditionalTests: func(ctx *common.TestContext) { + AdditionalTests: func(ctx *common.TestContext, t *test.TestCase) { Context("additional non-generic tests", func() { Describe("PATCH", func() { var id string diff --git a/test/service_plan_test/service_plan_test.go b/test/service_plan_test/service_plan_test.go index 029384dd4..39aa2156f 100644 --- a/test/service_plan_test/service_plan_test.go +++ b/test/service_plan_test/service_plan_test.go @@ -48,7 +48,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ ResourceBlueprint: blueprint, ResourceWithoutNullableFieldsBlueprint: blueprint, PatchResource: test.APIResourcePatch, - AdditionalTests: func(ctx *common.TestContext) { + AdditionalTests: func(ctx *common.TestContext, t *test.TestCase) { Context("additional non-generic tests", func() { Describe("PATCH", func() { var id string diff --git a/test/test.go b/test/test.go index 0cca3774d..4726c0ecc 100644 --- a/test/test.go +++ b/test/test.go @@ -62,8 +62,21 @@ const ( Sync ResponseMode = false Async ResponseMode = true + + JobTimeout = 15 * time.Second + cleanupInterval = 60 * time.Second + operationExpiration = 60 * time.Second ) +func postHookWithOperationsConfig() func(e env.Environment, servers map[string]common.FakeServer) { + return func(e env.Environment, servers map[string]common.FakeServer) { + e.Set("operations.action_timeout", JobTimeout) + e.Set("operations.cleanup_interval", cleanupInterval) + e.Set("operations.lifespan", operationExpiration) + e.Set("operations.reconciliation_operation_timeout", 9999*time.Hour) + } +} + type MultitenancySettings struct { ClientID string ClientIDTokenClaim string @@ -88,7 +101,8 @@ type TestCase struct { ResourceWithoutNullableFieldsBlueprint func(ctx *common.TestContext, smClient *common.SMExpect, async bool) common.Object PatchResource func(ctx *common.TestContext, tenantScoped bool, apiPath string, objID string, resourceType types.ObjectType, patchLabels []*query.LabelChange, async bool) - AdditionalTests func(ctx *common.TestContext) + AdditionalTests func(ctx *common.TestContext, t *TestCase) + ContextBuilder *common.TestContextBuilder } func stripObject(obj common.Object, properties ...string) { @@ -237,11 +251,8 @@ func DescribeTestsFor(t TestCase) bool { ctx.Cleanup() }) - func() { - By("==== Preparation for SM tests... ====") - - defer GinkgoRecover() - ctxBuilder := common.NewTestContextBuilderWithSecurity() + ctxBuilder := func() *common.TestContextBuilder { + ctxBuilder := common.NewTestContextBuilderWithSecurity().WithEnvPostExtensions(postHookWithOperationsConfig()) if t.MultitenancySettings != nil { ctxBuilder. @@ -268,7 +279,16 @@ func DescribeTestsFor(t TestCase) bool { return nil }) } - ctx = ctxBuilder.Build() + return ctxBuilder + } + + t.ContextBuilder = ctxBuilder() + + func() { + By("==== Preparation for SM tests... ====") + + defer GinkgoRecover() + ctx = ctxBuilder().Build() // A panic outside of Ginkgo's primitives (during test setup) would be recovered // by the deferred GinkgoRecover() and the error will be associated with the first @@ -307,7 +327,7 @@ func DescribeTestsFor(t TestCase) bool { } if t.AdditionalTests != nil { - t.AdditionalTests(ctx) + t.AdditionalTests(ctx, &t) } By("==== Successfully finished preparation for SM tests. Running API tests suite... ====") diff --git a/test/visibility_test/visibility_test.go b/test/visibility_test/visibility_test.go index 6ba91c5d5..2bd4d2c33 100644 --- a/test/visibility_test/visibility_test.go +++ b/test/visibility_test/visibility_test.go @@ -48,7 +48,7 @@ var _ = test.DescribeTestsFor(test.TestCase{ ResourceBlueprint: blueprint(true), ResourceWithoutNullableFieldsBlueprint: blueprint(false), PatchResource: test.APIResourcePatch, - AdditionalTests: func(ctx *common.TestContext) { + AdditionalTests: func(ctx *common.TestContext, t *test.TestCase) { Context("non-generic tests", func() { var ( existingPlatformID string