Merge branch 'main' into kristina/rtk-corr
This commit is contained in:
+6
-20
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;",
|
||||
|
||||
Generated
+8
-9
@@ -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
|
||||
|
||||
|
Generated
+8
-12
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
+818
@@ -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
|
||||
Reference in New Issue
Block a user