From a7f291a38e84e99c3e513ce2ea7b298ec4e62c55 Mon Sep 17 00:00:00 2001 From: Rodrigue Koffi Date: Wed, 15 May 2024 21:47:19 +0200 Subject: [PATCH] Add support for Managed Grafana v10 (#28) * Introduce support for service account tokens * Update README * Update tests * Bump binary version * Fix existing tests * Update SDK for v10 support * Go mod tidy --- README.md | 57 ++++++++++++++++---- go.mod | 6 +-- go.sum | 12 ++--- internal/pkg/app/app.go | 9 +--- internal/pkg/app/app_test.go | 2 +- internal/pkg/app/input.go | 81 ++++++++++++++++++---------- internal/pkg/app/input_test.go | 43 +++++++++------ internal/pkg/aws/aws.go | 84 +++++++++++++++++++++++++++++- internal/pkg/aws/mocks/mock_aws.go | 82 +++++++++++++++++++++++++++++ internal/pkg/cli/migrate.go | 31 ++++------- main.go | 2 +- 11 files changed, 315 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index 1e779e8..c5763ee 100644 --- a/README.md +++ b/README.md @@ -3,22 +3,25 @@ [![Build](https://github.com/aws-observability/amazon-managed-grafana-migrator/actions/workflows/go.yml/badge.svg)](https://github.com/aws-observability/amazon-managed-grafana-migrator/actions/workflows/go.yml) [![Go Report Card](https://goreportcard.com/badge/github.com/aws-observability/amazon-managed-grafana-migrator)](https://goreportcard.com/report/github.com/aws-observability/amazon-managed-grafana-migrator) -🚨 Jul-25: Alerts migration are currently disabled with v0.1.9. See [#19](https://github.com/aws-observability/amazon-managed-grafana-migrator/issues/19) +🎉 May-15-24: v0.2.0 supports Grafana Service Accounts for v9 and v10 workspaces. +See [Amazon Managed Grafana announces support for Grafana version 10.4]() -🎉 Jul-19: [Amazon Grafana supports now in-place update from v8.4 to v9.4](https://aws.amazon.com/about-aws/whats-new/2023/07/amazon-managed-grafana-in-place-update-version-9-4/) +🚨 Jul-25-23: Alerts migration are currently disabled with v0.1.9. See [#19](https://github.com/aws-observability/amazon-managed-grafana-migrator/issues/19) + +🎉 Jul-19-23: [Amazon Grafana supports now in-place update from v8.4 to v9.4](https://aws.amazon.com/about-aws/whats-new/2023/07/amazon-managed-grafana-in-place-update-version-9-4/) Amazon Managed Grafana Migrator is a CLI migration utility to migrate Grafana content (data sources, dashboards, folders and alert rules) to Amazon Managed Grafana. It supports the following migration scenarios: -- Migrating from and to Amazon Managed Grafana Workspace (eg. Moving to v9.4), although consider using the native functionality in the AWS Console +- Migrating from and to Amazon Managed Grafana Workspace (eg. Moving to v10.4), although consider using the native functionality in the AWS Console, after testing - Migrating from a Grafana server to an Amazon Managed Grafana Workspace - -:warning: Alerting rules migration are only supported in a migration to Amazon -Managed Grafana v9.4. (migrating alerts to v8.x is not supported) +Amazon Managed Grafana v10.4 workspaces will require to +provide an `ADMIN` level [Grafana Service Account]() with the +`--src-service-account-id` or `--src-service-account-id` flags. ## Installation @@ -52,6 +55,30 @@ amazon-managed-grafana-migrator -v amazon-managed-grafana-migrator discover --region eu-west-1 ``` +### Migrating to Amazon Managed Grafana v10 + +v9 and v10 introduced Grafana Service Accounts which will be required by the +migrator, especially for v10. Note that Service Accounts are billed as active +users + +1. Creating a Service Account + +```console + aws grafana create-workspace-service-account --workspace-id g-abcdef5678 \ + --grafana-role ADMIN \ + --name +``` + +2. Running the migration + +```console +amazon-managed-grafana-migrator migrate \ + --src-url https://grafana.example.com/ + --src-api-key API_KEY_HERE + --dst g-abcdef5678.grafana-workspace.us-west-2.amazonaws.com + --dst-service-account-id SERVICE_ACCOUNT_ID_HERE +``` + ### Migrating between Workspaces ```console @@ -60,7 +87,17 @@ amazon-managed-grafana-migrator migrate \ --dst g-abcdef5678.grafana-workspace.us-west-2.amazonaws.com ``` -### Migrating to Amazon Managed Grafana +Or for v9+ workspaces: + +```console +amazon-managed-grafana-migrator migrate \ + --src g-abcdef1234.grafana-workspace.eu-central-1.amazonaws.com \ + --src-service-account-id SERVICE_ACCOUNT_ID_HERE + --dst g-abcdef5678.grafana-workspace.us-west-2.amazonaws.com + --dst-service-account-id SERVICE_ACCOUNT_ID_HERE +``` + +### Migrating to Amazon Managed Grafana v8/v9 ```console amazon-managed-grafana-migrator migrate \ @@ -91,9 +128,11 @@ start. Below are the minimum permissions required by the tool: { "Effect": "Allow", "Action": [ - "grafana:DeleteWorkspaceApiKey", "grafana:DescribeWorkspace", - "grafana:CreateWorkspaceApiKey" + "grafana:CreateWorkspaceApiKey", + "grafana:DeleteWorkspaceApiKey", + "grafana:CreateWorkspaceServiceAccountToken", + "grafana:DeleteWorkspaceServiceAccountToken" ], "Resource": "arn:aws:grafana:*::/workspaces/" }, diff --git a/go.mod b/go.mod index 2dd7b64..194c9f3 100644 --- a/go.mod +++ b/go.mod @@ -15,13 +15,13 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/sys v0.19.0 // indirect + golang.org/x/sys v0.20.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) require ( - github.com/aws/aws-sdk-go v1.51.30 - github.com/fatih/color v1.16.0 + github.com/aws/aws-sdk-go v1.53.3 + github.com/fatih/color v1.17.0 github.com/golang/mock v1.6.0 github.com/grafana/grafana-api-golang-client v0.27.0 github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/go.sum b/go.sum index 3721dc3..1a447fc 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,11 @@ -github.com/aws/aws-sdk-go v1.51.30 h1:RVFkjn9P0JMwnuZCVH0TlV5k9zepHzlbc4943eZMhGw= -github.com/aws/aws-sdk-go v1.51.30/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= +github.com/aws/aws-sdk-go v1.53.3 h1:xv0iGCCLdf6ZtlLPMCBjm+tU9UBLP5hXnSqnbKFYmto= +github.com/aws/aws-sdk-go v1.53.3/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= -github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= +github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b h1:/vQ+oYKu+JoyaMPDsv5FzwuL2wwWBgBbtj/YLCi4LuA= github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b/go.mod h1:Xo4aNUOrJnVruqWQJBtW6+bTBDTniY8yZum5rF3b5jw= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= @@ -51,8 +51,8 @@ golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= -golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= diff --git a/internal/pkg/app/app.go b/internal/pkg/app/app.go index d3eed62..953b8fb 100644 --- a/internal/pkg/app/app.go +++ b/internal/pkg/app/app.go @@ -31,7 +31,7 @@ type App struct { // const minAlertingMigrationVersion = 9.4 // Run orchestrates the migration of grafana contents -func (a *App) Run(srcCustomGrafanaClient CustomGrafanaClient) error { +func (a *App) Run() error { log.Info() migratedDs, err := a.migrateDataSources() if err != nil { @@ -54,12 +54,5 @@ func (a *App) Run(srcCustomGrafanaClient CustomGrafanaClient) error { log.Info() log.Info("Skipping alert rules migration") - /* - alertsMigrated, err := a.migrateAlertRules(fx, srcCustomGrafanaClient) - if err != nil { - return err - } - log.Success("Migrated ", alertsMigrated, " alerts") - */ return nil } diff --git a/internal/pkg/app/app_test.go b/internal/pkg/app/app_test.go index f135ebf..bd13425 100644 --- a/internal/pkg/app/app_test.go +++ b/internal/pkg/app/app_test.go @@ -176,7 +176,7 @@ func TestApp_Run(t *testing.T) { Dst: mockDst, } - err := app.Run(CustomGrafanaClient{Client: mockcustomAPI}) + err := app.Run() if tc.expectedError != nil { require.EqualError(t, err, tc.expectedError.Error()) } else { diff --git a/internal/pkg/app/input.go b/internal/pkg/app/input.go index d99d277..f449cc2 100644 --- a/internal/pkg/app/input.go +++ b/internal/pkg/app/input.go @@ -11,37 +11,45 @@ import ( gapi "github.com/grafana/grafana-api-golang-client" ) +const ( + AMG_V10 = "10.4" + AMG_V9 = "9.4" + AMG_V8 = "8.4" +) + // GrafanaInput holds the infos about the grafana server from the CLI type GrafanaInput struct { - URL string - WorkspaceID string - APIKey string - Region string - IsAMG bool - IsGamma bool + URL string + WorkspaceID string + APIKey string + Region string + ServiceAccountID string + WorkspaceVersion string + IsAMG bool + IsGamma bool } // GrafanaHTTPClient contains the grafana client and AWS API key type GrafanaHTTPClient struct { Client *gapi.Client - Key aws.AMGApiKey + Auth aws.GrafanaAuth Input *GrafanaInput } // NewGrafanaInput validate input from command line to return a GrafanaInput object -func NewGrafanaInput(wkspEndpoint, url, apiKey string) (GrafanaInput, error) { - +func NewGrafanaInput(wkspEndpoint, url, serviceAccountID, apiKey string) (GrafanaInput, error) { if wkspEndpoint != "" { sx := strings.Split(wkspEndpoint, ".") if len(sx) != 5 { return GrafanaInput{}, fmt.Errorf("invalid input: workspace should be its DNS endpoint") } return GrafanaInput{ - WorkspaceID: sx[0], - Region: sx[2], - URL: wkspEndpoint, - IsAMG: true, - IsGamma: strings.Contains(sx[1], "gamma"), + WorkspaceID: sx[0], + Region: sx[2], + URL: wkspEndpoint, + ServiceAccountID: serviceAccountID, + IsAMG: true, + IsGamma: strings.Contains(sx[1], "gamma"), }, nil } else if url != "" && apiKey != "" { return GrafanaInput{ @@ -54,8 +62,9 @@ func NewGrafanaInput(wkspEndpoint, url, apiKey string) (GrafanaInput, error) { return GrafanaInput{}, errors.New("invalid input") } -// getAPIKey create api keys only when provided with a managed grafana ID -func (input *GrafanaInput) getAPIKey(awsgrafanacli *aws.AMG) (aws.AMGApiKey, error) { +// getGrafanaAuthToken create Grafana api keys only when provided with a managed grafana ID +// if service account is provided, it will create a service account token +func (input *GrafanaInput) getGrafanaAuthToken(awsgrafanacli *aws.AMG) (aws.GrafanaAuth, error) { if !input.IsAMG { log.InfoLight("Skipping API key creation for ", input.URL) @@ -64,11 +73,23 @@ func (input *GrafanaInput) getAPIKey(awsgrafanacli *aws.AMG) (aws.AMGApiKey, err }, nil } - key, err := awsgrafanacli.CreateWorkspaceApiKey(input.WorkspaceID) - if err != nil { - return key, err + wksp, err := awsgrafanacli.DescribeWorkspace(input.WorkspaceID) + if err == nil { + input.WorkspaceVersion = wksp.Version + } + + // forcing V10 to use service account token + if input.WorkspaceVersion == AMG_V10 && input.ServiceAccountID == "" { + return nil, errors.New("input error: service account ID is required for AMG v10, run migrate -h for help") } - return key, nil + + // creating service account token if service account is provided + if input.ServiceAccountID != "" { + return awsgrafanacli.CreateServiceAccountToken(input.WorkspaceID, input.ServiceAccountID) + } + + // creating temporary API key if no service account is provided + return awsgrafanacli.CreateWorkspaceApiKey(input.WorkspaceID) } // CreateGrafanaAPIClient create a grafana HTTP API client from the input @@ -76,33 +97,37 @@ func (input *GrafanaInput) CreateGrafanaAPIClient(awsgrafanacli *aws.AMG) (*Graf var url string if input.IsAMG { - // could be replaced by describe workspace url = fmt.Sprintf("https://%s", input.URL) } else { url = input.URL } - // get API keys - apiKey, err := input.getAPIKey(awsgrafanacli) + // get final auth key or token + apiKey, err := input.getGrafanaAuthToken(awsgrafanacli) if err != nil { return nil, err } - client, err := gapi.New(url, gapi.Config{APIKey: apiKey.APIKey}) + client, err := gapi.New(url, gapi.Config{APIKey: apiKey.GetAuth()}) if err != nil { return nil, err } return &GrafanaHTTPClient{ Client: client, - Key: apiKey, + Auth: apiKey, Input: input, }, nil } -// DeleteAPIKeys delete the temporary API key from the AWS grafana workspace -func (input *GrafanaInput) DeleteAPIKeys(awsgrafanacli *aws.AMG, apiKey aws.AMGApiKey) error { +// DeleteGrafanaAuth delete the temporary API key from the AWS grafana workspace +func (input *GrafanaInput) DeleteGrafanaAuth(awsgrafanacli *aws.AMG, auth aws.GrafanaAuth) error { if !input.IsAMG { return nil } - return awsgrafanacli.DeleteWorkspaceApiKey(apiKey) + + if input.ServiceAccountID != "" { + return awsgrafanacli.DeleteServiceAccountToken(auth.(aws.AMGServiceAccountToken)) + } + + return awsgrafanacli.DeleteWorkspaceApiKey(auth.(aws.AMGApiKey)) } diff --git a/internal/pkg/app/input_test.go b/internal/pkg/app/input_test.go index 2d6b8fc..19c6b77 100644 --- a/internal/pkg/app/input_test.go +++ b/internal/pkg/app/input_test.go @@ -20,9 +20,10 @@ import ( func TestInput_NewGrafanaInput(t *testing.T) { //test cases tests := map[string]struct { - wkspEndpoint string - url string - apiKey string + wkspEndpoint string + url string + serviceAccountID string + apiKey string expectedResult GrafanaInput expectedError error @@ -31,8 +32,9 @@ func TestInput_NewGrafanaInput(t *testing.T) { // this should be an endpoint instead wkspEndpoint: "g-abcdefg234", // workspace ID is mutually exclusive with URL/API key - url: "", - apiKey: "", + url: "", + apiKey: "", + serviceAccountID: "", //expected input should be expectedResult: GrafanaInput{}, @@ -42,8 +44,9 @@ func TestInput_NewGrafanaInput(t *testing.T) { "grafana workspace": { wkspEndpoint: "g-abcdefg234.grafana-workspace.eu-central-1.amazonaws.com", // workspace ID is mutually exclusive with URL/API key - url: "", - apiKey: "", + url: "", + apiKey: "", + serviceAccountID: "", //expected input should be expectedResult: GrafanaInput{ @@ -58,8 +61,9 @@ func TestInput_NewGrafanaInput(t *testing.T) { "grafana oss": { wkspEndpoint: "", // workspace ID is mutually exclusive with URL/API key - url: "https://grafana.example.com", - apiKey: "fakeAPIKey", + url: "https://grafana.example.com", + apiKey: "fakeAPIKey", + serviceAccountID: "", //expected input should be expectedResult: GrafanaInput{ @@ -71,9 +75,10 @@ func TestInput_NewGrafanaInput(t *testing.T) { expectedError: nil, }, "no input": { - wkspEndpoint: "", - url: "", - apiKey: "", + wkspEndpoint: "", + url: "", + apiKey: "", + serviceAccountID: "", //expected input should be expectedResult: GrafanaInput{}, @@ -84,7 +89,7 @@ func TestInput_NewGrafanaInput(t *testing.T) { for name, tc := range tests { t.Run(name, func(t *testing.T) { - input, err := NewGrafanaInput(tc.wkspEndpoint, tc.url, tc.apiKey) + input, err := NewGrafanaInput(tc.wkspEndpoint, tc.url, tc.serviceAccountID, tc.apiKey) if tc.expectedError != nil { require.EqualError(t, err, tc.expectedError.Error()) @@ -149,7 +154,7 @@ func TestInput_GetApiKey(t *testing.T) { Client: mock, } - apiKey, err := tc.input.getAPIKey(&awsgrafanacli) + apiKey, err := tc.input.getGrafanaAuthToken(&awsgrafanacli) require.Equal(t, tc.expectedResult, apiKey) if tc.expectedError != nil { require.EqualError(t, err, tc.expectedError.Error()) @@ -178,6 +183,14 @@ func TestInput_CreateGrafanaAPIClient(t *testing.T) { "amg client": { input: generateGrafanaInput(t, true), callMock: func(m *mocks.Mockapi) { + m.EXPECT().DescribeWorkspace(gomock.Any()).Return(&managedgrafana.DescribeWorkspaceOutput{ + Workspace: &managedgrafana.WorkspaceDescription{ + GrafanaVersion: awssdk.String("9.4"), + Id: awssdk.String("g-abcdef1234"), + Name: awssdk.String("unittest"), + Endpoint: awssdk.String("g-abcdef1234.grafana-workspace.eu-central-1.amazonaws.com"), + }, + }, nil).AnyTimes() m.EXPECT().CreateWorkspaceApiKey(gomock.Any()).Return(&managedgrafana.CreateWorkspaceApiKeyOutput{ Key: awssdk.String("fakekey"), WorkspaceId: awssdk.String("g-abcdef1234"), @@ -255,7 +268,7 @@ func TestInput_DeleteAPIKeys(t *testing.T) { Client: mock, } - err := tc.input.DeleteAPIKeys(&awsgrafanacli, tc.amgAPIKey) + err := tc.input.DeleteGrafanaAuth(&awsgrafanacli, tc.amgAPIKey) if tc.expectedError != nil { require.EqualError(t, err, tc.expectedError.Error()) diff --git a/internal/pkg/aws/aws.go b/internal/pkg/aws/aws.go index 905815a..2edb9f5 100644 --- a/internal/pkg/aws/aws.go +++ b/internal/pkg/aws/aws.go @@ -14,15 +14,36 @@ import ( type api interface { ListWorkspaces(input *managedgrafana.ListWorkspacesInput) (*managedgrafana.ListWorkspacesOutput, error) CreateWorkspaceApiKey(input *managedgrafana.CreateWorkspaceApiKeyInput) (*managedgrafana.CreateWorkspaceApiKeyOutput, error) + CreateWorkspaceServiceAccountToken(input *managedgrafana.CreateWorkspaceServiceAccountTokenInput) (*managedgrafana.CreateWorkspaceServiceAccountTokenOutput, error) DeleteWorkspaceApiKey(input *managedgrafana.DeleteWorkspaceApiKeyInput) (*managedgrafana.DeleteWorkspaceApiKeyOutput, error) - //DescribeWorkspace(input *managedgrafana.DescribeWorkspaceInput) (*managedgrafana.DescribeWorkspaceOutput, error) + DeleteWorkspaceServiceAccountToken(input *managedgrafana.DeleteWorkspaceServiceAccountTokenInput) (*managedgrafana.DeleteWorkspaceServiceAccountTokenOutput, error) + DescribeWorkspace(input *managedgrafana.DescribeWorkspaceInput) (*managedgrafana.DescribeWorkspaceOutput, error) } -// AMGApiKey holds the dataplane Grafana API key for a workspace +type GrafanaAuth interface { + GetAuth() string +} + +// AMGApiKey is a GrafanaAuth struct for Grafana API key type AMGApiKey struct { KeyName, APIKey, WorkspaceID string } +// GetAuth returns the API key for a workspace +func (k AMGApiKey) GetAuth() string { + return k.APIKey +} + +// AMGServiceAccountToken is a GrafanaAuth struct for service account tokens +type AMGServiceAccountToken struct { + ServiceAccountID, SATokenID, Token, WorkspaceID string +} + +// GetAuth returns the SA token for a workspace +func (t AMGServiceAccountToken) GetAuth() string { + return t.Token +} + // AMG is a AWS SDK client for AMG apis type AMG struct { Client api @@ -72,6 +93,23 @@ func (a *AMG) ListWorkspaces() ([]Workspace, error) { return wx, nil } +// DescribeWorkspace returns information about a workspace +func (a *AMG) DescribeWorkspace(workspaceID string) (Workspace, error) { + res, err := a.Client.DescribeWorkspace(&managedgrafana.DescribeWorkspaceInput{ + WorkspaceId: aws.String(workspaceID), + }) + if err != nil { + return Workspace{}, err + } + w := Workspace{ + ID: workspaceID, + Name: aws.StringValue(res.Workspace.Name), + Version: aws.StringValue(res.Workspace.GrafanaVersion), + Endpoint: aws.StringValue(res.Workspace.Endpoint), + } + return w, nil +} + // CreateWorkspaceApiKey creates a new API key for a workspace // //revive:disable @@ -111,3 +149,45 @@ func (a *AMG) DeleteWorkspaceApiKey(apiKey AMGApiKey) error { } return err } + +func (a *AMG) CreateServiceAccountToken(workspaceID, serviceAccountID string) (AMGServiceAccountToken, error) { + log.Info() + log.InfoLight("Creating service account token for service account ", serviceAccountID) + currentTime := time.Now().UTC() + saTokenName := fmt.Sprintf("%s-%d", "amg-migrator", currentTime.UnixMilli()) + + duration := time.Duration(30 * 60 * time.Second) + resp, err := a.Client.CreateWorkspaceServiceAccountToken(&managedgrafana.CreateWorkspaceServiceAccountTokenInput{ + Name: aws.String(saTokenName), + SecondsToLive: aws.Int64(int64(duration.Seconds())), + WorkspaceId: aws.String(workspaceID), + ServiceAccountId: aws.String(serviceAccountID), + }) + + if err != nil { + return AMGServiceAccountToken{}, err + } + + return AMGServiceAccountToken{ + ServiceAccountID: serviceAccountID, + SATokenID: *resp.ServiceAccountToken.Id, + Token: *resp.ServiceAccountToken.Key, + WorkspaceID: workspaceID, + }, nil +} + +func (a *AMG) DeleteServiceAccountToken(saToken AMGServiceAccountToken) error { + log.Info() + log.InfoLight("Removing service account token for service account ", saToken.ServiceAccountID) + + _, err := a.Client.DeleteWorkspaceServiceAccountToken(&managedgrafana.DeleteWorkspaceServiceAccountTokenInput{ + ServiceAccountId: aws.String(saToken.ServiceAccountID), + TokenId: aws.String(saToken.SATokenID), + WorkspaceId: aws.String(saToken.WorkspaceID), + }) + + if err != nil { + log.Error(err) + } + return err +} diff --git a/internal/pkg/aws/mocks/mock_aws.go b/internal/pkg/aws/mocks/mock_aws.go index fa91db0..a13569b 100644 --- a/internal/pkg/aws/mocks/mock_aws.go +++ b/internal/pkg/aws/mocks/mock_aws.go @@ -49,6 +49,21 @@ func (mr *MockapiMockRecorder) CreateWorkspaceApiKey(input interface{}) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateWorkspaceApiKey", reflect.TypeOf((*Mockapi)(nil).CreateWorkspaceApiKey), input) } +// CreateWorkspaceServiceAccountToken mocks base method. +func (m *Mockapi) CreateWorkspaceServiceAccountToken(input *managedgrafana.CreateWorkspaceServiceAccountTokenInput) (*managedgrafana.CreateWorkspaceServiceAccountTokenOutput, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateWorkspaceServiceAccountToken", input) + ret0, _ := ret[0].(*managedgrafana.CreateWorkspaceServiceAccountTokenOutput) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateWorkspaceServiceAccountToken indicates an expected call of CreateWorkspaceServiceAccountToken. +func (mr *MockapiMockRecorder) CreateWorkspaceServiceAccountToken(input interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateWorkspaceServiceAccountToken", reflect.TypeOf((*Mockapi)(nil).CreateWorkspaceServiceAccountToken), input) +} + // DeleteWorkspaceApiKey mocks base method. func (m *Mockapi) DeleteWorkspaceApiKey(input *managedgrafana.DeleteWorkspaceApiKeyInput) (*managedgrafana.DeleteWorkspaceApiKeyOutput, error) { m.ctrl.T.Helper() @@ -64,6 +79,36 @@ func (mr *MockapiMockRecorder) DeleteWorkspaceApiKey(input interface{}) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWorkspaceApiKey", reflect.TypeOf((*Mockapi)(nil).DeleteWorkspaceApiKey), input) } +// DeleteWorkspaceServiceAccountToken mocks base method. +func (m *Mockapi) DeleteWorkspaceServiceAccountToken(input *managedgrafana.DeleteWorkspaceServiceAccountTokenInput) (*managedgrafana.DeleteWorkspaceServiceAccountTokenOutput, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteWorkspaceServiceAccountToken", input) + ret0, _ := ret[0].(*managedgrafana.DeleteWorkspaceServiceAccountTokenOutput) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteWorkspaceServiceAccountToken indicates an expected call of DeleteWorkspaceServiceAccountToken. +func (mr *MockapiMockRecorder) DeleteWorkspaceServiceAccountToken(input interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWorkspaceServiceAccountToken", reflect.TypeOf((*Mockapi)(nil).DeleteWorkspaceServiceAccountToken), input) +} + +// DescribeWorkspace mocks base method. +func (m *Mockapi) DescribeWorkspace(input *managedgrafana.DescribeWorkspaceInput) (*managedgrafana.DescribeWorkspaceOutput, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DescribeWorkspace", input) + ret0, _ := ret[0].(*managedgrafana.DescribeWorkspaceOutput) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DescribeWorkspace indicates an expected call of DescribeWorkspace. +func (mr *MockapiMockRecorder) DescribeWorkspace(input interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeWorkspace", reflect.TypeOf((*Mockapi)(nil).DescribeWorkspace), input) +} + // ListWorkspaces mocks base method. func (m *Mockapi) ListWorkspaces(input *managedgrafana.ListWorkspacesInput) (*managedgrafana.ListWorkspacesOutput, error) { m.ctrl.T.Helper() @@ -78,3 +123,40 @@ func (mr *MockapiMockRecorder) ListWorkspaces(input interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListWorkspaces", reflect.TypeOf((*Mockapi)(nil).ListWorkspaces), input) } + +// MockGrafanaAuth is a mock of GrafanaAuth interface. +type MockGrafanaAuth struct { + ctrl *gomock.Controller + recorder *MockGrafanaAuthMockRecorder +} + +// MockGrafanaAuthMockRecorder is the mock recorder for MockGrafanaAuth. +type MockGrafanaAuthMockRecorder struct { + mock *MockGrafanaAuth +} + +// NewMockGrafanaAuth creates a new mock instance. +func NewMockGrafanaAuth(ctrl *gomock.Controller) *MockGrafanaAuth { + mock := &MockGrafanaAuth{ctrl: ctrl} + mock.recorder = &MockGrafanaAuthMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockGrafanaAuth) EXPECT() *MockGrafanaAuthMockRecorder { + return m.recorder +} + +// GetAuth mocks base method. +func (m *MockGrafanaAuth) GetAuth() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAuth") + ret0, _ := ret[0].(string) + return ret0 +} + +// GetAuth indicates an expected call of GetAuth. +func (mr *MockGrafanaAuthMockRecorder) GetAuth() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuth", reflect.TypeOf((*MockGrafanaAuth)(nil).GetAuth)) +} diff --git a/internal/pkg/cli/migrate.go b/internal/pkg/cli/migrate.go index 038495c..d69e84a 100644 --- a/internal/pkg/cli/migrate.go +++ b/internal/pkg/cli/migrate.go @@ -3,7 +3,6 @@ package cli import ( "github.com/aws-observability/amazon-managed-grafana-migrator/internal/pkg/app" "github.com/aws-observability/amazon-managed-grafana-migrator/internal/pkg/aws" - "github.com/aws-observability/amazon-managed-grafana-migrator/internal/pkg/grafana" "github.com/aws-observability/amazon-managed-grafana-migrator/internal/pkg/log" "github.com/aws/aws-sdk-go/aws/session" @@ -11,8 +10,8 @@ import ( ) var ( - src, srcURL, srcAPIKey, dst string - verbose bool + src, srcURL, srcServiceAccountID, srcAPIKey, dst, dstServiceAccountID string + verbose bool ) func migrate(src, dst app.GrafanaInput, verbose bool) error { @@ -26,29 +25,17 @@ func migrate(src, dst app.GrafanaInput, verbose bool) error { if err != nil { return err } - defer src.DeleteAPIKeys(srcAWSClient, srcGrafanaClient.Key) + defer src.DeleteGrafanaAuth(srcAWSClient, srcGrafanaClient.Auth) dstAWSClient := aws.New(sess, dst.Region, dst.IsGamma) dstGrafanaClient, err := dst.CreateGrafanaAPIClient(dstAWSClient) if err != nil { return err } - defer dst.DeleteAPIKeys(dstAWSClient, dstGrafanaClient.Key) - - //looking for API key for CLI provided or AWS CLI SDK - apikey := srcGrafanaClient.Key.APIKey - if apikey == "" { - apikey = srcGrafanaClient.Input.APIKey - } - - // new custom client - customClient, err := grafana.New(srcGrafanaClient.Input.URL, apikey) - if err != nil { - return err - } + defer dst.DeleteGrafanaAuth(dstAWSClient, dstGrafanaClient.Auth) migrate := app.App{Src: srcGrafanaClient.Client, Dst: dstGrafanaClient.Client, Verbose: verbose} - return migrate.Run(app.CustomGrafanaClient{Client: customClient}) + return migrate.Run() } // BuildMigrateCmd builds the migrate CLI command @@ -59,11 +46,11 @@ func BuildMigrateCmd() *cobra.Command { Long: "Discover Managed Grafana workspaces", RunE: runCmdE(func(cmd *cobra.Command, args []string) error { log.Info() - src, err := app.NewGrafanaInput(src, srcURL, srcAPIKey) + src, err := app.NewGrafanaInput(src, srcURL, srcServiceAccountID, srcAPIKey) if err != nil { return err } - dst, err := app.NewGrafanaInput(dst, "", "") + dst, err := app.NewGrafanaInput(dst, "", dstServiceAccountID, "") if err != nil { return err } @@ -72,12 +59,14 @@ func BuildMigrateCmd() *cobra.Command { } cmd.Flags().StringVarP(&src, "src", "s", "", "Source Grafana workspace") + cmd.Flags().StringVarP(&srcServiceAccountID, "src-service-account-id", "", "", "Grafana Service Account ID for source workspace (exclusive with src)") cmd.Flags().StringVarP(&srcURL, "src-url", "", "", "Source Grafana URL (exclusive with src)") - cmd.Flags().StringVarP(&srcAPIKey, "src-api-key", "", "", "Source Grafana API Key (mandatory when using src-url)") + cmd.Flags().StringVarP(&srcAPIKey, "src-api-key", "", "", "Source Grafana API Key or Service Account Token (mandatory when using src-url)") cmd.MarkFlagsRequiredTogether("src-url", "src-api-key") cmd.MarkFlagsMutuallyExclusive("src-url", "src") cmd.Flags().StringVarP(&dst, "dst", "d", "", "Destination Grafana Workspace endpoint") + cmd.Flags().StringVarP(&dstServiceAccountID, "dst-service-account-id", "", "", "Grafana Service Account ID for destination workspace (required for v10+ workspaces)") cmd.MarkFlagRequired("dst") cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Verbose mode") return cmd diff --git a/main.go b/main.go index b89b0fd..96ae0ff 100644 --- a/main.go +++ b/main.go @@ -11,7 +11,7 @@ import ( const ( shortDescription = "Amazon Managed Grafana migration utility" - version = "0.1.12" + version = "0.2.0" ) var region string