Merge branch 'main' into kristina/rtk-corr

This commit is contained in:
Kristina Durivage
2025-11-13 14:06:12 -06:00
246 changed files with 13660 additions and 1551 deletions
+6 -20
View File
@@ -7,7 +7,6 @@ import (
"fmt"
"net/http"
"net/url"
"path"
"regexp"
"strings"
@@ -41,8 +40,10 @@ var getViewIndex = func() string {
return viewIndex
}
// Only allow redirects that start with a slash followed by an alphanumerical character, a dash or an underscore.
var redirectRe = regexp.MustCompile(`^/[a-zA-Z0-9-_].*`)
var redirectAllowRe = regexp.MustCompile(`^/[a-zA-Z0-9-_./]*$`)
// Do not allow redirect URLs that contain "//" or ".."
var redirectDenyRe = regexp.MustCompile(`(//|\.\.)`)
var (
errAbsoluteRedirectTo = errors.New("absolute URLs are not allowed for redirect_to cookie value")
@@ -64,26 +65,11 @@ func (hs *HTTPServer) ValidateRedirectTo(redirectTo string) error {
return errForbiddenRedirectTo
}
// path should have exactly one leading slash
if !strings.HasPrefix(to.Path, "/") {
if redirectDenyRe.MatchString(to.Path) {
return errForbiddenRedirectTo
}
if strings.HasPrefix(to.Path, "//") {
return errForbiddenRedirectTo
}
if to.Path != "/" && !redirectRe.MatchString(to.Path) {
return errForbiddenRedirectTo
}
cleanPath := path.Clean(to.Path)
// "." is what path.Clean returns for empty paths
if cleanPath == "." {
return errForbiddenRedirectTo
}
if cleanPath != "/" && !redirectRe.MatchString(cleanPath) {
if to.Path != "/" && !redirectAllowRe.MatchString(to.Path) {
return errForbiddenRedirectTo
}
+7 -6
View File
@@ -3,7 +3,6 @@ package middleware
import (
"fmt"
"net/http"
"path"
"regexp"
"strconv"
@@ -13,8 +12,10 @@ import (
"github.com/grafana/grafana/pkg/web"
)
// Only allow redirects that start with a slash followed by an alphanumerical character, a dash or an underscore.
var redirectRe = regexp.MustCompile(`^/?[a-zA-Z0-9-_].*`)
var redirectAllowRe = regexp.MustCompile(`^/?[a-zA-Z0-9-_./]*$`)
// Do not allow redirect URLs that contain "//" or ".."
var redirectDenyRe = regexp.MustCompile(`(//|\.\.)`)
// OrgRedirect changes org and redirects users if the
// querystring `orgId` doesn't match the active org.
@@ -66,9 +67,9 @@ func OrgRedirect(cfg *setting.Cfg, userSvc user.Service) web.Handler {
}
func validRedirectPath(p string) bool {
if p != "" && p != "/" && !redirectRe.MatchString(p) {
if redirectDenyRe.MatchString(p) {
return false
}
cleanPath := path.Clean(p)
return cleanPath == "." || cleanPath == "/" || redirectRe.MatchString(cleanPath)
return p == "" || p == "/" || redirectAllowRe.MatchString(p)
}
+10
View File
@@ -32,6 +32,16 @@ func ValidateHostHeader(cfg *setting.Cfg) web.Handler {
}
if !strings.EqualFold(h, cfg.Domain) {
// the normal redirecting logic doesn't work when running as frontend service, since it has no knowledge of the custom domain.
// instead, we modify the single tenant `/bootdata` call with a 204 response and a header indicating the domain to redirect to
// this is safe because only the frontend service calls `/bootdata`
// the redirect is then handled client side.
// see pkg/services/frontend/index.html
if c.Req.URL.Path == "/bootdata" {
c.Resp.Header().Set("Redirect-Domain", cfg.Domain)
c.Resp.WriteHeader(204)
return
}
hostRedirectCounter.Inc()
c.Logger.Info("Enforcing Host header", "hosted", c.Req.Host, "expected", cfg.Domain)
c.Redirect(strings.TrimSuffix(cfg.AppURL, "/")+c.Req.RequestURI, 301)
-1
View File
@@ -28,7 +28,6 @@ type PluginManagementCfg struct {
// Features contains the feature toggles used for the plugin management system.
type Features struct {
SkipHostEnvVarsEnabled bool
SriChecksEnabled bool
LocalizationForPlugins bool
// Needed only until Tempo Alerting / metrics TraceQL is stable
+38 -33
View File
@@ -122,12 +122,13 @@ func TestLoader_Load(t *testing.T) {
Backend: true,
QueryOptions: map[string]bool{"minInterval": true},
},
Module: "core:plugin/cloudwatch",
BaseURL: "public/plugins/cloudwatch",
FS: mustNewStaticFSForTests(t, filepath.Join(corePluginDir, "app/plugins/datasource/cloudwatch")),
Signature: plugins.SignatureStatusInternal,
Class: plugins.ClassCore,
Translations: map[string]string{},
Module: "core:plugin/cloudwatch",
BaseURL: "public/plugins/cloudwatch",
FS: mustNewStaticFSForTests(t, filepath.Join(corePluginDir, "app/plugins/datasource/cloudwatch")),
Signature: plugins.SignatureStatusInternal,
Class: plugins.ClassCore,
SkipHostEnvVars: true,
Translations: map[string]string{},
},
},
},
@@ -215,14 +216,15 @@ func TestLoader_Load(t *testing.T) {
ExtensionPoints: []plugins.ExtensionPoint{},
},
},
Class: plugins.ClassExternal,
Module: "public/plugins/test-app/module.js",
BaseURL: "public/plugins/test-app",
FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/includes-symlinks")),
Signature: "valid",
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
Translations: map[string]string{},
Class: plugins.ClassExternal,
Module: "public/plugins/test-app/module.js",
BaseURL: "public/plugins/test-app",
FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/includes-symlinks")),
Signature: "valid",
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
SkipHostEnvVars: true,
Translations: map[string]string{},
},
},
},
@@ -268,12 +270,13 @@ func TestLoader_Load(t *testing.T) {
Backend: true,
State: plugins.ReleaseStateAlpha,
},
Class: plugins.ClassExternal,
Module: "public/plugins/test-datasource/module.js",
BaseURL: "public/plugins/test-datasource",
FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/unsigned-datasource/plugin")),
Signature: "unsigned",
Translations: map[string]string{},
Class: plugins.ClassExternal,
Module: "public/plugins/test-datasource/module.js",
BaseURL: "public/plugins/test-datasource",
FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/unsigned-datasource/plugin")),
Signature: "unsigned",
SkipHostEnvVars: true,
Translations: map[string]string{},
},
},
},
@@ -326,12 +329,13 @@ func TestLoader_Load(t *testing.T) {
Backend: true,
State: plugins.ReleaseStateAlpha,
},
Class: plugins.ClassExternal,
Module: "public/plugins/test-datasource/module.js",
BaseURL: "public/plugins/test-datasource",
FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/unsigned-datasource/plugin")),
Signature: plugins.SignatureStatusUnsigned,
Translations: map[string]string{},
Class: plugins.ClassExternal,
Module: "public/plugins/test-datasource/module.js",
BaseURL: "public/plugins/test-datasource",
FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/unsigned-datasource/plugin")),
Signature: plugins.SignatureStatusUnsigned,
SkipHostEnvVars: true,
Translations: map[string]string{},
},
},
},
@@ -422,13 +426,14 @@ func TestLoader_Load(t *testing.T) {
},
Backend: false,
},
DefaultNavURL: "/plugins/test-app/page/root-page-react",
FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/test-app-with-includes")),
Class: plugins.ClassExternal,
Signature: plugins.SignatureStatusUnsigned,
Module: "public/plugins/test-app/module.js",
BaseURL: "public/plugins/test-app",
Translations: map[string]string{},
DefaultNavURL: "/plugins/test-app/page/root-page-react",
FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/test-app-with-includes")),
Class: plugins.ClassExternal,
Signature: plugins.SignatureStatusUnsigned,
Module: "public/plugins/test-app/module.js",
BaseURL: "public/plugins/test-app",
SkipHostEnvVars: true,
Translations: map[string]string{},
},
},
},
@@ -137,11 +137,10 @@ func configureAppChildPlugin(parent *plugins.Plugin, child *plugins.Plugin) {
}
// SkipHostEnvVarsDecorateFunc returns a DecorateFunc that configures the SkipHostEnvVars field of the plugin.
// It will be set to true if the FlagPluginsSkipHostEnvVars feature flag is set, and the plugin is not present in the
// ForwardHostEnvVars plugin ids list.
// It will be set to true if the plugin is not present in the ForwardHostEnvVars plugin ids list.
func SkipHostEnvVarsDecorateFunc(cfg *config.PluginManagementCfg) DecorateFunc {
return func(_ context.Context, p *plugins.Plugin) (*plugins.Plugin, error) {
p.SkipHostEnvVars = cfg.Features.SkipHostEnvVarsEnabled && !slices.Contains(cfg.ForwardHostEnvVars, p.ID)
p.SkipHostEnvVars = !slices.Contains(cfg.ForwardHostEnvVars, p.ID)
return p, nil
}
}
@@ -144,65 +144,39 @@ func Test_configureAppChildPlugin(t *testing.T) {
func TestSkipEnvVarsDecorateFunc(t *testing.T) {
const pluginID = "plugin-id"
t.Run("config field is false", func(t *testing.T) {
f := SkipHostEnvVarsDecorateFunc(&config.PluginManagementCfg{
Features: config.Features{SkipHostEnvVarsEnabled: false},
})
p, err := f(context.Background(), &plugins.Plugin{JSONData: plugins.JSONData{ID: pluginID}})
require.NoError(t, err)
require.False(t, p.SkipHostEnvVars)
})
t.Run("config field is true", func(t *testing.T) {
t.Run("no plugin settings should set SkipHostEnvVars to true", func(t *testing.T) {
for _, tc := range []struct {
name string
forwardHostEnvVars []string
expSkipHostEnvVars bool
}{
{
name: "plugin id not present in forwardHostEnvVars should set SkipHostEnvVars to true (empty)",
forwardHostEnvVars: []string{},
expSkipHostEnvVars: true,
},
{
name: "plugin id not present in forwardHostEnvVars should set SkipHostEnvVars to true (other id)",
forwardHostEnvVars: []string{"other-id", "yet-another-id"},
expSkipHostEnvVars: true,
},
{
name: "plugin id in forwardHostEnvVars should set SkipHostEnvVars to false (only)",
forwardHostEnvVars: []string{pluginID},
expSkipHostEnvVars: false,
},
{
name: "plugin id in forwardHostEnvVars should set SkipHostEnvVars to false (with other)",
forwardHostEnvVars: []string{"a-plugin", pluginID, "other-id"},
expSkipHostEnvVars: false,
},
} {
t.Run(tc.name, func(t *testing.T) {
f := SkipHostEnvVarsDecorateFunc(&config.PluginManagementCfg{
Features: config.Features{SkipHostEnvVarsEnabled: true},
ForwardHostEnvVars: tc.forwardHostEnvVars,
})
p, err := f(context.Background(), &plugins.Plugin{JSONData: plugins.JSONData{ID: pluginID}})
require.NoError(t, err)
require.True(t, p.SkipHostEnvVars)
require.Equal(t, tc.expSkipHostEnvVars, p.SkipHostEnvVars)
})
t.Run("plugin setting", func(t *testing.T) {
for _, tc := range []struct {
name string
forwardHostEnvVars []string
expSkipHostEnvVars bool
}{
{
name: "plugin id not present in forwardHostEnvVars should set SkipHostEnvVars to true (empty)",
forwardHostEnvVars: []string{},
expSkipHostEnvVars: true,
},
{
name: "plugin id not present in forwardHostEnvVars should set SkipHostEnvVars to true (other id)",
forwardHostEnvVars: []string{"other-id", "yet-another-id"},
expSkipHostEnvVars: true,
},
{
name: "plugin id in forwardHostEnvVars should set SkipHostEnvVars to false (only)",
forwardHostEnvVars: []string{pluginID},
expSkipHostEnvVars: false,
},
{
name: "plugin id in forwardHostEnvVars should set SkipHostEnvVars to false (with other)",
forwardHostEnvVars: []string{"a-plugin", pluginID, "other-id"},
expSkipHostEnvVars: false,
},
} {
t.Run(tc.name, func(t *testing.T) {
f := SkipHostEnvVarsDecorateFunc(&config.PluginManagementCfg{
Features: config.Features{
SkipHostEnvVarsEnabled: true,
},
ForwardHostEnvVars: tc.forwardHostEnvVars,
})
p, err := f(context.Background(), &plugins.Plugin{JSONData: plugins.JSONData{ID: pluginID}})
require.NoError(t, err)
require.Equal(t, tc.expSkipHostEnvVars, p.SkipHostEnvVars)
})
}
})
})
}
}
-4
View File
@@ -64,10 +64,6 @@ func NewAPIBuilder(providerType string, url *url.URL, insecure bool, caFile stri
}
func RegisterAPIService(apiregistration builder.APIRegistrar, cfg *setting.Cfg) (*APIBuilder, error) {
if !cfg.OpenFeature.APIEnabled {
return nil, nil
}
var staticEvaluator featuremgmt.StaticFlagEvaluator // No static evaluator needed for non-static provider
var err error
if cfg.OpenFeature.ProviderType == setting.StaticProviderType {
@@ -55,7 +55,10 @@ type RepositoryController struct {
logger logging.Logger
dualwrite dualwrite.Service
jobs jobs.Queue
jobs interface {
jobs.Queue
jobs.Store
}
finalizer finalizerProcessor
statusPatcher StatusPatcher
@@ -79,7 +82,10 @@ func NewRepositoryController(
repoFactory repository.Factory,
resourceLister resources.ResourceLister,
clients resources.ClientFactory,
jobs jobs.Queue,
jobs interface {
jobs.Queue
jobs.Store
},
dualwrite dualwrite.Service,
healthChecker *HealthChecker,
statusPatcher StatusPatcher,
@@ -273,7 +279,7 @@ func (rc *RepositoryController) updateDeleteStatus(ctx context.Context, obj *pro
})
}
func (rc *RepositoryController) shouldResync(obj *provisioning.Repository) bool {
func (rc *RepositoryController) shouldResync(ctx context.Context, obj *provisioning.Repository) bool {
// don't trigger resync if a sync was never started
if obj.Status.Sync.Finished == 0 && obj.Status.Sync.State == "" {
return false
@@ -283,6 +289,30 @@ func (rc *RepositoryController) shouldResync(obj *provisioning.Repository) bool
syncInterval := time.Duration(obj.Spec.Sync.IntervalSeconds) * time.Second
tolerance := time.Second
// Check for stale sync status - if sync status indicates a job is running but the job no longer exists
// Only check if Finished is set (meaning a sync has completed before) to avoid interfering with initial syncs
// Only trigger resync if sync is enabled and sync interval has elapsed (to avoid unnecessary operations)
if obj.Status.Sync.Finished > 0 &&
obj.Spec.Sync.Enabled &&
(obj.Status.Sync.State == provisioning.JobStatePending || obj.Status.Sync.State == provisioning.JobStateWorking) &&
obj.Status.Sync.JobID != "" {
_, err := rc.jobs.Get(ctx, obj.Namespace, obj.Status.Sync.JobID)
if apierrors.IsNotFound(err) {
// Job was cleaned up but sync status wasn't updated - trigger resync to reconcile
// Only trigger if sync interval has elapsed to avoid unnecessary operations
if syncAge >= (syncInterval - tolerance) {
logger := logging.FromContext(ctx)
logger.Info("detected stale sync status", "job_id", obj.Status.Sync.JobID)
return true
}
}
// For other errors, log but continue with normal logic
if err != nil {
logger := logging.FromContext(ctx)
logger.Warn("failed to check job existence for stale sync status", "error", err, "job_id", obj.Status.Sync.JobID)
}
}
// HACK: how would this work in a multi-tenant world or under heavy load?
// It will start queueing up jobs and we will have to deal with that
pendingForTooLong := syncAge >= syncInterval/2 && obj.Status.Sync.State == provisioning.JobStatePending
@@ -490,7 +520,7 @@ func (rc *RepositoryController) process(item *queueItem) error {
return rc.handleDelete(ctx, obj)
}
shouldResync := rc.shouldResync(obj)
shouldResync := rc.shouldResync(ctx, obj)
shouldCheckHealth := rc.healthChecker.ShouldCheckHealth(obj)
hasSpecChanged := obj.Generation != obj.Status.ObservedGeneration
patchOperations := []map[string]interface{}{}
@@ -3,15 +3,20 @@ package controller
import (
"context"
"testing"
"time"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/controller/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/rest"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/controller/mocks"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
provisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1"
client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1"
@@ -338,3 +343,204 @@ func TestShouldUseIncrementalSync(t *testing.T) {
assert.False(t, got)
})
}
// mockJobsQueueStore implements both jobs.Queue and jobs.Store for testing
type mockJobsQueueStore struct {
*jobs.MockQueue
*jobs.MockStore
}
func TestRepositoryController_shouldResync_StaleSyncStatus(t *testing.T) {
testCases := []struct {
name string
repo *provisioning.Repository
jobGetError error
expectedResync bool
description string
}{
{
name: "stale sync status with Pending state - job not found",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Sync: provisioning.SyncOptions{
Enabled: true,
IntervalSeconds: 300,
},
},
Status: provisioning.RepositoryStatus{
Sync: provisioning.SyncStatus{
State: provisioning.JobStatePending,
JobID: "test-job-123",
Started: time.Now().Add(-10 * time.Minute).UnixMilli(),
Finished: time.Now().Add(-10 * time.Minute).UnixMilli(),
},
},
},
jobGetError: apierrors.NewNotFound(schema.GroupResource{Resource: "jobs"}, "test-job-123"),
expectedResync: true,
description: "should return true to trigger resync when job is not found",
},
{
name: "stale sync status with Working state - job not found",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Sync: provisioning.SyncOptions{
Enabled: true,
IntervalSeconds: 300,
},
},
Status: provisioning.RepositoryStatus{
Sync: provisioning.SyncStatus{
State: provisioning.JobStateWorking,
JobID: "test-job-456",
Started: time.Now().Add(-5 * time.Minute).UnixMilli(),
Finished: time.Now().Add(-5 * time.Minute).UnixMilli(),
},
},
},
jobGetError: apierrors.NewNotFound(schema.GroupResource{Resource: "jobs"}, "test-job-456"),
expectedResync: true,
description: "should return true to trigger resync when working job is not found",
},
{
name: "non-stale sync status - job exists",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Sync: provisioning.SyncOptions{
Enabled: true,
IntervalSeconds: 300,
},
},
Status: provisioning.RepositoryStatus{
Sync: provisioning.SyncStatus{
State: provisioning.JobStatePending,
JobID: "test-job-789",
Started: time.Now().Add(-2 * time.Minute).UnixMilli(),
Finished: time.Now().Add(-2 * time.Minute).UnixMilli(),
},
},
},
jobGetError: nil, // Job exists
expectedResync: false, // Should continue with normal logic
description: "should continue with normal logic when job exists",
},
{
name: "non-stale sync status - no JobID",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Sync: provisioning.SyncOptions{
Enabled: true,
IntervalSeconds: 300,
},
},
Status: provisioning.RepositoryStatus{
Sync: provisioning.SyncStatus{
State: provisioning.JobStatePending,
JobID: "",
Started: time.Now().Add(-2 * time.Minute).UnixMilli(),
Finished: time.Now().Add(-2 * time.Minute).UnixMilli(),
},
},
},
jobGetError: nil,
expectedResync: false,
description: "should not check when JobID is empty",
},
{
name: "non-stale sync status - already finished",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Sync: provisioning.SyncOptions{
Enabled: true,
IntervalSeconds: 300,
},
},
Status: provisioning.RepositoryStatus{
Sync: provisioning.SyncStatus{
State: provisioning.JobStateSuccess,
JobID: "test-job-999",
Finished: time.Now().Add(-1 * time.Minute).UnixMilli(),
},
},
},
jobGetError: nil,
expectedResync: false,
description: "should not check when sync status is already finished",
},
{
name: "stale sync status - job lookup error (non-NotFound)",
repo: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Sync: provisioning.SyncOptions{
Enabled: true,
IntervalSeconds: 300,
},
},
Status: provisioning.RepositoryStatus{
Sync: provisioning.SyncStatus{
State: provisioning.JobStatePending,
JobID: "test-job-error",
Started: time.Now().Add(-2 * time.Minute).UnixMilli(),
Finished: time.Now().Add(-2 * time.Minute).UnixMilli(),
},
},
},
jobGetError: assert.AnError, // Non-NotFound error
expectedResync: false, // Should continue with normal logic
description: "should handle non-NotFound errors gracefully and continue with normal logic",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create mocks
mockQueue := jobs.NewMockQueue(t)
mockStore := jobs.NewMockStore(t)
mockJobs := &mockJobsQueueStore{
MockQueue: mockQueue,
MockStore: mockStore,
}
// Set up job Get mock
if tc.repo.Status.Sync.JobID != "" && (tc.repo.Status.Sync.State == provisioning.JobStatePending || tc.repo.Status.Sync.State == provisioning.JobStateWorking) {
mockStore.On("Get", mock.Anything, tc.repo.Namespace, tc.repo.Status.Sync.JobID).Return(nil, tc.jobGetError).Once()
}
// Create controller
rc := &RepositoryController{
jobs: mockJobs,
}
// Test shouldResync
ctx := context.Background()
result := rc.shouldResync(ctx, tc.repo)
// Verify
assert.Equal(t, tc.expectedResync, result, tc.description)
})
}
}
@@ -14,7 +14,6 @@ import (
type ConcurrentJobDriver struct {
numDrivers int
jobTimeout time.Duration
cleanupInterval time.Duration
jobInterval time.Duration
leaseRenewalInterval time.Duration
store Store
@@ -27,7 +26,7 @@ type ConcurrentJobDriver struct {
// NewConcurrentJobDriver creates a new concurrent job driver that spawns multiple job drivers.
func NewConcurrentJobDriver(
numDrivers int,
jobTimeout, cleanupInterval, jobInterval, leaseRenewalInterval time.Duration,
jobTimeout, jobInterval, leaseRenewalInterval time.Duration,
store Store,
repoGetter RepoGetter,
historicJobs HistoryWriter,
@@ -45,24 +44,12 @@ func NewConcurrentJobDriver(
if leaseRenewalInterval < 5*time.Second {
leaseRenewalInterval = 5 * time.Second
}
// For lease-based cleanup, run at most every 3-4 lease renewal intervals
// to detect expired leases promptly but not too aggressively
if cleanupInterval <= 0 {
cleanupInterval = leaseRenewalInterval * 3
}
if cleanupInterval < 30*time.Second {
cleanupInterval = 30 * time.Second // Minimum cleanup interval
}
if cleanupInterval > 5*time.Minute {
cleanupInterval = 5 * time.Minute // Maximum cleanup interval
}
recordConcurrentDriverMetric(registry, numDrivers)
return &ConcurrentJobDriver{
numDrivers: numDrivers,
jobTimeout: jobTimeout,
cleanupInterval: cleanupInterval,
jobInterval: jobInterval,
leaseRenewalInterval: leaseRenewalInterval,
store: store,
@@ -73,43 +60,17 @@ func NewConcurrentJobDriver(
}, nil
}
// Run starts multiple job drivers concurrently and handles cleanup coordination.
// Run starts multiple job drivers concurrently.
// This is a blocking function that will run until the context is canceled or an error occurs.
//
// Note: This function intentionally does NOT create a tracing span because it runs indefinitely
// until shutdown. Individual job processing and cleanup operations already have their own spans.
// until shutdown. Individual job processing operations already have their own spans.
func (c *ConcurrentJobDriver) Run(ctx context.Context) error {
logger := logging.FromContext(ctx).With("logger", "concurrent-job-driver", "num_drivers", c.numDrivers)
logger.Info("start concurrent job driver", "num_drivers", c.numDrivers, "cleanup_interval", c.cleanupInterval)
// Set up cleanup ticker - runs more frequently with lease-based approach
cleanupTicker := time.NewTicker(c.cleanupInterval)
defer cleanupTicker.Stop()
// Initial cleanup
if err := c.store.Cleanup(ctx); err != nil {
logger.Error("failed initial cleanup", "error", err)
}
logger.Info("start concurrent job driver", "num_drivers", c.numDrivers)
var wg sync.WaitGroup
errChan := make(chan error, c.numDrivers+1) // +1 for cleanup goroutine
// Start cleanup goroutine
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-cleanupTicker.C:
if err := c.store.Cleanup(ctx); err != nil {
logger.Error("failed cleanup", "error", err)
}
case <-ctx.Done():
logger.Debug("cleanup routine stopped")
return
}
}
}()
errChan := make(chan error, c.numDrivers)
// Start driver goroutines
for i := 0; i < c.numDrivers; i++ {
@@ -31,14 +31,10 @@ type Store interface {
// The err may be ErrNoJobs if there are no jobs to claim.
Claim(ctx context.Context) (job *provisioning.Job, rollback func(), err error)
// Complete marks a job as completed and moves it to the historic job store.
// When in the historic store, there is no more claim on the job.
// Complete marks a job as completed and removes it from the active job store.
// Callers are responsible for writing the job to history after calling this.
Complete(ctx context.Context, job *provisioning.Job) error
// Cleanup should be called periodically to clean up abandoned jobs.
// An abandoned job is one that has been claimed by a worker, but the worker has not updated the job in a while.
Cleanup(ctx context.Context) error
// Update saves the job back to the store.
Update(ctx context.Context, job *provisioning.Job) (*provisioning.Job, error)
@@ -48,6 +44,10 @@ type Store interface {
// Get retrieves a job by name for conflict resolution.
Get(ctx context.Context, namespace, name string) (*provisioning.Job, error)
// ListExpiredJobs lists jobs with expired leases (claim timestamp older than the given time).
// Returns jobs in batches up to the specified limit.
ListExpiredJobs(ctx context.Context, expiredBefore time.Time, limit int) ([]*provisioning.Job, error)
}
// jobDriver drives jobs to completion and manages the job queue.
@@ -0,0 +1,177 @@
package jobs
import (
"context"
"time"
"go.opentelemetry.io/otel/attribute"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/grafana/apps/provisioning/pkg/apifmt"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/tracing"
)
// JobCleanupController handles cleanup of expired/abandoned jobs.
type JobCleanupController struct {
store Store
historicJobs HistoryWriter
clock func() time.Time
expiry time.Duration
cleanupInterval time.Duration
}
// NewJobCleanupController creates a new job cleanup controller.
func NewJobCleanupController(
store Store,
historicJobs HistoryWriter,
expiry time.Duration,
) *JobCleanupController {
// Calculate cleanup interval based on expiry duration
// Run cleanup every 3-4 expiry intervals to detect expired leases promptly but not too aggressively
cleanupInterval := expiry * 3
// Enforce minimum and maximum bounds
if cleanupInterval < 30*time.Second {
cleanupInterval = 30 * time.Second
}
if cleanupInterval > 5*time.Minute {
cleanupInterval = 5 * time.Minute
}
return &JobCleanupController{
store: store,
historicJobs: historicJobs,
clock: time.Now,
expiry: expiry,
cleanupInterval: cleanupInterval,
}
}
// Run starts the cleanup loop that runs at an appropriate interval.
// This is a blocking function that runs until the context is canceled.
func (c *JobCleanupController) Run(ctx context.Context) error {
logger := logging.FromContext(ctx).With("logger", "job-cleanup-controller")
ctx = logging.Context(ctx, logger)
// Set up provisioning identity to access jobs across all namespaces
ctx, _, err := identity.WithProvisioningIdentity(ctx, "*")
if err != nil {
return apifmt.Errorf("failed to grant provisioning identity for cleanup: %w", err)
}
logger.Info("starting job cleanup controller", "cleanup_interval", c.cleanupInterval, "expiry", c.expiry)
// Initial cleanup
if err := c.Cleanup(ctx); err != nil {
logger.Error("failed to clean up jobs at start", "error", err)
}
ticker := time.NewTicker(c.cleanupInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := c.Cleanup(ctx); err != nil {
logger.Error("failed to cleanup jobs", "error", err)
}
case <-ctx.Done():
logger.Info("job cleanup controller stopping")
return ctx.Err()
}
}
}
// Cleanup finds jobs with expired leases and marks them as failed.
// This should be called periodically to clean up jobs from crashed workers.
func (c *JobCleanupController) Cleanup(ctx context.Context) error {
ctx, span := tracing.Start(ctx, "provisioning.jobs.cleanup")
defer span.End()
startTime := c.clock()
logger := logging.FromContext(ctx)
// Find jobs with expired leases
expiredBefore := c.clock().Add(-c.expiry)
// Process in batches of 100 to avoid overwhelming the system
const batchSize = 100
jobs, err := c.store.ListExpiredJobs(ctx, expiredBefore, batchSize)
if err != nil {
span.RecordError(err)
return apifmt.Errorf("failed to list jobs with expired leases: %w", err)
}
// If no jobs found, cleanup is complete
if len(jobs) == 0 {
duration := c.clock().Sub(startTime)
span.SetAttributes(
attribute.Int("count", 0),
attribute.Int64("duration_ms", duration.Milliseconds()),
)
return nil
}
logger.Info("cleaning up expired jobs", "count", len(jobs))
for _, job := range jobs {
if err := c.cleanUpExpiredJob(ctx, job); err != nil {
// Log error but continue processing other jobs
logger.Error("failed to clean up expired job", "error", err, "job", job.GetName(), "namespace", job.GetNamespace())
}
}
duration := c.clock().Sub(startTime)
logger.Info("cleanup complete", "duration", duration, "count", len(jobs))
span.SetAttributes(
attribute.Int("count", len(jobs)),
attribute.Int64("duration_ms", duration.Milliseconds()),
)
return nil
}
// cleanUpExpiredJob marks a single expired job as failed and archives it.
func (c *JobCleanupController) cleanUpExpiredJob(ctx context.Context, job *provisioning.Job) error {
ctx, span := tracing.Start(ctx, "provisioning.jobs.cleanup.complete_expired_job")
defer span.End()
// Mark job as failed due to lease expiry
jobCopy := job.DeepCopy()
jobCopy.Status.State = provisioning.JobStateError
jobCopy.Status.Message = "Job failed due to lease expiry - worker may have crashed or lost connection"
jobCopy.Status.Finished = c.clock().UnixMilli()
span.SetAttributes(
attribute.String("job.name", jobCopy.GetName()),
attribute.String("job.namespace", jobCopy.GetNamespace()),
attribute.String("job.repository", jobCopy.Spec.Repository),
attribute.String("job.action", string(jobCopy.Spec.Action)),
)
jobLogger := logging.FromContext(ctx).With("namespace", jobCopy.GetNamespace(), "job", jobCopy.GetName(), "action", jobCopy.Spec.Action)
// Delete from active job store first
if err := c.store.Complete(ctx, jobCopy); err != nil {
span.RecordError(err)
return apifmt.Errorf("failed to complete expired job: %w", err)
}
// Remove the claim label before archiving
if jobCopy.Labels != nil {
delete(jobCopy.Labels, LabelJobClaim)
}
// Write to history after deleting from active store (matching driver.go pattern)
if err := c.historicJobs.WriteJob(ctx, jobCopy); err != nil {
span.RecordError(err)
jobLogger.Warn("failed to write expired job to history", "error", err)
// Job was already deleted, so we can't recover from this
}
jobLogger.Debug("cleaned up expired job")
return nil
}
@@ -0,0 +1,465 @@
package jobs
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestNewJobCleanupController(t *testing.T) {
store := &MockStore{}
historyWriter := &MockHistoryWriter{}
t.Run("creates controller with default cleanup interval", func(t *testing.T) {
expiry := 30 * time.Second
controller := NewJobCleanupController(store, historyWriter, expiry)
assert.NotNil(t, controller)
assert.Equal(t, expiry, controller.expiry)
// Cleanup interval should be 3x expiry = 90 seconds
assert.Equal(t, 90*time.Second, controller.cleanupInterval)
})
t.Run("enforces minimum cleanup interval", func(t *testing.T) {
// With expiry of 5 seconds, 3x = 15 seconds, but minimum is 30 seconds
expiry := 5 * time.Second
controller := NewJobCleanupController(store, historyWriter, expiry)
assert.Equal(t, 30*time.Second, controller.cleanupInterval)
})
t.Run("enforces maximum cleanup interval", func(t *testing.T) {
// With expiry of 5 minutes, 3x = 15 minutes, but maximum is 5 minutes
expiry := 5 * time.Minute
controller := NewJobCleanupController(store, historyWriter, expiry)
assert.Equal(t, 5*time.Minute, controller.cleanupInterval)
})
}
func TestJobCleanupController_Cleanup(t *testing.T) {
t.Run("no expired jobs returns nil", func(t *testing.T) {
store := &MockStore{}
historyWriter := &MockHistoryWriter{}
controller := NewJobCleanupController(store, historyWriter, 30*time.Second)
ctx := context.Background()
store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).Return([]*provisioning.Job{}, nil)
err := controller.Cleanup(ctx)
assert.NoError(t, err)
store.AssertExpectations(t)
// store.AssertNotCalled(t, "Complete") - not needed with combined Store mock
historyWriter.AssertNotCalled(t, "WriteJob")
})
t.Run("error listing expired jobs returns error", func(t *testing.T) {
store := &MockStore{}
historyWriter := &MockHistoryWriter{}
controller := NewJobCleanupController(store, historyWriter, 30*time.Second)
ctx := context.Background()
expectedErr := errors.New("list failed")
store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).Return(nil, expectedErr)
err := controller.Cleanup(ctx)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to list jobs with expired leases")
store.AssertExpectations(t)
})
t.Run("successfully cleans up expired job", func(t *testing.T) {
store := &MockStore{}
historyWriter := &MockHistoryWriter{}
controller := NewJobCleanupController(store, historyWriter, 30*time.Second)
ctx := context.Background()
job := &provisioning.Job{
ObjectMeta: metav1.ObjectMeta{
Name: "test-job",
Namespace: "test-ns",
Labels: map[string]string{
LabelJobClaim: "123456789",
},
},
Spec: provisioning.JobSpec{
Repository: "test-repo",
Action: provisioning.JobActionPull,
},
}
store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).Return([]*provisioning.Job{job}, nil)
store.On("Complete", mock.Anything, mock.MatchedBy(func(j *provisioning.Job) bool {
return j.Status.State == provisioning.JobStateError &&
j.Status.Message == "Job failed due to lease expiry - worker may have crashed or lost connection"
})).Return(nil)
historyWriter.On("WriteJob", mock.Anything, mock.MatchedBy(func(j *provisioning.Job) bool {
// Verify claim label was removed before writing to history
_, hasLabel := j.Labels[LabelJobClaim]
return !hasLabel && j.Status.State == provisioning.JobStateError
})).Return(nil)
err := controller.Cleanup(ctx)
assert.NoError(t, err)
store.AssertExpectations(t)
historyWriter.AssertExpectations(t)
})
t.Run("continues on complete error", func(t *testing.T) {
store := &MockStore{}
historyWriter := &MockHistoryWriter{}
controller := NewJobCleanupController(store, historyWriter, 30*time.Second)
ctx := context.Background()
job1 := &provisioning.Job{
ObjectMeta: metav1.ObjectMeta{
Name: "job-1",
Namespace: "test-ns",
Labels: map[string]string{LabelJobClaim: "123"},
},
Spec: provisioning.JobSpec{
Repository: "repo-1",
Action: provisioning.JobActionPull,
},
}
job2 := &provisioning.Job{
ObjectMeta: metav1.ObjectMeta{
Name: "job-2",
Namespace: "test-ns",
Labels: map[string]string{LabelJobClaim: "456"},
},
Spec: provisioning.JobSpec{
Repository: "repo-2",
Action: provisioning.JobActionPull,
},
}
store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).Return([]*provisioning.Job{job1, job2}, nil)
// First job fails to complete
store.On("Complete", mock.Anything, mock.MatchedBy(func(j *provisioning.Job) bool {
return j.Name == "job-1"
})).Return(errors.New("complete failed"))
// Second job succeeds
store.On("Complete", mock.Anything, mock.MatchedBy(func(j *provisioning.Job) bool {
return j.Name == "job-2"
})).Return(nil)
historyWriter.On("WriteJob", mock.Anything, mock.MatchedBy(func(j *provisioning.Job) bool {
return j.Name == "job-2"
})).Return(nil)
err := controller.Cleanup(ctx)
// Should not return error, continues processing
assert.NoError(t, err)
store.AssertExpectations(t)
historyWriter.AssertExpectations(t)
})
t.Run("continues on history write error", func(t *testing.T) {
store := &MockStore{}
historyWriter := &MockHistoryWriter{}
controller := NewJobCleanupController(store, historyWriter, 30*time.Second)
ctx := context.Background()
job := &provisioning.Job{
ObjectMeta: metav1.ObjectMeta{
Name: "test-job",
Namespace: "test-ns",
Labels: map[string]string{LabelJobClaim: "123"},
},
Spec: provisioning.JobSpec{
Repository: "test-repo",
Action: provisioning.JobActionPull,
},
}
store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).Return([]*provisioning.Job{job}, nil)
store.On("Complete", mock.Anything, mock.Anything).Return(nil)
historyWriter.On("WriteJob", mock.Anything, mock.Anything).Return(errors.New("write failed"))
err := controller.Cleanup(ctx)
// Should not return error, just log warning
assert.NoError(t, err)
store.AssertExpectations(t)
historyWriter.AssertExpectations(t)
})
t.Run("sets job status correctly", func(t *testing.T) {
store := &MockStore{}
historyWriter := &MockHistoryWriter{}
fixedTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
controller := NewJobCleanupController(store, historyWriter, 30*time.Second)
controller.clock = func() time.Time { return fixedTime }
ctx := context.Background()
job := &provisioning.Job{
ObjectMeta: metav1.ObjectMeta{
Name: "test-job",
Namespace: "test-ns",
Labels: map[string]string{LabelJobClaim: "123"},
},
Spec: provisioning.JobSpec{
Repository: "test-repo",
Action: provisioning.JobActionPull,
},
}
store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).Return([]*provisioning.Job{job}, nil)
store.On("Complete", mock.Anything, mock.MatchedBy(func(j *provisioning.Job) bool {
assert.Equal(t, provisioning.JobStateError, j.Status.State)
assert.Equal(t, "Job failed due to lease expiry - worker may have crashed or lost connection", j.Status.Message)
assert.Equal(t, fixedTime.UnixMilli(), j.Status.Finished)
return true
})).Return(nil)
historyWriter.On("WriteJob", mock.Anything, mock.Anything).Return(nil)
err := controller.Cleanup(ctx)
assert.NoError(t, err)
store.AssertExpectations(t)
})
t.Run("removes claim label before writing to history", func(t *testing.T) {
store := &MockStore{}
historyWriter := &MockHistoryWriter{}
controller := NewJobCleanupController(store, historyWriter, 30*time.Second)
ctx := context.Background()
job := &provisioning.Job{
ObjectMeta: metav1.ObjectMeta{
Name: "test-job",
Namespace: "test-ns",
Labels: map[string]string{
LabelJobClaim: "123456789",
"other-label": "value",
},
},
Spec: provisioning.JobSpec{
Repository: "test-repo",
Action: provisioning.JobActionPull,
},
}
store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).Return([]*provisioning.Job{job}, nil)
store.On("Complete", mock.Anything, mock.Anything).Return(nil)
historyWriter.On("WriteJob", mock.Anything, mock.MatchedBy(func(j *provisioning.Job) bool {
_, hasClaim := j.Labels[LabelJobClaim]
_, hasOther := j.Labels["other-label"]
assert.False(t, hasClaim, "claim label should be removed")
assert.True(t, hasOther, "other labels should be preserved")
return !hasClaim && hasOther
})).Return(nil)
err := controller.Cleanup(ctx)
assert.NoError(t, err)
historyWriter.AssertExpectations(t)
})
t.Run("processes multiple expired jobs", func(t *testing.T) {
store := &MockStore{}
historyWriter := &MockHistoryWriter{}
controller := NewJobCleanupController(store, historyWriter, 30*time.Second)
ctx := context.Background()
jobs := []*provisioning.Job{
{
ObjectMeta: metav1.ObjectMeta{
Name: "job-1",
Namespace: "ns-1",
Labels: map[string]string{LabelJobClaim: "111"},
},
Spec: provisioning.JobSpec{Repository: "repo-1", Action: provisioning.JobActionPull},
},
{
ObjectMeta: metav1.ObjectMeta{
Name: "job-2",
Namespace: "ns-2",
Labels: map[string]string{LabelJobClaim: "222"},
},
Spec: provisioning.JobSpec{Repository: "repo-2", Action: provisioning.JobActionPush},
},
{
ObjectMeta: metav1.ObjectMeta{
Name: "job-3",
Namespace: "ns-3",
Labels: map[string]string{LabelJobClaim: "333"},
},
Spec: provisioning.JobSpec{Repository: "repo-3", Action: provisioning.JobActionMigrate},
},
}
store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).Return(jobs, nil)
for _, job := range jobs {
store.On("Complete", mock.Anything, mock.MatchedBy(func(j *provisioning.Job) bool {
return j.Name == job.Name
})).Return(nil)
historyWriter.On("WriteJob", mock.Anything, mock.MatchedBy(func(j *provisioning.Job) bool {
return j.Name == job.Name
})).Return(nil)
}
err := controller.Cleanup(ctx)
assert.NoError(t, err)
store.AssertExpectations(t)
historyWriter.AssertExpectations(t)
// Verify all 3 jobs were processed
store.AssertNumberOfCalls(t, "Complete", 3)
historyWriter.AssertNumberOfCalls(t, "WriteJob", 3)
})
}
func TestJobCleanupController_Run(t *testing.T) {
t.Run("runs cleanup on start and periodically", func(t *testing.T) {
store := &MockStore{}
historyWriter := &MockHistoryWriter{}
// Use short expiry to get short cleanup interval for testing
controller := NewJobCleanupController(store, historyWriter, 10*time.Second)
// Override to even shorter for test
controller.cleanupInterval = 50 * time.Millisecond
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
// Expect initial cleanup + periodic cleanups (at least 2, maybe more depending on timing)
callCount := 0
store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).
Return([]*provisioning.Job{}, nil).
Run(func(args mock.Arguments) {
callCount++
}).
Maybe() // Allow variable number of calls due to timing
err := controller.Run(ctx)
// Should return context.DeadlineExceeded when context times out
require.Error(t, err)
assert.Equal(t, context.DeadlineExceeded, err)
// Verify cleanup was called at least 3 times (initial + 2 periodic)
assert.GreaterOrEqual(t, callCount, 3, "should have run cleanup at least 3 times")
})
t.Run("stops when context is cancelled", func(t *testing.T) {
store := &MockStore{}
historyWriter := &MockHistoryWriter{}
controller := NewJobCleanupController(store, historyWriter, 30*time.Second)
controller.cleanupInterval = 1 * time.Second
ctx, cancel := context.WithCancel(context.Background())
// Expect initial cleanup
store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).Return([]*provisioning.Job{}, nil).Once()
// Cancel after initial cleanup
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
err := controller.Run(ctx)
assert.Error(t, err)
assert.Equal(t, context.Canceled, err)
store.AssertExpectations(t)
})
t.Run("continues running after cleanup error", func(t *testing.T) {
store := &MockStore{}
historyWriter := &MockHistoryWriter{}
controller := NewJobCleanupController(store, historyWriter, 10*time.Second)
controller.cleanupInterval = 50 * time.Millisecond
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()
// Track successful calls after initial failure
successCount := 0
// First cleanup fails
store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).
Return(nil, errors.New("first failure")).Once()
// Subsequent cleanups succeed
store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).
Run(func(args mock.Arguments) {
successCount++
}).
Return([]*provisioning.Job{}, nil).
Maybe()
err := controller.Run(ctx)
// Should still run and return context error
require.Error(t, err)
assert.Equal(t, context.DeadlineExceeded, err)
// Verify it was called successfully at least once after first failure
assert.GreaterOrEqual(t, successCount, 1, "should have retried after first failure")
})
t.Run("logs error when periodic cleanup fails", func(t *testing.T) {
store := &MockStore{}
historyWriter := &MockHistoryWriter{}
controller := NewJobCleanupController(store, historyWriter, 10*time.Second)
controller.cleanupInterval = 50 * time.Millisecond
ctx, cancel := context.WithTimeout(context.Background(), 125*time.Millisecond)
defer cancel()
// Initial cleanup succeeds
store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).
Return([]*provisioning.Job{}, nil).Once()
// First periodic cleanup fails (this tests the error logging in ticker case)
store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).
Return(nil, errors.New("periodic failure")).Once()
// Subsequent cleanups succeed
store.On("ListExpiredJobs", mock.Anything, mock.Anything, 100).
Return([]*provisioning.Job{}, nil).
Maybe()
err := controller.Run(ctx)
// Should still run and return context error, not the cleanup error
require.Error(t, err)
assert.Equal(t, context.DeadlineExceeded, err)
store.AssertExpectations(t)
})
}
@@ -0,0 +1,158 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
package jobs
import (
context "context"
v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
mock "github.com/stretchr/testify/mock"
)
// MockHistoryReader is an autogenerated mock type for the HistoryReader type
type MockHistoryReader struct {
mock.Mock
}
type MockHistoryReader_Expecter struct {
mock *mock.Mock
}
func (_m *MockHistoryReader) EXPECT() *MockHistoryReader_Expecter {
return &MockHistoryReader_Expecter{mock: &_m.Mock}
}
// GetJob provides a mock function with given fields: ctx, namespace, repo, uid
func (_m *MockHistoryReader) GetJob(ctx context.Context, namespace string, repo string, uid string) (*v0alpha1.Job, error) {
ret := _m.Called(ctx, namespace, repo, uid)
if len(ret) == 0 {
panic("no return value specified for GetJob")
}
var r0 *v0alpha1.Job
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, string, string) (*v0alpha1.Job, error)); ok {
return rf(ctx, namespace, repo, uid)
}
if rf, ok := ret.Get(0).(func(context.Context, string, string, string) *v0alpha1.Job); ok {
r0 = rf(ctx, namespace, repo, uid)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*v0alpha1.Job)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok {
r1 = rf(ctx, namespace, repo, uid)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockHistoryReader_GetJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetJob'
type MockHistoryReader_GetJob_Call struct {
*mock.Call
}
// GetJob is a helper method to define mock.On call
// - ctx context.Context
// - namespace string
// - repo string
// - uid string
func (_e *MockHistoryReader_Expecter) GetJob(ctx interface{}, namespace interface{}, repo interface{}, uid interface{}) *MockHistoryReader_GetJob_Call {
return &MockHistoryReader_GetJob_Call{Call: _e.mock.On("GetJob", ctx, namespace, repo, uid)}
}
func (_c *MockHistoryReader_GetJob_Call) Run(run func(ctx context.Context, namespace string, repo string, uid string)) *MockHistoryReader_GetJob_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string))
})
return _c
}
func (_c *MockHistoryReader_GetJob_Call) Return(_a0 *v0alpha1.Job, _a1 error) *MockHistoryReader_GetJob_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockHistoryReader_GetJob_Call) RunAndReturn(run func(context.Context, string, string, string) (*v0alpha1.Job, error)) *MockHistoryReader_GetJob_Call {
_c.Call.Return(run)
return _c
}
// RecentJobs provides a mock function with given fields: ctx, namespace, repo
func (_m *MockHistoryReader) RecentJobs(ctx context.Context, namespace string, repo string) (*v0alpha1.JobList, error) {
ret := _m.Called(ctx, namespace, repo)
if len(ret) == 0 {
panic("no return value specified for RecentJobs")
}
var r0 *v0alpha1.JobList
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, string) (*v0alpha1.JobList, error)); ok {
return rf(ctx, namespace, repo)
}
if rf, ok := ret.Get(0).(func(context.Context, string, string) *v0alpha1.JobList); ok {
r0 = rf(ctx, namespace, repo)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*v0alpha1.JobList)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
r1 = rf(ctx, namespace, repo)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockHistoryReader_RecentJobs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RecentJobs'
type MockHistoryReader_RecentJobs_Call struct {
*mock.Call
}
// RecentJobs is a helper method to define mock.On call
// - ctx context.Context
// - namespace string
// - repo string
func (_e *MockHistoryReader_Expecter) RecentJobs(ctx interface{}, namespace interface{}, repo interface{}) *MockHistoryReader_RecentJobs_Call {
return &MockHistoryReader_RecentJobs_Call{Call: _e.mock.On("RecentJobs", ctx, namespace, repo)}
}
func (_c *MockHistoryReader_RecentJobs_Call) Run(run func(ctx context.Context, namespace string, repo string)) *MockHistoryReader_RecentJobs_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string))
})
return _c
}
func (_c *MockHistoryReader_RecentJobs_Call) Return(_a0 *v0alpha1.JobList, _a1 error) *MockHistoryReader_RecentJobs_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockHistoryReader_RecentJobs_Call) RunAndReturn(run func(context.Context, string, string) (*v0alpha1.JobList, error)) *MockHistoryReader_RecentJobs_Call {
_c.Call.Return(run)
return _c
}
// NewMockHistoryReader creates a new instance of MockHistoryReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockHistoryReader(t interface {
mock.TestingT
Cleanup(func())
}) *MockHistoryReader {
mock := &MockHistoryReader{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -0,0 +1,84 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
package jobs
import (
context "context"
v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
mock "github.com/stretchr/testify/mock"
)
// MockHistoryWriter is an autogenerated mock type for the HistoryWriter type
type MockHistoryWriter struct {
mock.Mock
}
type MockHistoryWriter_Expecter struct {
mock *mock.Mock
}
func (_m *MockHistoryWriter) EXPECT() *MockHistoryWriter_Expecter {
return &MockHistoryWriter_Expecter{mock: &_m.Mock}
}
// WriteJob provides a mock function with given fields: ctx, job
func (_m *MockHistoryWriter) WriteJob(ctx context.Context, job *v0alpha1.Job) error {
ret := _m.Called(ctx, job)
if len(ret) == 0 {
panic("no return value specified for WriteJob")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Job) error); ok {
r0 = rf(ctx, job)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockHistoryWriter_WriteJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteJob'
type MockHistoryWriter_WriteJob_Call struct {
*mock.Call
}
// WriteJob is a helper method to define mock.On call
// - ctx context.Context
// - job *v0alpha1.Job
func (_e *MockHistoryWriter_Expecter) WriteJob(ctx interface{}, job interface{}) *MockHistoryWriter_WriteJob_Call {
return &MockHistoryWriter_WriteJob_Call{Call: _e.mock.On("WriteJob", ctx, job)}
}
func (_c *MockHistoryWriter_WriteJob_Call) Run(run func(ctx context.Context, job *v0alpha1.Job)) *MockHistoryWriter_WriteJob_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(*v0alpha1.Job))
})
return _c
}
func (_c *MockHistoryWriter_WriteJob_Call) Return(_a0 error) *MockHistoryWriter_WriteJob_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockHistoryWriter_WriteJob_Call) RunAndReturn(run func(context.Context, *v0alpha1.Job) error) *MockHistoryWriter_WriteJob_Call {
_c.Call.Return(run)
return _c
}
// NewMockHistoryWriter creates a new instance of MockHistoryWriter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockHistoryWriter(t interface {
mock.TestingT
Cleanup(func())
}) *MockHistoryWriter {
mock := &MockHistoryWriter{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -134,8 +134,7 @@ func (_c *MockLokiClient_RangeQuery_Call) RunAndReturn(run func(context.Context,
func NewMockLokiClient(t interface {
mock.TestingT
Cleanup(func())
},
) *MockLokiClient {
}) *MockLokiClient {
mock := &MockLokiClient{}
mock.Mock.Test(t)
@@ -306,11 +306,11 @@ func (s *persistentStore) Complete(ctx context.Context, job *provisioning.Job) e
return apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err)
}
// We need to delete the job from the job store and create it in the historic job store.
// We are fine with the job being lost if the historic job store fails to create it.
// Delete the job from the active job store.
// Callers are responsible for writing the job to history after calling this.
//
// We will assume that the caller is the claimant. If this is not true, an error is returned.
// This is a best-effort operation; if the job is not in the claimed state, we will still attempt to move it to the historic job store.
// This is a best-effort operation; if the job is not in the claimed state, we will still attempt to delete it.
err = s.client.Jobs(job.GetNamespace()).Delete(ctx, job.GetName(), metav1.DeleteOptions{})
if err != nil {
span.RecordError(err)
@@ -329,6 +329,56 @@ func (s *persistentStore) Complete(ctx context.Context, job *provisioning.Job) e
return nil
}
// ListExpiredJobs lists jobs with expired leases (claim timestamp older than the given time).
// Returns jobs in batches up to the specified limit.
func (s *persistentStore) ListExpiredJobs(ctx context.Context, expiredBefore time.Time, limit int) ([]*provisioning.Job, error) {
ctx, span := tracing.Start(ctx, "provisioning.jobs.list_expired_jobs")
defer span.End()
logger := logging.FromContext(ctx).With("operation", "list_expired_jobs")
// Set up provisioning identity to access jobs across all namespaces
ctx, _, err := identity.WithProvisioningIdentity(ctx, "*")
if err != nil {
span.RecordError(err)
return nil, apifmt.Errorf("failed to grant provisioning identity for listing expired jobs: %w", err)
}
// Find jobs with expired leases (older than expiredBefore)
expiry := expiredBefore.UnixMilli()
logger.Debug("searching for expired jobs", "expiry_threshold", expiredBefore.Format(time.RFC3339))
requirement, err := labels.NewRequirement(LabelJobClaim, selection.LessThan, []string{strconv.FormatInt(expiry, 10)})
if err != nil {
span.RecordError(err)
return nil, apifmt.Errorf("could not create requirement: %w", err)
}
span.SetAttributes(
attribute.String("expiry_threshold", expiredBefore.Format(time.RFC3339)),
attribute.Int("limit", limit),
)
jobList, err := s.client.Jobs("").List(ctx, metav1.ListOptions{
LabelSelector: labels.NewSelector().Add(*requirement).String(),
Limit: int64(limit),
})
if err != nil {
span.RecordError(err)
return nil, apifmt.Errorf("failed to list jobs with expired leases: %w", err)
}
result := make([]*provisioning.Job, len(jobList.Items))
for i := range jobList.Items {
result[i] = &jobList.Items[i]
}
span.SetAttributes(attribute.Int("jobs_found", len(result)))
logger.Debug("found expired jobs", "count", len(result))
return result, nil
}
// RenewLease renews the lease for a claimed job, extending its expiry time.
// Returns an error if the lease cannot be renewed (e.g., job was completed or lease expired).
func (s *persistentStore) RenewLease(ctx context.Context, job *provisioning.Job) error {
@@ -405,164 +455,6 @@ func (s *persistentStore) RenewLease(ctx context.Context, job *provisioning.Job)
return nil
}
// Cleanup finds jobs with expired leases and marks them as failed.
// This replaces the old cleanup mechanism and should be called more frequently.
func (s *persistentStore) Cleanup(ctx context.Context) error {
ctx, span := tracing.Start(ctx, "provisioning.jobs.cleanup")
defer span.End()
startTime := s.clock()
logger := logging.FromContext(ctx).With("operation", "cleanup")
// List expired jobs
jobs, err := s.listExpiredJobs(ctx)
if err != nil {
span.RecordError(err)
return err
}
// If no jobs found, cleanup is complete
if len(jobs) == 0 {
duration := s.clock().Sub(startTime)
logger.Info("cleanup complete - no expired jobs found", "duration", duration)
span.SetAttributes(
attribute.Int("count", 0),
attribute.Int64("duration_ms", duration.Milliseconds()),
)
return nil
}
logger.Info("found expired jobs", "count", len(jobs))
// Clean up each expired job
for _, job := range jobs {
if err := s.cleanUpExpiredJob(ctx, job); err != nil {
span.RecordError(err)
return err
}
}
duration := s.clock().Sub(startTime)
logger.Info("cleanup complete",
"duration", duration,
"count", len(jobs),
)
span.SetAttributes(
attribute.Int("count", len(jobs)),
attribute.Int64("duration_ms", duration.Milliseconds()),
)
return nil
}
// listExpiredJobs returns jobs with expired leases.
func (s *persistentStore) listExpiredJobs(ctx context.Context) ([]provisioning.Job, error) {
logger := logging.FromContext(ctx)
// Set up provisioning identity to access jobs across all namespaces
ctx, _, err := identity.WithProvisioningIdentity(ctx, "*") // "*" grants access to all namespaces
if err != nil {
return nil, apifmt.Errorf("failed to grant provisioning identity for cleanup: %w", err)
}
// Find jobs with expired leases (older than expiry time)
expiry := s.clock().Add(-s.expiry).UnixMilli()
expiryTime := time.UnixMilli(expiry)
logger.Debug("search for expired jobs", "expiry_threshold", expiryTime.Format(time.RFC3339))
requirement, err := labels.NewRequirement(LabelJobClaim, selection.LessThan, []string{strconv.FormatInt(expiry, 10)})
if err != nil {
return nil, apifmt.Errorf("could not create requirement: %w", err)
}
listCtx, listSpan := tracing.Start(ctx, "provisioning.jobs.cleanup.list_expired_jobs")
defer listSpan.End()
listSpan.SetAttributes(
attribute.String("expiry_threshold", expiryTime.Format(time.RFC3339)),
attribute.Int64("expiry_duration_seconds", int64(s.expiry.Seconds())),
)
timeoutCtx, cancel := context.WithTimeout(listCtx, 5*time.Second)
defer cancel()
jobList, err := s.client.Jobs("").List(timeoutCtx, metav1.ListOptions{
LabelSelector: labels.NewSelector().Add(*requirement).String(),
Limit: 100, // Process in batches
})
if err != nil {
listSpan.RecordError(err)
return nil, apifmt.Errorf("failed to list jobs with expired leases: %w", err)
}
listSpan.SetAttributes(attribute.Int("jobs_found", len(jobList.Items)))
return jobList.Items, nil
}
// cleanUpExpiredJob marks a single expired job as failed and archives it.
func (s *persistentStore) cleanUpExpiredJob(ctx context.Context, job provisioning.Job) error {
// Calculate how long the job has been expired
var expiredFor time.Duration
var claimTimestamp time.Time
if claimTime, exists := job.Labels[LabelJobClaim]; exists {
claimMillis, parseErr := strconv.ParseInt(claimTime, 10, 64)
if parseErr == nil {
claimTimestamp = time.UnixMilli(claimMillis)
expiredFor = s.clock().Sub(claimTimestamp)
}
}
logger := logging.FromContext(ctx).With(
"job", job.GetName(),
"namespace", job.GetNamespace(),
"repository", job.Spec.Repository,
"action", job.Spec.Action,
"expired_for", expiredFor,
)
if !claimTimestamp.IsZero() {
logger = logger.With("claim_time", claimTimestamp.Format(time.RFC3339))
}
jobCtx, jobSpan := tracing.Start(ctx, "provisioning.jobs.cleanup.complete_expired_job")
defer jobSpan.End()
jobSpan.SetAttributes(
attribute.String("job.name", job.GetName()),
attribute.String("job.namespace", job.GetNamespace()),
attribute.String("job.repository", job.Spec.Repository),
attribute.String("job.action", string(job.Spec.Action)),
attribute.String("job.expired_for", expiredFor.String()),
)
// Mark job as failed due to lease expiry and archive it
jobCopy := job.DeepCopy()
jobCopy.Status.State = provisioning.JobStateError
jobCopy.Status.Message = "Job failed due to lease expiry - worker may have crashed or lost connection"
// Set namespace context for the completion
jobCtx, _, err := identity.WithProvisioningIdentity(jobCtx, job.GetNamespace())
if err != nil {
jobSpan.RecordError(err)
return apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err)
}
// Use Complete to properly archive the failed job
if err := s.Complete(jobCtx, jobCopy); err != nil {
if apierrors.IsNotFound(err) {
// Job was already completed/deleted by another process - this is expected
logger.Warn("job already completed or deleted by another process")
return nil
}
jobSpan.RecordError(err)
return apifmt.Errorf("failed to complete expired job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
}
logger.Info("clean up expired job complete")
return nil
}
func (s *persistentStore) Insert(ctx context.Context, namespace string, spec provisioning.JobSpec) (*provisioning.Job, error) {
ctx, span := tracing.Start(ctx, "provisioning.jobs.insert")
defer span.End()
@@ -1,12 +1,14 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
// Code generated by mockery v2.53.4. DO NOT EDIT.
package jobs
import (
context "context"
time "time"
mock "github.com/stretchr/testify/mock"
v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
mock "github.com/stretchr/testify/mock"
)
// MockStore is an autogenerated mock type for the Store type
@@ -89,52 +91,6 @@ func (_c *MockStore_Claim_Call) RunAndReturn(run func(context.Context) (*v0alpha
return _c
}
// Cleanup provides a mock function with given fields: ctx
func (_m *MockStore) Cleanup(ctx context.Context) error {
ret := _m.Called(ctx)
if len(ret) == 0 {
panic("no return value specified for Cleanup")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context) error); ok {
r0 = rf(ctx)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockStore_Cleanup_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Cleanup'
type MockStore_Cleanup_Call struct {
*mock.Call
}
// Cleanup is a helper method to define mock.On call
// - ctx context.Context
func (_e *MockStore_Expecter) Cleanup(ctx interface{}) *MockStore_Cleanup_Call {
return &MockStore_Cleanup_Call{Call: _e.mock.On("Cleanup", ctx)}
}
func (_c *MockStore_Cleanup_Call) Run(run func(ctx context.Context)) *MockStore_Cleanup_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *MockStore_Cleanup_Call) Return(_a0 error) *MockStore_Cleanup_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockStore_Cleanup_Call) RunAndReturn(run func(context.Context) error) *MockStore_Cleanup_Call {
_c.Call.Return(run)
return _c
}
// Complete provides a mock function with given fields: ctx, job
func (_m *MockStore) Complete(ctx context.Context, job *v0alpha1.Job) error {
ret := _m.Called(ctx, job)
@@ -182,9 +138,9 @@ func (_c *MockStore_Complete_Call) RunAndReturn(run func(context.Context, *v0alp
return _c
}
// Get provides a mock function with given fields: ctx, name
func (_m *MockStore) Get(ctx context.Context, name string) (*v0alpha1.Job, error) {
ret := _m.Called(ctx, name)
// Get provides a mock function with given fields: ctx, namespace, name
func (_m *MockStore) Get(ctx context.Context, namespace string, name string) (*v0alpha1.Job, error) {
ret := _m.Called(ctx, namespace, name)
if len(ret) == 0 {
panic("no return value specified for Get")
@@ -192,19 +148,19 @@ func (_m *MockStore) Get(ctx context.Context, name string) (*v0alpha1.Job, error
var r0 *v0alpha1.Job
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string) (*v0alpha1.Job, error)); ok {
return rf(ctx, name)
if rf, ok := ret.Get(0).(func(context.Context, string, string) (*v0alpha1.Job, error)); ok {
return rf(ctx, namespace, name)
}
if rf, ok := ret.Get(0).(func(context.Context, string) *v0alpha1.Job); ok {
r0 = rf(ctx, name)
if rf, ok := ret.Get(0).(func(context.Context, string, string) *v0alpha1.Job); ok {
r0 = rf(ctx, namespace, name)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*v0alpha1.Job)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, name)
if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
r1 = rf(ctx, namespace, name)
} else {
r1 = ret.Error(1)
}
@@ -219,14 +175,15 @@ type MockStore_Get_Call struct {
// Get is a helper method to define mock.On call
// - ctx context.Context
// - namespace string
// - name string
func (_e *MockStore_Expecter) Get(ctx interface{}, name interface{}) *MockStore_Get_Call {
return &MockStore_Get_Call{Call: _e.mock.On("Get", ctx, name)}
func (_e *MockStore_Expecter) Get(ctx interface{}, namespace interface{}, name interface{}) *MockStore_Get_Call {
return &MockStore_Get_Call{Call: _e.mock.On("Get", ctx, namespace, name)}
}
func (_c *MockStore_Get_Call) Run(run func(ctx context.Context, name string)) *MockStore_Get_Call {
func (_c *MockStore_Get_Call) Run(run func(ctx context.Context, namespace string, name string)) *MockStore_Get_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string))
run(args[0].(context.Context), args[1].(string), args[2].(string))
})
return _c
}
@@ -236,7 +193,67 @@ func (_c *MockStore_Get_Call) Return(_a0 *v0alpha1.Job, _a1 error) *MockStore_Ge
return _c
}
func (_c *MockStore_Get_Call) RunAndReturn(run func(context.Context, string) (*v0alpha1.Job, error)) *MockStore_Get_Call {
func (_c *MockStore_Get_Call) RunAndReturn(run func(context.Context, string, string) (*v0alpha1.Job, error)) *MockStore_Get_Call {
_c.Call.Return(run)
return _c
}
// ListExpiredJobs provides a mock function with given fields: ctx, expiredBefore, limit
func (_m *MockStore) ListExpiredJobs(ctx context.Context, expiredBefore time.Time, limit int) ([]*v0alpha1.Job, error) {
ret := _m.Called(ctx, expiredBefore, limit)
if len(ret) == 0 {
panic("no return value specified for ListExpiredJobs")
}
var r0 []*v0alpha1.Job
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, time.Time, int) ([]*v0alpha1.Job, error)); ok {
return rf(ctx, expiredBefore, limit)
}
if rf, ok := ret.Get(0).(func(context.Context, time.Time, int) []*v0alpha1.Job); ok {
r0 = rf(ctx, expiredBefore, limit)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*v0alpha1.Job)
}
}
if rf, ok := ret.Get(1).(func(context.Context, time.Time, int) error); ok {
r1 = rf(ctx, expiredBefore, limit)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockStore_ListExpiredJobs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListExpiredJobs'
type MockStore_ListExpiredJobs_Call struct {
*mock.Call
}
// ListExpiredJobs is a helper method to define mock.On call
// - ctx context.Context
// - expiredBefore time.Time
// - limit int
func (_e *MockStore_Expecter) ListExpiredJobs(ctx interface{}, expiredBefore interface{}, limit interface{}) *MockStore_ListExpiredJobs_Call {
return &MockStore_ListExpiredJobs_Call{Call: _e.mock.On("ListExpiredJobs", ctx, expiredBefore, limit)}
}
func (_c *MockStore_ListExpiredJobs_Call) Run(run func(ctx context.Context, expiredBefore time.Time, limit int)) *MockStore_ListExpiredJobs_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(time.Time), args[2].(int))
})
return _c
}
func (_c *MockStore_ListExpiredJobs_Call) Return(_a0 []*v0alpha1.Job, _a1 error) *MockStore_ListExpiredJobs_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockStore_ListExpiredJobs_Call) RunAndReturn(run func(context.Context, time.Time, int) ([]*v0alpha1.Job, error)) *MockStore_ListExpiredJobs_Call {
_c.Call.Return(run)
return _c
}
+33 -16
View File
@@ -287,6 +287,16 @@ func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer {
return authorizer.DecisionAllow, "", nil
}
// Check if any extra authorizer has a decision.
// Since the move to access checker when useExclusivelyAccessCheckerForAuthz=true, extra authorizers
// need to run first because access checker is not aware of the extras logic
for _, extra := range b.extras {
decision, reason, err := extra.Authorize(ctx, a)
if decision != authorizer.DecisionNoOpinion {
return decision, reason, err
}
}
info, ok := authlib.AuthInfoFrom(ctx)
// when running as standalone API server, the identity type may not always match TypeAccessPolicy
// so we allow it to use the access checker if there is any auth info available
@@ -310,6 +320,12 @@ func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer {
return authorizer.DecisionAllow, "", nil
}
id, err := identity.GetRequester(ctx)
if err != nil {
return authorizer.DecisionDeny, "failed to find requester", err
}
// Different routes may need different permissions.
// * Reading and modifying a repository's configuration requires administrator privileges.
// * Reading a repository's limited configuration (/stats & /settings) requires viewer privileges.
@@ -322,19 +338,6 @@ func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer {
// * Testing a repository configuration requires administrator privileges.
// * Viewing a repository's history requires editor privileges.
id, err := identity.GetRequester(ctx)
if err != nil {
return authorizer.DecisionDeny, "failed to find requester", err
}
// Check if any extra authorizer has a decision.
for _, extra := range b.extras {
decision, reason, err := extra.Authorize(ctx, a)
if decision != authorizer.DecisionNoOpinion {
return decision, reason, err
}
}
switch a.GetResource() {
case provisioning.RepositoryResourceInfo.GetName():
// TODO: Support more fine-grained permissions than the basic roles. Especially on Enterprise.
@@ -772,13 +775,21 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
}
repoGetter := resources.NewRepositoryGetter(b.repoFactory, b.client)
// Create job cleanup controller
jobExpiry := 30 * time.Second
jobCleanupController := jobs.NewJobCleanupController(
b.jobs,
jobHistoryWriter,
jobExpiry,
)
// This is basically our own JobQueue system
driver, err := jobs.NewConcurrentJobDriver(
3, // 3 drivers for now
20*time.Minute, // Max time for each job
time.Minute, // Cleanup jobs
30*time.Second, // Periodically look for new jobs
30*time.Second, // Lease renewal interval
jobExpiry, // Lease renewal interval
b.jobs, repoGetter, jobHistoryWriter,
jobController.InsertNotifications(),
b.registry,
@@ -794,6 +805,12 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
}
}()
go func() {
if err := jobCleanupController.Run(postStartHookCtx.Context); err != nil {
logging.FromContext(postStartHookCtx.Context).Error("job cleanup controller failed", "error", err)
}
}()
repoController, err := controller.NewRepositoryController(
b.GetClient(),
repoInformer,
@@ -818,7 +835,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
if b.jobHistoryLoki == nil {
// Create HistoryJobController for cleanup of old job history entries
// Separate informer factory for HistoryJob cleanup with resync interval
historyJobExpiration := 30 * time.Second
historyJobExpiration := 10 * time.Minute
historyJobInformerFactory := informers.NewSharedInformerFactory(c, historyJobExpiration)
historyJobInformer := historyJobInformerFactory.Provisioning().V0alpha1().HistoricJobs()
go historyJobInformer.Informer().Run(postStartHookCtx.Done())
+38 -34
View File
@@ -308,6 +308,7 @@ func InstallAPIs(
var mode = grafanarest.DualWriterMode(0)
var (
err error
dualWriterPeriodicDataSyncJobEnabled bool
dualWriterMigrationDataSyncDisabled bool
dataSyncerInterval = time.Hour
@@ -329,29 +330,45 @@ func InstallAPIs(
return storage, nil
}
// TODO: inherited context from main Grafana process
ctx := context.Background()
currentMode := mode
if !dualWriterMigrationDataSyncDisabled || dualWriterPeriodicDataSyncJobEnabled {
// TODO: inherited context from main Grafana process
ctx := context.Background()
// Moving from one version to the next can only happen after the previous step has
// successfully synchronized.
requestInfo := getRequestInfo(gr, namespaceMapper)
// Moving from one version to the next can only happen after the previous step has
// successfully synchronized.
requestInfo := getRequestInfo(gr, namespaceMapper)
syncerCfg := &grafanarest.SyncerConfig{
Kind: key,
RequestInfo: requestInfo,
Mode: mode,
SkipDataSync: dualWriterMigrationDataSyncDisabled,
LegacyStorage: legacy,
Storage: storage,
ServerLockService: serverLock,
DataSyncerInterval: dataSyncerInterval,
DataSyncerRecordsLimit: dataSyncerRecordsLimit,
}
syncerCfg := &grafanarest.SyncerConfig{
Kind: key,
RequestInfo: requestInfo,
Mode: mode,
SkipDataSync: dualWriterMigrationDataSyncDisabled,
LegacyStorage: legacy,
Storage: storage,
ServerLockService: serverLock,
DataSyncerInterval: dataSyncerInterval,
DataSyncerRecordsLimit: dataSyncerRecordsLimit,
}
// This also sets the currentMode on the syncer config.
currentMode, err := grafanarest.SetDualWritingMode(ctx, kvStore, syncerCfg, dualWriterMetrics)
if err != nil {
return nil, err
// This also sets the currentMode on the syncer config.
currentMode, err = grafanarest.SetDualWritingMode(ctx, kvStore, syncerCfg, dualWriterMetrics)
if err != nil {
return nil, err
}
// when unable to use
if currentMode != mode {
klog.Warningf("Requested DualWrite mode: %d, but using %d for %+v", mode, currentMode, gr)
}
if dualWriterPeriodicDataSyncJobEnabled && (currentMode >= grafanarest.Mode1 && currentMode <= grafanarest.Mode3) {
// The mode might have changed in SetDualWritingMode, so apply current mode first.
syncerCfg.Mode = currentMode
if err := grafanarest.StartPeriodicDataSyncer(ctx, syncerCfg, dualWriterMetrics); err != nil {
return nil, err
}
}
}
builderMetrics.RecordDualWriterModes(gr.Resource, gr.Group, mode, currentMode)
@@ -362,21 +379,8 @@ func InstallAPIs(
case grafanarest.Mode4, grafanarest.Mode5:
return storage, nil
default:
return dualwrite.NewDualWriter(gr, currentMode, legacy, storage)
}
if dualWriterPeriodicDataSyncJobEnabled {
// The mode might have changed in SetDualWritingMode, so apply current mode first.
syncerCfg.Mode = currentMode
if err := grafanarest.StartPeriodicDataSyncer(ctx, syncerCfg, dualWriterMetrics); err != nil {
return nil, err
}
}
// when unable to use
if currentMode != mode {
klog.Warningf("Requested DualWrite mode: %d, but using %d for %+v", mode, currentMode, gr)
}
return dualwrite.NewDualWriter(gr, currentMode, legacy, storage)
}
}
+61
View File
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"net"
"strconv"
"strings"
"time"
"github.com/spf13/pflag"
@@ -14,6 +16,7 @@ import (
"k8s.io/apiserver/pkg/server/options"
"k8s.io/client-go/rest"
apiserverrest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/infra/tracing"
secret "github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
inlinesecurevalue "github.com/grafana/grafana/pkg/registry/apis/secret/inline"
@@ -87,6 +90,58 @@ type StorageOptions struct {
ConfigProvider RestConfigProvider
}
// unifiedStorageConfigValue implements pflag.Value for parsing unified storage config
type unifiedStorageConfigValue struct {
config *map[string]setting.UnifiedStorageConfig
}
func (v *unifiedStorageConfigValue) String() string {
if v.config == nil || len(*v.config) == 0 {
return ""
}
parts := make([]string, 0, len(*v.config))
for key, cfg := range *v.config {
parts = append(parts, fmt.Sprintf("%s=%d", key, cfg.DualWriterMode))
}
return strings.Join(parts, ",")
}
func (v *unifiedStorageConfigValue) Set(val string) error {
if val == "" {
return nil
}
// Parse comma-separated key=value pairs
pairs := strings.Split(val, ",")
for _, pair := range pairs {
kv := strings.SplitN(pair, "=", 2)
if len(kv) != 2 {
return fmt.Errorf("invalid format: %s (expected key=value)", pair)
}
key := strings.TrimSpace(kv[0])
mode, err := strconv.Atoi(strings.TrimSpace(kv[1]))
if err != nil {
return fmt.Errorf("invalid mode value for %s: %w", key, err)
}
if mode < 0 || mode > 5 {
return fmt.Errorf("mode must be between 0 and 5, got %d for %s", mode, key)
}
(*v.config)[key] = setting.UnifiedStorageConfig{
DualWriterMode: apiserverrest.DualWriterMode(mode),
DualWriterMigrationDataSyncDisabled: true,
}
}
return nil
}
func (v *unifiedStorageConfigValue) Type() string {
return "stringToUnifiedStorageConfig"
}
func NewStorageOptions() *StorageOptions {
return &StorageOptions{
StorageType: StorageTypeUnified,
@@ -95,6 +150,7 @@ func NewStorageOptions() *StorageOptions {
GrpcClientAuthenticationAllowInsecure: false,
GrpcClientKeepaliveTime: 0,
BlobThresholdBytes: BlobThresholdDefault,
UnifiedStorageConfig: make(map[string]setting.UnifiedStorageConfig),
}
}
@@ -109,6 +165,11 @@ func (o *StorageOptions) AddFlags(fs *pflag.FlagSet) {
fs.BoolVar(&o.GrpcClientAuthenticationAllowInsecure, "grpc-client-authentication-allow-insecure", o.GrpcClientAuthenticationAllowInsecure, "Allow insecure grpc client authentication")
fs.DurationVar(&o.GrpcClientKeepaliveTime, "grpc-client-keepalive-time", o.GrpcClientKeepaliveTime, "gRPC client keep-alive ping interval (e.g., 6m).")
// Use custom flag value for unified storage config
fs.Var(&unifiedStorageConfigValue{config: &o.UnifiedStorageConfig},
"grafana-apiserver-unified-storage-config",
"Unified storage configuration per resource.group in the format resource.group=mode,... where mode is 0-5")
// Secrets Manager Configuration flags
fs.BoolVar(&o.SecretsManagerGrpcClientEnable, "grafana.secrets-manager.grpc-client-enable", false, "Enable gRPC client for secrets manager")
fs.StringVar(&o.SecretsManagerGrpcServerAddress, "grafana.secrets-manager.grpc-server-address", "", "gRPC server address for secrets manager")
+1 -12
View File
@@ -618,6 +618,7 @@ func (s *Service) checkPermission(ctx context.Context, scopeMap map[string]bool,
}
if t.SkipScope(req.Verb) {
// Resource doesn't require scope on this verb, so allow if the user has the action
return scopeMap[""], nil
}
@@ -627,18 +628,6 @@ func (s *Service) checkPermission(ctx context.Context, scopeMap map[string]bool,
req.ParentFolder = accesscontrol.GeneralFolderUID
}
//if req.Verb == utils.VerbCreate {
// // Resource doesn't require scope on create, so allow if the user has the action
// if t.SkipScopeOnCreate() {
// return scopeMap[""], nil
// }
// // If creating a resource that goes in a folder, but no folder is specified,
// // assume parent folder is the general folder
// if t.HasFolderSupport() && req.ParentFolder == "" {
// req.ParentFolder = accesscontrol.GeneralFolderUID
// }
//}
// Wildcard grant, no further checks needed
if scopeMap["*"] {
return true, nil
+1 -1
View File
@@ -25,7 +25,7 @@ func newZanzanaServerMetrics(reg prometheus.Registerer) *metrics {
Subsystem: metricsSubSystem,
Buckets: prometheus.ExponentialBuckets(0.00001, 4, 10),
},
[]string{"method", "namespace"},
[]string{"method", "request_namespace"},
),
}
}
+22 -28
View File
@@ -69,14 +69,6 @@ var (
Stage: FeatureStageExperimental,
Owner: grafanaSearchAndStorageSquad,
},
{
Name: "correlations",
Description: "Correlations page",
Stage: FeatureStageGeneralAvailability,
Owner: grafanaDataProSquad,
Expression: "true", // enabled by default
AllowSelfServe: true,
},
{
Name: "canvasPanelNesting",
Description: "Allow elements nesting",
@@ -169,7 +161,7 @@ var (
Description: "populate star status from apiserver",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaFrontendPlatformSquad,
Owner: grafanaFrontendSearchNavOrganise,
AllowSelfServe: false,
HideFromDocs: true,
},
@@ -583,6 +575,14 @@ var (
HideFromAdminPage: true,
HideFromDocs: true,
},
{
Name: "alertingUIUseBackendFilters",
Description: "Enables the UI to use certain backend-side filters",
Stage: FeatureStageExperimental,
Owner: grafanaAlertingSquad,
HideFromAdminPage: true,
HideFromDocs: true,
},
{
Name: "alertmanagerRemotePrimary",
Description: "Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager.",
@@ -687,13 +687,6 @@ var (
Expression: "true",
Owner: grafanaObservabilityLogsSquad,
},
{
Name: "pluginsSkipHostEnvVars",
Description: "Disables passing host environment variable to plugin processes",
Stage: FeatureStageExperimental,
FrontendOnly: false,
Owner: grafanaPluginsPlatformSquad,
},
{
Name: "tableSharedCrosshair",
Description: "Enables shared crosshair in table panel",
@@ -1001,7 +994,7 @@ var (
Name: "pinNavItems",
Description: "Enables pinning of nav items",
Stage: FeatureStageGeneralAvailability,
Owner: grafanaFrontendPlatformSquad,
Owner: grafanaFrontendSearchNavOrganise,
Expression: "true", // enabled by default
},
{
@@ -1394,7 +1387,7 @@ var (
Name: "unifiedHistory",
Description: "Displays the navigation history so the user can navigate back to previous pages",
Stage: FeatureStageExperimental,
Owner: grafanaFrontendPlatformSquad,
Owner: grafanaFrontendSearchNavOrganise,
FrontendOnly: true,
},
{
@@ -1442,13 +1435,6 @@ var (
FrontendOnly: false,
Owner: identityAccessTeam,
},
{
Name: "templateVariablesUsesCombobox",
Description: "Use new **Combobox** component for template variables",
Stage: FeatureStageExperimental,
Owner: grafanaFrontendPlatformSquad,
FrontendOnly: true,
},
{
Name: "grafanaAdvisor",
Description: "Enables Advisor app",
@@ -1517,7 +1503,7 @@ var (
Name: "useScopesNavigationEndpoint",
Description: "Use the scopes navigation endpoint instead of the dashboardbindings endpoint",
Stage: FeatureStageExperimental,
Owner: grafanaFrontendPlatformSquad,
Owner: grafanaOperatorExperienceSquad,
FrontendOnly: true,
HideFromDocs: true,
HideFromAdminPage: true,
@@ -1526,7 +1512,7 @@ var (
Name: "scopeSearchAllLevels",
Description: "Enable scope search to include all levels of the scope node tree",
Stage: FeatureStageExperimental,
Owner: grafanaFrontendPlatformSquad,
Owner: grafanaOperatorExperienceSquad,
HideFromDocs: true,
HideFromAdminPage: true,
},
@@ -1779,7 +1765,7 @@ var (
Name: "restoreDashboards",
Description: "Enables restore deleted dashboards feature",
Stage: FeatureStageExperimental,
Owner: grafanaFrontendPlatformSquad,
Owner: grafanaFrontendSearchNavOrganise,
HideFromAdminPage: true,
Expression: "false",
},
@@ -2055,6 +2041,14 @@ var (
Owner: grafanaDatavizSquad,
Expression: "false",
},
{
Name: "newVizSuggestions",
Description: "Enable new visualization suggestions",
Stage: FeatureStagePublicPreview,
FrontendOnly: true,
Owner: grafanaDatavizSquad,
Expression: "false",
},
{
Name: "preventPanelChromeOverflow",
Description: "Restrict PanelChrome contents with overflow: hidden;",
+8 -9
View File
@@ -6,7 +6,6 @@ publicDashboardsScene,GA,@grafana/grafana-operator-experience-squad,false,false,
lokiExperimentalStreaming,experimental,@grafana/observability-logs,false,false,false
featureHighlights,GA,@grafana/grafana-operator-experience-squad,false,false,false
storage,experimental,@grafana/search-and-storage,false,false,false
correlations,GA,@grafana/datapro,false,false,false
canvasPanelNesting,experimental,@grafana/dataviz-squad,false,false,true
logRequestsInstrumentedAsUnknown,experimental,@grafana/grafana-backend-group,false,false,false
grpcServer,preview,@grafana/search-and-storage,false,false,false
@@ -19,7 +18,7 @@ lokiShardSplitting,experimental,@grafana/observability-logs,false,false,true
lokiQuerySplitting,GA,@grafana/observability-logs,false,false,true
individualCookiePreferences,experimental,@grafana/grafana-backend-group,false,false,false
influxdbBackendMigration,GA,@grafana/partner-datasources,false,false,true
starsFromAPIServer,experimental,@grafana/grafana-frontend-platform,false,false,true
starsFromAPIServer,experimental,@grafana/grafana-search-navigate-organise,false,false,true
kubernetesStars,experimental,@grafana/grafana-app-platform-squad,false,true,false
influxqlStreamingParser,experimental,@grafana/partner-datasources,false,false,false
influxdbRunQueriesInParallel,privatePreview,@grafana/partner-datasources,false,false,false
@@ -77,6 +76,7 @@ cachingOptimizeSerializationMemoryUsage,experimental,@grafana/grafana-operator-e
addFieldFromCalculationStatFunctions,GA,@grafana/datapro,false,false,true
alertmanagerRemoteSecondary,experimental,@grafana/alerting-squad,false,false,false
alertingProvenanceLockWrites,experimental,@grafana/alerting-squad,false,false,false
alertingUIUseBackendFilters,experimental,@grafana/alerting-squad,false,false,false
alertmanagerRemotePrimary,experimental,@grafana/alerting-squad,false,false,false
annotationPermissionUpdate,GA,@grafana/identity-access-team,false,false,false
dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true
@@ -91,7 +91,6 @@ canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true
timeComparison,experimental,@grafana/dataviz-squad,false,false,true
logsInfiniteScrolling,GA,@grafana/observability-logs,false,false,true
logRowsPopoverMenu,GA,@grafana/observability-logs,false,false,true
pluginsSkipHostEnvVars,experimental,@grafana/plugins-platform-backend,false,false,false
tableSharedCrosshair,experimental,@grafana/dataviz-squad,false,false,true
regressionTransformation,preview,@grafana/datapro,false,false,true
kubernetesFeatureToggles,experimental,@grafana/grafana-operator-experience-squad,false,false,true
@@ -130,7 +129,7 @@ preserveDashboardStateWhenNavigating,experimental,@grafana/dashboards-squad,fals
alertingCentralAlertHistory,experimental,@grafana/alerting-squad,false,false,true
pluginProxyPreserveTrailingSlash,GA,@grafana/plugins-platform-backend,false,false,false
azureMonitorPrometheusExemplars,GA,@grafana/partner-datasources,false,false,false
pinNavItems,GA,@grafana/grafana-frontend-platform,false,false,false
pinNavItems,GA,@grafana/grafana-search-navigate-organise,false,false,false
authZGRPCServer,experimental,@grafana/identity-access-team,false,false,false
ssoSettingsLDAP,GA,@grafana/identity-access-team,false,true,false
zanzana,experimental,@grafana/identity-access-team,false,false,false
@@ -181,14 +180,13 @@ alertingNotificationsStepMode,GA,@grafana/alerting-squad,false,false,true
feedbackButton,experimental,@grafana/grafana-operator-experience-squad,false,false,false
unifiedStorageSearchUI,experimental,@grafana/search-and-storage,false,false,false
elasticsearchCrossClusterSearch,GA,@grafana/partner-datasources,false,false,false
unifiedHistory,experimental,@grafana/grafana-frontend-platform,false,false,true
unifiedHistory,experimental,@grafana/grafana-search-navigate-organise,false,false,true
lokiLabelNamesQueryApi,GA,@grafana/observability-logs,false,false,false
investigationsBackend,experimental,@grafana/grafana-app-platform-squad,false,false,false
k8SFolderCounts,experimental,@grafana/search-and-storage,false,false,false
k8SFolderMove,experimental,@grafana/search-and-storage,false,false,false
improvedExternalSessionHandlingSAML,GA,@grafana/identity-access-team,false,false,false
teamHttpHeadersTempo,experimental,@grafana/identity-access-team,false,false,false
templateVariablesUsesCombobox,experimental,@grafana/grafana-frontend-platform,false,false,true
grafanaAdvisor,privatePreview,@grafana/plugins-platform-backend,false,false,false
elasticsearchImprovedParsing,experimental,@grafana/aws-datasources,false,false,false
datasourceConnectionsTab,privatePreview,@grafana/plugins-platform-backend,false,false,true
@@ -197,8 +195,8 @@ newLogsPanel,GA,@grafana/observability-logs,false,false,true
grafanaconThemes,GA,@grafana/grafana-frontend-platform,false,true,false
alertingJiraIntegration,experimental,@grafana/alerting-squad,false,false,true
alertingUseNewSimplifiedRoutingHashAlgorithm,preview,@grafana/alerting-squad,false,true,false
useScopesNavigationEndpoint,experimental,@grafana/grafana-frontend-platform,false,false,true
scopeSearchAllLevels,experimental,@grafana/grafana-frontend-platform,false,false,false
useScopesNavigationEndpoint,experimental,@grafana/grafana-operator-experience-squad,false,false,true
scopeSearchAllLevels,experimental,@grafana/grafana-operator-experience-squad,false,false,false
alertingRuleVersionHistoryRestore,GA,@grafana/alerting-squad,false,false,true
newShareReportDrawer,preview,@grafana/grafana-operator-experience-squad,false,false,false
rendererDisableAppPluginsPreload,experimental,@grafana/grafana-operator-experience-squad,false,false,true
@@ -230,7 +228,7 @@ kubernetesAuthZHandlerRedirect,experimental,@grafana/identity-access-team,false,
kubernetesAuthzResourcePermissionApis,experimental,@grafana/identity-access-team,false,false,false
kubernetesAuthzZanzanaSync,experimental,@grafana/identity-access-team,false,false,false
kubernetesAuthnMutation,experimental,@grafana/identity-access-team,false,false,false
restoreDashboards,experimental,@grafana/grafana-frontend-platform,false,false,false
restoreDashboards,experimental,@grafana/grafana-search-navigate-organise,false,false,false
alertEnrichment,experimental,@grafana/alerting-squad,false,false,false
alertEnrichmentMultiStep,experimental,@grafana/alerting-squad,false,false,false
alertEnrichmentConditional,experimental,@grafana/alerting-squad,false,false,false
@@ -264,6 +262,7 @@ cdnPluginsLoadFirst,experimental,@grafana/plugins-platform-backend,false,false,f
cdnPluginsUrls,experimental,@grafana/plugins-platform-backend,false,false,false
pluginInstallAPISync,experimental,@grafana/plugins-platform-backend,false,false,false
newGauge,experimental,@grafana/dataviz-squad,false,false,true
newVizSuggestions,preview,@grafana/dataviz-squad,false,false,true
preventPanelChromeOverflow,preview,@grafana/grafana-frontend-platform,false,false,true
jaegerEnableGrpcEndpoint,experimental,@grafana/oss-big-tent,false,false,false
pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
6 lokiExperimentalStreaming experimental @grafana/observability-logs false false false
7 featureHighlights GA @grafana/grafana-operator-experience-squad false false false
8 storage experimental @grafana/search-and-storage false false false
correlations GA @grafana/datapro false false false
9 canvasPanelNesting experimental @grafana/dataviz-squad false false true
10 logRequestsInstrumentedAsUnknown experimental @grafana/grafana-backend-group false false false
11 grpcServer preview @grafana/search-and-storage false false false
18 lokiQuerySplitting GA @grafana/observability-logs false false true
19 individualCookiePreferences experimental @grafana/grafana-backend-group false false false
20 influxdbBackendMigration GA @grafana/partner-datasources false false true
21 starsFromAPIServer experimental @grafana/grafana-frontend-platform @grafana/grafana-search-navigate-organise false false true
22 kubernetesStars experimental @grafana/grafana-app-platform-squad false true false
23 influxqlStreamingParser experimental @grafana/partner-datasources false false false
24 influxdbRunQueriesInParallel privatePreview @grafana/partner-datasources false false false
76 addFieldFromCalculationStatFunctions GA @grafana/datapro false false true
77 alertmanagerRemoteSecondary experimental @grafana/alerting-squad false false false
78 alertingProvenanceLockWrites experimental @grafana/alerting-squad false false false
79 alertingUIUseBackendFilters experimental @grafana/alerting-squad false false false
80 alertmanagerRemotePrimary experimental @grafana/alerting-squad false false false
81 annotationPermissionUpdate GA @grafana/identity-access-team false false false
82 dashboardSceneForViewers GA @grafana/dashboards-squad false false true
91 timeComparison experimental @grafana/dataviz-squad false false true
92 logsInfiniteScrolling GA @grafana/observability-logs false false true
93 logRowsPopoverMenu GA @grafana/observability-logs false false true
pluginsSkipHostEnvVars experimental @grafana/plugins-platform-backend false false false
94 tableSharedCrosshair experimental @grafana/dataviz-squad false false true
95 regressionTransformation preview @grafana/datapro false false true
96 kubernetesFeatureToggles experimental @grafana/grafana-operator-experience-squad false false true
129 alertingCentralAlertHistory experimental @grafana/alerting-squad false false true
130 pluginProxyPreserveTrailingSlash GA @grafana/plugins-platform-backend false false false
131 azureMonitorPrometheusExemplars GA @grafana/partner-datasources false false false
132 pinNavItems GA @grafana/grafana-frontend-platform @grafana/grafana-search-navigate-organise false false false
133 authZGRPCServer experimental @grafana/identity-access-team false false false
134 ssoSettingsLDAP GA @grafana/identity-access-team false true false
135 zanzana experimental @grafana/identity-access-team false false false
180 feedbackButton experimental @grafana/grafana-operator-experience-squad false false false
181 unifiedStorageSearchUI experimental @grafana/search-and-storage false false false
182 elasticsearchCrossClusterSearch GA @grafana/partner-datasources false false false
183 unifiedHistory experimental @grafana/grafana-frontend-platform @grafana/grafana-search-navigate-organise false false true
184 lokiLabelNamesQueryApi GA @grafana/observability-logs false false false
185 investigationsBackend experimental @grafana/grafana-app-platform-squad false false false
186 k8SFolderCounts experimental @grafana/search-and-storage false false false
187 k8SFolderMove experimental @grafana/search-and-storage false false false
188 improvedExternalSessionHandlingSAML GA @grafana/identity-access-team false false false
189 teamHttpHeadersTempo experimental @grafana/identity-access-team false false false
templateVariablesUsesCombobox experimental @grafana/grafana-frontend-platform false false true
190 grafanaAdvisor privatePreview @grafana/plugins-platform-backend false false false
191 elasticsearchImprovedParsing experimental @grafana/aws-datasources false false false
192 datasourceConnectionsTab privatePreview @grafana/plugins-platform-backend false false true
195 grafanaconThemes GA @grafana/grafana-frontend-platform false true false
196 alertingJiraIntegration experimental @grafana/alerting-squad false false true
197 alertingUseNewSimplifiedRoutingHashAlgorithm preview @grafana/alerting-squad false true false
198 useScopesNavigationEndpoint experimental @grafana/grafana-frontend-platform @grafana/grafana-operator-experience-squad false false true
199 scopeSearchAllLevels experimental @grafana/grafana-frontend-platform @grafana/grafana-operator-experience-squad false false false
200 alertingRuleVersionHistoryRestore GA @grafana/alerting-squad false false true
201 newShareReportDrawer preview @grafana/grafana-operator-experience-squad false false false
202 rendererDisableAppPluginsPreload experimental @grafana/grafana-operator-experience-squad false false true
228 kubernetesAuthzResourcePermissionApis experimental @grafana/identity-access-team false false false
229 kubernetesAuthzZanzanaSync experimental @grafana/identity-access-team false false false
230 kubernetesAuthnMutation experimental @grafana/identity-access-team false false false
231 restoreDashboards experimental @grafana/grafana-frontend-platform @grafana/grafana-search-navigate-organise false false false
232 alertEnrichment experimental @grafana/alerting-squad false false false
233 alertEnrichmentMultiStep experimental @grafana/alerting-squad false false false
234 alertEnrichmentConditional experimental @grafana/alerting-squad false false false
262 cdnPluginsUrls experimental @grafana/plugins-platform-backend false false false
263 pluginInstallAPISync experimental @grafana/plugins-platform-backend false false false
264 newGauge experimental @grafana/dataviz-squad false false true
265 newVizSuggestions preview @grafana/dataviz-squad false false true
266 preventPanelChromeOverflow preview @grafana/grafana-frontend-platform false false true
267 jaegerEnableGrpcEndpoint experimental @grafana/oss-big-tent false false false
268 pluginStoreServiceLoading experimental @grafana/plugins-platform-backend false false false
+8 -12
View File
@@ -35,10 +35,6 @@ const (
// Configurable storage for dashboards, datasources, and resources
FlagStorage = "storage"
// FlagCorrelations
// Correlations page
FlagCorrelations = "correlations"
// FlagCanvasPanelNesting
// Allow elements nesting
FlagCanvasPanelNesting = "canvasPanelNesting"
@@ -319,6 +315,10 @@ const (
// Enables a feature to avoid issues with concurrent writes to the alerting provenance table in MySQL
FlagAlertingProvenanceLockWrites = "alertingProvenanceLockWrites"
// FlagAlertingUIUseBackendFilters
// Enables the UI to use certain backend-side filters
FlagAlertingUIUseBackendFilters = "alertingUIUseBackendFilters"
// FlagAlertmanagerRemotePrimary
// Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager.
FlagAlertmanagerRemotePrimary = "alertmanagerRemotePrimary"
@@ -375,10 +375,6 @@ const (
// Enable filtering menu displayed when text of a log line is selected
FlagLogRowsPopoverMenu = "logRowsPopoverMenu"
// FlagPluginsSkipHostEnvVars
// Disables passing host environment variable to plugin processes
FlagPluginsSkipHostEnvVars = "pluginsSkipHostEnvVars"
// FlagTableSharedCrosshair
// Enables shared crosshair in table panel
FlagTableSharedCrosshair = "tableSharedCrosshair"
@@ -763,10 +759,6 @@ const (
// Enables LBAC for datasources for Tempo to apply LBAC filtering of traces to the client requests for users in teams
FlagTeamHttpHeadersTempo = "teamHttpHeadersTempo"
// FlagTemplateVariablesUsesCombobox
// Use new **Combobox** component for template variables
FlagTemplateVariablesUsesCombobox = "templateVariablesUsesCombobox"
// FlagGrafanaAdvisor
// Enables Advisor app
FlagGrafanaAdvisor = "grafanaAdvisor"
@@ -1066,6 +1058,10 @@ const (
// Enable new gauge visualization
FlagNewGauge = "newGauge"
// FlagNewVizSuggestions
// Enable new visualization suggestions
FlagNewVizSuggestions = "newVizSuggestions"
// FlagPreventPanelChromeOverflow
// Restrict PanelChrome contents with overflow: hidden;
FlagPreventPanelChromeOverflow = "preventPanelChromeOverflow"
+69 -20
View File
@@ -614,6 +614,20 @@
"expression": "true"
}
},
{
"metadata": {
"name": "alertingUIUseBackendFilters",
"resourceVersion": "1762966218072",
"creationTimestamp": "2025-11-12T16:50:18Z"
},
"spec": {
"description": "Enables the UI to use certain backend-side filters",
"stage": "experimental",
"codeowner": "@grafana/alerting-squad",
"hideFromAdminPage": true,
"hideFromDocs": true
}
},
{
"metadata": {
"name": "alertingUseNewSimplifiedRoutingHashAlgorithm",
@@ -1024,6 +1038,7 @@
"name": "correlations",
"resourceVersion": "1762442825881",
"creationTimestamp": "2022-09-16T13:14:27Z",
"deletionTimestamp": "2025-11-12T13:11:31Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-11-06 15:27:05.88172 +0000 UTC"
}
@@ -2896,6 +2911,20 @@
"hideFromDocs": true
}
},
{
"metadata": {
"name": "newVizSuggestions",
"resourceVersion": "1762456851857",
"creationTimestamp": "2025-11-06T19:20:51Z"
},
"spec": {
"description": "Enable new visualization suggestions",
"stage": "preview",
"codeowner": "@grafana/dataviz-squad",
"frontend": true,
"expression": "false"
}
},
{
"metadata": {
"name": "oauthRequireSubClaim",
@@ -3063,13 +3092,16 @@
{
"metadata": {
"name": "pinNavItems",
"resourceVersion": "1753448760331",
"creationTimestamp": "2024-06-10T11:40:03Z"
"resourceVersion": "1762958248290",
"creationTimestamp": "2024-06-10T11:40:03Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC"
}
},
"spec": {
"description": "Enables pinning of nav items",
"stage": "GA",
"codeowner": "@grafana/grafana-frontend-platform",
"codeowner": "@grafana/grafana-search-navigate-organise",
"expression": "true"
}
},
@@ -3188,7 +3220,8 @@
"metadata": {
"name": "pluginsSkipHostEnvVars",
"resourceVersion": "1753448760331",
"creationTimestamp": "2023-11-15T17:09:14Z"
"creationTimestamp": "2023-11-15T17:09:14Z",
"deletionTimestamp": "2025-11-04T16:51:13Z"
},
"spec": {
"description": "Disables passing host environment variable to plugin processes",
@@ -3589,13 +3622,16 @@
{
"metadata": {
"name": "restoreDashboards",
"resourceVersion": "1753448760331",
"creationTimestamp": "2025-05-23T14:35:54Z"
"resourceVersion": "1762958248290",
"creationTimestamp": "2025-05-23T14:35:54Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC"
}
},
"spec": {
"description": "Enables restore deleted dashboards feature",
"stage": "experimental",
"codeowner": "@grafana/grafana-frontend-platform",
"codeowner": "@grafana/grafana-search-navigate-organise",
"hideFromAdminPage": true,
"expression": "false"
}
@@ -3690,13 +3726,16 @@
{
"metadata": {
"name": "scopeSearchAllLevels",
"resourceVersion": "1753448760331",
"creationTimestamp": "2025-04-14T07:42:16Z"
"resourceVersion": "1762958248290",
"creationTimestamp": "2025-04-14T07:42:16Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC"
}
},
"spec": {
"description": "Enable scope search to include all levels of the scope node tree",
"stage": "experimental",
"codeowner": "@grafana/grafana-frontend-platform",
"codeowner": "@grafana/grafana-operator-experience-squad",
"hideFromAdminPage": true,
"hideFromDocs": true
}
@@ -3846,13 +3885,16 @@
{
"metadata": {
"name": "starsFromAPIServer",
"resourceVersion": "1758276055065",
"creationTimestamp": "2025-09-19T10:00:55Z"
"resourceVersion": "1762958248290",
"creationTimestamp": "2025-09-19T10:00:55Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC"
}
},
"spec": {
"description": "populate star status from apiserver",
"stage": "experimental",
"codeowner": "@grafana/grafana-frontend-platform",
"codeowner": "@grafana/grafana-search-navigate-organise",
"frontend": true,
"hideFromDocs": true
}
@@ -3979,7 +4021,8 @@
"metadata": {
"name": "templateVariablesUsesCombobox",
"resourceVersion": "1753448760331",
"creationTimestamp": "2025-01-31T09:53:13Z"
"creationTimestamp": "2025-01-31T09:53:13Z",
"deletionTimestamp": "2025-11-12T14:40:39Z"
},
"spec": {
"description": "Use new **Combobox** component for template variables",
@@ -4091,13 +4134,16 @@
{
"metadata": {
"name": "unifiedHistory",
"resourceVersion": "1753448760331",
"creationTimestamp": "2024-12-13T10:41:18Z"
"resourceVersion": "1762958248290",
"creationTimestamp": "2024-12-13T10:41:18Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC"
}
},
"spec": {
"description": "Displays the navigation history so the user can navigate back to previous pages",
"stage": "experimental",
"codeowner": "@grafana/grafana-frontend-platform",
"codeowner": "@grafana/grafana-search-navigate-organise",
"frontend": true
}
},
@@ -4325,13 +4371,16 @@
{
"metadata": {
"name": "useScopesNavigationEndpoint",
"resourceVersion": "1753448760331",
"creationTimestamp": "2025-03-31T15:20:00Z"
"resourceVersion": "1762958248290",
"creationTimestamp": "2025-03-31T15:20:00Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC"
}
},
"spec": {
"description": "Use the scopes navigation endpoint instead of the dashboardbindings endpoint",
"stage": "experimental",
"codeowner": "@grafana/grafana-frontend-platform",
"codeowner": "@grafana/grafana-operator-experience-squad",
"frontend": true,
"hideFromAdminPage": true,
"hideFromDocs": true
+10
View File
@@ -220,6 +220,16 @@
}
const resp = await fetch(bootDataUrl);
// manual redirect for custom domains
// see pkg/middleware/validate_host.go
if (resp.status === 204) {
const redirectDomain = resp.headers.get('Redirect-Domain');
if (redirectDomain) {
window.location.hostname = redirectDomain;
return;
}
}
const textResponse = await resp.text();
let rawBootData;
+1 -1
View File
@@ -89,7 +89,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink
Url: s.cfg.AppSubURL + "/plugins",
})
}
if s.features.IsEnabled(ctx, featuremgmt.FlagCorrelations) && hasAccess(correlations.ConfigurationPageAccess) {
if hasAccess(correlations.ConfigurationPageAccess) {
pluginsNodeLinks = append(pluginsNodeLinks, &navtree.NavLink{
Text: "Correlations",
Icon: "gf-glue",
@@ -2215,6 +2215,7 @@ func createProvisioningSrvSutFromEnv(t *testing.T, env *testEnvironment) Provisi
env.log,
ngalertfakes.NewFakeReceiverPermissionsService(),
tracer,
false,
)
return ProvisioningSrv{
log: env.log,
@@ -479,6 +479,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
ruleGroups := opts.Query["rule_group"]
receiverName := opts.Query.Get("receiver_name")
title := opts.Query.Get("search.rule_name")
maxGroups := getInt64WithDefault(opts.Query, "group_limit", -1)
nextToken := opts.Query.Get("group_next_token")
@@ -495,6 +496,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
PanelID: panelID,
RuleGroups: ruleGroups,
ReceiverName: receiverName,
SearchTitle: title,
},
Limit: maxGroups,
ContinueToken: nextToken,
@@ -624,6 +626,7 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru
ruleGroups := opts.Query["rule_group"]
receiverName := opts.Query.Get("receiver_name")
title := opts.Query.Get("search.rule_name")
alertRuleQuery := ngmodels.ListAlertRulesQuery{
OrgID: opts.OrgID,
@@ -632,6 +635,7 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru
PanelID: panelID,
RuleGroups: ruleGroups,
ReceiverName: receiverName,
SearchTitle: title,
}
ruleList, err := store.ListAlertRules(opts.Ctx, &alertRuleQuery)
if err != nil {
@@ -978,6 +978,9 @@ type ListAlertRulesQuery struct {
ReceiverName string
TimeIntervalName string
// SearchTitle allows searching for alert rules that contain
// the given string in their title (case insensitive)
SearchTitle string
HasPrometheusRuleDefinition *bool
}
+12
View File
@@ -1256,6 +1256,18 @@ func (n ReceiverMutators) WithOrigin(origin ResourceOrigin) Mutator[Receiver] {
}
}
func (n ReceiverMutators) WithEmptyIntegrations() Mutator[Receiver] {
return func(r *Receiver) {
r.Integrations = []*Integration{}
}
}
func (n ReceiverMutators) WithUID(uid string) Mutator[Receiver] {
return func(r *Receiver) {
r.UID = uid
}
}
// Integrations
// CopyIntegrationWith creates a deep copy of Integration and then applies mutators to it.
+3
View File
@@ -408,6 +408,8 @@ func (ng *AlertNG) init() error {
ng.Log,
ng.ResourcePermissions,
ng.tracer,
//nolint:staticcheck // not yet migrated to OpenFeature
ng.FeatureToggles.IsEnabledGlobally(featuremgmt.FlagAlertingImportAlertmanagerAPI),
)
provisioningReceiverService := notifier.NewReceiverService(
ac.NewReceiverAccess[*models.Receiver](ng.accesscontrol, true),
@@ -419,6 +421,7 @@ func (ng *AlertNG) init() error {
ng.Log,
ng.ResourcePermissions,
ng.tracer,
false, // imported resources are not exposed via provisioning APIs
)
// Provisioning
@@ -71,6 +71,13 @@ func PostableApiReceiverToReceiver(postable *apimodels.PostableApiReceiver, prov
if err != nil {
return nil, err
}
if postable.HasMimirIntegrations() {
mimir, err := PostableMimirReceiverToIntegrations(postable.Receiver)
if err != nil {
return nil, err
}
integrations = append(integrations, mimir...)
}
r := &models.Receiver{
UID: NameToUid(postable.GetName()), // TODO replace with stable UID.
Name: postable.GetName(),
@@ -117,6 +124,26 @@ func PostableGrafanaReceiversToIntegrations(postables []*apimodels.PostableGrafa
return integrations, nil
}
func PostableMimirReceiverToIntegrations(r alertingNotify.ConfigReceiver) ([]*models.Integration, error) {
v0, err := alertingNotify.ConfigReceiverToMimirIntegrations(r)
if err != nil {
return nil, fmt.Errorf("failed to convert v0 receiver to integrations: %w", err)
}
result := make([]*models.Integration, 0, len(v0))
for _, config := range v0 {
s, err := config.ConfigMap()
if err != nil {
return nil, fmt.Errorf("failed to get settings of v0 receiver %s (version %s): %w", config.Schema.Type(), config.Schema.Version, err)
}
result = append(result, &models.Integration{
Config: config.Schema,
Settings: s,
SecureSettings: map[string]string{},
})
}
return result, nil
}
func PostableGrafanaReceiverToIntegration(p *apimodels.PostableGrafanaReceiver) (*models.Integration, error) {
integrationType, err := alertingNotify.IntegrationTypeFromString(p.Type)
if err != nil {
@@ -0,0 +1,59 @@
package legacy_storage
import (
"fmt"
"reflect"
"testing"
"github.com/grafana/alerting/definition"
"github.com/grafana/alerting/notify"
"github.com/grafana/alerting/notify/notifytest"
"github.com/grafana/alerting/receivers/schema"
"github.com/grafana/alerting/receivers/teams"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPostableMimirReceiverToIntegrations(t *testing.T) {
t.Run("can convert all known types", func(t *testing.T) {
notifytest.ForEachIntegrationTypeReceiver(t, func(configType reflect.Type, receiver notify.ConfigReceiver, rawConfig string) {
expectedType, err := notify.IntegrationTypeFromMimirTypeReflect(configType)
assert.NoError(t, err)
expectedVersion := schema.V0mimir1
if configType.Name() == "MSTeamsConfig" {
expectedType = teams.Type
}
if configType.Name() == "MSTeamsV2Config" {
expectedType = teams.Type
expectedVersion = schema.V0mimir2
}
t.Run(fmt.Sprintf("%s as %s %s", configType.Name(), expectedType, expectedVersion), func(t *testing.T) {
integrations, err := PostableMimirReceiverToIntegrations(receiver)
require.NoError(t, err)
require.Len(t, integrations, 1)
integration := integrations[0]
rawSettings, err := definition.MarshalJSONWithSecrets(integration.Settings)
require.NoError(t, err)
assert.EqualValues(t, expectedVersion, integration.Config.Version)
assert.EqualValues(t, expectedType, integration.Config.Type())
assert.JSONEq(t, rawConfig, string(rawSettings))
assert.Empty(t, integration.SecureSettings)
})
})
})
t.Run("can convert receiver with all integrations", func(t *testing.T) {
recv, err := notifytest.GetMimirReceiverWithAllIntegrations()
require.NoError(t, err)
integrations, err := PostableMimirReceiverToIntegrations(recv)
require.NoError(t, err)
require.Len(t, integrations, len(notifytest.AllValidMimirConfigs))
})
t.Run("returns empty if receiver has no integrations", func(t *testing.T) {
integrations, err := PostableMimirReceiverToIntegrations(notify.ConfigReceiver{Name: "test"})
require.NoError(t, err)
require.Empty(t, integrations)
})
}
@@ -0,0 +1,87 @@
package legacy_storage
import (
"fmt"
"slices"
"github.com/grafana/alerting/definition"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
type ImportedConfigRevision struct {
rev *ConfigRevision
opts definition.MergeOpts
importedConfig *definition.PostableApiAlertingConfig
}
func (rev *ConfigRevision) Imported() (ImportedConfigRevision, error) {
result := ImportedConfigRevision{
rev: rev,
}
if len(rev.Config.ExtraConfigs) == 0 {
return result, nil
}
// support only one config for now
mimirCfg := rev.Config.ExtraConfigs[0]
opts := definition.MergeOpts{
DedupSuffix: mimirCfg.Identifier,
SubtreeMatchers: mimirCfg.MergeMatchers,
}
if err := opts.Validate(); err != nil {
return result, fmt.Errorf("invalid merge options: %w", err)
}
mcfg, err := mimirCfg.GetAlertmanagerConfig()
if err != nil {
return result, fmt.Errorf("failed to get mimir alertmanager config: %w", err)
}
result.importedConfig = &mcfg
result.opts = opts
return result, nil
}
func (e ImportedConfigRevision) GetReceivers(uids []string) ([]*models.Receiver, error) {
if e.importedConfig == nil {
return nil, nil
}
original := e.rev.Config.AlertmanagerConfig.GetReceivers()
merged, _ := definition.MergeReceivers(original, e.importedConfig.GetReceivers(), e.opts.DedupSuffix)
capacity := len(uids)
if capacity == 0 {
capacity = len(e.importedConfig.Receivers)
}
result := make([]*models.Receiver, 0, capacity)
// merged config contains all receivers from both. We only want the ones from the staged config. However, we need to rename them if necessary.
for _, r := range merged[len(original):] {
uid := NameToUid(r.Name)
if len(uids) > 0 && !slices.Contains(uids, uid) {
continue
}
recv, err := PostableApiReceiverToReceiver(r, models.ProvenanceConvertedPrometheus, models.ResourceOriginImported)
if err != nil {
return nil, fmt.Errorf("failed to convert receiver %q: %w", r.Name, err)
}
result = append(result, recv)
}
return result, nil
}
// ReceiverUseByName returns a map of receiver names to the number of times they are used in routes.
func (e ImportedConfigRevision) ReceiverUseByName() map[string]int {
if e.importedConfig == nil {
return nil
}
m := make(map[string]int)
receiverUseCounts([]*definitions.Route{e.importedConfig.Route}, m)
_, renames := definition.MergeReceivers(e.rev.Config.AlertmanagerConfig.GetReceivers(), e.importedConfig.GetReceivers(), e.opts.DedupSuffix)
for original, renamed := range renames {
if cnt, ok := m[original]; ok {
delete(m, original)
m[renamed] = cnt
}
}
return m
}
@@ -0,0 +1,223 @@
package legacy_storage
import (
"testing"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/pkg/labels"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
func TestConfigRevisionImported(t *testing.T) {
t.Run("should return error if extra config is invalid", func(t *testing.T) {
rev := getConfigRevisionForTest(withExtraConfig(extraConfig("invalid")))
_, err := rev.Imported()
require.Error(t, err)
})
t.Run("should return imported config if extra config is valid even if it cannot be fully merged", func(t *testing.T) {
rev := getConfigRevisionForTest(withExtraConfigAndConflictingMatchers())
imported, err := rev.Imported()
require.NoError(t, err)
require.NotNil(t, imported)
})
t.Run("should return imported config if extra config is valid", func(t *testing.T) {
rev := getConfigRevisionForTest(withExtraConfig(extraConfig(extraConfigurationYaml)))
imported, err := rev.Imported()
require.NoError(t, err)
require.NotNil(t, imported)
})
}
func TestConfigRevisionImported_GetReceivers(t *testing.T) {
rev := getConfigRevisionForTest(withExtraConfig(extraConfig(extraConfigurationYaml)))
imported, err := rev.Imported()
require.NoError(t, err)
t.Run("should return all receivers available in imported config", func(t *testing.T) {
receivers, err := imported.GetReceivers(nil)
require.NoError(t, err)
require.Len(t, receivers, 2)
assert.Equal(t, "imported-receiver-1", receivers[0].Name)
assert.Equal(t, NameToUid("imported-receiver-1"), receivers[0].UID)
assert.Equal(t, models.ProvenanceConvertedPrometheus, receivers[0].Provenance)
assert.Equal(t, models.ResourceOriginImported, receivers[0].Origin)
assert.Len(t, receivers[0].Integrations, 1)
assert.Equal(t, "imported-receiver-2", receivers[1].Name)
assert.Equal(t, NameToUid("imported-receiver-2"), receivers[1].UID)
assert.Equal(t, models.ProvenanceConvertedPrometheus, receivers[1].Provenance)
assert.Equal(t, models.ResourceOriginImported, receivers[1].Origin)
assert.Len(t, receivers[1].Integrations, 1)
})
t.Run("should filter receivers in imported config", func(t *testing.T) {
receivers, err := imported.GetReceivers([]string{models.NameToUid("imported-receiver-2")})
require.NoError(t, err)
require.Len(t, receivers, 1)
assert.Equal(t, "imported-receiver-2", receivers[0].Name)
assert.Equal(t, NameToUid("imported-receiver-2"), receivers[0].UID)
receivers, err = imported.GetReceivers([]string{models.NameToUid("not-found")})
require.NoError(t, err)
assert.Empty(t, receivers)
})
t.Run("should be correctly named in case of renames", func(t *testing.T) {
const dupeConfig = `
route:
receiver: receiver1
receivers:
- name: receiver1
- name: dupe-receiver
`
extra := extraConfig(dupeConfig)
expectedDedupSuffix := extra.Identifier
rev := getConfigRevisionForTest(withExtraConfig(extra))
imported, err = rev.Imported()
require.NoError(t, err)
result, err := imported.GetReceivers(nil)
require.NoError(t, err)
require.Len(t, result, 2)
assert.Equal(t, "receiver1"+expectedDedupSuffix, result[0].Name)
assert.Equal(t, models.NameToUid("receiver1"+expectedDedupSuffix), result[0].UID)
assert.Equal(t, "dupe-receiver"+expectedDedupSuffix, result[1].Name)
assert.Equal(t, models.NameToUid("dupe-receiver"+expectedDedupSuffix), result[1].UID)
t.Run("should search by renamed uid", func(t *testing.T) {
result, err = imported.GetReceivers([]string{NameToUid("receiver1")})
require.NoError(t, err)
require.Empty(t, result)
result, err = imported.GetReceivers([]string{NameToUid("receiver1" + expectedDedupSuffix)})
require.NoError(t, err)
require.Len(t, result, 1)
})
})
t.Run("should return empty list if no imported configuration", func(t *testing.T) {
rev := getConfigRevisionForTest()
imported, err = rev.Imported()
require.NoError(t, err)
assert.Nil(t, imported.importedConfig)
result, err := imported.GetReceivers(nil)
require.NoError(t, err)
require.Empty(t, result)
})
}
func TestConfigRevisionImported_ReceiverUseByName(t *testing.T) {
t.Run("should be empty if no configuration", func(t *testing.T) {
rev := getConfigRevisionForTest()
imported, err := rev.Imported()
require.NoError(t, err)
require.Empty(t, imported.ReceiverUseByName())
})
t.Run("should count usages of the receivers", func(t *testing.T) {
const cfg = `
route:
receiver: r1
routes:
- receiver: r2
routes:
- receiver: r1
- routes:
- receiver: r2
receivers:
- name: r1
- name: r2
`
extra := extraConfig(cfg)
rev := getConfigRevisionForTest(withExtraConfig(extra))
imported, err := rev.Imported()
require.NoError(t, err)
actual := imported.ReceiverUseByName()
require.EqualValues(t, map[string]int{
"": 1,
"r1": 2,
"r2": 2,
}, actual)
})
t.Run("should correctly handle deduplicated names", func(t *testing.T) {
const dupeConfig = `
route:
receiver: receiver1
routes:
- receiver: dupe-receiver
- receiver: r1
receivers:
- name: receiver1
- name: dupe-receiver
- name: r1
`
extra := extraConfig(dupeConfig)
expectedDedupSuffix := extra.Identifier
rev := getConfigRevisionForTest(withExtraConfig(extra))
imported, err := rev.Imported()
require.NoError(t, err)
actual := imported.ReceiverUseByName()
require.EqualValues(t, map[string]int{
"receiver1" + expectedDedupSuffix: 1,
"dupe-receiver" + expectedDedupSuffix: 1,
"r1": 1,
}, actual)
})
}
func extraConfig(yamlString string) definitions.ExtraConfiguration {
return definitions.ExtraConfiguration{
Identifier: "test",
MergeMatchers: config.Matchers{
&labels.Matcher{Type: labels.MatchEqual, Name: "__imported", Value: "test"},
},
AlertmanagerConfig: yamlString,
}
}
func withExtraConfig(extra definitions.ExtraConfiguration) opt {
return func(rev *ConfigRevision) {
rev.Config.ExtraConfigs = []definitions.ExtraConfiguration{
extra,
}
}
}
func withExtraConfigAndConflictingMatchers() opt {
return func(rev *ConfigRevision) {
matcher := &labels.Matcher{Type: labels.MatchEqual, Name: "__imported", Value: "test"}
rev.Config.AlertmanagerConfig.Route.Routes = append(rev.Config.AlertmanagerConfig.Route.Routes, &definitions.Route{
Matchers: []*labels.Matcher{matcher},
})
rev.Config.ExtraConfigs = []definitions.ExtraConfiguration{
{
Identifier: "test",
MergeMatchers: config.Matchers{
matcher,
},
TemplateFiles: nil,
AlertmanagerConfig: extraConfigurationYaml,
},
}
}
}
const extraConfigurationYaml = `
route:
receiver: imported-receiver-1
receivers:
- name: imported-receiver-1
webhook_configs:
- url: "http://localhost/"
- name: imported-receiver-2
webhook_configs:
- url: "http://localhost/"
`
@@ -469,8 +469,10 @@ func TestRenameReceiverInRoutes(t *testing.T) {
})
}
func getConfigRevisionForTest() *ConfigRevision {
return &ConfigRevision{
type opt func(*ConfigRevision)
func getConfigRevisionForTest(opts ...opt) *ConfigRevision {
r := &ConfigRevision{
Config: &definitions.PostableUserConfig{
AlertmanagerConfig: definitions.PostableApiAlertingConfig{
Config: definitions.Config{
@@ -519,4 +521,8 @@ func getConfigRevisionForTest() *ConfigRevision {
},
},
}
for _, o := range opts {
o(r)
}
return r
}
+121 -28
View File
@@ -55,6 +55,7 @@ type ReceiverService struct {
provenanceValidator validation.ProvenanceStatusTransitionValidator
resourcePermissions ac.ReceiverPermissionsService
tracer tracing.Tracer
includeImported bool
}
type alertRuleNotificationSettingsStore interface {
@@ -108,6 +109,7 @@ func NewReceiverService(
log log.Logger,
resourcePermissions ac.ReceiverPermissionsService,
tracer tracing.Tracer,
includeStaged bool,
) *ReceiverService {
return &ReceiverService{
authz: authz,
@@ -120,6 +122,7 @@ func NewReceiverService(
provenanceValidator: validation.ValidateProvenanceRelaxed,
resourcePermissions: resourcePermissions,
tracer: tracer,
includeImported: includeStaged,
}
}
@@ -149,7 +152,15 @@ func (rs *ReceiverService) GetReceiver(ctx context.Context, q models.GetReceiver
rcv, err := revision.GetReceiver(legacy_storage.NameToUid(q.Name), prov)
if err != nil {
return nil, err
if errors.Is(err, legacy_storage.ErrReceiverNotFound) && rs.includeImported {
imported := rs.getImportedReceivers(ctx, span, []string{legacy_storage.NameToUid(q.Name)}, revision)
if len(imported) > 0 {
rcv = imported[0]
}
}
if rcv == nil {
return nil, err
}
}
span.AddEvent("Loaded receiver", trace.WithAttributes(
@@ -216,6 +227,11 @@ func (rs *ReceiverService) GetReceivers(ctx context.Context, q models.GetReceive
attribute.Int("count", len(receivers)),
))
if rs.includeImported {
imported := rs.getImportedReceivers(ctx, span, uids, revision)
receivers = append(receivers, imported...)
}
filterFn := rs.authz.FilterReadDecrypted
if !q.Decrypt {
filterFn = rs.authz.FilterRead
@@ -270,14 +286,17 @@ func (rs *ReceiverService) DeleteReceiver(ctx context.Context, uid string, calle
existing, err := revision.GetReceiver(uid, prov)
if err != nil {
if errors.Is(err, legacy_storage.ErrReceiverNotFound) {
return nil
if !errors.Is(err, legacy_storage.ErrReceiverNotFound) {
return err
}
return err
}
if existing.Origin != models.ResourceOriginGrafana {
return makeErrReceiverOrigin(existing, "delete")
if rs.includeImported {
// try to get the imported receiver and return a specific error if it exists
result := rs.getImportedReceivers(ctx, span, []string{uid}, revision)
if len(result) > 0 {
return makeErrReceiverOrigin(result[0], "delete")
}
}
return nil
}
logger := rs.log.FromContext(ctx).New("receiver", existing.Name, "uid", uid, "version", version, "integrations", existing.GetIntegrationTypes())
@@ -395,6 +414,10 @@ func (rs *ReceiverService) UpdateReceiver(ctx context.Context, r *models.Receive
attribute.StringSlice("integrations", r.GetIntegrationTypes()),
))
defer span.End()
// check origin of the provided receiver
if r.Origin != models.ResourceOriginGrafana {
return nil, makeErrReceiverOrigin(r, "update")
}
if err := rs.authz.AuthorizeUpdate(ctx, user, r); err != nil {
return nil, err
@@ -415,6 +438,13 @@ func (rs *ReceiverService) UpdateReceiver(ctx context.Context, r *models.Receive
existing, err := revision.GetReceiver(r.GetUID(), prov)
if err != nil {
if errors.Is(err, legacy_storage.ErrReceiverNotFound) && rs.includeImported {
// try to get the imported receiver and return a specific error if it exists
result := rs.getImportedReceivers(ctx, span, []string{r.GetUID()}, revision)
if len(result) > 0 {
return nil, makeErrReceiverOrigin(result[0], "update")
}
}
return nil, err
}
@@ -440,10 +470,6 @@ func (rs *ReceiverService) UpdateReceiver(ctx context.Context, r *models.Receive
return nil, err
}
if existing.Origin != models.ResourceOriginGrafana {
return nil, makeErrReceiverOrigin(existing, "update")
}
if err := rs.provenanceValidator(existing.Provenance, r.Provenance); err != nil {
return nil, err
}
@@ -519,7 +545,21 @@ func (rs *ReceiverService) UsedByRules(ctx context.Context, orgID int64, name st
// AccessControlMetadata returns access control metadata for the given Receivers.
func (rs *ReceiverService) AccessControlMetadata(ctx context.Context, user identity.Requester, receivers ...*models.Receiver) (map[string]models.ReceiverPermissionSet, error) {
return rs.authz.Access(ctx, user, receivers...)
permissions, err := rs.authz.Access(ctx, user, receivers...)
if err != nil {
return nil, err
}
for _, m := range receivers {
if m.Origin == models.ResourceOriginGrafana {
continue
}
perms := permissions[m.GetUID()]
perms.Set(models.ReceiverPermissionAdmin, false)
perms.Set(models.ReceiverPermissionWrite, false)
perms.Set(models.ReceiverPermissionDelete, false)
permissions[m.GetUID()] = perms
}
return permissions, nil
}
// InUseMetadata returns metadata for the given Receivers about their usage in routes and rules.
@@ -528,31 +568,64 @@ func (rs *ReceiverService) InUseMetadata(ctx context.Context, orgID int64, recei
if err != nil {
return nil, err
}
receiverUses := revision.ReceiverUseByName()
q := models.ListNotificationSettingsQuery{OrgID: orgID}
if len(receivers) == 1 {
q.ReceiverName = receivers[0].Name
}
keys, err := rs.ruleNotificationsStore.ListNotificationSettings(ctx, q)
if err != nil {
return nil, err
var hasGrafanaOrigin, hasImportedOrigin bool
for i := range receivers {
switch receivers[i].Origin {
case models.ResourceOriginGrafana:
hasGrafanaOrigin = true
case models.ResourceOriginImported:
hasImportedOrigin = true
}
if hasGrafanaOrigin && hasImportedOrigin {
break
}
}
byReceiver := map[string][]models.AlertRuleKey{}
for key, settings := range keys {
for _, s := range settings {
if s.Receiver != "" {
byReceiver[s.Receiver] = append(byReceiver[s.Receiver], key)
var receiverUsesInRoutes map[string]int
var importedUsesInRoutes map[string]int
receiverUsesInRules := map[string][]models.AlertRuleKey{}
if hasGrafanaOrigin {
receiverUsesInRoutes = revision.ReceiverUseByName()
q := models.ListNotificationSettingsQuery{OrgID: orgID}
if len(receivers) == 1 {
q.ReceiverName = receivers[0].Name
}
keys, err := rs.ruleNotificationsStore.ListNotificationSettings(ctx, q)
if err != nil {
return nil, err
}
for key, settings := range keys {
for _, s := range settings {
if s.Receiver != "" {
receiverUsesInRules[s.Receiver] = append(receiverUsesInRules[s.Receiver], key)
}
}
}
}
if hasImportedOrigin {
s, err := revision.Imported()
if err == nil {
importedUsesInRoutes = s.ReceiverUseByName()
} else {
rs.log.FromContext(ctx).Warn("Unable to include imported receivers. Skipping", "err", err)
}
}
results := make(map[string]models.ReceiverMetadata, len(receivers))
for _, rcv := range receivers {
if rcv.Origin == models.ResourceOriginImported {
results[rcv.GetUID()] = models.ReceiverMetadata{
InUseByRoutes: importedUsesInRoutes[rcv.Name],
InUseByRules: nil,
CanUse: false,
}
continue
}
results[rcv.GetUID()] = models.ReceiverMetadata{
InUseByRoutes: receiverUses[rcv.Name],
InUseByRules: byReceiver[rcv.Name],
InUseByRoutes: receiverUsesInRoutes[rcv.Name],
InUseByRules: receiverUsesInRules[rcv.Name],
CanUse: rcv.Origin == models.ResourceOriginGrafana, // Only receivers from the Grafana configuration can be used.
}
}
@@ -741,3 +814,23 @@ func (rs *ReceiverService) RenameReceiverInDependentResources(ctx context.Contex
}
return nil
}
func (rs *ReceiverService) getImportedReceivers(ctx context.Context, span trace.Span, uids []string, revision *legacy_storage.ConfigRevision) []*models.Receiver {
var result []*models.Receiver
imported, err := revision.Imported()
if err == nil {
result, err = imported.GetReceivers(uids)
}
if err != nil {
rs.log.FromContext(ctx).Warn("Unable to include imported receivers. Skipping", "err", err)
span.RecordError(err, trace.WithAttributes(
attribute.String("concurrency_token", revision.ConcurrencyToken),
))
} else if len(result) > 0 { // if the list is empty, then we do not have any imported configuration
span.AddEvent("Loaded importedReceivers receivers", trace.WithAttributes(
attribute.String("concurrency_token", revision.ConcurrencyToken),
attribute.Int("count", len(result)),
))
}
return result
}
@@ -9,6 +9,7 @@ import (
"github.com/grafana/alerting/receivers/line"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/pkg/labels"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -49,20 +50,43 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) {
t.Run("service gets receiver from AM config", func(t *testing.T) {
sut := createReceiverServiceSut(t, secretsService)
Receiver, err := sut.GetReceiver(context.Background(), singleQ(1, "slack receiver"), redactedUser)
recv, err := sut.GetReceiver(context.Background(), singleQ(1, "slack receiver"), redactedUser)
require.NoError(t, err)
require.Equal(t, "slack receiver", Receiver.Name)
require.Len(t, Receiver.Integrations, 1)
require.Equal(t, "UID2", Receiver.Integrations[0].UID)
require.Equal(t, "slack receiver", recv.Name)
require.Len(t, recv.Integrations, 1)
require.Equal(t, "UID2", recv.Integrations[0].UID)
})
t.Run("service returns error when receiver does not exist", func(t *testing.T) {
sut := createReceiverServiceSut(t, secretsService)
_, err := sut.GetReceiver(context.Background(), singleQ(1, "nonexistent"), redactedUser)
_, err := sut.GetReceiver(context.Background(), singleQ(1, "receiver1"), redactedUser)
require.ErrorIs(t, err, legacy_storage.ErrReceiverNotFound)
})
t.Run("when includeImported is true", func(t *testing.T) {
t.Run("gets imported receivers", func(t *testing.T) {
sut := createReceiverServiceSut(t, secretsService, withImportedIncluded)
recv, err := sut.GetReceiver(context.Background(), singleQ(1, "receiver1"), redactedUser)
require.NoError(t, err)
assert.Equal(t, models.ResourceOriginImported, recv.Origin)
assert.Equal(t, "receiver1", recv.Name)
assert.Equal(t, models.ProvenanceConvertedPrometheus, recv.Provenance)
require.Len(t, recv.Integrations, 2)
integration := recv.Integrations[0]
assert.Equal(t, "", integration.UID)
})
t.Run("falls to only Grafana if cannot read imported receivers", func(t *testing.T) {
sut := createReceiverServiceSut(t, secretsService, withImportedIncluded, withInvalidExtraConfig)
_, err := sut.GetReceiver(context.Background(), singleQ(1, "receiver1"), redactedUser)
require.ErrorIs(t, err, legacy_storage.ErrReceiverNotFound)
_, err = sut.GetReceiver(context.Background(), singleQ(1, "slack receiver"), redactedUser)
require.NoError(t, err)
})
})
}
func TestIntegrationReceiverService_GetReceivers(t *testing.T) {
@@ -95,6 +119,29 @@ func TestIntegrationReceiverService_GetReceivers(t *testing.T) {
require.Len(t, Receivers, 1)
require.Equal(t, "slack receiver", Receivers[0].Name)
})
t.Run("when includeImported is true", func(t *testing.T) {
t.Run("returns imported receivers in the list", func(t *testing.T) {
sut := createReceiverServiceSut(t, secretsService, withImportedIncluded)
recvs, err := sut.GetReceivers(context.Background(), multiQ(1), redactedUser)
require.NoError(t, err)
require.Len(t, recvs, 5)
names := make([]string, 0, len(recvs))
for _, recv := range recvs {
names = append(names, recv.Name)
}
expectedNames := []string{"grafana-default-email", "slack receiver", "receiver1", "empty receiver", "email-receiver"}
assert.ElementsMatch(t, expectedNames, names)
})
t.Run("falls to only Grafana if cannot read imported receivers", func(t *testing.T) {
sut := createReceiverServiceSut(t, secretsService, withImportedIncluded, withInvalidExtraConfig)
recvs, err := sut.GetReceivers(context.Background(), multiQ(1), redactedUser)
require.NoError(t, err)
require.Len(t, recvs, 2)
})
})
}
func TestIntegrationReceiverService_DecryptRedact(t *testing.T) {
@@ -153,51 +200,75 @@ func TestIntegrationReceiverService_DecryptRedact(t *testing.T) {
err: "",
},
} {
for _, method := range getMethods {
t.Run(fmt.Sprintf("%s %s", tc.name, method), func(t *testing.T) {
sut := createReceiverServiceSut(t, secretsService)
origin := []struct {
origin models.ResourceOrigin
receiver string
opts []createReceiverServiceSutOpt
secureSettingKey string
decryptedSettingValue string
}{
{
origin: models.ResourceOriginImported,
secureSettingKey: "auth_password",
decryptedSettingValue: "another-secret-password",
receiver: "email-receiver",
opts: []createReceiverServiceSutOpt{
withImportedIncluded,
},
},
{
origin: models.ResourceOriginGrafana,
secureSettingKey: "url",
decryptedSettingValue: "secure url",
receiver: "slack receiver",
},
}
var res *models.Receiver
var err error
if method == "single" {
q := singleQ(1, "slack receiver")
q.Decrypt = tc.decrypt
res, err = sut.GetReceiver(context.Background(), q, tc.user)
} else {
q := multiQ(1, "slack receiver")
q.Decrypt = tc.decrypt
var multiRes []*models.Receiver
multiRes, err = sut.GetReceivers(context.Background(), q, tc.user)
if tc.err == "" {
require.Len(t, multiRes, 1)
res = multiRes[0]
}
}
if tc.err == "" {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, tc.err)
}
for _, o := range origin {
for _, method := range getMethods {
t.Run(fmt.Sprintf("%s %s", tc.name, method), func(t *testing.T) {
t.Run(fmt.Sprintf("%s %s (%s)", tc.name, method, o.origin), func(t *testing.T) {
sut := createReceiverServiceSut(t, secretsService, o.opts...)
if tc.err == "" {
require.Equal(t, "slack receiver", res.Name)
require.Len(t, res.Integrations, 1)
require.Equal(t, "UID2", res.Integrations[0].UID)
var res *models.Receiver
var err error
if method == "single" {
q := singleQ(1, o.receiver)
q.Decrypt = tc.decrypt
res, err = sut.GetReceiver(context.Background(), q, tc.user)
} else {
q := multiQ(1, o.receiver)
q.Decrypt = tc.decrypt
var multiRes []*models.Receiver
multiRes, err = sut.GetReceivers(context.Background(), q, tc.user)
if tc.err == "" {
require.Len(t, multiRes, 1)
res = multiRes[0]
}
}
if tc.err != "" {
require.ErrorContains(t, err, tc.err)
return
}
require.NoError(t, err)
if tc.decrypt {
require.Equal(t, "secure url", res.Integrations[0].Settings["url"])
require.NotContains(t, res.Integrations[0].SecureSettings, "url")
} else {
require.NotContains(t, res.Integrations[0].Settings, "url")
require.Equal(t, o.receiver, res.Name)
require.Len(t, res.Integrations, 1)
require.NoError(t, err)
// Ensure the encrypted value exists and is not redacted or decrypted.
require.NotEmpty(t, res.Integrations[0].SecureSettings["url"])
require.NotEqual(t, definitions.RedactedValue, res.Integrations[0].SecureSettings["url"])
require.NotEqual(t, "secure url", res.Integrations[0].SecureSettings["url"])
}
}
})
if tc.decrypt {
require.Equal(t, o.decryptedSettingValue, res.Integrations[0].Settings[o.secureSettingKey])
require.NotContains(t, res.Integrations[0].SecureSettings, o.secureSettingKey)
} else {
require.NotContains(t, res.Integrations[0].Settings, o.secureSettingKey)
// Ensure the encrypted value exists and is not redacted or decrypted.
require.NotEmpty(t, res.Integrations[0].SecureSettings[o.secureSettingKey])
require.NotEqual(t, definitions.RedactedValue, res.Integrations[0].SecureSettings[o.secureSettingKey])
require.NotEqual(t, o.decryptedSettingValue, res.Integrations[0].SecureSettings[o.secureSettingKey])
}
})
})
}
}
}
}
@@ -225,6 +296,7 @@ func TestReceiverService_Delete(t *testing.T) {
storeSettings map[models.AlertRuleKey][]models.NotificationSettings
existing *models.Receiver
expectedErr error
opts []createReceiverServiceSutOpt
}{
{
name: "service deletes receiver",
@@ -293,13 +365,32 @@ func TestReceiverService_Delete(t *testing.T) {
version: "wrong version",
expectedErr: ErrReceiverVersionConflict,
},
{
name: "delete of receiver with non-Grafana origin fails",
user: writer,
deleteUID: legacy_storage.NameToUid("empty receiver"),
expectedErr: ErrReceiverOrigin,
opts: []createReceiverServiceSutOpt{
withImportedIncluded,
},
},
{
name: "delete of receiver succeeds even with invalid imported config",
user: writer,
deleteUID: baseReceiver.UID,
existing: util.Pointer(baseReceiver.Clone()),
opts: []createReceiverServiceSutOpt{
withInvalidExtraConfig,
withImportedIncluded,
},
},
} {
t.Run(tc.name, func(t *testing.T) {
store := &fakeAlertRuleNotificationStore{}
store.ListNotificationSettingsFn = func(ctx context.Context, q models.ListNotificationSettingsQuery) (map[models.AlertRuleKey][]models.NotificationSettings, error) {
return tc.storeSettings, nil
}
sut := createReceiverServiceSut(t, &secretsService)
sut := createReceiverServiceSut(t, &secretsService, tc.opts...)
sut.ruleNotificationsStore = store
if tc.existing != nil {
@@ -364,6 +455,7 @@ func TestReceiverService_Create(t *testing.T) {
expectedStored *definitions.PostableApiReceiver
expectedErr error
expectedProvenances map[string]models.Provenance
opts []createReceiverServiceSutOpt
}{
{
name: "service creates receiver",
@@ -482,9 +574,20 @@ func TestReceiverService_Create(t *testing.T) {
receiver: models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.WithName("")),
expectedErr: legacy_storage.ErrReceiverInvalid,
},
{
name: "should be able to create receiver with the same name as imported ones",
user: writer,
receiver: models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.WithName("receiver1")),
expectedCreate: models.CopyReceiverWith(baseReceiver,
models.ReceiverMuts.Encrypted(models.Base64Enrypt),
models.ReceiverMuts.WithName("receiver1"),
),
expectedProvenances: map[string]models.Provenance{slackIntegration.UID: models.ProvenanceNone},
opts: []createReceiverServiceSutOpt{withImportedIncluded},
},
} {
t.Run(tc.name, func(t *testing.T) {
sut := createReceiverServiceSut(t, &secretsService)
sut := createReceiverServiceSut(t, &secretsService, tc.opts...)
created, err := sut.CreateReceiver(context.Background(), &tc.receiver, tc.user.GetOrgID(), tc.user)
if tc.expectedErr == nil {
@@ -586,6 +689,7 @@ func TestReceiverService_Update(t *testing.T) {
expectedUpdate models.Receiver
expectedProvenances map[string]models.Provenance
expectedErr error
opts []createReceiverServiceSutOpt
}{
{
name: "copies existing secure fields",
@@ -741,9 +845,31 @@ func TestReceiverService_Update(t *testing.T) {
existing: util.Pointer(baseReceiver.Clone()),
expectedErr: legacy_storage.ErrReceiverInvalid,
},
{
name: "receivers with non-Grafana origin are not accepted",
user: writer,
receiver: models.CopyReceiverWith(baseReceiver, rm.WithOrigin(models.ResourceOriginImported)),
existing: util.Pointer(baseReceiver.Clone()),
expectedErr: ErrReceiverOrigin,
},
{
name: "receivers of non-Grafana origin cannot be updated",
user: writer,
receiver: models.CopyReceiverWith(baseReceiver, rm.WithName("receiver1")),
expectedErr: ErrReceiverOrigin,
opts: []createReceiverServiceSutOpt{withImportedIncluded},
},
{
name: "update should not fail if imported cannot be included",
user: writer,
receiver: models.CopyReceiverWith(baseReceiver, rm.WithEmptyIntegrations()),
existing: util.Pointer(models.CopyReceiverWith(baseReceiver)),
expectedUpdate: models.CopyReceiverWith(baseReceiver, rm.WithEmptyIntegrations()),
opts: []createReceiverServiceSutOpt{withImportedIncluded, withInvalidExtraConfig},
},
} {
t.Run(tc.name, func(t *testing.T) {
sut := createReceiverServiceSut(t, &secretsService)
sut := createReceiverServiceSut(t, &secretsService, tc.opts...)
if tc.existing != nil {
// Create route after receivers as they will be referenced.
@@ -813,6 +939,9 @@ func TestReceiverService_Update(t *testing.T) {
provenances, err := sut.provisioningStore.GetProvenances(context.Background(), tc.user.GetOrgID(), (&definitions.EmbeddedContactPoint{}).ResourceType())
require.NoError(t, err)
if tc.expectedProvenances == nil {
tc.expectedProvenances = make(map[string]models.Provenance)
}
assert.Equal(t, tc.expectedProvenances, provenances)
})
}
@@ -917,6 +1046,30 @@ func TestReceiverService_UpdateReceiverName(t *testing.T) {
_, err := sut.UpdateReceiver(context.Background(), &baseReceiver, nil, writer.GetOrgID(), writer)
require.ErrorIs(t, err, legacy_storage.ErrReceiverInvalid)
})
t.Run("can rename receiver to name that is already used by another receiver of different origin", func(t *testing.T) {
ruleStore := &fakeAlertRuleNotificationStore{}
sut := createReceiverServiceSut(t, &secretsService, withImportedIncluded)
sut.ruleNotificationsStore = ruleStore
newReceiverName = "receiver1"
actual, err := sut.GetReceiver(context.Background(), models.GetReceiverQuery{OrgID: writer.GetOrgID(), Name: newReceiverName}, writer)
require.NoError(t, err)
require.Equal(t, models.ResourceOriginImported, actual.Origin)
require.Equal(t, newReceiverName, actual.Name)
require.NotEmpty(t, actual.Integrations)
baseReceiver.Name = newReceiverName
recv, err := sut.UpdateReceiver(context.Background(), &baseReceiver, nil, writer.GetOrgID(), writer)
require.NoError(t, err)
require.NotEqual(t, actual, recv)
require.Equal(t, models.ResourceOriginGrafana, recv.Origin)
actual, err = sut.GetReceiver(context.Background(), models.GetReceiverQuery{OrgID: writer.GetOrgID(), Name: newReceiverName}, writer)
require.NoError(t, err)
require.Equal(t, recv.Name, actual.Name)
})
}
func TestReceiverServiceAC_Read(t *testing.T) {
@@ -1557,13 +1710,61 @@ func TestReceiverService_InUseMetadata(t *testing.T) {
}
}
func createReceiverServiceSut(t *testing.T, encryptSvc secretService) *ReceiverService {
cfg := createEncryptedConfig(t, encryptSvc)
func TestReceiverService_AccessControlMetadata(t *testing.T) {
secretsService := fake_secrets.NewFakeSecretsService()
sut := createReceiverServiceSut(t, &secretsService, withImportedIncluded)
admin := &user.SignedInUser{OrgID: 1, OrgRole: org.RoleAdmin, Permissions: map[int64]map[string][]string{
1: {
accesscontrol.ActionAlertingNotificationsWrite: nil,
accesscontrol.ActionAlertingNotificationsRead: nil,
accesscontrol.ActionAlertingReceiversReadSecrets: []string{
models.ScopeReceiversProvider.GetResourceAllScope(),
},
},
}}
r, err := sut.GetReceiver(context.Background(), models.GetReceiverQuery{OrgID: 1, Name: "receiver1"}, admin)
require.NoError(t, err)
t.Run("should override metadata for imported receivers", func(t *testing.T) {
meta, err := sut.AccessControlMetadata(context.Background(), admin, r)
require.NoError(t, err)
expectedPermissions := models.NewReceiverPermissionSet()
expectedPermissions.Set(models.ReceiverPermissionAdmin, false)
expectedPermissions.Set(models.ReceiverPermissionWrite, false)
expectedPermissions.Set(models.ReceiverPermissionDelete, false)
expectedPermissions.Set(models.ReceiverPermissionReadSecret, true)
expected := map[string]models.ReceiverPermissionSet{
r.GetUID(): expectedPermissions,
}
assert.Equal(t, expected, meta)
})
}
type createReceiverServiceSutOpt func(*testing.T, *ReceiverService)
func withInvalidExtraConfig(t *testing.T, sut *ReceiverService) {
t.Helper()
extra := getExtraConfig()
extra.AlertmanagerConfig = "yaml:invalid"
cfg := createEncryptedConfig(t, sut.encryptionService, extra)
store := fakes.NewFakeAlertmanagerConfigStore(cfg)
sut.cfgStore = legacy_storage.NewAlertmanagerConfigStore(store, NewExtraConfigsCrypto(sut.encryptionService))
}
func withImportedIncluded(_ *testing.T, sut *ReceiverService) {
sut.includeImported = true
}
func createReceiverServiceSut(t *testing.T, encryptSvc secretService, opts ...createReceiverServiceSutOpt) *ReceiverService {
cfg := createEncryptedConfig(t, encryptSvc, getExtraConfig())
store := fakes.NewFakeAlertmanagerConfigStore(cfg)
xact := newNopTransactionManager()
provisioningStore := fakes.NewFakeProvisioningStore()
return NewReceiverService(
sut := NewReceiverService(
ac.NewReceiverAccess[*models.Receiver](acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), false),
legacy_storage.NewAlertmanagerConfigStore(store, NewExtraConfigsCrypto(encryptSvc)),
provisioningStore,
@@ -1573,10 +1774,15 @@ func createReceiverServiceSut(t *testing.T, encryptSvc secretService) *ReceiverS
log.NewNopLogger(),
fakes.NewFakeReceiverPermissionsService(),
tracing.InitializeTracerForTest(),
false,
)
for _, opt := range opts {
opt(t, sut)
}
return sut
}
func createEncryptedConfig(t *testing.T, secretService secretService) string {
func createEncryptedConfig(t *testing.T, secretService secretService, extraConfig *definitions.ExtraConfiguration) string {
c := &definitions.PostableUserConfig{}
err := json.Unmarshal([]byte(defaultAlertmanagerConfigJSON), c)
require.NoError(t, err)
@@ -1584,6 +1790,10 @@ func createEncryptedConfig(t *testing.T, secretService secretService) string {
return secretService.Encrypt(ctx, payload, secrets.WithoutScope())
})
require.NoError(t, err)
if extraConfig != nil {
c.ExtraConfigs = append(c.ExtraConfigs, *extraConfig)
require.NoError(t, NewExtraConfigsCrypto(secretService).EncryptExtraConfigs(context.Background(), c))
}
bytes, err := json.Marshal(c)
require.NoError(t, err)
return string(bytes)
@@ -1645,6 +1855,40 @@ const defaultAlertmanagerConfigJSON = `
}
`
func getExtraConfig() *definitions.ExtraConfiguration {
return &definitions.ExtraConfiguration{
Identifier: "import",
MergeMatchers: []*labels.Matcher{{Type: labels.MatchEqual, Name: "__imported", Value: "true"}},
TemplateFiles: nil,
AlertmanagerConfig: defaultExtraConfig,
}
}
const defaultExtraConfig = `
route:
receiver: receiver1
routes:
- receiver: email-receiver
receivers:
- name: empty receiver
- name: receiver1
webhook_configs:
- url: 'https://webhook.example.com/alerts'
http_config:
basic_auth:
username: 'admin'
password: 'super-secret-password'
- url: 'https://slack.com/webhook/ABC123'
send_resolved: true
- name: email-receiver
email_configs:
- to: 'alerts@example.com'
from: 'grafana@example.com'
smarthost: 'smtp.gmail.com:587'
auth_username: 'grafana@example.com'
auth_password: 'another-secret-password'
`
type NopTransactionManager struct{}
func newNopTransactionManager() *NopTransactionManager {
@@ -570,6 +570,7 @@ func createContactPointServiceSutWithConfigStore(t *testing.T, secretService sec
log.NewNopLogger(),
fakes.NewFakeReceiverPermissionsService(),
tracing.InitializeTracerForTest(),
false,
)
return NewContactPointService(
+10
View File
@@ -855,6 +855,16 @@ func (st DBstore) buildListAlertRulesQuery(sess *db.Session, query *ngmodels.Lis
}
}
if query.SearchTitle != "" {
words := strings.Fields(query.SearchTitle)
if len(words) > 0 {
// Build sequential pattern: %word1%word2%word3%
pattern := strings.Join(words, "%")
sql, param := st.SQLStore.GetDialect().LikeOperator("title", true, pattern, true)
q = q.And(sql, param)
}
}
if query.HasPrometheusRuleDefinition != nil {
q, err = st.filterWithPrometheusRuleDefinition(*query.HasPrometheusRuleDefinition, q)
if err != nil {
+71 -2
View File
@@ -2020,12 +2020,10 @@ func Benchmark_ListAlertRules(b *testing.B) {
func TestIntegration_ListAlertRules(t *testing.T) {
tutil.SkipIntegrationTestInShortMode(t)
sqlStore := db.InitTestDB(t)
cfg := setting.NewCfg()
cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{
BaseInterval: time.Duration(rand.Int64N(100)) * time.Second,
}
folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures())
b := &fakeBus{}
orgID := int64(1)
ruleGen := models.RuleGen
@@ -2034,6 +2032,8 @@ func TestIntegration_ListAlertRules(t *testing.T) {
ruleGen.WithOrgID(orgID),
)
t.Run("filter by HasPrometheusRuleDefinition", func(t *testing.T) {
sqlStore := db.InitTestDB(t)
folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures())
store := createTestStore(sqlStore, folderService, &logtest.Fake{}, cfg.UnifiedAlerting, b)
regularRule := createRule(t, store, ruleGen)
importedRule := createRule(t, store, ruleGen.With(
@@ -2072,6 +2072,75 @@ func TestIntegration_ListAlertRules(t *testing.T) {
})
}
})
t.Run("filter by SearchTitle", func(t *testing.T) {
sqlStore := db.InitTestDB(t)
folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures())
store := createTestStore(sqlStore, folderService, &logtest.Fake{}, cfg.UnifiedAlerting, b)
rule1 := createRule(t, store, ruleGen.With(models.RuleMuts.WithTitle("CPU Usage Alert")))
rule2 := createRule(t, store, ruleGen.With(models.RuleMuts.WithTitle("Memory Usage Alert")))
rule3 := createRule(t, store, ruleGen.With(models.RuleMuts.WithTitle("Disk Space Alert")))
rule4 := createRule(t, store, ruleGen.With(models.RuleMuts.WithTitle("Application Error Rate")))
tc := []struct {
name string
titleSearch string
expectedRules []*models.AlertRule
}{
{
name: "should find rules",
titleSearch: "alert",
expectedRules: []*models.AlertRule{rule1, rule2, rule3},
},
{
name: "should find rule with partial match",
titleSearch: "aPpl",
expectedRules: []*models.AlertRule{rule4},
},
{
name: "should return no rules when no match",
titleSearch: "nonexistent",
expectedRules: []*models.AlertRule{},
},
{
name: "should return all rules when empty",
titleSearch: "",
expectedRules: []*models.AlertRule{rule1, rule2, rule3, rule4},
},
{
name: "should not find rules when word order is reversed",
titleSearch: "usage cpu",
expectedRules: []*models.AlertRule{},
},
{
name: "should find multiple rules matching sequential words",
titleSearch: "usage alert",
expectedRules: []*models.AlertRule{rule1, rule2},
},
{
name: "should handle extra whitespace between words",
titleSearch: " cpu usage ",
expectedRules: []*models.AlertRule{rule1},
},
{
name: "should handle multiple words with partial matches",
titleSearch: "aPp erR",
expectedRules: []*models.AlertRule{rule4},
},
}
for _, tt := range tc {
t.Run(tt.name, func(t *testing.T) {
query := &models.ListAlertRulesQuery{
OrgID: orgID,
SearchTitle: tt.titleSearch,
}
result, err := store.ListAlertRules(context.Background(), query)
require.NoError(t, err)
require.ElementsMatch(t, tt.expectedRules, result)
})
}
})
}
func TestIntegration_ListAlertRulesPaginated(t *testing.T) {
@@ -119,12 +119,13 @@ func TestLoader_Load(t *testing.T) {
Backend: true,
QueryOptions: map[string]bool{"minInterval": true},
},
Module: "core:plugin/cloudwatch",
BaseURL: "public/plugins/cloudwatch",
FS: mustNewStaticFSForTests(t, filepath.Join(corePluginDir(t), "app/plugins/datasource/cloudwatch")),
Signature: plugins.SignatureStatusInternal,
Class: plugins.ClassCore,
Translations: map[string]string{},
Class: plugins.ClassCore,
BaseURL: "public/plugins/cloudwatch",
FS: mustNewStaticFSForTests(t, filepath.Join(corePluginDir(t), "app/plugins/datasource/cloudwatch")),
Module: "core:plugin/cloudwatch",
Signature: plugins.SignatureStatusInternal,
SkipHostEnvVars: true,
Translations: map[string]string{},
},
},
},
@@ -212,14 +213,15 @@ func TestLoader_Load(t *testing.T) {
ExtensionPoints: []plugins.ExtensionPoint{},
},
},
Class: plugins.ClassExternal,
Module: "public/plugins/test-app/module.js",
BaseURL: "public/plugins/test-app",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "includes-symlinks")),
Signature: "valid",
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
Translations: map[string]string{},
Class: plugins.ClassExternal,
Module: "public/plugins/test-app/module.js",
BaseURL: "public/plugins/test-app",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "includes-symlinks")),
Signature: "valid",
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
SkipHostEnvVars: true,
Translations: map[string]string{},
},
},
},
@@ -265,12 +267,13 @@ func TestLoader_Load(t *testing.T) {
Backend: true,
State: plugins.ReleaseStateAlpha,
},
Class: plugins.ClassExternal,
Module: "public/plugins/test-datasource/module.js",
BaseURL: "public/plugins/test-datasource",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "unsigned-datasource/plugin")),
Signature: "unsigned",
Translations: map[string]string{},
Class: plugins.ClassExternal,
Module: "public/plugins/test-datasource/module.js",
BaseURL: "public/plugins/test-datasource",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "unsigned-datasource/plugin")),
Signature: "unsigned",
SkipHostEnvVars: true,
Translations: map[string]string{},
},
},
},
@@ -329,12 +332,13 @@ func TestLoader_Load(t *testing.T) {
Backend: true,
State: plugins.ReleaseStateAlpha,
},
Class: plugins.ClassExternal,
Module: "public/plugins/test-datasource/module.js",
BaseURL: "public/plugins/test-datasource",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "unsigned-datasource/plugin")),
Signature: plugins.SignatureStatusUnsigned,
Translations: map[string]string{},
Class: plugins.ClassExternal,
Module: "public/plugins/test-datasource/module.js",
BaseURL: "public/plugins/test-datasource",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "unsigned-datasource/plugin")),
Signature: plugins.SignatureStatusUnsigned,
SkipHostEnvVars: true,
Translations: map[string]string{},
},
},
},
@@ -449,13 +453,14 @@ func TestLoader_Load(t *testing.T) {
},
Backend: false,
},
DefaultNavURL: "/plugins/test-app/page/root-page-react",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app-with-includes")),
Class: plugins.ClassExternal,
Signature: plugins.SignatureStatusUnsigned,
Module: "public/plugins/test-app/module.js",
BaseURL: "public/plugins/test-app",
Translations: map[string]string{},
DefaultNavURL: "/plugins/test-app/page/root-page-react",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app-with-includes")),
Class: plugins.ClassExternal,
Signature: plugins.SignatureStatusUnsigned,
Module: "public/plugins/test-app/module.js",
BaseURL: "public/plugins/test-app",
SkipHostEnvVars: true,
Translations: map[string]string{},
},
},
},
@@ -546,7 +551,8 @@ func TestLoader_Load_ExternalRegistration(t *testing.T) {
ClientID: "client-id",
ClientSecret: "secretz",
},
Translations: map[string]string{},
SkipHostEnvVars: true,
Translations: map[string]string{},
},
}
@@ -641,14 +647,15 @@ func TestLoader_Load_MultiplePlugins(t *testing.T) {
Executable: "test",
State: plugins.ReleaseStateAlpha,
},
Class: plugins.ClassExternal,
Module: "public/plugins/test-datasource/module.js",
BaseURL: "public/plugins/test-datasource",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature/plugin")),
Signature: "valid",
SignatureType: plugins.SignatureTypePrivate,
SignatureOrg: "Will Browne",
Translations: map[string]string{},
Class: plugins.ClassExternal,
Module: "public/plugins/test-datasource/module.js",
BaseURL: "public/plugins/test-datasource",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature/plugin")),
Signature: "valid",
SignatureType: plugins.SignatureTypePrivate,
SignatureOrg: "Will Browne",
SkipHostEnvVars: true,
Translations: map[string]string{},
},
},
pluginErrors: map[string]*plugins.Error{
@@ -760,14 +767,15 @@ func TestLoader_Load_RBACReady(t *testing.T) {
},
Backend: false,
},
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app-with-roles")),
Class: plugins.ClassExternal,
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypePrivate,
SignatureOrg: "gabrielmabille",
Module: "public/plugins/test-app/module.js",
BaseURL: "public/plugins/test-app",
Translations: map[string]string{},
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app-with-roles")),
Class: plugins.ClassExternal,
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypePrivate,
SignatureOrg: "gabrielmabille",
Module: "public/plugins/test-app/module.js",
BaseURL: "public/plugins/test-app",
SkipHostEnvVars: true,
Translations: map[string]string{},
},
},
},
@@ -829,14 +837,15 @@ func TestLoader_Load_Signature_RootURL(t *testing.T) {
Backend: true,
Executable: "test",
},
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature-root-url-uri/plugin")),
Class: plugins.ClassExternal,
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypePrivate,
SignatureOrg: "Will Browne",
Module: "public/plugins/test-datasource/module.js",
BaseURL: "public/plugins/test-datasource",
Translations: map[string]string{},
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature-root-url-uri/plugin")),
Class: plugins.ClassExternal,
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypePrivate,
SignatureOrg: "Will Browne",
SkipHostEnvVars: true,
Module: "public/plugins/test-datasource/module.js",
BaseURL: "public/plugins/test-datasource",
Translations: map[string]string{},
},
}
@@ -916,14 +925,15 @@ func TestLoader_Load_DuplicatePlugins(t *testing.T) {
},
Backend: false,
},
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app")),
Class: plugins.ClassExternal,
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
Module: "public/plugins/test-app/module.js",
BaseURL: "public/plugins/test-app",
Translations: map[string]string{},
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app")),
Class: plugins.ClassExternal,
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
Module: "public/plugins/test-app/module.js",
BaseURL: "public/plugins/test-app",
SkipHostEnvVars: true,
Translations: map[string]string{},
},
}
@@ -1007,14 +1017,15 @@ func TestLoader_Load_SkipUninitializedPlugins(t *testing.T) {
},
Backend: false,
},
FS: mustNewStaticFSForTests(t, pluginDir1),
Class: plugins.ClassExternal,
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
Module: "public/plugins/test-app/module.js",
BaseURL: "public/plugins/test-app",
Translations: map[string]string{},
FS: mustNewStaticFSForTests(t, pluginDir1),
Class: plugins.ClassExternal,
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
Module: "public/plugins/test-app/module.js",
BaseURL: "public/plugins/test-app",
SkipHostEnvVars: true,
Translations: map[string]string{},
},
}
@@ -1169,14 +1180,15 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
},
Backend: true,
},
Module: "public/plugins/test-datasource/module.js",
BaseURL: "public/plugins/test-datasource",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent")),
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
Class: plugins.ClassExternal,
Translations: map[string]string{},
Module: "public/plugins/test-datasource/module.js",
BaseURL: "public/plugins/test-datasource",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent")),
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
Class: plugins.ClassExternal,
SkipHostEnvVars: true,
Translations: map[string]string{},
}
child := &plugins.Plugin{
@@ -1213,14 +1225,15 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
ExtensionPoints: []plugins.ExtensionPoint{},
},
},
Module: "public/plugins/test-panel/module.js",
BaseURL: "public/plugins/test-panel",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent/nested")),
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
Class: plugins.ClassExternal,
Translations: map[string]string{},
Module: "public/plugins/test-panel/module.js",
BaseURL: "public/plugins/test-panel",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent/nested")),
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
Class: plugins.ClassExternal,
SkipHostEnvVars: true,
Translations: map[string]string{},
}
parent.Children = []*plugins.Plugin{child}
@@ -1362,15 +1375,16 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
},
Backend: false,
},
Module: "public/plugins/myorgid-simple-app/module.js",
BaseURL: "public/plugins/myorgid-simple-app",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "app-with-child/dist")),
DefaultNavURL: "/plugins/myorgid-simple-app/page/root-page-react",
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
Class: plugins.ClassExternal,
Translations: map[string]string{},
Module: "public/plugins/myorgid-simple-app/module.js",
BaseURL: "public/plugins/myorgid-simple-app",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "app-with-child/dist")),
DefaultNavURL: "/plugins/myorgid-simple-app/page/root-page-react",
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
Class: plugins.ClassExternal,
SkipHostEnvVars: true,
Translations: map[string]string{},
}
child := &plugins.Plugin{
@@ -1421,6 +1435,7 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
Class: plugins.ClassExternal,
SkipHostEnvVars: true,
Translations: map[string]string{},
}
@@ -30,9 +30,8 @@ func ProvidePluginManagementConfig(cfg *setting.Cfg, settingProvider setting.Pro
cfg.AppURL,
//nolint:staticcheck // not yet migrated to OpenFeature
config.Features{
SkipHostEnvVarsEnabled: features.IsEnabledGlobally(featuremgmt.FlagPluginsSkipHostEnvVars),
SriChecksEnabled: features.IsEnabledGlobally(featuremgmt.FlagPluginsSriChecks),
TempoAlertingEnabled: features.IsEnabledGlobally(featuremgmt.FlagTempoAlerting),
SriChecksEnabled: features.IsEnabledGlobally(featuremgmt.FlagPluginsSriChecks),
TempoAlertingEnabled: features.IsEnabledGlobally(featuremgmt.FlagTempoAlerting),
},
cfg.GrafanaComAPIURL,
cfg.DisablePlugins,
@@ -332,6 +332,7 @@ func (ps *ProvisioningServiceImpl) ProvisionAlerting(ctx context.Context) error
ps.log,
ps.resourcePermissions,
ps.tracer,
false,
)
contactPointService := provisioning.NewContactPointService(configStore, ps.secretService,
ps.alertingStore, ps.SQLStore, receiverSvc, ps.log, ps.alertingStore, ps.resourcePermissions)
-3
View File
@@ -36,8 +36,6 @@ type SecretsManagerSettings struct {
// How long to wait for the process to clean up a secure value to complete.
GCWorkerPerSecureValueCleanupTimeout time.Duration
// Whether to register the MT CRUD API
RegisterAPIServer bool
// Whether to create the MT secrets management database
RunSecretsDBMigrations bool
// Whether to run the data key id migration. Requires that RunSecretsDBMigrations is also true.
@@ -62,7 +60,6 @@ func (cfg *Cfg) readSecretsManagerSettings() {
cfg.SecretsManagement.GCWorkerPollInterval = secretsMgmt.Key("gc_worker_poll_interval").MustDuration(1 * time.Minute)
cfg.SecretsManagement.GCWorkerPerSecureValueCleanupTimeout = secretsMgmt.Key("gc_worker_per_request_timeout").MustDuration(5 * time.Second)
cfg.SecretsManagement.RegisterAPIServer = secretsMgmt.Key("register_api_server").MustBool(true)
cfg.SecretsManagement.RunSecretsDBMigrations = secretsMgmt.Key("run_secrets_db_migrations").MustBool(true)
cfg.SecretsManagement.RunDataKeyMigration = secretsMgmt.Key("run_data_key_migration").MustBool(true)
@@ -171,28 +171,6 @@ domain = example.com
assert.Empty(t, cfg.SecretsManagement.ConfiguredKMSProviders)
})
t.Run("should handle configuration with register_api_server disabled", func(t *testing.T) {
iniContent := `
[secrets_manager]
register_api_server = false
`
cfg, err := NewCfgFromBytes([]byte(iniContent))
require.NoError(t, err)
assert.False(t, cfg.SecretsManagement.RegisterAPIServer)
})
t.Run("should handle configuration without register_api_server set", func(t *testing.T) {
iniContent := `
[secrets_manager]
encryption_provider = aws_kms
`
cfg, err := NewCfgFromBytes([]byte(iniContent))
require.NoError(t, err)
assert.True(t, cfg.SecretsManagement.RegisterAPIServer)
})
t.Run("should handle configuration with run_secrets_db_migrations disabled", func(t *testing.T) {
iniContent := `
[secrets_manager]
+24 -3
View File
@@ -33,6 +33,7 @@ import (
"k8s.io/client-go/tools/cache"
authtypes "github.com/grafana/authlib/types"
"github.com/grafana/dskit/concurrency"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/grafana/pkg/apimachinery/utils"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
@@ -472,16 +473,36 @@ func (s *Storage) GetList(ctx context.Context, key string, opts storage.ListOpti
}
if v.IsNil() {
v.Set(reflect.MakeSlice(v.Type(), 0, 0))
v.Set(reflect.MakeSlice(v.Type(), 0, len(rsp.Items)))
}
for _, item := range rsp.Items {
// Pre-allocate results slice to preserve order and avoid race conditions.
// Each goroutine writes to its own index, no mutex needed.
type resultSlot struct {
obj runtime.Object
shouldAppend bool
}
results := make([]resultSlot, len(rsp.Items))
// Concurrently process items as some may be large and take a while to process.
err = concurrency.ForEachJob(ctx, len(rsp.Items), 10, func(ctx context.Context, idx int) error {
item := rsp.Items[idx]
obj, shouldAppend, err := s.processItem(ctx, item, opts, predicate)
if err != nil {
return err
}
if shouldAppend {
v.Set(reflect.Append(v, reflect.ValueOf(obj).Elem()))
results[idx] = resultSlot{obj: obj, shouldAppend: true}
}
return nil
})
if err != nil {
return err
}
for _, r := range results {
if r.shouldAppend {
v.Set(reflect.Append(v, reflect.ValueOf(r.obj).Elem()))
}
}
+5 -1
View File
@@ -66,5 +66,9 @@ func NewSearchOptions(
IndexMinUpdateInterval: cfg.IndexMinUpdateInterval,
}, nil
}
return resource.SearchOptions{}, nil
return resource.SearchOptions{
// it is used for search after write and throttles index updates
IndexMinUpdateInterval: cfg.IndexMinUpdateInterval,
MaxIndexAge: cfg.MaxFileIndexAge,
}, nil
}
@@ -0,0 +1,148 @@
package receivers
import (
"bytes"
"context"
"encoding/json"
"os"
"path"
"testing"
"github.com/grafana/alerting/notify"
"github.com/grafana/alerting/receivers/schema"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/api/errors"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/grafana/grafana/apps/alerting/notifications/pkg/apis/alertingnotifications/v0alpha1"
"github.com/grafana/grafana/pkg/services/featuremgmt"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/tests/api/alerting"
"github.com/grafana/grafana/pkg/tests/apis"
test_common "github.com/grafana/grafana/pkg/tests/apis/alerting/notifications/common"
"github.com/grafana/grafana/pkg/tests/testinfra"
)
func TestIntegrationReadImported_Snapshot(t *testing.T) {
ctx := context.Background()
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
EnableFeatureToggles: []string{
featuremgmt.FlagAlertingImportAlertmanagerAPI,
},
})
receiverClient := test_common.NewReceiverClient(t, helper.Org1.Admin)
cliCfg := helper.Org1.Admin.NewRestConfig()
alertingApi := alerting.NewAlertingLegacyAPIClient(helper.GetEnv().Server.HTTPServer.Listener.Addr().String(), cliCfg.Username, cliCfg.Password)
configYaml, err := testData.ReadFile(path.Join("test-data", "imported.yaml"))
require.NoError(t, err)
identifier := "test-create-get-config"
mergeMatchers := "_imported=true"
headers := map[string]string{
"Content-Type": "application/yaml",
"X-Grafana-Alerting-Config-Identifier": identifier,
"X-Grafana-Alerting-Merge-Matchers": mergeMatchers,
}
amConfig := apimodels.AlertmanagerUserConfig{
AlertmanagerConfig: string(configYaml),
}
response := alertingApi.ConvertPrometheusPostAlertmanagerConfig(t, amConfig, headers)
require.Equal(t, "success", response.Status)
receiversRaw, err := receiverClient.Client.List(ctx, v1.ListOptions{})
require.NoError(t, err)
raw, err := receiversRaw.MarshalJSON()
require.NoError(t, err)
expectedBytes, err := os.ReadFile(path.Join("test-data", "imported-expected-snapshot.json"))
require.NoError(t, err)
if !assert.JSONEq(t, string(expectedBytes), string(raw)) {
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, raw, "", " ")
require.NoError(t, err)
err = os.WriteFile(path.Join("test-data", "imported-expected-snapshot.json"), prettyJSON.Bytes(), 0o644)
require.NoError(t, err)
}
receivers, err := receiverClient.List(ctx, v1.ListOptions{})
require.NoError(t, err)
t.Run("secure fields should be properly masked", func(t *testing.T) {
for _, receiver := range receivers.Items {
if receiver.Spec.Title == "grafana-default-email" {
continue
}
for _, integration := range receiver.Spec.Integrations {
sch, ok := notify.GetSchemaVersionForIntegration(schema.IntegrationType(integration.Type), schema.Version(integration.Version))
require.Truef(t, ok, "unknown integration type %s and version %s", integration.Type, integration.Version)
keys := flattenKeys(integration.Settings)
secrets := make(map[string]struct{})
for _, fieldPath := range sch.GetSecretFieldsPaths() {
assert.NotContainsf(t, keys, fieldPath.String(), "receiver %s integration %s has secret field %s", receiver.Name, integration.Type, fieldPath.String())
secrets[fieldPath.String()] = struct{}{}
}
for key := range integration.SecureFields {
assert.Containsf(t, secrets, key, "receiver %s integration %s has secure field %s that is not in the schema", receiver.Name, integration.Type, key)
}
}
}
})
t.Run("should set the correct annotations", func(t *testing.T) {
for _, receiver := range receivers.Items {
if receiver.Spec.Title == "grafana-default-email" {
continue
}
assert.EqualValuesf(t, models.ProvenanceConvertedPrometheus, receiver.GetProvenanceStatus(), "receiver %s has unexpected provenance", receiver.Name)
assert.Equalf(t, "false", receiver.Annotations[v0alpha1.CanUseAnnotationKey], "receiver %s has unexpected can use annotation", receiver.Name)
assert.Equalf(t, "", receiver.Annotations[v0alpha1.AccessControlAnnotation("canAdmin")], "receiver %s has unexpected can admin annotation", receiver.Name)
assert.Equalf(t, "", receiver.Annotations[v0alpha1.AccessControlAnnotation("canDelete")], "receiver %s has unexpected can delete annotation", receiver.Name)
assert.Equalf(t, "true", receiver.Annotations[v0alpha1.AccessControlAnnotation("canReadSecrets")], "receiver %s has unexpected can read secrets annotation", receiver.Name)
assert.Equalf(t, "", receiver.Annotations[v0alpha1.AccessControlAnnotation("canWrite")], "receiver %s has unexpected can write annotation", receiver.Name)
}
})
t.Run("should not be able to update", func(t *testing.T) {
toUpdate := receivers.Items[1]
toUpdate.Spec.Title = "another title"
_, err = receiverClient.Update(ctx, &toUpdate, v1.UpdateOptions{})
require.Truef(t, errors.IsBadRequest(err), "Expected BadRequest but got %s", err)
})
t.Run("should not be able to delete", func(t *testing.T) {
toDelete := receivers.Items[1]
err = receiverClient.Delete(ctx, toDelete.Name, v1.DeleteOptions{})
require.Truef(t, errors.IsBadRequest(err), "Expected BadRequest but got %s", err)
})
}
func flattenKeys(m map[string]any) map[string]struct{} {
result := map[string]struct{}{}
flattenHelper("", m, result)
return result
}
func flattenHelper(prefix string, m map[string]any, result map[string]struct{}) {
for key, value := range m {
newKey := key
if prefix != "" {
newKey = prefix + "." + key
}
if valMap, ok := value.(map[string]any); ok {
flattenHelper(newKey, valMap, result)
} else {
result[newKey] = struct{}{}
}
}
}
@@ -0,0 +1,818 @@
{
"apiVersion": "notifications.alerting.grafana.app/v0alpha1",
"items": [
{
"apiVersion": "notifications.alerting.grafana.app/v0alpha1",
"kind": "Receiver",
"metadata": {
"annotations": {
"grafana.com/access/canAdmin": "true",
"grafana.com/access/canDelete": "true",
"grafana.com/access/canReadSecrets": "true",
"grafana.com/access/canWrite": "true",
"grafana.com/canUse": "true",
"grafana.com/inUse/routes": "1",
"grafana.com/inUse/rules": "0",
"grafana.com/provenance": "none"
},
"name": "Z3JhZmFuYS1kZWZhdWx0LWVtYWls",
"namespace": "default",
"resourceVersion": "a82b34036bdabbc4",
"uid": "zyXFk301pvwNz4HRPrTMKPMFO2934cPB7H1ZXmyM1TUX"
},
"spec": {
"integrations": [
{
"disableResolveMessage": false,
"settings": {
"addresses": "\u003cexample@email.com\u003e"
},
"type": "email",
"uid": "",
"version": "v1"
}
],
"title": "grafana-default-email"
}
},
{
"apiVersion": "notifications.alerting.grafana.app/v0alpha1",
"kind": "Receiver",
"metadata": {
"annotations": {
"grafana.com/access/canReadSecrets": "true",
"grafana.com/canUse": "false",
"grafana.com/inUse/routes": "0",
"grafana.com/inUse/rules": "0",
"grafana.com/provenance": "converted_prometheus"
},
"name": "Z3JhZmFuYS1kZWZhdWx0LWVtYWlsdGVzdC1jcmVhdGUtZ2V0LWNvbmZpZw",
"namespace": "default",
"resourceVersion": "b2823b50ffa1eff6",
"uid": "JzW6DIlcxj4sRN8A2ULcwTXAmm0Vs0Z68aEBqXSvxK0X"
},
"spec": {
"integrations": [],
"title": "grafana-default-emailtest-create-get-config"
}
},
{
"apiVersion": "notifications.alerting.grafana.app/v0alpha1",
"kind": "Receiver",
"metadata": {
"annotations": {
"grafana.com/access/canReadSecrets": "true",
"grafana.com/canUse": "false",
"grafana.com/inUse/routes": "0",
"grafana.com/inUse/rules": "0",
"grafana.com/provenance": "converted_prometheus"
},
"name": "ZGlzY29yZA",
"namespace": "default",
"resourceVersion": "06e437697f62ac59",
"uid": "8cH8Ql2S6VhPEVUhwlQEKYWyPbRJS7YKj2lEXdrehH8X"
},
"spec": {
"integrations": [
{
"disableResolveMessage": false,
"secureFields": {
"webhook_url": true
},
"settings": {
"http_config": {
"enable_http2": true,
"follow_redirects": false,
"proxy_url": null,
"tls_config": {
"insecure_skip_verify": false
}
},
"message": "{{ template \"discord.default.message\" . }}",
"send_resolved": true,
"title": "{{ template \"discord.default.title\" . }}"
},
"type": "discord",
"uid": "",
"version": "v0mimir1"
}
],
"title": "discord"
}
},
{
"apiVersion": "notifications.alerting.grafana.app/v0alpha1",
"kind": "Receiver",
"metadata": {
"annotations": {
"grafana.com/access/canReadSecrets": "true",
"grafana.com/canUse": "false",
"grafana.com/inUse/routes": "0",
"grafana.com/inUse/rules": "0",
"grafana.com/provenance": "converted_prometheus"
},
"name": "ZW1haWw",
"namespace": "default",
"resourceVersion": "9b3ffed277cee189",
"uid": "bhlvlN758xmnwVrHVPX0c5XvFHepenUbOXP0fuE6eUMX"
},
"spec": {
"integrations": [
{
"disableResolveMessage": false,
"secureFields": {
"auth_password": true
},
"settings": {
"auth_username": "alertmanager",
"from": "alertmanager@example.com",
"headers": {
"Subject": "test subject"
},
"hello": "localhost",
"html": "{{ template \"email.default.html\" . }}",
"require_tls": true,
"send_resolved": true,
"smarthost": "smtp.example.com:587",
"text": "test email",
"tls_config": {
"insecure_skip_verify": false
},
"to": "team@example.com"
},
"type": "email",
"uid": "",
"version": "v0mimir1"
}
],
"title": "email"
}
},
{
"apiVersion": "notifications.alerting.grafana.app/v0alpha1",
"kind": "Receiver",
"metadata": {
"annotations": {
"grafana.com/access/canReadSecrets": "true",
"grafana.com/canUse": "false",
"grafana.com/inUse/routes": "0",
"grafana.com/inUse/rules": "0",
"grafana.com/provenance": "converted_prometheus"
},
"name": "amlyYQ",
"namespace": "default",
"resourceVersion": "deae9d34f8554205",
"uid": "7Pu4xcRXbvw4XEX279SoqyO8Ibo8cMl0vAJyYTsJ0NEX"
},
"spec": {
"integrations": [
{
"disableResolveMessage": false,
"secureFields": {
"http_config.basic_auth.password": true
},
"settings": {
"api_url": "http://localhost/jira",
"custom_fields": {
"customfield_10000": "test customfield_10000"
},
"description": "{{ template \"jira.default.description\" . }}",
"http_config": {
"basic_auth": {
"username": "alertmanager@example.com"
},
"enable_http2": true,
"follow_redirects": true,
"proxy_url": null,
"tls_config": {
"insecure_skip_verify": false
}
},
"issue_type": "Bug",
"labels": [
"alertmanager",
"{{ .CommonLabels.severity }}"
],
"priority": "{{ template \"jira.default.priority\" . }}",
"project": "PROJ",
"send_resolved": true,
"summary": "{{ template \"jira.default.summary\" . }}"
},
"type": "jira",
"uid": "",
"version": "v0mimir1"
}
],
"title": "jira"
}
},
{
"apiVersion": "notifications.alerting.grafana.app/v0alpha1",
"kind": "Receiver",
"metadata": {
"annotations": {
"grafana.com/access/canReadSecrets": "true",
"grafana.com/canUse": "false",
"grafana.com/inUse/routes": "0",
"grafana.com/inUse/rules": "0",
"grafana.com/provenance": "converted_prometheus"
},
"name": "bXN0ZWFtcw",
"namespace": "default",
"resourceVersion": "95c8d082d65466a3",
"uid": "z7xTMDjrk1HAHXPEx78tQb63LXYA6ivXLOtz2Z09ucIX"
},
"spec": {
"integrations": [
{
"disableResolveMessage": false,
"secureFields": {
"webhook_url": true
},
"settings": {
"http_config": {
"enable_http2": true,
"follow_redirects": true,
"proxy_url": "http://proxy.example.com:8080",
"tls_config": {
"insecure_skip_verify": false
}
},
"send_resolved": true,
"summary": "{{ template \"msteams.default.summary\" . }}",
"text": "{{ template \"msteams.default.text\" . }}",
"title": "{{ template \"msteams.default.title\" . }}"
},
"type": "teams",
"uid": "",
"version": "v0mimir1"
}
],
"title": "msteams"
}
},
{
"apiVersion": "notifications.alerting.grafana.app/v0alpha1",
"kind": "Receiver",
"metadata": {
"annotations": {
"grafana.com/access/canReadSecrets": "true",
"grafana.com/canUse": "false",
"grafana.com/inUse/routes": "0",
"grafana.com/inUse/rules": "0",
"grafana.com/provenance": "converted_prometheus"
},
"name": "b3BzZ2VuaWU",
"namespace": "default",
"resourceVersion": "8ee2957ba150ba16",
"uid": "XmkZ214Dj030hvynYiwNLq8i6uRCjUYXMXjE5m19OKAX"
},
"spec": {
"integrations": [
{
"disableResolveMessage": false,
"secureFields": {
"api_key": true
},
"settings": {
"actions": "test actions",
"api_url": "http://localhost/opsgenie/",
"description": "test description",
"details": {
"firing": "test firing"
},
"entity": "test entity",
"http_config": {
"enable_http2": true,
"follow_redirects": true,
"proxy_url": null,
"tls_config": {
"insecure_skip_verify": false
}
},
"message": "test message",
"note": "Triggered by Alertmanager",
"priority": "P3",
"responders": [
{
"name": "ops-team",
"type": "team"
}
],
"send_resolved": true,
"source": "Alertmanager",
"tags": "test-tags",
"update_alerts": true
},
"type": "opsgenie",
"uid": "",
"version": "v0mimir1"
}
],
"title": "opsgenie"
}
},
{
"apiVersion": "notifications.alerting.grafana.app/v0alpha1",
"kind": "Receiver",
"metadata": {
"annotations": {
"grafana.com/access/canReadSecrets": "true",
"grafana.com/canUse": "false",
"grafana.com/inUse/routes": "1",
"grafana.com/inUse/rules": "0",
"grafana.com/provenance": "converted_prometheus"
},
"name": "cGFnZXJkdXR5",
"namespace": "default",
"resourceVersion": "fe673d5dcd67ccf0",
"uid": "QNitkUCkwzrIc7WVCCJGGDyvXLyo9csSUVqfyStyctQX"
},
"spec": {
"integrations": [
{
"disableResolveMessage": false,
"secureFields": {
"routing_key": true,
"service_key": true
},
"settings": {
"class": "test class",
"client": "Alertmanager",
"client_url": "https://monitoring.example.com",
"component": "test component",
"description": "test description",
"details": {
"firing": "test firing",
"num_firing": "{{ .Alerts.Firing | len }}",
"num_resolved": "{{ .Alerts.Resolved | len }}",
"resolved": "{{ template \"pagerduty.default.instances\" .Alerts.Resolved }}"
},
"group": "test group",
"http_config": {
"enable_http2": true,
"follow_redirects": true,
"proxy_url": null,
"tls_config": {
"insecure_skip_verify": false
}
},
"images": [
{
"alt": "test alt",
"href": "http://localhost",
"src": "test src"
}
],
"links": [
{
"href": "http://localhost",
"text": "test text"
}
],
"send_resolved": true,
"severity": "test severity",
"source": "test source",
"url": "http://localhost/pagerduty"
},
"type": "pagerduty",
"uid": "",
"version": "v0mimir1"
}
],
"title": "pagerduty"
}
},
{
"apiVersion": "notifications.alerting.grafana.app/v0alpha1",
"kind": "Receiver",
"metadata": {
"annotations": {
"grafana.com/access/canReadSecrets": "true",
"grafana.com/canUse": "false",
"grafana.com/inUse/routes": "0",
"grafana.com/inUse/rules": "0",
"grafana.com/provenance": "converted_prometheus"
},
"name": "cHVzaG92ZXI",
"namespace": "default",
"resourceVersion": "6ae076725ab463e0",
"uid": "t2TJSktI6vyGfdbLOKmxH4eBqgcIGsAuW8Qm9m0HRycX"
},
"spec": {
"integrations": [
{
"disableResolveMessage": false,
"secureFields": {
"http_config.authorization.credentials": true,
"token": true,
"user_key": true
},
"settings": {
"expire": "1h0m0s",
"http_config": {
"authorization": {
"type": "Bearer"
},
"enable_http2": true,
"follow_redirects": true,
"proxy_url": null,
"tls_config": {
"insecure_skip_verify": false
}
},
"message": "{{ template \"pushover.default.message\" . }}",
"priority": "{{ if eq .Status \"firing\" }}2{{ else }}0{{ end }}",
"retry": "30s",
"send_resolved": true,
"title": "{{ template \"pushover.default.title\" . }}",
"url": "http://localhost/pushover"
},
"type": "pushover",
"uid": "",
"version": "v0mimir1"
}
],
"title": "pushover"
}
},
{
"apiVersion": "notifications.alerting.grafana.app/v0alpha1",
"kind": "Receiver",
"metadata": {
"annotations": {
"grafana.com/access/canReadSecrets": "true",
"grafana.com/canUse": "false",
"grafana.com/inUse/routes": "0",
"grafana.com/inUse/rules": "0",
"grafana.com/provenance": "converted_prometheus"
},
"name": "c2xhY2s",
"namespace": "default",
"resourceVersion": "ec0e343029ff5d8b",
"uid": "xSB0hnoc9j1CnLCHR3VgeVGXdVXILM0p2dM64bbHN9oX"
},
"spec": {
"integrations": [
{
"disableResolveMessage": false,
"secureFields": {
"api_url": true
},
"settings": {
"actions": [
{
"confirm": {
"dismiss_text": "test-dismiss-text",
"ok_text": "test-ok-text",
"text": "test-text",
"title": "test-title"
},
"name": "test-name",
"style": "test-style",
"text": "test-text",
"type": "test-type",
"value": "test-value"
}
],
"callback_id": "test callback id",
"channel": "#alerts",
"color": "danger",
"fallback": "test fallback",
"fields": [
{
"short": true,
"title": "test title",
"value": "test value"
}
],
"footer": "test footer",
"http_config": {
"enable_http2": true,
"follow_redirects": true,
"proxy_url": null,
"tls_config": {
"insecure_skip_verify": false
}
},
"icon_emoji": ":warning:",
"icon_url": "https://example.com/icon.png",
"image_url": "https://example.com/image.png",
"link_names": true,
"mrkdwn_in": [
"fallback",
"pretext",
"text"
],
"pretext": "test pretext",
"send_resolved": true,
"text": "test text",
"thumb_url": "https://example.com/thumb.png",
"title": "test title",
"title_link": "http://localhost",
"username": "Alerting Team"
},
"type": "slack",
"uid": "",
"version": "v0mimir1"
}
],
"title": "slack"
}
},
{
"apiVersion": "notifications.alerting.grafana.app/v0alpha1",
"kind": "Receiver",
"metadata": {
"annotations": {
"grafana.com/access/canReadSecrets": "true",
"grafana.com/canUse": "false",
"grafana.com/inUse/routes": "0",
"grafana.com/inUse/rules": "0",
"grafana.com/provenance": "converted_prometheus"
},
"name": "c25z",
"namespace": "default",
"resourceVersion": "77d734ad4c196d36",
"uid": "vSP8NtFr23hnqZqLxRgzUKfr1wOemOvZm1S6MYkfRI4X"
},
"spec": {
"integrations": [
{
"disableResolveMessage": false,
"secureFields": {
"sigv4.SecretKey": true
},
"settings": {
"attributes": {
"key1": "value1"
},
"http_config": {
"enable_http2": false,
"follow_redirects": true,
"proxy_url": null,
"tls_config": {
"insecure_skip_verify": false
}
},
"message": "{{ template \"sns.default.message\" . }}",
"send_resolved": true,
"sigv4": {
"AccessKey": "secret-access-key",
"Profile": "default,",
"Region": "us-east-1",
"RoleARN": "arn:aws:iam::123456789012:role/role-name"
},
"subject": "{{ template \"sns.default.subject\" . }}",
"topic_arn": "arn:aws:sns:us-east-1:123456789012:alerts"
},
"type": "sns",
"uid": "",
"version": "v0mimir1"
}
],
"title": "sns"
}
},
{
"apiVersion": "notifications.alerting.grafana.app/v0alpha1",
"kind": "Receiver",
"metadata": {
"annotations": {
"grafana.com/access/canReadSecrets": "true",
"grafana.com/canUse": "false",
"grafana.com/inUse/routes": "0",
"grafana.com/inUse/rules": "0",
"grafana.com/provenance": "converted_prometheus"
},
"name": "dGVsZWdyYW0",
"namespace": "default",
"resourceVersion": "d9850878a33e302e",
"uid": "XLWjtmYcjP5PiqBCwZXX3YKHV1G8niRtpCakIpcHqoYX"
},
"spec": {
"integrations": [
{
"disableResolveMessage": false,
"secureFields": {
"token": true
},
"settings": {
"api_url": "http://localhost/telegram-default",
"chat": -1001234567890,
"http_config": {
"enable_http2": true,
"follow_redirects": true,
"proxy_url": null,
"tls_config": {
"insecure_skip_verify": false
}
},
"message": "{{ template \"telegram.default.message\" . }}",
"parse_mode": "MarkdownV2",
"send_resolved": true
},
"type": "telegram",
"uid": "",
"version": "v0mimir1"
}
],
"title": "telegram"
}
},
{
"apiVersion": "notifications.alerting.grafana.app/v0alpha1",
"kind": "Receiver",
"metadata": {
"annotations": {
"grafana.com/access/canReadSecrets": "true",
"grafana.com/canUse": "false",
"grafana.com/inUse/routes": "0",
"grafana.com/inUse/rules": "0",
"grafana.com/provenance": "converted_prometheus"
},
"name": "dmljdG9yb3Bz",
"namespace": "default",
"resourceVersion": "1e6886531440afc2",
"uid": "EWiwQ6TIW0GpEo46WusW7Nvg0HuD4QAbHf0JZ2OSOhEX"
},
"spec": {
"integrations": [
{
"disableResolveMessage": false,
"secureFields": {
"api_key": true
},
"settings": {
"api_url": "http://localhost/victorops-default/",
"entity_display_name": "{{ template \"victorops.default.entity_display_name\" . }}",
"http_config": {
"enable_http2": true,
"follow_redirects": true,
"proxy_url": null,
"tls_config": {
"insecure_skip_verify": false,
"server_name": "victorops.com"
}
},
"message_type": "CRITICAL",
"monitoring_tool": "{{ template \"victorops.default.monitoring_tool\" . }}",
"routing_key": "team1",
"send_resolved": true,
"state_message": "{{ template \"victorops.default.state_message\" . }}"
},
"type": "victorops",
"uid": "",
"version": "v0mimir1"
}
],
"title": "victorops"
}
},
{
"apiVersion": "notifications.alerting.grafana.app/v0alpha1",
"kind": "Receiver",
"metadata": {
"annotations": {
"grafana.com/access/canReadSecrets": "true",
"grafana.com/canUse": "false",
"grafana.com/inUse/routes": "0",
"grafana.com/inUse/rules": "0",
"grafana.com/provenance": "converted_prometheus"
},
"name": "d2ViZXg",
"namespace": "default",
"resourceVersion": "08fc955a08dfe9c0",
"uid": "wDNufI44UXHWq4ERRYenZ7XgXVV3Tjxaokz9IjMRZ54X"
},
"spec": {
"integrations": [
{
"disableResolveMessage": false,
"secureFields": {
"http_config.authorization.credentials": true
},
"settings": {
"api_url": "http://localhost/webes-default",
"http_config": {
"authorization": {
"type": "Bearer"
},
"enable_http2": true,
"follow_redirects": true,
"proxy_url": null,
"tls_config": {
"insecure_skip_verify": false
}
},
"message": "{{ template \"webex.default.message\" . }}",
"room_id": "Y2lzY29zcGFyazovL3VzL1JPT00v12345678",
"send_resolved": true
},
"type": "webex",
"uid": "",
"version": "v0mimir1"
}
],
"title": "webex"
}
},
{
"apiVersion": "notifications.alerting.grafana.app/v0alpha1",
"kind": "Receiver",
"metadata": {
"annotations": {
"grafana.com/access/canReadSecrets": "true",
"grafana.com/canUse": "false",
"grafana.com/inUse/routes": "1",
"grafana.com/inUse/rules": "0",
"grafana.com/provenance": "converted_prometheus"
},
"name": "d2ViaG9vaw",
"namespace": "default",
"resourceVersion": "494392f899a7b410",
"uid": "aKzigXATPp6HOh20yTrlTcuF2Y9IrPHridGIcWrJygsX"
},
"spec": {
"integrations": [
{
"disableResolveMessage": false,
"secureFields": {
"url": true
},
"settings": {
"http_config": {
"enable_http2": true,
"follow_redirects": true,
"proxy_url": null,
"tls_config": {
"insecure_skip_verify": false
}
},
"max_alerts": 10,
"send_resolved": true,
"timeout": "0s",
"url_file": ""
},
"type": "webhook",
"uid": "",
"version": "v0mimir1"
}
],
"title": "webhook"
}
},
{
"apiVersion": "notifications.alerting.grafana.app/v0alpha1",
"kind": "Receiver",
"metadata": {
"annotations": {
"grafana.com/access/canReadSecrets": "true",
"grafana.com/canUse": "false",
"grafana.com/inUse/routes": "0",
"grafana.com/inUse/rules": "0",
"grafana.com/provenance": "converted_prometheus"
},
"name": "d2VjaGF0",
"namespace": "default",
"resourceVersion": "135913515cbc156b",
"uid": "jkXCvNrNVw7XX5nmYFyrGiA4ckAvJ282u2scW8KZq7IX"
},
"spec": {
"integrations": [
{
"disableResolveMessage": false,
"secureFields": {
"api_secret": true
},
"settings": {
"agent_id": "1000002",
"api_url": "http://localhost/wechat/",
"corp_id": "12345",
"http_config": {
"enable_http2": true,
"follow_redirects": true,
"proxy_url": null,
"tls_config": {
"insecure_skip_verify": false
}
},
"message": "test message",
"message_type": "text",
"send_resolved": true,
"to_party": "party1",
"to_tag": "tag1",
"to_user": "user1"
},
"type": "wechat",
"uid": "",
"version": "v0mimir1"
}
],
"title": "wechat"
}
}
],
"kind": "ReceiverList",
"metadata": {}
}
@@ -0,0 +1,247 @@
global:
resolve_timeout: 5m
smtp_smarthost: localhost:1025
smtp_from: alertmanager@example.com
smtp_auth_username: alertmanager
smtp_auth_password: password123
smtp_require_tls: false
smtp_hello: localhost
http_config:
follow_redirects: true
enable_http2: true
pagerduty_url: http://localhost/pagerduty-default
opsgenie_api_key: opsgenie-api-key-secret
opsgenie_api_url: http://localhost/opsgenie-default
victorops_api_key: victorops-api-key-secret
victorops_api_url: http://localhost/victorops-default
slack_api_url: http://localhost/slack-default
wechat_api_url: http://localhost/wechat-default
wechat_api_secret: wechat-api-secret
wechat_api_corp_id: corp123
telegram_api_url: http://localhost/telegram-default
webex_api_url: http://localhost/webes-default
jira_api_url: http://localhost/jira-default
route:
receiver: webhook
group_by:
- alertname
- cluster
group_wait: 1s
group_interval: 5s
repeat_interval: 60s
routes:
- matchers:
- severity="critical"
receiver: pagerduty
receivers:
- name: grafana-default-email
- name: discord
discord_configs:
- webhook_url: http://localhost/discord
send_resolved: true
http_config:
follow_redirects: false
enable_http2: true
title: '{{ template "discord.default.title" . }}'
message: '{{ template "discord.default.message" . }}'
- name: email
email_configs:
- to: team@example.com
from: alertmanager@example.com
smarthost: smtp.example.com:587
auth_username: alertmanager
auth_password: password123
require_tls: true
send_resolved: true
text: test email
hello: localhost
headers:
Subject: test subject
- name: jira
jira_configs:
- api_url: http://localhost/jira
send_resolved: true
http_config:
basic_auth:
username: alertmanager@example.com
password: pat123
project: PROJ
issue_type: Bug
summary: '{{ template "jira.default.summary" . }}'
description: '{{ template "jira.default.description" . }}'
priority: '{{ template "jira.default.priority" . }}'
labels:
- alertmanager
- '{{ .CommonLabels.severity }}'
fields:
customfield_10000: test customfield_10000
- name: msteams
msteams_configs:
- webhook_url: http://localhost/msteams
send_resolved: true
http_config:
proxy_url: http://proxy.example.com:8080
title: '{{ template "msteams.default.title" . }}'
summary: '{{ template "msteams.default.summary" . }}'
text: '{{ template "msteams.default.text" . }}'
- name: opsgenie
opsgenie_configs:
- api_key: api-secret-key
api_url: http://localhost/opsgenie
message: test message
description: test description
source: Alertmanager
details:
firing: test firing
entity: test entity
responders:
- type: team
name: ops-team
actions: test actions
tags: test-tags
note: Triggered by Alertmanager
priority: P3
update_alerts: true
send_resolved: true
- name: pagerduty
pagerduty_configs:
- url: http://localhost/pagerduty
routing_key: test-routing-secret-key
service_key: test-service-secret-key
client: Alertmanager
client_url: https://monitoring.example.com
description: test description
severity: test severity
details:
firing: test firing
images:
- alt: test alt
src: test src
href: http://localhost
links:
- href: http://localhost
text: test text
source: test source
class: test class
component: test component
group: test group
send_resolved: true
- name: pushover
pushover_configs:
- user_key: secret-user-key
token: secret-token
send_resolved: true
http_config:
bearer_token: token123
title: '{{ template "pushover.default.title" . }}'
message: '{{ template "pushover.default.message" . }}'
url: http://localhost/pushover
priority: '{{ if eq .Status "firing" }}2{{ else }}0{{ end }}'
retry: 30s
expire: 1h
- name: slack
slack_configs:
- api_url: http://localhost/slack
channel: '#alerts'
username: Alerting Team
color: danger
title: test title
title_link: http://localhost
pretext: test pretext
text: test text
fields:
- title: test title
value: test value
short: true
short_fields: false
footer: test footer
fallback: test fallback
callback_id: test callback id
icon_emoji: ':warning:'
icon_url: https://example.com/icon.png
image_url: https://example.com/image.png
thumb_url: https://example.com/thumb.png
link_names: true
mrkdwn_in:
- fallback
- pretext
- text
actions:
- type: test-type
text: test-text
style: test-style
name: test-name
value: test-value
confirm:
title: test-title
text: test-text
ok_text: test-ok-text
dismiss_text: test-dismiss-text
send_resolved: true
- name: sns
sns_configs:
- topic_arn: arn:aws:sns:us-east-1:123456789012:alerts
send_resolved: true
sigv4:
region: us-east-1
access_key: secret-access-key
secret_key: secret-secret-key
profile: default,
role_arn: "arn:aws:iam::123456789012:role/role-name"
http_config:
enable_http2: false
subject: '{{ template "sns.default.subject" . }}'
message: '{{ template "sns.default.message" . }}'
attributes:
key1: value1
- name: telegram
telegram_configs:
- bot_token: 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
chat_id: -1001234567890
send_resolved: true
http_config:
follow_redirects: true
enable_http2: true
message: '{{ template "telegram.default.message" . }}'
parse_mode: MarkdownV2
- name: victorops
victorops_configs:
- api_key: victorops-api-key-secret
send_resolved: true
http_config:
tls_config:
server_name: victorops.com
routing_key: team1
message_type: CRITICAL
entity_display_name: '{{ template "victorops.default.entity_display_name" . }}'
state_message: '{{ template "victorops.default.state_message" . }}'
monitoring_tool: '{{ template "victorops.default.monitoring_tool" . }}'
- name: webex
webex_configs:
- send_resolved: true
http_config:
authorization:
type: Bearer
credentials: webex-secret-token
room_id: Y2lzY29zcGFyazovL3VzL1JPT00v12345678
message: '{{ template "webex.default.message" . }}'
- name: webhook
webhook_configs:
- url: http://localhost/webhook
send_resolved: true
http_config:
follow_redirects: true
enable_http2: true
max_alerts: 10
- name: wechat
wechat_configs:
- send_resolved: true
api_url: http://localhost/wechat
api_secret: wechat-api-secret
corp_id: 12345
to_user: user1
to_party: party1
to_tag: tag1
agent_id: 1000002
message: test message
message_type: text