From 83543c6b12a5361a97d2a167b1cbf54d93695b59 Mon Sep 17 00:00:00 2001 From: Bruno Date: Fri, 31 May 2024 09:39:10 -0300 Subject: [PATCH] Cloud migrations: create route to fetch cloud migration token (#88176) * Cloud migration: create route to fetch cloud migration token * implement gcomStub.ListTokens * fix swagger for POST /cloudmigration/migration * fix swagger for POST /cloudmigration/migration * fix swagger for POST /cloudmigration/migration --- pkg/services/cloudmigration/api/api.go | 63 +++++++++++++++ pkg/services/cloudmigration/cloudmigration.go | 6 ++ .../cloudmigrationimpl/cloudmigration.go | 43 ++++++++++- .../cloudmigrationimpl/cloudmigration_noop.go | 5 ++ .../cloudmigrationimpl/gcomstub.go | 16 ++++ pkg/services/cloudmigration/model.go | 1 + pkg/services/gcom/gcom.go | 69 +++++++++++++++++ pkg/setting/setting_cloud_migration.go | 2 + public/api-merged.json | 71 +++++++++++++++++ public/openapi3.json | 76 +++++++++++++++++++ 10 files changed, 351 insertions(+), 1 deletion(-) diff --git a/pkg/services/cloudmigration/api/api.go b/pkg/services/cloudmigration/api/api.go index 6c4b6f645e9..9639fbdb985 100644 --- a/pkg/services/cloudmigration/api/api.go +++ b/pkg/services/cloudmigration/api/api.go @@ -1,6 +1,7 @@ package api import ( + "errors" "net/http" "github.com/grafana/grafana/pkg/api/response" @@ -47,10 +48,46 @@ func (cma *CloudMigrationAPI) registerEndpoints() { cloudMigrationRoute.Post("/migration/:uid/run", routing.Wrap(cma.RunMigration)) cloudMigrationRoute.Get("/migration/:uid/run", routing.Wrap(cma.GetMigrationRunList)) cloudMigrationRoute.Get("/migration/run/:runUID", routing.Wrap(cma.GetMigrationRun)) + cloudMigrationRoute.Get("/token", routing.Wrap(cma.GetToken)) cloudMigrationRoute.Post("/token", routing.Wrap(cma.CreateToken)) }, middleware.ReqOrgAdmin) } +// swagger:route GET /cloudmigration/token migrations getCloudMigrationToken +// +// Fetch the cloud migration token if it exists. +// +// Responses: +// 200: cloudMigrationGetTokenResponse +// 401: unauthorisedError +// 404: notFoundError +// 403: forbiddenError +// 500: internalServerError +func (cma *CloudMigrationAPI) GetToken(c *contextmodel.ReqContext) response.Response { + ctx, span := cma.tracer.Start(c.Req.Context(), "MigrationAPI.GetToken") + defer span.End() + + logger := cma.log.FromContext(ctx) + + token, err := cma.cloudMigrationService.GetToken(ctx) + if err != nil { + if !errors.Is(err, cloudmigration.ErrTokenNotFound) { + logger.Error("fetching cloud migration access token", "err", err.Error()) + } + + return response.ErrOrFallback(http.StatusInternalServerError, "fetching cloud migration access token", err) + } + + return response.JSON(http.StatusOK, GetAccessTokenResponseDTO{ + ID: token.ID, + DisplayName: token.DisplayName, + ExpiresAt: token.ExpiresAt, + FirstUsedAt: token.FirstUsedAt, + LastUsedAt: token.LastUsedAt, + CreatedAt: token.CreatedAt, + }) +} + // swagger:route POST /cloudmigration/token migrations createCloudMigrationToken // // Create gcom access token. @@ -310,6 +347,13 @@ type CloudMigrationListResponse struct { Body cloudmigration.CloudMigrationListResponse } +// swagger:parameters createMigration +type CreateMigration struct { + // in:body + // required:true + Body cloudmigration.CloudMigrationRequest +} + // swagger:response cloudMigrationResponse type CloudMigrationResponse struct { // in: body @@ -322,6 +366,25 @@ type CloudMigrationRunListResponse struct { Body cloudmigration.CloudMigrationRunList } +// swagger:parameters getCloudMigrationToken +type GetCloudMigrationToken struct { +} + +// swagger:response cloudMigrationGetTokenResponse +type CloudMigrationGetTokenResponse struct { + // in: body + Body GetAccessTokenResponseDTO +} + +type GetAccessTokenResponseDTO struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + ExpiresAt string `json:"expiresAt"` + FirstUsedAt string `json:"firstUsedAt"` + LastUsedAt string `json:"lastUsedAt"` + CreatedAt string `json:"createdAt"` +} + // swagger:response cloudMigrationCreateTokenResponse type CloudMigrationCreateTokenResponse struct { // in: body diff --git a/pkg/services/cloudmigration/cloudmigration.go b/pkg/services/cloudmigration/cloudmigration.go index ea21b691ca9..63bdfbe23e7 100644 --- a/pkg/services/cloudmigration/cloudmigration.go +++ b/pkg/services/cloudmigration/cloudmigration.go @@ -2,10 +2,16 @@ package cloudmigration import ( "context" + + "github.com/grafana/grafana/pkg/services/gcom" ) type Service interface { + // Returns the cloud migration token if it exists. + GetToken(context.Context) (gcom.TokenView, error) + // Creates a cloud migration token. CreateToken(context.Context) (CreateAccessTokenResponse, error) + // Sends a request to CMS to test the token. ValidateToken(context.Context, CloudMigration) error CreateMigration(context.Context, CloudMigrationRequest) (*CloudMigrationResponse, error) diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go index 413f65f7f17..f6603534a9b 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go @@ -102,7 +102,7 @@ func ProvideService( s.gcomService = gcom.New(gcom.Config{ApiURL: cfg.GrafanaComAPIURL, Token: cfg.CloudMigration.GcomAPIToken}) } else { s.cmsClient = cmsclient.NewInMemoryClient() - s.gcomService = &gcomStub{map[string]gcom.AccessPolicy{}} + s.gcomService = &gcomStub{policies: map[string]gcom.AccessPolicy{}, token: nil} s.cfg.StackID = "12345" } @@ -113,6 +113,47 @@ func ProvideService( return s, nil } +func (s *Service) GetToken(ctx context.Context) (gcom.TokenView, error) { + ctx, span := s.tracer.Start(ctx, "CloudMigrationService.GetToken") + defer span.End() + logger := s.log.FromContext(ctx) + requestID := tracing.TraceIDFromContext(ctx, false) + + timeoutCtx, cancel := context.WithTimeout(ctx, s.cfg.CloudMigration.FetchInstanceTimeout) + defer cancel() + instance, err := s.gcomService.GetInstanceByID(timeoutCtx, requestID, s.cfg.StackID) + if err != nil { + return gcom.TokenView{}, fmt.Errorf("fetching instance by id: id=%s %w", s.cfg.StackID, err) + } + + logger.Info("instance found", "slug", instance.Slug) + + accessPolicyName := fmt.Sprintf("%s-%s", cloudMigrationAccessPolicyNamePrefix, s.cfg.StackID) + accessTokenName := fmt.Sprintf("%s-%s", cloudMigrationTokenNamePrefix, s.cfg.StackID) + + timeoutCtx, cancel = context.WithTimeout(ctx, s.cfg.CloudMigration.ListTokensTimeout) + defer cancel() + tokens, err := s.gcomService.ListTokens(timeoutCtx, gcom.ListTokenParams{ + RequestID: requestID, + Region: instance.RegionSlug, + AccessPolicyName: accessPolicyName, + TokenName: accessTokenName}) + if err != nil { + return gcom.TokenView{}, fmt.Errorf("listing tokens: %w", err) + } + logger.Info("found access tokens", "num_tokens", len(tokens)) + + for _, token := range tokens { + if token.Name == accessTokenName { + logger.Info("found existing cloud migration token", "tokenID", token.ID, "accessPolicyID", token.AccessPolicyID) + return token, nil + } + } + + logger.Info("cloud migration token not found") + return gcom.TokenView{}, cloudmigration.ErrTokenNotFound +} + func (s *Service) CreateToken(ctx context.Context) (cloudmigration.CreateAccessTokenResponse, error) { ctx, span := s.tracer.Start(ctx, "CloudMigrationService.CreateToken") defer span.End() diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_noop.go b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_noop.go index f95188f088c..5c6aa3ffb34 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_noop.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_noop.go @@ -4,6 +4,7 @@ import ( "context" "github.com/grafana/grafana/pkg/services/cloudmigration" + "github.com/grafana/grafana/pkg/services/gcom" ) // NoopServiceImpl Define the Service Implementation. @@ -11,6 +12,10 @@ type NoopServiceImpl struct{} var _ cloudmigration.Service = (*NoopServiceImpl)(nil) +func (s *NoopServiceImpl) GetToken(ctx context.Context) (gcom.TokenView, error) { + return gcom.TokenView{}, cloudmigration.ErrFeatureDisabledError +} + func (s *NoopServiceImpl) CreateToken(ctx context.Context) (cloudmigration.CreateAccessTokenResponse, error) { return cloudmigration.CreateAccessTokenResponse{}, cloudmigration.ErrFeatureDisabledError } diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/gcomstub.go b/pkg/services/cloudmigration/cloudmigrationimpl/gcomstub.go index 79d4001b5b0..e77cdfb5ac5 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/gcomstub.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/gcomstub.go @@ -10,6 +10,8 @@ import ( ) type gcomStub struct { + // The cloud migration token created by this stub. + token *gcom.TokenView policies map[string]gcom.AccessPolicy } @@ -49,6 +51,14 @@ func (client *gcomStub) ListAccessPolicies(ctx context.Context, params gcom.List return items, nil } +func (client *gcomStub) ListTokens(ctx context.Context, params gcom.ListTokenParams) ([]gcom.TokenView, error) { + if client.token == nil { + return []gcom.TokenView{}, nil + } + + return []gcom.TokenView{*client.token}, nil +} + func (client *gcomStub) CreateToken(ctx context.Context, params gcom.CreateTokenParams, payload gcom.CreateTokenPayload) (gcom.Token, error) { token := gcom.Token{ ID: fmt.Sprintf("random-token-%s", util.GenerateShortUID()), @@ -56,5 +66,11 @@ func (client *gcomStub) CreateToken(ctx context.Context, params gcom.CreateToken AccessPolicyID: payload.AccessPolicyID, Token: fmt.Sprintf("completely_fake_token_%s", util.GenerateShortUID()), } + client.token = &gcom.TokenView{ + ID: token.ID, + Name: token.Name, + AccessPolicyID: token.AccessPolicyID, + DisplayName: token.Name, + } return token, nil } diff --git a/pkg/services/cloudmigration/model.go b/pkg/services/cloudmigration/model.go index adbbf5c1249..0b58b1e1c71 100644 --- a/pkg/services/cloudmigration/model.go +++ b/pkg/services/cloudmigration/model.go @@ -14,6 +14,7 @@ var ( ErrMigrationNotFound = errutil.NotFound("cloudmigrations.migrationNotFound").Errorf("Migration not found") ErrMigrationRunNotFound = errutil.NotFound("cloudmigrations.migrationRunNotFound").Errorf("Migration run not found") ErrMigrationNotDeleted = errutil.Internal("cloudmigrations.migrationNotDeleted").Errorf("Migration not deleted") + ErrTokenNotFound = errutil.NotFound("cloudmigrations.tokenNotFound").Errorf("Token not found") ) // CloudMigration api dtos diff --git a/pkg/services/gcom/gcom.go b/pkg/services/gcom/gcom.go index 54be4386742..c72e7d11f17 100644 --- a/pkg/services/gcom/gcom.go +++ b/pkg/services/gcom/gcom.go @@ -20,6 +20,7 @@ type Service interface { CreateAccessPolicy(ctx context.Context, params CreateAccessPolicyParams, payload CreateAccessPolicyPayload) (AccessPolicy, error) ListAccessPolicies(ctx context.Context, params ListAccessPoliciesParams) ([]AccessPolicy, error) DeleteAccessPolicy(ctx context.Context, params DeleteAccessPolicyParams) (bool, error) + ListTokens(ctx context.Context, params ListTokenParams) ([]TokenView, error) CreateToken(ctx context.Context, params CreateTokenParams, payload CreateTokenPayload) (Token, error) } @@ -73,6 +74,13 @@ type DeleteAccessPolicyParams struct { Region string } +type ListTokenParams struct { + RequestID string + Region string + AccessPolicyName string + TokenName string +} + type CreateTokenParams struct { RequestID string Region string @@ -85,6 +93,7 @@ type CreateTokenPayload struct { ExpiresAt time.Time `json:"expiresAt"` } +// The token returned by gcom api when a token gets created. type Token struct { ID string `json:"id"` AccessPolicyID string `json:"accessPolicyId"` @@ -92,6 +101,22 @@ type Token struct { Token string `json:"token"` } +// The token returned by gcom api for a GET token request. +type TokenView struct { + ID string `json:"id"` + AccessPolicyID string `json:"accessPolicyId"` + Name string `json:"name"` + DisplayName string `json:"displayName"` + ExpiresAt string `json:"expiresAt"` + FirstUsedAt string `json:"firstUsedAt"` + LastUsedAt string `json:"lastUsedAt"` + CreatedAt string `json:"createdAt"` +} + +type listTokensResponse struct { + Items []TokenView `json:"items"` +} + type GcomClient struct { log log.Logger cfg Config @@ -281,6 +306,50 @@ func (client *GcomClient) ListAccessPolicies(ctx context.Context, params ListAcc return responseBody.Items, nil } +func (client *GcomClient) ListTokens(ctx context.Context, params ListTokenParams) ([]TokenView, error) { + endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/tokens") + if err != nil { + return nil, fmt.Errorf("building gcom tokens url: %w", err) + } + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("creating http request: %w", err) + } + + query := url.Values{} + query.Set("region", params.Region) + query.Set("accessPolicyName", params.AccessPolicyName) + query.Set("name", params.TokenName) + + request.URL.RawQuery = query.Encode() + request.Header.Set("x-request-id", params.RequestID) + request.Header.Set("Content-Type", "application/json") + + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.cfg.Token)) + + response, err := client.httpClient.Do(request) + if err != nil { + return nil, fmt.Errorf("sending http request to list access tokens: %w", err) + } + defer func() { + if err := response.Body.Close(); err != nil { + client.log.Error("closing http response body", "err", err.Error()) + } + }() + + if response.StatusCode != http.StatusOK { + body, _ := io.ReadAll(response.Body) + return nil, fmt.Errorf("unexpected response when fetching access tokens: code=%d body=%s", response.StatusCode, body) + } + + var body listTokensResponse + if err := json.NewDecoder(response.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("unmarshaling response body: %w", err) + } + + return body.Items, nil +} func (client *GcomClient) CreateToken(ctx context.Context, params CreateTokenParams, payload CreateTokenPayload) (Token, error) { endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/tokens") if err != nil { diff --git a/pkg/setting/setting_cloud_migration.go b/pkg/setting/setting_cloud_migration.go index a289123edee..e5de08f83f0 100644 --- a/pkg/setting/setting_cloud_migration.go +++ b/pkg/setting/setting_cloud_migration.go @@ -11,6 +11,7 @@ type CloudMigrationSettings struct { CreateAccessPolicyTimeout time.Duration FetchAccessPolicyTimeout time.Duration DeleteAccessPolicyTimeout time.Duration + ListTokensTimeout time.Duration CreateTokenTimeout time.Duration TokenExpiresAfter time.Duration @@ -25,6 +26,7 @@ func (cfg *Cfg) readCloudMigrationSettings() { cfg.CloudMigration.CreateAccessPolicyTimeout = cloudMigration.Key("create_access_policy_timeout").MustDuration(5 * time.Second) cfg.CloudMigration.FetchAccessPolicyTimeout = cloudMigration.Key("fetch_access_policy_timeout").MustDuration(5 * time.Second) cfg.CloudMigration.DeleteAccessPolicyTimeout = cloudMigration.Key("delete_access_policy_timeout").MustDuration(5 * time.Second) + cfg.CloudMigration.ListTokensTimeout = cloudMigration.Key("list_tokens_timeout").MustDuration(5 * time.Second) cfg.CloudMigration.CreateTokenTimeout = cloudMigration.Key("create_token_timeout").MustDuration(5 * time.Second) cfg.CloudMigration.TokenExpiresAfter = cloudMigration.Key("token_expires_after").MustDuration(7 * 24 * time.Hour) cfg.CloudMigration.IsDeveloperMode = cloudMigration.Key("developer_mode").MustBool(false) diff --git a/public/api-merged.json b/public/api-merged.json index c41ab3f3734..a426e77cecd 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -2322,6 +2322,16 @@ ], "summary": "Create a migration.", "operationId": "createMigration", + "parameters": [ + { + "name": "Body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CloudMigrationRequest" + } + } + ], "responses": { "200": { "$ref": "#/responses/cloudMigrationResponse" @@ -2494,6 +2504,30 @@ } }, "/cloudmigration/token": { + "get": { + "tags": [ + "migrations" + ], + "summary": "Fetch the cloud migration token if it exists.", + "operationId": "getCloudMigrationToken", + "responses": { + "200": { + "$ref": "#/responses/cloudMigrationGetTokenResponse" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "403": { + "$ref": "#/responses/forbiddenError" + }, + "404": { + "$ref": "#/responses/notFoundError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + }, "post": { "tags": [ "migrations" @@ -13207,6 +13241,14 @@ } } }, + "CloudMigrationRequest": { + "type": "object", + "properties": { + "authToken": { + "type": "string" + } + } + }, "CloudMigrationResponse": { "type": "object", "properties": { @@ -15075,6 +15117,29 @@ } } }, + "GetAccessTokenResponseDTO": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "expiresAt": { + "type": "string" + }, + "firstUsedAt": { + "type": "string" + }, + "id": { + "type": "string" + }, + "lastUsedAt": { + "type": "string" + } + } + }, "GetAnnotationTagsResponse": { "type": "object", "title": "GetAnnotationTagsResponse is a response struct for FindTagsResult.", @@ -22162,6 +22227,12 @@ "$ref": "#/definitions/CreateAccessTokenResponseDTO" } }, + "cloudMigrationGetTokenResponse": { + "description": "(empty)", + "schema": { + "$ref": "#/definitions/GetAccessTokenResponseDTO" + } + }, "cloudMigrationListResponse": { "description": "(empty)", "schema": { diff --git a/public/openapi3.json b/public/openapi3.json index 1731977c869..bb8d9217ca5 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -183,6 +183,16 @@ }, "description": "(empty)" }, + "cloudMigrationGetTokenResponse": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetAccessTokenResponseDTO" + } + } + }, + "description": "(empty)" + }, "cloudMigrationListResponse": { "content": { "application/json": { @@ -3656,6 +3666,14 @@ }, "type": "object" }, + "CloudMigrationRequest": { + "properties": { + "authToken": { + "type": "string" + } + }, + "type": "object" + }, "CloudMigrationResponse": { "properties": { "created": { @@ -5524,6 +5542,29 @@ }, "type": "object" }, + "GetAccessTokenResponseDTO": { + "properties": { + "createdAt": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "expiresAt": { + "type": "string" + }, + "firstUsedAt": { + "type": "string" + }, + "id": { + "type": "string" + }, + "lastUsedAt": { + "type": "string" + } + }, + "type": "object" + }, "GetAnnotationTagsResponse": { "properties": { "result": { @@ -14995,6 +15036,17 @@ }, "post": { "operationId": "createMigration", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CloudMigrationRequest" + } + } + }, + "required": true, + "x-originalParamName": "Body" + }, "responses": { "200": { "$ref": "#/components/responses/cloudMigrationResponse" @@ -15181,6 +15233,30 @@ } }, "/cloudmigration/token": { + "get": { + "operationId": "getCloudMigrationToken", + "responses": { + "200": { + "$ref": "#/components/responses/cloudMigrationGetTokenResponse" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "403": { + "$ref": "#/components/responses/forbiddenError" + }, + "404": { + "$ref": "#/components/responses/notFoundError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "Fetch the cloud migration token if it exists.", + "tags": [ + "migrations" + ] + }, "post": { "operationId": "createCloudMigrationToken", "responses": {