From 1337b2a9a16fc6ef01aa178f577b2bc5a7bc6807 Mon Sep 17 00:00:00 2001 From: Johannes Malsam <60240743+johannes94@users.noreply.github.com> Date: Mon, 14 Oct 2024 11:11:11 +0200 Subject: [PATCH] ROX-26275: cluster reassignment admin API (#2053) * add assign-cluster admin API endpoint * add integration test helpers for admin API * add integration tests for assign-cluster admin API endpoint --- .secrets.baseline | 4 +- .../pkg/api/admin/private/api/openapi.yaml | 54 +++++++ .../pkg/api/admin/private/api_default.go | 108 +++++++++++++ .../model_central_assign_cluster_request.go | 16 ++ .../dinosaur/pkg/handlers/admin_dinosaur.go | 21 +++ internal/dinosaur/pkg/routes/route_loader.go | 8 + .../services/cluster_placement_strategy.go | 33 +++- internal/dinosaur/pkg/services/dinosaur.go | 29 +++- .../pkg/services/dinosaurservice_moq.go | 56 +++++++ internal/dinosaur/test/helper.go | 40 +++-- .../dinosaur/test/integration/admin_test.go | 142 ++++++++++++++++++ .../dinosaur/test/integration/auth_test.go | 18 +-- .../test/integration/cloudprovider_test.go | 10 +- .../integration/data_plane_endpoints_test.go | 2 +- .../test/integration/dinosaurs_test.go | 6 +- ...equire_terms_acceptance_middleware_test.go | 2 +- openapi/fleet-manager-private-admin.yaml | 49 +++++- pkg/auth/helper.go | 8 +- pkg/features/list.go | 3 + test/helper.go | 24 ++- 20 files changed, 589 insertions(+), 44 deletions(-) create mode 100644 internal/dinosaur/pkg/api/admin/private/model_central_assign_cluster_request.go create mode 100644 internal/dinosaur/test/integration/admin_test.go diff --git a/.secrets.baseline b/.secrets.baseline index 5550f89bee..b841f0a1f4 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -302,7 +302,7 @@ "filename": "internal/dinosaur/pkg/services/dinosaurservice_moq.go", "hashed_secret": "44e17306b837162269a410204daaa5ecee4ec22c", "is_verified": false, - "line_number": 1056 + "line_number": 1112 } ], "pkg/client/fleetmanager/impl/testdata/token": [ @@ -425,5 +425,5 @@ } ] }, - "generated_at": "2024-10-07T22:53:50Z" + "generated_at": "2024-10-14T07:07:48Z" } diff --git a/internal/dinosaur/pkg/api/admin/private/api/openapi.yaml b/internal/dinosaur/pkg/api/admin/private/api/openapi.yaml index 6b240309f1..14d8065947 100644 --- a/internal/dinosaur/pkg/api/admin/private/api/openapi.yaml +++ b/internal/dinosaur/pkg/api/admin/private/api/openapi.yaml @@ -605,6 +605,53 @@ paths: security: - Bearer: [] summary: Delete a Central directly in the Database by ID + /api/rhacs/v1/admin/centrals/{id}/assign-cluster: + post: + operationId: assignCentralCluster + parameters: + - description: The ID of record + in: path + name: id + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CentralAssignClusterRequest' + description: Body for Cluster reassignment + required: true + responses: + "200": + description: Central cluster assignment updated + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Auth token is invalid + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: User is not authorised to access the service + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: No Central found with the specified ID + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Unexpected error occurred + security: + - Bearer: [] + summary: Reassign the cluster a central tenant should be scheduled to /api/rhacs/v1/admin/centrals/{id}/traits: get: operationId: getCentralTraits @@ -857,6 +904,13 @@ components: - RHACS type: string type: object + CentralAssignClusterRequest: + example: + cluster_id: cluster_id + properties: + cluster_id: + type: string + type: object Error: allOf: - $ref: '#/components/schemas/ObjectReference' diff --git a/internal/dinosaur/pkg/api/admin/private/api_default.go b/internal/dinosaur/pkg/api/admin/private/api_default.go index c1097c5e0b..de58ee3882 100644 --- a/internal/dinosaur/pkg/api/admin/private/api_default.go +++ b/internal/dinosaur/pkg/api/admin/private/api_default.go @@ -143,6 +143,114 @@ func (a *DefaultApiService) ApiRhacsV1AdminCentralsIdRestorePost(ctx _context.Co return localVarHTTPResponse, nil } +/* +AssignCentralCluster Reassign the cluster a central tenant should be scheduled to + - @param ctx _context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + - @param id The ID of record + - @param centralAssignClusterRequest Body for Cluster reassignment +*/ +func (a *DefaultApiService) AssignCentralCluster(ctx _context.Context, id string, centralAssignClusterRequest CentralAssignClusterRequest) (*_nethttp.Response, error) { + var ( + localVarHTTPMethod = _nethttp.MethodPost + localVarPostBody interface{} + localVarFormFileName string + localVarFileName string + localVarFileBytes []byte + ) + + // create path and map variables + localVarPath := a.client.cfg.BasePath + "/api/rhacs/v1/admin/centrals/{id}/assign-cluster" + localVarPath = strings.Replace(localVarPath, "{"+"id"+"}", _neturl.QueryEscape(parameterToString(id, "")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := _neturl.Values{} + localVarFormParams := _neturl.Values{} + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{"application/json"} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + // body params + localVarPostBody = ¢ralAssignClusterRequest + r, err := a.client.prepareRequest(ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, localVarFormFileName, localVarFileName, localVarFileBytes) + if err != nil { + return nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(r) + if err != nil || localVarHTTPResponse == nil { + return localVarHTTPResponse, err + } + + localVarBody, err := _ioutil.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + if err != nil { + return localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 401 { + var v Error + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarHTTPResponse, newErr + } + newErr.model = v + return localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 403 { + var v Error + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarHTTPResponse, newErr + } + newErr.model = v + return localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 404 { + var v Error + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarHTTPResponse, newErr + } + newErr.model = v + return localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 500 { + var v Error + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarHTTPResponse, newErr + } + newErr.model = v + } + return localVarHTTPResponse, newErr + } + + return localVarHTTPResponse, nil +} + /* CentralRotateSecrets Rotate RHSSO client or Secret Backup of a central tenant - @param ctx _context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). diff --git a/internal/dinosaur/pkg/api/admin/private/model_central_assign_cluster_request.go b/internal/dinosaur/pkg/api/admin/private/model_central_assign_cluster_request.go new file mode 100644 index 0000000000..f3a7a502a1 --- /dev/null +++ b/internal/dinosaur/pkg/api/admin/private/model_central_assign_cluster_request.go @@ -0,0 +1,16 @@ +/* + * Red Hat Advanced Cluster Security Service Fleet Manager Admin API + * + * Red Hat Advanced Cluster Security (RHACS) Service Fleet Manager Admin APIs that can be used by RHACS Managed Service Operations Team. + * + * API version: 0.0.3 + * Generated by: OpenAPI Generator (https://openapi-generator.tech) + */ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech). DO NOT EDIT. +package private + +// CentralAssignClusterRequest struct for CentralAssignClusterRequest +type CentralAssignClusterRequest struct { + ClusterId string `json:"cluster_id,omitempty"` +} diff --git a/internal/dinosaur/pkg/handlers/admin_dinosaur.go b/internal/dinosaur/pkg/handlers/admin_dinosaur.go index 836ddba078..b4cfdf5b53 100644 --- a/internal/dinosaur/pkg/handlers/admin_dinosaur.go +++ b/internal/dinosaur/pkg/handlers/admin_dinosaur.go @@ -53,6 +53,9 @@ type AdminCentralHandler interface { // a tenant. In particular, avoid two Central CRs appearing in the same // tenant namespace. This may cause conflicts due to mixed resource ownership. PatchName(w http.ResponseWriter, r *http.Request) + // AssignCluster assigns the dataplane cluster_id of the central tenant to + // the given cluster_id in the requests body. + AssignCluster(w http.ResponseWriter, r *http.Request) // ListTraits returns all central traits ListTraits(w http.ResponseWriter, r *http.Request) @@ -320,6 +323,24 @@ func (h adminCentralHandler) PatchName(w http.ResponseWriter, r *http.Request) { handlers.Handle(w, r, cfg, http.StatusOK) } +func (h adminCentralHandler) AssignCluster(w http.ResponseWriter, r *http.Request) { + assignClusterRequest := private.CentralAssignClusterRequest{} + centralID := mux.Vars(r)["id"] + cfg := &handlers.HandlerConfig{ + MarshalInto: &assignClusterRequest, + Validate: []handlers.Validate{ + handlers.ValidateMinLength(&assignClusterRequest.ClusterId, "cluster_id", handlers.MinRequiredFieldLength), + handlers.ValidateMinLength(¢ralID, "id", handlers.MinRequiredFieldLength), + }, + Action: func() (i interface{}, serviceError *errors.ServiceError) { + glog.Infof("Assigning cluster_id for central %q to: %q", centralID, assignClusterRequest.ClusterId) + + return nil, h.service.AssignCluster(r.Context(), centralID, assignClusterRequest.ClusterId) + }, + } + handlers.Handle(w, r, cfg, http.StatusOK) +} + func (h adminCentralHandler) ListTraits(w http.ResponseWriter, r *http.Request) { cfg := &handlers.HandlerConfig{ Action: func() (i interface{}, serviceError *errors.ServiceError) { diff --git a/internal/dinosaur/pkg/routes/route_loader.go b/internal/dinosaur/pkg/routes/route_loader.go index c76a11af64..54f32c6286 100644 --- a/internal/dinosaur/pkg/routes/route_loader.go +++ b/internal/dinosaur/pkg/routes/route_loader.go @@ -24,6 +24,7 @@ import ( "github.com/stackrox/acs-fleet-manager/pkg/db" "github.com/stackrox/acs-fleet-manager/pkg/environments" "github.com/stackrox/acs-fleet-manager/pkg/errors" + "github.com/stackrox/acs-fleet-manager/pkg/features" coreHandlers "github.com/stackrox/acs-fleet-manager/pkg/handlers" "github.com/stackrox/acs-fleet-manager/pkg/logger" "github.com/stackrox/acs-fleet-manager/pkg/server" @@ -42,6 +43,7 @@ type options struct { AMSClient ocm.AMSClient Central services.DinosaurService + ClusterService services.ClusterService CloudProviders services.CloudProvidersService Observatorium services.ObservatoriumService DataPlaneCluster services.DataPlaneClusterService @@ -261,6 +263,12 @@ func (s *options) buildAPIBaseRouter(mainRouter *mux.Router, basePath string, op Name(logger.NewLogEvent("admin-billing", "[admin] change central billing parameters").ToString()). Methods(http.MethodPatch) + if features.ClusterMigration.Enabled() { + adminCentralsRouter.HandleFunc("/{id}/assign-cluster", adminCentralHandler.AssignCluster). + Name(logger.NewLogEvent("admin-central-assign-cluster", "[admin] change central cluster assignment").ToString()). + Methods(http.MethodPost) + } + adminCentralsRouter.HandleFunc("/{id}/traits", adminCentralHandler.ListTraits). Name(logger.NewLogEvent("admin-list-traits", "[admin] list central traits").ToString()). Methods(http.MethodGet) diff --git a/internal/dinosaur/pkg/services/cluster_placement_strategy.go b/internal/dinosaur/pkg/services/cluster_placement_strategy.go index 538a2a3bb9..8955144841 100644 --- a/internal/dinosaur/pkg/services/cluster_placement_strategy.go +++ b/internal/dinosaur/pkg/services/cluster_placement_strategy.go @@ -31,12 +31,7 @@ type FirstReadyPlacementStrategy struct { // FindCluster ... func (d FirstReadyPlacementStrategy) FindCluster(central *dbapi.CentralRequest) (*api.Cluster, error) { - clusters, err := d.clusterService.FindAllClusters(FindClusterCriteria{ - Provider: central.CloudProvider, - Region: central.Region, - MultiAZ: central.MultiAZ, - Status: api.ClusterReady, - }) + clusters, err := d.clusterService.FindAllClusters(centralToFindClusterCriteria(central)) if err != nil { return nil, err } @@ -50,6 +45,23 @@ func (d FirstReadyPlacementStrategy) FindCluster(central *dbapi.CentralRequest) return nil, nil } +// AllMatchingClustersForCentral returns all cluster that fit the criteria to run a central +func AllMatchingClustersForCentral(central *dbapi.CentralRequest, clusterService ClusterService) ([]*api.Cluster, error) { + clusters, err := clusterService.FindAllClusters(centralToFindClusterCriteria(central)) + if err != nil { + return nil, err + } + + matchingClusters := []*api.Cluster{} + for _, c := range clusters { + if c.Schedulable && supportsInstanceType(c, central.InstanceType) { + matchingClusters = append(matchingClusters, c) + } + } + + return matchingClusters, nil +} + func supportsInstanceType(c *api.Cluster, instanceType string) bool { supportedTypes := strings.Split(c.SupportedInstanceType, ",") for _, t := range supportedTypes { @@ -60,3 +72,12 @@ func supportsInstanceType(c *api.Cluster, instanceType string) bool { return false } + +func centralToFindClusterCriteria(central *dbapi.CentralRequest) FindClusterCriteria { + return FindClusterCriteria{ + Provider: central.CloudProvider, + Region: central.Region, + MultiAZ: central.MultiAZ, + Status: api.ClusterReady, + } +} diff --git a/internal/dinosaur/pkg/services/dinosaur.go b/internal/dinosaur/pkg/services/dinosaur.go index 9b8c8cef95..6613021a40 100644 --- a/internal/dinosaur/pkg/services/dinosaur.go +++ b/internal/dinosaur/pkg/services/dinosaur.go @@ -5,6 +5,7 @@ import ( "database/sql" "fmt" "reflect" + "slices" "sync" "time" @@ -116,6 +117,7 @@ type DinosaurService interface { // to accomated for regular processes like central TLS cert rotation. ResetCentralSecretBackup(ctx context.Context, centralRequest *dbapi.CentralRequest) *errors.ServiceError ChangeBillingParameters(ctx context.Context, centralID string, billingModel string, cloudAccountID string, cloudProvider string, product string) *errors.ServiceError + AssignCluster(ctx context.Context, centralID string, clusterID string) *errors.ServiceError } var _ DinosaurService = &dinosaurService{} @@ -207,7 +209,7 @@ func (k *dinosaurService) HasAvailableCapacityInRegion(dinosaurRequest *dbapi.Ce return false, errors.NewWithCause(errors.ErrorGeneral, err, "failed to count central request") } - glog.Infof("%d of %d central clusters currently instantiated in region %v", count, regionCapacity, dinosaurRequest.Region) + glog.Infof("%d of %d central tenants currently instantiated in region %v", count, regionCapacity, dinosaurRequest.Region) return count < regionCapacity, nil } @@ -873,6 +875,31 @@ func (k *dinosaurService) Restore(ctx context.Context, id string) *errors.Servic return nil } +func (k *dinosaurService) AssignCluster(ctx context.Context, centralID string, clusterID string) *errors.ServiceError { + central, serviceErr := k.GetByID(centralID) + if serviceErr != nil { + return serviceErr + } + + readyStatus := dinosaurConstants.CentralRequestStatusReady.String() + if central.Status != readyStatus { + return errors.BadRequest("Cannot assing cluster_id for tenant in status: %q, status %q is required", central.Status, readyStatus) + } + + clusters, err := AllMatchingClustersForCentral(central, k.clusterService) + if err != nil { + glog.Errorf("internal error getting all matching cluster for central: %q, err: %s", centralID, err.Error()) + return errors.GeneralError("error getting matching clusters for central: %q", centralID) + } + + if !slices.ContainsFunc(clusters, func(c *api.Cluster) bool { return c.ClusterID == clusterID }) { + return errors.BadRequest("Given cluster_id: %q not found in list of matching clusters for central: %q.", clusterID, centralID) + } + + central.ClusterID = clusterID + return k.Updates(central, map[string]interface{}{"cluster_id": central.ClusterID}) +} + // DinosaurStatusCount ... type DinosaurStatusCount struct { Status dinosaurConstants.CentralStatus diff --git a/internal/dinosaur/pkg/services/dinosaurservice_moq.go b/internal/dinosaur/pkg/services/dinosaurservice_moq.go index 9ba5089fee..bd642c96f3 100644 --- a/internal/dinosaur/pkg/services/dinosaurservice_moq.go +++ b/internal/dinosaur/pkg/services/dinosaurservice_moq.go @@ -28,6 +28,9 @@ var _ DinosaurService = &DinosaurServiceMock{} // AcceptCentralRequestFunc: func(centralRequest *dbapi.CentralRequest) *serviceError.ServiceError { // panic("mock out the AcceptCentralRequest method") // }, +// AssignClusterFunc: func(ctx context.Context, centralID string, clusterID string) *serviceError.ServiceError { +// panic("mock out the AssignCluster method") +// }, // ChangeBillingParametersFunc: func(ctx context.Context, centralID string, billingModel string, cloudAccountID string, cloudProvider string, product string) *serviceError.ServiceError { // panic("mock out the ChangeBillingParameters method") // }, @@ -116,6 +119,9 @@ type DinosaurServiceMock struct { // AcceptCentralRequestFunc mocks the AcceptCentralRequest method. AcceptCentralRequestFunc func(centralRequest *dbapi.CentralRequest) *serviceError.ServiceError + // AssignClusterFunc mocks the AssignCluster method. + AssignClusterFunc func(ctx context.Context, centralID string, clusterID string) *serviceError.ServiceError + // ChangeBillingParametersFunc mocks the ChangeBillingParameters method. ChangeBillingParametersFunc func(ctx context.Context, centralID string, billingModel string, cloudAccountID string, cloudProvider string, product string) *serviceError.ServiceError @@ -201,6 +207,15 @@ type DinosaurServiceMock struct { // CentralRequest is the centralRequest argument value. CentralRequest *dbapi.CentralRequest } + // AssignCluster holds details about calls to the AssignCluster method. + AssignCluster []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // CentralID is the centralID argument value. + CentralID string + // ClusterID is the clusterID argument value. + ClusterID string + } // ChangeBillingParameters holds details about calls to the ChangeBillingParameters method. ChangeBillingParameters []struct { // Ctx is the ctx argument value. @@ -359,6 +374,7 @@ type DinosaurServiceMock struct { } } lockAcceptCentralRequest sync.RWMutex + lockAssignCluster sync.RWMutex lockChangeBillingParameters sync.RWMutex lockChangeCentralCNAMErecords sync.RWMutex lockCountByRegionAndInstanceType sync.RWMutex @@ -419,6 +435,46 @@ func (mock *DinosaurServiceMock) AcceptCentralRequestCalls() []struct { return calls } +// AssignCluster calls AssignClusterFunc. +func (mock *DinosaurServiceMock) AssignCluster(ctx context.Context, centralID string, clusterID string) *serviceError.ServiceError { + if mock.AssignClusterFunc == nil { + panic("DinosaurServiceMock.AssignClusterFunc: method is nil but DinosaurService.AssignCluster was just called") + } + callInfo := struct { + Ctx context.Context + CentralID string + ClusterID string + }{ + Ctx: ctx, + CentralID: centralID, + ClusterID: clusterID, + } + mock.lockAssignCluster.Lock() + mock.calls.AssignCluster = append(mock.calls.AssignCluster, callInfo) + mock.lockAssignCluster.Unlock() + return mock.AssignClusterFunc(ctx, centralID, clusterID) +} + +// AssignClusterCalls gets all the calls that were made to AssignCluster. +// Check the length with: +// +// len(mockedDinosaurService.AssignClusterCalls()) +func (mock *DinosaurServiceMock) AssignClusterCalls() []struct { + Ctx context.Context + CentralID string + ClusterID string +} { + var calls []struct { + Ctx context.Context + CentralID string + ClusterID string + } + mock.lockAssignCluster.RLock() + calls = mock.calls.AssignCluster + mock.lockAssignCluster.RUnlock() + return calls +} + // ChangeBillingParameters calls ChangeBillingParametersFunc. func (mock *DinosaurServiceMock) ChangeBillingParameters(ctx context.Context, centralID string, billingModel string, cloudAccountID string, cloudProvider string, product string) *serviceError.ServiceError { if mock.ChangeBillingParametersFunc == nil { diff --git a/internal/dinosaur/test/helper.go b/internal/dinosaur/test/helper.go index fc846934fc..ae5118850a 100644 --- a/internal/dinosaur/test/helper.go +++ b/internal/dinosaur/test/helper.go @@ -52,15 +52,39 @@ type Services struct { // TestServices ... var TestServices Services -// NewDinosaurHelper Register a test +// NewCentralHelper Register a test // This should be run before every integration test -func NewDinosaurHelper(t *testing.T, server *httptest.Server) (*test.Helper, *public.APIClient, func()) { - return NewDinosaurHelperWithHooks(t, server, nil) +func NewCentralHelper(t *testing.T, server *httptest.Server) (*test.Helper, *public.APIClient, func()) { + return NewCentralHelperWithHooks(t, server, nil) } -// NewDinosaurHelperWithHooks ... -func NewDinosaurHelperWithHooks(t *testing.T, server *httptest.Server, configurationHook interface{}) (*test.Helper, *public.APIClient, func()) { - h, teardown := test.NewHelperWithHooks(t, server, configurationHook, dinosaur.ConfigProviders(), di.ProvideValue(environments.BeforeCreateServicesHook{ +// NewCentralHelperWithHooks helper, public API Client and teardown function for integration testing public API endpoints +func NewCentralHelperWithHooks(t *testing.T, server *httptest.Server, configurationHook interface{}) (*test.Helper, *public.APIClient, func()) { + h, teardown := newCentralHelperWithHooks(t, server, configurationHook) + if err := h.Env.ServiceContainer.Resolve(&TestServices); err != nil { + glog.Fatalf("Unable to initialize testing environment: %s", err.Error()) + } + return h, NewAPIClient(h), teardown +} + +// NewAdminHelperWithHooks returns helper, adminprivate.APIClient and teardown function for integration testing Admin API endpoints +func NewAdminHelperWithHooks(t *testing.T, server *httptest.Server, configurationHook interface{}) (*test.Helper, *adminprivate.APIClient, func()) { + h, teardown := newCentralHelperWithHooks(t, server, configurationHook) + if err := h.Env.ServiceContainer.Resolve(&TestServices); err != nil { + glog.Fatalf("Unable to initialize testing environment: %s", err.Error()) + } + var iamConfig *iam.IAMConfig + h.Env.MustResolve(&iamConfig) + if iamConfig == nil { + glog.Fatal("Unable to resolve IAMConfig") + } + h.AuthHelper.OcmTokenIssuer = iamConfig.InternalSSORealm.ValidIssuerURI + + return h, NewAdminPrivateAPIClient(h), teardown +} + +func newCentralHelperWithHooks(t *testing.T, server *httptest.Server, configurationHook interface{}) (*test.Helper, func()) { + return test.NewHelperWithHooks(t, server, configurationHook, dinosaur.ConfigProviders(), di.ProvideValue(environments.BeforeCreateServicesHook{ Func: func(dataplaneClusterConfig *config.DataplaneClusterConfig, dinosaurConfig *config.CentralConfig, observabilityConfiguration *observatorium.ObservabilityConfiguration, fleetshardConfig *config.FleetshardConfig, ocmConfig *ocm.OCMConfig) { dinosaurConfig.CentralLifespan.EnableDeletionOfExpiredCentral = true observabilityConfiguration.EnableMock = true @@ -70,10 +94,6 @@ func NewDinosaurHelperWithHooks(t *testing.T, server *httptest.Server, configura ocmConfig.ReadFiles() }, })) - if err := h.Env.ServiceContainer.Resolve(&TestServices); err != nil { - glog.Fatalf("Unable to initialize testing environment: %s", err.Error()) - } - return h, NewAPIClient(h), teardown } // NewAPIClient ... diff --git a/internal/dinosaur/test/integration/admin_test.go b/internal/dinosaur/test/integration/admin_test.go new file mode 100644 index 0000000000..e8b541d138 --- /dev/null +++ b/internal/dinosaur/test/integration/admin_test.go @@ -0,0 +1,142 @@ +package integration + +import ( + "net/http" + "testing" + + constants2 "github.com/stackrox/acs-fleet-manager/internal/dinosaur/constants" + "github.com/stackrox/acs-fleet-manager/internal/dinosaur/pkg/api/admin/private" + "github.com/stackrox/acs-fleet-manager/internal/dinosaur/pkg/api/dbapi" + "github.com/stackrox/acs-fleet-manager/internal/dinosaur/test" + "github.com/stackrox/acs-fleet-manager/pkg/api" + "github.com/stackrox/acs-fleet-manager/pkg/errors" + "github.com/stackrox/acs-fleet-manager/test/mocks" + "github.com/stretchr/testify/require" +) + +func ReturningError() *errors.ServiceError { + return nil +} + +func TestAssignCluster(t *testing.T) { + t.Setenv("RHACS_CLUSTER_MIGRATION", "true") + ocmServer := mocks.NewMockConfigurableServerBuilder().Build() + defer ocmServer.Close() + + clusters := []*api.Cluster{ + testCluster("initial-cluster-1234"), + testCluster("new-cluster-1234"), + } + + helper, adminClient, teardown := test.NewAdminHelperWithHooks(t, ocmServer, nil) + defer teardown() + + orgID := "13640203" + + centrals := []*dbapi.CentralRequest{ + { + MultiAZ: clusters[0].MultiAZ, + Owner: "assignclusteruser1", + Region: clusters[0].Region, + CloudProvider: clusters[0].CloudProvider, + Name: "assign-cluster-central", + OrganisationID: orgID, + Status: constants2.CentralRequestStatusReady.String(), + InstanceType: clusters[0].SupportedInstanceType, + ClusterID: clusters[0].ClusterID, + Meta: api.Meta{ID: api.NewID()}, + }, + { + MultiAZ: clusters[0].MultiAZ, + Owner: "assignclusteruser2", + Region: clusters[0].Region, + CloudProvider: clusters[0].CloudProvider, + Name: "assign-cluster-central-2", + OrganisationID: orgID, + Status: constants2.CentralRequestStatusReady.String(), + InstanceType: clusters[0].SupportedInstanceType, + ClusterID: clusters[0].ClusterID, + Meta: api.Meta{ID: api.NewID()}, + }, + } + + db := test.TestServices.DBFactory.New() + require.NoError(t, db.Create(&clusters).Error) + require.NoError(t, db.Create(¢rals).Error) + + account := helper.NewRandAccount() + ctx := helper.NewAuthenticatedAdminContext(account, nil) + + res, err := adminClient.DefaultApi.AssignCentralCluster(ctx, centrals[0].Meta.ID, private.CentralAssignClusterRequest{ClusterId: clusters[1].ClusterID}) + if err != nil { + if err, ok := err.(private.GenericOpenAPIError); ok { + t.Fatal(string(err.Body()), res.StatusCode) + } + } + + cr, sErr := test.TestServices.DinosaurService.GetByID(centrals[0].Meta.ID) + if sErr != nil { + // not using require.NoError because serviceErr is a type wrapping + // require would wrap it in an error interface which would cause it to fail on nil comparisons + t.Fatal("Unexpected error getting central request", err) + } + + require.Equal(t, "new-cluster-1234", cr.ClusterID, "ClusterID was not set properly.") +} + +func TestAssignClusterCentralMismatch(t *testing.T) { + t.Setenv("RHACS_CLUSTER_MIGRATION", "true") + ocmServer := mocks.NewMockConfigurableServerBuilder().Build() + defer ocmServer.Close() + + clusters := []*api.Cluster{ + testCluster("initial-cluster-1234"), + testCluster("new-cluster-1234"), + } + + helper, adminClient, teardown := test.NewAdminHelperWithHooks(t, ocmServer, nil) + defer teardown() + + orgID := "13640203" + + centrals := []*dbapi.CentralRequest{ + { + MultiAZ: clusters[0].MultiAZ, + Owner: "assigclusteruser1", + Region: "non-matching-region", + CloudProvider: clusters[0].CloudProvider, + Name: "assign-cluster-central", + OrganisationID: orgID, + Status: constants2.CentralRequestStatusReady.String(), + InstanceType: clusters[0].SupportedInstanceType, + ClusterID: clusters[0].ClusterID, + Meta: api.Meta{ID: api.NewID()}, + }, + } + + db := test.TestServices.DBFactory.New() + require.NoError(t, db.Create(&clusters).Error) + require.NoError(t, db.Create(¢rals).Error) + + account := helper.NewRandAccount() + ctx := helper.NewAuthenticatedAdminContext(account, nil) + + res, err := adminClient.DefaultApi.AssignCentralCluster(ctx, centrals[0].Meta.ID, private.CentralAssignClusterRequest{ClusterId: clusters[1].ClusterID}) + require.Error(t, err, "Expected bad requests error for central AssignCluster to non-matching region") + require.NotNil(t, res) + require.Equal(t, http.StatusBadRequest, res.StatusCode, "Expected bad request for central AssignCluster to non-matching region") +} + +func testCluster(clusterID string) *api.Cluster { + return &api.Cluster{ + CloudProvider: "testprovider", + Region: "testregion", + MultiAZ: false, + ClusterID: clusterID, + Status: api.ClusterReady, + ProviderType: api.ClusterProviderStandalone, + ClusterDNS: "some.test.dns", + SupportedInstanceType: "testtype", + Schedulable: true, + } +} diff --git a/internal/dinosaur/test/integration/auth_test.go b/internal/dinosaur/test/integration/auth_test.go index 93250dc419..e1da27108b 100644 --- a/internal/dinosaur/test/integration/auth_test.go +++ b/internal/dinosaur/test/integration/auth_test.go @@ -27,7 +27,7 @@ func TestAuth_success(t *testing.T) { // setup the test environment, if OCM_ENV=integration then the ocmServer provided will be used instead of actual // ocm - h, _, teardown := test.NewDinosaurHelper(t, ocmServer) + h, _, teardown := test.NewCentralHelper(t, ocmServer) defer teardown() serviceAccount := h.NewAccount(h.NewID(), faker.Name(), faker.Email(), "13640203") @@ -45,7 +45,7 @@ func TestAuthSucess_publicUrls(t *testing.T) { ocmServer := mocks.NewMockConfigurableServerBuilder().Build() defer ocmServer.Close() - h, client, teardown := test.NewDinosaurHelper(t, ocmServer) + h, client, teardown := test.NewCentralHelper(t, ocmServer) defer teardown() restyResp, err := resty.R(). SetHeader("Content-Type", "application/json"). @@ -68,7 +68,7 @@ func TestAuthSuccess_usingSSORHToken(t *testing.T) { ocmServer := mocks.NewMockConfigurableServerBuilder().Build() defer ocmServer.Close() - h, _, teardown := test.NewDinosaurHelper(t, ocmServer) + h, _, teardown := test.NewCentralHelper(t, ocmServer) serviceAccount := h.NewAccount(h.NewID(), faker.Name(), faker.Email(), "13640203") defer teardown() claims := jwt.MapClaims{ @@ -90,7 +90,7 @@ func TestAuthFailure_withoutToken(t *testing.T) { ocmServer := mocks.NewMockConfigurableServerBuilder().Build() defer ocmServer.Close() - h, _, teardown := test.NewDinosaurHelper(t, ocmServer) + h, _, teardown := test.NewCentralHelper(t, ocmServer) defer teardown() restyResp, err := resty.R(). @@ -107,7 +107,7 @@ func TestAuthFailure_invalidTokenWithInvalidTyp(t *testing.T) { ocmServer := mocks.NewMockConfigurableServerBuilder().Build() defer ocmServer.Close() - h, _, teardown := test.NewDinosaurHelper(t, ocmServer) + h, _, teardown := test.NewCentralHelper(t, ocmServer) serviceAccount := h.NewAccount(h.NewID(), faker.Name(), faker.Email(), "13640203") defer teardown() claims := jwt.MapClaims{ @@ -131,7 +131,7 @@ func TestAuthFailure_ExpiredToken(t *testing.T) { ocmServer := mocks.NewMockConfigurableServerBuilder().Build() defer ocmServer.Close() - h, _, teardown := test.NewDinosaurHelper(t, ocmServer) + h, _, teardown := test.NewCentralHelper(t, ocmServer) serviceAccount := h.NewAccount(h.NewID(), faker.Name(), faker.Email(), "13640203") defer teardown() claims := jwt.MapClaims{ @@ -154,7 +154,7 @@ func TestAuthFailure_invalidTokenMissingIat(t *testing.T) { ocmServer := mocks.NewMockConfigurableServerBuilder().Build() defer ocmServer.Close() - h, _, teardown := test.NewDinosaurHelper(t, ocmServer) + h, _, teardown := test.NewCentralHelper(t, ocmServer) serviceAccount := h.NewAccount(h.NewID(), faker.Name(), faker.Email(), "13640203") defer teardown() claims := jwt.MapClaims{ @@ -177,7 +177,7 @@ func TestAuthFailure_invalidTokenMissingAlgHeader(t *testing.T) { ocmServer := mocks.NewMockConfigurableServerBuilder().Build() defer ocmServer.Close() - h, _, teardown := test.NewDinosaurHelper(t, ocmServer) + h, _, teardown := test.NewCentralHelper(t, ocmServer) serviceAccount := h.NewAccount(h.NewID(), faker.Name(), faker.Email(), "13640203") defer teardown() claims := jwt.MapClaims{ @@ -209,7 +209,7 @@ func TestAuthFailure_invalidTokenUnsigned(t *testing.T) { ocmServer := mocks.NewMockConfigurableServerBuilder().Build() defer ocmServer.Close() - h, _, teardown := test.NewDinosaurHelper(t, ocmServer) + h, _, teardown := test.NewCentralHelper(t, ocmServer) serviceAccount := h.NewAccount(h.NewID(), faker.Name(), faker.Email(), "13640203") defer teardown() claims := jwt.MapClaims{ diff --git a/internal/dinosaur/test/integration/cloudprovider_test.go b/internal/dinosaur/test/integration/cloudprovider_test.go index d6235cc0dd..68ccab8f55 100644 --- a/internal/dinosaur/test/integration/cloudprovider_test.go +++ b/internal/dinosaur/test/integration/cloudprovider_test.go @@ -128,7 +128,7 @@ func TestCloudProviderRegions(t *testing.T) { defer ocmServer.Close() // start servers - _, _, teardown := test.NewDinosaurHelper(t, ocmServer) + _, _, teardown := test.NewCentralHelper(t, ocmServer) defer teardown() // Create two clusters each with different provider type @@ -166,7 +166,7 @@ func TestCachedCloudProviderRegions(t *testing.T) { defer ocmServer.Close() // start servers - _, _, teardown := test.NewDinosaurHelper(t, ocmServer) + _, _, teardown := test.NewCentralHelper(t, ocmServer) defer teardown() // Create two clusters each with different provider type @@ -201,7 +201,7 @@ func TestListCloudProviders(t *testing.T) { ocmServer := mocks.NewMockConfigurableServerBuilder().Build() defer ocmServer.Close() - h, client, teardown := test.NewDinosaurHelper(t, ocmServer) + h, client, teardown := test.NewCentralHelper(t, ocmServer) defer teardown() // Create two clusters each with different provider type @@ -236,7 +236,7 @@ func TestListCloudProviderRegions(t *testing.T) { } defer ocmServer.Close() - h, client, teardown := test.NewDinosaurHelperWithHooks(t, ocmServer, func(pc *config.ProviderConfig) { + h, client, teardown := test.NewCentralHelperWithHooks(t, ocmServer, func(pc *config.ProviderConfig) { pc.ProvidersConfig.SupportedProviders = config.ProviderList{ { Name: "aws", @@ -310,7 +310,7 @@ func TestListCloudProviderRegionsWithInstanceType(t *testing.T) { } defer ocmServer.Close() - h, client, teardown := test.NewDinosaurHelperWithHooks(t, ocmServer, func(pc *config.ProviderConfig) { + h, client, teardown := test.NewCentralHelperWithHooks(t, ocmServer, func(pc *config.ProviderConfig) { pc.ProvidersConfig.SupportedProviders = config.ProviderList{ { Name: "aws", diff --git a/internal/dinosaur/test/integration/data_plane_endpoints_test.go b/internal/dinosaur/test/integration/data_plane_endpoints_test.go index ea12dc5358..446f0b0198 100644 --- a/internal/dinosaur/test/integration/data_plane_endpoints_test.go +++ b/internal/dinosaur/test/integration/data_plane_endpoints_test.go @@ -34,7 +34,7 @@ func TestDataPlaneClusterStatus(t *testing.T) { ocmServerBuilder.SetClusterGetResponse(mockedGetClusterResponse, nil) ocmServer := ocmServerBuilder.Build() defer ocmServer.Close() - h, _, tearDown := test.NewDinosaurHelper(t, ocmServer) + h, _, tearDown := test.NewCentralHelper(t, ocmServer) defer tearDown() clusterID := api.NewID() diff --git a/internal/dinosaur/test/integration/dinosaurs_test.go b/internal/dinosaur/test/integration/dinosaurs_test.go index de4e776813..f40da24a3a 100644 --- a/internal/dinosaur/test/integration/dinosaurs_test.go +++ b/internal/dinosaur/test/integration/dinosaurs_test.go @@ -41,7 +41,7 @@ func TestDinosaurCreate_Success(t *testing.T) { // setup the test environment, if OCM_ENV=integration then the ocmServer provided will be used instead of actual // ocm - h, client, teardown := test.NewDinosaurHelperWithHooks(t, ocmServer, func(c *config.DataplaneClusterConfig) { + h, client, teardown := test.NewCentralHelperWithHooks(t, ocmServer, func(c *config.DataplaneClusterConfig) { c.ClusterConfig = config.NewClusterConfig([]config.ManualCluster{test.NewMockDataplaneCluster(mockDinosaurClusterName, 1)}) }) defer teardown() @@ -76,7 +76,7 @@ func TestDinosaurCreate_TooManyDinosaurs(t *testing.T) { // setup the test environment, if OCM_ENV=integration then the ocmServer provided will be used instead of actual // ocm - h, client, tearDown := test.NewDinosaurHelperWithHooks(t, ocmServer, func(c *config.DataplaneClusterConfig) { + h, client, tearDown := test.NewCentralHelperWithHooks(t, ocmServer, func(c *config.DataplaneClusterConfig) { c.ClusterConfig = config.NewClusterConfig([]config.ManualCluster{test.NewMockDataplaneCluster(mockDinosaurClusterName, 1)}) }) defer tearDown() @@ -149,7 +149,7 @@ func TestDinosaur_Delete(t *testing.T) { ocmServer := ocmServerBuilder.Build() defer ocmServer.Close() - h, _, tearDown := test.NewDinosaurHelper(t, ocmServer) + h, _, tearDown := test.NewCentralHelper(t, ocmServer) defer tearDown() userAccount := h.NewAccount(owner, "test-user", "test@gmail.com", orgID) diff --git a/internal/dinosaur/test/integration/require_terms_acceptance_middleware_test.go b/internal/dinosaur/test/integration/require_terms_acceptance_middleware_test.go index e69095ac52..9619da553f 100644 --- a/internal/dinosaur/test/integration/require_terms_acceptance_middleware_test.go +++ b/internal/dinosaur/test/integration/require_terms_acceptance_middleware_test.go @@ -35,7 +35,7 @@ func termsRequiredSetup(termsRequired bool, t *testing.T) TestEnv { // setup the test environment, if OCM_ENV=integration then the ocmServer provided will be used instead of actual // ocm - h, client, tearDown := test.NewDinosaurHelperWithHooks(t, ocmServer, func(serverConfig *server.ServerConfig, c *config.DataplaneClusterConfig) { + h, client, tearDown := test.NewCentralHelperWithHooks(t, ocmServer, func(serverConfig *server.ServerConfig, c *config.DataplaneClusterConfig) { c.ClusterConfig = config.NewClusterConfig([]config.ManualCluster{test.NewMockDataplaneCluster(mockDinosaurClusterName, 2)}) serverConfig.EnableTermsAcceptance = true }) diff --git a/openapi/fleet-manager-private-admin.yaml b/openapi/fleet-manager-private-admin.yaml index cabfa07a16..daf03b178d 100644 --- a/openapi/fleet-manager-private-admin.yaml +++ b/openapi/fleet-manager-private-admin.yaml @@ -457,7 +457,48 @@ paths: application/json: schema: $ref: 'fleet-manager.yaml#/components/schemas/Error' - + '/api/rhacs/v1/admin/centrals/{id}/assign-cluster': + post: + summary: Reassign the cluster a central tenant should be scheduled to + parameters: + - $ref: "fleet-manager.yaml#/components/parameters/id" + requestBody: + description: Body for Cluster reassignment + content: + application/json: + schema: + $ref: '#/components/schemas/CentralAssignClusterRequest' + required: true + security: + - Bearer: [ ] + operationId: assignCentralCluster + responses: + "200": + description: Central cluster assignment updated + "401": + description: Auth token is invalid + content: + application/json: + schema: + $ref: 'fleet-manager.yaml#/components/schemas/Error' + "403": + description: User is not authorised to access the service + content: + application/json: + schema: + $ref: 'fleet-manager.yaml#/components/schemas/Error' + "404": + description: No Central found with the specified ID + content: + application/json: + schema: + $ref: 'fleet-manager.yaml#/components/schemas/Error' + "500": + description: Unexpected error occurred + content: + application/json: + schema: + $ref: 'fleet-manager.yaml#/components/schemas/Error' '/api/rhacs/v1/admin/centrals/{id}/traits': get: summary: Returns a list of central traits. @@ -720,6 +761,12 @@ components: enum: - RHACS + CentralAssignClusterRequest: + type: object + properties: + cluster_id: + type: string + parameters: trait: name: trait diff --git a/pkg/auth/helper.go b/pkg/auth/helper.go index a9168086fc..38c16859f6 100644 --- a/pkg/auth/helper.go +++ b/pkg/auth/helper.go @@ -29,7 +29,7 @@ const ( type AuthHelper struct { JWTPrivateKey *rsa.PrivateKey JWTCA *rsa.PublicKey - ocmTokenIssuer string + OcmTokenIssuer string } // NewAuthHelper Creates an auth helper to be used for creating new accounts and jwt. @@ -47,7 +47,7 @@ func NewAuthHelper(jwtKeyFilePath, jwtCAFilePath, ocmTokenIssuer string) (*AuthH return &AuthHelper{ JWTPrivateKey: jwtKey, // pragma: allowlist secret JWTCA: jwtCA, - ocmTokenIssuer: ocmTokenIss, + OcmTokenIssuer: ocmTokenIss, }, nil } @@ -104,9 +104,9 @@ func (authHelper *AuthHelper) CreateJWTWithClaims(account *amv1.Account, jwtClai "exp": time.Now().Add(time.Minute * time.Duration(TokenExpMin)).Unix(), } - if jwtClaims == nil || jwtClaims["iss"] == nil || jwtClaims["iss"] == "" || jwtClaims["iss"] == authHelper.ocmTokenIssuer { + if jwtClaims == nil || jwtClaims["iss"] == nil || jwtClaims["iss"] == "" || jwtClaims["iss"] == authHelper.OcmTokenIssuer { // Set default claim values for ocm tokens - claims["iss"] = authHelper.ocmTokenIssuer + claims["iss"] = authHelper.OcmTokenIssuer claims[tenantUsernameClaim] = account.Username() claims["first_name"] = account.FirstName() claims["last_name"] = account.LastName() diff --git a/pkg/features/list.go b/pkg/features/list.go index 30c3a3ded5..f0c52b1650 100644 --- a/pkg/features/list.go +++ b/pkg/features/list.go @@ -15,4 +15,7 @@ var ( // AddonAutoUpgrade enables addon auto upgrade feature AddonAutoUpgrade = registerFeature("Addon auto upgrade", "RHACS_ADDON_AUTO_UPGRADE", true) + + // ClusterMigration enables the feature to migrate a tenant to another cluster + ClusterMigration = registerFeature("Cluster migraiton", "RHACS_CLUSTER_MIGRATION", false) ) diff --git a/test/helper.go b/test/helper.go index 5058e57878..1f47ec0dcd 100644 --- a/test/helper.go +++ b/test/helper.go @@ -31,7 +31,7 @@ import ( "github.com/google/uuid" amv1 "github.com/openshift-online/ocm-sdk-go/accountsmgmt/v1" "github.com/rs/xid" - + adminprivate "github.com/stackrox/acs-fleet-manager/internal/dinosaur/pkg/api/admin/private" "github.com/stackrox/acs-fleet-manager/pkg/auth" "github.com/stackrox/acs-fleet-manager/pkg/db" "github.com/stackrox/acs-fleet-manager/pkg/environments" @@ -43,6 +43,7 @@ const ( jwtKeyFile = "test/support/jwt_private_key.pem" jwtCAFile = "test/support/jwt_ca.pem" dataplaneIssuerURI = "https://dataplane.issuer.test.local" + adminIssuer = "https://auth.redhat.com/" ) // TODO jwk mock server needs to be refactored out of the helper and into the testing environment @@ -284,6 +285,27 @@ func (helper *Helper) NewAuthenticatedContext(account *amv1.Account, claims jwt. return context.WithValue(context.Background(), compat.ContextAccessToken, token) } +// NewAuthenticatedAdminContext return an authenticated context that can be used with openapi function generated for the admin API +func (helper *Helper) NewAuthenticatedAdminContext(account *amv1.Account, claims jwt.MapClaims) context.Context { + if claims == nil { + claims = jwt.MapClaims{} + } + + // do not override roles if explicitly defined + if _, hasRealmAccess := claims["realm_access"]; !hasRealmAccess { + claims["realm_access"] = map[string]interface{}{ + "roles": []string{"acs-fleet-manager-admin-full"}, + } + } + + token, err := helper.AuthHelper.CreateSignedJWT(account, claims) + if err != nil { + helper.T.Errorf(fmt.Sprintf("Unable to create a signed token: %s", err.Error())) + } + + return context.WithValue(context.Background(), adminprivate.ContextAccessToken, token) +} + // StartJWKCertServerMock ... func (helper *Helper) StartJWKCertServerMock() (string, func()) { return mocks.NewJWKCertServerMock(helper.T, helper.JWTCA, auth.JwkKID)