package azuremonitor import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "testing" "github.com/google/go-cmp/cmp" "github.com/grafana/grafana-azure-sdk-go/v2/azcredentials" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/grafana/grafana-plugin-sdk-go/experimental/featuretoggles" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) var testRoutes = map[string]types.AzRoute{ azureMonitor: { URL: "https://management.azure.com", Scopes: []string{"https://management.azure.com/.default"}, Headers: map[string]string{"x-ms-app": "Grafana"}, }, azureLogAnalytics: { URL: "https://api.loganalytics.io", Scopes: []string{"https://api.loganalytics.io/.default"}, Headers: map[string]string{"x-ms-app": "Grafana", "Cache-Control": "public, max-age=60"}, }, azureResourceGraph: { URL: "https://management.azure.com", Scopes: []string{"https://management.azure.com/.default"}, Headers: map[string]string{"x-ms-app": "Grafana"}, }, azureTraces: { URL: "https://api.loganalytics.io", Scopes: []string{"https://api.loganalytics.io/.default"}, Headers: map[string]string{"x-ms-app": "Grafana", "Cache-Control": "public, max-age=60"}, }, traceExemplar: { URL: "https://api.loganalytics.io", Scopes: []string{"https://api.loganalytics.io/.default"}, Headers: map[string]string{"x-ms-app": "Grafana", "Cache-Control": "public, max-age=60"}, }, azurePortal: { URL: "https://portal.azure.com", }, } func TestNewInstanceSettings(t *testing.T) { tests := []struct { name string settings backend.DataSourceInstanceSettings expectedModel *types.DatasourceInfo Err require.ErrorAssertionFunc setupContext func(ctx context.Context) context.Context }{ { name: "current user authentication disabled by feature toggle", settings: backend.DataSourceInstanceSettings{ JSONData: []byte(`{"azureAuthType":"currentuser"}`), DecryptedSecureJSONData: map[string]string{}, ID: 60, }, expectedModel: nil, Err: func(t require.TestingT, err error, _ ...interface{}) { require.Error(t, err) require.Contains(t, err.Error(), "current user authentication is not enabled for azure monitor") }, setupContext: func(ctx context.Context) context.Context { featureToggles := backend.NewGrafanaCfg(map[string]string{ featuretoggles.EnabledFeatures: "", // No enabled features }) return backend.WithGrafanaConfig(ctx, featureToggles) }, }, { name: "creates an instance", settings: backend.DataSourceInstanceSettings{ JSONData: []byte(`{"azureAuthType":"msi"}`), DecryptedSecureJSONData: map[string]string{"key": "value"}, ID: 40, }, expectedModel: &types.DatasourceInfo{ Credentials: &azcredentials.AzureManagedIdentityCredentials{}, Settings: types.AzureMonitorSettings{}, Routes: testRoutes, JSONData: map[string]any{"azureAuthType": "msi"}, DatasourceID: 40, DecryptedSecureJSONData: map[string]string{"key": "value"}, Services: map[string]types.DatasourceService{}, }, Err: require.NoError, }, { name: "creates an instance for customized cloud", settings: backend.DataSourceInstanceSettings{ JSONData: []byte(`{"cloudName":"customizedazuremonitor","customizedRoutes":{"Route":{"URL":"url"}},"azureAuthType":"clientsecret"}`), DecryptedSecureJSONData: map[string]string{"clientSecret": "secret"}, ID: 50, }, expectedModel: &types.DatasourceInfo{ Credentials: &azcredentials.AzureClientSecretCredentials{ AzureCloud: "AzureCustomizedCloud", ClientSecret: "secret", }, Settings: types.AzureMonitorSettings{}, Routes: map[string]types.AzRoute{ "Route": { URL: "url", }, }, JSONData: map[string]any{ "azureAuthType": "clientsecret", "cloudName": "customizedazuremonitor", "customizedRoutes": map[string]any{ "Route": map[string]any{ "URL": "url", }, }, }, DatasourceID: 50, DecryptedSecureJSONData: map[string]string{"clientSecret": "secret"}, Services: map[string]types.DatasourceService{}, }, Err: require.NoError, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() if tt.setupContext != nil { ctx = tt.setupContext(ctx) } factory := NewInstanceSettings(&httpclient.Provider{}, map[string]azDatasourceExecutor{}, log.DefaultLogger) instance, err := factory(ctx, tt.settings) tt.Err(t, err) if tt.expectedModel == nil { require.Nil(t, instance, "Expected instance to be nil") } else { require.NotNil(t, instance, "Expected instance to be created") if !cmp.Equal(instance, *tt.expectedModel) { t.Errorf("Unexpected instance: %v", cmp.Diff(instance, *tt.expectedModel)) } } }) } } type fakeInstance struct { routes map[string]types.AzRoute services map[string]types.DatasourceService settings types.AzureMonitorSettings } func (f *fakeInstance) Get(_ context.Context, _ backend.PluginContext) (instancemgmt.Instance, error) { return types.DatasourceInfo{ Routes: f.routes, Services: f.services, Settings: f.settings, }, nil } func (f *fakeInstance) Do(_ context.Context, _ backend.PluginContext, _ instancemgmt.InstanceCallbackFunc) error { return nil } type fakeExecutor struct { t *testing.T queryType string expectedURL string } func (f *fakeExecutor) ResourceRequest(rw http.ResponseWriter, req *http.Request, cli *http.Client) (http.ResponseWriter, error) { return nil, nil } func (f *fakeExecutor) ExecuteTimeSeriesQuery(ctx context.Context, originalQueries []backend.DataQuery, dsInfo types.DatasourceInfo, client *http.Client, url string, fromAlert bool) (*backend.QueryDataResponse, error) { if client == nil { f.t.Errorf("The HTTP client for %s is missing", f.queryType) } else { if url != f.expectedURL { f.t.Errorf("Unexpected URL %s wanted %s", url, f.expectedURL) } } return &backend.QueryDataResponse{}, nil } func Test_newMux(t *testing.T) { tests := []struct { name string queryType string expectedURL string Err require.ErrorAssertionFunc }{ { name: "creates an Azure Monitor executor", queryType: azureMonitor, expectedURL: testRoutes[azureMonitor].URL, Err: require.NoError, }, { name: "creates an Azure Log Analytics executor", queryType: azureLogAnalytics, expectedURL: testRoutes[azureLogAnalytics].URL, Err: require.NoError, }, { name: "creates an Azure Traces executor", queryType: azureTraces, expectedURL: testRoutes[azureLogAnalytics].URL, Err: require.NoError, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s := &Service{ im: &fakeInstance{ routes: testRoutes, services: map[string]types.DatasourceService{ tt.queryType: { URL: testRoutes[tt.queryType].URL, HTTPClient: &http.Client{}, }, }, }, executors: map[string]azDatasourceExecutor{ tt.queryType: &fakeExecutor{ t: t, queryType: tt.queryType, expectedURL: tt.expectedURL, }, }, } mux := s.newQueryMux() res, err := mux.QueryData(context.Background(), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ Name: "datasource_name", UID: "datasource_UID", }, }, Queries: []backend.DataQuery{ {QueryType: tt.queryType}, }, }) tt.Err(t, err) // Dummy response from the fake implementation if res == nil { t.Errorf("Expecting a response") } }) } } type RoundTripFunc func(req *http.Request) (*http.Response, error) func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } func NewTestClient(fn RoundTripFunc) *http.Client { return &http.Client{ Transport: fn, } } func TestCheckHealth(t *testing.T) { logAnalyticsResponse := func(empty bool) (*http.Response, error) { if !empty { body := struct { Value []types.LogAnalyticsWorkspaceResponse }{Value: []types.LogAnalyticsWorkspaceResponse{{ Id: "abcd-1234", Location: "location", Name: "test-workspace", Properties: types.LogAnalyticsWorkspaceProperties{ CreatedDate: "", CustomerId: "abcd-1234", Features: types.LogAnalyticsWorkspaceFeatures{}, }, ProvisioningState: "provisioned", PublicNetworkAccessForIngestion: "enabled", PublicNetworkAccessForQuery: "disabled", RetentionInDays: 0}, }} bodyMarshal, err := json.Marshal(body) if err != nil { return nil, err } return &http.Response{ StatusCode: 200, Body: io.NopCloser(bytes.NewBuffer(bodyMarshal)), Header: make(http.Header), }, nil } else { body := struct { Value []types.LogAnalyticsWorkspaceResponse }{Value: []types.LogAnalyticsWorkspaceResponse{}} bodyMarshal, err := json.Marshal(body) if err != nil { return nil, err } return &http.Response{ StatusCode: 200, Body: io.NopCloser(bytes.NewBuffer(bodyMarshal)), Header: make(http.Header), }, nil } } azureMonitorClient := func(logAnalyticsEmpty bool, fail bool) *http.Client { return NewTestClient(func(req *http.Request) (*http.Response, error) { if strings.Contains(req.URL.String(), "workspaces") { return logAnalyticsResponse(logAnalyticsEmpty) } else { if !fail { return &http.Response{ StatusCode: 200, Body: io.NopCloser(bytes.NewBufferString("{\"value\": [{\"subscriptionId\": \"abcd-1234\"}]}")), Header: make(http.Header), }, nil } else { return &http.Response{ StatusCode: 404, Body: io.NopCloser(bytes.NewBufferString("not found")), Header: make(http.Header), }, nil } } }) } okClient := NewTestClient(func(req *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: 200, Body: io.NopCloser(bytes.NewBufferString("OK")), Header: make(http.Header), }, nil }) failClient := func(azureHealthCheckError bool) *http.Client { return NewTestClient(func(req *http.Request) (*http.Response, error) { if azureHealthCheckError { return nil, errors.New("not found") } return &http.Response{ StatusCode: 404, Body: io.NopCloser(bytes.NewBufferString("not found")), Header: make(http.Header), }, nil }) } tests := []struct { name string errorExpected bool expectedResult *backend.CheckHealthResult customServices map[string]types.DatasourceService }{ { name: "Successfully queries all endpoints", errorExpected: false, expectedResult: &backend.CheckHealthResult{ Status: backend.HealthStatusOk, Message: "Successfully connected to all Azure Monitor endpoints.", }, customServices: map[string]types.DatasourceService{ azureMonitor: { URL: testRoutes["Azure Monitor"].URL, HTTPClient: azureMonitorClient(false, false), }, azureLogAnalytics: { URL: testRoutes["Azure Log Analytics"].URL, HTTPClient: okClient, }, azureResourceGraph: { URL: testRoutes["Azure Resource Graph"].URL, HTTPClient: okClient, }}, }, { name: "Successfully queries all endpoints except metrics", errorExpected: false, expectedResult: &backend.CheckHealthResult{ Status: backend.HealthStatusError, Message: "One or more health checks failed. See details below.", JSONDetails: []byte( `{"verboseMessage": "1. Error connecting to Azure Monitor endpoint: not found\n2. Successfully connected to Azure Log Analytics endpoint.\n3. Successfully connected to Azure Resource Graph endpoint." }`), }, customServices: map[string]types.DatasourceService{ azureMonitor: { URL: testRoutes["Azure Monitor"].URL, HTTPClient: azureMonitorClient(false, true), }, azureLogAnalytics: { URL: testRoutes["Azure Log Analytics"].URL, HTTPClient: okClient, }, azureResourceGraph: { URL: testRoutes["Azure Resource Graph"].URL, HTTPClient: okClient, }}, }, { name: "Successfully queries all endpoints except log analytics", errorExpected: false, expectedResult: &backend.CheckHealthResult{ Status: backend.HealthStatusError, Message: "One or more health checks failed. See details below.", JSONDetails: []byte( `{"verboseMessage": "1. Successfully connected to Azure Monitor endpoint.\n2. Error connecting to Azure Log Analytics endpoint: not found\n3. Successfully connected to Azure Resource Graph endpoint." }`), }, customServices: map[string]types.DatasourceService{ azureMonitor: { URL: testRoutes["Azure Monitor"].URL, HTTPClient: azureMonitorClient(false, false), }, azureLogAnalytics: { URL: testRoutes["Azure Log Analytics"].URL, HTTPClient: failClient(false), }, azureResourceGraph: { URL: testRoutes["Azure Resource Graph"].URL, HTTPClient: okClient, }}, }, { name: "Successfully queries all endpoints except resource graph", errorExpected: false, expectedResult: &backend.CheckHealthResult{ Status: backend.HealthStatusError, Message: "One or more health checks failed. See details below.", JSONDetails: []byte( `{"verboseMessage": "1. Successfully connected to Azure Monitor endpoint.\n2. Successfully connected to Azure Log Analytics endpoint.\n3. Error connecting to Azure Resource Graph endpoint: not found" }`), }, customServices: map[string]types.DatasourceService{ azureMonitor: { URL: testRoutes["Azure Monitor"].URL, HTTPClient: azureMonitorClient(false, false), }, azureLogAnalytics: { URL: testRoutes["Azure Log Analytics"].URL, HTTPClient: okClient, }, azureResourceGraph: { URL: testRoutes["Azure Resource Graph"].URL, HTTPClient: failClient(false), }}, }, { name: "Successfully returns UNKNOWN status if no log analytics workspace is found", errorExpected: false, expectedResult: &backend.CheckHealthResult{ Status: backend.HealthStatusUnknown, Message: "One or more health checks failed. See details below.", JSONDetails: []byte( `{"verboseMessage": "1. Successfully connected to Azure Monitor endpoint.\n2. No Log Analytics workspaces found.\n3. Successfully connected to Azure Resource Graph endpoint." }`), }, customServices: map[string]types.DatasourceService{ azureMonitor: { URL: testRoutes["Azure Monitor"].URL, HTTPClient: azureMonitorClient(true, false), }, azureLogAnalytics: { URL: testRoutes["Azure Log Analytics"].URL, HTTPClient: okClient, }, azureResourceGraph: { URL: testRoutes["Azure Resource Graph"].URL, HTTPClient: okClient, }}, }, { name: "Successfully returns Azure health check errors", errorExpected: false, expectedResult: &backend.CheckHealthResult{ Status: backend.HealthStatusError, Message: "One or more health checks failed. See details below.", JSONDetails: []byte( `{"verboseMessage": "1. Error connecting to Azure Monitor endpoint: health check failed: Get \"https://management.azure.com/subscriptions?api-version=2020-01-01\": not found\n2. Error connecting to Azure Log Analytics endpoint: health check failed: Get \"https://management.azure.com/subscriptions//providers/Microsoft.OperationalInsights/workspaces?api-version=2017-04-26-preview\": not found\n3. Error connecting to Azure Resource Graph endpoint: health check failed: Post \"https://management.azure.com/providers/Microsoft.ResourceGraph/resources?api-version=2021-06-01-preview\": not found" }`), }, customServices: map[string]types.DatasourceService{ azureMonitor: { URL: testRoutes["Azure Monitor"].URL, HTTPClient: failClient(true), }, azureLogAnalytics: { URL: testRoutes["Azure Log Analytics"].URL, HTTPClient: failClient(true), }, azureResourceGraph: { URL: testRoutes["Azure Resource Graph"].URL, HTTPClient: failClient(true), }}, }, } instance := &fakeInstance{ routes: testRoutes, services: map[string]types.DatasourceService{}, settings: types.AzureMonitorSettings{ LogAnalyticsDefaultWorkspace: "workspace-id", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { instance.services = tt.customServices s := &Service{ im: instance, } res, err := s.CheckHealth(context.Background(), &backend.CheckHealthRequest{ PluginContext: backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}, }}) if tt.errorExpected { assert.Error(t, err) } else { assert.NoError(t, err) } assert.Equal(t, tt.expectedResult, res) }) } } func Test_QueryData(t *testing.T) { tests := []struct { name string queryType string expectedURL string Err require.ErrorAssertionFunc ExpectedError error }{ { name: "Azure Monitor query type", queryType: azureMonitor, expectedURL: testRoutes[azureMonitor].URL, Err: require.NoError, ExpectedError: nil, }, { name: "Azure Log Analytics query type", queryType: azureLogAnalytics, expectedURL: testRoutes[azureLogAnalytics].URL, Err: require.NoError, ExpectedError: nil, }, { name: "Azure Resource Graph query type", queryType: azureResourceGraph, expectedURL: testRoutes[azureResourceGraph].URL, Err: require.NoError, ExpectedError: nil, }, { name: "Azure Traces query type", queryType: azureTraces, expectedURL: testRoutes[azureLogAnalytics].URL, Err: require.NoError, ExpectedError: nil, }, { name: "traceExemplar query type", queryType: traceExemplar, expectedURL: testRoutes[traceExemplar].URL, Err: require.NoError, ExpectedError: nil, }, { name: "Deprecated Application Insights query type", queryType: "Application Insights", expectedURL: "", Err: require.Error, ExpectedError: fmt.Errorf("query type: '%s' is no longer supported. Please migrate this query (see https://grafana.com/docs/grafana/v9.0/datasources/azuremonitor/deprecated-application-insights/ for details)", "Application Insights"), }, { name: "Deprecated Insights Analytics query type", queryType: "Insights Analytics", expectedURL: "", Err: require.Error, ExpectedError: fmt.Errorf("query type: '%s' is no longer supported. Please migrate this query (see https://grafana.com/docs/grafana/v9.0/datasources/azuremonitor/deprecated-application-insights/ for details)", "Insights Analytics"), }, } service := &Service{ im: &fakeInstance{ routes: testRoutes, services: map[string]types.DatasourceService{ azureMonitor: { URL: testRoutes[azureMonitor].URL, HTTPClient: &http.Client{}, }, azureLogAnalytics: { URL: testRoutes[azureLogAnalytics].URL, HTTPClient: &http.Client{}, }, azureResourceGraph: { URL: testRoutes[azureResourceGraph].URL, HTTPClient: &http.Client{}, }, azureTraces: { URL: testRoutes[azureTraces].URL, HTTPClient: &http.Client{}, }, traceExemplar: { URL: testRoutes[traceExemplar].URL, HTTPClient: &http.Client{}, }, }, }, executors: map[string]azDatasourceExecutor{ azureMonitor: &fakeExecutor{ t: t, queryType: azureMonitor, expectedURL: testRoutes[azureMonitor].URL, }, azureLogAnalytics: &fakeExecutor{ t: t, queryType: azureMonitor, expectedURL: testRoutes[azureLogAnalytics].URL, }, azureResourceGraph: &fakeExecutor{ t: t, queryType: azureMonitor, expectedURL: testRoutes[azureResourceGraph].URL, }, azureTraces: &fakeExecutor{ t: t, queryType: azureMonitor, expectedURL: testRoutes[azureTraces].URL, }, traceExemplar: &fakeExecutor{ t: t, queryType: azureMonitor, expectedURL: testRoutes[traceExemplar].URL, }, }, } service.queryMux = service.newQueryMux() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { res, _ := service.QueryData(context.Background(), &backend.QueryDataRequest{ PluginContext: backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ Name: "datasource_name", UID: "datasource_UID", }, }, Queries: []backend.DataQuery{ {QueryType: tt.queryType, RefID: "test"}, }, }) if res == nil { t.Errorf("Expecting a response") } if res != nil { tt.Err(t, res.Responses["test"].Error) if tt.ExpectedError != nil { assert.EqualError(t, res.Responses["test"].Error, tt.ExpectedError.Error()) } } }) } }