From 29e9b1f8f37ba9120e6822a9fb72fe721a44ca73 Mon Sep 17 00:00:00 2001 From: Ashwin Krishna Date: Mon, 24 Jan 2022 17:09:54 -0800 Subject: [PATCH] Add POST /v3/service_instances endpoint Issue #430 Co-authored-by: Dave Walter --- .../fake/cfservice_instance_repository.go | 123 ++++++ api/apis/integration/route_test.go | 18 +- api/apis/service_instance_handler.go | 116 ++++++ api/apis/service_instance_handler_test.go | 386 ++++++++++++++++++ api/apis/shared.go | 51 ++- api/config/base/rbac/role.yaml | 6 + api/main.go | 11 +- api/payloads/service_instance.go | 28 ++ api/presenter/service_instance.go | 91 +++++ api/reference/cf-k8s-api.yaml | 6 + api/repositories/repositories_suite_test.go | 4 + .../service_instance_repository.go | 143 +++++++ .../service_instance_repository_test.go | 146 +++++++ api/repositories/shared.go | 7 +- .../v1alpha1/cfserviceinstance_types.go | 2 +- controllers/config/cf_roles/cf_admin.yaml | 8 +- .../config/cf_roles/cf_space_developer.yaml | 7 + ...s.cloudfoundry.org_cfserviceinstances.yaml | 1 - controllers/reference/cf-k8s-controllers.yaml | 15 +- 19 files changed, 1136 insertions(+), 33 deletions(-) create mode 100644 api/apis/fake/cfservice_instance_repository.go create mode 100644 api/apis/service_instance_handler.go create mode 100644 api/apis/service_instance_handler_test.go create mode 100644 api/payloads/service_instance.go create mode 100644 api/presenter/service_instance.go create mode 100644 api/repositories/service_instance_repository.go create mode 100644 api/repositories/service_instance_repository_test.go diff --git a/api/apis/fake/cfservice_instance_repository.go b/api/apis/fake/cfservice_instance_repository.go new file mode 100644 index 000000000..efa6ae980 --- /dev/null +++ b/api/apis/fake/cfservice_instance_repository.go @@ -0,0 +1,123 @@ +// Code generated by counterfeiter. DO NOT EDIT. +package fake + +import ( + "context" + "sync" + + "code.cloudfoundry.org/cf-k8s-controllers/api/apis" + "code.cloudfoundry.org/cf-k8s-controllers/api/authorization" + "code.cloudfoundry.org/cf-k8s-controllers/api/repositories" +) + +type CFServiceInstanceRepository struct { + CreateServiceInstanceStub func(context.Context, authorization.Info, repositories.CreateServiceInstanceMessage) (repositories.ServiceInstanceRecord, error) + createServiceInstanceMutex sync.RWMutex + createServiceInstanceArgsForCall []struct { + arg1 context.Context + arg2 authorization.Info + arg3 repositories.CreateServiceInstanceMessage + } + createServiceInstanceReturns struct { + result1 repositories.ServiceInstanceRecord + result2 error + } + createServiceInstanceReturnsOnCall map[int]struct { + result1 repositories.ServiceInstanceRecord + result2 error + } + invocations map[string][][]interface{} + invocationsMutex sync.RWMutex +} + +func (fake *CFServiceInstanceRepository) CreateServiceInstance(arg1 context.Context, arg2 authorization.Info, arg3 repositories.CreateServiceInstanceMessage) (repositories.ServiceInstanceRecord, error) { + fake.createServiceInstanceMutex.Lock() + ret, specificReturn := fake.createServiceInstanceReturnsOnCall[len(fake.createServiceInstanceArgsForCall)] + fake.createServiceInstanceArgsForCall = append(fake.createServiceInstanceArgsForCall, struct { + arg1 context.Context + arg2 authorization.Info + arg3 repositories.CreateServiceInstanceMessage + }{arg1, arg2, arg3}) + stub := fake.CreateServiceInstanceStub + fakeReturns := fake.createServiceInstanceReturns + fake.recordInvocation("CreateServiceInstance", []interface{}{arg1, arg2, arg3}) + fake.createServiceInstanceMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *CFServiceInstanceRepository) CreateServiceInstanceCallCount() int { + fake.createServiceInstanceMutex.RLock() + defer fake.createServiceInstanceMutex.RUnlock() + return len(fake.createServiceInstanceArgsForCall) +} + +func (fake *CFServiceInstanceRepository) CreateServiceInstanceCalls(stub func(context.Context, authorization.Info, repositories.CreateServiceInstanceMessage) (repositories.ServiceInstanceRecord, error)) { + fake.createServiceInstanceMutex.Lock() + defer fake.createServiceInstanceMutex.Unlock() + fake.CreateServiceInstanceStub = stub +} + +func (fake *CFServiceInstanceRepository) CreateServiceInstanceArgsForCall(i int) (context.Context, authorization.Info, repositories.CreateServiceInstanceMessage) { + fake.createServiceInstanceMutex.RLock() + defer fake.createServiceInstanceMutex.RUnlock() + argsForCall := fake.createServiceInstanceArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 +} + +func (fake *CFServiceInstanceRepository) CreateServiceInstanceReturns(result1 repositories.ServiceInstanceRecord, result2 error) { + fake.createServiceInstanceMutex.Lock() + defer fake.createServiceInstanceMutex.Unlock() + fake.CreateServiceInstanceStub = nil + fake.createServiceInstanceReturns = struct { + result1 repositories.ServiceInstanceRecord + result2 error + }{result1, result2} +} + +func (fake *CFServiceInstanceRepository) CreateServiceInstanceReturnsOnCall(i int, result1 repositories.ServiceInstanceRecord, result2 error) { + fake.createServiceInstanceMutex.Lock() + defer fake.createServiceInstanceMutex.Unlock() + fake.CreateServiceInstanceStub = nil + if fake.createServiceInstanceReturnsOnCall == nil { + fake.createServiceInstanceReturnsOnCall = make(map[int]struct { + result1 repositories.ServiceInstanceRecord + result2 error + }) + } + fake.createServiceInstanceReturnsOnCall[i] = struct { + result1 repositories.ServiceInstanceRecord + result2 error + }{result1, result2} +} + +func (fake *CFServiceInstanceRepository) Invocations() map[string][][]interface{} { + fake.invocationsMutex.RLock() + defer fake.invocationsMutex.RUnlock() + fake.createServiceInstanceMutex.RLock() + defer fake.createServiceInstanceMutex.RUnlock() + copiedInvocations := map[string][][]interface{}{} + for key, value := range fake.invocations { + copiedInvocations[key] = value + } + return copiedInvocations +} + +func (fake *CFServiceInstanceRepository) recordInvocation(key string, args []interface{}) { + fake.invocationsMutex.Lock() + defer fake.invocationsMutex.Unlock() + if fake.invocations == nil { + fake.invocations = map[string][][]interface{}{} + } + if fake.invocations[key] == nil { + fake.invocations[key] = [][]interface{}{} + } + fake.invocations[key] = append(fake.invocations[key], args) +} + +var _ apis.CFServiceInstanceRepository = new(CFServiceInstanceRepository) diff --git a/api/apis/integration/route_test.go b/api/apis/integration/route_test.go index 301b1c3fd..f1c3db72e 100644 --- a/api/apis/integration/route_test.go +++ b/api/apis/integration/route_test.go @@ -67,10 +67,10 @@ var _ = Describe("Route Handler", func() { BeforeEach(func() { createRoleBinding(ctx, userName, spaceDeveloperRole.Name, namespace.Name) - + routeGUID = generateGUID() domainGUID = generateGUID() - + cfDomain := &networkingv1alpha1.CFDomain{ ObjectMeta: metav1.ObjectMeta{ Name: domainGUID, @@ -82,7 +82,7 @@ var _ = Describe("Route Handler", func() { Expect( k8sClient.Create(ctx, cfDomain), ).To(Succeed()) - + cfRoute = &networkingv1alpha1.CFRoute{ ObjectMeta: metav1.ObjectMeta{ Name: routeGUID, @@ -111,27 +111,27 @@ var _ = Describe("Route Handler", func() { Expect( k8sClient.Create(ctx, cfRoute), ).To(Succeed()) - + Eventually(func() error { route := &networkingv1alpha1.CFRoute{} return k8sClient.Get(ctx, client.ObjectKey{Namespace: namespace.Name, Name: routeGUID}, route) }).ShouldNot(HaveOccurred()) - + var err error req, err = http.NewRequestWithContext(ctx, "DELETE", serverURI("/v3/routes/"+routeGUID), strings.NewReader("")) Expect(err).NotTo(HaveOccurred()) - + req.Header.Add("Content-type", "application/json") }) - + JustBeforeEach(func() { router.ServeHTTP(rr, req) }) - + It("returns 202 and eventually deletes the route", func() { testCtx := context.Background() Expect(rr.Code).To(Equal(202)) - + Eventually(func() error { route := &networkingv1alpha1.CFRoute{} return k8sClient.Get(testCtx, client.ObjectKey{Namespace: namespace.Name, Name: routeGUID}, route) diff --git a/api/apis/service_instance_handler.go b/api/apis/service_instance_handler.go new file mode 100644 index 000000000..53ab5ff9c --- /dev/null +++ b/api/apis/service_instance_handler.go @@ -0,0 +1,116 @@ +package apis + +import ( + "context" + "net/http" + "net/url" + + "code.cloudfoundry.org/cf-k8s-controllers/api/payloads" + + "code.cloudfoundry.org/cf-k8s-controllers/api/presenter" + + "code.cloudfoundry.org/cf-k8s-controllers/api/repositories" + + "code.cloudfoundry.org/cf-k8s-controllers/api/authorization" + + "github.com/gorilla/mux" + + "github.com/go-logr/logr" +) + +const ( + ServiceInstanceCreateEndpoint = "/v3/service_instances" +) + +//counterfeiter:generate -o fake -fake-name CFServiceInstanceRepository . CFServiceInstanceRepository +type CFServiceInstanceRepository interface { + CreateServiceInstance(context.Context, authorization.Info, repositories.CreateServiceInstanceMessage) (repositories.ServiceInstanceRecord, error) +} + +type ServiceInstanceHandler struct { + logger logr.Logger + serverURL url.URL + serviceInstanceRepo CFServiceInstanceRepository + appRepo CFAppRepository +} + +func NewServiceInstanceHandler( + logger logr.Logger, + serverURL url.URL, + serviceInstanceRepo CFServiceInstanceRepository, + appRepo CFAppRepository, +) *ServiceInstanceHandler { + return &ServiceInstanceHandler{ + logger: logger, + serverURL: serverURL, + serviceInstanceRepo: serviceInstanceRepo, + appRepo: appRepo, + } +} + +func (h *ServiceInstanceHandler) serviceInstanceCreateHandler(authInfo authorization.Info, w http.ResponseWriter, r *http.Request) { + ctx := context.Background() + w.Header().Set("Content-Type", "application/json") + + var payload payloads.ServiceInstanceCreate + rme := decodeAndValidateJSONPayload(r, &payload) + if rme != nil { + writeRequestMalformedErrorResponse(w, rme) + return + } + + namespaceGUID := payload.Relationships.Space.Data.GUID + _, err := h.appRepo.GetNamespace(ctx, authInfo, namespaceGUID) + if err != nil { + switch err.(type) { + case repositories.PermissionDeniedOrNotFoundError: + h.logger.Info("Namespace not found", "Namespace GUID", namespaceGUID) + writeUnprocessableEntityError(w, "Invalid space. Ensure that the space exists and you have access to it.") + return + default: + h.logger.Error(err, "Failed to fetch namespace from Kubernetes", "Namespace GUID", namespaceGUID) + writeUnknownErrorResponse(w) + return + } + } + + serviceInstanceRecord, err := h.serviceInstanceRepo.CreateServiceInstance(ctx, authInfo, payload.ToServiceInstanceCreateMessage()) + if err != nil { + if authorization.IsInvalidAuth(err) { + h.logger.Error(err, "unauthorized to create service instance") + writeInvalidAuthErrorResponse(w) + + return + } + + if authorization.IsNotAuthenticated(err) { + h.logger.Error(err, "unauthorized to create service instance") + writeNotAuthenticatedErrorResponse(w) + + return + } + + if repositories.IsForbiddenError(err) { + h.logger.Error(err, "not allowed to create service instance") + writeNotAuthorizedErrorResponse(w) + + return + } + + h.logger.Error(err, "Failed to create service instance", "Service Instance Name", serviceInstanceRecord.Name) + writeUnknownErrorResponse(w) + return + } + + err = writeJsonResponse(w, presenter.ForServiceInstance(serviceInstanceRecord, h.serverURL), http.StatusCreated) + if err != nil { + // untested + h.logger.Error(err, "Failed to render response", "ServiceInstance Name", payload.Name) + writeUnknownErrorResponse(w) + } +} + +func (h *ServiceInstanceHandler) RegisterRoutes(router *mux.Router) { + w := NewAuthAwareHandlerFuncWrapper(h.logger) + router.Path(ServiceInstanceCreateEndpoint).Methods("POST").HandlerFunc(w.Wrap(h.serviceInstanceCreateHandler)) +} diff --git a/api/apis/service_instance_handler_test.go b/api/apis/service_instance_handler_test.go new file mode 100644 index 000000000..80fefe393 --- /dev/null +++ b/api/apis/service_instance_handler_test.go @@ -0,0 +1,386 @@ +package apis_test + +import ( + "errors" + "fmt" + "math/rand" + "net/http" + "strings" + "time" + + "code.cloudfoundry.org/cf-k8s-controllers/api/authorization" + + "code.cloudfoundry.org/cf-k8s-controllers/api/repositories" + + "code.cloudfoundry.org/cf-k8s-controllers/api/apis/fake" + + . "code.cloudfoundry.org/cf-k8s-controllers/api/apis" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ServiceInstanceHandler", func() { + const ( + testServiceInstanceHandlerLoggerName = "TestServiceInstanceHandler" + serviceInstanceGUID = "test-service-instance-guid" + serviceInstanceSpaceGUID = "test-space-guid" + serviceInstanceTypeUserProvided = "user-provided" + ) + + var ( + req *http.Request + serviceInstanceRepo *fake.CFServiceInstanceRepository + appRepo *fake.CFAppRepository + ) + + BeforeEach(func() { + serviceInstanceRepo = new(fake.CFServiceInstanceRepository) + appRepo = new(fake.CFAppRepository) + serviceInstanceHandler := NewServiceInstanceHandler( + logf.Log.WithName(testServiceInstanceHandlerLoggerName), + *serverURL, + serviceInstanceRepo, + appRepo, + ) + serviceInstanceHandler.RegisterRoutes(router) + }) + + JustBeforeEach(func() { + router.ServeHTTP(rr, req) + }) + + Describe("the POST /v3/service_instances endpoint", func() { + makePostRequest := func(body string) { + var err error + req, err = http.NewRequestWithContext(ctx, "POST", "/v3/service_instances", strings.NewReader(body)) + Expect(err).NotTo(HaveOccurred()) + } + + const ( + serviceInstanceName = "my-upsi" + createdAt = "1906-04-18T13:12:00Z" + updatedAt = "1906-04-18T13:12:00Z" + validBody = `{ + "name": "` + serviceInstanceName + `", + "tags": ["foo", "bar"], + "relationships": { + "space": { + "data": { + "guid": "` + serviceInstanceSpaceGUID + `" + } + } + }, + "type": "` + serviceInstanceTypeUserProvided + `" + }` + ) + + When("on the happy path", func() { + BeforeEach(func() { + serviceInstanceRepo.CreateServiceInstanceReturns(repositories.ServiceInstanceRecord{ + Name: serviceInstanceName, + GUID: serviceInstanceGUID, + SpaceGUID: serviceInstanceSpaceGUID, + SecretName: serviceInstanceGUID, + Tags: []string{"foo", "bar"}, + Type: serviceInstanceTypeUserProvided, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, nil) + + makePostRequest(validBody) + }) + + It("returns status 201 CREATED", func() { + Expect(rr.Code).To(Equal(http.StatusCreated), "Matching HTTP response code:") + }) + + It("creates a CFServiceInstance", func() { + Expect(serviceInstanceRepo.CreateServiceInstanceCallCount()).To(Equal(1)) + _, actualAuthInfo, actualCreate := serviceInstanceRepo.CreateServiceInstanceArgsForCall(0) + Expect(actualAuthInfo).To(Equal(authInfo)) + Expect(actualCreate).To(Equal(repositories.CreateServiceInstanceMessage{ + Name: serviceInstanceName, + SpaceGUID: serviceInstanceSpaceGUID, + Type: serviceInstanceTypeUserProvided, + Tags: []string{"foo", "bar"}, + })) + }) + + It("returns the ServiceInstance in the response", func() { + contentTypeHeader := rr.Header().Get("Content-Type") + Expect(contentTypeHeader).To(Equal(jsonHeader), "Matching Content-Type header:") + + Expect(rr.Body.String()).To(MatchJSON(fmt.Sprintf(`{ + "created_at": "%[4]s", + "guid": "%[2]s", + "last_operation": { + "created_at": "%[4]s", + "description": "Operation succeeded", + "state": "succeeded", + "type": "create", + "updated_at": "%[5]s" + }, + "links": { + "credentials": { + "href": "%[1]s/v3/service_instances/%[2]s/credentials" + }, + "self": { + "href": "%[1]s/v3/service_instances/%[2]s" + }, + "service_credential_bindings": { + "href": "%[1]s/v3/service_credential_bindings?service_instance_guids=%[2]s" + }, + "service_route_bindings": { + "href": "%[1]s/v3/service_route_bindings?service_instance_guids=%[2]s" + }, + "space": { + "href": "%[1]s/v3/spaces/%[3]s" + } + }, + "metadata": { + "annotations": {}, + "labels": {} + }, + "name": "%[6]s", + "relationships": { + "space": { + "data": { + "guid": "%[3]s" + } + } + }, + "route_service_url": null, + "syslog_drain_url": null, + "tags": ["foo", "bar"], + "type": "user-provided", + "updated_at": "%[5]s" + }`, defaultServerURL, serviceInstanceGUID, serviceInstanceSpaceGUID, createdAt, updatedAt, serviceInstanceName)), "Response body matches response:") + }) + }) + + When("the request body is not valid", func() { + BeforeEach(func() { + makePostRequest(`{"description" : "Invalid Request"}`) + }) + + It("returns an error", func() { + expectUnprocessableEntityError(`invalid request body: json: unknown field "description"`) + }) + }) + + When("the request body has route_service_url set", func() { + BeforeEach(func() { + makePostRequest(`{"route_service_url" : "Invalid Request"}`) + }) + + It("returns an error", func() { + expectUnprocessableEntityError(`invalid request body: json: unknown field "route_service_url"`) + }) + }) + + When("the request body has syslog_drain_url set", func() { + BeforeEach(func() { + makePostRequest(`{"syslog_drain_url" : "Invalid Request"}`) + }) + + It("returns an error", func() { + expectUnprocessableEntityError(`invalid request body: json: unknown field "syslog_drain_url"`) + }) + }) + + When("the request body is invalid with missing required name field", func() { + BeforeEach(func() { + makePostRequest(`{ + "relationships": { + "space": { + "data": { + "guid": "` + serviceInstanceSpaceGUID + `" + } + } + }, + "type": "` + serviceInstanceTypeUserProvided + `" + }`) + }) + + It("returns an error", func() { + expectUnprocessableEntityError("Name is a required field") + }) + }) + + When("the request body is invalid with invalid name", func() { + BeforeEach(func() { + makePostRequest(`{ + "name": 12345, + "relationships": { + "space": { + "data": { + "guid": "` + serviceInstanceSpaceGUID + `" + } + } + }, + "type": "` + serviceInstanceTypeUserProvided + `" + }`) + }) + + It("returns an error", func() { + expectUnprocessableEntityError("Name must be a string") + }) + }) + + When("the request body is invalid with missing required type field", func() { + BeforeEach(func() { + makePostRequest(`{ + "name": "` + serviceInstanceName + `", + "relationships": { + "space": { + "data": { + "guid": "` + serviceInstanceSpaceGUID + `" + } + } + } + }`) + }) + + It("returns an error", func() { + expectUnprocessableEntityError("Type is a required field") + }) + }) + + When("the request body is invalid with invalid type", func() { + BeforeEach(func() { + makePostRequest(`{ + "name": "` + serviceInstanceName + `", + "relationships": { + "space": { + "data": { + "guid": "` + serviceInstanceSpaceGUID + `" + } + } + }, + "type": "managed" + }`) + }) + + It("returns an error", func() { + expectUnprocessableEntityError("Type must be one of [user-provided]") + }) + }) + + When("the request body is invalid with missing relationship field", func() { + BeforeEach(func() { + makePostRequest(`{ + "name": "` + serviceInstanceName + `", + "type": "` + serviceInstanceTypeUserProvided + `" + }`) + }) + + It("returns an error", func() { + expectUnprocessableEntityError("Data is a required field") + }) + }) + + When("the request body is invalid with Tags that combine to exceed length 2048", func() { + BeforeEach(func() { + makePostRequest(`{ + "name": "` + serviceInstanceName + `", + "tags": ["` + randomString(2048) + `"], + "relationships": { + "space": { + "data": { + "guid": "` + serviceInstanceSpaceGUID + `" + } + } + }, + "type": "` + serviceInstanceTypeUserProvided + `" + }`) + }) + + It("returns an error", func() { + expectUnprocessableEntityError("Key: 'ServiceInstanceCreate.Tags' Error:Field validation for 'Tags' failed on the 'serviceinstancetaglength' tag") + }) + }) + + When("the space does not exist", func() { + BeforeEach(func() { + appRepo.GetNamespaceReturns( + repositories.SpaceRecord{}, + repositories.PermissionDeniedOrNotFoundError{Err: errors.New("not found")}, + ) + + makePostRequest(validBody) + }) + + It("returns an error", func() { + expectUnprocessableEntityError("Invalid space. Ensure that the space exists and you have access to it.") + }) + }) + + When("the get namespace returns an unknown error", func() { + BeforeEach(func() { + appRepo.GetNamespaceReturns( + repositories.SpaceRecord{}, + errors.New("unknown"), + ) + + makePostRequest(validBody) + }) + + It("returns an error", func() { + expectUnknownError() + }) + }) + + When("authentication is invalid", func() { + BeforeEach(func() { + serviceInstanceRepo.CreateServiceInstanceReturns(repositories.ServiceInstanceRecord{}, authorization.InvalidAuthError{}) + makePostRequest(validBody) + }) + + It("returns Invalid Auth error", func() { + expectInvalidAuthError() + }) + }) + + When("authentication is not provided", func() { + BeforeEach(func() { + serviceInstanceRepo.CreateServiceInstanceReturns(repositories.ServiceInstanceRecord{}, authorization.NotAuthenticatedError{}) + makePostRequest(validBody) + }) + + It("returns a NotAuthenticated error", func() { + expectNotAuthenticatedError() + }) + }) + + When("user is not allowed to create a service instance", func() { + BeforeEach(func() { + serviceInstanceRepo.CreateServiceInstanceReturns(repositories.ServiceInstanceRecord{}, repositories.NewForbiddenError(errors.New("nope"))) + makePostRequest(validBody) + }) + + It("returns an unauthorised error", func() { + expectUnauthorizedError() + }) + }) + + When("providing the service instance repository fails", func() { + BeforeEach(func() { + serviceInstanceRepo.CreateServiceInstanceReturns(repositories.ServiceInstanceRecord{}, errors.New("space-repo-provisioning-failed")) + makePostRequest(validBody) + }) + + It("returns unknown error", func() { + expectUnknownError() + }) + }) + }) +}) + +func randomString(length int) string { + rand.Seed(time.Now().UnixNano()) + b := make([]byte, length) + rand.Read(b) + return fmt.Sprintf("%x", b)[:length] +} diff --git a/api/apis/shared.go b/api/apis/shared.go index 8374c72ed..32f7d44b9 100644 --- a/api/apis/shared.go +++ b/api/apis/shared.go @@ -74,6 +74,7 @@ func validatePayload(object interface{}) *requestMalformedError { _ = v.RegisterValidation("megabytestring", megabyteFormattedString, true) _ = v.RegisterValidation("route", routeString) _ = v.RegisterValidation("routepathstartswithslash", routePathStartsWithSlash) + _ = v.RegisterValidation("serviceinstancetaglength", serviceInstanceTagLength) v.RegisterStructValidation(checkRoleTypeAndOrgSpace, payloads.RoleCreate{}) _ = v.RegisterTranslation("cannot_have_both_org_and_space_set", trans, func(ut ut.Translator) error { @@ -275,6 +276,38 @@ func routePathStartsWithSlash(fl validator.FieldLevel) bool { return true } +func megabyteFormattedString(fl validator.FieldLevel) bool { + val, ok := fl.Field().Interface().(string) + if !ok { + return true // the value is optional, and is set to nil + } + + _, err := bytefmt.ToMegabytes(val) + return err == nil +} + +func routeString(fl validator.FieldLevel) bool { + val := fl.Field().String() + routeRegex := regexp.MustCompile( + `^(?:https?://|tcp://)?(?:(?:[\w-]+\.)|(?:[*]\.))+\w+(?:\:\d+)?(?:/.*)*(?:\.\w+)?$`, + ) + return routeRegex.MatchString(val) +} + +func serviceInstanceTagLength(fl validator.FieldLevel) bool { + tags, ok := fl.Field().Interface().([]string) + if !ok { + return true // the value is optional, and is set to nil + } + + tagLen := 0 + for _, tag := range tags { + tagLen += len(tag) + } + + return tagLen < 2048 +} + func checkRoleTypeAndOrgSpace(sl validator.StructLevel) { roleCreate := sl.Current().Interface().(payloads.RoleCreate) @@ -311,21 +344,3 @@ func checkRoleTypeAndOrgSpace(sl validator.StructLevel) { sl.ReportError(roleCreate.Type, "type", "Role type", "valid_role", "") } } - -func megabyteFormattedString(fl validator.FieldLevel) bool { - val, ok := fl.Field().Interface().(string) - if !ok { - return true // the value is optional, and is set to nil - } - - _, err := bytefmt.ToMegabytes(val) - return err == nil -} - -func routeString(fl validator.FieldLevel) bool { - val := fl.Field().String() - routeRegex := regexp.MustCompile( - `^(?:https?://|tcp://)?(?:(?:[\w-]+\.)|(?:[*]\.))+\w+(?:\:\d+)?(?:/.*)*(?:\.\w+)?$`, - ) - return routeRegex.MatchString(val) -} diff --git a/api/config/base/rbac/role.yaml b/api/config/base/rbac/role.yaml index 59be0752a..407b0e0de 100644 --- a/api/config/base/rbac/role.yaml +++ b/api/config/base/rbac/role.yaml @@ -135,6 +135,12 @@ rules: verbs: - create - list +- apiGroups: + - services.cloudfoundry.org + resources: + - cfserviceinstances + verbs: + - create - apiGroups: - workloads.cloudfoundry.org resources: diff --git a/api/main.go b/api/main.go index 904ff109c..98782c286 100644 --- a/api/main.go +++ b/api/main.go @@ -35,6 +35,7 @@ import ( buildv1alpha2 "github.com/pivotal/kpack/pkg/apis/build/v1alpha2" networkingv1alpha1 "code.cloudfoundry.org/cf-k8s-controllers/controllers/apis/networking/v1alpha1" + servicesv1alpha1 "code.cloudfoundry.org/cf-k8s-controllers/controllers/apis/services/v1alpha1" workloadsv1alpha1 "code.cloudfoundry.org/cf-k8s-controllers/controllers/apis/workloads/v1alpha1" ) @@ -42,8 +43,9 @@ var createTimeout = time.Second * 120 func init() { utilruntime.Must(workloadsv1alpha1.AddToScheme(scheme.Scheme)) - utilruntime.Must(buildv1alpha2.AddToScheme(scheme.Scheme)) utilruntime.Must(networkingv1alpha1.AddToScheme(scheme.Scheme)) + utilruntime.Must(servicesv1alpha1.AddToScheme(scheme.Scheme)) + utilruntime.Must(buildv1alpha2.AddToScheme(scheme.Scheme)) utilruntime.Must(hnsv1alpha2.AddToScheme(scheme.Scheme)) } @@ -213,6 +215,13 @@ func main() { repositories.NewBuildpackRepository(privilegedCRClient, buildUserClient, config.AuthEnabled), config.ClusterBuilderName, ), + + apis.NewServiceInstanceHandler( + ctrl.Log.WithName("ServiceInstanceHandler"), + *serverURL, + repositories.NewServiceInstanceRepo(buildUserClient), + repositories.NewAppRepo(privilegedCRClient, buildUserClient, nsPermissions, config.AuthEnabled), + ), } router := mux.NewRouter() diff --git a/api/payloads/service_instance.go b/api/payloads/service_instance.go new file mode 100644 index 000000000..feeef7b69 --- /dev/null +++ b/api/payloads/service_instance.go @@ -0,0 +1,28 @@ +package payloads + +import "code.cloudfoundry.org/cf-k8s-controllers/api/repositories" + +type ServiceInstanceCreate struct { + Name string `json:"name" validate:"required"` + Type string `json:"type" validate:"required,oneof=user-provided"` + Tags []string `json:"tags" validate:"serviceinstancetaglength"` + Credentials map[string]string `json:"credentials"` + Relationships ServiceInstanceRelationships `json:"relationships" validate:"required"` + Metadata Metadata `json:"metadata"` +} + +type ServiceInstanceRelationships struct { + Space Relationship `json:"space" validate:"required"` +} + +func (p ServiceInstanceCreate) ToServiceInstanceCreateMessage() repositories.CreateServiceInstanceMessage { + return repositories.CreateServiceInstanceMessage{ + Name: p.Name, + SpaceGUID: p.Relationships.Space.Data.GUID, + Credentials: p.Credentials, + Type: p.Type, + Tags: p.Tags, + Labels: p.Metadata.Labels, + Annotations: p.Metadata.Annotations, + } +} diff --git a/api/presenter/service_instance.go b/api/presenter/service_instance.go new file mode 100644 index 000000000..2f239f17f --- /dev/null +++ b/api/presenter/service_instance.go @@ -0,0 +1,91 @@ +package presenter + +import ( + "net/url" + + "code.cloudfoundry.org/cf-k8s-controllers/api/repositories" +) + +const ( + serviceInstancesBase = "/v3/service_instances" + serviceCredentialBindingsBase = "/v3/service_credential_bindings" + serviceRouteBindingsBase = "/v3/service_route_bindings" +) + +type ServiceInstanceResponse struct { + Name string `json:"name"` + GUID string `json:"guid"` + Type string `json:"type"` + Tags []string `json:"tags"` + LastOperation lastOperation `json:"last_operation"` + RouteServiceURL *string `json:"route_service_url"` + SyslogDrainURL *string `json:"syslog_drain_url"` + + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Relationships Relationships `json:"relationships"` + Metadata Metadata `json:"metadata"` + Links ServiceInstanceLinks `json:"links"` +} + +type lastOperation struct { + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Description string `json:"description"` + State string `json:"state"` + Type string `json:"type"` +} + +type ServiceInstanceLinks struct { + Self Link `json:"self"` + Space Link `json:"space"` + Credentials Link `json:"credentials"` + ServiceCredentialBindings Link `json:"service_credential_bindings"` + ServiceRouteBindings Link `json:"service_route_bindings"` +} + +func ForServiceInstance(serviceInstanceRecord repositories.ServiceInstanceRecord, baseURL url.URL) ServiceInstanceResponse { + return ServiceInstanceResponse{ + Name: serviceInstanceRecord.Name, + GUID: serviceInstanceRecord.GUID, + Type: serviceInstanceRecord.Type, + Tags: serviceInstanceRecord.Tags, + LastOperation: lastOperation{ + CreatedAt: serviceInstanceRecord.CreatedAt, + UpdatedAt: serviceInstanceRecord.UpdatedAt, + Description: "Operation succeeded", + State: "succeeded", + Type: "create", + }, + CreatedAt: serviceInstanceRecord.CreatedAt, + UpdatedAt: serviceInstanceRecord.UpdatedAt, + Relationships: Relationships{ + "space": Relationship{ + Data: &RelationshipData{ + GUID: serviceInstanceRecord.SpaceGUID, + }, + }, + }, + Metadata: Metadata{ + Labels: map[string]string{}, + Annotations: map[string]string{}, + }, + Links: ServiceInstanceLinks{ + Self: Link{ + HREF: buildURL(baseURL).appendPath(serviceInstancesBase, serviceInstanceRecord.GUID).build(), + }, + Space: Link{ + HREF: buildURL(baseURL).appendPath(spacesBase, serviceInstanceRecord.SpaceGUID).build(), + }, + Credentials: Link{ + HREF: buildURL(baseURL).appendPath(serviceInstancesBase, serviceInstanceRecord.GUID, "credentials").build(), + }, + ServiceCredentialBindings: Link{ + HREF: buildURL(baseURL).appendPath(serviceCredentialBindingsBase).setQuery("service_instance_guids=" + serviceInstanceRecord.GUID).build(), + }, + ServiceRouteBindings: Link{ + HREF: buildURL(baseURL).appendPath(serviceRouteBindingsBase).setQuery("service_instance_guids=" + serviceInstanceRecord.GUID).build(), + }, + }, + } +} diff --git a/api/reference/cf-k8s-api.yaml b/api/reference/cf-k8s-api.yaml index 57eaafe16..c1913e7f1 100644 --- a/api/reference/cf-k8s-api.yaml +++ b/api/reference/cf-k8s-api.yaml @@ -144,6 +144,12 @@ rules: verbs: - create - list +- apiGroups: + - services.cloudfoundry.org + resources: + - cfserviceinstances + verbs: + - create - apiGroups: - workloads.cloudfoundry.org resources: diff --git a/api/repositories/repositories_suite_test.go b/api/repositories/repositories_suite_test.go index 4802f1238..611fd6532 100644 --- a/api/repositories/repositories_suite_test.go +++ b/api/repositories/repositories_suite_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "testing" + servicesv1alpha1 "code.cloudfoundry.org/cf-k8s-controllers/controllers/apis/services/v1alpha1" + hnsv1alpha2 "sigs.k8s.io/hierarchical-namespaces/api/v1alpha2" networkingv1alpha1 "code.cloudfoundry.org/cf-k8s-controllers/controllers/apis/networking/v1alpha1" @@ -76,6 +78,8 @@ var _ = BeforeSuite(func() { Expect(err).NotTo(HaveOccurred()) err = networkingv1alpha1.AddToScheme(scheme.Scheme) Expect(err).NotTo(HaveOccurred()) + err = servicesv1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) err = hnsv1alpha2.AddToScheme(scheme.Scheme) Expect(err).NotTo(HaveOccurred()) err = buildv1alpha2.AddToScheme(scheme.Scheme) diff --git a/api/repositories/service_instance_repository.go b/api/repositories/service_instance_repository.go new file mode 100644 index 000000000..70fcc82a4 --- /dev/null +++ b/api/repositories/service_instance_repository.go @@ -0,0 +1,143 @@ +package repositories + +import ( + "context" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + corev1 "k8s.io/api/core/v1" + + servicesv1alpha1 "code.cloudfoundry.org/cf-k8s-controllers/controllers/apis/services/v1alpha1" + "github.com/google/uuid" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "code.cloudfoundry.org/cf-k8s-controllers/api/authorization" +) + +//+kubebuilder:rbac:groups=services.cloudfoundry.org,resources=cfserviceinstances,verbs=create + +const ( + CFServiceInstanceGUIDLabel = "services.cloudfoundry.org/service-instance-guid" + ServiceInstanceCredentialSecretType = "servicebinding.io/user-provided" +) + +type ServiceInstanceRepo struct { + userClientFactory UserK8sClientFactory +} + +func NewServiceInstanceRepo( + userClientFactory UserK8sClientFactory, +) *ServiceInstanceRepo { + return &ServiceInstanceRepo{ + userClientFactory: userClientFactory, + } +} + +type CreateServiceInstanceMessage struct { + Name string + SpaceGUID string + Credentials map[string]string + Type string + Tags []string + Labels map[string]string + Annotations map[string]string +} + +type ServiceInstanceRecord struct { + Name string + GUID string + SpaceGUID string + SecretName string + Tags []string + Type string + CreatedAt string + UpdatedAt string +} + +func (r *ServiceInstanceRepo) CreateServiceInstance(ctx context.Context, authInfo authorization.Info, message CreateServiceInstanceMessage) (ServiceInstanceRecord, error) { + userClient, err := r.userClientFactory.BuildClient(authInfo) + if err != nil { + // untested + return ServiceInstanceRecord{}, fmt.Errorf("failed to build user client: %w", err) + } + + cfServiceInstance := message.toCFServiceInstance() + err = userClient.Create(ctx, &cfServiceInstance) + if err != nil { + if apierrors.IsForbidden(err) { + return ServiceInstanceRecord{}, NewForbiddenError(err) + } + // untested + return ServiceInstanceRecord{}, err + } + + secretObj := cfServiceInstanceToSecret(cfServiceInstance) + _, err = controllerutil.CreateOrPatch(ctx, userClient, &secretObj, func() error { + secretObj.StringData = message.Credentials + return nil + }) + if err != nil { + // untested + return ServiceInstanceRecord{}, err + } + + return cfServiceInstanceToServiceInstanceRecord(cfServiceInstance), nil +} + +func (m CreateServiceInstanceMessage) toCFServiceInstance() servicesv1alpha1.CFServiceInstance { + guid := uuid.NewString() + return servicesv1alpha1.CFServiceInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: guid, + Namespace: m.SpaceGUID, + Labels: m.Labels, + Annotations: m.Annotations, + }, + Spec: servicesv1alpha1.CFServiceInstanceSpec{ + Name: m.Name, + SecretName: guid, + Type: servicesv1alpha1.InstanceType(m.Type), + Tags: m.Tags, + }, + } +} + +func cfServiceInstanceToServiceInstanceRecord(cfServiceInstance servicesv1alpha1.CFServiceInstance) ServiceInstanceRecord { + updatedAtTime, _ := getTimeLastUpdatedTimestamp(&cfServiceInstance.ObjectMeta) + + return ServiceInstanceRecord{ + Name: cfServiceInstance.Spec.Name, + GUID: cfServiceInstance.Name, + SpaceGUID: cfServiceInstance.Namespace, + SecretName: cfServiceInstance.Spec.SecretName, + Tags: cfServiceInstance.Spec.Tags, + Type: string(cfServiceInstance.Spec.Type), + CreatedAt: cfServiceInstance.CreationTimestamp.UTC().Format(TimestampFormat), + UpdatedAt: updatedAtTime, + } +} + +func cfServiceInstanceToSecret(cfServiceInstance servicesv1alpha1.CFServiceInstance) corev1.Secret { + labels := make(map[string]string, 1) + labels[CFServiceInstanceGUIDLabel] = cfServiceInstance.Name + + return corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: cfServiceInstance.Name, + Namespace: cfServiceInstance.Namespace, + Labels: labels, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: servicesv1alpha1.GroupVersion.String(), + Kind: "CFServiceInstance", + Name: cfServiceInstance.Name, + UID: cfServiceInstance.UID, + }, + }, + }, + Type: ServiceInstanceCredentialSecretType, + } +} diff --git a/api/repositories/service_instance_repository_test.go b/api/repositories/service_instance_repository_test.go new file mode 100644 index 000000000..18d737994 --- /dev/null +++ b/api/repositories/service_instance_repository_test.go @@ -0,0 +1,146 @@ +package repositories_test + +import ( + "context" + "time" + + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/types" + + "code.cloudfoundry.org/cf-k8s-controllers/api/repositories" + . "code.cloudfoundry.org/cf-k8s-controllers/api/repositories" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gstruct" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = Describe("ServiceInstanceRepository", func() { + var ( + testCtx context.Context + serviceInstanceRepo *ServiceInstanceRepo + clientFactory repositories.UserK8sClientFactory + spaceDeveloperClusterRole *rbacv1.ClusterRole + ) + + BeforeEach(func() { + testCtx = context.Background() + clientFactory = repositories.NewUnprivilegedClientFactory(k8sConfig) + serviceInstanceRepo = NewServiceInstanceRepo(clientFactory) + spaceDeveloperClusterRole = createClusterRole(testCtx, SpaceDeveloperClusterRoleRules) + }) + + Describe("CreateServiceInstance", func() { + const ( + testServiceInstanceName = "my-uspi" + ) + + var ( + serviceInstanceCreateMessage CreateServiceInstanceMessage + spaceGUID string + serviceInstanceTags []string + ) + + BeforeEach(func() { + spaceGUID = generateGUID() + + serviceInstanceTags = []string{"foo", "bar"} + + Expect(k8sClient.Create(testCtx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: spaceGUID}, + })).To(Succeed()) + + DeferCleanup(func() { + _ = k8sClient.Delete(testCtx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: spaceGUID}}) + }) + + serviceInstanceCreateMessage = initializeServiceInstanceCreateMessage(testServiceInstanceName, spaceGUID, serviceInstanceTags) + }) + + When("user has permissions to create ServiceInstances", func() { + BeforeEach(func() { + createRoleBinding(testCtx, userName, spaceDeveloperClusterRole.Name, spaceGUID) + }) + + It("creates a new ServiceInstance CR", func() { + createdServiceInstanceRecord, err := serviceInstanceRepo.CreateServiceInstance(testCtx, authInfo, serviceInstanceCreateMessage) + Expect(err).NotTo(HaveOccurred()) + Expect(createdServiceInstanceRecord.GUID).To(MatchRegexp("^[-0-9a-f]{36}$"), "record GUID was not a 36 character guid") + Expect(createdServiceInstanceRecord.SpaceGUID).To(Equal(spaceGUID), "SpaceGUID in record did not match input") + Expect(createdServiceInstanceRecord.Name).To(Equal(testServiceInstanceName), "Name in record did not match input") + Expect(createdServiceInstanceRecord.Type).To(Equal("user-provided"), "Type in record did not match input") + Expect(createdServiceInstanceRecord.Tags).To(ConsistOf([]string{"foo", "bar"}), "Tags in record did not match input") + + recordCreatedTime, err := time.Parse(TimestampFormat, createdServiceInstanceRecord.CreatedAt) + Expect(err).NotTo(HaveOccurred()) + Expect(recordCreatedTime).To(BeTemporally("~", time.Now(), 2*time.Second)) + + recordUpdatedTime, err := time.Parse(TimestampFormat, createdServiceInstanceRecord.UpdatedAt) + Expect(err).NotTo(HaveOccurred()) + Expect(recordUpdatedTime).To(BeTemporally("~", time.Now(), 2*time.Second)) + }) + + When("no ServiceInstance credentials are given", func() { + It("creates an empty secret and sets the secret ref on the ServiceInstance", func() { + createdServceInstanceRecord, err := serviceInstanceRepo.CreateServiceInstance(testCtx, authInfo, serviceInstanceCreateMessage) + Expect(err).NotTo(HaveOccurred()) + Expect(createdServceInstanceRecord).NotTo(BeNil()) + Expect(createdServceInstanceRecord.SecretName).To(Equal(createdServceInstanceRecord.GUID)) + + secretLookupKey := types.NamespacedName{Name: createdServceInstanceRecord.SecretName, Namespace: createdServceInstanceRecord.SpaceGUID} + createdSecret := new(corev1.Secret) + Eventually(func() error { + return k8sClient.Get(context.Background(), secretLookupKey, createdSecret) + }, 10*time.Second, 250*time.Millisecond).Should(Succeed()) + + Expect(createdSecret.Data).To(BeEmpty()) + Expect(createdSecret.Type).To(Equal(corev1.SecretType("servicebinding.io/user-provided"))) + }) + }) + + When("ServiceInstance credentials are given", func() { + BeforeEach(func() { + serviceInstanceCreateMessage.Credentials = map[string]string{ + "foo": "bar", + "baz": "baz", + } + }) + + It("creates a secret and sets the secret ref on the ServiceInstance", func() { + createdServceInstanceRecord, err := serviceInstanceRepo.CreateServiceInstance(testCtx, authInfo, serviceInstanceCreateMessage) + Expect(err).NotTo(HaveOccurred()) + Expect(createdServceInstanceRecord).NotTo(BeNil()) + Expect(createdServceInstanceRecord.SecretName).To(Equal(createdServceInstanceRecord.GUID)) + + secretLookupKey := types.NamespacedName{Name: createdServceInstanceRecord.SecretName, Namespace: createdServceInstanceRecord.SpaceGUID} + createdSecret := new(corev1.Secret) + Eventually(func() error { + return k8sClient.Get(context.Background(), secretLookupKey, createdSecret) + }, 10*time.Second, 250*time.Millisecond).Should(Succeed()) + + Expect(createdSecret.Data).To(MatchAllKeys(Keys{ + "foo": BeEquivalentTo("bar"), + "baz": BeEquivalentTo("baz"), + })) + }) + }) + }) + + When("user does not have permissions to create ServiceInstances", func() { + It("returns a Forbidden error", func() { + _, err := serviceInstanceRepo.CreateServiceInstance(testCtx, authInfo, serviceInstanceCreateMessage) + Expect(err).To(BeAssignableToTypeOf(repositories.ForbiddenError{})) + }) + }) + }) +}) + +func initializeServiceInstanceCreateMessage(serviceInstanceName string, spaceGUID string, tags []string) CreateServiceInstanceMessage { + return CreateServiceInstanceMessage{ + Name: serviceInstanceName, + SpaceGUID: spaceGUID, + Type: "user-provided", + Tags: tags, + } +} diff --git a/api/repositories/shared.go b/api/repositories/shared.go index 59ab5edbd..e2e68d954 100644 --- a/api/repositories/shared.go +++ b/api/repositories/shared.go @@ -78,7 +78,7 @@ var ( SpaceDeveloperClusterRoleRules = []rbacv1.PolicyRule{ { - Verbs: []string{"get", "patch"}, + Verbs: []string{"get", "create", "patch"}, APIGroups: []string{""}, Resources: []string{"secrets"}, }, @@ -92,6 +92,11 @@ var ( APIGroups: []string{"networking.cloudfoundry.org"}, Resources: []string{"cfroutes"}, }, + { + Verbs: []string{"create"}, + APIGroups: []string{"services.cloudfoundry.org"}, + Resources: []string{"cfserviceinstances"}, + }, { Verbs: []string{"get"}, APIGroups: []string{"kpack.io"}, diff --git a/controllers/apis/services/v1alpha1/cfserviceinstance_types.go b/controllers/apis/services/v1alpha1/cfserviceinstance_types.go index db630887d..709c6acae 100644 --- a/controllers/apis/services/v1alpha1/cfserviceinstance_types.go +++ b/controllers/apis/services/v1alpha1/cfserviceinstance_types.go @@ -36,7 +36,7 @@ type CFServiceInstanceSpec struct { Type InstanceType `json:"type"` // Tags are used by apps to identify service instances - Tags []string `json:"tags"` + Tags []string `json:"tags,omitempty"` } // InstanceType defines the type of the Service Instance diff --git a/controllers/config/cf_roles/cf_admin.yaml b/controllers/config/cf_roles/cf_admin.yaml index 3da043008..1673918b4 100644 --- a/controllers/config/cf_roles/cf_admin.yaml +++ b/controllers/config/cf_roles/cf_admin.yaml @@ -14,6 +14,12 @@ rules: - patch - delete - list +- apiGroups: + - services.cloudfoundry.org + resources: + - cfserviceinstances + verbs: + - create - apiGroups: - rbac.authorization.k8s.io resources: @@ -39,7 +45,7 @@ rules: verbs: - patch - get - + - create - apiGroups: - kpack.io resources: diff --git a/controllers/config/cf_roles/cf_space_developer.yaml b/controllers/config/cf_roles/cf_space_developer.yaml index e22283226..f83f4d872 100644 --- a/controllers/config/cf_roles/cf_space_developer.yaml +++ b/controllers/config/cf_roles/cf_space_developer.yaml @@ -11,6 +11,7 @@ rules: verbs: - patch - get + - create - apiGroups: - workloads.cloudfoundry.org resources: @@ -21,6 +22,12 @@ rules: - patch - delete - list +- apiGroups: + - services.cloudfoundry.org + resources: + - cfserviceinstances + verbs: + - create - apiGroups: - networking.cloudfoundry.org resources: diff --git a/controllers/config/crd/bases/services.cloudfoundry.org_cfserviceinstances.yaml b/controllers/config/crd/bases/services.cloudfoundry.org_cfserviceinstances.yaml index 7a9a1aac7..6bce84e61 100644 --- a/controllers/config/crd/bases/services.cloudfoundry.org_cfserviceinstances.yaml +++ b/controllers/config/crd/bases/services.cloudfoundry.org_cfserviceinstances.yaml @@ -51,7 +51,6 @@ spec: required: - name - secretName - - tags - type type: object status: diff --git a/controllers/reference/cf-k8s-controllers.yaml b/controllers/reference/cf-k8s-controllers.yaml index ee9d1b9a1..f3347d990 100644 --- a/controllers/reference/cf-k8s-controllers.yaml +++ b/controllers/reference/cf-k8s-controllers.yaml @@ -1361,7 +1361,6 @@ spec: required: - name - secretName - - tags - type type: object status: @@ -1451,6 +1450,12 @@ rules: - patch - delete - list +- apiGroups: + - services.cloudfoundry.org + resources: + - cfserviceinstances + verbs: + - create - apiGroups: - rbac.authorization.k8s.io resources: @@ -1476,6 +1481,7 @@ rules: verbs: - patch - get + - create - apiGroups: - kpack.io resources: @@ -1864,6 +1870,7 @@ rules: verbs: - patch - get + - create - apiGroups: - workloads.cloudfoundry.org resources: @@ -1874,6 +1881,12 @@ rules: - patch - delete - list +- apiGroups: + - services.cloudfoundry.org + resources: + - cfserviceinstances + verbs: + - create - apiGroups: - networking.cloudfoundry.org resources: