diff --git a/docs/sources/panels-visualizations/visualizations/status-history/index.md b/docs/sources/panels-visualizations/visualizations/status-history/index.md index 3ca5df864e6..357461f3382 100644 --- a/docs/sources/panels-visualizations/visualizations/status-history/index.md +++ b/docs/sources/panels-visualizations/visualizations/status-history/index.md @@ -111,6 +111,10 @@ Controls whether values are rendered inside the value boxes. Auto will render va Controls the height of boxes. 1 = maximum space and 0 = minimum space. +### Page size (enable pagination) + +The **Page size** option lets you paginate the status history visualization to limit how many series are visible at once. This is useful when you have many series. + ### Column width Controls the width of boxes. 1 = maximum space and 0 = minimum space. diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 435c59f79ef..68bb56787e1 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -191,7 +191,6 @@ Experimental features might be changed or removed without prior notice. | `tableSharedCrosshair` | Enables shared crosshair in table panel | | `kubernetesFeatureToggles` | Use the kubernetes API for feature toggle management in the frontend | | `newFolderPicker` | Enables the nested folder picker without having nested folders enabled | -| `onPremToCloudMigrationsAuthApiMig` | Enables the use of auth api instead of gcom for internal token services. Requires `onPremToCloudMigrations` to be enabled in conjunction. | | `scopeApi` | In-development feature flag for the scope api using the app platform. | | `sqlExpressions` | Enables using SQL and DuckDB functions as Expressions. | | `nodeGraphDotLayout` | Changed the layout algorithm for the node graph | diff --git a/e2e/plugin-e2e/jaeger/jaeger.spec.ts b/e2e/plugin-e2e/jaeger/jaeger.spec.ts new file mode 100644 index 00000000000..c7e1bc88951 --- /dev/null +++ b/e2e/plugin-e2e/jaeger/jaeger.spec.ts @@ -0,0 +1,8 @@ +import { test, expect } from '@grafana/plugin-e2e'; + +test('Smoke test: plugin loads', async ({ createDataSourceConfigPage, page }) => { + await createDataSourceConfigPage({ type: 'jaeger' }); + + await expect(await page.getByText('Type: Jaeger', { exact: true })).toBeVisible(); + await expect(await page.getByRole('heading', { name: 'Connection', exact: true })).toBeVisible(); +}); diff --git a/e2e/plugin-e2e/postgres/postgres.spec.ts b/e2e/plugin-e2e/postgres/postgres.spec.ts new file mode 100644 index 00000000000..aafdcbee40b --- /dev/null +++ b/e2e/plugin-e2e/postgres/postgres.spec.ts @@ -0,0 +1,8 @@ +import { test, expect } from '@grafana/plugin-e2e'; + +test('Smoke test: plugin loads', async ({ createDataSourceConfigPage, page }) => { + await createDataSourceConfigPage({ type: 'grafana-postgresql-datasource' }); + + await expect(await page.getByText('Type: PostgreSQL', { exact: true })).toBeVisible(); + await expect(await page.getByRole('heading', { name: 'Connection', exact: true })).toBeVisible(); +}); diff --git a/e2e/plugin-e2e/zipkin/zipkin.spec.ts b/e2e/plugin-e2e/zipkin/zipkin.spec.ts new file mode 100644 index 00000000000..5a58dc656fb --- /dev/null +++ b/e2e/plugin-e2e/zipkin/zipkin.spec.ts @@ -0,0 +1,8 @@ +import { test, expect } from '@grafana/plugin-e2e'; + +test('Smoke test: plugin loads', async ({ createDataSourceConfigPage, page }) => { + await createDataSourceConfigPage({ type: 'zipkin' }); + + await expect(await page.getByText('Type: Zipkin', { exact: true })).toBeVisible(); + await expect(await page.getByRole('heading', { name: 'Connection', exact: true })).toBeVisible(); +}); diff --git a/packages/grafana-data/src/field/fieldComparers.ts b/packages/grafana-data/src/field/fieldComparers.ts index bdcf8eb32a5..94bb4312b14 100644 --- a/packages/grafana-data/src/field/fieldComparers.ts +++ b/packages/grafana-data/src/field/fieldComparers.ts @@ -51,11 +51,16 @@ const numericComparer = (a: number, b: number): number => { return a - b; }; +// Using the Intl.Collator object compare method results in much faster +// string sorting than .localeCompare +const compare = new Intl.Collator('en', { sensitivity: 'base' }).compare; + const stringComparer = (a: string, b: string): number => { if (!a || !b) { return falsyComparer(a, b); } - return a.localeCompare(b); + + return compare(String(a), String(b)); }; const booleanComparer = (a: boolean, b: boolean): number => { diff --git a/packages/grafana-data/src/transformations/transformers/groupingToMatrix.test.ts b/packages/grafana-data/src/transformations/transformers/groupingToMatrix.test.ts index debc814c89e..cba642d8340 100644 --- a/packages/grafana-data/src/transformations/transformers/groupingToMatrix.test.ts +++ b/packages/grafana-data/src/transformations/transformers/groupingToMatrix.test.ts @@ -31,7 +31,7 @@ describe('Grouping to Matrix', () => { const expected: Field[] = [ { name: 'Time\\Time', - type: FieldType.string, + type: FieldType.time, values: [1000, 1001, 1002], config: {}, }, @@ -133,7 +133,7 @@ describe('Grouping to Matrix', () => { const expected: Field[] = [ { name: 'Time\\Time', - type: FieldType.string, + type: FieldType.time, values: [1000, 1001], config: {}, }, diff --git a/packages/grafana-data/src/transformations/transformers/groupingToMatrix.ts b/packages/grafana-data/src/transformations/transformers/groupingToMatrix.ts index 94ce41f79e2..d3b04d281ca 100644 --- a/packages/grafana-data/src/transformations/transformers/groupingToMatrix.ts +++ b/packages/grafana-data/src/transformations/transformers/groupingToMatrix.ts @@ -1,7 +1,7 @@ import { map } from 'rxjs/operators'; import { getFieldDisplayName } from '../../field/fieldState'; -import { DataFrame, Field, FieldType } from '../../types/dataFrame'; +import { DataFrame, Field } from '../../types/dataFrame'; import { SpecialValue, DataTransformerInfo, @@ -106,7 +106,7 @@ export const groupingToMatrixTransformer: DataTransformerInfo(values: T[]): T[] { - const unique = new Set(); - - for (let index = 0; index < values.length; index++) { - unique.add(values[index]); - } - + const unique = new Set(values); return Array.from(unique); } diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 97e532863af..683bdb2a59a 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -153,7 +153,6 @@ export interface FeatureToggles { newFolderPicker?: boolean; jitterAlertRulesWithinGroups?: boolean; onPremToCloudMigrations?: boolean; - onPremToCloudMigrationsAuthApiMig?: boolean; alertingSaveStatePeriodic?: boolean; scopeApi?: boolean; promQLScope?: boolean; diff --git a/packages/grafana-schema/src/raw/composable/statushistory/panelcfg/x/StatusHistoryPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/statushistory/panelcfg/x/StatusHistoryPanelCfg_types.gen.ts index 1efeeb3dec4..a6138868be9 100644 --- a/packages/grafana-schema/src/raw/composable/statushistory/panelcfg/x/StatusHistoryPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/statushistory/panelcfg/x/StatusHistoryPanelCfg_types.gen.ts @@ -17,6 +17,10 @@ export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui * Controls the column width */ colWidth?: number; + /** + * Enables pagination when > 0 + */ + perPage?: number; /** * Set the height of the rows */ @@ -29,6 +33,7 @@ export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui export const defaultOptions: Partial = { colWidth: 0.9, + perPage: 20, rowHeight: 0.9, showValue: ui.VisibilityMode.Auto, }; diff --git a/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go b/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go index 242dbcea396..f4e4bf6630a 100644 --- a/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go +++ b/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go @@ -636,7 +636,7 @@ func createAlertRule(t *testing.T, sql *sqlstore.SQLStore, title string, generat rule.PanelID = nil ruleStore := store.SetupStoreForTesting(t, sql) - ids, err := ruleStore.InsertAlertRules(context.Background(), []ngmodels.AlertRule{rule}) + ids, err := ruleStore.InsertAlertRules(context.Background(), nil, []ngmodels.AlertRule{rule}) require.NoError(t, err) result, err := ruleStore.GetAlertRuleByUID(context.Background(), &ngmodels.GetAlertRuleByUIDQuery{OrgID: rule.OrgID, UID: ids[0].UID}) require.NoError(t, err) @@ -668,7 +668,7 @@ func createAlertRuleFromDashboard(t *testing.T, sql *sqlstore.SQLStore, title st rule.PanelID = panelID } ruleStore := store.SetupStoreForTesting(t, sql) - ids, err := ruleStore.InsertAlertRules(context.Background(), []ngmodels.AlertRule{rule}) + ids, err := ruleStore.InsertAlertRules(context.Background(), nil, []ngmodels.AlertRule{rule}) require.NoError(t, err) result, err := ruleStore.GetAlertRuleByUID(context.Background(), &ngmodels.GetAlertRuleByUIDQuery{OrgID: rule.OrgID, UID: ids[0].UID}) require.NoError(t, err) diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go index 3a8806e26af..7b004dcdd05 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go @@ -168,18 +168,12 @@ func ProvideService( } s.gcomService = gcom.New(gcom.Config{ApiURL: cfg.GrafanaComAPIURL, Token: cfg.CloudMigration.GcomAPIToken}, httpClientGcom) - if features.IsEnabledGlobally(featuremgmt.FlagOnPremToCloudMigrationsAuthApiMig) { - s.log.Info("using authapi client because feature flag is enabled") - httpClientAuthApi, err := httpClientProvider.New() - if err != nil { - return nil, fmt.Errorf("creating http client for AuthApi: %w", err) - } - // the api token is the same as for gcom - s.authApiService = authapi.New(authapi.Config{ApiURL: cfg.CloudMigration.AuthAPIUrl, Token: cfg.CloudMigration.GcomAPIToken}, httpClientAuthApi) - } else { - s.log.Info("using gcom client for auth") - s.authApiService = gcom.New(gcom.Config{ApiURL: cfg.GrafanaComAPIURL, Token: cfg.CloudMigration.GcomAPIToken}, httpClientGcom).(*gcom.GcomClient) + httpClientAuthApi, err := httpClientProvider.New() + if err != nil { + return nil, fmt.Errorf("creating http client for AuthApi: %w", err) } + // the api token is the same as for gcom + s.authApiService = authapi.New(authapi.Config{ApiURL: cfg.CloudMigration.AuthAPIUrl, Token: cfg.CloudMigration.GcomAPIToken}, httpClientAuthApi) } else { s.gmsClient = gmsclient.NewInMemoryClient() s.gcomService = &gcomStub{} diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index e59070b8c24..0f2957ee0ce 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -952,11 +952,26 @@ func (dr *DashboardServiceImpl) UnprovisionDashboard(ctx context.Context, dashbo func (dr *DashboardServiceImpl) GetDashboardsByPluginID(ctx context.Context, query *dashboards.GetDashboardsByPluginIDQuery) ([]*dashboards.Dashboard, error) { if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesCliDashboards) { - return dr.searchDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{ + dashs, err := dr.searchDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{ OrgId: query.OrgID, ProvisionedRepo: pluginIDRepoName, ProvisionedPath: query.PluginID, }) + if err != nil { + return nil, err + } + + // search only returns the metadata, need to get the dashboard.Data too + results := make([]*dashboards.Dashboard, len(dashs)) + for i, d := range dashs { + dash, err := dr.GetDashboard(ctx, &dashboards.GetDashboardQuery{OrgID: d.OrgID, UID: d.UID}) + if err != nil { + return nil, err + } + results[i] = dash + } + + return results, nil } return dr.dashboardStore.GetDashboardsByPluginID(ctx, query) } diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go index d17610fb1eb..27653a41b63 100644 --- a/pkg/services/dashboards/service/dashboard_service_test.go +++ b/pkg/services/dashboards/service/dashboard_service_test.go @@ -1020,6 +1020,15 @@ func TestGetDashboardsByPluginID(t *testing.T) { PluginID: "testing", OrgID: 1, } + uidUnstructured := &unstructured.Unstructured{Object: map[string]any{ + "metadata": map[string]any{ + "name": "uid1", + }, + "spec": map[string]any{ + "title": "Dashboard 1", + }, + }} + t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) { service.features = featuremgmt.WithFeatures() fakeStore.On("GetDashboardsByPluginID", mock.Anything, mock.Anything).Return([]*dashboards.Dashboard{}, nil).Once() @@ -1030,6 +1039,7 @@ func TestGetDashboardsByPluginID(t *testing.T) { t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) { ctx, k8sCliMock := setupK8sDashboardTests(service) + k8sCliMock.On("Get", mock.Anything, "uid", mock.Anything, mock.Anything).Return(uidUnstructured, nil) k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool { return req.Options.Fields[0].Key == "repo.name" && req.Options.Fields[0].Values[0] == "plugin" && req.Options.Fields[1].Key == "repo.path" && req.Options.Fields[1].Values[0] == "testing" diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 3ac4af60495..b0ca965e45d 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1020,12 +1020,6 @@ var ( Owner: grafanaOperatorExperienceSquad, Expression: "true", }, - { - Name: "onPremToCloudMigrationsAuthApiMig", - Description: "Enables the use of auth api instead of gcom for internal token services. Requires `onPremToCloudMigrations` to be enabled in conjunction.", - Stage: FeatureStageExperimental, - Owner: grafanaOperatorExperienceSquad, - }, { Name: "alertingSaveStatePeriodic", Description: "Writes the state periodically to the database, asynchronous to rule evaluation", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 013ef1b7373..169683aea81 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -134,7 +134,6 @@ alertingQueryOptimization,GA,@grafana/alerting-squad,false,false,false newFolderPicker,experimental,@grafana/grafana-frontend-platform,false,false,true jitterAlertRulesWithinGroups,preview,@grafana/alerting-squad,false,true,false onPremToCloudMigrations,preview,@grafana/grafana-operator-experience-squad,false,false,false -onPremToCloudMigrationsAuthApiMig,experimental,@grafana/grafana-operator-experience-squad,false,false,false alertingSaveStatePeriodic,privatePreview,@grafana/alerting-squad,false,false,false scopeApi,experimental,@grafana/grafana-app-platform-squad,false,false,false promQLScope,GA,@grafana/oss-big-tent,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index c9bfd15c6b7..9a82b4d86a9 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -547,10 +547,6 @@ const ( // Enable the Grafana Migration Assistant, which helps you easily migrate on-prem resources, such as dashboards, folders, and data source configurations, to your Grafana Cloud stack. FlagOnPremToCloudMigrations = "onPremToCloudMigrations" - // FlagOnPremToCloudMigrationsAuthApiMig - // Enables the use of auth api instead of gcom for internal token services. Requires `onPremToCloudMigrations` to be enabled in conjunction. - FlagOnPremToCloudMigrationsAuthApiMig = "onPremToCloudMigrationsAuthApiMig" - // FlagAlertingSaveStatePeriodic // Writes the state periodically to the database, asynchronous to rule evaluation FlagAlertingSaveStatePeriodic = "alertingSaveStatePeriodic" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 4170f739681..23615673bd0 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2721,7 +2721,8 @@ "metadata": { "name": "onPremToCloudMigrationsAuthApiMig", "resourceVersion": "1732033809064", - "creationTimestamp": "2024-11-21T18:46:06Z" + "creationTimestamp": "2024-11-21T18:46:06Z", + "deletionTimestamp": "2025-01-22T11:00:18Z" }, "spec": { "description": "Enables the use of auth api instead of gcom for internal token services. Requires `onPremToCloudMigrations` to be enabled in conjunction.", diff --git a/pkg/services/folder/folderimpl/folder_test.go b/pkg/services/folder/folderimpl/folder_test.go index 852542b9ff4..a44b75114e5 100644 --- a/pkg/services/folder/folderimpl/folder_test.go +++ b/pkg/services/folder/folderimpl/folder_test.go @@ -2581,7 +2581,7 @@ func createRule(t *testing.T, store *ngstore.DBstore, folderUID, title string) * gen.WithNamespaceUID(folderUID), gen.WithIntervalSeconds(10), ).Generate() - ids, err := store.InsertAlertRules(context.Background(), []models.AlertRule{rule}) + ids, err := store.InsertAlertRules(context.Background(), nil, []models.AlertRule{rule}) require.NoError(t, err) result, err := store.GetAlertRuleByUID(context.Background(), &models.GetAlertRuleByUIDQuery{OrgID: orgID, UID: ids[0].UID}) diff --git a/pkg/services/gcom/gcomauth.go b/pkg/services/gcom/gcomauth.go deleted file mode 100644 index a43faf13d73..00000000000 --- a/pkg/services/gcom/gcomauth.go +++ /dev/null @@ -1,289 +0,0 @@ -package gcom - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - - "github.com/grafana/grafana/pkg/services/authapi" -) - -// this will be removed when service in authapi is fully enabled - -type listTokensResponse struct { - Items []authapi.TokenView `json:"items"` -} - -type listAccessPoliciesResponse struct { - Items []authapi.AccessPolicy `json:"items"` -} - -var _ authapi.Service = (*GcomClient)(nil) - -func (client *GcomClient) CreateAccessPolicy(ctx context.Context, params authapi.CreateAccessPolicyParams, payload authapi.CreateAccessPolicyPayload) (authapi.AccessPolicy, error) { - endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/accesspolicies") - if err != nil { - return authapi.AccessPolicy{}, fmt.Errorf("building gcom access policy url: %w", err) - } - - body, err := json.Marshal(&payload) - if err != nil { - return authapi.AccessPolicy{}, fmt.Errorf("marshaling request body: %w", err) - } - - request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) - if err != nil { - return authapi.AccessPolicy{}, fmt.Errorf("creating http request: %w", err) - } - - query := url.Values{} - query.Set("region", params.Region) - - 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 authapi.AccessPolicy{}, fmt.Errorf("sending http request to create access policy: %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 authapi.AccessPolicy{}, fmt.Errorf("unexpected response when creating access policy: code=%d body=%s", response.StatusCode, body) - } - - var accessPolicy authapi.AccessPolicy - if err := json.NewDecoder(response.Body).Decode(&accessPolicy); err != nil { - return accessPolicy, fmt.Errorf("unmarshaling response body: %w", err) - } - - return accessPolicy, nil -} - -func (client *GcomClient) DeleteAccessPolicy(ctx context.Context, params authapi.DeleteAccessPolicyParams) (bool, error) { - endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/accesspolicies/", params.AccessPolicyID) - if err != nil { - return false, fmt.Errorf("building gcom access policy url: %w", err) - } - - request, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil) - if err != nil { - return false, fmt.Errorf("creating http request: %w", err) - } - - query := url.Values{} - query.Set("region", params.Region) - - request.URL.RawQuery = query.Encode() - request.Header.Set("x-request-id", params.RequestID) - request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.cfg.Token)) - - response, err := client.httpClient.Do(request) - if err != nil { - return false, fmt.Errorf("sending http request to create access policy: %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.StatusNotFound { - return false, nil - } - - if response.StatusCode == http.StatusOK || response.StatusCode == http.StatusNoContent { - return true, nil - } - - body, _ := io.ReadAll(response.Body) - return false, fmt.Errorf("unexpected response when deleting access policy: code=%d body=%s", response.StatusCode, body) -} - -func (client *GcomClient) ListAccessPolicies(ctx context.Context, params authapi.ListAccessPoliciesParams) ([]authapi.AccessPolicy, error) { - endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/accesspolicies") - if err != nil { - return nil, fmt.Errorf("building gcom access policy 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("name", params.Name) - request.URL.RawQuery = query.Encode() - request.Header.Set("x-request-id", params.RequestID) - request.Header.Set("Accept", "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 create access policy: %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 listing access policies: code=%d body=%s", response.StatusCode, body) - } - - var responseBody listAccessPoliciesResponse - if err := json.NewDecoder(response.Body).Decode(&responseBody); err != nil { - return responseBody.Items, fmt.Errorf("unmarshaling response body: %w", err) - } - - return responseBody.Items, nil -} - -func (client *GcomClient) ListTokens(ctx context.Context, params authapi.ListTokenParams) ([]authapi.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 authapi.CreateTokenParams, payload authapi.CreateTokenPayload) (authapi.Token, error) { - endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/tokens") - if err != nil { - return authapi.Token{}, fmt.Errorf("building gcom tokens url: %w", err) - } - - body, err := json.Marshal(&payload) - if err != nil { - return authapi.Token{}, fmt.Errorf("marshaling request body: %w", err) - } - - request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) - if err != nil { - return authapi.Token{}, fmt.Errorf("creating http request: %w", err) - } - - query := url.Values{} - query.Set("region", params.Region) - - 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 authapi.Token{}, fmt.Errorf("sending http request to create access token: %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 authapi.Token{}, fmt.Errorf("unexpected response when creating access token: code=%d body=%s", response.StatusCode, body) - } - - var token authapi.Token - if err := json.NewDecoder(response.Body).Decode(&token); err != nil { - return token, fmt.Errorf("unmarshaling response body: %w", err) - } - - return token, nil -} - -func (client *GcomClient) DeleteToken(ctx context.Context, params authapi.DeleteTokenParams) error { - endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/tokens", params.TokenID) - if err != nil { - return fmt.Errorf("building gcom tokens url: %w", err) - } - - request, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil) - if err != nil { - return fmt.Errorf("creating http request: %w", err) - } - - query := url.Values{} - query.Set("region", params.Region) - - request.URL.RawQuery = query.Encode() - request.Header.Set("x-request-id", params.RequestID) - request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.cfg.Token)) - - response, err := client.httpClient.Do(request) - if err != nil { - return fmt.Errorf("sending http request to delete access token: %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.StatusNotFound { - return fmt.Errorf("token id: %s %w", params.TokenID, ErrTokenNotFound) - } - - if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusNoContent { - body, _ := io.ReadAll(response.Body) - return fmt.Errorf("unexpected response when deleting access token: code=%d body=%s", response.StatusCode, body) - } - - return nil -} diff --git a/pkg/services/ngalert/api/api_ruler.go b/pkg/services/ngalert/api/api_ruler.go index b70801e38d6..55783cfe853 100644 --- a/pkg/services/ngalert/api/api_ruler.go +++ b/pkg/services/ngalert/api/api_ruler.go @@ -443,7 +443,7 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *contextmodel.ReqContext, groupKey New: *update.New, }) } - err = srv.store.UpdateAlertRules(tranCtx, updates) + err = srv.store.UpdateAlertRules(tranCtx, ngmodels.NewUserUID(c.SignedInUser), updates) if err != nil { return fmt.Errorf("failed to update rules: %w", err) } @@ -454,7 +454,7 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *contextmodel.ReqContext, groupKey for _, rule := range finalChanges.New { inserts = append(inserts, *rule) } - added, err := srv.store.InsertAlertRules(tranCtx, inserts) + added, err := srv.store.InsertAlertRules(tranCtx, ngmodels.NewUserUID(c.SignedInUser), inserts) if err != nil { return fmt.Errorf("failed to add rules: %w", err) } diff --git a/pkg/services/ngalert/api/persist.go b/pkg/services/ngalert/api/persist.go index faecfa2ccc4..b05abd5f6df 100644 --- a/pkg/services/ngalert/api/persist.go +++ b/pkg/services/ngalert/api/persist.go @@ -22,8 +22,8 @@ type RuleStore interface { // InsertAlertRules will insert all alert rules passed into the function // and return the map of uuid to id. - InsertAlertRules(ctx context.Context, rule []ngmodels.AlertRule) ([]ngmodels.AlertRuleKeyWithId, error) - UpdateAlertRules(ctx context.Context, rule []ngmodels.UpdateRule) error + InsertAlertRules(ctx context.Context, user *ngmodels.UserUID, rules []ngmodels.AlertRule) ([]ngmodels.AlertRuleKeyWithId, error) + UpdateAlertRules(ctx context.Context, user *ngmodels.UserUID, rules []ngmodels.UpdateRule) error DeleteAlertRulesByUID(ctx context.Context, orgID int64, ruleUID ...string) error // IncreaseVersionForAllRulesInNamespaces Increases version for all rules that have specified namespace uids diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index ef8d15827df..07a5eb86280 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -243,6 +243,21 @@ func SortAlertRuleGroupWithFolderTitle(g []AlertRuleGroupWithFolderFullpath) { }) } +type UserUID string + +func NewUserUID(requester interface{ GetIdentifier() string }) *UserUID { + // use anonymous interface to abstract from identity package, which is part of apimachinery + if requester == nil { + return nil + } + identifier := requester.GetIdentifier() + if identifier == "" { + return nil + } + userUID := UserUID(identifier) + return &userUID +} + // AlertRule is the model for alert rules in unified alerting. type AlertRule struct { ID int64 @@ -251,6 +266,7 @@ type AlertRule struct { Condition string Data []AlertQuery Updated time.Time + UpdatedBy *UserUID IntervalSeconds int64 Version int64 UID string @@ -535,7 +551,7 @@ func (alertRule *AlertRule) GetGroupKey() AlertRuleGroupKey { } // PreSave sets default values and loads the updated model for each alert query. -func (alertRule *AlertRule) PreSave(timeNow func() time.Time) error { +func (alertRule *AlertRule) PreSave(timeNow func() time.Time, userUID *UserUID) error { for i, q := range alertRule.Data { err := q.PreSave() if err != nil { @@ -544,6 +560,7 @@ func (alertRule *AlertRule) PreSave(timeNow func() time.Time) error { alertRule.Data[i] = q } alertRule.Updated = timeNow() + alertRule.UpdatedBy = userUID return nil } diff --git a/pkg/services/ngalert/models/alert_rule_test.go b/pkg/services/ngalert/models/alert_rule_test.go index bb7bfdadf33..7bb1b44a39b 100644 --- a/pkg/services/ngalert/models/alert_rule_test.go +++ b/pkg/services/ngalert/models/alert_rule_test.go @@ -445,6 +445,11 @@ func TestDiff(t *testing.T) { assert.Equal(t, rule2.Updated, diff[0].Right.Interface()) difCnt++ } + if rule1.UpdatedBy != rule2.UpdatedBy { + diff := diffs.GetDiffsForField("UpdatedBy") + assert.Len(t, diff, 1) + difCnt++ + } if rule1.IntervalSeconds != rule2.IntervalSeconds { diff := diffs.GetDiffsForField("IntervalSeconds") assert.Len(t, diff, 1) diff --git a/pkg/services/ngalert/models/testing.go b/pkg/services/ngalert/models/testing.go index adc27759b58..e5b988cf084 100644 --- a/pkg/services/ngalert/models/testing.go +++ b/pkg/services/ngalert/models/testing.go @@ -96,6 +96,11 @@ func (g *AlertRuleGenerator) Generate() AlertRule { ns = append(ns, NotificationSettingsGen()()) } + var updatedBy *UserUID + if rand.Int63()%2 == 0 { + updatedBy = util.Pointer(UserUID(util.GenerateShortUID())) + } + rule := AlertRule{ ID: 0, OrgID: rand.Int63n(1500) + 1, // Prevent OrgID=0 as this does not pass alert rule validation. @@ -103,6 +108,7 @@ func (g *AlertRuleGenerator) Generate() AlertRule { Condition: "A", Data: []AlertQuery{g.GenerateQuery()}, Updated: time.Now().Add(-time.Duration(rand.Intn(100) + 1)), + UpdatedBy: updatedBy, IntervalSeconds: rand.Int63n(60) + 1, Version: rand.Int63n(1500), // Don't generate a rule ID too big for postgres UID: util.GenerateShortUID(), @@ -538,6 +544,12 @@ func (a *AlertRuleMutators) WithRecordFrom(from string) AlertRuleMutator { } } +func (a *AlertRuleMutators) WithUpdatedBy(uid *UserUID) AlertRuleMutator { + return func(r *AlertRule) { + r.UpdatedBy = uid + } +} + func (g *AlertRuleGenerator) GenerateLabels(min, max int, prefix string) data.Labels { count := max if min > max { @@ -618,6 +630,7 @@ func CopyRule(r *AlertRule, mutators ...AlertRuleMutator) *AlertRule { Title: r.Title, Condition: r.Condition, Updated: r.Updated, + UpdatedBy: r.UpdatedBy, IntervalSeconds: r.IntervalSeconds, Version: r.Version, UID: r.UID, diff --git a/pkg/services/ngalert/provisioning/alert_rules.go b/pkg/services/ngalert/provisioning/alert_rules.go index ea618938b58..9b8cd429653 100644 --- a/pkg/services/ngalert/provisioning/alert_rules.go +++ b/pkg/services/ngalert/provisioning/alert_rules.go @@ -232,7 +232,7 @@ func (service *AlertRuleService) CreateAlertRule(ctx context.Context, user ident } } err = service.xact.InTransaction(ctx, func(ctx context.Context) error { - ids, err := service.ruleStore.InsertAlertRules(ctx, []models.AlertRule{ + ids, err := service.ruleStore.InsertAlertRules(ctx, models.NewUserUID(user), []models.AlertRule{ rule, }) if err != nil { @@ -359,7 +359,7 @@ func (service *AlertRuleService) UpdateRuleGroup(ctx context.Context, user ident } } - return service.ruleStore.UpdateAlertRules(ctx, updateRules) + return service.ruleStore.UpdateAlertRules(ctx, models.NewUserUID(user), updateRules) }) } @@ -511,7 +511,7 @@ func (service *AlertRuleService) persistDelta(ctx context.Context, user identity New: *update.New, }) } - if err := service.ruleStore.UpdateAlertRules(ctx, updates); err != nil { + if err := service.ruleStore.UpdateAlertRules(ctx, models.NewUserUID(user), updates); err != nil { return fmt.Errorf("failed to update alert rules: %w", err) } for _, update := range delta.Update { @@ -522,7 +522,7 @@ func (service *AlertRuleService) persistDelta(ctx context.Context, user identity } if len(delta.New) > 0 { - uids, err := service.ruleStore.InsertAlertRules(ctx, withoutNilAlertRules(delta.New)) + uids, err := service.ruleStore.InsertAlertRules(ctx, models.NewUserUID(user), withoutNilAlertRules(delta.New)) if err != nil { return fmt.Errorf("failed to insert alert rules: %w", err) } @@ -618,7 +618,7 @@ func (service *AlertRuleService) UpdateAlertRule(ctx context.Context, user ident return models.AlertRule{}, err } err = service.xact.InTransaction(ctx, func(ctx context.Context) error { - err := service.ruleStore.UpdateAlertRules(ctx, []models.UpdateRule{ + err := service.ruleStore.UpdateAlertRules(ctx, models.NewUserUID(user), []models.UpdateRule{ { Existing: storedRule, New: rule, diff --git a/pkg/services/ngalert/provisioning/alert_rules_test.go b/pkg/services/ngalert/provisioning/alert_rules_test.go index 0ec1814c0ae..ec15a851280 100644 --- a/pkg/services/ngalert/provisioning/alert_rules_test.go +++ b/pkg/services/ngalert/provisioning/alert_rules_test.go @@ -41,8 +41,9 @@ func TestAlertRuleService(t *testing.T) { ruleService := createAlertRuleService(t, nil) var orgID int64 = 1 u := &user.SignedInUser{ - UserID: 1, - OrgID: orgID, + UserUID: util.GenerateShortUID(), + UserID: 1, + OrgID: orgID, } t.Run("group creation should set the right provenance", func(t *testing.T) { @@ -196,7 +197,7 @@ func TestAlertRuleService(t *testing.T) { }, } rule.Metadata = ruleMetadata - r, err := ruleService.ruleStore.InsertAlertRules(context.Background(), []models.AlertRule{rule}) + r, err := ruleService.ruleStore.InsertAlertRules(context.Background(), models.NewUserUID(u), []models.AlertRule{rule}) require.NoError(t, err) require.Len(t, r, 1) @@ -233,7 +234,7 @@ func TestAlertRuleService(t *testing.T) { }, } rule.Metadata = ruleMetadata - r, err := ruleService.ruleStore.InsertAlertRules(context.Background(), []models.AlertRule{rule}) + r, err := ruleService.ruleStore.InsertAlertRules(context.Background(), models.NewUserUID(u), []models.AlertRule{rule}) require.NoError(t, err) require.Len(t, r, 1) @@ -624,7 +625,7 @@ func TestAlertRuleService(t *testing.T) { func TestCreateAlertRule(t *testing.T) { orgID := rand.Int63() - u := &user.SignedInUser{OrgID: orgID} + u := &user.SignedInUser{OrgID: orgID, UserUID: util.GenerateShortUID()} groupKey := models.GenerateGroupKey(orgID) groupIntervalSeconds := int64(30) gen := models.RuleGen @@ -1008,7 +1009,7 @@ func TestUpdateAlertRule(t *testing.T) { rule := models.CopyRule(rules[0]) - _, err := service.ruleStore.InsertAlertRules(context.Background(), []models.AlertRule{*rule}) + _, err := service.ruleStore.InsertAlertRules(context.Background(), models.NewUserUID(u), []models.AlertRule{*rule}) require.NoError(t, err) ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) { @@ -1626,7 +1627,7 @@ func TestProvisiongWithFullpath(t *testing.T) { require.NoError(t, err) t.Run("for a rule under a root folder should set the right fullpath", func(t *testing.T) { - r, err := ruleService.ruleStore.InsertAlertRules(context.Background(), []models.AlertRule{ + r, err := ruleService.ruleStore.InsertAlertRules(context.Background(), models.NewUserUID(&signedInUser), []models.AlertRule{ createTestRule("my-cool-group", "my-cool-group", orgID, namespaceUID), }) require.NoError(t, err) @@ -1657,7 +1658,7 @@ func TestProvisiongWithFullpath(t *testing.T) { }) require.NoError(t, err) - r, err := ruleService.ruleStore.InsertAlertRules(context.Background(), []models.AlertRule{ + r, err := ruleService.ruleStore.InsertAlertRules(context.Background(), models.NewUserUID(&signedInUser), []models.AlertRule{ createTestRule("my-cool-group-2", "my-cool-group-2", orgID, otherNamespaceUID), }) require.NoError(t, err) diff --git a/pkg/services/ngalert/provisioning/persist.go b/pkg/services/ngalert/provisioning/persist.go index 286652b07a4..f41ea602927 100644 --- a/pkg/services/ngalert/provisioning/persist.go +++ b/pkg/services/ngalert/provisioning/persist.go @@ -33,8 +33,8 @@ type RuleStore interface { GetAlertRuleByUID(ctx context.Context, query *models.GetAlertRuleByUIDQuery) (*models.AlertRule, error) ListAlertRules(ctx context.Context, query *models.ListAlertRulesQuery) (models.RulesGroup, error) GetRuleGroupInterval(ctx context.Context, orgID int64, namespaceUID string, ruleGroup string) (int64, error) - InsertAlertRules(ctx context.Context, rule []models.AlertRule) ([]models.AlertRuleKeyWithId, error) - UpdateAlertRules(ctx context.Context, rule []models.UpdateRule) error + InsertAlertRules(ctx context.Context, user *models.UserUID, rule []models.AlertRule) ([]models.AlertRuleKeyWithId, error) + UpdateAlertRules(ctx context.Context, user *models.UserUID, rule []models.UpdateRule) error DeleteAlertRulesByUID(ctx context.Context, orgID int64, ruleUID ...string) error GetAlertRulesGroupByRuleUID(ctx context.Context, query *models.GetAlertRulesGroupByRuleUIDQuery) ([]*models.AlertRule, error) } diff --git a/pkg/services/ngalert/remote/alertmanager.go b/pkg/services/ngalert/remote/alertmanager.go index 3d209aa85b9..82b222713ef 100644 --- a/pkg/services/ngalert/remote/alertmanager.go +++ b/pkg/services/ngalert/remote/alertmanager.go @@ -642,7 +642,7 @@ func (am *Alertmanager) shouldSendConfig(ctx context.Context, config *apimodels. rc, err := am.mimirClient.GetGrafanaAlertmanagerConfig(ctx) if err != nil { // Log the error and return true so we try to upload our config anyway. - am.log.Error("Unable to get the remote Alertmanager configuration for comparison", "err", err) + am.log.Warn("Unable to get the remote Alertmanager configuration for comparison, sending the configuration without comparing", "err", err) return true } @@ -669,7 +669,7 @@ func (am *Alertmanager) shouldSendState(ctx context.Context, state string) bool rs, err := am.mimirClient.GetGrafanaAlertmanagerState(ctx) if err != nil { // Log the error and return true so we try to upload our state anyway. - am.log.Error("Unable to get the remote Alertmanager state for comparison", "err", err) + am.log.Warn("Unable to get the remote Alertmanager state for comparison, sending the state without comparing", "err", err) return true } diff --git a/pkg/services/ngalert/remote/client/alertmanager.go b/pkg/services/ngalert/remote/client/alertmanager.go index 2ca7a9bebee..bc228884460 100644 --- a/pkg/services/ngalert/remote/client/alertmanager.go +++ b/pkg/services/ngalert/remote/client/alertmanager.go @@ -110,6 +110,14 @@ func (am *Alertmanager) IsReadyWithBackoff(ctx context.Context) (bool, error) { if status != http.StatusOK { if status >= 400 && status < 500 { + if status == http.StatusNotAcceptable { + // Mimir returns a 406 when the Alertmanager for the tenant is not running. + // This is expected if the Grafana Alertmanager configuration is default or not promoted. + // We can still use the endpoints to store and retrieve configuration/state. + am.logger.Debug("Remote Alertmanager not initialized for tenant", "attempt", attempts, "status", status) + return true, nil + } + am.logger.Debug("Ready check failed with non-retriable status code", "attempt", attempts, "status", status) return false, fmt.Errorf("ready check failed with non-retriable status code %d", status) } diff --git a/pkg/services/ngalert/schedule/registry_test.go b/pkg/services/ngalert/schedule/registry_test.go index a28ec6265aa..6ee2517a706 100644 --- a/pkg/services/ngalert/schedule/registry_test.go +++ b/pkg/services/ngalert/schedule/registry_test.go @@ -259,6 +259,7 @@ func TestRuleWithFolderFingerprint(t *testing.T) { excludedFields := map[string]struct{}{ "Version": {}, "Updated": {}, + "UpdatedBy": {}, "IntervalSeconds": {}, "Annotations": {}, } diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index bbc64fe04cb..b248d8e4fee 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -181,7 +181,7 @@ func (st DBstore) GetAlertRulesGroupByRuleUID(ctx context.Context, query *ngmode // InsertAlertRules is a handler for creating/updating alert rules. // Returns the UID and ID of rules that were created in the same order as the input rules. -func (st DBstore) InsertAlertRules(ctx context.Context, rules []ngmodels.AlertRule) ([]ngmodels.AlertRuleKeyWithId, error) { +func (st DBstore) InsertAlertRules(ctx context.Context, user *ngmodels.UserUID, rules []ngmodels.AlertRule) ([]ngmodels.AlertRuleKeyWithId, error) { ids := make([]ngmodels.AlertRuleKeyWithId, 0, len(rules)) keys := make([]ngmodels.AlertRuleKey, 0, len(rules)) return ids, st.SQLStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error { @@ -200,7 +200,7 @@ func (st DBstore) InsertAlertRules(ctx context.Context, rules []ngmodels.AlertRu if err := st.validateAlertRule(r); err != nil { return err } - if err := (&r).PreSave(TimeNow); err != nil { + if err := (&r).PreSave(TimeNow, user); err != nil { return err } @@ -245,7 +245,7 @@ func (st DBstore) InsertAlertRules(ctx context.Context, rules []ngmodels.AlertRu } // UpdateAlertRules is a handler for updating alert rules. -func (st DBstore) UpdateAlertRules(ctx context.Context, rules []ngmodels.UpdateRule) error { +func (st DBstore) UpdateAlertRules(ctx context.Context, user *ngmodels.UserUID, rules []ngmodels.UpdateRule) error { return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error { err := st.preventIntermediateUniqueConstraintViolations(sess, rules) if err != nil { @@ -263,7 +263,7 @@ func (st DBstore) UpdateAlertRules(ctx context.Context, rules []ngmodels.UpdateR if err := st.validateAlertRule(r.New); err != nil { return err } - if err := (&r.New).PreSave(TimeNow); err != nil { + if err := (&r.New).PreSave(TimeNow, user); err != nil { return err } converted, err := alertRuleFromModelsAlertRule(r.New) @@ -956,7 +956,9 @@ func (st DBstore) RenameReceiverInNotificationSettings(ctx context.Context, orgI if dryRun { return result, nil, nil } - return result, nil, st.UpdateAlertRules(ctx, updates) + // Provide empty user identifier to ensure it's clear that the rule update was made by the system + // and not by the user who changed the receiver's title. + return result, nil, st.UpdateAlertRules(ctx, nil, updates) } // RenameTimeIntervalInNotificationSettings renames all rules that use old time interval name to the new name. @@ -1031,7 +1033,9 @@ func (st DBstore) RenameTimeIntervalInNotificationSettings( if dryRun { return result, nil, nil } - return result, nil, st.UpdateAlertRules(ctx, updates) + // Provide empty user identifier to ensure it's clear that the rule update was made by the system + // and not by the user who changed the receiver's title. + return result, nil, st.UpdateAlertRules(ctx, nil, updates) } func ruleConstraintViolationToErr(sess *db.Session, rule ngmodels.AlertRule, err error, logger log.Logger) error { diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index dd55c5df488..10ccf9b9faf 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -47,6 +47,7 @@ func TestIntegrationUpdateAlertRules(t *testing.T) { folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) b := &fakeBus{} store := createTestStore(sqlStore, folderService, logger, cfg.UnifiedAlerting, b) + usr := models.UserUID("1234") gen := models.RuleGen gen = gen.With(gen.WithIntervalMatching(store.Cfg.BaseInterval)) @@ -56,7 +57,7 @@ func TestIntegrationUpdateAlertRules(t *testing.T) { rule := createRule(t, store, gen) newRule := models.CopyRule(rule) newRule.Title = util.GenerateShortUID() - err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{ + err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{ Existing: rule, New: *newRule, }, @@ -79,7 +80,7 @@ func TestIntegrationUpdateAlertRules(t *testing.T) { newRule := models.CopyRule(rule) newRule.Record.Metric = "new_metric" - err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{ + err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{ Existing: rule, New: *newRule, }, @@ -104,7 +105,7 @@ func TestIntegrationUpdateAlertRules(t *testing.T) { newRule := models.CopyRule(rule) newRule.Title = util.GenerateShortUID() - err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{ + err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{ Existing: rule, New: *newRule, }, @@ -125,22 +126,78 @@ func TestIntegrationUpdateAlertRules(t *testing.T) { called = true return nil } + t.Cleanup(func() { + b.publishFn = nil + }) newRule := models.CopyRule(rule) newRule.Title = util.GenerateShortUID() - err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{ + err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{ Existing: rule, New: *newRule, }}) require.NoError(t, err) require.True(t, called) }) + + t.Run("should set UpdatedBy", func(t *testing.T) { + rule := createRule(t, store, gen) + newRule := models.CopyRule(rule) + newRule.Title = util.GenerateShortUID() + err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{ + Existing: rule, + New: *newRule, + }, + }) + require.NoError(t, err) + + dbrule := &alertRule{} + err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error { + exist, err := sess.Table(alertRule{}).ID(rule.ID).Get(dbrule) + require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule.ID)) + return err + }) + + require.NoError(t, err) + require.NotNil(t, dbrule.UpdatedBy) + require.Equal(t, string(usr), *dbrule.UpdatedBy) + + t.Run("should set CreatedBy in rule version table", func(t *testing.T) { + dbVersion := &alertRuleVersion{} + err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error { + exist, err := sess.Table(alertRuleVersion{}).Where("rule_uid = ? AND version = ?", dbrule.UID, dbrule.Version).Get(dbVersion) + require.Truef(t, exist, "new version of the rule does not exist in version table") + return err + }) + require.NoError(t, err) + require.NotNil(t, dbVersion.CreatedBy) + require.Equal(t, *dbrule.UpdatedBy, *dbVersion.CreatedBy) + }) + + t.Run("nil identity should be handled correctly", func(t *testing.T) { + rule.Version++ + newRule.Title = util.GenerateShortUID() + err = store.UpdateAlertRules(context.Background(), nil, []models.UpdateRule{{ + Existing: rule, + New: *newRule, + }, + }) + dbrule := &alertRule{} + err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error { + exist, err := sess.Table(alertRule{}).ID(rule.ID).Get(dbrule) + require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule.ID)) + return err + }) + assert.Nil(t, dbrule.UpdatedBy) + }) + }) } func TestIntegrationUpdateAlertRulesWithUniqueConstraintViolation(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } + usr := models.UserUID("test") cfg := setting.NewCfg() cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{BaseInterval: time.Duration(rand.Int63n(100)+1) * time.Second} sqlStore := db.InitTestDB(t) @@ -167,7 +224,7 @@ func TestIntegrationUpdateAlertRulesWithUniqueConstraintViolation(t *testing.T) newRule1.Title = rule2.Title newRule2.Title = util.GenerateShortUID() - err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{ + err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{ Existing: rule1, New: *newRule1, }, { @@ -211,7 +268,7 @@ func TestIntegrationUpdateAlertRulesWithUniqueConstraintViolation(t *testing.T) newRule2.Title = rule3.Title newRule3.Title = rule1.Title - err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{ + err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{ Existing: rule1, New: *newRule1, }, { @@ -263,7 +320,7 @@ func TestIntegrationUpdateAlertRulesWithUniqueConstraintViolation(t *testing.T) newRule1.Title = strings.ToUpper(rule2.Title) newRule2.Title = strings.ToUpper(rule1.Title) - err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{ + err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{ Existing: rule1, New: *newRule1, }, { @@ -310,7 +367,7 @@ func TestIntegrationUpdateAlertRulesWithUniqueConstraintViolation(t *testing.T) newRule3.Title = rule4.Title newRule4.Title = rule3.Title - err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{ + err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{ Existing: rule1, New: *newRule1, }, { @@ -372,7 +429,7 @@ func TestIntegrationUpdateAlertRulesWithUniqueConstraintViolation(t *testing.T) newRule2 := models.CopyRule(rule2) newRule2.Title = newRule1.Title - err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{ + err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{ Existing: rule2, New: *newRule2, }, @@ -704,6 +761,7 @@ func TestIntegrationInsertAlertRules(t *testing.T) { } orgID := int64(1) + usr := models.UserUID("test") sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() cfg.UnifiedAlerting.BaseInterval = 1 * time.Second @@ -724,7 +782,7 @@ func TestIntegrationInsertAlertRules(t *testing.T) { rules := append(gen.GenerateMany(5), recordingRulesGen.GenerateMany(5)...) - ids, err := store.InsertAlertRules(context.Background(), rules) + ids, err := store.InsertAlertRules(context.Background(), &usr, rules) require.NoError(t, err) require.Len(t, ids, len(rules)) @@ -776,12 +834,20 @@ func TestIntegrationInsertAlertRules(t *testing.T) { } }) + t.Run("inserted rules should have UpdatedBy set", func(t *testing.T) { + for _, rule := range dbRules { + if assert.NotNil(t, rule.UpdatedBy) { + assert.Equal(t, usr, *rule.UpdatedBy) + } + } + }) + t.Run("inserted recording rules fail validation if metric name is invalid", func(t *testing.T) { t.Run("invalid UTF-8", func(t *testing.T) { invalidMetric := "my_metric\x80" invalidRule := recordingRulesGen.Generate() invalidRule.Record.Metric = invalidMetric - _, err := store.InsertAlertRules(context.Background(), []models.AlertRule{invalidRule}) + _, err := store.InsertAlertRules(context.Background(), &usr, []models.AlertRule{invalidRule}) require.ErrorIs(t, err, models.ErrAlertRuleFailedValidation) require.ErrorContains(t, err, "metric name for recording rule must be a valid utf8 string") }) @@ -789,7 +855,7 @@ func TestIntegrationInsertAlertRules(t *testing.T) { t.Run("clears fields that should not exist on recording rules", func(t *testing.T) { rule := recordingRulesGen.Generate() - rules, err := store.InsertAlertRules(context.Background(), []models.AlertRule{rule}) + rules, err := store.InsertAlertRules(context.Background(), &usr, []models.AlertRule{rule}) require.NoError(t, err) require.Len(t, rules, 1) ruleUID := rules[0].UID @@ -806,13 +872,13 @@ func TestIntegrationInsertAlertRules(t *testing.T) { }) t.Run("fail to insert rules with same ID", func(t *testing.T) { - _, err = store.InsertAlertRules(context.Background(), []models.AlertRule{rules[0]}) + _, err = store.InsertAlertRules(context.Background(), &usr, []models.AlertRule{rules[0]}) require.ErrorIs(t, err, models.ErrAlertRuleConflictBase) }) t.Run("fail insert rules with the same title in a folder", func(t *testing.T) { cp := models.CopyRule(&rules[0]) cp.UID = cp.UID + "-new" - _, err = store.InsertAlertRules(context.Background(), []models.AlertRule{*cp}) + _, err = store.InsertAlertRules(context.Background(), &usr, []models.AlertRule{*cp}) require.ErrorIs(t, err, models.ErrAlertRuleConflictBase) require.ErrorIs(t, err, models.ErrAlertRuleUniqueConstraintViolation) require.NotEqual(t, rules[0].UID, "") @@ -825,7 +891,7 @@ func TestIntegrationInsertAlertRules(t *testing.T) { t.Run("should not let insert rules with the same UID", func(t *testing.T) { cp := models.CopyRule(&rules[0]) cp.Title = "unique-test-title" - _, err = store.InsertAlertRules(context.Background(), []models.AlertRule{*cp}) + _, err = store.InsertAlertRules(context.Background(), &usr, []models.AlertRule{*cp}) require.ErrorIs(t, err, models.ErrAlertRuleConflictBase) require.ErrorContains(t, err, "rule UID under the same organisation should be unique") }) @@ -833,6 +899,9 @@ func TestIntegrationInsertAlertRules(t *testing.T) { t.Run("should emit event when rules are inserted", func(t *testing.T) { rule := gen.Generate() called := false + t.Cleanup(func() { + b.publishFn = nil + }) b.publishFn = func(ctx context.Context, msg bus.Msg) error { event, ok := msg.(*RuleChangeEvent) require.True(t, ok) @@ -843,11 +912,23 @@ func TestIntegrationInsertAlertRules(t *testing.T) { return nil } - rules, err := store.InsertAlertRules(context.Background(), []models.AlertRule{rule}) + rules, err := store.InsertAlertRules(context.Background(), &usr, []models.AlertRule{rule}) require.NoError(t, err) require.Len(t, rules, 1) require.True(t, called) }) + + t.Run("nil identity should be handled correctly", func(t *testing.T) { + rule := gen.Generate() + ids, err = store.InsertAlertRules(context.Background(), nil, []models.AlertRule{rule}) + require.NoError(t, err) + insertedRule, err := store.GetRuleByID(context.Background(), models.GetAlertRuleByIDQuery{ + ID: ids[0].ID, + OrgID: rule.OrgID, + }) + require.NoError(t, err) + require.Nil(t, insertedRule.UpdatedBy) + }) } func TestIntegrationAlertRulesNotificationSettings(t *testing.T) { @@ -855,6 +936,8 @@ func TestIntegrationAlertRulesNotificationSettings(t *testing.T) { t.Skip("skipping integration test") } + usr := models.UserUID("test") + getKeyMap := func(r []*models.AlertRule) map[models.AlertRuleKey]struct{} { result := make(map[models.AlertRuleKey]struct{}, len(r)) for _, rule := range r { @@ -894,7 +977,7 @@ func TestIntegrationAlertRulesNotificationSettings(t *testing.T) { require.NoError(t, store.SetProvenance(context.Background(), rule, rule.OrgID, p)) } - _, err := store.InsertAlertRules(context.Background(), deref) + _, err := store.InsertAlertRules(context.Background(), &usr, deref) require.NoError(t, err) t.Run("should find rules by receiver name", func(t *testing.T) { @@ -1136,7 +1219,7 @@ func TestIntegrationListNotificationSettings(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - + usr := models.UserUID("test") sqlStore := db.InitTestDB(t) folderService := setupFolderService(t, sqlStore, setting.NewCfg(), featuremgmt.WithFeatures()) logger := log.New("test-dbstore") @@ -1168,7 +1251,7 @@ func TestIntegrationListNotificationSettings(t *testing.T) { orgRules := append(rulesWithNotificationsAndReceiver, rulesWithNotificationsAndTimeInterval...) - _, err := store.InsertAlertRules(context.Background(), deref) + _, err := store.InsertAlertRules(context.Background(), &usr, deref) require.NoError(t, err) result, err := store.ListNotificationSettings(context.Background(), models.ListNotificationSettingsQuery{OrgID: 1}) @@ -1258,6 +1341,8 @@ func TestIntegrationGetNamespacesByRuleUID(t *testing.T) { t.Skip("skipping integration test") } + usr := models.UserUID("test") + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() cfg.UnifiedAlerting.BaseInterval = 1 * time.Second @@ -1267,7 +1352,7 @@ func TestIntegrationGetNamespacesByRuleUID(t *testing.T) { store := createTestStore(sqlStore, folderService, logger, cfg.UnifiedAlerting, b) rules := models.RuleGen.With(models.RuleMuts.WithOrgID(1), models.RuleMuts.WithRandomRecordingRules()).GenerateMany(5) - _, err := store.InsertAlertRules(context.Background(), rules) + _, err := store.InsertAlertRules(context.Background(), &usr, rules) require.NoError(t, err) uids := make([]string, 0, len(rules)) @@ -1306,6 +1391,7 @@ func TestIntegrationRuleGroupsCaseSensitive(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } + usr := models.UserUID("test") sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() @@ -1329,7 +1415,7 @@ func TestIntegrationRuleGroupsCaseSensitive(t *testing.T) { group2 := gen.With(gen.WithGroupKey(groupKey2)).GenerateMany(1, 3) group3 := gen.With(gen.WithGroupKey(groupKey3)).GenerateMany(1, 3) - _, err := store.InsertAlertRules(context.Background(), append(append(append(misc, group1...), group2...), group3...)) + _, err := store.InsertAlertRules(context.Background(), &usr, append(append(append(misc, group1...), group2...), group3...)) require.NoError(t, err) t.Run("GetAlertRulesGroupByRuleUID", func(t *testing.T) { @@ -1524,6 +1610,7 @@ func TestIntegration_AlertRuleVersionsCleanup(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } + usr := models.UserUID("test") cfg := setting.NewCfg() cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{ BaseInterval: time.Duration(rand.Int63n(100)+1) * time.Second, @@ -1544,7 +1631,7 @@ func TestIntegration_AlertRuleVersionsCleanup(t *testing.T) { rule := createRule(t, store, generator) firstNewRule := models.CopyRule(rule) firstNewRule.Title = util.GenerateShortUID() - err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{ + err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{ Existing: rule, New: *firstNewRule, }, @@ -1553,7 +1640,7 @@ func TestIntegration_AlertRuleVersionsCleanup(t *testing.T) { firstNewRule.Version = firstNewRule.Version + 1 secondNewRule := models.CopyRule(firstNewRule) secondNewRule.Title = util.GenerateShortUID() - err = store.UpdateAlertRules(context.Background(), []models.UpdateRule{{ + err = store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{ Existing: firstNewRule, New: *secondNewRule, }, @@ -1592,7 +1679,7 @@ func TestIntegration_AlertRuleVersionsCleanup(t *testing.T) { rule := createRule(t, store, generator) oldRule := models.CopyRule(rule) oldRule.Title = "old-record" - err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{ + err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{ Existing: rule, New: *oldRule, }}) // first entry in `rule_version_history` table happens here @@ -1601,19 +1688,19 @@ func TestIntegration_AlertRuleVersionsCleanup(t *testing.T) { rule.Version = rule.Version + 1 middleRule := models.CopyRule(rule) middleRule.Title = "middle-record" - err = store.UpdateAlertRules(context.Background(), []models.UpdateRule{{ + err = store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{ Existing: rule, New: *middleRule, - }}) //second entry in `rule_version_history` table happens here + }}) // second entry in `rule_version_history` table happens here require.NoError(t, err) rule.Version = rule.Version + 1 newerRule := models.CopyRule(rule) newerRule.Title = "newer-record" - err = store.UpdateAlertRules(context.Background(), []models.UpdateRule{{ + err = store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{ Existing: rule, New: *newerRule, - }}) //second entry in `rule_version_history` table happens here + }}) // second entry in `rule_version_history` table happens here require.NoError(t, err) // only the `old-record` should be deleted since limit is set to 1 and there are total 2 records diff --git a/pkg/services/ngalert/store/compat.go b/pkg/services/ngalert/store/compat.go index 7f255b8c7b0..ed36782114b 100644 --- a/pkg/services/ngalert/store/compat.go +++ b/pkg/services/ngalert/store/compat.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/services/ngalert/models" ) @@ -35,6 +36,10 @@ func alertRuleToModelsAlertRule(ar alertRule, l log.Logger) (models.AlertRule, e IsPaused: ar.IsPaused, } + if ar.UpdatedBy != nil { + result.UpdatedBy = util.Pointer(models.UserUID(*ar.UpdatedBy)) + } + if ar.NoDataState != "" { result.NoDataState, err = models.NoDataStateFromString(ar.NoDataState) if err != nil { @@ -120,6 +125,10 @@ func alertRuleFromModelsAlertRule(ar models.AlertRule) (alertRule, error) { IsPaused: ar.IsPaused, } + if ar.UpdatedBy != nil { + result.UpdatedBy = util.Pointer(string(*ar.UpdatedBy)) + } + // Serialize complex types to JSON strings data, err := json.Marshal(ar.Data) if err != nil { @@ -179,6 +188,7 @@ func alertRuleToAlertRuleVersion(rule alertRule) alertRuleVersion { RestoredFrom: 0, Version: rule.Version, Created: rule.Updated, // assuming the Updated time as the creation time + CreatedBy: rule.UpdatedBy, Title: rule.Title, Condition: rule.Condition, Data: rule.Data, diff --git a/pkg/services/ngalert/store/deltas.go b/pkg/services/ngalert/store/deltas.go index c693d51d3b3..27469f5733a 100644 --- a/pkg/services/ngalert/store/deltas.go +++ b/pkg/services/ngalert/store/deltas.go @@ -9,7 +9,7 @@ import ( ) // AlertRuleFieldsToIgnoreInDiff contains fields that are ignored when calculating the RuleDelta.Diff. -var AlertRuleFieldsToIgnoreInDiff = [...]string{"ID", "Version", "Updated"} +var AlertRuleFieldsToIgnoreInDiff = [...]string{"ID", "Version", "Updated", "UpdatedBy"} type RuleDelta struct { Existing *models.AlertRule diff --git a/pkg/services/ngalert/store/models.go b/pkg/services/ngalert/store/models.go index 39ba210995c..3e47ab90fa4 100644 --- a/pkg/services/ngalert/store/models.go +++ b/pkg/services/ngalert/store/models.go @@ -10,6 +10,7 @@ type alertRule struct { Condition string Data string Updated time.Time + UpdatedBy *string `xorm:"updated_by"` IntervalSeconds int64 Version int64 `xorm:"version"` // this tag makes xorm add optimistic lock (see https://xorm.io/docs/chapter-06/1.lock/) UID string `xorm:"uid"` @@ -46,6 +47,7 @@ type alertRuleVersion struct { Version int64 Created time.Time + CreatedBy *string `xorm:"created_by"` Title string Condition string Data string diff --git a/pkg/services/ngalert/tests/fakes/rules.go b/pkg/services/ngalert/tests/fakes/rules.go index 014de832bfe..3784ee6ed9b 100644 --- a/pkg/services/ngalert/tests/fakes/rules.go +++ b/pkg/services/ngalert/tests/fakes/rules.go @@ -258,7 +258,7 @@ func (f *RuleStore) GetNamespaceByUID(_ context.Context, uid string, orgID int64 return nil, fmt.Errorf("not found") } -func (f *RuleStore) UpdateAlertRules(_ context.Context, q []models.UpdateRule) error { +func (f *RuleStore) UpdateAlertRules(_ context.Context, _ *models.UserUID, q []models.UpdateRule) error { f.mtx.Lock() defer f.mtx.Unlock() f.RecordedOps = append(f.RecordedOps, q) @@ -268,7 +268,7 @@ func (f *RuleStore) UpdateAlertRules(_ context.Context, q []models.UpdateRule) e return nil } -func (f *RuleStore) InsertAlertRules(_ context.Context, q []models.AlertRule) ([]models.AlertRuleKeyWithId, error) { +func (f *RuleStore) InsertAlertRules(_ context.Context, _ *models.UserUID, q []models.AlertRule) ([]models.AlertRuleKeyWithId, error) { f.mtx.Lock() defer f.mtx.Unlock() f.RecordedOps = append(f.RecordedOps, q) diff --git a/pkg/services/ngalert/tests/util.go b/pkg/services/ngalert/tests/util.go index e5ff7a1b10b..6188d6291a9 100644 --- a/pkg/services/ngalert/tests/util.go +++ b/pkg/services/ngalert/tests/util.go @@ -113,7 +113,7 @@ func CreateTestAlertRuleWithLabels(t testing.TB, ctx context.Context, dbstore *s } require.NoError(t, err) - _, err = dbstore.InsertAlertRules(ctx, []models.AlertRule{ + _, err = dbstore.InsertAlertRules(ctx, models.NewUserUID(user), []models.AlertRule{ { ID: 0, diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 9ba0bcaea9a..317eb39a13d 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -141,4 +141,6 @@ func (oss *OSSMigrations) AddMigration(mg *Migrator) { externalsession.AddMigration(mg) accesscontrol.AddReceiverCreateScopeMigration(mg) + + ualert.AddAlertRuleUpdatedByMigration(mg) } diff --git a/pkg/services/sqlstore/migrations/ualert/rule_creator_mig.go b/pkg/services/sqlstore/migrations/ualert/rule_creator_mig.go new file mode 100644 index 00000000000..563972b0ae0 --- /dev/null +++ b/pkg/services/sqlstore/migrations/ualert/rule_creator_mig.go @@ -0,0 +1,20 @@ +package ualert + +import "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + +// AddRuleNotificationSettingsColumns creates a column for notification settings in the alert_rule and alert_rule_version tables. +func AddAlertRuleUpdatedByMigration(mg *migrator.Migrator) { + mg.AddMigration("add created_by column to alert_rule_version table", migrator.NewAddColumnMigration(migrator.Table{Name: "alert_rule_version"}, &migrator.Column{ + Name: "created_by", + Type: migrator.DB_Varchar, + Length: 40, + Nullable: true, + })) + + mg.AddMigration("add updated_by column to alert_rule table", migrator.NewAddColumnMigration(migrator.Table{Name: "alert_rule"}, &migrator.Column{ + Name: "updated_by", + Type: migrator.DB_Varchar, + Length: 40, + Nullable: true, + })) +} diff --git a/pkg/storage/unified/federated/federatedtests/stats_test.go b/pkg/storage/unified/federated/federatedtests/stats_test.go index ff4d04e5ba8..8b67af4fce9 100644 --- a/pkg/storage/unified/federated/federatedtests/stats_test.go +++ b/pkg/storage/unified/federated/federatedtests/stats_test.go @@ -69,7 +69,7 @@ func TestDirectSQLStats(t *testing.T) { // create an alert rule inside of folder test2 ruleStore := ngalertstore.SetupStoreForTesting(t, db) - _, err = ruleStore.InsertAlertRules(context.Background(), []ngmodels.AlertRule{ + _, err = ruleStore.InsertAlertRules(context.Background(), ngmodels.NewUserUID(tempUser), []ngmodels.AlertRule{ { DashboardUID: &folder2UID, UID: "test", diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go index 79747f40b32..4bf9d97c980 100644 --- a/pkg/tests/api/alerting/api_alertmanager_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_test.go @@ -2,46 +2,28 @@ package alerting import ( "bytes" - "context" "encoding/json" "fmt" "io" "net/http" "regexp" - "strings" "testing" "time" "github.com/go-openapi/strfmt" amv2 "github.com/prometheus/alertmanager/api/v2/models" - "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/apimachinery/errutil" - "github.com/grafana/grafana/pkg/expr" - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/featuremgmt" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" - ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" - ngstore "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/org/orgimpl" - "github.com/grafana/grafana/pkg/services/quota/quotaimpl" - "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/services/user/userimpl" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests/testinfra" "github.com/grafana/grafana/pkg/util" ) -type Response struct { - Message string `json:"message"` - TraceID string `json:"traceID"` -} - func TestIntegrationAMConfigAccess(t *testing.T) { testinfra.SQLiteIntegrationTest(t) @@ -419,1482 +401,6 @@ func TestIntegrationAMConfigAccess(t *testing.T) { }) } -func TestIntegrationAlertAndGroupsQuery(t *testing.T) { - testinfra.SQLiteIntegrationTest(t) - - dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ - DisableLegacyAlerting: true, - EnableUnifiedAlerting: true, - DisableAnonymous: true, - AppModeProduction: true, - }) - - grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) - - // unauthenticated request to get the alerts should fail - { - alertsURL := fmt.Sprintf("http://%s/api/alertmanager/grafana/api/v2/alerts", grafanaListedAddr) - // nolint:gosec - resp, err := http.Get(alertsURL) - require.NoError(t, err) - t.Cleanup(func() { - err := resp.Body.Close() - require.NoError(t, err) - }) - b, err := io.ReadAll(resp.Body) - require.NoError(t, err) - require.Equal(t, http.StatusUnauthorized, resp.StatusCode) - require.Contains(t, string(b), `"message":"Unauthorized"`) - } - - // Create a user to make authenticated requests - createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ - DefaultOrgRole: string(org.RoleEditor), - Password: "password", - Login: "grafana", - }) - - apiClient := newAlertingApiClient(grafanaListedAddr, "grafana", "password") - - // invalid credentials request to get the alerts should fail - { - alertsURL := fmt.Sprintf("http://grafana:invalid@%s/api/alertmanager/grafana/api/v2/alerts", grafanaListedAddr) - // nolint:gosec - resp, err := http.Get(alertsURL) - require.NoError(t, err) - t.Cleanup(func() { - err := resp.Body.Close() - require.NoError(t, err) - }) - b, err := io.ReadAll(resp.Body) - require.NoError(t, err) - assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) - - var res map[string]any - require.NoError(t, json.Unmarshal(b, &res)) - assert.Equal(t, "Invalid username or password", res["message"]) - } - - // When there are no alerts available, it returns an empty list. - { - alertsURL := fmt.Sprintf("http://grafana:password@%s/api/alertmanager/grafana/api/v2/alerts", grafanaListedAddr) - // nolint:gosec - resp, err := http.Get(alertsURL) - require.NoError(t, err) - t.Cleanup(func() { - err := resp.Body.Close() - require.NoError(t, err) - }) - b, err := io.ReadAll(resp.Body) - require.NoError(t, err) - require.Equal(t, 200, resp.StatusCode) - require.JSONEq(t, "[]", string(b)) - } - - // When are there no alerts available, it returns an empty list of groups. - { - alertsURL := fmt.Sprintf("http://grafana:password@%s/api/alertmanager/grafana/api/v2/alerts/groups", grafanaListedAddr) - // nolint:gosec - resp, err := http.Get(alertsURL) - require.NoError(t, err) - t.Cleanup(func() { - err := resp.Body.Close() - require.NoError(t, err) - }) - b, err := io.ReadAll(resp.Body) - require.NoError(t, err) - require.NoError(t, err) - require.Equal(t, 200, resp.StatusCode) - require.JSONEq(t, "[]", string(b)) - } - - // Now, let's test the endpoint with some alerts. - { - // Create the namespace we'll save our alerts to. - apiClient.CreateFolder(t, "default", "default") - } - - // Create an alert that will fire as quickly as possible - { - interval, err := model.ParseDuration("10s") - require.NoError(t, err) - rules := apimodels.PostableRuleGroupConfig{ - Name: "arulegroup", - Interval: interval, - Rules: []apimodels.PostableExtendedRuleNode{ - { - GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - Title: "AlwaysFiring", - Condition: "A", - Data: []apimodels.AlertQuery{ - { - RefID: "A", - RelativeTimeRange: apimodels.RelativeTimeRange{ - From: apimodels.Duration(time.Duration(5) * time.Hour), - To: apimodels.Duration(time.Duration(3) * time.Hour), - }, - DatasourceUID: expr.DatasourceUID, - Model: json.RawMessage(`{ - "type": "math", - "expression": "2 + 3 > 1" - }`), - }, - }, - }, - }, - }, - } - - _, status, _ := apiClient.PostRulesGroupWithStatus(t, "default", &rules) - assert.Equal(t, http.StatusAccepted, status) - } - - // Eventually, we'll get an alert with its state being active. - { - alertsURL := fmt.Sprintf("http://grafana:password@%s/api/alertmanager/grafana/api/v2/alerts", grafanaListedAddr) - // nolint:gosec - require.Eventually(t, func() bool { - resp, err := http.Get(alertsURL) - require.NoError(t, err) - t.Cleanup(func() { - err := resp.Body.Close() - require.NoError(t, err) - }) - b, err := io.ReadAll(resp.Body) - require.NoError(t, err) - require.Equal(t, 200, resp.StatusCode) - - var alerts apimodels.GettableAlerts - err = json.Unmarshal(b, &alerts) - require.NoError(t, err) - - if len(alerts) > 0 { - status := alerts[0].Status - return status != nil && status.State != nil && *status.State == "active" - } - - return false - }, 18*time.Second, 2*time.Second) - } -} - -func TestIntegrationRulerAccess(t *testing.T) { - testinfra.SQLiteIntegrationTest(t) - - // Setup Grafana and its Database - dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ - DisableLegacyAlerting: true, - EnableUnifiedAlerting: true, - EnableQuota: true, - DisableAnonymous: true, - ViewersCanEdit: true, - AppModeProduction: true, - }) - - grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) - - // Create a users to make authenticated requests - createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ - DefaultOrgRole: string(org.RoleViewer), - Password: "viewer", - Login: "viewer", - }) - createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ - DefaultOrgRole: string(org.RoleEditor), - Password: "editor", - Login: "editor", - }) - createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ - DefaultOrgRole: string(org.RoleAdmin), - Password: "admin", - Login: "admin", - }) - - client := newAlertingApiClient(grafanaListedAddr, "editor", "editor") - - // Create the namespace we'll save our alerts to. - client.CreateFolder(t, "default", "default") - - // Now, let's test the access policies. - testCases := []struct { - desc string - client apiClient - expStatus int - expectedMessage string - }{ - { - desc: "un-authenticated request should fail", - client: newAlertingApiClient(grafanaListedAddr, "", ""), - expStatus: http.StatusUnauthorized, - expectedMessage: `Unauthorized`, - }, - { - desc: "viewer request should fail", - client: newAlertingApiClient(grafanaListedAddr, "viewer", "viewer"), - expStatus: http.StatusForbidden, - expectedMessage: `You'll need additional permissions to perform this action. Permissions needed: all of alert.rules:read, folders:read, any of alert.rules:write, alert.rules:create, alert.rules:delete`, - }, - { - desc: "editor request should succeed", - client: newAlertingApiClient(grafanaListedAddr, "editor", "editor"), - expStatus: http.StatusAccepted, - expectedMessage: `rule group updated successfully`, - }, - { - desc: "admin request should succeed", - client: newAlertingApiClient(grafanaListedAddr, "admin", "admin"), - expStatus: http.StatusAccepted, - expectedMessage: `rule group updated successfully`, - }, - } - - for i, tc := range testCases { - t.Run(tc.desc, func(t *testing.T) { - interval, err := model.ParseDuration("1m") - require.NoError(t, err) - - rules := apimodels.PostableRuleGroupConfig{ - Name: "arulegroup", - Rules: []apimodels.PostableExtendedRuleNode{ - { - ApiRuleNode: &apimodels.ApiRuleNode{ - For: &interval, - Labels: map[string]string{"label1": "val1"}, - Annotations: map[string]string{"annotation1": "val1"}, - }, - // this rule does not explicitly set no data and error states - // therefore it should get the default values - GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - Title: fmt.Sprintf("AlwaysFiring %d", i), - Condition: "A", - Data: []apimodels.AlertQuery{ - { - RefID: "A", - RelativeTimeRange: apimodels.RelativeTimeRange{ - From: apimodels.Duration(time.Duration(5) * time.Hour), - To: apimodels.Duration(time.Duration(3) * time.Hour), - }, - DatasourceUID: expr.DatasourceUID, - Model: json.RawMessage(`{ - "type": "math", - "expression": "2 + 3 > 1" - }`), - }, - }, - }, - }, - }, - } - _, status, body := tc.client.PostRulesGroupWithStatus(t, "default", &rules) - assert.Equal(t, tc.expStatus, status) - res := &Response{} - err = json.Unmarshal([]byte(body), &res) - require.NoError(t, err) - require.Equal(t, tc.expectedMessage, res.Message) - }) - } -} - -func TestIntegrationDeleteFolderWithRules(t *testing.T) { - testinfra.SQLiteIntegrationTest(t) - - opts := testinfra.GrafanaOpts{ - DisableLegacyAlerting: true, - EnableUnifiedAlerting: true, - EnableQuota: true, - DisableAnonymous: true, - ViewersCanEdit: true, - AppModeProduction: true, - } - - // Setup Grafana and its Database - dir, path := testinfra.CreateGrafDir(t, opts) - - grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) - - createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ - DefaultOrgRole: string(org.RoleViewer), - Password: "viewer", - Login: "viewer", - }) - createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ - DefaultOrgRole: string(org.RoleEditor), - Password: "editor", - Login: "editor", - }) - - apiClient := newAlertingApiClient(grafanaListedAddr, "editor", "editor") - - // Create the namespace we'll save our alerts to. - namespaceUID := "default" //nolint:goconst - apiClient.CreateFolder(t, namespaceUID, namespaceUID) - - createRule(t, apiClient, "default") - - t.Run("editor create a rule within the folder/namespace", func(t *testing.T) { - u := fmt.Sprintf("http://editor:editor@%s/api/ruler/grafana/api/v1/rules", grafanaListedAddr) - // nolint:gosec - resp, err := http.Get(u) - require.NoError(t, err) - t.Cleanup(func() { - err := resp.Body.Close() - require.NoError(t, err) - }) - b, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, 200, resp.StatusCode) - - re := regexp.MustCompile(`"uid":"([\w|-]+)"`) - b = re.ReplaceAll(b, []byte(`"uid":""`)) - re = regexp.MustCompile(`"updated":"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)"`) - b = re.ReplaceAll(b, []byte(`"updated":"2021-05-19T19:47:55Z"`)) - - expectedGetRulesResponseBody := fmt.Sprintf(`{ - "default": [ - { - "name": "arulegroup", - "interval": "1m", - "rules": [ - { - "expr": "", - "for": "2m", - "labels": { - "label1": "val1" - }, - "annotations": { - "annotation1": "val1" - }, - "grafana_alert": { - "id": 1, - "orgId": 1, - "title": "rule under folder default", - "condition": "A", - "data": [ - { - "refId": "A", - "queryType": "", - "relativeTimeRange": { - "from": 18000, - "to": 10800 - }, - "datasourceUid": "__expr__", - "model": { - "expression": "2 + 3 > 1", - "intervalMs": 1000, - "maxDataPoints": 43200, - "type": "math" - } - } - ], - "updated": "2021-05-19T19:47:55Z", - "intervalSeconds": 60, - "is_paused": false, - "version": 1, - "uid": "", - "namespace_uid": %q, - "rule_group": "arulegroup", - "no_data_state": "NoData", - "exec_err_state": "Alerting", - "metadata": { - "editor_settings": { - "simplified_query_and_expressions_section": false, - "simplified_notifications_section": false - } - } - } - } - ] - } - ] - }`, namespaceUID) - assert.JSONEq(t, expectedGetRulesResponseBody, string(b)) - }) - t.Run("editor can not delete the folder because it contains Grafana 8 alerts", func(t *testing.T) { - u := fmt.Sprintf("http://editor:editor@%s/api/folders/%s", grafanaListedAddr, namespaceUID) - req, err := http.NewRequest(http.MethodDelete, u, nil) - require.NoError(t, err) - client := &http.Client{} - resp, err := client.Do(req) - require.NoError(t, err) - t.Cleanup(func() { - err := resp.Body.Close() - require.NoError(t, err) - }) - b, err := io.ReadAll(resp.Body) - require.NoError(t, err) - require.Equal(t, http.StatusBadRequest, resp.StatusCode) - var errutilErr errutil.PublicError - err = json.Unmarshal(b, &errutilErr) - require.NoError(t, err) - assert.Equal(t, "Folder cannot be deleted: folder is not empty", errutilErr.Message) - }) - t.Run("editor can delete the folder if forceDeleteRules is true", func(t *testing.T) { - u := fmt.Sprintf("http://editor:editor@%s/api/folders/%s?forceDeleteRules=true", grafanaListedAddr, namespaceUID) - req, err := http.NewRequest(http.MethodDelete, u, nil) - require.NoError(t, err) - client := &http.Client{} - resp, err := client.Do(req) - require.NoError(t, err) - t.Cleanup(func() { - err := resp.Body.Close() - require.NoError(t, err) - }) - _, err = io.ReadAll(resp.Body) - require.NoError(t, err) - require.Equal(t, 200, resp.StatusCode) - }) - t.Run("editor can delete rules", func(t *testing.T) { - u := fmt.Sprintf("http://editor:editor@%s/api/ruler/grafana/api/v1/rules", grafanaListedAddr) - // nolint:gosec - resp, err := http.Get(u) - require.NoError(t, err) - t.Cleanup(func() { - err := resp.Body.Close() - require.NoError(t, err) - }) - b, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, 200, resp.StatusCode) - assert.JSONEq(t, "{}", string(b)) - }) - // TODO(@leonorfmartins): write tests for uni store when we are able to support it -} - -func TestIntegrationAlertRuleCRUD(t *testing.T) { - testinfra.SQLiteIntegrationTest(t) - - // Setup Grafana and its Database - dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ - DisableLegacyAlerting: true, - EnableUnifiedAlerting: true, - EnableQuota: true, - DisableAnonymous: true, - AppModeProduction: true, - }) - - grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) - - createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ - DefaultOrgRole: string(org.RoleEditor), - Password: "password", - Login: "grafana", - }) - - apiClient := newAlertingApiClient(grafanaListedAddr, "grafana", "password") - - // Create the namespace we'll save our alerts to. - apiClient.CreateFolder(t, "default", "default") - - interval, err := model.ParseDuration("1m") - require.NoError(t, err) - - invalidInterval, err := model.ParseDuration("1s") - require.NoError(t, err) - - // Now, let's try to create some invalid alert rules. - { - testCases := []struct { - desc string - rulegroup string - interval model.Duration - rule apimodels.PostableExtendedRuleNode - expectedCode int - expectedMessage string - }{ - { - desc: "alert rule without queries and expressions", - rulegroup: "arulegroup", - rule: apimodels.PostableExtendedRuleNode{ - ApiRuleNode: &apimodels.ApiRuleNode{ - For: &interval, - Labels: map[string]string{"label1": "val1"}, - Annotations: map[string]string{"annotation1": "val1"}, - }, - GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - Title: "AlwaysFiring", - Condition: "A", - Data: []apimodels.AlertQuery{}, - }, - }, - expectedMessage: "invalid rule specification at index [0]: invalid alert rule: no queries or expressions are found", - }, - { - desc: "alert rule with empty title", - rulegroup: "arulegroup", - rule: apimodels.PostableExtendedRuleNode{ - ApiRuleNode: &apimodels.ApiRuleNode{ - For: &interval, - Labels: map[string]string{"label1": "val1"}, - Annotations: map[string]string{"annotation1": "val1"}, - }, - GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - Title: "", - Condition: "A", - Data: []apimodels.AlertQuery{ - { - RefID: "A", - RelativeTimeRange: apimodels.RelativeTimeRange{ - From: apimodels.Duration(time.Duration(5) * time.Hour), - To: apimodels.Duration(time.Duration(3) * time.Hour), - }, - DatasourceUID: expr.DatasourceUID, - Model: json.RawMessage(`{ - "type": "math", - "expression": "2 + 3 > 1" - }`), - }, - }, - }, - }, - expectedMessage: "invalid rule specification at index [0]: alert rule title cannot be empty", - }, - { - desc: "alert rule with too long name", - rulegroup: "arulegroup", - rule: apimodels.PostableExtendedRuleNode{ - ApiRuleNode: &apimodels.ApiRuleNode{ - For: &interval, - Labels: map[string]string{"label1": "val1"}, - Annotations: map[string]string{"annotation1": "val1"}, - }, - GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - Title: getLongString(t, ngstore.AlertRuleMaxTitleLength+1), - Condition: "A", - Data: []apimodels.AlertQuery{ - { - RefID: "A", - RelativeTimeRange: apimodels.RelativeTimeRange{ - From: apimodels.Duration(time.Duration(5) * time.Hour), - To: apimodels.Duration(time.Duration(3) * time.Hour), - }, - DatasourceUID: expr.DatasourceUID, - Model: json.RawMessage(`{ - "type": "math", - "expression": "2 + 3 > 1" - }`), - }, - }, - }, - }, - expectedMessage: "invalid rule specification at index [0]: alert rule title is too long. Max length is 190", - }, - { - desc: "alert rule with too long rulegroup", - rulegroup: getLongString(t, ngstore.AlertRuleMaxTitleLength+1), - rule: apimodels.PostableExtendedRuleNode{ - ApiRuleNode: &apimodels.ApiRuleNode{ - For: &interval, - Labels: map[string]string{"label1": "val1"}, - Annotations: map[string]string{"annotation1": "val1"}, - }, - GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - Title: "AlwaysFiring", - Condition: "A", - Data: []apimodels.AlertQuery{ - { - RefID: "A", - RelativeTimeRange: apimodels.RelativeTimeRange{ - From: apimodels.Duration(time.Duration(5) * time.Hour), - To: apimodels.Duration(time.Duration(3) * time.Hour), - }, - DatasourceUID: expr.DatasourceUID, - Model: json.RawMessage(`{ - "type": "math", - "expression": "2 + 3 > 1" - }`), - }, - }, - }, - }, - expectedMessage: "rule group name is too long. Max length is 190", - }, - { - desc: "alert rule with invalid interval", - rulegroup: "arulegroup", - interval: invalidInterval, - rule: apimodels.PostableExtendedRuleNode{ - ApiRuleNode: &apimodels.ApiRuleNode{ - For: &interval, - Labels: map[string]string{"label1": "val1"}, - Annotations: map[string]string{"annotation1": "val1"}, - }, - GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - Title: "AlwaysFiring", - Condition: "A", - Data: []apimodels.AlertQuery{ - { - RefID: "A", - RelativeTimeRange: apimodels.RelativeTimeRange{ - From: apimodels.Duration(time.Duration(5) * time.Hour), - To: apimodels.Duration(time.Duration(3) * time.Hour), - }, - DatasourceUID: expr.DatasourceUID, - Model: json.RawMessage(`{ - "type": "math", - "expression": "2 + 3 > 1" - }`), - }, - }, - }, - }, - expectedMessage: "rule evaluation interval (1 second) should be positive number that is multiple of the base interval of 10 seconds", - }, - { - desc: "alert rule with unknown datasource", - rulegroup: "arulegroup", - rule: apimodels.PostableExtendedRuleNode{ - ApiRuleNode: &apimodels.ApiRuleNode{ - For: &interval, - Labels: map[string]string{"label1": "val1"}, - Annotations: map[string]string{"annotation1": "val1"}, - }, - GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - Title: "AlwaysFiring", - Condition: "A", - Data: []apimodels.AlertQuery{ - { - RefID: "A", - RelativeTimeRange: apimodels.RelativeTimeRange{ - From: apimodels.Duration(time.Duration(5) * time.Hour), - To: apimodels.Duration(time.Duration(3) * time.Hour), - }, - DatasourceUID: "unknown", - Model: json.RawMessage(`{ - "type": "math", - "expression": "2 + 3 > 1" - }`), - }, - }, - }, - }, - expectedCode: func() int { - if setting.IsEnterprise { - return http.StatusForbidden - } - return http.StatusBadRequest - }(), - expectedMessage: func() string { - if setting.IsEnterprise { - return "user is not authorized to create a new alert rule 'AlwaysFiring'" - } - return "failed to update rule group: invalid alert rule 'AlwaysFiring': failed to build query 'A': data source not found" - }(), - }, - { - desc: "alert rule with invalid condition", - rulegroup: "arulegroup", - rule: apimodels.PostableExtendedRuleNode{ - ApiRuleNode: &apimodels.ApiRuleNode{ - For: &interval, - Labels: map[string]string{"label1": "val1"}, - Annotations: map[string]string{"annotation1": "val1"}, - }, - GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - Title: "AlwaysFiring", - Condition: "B", - Data: []apimodels.AlertQuery{ - { - RefID: "A", - RelativeTimeRange: apimodels.RelativeTimeRange{ - From: apimodels.Duration(time.Duration(5) * time.Hour), - To: apimodels.Duration(time.Duration(3) * time.Hour), - }, - DatasourceUID: expr.DatasourceUID, - Model: json.RawMessage(`{ - "type": "math", - "expression": "2 + 3 > 1" - }`), - }, - }, - }, - }, - expectedMessage: "invalid rule specification at index [0]: invalid alert rule: condition B does not exist, must be one of [A]", - }, - } - - for _, tc := range testCases { - t.Run(tc.desc, func(t *testing.T) { - rules := apimodels.PostableRuleGroupConfig{ - Name: tc.rulegroup, - Interval: tc.interval, - Rules: []apimodels.PostableExtendedRuleNode{ - tc.rule, - }, - } - _, status, body := apiClient.PostRulesGroupWithStatus(t, "default", &rules) - res := &Response{} - err = json.Unmarshal([]byte(body), &res) - require.NoError(t, err) - - assert.Equal(t, tc.expectedMessage, res.Message) - expectedCode := tc.expectedCode - if expectedCode == 0 { - expectedCode = http.StatusBadRequest - } - assert.Equal(t, expectedCode, status) - }) - } - } - - var ruleUID string - var expectedGetNamespaceResponseBody string - // Now, let's create two alerts. - { - rules := apimodels.PostableRuleGroupConfig{ - Name: "arulegroup", - Rules: []apimodels.PostableExtendedRuleNode{ - { - ApiRuleNode: &apimodels.ApiRuleNode{ - For: &interval, - Labels: map[string]string{"label1": "val1"}, - Annotations: map[string]string{"annotation1": "val1"}, - }, - // this rule does not explicitly set no data and error states - // therefore it should get the default values - GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - Title: "AlwaysFiring", - Condition: "A", - Data: []apimodels.AlertQuery{ - { - RefID: "A", - RelativeTimeRange: apimodels.RelativeTimeRange{ - From: apimodels.Duration(time.Duration(5) * time.Hour), - To: apimodels.Duration(time.Duration(3) * time.Hour), - }, - DatasourceUID: expr.DatasourceUID, - Model: json.RawMessage(`{ - "type": "math", - "expression": "2 + 3 > 1" - }`), - }, - }, - }, - }, - { - GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - Title: "AlwaysFiringButSilenced", - Condition: "A", - Data: []apimodels.AlertQuery{ - { - RefID: "A", - RelativeTimeRange: apimodels.RelativeTimeRange{ - From: apimodels.Duration(time.Duration(5) * time.Hour), - To: apimodels.Duration(time.Duration(3) * time.Hour), - }, - DatasourceUID: expr.DatasourceUID, - Model: json.RawMessage(`{ - "type": "math", - "expression": "2 + 3 > 1" - }`), - }, - }, - NoDataState: apimodels.NoDataState(ngmodels.Alerting), - ExecErrState: apimodels.ExecutionErrorState(ngmodels.AlertingErrState), - }, - }, - }, - } - resp, status, _ := apiClient.PostRulesGroupWithStatus(t, "default", &rules) - assert.Equal(t, http.StatusAccepted, status) - require.Equal(t, "rule group updated successfully", resp.Message) - assert.Len(t, resp.Created, 2) - assert.Empty(t, resp.Updated) - assert.Empty(t, resp.Deleted) - } - - // With the rules created, let's make sure that rule definition is stored correctly. - { - u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) - // nolint:gosec - resp, err := http.Get(u) - require.NoError(t, err) - t.Cleanup(func() { - err := resp.Body.Close() - require.NoError(t, err) - }) - b, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, resp.StatusCode, 202) - - body, m := rulesNamespaceWithoutVariableValues(t, b) - generatedUIDs, ok := m["default,arulegroup"] - assert.True(t, ok) - assert.Equal(t, 2, len(generatedUIDs)) - // assert that generated UIDs are unique - assert.NotEqual(t, generatedUIDs[0], generatedUIDs[1]) - // copy result to a variable with a wider scope - // to be used by the next test - ruleUID = generatedUIDs[0] - expectedGetNamespaceResponseBody = ` - { - "default":[ - { - "name":"arulegroup", - "interval":"1m", - "rules":[ - { - "annotations": { - "annotation1": "val1" - }, - "expr":"", - "for": "1m", - "labels": { - "label1": "val1" - }, - "grafana_alert":{ - "id":1, - "orgId":1, - "title":"AlwaysFiring", - "condition":"A", - "data":[ - { - "refId":"A", - "queryType":"", - "relativeTimeRange":{ - "from":18000, - "to":10800 - }, - "datasourceUid":"__expr__", - "model":{ - "expression":"2 + 3 \u003e 1", - "intervalMs":1000, - "maxDataPoints":43200, - "type":"math" - } - } - ], - "updated":"2021-02-21T01:10:30Z", - "intervalSeconds":60, - "is_paused": false, - "version":1, - "uid":"uid", - "namespace_uid":"nsuid", - "rule_group":"arulegroup", - "no_data_state":"NoData", - "exec_err_state":"Alerting", - "metadata": { - "editor_settings": { - "simplified_query_and_expressions_section": false, - "simplified_notifications_section": false - } - } - } - }, - { - "expr":"", - "for": "0s", - "grafana_alert":{ - "id":2, - "orgId":1, - "title":"AlwaysFiringButSilenced", - "condition":"A", - "data":[ - { - "refId":"A", - "queryType":"", - "relativeTimeRange":{ - "from":18000, - "to":10800 - }, - "datasourceUid":"__expr__", - "model":{ - "expression":"2 + 3 \u003e 1", - "intervalMs":1000, - "maxDataPoints":43200, - "type":"math" - } - } - ], - "updated":"2021-02-21T01:10:30Z", - "intervalSeconds":60, - "is_paused": false, - "version":1, - "uid":"uid", - "namespace_uid":"nsuid", - "rule_group":"arulegroup", - "no_data_state":"Alerting", - "exec_err_state":"Alerting", - "metadata": { - "editor_settings": { - "simplified_query_and_expressions_section": false, - "simplified_notifications_section": false - } - } - } - } - ] - } - ] - }` - assert.JSONEq(t, expectedGetNamespaceResponseBody, body) - } - - // try to update by pass an invalid UID - { - interval, err := model.ParseDuration("30s") - require.NoError(t, err) - - rules := apimodels.PostableRuleGroupConfig{ - Name: "arulegroup", - Rules: []apimodels.PostableExtendedRuleNode{ - { - ApiRuleNode: &apimodels.ApiRuleNode{ - For: &interval, - Labels: map[string]string{ - "label1": "val42", - "foo": "bar", - }, - Annotations: map[string]string{ - "annotation1": "val42", - "foo": "bar", - }, - }, - GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - UID: "unknown", - Title: "AlwaysNormal", - Condition: "A", - Data: []apimodels.AlertQuery{ - { - RefID: "A", - RelativeTimeRange: apimodels.RelativeTimeRange{ - From: apimodels.Duration(time.Duration(5) * time.Hour), - To: apimodels.Duration(time.Duration(3) * time.Hour), - }, - DatasourceUID: expr.DatasourceUID, - Model: json.RawMessage(`{ - "type": "math", - "expression": "2 + 3 < 1" - }`), - }, - }, - NoDataState: apimodels.NoDataState(ngmodels.Alerting), - ExecErrState: apimodels.ExecutionErrorState(ngmodels.AlertingErrState), - }, - }, - }, - Interval: interval, - } - - _, status, body := apiClient.PostRulesGroupWithStatus(t, "default", &rules) - assert.Equal(t, http.StatusNotFound, status) - var res map[string]any - assert.NoError(t, json.Unmarshal([]byte(body), &res)) - require.Equal(t, "failed to update rule group: failed to update rule with UID unknown because could not find alert rule", res["message"]) - - // let's make sure that rule definitions are not affected by the failed POST request. - u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) - // nolint:gosec - resp, err := http.Get(u) - require.NoError(t, err) - t.Cleanup(func() { - err := resp.Body.Close() - require.NoError(t, err) - }) - b, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, resp.StatusCode, 202) - - body, m := rulesNamespaceWithoutVariableValues(t, b) - returnedUIDs, ok := m["default,arulegroup"] - assert.True(t, ok) - assert.Equal(t, 2, len(returnedUIDs)) - assert.JSONEq(t, expectedGetNamespaceResponseBody, body) - } - - // try to update by pass two rules with conflicting UIDs - { - interval, err := model.ParseDuration("30s") - require.NoError(t, err) - - rules := apimodels.PostableRuleGroupConfig{ - Name: "arulegroup", - Rules: []apimodels.PostableExtendedRuleNode{ - { - ApiRuleNode: &apimodels.ApiRuleNode{ - For: &interval, - Labels: map[string]string{ - "label1": "val42", - "foo": "bar", - }, - Annotations: map[string]string{ - "annotation1": "val42", - "foo": "bar", - }, - }, - GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - UID: ruleUID, - Title: "AlwaysNormal", - Condition: "A", - Data: []apimodels.AlertQuery{ - { - RefID: "A", - RelativeTimeRange: apimodels.RelativeTimeRange{ - From: apimodels.Duration(time.Duration(5) * time.Hour), - To: apimodels.Duration(time.Duration(3) * time.Hour), - }, - DatasourceUID: expr.DatasourceUID, - Model: json.RawMessage(`{ - "type": "math", - "expression": "2 + 3 < 1" - }`), - }, - }, - NoDataState: apimodels.NoDataState(ngmodels.Alerting), - ExecErrState: apimodels.ExecutionErrorState(ngmodels.AlertingErrState), - }, - }, - { - ApiRuleNode: &apimodels.ApiRuleNode{ - For: &interval, - Labels: map[string]string{ - "label1": "val42", - "foo": "bar", - }, - Annotations: map[string]string{ - "annotation1": "val42", - "foo": "bar", - }, - }, - GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - UID: ruleUID, - Title: "AlwaysAlerting", - Condition: "A", - Data: []apimodels.AlertQuery{ - { - RefID: "A", - RelativeTimeRange: apimodels.RelativeTimeRange{ - From: apimodels.Duration(time.Duration(5) * time.Hour), - To: apimodels.Duration(time.Duration(3) * time.Hour), - }, - DatasourceUID: expr.DatasourceUID, - Model: json.RawMessage(`{ - "type": "math", - "expression": "2 + 3 > 1" - }`), - }, - }, - NoDataState: apimodels.NoDataState(ngmodels.Alerting), - ExecErrState: apimodels.ExecutionErrorState(ngmodels.AlertingErrState), - }, - }, - }, - Interval: interval, - } - _, status, body := apiClient.PostRulesGroupWithStatus(t, "default", &rules) - assert.Equal(t, http.StatusBadRequest, status) - var res map[string]any - require.NoError(t, json.Unmarshal([]byte(body), &res)) - require.Equal(t, fmt.Sprintf("rule [1] has UID %s that is already assigned to another rule at index 0", ruleUID), res["message"]) - - // let's make sure that rule definitions are not affected by the failed POST request. - u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) - // nolint:gosec - resp, err := http.Get(u) - require.NoError(t, err) - t.Cleanup(func() { - err := resp.Body.Close() - require.NoError(t, err) - }) - b, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, resp.StatusCode, 202) - - body, m := rulesNamespaceWithoutVariableValues(t, b) - returnedUIDs, ok := m["default,arulegroup"] - assert.True(t, ok) - assert.Equal(t, 2, len(returnedUIDs)) - assert.JSONEq(t, expectedGetNamespaceResponseBody, body) - } - - // update the first rule and completely remove the other - { - forValue, err := model.ParseDuration("30s") - require.NoError(t, err) - - rules := apimodels.PostableRuleGroupConfig{ - Name: "arulegroup", - Rules: []apimodels.PostableExtendedRuleNode{ - { - ApiRuleNode: &apimodels.ApiRuleNode{ - For: &forValue, - Labels: map[string]string{ - // delete foo label - "label1": "val1", // update label value - "label2": "val2", // new label - }, - Annotations: map[string]string{ - // delete foo annotation - "annotation1": "val1", // update annotation value - "annotation2": "val2", // new annotation - }, - }, - GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - UID: ruleUID, // Including the UID in the payload makes the endpoint update the existing rule. - Title: "AlwaysNormal", - Condition: "A", - Data: []apimodels.AlertQuery{ - { - RefID: "A", - RelativeTimeRange: apimodels.RelativeTimeRange{ - From: apimodels.Duration(time.Duration(5) * time.Hour), - To: apimodels.Duration(time.Duration(3) * time.Hour), - }, - DatasourceUID: expr.DatasourceUID, - Model: json.RawMessage(`{ - "type": "math", - "expression": "2 + 3 < 1" - }`), - }, - }, - NoDataState: apimodels.NoDataState(ngmodels.Alerting), - ExecErrState: apimodels.ExecutionErrorState(ngmodels.AlertingErrState), - }, - }, - }, - Interval: interval, - } - respModel, status, _ := apiClient.PostRulesGroupWithStatus(t, "default", &rules) - assert.Equal(t, http.StatusAccepted, status) - require.Equal(t, respModel.Updated, []string{ruleUID}) - require.Len(t, respModel.Deleted, 1) - - // let's make sure that rule definitions are updated correctly. - u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) - // nolint:gosec - resp, err := http.Get(u) - require.NoError(t, err) - t.Cleanup(func() { - err := resp.Body.Close() - require.NoError(t, err) - }) - b, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, resp.StatusCode, 202) - - body, m := rulesNamespaceWithoutVariableValues(t, b) - returnedUIDs, ok := m["default,arulegroup"] - assert.True(t, ok) - assert.Equal(t, 1, len(returnedUIDs)) - assert.Equal(t, ruleUID, returnedUIDs[0]) - assert.JSONEq(t, ` - { - "default":[ - { - "name":"arulegroup", - "interval":"1m", - "rules":[ - { - "annotations": { - "annotation1": "val1", - "annotation2": "val2" - }, - "expr":"", - "for": "30s", - "labels": { - "label1": "val1", - "label2": "val2" - }, - "grafana_alert":{ - "id":1, - "orgId":1, - "title":"AlwaysNormal", - "condition":"A", - "data":[ - { - "refId":"A", - "queryType":"", - "relativeTimeRange":{ - "from":18000, - "to":10800 - }, - "datasourceUid":"__expr__", - "model":{ - "expression":"2 + 3 \u003C 1", - "intervalMs":1000, - "maxDataPoints":43200, - "type":"math" - } - } - ], - "updated":"2021-02-21T01:10:30Z", - "intervalSeconds":60, - "is_paused": false, - "version":2, - "uid":"uid", - "namespace_uid":"nsuid", - "rule_group":"arulegroup", - "no_data_state":"Alerting", - "exec_err_state":"Alerting", - "metadata": { - "editor_settings": { - "simplified_query_and_expressions_section": false, - "simplified_notifications_section": false - } - } - } - } - ] - } - ] - }`, body) - } - - // update the rule; delete labels and annotations - { - forValue, err := model.ParseDuration("30s") - require.NoError(t, err) - - rules := apimodels.PostableRuleGroupConfig{ - Name: "arulegroup", - Rules: []apimodels.PostableExtendedRuleNode{ - { - ApiRuleNode: &apimodels.ApiRuleNode{ - For: &forValue, - }, - GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - UID: ruleUID, // Including the UID in the payload makes the endpoint update the existing rule. - Title: "AlwaysNormal", - Condition: "A", - Data: []apimodels.AlertQuery{ - { - RefID: "A", - RelativeTimeRange: apimodels.RelativeTimeRange{ - From: apimodels.Duration(time.Duration(5) * time.Hour), - To: apimodels.Duration(time.Duration(3) * time.Hour), - }, - DatasourceUID: expr.DatasourceUID, - Model: json.RawMessage(`{ - "type": "math", - "expression": "2 + 3 < 1" - }`), - }, - }, - NoDataState: apimodels.NoDataState(ngmodels.Alerting), - ExecErrState: apimodels.ExecutionErrorState(ngmodels.AlertingErrState), - }, - }, - }, - Interval: interval, - } - respModel, status, _ := apiClient.PostRulesGroupWithStatus(t, "default", &rules) - assert.Equal(t, http.StatusAccepted, status) - require.Equal(t, respModel.Updated, []string{ruleUID}) - - // let's make sure that rule definitions are updated correctly. - u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) - // nolint:gosec - resp, err := http.Get(u) - require.NoError(t, err) - t.Cleanup(func() { - err := resp.Body.Close() - require.NoError(t, err) - }) - b, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, resp.StatusCode, 202) - - body, m := rulesNamespaceWithoutVariableValues(t, b) - returnedUIDs, ok := m["default,arulegroup"] - assert.True(t, ok) - assert.Equal(t, 1, len(returnedUIDs)) - assert.Equal(t, ruleUID, returnedUIDs[0]) - assert.JSONEq(t, ` - { - "default":[ - { - "name":"arulegroup", - "interval":"1m", - "rules":[ - { - "expr":"", - "for": "30s", - "grafana_alert":{ - "id":1, - "orgId":1, - "title":"AlwaysNormal", - "condition":"A", - "data":[ - { - "refId":"A", - "queryType":"", - "relativeTimeRange":{ - "from":18000, - "to":10800 - }, - "datasourceUid":"__expr__", - "model":{ - "expression":"2 + 3 \u003C 1", - "intervalMs":1000, - "maxDataPoints":43200, - "type":"math" - } - } - ], - "updated":"2021-02-21T01:10:30Z", - "intervalSeconds":60, - "is_paused":false, - "version":3, - "uid":"uid", - "namespace_uid":"nsuid", - "rule_group":"arulegroup", - "no_data_state":"Alerting", - "exec_err_state":"Alerting", - "metadata": { - "editor_settings": { - "simplified_query_and_expressions_section": false, - "simplified_notifications_section": false - } - } - } - } - ] - } - ] - }`, body) - } - - // update the rule; keep title, condition, no data state, error state, queries and expressions if not provided. should be noop - { - rules := apimodels.PostableRuleGroupConfig{ - Name: "arulegroup", - Rules: []apimodels.PostableExtendedRuleNode{ - { - GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - UID: ruleUID, // Including the UID in the payload makes the endpoint update the existing rule. - }, - }, - }, - Interval: interval, - } - respModel, status, _ := apiClient.PostRulesGroupWithStatus(t, "default", &rules) - assert.Equal(t, http.StatusAccepted, status) - require.Equal(t, "no changes detected in the rule group", respModel.Message) - assert.Empty(t, respModel.Created) - assert.Empty(t, respModel.Updated) - assert.Empty(t, respModel.Deleted) - - // let's make sure that rule definitions are updated correctly. - u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) - // nolint:gosec - resp, err := http.Get(u) - require.NoError(t, err) - t.Cleanup(func() { - err := resp.Body.Close() - require.NoError(t, err) - }) - b, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, resp.StatusCode, 202) - - body, m := rulesNamespaceWithoutVariableValues(t, b) - returnedUIDs, ok := m["default,arulegroup"] - assert.True(t, ok) - assert.Equal(t, 1, len(returnedUIDs)) - assert.Equal(t, ruleUID, returnedUIDs[0]) - assert.JSONEq(t, ` - { - "default":[ - { - "name":"arulegroup", - "interval":"1m", - "rules":[ - { - "expr":"", - "for": "30s", - "grafana_alert":{ - "id":1, - "orgId":1, - "title":"AlwaysNormal", - "condition":"A", - "data":[ - { - "refId":"A", - "queryType":"", - "relativeTimeRange":{ - "from":18000, - "to":10800 - }, - "datasourceUid":"__expr__", - "model":{ - "expression":"2 + 3 \u003C 1", - "intervalMs":1000, - "maxDataPoints":43200, - "type":"math" - } - } - ], - "updated":"2021-02-21T01:10:30Z", - "intervalSeconds":60, - "is_paused":false, - "version":3, - "uid":"uid", - "namespace_uid":"nsuid", - "rule_group":"arulegroup", - "no_data_state":"Alerting", - "exec_err_state":"Alerting", - "metadata": { - "editor_settings": { - "simplified_query_and_expressions_section": false, - "simplified_notifications_section": false - } - } - } - } - ] - } - ] - }`, body) - } - - client := &http.Client{} - // Finally, make sure we can delete it. - { - t.Run("succeed if the rule group name does not exists", func(t *testing.T) { - u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default/groupnotexist", grafanaListedAddr) - req, err := http.NewRequest(http.MethodDelete, u, nil) - require.NoError(t, err) - resp, err := client.Do(req) - require.NoError(t, err) - t.Cleanup(func() { - err := resp.Body.Close() - require.NoError(t, err) - }) - b, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - require.Equal(t, http.StatusAccepted, resp.StatusCode) - var res map[string]any - require.NoError(t, json.Unmarshal(b, &res)) - require.Equal(t, "rules deleted", res["message"]) - }) - - t.Run("succeed if the rule group name does exist", func(t *testing.T) { - u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default/arulegroup", grafanaListedAddr) - req, err := http.NewRequest(http.MethodDelete, u, nil) - require.NoError(t, err) - resp, err := client.Do(req) - require.NoError(t, err) - t.Cleanup(func() { - err := resp.Body.Close() - require.NoError(t, err) - }) - b, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - require.Equal(t, http.StatusAccepted, resp.StatusCode) - require.JSONEq(t, `{"message":"rules deleted"}`, string(b)) - }) - } -} - func TestIntegrationAlertmanagerCreateSilence(t *testing.T) { testinfra.SQLiteIntegrationTest(t) dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ @@ -2180,550 +686,3 @@ func TestIntegrationAlertmanagerStatus(t *testing.T) { }) } } - -func TestIntegrationQuota(t *testing.T) { - testinfra.SQLiteIntegrationTest(t) - - // Setup Grafana and its Database - dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ - DisableLegacyAlerting: true, - EnableUnifiedAlerting: true, - EnableQuota: true, - DisableAnonymous: true, - AppModeProduction: true, - }) - - grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) - - // Create a user to make authenticated requests - createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ - // needs permission to update org quota - IsAdmin: true, - DefaultOrgRole: string(org.RoleEditor), - Password: "password", - Login: "grafana", - }) - apiClient := newAlertingApiClient(grafanaListedAddr, "grafana", "password") - // Create the namespace we'll save our alerts to. - apiClient.CreateFolder(t, "default", "default") - - interval, err := model.ParseDuration("1m") - require.NoError(t, err) - - // Create rule under folder1 - createRule(t, apiClient, "default") - - // get the generated rule UID - var ruleUID string - { - u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) - // nolint:gosec - resp, err := http.Get(u) - require.NoError(t, err) - t.Cleanup(func() { - err := resp.Body.Close() - require.NoError(t, err) - }) - b, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, resp.StatusCode, 202) - - _, m := rulesNamespaceWithoutVariableValues(t, b) - generatedUIDs, ok := m["default,arulegroup"] - assert.True(t, ok) - assert.Equal(t, 1, len(generatedUIDs)) - ruleUID = generatedUIDs[0] - } - - // check quota limits - t.Run("when quota limit exceed creating new rule should fail", func(t *testing.T) { - // get existing org quota - limit, used := apiClient.GetOrgQuotaLimits(t, 1) - apiClient.UpdateAlertRuleOrgQuota(t, 1, used) - t.Cleanup(func() { - apiClient.UpdateAlertRuleOrgQuota(t, 1, limit) - }) - - // try to create an alert rule - rules := apimodels.PostableRuleGroupConfig{ - Name: "arulegroup", - Interval: interval, - Rules: []apimodels.PostableExtendedRuleNode{ - { - GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - Title: "One more alert rule", - Condition: "A", - Data: []apimodels.AlertQuery{ - { - RefID: "A", - RelativeTimeRange: apimodels.RelativeTimeRange{ - From: apimodels.Duration(time.Duration(5) * time.Hour), - To: apimodels.Duration(time.Duration(3) * time.Hour), - }, - DatasourceUID: expr.DatasourceUID, - Model: json.RawMessage(`{ - "type": "math", - "expression": "2 + 3 > 1" - }`), - }, - }, - }, - }, - }, - } - _, status, body := apiClient.PostRulesGroupWithStatus(t, "default", &rules) - assert.Equal(t, http.StatusForbidden, status) - var res map[string]any - require.NoError(t, json.Unmarshal([]byte(body), &res)) - require.Equal(t, "quota has been exceeded", res["message"]) - }) - - t.Run("when quota limit exceed updating existing rule should succeed", func(t *testing.T) { - // try to create an alert rule - rules := apimodels.PostableRuleGroupConfig{ - Name: "arulegroup", - Interval: interval, - Rules: []apimodels.PostableExtendedRuleNode{ - { - GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - Title: "Updated alert rule", - Condition: "A", - Data: []apimodels.AlertQuery{ - { - RefID: "A", - RelativeTimeRange: apimodels.RelativeTimeRange{ - From: apimodels.Duration(time.Duration(5) * time.Hour), - To: apimodels.Duration(time.Duration(3) * time.Hour), - }, - DatasourceUID: expr.DatasourceUID, - Model: json.RawMessage(`{ - "type": "math", - "expression": "2 + 4 > 1" - }`), - }, - }, - UID: ruleUID, - }, - }, - }, - } - - respModel, status, _ := apiClient.PostRulesGroupWithStatus(t, "default", &rules) - assert.Equal(t, http.StatusAccepted, status) - require.Len(t, respModel.Updated, 1) - - // let's make sure that rule definitions are updated correctly. - u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) - // nolint:gosec - resp, err := http.Get(u) - require.NoError(t, err) - t.Cleanup(func() { - err := resp.Body.Close() - require.NoError(t, err) - }) - b, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - assert.Equal(t, resp.StatusCode, 202) - - body, m := rulesNamespaceWithoutVariableValues(t, b) - returnedUIDs, ok := m["default,arulegroup"] - assert.True(t, ok) - assert.Equal(t, 1, len(returnedUIDs)) - assert.Equal(t, ruleUID, returnedUIDs[0]) - assert.JSONEq(t, ` - { - "default":[ - { - "name":"arulegroup", - "interval":"1m", - "rules":[ - { - "expr":"", - "for": "2m", - "grafana_alert":{ - "id":1, - "orgId":1, - "title":"Updated alert rule", - "condition":"A", - "data":[ - { - "refId":"A", - "queryType":"", - "relativeTimeRange":{ - "from":18000, - "to":10800 - }, - "datasourceUid":"__expr__", - "model":{ - "expression":"2 + 4 \u003E 1", - "intervalMs":1000, - "maxDataPoints":43200, - "type":"math" - } - } - ], - "updated":"2021-02-21T01:10:30Z", - "intervalSeconds":60, - "is_paused": false, - "version":2, - "uid":"uid", - "namespace_uid":"nsuid", - "rule_group":"arulegroup", - "no_data_state":"NoData", - "exec_err_state":"Alerting", - "metadata": { - "editor_settings": { - "simplified_query_and_expressions_section": false, - "simplified_notifications_section": false - } - } - } - } - ] - } - ] - }`, body) - }) -} - -func TestIntegrationEval(t *testing.T) { - testinfra.SQLiteIntegrationTest(t) - - // Setup Grafana and its Database - dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ - DisableLegacyAlerting: true, - EnableUnifiedAlerting: true, - EnableQuota: true, - DisableAnonymous: true, - AppModeProduction: true, - }) - - grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) - - createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ - DefaultOrgRole: string(org.RoleEditor), - Password: "password", - Login: "grafana", - }) - apiClient := newAlertingApiClient(grafanaListedAddr, "grafana", "password") - // Create the namespace we'll save our alerts to. - apiClient.CreateFolder(t, "default", "default") - - // test eval conditions - testCases := []struct { - desc string - payload string - expectedStatusCode func() int - expectedResponse func() string - expectedMessage func() string - }{ - { - desc: "alerting condition", - payload: ` - { - "data": [ - { - "refId": "A", - "relativeTimeRange": { - "from": 18000, - "to": 10800 - }, - "datasourceUid": "__expr__", - "model": { - "type":"math", - "expression":"1 < 2" - } - } - ], - "condition": "A", - "now": "2021-04-11T14:38:14Z" - } - `, - expectedMessage: func() string { return "" }, - expectedStatusCode: func() int { return http.StatusOK }, - expectedResponse: func() string { - return `{ - "results": { - "A": { - "status": 200, - "frames": [ - { - "schema": { - "refId": "A", - "fields": [ - { - "name": "A", - "type": "number", - "typeInfo": { - "frame": "float64", - "nullable": true - } - } - ], - "meta": { - "type": "numeric-multi", - "typeVersion": [0, 1] - } - }, - "data": { - "values": [ - [ - 1 - ] - ] - } - } - ] - } - } - }` - }, - }, - { - desc: "normal condition", - payload: ` - { - "data": [ - { - "refId": "A", - "relativeTimeRange": { - "from": 18000, - "to": 10800 - }, - "datasourceUid": "__expr__", - "model": { - "type":"math", - "expression":"1 > 2" - } - } - ], - "condition": "A", - "now": "2021-04-11T14:38:14Z" - } - `, - expectedMessage: func() string { return "" }, - expectedStatusCode: func() int { return http.StatusOK }, - expectedResponse: func() string { - return `{ - "results": { - "A": { - "status": 200, - "frames": [ - { - "schema": { - "refId": "A", - "fields": [ - { - "name": "A", - "type": "number", - "typeInfo": { - "frame": "float64", - "nullable": true - } - } - ], - "meta": { - "type": "numeric-multi", - "typeVersion": [0, 1] - } - }, - "data": { - "values": [ - [ - 0 - ] - ] - } - } - ] - } - } - }` - }, - }, - { - desc: "unknown query datasource", - payload: ` - { - "data": [ - { - "refId": "A", - "relativeTimeRange": { - "from": 18000, - "to": 10800 - }, - "datasourceUid": "unknown", - "model": { - } - } - ], - "condition": "A", - "now": "2021-04-11T14:38:14Z" - } - `, - expectedResponse: func() string { return "" }, - expectedStatusCode: func() int { - if setting.IsEnterprise { - return http.StatusForbidden - } - return http.StatusBadRequest - }, - expectedMessage: func() string { - if setting.IsEnterprise { - return "user is not authorized to access one or many data sources" - } - return "Failed to build evaluator for queries and expressions: failed to build query 'A': data source not found" - }, - }, - { - desc: "condition is empty", - payload: ` - { - "data": [ - { - "refId": "A", - "relativeTimeRange": { - "from": 18000, - "to": 10800 - }, - "datasourceUid": "__expr__", - "model": { - "type":"math", - "expression":"1 > 2" - } - } - ], - "now": "2021-04-11T14:38:14Z" - } - `, - expectedStatusCode: func() int { return http.StatusOK }, - expectedMessage: func() string { return "" }, - expectedResponse: func() string { - return `{ - "results": { - "A": { - "status": 200, - "frames": [ - { - "schema": { - "refId": "A", - "fields": [ - { - "name": "A", - "type": "number", - "typeInfo": { - "frame": "float64", - "nullable": true - } - } - ], - "meta": { - "type": "numeric-multi", - "typeVersion": [0, 1] - } - }, - "data": { - "values": [ - [ - 0 - ] - ] - } - } - ] - } - } - }` - }, - }, - } - - for _, tc := range testCases { - t.Run(tc.desc, func(t *testing.T) { - u := fmt.Sprintf("http://grafana:password@%s/api/v1/eval", grafanaListedAddr) - r := strings.NewReader(tc.payload) - // nolint:gosec - resp, err := http.Post(u, "application/json", r) - require.NoError(t, err) - t.Cleanup(func() { - err := resp.Body.Close() - require.NoError(t, err) - }) - b, err := io.ReadAll(resp.Body) - require.NoError(t, err) - res := Response{} - err = json.Unmarshal(b, &res) - require.NoError(t, err) - - assert.Equal(t, tc.expectedStatusCode(), resp.StatusCode) - if tc.expectedResponse() != "" { - require.JSONEq(t, tc.expectedResponse(), string(b)) - } - if tc.expectedMessage() != "" { - require.Equal(t, tc.expectedMessage(), res.Message) - } - }) - } -} - -// rulesNamespaceWithoutVariableValues takes a apimodels.NamespaceConfigResponse JSON-based input and makes the dynamic fields static e.g. uid, dates, etc. -// it returns a map of the modified rule UIDs with the namespace,rule_group as a key -func rulesNamespaceWithoutVariableValues(t *testing.T, b []byte) (string, map[string][]string) { - t.Helper() - - var r apimodels.NamespaceConfigResponse - require.NoError(t, json.Unmarshal(b, &r)) - // create a map holding the created rule UIDs per namespace/group - m := make(map[string][]string) - for namespace, nodes := range r { - for _, node := range nodes { - compositeKey := strings.Join([]string{namespace, node.Name}, ",") - _, ok := m[compositeKey] - if !ok { - m[compositeKey] = make([]string, 0, len(node.Rules)) - } - for _, rule := range node.Rules { - m[compositeKey] = append(m[compositeKey], rule.GrafanaManagedAlert.UID) - rule.GrafanaManagedAlert.UID = "uid" - rule.GrafanaManagedAlert.NamespaceUID = "nsuid" - rule.GrafanaManagedAlert.Updated = time.Date(2021, time.Month(2), 21, 1, 10, 30, 0, time.UTC) - } - } - } - - json, err := json.Marshal(&r) - require.NoError(t, err) - return string(json), m -} - -func createUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) int64 { - t.Helper() - - cfg.AutoAssignOrg = true - cfg.AutoAssignOrgId = 1 - - quotaService := quotaimpl.ProvideService(db, cfg) - orgService, err := orgimpl.ProvideService(db, cfg, quotaService) - require.NoError(t, err) - usrSvc, err := userimpl.ProvideService( - db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), - quotaService, supportbundlestest.NewFakeBundleService(), - ) - require.NoError(t, err) - - u, err := usrSvc.Create(context.Background(), &cmd) - require.NoError(t, err) - return u.ID -} - -func getLongString(t *testing.T, n int) string { - t.Helper() - - b := make([]rune, n) - for i := range b { - b[i] = 'a' - } - return string(b) -} diff --git a/pkg/tests/api/alerting/api_backtesting_test.go b/pkg/tests/api/alerting/api_backtesting_test.go index f292ca734de..fb54a01712a 100644 --- a/pkg/tests/api/alerting/api_backtesting_test.go +++ b/pkg/tests/api/alerting/api_backtesting_test.go @@ -4,8 +4,7 @@ import ( "context" "encoding/json" "net/http" - "os" - "path/filepath" + "path" "testing" "github.com/grafana/grafana-plugin-sdk-go/data" @@ -23,7 +22,7 @@ import ( ) func TestBacktesting(t *testing.T) { - dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + dir, grafanaPath := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, EnableUnifiedAlerting: true, DisableAnonymous: true, @@ -34,7 +33,7 @@ func TestBacktesting(t *testing.T) { EnableLog: false, }) - grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, grafanaPath) userId := createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ DefaultOrgRole: string(org.RoleAdmin), @@ -44,7 +43,7 @@ func TestBacktesting(t *testing.T) { apiCli := newAlertingApiClient(grafanaListedAddr, "admin", "admin") - input, err := os.ReadFile(filepath.Join("api_backtesting_data.json")) + input, err := testData.ReadFile(path.Join("test-data", "api_backtesting_data.json")) require.NoError(t, err) var testData map[string]apimodels.BacktestConfig require.NoError(t, json.Unmarshal(input, &testData)) diff --git a/pkg/tests/api/alerting/api_ruler_test.go b/pkg/tests/api/alerting/api_ruler_test.go index b54f70dec15..5c101cb3768 100644 --- a/pkg/tests/api/alerting/api_ruler_test.go +++ b/pkg/tests/api/alerting/api_ruler_test.go @@ -9,6 +9,7 @@ import ( "math/rand" "net/http" "path" + "regexp" "slices" "strings" "testing" @@ -22,6 +23,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/apimachinery/errutil" "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" @@ -30,6 +32,7 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + ngstore "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -677,49 +680,6 @@ func TestIntegrationAlertRuleNestedPermissions(t *testing.T) { }) } -func createRule(t *testing.T, client apiClient, folder string) (apimodels.PostableRuleGroupConfig, string) { - t.Helper() - - interval, err := model.ParseDuration("1m") - require.NoError(t, err) - doubleInterval := 2 * interval - rules := apimodels.PostableRuleGroupConfig{ - Name: "arulegroup", - Interval: interval, - Rules: []apimodels.PostableExtendedRuleNode{ - { - ApiRuleNode: &apimodels.ApiRuleNode{ - For: &doubleInterval, - Labels: map[string]string{"label1": "val1"}, - Annotations: map[string]string{"annotation1": "val1"}, - }, - GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - Title: fmt.Sprintf("rule under folder %s", folder), - Condition: "A", - Data: []apimodels.AlertQuery{ - { - RefID: "A", - RelativeTimeRange: apimodels.RelativeTimeRange{ - From: apimodels.Duration(time.Duration(5) * time.Hour), - To: apimodels.Duration(time.Duration(3) * time.Hour), - }, - DatasourceUID: expr.DatasourceUID, - Model: json.RawMessage(`{ - "type": "math", - "expression": "2 + 3 > 1" - }`), - }, - }, - }, - }, - }, - } - resp, status, _ := client.PostRulesGroupWithStatus(t, folder, &rules) - assert.Equal(t, http.StatusAccepted, status) - require.Len(t, resp.Created, 1) - return rules, resp.Created[0] -} - func TestAlertRulePostExport(t *testing.T) { testinfra.SQLiteIntegrationTest(t) @@ -1738,71 +1698,1966 @@ func TestIntegrationRuleUpdate(t *testing.T) { }) } -func newTestingRuleConfig(t *testing.T) apimodels.PostableRuleGroupConfig { +func TestIntegrationAlertAndGroupsQuery(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + DisableAnonymous: true, + AppModeProduction: true, + }) + + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) + + // unauthenticated request to get the alerts should fail + { + alertsURL := fmt.Sprintf("http://%s/api/alertmanager/grafana/api/v2/alerts", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(alertsURL) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + require.Contains(t, string(b), `"message":"Unauthorized"`) + } + + // Create a user to make authenticated requests + createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleEditor), + Password: "password", + Login: "grafana", + }) + + apiClient := newAlertingApiClient(grafanaListedAddr, "grafana", "password") + + // invalid credentials request to get the alerts should fail + { + alertsURL := fmt.Sprintf("http://grafana:invalid@%s/api/alertmanager/grafana/api/v2/alerts", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(alertsURL) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + + var res map[string]any + require.NoError(t, json.Unmarshal(b, &res)) + assert.Equal(t, "Invalid username or password", res["message"]) + } + + // When there are no alerts available, it returns an empty list. + { + alertsURL := fmt.Sprintf("http://grafana:password@%s/api/alertmanager/grafana/api/v2/alerts", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(alertsURL) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) + require.JSONEq(t, "[]", string(b)) + } + + // When are there no alerts available, it returns an empty list of groups. + { + alertsURL := fmt.Sprintf("http://grafana:password@%s/api/alertmanager/grafana/api/v2/alerts/groups", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(alertsURL) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) + require.JSONEq(t, "[]", string(b)) + } + + // Now, let's test the endpoint with some alerts. + { + // Create the namespace we'll save our alerts to. + apiClient.CreateFolder(t, "default", "default") + } + + // Create an alert that will fire as quickly as possible + { + interval, err := model.ParseDuration("10s") + require.NoError(t, err) + rules := apimodels.PostableRuleGroupConfig{ + Name: "arulegroup", + Interval: interval, + Rules: []apimodels.PostableExtendedRuleNode{ + { + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: "AlwaysFiring", + Condition: "A", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: apimodels.RelativeTimeRange{ + From: apimodels.Duration(time.Duration(5) * time.Hour), + To: apimodels.Duration(time.Duration(3) * time.Hour), + }, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + }, + }, + }, + } + + _, status, _ := apiClient.PostRulesGroupWithStatus(t, "default", &rules) + assert.Equal(t, http.StatusAccepted, status) + } + + // Eventually, we'll get an alert with its state being active. + { + alertsURL := fmt.Sprintf("http://grafana:password@%s/api/alertmanager/grafana/api/v2/alerts", grafanaListedAddr) + // nolint:gosec + require.Eventually(t, func() bool { + resp, err := http.Get(alertsURL) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) + + var alerts apimodels.GettableAlerts + err = json.Unmarshal(b, &alerts) + require.NoError(t, err) + + if len(alerts) > 0 { + status := alerts[0].Status + return status != nil && status.State != nil && *status.State == "active" + } + + return false + }, 18*time.Second, 2*time.Second) + } +} + +func TestIntegrationRulerAccess(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + + // Setup Grafana and its Database + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + EnableQuota: true, + DisableAnonymous: true, + ViewersCanEdit: true, + AppModeProduction: true, + }) + + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) + + // Create a users to make authenticated requests + createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleViewer), + Password: "viewer", + Login: "viewer", + }) + createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleEditor), + Password: "editor", + Login: "editor", + }) + createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleAdmin), + Password: "admin", + Login: "admin", + }) + + client := newAlertingApiClient(grafanaListedAddr, "editor", "editor") + + // Create the namespace we'll save our alerts to. + client.CreateFolder(t, "default", "default") + + // Now, let's test the access policies. + testCases := []struct { + desc string + client apiClient + expStatus int + expectedMessage string + }{ + { + desc: "un-authenticated request should fail", + client: newAlertingApiClient(grafanaListedAddr, "", ""), + expStatus: http.StatusUnauthorized, + expectedMessage: `Unauthorized`, + }, + { + desc: "viewer request should fail", + client: newAlertingApiClient(grafanaListedAddr, "viewer", "viewer"), + expStatus: http.StatusForbidden, + expectedMessage: `You'll need additional permissions to perform this action. Permissions needed: all of alert.rules:read, folders:read, any of alert.rules:write, alert.rules:create, alert.rules:delete`, + }, + { + desc: "editor request should succeed", + client: newAlertingApiClient(grafanaListedAddr, "editor", "editor"), + expStatus: http.StatusAccepted, + expectedMessage: `rule group updated successfully`, + }, + { + desc: "admin request should succeed", + client: newAlertingApiClient(grafanaListedAddr, "admin", "admin"), + expStatus: http.StatusAccepted, + expectedMessage: `rule group updated successfully`, + }, + } + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + interval, err := model.ParseDuration("1m") + require.NoError(t, err) + + rules := apimodels.PostableRuleGroupConfig{ + Name: "arulegroup", + Rules: []apimodels.PostableExtendedRuleNode{ + { + ApiRuleNode: &apimodels.ApiRuleNode{ + For: &interval, + Labels: map[string]string{"label1": "val1"}, + Annotations: map[string]string{"annotation1": "val1"}, + }, + // this rule does not explicitly set no data and error states + // therefore it should get the default values + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: fmt.Sprintf("AlwaysFiring %d", i), + Condition: "A", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: apimodels.RelativeTimeRange{ + From: apimodels.Duration(time.Duration(5) * time.Hour), + To: apimodels.Duration(time.Duration(3) * time.Hour), + }, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + }, + }, + }, + } + _, status, body := tc.client.PostRulesGroupWithStatus(t, "default", &rules) + assert.Equal(t, tc.expStatus, status) + res := &Response{} + err = json.Unmarshal([]byte(body), &res) + require.NoError(t, err) + require.Equal(t, tc.expectedMessage, res.Message) + }) + } +} + +func TestIntegrationEval(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + + // Setup Grafana and its Database + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + EnableQuota: true, + DisableAnonymous: true, + AppModeProduction: true, + }) + + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) + + createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleEditor), + Password: "password", + Login: "grafana", + }) + apiClient := newAlertingApiClient(grafanaListedAddr, "grafana", "password") + // Create the namespace we'll save our alerts to. + apiClient.CreateFolder(t, "default", "default") + + // test eval conditions + testCases := []struct { + desc string + payload string + expectedStatusCode func() int + expectedResponse func() string + expectedMessage func() string + }{ + { + desc: "alerting condition", + payload: ` + { + "data": [ + { + "refId": "A", + "relativeTimeRange": { + "from": 18000, + "to": 10800 + }, + "datasourceUid": "__expr__", + "model": { + "type":"math", + "expression":"1 < 2" + } + } + ], + "condition": "A", + "now": "2021-04-11T14:38:14Z" + } + `, + expectedMessage: func() string { return "" }, + expectedStatusCode: func() int { return http.StatusOK }, + expectedResponse: func() string { + return `{ + "results": { + "A": { + "status": 200, + "frames": [ + { + "schema": { + "refId": "A", + "fields": [ + { + "name": "A", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + } + } + ], + "meta": { + "type": "numeric-multi", + "typeVersion": [0, 1] + } + }, + "data": { + "values": [ + [ + 1 + ] + ] + } + } + ] + } + } + }` + }, + }, + { + desc: "normal condition", + payload: ` + { + "data": [ + { + "refId": "A", + "relativeTimeRange": { + "from": 18000, + "to": 10800 + }, + "datasourceUid": "__expr__", + "model": { + "type":"math", + "expression":"1 > 2" + } + } + ], + "condition": "A", + "now": "2021-04-11T14:38:14Z" + } + `, + expectedMessage: func() string { return "" }, + expectedStatusCode: func() int { return http.StatusOK }, + expectedResponse: func() string { + return `{ + "results": { + "A": { + "status": 200, + "frames": [ + { + "schema": { + "refId": "A", + "fields": [ + { + "name": "A", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + } + } + ], + "meta": { + "type": "numeric-multi", + "typeVersion": [0, 1] + } + }, + "data": { + "values": [ + [ + 0 + ] + ] + } + } + ] + } + } + }` + }, + }, + { + desc: "unknown query datasource", + payload: ` + { + "data": [ + { + "refId": "A", + "relativeTimeRange": { + "from": 18000, + "to": 10800 + }, + "datasourceUid": "unknown", + "model": { + } + } + ], + "condition": "A", + "now": "2021-04-11T14:38:14Z" + } + `, + expectedResponse: func() string { return "" }, + expectedStatusCode: func() int { + if setting.IsEnterprise { + return http.StatusForbidden + } + return http.StatusBadRequest + }, + expectedMessage: func() string { + if setting.IsEnterprise { + return "user is not authorized to access one or many data sources" + } + return "Failed to build evaluator for queries and expressions: failed to build query 'A': data source not found" + }, + }, + { + desc: "condition is empty", + payload: ` + { + "data": [ + { + "refId": "A", + "relativeTimeRange": { + "from": 18000, + "to": 10800 + }, + "datasourceUid": "__expr__", + "model": { + "type":"math", + "expression":"1 > 2" + } + } + ], + "now": "2021-04-11T14:38:14Z" + } + `, + expectedStatusCode: func() int { return http.StatusOK }, + expectedMessage: func() string { return "" }, + expectedResponse: func() string { + return `{ + "results": { + "A": { + "status": 200, + "frames": [ + { + "schema": { + "refId": "A", + "fields": [ + { + "name": "A", + "type": "number", + "typeInfo": { + "frame": "float64", + "nullable": true + } + } + ], + "meta": { + "type": "numeric-multi", + "typeVersion": [0, 1] + } + }, + "data": { + "values": [ + [ + 0 + ] + ] + } + } + ] + } + } + }` + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + u := fmt.Sprintf("http://grafana:password@%s/api/v1/eval", grafanaListedAddr) + r := strings.NewReader(tc.payload) + // nolint:gosec + resp, err := http.Post(u, "application/json", r) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + res := Response{} + err = json.Unmarshal(b, &res) + require.NoError(t, err) + + assert.Equal(t, tc.expectedStatusCode(), resp.StatusCode) + if tc.expectedResponse() != "" { + require.JSONEq(t, tc.expectedResponse(), string(b)) + } + if tc.expectedMessage() != "" { + require.Equal(t, tc.expectedMessage(), res.Message) + } + }) + } +} + +func TestIntegrationQuota(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + + // Setup Grafana and its Database + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + EnableQuota: true, + DisableAnonymous: true, + AppModeProduction: true, + }) + + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) + + // Create a user to make authenticated requests + createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + // needs permission to update org quota + IsAdmin: true, + DefaultOrgRole: string(org.RoleEditor), + Password: "password", + Login: "grafana", + }) + apiClient := newAlertingApiClient(grafanaListedAddr, "grafana", "password") + // Create the namespace we'll save our alerts to. + apiClient.CreateFolder(t, "default", "default") + interval, err := model.ParseDuration("1m") require.NoError(t, err) - firstRule := apimodels.PostableExtendedRuleNode{ - ApiRuleNode: &apimodels.ApiRuleNode{ - For: &interval, - Labels: map[string]string{"label1": "val1"}, - Annotations: map[string]string{"annotation1": "val1"}, - }, - // this rule does not explicitly set no data and error states - // therefore it should get the default values - GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - Title: "AlwaysFiring", - Condition: "A", - Data: []apimodels.AlertQuery{ - { - RefID: "A", - RelativeTimeRange: apimodels.RelativeTimeRange{ - From: apimodels.Duration(time.Duration(5) * time.Hour), - To: apimodels.Duration(time.Duration(3) * time.Hour), - }, - DatasourceUID: expr.DatasourceUID, - Model: json.RawMessage(`{ - "type": "math", - "expression": "2 + 3 > 1" - }`), - }, - }, - }, - } - secondRule := apimodels.PostableExtendedRuleNode{ - ApiRuleNode: &apimodels.ApiRuleNode{ - For: &interval, - Labels: map[string]string{"label1": "val1"}, - Annotations: map[string]string{"annotation1": "val1"}, - }, - // this rule does not explicitly set no data and error states - // therefore it should get the default values - GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - Title: "AlwaysFiring2", - Condition: "A", - Data: []apimodels.AlertQuery{ - { - RefID: "A", - RelativeTimeRange: apimodels.RelativeTimeRange{ - From: apimodels.Duration(time.Duration(5) * time.Hour), - To: apimodels.Duration(time.Duration(3) * time.Hour), - }, - DatasourceUID: expr.DatasourceUID, - Model: json.RawMessage(`{ - "type": "math", - "expression": "2 + 3 > 1" - }`), - }, - }, - }, + // Create rule under folder1 + createRule(t, apiClient, "default") + + // get the generated rule UID + var ruleUID string + { + u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(u) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, resp.StatusCode, 202) + + _, m := rulesNamespaceWithoutVariableValues(t, b) + generatedUIDs, ok := m["default,arulegroup"] + assert.True(t, ok) + assert.Equal(t, 1, len(generatedUIDs)) + ruleUID = generatedUIDs[0] } - return apimodels.PostableRuleGroupConfig{ - Name: "arulegroup", - Rules: []apimodels.PostableExtendedRuleNode{ - firstRule, - secondRule, - }, + // check quota limits + t.Run("when quota limit exceed creating new rule should fail", func(t *testing.T) { + // get existing org quota + limit, used := apiClient.GetOrgQuotaLimits(t, 1) + apiClient.UpdateAlertRuleOrgQuota(t, 1, used) + t.Cleanup(func() { + apiClient.UpdateAlertRuleOrgQuota(t, 1, limit) + }) + + // try to create an alert rule + rules := apimodels.PostableRuleGroupConfig{ + Name: "arulegroup", + Interval: interval, + Rules: []apimodels.PostableExtendedRuleNode{ + { + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: "One more alert rule", + Condition: "A", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: apimodels.RelativeTimeRange{ + From: apimodels.Duration(time.Duration(5) * time.Hour), + To: apimodels.Duration(time.Duration(3) * time.Hour), + }, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + }, + }, + }, + } + _, status, body := apiClient.PostRulesGroupWithStatus(t, "default", &rules) + assert.Equal(t, http.StatusForbidden, status) + var res map[string]any + require.NoError(t, json.Unmarshal([]byte(body), &res)) + require.Equal(t, "quota has been exceeded", res["message"]) + }) + + t.Run("when quota limit exceed updating existing rule should succeed", func(t *testing.T) { + // try to create an alert rule + rules := apimodels.PostableRuleGroupConfig{ + Name: "arulegroup", + Interval: interval, + Rules: []apimodels.PostableExtendedRuleNode{ + { + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: "Updated alert rule", + Condition: "A", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: apimodels.RelativeTimeRange{ + From: apimodels.Duration(time.Duration(5) * time.Hour), + To: apimodels.Duration(time.Duration(3) * time.Hour), + }, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 4 > 1" + }`), + }, + }, + UID: ruleUID, + }, + }, + }, + } + + respModel, status, _ := apiClient.PostRulesGroupWithStatus(t, "default", &rules) + assert.Equal(t, http.StatusAccepted, status) + require.Len(t, respModel.Updated, 1) + + // let's make sure that rule definitions are updated correctly. + u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(u) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, resp.StatusCode, 202) + + body, m := rulesNamespaceWithoutVariableValues(t, b) + returnedUIDs, ok := m["default,arulegroup"] + assert.True(t, ok) + assert.Equal(t, 1, len(returnedUIDs)) + assert.Equal(t, ruleUID, returnedUIDs[0]) + assert.JSONEq(t, ` + { + "default":[ + { + "name":"arulegroup", + "interval":"1m", + "rules":[ + { + "expr":"", + "for": "2m", + "grafana_alert":{ + "id":1, + "orgId":1, + "title":"Updated alert rule", + "condition":"A", + "data":[ + { + "refId":"A", + "queryType":"", + "relativeTimeRange":{ + "from":18000, + "to":10800 + }, + "datasourceUid":"__expr__", + "model":{ + "expression":"2 + 4 \u003E 1", + "intervalMs":1000, + "maxDataPoints":43200, + "type":"math" + } + } + ], + "updated":"2021-02-21T01:10:30Z", + "intervalSeconds":60, + "is_paused": false, + "version":2, + "uid":"uid", + "namespace_uid":"nsuid", + "rule_group":"arulegroup", + "no_data_state":"NoData", + "exec_err_state":"Alerting", + "metadata": { + "editor_settings": { + "simplified_query_and_expressions_section": false, + "simplified_notifications_section": false + } + } + } + } + ] + } + ] + }`, body) + }) +} + +func TestIntegrationDeleteFolderWithRules(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + + opts := testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + EnableQuota: true, + DisableAnonymous: true, + ViewersCanEdit: true, + AppModeProduction: true, + } + + // Setup Grafana and its Database + dir, path := testinfra.CreateGrafDir(t, opts) + + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) + + createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleViewer), + Password: "viewer", + Login: "viewer", + }) + createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleEditor), + Password: "editor", + Login: "editor", + }) + + apiClient := newAlertingApiClient(grafanaListedAddr, "editor", "editor") + + // Create the namespace we'll save our alerts to. + namespaceUID := "default" //nolint:goconst + apiClient.CreateFolder(t, namespaceUID, namespaceUID) + + createRule(t, apiClient, "default") + + t.Run("editor create a rule within the folder/namespace", func(t *testing.T) { + u := fmt.Sprintf("http://editor:editor@%s/api/ruler/grafana/api/v1/rules", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(u) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, 200, resp.StatusCode) + + re := regexp.MustCompile(`"uid":"([\w|-]+)"`) + b = re.ReplaceAll(b, []byte(`"uid":""`)) + re = regexp.MustCompile(`"updated":"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)"`) + b = re.ReplaceAll(b, []byte(`"updated":"2021-05-19T19:47:55Z"`)) + + expectedGetRulesResponseBody := fmt.Sprintf(`{ + "default": [ + { + "name": "arulegroup", + "interval": "1m", + "rules": [ + { + "expr": "", + "for": "2m", + "labels": { + "label1": "val1" + }, + "annotations": { + "annotation1": "val1" + }, + "grafana_alert": { + "id": 1, + "orgId": 1, + "title": "rule under folder default", + "condition": "A", + "data": [ + { + "refId": "A", + "queryType": "", + "relativeTimeRange": { + "from": 18000, + "to": 10800 + }, + "datasourceUid": "__expr__", + "model": { + "expression": "2 + 3 > 1", + "intervalMs": 1000, + "maxDataPoints": 43200, + "type": "math" + } + } + ], + "updated": "2021-05-19T19:47:55Z", + "intervalSeconds": 60, + "is_paused": false, + "version": 1, + "uid": "", + "namespace_uid": %q, + "rule_group": "arulegroup", + "no_data_state": "NoData", + "exec_err_state": "Alerting", + "metadata": { + "editor_settings": { + "simplified_query_and_expressions_section": false, + "simplified_notifications_section": false + } + } + } + } + ] + } + ] + }`, namespaceUID) + assert.JSONEq(t, expectedGetRulesResponseBody, string(b)) + }) + t.Run("editor can not delete the folder because it contains Grafana 8 alerts", func(t *testing.T) { + u := fmt.Sprintf("http://editor:editor@%s/api/folders/%s", grafanaListedAddr, namespaceUID) + req, err := http.NewRequest(http.MethodDelete, u, nil) + require.NoError(t, err) + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + var errutilErr errutil.PublicError + err = json.Unmarshal(b, &errutilErr) + require.NoError(t, err) + assert.Equal(t, "Folder cannot be deleted: folder is not empty", errutilErr.Message) + }) + t.Run("editor can delete the folder if forceDeleteRules is true", func(t *testing.T) { + u := fmt.Sprintf("http://editor:editor@%s/api/folders/%s?forceDeleteRules=true", grafanaListedAddr, namespaceUID) + req, err := http.NewRequest(http.MethodDelete, u, nil) + require.NoError(t, err) + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) + }) + t.Run("editor can delete rules", func(t *testing.T) { + u := fmt.Sprintf("http://editor:editor@%s/api/ruler/grafana/api/v1/rules", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(u) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, 200, resp.StatusCode) + assert.JSONEq(t, "{}", string(b)) + }) + // TODO(@leonorfmartins): write tests for uni store when we are able to support it +} + +func TestIntegrationAlertRuleCRUD(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + + // Setup Grafana and its Database + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + EnableQuota: true, + DisableAnonymous: true, + AppModeProduction: true, + }) + + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) + + createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleEditor), + Password: "password", + Login: "grafana", + }) + + apiClient := newAlertingApiClient(grafanaListedAddr, "grafana", "password") + + // Create the namespace we'll save our alerts to. + apiClient.CreateFolder(t, "default", "default") + + interval, err := model.ParseDuration("1m") + require.NoError(t, err) + + invalidInterval, err := model.ParseDuration("1s") + require.NoError(t, err) + + // Now, let's try to create some invalid alert rules. + { + testCases := []struct { + desc string + rulegroup string + interval model.Duration + rule apimodels.PostableExtendedRuleNode + expectedCode int + expectedMessage string + }{ + { + desc: "alert rule without queries and expressions", + rulegroup: "arulegroup", + rule: apimodels.PostableExtendedRuleNode{ + ApiRuleNode: &apimodels.ApiRuleNode{ + For: &interval, + Labels: map[string]string{"label1": "val1"}, + Annotations: map[string]string{"annotation1": "val1"}, + }, + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: "AlwaysFiring", + Condition: "A", + Data: []apimodels.AlertQuery{}, + }, + }, + expectedMessage: "invalid rule specification at index [0]: invalid alert rule: no queries or expressions are found", + }, + { + desc: "alert rule with empty title", + rulegroup: "arulegroup", + rule: apimodels.PostableExtendedRuleNode{ + ApiRuleNode: &apimodels.ApiRuleNode{ + For: &interval, + Labels: map[string]string{"label1": "val1"}, + Annotations: map[string]string{"annotation1": "val1"}, + }, + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: "", + Condition: "A", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: apimodels.RelativeTimeRange{ + From: apimodels.Duration(time.Duration(5) * time.Hour), + To: apimodels.Duration(time.Duration(3) * time.Hour), + }, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + }, + }, + expectedMessage: "invalid rule specification at index [0]: alert rule title cannot be empty", + }, + { + desc: "alert rule with too long name", + rulegroup: "arulegroup", + rule: apimodels.PostableExtendedRuleNode{ + ApiRuleNode: &apimodels.ApiRuleNode{ + For: &interval, + Labels: map[string]string{"label1": "val1"}, + Annotations: map[string]string{"annotation1": "val1"}, + }, + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: getLongString(t, ngstore.AlertRuleMaxTitleLength+1), + Condition: "A", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: apimodels.RelativeTimeRange{ + From: apimodels.Duration(time.Duration(5) * time.Hour), + To: apimodels.Duration(time.Duration(3) * time.Hour), + }, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + }, + }, + expectedMessage: "invalid rule specification at index [0]: alert rule title is too long. Max length is 190", + }, + { + desc: "alert rule with too long rulegroup", + rulegroup: getLongString(t, ngstore.AlertRuleMaxTitleLength+1), + rule: apimodels.PostableExtendedRuleNode{ + ApiRuleNode: &apimodels.ApiRuleNode{ + For: &interval, + Labels: map[string]string{"label1": "val1"}, + Annotations: map[string]string{"annotation1": "val1"}, + }, + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: "AlwaysFiring", + Condition: "A", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: apimodels.RelativeTimeRange{ + From: apimodels.Duration(time.Duration(5) * time.Hour), + To: apimodels.Duration(time.Duration(3) * time.Hour), + }, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + }, + }, + expectedMessage: "rule group name is too long. Max length is 190", + }, + { + desc: "alert rule with invalid interval", + rulegroup: "arulegroup", + interval: invalidInterval, + rule: apimodels.PostableExtendedRuleNode{ + ApiRuleNode: &apimodels.ApiRuleNode{ + For: &interval, + Labels: map[string]string{"label1": "val1"}, + Annotations: map[string]string{"annotation1": "val1"}, + }, + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: "AlwaysFiring", + Condition: "A", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: apimodels.RelativeTimeRange{ + From: apimodels.Duration(time.Duration(5) * time.Hour), + To: apimodels.Duration(time.Duration(3) * time.Hour), + }, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + }, + }, + expectedMessage: "rule evaluation interval (1 second) should be positive number that is multiple of the base interval of 10 seconds", + }, + { + desc: "alert rule with unknown datasource", + rulegroup: "arulegroup", + rule: apimodels.PostableExtendedRuleNode{ + ApiRuleNode: &apimodels.ApiRuleNode{ + For: &interval, + Labels: map[string]string{"label1": "val1"}, + Annotations: map[string]string{"annotation1": "val1"}, + }, + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: "AlwaysFiring", + Condition: "A", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: apimodels.RelativeTimeRange{ + From: apimodels.Duration(time.Duration(5) * time.Hour), + To: apimodels.Duration(time.Duration(3) * time.Hour), + }, + DatasourceUID: "unknown", + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + }, + }, + expectedCode: func() int { + if setting.IsEnterprise { + return http.StatusForbidden + } + return http.StatusBadRequest + }(), + expectedMessage: func() string { + if setting.IsEnterprise { + return "user is not authorized to create a new alert rule 'AlwaysFiring'" + } + return "failed to update rule group: invalid alert rule 'AlwaysFiring': failed to build query 'A': data source not found" + }(), + }, + { + desc: "alert rule with invalid condition", + rulegroup: "arulegroup", + rule: apimodels.PostableExtendedRuleNode{ + ApiRuleNode: &apimodels.ApiRuleNode{ + For: &interval, + Labels: map[string]string{"label1": "val1"}, + Annotations: map[string]string{"annotation1": "val1"}, + }, + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: "AlwaysFiring", + Condition: "B", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: apimodels.RelativeTimeRange{ + From: apimodels.Duration(time.Duration(5) * time.Hour), + To: apimodels.Duration(time.Duration(3) * time.Hour), + }, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + }, + }, + expectedMessage: "invalid rule specification at index [0]: invalid alert rule: condition B does not exist, must be one of [A]", + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + rules := apimodels.PostableRuleGroupConfig{ + Name: tc.rulegroup, + Interval: tc.interval, + Rules: []apimodels.PostableExtendedRuleNode{ + tc.rule, + }, + } + _, status, body := apiClient.PostRulesGroupWithStatus(t, "default", &rules) + res := &Response{} + err = json.Unmarshal([]byte(body), &res) + require.NoError(t, err) + + assert.Equal(t, tc.expectedMessage, res.Message) + expectedCode := tc.expectedCode + if expectedCode == 0 { + expectedCode = http.StatusBadRequest + } + assert.Equal(t, expectedCode, status) + }) + } + } + + var ruleUID string + var expectedGetNamespaceResponseBody string + // Now, let's create two alerts. + { + rules := apimodels.PostableRuleGroupConfig{ + Name: "arulegroup", + Rules: []apimodels.PostableExtendedRuleNode{ + { + ApiRuleNode: &apimodels.ApiRuleNode{ + For: &interval, + Labels: map[string]string{"label1": "val1"}, + Annotations: map[string]string{"annotation1": "val1"}, + }, + // this rule does not explicitly set no data and error states + // therefore it should get the default values + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: "AlwaysFiring", + Condition: "A", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: apimodels.RelativeTimeRange{ + From: apimodels.Duration(time.Duration(5) * time.Hour), + To: apimodels.Duration(time.Duration(3) * time.Hour), + }, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + }, + }, + { + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: "AlwaysFiringButSilenced", + Condition: "A", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: apimodels.RelativeTimeRange{ + From: apimodels.Duration(time.Duration(5) * time.Hour), + To: apimodels.Duration(time.Duration(3) * time.Hour), + }, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + NoDataState: apimodels.NoDataState(ngmodels.Alerting), + ExecErrState: apimodels.ExecutionErrorState(ngmodels.AlertingErrState), + }, + }, + }, + } + resp, status, _ := apiClient.PostRulesGroupWithStatus(t, "default", &rules) + assert.Equal(t, http.StatusAccepted, status) + require.Equal(t, "rule group updated successfully", resp.Message) + assert.Len(t, resp.Created, 2) + assert.Empty(t, resp.Updated) + assert.Empty(t, resp.Deleted) + } + + // With the rules created, let's make sure that rule definition is stored correctly. + { + u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(u) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, resp.StatusCode, 202) + + body, m := rulesNamespaceWithoutVariableValues(t, b) + generatedUIDs, ok := m["default,arulegroup"] + assert.True(t, ok) + assert.Equal(t, 2, len(generatedUIDs)) + // assert that generated UIDs are unique + assert.NotEqual(t, generatedUIDs[0], generatedUIDs[1]) + // copy result to a variable with a wider scope + // to be used by the next test + ruleUID = generatedUIDs[0] + expectedGetNamespaceResponseBody = ` + { + "default":[ + { + "name":"arulegroup", + "interval":"1m", + "rules":[ + { + "annotations": { + "annotation1": "val1" + }, + "expr":"", + "for": "1m", + "labels": { + "label1": "val1" + }, + "grafana_alert":{ + "id":1, + "orgId":1, + "title":"AlwaysFiring", + "condition":"A", + "data":[ + { + "refId":"A", + "queryType":"", + "relativeTimeRange":{ + "from":18000, + "to":10800 + }, + "datasourceUid":"__expr__", + "model":{ + "expression":"2 + 3 \u003e 1", + "intervalMs":1000, + "maxDataPoints":43200, + "type":"math" + } + } + ], + "updated":"2021-02-21T01:10:30Z", + "intervalSeconds":60, + "is_paused": false, + "version":1, + "uid":"uid", + "namespace_uid":"nsuid", + "rule_group":"arulegroup", + "no_data_state":"NoData", + "exec_err_state":"Alerting", + "metadata": { + "editor_settings": { + "simplified_query_and_expressions_section": false, + "simplified_notifications_section": false + } + } + } + }, + { + "expr":"", + "for": "0s", + "grafana_alert":{ + "id":2, + "orgId":1, + "title":"AlwaysFiringButSilenced", + "condition":"A", + "data":[ + { + "refId":"A", + "queryType":"", + "relativeTimeRange":{ + "from":18000, + "to":10800 + }, + "datasourceUid":"__expr__", + "model":{ + "expression":"2 + 3 \u003e 1", + "intervalMs":1000, + "maxDataPoints":43200, + "type":"math" + } + } + ], + "updated":"2021-02-21T01:10:30Z", + "intervalSeconds":60, + "is_paused": false, + "version":1, + "uid":"uid", + "namespace_uid":"nsuid", + "rule_group":"arulegroup", + "no_data_state":"Alerting", + "exec_err_state":"Alerting", + "metadata": { + "editor_settings": { + "simplified_query_and_expressions_section": false, + "simplified_notifications_section": false + } + } + } + } + ] + } + ] + }` + assert.JSONEq(t, expectedGetNamespaceResponseBody, body) + } + + // try to update by pass an invalid UID + { + interval, err := model.ParseDuration("30s") + require.NoError(t, err) + + rules := apimodels.PostableRuleGroupConfig{ + Name: "arulegroup", + Rules: []apimodels.PostableExtendedRuleNode{ + { + ApiRuleNode: &apimodels.ApiRuleNode{ + For: &interval, + Labels: map[string]string{ + "label1": "val42", + "foo": "bar", + }, + Annotations: map[string]string{ + "annotation1": "val42", + "foo": "bar", + }, + }, + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + UID: "unknown", + Title: "AlwaysNormal", + Condition: "A", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: apimodels.RelativeTimeRange{ + From: apimodels.Duration(time.Duration(5) * time.Hour), + To: apimodels.Duration(time.Duration(3) * time.Hour), + }, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 < 1" + }`), + }, + }, + NoDataState: apimodels.NoDataState(ngmodels.Alerting), + ExecErrState: apimodels.ExecutionErrorState(ngmodels.AlertingErrState), + }, + }, + }, + Interval: interval, + } + + _, status, body := apiClient.PostRulesGroupWithStatus(t, "default", &rules) + assert.Equal(t, http.StatusNotFound, status) + var res map[string]any + assert.NoError(t, json.Unmarshal([]byte(body), &res)) + require.Equal(t, "failed to update rule group: failed to update rule with UID unknown because could not find alert rule", res["message"]) + + // let's make sure that rule definitions are not affected by the failed POST request. + u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(u) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, resp.StatusCode, 202) + + body, m := rulesNamespaceWithoutVariableValues(t, b) + returnedUIDs, ok := m["default,arulegroup"] + assert.True(t, ok) + assert.Equal(t, 2, len(returnedUIDs)) + assert.JSONEq(t, expectedGetNamespaceResponseBody, body) + } + + // try to update by pass two rules with conflicting UIDs + { + interval, err := model.ParseDuration("30s") + require.NoError(t, err) + + rules := apimodels.PostableRuleGroupConfig{ + Name: "arulegroup", + Rules: []apimodels.PostableExtendedRuleNode{ + { + ApiRuleNode: &apimodels.ApiRuleNode{ + For: &interval, + Labels: map[string]string{ + "label1": "val42", + "foo": "bar", + }, + Annotations: map[string]string{ + "annotation1": "val42", + "foo": "bar", + }, + }, + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + UID: ruleUID, + Title: "AlwaysNormal", + Condition: "A", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: apimodels.RelativeTimeRange{ + From: apimodels.Duration(time.Duration(5) * time.Hour), + To: apimodels.Duration(time.Duration(3) * time.Hour), + }, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 < 1" + }`), + }, + }, + NoDataState: apimodels.NoDataState(ngmodels.Alerting), + ExecErrState: apimodels.ExecutionErrorState(ngmodels.AlertingErrState), + }, + }, + { + ApiRuleNode: &apimodels.ApiRuleNode{ + For: &interval, + Labels: map[string]string{ + "label1": "val42", + "foo": "bar", + }, + Annotations: map[string]string{ + "annotation1": "val42", + "foo": "bar", + }, + }, + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + UID: ruleUID, + Title: "AlwaysAlerting", + Condition: "A", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: apimodels.RelativeTimeRange{ + From: apimodels.Duration(time.Duration(5) * time.Hour), + To: apimodels.Duration(time.Duration(3) * time.Hour), + }, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + NoDataState: apimodels.NoDataState(ngmodels.Alerting), + ExecErrState: apimodels.ExecutionErrorState(ngmodels.AlertingErrState), + }, + }, + }, + Interval: interval, + } + _, status, body := apiClient.PostRulesGroupWithStatus(t, "default", &rules) + assert.Equal(t, http.StatusBadRequest, status) + var res map[string]any + require.NoError(t, json.Unmarshal([]byte(body), &res)) + require.Equal(t, fmt.Sprintf("rule [1] has UID %s that is already assigned to another rule at index 0", ruleUID), res["message"]) + + // let's make sure that rule definitions are not affected by the failed POST request. + u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(u) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, resp.StatusCode, 202) + + body, m := rulesNamespaceWithoutVariableValues(t, b) + returnedUIDs, ok := m["default,arulegroup"] + assert.True(t, ok) + assert.Equal(t, 2, len(returnedUIDs)) + assert.JSONEq(t, expectedGetNamespaceResponseBody, body) + } + + // update the first rule and completely remove the other + { + forValue, err := model.ParseDuration("30s") + require.NoError(t, err) + + rules := apimodels.PostableRuleGroupConfig{ + Name: "arulegroup", + Rules: []apimodels.PostableExtendedRuleNode{ + { + ApiRuleNode: &apimodels.ApiRuleNode{ + For: &forValue, + Labels: map[string]string{ + // delete foo label + "label1": "val1", // update label value + "label2": "val2", // new label + }, + Annotations: map[string]string{ + // delete foo annotation + "annotation1": "val1", // update annotation value + "annotation2": "val2", // new annotation + }, + }, + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + UID: ruleUID, // Including the UID in the payload makes the endpoint update the existing rule. + Title: "AlwaysNormal", + Condition: "A", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: apimodels.RelativeTimeRange{ + From: apimodels.Duration(time.Duration(5) * time.Hour), + To: apimodels.Duration(time.Duration(3) * time.Hour), + }, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 < 1" + }`), + }, + }, + NoDataState: apimodels.NoDataState(ngmodels.Alerting), + ExecErrState: apimodels.ExecutionErrorState(ngmodels.AlertingErrState), + }, + }, + }, + Interval: interval, + } + respModel, status, _ := apiClient.PostRulesGroupWithStatus(t, "default", &rules) + assert.Equal(t, http.StatusAccepted, status) + require.Equal(t, respModel.Updated, []string{ruleUID}) + require.Len(t, respModel.Deleted, 1) + + // let's make sure that rule definitions are updated correctly. + u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(u) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, resp.StatusCode, 202) + + body, m := rulesNamespaceWithoutVariableValues(t, b) + returnedUIDs, ok := m["default,arulegroup"] + assert.True(t, ok) + assert.Equal(t, 1, len(returnedUIDs)) + assert.Equal(t, ruleUID, returnedUIDs[0]) + assert.JSONEq(t, ` + { + "default":[ + { + "name":"arulegroup", + "interval":"1m", + "rules":[ + { + "annotations": { + "annotation1": "val1", + "annotation2": "val2" + }, + "expr":"", + "for": "30s", + "labels": { + "label1": "val1", + "label2": "val2" + }, + "grafana_alert":{ + "id":1, + "orgId":1, + "title":"AlwaysNormal", + "condition":"A", + "data":[ + { + "refId":"A", + "queryType":"", + "relativeTimeRange":{ + "from":18000, + "to":10800 + }, + "datasourceUid":"__expr__", + "model":{ + "expression":"2 + 3 \u003C 1", + "intervalMs":1000, + "maxDataPoints":43200, + "type":"math" + } + } + ], + "updated":"2021-02-21T01:10:30Z", + "intervalSeconds":60, + "is_paused": false, + "version":2, + "uid":"uid", + "namespace_uid":"nsuid", + "rule_group":"arulegroup", + "no_data_state":"Alerting", + "exec_err_state":"Alerting", + "metadata": { + "editor_settings": { + "simplified_query_and_expressions_section": false, + "simplified_notifications_section": false + } + } + } + } + ] + } + ] + }`, body) + } + + // update the rule; delete labels and annotations + { + forValue, err := model.ParseDuration("30s") + require.NoError(t, err) + + rules := apimodels.PostableRuleGroupConfig{ + Name: "arulegroup", + Rules: []apimodels.PostableExtendedRuleNode{ + { + ApiRuleNode: &apimodels.ApiRuleNode{ + For: &forValue, + }, + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + UID: ruleUID, // Including the UID in the payload makes the endpoint update the existing rule. + Title: "AlwaysNormal", + Condition: "A", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: apimodels.RelativeTimeRange{ + From: apimodels.Duration(time.Duration(5) * time.Hour), + To: apimodels.Duration(time.Duration(3) * time.Hour), + }, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 < 1" + }`), + }, + }, + NoDataState: apimodels.NoDataState(ngmodels.Alerting), + ExecErrState: apimodels.ExecutionErrorState(ngmodels.AlertingErrState), + }, + }, + }, + Interval: interval, + } + respModel, status, _ := apiClient.PostRulesGroupWithStatus(t, "default", &rules) + assert.Equal(t, http.StatusAccepted, status) + require.Equal(t, respModel.Updated, []string{ruleUID}) + + // let's make sure that rule definitions are updated correctly. + u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(u) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, resp.StatusCode, 202) + + body, m := rulesNamespaceWithoutVariableValues(t, b) + returnedUIDs, ok := m["default,arulegroup"] + assert.True(t, ok) + assert.Equal(t, 1, len(returnedUIDs)) + assert.Equal(t, ruleUID, returnedUIDs[0]) + assert.JSONEq(t, ` + { + "default":[ + { + "name":"arulegroup", + "interval":"1m", + "rules":[ + { + "expr":"", + "for": "30s", + "grafana_alert":{ + "id":1, + "orgId":1, + "title":"AlwaysNormal", + "condition":"A", + "data":[ + { + "refId":"A", + "queryType":"", + "relativeTimeRange":{ + "from":18000, + "to":10800 + }, + "datasourceUid":"__expr__", + "model":{ + "expression":"2 + 3 \u003C 1", + "intervalMs":1000, + "maxDataPoints":43200, + "type":"math" + } + } + ], + "updated":"2021-02-21T01:10:30Z", + "intervalSeconds":60, + "is_paused":false, + "version":3, + "uid":"uid", + "namespace_uid":"nsuid", + "rule_group":"arulegroup", + "no_data_state":"Alerting", + "exec_err_state":"Alerting", + "metadata": { + "editor_settings": { + "simplified_query_and_expressions_section": false, + "simplified_notifications_section": false + } + } + } + } + ] + } + ] + }`, body) + } + + // update the rule; keep title, condition, no data state, error state, queries and expressions if not provided. should be noop + { + rules := apimodels.PostableRuleGroupConfig{ + Name: "arulegroup", + Rules: []apimodels.PostableExtendedRuleNode{ + { + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + UID: ruleUID, // Including the UID in the payload makes the endpoint update the existing rule. + }, + }, + }, + Interval: interval, + } + respModel, status, _ := apiClient.PostRulesGroupWithStatus(t, "default", &rules) + assert.Equal(t, http.StatusAccepted, status) + require.Equal(t, "no changes detected in the rule group", respModel.Message) + assert.Empty(t, respModel.Created) + assert.Empty(t, respModel.Updated) + assert.Empty(t, respModel.Deleted) + + // let's make sure that rule definitions are updated correctly. + u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(u) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, resp.StatusCode, 202) + + body, m := rulesNamespaceWithoutVariableValues(t, b) + returnedUIDs, ok := m["default,arulegroup"] + assert.True(t, ok) + assert.Equal(t, 1, len(returnedUIDs)) + assert.Equal(t, ruleUID, returnedUIDs[0]) + assert.JSONEq(t, ` + { + "default":[ + { + "name":"arulegroup", + "interval":"1m", + "rules":[ + { + "expr":"", + "for": "30s", + "grafana_alert":{ + "id":1, + "orgId":1, + "title":"AlwaysNormal", + "condition":"A", + "data":[ + { + "refId":"A", + "queryType":"", + "relativeTimeRange":{ + "from":18000, + "to":10800 + }, + "datasourceUid":"__expr__", + "model":{ + "expression":"2 + 3 \u003C 1", + "intervalMs":1000, + "maxDataPoints":43200, + "type":"math" + } + } + ], + "updated":"2021-02-21T01:10:30Z", + "intervalSeconds":60, + "is_paused":false, + "version":3, + "uid":"uid", + "namespace_uid":"nsuid", + "rule_group":"arulegroup", + "no_data_state":"Alerting", + "exec_err_state":"Alerting", + "metadata": { + "editor_settings": { + "simplified_query_and_expressions_section": false, + "simplified_notifications_section": false + } + } + } + } + ] + } + ] + }`, body) + } + + client := &http.Client{} + // Finally, make sure we can delete it. + { + t.Run("succeed if the rule group name does not exists", func(t *testing.T) { + u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default/groupnotexist", grafanaListedAddr) + req, err := http.NewRequest(http.MethodDelete, u, nil) + require.NoError(t, err) + resp, err := client.Do(req) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + require.Equal(t, http.StatusAccepted, resp.StatusCode) + var res map[string]any + require.NoError(t, json.Unmarshal(b, &res)) + require.Equal(t, "rules deleted", res["message"]) + }) + + t.Run("succeed if the rule group name does exist", func(t *testing.T) { + u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default/arulegroup", grafanaListedAddr) + req, err := http.NewRequest(http.MethodDelete, u, nil) + require.NoError(t, err) + resp, err := client.Do(req) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + require.Equal(t, http.StatusAccepted, resp.StatusCode) + require.JSONEq(t, `{"message":"rules deleted"}`, string(b)) + }) } } @@ -2344,3 +4199,154 @@ func TestIntegrationRuleUpdateAllDatabases(t *testing.T) { require.Equal(t, newGroup, getGroup.Rules[0].GrafanaManagedAlert.RuleGroup) }) } + +func newTestingRuleConfig(t *testing.T) apimodels.PostableRuleGroupConfig { + interval, err := model.ParseDuration("1m") + require.NoError(t, err) + + firstRule := apimodels.PostableExtendedRuleNode{ + ApiRuleNode: &apimodels.ApiRuleNode{ + For: &interval, + Labels: map[string]string{"label1": "val1"}, + Annotations: map[string]string{"annotation1": "val1"}, + }, + // this rule does not explicitly set no data and error states + // therefore it should get the default values + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: "AlwaysFiring", + Condition: "A", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: apimodels.RelativeTimeRange{ + From: apimodels.Duration(time.Duration(5) * time.Hour), + To: apimodels.Duration(time.Duration(3) * time.Hour), + }, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + }, + } + secondRule := apimodels.PostableExtendedRuleNode{ + ApiRuleNode: &apimodels.ApiRuleNode{ + For: &interval, + Labels: map[string]string{"label1": "val1"}, + Annotations: map[string]string{"annotation1": "val1"}, + }, + // this rule does not explicitly set no data and error states + // therefore it should get the default values + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: "AlwaysFiring2", + Condition: "A", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: apimodels.RelativeTimeRange{ + From: apimodels.Duration(time.Duration(5) * time.Hour), + To: apimodels.Duration(time.Duration(3) * time.Hour), + }, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + }, + } + + return apimodels.PostableRuleGroupConfig{ + Name: "arulegroup", + Rules: []apimodels.PostableExtendedRuleNode{ + firstRule, + secondRule, + }, + } +} + +// rulesNamespaceWithoutVariableValues takes a apimodels.NamespaceConfigResponse JSON-based input and makes the dynamic fields static e.g. uid, dates, etc. +// it returns a map of the modified rule UIDs with the namespace,rule_group as a key +func rulesNamespaceWithoutVariableValues(t *testing.T, b []byte) (string, map[string][]string) { + t.Helper() + + var r apimodels.NamespaceConfigResponse + require.NoError(t, json.Unmarshal(b, &r)) + // create a map holding the created rule UIDs per namespace/group + m := make(map[string][]string) + for namespace, nodes := range r { + for _, node := range nodes { + compositeKey := strings.Join([]string{namespace, node.Name}, ",") + _, ok := m[compositeKey] + if !ok { + m[compositeKey] = make([]string, 0, len(node.Rules)) + } + for _, rule := range node.Rules { + m[compositeKey] = append(m[compositeKey], rule.GrafanaManagedAlert.UID) + rule.GrafanaManagedAlert.UID = "uid" + rule.GrafanaManagedAlert.NamespaceUID = "nsuid" + rule.GrafanaManagedAlert.Updated = time.Date(2021, time.Month(2), 21, 1, 10, 30, 0, time.UTC) + } + } + } + + json, err := json.Marshal(&r) + require.NoError(t, err) + return string(json), m +} + +func createRule(t *testing.T, client apiClient, folder string) (apimodels.PostableRuleGroupConfig, string) { + t.Helper() + + interval, err := model.ParseDuration("1m") + require.NoError(t, err) + doubleInterval := 2 * interval + rules := apimodels.PostableRuleGroupConfig{ + Name: "arulegroup", + Interval: interval, + Rules: []apimodels.PostableExtendedRuleNode{ + { + ApiRuleNode: &apimodels.ApiRuleNode{ + For: &doubleInterval, + Labels: map[string]string{"label1": "val1"}, + Annotations: map[string]string{"annotation1": "val1"}, + }, + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: fmt.Sprintf("rule under folder %s", folder), + Condition: "A", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: apimodels.RelativeTimeRange{ + From: apimodels.Duration(time.Duration(5) * time.Hour), + To: apimodels.Duration(time.Duration(3) * time.Hour), + }, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + }, + }, + }, + } + resp, status, _ := client.PostRulesGroupWithStatus(t, folder, &rules) + assert.Equal(t, http.StatusAccepted, status) + require.Len(t, resp.Created, 1) + return rules, resp.Created[0] +} + +func getLongString(t *testing.T, n int) string { + t.Helper() + + b := make([]rune, n) + for i := range b { + b[i] = 'a' + } + return string(b) +} diff --git a/pkg/tests/api/alerting/api_backtesting_data.json b/pkg/tests/api/alerting/test-data/api_backtesting_data.json similarity index 100% rename from pkg/tests/api/alerting/api_backtesting_data.json rename to pkg/tests/api/alerting/test-data/api_backtesting_data.json diff --git a/pkg/tests/api/alerting/testing.go b/pkg/tests/api/alerting/testing.go index e2ec0fe3b54..f599ec060a1 100644 --- a/pkg/tests/api/alerting/testing.go +++ b/pkg/tests/api/alerting/testing.go @@ -2,6 +2,7 @@ package alerting import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -20,11 +21,19 @@ import ( "github.com/grafana/grafana/pkg/api" "github.com/grafana/grafana/pkg/expr" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/folder" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/services/quota/quotaimpl" + "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -53,6 +62,11 @@ const defaultAlertmanagerConfigJSON = ` } ` +type Response struct { + Message string `json:"message"` + TraceID string `json:"traceID"` +} + func getRequest(t *testing.T, url string, expStatusCode int) *http.Response { t.Helper() // nolint:gosec @@ -1008,3 +1022,23 @@ func requireStatusCode(t *testing.T, expected, actual int, response string) { t.Helper() require.Equalf(t, expected, actual, "Unexpected status. Response: %s", response) } + +func createUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) int64 { + t.Helper() + + cfg.AutoAssignOrg = true + cfg.AutoAssignOrgId = 1 + + quotaService := quotaimpl.ProvideService(db, cfg) + orgService, err := orgimpl.ProvideService(db, cfg, quotaService) + require.NoError(t, err) + usrSvc, err := userimpl.ProvideService( + db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + quotaService, supportbundlestest.NewFakeBundleService(), + ) + require.NoError(t, err) + + u, err := usrSvc.Create(context.Background(), &cmd) + require.NoError(t, err) + return u.ID +} diff --git a/playwright.config.ts b/playwright.config.ts index 16e635ff0b6..ff4e8ae7d10 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -160,5 +160,32 @@ export default defineConfig({ }, dependencies: ['authenticate'], }, + { + name: 'jaeger', + testDir: path.join(testDirRoot, '/jaeger'), + use: { + ...devices['Desktop Chrome'], + storageState: 'playwright/.auth/admin.json', + }, + dependencies: ['authenticate'], + }, + { + name: 'grafana-postgresql-datasource', + testDir: path.join(testDirRoot, '/grafana-postgresql-datasource'), + use: { + ...devices['Desktop Chrome'], + storageState: 'playwright/.auth/admin.json', + }, + dependencies: ['authenticate'], + }, + { + name: 'zipkin', + testDir: path.join(testDirRoot, '/zipkin'), + use: { + ...devices['Desktop Chrome'], + storageState: 'playwright/.auth/admin.json', + }, + dependencies: ['authenticate'], + }, ], }); diff --git a/public/app/features/alerting/unified/components/rule-editor/dag.test.ts b/public/app/features/alerting/unified/components/rule-editor/dag.test.ts index 914420e5b06..fdb5b22cea4 100644 --- a/public/app/features/alerting/unified/components/rule-editor/dag.test.ts +++ b/public/app/features/alerting/unified/components/rule-editor/dag.test.ts @@ -2,10 +2,9 @@ import { Graph } from 'app/core/utils/dag'; import { AlertQuery } from 'app/types/unified-alerting-dto'; import { - _createDagFromQueries, _getDescendants, _getOriginsOfRefId, - fingerPrintQueries, + createDagFromQueries, fingerprintGraph, parseRefsFromMathExpression, } from './dag'; @@ -45,7 +44,7 @@ describe('working with dag', () => { }, ] as AlertQuery[]; - const dag = _createDagFromQueries(queries); + const dag = createDagFromQueries(queries); expect(Object.keys(dag.nodes)).toHaveLength(4); @@ -84,9 +83,9 @@ describe('working with dag', () => { }, ] as AlertQuery[]; - expect(() => _createDagFromQueries(queries)).not.toThrow(); + expect(() => createDagFromQueries(queries)).not.toThrow(); - const dag = _createDagFromQueries(queries); + const dag = createDagFromQueries(queries); expect(Object.keys(dag.nodes)).toHaveLength(1); @@ -157,36 +156,4 @@ describe('fingerprints', () => { expect(fingerprintGraph(graph)).toMatchInlineSnapshot(`"A:B: B:C:A, D C::B D:B:"`); }); - - test('Queries fingerprint', () => { - const queries = [ - { - refId: 'A', - queryType: 'query', - model: { - refId: 'A', - expression: '', - }, - }, - { - refId: 'B', - queryType: 'query', - model: { - refId: 'B', - expression: 'A', - }, - }, - { - refId: 'C', - queryType: 'query', - model: { - refId: 'C', - expression: '$B > 0', - type: 'math', - }, - }, - ] as AlertQuery[]; - - expect(fingerPrintQueries(queries)).toMatchInlineSnapshot(`"Aquery,BAquery,C$B > 0math"`); - }); }); diff --git a/public/app/features/alerting/unified/components/rule-editor/dag.ts b/public/app/features/alerting/unified/components/rule-editor/dag.ts index 51d519854f5..8ca3c0c1969 100644 --- a/public/app/features/alerting/unified/components/rule-editor/dag.ts +++ b/public/app/features/alerting/unified/components/rule-editor/dag.ts @@ -1,23 +1,15 @@ import { compact, memoize, uniq } from 'lodash'; -import memoizeOne from 'memoize-one'; import { Edge, Graph, Node } from 'app/core/utils/dag'; import { isExpressionQuery } from 'app/features/expressions/guards'; +import { ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types'; import { AlertQuery } from 'app/types/unified-alerting-dto'; -// memoized version of _createDagFromQueries to prevent recreating the DAG if no sources or targets are modified -export const createDagFromQueries = memoizeOne( - _createDagFromQueries, - (previous: Parameters, next: Parameters) => { - return fingerPrintQueries(previous[0]) === fingerPrintQueries(next[0]); - } -); - /** * Turn the array of alert queries (this means data queries and expressions) * in to a DAG, a directed acyclical graph */ -export function _createDagFromQueries(queries: AlertQuery[]): Graph { +export function createDagFromQueries(queries: AlertQuery[]): Graph { const graph = new Graph(); const nodes = queries.map((query) => query.refId); @@ -28,12 +20,9 @@ export function _createDagFromQueries(queries: AlertQuery[]): Graph { return; } const source = query.refId; - const isMathExpression = query.model.type === 'math'; // some expressions have multiple targets (like the math expression) - const targets = isMathExpression - ? parseRefsFromMathExpression(query.model.expression ?? '') - : [query.model.expression]; + const targets = getTargets(query.model); targets.forEach((target) => { const isSelf = source === target; @@ -47,6 +36,19 @@ export function _createDagFromQueries(queries: AlertQuery[]): Graph { return graph; } +function getTargets(model: ExpressionQuery) { + const isMathExpression = model.type === ExpressionQueryType.math; + const isClassicCondition = model.type === ExpressionQueryType.classic; + + if (isMathExpression) { + return parseRefsFromMathExpression(model.expression ?? ''); + } + if (isClassicCondition) { + return model.conditions?.map((c) => c.query.params[0]) ?? []; + } + return [model.expression]; +} + /** * parse an expression like "$A > $B" or "${FOO BAR} > 0" to an array of refIds */ @@ -129,13 +131,3 @@ export function fingerprintGraph(graph: Graph) { }) .join(' '); } - -// create a unique fingerprint of the array of queries -export function fingerPrintQueries(queries: AlertQuery[]) { - return queries - .map((query) => { - const type = isExpressionQuery(query.model) ? query.model.type : query.queryType; - return query.refId + (query.model.expression ?? '') + type; - }) - .join(); -} diff --git a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx index 0201bec3161..9efe62efaf4 100644 --- a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx @@ -7,7 +7,6 @@ import { useDeleteModal } from 'app/features/alerting/unified/components/rule-vi import { INSTANCES_DISPLAY_LIMIT } from 'app/features/alerting/unified/components/rules/RuleDetails'; import SilenceGrafanaRuleDrawer from 'app/features/alerting/unified/components/silences/SilenceGrafanaRuleDrawer'; import { useRulesFilter } from 'app/features/alerting/unified/hooks/useFilteredRules'; -import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext'; import { useDispatch } from 'app/types'; import { CombinedRule, RuleIdentifier, RulesSource } from 'app/types/unified-alerting'; @@ -126,9 +125,7 @@ export const RuleActionsButtons = ({ compact, showViewButton, rule, rulesSource /> {deleteModal} {isGrafanaAlertingRule(rule.rulerRule) && showSilenceDrawer && ( - - setShowSilenceDrawer(false)} /> - + setShowSilenceDrawer(false)} /> )} {redirectToClone?.identifier && ( void; @@ -12,29 +15,33 @@ type Props = { /** * For a given Grafana managed rule, renders a drawer containing silences editor and Alertmanager selection */ -const SilenceGrafanaRuleDrawer = ({ rulerRule, onClose }: Props) => { - const { uid } = rulerRule.grafana_alert; +const SilenceGrafanaRuleDrawer = React.memo( + ({ rulerRule, onClose }: Props) => { + const { uid } = rulerRule.grafana_alert; - const formValues = getDefaultSilenceFormValues(); - - return ( - - - - - - ); -}; + return ( + + + + + + + + ); + }, + (prevProps, nextProps) => { + return prevProps.rulerRule.grafana_alert.uid === nextProps.rulerRule.grafana_alert.uid; + } +); export default SilenceGrafanaRuleDrawer; diff --git a/public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx b/public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx index 405f2f4ae2d..259f3038081 100644 --- a/public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx +++ b/public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx @@ -9,7 +9,6 @@ import { RedirectToCloneRule } from 'app/features/alerting/unified/components/ru import { INSTANCES_DISPLAY_LIMIT } from 'app/features/alerting/unified/components/rules/RuleDetails'; import SilenceGrafanaRuleDrawer from 'app/features/alerting/unified/components/silences/SilenceGrafanaRuleDrawer'; import { useRulesFilter } from 'app/features/alerting/unified/hooks/useFilteredRules'; -import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext'; import { useDispatch } from 'app/types'; import { Rule, RuleGroupIdentifierV2, RuleIdentifier } from 'app/types/unified-alerting'; import { RulerRuleDTO } from 'app/types/unified-alerting-dto'; @@ -93,9 +92,7 @@ export function RuleActionsButtons({ compact, rule, promRule, groupIdentifier }: /> {deleteModal} {isGrafanaAlertingRule(rule) && showSilenceDrawer && ( - - setShowSilenceDrawer(false)} /> - + setShowSilenceDrawer(false)} /> )} {redirectToClone?.identifier && ( implements VizPanelMenu, SceneObject { + constructor(state: Partial) { + super({ ...state, addExplorationsLink: state.addExplorationsLink ?? true }); + this.addActivationHandler(() => { + let exploreUrl: Promise | undefined; + try { + const viz = sceneGraph.getAncestor(this, VizPanel); + const queryRunner = getQueryRunnerFor(viz); + const queries = queryRunner?.state.queries ?? []; + queries.forEach((query) => { + // removing legendFormat to get verbose legend in Explore + delete query.legendFormat; + }); + const trail = getTrailFor(this); + const dsValue = getDataSource(trail); + const timeRange = sceneGraph.getTimeRange(this); + exploreUrl = getExploreUrl({ + queries, + dsRef: { uid: dsValue }, + timeRange: timeRange.state.value, + scopedVars: { __sceneObject: { value: viz } }, + }); + } catch (e) {} + + // Navigation options (all panels) + const items: PanelMenuItem[] = [ + { + text: 'Navigation', + type: 'group', + }, + { + text: 'Explore', + iconClassName: 'compass', + onClick: () => exploreUrl?.then((url) => url && window.open(url, '_blank')), + shortcut: 'p x', + }, + ]; + + this.setState({ + body: new VizPanelMenu({ + items, + }), + }); + + const addToExplorationsButton = new AddToExplorationButton({ + labelName: this.state.labelName, + fieldName: this.state.fieldName, + frame: this.state.frame, + }); + this._subs.add( + addToExplorationsButton?.subscribeToState(() => { + subscribeToAddToExploration(this); + }) + ); + this.setState({ + explorationsButton: addToExplorationsButton, + }); + + if (this.state.addExplorationsLink) { + this.state.explorationsButton?.activate(); + } + }); + } + + addItem(item: PanelMenuItem): void { + if (this.state.body) { + this.state.body.addItem(item); + } + } + + setItems(items: PanelMenuItem[]): void { + if (this.state.body) { + this.state.body.setItems(items); + } + } + + public static Component = ({ model }: SceneComponentProps) => { + const { body } = model.useState(); + + if (body) { + return ; + } + + return <>; + }; +} + +const getInvestigationLink = (addToExplorations: AddToExplorationButton) => { + const links = getPluginLinkExtensions({ + extensionPointId: extensionPointId, + context: addToExplorations.state.context, + }); + + return links.extensions[0]; +}; + +const onAddToInvestigationClick = (event: React.MouseEvent, addToExplorations: AddToExplorationButton) => { + const link = getInvestigationLink(addToExplorations); + if (link && link.onClick) { + link.onClick(event); + } +}; + +function subscribeToAddToExploration(menu: PanelMenu) { + const addToExplorationButton = menu.state.explorationsButton; + if (addToExplorationButton) { + const link = getInvestigationLink(addToExplorationButton); + + const existingMenuItems = menu.state.body?.state.items ?? []; + + const existingAddToExplorationLink = existingMenuItems.find((item) => item.text === ADD_TO_INVESTIGATION_MENU_TEXT); + + if (link) { + if (!existingAddToExplorationLink) { + menu.state.body?.addItem({ + text: ADD_TO_INVESTIGATION_MENU_DIVIDER_TEXT, + type: 'divider', + }); + menu.state.body?.addItem({ + text: ADD_TO_INVESTIGATION_MENU_GROUP_TEXT, + type: 'group', + }); + menu.state.body?.addItem({ + text: ADD_TO_INVESTIGATION_MENU_TEXT, + iconClassName: 'plus-square', + onClick: (e) => onAddToInvestigationClick(e, addToExplorationButton), + }); + } else { + if (existingAddToExplorationLink) { + menu.state.body?.setItems( + existingMenuItems.filter( + (item) => + [ + ADD_TO_INVESTIGATION_MENU_DIVIDER_TEXT, + ADD_TO_INVESTIGATION_MENU_GROUP_TEXT, + ADD_TO_INVESTIGATION_MENU_TEXT, + ].includes(item.text) === false + ) + ); + } + } + } + } +} diff --git a/public/app/features/trails/MetricSelect/MetricSelectScene.tsx b/public/app/features/trails/MetricSelect/MetricSelectScene.tsx index c16ef0e1b7c..de1a13aea61 100644 --- a/public/app/features/trails/MetricSelect/MetricSelectScene.tsx +++ b/public/app/features/trails/MetricSelect/MetricSelectScene.tsx @@ -45,7 +45,6 @@ import { } from '../shared'; import { getFilters, getTrailFor, isSceneTimeRangeState } from '../utils'; -import { AddToExplorationButton } from './AddToExplorationsButton'; import { SelectMetricAction } from './SelectMetricAction'; import { getMetricNames } from './api'; import { getPreviewPanelFor } from './previewPanel'; @@ -424,7 +423,7 @@ export class MetricSelectScene extends SceneObjectBase i } // refactor this into the query generator in future const isNative = trail.isNativeHistogram(metric.name); - const panel = getPreviewPanelFor(metric.name, index, currentFilterCount, description, isNative); + const panel = getPreviewPanelFor(metric.name, index, currentFilterCount, description, isNative, true); metric.itemRef = panel.getRef(); metric.isPanel = true; @@ -655,10 +654,7 @@ function getCardPanelFor(metric: string, description?: string) { return PanelBuilders.text() .setTitle(metric) .setDescription(description) - .setHeaderActions([ - new SelectMetricAction({ metric, title: 'Select' }), - new AddToExplorationButton({ labelName: metric }), - ]) + .setHeaderActions([new SelectMetricAction({ metric, title: 'Select' })]) .setOption('content', '') .build(); } diff --git a/public/app/features/trails/MetricSelect/previewPanel.ts b/public/app/features/trails/MetricSelect/previewPanel.ts index 7206d3bb8e5..e21c346b090 100644 --- a/public/app/features/trails/MetricSelect/previewPanel.ts +++ b/public/app/features/trails/MetricSelect/previewPanel.ts @@ -1,11 +1,11 @@ import { PromQuery } from '@grafana/prometheus'; import { SceneCSSGridItem, SceneQueryRunner, SceneVariableSet } from '@grafana/scenes'; +import { PanelMenu } from '../Menu/PanelMenu'; import { getAutoQueriesForMetric } from '../autoQuery/getAutoQueriesForMetric'; import { getVariablesWithMetricConstant, MDP_METRIC_PREVIEW, trailDS } from '../shared'; import { getColorByIndex } from '../utils'; -import { AddToExplorationButton } from './AddToExplorationsButton'; import { NativeHistogramBadge } from './NativeHistogramBadge'; import { SelectMetricAction } from './SelectMetricAction'; import { hideEmptyPreviews } from './hideEmptyPreviews'; @@ -15,24 +15,29 @@ export function getPreviewPanelFor( index: number, currentFilterCount: number, description?: string, - nativeHistogram?: boolean + nativeHistogram?: boolean, + hideMenu?: boolean ) { const autoQuery = getAutoQueriesForMetric(metric, nativeHistogram); - let actions: Array = [ - new SelectMetricAction({ metric, title: 'Select' }), - new AddToExplorationButton({ labelName: metric }), - ]; + let actions: Array = [new SelectMetricAction({ metric, title: 'Select' })]; if (nativeHistogram) { actions.unshift(new NativeHistogramBadge({})); } - const vizPanel = autoQuery.preview + let vizPanelBuilder = autoQuery.preview .vizBuilder() .setColor({ mode: 'fixed', fixedColor: getColorByIndex(index) }) .setDescription(description) .setHeaderActions(actions) - .build(); + .setShowMenuAlways(true) + .setMenu(new PanelMenu({ labelName: metric })); + + if (!hideMenu) { + vizPanelBuilder = vizPanelBuilder.setShowMenuAlways(true).setMenu(new PanelMenu({ labelName: metric })); + } + + const vizPanel = vizPanelBuilder.build(); const queries = autoQuery.preview.queries.map((query) => convertPreviewQueriesToIgnoreUsage(query, currentFilterCount) diff --git a/public/app/features/trails/autoQuery/components/AutoVizPanel.tsx b/public/app/features/trails/autoQuery/components/AutoVizPanel.tsx index 85096ecbb0e..a2996330fb9 100644 --- a/public/app/features/trails/autoQuery/components/AutoVizPanel.tsx +++ b/public/app/features/trails/autoQuery/components/AutoVizPanel.tsx @@ -1,6 +1,6 @@ -import { SceneObjectState, SceneObjectBase, SceneComponentProps, VizPanel, SceneQueryRunner } from '@grafana/scenes'; +import { SceneComponentProps, SceneObjectBase, SceneObjectState, SceneQueryRunner, VizPanel } from '@grafana/scenes'; -import { AddToExplorationButton } from '../../MetricSelect/AddToExplorationsButton'; +import { PanelMenu } from '../../Menu/PanelMenu'; import { getMetricDescription } from '../../helpers/MetricDatasourceHelper'; import { MDP_METRIC_OVERVIEW, trailDS } from '../../shared'; import { getMetricSceneFor, getTrailFor } from '../../utils'; @@ -57,10 +57,9 @@ export class AutoVizPanel extends SceneObjectBase { }) ) .setDescription(description) - .setHeaderActions([ - new AutoVizPanelQuerySelector({ queryDef: def, onChangeQuery: this.onChangeQuery }), - new AddToExplorationButton({ labelName: metric ?? this.state.metric }), - ]) + .setHeaderActions([new AutoVizPanelQuerySelector({ queryDef: def, onChangeQuery: this.onChangeQuery })]) + .setShowMenuAlways(true) + .setMenu(new PanelMenu({ labelName: metric ?? this.state.metric })) .build(); } diff --git a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx index 4c57d757a05..9524beba351 100644 --- a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx +++ b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx @@ -1,12 +1,9 @@ -import { css } from '@emotion/css'; import { useMemo, useState } from 'react'; -import { useMeasure } from 'react-use'; -import { DashboardCursorSync, DataFrame, PanelProps } from '@grafana/data'; +import { DashboardCursorSync, PanelProps } from '@grafana/data'; import { AxisPlacement, EventBusPlugin, - Pagination, TooltipDisplayMode, TooltipPlugin2, usePanelContext, @@ -15,7 +12,6 @@ import { import { TimeRange2, TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; import { TimelineChart } from 'app/core/components/TimelineChart/TimelineChart'; import { - makeFramePerSeries, prepareTimelineFields, prepareTimelineLegendItems, TimelineMode, @@ -26,73 +22,11 @@ import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin'; import { getTimezones } from '../timeseries/utils'; import { StateTimelineTooltip2 } from './StateTimelineTooltip2'; -import { Options, defaultOptions } from './panelcfg.gen'; +import { Options } from './panelcfg.gen'; +import { containerStyles, usePagination } from './utils'; interface TimelinePanelProps extends PanelProps {} -const styles = { - container: css({ - display: 'flex', - flexDirection: 'column', - }), - paginationContainer: css({ - display: 'flex', - justifyContent: 'center', - width: '100%', - }), - paginationElement: css({ - marginTop: '8px', - }), -}; - -function usePagination(frames?: DataFrame[], perPage?: number) { - const [currentPage, setCurrentPage] = useState(1); - - const [paginationWrapperRef, { height: paginationHeight, width: paginationWidth }] = useMeasure(); - - const pagedFrames = useMemo( - () => (!perPage || frames == null ? frames : makeFramePerSeries(frames)), - [frames, perPage] - ); - - if (!perPage || pagedFrames == null) { - return { - paginatedFrames: pagedFrames, - paginationRev: 'disabled', - paginationElement: undefined, - paginationHeight: 0, - }; - } - - perPage ||= defaultOptions.perPage!; - - const numberOfPages = Math.ceil(pagedFrames.length / perPage); - // `perPage` changing might lead to temporarily too large values of `currentPage`. - const currentPageCapped = Math.min(currentPage, numberOfPages); - const pageOffset = (currentPageCapped - 1) * perPage; - const currentPageFrames = pagedFrames.slice(pageOffset, pageOffset + perPage); - - // `paginationRev` needs to change value whenever any of the pagination settings changes. - // It's used in to trigger a reconfiguration of the underlying graphs (which is cached, - // hence an explicit nudge is required). - const paginationRev = `${currentPageCapped}/${perPage}`; - - const showSmallVersion = paginationWidth < 550; - const paginationElement = ( -
- -
- ); - - return { paginatedFrames: currentPageFrames, paginationRev, paginationElement, paginationHeight }; -} - /** * @alpha */ @@ -141,7 +75,7 @@ export const StateTimelinePanel = ({ const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations()); return ( -
+
(); + + const pagedFrames = useMemo( + () => (!perPage || frames == null ? frames : makeFramePerSeries(frames)), + [frames, perPage] + ); + + if (!perPage || pagedFrames == null) { + return { + paginatedFrames: pagedFrames, + paginationRev: 'disabled', + paginationElement: undefined, + paginationHeight: 0, + }; + } + + perPage ||= defaultOptions.perPage!; + + const numberOfPages = Math.ceil(pagedFrames.length / perPage); + // `perPage` changing might lead to temporarily too large values of `currentPage`. + const currentPageCapped = Math.min(currentPage, numberOfPages); + const pageOffset = (currentPageCapped - 1) * perPage; + const currentPageFrames = pagedFrames.slice(pageOffset, pageOffset + perPage); + + // `paginationRev` needs to change value whenever any of the pagination settings changes. + // It's used in to trigger a reconfiguration of the underlying graphs (which is cached, + // hence an explicit nudge is required). + const paginationRev = `${currentPageCapped}/${perPage}`; + + const showSmallVersion = paginationWidth < 550; + const paginationElement = ( +
+ +
+ ); + + return { paginatedFrames: currentPageFrames, paginationRev, paginationElement, paginationHeight }; +} diff --git a/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx b/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx index 237f1469723..8ed1a266a27 100644 --- a/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx +++ b/public/app/plugins/panel/status-history/StatusHistoryPanel.tsx @@ -18,6 +18,7 @@ import { } from 'app/core/components/TimelineChart/utils'; import { StateTimelineTooltip2 } from '../state-timeline/StateTimelineTooltip2'; +import { containerStyles, usePagination } from '../state-timeline/utils'; import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2'; import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin'; import { getTimezones } from '../timeseries/utils'; @@ -53,14 +54,19 @@ export const StatusHistoryPanel = ({ [data.series, timeRange, theme] ); + const { paginatedFrames, paginationRev, paginationElement, paginationHeight } = usePagination( + frames, + options.perPage + ); + const legendItems = useMemo( - () => prepareTimelineLegendItems(frames, options.legend, theme), - [frames, options.legend, theme] + () => prepareTimelineLegendItems(paginatedFrames, options.legend, theme), + [paginatedFrames, options.legend, theme] ); const timezones = useMemo(() => getTimezones(options.timezone, timeZone), [options.timezone, timeZone]); - if (!frames || warn) { + if (!paginatedFrames || warn) { return (

{warn ?? 'No data found in response'}

@@ -69,99 +75,103 @@ export const StatusHistoryPanel = ({ } // Status grid requires some space between values - if (frames[0].length > width / 2) { + if (paginatedFrames[0].length > width / 2) { return (

Too many points to visualize properly.
- Update the query to return fewer points.
({frames[0].length} points received) + Update the query to return fewer points.
({paginatedFrames[0].length} points received)

); } return ( - - {(builder, alignedFrame) => { - return ( - <> - {cursorSync !== DashboardCursorSync.Off && ( - - )} - {options.tooltip.mode !== TooltipDisplayMode.None && ( - - alignedFrame.fields[seriesIdx]!.getLinks?.({ valueRowIndex: dataIdx }) ?? [] - } - render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync, dataLinks) => { - if (enableAnnotationCreation && timeRange2 != null) { - setNewAnnotationRange(timeRange2); - dismiss(); - return; +
+ + {(builder, alignedFrame) => { + return ( + <> + {cursorSync !== DashboardCursorSync.Off && ( + + )} + {options.tooltip.mode !== TooltipDisplayMode.None && ( + + alignedFrame.fields[seriesIdx]!.getLinks?.({ valueRowIndex: dataIdx }) ?? [] + } + render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync, dataLinks) => { + if (enableAnnotationCreation && timeRange2 != null) { + setNewAnnotationRange(timeRange2); + dismiss(); + return; + } - const annotate = () => { - let xVal = u.posToVal(u.cursor.left!, 'x'); + const annotate = () => { + let xVal = u.posToVal(u.cursor.left!, 'x'); - setNewAnnotationRange({ from: xVal, to: xVal }); - dismiss(); - }; + setNewAnnotationRange({ from: xVal, to: xVal }); + dismiss(); + }; - return ( - - ); - }} - maxWidth={options.tooltip.maxWidth} - /> - )} - {alignedFrame.fields[0].config.custom?.axisPlacement !== AxisPlacement.Hidden && ( - - )} - - - ); - }} - + return ( + + ); + }} + maxWidth={options.tooltip.maxWidth} + /> + )} + {alignedFrame.fields[0].config.custom?.axisPlacement !== AxisPlacement.Hidden && ( + + )} + + + ); + }} + + {paginationElement} +
); }; diff --git a/public/app/plugins/panel/status-history/module.tsx b/public/app/plugins/panel/status-history/module.tsx index 58ff47be783..722f8936199 100644 --- a/public/app/plugins/panel/status-history/module.tsx +++ b/public/app/plugins/panel/status-history/module.tsx @@ -82,6 +82,15 @@ export const plugin = new PanelPlugin(StatusHistoryPanel) max: 1, step: 0.01, }, + }) + .addNumberInput({ + path: 'perPage', + name: 'Page size (enable pagination)', + settings: { + min: 1, + step: 1, + integer: true, + }, }); commonOptionsBuilder.addLegendOptions(builder, false); diff --git a/public/app/plugins/panel/status-history/panelcfg.cue b/public/app/plugins/panel/status-history/panelcfg.cue index 55a96b501d2..f8bfc020da0 100644 --- a/public/app/plugins/panel/status-history/panelcfg.cue +++ b/public/app/plugins/panel/status-history/panelcfg.cue @@ -35,6 +35,8 @@ composableKinds: PanelCfg: { showValue: ui.VisibilityMode & (*"auto" | _) //Controls the column width colWidth?: float & <=1 | *0.9 + //Enables pagination when > 0 + perPage?: number & >=1 | *20 } @cuetsy(kind="interface") FieldConfig: { ui.AxisConfig diff --git a/public/app/plugins/panel/status-history/panelcfg.gen.ts b/public/app/plugins/panel/status-history/panelcfg.gen.ts index fac1c7e19df..366f69f05fd 100644 --- a/public/app/plugins/panel/status-history/panelcfg.gen.ts +++ b/public/app/plugins/panel/status-history/panelcfg.gen.ts @@ -15,6 +15,10 @@ export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui * Controls the column width */ colWidth?: number; + /** + * Enables pagination when > 0 + */ + perPage?: number; /** * Set the height of the rows */ @@ -27,6 +31,7 @@ export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui export const defaultOptions: Partial = { colWidth: 0.9, + perPage: 20, rowHeight: 0.9, showValue: ui.VisibilityMode.Auto, };