From d0e3e91b03e4cbd361558d4b7b93c10278807406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Philippe=20Qu=C3=A9m=C3=A9ner?= Date: Mon, 29 Sep 2025 23:12:34 +0200 Subject: [PATCH] fix(folders): use namespace_uid for alert count (#111773) --- pkg/storage/unified/federated/stats.go | 2 +- pkg/storage/unified/federated/stats_test.go | 5 +- pkg/tests/apis/folder/folders_test.go | 89 +++++++++++++++++++++ 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/pkg/storage/unified/federated/stats.go b/pkg/storage/unified/federated/stats.go index 40721caa1d0..ce2d6199b2b 100644 --- a/pkg/storage/unified/federated/stats.go +++ b/pkg/storage/unified/federated/stats.go @@ -60,7 +60,7 @@ func (s *LegacyStatsGetter) GetStats(ctx context.Context, in *resourcepb.Resourc group := "sql-fallback" // Legacy alert rule table - err = fn("alert_rule", "org_id=? AND dashboard_uid=?", group, "alertrules", false) + err = fn("alert_rule", "org_id=? AND namespace_uid=?", group, "alertrules", false) if err != nil { return err } diff --git a/pkg/storage/unified/federated/stats_test.go b/pkg/storage/unified/federated/stats_test.go index 3fa1c56a55e..89d50d1da87 100644 --- a/pkg/storage/unified/federated/stats_test.go +++ b/pkg/storage/unified/federated/stats_test.go @@ -78,9 +78,10 @@ func TestIntegrationDirectSQLStats(t *testing.T) { require.NoError(t, err) ruleStore := ngalertstore.SetupStoreForTesting(t, db) + dashboardUID := "test" _, err = ruleStore.InsertAlertRules(context.Background(), ngmodels.NewUserUID(tempUser), []ngmodels.AlertRule{ { - DashboardUID: &folder2UID, + DashboardUID: &dashboardUID, UID: "test", Title: "test", OrgID: 1, @@ -97,7 +98,7 @@ func TestIntegrationDirectSQLStats(t *testing.T) { }, Condition: "ok", Updated: now, - NamespaceUID: "test", + NamespaceUID: folder2UID, ExecErrState: ngmodels.ExecutionErrorState(ngmodels.Alerting), NoDataState: ngmodels.Alerting, IntervalSeconds: 60, diff --git a/pkg/tests/apis/folder/folders_test.go b/pkg/tests/apis/folder/folders_test.go index d7f64aea418..cddec709514 100644 --- a/pkg/tests/apis/folder/folders_test.go +++ b/pkg/tests/apis/folder/folders_test.go @@ -8,6 +8,7 @@ import ( "net/http" "slices" "testing" + "time" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/api/meta" @@ -15,6 +16,10 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" + "github.com/grafana/grafana/pkg/expr" + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/prometheus/common/model" + folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" "github.com/grafana/grafana/pkg/api/dtos" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" @@ -26,6 +31,7 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + alerting "github.com/grafana/grafana/pkg/tests/api/alerting" "github.com/grafana/grafana/pkg/tests/apis" "github.com/grafana/grafana/pkg/tests/testinfra" "github.com/grafana/grafana/pkg/tests/testsuite" @@ -223,6 +229,89 @@ func TestIntegrationFoldersApp(t *testing.T) { }) } +// Validates that folder delete checks alert_rule stats and blocks deletion +func TestIntegrationFolderDeletionBlockedByAlertRules(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + if !db.IsTestDbSQLite() { + t.Skip("test only on sqlite for now") + } + + t.Run("should be blocked by alert rules", func(t *testing.T) { + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: true, + DisableAnonymous: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + folders.RESOURCEGROUP: {DualWriterMode: grafanarest.Mode5}, + }, + EnableFeatureToggles: []string{ + featuremgmt.FlagUnifiedStorageSearch, + }, + }) + + client := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + GVR: gvr, + }) + + // Create a folder via legacy API so it is visible everywhere. + folderUID := "alertrule-del-test" + legacyPayload := fmt.Sprintf(`{"title": "Folder With Alert Rule", "uid": "%s"}`, folderUID) + legacyCreate := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodPost, + Path: "/api/folders", + Body: []byte(legacyPayload), + }, &folder.Folder{}) + require.NotNil(t, legacyCreate.Result) + require.Equal(t, folderUID, legacyCreate.Result.UID) + + // Create one alert rule in that folder namespace via ruler API. + addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String() + api := alerting.NewAlertingLegacyAPIClient(addr, "admin", "admin") + + // simple always-true rule + forDuration := model.Duration(10 * time.Second) + rule := apimodels.PostableExtendedRuleNode{ + ApiRuleNode: &apimodels.ApiRuleNode{For: &forDuration}, + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: "rule-in-folder", + Condition: "A", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + DatasourceUID: expr.DatasourceUID, + RelativeTimeRange: apimodels.RelativeTimeRange{ + From: apimodels.Duration(600 * time.Second), + To: 0, + }, + Model: json.RawMessage(`{"type":"math","expression":"2 + 3 > 1"}`), + }, + }, + }, + } + group := apimodels.PostableRuleGroupConfig{ + Name: "arulegroup", + Interval: model.Duration(10 * time.Second), + Rules: []apimodels.PostableExtendedRuleNode{rule}, + } + _ = api.PostRulesGroup(t, folderUID, &group, false) + + // Attempt to delete the folder via K8s API. This should be blocked by alert rules. + err := client.Resource.Delete(context.Background(), folderUID, metav1.DeleteOptions{}) + require.Error(t, err, "expected folder deletion to be blocked when alert rules exist") + + // Delete the rule group from ruler. + status, body := api.DeleteRulesGroup(t, folderUID, group.Name, true) + require.Equalf(t, http.StatusAccepted, status, body) + + // Now we should be able to delete the folder. + err = client.Resource.Delete(context.Background(), folderUID, metav1.DeleteOptions{}) + require.NoError(t, err) + }) +} + func doFolderTests(t *testing.T, helper *apis.K8sTestHelper) *apis.K8sTestHelper { t.Run("Check folder CRUD (just create for now) in legacy API appears in k8s apis", func(t *testing.T) { client := helper.GetResourceClient(apis.ResourceClientArgs{