CloudMigration - Display different error messages for create migration errors (#94683)

* start on tokens

* more error messages

* more handling

* rephrased with suggestions from Daniel

* separate gms parse method

* use translation

* refactor initial idea to use error obj

* use error dto result

* handle gms client

* clean logs and comments

* fix tests

* tests for gms

* test and lint

* lint

* one more handling from gms

* typing in fe

* use error interface

* use validation error

* remove unused gms error

* use errorlib and helper function in fe

* regen api

* use same error util

* one more error to handle
This commit is contained in:
Dana Axinte 2024-10-21 04:45:54 -04:00 committed by GitHub
parent d608668335
commit 98e5048370
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 193 additions and 36 deletions

View File

@ -324,7 +324,7 @@ func (s *Service) ValidateToken(ctx context.Context, cm cloudmigration.CloudMigr
defer span.End()
if err := s.gmsClient.ValidateKey(ctx, cm); err != nil {
return fmt.Errorf("validating token: %w", err)
return err
}
return nil
@ -398,22 +398,22 @@ func (s *Service) CreateSession(ctx context.Context, cmd cloudmigration.CloudMig
base64Token := cmd.AuthToken
b, err := base64.StdEncoding.DecodeString(base64Token)
if err != nil {
return nil, fmt.Errorf("token could not be decoded")
return nil, cloudmigration.ErrTokenInvalid.Errorf("token could not be decoded")
}
var token cloudmigration.Base64EncodedTokenPayload
if err := json.Unmarshal(b, &token); err != nil {
return nil, fmt.Errorf("invalid token") // don't want to leak info here
return nil, cloudmigration.ErrTokenInvalid.Errorf("token could not be decoded") // don't want to leak info here
}
migration := token.ToMigration()
// validate token against GMS before saving
if err := s.ValidateToken(ctx, migration); err != nil {
return nil, fmt.Errorf("token validation: %w", err)
return nil, err
}
cm, err := s.store.CreateMigrationSession(ctx, migration)
if err != nil {
return nil, fmt.Errorf("error creating migration: %w", err)
return nil, cloudmigration.ErrSessionCreationFailure.Errorf("error creating migration")
}
s.report(ctx, &migration, gmsclient.EventConnect, 0, nil)

View File

@ -26,7 +26,7 @@ func (s *NoopServiceImpl) DeleteToken(ctx context.Context, uid string) error {
}
func (s *NoopServiceImpl) ValidateToken(ctx context.Context, cm cloudmigration.CloudMigrationSession) error {
return cloudmigration.ErrFeatureDisabledError
return cloudmigration.ErrMigrationDisabled
}
func (s *NoopServiceImpl) GetSession(ctx context.Context, uid string) (*cloudmigration.CloudMigrationSession, error) {
@ -38,7 +38,7 @@ func (s *NoopServiceImpl) GetSessionList(ctx context.Context) (*cloudmigration.C
}
func (s *NoopServiceImpl) CreateSession(ctx context.Context, cm cloudmigration.CloudMigrationSessionRequest) (*cloudmigration.CloudMigrationSessionResponse, error) {
return nil, cloudmigration.ErrFeatureDisabledError
return nil, cloudmigration.ErrMigrationDisabled
}
func (s *NoopServiceImpl) DeleteSession(ctx context.Context, uid string) (*cloudmigration.CloudMigrationSession, error) {

View File

@ -47,7 +47,7 @@ func (m FakeServiceImpl) DeleteToken(_ context.Context, _ string) error {
func (m FakeServiceImpl) CreateSession(_ context.Context, _ cloudmigration.CloudMigrationSessionRequest) (*cloudmigration.CloudMigrationSessionResponse, error) {
if m.ReturnError {
return nil, fmt.Errorf("mock error")
return nil, cloudmigration.ErrSessionCreationFailure
}
return &cloudmigration.CloudMigrationSessionResponse{
UID: "fake_uid",

View File

@ -62,3 +62,14 @@ const (
EventStartUploadingSnapshot LocalEventType = "start_uploading_snapshot"
EventDoneUploadingSnapshot LocalEventType = "done_uploading_snapshot"
)
type GMSAPIError struct {
Message string `json:"message"`
}
// Error messages returned from GMS
var (
GMSErrorMessageInstanceUnreachable = "instance is unreachable"
GMSErrorMessageInstanceCheckingError = "checking if instance is reachable"
GMSErrorMessageInstanceFetching = "fetching instance by stack id"
)

View File

@ -49,7 +49,7 @@ func (c *gmsClientImpl) ValidateKey(ctx context.Context, cm cloudmigration.Cloud
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewReader(nil))
if err != nil {
c.log.Error("error creating http request for token validation", "err", err.Error())
return fmt.Errorf("http request error: %w", err)
return cloudmigration.ErrTokenRequestError.Errorf("create http request error")
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %d:%s", cm.StackID, cm.AuthToken))
@ -57,17 +57,21 @@ func (c *gmsClientImpl) ValidateKey(ctx context.Context, cm cloudmigration.Cloud
resp, err := c.httpClient.Do(req)
if err != nil {
c.log.Error("error sending http request for token validation", "err", err.Error())
return fmt.Errorf("http request error: %w", err)
return cloudmigration.ErrTokenRequestError.Errorf("send http request error")
}
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
err = errors.Join(err, fmt.Errorf("closing response body: %w", closeErr))
c.log.Error("error closing the request body", "err", err.Error())
err = errors.Join(err, cloudmigration.ErrTokenRequestError.Errorf("closing response body"))
}
}()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("token validation failure: %v", string(body))
if gmsErr := c.handleGMSErrors(body); gmsErr != nil {
return gmsErr
}
return cloudmigration.ErrTokenValidationFailure.Errorf("token validation failure")
}
return nil
@ -258,3 +262,22 @@ func (c *gmsClientImpl) buildBasePath(clusterSlug string) string {
}
return fmt.Sprintf("https://cms-%s.%s/cloud-migrations", clusterSlug, domain)
}
// handleGMSErrors parses the error message from GMS and translates it to an appropriate error message
// use ErrTokenValidationFailure for any errors which are not specifically handled
func (c *gmsClientImpl) handleGMSErrors(responseBody []byte) error {
var apiError GMSAPIError
if err := json.Unmarshal(responseBody, &apiError); err != nil {
return cloudmigration.ErrTokenValidationFailure.Errorf("token validation failure")
}
if strings.Contains(apiError.Message, GMSErrorMessageInstanceUnreachable) {
return cloudmigration.ErrInstanceUnreachable.Errorf("instance unreachable")
} else if strings.Contains(apiError.Message, GMSErrorMessageInstanceCheckingError) {
return cloudmigration.ErrInstanceRequestError.Errorf("instance checking error")
} else if strings.Contains(apiError.Message, GMSErrorMessageInstanceFetching) {
return cloudmigration.ErrInstanceRequestError.Errorf("fetching instance")
}
return cloudmigration.ErrTokenValidationFailure.Errorf("token validation failure")
}

View File

@ -4,6 +4,7 @@ import (
"net/http"
"testing"
"github.com/grafana/grafana/pkg/services/cloudmigration"
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -65,3 +66,48 @@ func Test_buildBasePath(t *testing.T) {
})
}
}
func Test_handleGMSErrors(t *testing.T) {
t.Parallel()
c, err := NewGMSClient(&setting.Cfg{
CloudMigration: setting.CloudMigrationSettings{
GMSDomain: "http://some-domain:8080",
},
},
http.DefaultClient,
)
require.NoError(t, err)
client := c.(*gmsClientImpl)
testscases := []struct {
gmsResBody []byte
expectedError error
}{
{
gmsResBody: []byte(`{"message":"instance is unreachable, make sure the instance is running"}`),
expectedError: cloudmigration.ErrInstanceUnreachable,
},
{
gmsResBody: []byte(`{"message":"checking if instance is reachable"}`),
expectedError: cloudmigration.ErrInstanceRequestError,
},
{
gmsResBody: []byte(`{"message":"fetching instance by stack id 1234"}`),
expectedError: cloudmigration.ErrInstanceRequestError,
},
{
gmsResBody: []byte(`{"status":"error","error":"authentication error: invalid token"}`),
expectedError: cloudmigration.ErrTokenValidationFailure,
},
{
gmsResBody: []byte(""),
expectedError: cloudmigration.ErrTokenValidationFailure,
},
}
for _, tc := range testscases {
resError := client.handleGMSErrors(tc.gmsResBody)
require.ErrorIs(t, resError, tc.expectedError)
}
}

View File

@ -254,3 +254,13 @@ const (
SnapshotStateError SnapshotState = "ERROR"
SnapshotStateUnknown SnapshotState = "UNKNOWN"
)
var (
ErrTokenInvalid = errutil.Internal("cloudmigrations.createMigration.tokenInvalid", errutil.WithPublicMessage("Token is not valid. Generate a new token on your cloud instance and try again."))
ErrTokenRequestError = errutil.Internal("cloudmigrations.createMigration.tokenRequestError", errutil.WithPublicMessage("An error occurred while validating the token. Please check the Grafana instance logs."))
ErrTokenValidationFailure = errutil.Internal("cloudmigrations.createMigration.tokenValidationFailure", errutil.WithPublicMessage("Token is not valid. Please ensure the token matches the migration token on your cloud instance."))
ErrInstanceUnreachable = errutil.Internal("cloudmigrations.createMigration.instanceUnreachable", errutil.WithPublicMessage("The cloud instance cannot be reached. Make sure the instance is running and try again."))
ErrInstanceRequestError = errutil.Internal("cloudmigrations.createMigration.instanceRequestError", errutil.WithPublicMessage("An error occurred while attempting to verify the cloud instance's connectivity. Please check the network settings or cloud instance status."))
ErrSessionCreationFailure = errutil.Internal("cloudmigrations.createMigration.sessionCreationFailure", errutil.WithPublicMessage("There was an error creating the migration. Please try again."))
ErrMigrationDisabled = errutil.Internal("cloudmigrations.createMigration.migrationDisabled", errutil.WithPublicMessage("Cloud migrations are disabled on this instance."))
)

View File

@ -0,0 +1,19 @@
import { isFetchError } from '@grafana/runtime';
// TODO: candidate to hoist and share
export function maybeAPIError(err: unknown) {
if (!isFetchError<unknown>(err) || typeof err.data !== 'object' || !err.data) {
return null;
}
const data = err?.data;
const message = 'message' in data && typeof data.message === 'string' ? data.message : null;
const messageId = 'messageId' in data && typeof data.messageId === 'string' ? data.messageId : null;
const statusCode = 'statusCode' in data && typeof data.statusCode === 'number' ? data.statusCode : null;
if (!message || !messageId || !statusCode) {
return null;
}
return { message, messageId, statusCode };
}

View File

@ -1,6 +1,6 @@
import { useCallback, useState } from 'react';
import { isFetchError, reportInteraction } from '@grafana/runtime';
import { reportInteraction } from '@grafana/runtime';
import { Box, Button, Text } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
@ -9,30 +9,13 @@ import {
useDeleteCloudMigrationTokenMutation,
useGetCloudMigrationTokenQuery,
} from '../../api';
import { maybeAPIError } from '../../api/errors';
import { TokenErrorAlert } from '../TokenErrorAlert';
import { CreateTokenModal } from './CreateTokenModal';
import { DeleteTokenConfirmationModal } from './DeleteTokenConfirmationModal';
import { TokenStatus } from './TokenStatus';
// TODO: candidate to hoist and share
function maybeAPIError(err: unknown) {
if (!isFetchError<unknown>(err) || typeof err.data !== 'object' || !err.data) {
return null;
}
const data = err?.data;
const message = 'message' in data && typeof data.message === 'string' ? data.message : null;
const messageId = 'messageId' in data && typeof data.messageId === 'string' ? data.messageId : null;
const statusCode = 'statusCode' in data && typeof data.statusCode === 'number' ? data.statusCode : null;
if (!message || !messageId || !statusCode) {
return null;
}
return { message, messageId, statusCode };
}
export const MigrationTokenPane = () => {
const [showCreateModal, setShowCreateModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);

View File

@ -8,6 +8,7 @@ import { Trans, t } from 'app/core/internationalization';
import { AlertWithTraceID } from 'app/features/migrate-to-cloud/shared/AlertWithTraceID';
import { CreateSessionApiArg } from '../../../api';
import { maybeAPIError } from '../../../api/errors';
interface Props {
isOpen: boolean;
@ -21,6 +22,51 @@ interface FormData {
token: string;
}
function getTMessage(messageId: string): string {
switch (messageId) {
case 'cloudmigrations.createMigration.tokenInvalid':
return t(
'migrate-to-cloud.connect-modal.token-errors.token-invalid',
'Token is not valid. Generate a new token on your cloud instance and try again.'
);
case 'cloudmigrations.createMigration.tokenRequestError':
return t(
'migrate-to-cloud.connect-modal.token-errors.token-request-error',
'An error occurred while validating the token. Please check the Grafana instance logs.'
);
case 'cloudmigrations.createMigration.tokenValidationFailure':
return t(
'migrate-to-cloud.connect-modal.token-errors.token-validation-failure',
'Token is not valid. Please ensure the token matches the migration token on your cloud instance.'
);
case 'cloudmigrations.createMigration.instanceUnreachable':
return t(
'migrate-to-cloud.connect-modal.token-errors.instance-unreachable',
'The cloud instance cannot be reached. Make sure the instance is running and try again.'
);
case 'cloudmigrations.createMigration.instanceRequestError':
return t(
'migrate-to-cloud.connect-modal.token-errors.instance-request-error',
"An error occurred while attempting to verify the cloud instance's connectivity. Please check the network settings or cloud instance status."
);
case 'cloudmigrations.createMigration.sessionCreationFailure':
return t(
'migrate-to-cloud.connect-modal.token-errors.session-creation-failure',
'There was an error creating the migration. Please try again.'
);
case 'cloudmigrations.createMigration.migrationDisabled':
return t(
'migrate-to-cloud.connect-modal.token-errors.migration-disabled',
'Cloud migrations are disabled on this instance.'
);
default:
return t(
'migrate-to-cloud.connect-modal.token-errors.token-not-saved',
'There was an error saving the token. See the Grafana server logs for more details.'
);
}
}
export const ConnectModal = ({ isOpen, isLoading, error, hideModal, onConfirm }: Props) => {
const tokenId = useId();
const styles = useStyles2(getStyles);
@ -101,9 +147,10 @@ export const ConnectModal = ({ isOpen, isLoading, error, hideModal, onConfirm }:
severity="error"
title={t('migrate-to-cloud.connect-modal.token-error-title', 'Error saving token')}
>
<Trans i18nKey="migrate-to-cloud.connect-modal.token-error-description">
There was an error saving the token. See the Grafana server logs for more details.
</Trans>
<Text element="p">
{getTMessage(maybeAPIError(error)?.messageId || '') ||
'There was an error saving the token. See the Grafana server logs for more details.'}
</Text>
</AlertWithTraceID>
) : undefined}

View File

@ -1402,8 +1402,17 @@
"connect": "Connect to this stack",
"connecting": "Connecting to this stack...",
"title": "Connect to a cloud stack",
"token-error-description": "There was an error saving the token. See the Grafana server logs for more details.",
"token-error-title": "Error saving token",
"token-errors": {
"instance-request-error": "An error occurred while attempting to verify the cloud instance's connectivity. Please check the network settings or cloud instance status.",
"instance-unreachable": "The cloud instance cannot be reached. Make sure the instance is running and try again.",
"migration-disabled": "Cloud migrations are disabled on this instance.",
"session-creation-failure": "There was an error creating the migration. Please try again.",
"token-invalid": "Token is not valid. Generate a new token on your cloud instance and try again.",
"token-not-saved": "There was an error saving the token. See the Grafana server logs for more details.",
"token-request-error": "An error occurred while validating the token. Please check the Grafana instance logs.",
"token-validation-failure": "Token is not valid. Please ensure the token matches the migration token on your cloud instance."
},
"token-required-error": "Migration token is required"
},
"cta": {

View File

@ -1402,8 +1402,17 @@
"connect": "Cőʼnʼnęčŧ ŧő ŧĥįş şŧäčĸ",
"connecting": "Cőʼnʼnęčŧįʼnģ ŧő ŧĥįş şŧäčĸ...",
"title": "Cőʼnʼnęčŧ ŧő ä čľőūđ şŧäčĸ",
"token-error-description": "Ŧĥęřę ŵäş äʼn ęřřőř şävįʼnģ ŧĥę ŧőĸęʼn. Ŝęę ŧĥę Ğřäƒäʼnä şęřvęř ľőģş ƒőř mőřę đęŧäįľş.",
"token-error-title": "Ēřřőř şävįʼnģ ŧőĸęʼn",
"token-errors": {
"instance-request-error": "Åʼn ęřřőř őččūřřęđ ŵĥįľę äŧŧęmpŧįʼnģ ŧő vęřįƒy ŧĥę čľőūđ įʼnşŧäʼnčę'ş čőʼnʼnęčŧįvįŧy. Pľęäşę čĥęčĸ ŧĥę ʼnęŧŵőřĸ şęŧŧįʼnģş őř čľőūđ įʼnşŧäʼnčę şŧäŧūş.",
"instance-unreachable": "Ŧĥę čľőūđ įʼnşŧäʼnčę čäʼnʼnőŧ þę řęäčĥęđ. Mäĸę şūřę ŧĥę įʼnşŧäʼnčę įş řūʼnʼnįʼnģ äʼnđ ŧřy äģäįʼn.",
"migration-disabled": "Cľőūđ mįģřäŧįőʼnş äřę đįşäþľęđ őʼn ŧĥįş įʼnşŧäʼnčę.",
"session-creation-failure": "Ŧĥęřę ŵäş äʼn ęřřőř čřęäŧįʼnģ ŧĥę mįģřäŧįőʼn. Pľęäşę ŧřy äģäįʼn.",
"token-invalid": "Ŧőĸęʼn įş ʼnőŧ väľįđ. Ğęʼnęřäŧę ä ʼnęŵ ŧőĸęʼn őʼn yőūř čľőūđ įʼnşŧäʼnčę äʼnđ ŧřy äģäįʼn.",
"token-not-saved": "Ŧĥęřę ŵäş äʼn ęřřőř şävįʼnģ ŧĥę ŧőĸęʼn. Ŝęę ŧĥę Ğřäƒäʼnä şęřvęř ľőģş ƒőř mőřę đęŧäįľş.",
"token-request-error": "Åʼn ęřřőř őččūřřęđ ŵĥįľę väľįđäŧįʼnģ ŧĥę ŧőĸęʼn. Pľęäşę čĥęčĸ ŧĥę Ğřäƒäʼnä įʼnşŧäʼnčę ľőģş.",
"token-validation-failure": "Ŧőĸęʼn įş ʼnőŧ väľįđ. Pľęäşę ęʼnşūřę ŧĥę ŧőĸęʼn mäŧčĥęş ŧĥę mįģřäŧįőʼn ŧőĸęʼn őʼn yőūř čľőūđ įʼnşŧäʼnčę."
},
"token-required-error": "Mįģřäŧįőʼn ŧőĸęʼn įş řęqūįřęđ"
},
"cta": {