From 6284dd61aedfd432ca3fdf425b46ee506425471c Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Fri, 24 Jan 2025 08:26:42 -0700 Subject: [PATCH 01/11] K8s: Fix get dashboard by plugin id (#99509) --- .../dashboards/service/dashboard_service.go | 17 ++++++++++++++++- .../service/dashboard_service_test.go | 10 ++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) 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" From 361312bbd7f6e6276655d90903df7ea3a4071dae Mon Sep 17 00:00:00 2001 From: Santiago Date: Fri, 24 Jan 2025 16:26:57 +0100 Subject: [PATCH 02/11] Alerting: Expect 406s from the remote Alertmanager during the readiness check (#99507) * Alerting: Expect 406s from the remote Alertmanager during the readiness check * make it clear in the warning logs that we'll attempt to send the confgiuration/state without comparing in case of error pulling the current state/config --- pkg/services/ngalert/remote/alertmanager.go | 4 ++-- pkg/services/ngalert/remote/client/alertmanager.go | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) 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) } From 888965023cc9ad836e5888b4a345dfd43667c189 Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Fri, 24 Jan 2025 16:45:46 +0100 Subject: [PATCH 03/11] Alerting: Fix getting targets in dag when using classic condition (#99503) * fix getting targets in dag when using classic condition * remove query fingerprinting the DAG function is probably fast enough and the fingerprinting function is too complex to make it work for all expression types --------- Co-authored-by: Gilles De Mey --- .../components/rule-editor/dag.test.ts | 41 ++----------------- .../unified/components/rule-editor/dag.ts | 40 ++++++++---------- 2 files changed, 20 insertions(+), 61 deletions(-) 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(); -} From 63a89afe00cdabdc8c3dea6001d98664e622a406 Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Fri, 24 Jan 2025 17:03:58 +0100 Subject: [PATCH 04/11] Alerting: Fix silence drawer being re-rendered when the alert list is refreshed (#99468) * Fix silence drawer being re-rendered when the alert list is refreshed * remove unnecessary formValues usage as prop * remove AlertmanagerProvider in RuleActionsButtonsV2 --- .../components/rules/RuleActionsButtons.tsx | 5 +- .../silences/SilenceGrafanaRuleDrawer.tsx | 55 +++++++++++-------- .../components/RuleActionsButtons.V2.tsx | 5 +- 3 files changed, 33 insertions(+), 32 deletions(-) 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 && ( Date: Fri, 24 Jan 2025 13:53:58 -0300 Subject: [PATCH 05/11] CloudMigrations: Remove onPremToCloudMigrationsAuthApiMig feature flag (#99368) --- .../feature-toggles/index.md | 1 - .../src/types/featureToggles.gen.ts | 1 - .../cloudmigrationimpl/cloudmigration.go | 16 +- pkg/services/featuremgmt/registry.go | 6 - pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.go | 4 - pkg/services/featuremgmt/toggles_gen.json | 3 +- pkg/services/gcom/gcomauth.go | 289 ------------------ 8 files changed, 7 insertions(+), 314 deletions(-) delete mode 100644 pkg/services/gcom/gcomauth.go 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/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/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/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/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 -} From 92d6762a3a5736f5ab9bcec24b79d7b5ea9ad31e Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Fri, 24 Jan 2025 12:09:17 -0500 Subject: [PATCH 06/11] Alerting: Store information about user that created\updated alert rule (#99395) * introduce new fields created_by in rule tables * update domain model and compat layer to support UpdatedBy * add alert rule generator mutators for UpdatedBy * ignore UpdatedBy in diff and hash calculation * Add user context to alert rule insert/update operations Updated InsertAlertRules and UpdateAlertRules methods to accept a user context parameter. This change ensures auditability and better tracking of user actions when creating or updating alert rules. Adjusted all relevant calls and interfaces to pass the user context accordingly. * set UpdatedBy in PreSave because this is where Updated is set * Use nil userID for system-initiated updates This ensures differentiation between system and user-initiated changes for better traceability and clarity in update origins. --------- Signed-off-by: Yuri Tseretyan --- .../loki/historian_store_test.go | 4 +- pkg/services/folder/folderimpl/folder_test.go | 2 +- pkg/services/ngalert/api/api_ruler.go | 4 +- pkg/services/ngalert/api/persist.go | 4 +- pkg/services/ngalert/models/alert_rule.go | 19 ++- .../ngalert/models/alert_rule_test.go | 5 + pkg/services/ngalert/models/testing.go | 13 ++ .../ngalert/provisioning/alert_rules.go | 10 +- .../ngalert/provisioning/alert_rules_test.go | 17 ++- pkg/services/ngalert/provisioning/persist.go | 4 +- .../ngalert/schedule/registry_test.go | 1 + pkg/services/ngalert/store/alert_rule.go | 16 +- pkg/services/ngalert/store/alert_rule_test.go | 143 ++++++++++++++---- pkg/services/ngalert/store/compat.go | 10 ++ pkg/services/ngalert/store/deltas.go | 2 +- pkg/services/ngalert/store/models.go | 2 + pkg/services/ngalert/tests/fakes/rules.go | 4 +- pkg/services/ngalert/tests/util.go | 2 +- .../sqlstore/migrations/migrations.go | 2 + .../migrations/ualert/rule_creator_mig.go | 20 +++ .../federated/federatedtests/stats_test.go | 2 +- 21 files changed, 224 insertions(+), 62 deletions(-) create mode 100644 pkg/services/sqlstore/migrations/ualert/rule_creator_mig.go 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/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/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/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", From 4570f7319d76836dafe21bfde370b14853dc6493 Mon Sep 17 00:00:00 2001 From: Adam Yeats <16296989+adamyeats@users.noreply.github.com> Date: Fri, 24 Jan 2025 19:21:41 +0000 Subject: [PATCH 07/11] Add E2E smoke tests for Jaeger, PostgreSQL, Zipkin (#99515) --- e2e/plugin-e2e/jaeger/jaeger.spec.ts | 8 +++++++ e2e/plugin-e2e/postgres/postgres.spec.ts | 8 +++++++ e2e/plugin-e2e/zipkin/zipkin.spec.ts | 8 +++++++ playwright.config.ts | 27 ++++++++++++++++++++++++ 4 files changed, 51 insertions(+) create mode 100644 e2e/plugin-e2e/jaeger/jaeger.spec.ts create mode 100644 e2e/plugin-e2e/postgres/postgres.spec.ts create mode 100644 e2e/plugin-e2e/zipkin/zipkin.spec.ts 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/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'], + }, ], }); From af663dadc7c551d53e3de36b2ecf89aa81174a41 Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Fri, 24 Jan 2025 14:49:05 -0500 Subject: [PATCH 08/11] Alerting: Refactor integration tests (#99519) --------- Signed-off-by: Yuri Tseretyan --- .../api/alerting/api_alertmanager_test.go | 2041 --------------- .../api/alerting/api_backtesting_test.go | 9 +- pkg/tests/api/alerting/api_ruler_test.go | 2212 ++++++++++++++++- .../{ => test-data}/api_backtesting_data.json | 0 pkg/tests/api/alerting/testing.go | 34 + 5 files changed, 2147 insertions(+), 2149 deletions(-) rename pkg/tests/api/alerting/{ => test-data}/api_backtesting_data.json (100%) 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 +} From d4098536830043e3e145e7f27f5714bc2e953b0f Mon Sep 17 00:00:00 2001 From: Kristina Date: Fri, 24 Jan 2025 13:52:04 -0600 Subject: [PATCH 09/11] StatusHistory: Add pagination option (#99517) * first pass * Add to docs * Move pagination hook and styles to a shared util * Update docs/sources/panels-visualizations/visualizations/status-history/index.md Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> --------- Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> --- .../visualizations/status-history/index.md | 4 + .../x/StatusHistoryPanelCfg_types.gen.ts | 5 + .../state-timeline/StateTimelinePanel.tsx | 74 +------- .../plugins/panel/state-timeline/utils.tsx | 75 ++++++++ .../status-history/StatusHistoryPanel.tsx | 176 +++++++++--------- .../plugins/panel/status-history/module.tsx | 9 + .../plugins/panel/status-history/panelcfg.cue | 2 + .../panel/status-history/panelcfg.gen.ts | 5 + 8 files changed, 197 insertions(+), 153 deletions(-) create mode 100644 public/app/plugins/panel/state-timeline/utils.tsx 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/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/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, }; From 35a581a2ba937dbdb88c8e1be497b7ed056c2b6a Mon Sep 17 00:00:00 2001 From: Alex Spencer <52186778+alexjonspencer1@users.noreply.github.com> Date: Fri, 24 Jan 2025 13:45:20 -0700 Subject: [PATCH 10/11] GroupingToMatrix: Type fix to resolve `localeCompare` error (#98710) * draft: coerce types to string to prevent localCompare bug * chore: update with comment for draft * chore: cleanup and change fix * fix: instantiate compare outside of functions scope * chore: update tests! * fix: make the linter happy again * fix: remove unnecessary loop to generate array * chore: revert forEach to for loops, other changes also * chore: reduce diff * chore: one more revert --------- Co-authored-by: drew08t --- packages/grafana-data/src/field/fieldComparers.ts | 7 ++++++- .../transformers/groupingToMatrix.test.ts | 4 ++-- .../transformations/transformers/groupingToMatrix.ts | 11 +++-------- 3 files changed, 11 insertions(+), 11 deletions(-) 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); } From 154a57cd302929bccb1e055020d33609e18a5a7b Mon Sep 17 00:00:00 2001 From: Sven Grossmann Date: Fri, 24 Jan 2025 21:51:04 +0100 Subject: [PATCH 11/11] Explore Metrics: Add panel menu with "Explore" and "Investigations" options (#98900) * Explore Metrics: Add panel menu with "Explore" and "Investigations" features * Explore Metrics: Get better explore link * hide menu in metricSelect * Update public/app/features/trails/Breakdown/LabelBreakdownScene.tsx Co-authored-by: Nick Richmond <5732000+NWRichmond@users.noreply.github.com> * Update public/app/features/trails/MetricSelect/MetricSelectScene.tsx Co-authored-by: Nick Richmond <5732000+NWRichmond@users.noreply.github.com> * fix: formatting --------- Co-authored-by: Nick Richmond <5732000+NWRichmond@users.noreply.github.com> Co-authored-by: Nick Richmond --- .../trails/Breakdown/LabelBreakdownScene.tsx | 16 +- public/app/features/trails/Menu/PanelMenu.tsx | 177 ++++++++++++++++++ .../trails/MetricSelect/MetricSelectScene.tsx | 8 +- .../trails/MetricSelect/previewPanel.ts | 21 ++- .../autoQuery/components/AutoVizPanel.tsx | 11 +- 5 files changed, 204 insertions(+), 29 deletions(-) create mode 100644 public/app/features/trails/Menu/PanelMenu.tsx diff --git a/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx b/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx index 8b9e014aa57..adc86af8f1c 100644 --- a/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx +++ b/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx @@ -32,8 +32,8 @@ import { Trans } from 'app/core/internationalization'; import { BreakdownLabelSelector } from '../BreakdownLabelSelector'; import { DataTrail } from '../DataTrail'; +import { PanelMenu } from '../Menu/PanelMenu'; import { MetricScene } from '../MetricScene'; -import { AddToExplorationButton } from '../MetricSelect/AddToExplorationsButton'; import { StatusWrapper } from '../StatusWrapper'; import { getAutoQueriesForMetric } from '../autoQuery/getAutoQueriesForMetric'; import { AutoQueryDef } from '../autoQuery/types'; @@ -478,10 +478,9 @@ export function buildAllLayout( ], }) ) - .setHeaderActions([ - new SelectLabelAction({ labelName: String(option.value) }), - new AddToExplorationButton({ labelName: String(option.value) }), - ]) + .setHeaderActions([new SelectLabelAction({ labelName: String(option.value) })]) + .setShowMenuAlways(true) + .setMenu(new PanelMenu({ labelName: String(option.value) })) .setUnit(unit) .setBehaviors([fixLegendForUnspecifiedLabelValueBehavior]) .build(); @@ -532,10 +531,9 @@ function buildNormalLayout( .setTitle(getLabelValue(frame)) .setData(new SceneDataNode({ data: { ...data, series: [frame] } })) .setColor({ mode: 'fixed', fixedColor: getColorByIndex(frameIndex) }) - .setHeaderActions([ - new AddToFiltersGraphAction({ frame }), - new AddToExplorationButton({ labelName: getLabelValue(frame) }), - ]) + .setHeaderActions([new AddToFiltersGraphAction({ frame })]) + .setShowMenuAlways(true) + .setMenu(new PanelMenu({ labelName: getLabelValue(frame) })) .setUnit(unit) .build(); diff --git a/public/app/features/trails/Menu/PanelMenu.tsx b/public/app/features/trails/Menu/PanelMenu.tsx new file mode 100644 index 00000000000..2d100a8b36b --- /dev/null +++ b/public/app/features/trails/Menu/PanelMenu.tsx @@ -0,0 +1,177 @@ +import { DataFrame, PanelMenuItem } from '@grafana/data'; +import { getPluginLinkExtensions } from '@grafana/runtime'; +import { + SceneComponentProps, + sceneGraph, + SceneObject, + SceneObjectBase, + SceneObjectState, + VizPanel, + VizPanelMenu, +} from '@grafana/scenes'; +import { getExploreUrl } from 'app/core/utils/explore'; +import { getQueryRunnerFor } from 'app/features/dashboard-scene/utils/utils'; + +import { AddToExplorationButton, extensionPointId } from '../MetricSelect/AddToExplorationsButton'; +import { getDataSource, getTrailFor } from '../utils'; + +const ADD_TO_INVESTIGATION_MENU_TEXT = 'Add to investigation'; +const ADD_TO_INVESTIGATION_MENU_DIVIDER_TEXT = 'investigations_divider'; // Text won't be visible +const ADD_TO_INVESTIGATION_MENU_GROUP_TEXT = 'Investigations'; + +interface PanelMenuState extends SceneObjectState { + body?: VizPanelMenu; + frame?: DataFrame; + labelName?: string; + fieldName?: string; + addExplorationsLink?: boolean; + explorationsButton?: AddToExplorationButton; +} + +/** + * @todo the VizPanelMenu interface is overly restrictive, doesn't allow any member functions on this class, so everything is currently inlined + */ +export class PanelMenu extends SceneObjectBase 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(); }