Merge remote-tracking branch 'origin/main' into ds-apiserver-schema-builder

This commit is contained in:
Ryan McKinley
2025-12-18 10:31:57 +03:00
197 changed files with 6865 additions and 6003 deletions
+9 -1
View File
@@ -81,7 +81,15 @@ func (s *SocialGoogle) Validate(ctx context.Context, newSettings ssoModels.SSOSe
return validation.Validate(info, requester,
validation.MustBeEmptyValidator(info.AuthUrl, "Auth URL"),
validation.MustBeEmptyValidator(info.TokenUrl, "Token URL"),
validation.MustBeEmptyValidator(info.ApiUrl, "API URL"))
validation.MustBeEmptyValidator(info.ApiUrl, "API URL"),
loginPromptValidator)
}
func loginPromptValidator(info *social.OAuthInfo, requester identity.Requester) error {
if info.UseRefreshToken && !slices.Contains([]string{"", "consent"}, info.LoginPrompt) {
return ssosettings.ErrInvalidOAuthConfig("If provided, login_prompt must be set to consent when use_refresh_token is enabled.")
}
return nil
}
func (s *SocialGoogle) Reload(ctx context.Context, settings ssoModels.SSOSettings) error {
@@ -9,6 +9,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
@@ -18,6 +19,7 @@ import (
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/apimachinery/errutil"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -871,6 +873,39 @@ func TestSocialGoogle_Validate(t *testing.T) {
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
{
name: "fails if use_refresh_token is enabled and login prompt is neither empty or 'consent'",
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"use_refresh_token": "true",
"login_prompt": "login",
},
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
{
name: "succeeds if use_refresh_token is enabled and login prompt is empty",
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"use_refresh_token": "true",
"login_prompt": "",
},
},
wantErr: nil,
},
{
name: "succeeds if use_refresh_token is enabled and login prompt is consent",
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"use_refresh_token": "true",
"login_prompt": "consent",
},
},
wantErr: nil,
},
}
for _, tc := range testCases {
@@ -886,7 +921,13 @@ func TestSocialGoogle_Validate(t *testing.T) {
require.ErrorIs(t, err, tc.wantErr)
return
}
require.NoError(t, err)
if err != nil {
var e errutil.Error
require.True(t, errors.As(err, &e))
require.NoError(t, e, "expected no error, got %v", e.PublicMessage)
return
}
})
}
}
@@ -1024,3 +1065,102 @@ func TestIsHDAllowed(t *testing.T) {
})
}
}
func TestSocialGoogle_AuthCodeURL(t *testing.T) {
testCases := []struct {
name string
info *social.OAuthInfo
opts []oauth2.AuthCodeOption
state string
wantURL *url.URL
}{
{
name: "should return the correct auth code URL",
info: &social.OAuthInfo{
ClientId: "client-id",
ClientSecret: "client-secret",
AuthUrl: "https://example.com/auth",
LoginPrompt: "login",
Scopes: []string{"openid", "email", "profile"},
},
state: "test-state",
opts: []oauth2.AuthCodeOption{
oauth2.SetAuthURLParam("extra_param", "extra_value"),
},
wantURL: &url.URL{
Scheme: "https",
Host: "example.com",
Path: "/auth",
RawQuery: url.Values{
"state": {"test-state"},
"prompt": {"login"},
"response_type": {"code"},
"client_id": {"client-id"},
"redirect_uri": {"/login/google"},
"scope": {"openid email profile"},
"extra_param": {"extra_value"},
}.Encode(),
},
},
{
name: "should add access type offline and approval force if use refresh token is enabled",
info: &social.OAuthInfo{
ClientId: "client-id",
ClientSecret: "client-secret",
AuthUrl: "https://example.com/auth",
Scopes: []string{"openid", "email", "profile"},
UseRefreshToken: true,
},
state: "test-state",
wantURL: &url.URL{
Scheme: "https",
Host: "example.com",
Path: "/auth",
RawQuery: url.Values{
"state": {"test-state"},
"prompt": {"consent"},
"response_type": {"code"},
"client_id": {"client-id"},
"redirect_uri": {"/login/google"},
"scope": {"openid email profile"},
"access_type": {"offline"},
}.Encode(),
},
},
{
name: "should override configured login prompt if use refresh token is enabled",
info: &social.OAuthInfo{
ClientId: "client-id",
ClientSecret: "client-secret",
AuthUrl: "https://example.com/auth",
Scopes: []string{"openid", "email", "profile"},
UseRefreshToken: true,
},
state: "test-state",
wantURL: &url.URL{
Scheme: "https",
Host: "example.com",
Path: "/auth",
RawQuery: url.Values{
"state": {"test-state"},
"prompt": {"consent"},
"response_type": {"code"},
"client_id": {"client-id"},
"redirect_uri": {"/login/google"},
"scope": {"openid email profile"},
"access_type": {"offline"},
}.Encode(),
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewGoogleProvider(tc.info, &setting.Cfg{}, nil, ssosettingstests.NewFakeService(), featuremgmt.WithFeatures())
gotURL := s.AuthCodeURL(tc.state, tc.opts...)
parsedURL, err := url.Parse(gotURL)
require.NoError(t, err)
require.EqualValues(t, tc.wantURL, parsedURL)
})
}
}
+5 -1
View File
@@ -91,7 +91,11 @@ func (s *SocialBase) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) st
func (s *SocialBase) getAuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
if s.info.LoginPrompt != "" {
promptOpt := oauth2.SetAuthURLParam("prompt", s.info.LoginPrompt)
opts = append(opts, promptOpt)
// Prepend the prompt option to the opts slice to ensure it is applied last.
// This is necessary in case the caller provides an option that overrides the prompt,
// such as `oauth2.ApprovalForce`.
opts = append([]oauth2.AuthCodeOption{promptOpt}, opts...)
}
return s.Config.AuthCodeURL(state, opts...)
@@ -85,7 +85,6 @@ func RunRepoController(deps server.OperatorDependencies) error {
resourceLister,
controllerCfg.clients,
jobs,
nil, // dualwrite -- standalone operator assumes it is backed by unified storage
healthChecker,
statusPatcher,
deps.Registerer,
@@ -3,11 +3,16 @@ package bootstrap
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/pluginassets"
"github.com/grafana/grafana/pkg/semconv"
)
// Bootstrapper is responsible for the Bootstrap stage of the plugin loader pipeline.
@@ -34,6 +39,7 @@ type Bootstrap struct {
constructStep ConstructFunc
decorateSteps []DecorateFunc
log log.Logger
tracer trace.Tracer
}
type Opts struct {
@@ -55,14 +61,21 @@ func New(cfg *config.PluginManagementCfg, opts Opts) *Bootstrap {
constructStep: opts.ConstructFunc,
decorateSteps: opts.DecorateFuncs,
log: log.New("plugins.bootstrap"),
tracer: otel.Tracer("github.com/grafana/grafana/pkg/plugins/manager/pipeline/bootstrap"),
}
}
// Bootstrap will execute the Construct and Decorate steps of the Bootstrap stage.
func (b *Bootstrap) Bootstrap(ctx context.Context, src plugins.PluginSource, found *plugins.FoundBundle) ([]*plugins.Plugin, error) {
pluginClass := src.PluginClass(ctx)
ctx, span := b.tracer.Start(ctx, "bootstrap.Bootstrap", trace.WithAttributes(
semconv.PluginSourceClass(pluginClass),
))
defer span.End()
ps, err := b.constructStep(ctx, src, found)
if err != nil {
return nil, err
return nil, tracing.Error(span, err)
}
if len(b.decorateSteps) == 0 {
@@ -76,7 +89,7 @@ func (b *Bootstrap) Bootstrap(ctx context.Context, src plugins.PluginSource, fou
ip, err = decorate(ctx, p)
if err != nil {
b.log.Error("Could not decorate plugin", "pluginId", p.ID, "error", err)
return nil, err
return nil, tracing.Error(span, err)
}
}
bootstrappedPlugins = append(bootstrappedPlugins, ip)
@@ -3,6 +3,11 @@ package discovery
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/log"
@@ -26,6 +31,7 @@ type FilterFunc func(ctx context.Context, class plugins.Class, bundles []*plugin
type Discovery struct {
filterSteps []FilterFunc
log log.Logger
tracer trace.Tracer
}
type Opts struct {
@@ -41,29 +47,37 @@ func New(_ *config.PluginManagementCfg, opts Opts) *Discovery {
return &Discovery{
filterSteps: opts.FilterFuncs,
log: log.New("plugins.discovery"),
tracer: otel.Tracer("github.com/grafana/grafana/pkg/plugins/manager/pipeline/discovery"),
}
}
// Discover will execute the Filter step of the Discovery stage.
func (d *Discovery) Discover(ctx context.Context, src plugins.PluginSource) ([]*plugins.FoundBundle, error) {
pluginClass := src.PluginClass(ctx)
ctx, span := d.tracer.Start(ctx, "discovery.Discover", trace.WithAttributes(
attribute.String("grafana.plugins.class", string(pluginClass)),
))
defer span.End()
ctxLogger := d.log.FromContext(ctx)
// Use the source's own Discover method
found, err := src.Discover(ctx)
if err != nil {
d.log.Warn("Discovery source failed", "class", src.PluginClass(ctx), "error", err)
return nil, err
ctxLogger.Warn("Discovery source failed", "class", pluginClass, "error", err)
return nil, tracing.Error(span, err)
}
d.log.Debug("Found plugins", "class", src.PluginClass(ctx), "count", len(found))
ctxLogger.Debug("Found plugins", "class", pluginClass, "count", len(found))
// Apply filtering steps
result := found
for _, filter := range d.filterSteps {
result, err = filter(ctx, src.PluginClass(ctx), result)
if err != nil {
return nil, err
return nil, tracing.Error(span, err)
}
}
d.log.Debug("Discovery complete", "class", src.PluginClass(ctx), "found", len(found), "filtered", len(result))
ctxLogger.Debug("Discovery complete", "class", pluginClass, "found", len(found), "filtered", len(result))
return result, nil
}
@@ -3,9 +3,14 @@ package initialization
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/semconv"
)
// Initializer is responsible for the Initialization stage of the plugin loader pipeline.
@@ -20,6 +25,7 @@ type Initialize struct {
cfg *config.PluginManagementCfg
initializeSteps []InitializeFunc
log log.Logger
tracer trace.Tracer
}
type Opts struct {
@@ -36,11 +42,17 @@ func New(cfg *config.PluginManagementCfg, opts Opts) *Initialize {
cfg: cfg,
initializeSteps: opts.InitializeFuncs,
log: log.New("plugins.initialization"),
tracer: otel.Tracer("github.com/grafana/grafana/pkg/plugins/manager/pipeline/initialization"),
}
}
// Initialize will execute the Initialize steps of the Initialization stage.
func (i *Initialize) Initialize(ctx context.Context, ps *plugins.Plugin) (*plugins.Plugin, error) {
ctx, span := i.tracer.Start(ctx, "initialization.Initialize", trace.WithAttributes(
semconv.GrafanaPluginId(ps.ID),
))
defer span.End()
if len(i.initializeSteps) == 0 {
return ps, nil
}
@@ -51,7 +63,7 @@ func (i *Initialize) Initialize(ctx context.Context, ps *plugins.Plugin) (*plugi
ip, err = init(ctx, ps)
if err != nil {
i.log.Error("Could not initialize plugin", "pluginId", ps.ID, "error", err)
return nil, err
return nil, tracing.Error(span, err)
}
}
@@ -3,9 +3,14 @@ package termination
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/semconv"
)
// Terminator is responsible for the Termination stage of the plugin loader pipeline.
@@ -20,6 +25,7 @@ type Terminate struct {
cfg *config.PluginManagementCfg
terminateSteps []TerminateFunc
log log.Logger
tracer trace.Tracer
}
type Opts struct {
@@ -36,14 +42,20 @@ func New(cfg *config.PluginManagementCfg, opts Opts) (*Terminate, error) {
cfg: cfg,
terminateSteps: opts.TerminateFuncs,
log: log.New("plugins.termination"),
tracer: otel.Tracer("github.com/grafana/grafana/pkg/plugins/manager/pipeline/termination"),
}, nil
}
// Terminate will execute the Terminate steps of the Termination stage.
func (t *Terminate) Terminate(ctx context.Context, p *plugins.Plugin) (*plugins.Plugin, error) {
ctx, span := t.tracer.Start(ctx, "termination.Terminate", trace.WithAttributes(
semconv.GrafanaPluginId(p.ID),
))
defer span.End()
for _, terminate := range t.terminateSteps {
if err := terminate(ctx, p); err != nil {
return nil, err
return nil, tracing.Error(span, err)
}
}
return p, nil
@@ -3,9 +3,14 @@ package validation
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/semconv"
)
// Validator is responsible for the Validation stage of the plugin loader pipeline.
@@ -20,6 +25,7 @@ type Validate struct {
cfg *config.PluginManagementCfg
validateSteps []ValidateFunc
log log.Logger
tracer trace.Tracer
}
type Opts struct {
@@ -36,11 +42,17 @@ func New(cfg *config.PluginManagementCfg, opts Opts) *Validate {
cfg: cfg,
validateSteps: opts.ValidateFuncs,
log: log.New("plugins.validation"),
tracer: otel.Tracer("github.com/grafana/grafana/pkg/plugins/manager/pipeline/validation"),
}
}
// Validate will execute the Validate steps of the Validation stage.
func (v *Validate) Validate(ctx context.Context, ps *plugins.Plugin) error {
ctx, span := v.tracer.Start(ctx, "validation.Validate", trace.WithAttributes(
semconv.GrafanaPluginId(ps.ID),
))
defer span.End()
if len(v.validateSteps) == 0 {
return nil
}
@@ -49,7 +61,7 @@ func (v *Validate) Validate(ctx context.Context, ps *plugins.Plugin) error {
err := validate(ctx, ps)
if err != nil {
v.log.Error("Plugin validation failed", "pluginId", ps.ID, "error", err)
return err
return tracing.Error(span, err)
}
}
@@ -501,7 +501,7 @@ func (a *dashboardSqlAccess) MigratePlaylists(ctx context.Context, orgId int64,
return nil, err
}
// Group playlist items by playlist ID
// Group playlist items by playlist ID while preserving order
type playlistData struct {
id int64
uid string
@@ -512,7 +512,8 @@ func (a *dashboardSqlAccess) MigratePlaylists(ctx context.Context, orgId int64,
updatedAt int64
}
playlists := make(map[int64]*playlistData)
playlistIndex := make(map[int64]int) // maps playlist ID to index in playlists slice
playlists := []*playlistData{}
var currentID int64
var orgID int64
var uid, name, interval string
@@ -527,7 +528,8 @@ func (a *dashboardSqlAccess) MigratePlaylists(ctx context.Context, orgId int64,
}
// Get or create playlist entry
pl, exists := playlists[currentID]
idx, exists := playlistIndex[currentID]
var pl *playlistData
if !exists {
pl = &playlistData{
id: currentID,
@@ -538,7 +540,10 @@ func (a *dashboardSqlAccess) MigratePlaylists(ctx context.Context, orgId int64,
createdAt: createdAt,
updatedAt: updatedAt,
}
playlists[currentID] = pl
playlistIndex[currentID] = len(playlists)
playlists = append(playlists, pl)
} else {
pl = playlists[idx]
}
// Add item if it exists (LEFT JOIN can return NULL for playlists without items)
@@ -554,7 +559,7 @@ func (a *dashboardSqlAccess) MigratePlaylists(ctx context.Context, orgId int64,
return nil, err
}
// Convert to K8s objects and send to stream
// Convert to K8s objects and send to stream (order is preserved)
for _, pl := range playlists {
playlist := &playlistv0.Playlist{
TypeMeta: metav1.TypeMeta{
@@ -360,7 +360,17 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resourcepb.Reso
})
}
list.TotalHits = int64(len(list.Results.Rows))
// the UI expects us to populate "TotalHits" with the total search hits, not however many we are returning.
// this is a dumb workaround due to the fact that legacy doesn't expose a way of "counting search hits"
// it fixes a bug that only happens in mode 1-2 that prevents pagination from working in the dashboard list view
// we only have a handful of instances running in this mode and moving towards 0 instances fast, so this is fine
query.Limit = 0
query.Page = 1
res, err = c.dashboardStore.FindDashboards(ctx, query)
if err != nil {
return nil, err
}
list.TotalHits = int64(len(res))
return list, nil
}
@@ -48,6 +48,18 @@ func TestDashboardSearchClient_Search(t *testing.T) {
{ID: 2, UID: "uid2", Title: "Test Dashboard2", FolderUID: "folder2", Tags: []string{}},
}, nil).Once()
// Second call for total count with Page: 1, Limit: 0
mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{
SignedInUser: user,
Type: "dash-db",
Sort: sorter,
Limit: 0,
Page: 1,
}).Return([]dashboards.DashboardSearchProjection{
{ID: 1, UID: "uid", Title: "Test Dashboard", FolderUID: "folder1", Tags: []string{"term"}},
{ID: 2, UID: "uid2", Title: "Test Dashboard2", FolderUID: "folder2", Tags: []string{}},
}, nil).Once()
req := &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: dashboardKey,
@@ -124,6 +136,17 @@ func TestDashboardSearchClient_Search(t *testing.T) {
{ID: 1, UID: "uid", Title: "Test Dashboard", FolderUID: "folder", SortMeta: int64(50), Tags: []string{}},
}, nil).Once()
// Second call for total count with Page: 1, Limit: 0
mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{
SignedInUser: user,
Type: "dash-db",
Sort: sortOptionAsc,
Limit: 0,
Page: 1,
}).Return([]dashboards.DashboardSearchProjection{
{ID: 1, UID: "uid", Title: "Test Dashboard", FolderUID: "folder", SortMeta: int64(50), Tags: []string{}},
}, nil).Once()
req := &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: dashboardKey,
@@ -189,6 +212,17 @@ func TestDashboardSearchClient_Search(t *testing.T) {
{ID: 1, UID: "uid", Title: "Test Dashboard", FolderUID: "folder", SortMeta: int64(2), Tags: []string{}},
}, nil).Once()
// Second call for total count with Page: 1, Limit: 0
mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{
SignedInUser: user,
Type: "dash-db",
Sort: sortOptionAsc,
Limit: 0,
Page: 1,
}).Return([]dashboards.DashboardSearchProjection{
{ID: 1, UID: "uid", Title: "Test Dashboard", FolderUID: "folder", SortMeta: int64(2), Tags: []string{}},
}, nil).Once()
req := &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: dashboardKey,
@@ -293,6 +327,17 @@ func TestDashboardSearchClient_Search(t *testing.T) {
{UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"},
}, nil).Once()
// Second call for total count with Page: 1, Limit: 0
mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{
Title: "test",
SignedInUser: user,
Type: "dash-db",
Limit: 0,
Page: 1,
}).Return([]dashboards.DashboardSearchProjection{
{UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"},
}, nil).Once()
req := &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: dashboardKey,
@@ -319,6 +364,18 @@ func TestDashboardSearchClient_Search(t *testing.T) {
{UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"},
}, nil).Once()
// Second call for total count with Page: 1, Limit: 0
mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{
Title: "test",
TitleExactMatch: true,
SignedInUser: user,
Type: "dash-db",
Limit: 0,
Page: 1,
}).Return([]dashboards.DashboardSearchProjection{
{UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"},
}, nil).Once()
req := &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: dashboardKey,
@@ -350,6 +407,17 @@ func TestDashboardSearchClient_Search(t *testing.T) {
{UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"},
}, nil).Once()
// Second call for total count with Page: 1, Limit: 0
mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{
DashboardIds: []int64{1, 2},
SignedInUser: user,
Type: "dash-db",
Limit: 0,
Page: 1,
}).Return([]dashboards.DashboardSearchProjection{
{UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"},
}, nil).Once()
req := &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: dashboardKey,
@@ -383,6 +451,19 @@ func TestDashboardSearchClient_Search(t *testing.T) {
{UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"},
}, nil).Once()
// Second call for total count with Page: 1, Limit: 0
mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{
DashboardUIDs: []string{"uid1", "uid2"},
Tags: []string{"tag1", "tag2"},
FolderUIDs: []string{"general", "folder1"},
SignedInUser: user,
Type: "dash-db",
Limit: 0,
Page: 1,
}).Return([]dashboards.DashboardSearchProjection{
{UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"},
}, nil).Once()
req := &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: dashboardKey,
@@ -532,6 +613,17 @@ func TestDashboardSearchClient_Search(t *testing.T) {
{ID: 1, UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"},
}, nil).Once()
// Second call for total count with Page: 1, Limit: 0
mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{
SignedInUser: user,
Sort: sort.SortAlphaAsc,
Type: "dash-db",
Limit: 0,
Page: 1,
}).Return([]dashboards.DashboardSearchProjection{
{ID: 1, UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"},
}, nil).Once()
req := &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: dashboardKey,
@@ -552,7 +644,7 @@ func TestDashboardSearchClient_Search(t *testing.T) {
t.Run("Should set correct sort field when sorting by views", func(t *testing.T) {
mockStore.On("FindDashboards", mock.Anything, mock.Anything).Return([]dashboards.DashboardSearchProjection{
{ID: 1, UID: "uid", Title: "Test Dashboard", FolderUID: "folder1", SortMeta: 100},
}, nil).Once()
}, nil).Twice() // Will be called twice due to the total count call
req := &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
@@ -584,7 +676,7 @@ func TestDashboardSearchClient_Search(t *testing.T) {
mockStore.On("FindDashboards", mock.Anything, mock.Anything).Return([]dashboards.DashboardSearchProjection{
{UID: "dashboard1", FolderUID: "folder1", ID: 1},
{UID: "dashboard2", FolderUID: "folder2", ID: 2},
}, nil).Once()
}, nil).Twice() // Will be called twice due to the total count call
req := &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
@@ -26,7 +26,6 @@ import (
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"github.com/prometheus/client_golang/prometheus"
)
@@ -53,7 +52,6 @@ type RepositoryController struct {
repoLister listers.RepositoryLister
repoSynced cache.InformerSynced
logger logging.Logger
dualwrite dualwrite.Service
jobs interface {
jobs.Queue
@@ -86,7 +84,6 @@ func NewRepositoryController(
jobs.Queue
jobs.Store
},
dualwrite dualwrite.Service,
healthChecker *HealthChecker,
statusPatcher StatusPatcher,
registry prometheus.Registerer,
@@ -114,11 +111,10 @@ func NewRepositoryController(
metrics: &finalizerMetrics,
maxWorkers: parallelOperations,
},
jobs: jobs,
logger: logging.DefaultLogger.With("logger", loggerName),
dualwrite: dualwrite,
registry: registry,
tracer: tracer,
jobs: jobs,
logger: logging.DefaultLogger.With("logger", loggerName),
registry: registry,
tracer: tracer,
}
_, err := repoInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
@@ -356,9 +352,6 @@ func (rc *RepositoryController) determineSyncStrategy(ctx context.Context, obj *
case !healthStatus.Healthy:
logger.Info("skip sync for unhealthy repository")
return nil
case rc.dualwrite != nil && dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, rc.dualwrite):
logger.Info("skip sync as we are reading from legacy storage")
return nil
case healthStatus.Healthy != obj.Status.Health.Healthy:
logger.Info("repository became healthy, full resync")
return &provisioning.SyncJobOptions{}
@@ -1,4 +1,4 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
// Code generated by mockery v2.53.4. DO NOT EDIT.
package export
@@ -1,4 +1,4 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
// Code generated by mockery v2.53.4. DO NOT EDIT.
package export
@@ -49,7 +49,6 @@ func TestLokiJobHistory_WriteJob(t *testing.T) {
Path: "/exported",
},
Migrate: &provisioning.MigrateJobOptions{
History: true,
Message: "Migration test",
},
Delete: &provisioning.DeleteJobOptions{
@@ -101,7 +100,7 @@ func TestLokiJobHistory_WriteJob(t *testing.T) {
}
t.Run("jobToStream creates correct stream with all fields", func(t *testing.T) {
history := createTestLokiJobHistory(t)
history := createTestLokiJobHistory()
// Clean job copy like WriteJob does
jobCopy := job.DeepCopy()
delete(jobCopy.Labels, LabelJobClaim)
@@ -147,7 +146,6 @@ func TestLokiJobHistory_WriteJob(t *testing.T) {
assert.Equal(t, "main", deserializedJob.Spec.Push.Branch)
assert.Equal(t, "/exported", deserializedJob.Spec.Push.Path)
require.NotNil(t, deserializedJob.Spec.Migrate)
assert.True(t, deserializedJob.Spec.Migrate.History)
assert.Equal(t, "Migration test", deserializedJob.Spec.Migrate.Message)
require.NotNil(t, deserializedJob.Spec.Delete)
assert.Equal(t, "main", deserializedJob.Spec.Delete.Ref)
@@ -190,7 +188,7 @@ func TestLokiJobHistory_WriteJob(t *testing.T) {
})
t.Run("buildJobQuery creates correct LogQL", func(t *testing.T) {
history := createTestLokiJobHistory(t)
history := createTestLokiJobHistory()
query := history.buildJobQuery("test-ns", "test-repo")
@@ -199,7 +197,7 @@ func TestLokiJobHistory_WriteJob(t *testing.T) {
})
t.Run("getJobTimestamp returns correct timestamp", func(t *testing.T) {
history := createTestLokiJobHistory(t)
history := createTestLokiJobHistory()
// Test finished time priority
jobWithFinished := &provisioning.Job{
@@ -584,7 +582,7 @@ func TestLokiJobHistory_GetJob(t *testing.T) {
}
// createTestLokiJobHistory creates a LokiJobHistory for testing
func createTestLokiJobHistory(t *testing.T) *LokiJobHistory {
func createTestLokiJobHistory() *LokiJobHistory {
// Create test URLs
readURL, _ := url.Parse("http://localhost:3100")
writeURL, _ := url.Parse("http://localhost:3100")
@@ -1,95 +0,0 @@
package migrate
import (
"context"
"errors"
"fmt"
"time"
"github.com/grafana/grafana-app-sdk/logging"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
)
type LegacyMigrator struct {
legacyMigrator LegacyResourcesMigrator
storageSwapper StorageSwapper
syncWorker jobs.Worker
wrapWithStageFn WrapWithStageFn
}
func NewLegacyMigrator(
legacyMigrator LegacyResourcesMigrator,
storageSwapper StorageSwapper,
syncWorker jobs.Worker,
wrapWithStageFn WrapWithStageFn,
) *LegacyMigrator {
return &LegacyMigrator{
legacyMigrator: legacyMigrator,
storageSwapper: storageSwapper,
syncWorker: syncWorker,
wrapWithStageFn: wrapWithStageFn,
}
}
func (m *LegacyMigrator) Migrate(ctx context.Context, rw repository.ReaderWriter, options provisioning.MigrateJobOptions, progress jobs.JobProgressRecorder) error {
namespace := rw.Config().Namespace
var stageMode repository.StageMode
if options.History {
// When History is true, we want to commit and push each file (previous PushOnWrites: true)
stageMode = repository.StageModeCommitAndPushOnEach
} else {
// When History is false, we want to commit only once (previous CommitOnlyOnce: true)
stageMode = repository.StageModeCommitOnlyOnce
}
stageOptions := repository.StageOptions{
Mode: stageMode,
CommitOnlyOnceMessage: options.Message,
// TODO: make this configurable
Timeout: 10 * time.Minute,
}
// Fail if migrating at least one
progress.StrictMaxErrors(1)
progress.SetMessage(ctx, "migrating legacy resources")
if err := m.wrapWithStageFn(ctx, rw, stageOptions, func(repo repository.Repository, staged bool) error {
rw, ok := repo.(repository.ReaderWriter)
if !ok {
return errors.New("migration job submitted targeting repository that is not a ReaderWriter")
}
return m.legacyMigrator.Migrate(ctx, rw, namespace, options, progress)
}); err != nil {
return fmt.Errorf("migrate from SQL: %w", err)
}
progress.SetMessage(ctx, "resetting unified storage")
if err := m.storageSwapper.WipeUnifiedAndSetMigratedFlag(ctx, namespace); err != nil {
return fmt.Errorf("unable to reset unified storage %w", err)
}
// Reset the results after the export as pull will operate on the same resources
progress.ResetResults()
// Delegate the import to a sync (from the already checked out go-git repository!)
progress.SetMessage(ctx, "pulling resources")
if err := m.syncWorker.Process(ctx, rw, provisioning.Job{
Spec: provisioning.JobSpec{
Pull: &provisioning.SyncJobOptions{
Incremental: false,
},
},
}, progress); err != nil { // this will have an error when too many errors exist
progress.SetMessage(ctx, "error importing resources, reverting")
if e2 := m.storageSwapper.StopReadingUnifiedStorage(ctx); e2 != nil {
logger := logging.FromContext(ctx)
logger.Warn("error trying to revert dual write settings after an error", "err", err)
}
return err
}
return nil
}
@@ -1,265 +0,0 @@
package migrate
import (
"context"
"fmt"
"k8s.io/apimachinery/pkg/runtime/schema"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/export"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources/signature"
unifiedmigrations "github.com/grafana/grafana/pkg/storage/unified/migrations"
"github.com/grafana/grafana/pkg/storage/unified/parquet"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
)
var _ resource.BulkResourceWriter = (*legacyResourceResourceMigrator)(nil)
//go:generate mockery --name LegacyResourcesMigrator --structname MockLegacyResourcesMigrator --inpackage --filename mock_legacy_resources_migrator.go --with-expecter
type LegacyResourcesMigrator interface {
Migrate(ctx context.Context, rw repository.ReaderWriter, namespace string, opts provisioning.MigrateJobOptions, progress jobs.JobProgressRecorder) error
}
type legacyResourcesMigrator struct {
repositoryResources resources.RepositoryResourcesFactory
parsers resources.ParserFactory
dashboardAccess legacy.MigrationDashboardAccessor
signerFactory signature.SignerFactory
clients resources.ClientFactory
exportFn export.ExportFn
}
func NewLegacyResourcesMigrator(
repositoryResources resources.RepositoryResourcesFactory,
parsers resources.ParserFactory,
dashboardAccess legacy.MigrationDashboardAccessor,
signerFactory signature.SignerFactory,
clients resources.ClientFactory,
exportFn export.ExportFn,
) LegacyResourcesMigrator {
return &legacyResourcesMigrator{
repositoryResources: repositoryResources,
parsers: parsers,
dashboardAccess: dashboardAccess,
signerFactory: signerFactory,
clients: clients,
exportFn: exportFn,
}
}
func (m *legacyResourcesMigrator) Migrate(ctx context.Context, rw repository.ReaderWriter, namespace string, opts provisioning.MigrateJobOptions, progress jobs.JobProgressRecorder) error {
parser, err := m.parsers.GetParser(ctx, rw)
if err != nil {
return fmt.Errorf("get parser: %w", err)
}
repositoryResources, err := m.repositoryResources.Client(ctx, rw)
if err != nil {
return fmt.Errorf("get repository resources: %w", err)
}
// FIXME: signature is only relevant for repositories which support signature
// Not all repositories support history
signer, err := m.signerFactory.New(ctx, signature.SignOptions{
Namespace: namespace,
History: opts.History,
})
if err != nil {
return fmt.Errorf("get signer: %w", err)
}
progress.SetMessage(ctx, "migrate folders from SQL")
clients, err := m.clients.Clients(ctx, namespace)
if err != nil {
return err
}
// nothing special for the export for now
exportOpts := provisioning.ExportJobOptions{}
if err = m.exportFn(ctx, rw.Config().Name, exportOpts, clients, repositoryResources, progress); err != nil {
return fmt.Errorf("migrate folders from SQL: %w", err)
}
progress.SetMessage(ctx, "migrate resources from SQL")
for _, kind := range resources.SupportedProvisioningResources {
if kind == resources.FolderResource {
continue // folders have special handling
}
reader := newLegacyResourceMigrator(
rw,
m.dashboardAccess,
parser,
repositoryResources,
progress,
opts,
namespace,
kind.GroupResource(),
signer,
)
if err := reader.Migrate(ctx); err != nil {
return fmt.Errorf("migrate resource %s: %w", kind, err)
}
}
return nil
}
type legacyResourceResourceMigrator struct {
repo repository.ReaderWriter
dashboardAccess legacy.MigrationDashboardAccessor
parser resources.Parser
progress jobs.JobProgressRecorder
namespace string
kind schema.GroupResource
options provisioning.MigrateJobOptions
resources resources.RepositoryResources
signer signature.Signer
history map[string]string // UID >> file path
}
func newLegacyResourceMigrator(
repo repository.ReaderWriter,
dashboardAccess legacy.MigrationDashboardAccessor,
parser resources.Parser,
resources resources.RepositoryResources,
progress jobs.JobProgressRecorder,
options provisioning.MigrateJobOptions,
namespace string,
kind schema.GroupResource,
signer signature.Signer,
) *legacyResourceResourceMigrator {
var history map[string]string
if options.History {
history = make(map[string]string)
}
return &legacyResourceResourceMigrator{
repo: repo,
dashboardAccess: dashboardAccess,
parser: parser,
progress: progress,
options: options,
namespace: namespace,
kind: kind,
resources: resources,
signer: signer,
history: history,
}
}
// Close implements resource.BulkResourceWriter.
func (r *legacyResourceResourceMigrator) Close() error {
return nil
}
// CloseWithResults implements resource.BulkResourceWriter.
func (r *legacyResourceResourceMigrator) CloseWithResults() (*resourcepb.BulkResponse, error) {
return &resourcepb.BulkResponse{}, nil
}
// Write implements resource.BulkResourceWriter.
func (r *legacyResourceResourceMigrator) Write(ctx context.Context, key *resourcepb.ResourceKey, value []byte) error {
// Reuse the same parse+cleanup logic
parsed, err := r.parser.Parse(ctx, &repository.FileInfo{
Path: "", // empty path to ignore file system
Data: value,
})
if err != nil {
return fmt.Errorf("unmarshal unstructured: %w", err)
}
// clear anything so it will get written
parsed.Meta.SetManagerProperties(utils.ManagerProperties{})
parsed.Meta.SetSourceProperties(utils.SourceProperties{})
// Add author signature to the context
ctx, err = r.signer.Sign(ctx, parsed.Meta)
if err != nil {
return fmt.Errorf("add author signature: %w", err)
}
// TODO: this seems to be same logic as the export job
// TODO: we should use a kind safe manager here
fileName, err := r.resources.WriteResourceFileFromObject(ctx, parsed.Obj, resources.WriteOptions{
Path: "",
Ref: "",
})
// When replaying history, the path to the file may change over time
// This happens when the title or folder change
if r.history != nil && err == nil {
name := parsed.Meta.GetName()
previous := r.history[name]
if previous != "" && previous != fileName {
err = r.repo.Delete(ctx, previous, "", fmt.Sprintf("moved to: %s", fileName))
}
r.history[name] = fileName
}
result := jobs.JobResourceResult{
Name: parsed.Meta.GetName(),
Group: r.kind.Group,
Kind: parsed.GVK.Kind,
Action: repository.FileActionCreated,
Path: fileName,
}
if err != nil {
result.Error = fmt.Errorf("writing resource %s/%s %s to file %s: %w", r.kind.Group, r.kind.Resource, parsed.Meta.GetName(), fileName, err)
}
r.progress.Record(ctx, result)
if err := r.progress.TooManyErrors(); err != nil {
return err
}
return nil
}
func (r *legacyResourceResourceMigrator) Migrate(ctx context.Context) error {
r.progress.SetMessage(ctx, fmt.Sprintf("migrate %s resource", r.kind.Resource))
// Create a parquet migrator with this instance as the BulkResourceWriter
parquetClient := parquet.NewBulkResourceWriterClient(r)
migrator := unifiedmigrations.ProvideUnifiedMigratorParquet(
r.dashboardAccess,
parquetClient,
)
opts := legacy.MigrateOptions{
Namespace: r.namespace,
WithHistory: r.options.History,
Resources: []schema.GroupResource{r.kind},
OnlyCount: true, // first get the count
}
stats, err := migrator.Migrate(ctx, opts)
if err != nil {
return fmt.Errorf("unable to count legacy items %w", err)
}
// FIXME: explain why we calculate it in this way
if len(stats.Summary) > 0 {
count := stats.Summary[0].Count //
history := stats.Summary[0].History
if history > count {
count = history // the number of items we will process
}
r.progress.SetTotal(ctx, int(count))
}
opts.OnlyCount = false // this time actually write
_, err = migrator.Migrate(ctx, opts)
if err != nil {
return fmt.Errorf("migrate legacy %s: %w", r.kind.Resource, err)
}
return nil
}
@@ -1,934 +0,0 @@
package migrate
import (
"context"
"errors"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/export"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources/signature"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
)
func TestLegacyResourcesMigrator_Migrate(t *testing.T) {
t.Run("should fail when parser factory fails", func(t *testing.T) {
mockParserFactory := resources.NewMockParserFactory(t)
mockParserFactory.On("GetParser", mock.Anything, mock.Anything).
Return(nil, errors.New("parser factory error"))
signerFactory := signature.NewMockSignerFactory(t)
mockClientFactory := resources.NewMockClientFactory(t)
mockExportFn := export.NewMockExportFn(t)
migrator := NewLegacyResourcesMigrator(
nil,
mockParserFactory,
nil,
signerFactory,
mockClientFactory,
mockExportFn.Execute,
)
err := migrator.Migrate(context.Background(), nil, "test-namespace", provisioning.MigrateJobOptions{}, jobs.NewMockJobProgressRecorder(t))
require.Error(t, err)
require.EqualError(t, err, "get parser: parser factory error")
mockParserFactory.AssertExpectations(t)
mockExportFn.AssertExpectations(t)
mockClientFactory.AssertExpectations(t)
})
t.Run("should fail when repository resources factory fails", func(t *testing.T) {
mockParserFactory := resources.NewMockParserFactory(t)
mockParserFactory.On("GetParser", mock.Anything, mock.Anything).
Return(resources.NewMockParser(t), nil)
mockRepoResourcesFactory := resources.NewMockRepositoryResourcesFactory(t)
mockRepoResourcesFactory.On("Client", mock.Anything, mock.Anything).
Return(nil, errors.New("repo resources factory error"))
signerFactory := signature.NewMockSignerFactory(t)
mockClientFactory := resources.NewMockClientFactory(t)
mockExportFn := export.NewMockExportFn(t)
migrator := NewLegacyResourcesMigrator(
mockRepoResourcesFactory,
mockParserFactory,
nil,
signerFactory,
mockClientFactory,
mockExportFn.Execute,
)
err := migrator.Migrate(context.Background(), nil, "test-namespace", provisioning.MigrateJobOptions{}, jobs.NewMockJobProgressRecorder(t))
require.Error(t, err)
require.EqualError(t, err, "get repository resources: repo resources factory error")
mockParserFactory.AssertExpectations(t)
mockRepoResourcesFactory.AssertExpectations(t)
mockExportFn.AssertExpectations(t)
mockClientFactory.AssertExpectations(t)
})
t.Run("should fail when resource migration fails", func(t *testing.T) {
mockParserFactory := resources.NewMockParserFactory(t)
mockParserFactory.On("GetParser", mock.Anything, mock.Anything).
Return(resources.NewMockParser(t), nil)
mockRepoResources := resources.NewMockRepositoryResources(t)
mockRepoResourcesFactory := resources.NewMockRepositoryResourcesFactory(t)
mockRepoResourcesFactory.On("Client", mock.Anything, mock.Anything).
Return(mockRepoResources, nil)
mockDashboardAccess := legacy.NewMockMigrationDashboardAccessor(t)
mockDashboardAccess.On("CountResources", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool {
return opts.OnlyCount && opts.Namespace == "test-namespace"
})).Return(&resourcepb.BulkResponse{}, errors.New("legacy migrator error"))
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, mock.Anything).Return()
signer := signature.NewMockSigner(t)
signerFactory := signature.NewMockSignerFactory(t)
signerFactory.On("New", mock.Anything, mock.Anything).
Return(signer, nil)
mockClients := resources.NewMockResourceClients(t)
mockClientFactory := resources.NewMockClientFactory(t)
mockClientFactory.On("Clients", mock.Anything, "test-namespace").
Return(mockClients, nil)
mockExportFn := export.NewMockExportFn(t)
migrator := NewLegacyResourcesMigrator(
mockRepoResourcesFactory,
mockParserFactory,
mockDashboardAccess,
signerFactory,
mockClientFactory,
mockExportFn.Execute,
)
repo := repository.NewMockRepository(t)
repo.On("Config").Return(&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test-namespace",
Name: "test-repo",
},
})
mockExportFn.On("Execute", mock.Anything, mock.Anything, provisioning.ExportJobOptions{}, mockClients, mockRepoResources, mock.Anything).
Return(nil)
err := migrator.Migrate(context.Background(), repo, "test-namespace", provisioning.MigrateJobOptions{}, progress)
require.Error(t, err)
require.Contains(t, err.Error(), "migrate resource")
mockParserFactory.AssertExpectations(t)
mockRepoResourcesFactory.AssertExpectations(t)
mockDashboardAccess.AssertExpectations(t)
progress.AssertExpectations(t)
mockExportFn.AssertExpectations(t)
mockClientFactory.AssertExpectations(t)
mockClients.AssertExpectations(t)
repo.AssertExpectations(t)
})
t.Run("should fail when client creation fails", func(t *testing.T) {
mockParserFactory := resources.NewMockParserFactory(t)
mockParserFactory.On("GetParser", mock.Anything, mock.Anything).
Return(resources.NewMockParser(t), nil)
mockRepoResources := resources.NewMockRepositoryResources(t)
mockRepoResourcesFactory := resources.NewMockRepositoryResourcesFactory(t)
mockRepoResourcesFactory.On("Client", mock.Anything, mock.Anything).
Return(mockRepoResources, nil)
mockSigner := signature.NewMockSigner(t)
mockSignerFactory := signature.NewMockSignerFactory(t)
mockSignerFactory.On("New", mock.Anything, mock.Anything).
Return(mockSigner, nil)
mockClientFactory := resources.NewMockClientFactory(t)
mockClientFactory.On("Clients", mock.Anything, "test-namespace").
Return(nil, errors.New("client creation error"))
mockExportFn := export.NewMockExportFn(t)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, "migrate folders from SQL").Return()
migrator := NewLegacyResourcesMigrator(
mockRepoResourcesFactory,
mockParserFactory,
nil,
mockSignerFactory,
mockClientFactory,
mockExportFn.Execute,
)
repo := repository.NewMockRepository(t)
err := migrator.Migrate(context.Background(), repo, "test-namespace", provisioning.MigrateJobOptions{}, progress)
require.Error(t, err)
require.EqualError(t, err, "client creation error")
mockParserFactory.AssertExpectations(t)
mockRepoResourcesFactory.AssertExpectations(t)
mockSignerFactory.AssertExpectations(t)
mockClientFactory.AssertExpectations(t)
progress.AssertExpectations(t)
mockExportFn.AssertExpectations(t)
repo.AssertExpectations(t)
})
t.Run("should fail when signer factory fails", func(t *testing.T) {
mockParserFactory := resources.NewMockParserFactory(t)
mockParserFactory.On("GetParser", mock.Anything, mock.Anything).
Return(resources.NewMockParser(t), nil)
mockRepoResources := resources.NewMockRepositoryResources(t)
mockRepoResourcesFactory := resources.NewMockRepositoryResourcesFactory(t)
mockRepoResourcesFactory.On("Client", mock.Anything, mock.Anything).
Return(mockRepoResources, nil)
mockSignerFactory := signature.NewMockSignerFactory(t)
mockSignerFactory.On("New", mock.Anything, signature.SignOptions{
Namespace: "test-namespace",
History: true,
}).Return(nil, fmt.Errorf("signer factory error"))
mockClientFactory := resources.NewMockClientFactory(t)
mockExportFn := export.NewMockExportFn(t)
progress := jobs.NewMockJobProgressRecorder(t)
migrator := NewLegacyResourcesMigrator(
mockRepoResourcesFactory,
mockParserFactory,
nil,
mockSignerFactory,
mockClientFactory,
mockExportFn.Execute,
)
err := migrator.Migrate(context.Background(), nil, "test-namespace", provisioning.MigrateJobOptions{
History: true,
}, progress)
require.Error(t, err)
require.EqualError(t, err, "get signer: signer factory error")
mockParserFactory.AssertExpectations(t)
mockRepoResourcesFactory.AssertExpectations(t)
mockSignerFactory.AssertExpectations(t)
mockClientFactory.AssertExpectations(t)
progress.AssertExpectations(t)
mockExportFn.AssertExpectations(t)
})
t.Run("should fail when folder export fails", func(t *testing.T) {
mockParser := resources.NewMockParser(t)
mockParserFactory := resources.NewMockParserFactory(t)
mockParserFactory.On("GetParser", mock.Anything, mock.Anything).
Return(mockParser, nil)
mockRepoResources := resources.NewMockRepositoryResources(t)
mockRepoResourcesFactory := resources.NewMockRepositoryResourcesFactory(t)
mockRepoResourcesFactory.On("Client", mock.Anything, mock.Anything).
Return(mockRepoResources, nil)
mockSigner := signature.NewMockSigner(t)
mockSignerFactory := signature.NewMockSignerFactory(t)
mockSignerFactory.On("New", mock.Anything, signature.SignOptions{
Namespace: "test-namespace",
History: false,
}).Return(mockSigner, nil)
mockClients := resources.NewMockResourceClients(t)
mockClientFactory := resources.NewMockClientFactory(t)
mockClientFactory.On("Clients", mock.Anything, "test-namespace").
Return(mockClients, nil)
mockExportFn := export.NewMockExportFn(t)
mockExportFn.On("Execute", mock.Anything, mock.Anything, provisioning.ExportJobOptions{}, mockClients, mockRepoResources, mock.Anything).
Return(fmt.Errorf("export error"))
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, "migrate folders from SQL").Return()
migrator := NewLegacyResourcesMigrator(
mockRepoResourcesFactory,
mockParserFactory,
nil,
mockSignerFactory,
mockClientFactory,
mockExportFn.Execute,
)
repo := repository.NewMockRepository(t)
repo.On("Config").Return(&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test-namespace",
Name: "test-repo",
},
})
err := migrator.Migrate(context.Background(), repo, "test-namespace", provisioning.MigrateJobOptions{}, progress)
require.Error(t, err)
require.Contains(t, err.Error(), "migrate folders from SQL: export error")
mockParserFactory.AssertExpectations(t)
mockRepoResourcesFactory.AssertExpectations(t)
mockSignerFactory.AssertExpectations(t)
mockClientFactory.AssertExpectations(t)
mockExportFn.AssertExpectations(t)
progress.AssertExpectations(t)
})
t.Run("should successfully migrate all resources", func(t *testing.T) {
mockParser := resources.NewMockParser(t)
mockParserFactory := resources.NewMockParserFactory(t)
mockParserFactory.On("GetParser", mock.Anything, mock.Anything).
Return(mockParser, nil)
mockRepoResources := resources.NewMockRepositoryResources(t)
mockRepoResourcesFactory := resources.NewMockRepositoryResourcesFactory(t)
mockRepoResourcesFactory.On("Client", mock.Anything, mock.Anything).
Return(mockRepoResources, nil)
mockSigner := signature.NewMockSigner(t)
mockSignerFactory := signature.NewMockSignerFactory(t)
mockSignerFactory.On("New", mock.Anything, signature.SignOptions{
Namespace: "test-namespace",
History: true,
}).Return(mockSigner, nil)
mockDashboardAccess := legacy.NewMockMigrationDashboardAccessor(t)
// Mock CountResources for the count phase
mockDashboardAccess.On("CountResources", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool {
return opts.OnlyCount && opts.Namespace == "test-namespace"
})).Return(&resourcepb.BulkResponse{}, nil).Once()
// Mock MigrateDashboards for the actual migration phase (dashboards resource)
mockDashboardAccess.On("MigrateDashboards", mock.Anything, mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool {
return !opts.OnlyCount && opts.Namespace == "test-namespace"
}), mock.Anything).Return(&legacy.BlobStoreInfo{
Count: 10,
Size: 5,
}, nil).Once()
mockClients := resources.NewMockResourceClients(t)
mockClientFactory := resources.NewMockClientFactory(t)
mockClientFactory.On("Clients", mock.Anything, "test-namespace").
Return(mockClients, nil)
mockExportFn := export.NewMockExportFn(t)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, "migrate folders from SQL").Return()
progress.On("SetMessage", mock.Anything, "migrate resources from SQL").Return()
progress.On("SetMessage", mock.Anything, "migrate dashboards resource").Return()
migrator := NewLegacyResourcesMigrator(
mockRepoResourcesFactory,
mockParserFactory,
mockDashboardAccess,
mockSignerFactory,
mockClientFactory,
mockExportFn.Execute,
)
repo := repository.NewMockRepository(t)
repo.On("Config").Return(&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test-namespace",
Name: "test-repo",
},
})
mockExportFn.On("Execute", mock.Anything, mock.Anything, provisioning.ExportJobOptions{}, mockClients, mockRepoResources, mock.Anything).
Return(nil)
err := migrator.Migrate(context.Background(), repo, "test-namespace", provisioning.MigrateJobOptions{
History: true,
}, progress)
require.NoError(t, err)
mockParserFactory.AssertExpectations(t)
mockRepoResourcesFactory.AssertExpectations(t)
mockDashboardAccess.AssertExpectations(t)
mockClientFactory.AssertExpectations(t)
mockExportFn.AssertExpectations(t)
progress.AssertExpectations(t)
mockClients.AssertExpectations(t)
})
}
func TestLegacyResourceResourceMigrator_Write(t *testing.T) {
t.Run("should fail when parser fails", func(t *testing.T) {
mockParser := resources.NewMockParser(t)
mockParser.On("Parse", mock.Anything, mock.Anything).
Return(nil, errors.New("parser error"))
progress := jobs.NewMockJobProgressRecorder(t)
migrator := newLegacyResourceMigrator(
nil,
nil,
mockParser,
nil,
progress,
provisioning.MigrateJobOptions{},
"test-namespace",
schema.GroupResource{Group: "test.grafana.app", Resource: "tests"},
signature.NewGrafanaSigner(),
)
err := migrator.Write(context.Background(), &resourcepb.ResourceKey{}, []byte("test"))
require.Error(t, err)
require.Contains(t, err.Error(), "unmarshal unstructured")
mockParser.AssertExpectations(t)
})
t.Run("records error when create resource file fails", func(t *testing.T) {
mockParser := resources.NewMockParser(t)
obj := &unstructured.Unstructured{
Object: map[string]any{
"metadata": map[string]any{
"name": "test",
},
},
}
meta, err := utils.MetaAccessor(obj)
require.NoError(t, err)
mockParser.On("Parse", mock.Anything, mock.Anything).
Return(&resources.ParsedResource{
Meta: meta,
Obj: obj,
}, nil)
mockRepoResources := resources.NewMockRepositoryResources(t)
mockRepoResources.On("WriteResourceFileFromObject", mock.Anything, mock.Anything, mock.Anything).
Return("", errors.New("create file error"))
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Action == repository.FileActionCreated &&
result.Name == "test" &&
result.Error != nil &&
result.Error.Error() == "writing resource test.grafana.app/tests test to file : create file error"
})).Return()
progress.On("TooManyErrors").Return(nil)
migrator := newLegacyResourceMigrator(
nil,
nil,
mockParser,
mockRepoResources,
progress,
provisioning.MigrateJobOptions{},
"test-namespace",
schema.GroupResource{Group: "test.grafana.app", Resource: "tests"},
signature.NewGrafanaSigner(),
)
err = migrator.Write(context.Background(), &resourcepb.ResourceKey{}, []byte("test"))
require.NoError(t, err) // Error is recorded but not returned
mockParser.AssertExpectations(t)
mockRepoResources.AssertExpectations(t)
progress.AssertExpectations(t)
})
t.Run("should fail when signer fails", func(t *testing.T) {
mockParser := resources.NewMockParser(t)
obj := &unstructured.Unstructured{
Object: map[string]any{
"metadata": map[string]any{
"name": "test",
},
},
}
meta, err := utils.MetaAccessor(obj)
require.NoError(t, err)
mockParser.On("Parse", mock.Anything, mock.Anything).
Return(&resources.ParsedResource{
Meta: meta,
Obj: obj,
}, nil)
mockSigner := signature.NewMockSigner(t)
mockSigner.On("Sign", mock.Anything, meta).
Return(nil, errors.New("signing error"))
progress := jobs.NewMockJobProgressRecorder(t)
migrator := newLegacyResourceMigrator(
nil,
nil,
mockParser,
nil,
progress,
provisioning.MigrateJobOptions{},
"test-namespace",
schema.GroupResource{Group: "test.grafana.app", Resource: "tests"},
mockSigner,
)
err = migrator.Write(context.Background(), &resourcepb.ResourceKey{}, []byte("test"))
require.Error(t, err)
require.EqualError(t, err, "add author signature: signing error")
mockParser.AssertExpectations(t)
mockSigner.AssertExpectations(t)
progress.AssertExpectations(t)
})
t.Run("should successfully add author signature", func(t *testing.T) {
mockParser := resources.NewMockParser(t)
obj := &unstructured.Unstructured{
Object: map[string]any{
"metadata": map[string]any{
"name": "test",
},
},
}
meta, err := utils.MetaAccessor(obj)
require.NoError(t, err)
mockParser.On("Parse", mock.Anything, mock.Anything).
Return(&resources.ParsedResource{
Meta: meta,
Obj: obj,
}, nil)
mockSigner := signature.NewMockSigner(t)
signedCtx := repository.WithAuthorSignature(context.Background(), repository.CommitSignature{
Name: "test-user",
Email: "test@example.com",
})
mockSigner.On("Sign", mock.Anything, meta).
Return(signedCtx, nil)
mockRepoResources := resources.NewMockRepositoryResources(t)
mockRepoResources.On("WriteResourceFileFromObject", signedCtx, mock.Anything, mock.Anything).
Return("test/path", nil)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Action == repository.FileActionCreated &&
result.Name == "test" &&
result.Error == nil &&
result.Path == "test/path"
})).Return()
progress.On("TooManyErrors").Return(nil)
migrator := newLegacyResourceMigrator(
nil,
nil,
mockParser,
mockRepoResources,
progress,
provisioning.MigrateJobOptions{},
"test-namespace",
schema.GroupResource{Group: "test.grafana.app", Resource: "tests"},
mockSigner,
)
err = migrator.Write(context.Background(), &resourcepb.ResourceKey{}, []byte("test"))
require.NoError(t, err)
mockParser.AssertExpectations(t)
mockSigner.AssertExpectations(t)
mockRepoResources.AssertExpectations(t)
progress.AssertExpectations(t)
})
t.Run("should maintain history", func(t *testing.T) {
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("Record", mock.Anything, mock.Anything).Return()
progress.On("TooManyErrors").Return(nil)
mockParser := resources.NewMockParser(t)
obj := &unstructured.Unstructured{
Object: map[string]any{
"metadata": map[string]any{
"name": "test",
},
},
}
meta, _ := utils.MetaAccessor(obj)
mockParser.On("Parse", mock.Anything, mock.Anything).
Return(&resources.ParsedResource{
Meta: meta,
Obj: obj,
}, nil)
mockRepo := repository.NewMockRepository(t)
mockRepoResources := resources.NewMockRepositoryResources(t)
writeResourceFileFromObject := mockRepoResources.On("WriteResourceFileFromObject", mock.Anything, mock.Anything, mock.Anything)
migrator := newLegacyResourceMigrator(
mockRepo,
nil,
mockParser,
mockRepoResources,
progress,
provisioning.MigrateJobOptions{
History: true,
},
"test-namespace",
schema.GroupResource{Group: "test.grafana.app", Resource: "tests"},
signature.NewGrafanaSigner(),
)
writeResourceFileFromObject.Return("aaaa.json", nil)
err := migrator.Write(context.Background(), &resourcepb.ResourceKey{}, []byte(""))
require.NoError(t, err)
require.Equal(t, "aaaa.json", migrator.history["test"], "kept track of the old files")
// Change the result file name
writeResourceFileFromObject.Return("bbbb.json", nil)
mockRepo.On("Delete", mock.Anything, "aaaa.json", "", "moved to: bbbb.json").
Return(nil).Once()
err = migrator.Write(context.Background(), &resourcepb.ResourceKey{}, []byte(""))
require.NoError(t, err)
require.Equal(t, "bbbb.json", migrator.history["test"], "kept track of the old files")
mockParser.AssertExpectations(t)
mockRepoResources.AssertExpectations(t)
progress.AssertExpectations(t)
})
t.Run("should successfully write resource", func(t *testing.T) {
mockParser := resources.NewMockParser(t)
obj := &unstructured.Unstructured{
Object: map[string]any{
"metadata": map[string]any{
"name": "test",
},
},
}
meta, err := utils.MetaAccessor(obj)
require.NoError(t, err)
meta.SetManagerProperties(utils.ManagerProperties{
Kind: utils.ManagerKindRepo,
Identity: "test",
AllowsEdits: true,
Suspended: false,
})
meta.SetSourceProperties(utils.SourceProperties{
Path: "test",
Checksum: "test",
TimestampMillis: 1234567890,
})
mockParser.On("Parse", mock.Anything, mock.MatchedBy(func(info *repository.FileInfo) bool {
return info != nil && info.Path == "" && string(info.Data) == "test"
})).
Return(&resources.ParsedResource{
Meta: meta,
Obj: obj,
}, nil)
mockRepoResources := resources.NewMockRepositoryResources(t)
mockRepoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool {
if obj == nil {
return false
}
if obj.GetName() != "test" {
return false
}
meta, err := utils.MetaAccessor(obj)
require.NoError(t, err)
managerProps, _ := meta.GetManagerProperties()
sourceProps, _ := meta.GetSourceProperties()
return assert.Zero(t, sourceProps) && assert.Zero(t, managerProps)
}), resources.WriteOptions{
Path: "",
Ref: "",
}).
Return("test/path", nil)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Action == repository.FileActionCreated &&
result.Name == "test" &&
result.Error == nil &&
result.Kind == "" && // empty kind
result.Group == "test.grafana.app" &&
result.Path == "test/path"
})).Return()
progress.On("TooManyErrors").Return(nil)
migrator := newLegacyResourceMigrator(
nil,
nil,
mockParser,
mockRepoResources,
progress,
provisioning.MigrateJobOptions{},
"test-namespace",
schema.GroupResource{Group: "test.grafana.app", Resource: "tests"},
signature.NewGrafanaSigner(),
)
err = migrator.Write(context.Background(), &resourcepb.ResourceKey{}, []byte("test"))
require.NoError(t, err)
mockParser.AssertExpectations(t)
mockRepoResources.AssertExpectations(t)
progress.AssertExpectations(t)
})
t.Run("should fail when too many errors", func(t *testing.T) {
mockParser := resources.NewMockParser(t)
obj := &unstructured.Unstructured{
Object: map[string]any{
"metadata": map[string]any{
"name": "test",
},
},
}
meta, err := utils.MetaAccessor(obj)
require.NoError(t, err)
mockParser.On("Parse", mock.Anything, mock.Anything).
Return(&resources.ParsedResource{
Meta: meta,
Obj: obj,
}, nil)
mockRepoResources := resources.NewMockRepositoryResources(t)
mockRepoResources.On("WriteResourceFileFromObject", mock.Anything, mock.Anything, resources.WriteOptions{}).
Return("test/path", nil)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("Record", mock.Anything, mock.Anything).Return()
progress.On("TooManyErrors").Return(errors.New("too many errors"))
migrator := newLegacyResourceMigrator(
nil,
nil,
mockParser,
mockRepoResources,
progress,
provisioning.MigrateJobOptions{},
"test-namespace",
schema.GroupResource{Group: "test.grafana.app", Resource: "tests"},
signature.NewGrafanaSigner(),
)
err = migrator.Write(context.Background(), &resourcepb.ResourceKey{}, []byte("test"))
require.EqualError(t, err, "too many errors")
mockParser.AssertExpectations(t)
mockRepoResources.AssertExpectations(t)
progress.AssertExpectations(t)
})
}
func TestLegacyResourceResourceMigrator_Migrate(t *testing.T) {
t.Run("should fail when legacy migrate count fails", func(t *testing.T) {
mockDashboardAccess := legacy.NewMockMigrationDashboardAccessor(t)
mockDashboardAccess.On("CountResources", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool {
return opts.OnlyCount && opts.Namespace == "test-namespace"
})).Return(&resourcepb.BulkResponse{}, errors.New("count error"))
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, mock.Anything).Return()
migrator := newLegacyResourceMigrator(
nil,
mockDashboardAccess,
nil,
nil,
progress,
provisioning.MigrateJobOptions{},
"test-namespace",
schema.GroupResource{Group: "test.grafana.app", Resource: "tests"},
signature.NewGrafanaSigner(),
)
err := migrator.Migrate(context.Background())
require.Error(t, err)
require.Contains(t, err.Error(), "unable to count legacy items")
mockDashboardAccess.AssertExpectations(t)
progress.AssertExpectations(t)
})
t.Run("should fail when legacy migrate write fails", func(t *testing.T) {
mockDashboardAccess := legacy.NewMockMigrationDashboardAccessor(t)
mockDashboardAccess.On("CountResources", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool {
return opts.OnlyCount && opts.Namespace == "test-namespace"
})).Return(&resourcepb.BulkResponse{}, nil).Once() // Count phase
// For test-resources GroupResource, we don't know which method it will call, but since it's not dashboards/folders/librarypanels,
// the Migrate will fail trying to map the resource type. Let's make it dashboards for this test.
mockDashboardAccess.On("MigrateDashboards", mock.Anything, mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool {
return !opts.OnlyCount && opts.Namespace == "test-namespace"
}), mock.Anything).Return(nil, errors.New("write error")).Once() // Write phase
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, mock.Anything).Return()
migrator := newLegacyResourceMigrator(
nil,
mockDashboardAccess,
nil,
nil,
progress,
provisioning.MigrateJobOptions{},
"test-namespace",
schema.GroupResource{Group: "dashboard.grafana.app", Resource: "dashboards"},
signature.NewGrafanaSigner(),
)
err := migrator.Migrate(context.Background())
require.Error(t, err)
require.Contains(t, err.Error(), "migrate legacy dashboards: write error")
mockDashboardAccess.AssertExpectations(t)
progress.AssertExpectations(t)
})
t.Run("should successfully migrate resource", func(t *testing.T) {
mockDashboardAccess := legacy.NewMockMigrationDashboardAccessor(t)
mockDashboardAccess.On("CountResources", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool {
return opts.OnlyCount && opts.Namespace == "test-namespace"
})).Return(&resourcepb.BulkResponse{}, nil).Once() // Count phase
mockDashboardAccess.On("MigrateDashboards", mock.Anything, mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool {
return !opts.OnlyCount && opts.Namespace == "test-namespace"
}), mock.Anything).Return(&legacy.BlobStoreInfo{}, nil).Once() // Write phase
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, mock.Anything).Return()
migrator := newLegacyResourceMigrator(
nil,
mockDashboardAccess,
nil,
nil,
progress,
provisioning.MigrateJobOptions{},
"test-namespace",
schema.GroupResource{Group: "dashboard.grafana.app", Resource: "dashboards"},
signature.NewGrafanaSigner(),
)
err := migrator.Migrate(context.Background())
require.NoError(t, err)
mockDashboardAccess.AssertExpectations(t)
progress.AssertExpectations(t)
})
t.Run("should set total to history if history is greater than count", func(t *testing.T) {
mockDashboardAccess := legacy.NewMockMigrationDashboardAccessor(t)
mockDashboardAccess.On("CountResources", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool {
return opts.OnlyCount && opts.Namespace == "test-namespace"
})).Return(&resourcepb.BulkResponse{
Summary: []*resourcepb.BulkResponse_Summary{
{
Group: "dashboard.grafana.app",
Resource: "dashboards",
Count: 1,
History: 100,
},
},
}, nil).Once() // Count phase
mockDashboardAccess.On("MigrateDashboards", mock.Anything, mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool {
return !opts.OnlyCount && opts.Namespace == "test-namespace"
}), mock.Anything).Return(&legacy.BlobStoreInfo{}, nil).Once() // Write phase
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, mock.Anything).Return()
progress.On("SetTotal", mock.Anything, 100).Return()
migrator := newLegacyResourceMigrator(
nil,
mockDashboardAccess,
nil,
nil,
progress,
provisioning.MigrateJobOptions{},
"test-namespace",
schema.GroupResource{Group: "dashboard.grafana.app", Resource: "dashboards"},
signature.NewGrafanaSigner(),
)
err := migrator.Migrate(context.Background())
require.NoError(t, err)
mockDashboardAccess.AssertExpectations(t)
progress.AssertExpectations(t)
})
t.Run("should set total to count if history is less than count", func(t *testing.T) {
mockDashboardAccess := legacy.NewMockMigrationDashboardAccessor(t)
mockDashboardAccess.On("CountResources", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool {
return opts.OnlyCount && opts.Namespace == "test-namespace"
})).Return(&resourcepb.BulkResponse{
Summary: []*resourcepb.BulkResponse_Summary{
{
Group: "dashboard.grafana.app",
Resource: "dashboards",
Count: 200,
History: 1,
},
},
}, nil).Once() // Count phase
mockDashboardAccess.On("MigrateDashboards", mock.Anything, mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool {
return !opts.OnlyCount && opts.Namespace == "test-namespace"
}), mock.Anything).Return(&legacy.BlobStoreInfo{}, nil).Once() // Write phase
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, mock.Anything).Return()
progress.On("SetTotal", mock.Anything, 200).Return()
signer := signature.NewMockSigner(t)
migrator := newLegacyResourceMigrator(
nil,
mockDashboardAccess,
nil,
nil,
progress,
provisioning.MigrateJobOptions{},
"test-namespace",
schema.GroupResource{Group: "dashboard.grafana.app", Resource: "dashboards"},
signer,
)
err := migrator.Migrate(context.Background())
require.NoError(t, err)
mockDashboardAccess.AssertExpectations(t)
progress.AssertExpectations(t)
})
}
func TestLegacyResourceResourceMigrator_Close(t *testing.T) {
t.Run("should return nil error", func(t *testing.T) {
migrator := &legacyResourceResourceMigrator{}
err := migrator.Close()
require.NoError(t, err)
})
}
func TestLegacyResourceResourceMigrator_CloseWithResults(t *testing.T) {
t.Run("should return empty bulk response and nil error", func(t *testing.T) {
migrator := &legacyResourceResourceMigrator{}
response, err := migrator.CloseWithResults()
require.NoError(t, err)
require.NotNil(t, response)
require.IsType(t, &resourcepb.BulkResponse{}, response)
require.Empty(t, response.Summary)
})
}
@@ -1,440 +0,0 @@
package migrate
import (
"context"
"errors"
"testing"
"time"
mock "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
)
func TestWrapWithStageFn(t *testing.T) {
t.Run("should return error when repository is not a ReaderWriter", func(t *testing.T) {
// Setup
ctx := context.Background()
// Create the wrapper function that matches WrapWithCloneFn signature
wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
// pass a reader to function call
repo := repository.NewMockReader(t)
return fn(repo, true)
}
legacyFoldersMigrator := NewLegacyMigrator(
NewMockLegacyResourcesMigrator(t),
NewMockStorageSwapper(t),
jobs.NewMockWorker(t),
wrapFn,
)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("StrictMaxErrors", 1).Return()
progress.On("SetMessage", mock.Anything, "migrating legacy resources").Return()
// Execute
repo := repository.NewMockRepository(t)
repo.On("Config").Return(&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test-namespace",
},
})
err := legacyFoldersMigrator.Migrate(ctx, repo, provisioning.MigrateJobOptions{}, progress)
// Assert
require.Error(t, err)
require.Contains(t, err.Error(), "migration job submitted targeting repository that is not a ReaderWriter")
})
}
func TestWrapWithCloneFn_Error(t *testing.T) {
t.Run("should return error when wrapFn fails", func(t *testing.T) {
// Setup
ctx := context.Background()
expectedErr := errors.New("clone failed")
// Create the wrapper function that returns an error
wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return expectedErr
}
legacyMigrator := NewLegacyMigrator(
NewMockLegacyResourcesMigrator(t),
NewMockStorageSwapper(t),
jobs.NewMockWorker(t),
wrapFn,
)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("StrictMaxErrors", 1).Return()
progress.On("SetMessage", mock.Anything, "migrating legacy resources").Return()
// Execute
repo := repository.NewMockRepository(t)
repo.On("Config").Return(&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test-namespace",
},
})
err := legacyMigrator.Migrate(ctx, repo, provisioning.MigrateJobOptions{}, progress)
// Assert
require.Error(t, err)
require.Contains(t, err.Error(), "migrate from SQL: clone failed")
})
}
func TestLegacyMigrator_MigrateFails(t *testing.T) {
t.Run("should return error when legacyMigrator.Migrate fails", func(t *testing.T) {
// Setup
ctx := context.Background()
expectedErr := errors.New("migration failed")
mockLegacyMigrator := NewMockLegacyResourcesMigrator(t)
mockLegacyMigrator.On("Migrate", mock.Anything, mock.Anything, "test-namespace", mock.Anything, mock.Anything).
Return(expectedErr)
mockStorageSwapper := NewMockStorageSwapper(t)
mockWorker := jobs.NewMockWorker(t)
// Create a wrapper function that calls the provided function
wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(rw, true)
}
legacyMigrator := NewLegacyMigrator(
mockLegacyMigrator,
mockStorageSwapper,
mockWorker,
wrapFn,
)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("StrictMaxErrors", 1).Return()
progress.On("SetMessage", mock.Anything, "migrating legacy resources").Return()
// Execute
repo := repository.NewMockRepository(t)
repo.On("Config").Return(&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test-namespace",
},
})
err := legacyMigrator.Migrate(ctx, repo, provisioning.MigrateJobOptions{}, progress)
// Assert
require.Error(t, err)
require.Contains(t, err.Error(), "migrate from SQL: migration failed")
// Storage swapper should not be called when migration fails
mockStorageSwapper.AssertNotCalled(t, "WipeUnifiedAndSetMigratedFlag")
})
}
func TestLegacyMigrator_ResetUnifiedStorageFails(t *testing.T) {
t.Run("should return error when storage reset fails", func(t *testing.T) {
// Setup
ctx := context.Background()
expectedErr := errors.New("reset failed")
mockLegacyMigrator := NewMockLegacyResourcesMigrator(t)
mockLegacyMigrator.On("Migrate", mock.Anything, mock.Anything, "test-namespace", mock.Anything, mock.Anything).
Return(nil)
mockStorageSwapper := NewMockStorageSwapper(t)
mockStorageSwapper.On("WipeUnifiedAndSetMigratedFlag", mock.Anything, "test-namespace").
Return(expectedErr)
mockWorker := jobs.NewMockWorker(t)
// Create a wrapper function that calls the provided function
wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(rw, true)
}
legacyMigrator := NewLegacyMigrator(
mockLegacyMigrator,
mockStorageSwapper,
mockWorker,
wrapFn,
)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("StrictMaxErrors", 1).Return()
progress.On("SetMessage", mock.Anything, "migrating legacy resources").Return()
progress.On("SetMessage", mock.Anything, "resetting unified storage").Return()
// Execute
repo := repository.NewMockRepository(t)
repo.On("Config").Return(&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test-namespace",
},
})
err := legacyMigrator.Migrate(ctx, repo, provisioning.MigrateJobOptions{}, progress)
// Assert
require.Error(t, err)
require.Contains(t, err.Error(), "unable to reset unified storage")
// Sync worker should not be called when reset fails
mockWorker.AssertNotCalled(t, "Process")
})
}
func TestLegacyMigrator_SyncFails(t *testing.T) {
t.Run("should revert storage settings when sync fails", func(t *testing.T) {
// Setup
ctx := context.Background()
expectedErr := errors.New("sync failed")
mockLegacyMigrator := NewMockLegacyResourcesMigrator(t)
mockLegacyMigrator.On("Migrate", mock.Anything, mock.Anything, "test-namespace", mock.Anything, mock.Anything).
Return(nil)
mockStorageSwapper := NewMockStorageSwapper(t)
mockStorageSwapper.On("WipeUnifiedAndSetMigratedFlag", mock.Anything, "test-namespace").
Return(nil)
mockStorageSwapper.On("StopReadingUnifiedStorage", mock.Anything).
Return(nil)
mockWorker := jobs.NewMockWorker(t)
mockWorker.On("Process", mock.Anything, mock.Anything, mock.MatchedBy(func(job provisioning.Job) bool {
return job.Spec.Pull != nil && !job.Spec.Pull.Incremental
}), mock.Anything).Return(expectedErr)
// Create a wrapper function that calls the provided function
wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(rw, true)
}
legacyMigrator := NewLegacyMigrator(
mockLegacyMigrator,
mockStorageSwapper,
mockWorker,
wrapFn,
)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("StrictMaxErrors", 1).Return()
progress.On("SetMessage", mock.Anything, "migrating legacy resources").Return()
progress.On("SetMessage", mock.Anything, "resetting unified storage").Return()
progress.On("ResetResults").Return()
progress.On("SetMessage", mock.Anything, "pulling resources").Return()
progress.On("SetMessage", mock.Anything, "error importing resources, reverting").Return()
// Execute
repo := repository.NewMockRepository(t)
repo.On("Config").Return(&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test-namespace",
},
})
err := legacyMigrator.Migrate(ctx, repo, provisioning.MigrateJobOptions{}, progress)
// Assert
require.Error(t, err)
require.Contains(t, err.Error(), "sync failed")
// Verify storage settings were reverted
mockStorageSwapper.AssertCalled(t, "StopReadingUnifiedStorage", mock.Anything)
})
t.Run("should handle revert failure after sync failure", func(t *testing.T) {
// Setup
ctx := context.Background()
syncErr := errors.New("sync failed")
revertErr := errors.New("revert failed")
mockLegacyMigrator := NewMockLegacyResourcesMigrator(t)
mockLegacyMigrator.On("Migrate", mock.Anything, mock.Anything, "test-namespace", mock.Anything, mock.Anything).
Return(nil)
mockStorageSwapper := NewMockStorageSwapper(t)
mockStorageSwapper.On("WipeUnifiedAndSetMigratedFlag", mock.Anything, "test-namespace").
Return(nil)
mockStorageSwapper.On("StopReadingUnifiedStorage", mock.Anything).
Return(revertErr)
mockWorker := jobs.NewMockWorker(t)
mockWorker.On("Process", mock.Anything, mock.Anything, mock.MatchedBy(func(job provisioning.Job) bool {
return job.Spec.Pull != nil && !job.Spec.Pull.Incremental
}), mock.Anything).Return(syncErr)
// Create a wrapper function that calls the provided function
wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(rw, true)
}
legacyMigrator := NewLegacyMigrator(
mockLegacyMigrator,
mockStorageSwapper,
mockWorker,
wrapFn,
)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("StrictMaxErrors", 1).Return()
progress.On("SetMessage", mock.Anything, "migrating legacy resources").Return()
progress.On("SetMessage", mock.Anything, "resetting unified storage").Return()
progress.On("ResetResults").Return()
progress.On("SetMessage", mock.Anything, "pulling resources").Return()
progress.On("SetMessage", mock.Anything, "error importing resources, reverting").Return()
// Execute
repo := repository.NewMockRepository(t)
repo.On("Config").Return(&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test-namespace",
},
})
err := legacyMigrator.Migrate(ctx, repo, provisioning.MigrateJobOptions{}, progress)
// Assert
require.Error(t, err)
require.Contains(t, err.Error(), "sync failed")
// Verify both errors occurred
mockStorageSwapper.AssertCalled(t, "StopReadingUnifiedStorage", mock.Anything)
})
}
func TestLegacyMigrator_Success(t *testing.T) {
t.Run("should complete migration successfully", func(t *testing.T) {
// Setup
ctx := context.Background()
mockLegacyMigrator := NewMockLegacyResourcesMigrator(t)
mockLegacyMigrator.On("Migrate", mock.Anything, mock.Anything, "test-namespace", mock.Anything, mock.Anything).
Return(nil)
mockStorageSwapper := NewMockStorageSwapper(t)
mockStorageSwapper.On("WipeUnifiedAndSetMigratedFlag", mock.Anything, "test-namespace").
Return(nil)
mockWorker := jobs.NewMockWorker(t)
mockWorker.On("Process", mock.Anything, mock.Anything, mock.MatchedBy(func(job provisioning.Job) bool {
return job.Spec.Pull != nil && !job.Spec.Pull.Incremental
}), mock.Anything).Return(nil)
// Create a wrapper function that calls the provided function
wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(rw, true)
}
legacyMigrator := NewLegacyMigrator(
mockLegacyMigrator,
mockStorageSwapper,
mockWorker,
wrapFn,
)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("StrictMaxErrors", 1).Return()
progress.On("SetMessage", mock.Anything, "migrating legacy resources").Return()
progress.On("SetMessage", mock.Anything, "resetting unified storage").Return()
progress.On("ResetResults").Return()
progress.On("SetMessage", mock.Anything, "pulling resources").Return()
// Execute
repo := repository.NewMockRepository(t)
repo.On("Config").Return(&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test-namespace",
},
})
err := legacyMigrator.Migrate(ctx, repo, provisioning.MigrateJobOptions{}, progress)
// Assert
require.NoError(t, err)
// Verify all expected operations were called in order
mockLegacyMigrator.AssertCalled(t, "Migrate", mock.Anything, mock.Anything, "test-namespace", mock.Anything, mock.Anything)
mockStorageSwapper.AssertCalled(t, "WipeUnifiedAndSetMigratedFlag", mock.Anything, "test-namespace")
mockWorker.AssertCalled(t, "Process", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
})
}
func TestLegacyMigrator_BeforeFnExecution(t *testing.T) {
t.Run("should execute beforeFn functions", func(t *testing.T) {
// Setup
mockLegacyMigrator := NewMockLegacyResourcesMigrator(t)
mockStorageSwapper := NewMockStorageSwapper(t)
mockWorker := jobs.NewMockWorker(t)
// Create a wrapper function that calls the provided function
wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return errors.New("abort test here")
}
legacyMigrator := NewLegacyMigrator(
mockLegacyMigrator,
mockStorageSwapper,
mockWorker,
wrapFn,
)
progress := jobs.NewMockJobProgressRecorder(t)
// No progress messages expected in current staging implementation
progress.On("StrictMaxErrors", 1).Return()
progress.On("SetMessage", mock.Anything, "migrating legacy resources").Return()
// Execute
repo := repository.NewMockRepository(t)
repo.On("Config").Return(&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test-namespace",
},
})
err := legacyMigrator.Migrate(context.Background(), repo, provisioning.MigrateJobOptions{}, progress)
require.EqualError(t, err, "migrate from SQL: abort test here")
})
}
func TestLegacyMigrator_ProgressScanner(t *testing.T) {
t.Run("should update progress with scanner", func(t *testing.T) {
mockLegacyMigrator := NewMockLegacyResourcesMigrator(t)
mockStorageSwapper := NewMockStorageSwapper(t)
mockWorker := jobs.NewMockWorker(t)
// Create a wrapper function that calls the provided function
wrapFn := func(ctx context.Context, rw repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return errors.New("abort test here")
}
legacyMigrator := NewLegacyMigrator(
mockLegacyMigrator,
mockStorageSwapper,
mockWorker,
wrapFn,
)
progress := jobs.NewMockJobProgressRecorder(t)
// No progress messages expected in current staging implementation
progress.On("StrictMaxErrors", 1).Return()
progress.On("SetMessage", mock.Anything, "migrating legacy resources").Return()
repo := repository.NewMockRepository(t)
repo.On("Config").Return(&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test-namespace",
},
})
err := legacyMigrator.Migrate(context.Background(), repo, provisioning.MigrateJobOptions{}, progress)
require.EqualError(t, err, "migrate from SQL: abort test here")
require.Eventually(t, func() bool {
// No progress message calls expected in current staging implementation
return progress.AssertExpectations(t)
}, time.Second, 10*time.Millisecond)
})
}
@@ -1,430 +0,0 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
package migrate
import (
context "context"
mock "github.com/stretchr/testify/mock"
metadata "google.golang.org/grpc/metadata"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
)
// BulkStore_BulkProcessClient is an autogenerated mock type for the BulkStore_BulkProcessClient type
type BulkStore_BulkProcessClient struct {
mock.Mock
}
type BulkStore_BulkProcessClient_Expecter struct {
mock *mock.Mock
}
func (_m *BulkStore_BulkProcessClient) EXPECT() *BulkStore_BulkProcessClient_Expecter {
return &BulkStore_BulkProcessClient_Expecter{mock: &_m.Mock}
}
// CloseAndRecv provides a mock function with no fields
func (_m *BulkStore_BulkProcessClient) CloseAndRecv() (*resourcepb.BulkResponse, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for CloseAndRecv")
}
var r0 *resourcepb.BulkResponse
var r1 error
if rf, ok := ret.Get(0).(func() (*resourcepb.BulkResponse, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() *resourcepb.BulkResponse); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*resourcepb.BulkResponse)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// BulkStore_BulkProcessClient_CloseAndRecv_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CloseAndRecv'
type BulkStore_BulkProcessClient_CloseAndRecv_Call struct {
*mock.Call
}
// CloseAndRecv is a helper method to define mock.On call
func (_e *BulkStore_BulkProcessClient_Expecter) CloseAndRecv() *BulkStore_BulkProcessClient_CloseAndRecv_Call {
return &BulkStore_BulkProcessClient_CloseAndRecv_Call{Call: _e.mock.On("CloseAndRecv")}
}
func (_c *BulkStore_BulkProcessClient_CloseAndRecv_Call) Run(run func()) *BulkStore_BulkProcessClient_CloseAndRecv_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *BulkStore_BulkProcessClient_CloseAndRecv_Call) Return(_a0 *resourcepb.BulkResponse, _a1 error) *BulkStore_BulkProcessClient_CloseAndRecv_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *BulkStore_BulkProcessClient_CloseAndRecv_Call) RunAndReturn(run func() (*resourcepb.BulkResponse, error)) *BulkStore_BulkProcessClient_CloseAndRecv_Call {
_c.Call.Return(run)
return _c
}
// CloseSend provides a mock function with no fields
func (_m *BulkStore_BulkProcessClient) CloseSend() error {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for CloseSend")
}
var r0 error
if rf, ok := ret.Get(0).(func() error); ok {
r0 = rf()
} else {
r0 = ret.Error(0)
}
return r0
}
// BulkStore_BulkProcessClient_CloseSend_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CloseSend'
type BulkStore_BulkProcessClient_CloseSend_Call struct {
*mock.Call
}
// CloseSend is a helper method to define mock.On call
func (_e *BulkStore_BulkProcessClient_Expecter) CloseSend() *BulkStore_BulkProcessClient_CloseSend_Call {
return &BulkStore_BulkProcessClient_CloseSend_Call{Call: _e.mock.On("CloseSend")}
}
func (_c *BulkStore_BulkProcessClient_CloseSend_Call) Run(run func()) *BulkStore_BulkProcessClient_CloseSend_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *BulkStore_BulkProcessClient_CloseSend_Call) Return(_a0 error) *BulkStore_BulkProcessClient_CloseSend_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *BulkStore_BulkProcessClient_CloseSend_Call) RunAndReturn(run func() error) *BulkStore_BulkProcessClient_CloseSend_Call {
_c.Call.Return(run)
return _c
}
// Context provides a mock function with no fields
func (_m *BulkStore_BulkProcessClient) Context() context.Context {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Context")
}
var r0 context.Context
if rf, ok := ret.Get(0).(func() context.Context); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(context.Context)
}
}
return r0
}
// BulkStore_BulkProcessClient_Context_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Context'
type BulkStore_BulkProcessClient_Context_Call struct {
*mock.Call
}
// Context is a helper method to define mock.On call
func (_e *BulkStore_BulkProcessClient_Expecter) Context() *BulkStore_BulkProcessClient_Context_Call {
return &BulkStore_BulkProcessClient_Context_Call{Call: _e.mock.On("Context")}
}
func (_c *BulkStore_BulkProcessClient_Context_Call) Run(run func()) *BulkStore_BulkProcessClient_Context_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *BulkStore_BulkProcessClient_Context_Call) Return(_a0 context.Context) *BulkStore_BulkProcessClient_Context_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *BulkStore_BulkProcessClient_Context_Call) RunAndReturn(run func() context.Context) *BulkStore_BulkProcessClient_Context_Call {
_c.Call.Return(run)
return _c
}
// Header provides a mock function with no fields
func (_m *BulkStore_BulkProcessClient) Header() (metadata.MD, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Header")
}
var r0 metadata.MD
var r1 error
if rf, ok := ret.Get(0).(func() (metadata.MD, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() metadata.MD); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(metadata.MD)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// BulkStore_BulkProcessClient_Header_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Header'
type BulkStore_BulkProcessClient_Header_Call struct {
*mock.Call
}
// Header is a helper method to define mock.On call
func (_e *BulkStore_BulkProcessClient_Expecter) Header() *BulkStore_BulkProcessClient_Header_Call {
return &BulkStore_BulkProcessClient_Header_Call{Call: _e.mock.On("Header")}
}
func (_c *BulkStore_BulkProcessClient_Header_Call) Run(run func()) *BulkStore_BulkProcessClient_Header_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *BulkStore_BulkProcessClient_Header_Call) Return(_a0 metadata.MD, _a1 error) *BulkStore_BulkProcessClient_Header_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *BulkStore_BulkProcessClient_Header_Call) RunAndReturn(run func() (metadata.MD, error)) *BulkStore_BulkProcessClient_Header_Call {
_c.Call.Return(run)
return _c
}
// RecvMsg provides a mock function with given fields: m
func (_m *BulkStore_BulkProcessClient) RecvMsg(m interface{}) error {
ret := _m.Called(m)
if len(ret) == 0 {
panic("no return value specified for RecvMsg")
}
var r0 error
if rf, ok := ret.Get(0).(func(interface{}) error); ok {
r0 = rf(m)
} else {
r0 = ret.Error(0)
}
return r0
}
// BulkStore_BulkProcessClient_RecvMsg_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RecvMsg'
type BulkStore_BulkProcessClient_RecvMsg_Call struct {
*mock.Call
}
// RecvMsg is a helper method to define mock.On call
// - m interface{}
func (_e *BulkStore_BulkProcessClient_Expecter) RecvMsg(m interface{}) *BulkStore_BulkProcessClient_RecvMsg_Call {
return &BulkStore_BulkProcessClient_RecvMsg_Call{Call: _e.mock.On("RecvMsg", m)}
}
func (_c *BulkStore_BulkProcessClient_RecvMsg_Call) Run(run func(m interface{})) *BulkStore_BulkProcessClient_RecvMsg_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(interface{}))
})
return _c
}
func (_c *BulkStore_BulkProcessClient_RecvMsg_Call) Return(_a0 error) *BulkStore_BulkProcessClient_RecvMsg_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *BulkStore_BulkProcessClient_RecvMsg_Call) RunAndReturn(run func(interface{}) error) *BulkStore_BulkProcessClient_RecvMsg_Call {
_c.Call.Return(run)
return _c
}
// Send provides a mock function with given fields: _a0
func (_m *BulkStore_BulkProcessClient) Send(_a0 *resourcepb.BulkRequest) error {
ret := _m.Called(_a0)
if len(ret) == 0 {
panic("no return value specified for Send")
}
var r0 error
if rf, ok := ret.Get(0).(func(*resourcepb.BulkRequest) error); ok {
r0 = rf(_a0)
} else {
r0 = ret.Error(0)
}
return r0
}
// BulkStore_BulkProcessClient_Send_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Send'
type BulkStore_BulkProcessClient_Send_Call struct {
*mock.Call
}
// Send is a helper method to define mock.On call
// - _a0 *resource.BulkRequest
func (_e *BulkStore_BulkProcessClient_Expecter) Send(_a0 interface{}) *BulkStore_BulkProcessClient_Send_Call {
return &BulkStore_BulkProcessClient_Send_Call{Call: _e.mock.On("Send", _a0)}
}
func (_c *BulkStore_BulkProcessClient_Send_Call) Run(run func(_a0 *resourcepb.BulkRequest)) *BulkStore_BulkProcessClient_Send_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(*resourcepb.BulkRequest))
})
return _c
}
func (_c *BulkStore_BulkProcessClient_Send_Call) Return(_a0 error) *BulkStore_BulkProcessClient_Send_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *BulkStore_BulkProcessClient_Send_Call) RunAndReturn(run func(*resourcepb.BulkRequest) error) *BulkStore_BulkProcessClient_Send_Call {
_c.Call.Return(run)
return _c
}
// SendMsg provides a mock function with given fields: m
func (_m *BulkStore_BulkProcessClient) SendMsg(m interface{}) error {
ret := _m.Called(m)
if len(ret) == 0 {
panic("no return value specified for SendMsg")
}
var r0 error
if rf, ok := ret.Get(0).(func(interface{}) error); ok {
r0 = rf(m)
} else {
r0 = ret.Error(0)
}
return r0
}
// BulkStore_BulkProcessClient_SendMsg_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendMsg'
type BulkStore_BulkProcessClient_SendMsg_Call struct {
*mock.Call
}
// SendMsg is a helper method to define mock.On call
// - m interface{}
func (_e *BulkStore_BulkProcessClient_Expecter) SendMsg(m interface{}) *BulkStore_BulkProcessClient_SendMsg_Call {
return &BulkStore_BulkProcessClient_SendMsg_Call{Call: _e.mock.On("SendMsg", m)}
}
func (_c *BulkStore_BulkProcessClient_SendMsg_Call) Run(run func(m interface{})) *BulkStore_BulkProcessClient_SendMsg_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(interface{}))
})
return _c
}
func (_c *BulkStore_BulkProcessClient_SendMsg_Call) Return(_a0 error) *BulkStore_BulkProcessClient_SendMsg_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *BulkStore_BulkProcessClient_SendMsg_Call) RunAndReturn(run func(interface{}) error) *BulkStore_BulkProcessClient_SendMsg_Call {
_c.Call.Return(run)
return _c
}
// Trailer provides a mock function with no fields
func (_m *BulkStore_BulkProcessClient) Trailer() metadata.MD {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Trailer")
}
var r0 metadata.MD
if rf, ok := ret.Get(0).(func() metadata.MD); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(metadata.MD)
}
}
return r0
}
// BulkStore_BulkProcessClient_Trailer_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Trailer'
type BulkStore_BulkProcessClient_Trailer_Call struct {
*mock.Call
}
// Trailer is a helper method to define mock.On call
func (_e *BulkStore_BulkProcessClient_Expecter) Trailer() *BulkStore_BulkProcessClient_Trailer_Call {
return &BulkStore_BulkProcessClient_Trailer_Call{Call: _e.mock.On("Trailer")}
}
func (_c *BulkStore_BulkProcessClient_Trailer_Call) Run(run func()) *BulkStore_BulkProcessClient_Trailer_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *BulkStore_BulkProcessClient_Trailer_Call) Return(_a0 metadata.MD) *BulkStore_BulkProcessClient_Trailer_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *BulkStore_BulkProcessClient_Trailer_Call) RunAndReturn(run func() metadata.MD) *BulkStore_BulkProcessClient_Trailer_Call {
_c.Call.Return(run)
return _c
}
// NewBulkStore_BulkProcessClient creates a new instance of BulkStore_BulkProcessClient. 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 NewBulkStore_BulkProcessClient(t interface {
mock.TestingT
Cleanup(func())
}) *BulkStore_BulkProcessClient {
mock := &BulkStore_BulkProcessClient{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -1,113 +0,0 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
package migrate
import (
context "context"
grpc "google.golang.org/grpc"
mock "github.com/stretchr/testify/mock"
resourcepb "github.com/grafana/grafana/pkg/storage/unified/resourcepb"
)
// MockBulkStoreClient is an autogenerated mock type for the BulkStoreClient type
type MockBulkStoreClient struct {
mock.Mock
}
type MockBulkStoreClient_Expecter struct {
mock *mock.Mock
}
func (_m *MockBulkStoreClient) EXPECT() *MockBulkStoreClient_Expecter {
return &MockBulkStoreClient_Expecter{mock: &_m.Mock}
}
// BulkProcess provides a mock function with given fields: ctx, opts
func (_m *MockBulkStoreClient) BulkProcess(ctx context.Context, opts ...grpc.CallOption) (resourcepb.BulkStore_BulkProcessClient, error) {
_va := make([]interface{}, len(opts))
for _i := range opts {
_va[_i] = opts[_i]
}
var _ca []interface{}
_ca = append(_ca, ctx)
_ca = append(_ca, _va...)
ret := _m.Called(_ca...)
if len(ret) == 0 {
panic("no return value specified for BulkProcess")
}
var r0 resourcepb.BulkStore_BulkProcessClient
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, ...grpc.CallOption) (resourcepb.BulkStore_BulkProcessClient, error)); ok {
return rf(ctx, opts...)
}
if rf, ok := ret.Get(0).(func(context.Context, ...grpc.CallOption) resourcepb.BulkStore_BulkProcessClient); ok {
r0 = rf(ctx, opts...)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(resourcepb.BulkStore_BulkProcessClient)
}
}
if rf, ok := ret.Get(1).(func(context.Context, ...grpc.CallOption) error); ok {
r1 = rf(ctx, opts...)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockBulkStoreClient_BulkProcess_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BulkProcess'
type MockBulkStoreClient_BulkProcess_Call struct {
*mock.Call
}
// BulkProcess is a helper method to define mock.On call
// - ctx context.Context
// - opts ...grpc.CallOption
func (_e *MockBulkStoreClient_Expecter) BulkProcess(ctx interface{}, opts ...interface{}) *MockBulkStoreClient_BulkProcess_Call {
return &MockBulkStoreClient_BulkProcess_Call{Call: _e.mock.On("BulkProcess",
append([]interface{}{ctx}, opts...)...)}
}
func (_c *MockBulkStoreClient_BulkProcess_Call) Run(run func(ctx context.Context, opts ...grpc.CallOption)) *MockBulkStoreClient_BulkProcess_Call {
_c.Call.Run(func(args mock.Arguments) {
variadicArgs := make([]grpc.CallOption, len(args)-1)
for i, a := range args[1:] {
if a != nil {
variadicArgs[i] = a.(grpc.CallOption)
}
}
run(args[0].(context.Context), variadicArgs...)
})
return _c
}
func (_c *MockBulkStoreClient_BulkProcess_Call) Return(_a0 resourcepb.BulkStore_BulkProcessClient, _a1 error) *MockBulkStoreClient_BulkProcess_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockBulkStoreClient_BulkProcess_Call) RunAndReturn(run func(context.Context, ...grpc.CallOption) (resourcepb.BulkStore_BulkProcessClient, error)) *MockBulkStoreClient_BulkProcess_Call {
_c.Call.Return(run)
return _c
}
// NewMockBulkStoreClient creates a new instance of MockBulkStoreClient. 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 NewMockBulkStoreClient(t interface {
mock.TestingT
Cleanup(func())
}) *MockBulkStoreClient {
mock := &MockBulkStoreClient{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -1,91 +0,0 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
package migrate
import (
context "context"
jobs "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
mock "github.com/stretchr/testify/mock"
repository "github.com/grafana/grafana/apps/provisioning/pkg/repository"
v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
)
// MockLegacyResourcesMigrator is an autogenerated mock type for the LegacyResourcesMigrator type
type MockLegacyResourcesMigrator struct {
mock.Mock
}
type MockLegacyResourcesMigrator_Expecter struct {
mock *mock.Mock
}
func (_m *MockLegacyResourcesMigrator) EXPECT() *MockLegacyResourcesMigrator_Expecter {
return &MockLegacyResourcesMigrator_Expecter{mock: &_m.Mock}
}
// Migrate provides a mock function with given fields: ctx, rw, namespace, opts, progress
func (_m *MockLegacyResourcesMigrator) Migrate(ctx context.Context, rw repository.ReaderWriter, namespace string, opts v0alpha1.MigrateJobOptions, progress jobs.JobProgressRecorder) error {
ret := _m.Called(ctx, rw, namespace, opts, progress)
if len(ret) == 0 {
panic("no return value specified for Migrate")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, repository.ReaderWriter, string, v0alpha1.MigrateJobOptions, jobs.JobProgressRecorder) error); ok {
r0 = rf(ctx, rw, namespace, opts, progress)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockLegacyResourcesMigrator_Migrate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Migrate'
type MockLegacyResourcesMigrator_Migrate_Call struct {
*mock.Call
}
// Migrate is a helper method to define mock.On call
// - ctx context.Context
// - rw repository.ReaderWriter
// - namespace string
// - opts v0alpha1.MigrateJobOptions
// - progress jobs.JobProgressRecorder
func (_e *MockLegacyResourcesMigrator_Expecter) Migrate(ctx interface{}, rw interface{}, namespace interface{}, opts interface{}, progress interface{}) *MockLegacyResourcesMigrator_Migrate_Call {
return &MockLegacyResourcesMigrator_Migrate_Call{Call: _e.mock.On("Migrate", ctx, rw, namespace, opts, progress)}
}
func (_c *MockLegacyResourcesMigrator_Migrate_Call) Run(run func(ctx context.Context, rw repository.ReaderWriter, namespace string, opts v0alpha1.MigrateJobOptions, progress jobs.JobProgressRecorder)) *MockLegacyResourcesMigrator_Migrate_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(repository.ReaderWriter), args[2].(string), args[3].(v0alpha1.MigrateJobOptions), args[4].(jobs.JobProgressRecorder))
})
return _c
}
func (_c *MockLegacyResourcesMigrator_Migrate_Call) Return(_a0 error) *MockLegacyResourcesMigrator_Migrate_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockLegacyResourcesMigrator_Migrate_Call) RunAndReturn(run func(context.Context, repository.ReaderWriter, string, v0alpha1.MigrateJobOptions, jobs.JobProgressRecorder) error) *MockLegacyResourcesMigrator_Migrate_Call {
_c.Call.Return(run)
return _c
}
// NewMockLegacyResourcesMigrator creates a new instance of MockLegacyResourcesMigrator. 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 NewMockLegacyResourcesMigrator(t interface {
mock.TestingT
Cleanup(func())
}) *MockLegacyResourcesMigrator {
mock := &MockLegacyResourcesMigrator{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -1,4 +1,4 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
// Code generated by mockery v2.53.4. DO NOT EDIT.
package migrate
@@ -1,4 +1,4 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
// Code generated by mockery v2.53.4. DO NOT EDIT.
package migrate
@@ -1,129 +0,0 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
package migrate
import (
context "context"
mock "github.com/stretchr/testify/mock"
)
// MockStorageSwapper is an autogenerated mock type for the StorageSwapper type
type MockStorageSwapper struct {
mock.Mock
}
type MockStorageSwapper_Expecter struct {
mock *mock.Mock
}
func (_m *MockStorageSwapper) EXPECT() *MockStorageSwapper_Expecter {
return &MockStorageSwapper_Expecter{mock: &_m.Mock}
}
// StopReadingUnifiedStorage provides a mock function with given fields: ctx
func (_m *MockStorageSwapper) StopReadingUnifiedStorage(ctx context.Context) error {
ret := _m.Called(ctx)
if len(ret) == 0 {
panic("no return value specified for StopReadingUnifiedStorage")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context) error); ok {
r0 = rf(ctx)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockStorageSwapper_StopReadingUnifiedStorage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StopReadingUnifiedStorage'
type MockStorageSwapper_StopReadingUnifiedStorage_Call struct {
*mock.Call
}
// StopReadingUnifiedStorage is a helper method to define mock.On call
// - ctx context.Context
func (_e *MockStorageSwapper_Expecter) StopReadingUnifiedStorage(ctx interface{}) *MockStorageSwapper_StopReadingUnifiedStorage_Call {
return &MockStorageSwapper_StopReadingUnifiedStorage_Call{Call: _e.mock.On("StopReadingUnifiedStorage", ctx)}
}
func (_c *MockStorageSwapper_StopReadingUnifiedStorage_Call) Run(run func(ctx context.Context)) *MockStorageSwapper_StopReadingUnifiedStorage_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *MockStorageSwapper_StopReadingUnifiedStorage_Call) Return(_a0 error) *MockStorageSwapper_StopReadingUnifiedStorage_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockStorageSwapper_StopReadingUnifiedStorage_Call) RunAndReturn(run func(context.Context) error) *MockStorageSwapper_StopReadingUnifiedStorage_Call {
_c.Call.Return(run)
return _c
}
// WipeUnifiedAndSetMigratedFlag provides a mock function with given fields: ctx, namespace
func (_m *MockStorageSwapper) WipeUnifiedAndSetMigratedFlag(ctx context.Context, namespace string) error {
ret := _m.Called(ctx, namespace)
if len(ret) == 0 {
panic("no return value specified for WipeUnifiedAndSetMigratedFlag")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string) error); ok {
r0 = rf(ctx, namespace)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockStorageSwapper_WipeUnifiedAndSetMigratedFlag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WipeUnifiedAndSetMigratedFlag'
type MockStorageSwapper_WipeUnifiedAndSetMigratedFlag_Call struct {
*mock.Call
}
// WipeUnifiedAndSetMigratedFlag is a helper method to define mock.On call
// - ctx context.Context
// - namespace string
func (_e *MockStorageSwapper_Expecter) WipeUnifiedAndSetMigratedFlag(ctx interface{}, namespace interface{}) *MockStorageSwapper_WipeUnifiedAndSetMigratedFlag_Call {
return &MockStorageSwapper_WipeUnifiedAndSetMigratedFlag_Call{Call: _e.mock.On("WipeUnifiedAndSetMigratedFlag", ctx, namespace)}
}
func (_c *MockStorageSwapper_WipeUnifiedAndSetMigratedFlag_Call) Run(run func(ctx context.Context, namespace string)) *MockStorageSwapper_WipeUnifiedAndSetMigratedFlag_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string))
})
return _c
}
func (_c *MockStorageSwapper_WipeUnifiedAndSetMigratedFlag_Call) Return(_a0 error) *MockStorageSwapper_WipeUnifiedAndSetMigratedFlag_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockStorageSwapper_WipeUnifiedAndSetMigratedFlag_Call) RunAndReturn(run func(context.Context, string) error) *MockStorageSwapper_WipeUnifiedAndSetMigratedFlag_Call {
_c.Call.Return(run)
return _c
}
// NewMockStorageSwapper creates a new instance of MockStorageSwapper. 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 NewMockStorageSwapper(t interface {
mock.TestingT
Cleanup(func())
}) *MockStorageSwapper {
mock := &MockStorageSwapper{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -0,0 +1,86 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
package migrate
import (
context "context"
repository "github.com/grafana/grafana/apps/provisioning/pkg/repository"
mock "github.com/stretchr/testify/mock"
)
// MockWrapWithStageFn is an autogenerated mock type for the WrapWithStageFn type
type MockWrapWithStageFn struct {
mock.Mock
}
type MockWrapWithStageFn_Expecter struct {
mock *mock.Mock
}
func (_m *MockWrapWithStageFn) EXPECT() *MockWrapWithStageFn_Expecter {
return &MockWrapWithStageFn_Expecter{mock: &_m.Mock}
}
// Execute provides a mock function with given fields: ctx, repo, stageOptions, fn
func (_m *MockWrapWithStageFn) Execute(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error {
ret := _m.Called(ctx, repo, stageOptions, fn)
if len(ret) == 0 {
panic("no return value specified for Execute")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, repository.Repository, repository.StageOptions, func(repository.Repository, bool) error) error); ok {
r0 = rf(ctx, repo, stageOptions, fn)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockWrapWithStageFn_Execute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Execute'
type MockWrapWithStageFn_Execute_Call struct {
*mock.Call
}
// Execute is a helper method to define mock.On call
// - ctx context.Context
// - repo repository.Repository
// - stageOptions repository.StageOptions
// - fn func(repository.Repository , bool) error
func (_e *MockWrapWithStageFn_Expecter) Execute(ctx interface{}, repo interface{}, stageOptions interface{}, fn interface{}) *MockWrapWithStageFn_Execute_Call {
return &MockWrapWithStageFn_Execute_Call{Call: _e.mock.On("Execute", ctx, repo, stageOptions, fn)}
}
func (_c *MockWrapWithStageFn_Execute_Call) Run(run func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error)) *MockWrapWithStageFn_Execute_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(repository.Repository), args[2].(repository.StageOptions), args[3].(func(repository.Repository, bool) error))
})
return _c
}
func (_c *MockWrapWithStageFn_Execute_Call) Return(_a0 error) *MockWrapWithStageFn_Execute_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockWrapWithStageFn_Execute_Call) RunAndReturn(run func(context.Context, repository.Repository, repository.StageOptions, func(repository.Repository, bool) error) error) *MockWrapWithStageFn_Execute_Call {
_c.Call.Return(run)
return _c
}
// NewMockWrapWithStageFn creates a new instance of MockWrapWithStageFn. 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 NewMockWrapWithStageFn(t interface {
mock.TestingT
Cleanup(func())
}) *MockWrapWithStageFn {
mock := &MockWrapWithStageFn{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -1,102 +0,0 @@
package migrate
import (
"context"
"fmt"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
)
//go:generate mockery --name BulkStoreClient --structname MockBulkStoreClient --inpackage --filename mock_bulk_store_client.go --with-expecter
//go:generate mockery --name=BulkStore_BulkProcessClient --srcpkg=github.com/grafana/grafana/pkg/storage/unified/resource --output=. --outpkg=migrate --filename=mock_bulk_process_client.go --with-expecter
type BulkStoreClient interface {
BulkProcess(ctx context.Context, opts ...grpc.CallOption) (resourcepb.BulkStore_BulkProcessClient, error)
}
//go:generate mockery --name StorageSwapper --structname MockStorageSwapper --inpackage --filename mock_storage_swapper.go --with-expecter
type StorageSwapper interface {
StopReadingUnifiedStorage(ctx context.Context) error
WipeUnifiedAndSetMigratedFlag(ctx context.Context, namespace string) error
}
type storageSwapper struct {
// Direct access to unified storage... use carefully!
bulk BulkStoreClient
dual dualwrite.Service
}
func NewStorageSwapper(bulk BulkStoreClient, dual dualwrite.Service) StorageSwapper {
return &storageSwapper{
bulk: bulk,
dual: dual,
}
}
func (s *storageSwapper) StopReadingUnifiedStorage(ctx context.Context) error {
// FIXME: dual writer is not namespaced which means that we would consider all namespaces migrated
// after one migrates
for _, gr := range resources.SupportedProvisioningResources {
status, _ := s.dual.Status(ctx, gr.GroupResource())
status.ReadUnified = false
status.Migrated = 0
status.Migrating = 0
_, err := s.dual.Update(ctx, status)
if err != nil {
return err
}
}
return nil
}
func (s *storageSwapper) WipeUnifiedAndSetMigratedFlag(ctx context.Context, namespace string) error {
for _, gr := range resources.SupportedProvisioningResources {
status, _ := s.dual.Status(ctx, gr.GroupResource())
if status.ReadUnified {
return fmt.Errorf("unexpected state - already using unified storage for: %s", gr)
}
if status.Migrating > 0 {
if time.Since(time.UnixMilli(status.Migrating)) < time.Second*30 {
return fmt.Errorf("another migration job is running for: %s", gr)
}
}
settings := resource.BulkSettings{
RebuildCollection: true, // wipes everything in the collection
Collection: []*resourcepb.ResourceKey{{
Namespace: namespace,
Group: gr.Group,
Resource: gr.Resource,
}},
}
ctx = metadata.NewOutgoingContext(ctx, settings.ToMD())
stream, err := s.bulk.BulkProcess(ctx)
if err != nil {
return fmt.Errorf("error clearing unified %s / %w", gr, err)
}
stats, err := stream.CloseAndRecv()
if err != nil {
return fmt.Errorf("error clearing unified %s / %w", gr, err)
}
logger := logging.FromContext(ctx)
logger.Error("cleared unified storage", "stats", stats)
status.Migrated = time.Now().UnixMilli() // but not really... since the sync is starting
status.ReadUnified = true
status.WriteLegacy = false // keep legacy "clean"
_, err = s.dual.Update(ctx, status)
if err != nil {
return err
}
}
return nil
}
@@ -1,204 +0,0 @@
package migrate
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
"google.golang.org/grpc/metadata"
)
func TestStorageSwapper_StopReadingUnifiedStorage(t *testing.T) {
tests := []struct {
name string
setupMocks func(*MockBulkStoreClient, *dualwrite.MockService)
expectedError string
}{
{
name: "should update status for all resources",
setupMocks: func(bulk *MockBulkStoreClient, dual *dualwrite.MockService) {
for _, gr := range resources.SupportedProvisioningResources {
status := dualwrite.StorageStatus{
ReadUnified: true,
Migrated: 123,
Migrating: 456,
}
dual.On("Status", mock.Anything, gr.GroupResource()).Return(status, nil)
dual.On("Update", mock.Anything, mock.MatchedBy(func(status dualwrite.StorageStatus) bool {
return !status.ReadUnified && status.Migrated == 0 && status.Migrating == 0
})).Return(dualwrite.StorageStatus{}, nil)
}
},
},
{
name: "should fail if status update fails",
setupMocks: func(bulk *MockBulkStoreClient, dual *dualwrite.MockService) {
gr := resources.SupportedProvisioningResources[0]
dual.On("Status", mock.Anything, gr.GroupResource()).Return(dualwrite.StorageStatus{}, nil)
dual.On("Update", mock.Anything, mock.Anything).Return(dualwrite.StorageStatus{}, errors.New("update failed"))
},
expectedError: "update failed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
bulk := NewMockBulkStoreClient(t)
dual := dualwrite.NewMockService(t)
if tt.setupMocks != nil {
tt.setupMocks(bulk, dual)
}
swapper := NewStorageSwapper(bulk, dual)
err := swapper.StopReadingUnifiedStorage(context.Background())
if tt.expectedError != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.expectedError)
} else {
require.NoError(t, err)
}
})
}
}
func TestStorageSwapper_WipeUnifiedAndSetMigratedFlag(t *testing.T) {
tests := []struct {
name string
setupMocks func(*MockBulkStoreClient, *dualwrite.MockService)
expectedError string
}{
{
name: "should fail if already using unified storage",
setupMocks: func(bulk *MockBulkStoreClient, dual *dualwrite.MockService) {
gr := resources.SupportedProvisioningResources[0]
status := dualwrite.StorageStatus{
ReadUnified: true,
}
dual.On("Status", mock.Anything, gr.GroupResource()).Return(status, nil)
},
expectedError: "unexpected state - already using unified storage",
},
{
name: "should fail if migration is in progress",
setupMocks: func(bulk *MockBulkStoreClient, dual *dualwrite.MockService) {
gr := resources.SupportedProvisioningResources[0]
status := dualwrite.StorageStatus{
ReadUnified: false,
Migrating: time.Now().UnixMilli(),
}
dual.On("Status", mock.Anything, gr.GroupResource()).Return(status, nil)
},
expectedError: "another migration job is running",
},
{
name: "should fail if bulk process fails",
setupMocks: func(bulk *MockBulkStoreClient, dual *dualwrite.MockService) {
gr := resources.SupportedProvisioningResources[0]
dual.On("Status", mock.Anything, gr.GroupResource()).Return(dualwrite.StorageStatus{}, nil)
bulk.On("BulkProcess", mock.Anything, mock.Anything).Return(nil, errors.New("bulk process failed"))
},
expectedError: "error clearing unified",
},
{
name: "should fail if status update fails after bulk process",
setupMocks: func(bulk *MockBulkStoreClient, dual *dualwrite.MockService) {
gr := resources.SupportedProvisioningResources[0]
dual.On("Status", mock.Anything, gr.GroupResource()).Return(dualwrite.StorageStatus{}, nil)
mockStream := NewBulkStore_BulkProcessClient(t)
mockStream.On("CloseAndRecv").Return(&resourcepb.BulkResponse{}, nil)
bulk.On("BulkProcess", mock.Anything, mock.Anything).Return(mockStream, nil)
dual.On("Update", mock.Anything, mock.MatchedBy(func(status dualwrite.StorageStatus) bool {
return status.ReadUnified && !status.WriteLegacy && status.Migrated > 0
})).Return(dualwrite.StorageStatus{}, errors.New("update failed"))
},
expectedError: "update failed",
},
{
name: "should fail if bulk process stream close fails",
setupMocks: func(bulk *MockBulkStoreClient, dual *dualwrite.MockService) {
gr := resources.SupportedProvisioningResources[0]
dual.On("Status", mock.Anything, gr.GroupResource()).Return(dualwrite.StorageStatus{}, nil)
mockStream := NewBulkStore_BulkProcessClient(t)
mockStream.On("CloseAndRecv").Return(nil, errors.New("stream close failed"))
bulk.On("BulkProcess", mock.Anything, mock.Anything).Return(mockStream, nil)
},
expectedError: "error clearing unified",
},
{
name: "should succeed with complete workflow",
setupMocks: func(bulk *MockBulkStoreClient, dual *dualwrite.MockService) {
for _, gr := range resources.SupportedProvisioningResources {
dual.On("Status", mock.Anything, gr.GroupResource()).Return(dualwrite.StorageStatus{}, nil)
mockStream := NewBulkStore_BulkProcessClient(t)
mockStream.On("CloseAndRecv").Return(&resourcepb.BulkResponse{}, nil)
bulk.On("BulkProcess", mock.MatchedBy(func(ctx context.Context) bool {
md, ok := metadata.FromOutgoingContext(ctx)
if !ok {
return false
}
//nolint:errcheck // hits the err != nil gotcha
settings, _ := resource.NewBulkSettings(md)
if !settings.RebuildCollection {
return false
}
if len(settings.Collection) != 1 {
return false
}
if settings.Collection[0].Namespace != "test-namespace" {
return false
}
if settings.Collection[0].Group != gr.Group {
return false
}
if settings.Collection[0].Resource != gr.Resource {
return false
}
return true
}), mock.Anything).Return(mockStream, nil)
dual.On("Update", mock.Anything, mock.MatchedBy(func(status dualwrite.StorageStatus) bool {
return status.ReadUnified && !status.WriteLegacy && status.Migrated > 0
})).Return(dualwrite.StorageStatus{}, nil)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
bulk := NewMockBulkStoreClient(t)
dual := dualwrite.NewMockService(t)
if tt.setupMocks != nil {
tt.setupMocks(bulk, dual)
}
swapper := NewStorageSwapper(bulk, dual)
err := swapper.WipeUnifiedAndSetMigratedFlag(context.Background(), "test-namespace")
if tt.expectedError != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.expectedError)
} else {
require.NoError(t, err)
}
})
}
}
@@ -33,23 +33,7 @@ func NewUnifiedStorageMigrator(
func (m *UnifiedStorageMigrator) Migrate(ctx context.Context, repo repository.ReaderWriter, options provisioning.MigrateJobOptions, progress jobs.JobProgressRecorder) error {
namespace := repo.Config().GetNamespace()
// For folder-type repositories, only run sync (skip export and cleaner)
if repo.Config().Spec.Sync.Target == provisioning.SyncTargetTypeFolder {
progress.SetMessage(ctx, "pull resources")
syncJob := provisioning.Job{
Spec: provisioning.JobSpec{
Pull: &provisioning.SyncJobOptions{
Incremental: false,
},
},
}
if err := m.syncWorker.Process(ctx, repo, syncJob, progress); err != nil {
return fmt.Errorf("pull resources: %w", err)
}
return nil
}
// For instance-type repositories, run the full workflow: export -> sync -> clean
// Export resources first (for both folder and instance sync)
progress.SetMessage(ctx, "export resources")
progress.StrictMaxErrors(1) // strict as we want the entire instance to be managed
@@ -67,6 +51,7 @@ func (m *UnifiedStorageMigrator) Migrate(ctx context.Context, repo repository.Re
// Reset the results after the export as pull will operate on the same resources
progress.ResetResults()
// Pull resources from the repository
progress.SetMessage(ctx, "pull resources")
syncJob := provisioning.Job{
Spec: provisioning.JobSpec{
@@ -79,9 +64,12 @@ func (m *UnifiedStorageMigrator) Migrate(ctx context.Context, repo repository.Re
return fmt.Errorf("pull resources: %w", err)
}
progress.SetMessage(ctx, "clean namespace")
if err := m.namespaceCleaner.Clean(ctx, namespace, progress); err != nil {
return fmt.Errorf("clean namespace: %w", err)
// For instance-type repositories, also clean the namespace
if repo.Config().Spec.Sync.Target != provisioning.SyncTargetTypeFolder {
progress.SetMessage(ctx, "clean namespace")
if err := m.namespaceCleaner.Clean(ctx, namespace, progress); err != nil {
return fmt.Errorf("clean namespace: %w", err)
}
}
return nil
@@ -134,7 +134,7 @@ func TestUnifiedStorageMigrator_Migrate(t *testing.T) {
expectedError: "",
},
{
name: "should only run sync for folder-type repositories",
name: "should run export and sync for folder-type repositories",
setupMocks: func(nc *MockNamespaceCleaner, ew *jobs.MockWorker, sw *jobs.MockWorker, pr *jobs.MockJobProgressRecorder, rw *repository.MockRepository) {
rw.On("Config").Return(&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
@@ -147,9 +147,15 @@ func TestUnifiedStorageMigrator_Migrate(t *testing.T) {
},
},
})
// Export should be skipped - no export-related mocks
// Cleaner should also be skipped - no cleaner-related mocks
// Only sync job should run
// Export should run for folder-type repositories
pr.On("SetMessage", mock.Anything, "export resources").Return()
pr.On("StrictMaxErrors", 1).Return()
ew.On("Process", mock.Anything, rw, mock.MatchedBy(func(job provisioning.Job) bool {
return job.Spec.Push != nil
}), pr).Return(nil)
pr.On("ResetResults").Return()
// Cleaner should be skipped - no cleaner-related mocks
// Sync job should run
pr.On("SetMessage", mock.Anything, "pull resources").Return()
sw.On("Process", mock.Anything, rw, mock.MatchedBy(func(job provisioning.Job) bool {
return job.Spec.Pull != nil && !job.Spec.Pull.Incremental
@@ -171,7 +177,14 @@ func TestUnifiedStorageMigrator_Migrate(t *testing.T) {
},
},
})
// Only sync job should run and fail
// Export should run first
pr.On("SetMessage", mock.Anything, "export resources").Return()
pr.On("StrictMaxErrors", 1).Return()
ew.On("Process", mock.Anything, rw, mock.MatchedBy(func(job provisioning.Job) bool {
return job.Spec.Push != nil
}), pr).Return(nil)
pr.On("ResetResults").Return()
// Sync job should run and fail
pr.On("SetMessage", mock.Anything, "pull resources").Return()
sw.On("Process", mock.Anything, rw, mock.MatchedBy(func(job provisioning.Job) bool {
return job.Spec.Pull != nil && !job.Spec.Pull.Incremental
@@ -7,7 +7,6 @@ import (
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
)
//go:generate mockery --name Migrator --structname MockMigrator --inpackage --filename mock_migrator.go --with-expecter
@@ -16,8 +15,6 @@ type Migrator interface {
}
type MigrationWorker struct {
storageStatus dualwrite.Service
legacyMigrator Migrator
unifiedMigrator Migrator
}
@@ -27,16 +24,9 @@ func NewMigrationWorkerFromUnified(unifiedMigrator Migrator) *MigrationWorker {
}
}
// HACK: we should decouple the implementation of these two
func NewMigrationWorker(
legacyMigrator Migrator,
unifiedMigrator Migrator,
storageStatus dualwrite.Service,
) *MigrationWorker {
func NewMigrationWorker(unifiedMigrator Migrator) *MigrationWorker {
return &MigrationWorker{
unifiedMigrator: unifiedMigrator,
legacyMigrator: legacyMigrator,
storageStatus: storageStatus,
}
}
@@ -56,28 +46,5 @@ func (w *MigrationWorker) Process(ctx context.Context, repo repository.Repositor
return errors.New("migration job submitted targeting repository that is not a ReaderWriter")
}
if options.History {
if repo.Config().Spec.Type != provisioning.GitHubRepositoryType {
return errors.New("history is only supported for github repositories")
}
}
// Block migrate for legacy resources if repository type is folder
if repo.Config().Spec.Sync.Target == provisioning.SyncTargetTypeFolder {
// HACK: we should not have to check for storage existence here
if w.storageStatus != nil && dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, w.storageStatus) {
return errors.New("migration of legacy resources is not supported for folder-type repositories")
}
}
// HACK: we should not have to check for storage existence here
if w.storageStatus != nil && dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, w.storageStatus) {
return w.legacyMigrator.Migrate(ctx, rw, *options, progress)
}
if options.History {
return errors.New("history is not yet supported in unified storage")
}
return w.unifiedMigrator.Migrate(ctx, rw, *options, progress)
}
@@ -2,7 +2,6 @@ package migrate
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
@@ -11,9 +10,7 @@ import (
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/grafana/grafana/apps/provisioning/pkg/repository/local"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
)
func TestMigrationWorker_IsSupported(t *testing.T) {
@@ -42,7 +39,7 @@ func TestMigrationWorker_IsSupported(t *testing.T) {
},
}
worker := NewMigrationWorker(nil, nil, nil)
worker := NewMigrationWorker(nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -53,7 +50,7 @@ func TestMigrationWorker_IsSupported(t *testing.T) {
}
func TestMigrationWorker_ProcessNotReaderWriter(t *testing.T) {
worker := NewMigrationWorker(nil, nil, nil)
worker := NewMigrationWorker(NewMockMigrator(t))
job := provisioning.Job{
Spec: provisioning.JobSpec{
Action: provisioning.JobActionMigrate,
@@ -68,56 +65,13 @@ func TestMigrationWorker_ProcessNotReaderWriter(t *testing.T) {
require.EqualError(t, err, "migration job submitted targeting repository that is not a ReaderWriter")
}
func TestMigrationWorker_WithHistory(t *testing.T) {
fakeDualwrite := dualwrite.NewMockService(t)
fakeDualwrite.On("ReadFromUnified", mock.Anything, mock.Anything).
Maybe().Return(true, nil) // using unified storage
worker := NewMigrationWorker(nil, nil, fakeDualwrite)
job := provisioning.Job{
Spec: provisioning.JobSpec{
Action: provisioning.JobActionMigrate,
Migrate: &provisioning.MigrateJobOptions{
History: true,
},
},
}
t.Run("fail local", func(t *testing.T) {
progressRecorder := jobs.NewMockJobProgressRecorder(t)
progressRecorder.On("SetTotal", mock.Anything, 10).Return()
repo := local.NewRepository(&provisioning.Repository{}, nil)
err := worker.Process(context.Background(), repo, job, progressRecorder)
require.EqualError(t, err, "history is only supported for github repositories")
})
t.Run("fail unified", func(t *testing.T) {
progressRecorder := jobs.NewMockJobProgressRecorder(t)
progressRecorder.On("SetTotal", mock.Anything, 10).Return()
repo := repository.NewMockRepository(t)
repo.On("Config").Return(&provisioning.Repository{
Spec: provisioning.RepositorySpec{
Type: provisioning.GitHubRepositoryType,
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "empty", // not valid
},
},
})
err := worker.Process(context.Background(), repo, job, progressRecorder)
require.EqualError(t, err, "history is not yet supported in unified storage")
})
}
func TestMigrationWorker_Process(t *testing.T) {
tests := []struct {
name string
setupMocks func(*MockMigrator, *MockMigrator, *dualwrite.MockService, *jobs.MockJobProgressRecorder)
setupRepo func(*repository.MockRepository)
job provisioning.Job
expectedError string
isLegacyActive bool
name string
setupMocks func(*MockMigrator, *jobs.MockJobProgressRecorder)
setupRepo func(*repository.MockRepository)
job provisioning.Job
expectedError string
}{
{
name: "should fail when migrate settings are missing",
@@ -127,7 +81,7 @@ func TestMigrationWorker_Process(t *testing.T) {
Migrate: nil,
},
},
setupMocks: func(lm *MockMigrator, um *MockMigrator, ds *dualwrite.MockService, pr *jobs.MockJobProgressRecorder) {
setupMocks: func(um *MockMigrator, pr *jobs.MockJobProgressRecorder) {
},
setupRepo: func(repo *repository.MockRepository) {
// No Config() call expected since we fail before that
@@ -135,150 +89,35 @@ func TestMigrationWorker_Process(t *testing.T) {
expectedError: "missing migrate settings",
},
{
name: "should use legacy migrator when legacy storage is active",
name: "should use unified storage migrator for instance-type repositories",
job: provisioning.Job{
Spec: provisioning.JobSpec{
Action: provisioning.JobActionMigrate,
Migrate: &provisioning.MigrateJobOptions{},
},
},
isLegacyActive: true,
setupMocks: func(lm *MockMigrator, um *MockMigrator, ds *dualwrite.MockService, pr *jobs.MockJobProgressRecorder) {
setupMocks: func(um *MockMigrator, pr *jobs.MockJobProgressRecorder) {
pr.On("SetTotal", mock.Anything, 10).Return()
ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(false, nil)
lm.On("Migrate", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
},
setupRepo: func(repo *repository.MockRepository) {
repo.On("Config").Return(&provisioning.Repository{
Spec: provisioning.RepositorySpec{
Sync: provisioning.SyncOptions{
Target: provisioning.SyncTargetTypeInstance,
},
},
})
},
},
{
name: "should use unified storage migrator when legacy storage is not active",
job: provisioning.Job{
Spec: provisioning.JobSpec{
Action: provisioning.JobActionMigrate,
Migrate: &provisioning.MigrateJobOptions{},
},
},
isLegacyActive: false,
setupMocks: func(lm *MockMigrator, um *MockMigrator, ds *dualwrite.MockService, pr *jobs.MockJobProgressRecorder) {
pr.On("SetTotal", mock.Anything, 10).Return()
ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil)
um.On("Migrate", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
},
setupRepo: func(repo *repository.MockRepository) {
repo.On("Config").Return(&provisioning.Repository{
Spec: provisioning.RepositorySpec{
Sync: provisioning.SyncOptions{
Target: provisioning.SyncTargetTypeInstance,
},
},
})
// No Config() call needed anymore
},
},
{
name: "should propagate migrator errors",
name: "should allow migration for folder-type repositories",
job: provisioning.Job{
Spec: provisioning.JobSpec{
Action: provisioning.JobActionMigrate,
Migrate: &provisioning.MigrateJobOptions{},
},
},
isLegacyActive: true,
setupMocks: func(lm *MockMigrator, um *MockMigrator, ds *dualwrite.MockService, pr *jobs.MockJobProgressRecorder) {
setupMocks: func(um *MockMigrator, pr *jobs.MockJobProgressRecorder) {
pr.On("SetTotal", mock.Anything, 10).Return()
ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(false, nil)
lm.On("Migrate", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(errors.New("migration failed"))
},
setupRepo: func(repo *repository.MockRepository) {
repo.On("Config").Return(&provisioning.Repository{
Spec: provisioning.RepositorySpec{
Sync: provisioning.SyncOptions{
Target: provisioning.SyncTargetTypeInstance,
},
},
})
},
expectedError: "migration failed",
},
{
name: "should block migration of legacy resources for folder-type repositories",
job: provisioning.Job{
Spec: provisioning.JobSpec{
Action: provisioning.JobActionMigrate,
Migrate: &provisioning.MigrateJobOptions{},
},
},
isLegacyActive: true,
setupMocks: func(lm *MockMigrator, um *MockMigrator, ds *dualwrite.MockService, pr *jobs.MockJobProgressRecorder) {
pr.On("SetTotal", mock.Anything, 10).Return()
ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(false, nil)
// legacyMigrator should not be called as we block before reaching it
},
setupRepo: func(repo *repository.MockRepository) {
repo.On("Config").Return(&provisioning.Repository{
Spec: provisioning.RepositorySpec{
Sync: provisioning.SyncOptions{
Target: provisioning.SyncTargetTypeFolder,
},
},
})
},
expectedError: "migration of legacy resources is not supported for folder-type repositories",
},
{
name: "should allow migration of legacy resources for instance-type repositories",
job: provisioning.Job{
Spec: provisioning.JobSpec{
Action: provisioning.JobActionMigrate,
Migrate: &provisioning.MigrateJobOptions{},
},
},
isLegacyActive: true,
setupMocks: func(lm *MockMigrator, um *MockMigrator, ds *dualwrite.MockService, pr *jobs.MockJobProgressRecorder) {
pr.On("SetTotal", mock.Anything, 10).Return()
ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(false, nil)
lm.On("Migrate", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
},
setupRepo: func(repo *repository.MockRepository) {
repo.On("Config").Return(&provisioning.Repository{
Spec: provisioning.RepositorySpec{
Sync: provisioning.SyncOptions{
Target: provisioning.SyncTargetTypeInstance,
},
},
})
},
expectedError: "",
},
{
name: "should allow migration for folder-type repositories when legacy storage is not active",
job: provisioning.Job{
Spec: provisioning.JobSpec{
Action: provisioning.JobActionMigrate,
Migrate: &provisioning.MigrateJobOptions{},
},
},
isLegacyActive: false,
setupMocks: func(lm *MockMigrator, um *MockMigrator, ds *dualwrite.MockService, pr *jobs.MockJobProgressRecorder) {
pr.On("SetTotal", mock.Anything, 10).Return()
ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil)
um.On("Migrate", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
},
setupRepo: func(repo *repository.MockRepository) {
repo.On("Config").Return(&provisioning.Repository{
Spec: provisioning.RepositorySpec{
Sync: provisioning.SyncOptions{
Target: provisioning.SyncTargetTypeFolder,
},
},
})
// No Config() call needed anymore
},
expectedError: "",
},
@@ -286,15 +125,13 @@ func TestMigrationWorker_Process(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
legacyMigrator := NewMockMigrator(t)
unifiedMigrator := NewMockMigrator(t)
dualWriteService := dualwrite.NewMockService(t)
progressRecorder := jobs.NewMockJobProgressRecorder(t)
worker := NewMigrationWorker(legacyMigrator, unifiedMigrator, dualWriteService)
worker := NewMigrationWorker(unifiedMigrator)
if tt.setupMocks != nil {
tt.setupMocks(legacyMigrator, unifiedMigrator, dualWriteService, progressRecorder)
tt.setupMocks(unifiedMigrator, progressRecorder)
}
rw := repository.NewMockRepository(t)
@@ -310,7 +147,7 @@ func TestMigrationWorker_Process(t *testing.T) {
require.NoError(t, err)
}
mock.AssertExpectationsForObjects(t, legacyMigrator, unifiedMigrator, dualWriteService, progressRecorder, rw)
mock.AssertExpectationsForObjects(t, unifiedMigrator, progressRecorder, rw)
})
}
}
@@ -1,4 +1,4 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
// Code generated by mockery v2.53.4. DO NOT EDIT.
package jobs
@@ -14,7 +14,7 @@ import (
//go:generate mockery --name FullSyncFn --structname MockFullSyncFn --inpackage --filename full_sync_fn_mock.go --with-expecter
type FullSyncFn func(ctx context.Context, repo repository.Reader, compare CompareFn, clients resources.ResourceClients, currentRef string, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder, tracer tracing.Tracer, maxSyncWorkers int, metrics jobs.JobMetrics) error
//go:generate mockery --name CompareFn --structname MockCompareFn --inpackage --filename compare_fn_mock.go --with-expecter
//go:generate mockery -name CompareFn --structname MockCompareFn --inpackage --filename compare_fn_mock.go --with-expecter
type CompareFn func(ctx context.Context, repo repository.Reader, repositoryResources resources.RepositoryResources, ref string) ([]ResourceFileChange, error)
//go:generate mockery --name IncrementalSyncFn --structname MockIncrementalSyncFn --inpackage --filename incremental_sync_fn_mock.go --with-expecter
@@ -12,7 +12,6 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/utils"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
@@ -29,9 +28,6 @@ type SyncWorker struct {
// ResourceClients for the repository
repositoryResources resources.RepositoryResourcesFactory
// Check if the system is using unified storage
storageStatus dualwrite.Service
// Patch status for the repository
patchStatus RepositoryPatchFn
@@ -48,7 +44,6 @@ type SyncWorker struct {
func NewSyncWorker(
clients resources.ClientFactory,
repositoryResources resources.RepositoryResourcesFactory,
storageStatus dualwrite.Service,
patchStatus RepositoryPatchFn,
syncer Syncer,
metrics jobs.JobMetrics,
@@ -59,7 +54,6 @@ func NewSyncWorker(
clients: clients,
repositoryResources: repositoryResources,
patchStatus: patchStatus,
storageStatus: storageStatus,
syncer: syncer,
metrics: metrics,
tracer: tracer,
@@ -96,13 +90,6 @@ func (r *SyncWorker) Process(ctx context.Context, repo repository.Repository, jo
)
}()
// Check if we are onboarding from legacy storage
// HACK -- this should be handled outside of this worker
if r.storageStatus != nil && dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, r.storageStatus) {
err := fmt.Errorf("sync not supported until storage has migrated")
return tracing.Error(span, err)
}
rw, ok := repo.(repository.ReaderWriter)
if !ok {
err := fmt.Errorf("sync job submitted for repository that does not support read-write")
@@ -10,7 +10,6 @@ import (
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
@@ -46,7 +45,7 @@ func TestSyncWorker_IsSupported(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
worker := NewSyncWorker(nil, nil, nil, nil, nil, metrics, tracing.NewNoopTracerService(), 10)
worker := NewSyncWorker(nil, nil, nil, nil, metrics, tracing.NewNoopTracerService(), 10)
result := worker.IsSupported(context.Background(), tt.job)
require.Equal(t, tt.expected, result)
})
@@ -63,9 +62,7 @@ func TestSyncWorker_ProcessNotReaderWriter(t *testing.T) {
Title: "test-repo",
},
})
fakeDualwrite := dualwrite.NewMockService(t)
fakeDualwrite.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice()
worker := NewSyncWorker(nil, nil, fakeDualwrite, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()), tracing.NewNoopTracerService(), 10)
worker := NewSyncWorker(nil, nil, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()), tracing.NewNoopTracerService(), 10)
err := worker.Process(context.Background(), repo, provisioning.Job{}, jobs.NewMockJobProgressRecorder(t))
require.EqualError(t, err, "sync job submitted for repository that does not support read-write")
}
@@ -73,31 +70,13 @@ func TestSyncWorker_ProcessNotReaderWriter(t *testing.T) {
func TestSyncWorker_Process(t *testing.T) {
tests := []struct {
name string
setupMocks func(*resources.MockClientFactory, *resources.MockRepositoryResourcesFactory, *dualwrite.MockService, *MockRepositoryPatchFn, *MockSyncer, *mockReaderWriter, *jobs.MockJobProgressRecorder)
setupMocks func(*resources.MockClientFactory, *resources.MockRepositoryResourcesFactory, *MockRepositoryPatchFn, *MockSyncer, *mockReaderWriter, *jobs.MockJobProgressRecorder)
expectedError string
expectedStatus *provisioning.SyncStatus
}{
{
name: "legacy storage not migrated",
setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, ds *dualwrite.MockService, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) {
rw.MockRepository.On("Config").Return(&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
},
Spec: provisioning.RepositorySpec{
Title: "test-repo",
},
})
ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(false, nil).Twice()
},
expectedError: "sync not supported until storage has migrated",
},
{
name: "failed initial status patching",
setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, ds *dualwrite.MockService, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) {
ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice()
setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) {
// Setup repository config with existing LastRef
repoConfig := &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
@@ -132,7 +111,7 @@ func TestSyncWorker_Process(t *testing.T) {
},
{
name: "failed getting repository resources",
setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, ds *dualwrite.MockService, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) {
setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) {
// Setup repository config
repoConfig := &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
@@ -149,9 +128,6 @@ func TestSyncWorker_Process(t *testing.T) {
}
rw.MockRepository.On("Config").Return(repoConfig)
// Storage is migrated
ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice()
// Initial status update succeeds - expect granular patches
pr.On("SetMessage", mock.Anything, "update sync status at start").Return()
rpf.On("Execute", mock.Anything, repoConfig, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
@@ -168,7 +144,7 @@ func TestSyncWorker_Process(t *testing.T) {
},
{
name: "failed getting clients for namespace",
setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, ds *dualwrite.MockService, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) {
setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) {
// Setup repository config
repoConfig := &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
@@ -186,9 +162,6 @@ func TestSyncWorker_Process(t *testing.T) {
}
rw.MockRepository.On("Config").Return(repoConfig)
// Storage is migrated
ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice()
// Initial status update succeeds - expect granular patches
pr.On("SetMessage", mock.Anything, "update sync status at start").Return()
rpf.On("Execute", mock.Anything, repoConfig, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
@@ -208,7 +181,7 @@ func TestSyncWorker_Process(t *testing.T) {
},
{
name: "successful sync",
setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, ds *dualwrite.MockService, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) {
setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) {
repoConfig := &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
@@ -222,9 +195,6 @@ func TestSyncWorker_Process(t *testing.T) {
}
rw.MockRepository.On("Config").Return(repoConfig)
// Storage is migrated
ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice()
// Initial status update - expect granular patches
pr.On("SetMessage", mock.Anything, "update sync status at start").Return()
rpf.On("Execute", mock.Anything, repoConfig, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
@@ -261,7 +231,7 @@ func TestSyncWorker_Process(t *testing.T) {
},
{
name: "failed sync",
setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, ds *dualwrite.MockService, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) {
setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) {
repoConfig := &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
@@ -275,9 +245,6 @@ func TestSyncWorker_Process(t *testing.T) {
}
rw.MockRepository.On("Config").Return(repoConfig)
// Storage is migrated
ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice()
// Initial status update - expect granular patches
pr.On("SetMessage", mock.Anything, "update sync status at start").Return()
rpf.On("Execute", mock.Anything, repoConfig, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
@@ -315,7 +282,7 @@ func TestSyncWorker_Process(t *testing.T) {
},
{
name: "stats call fails",
setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, ds *dualwrite.MockService, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) {
setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) {
repoConfig := &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
@@ -323,7 +290,6 @@ func TestSyncWorker_Process(t *testing.T) {
},
}
rw.MockRepository.On("Config").Return(repoConfig)
ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice()
mockRepoResources := resources.NewMockRepositoryResources(t)
mockRepoResources.On("Stats", mock.Anything).Return(nil, errors.New("stats error"))
@@ -344,7 +310,7 @@ func TestSyncWorker_Process(t *testing.T) {
},
{
name: "stats returns nil stats and nil error",
setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, ds *dualwrite.MockService, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) {
setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) {
repoConfig := &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
@@ -352,7 +318,6 @@ func TestSyncWorker_Process(t *testing.T) {
},
}
rw.MockRepository.On("Config").Return(repoConfig)
ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice()
mockRepoResources := resources.NewMockRepositoryResources(t)
mockRepoResources.On("Stats", mock.Anything).Return(nil, nil)
@@ -378,7 +343,7 @@ func TestSyncWorker_Process(t *testing.T) {
},
{
name: "stats returns one managed stats",
setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, ds *dualwrite.MockService, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) {
setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) {
repoConfig := &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
@@ -386,7 +351,6 @@ func TestSyncWorker_Process(t *testing.T) {
},
}
rw.MockRepository.On("Config").Return(repoConfig)
ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice()
// Initial patch with granular updates
rpf.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
@@ -439,7 +403,7 @@ func TestSyncWorker_Process(t *testing.T) {
},
{
name: "stats returns multiple managed stats",
setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, ds *dualwrite.MockService, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) {
setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) {
repoConfig := &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
@@ -447,7 +411,6 @@ func TestSyncWorker_Process(t *testing.T) {
},
}
rw.MockRepository.On("Config").Return(repoConfig)
ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice()
mockRepoResources := resources.NewMockRepositoryResources(t)
stats := &provisioning.ResourceStats{
@@ -495,7 +458,7 @@ func TestSyncWorker_Process(t *testing.T) {
},
{
name: "failed final status patch",
setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, ds *dualwrite.MockService, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) {
setupMocks: func(cf *resources.MockClientFactory, rrf *resources.MockRepositoryResourcesFactory, rpf *MockRepositoryPatchFn, s *MockSyncer, rw *mockReaderWriter, pr *jobs.MockJobProgressRecorder) {
repoConfig := &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
@@ -503,7 +466,6 @@ func TestSyncWorker_Process(t *testing.T) {
},
}
rw.MockRepository.On("Config").Return(repoConfig)
ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil).Twice()
// Initial status patch succeeds - expect granular patches
rpf.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
@@ -534,7 +496,6 @@ func TestSyncWorker_Process(t *testing.T) {
// Create mocks
clientFactory := resources.NewMockClientFactory(t)
repoResourcesFactory := resources.NewMockRepositoryResourcesFactory(t)
dualwriteService := dualwrite.NewMockService(t)
repositoryPatchFn := NewMockRepositoryPatchFn(t)
syncer := NewMockSyncer(t)
readerWriter := &mockReaderWriter{
@@ -544,13 +505,12 @@ func TestSyncWorker_Process(t *testing.T) {
progressRecorder := jobs.NewMockJobProgressRecorder(t)
// Setup mocks
tt.setupMocks(clientFactory, repoResourcesFactory, dualwriteService, repositoryPatchFn, syncer, readerWriter, progressRecorder)
tt.setupMocks(clientFactory, repoResourcesFactory, repositoryPatchFn, syncer, readerWriter, progressRecorder)
// Create worker
worker := NewSyncWorker(
clientFactory,
repoResourcesFactory,
dualwriteService,
repositoryPatchFn.Execute,
syncer,
jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()),
@@ -1,4 +1,4 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
// Code generated by mockery v2.53.4. DO NOT EDIT.
package jobs
+7 -97
View File
@@ -28,8 +28,6 @@ import (
authlib "github.com/grafana/authlib/types"
"github.com/grafana/grafana-app-sdk/logging"
dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
connectionvalidation "github.com/grafana/grafana/apps/provisioning/pkg/connection"
appcontroller "github.com/grafana/grafana/apps/provisioning/pkg/controller"
@@ -54,14 +52,12 @@ import (
movepkg "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/move"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/sync"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources/signature"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/usage"
"github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"github.com/grafana/grafana/pkg/storage/unified/migrations"
"github.com/grafana/grafana/pkg/storage/unified/resource"
)
@@ -112,7 +108,6 @@ type APIBuilder struct {
jobHistoryLoki *jobs.LokiJobHistory
resourceLister resources.ResourceLister
dashboardAccess legacy.MigrationDashboardAccessor
storageStatus dualwrite.Service
unified resource.ResourceClient
repoFactory repository.Factory
client client.ProvisioningV0alpha1Interface
@@ -159,9 +154,9 @@ func NewAPIBuilder(
} else {
clients = resources.NewClientFactory(configProvider)
}
parsers := resources.NewParserFactory(clients)
legacyMigrator := migrations.ProvideUnifiedMigrator(dashboardAccess, unified)
resourceLister := resources.NewResourceListerForMigrations(unified, legacyMigrator, storageStatus)
resourceLister := resources.NewResourceListerForMigrations(unified)
b := &APIBuilder{
onlyApiServer: onlyApiServer,
@@ -174,7 +169,6 @@ func NewAPIBuilder(
repositoryResources: resources.NewRepositoryResourcesFactory(parsers, clients, resourceLister),
resourceLister: resourceLister,
dashboardAccess: dashboardAccess,
storageStatus: storageStatus,
unified: unified,
access: access,
jobHistoryConfig: jobHistoryConfig,
@@ -250,6 +244,10 @@ func RegisterAPIService(
return nil, nil
}
if dualwrite.IsReadingLegacyDashboardsAndFolders(context.Background(), storageStatus) {
return nil, fmt.Errorf("resources are stored in an incompatible data format to use provisioning. Please enable data migration in settings for folders and dashboards by adding the following configuration:\n[unified_storage.folders.folder.grafana.app]\nenableMigration = true\n\n[unified_storage.dashboards.dashboard.grafana.app]\nenableMigration = true\n\nAlternatively, disable provisioning")
}
allowedTargets := []provisioning.SyncTargetType{}
for _, target := range cfg.ProvisioningAllowedTargets {
allowedTargets = append(allowedTargets, provisioning.SyncTargetType(target))
@@ -762,12 +760,6 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
go repoInformer.Informer().Run(postStartHookCtx.Done())
go jobInformer.Informer().Run(postStartHookCtx.Done())
// When starting with an empty instance -- swith to "mode 4+"
err = b.tryRunningOnlyUnifiedStorage()
if err != nil {
return err
}
// Create the repository resources factory
repositoryListerWrapper := func(ctx context.Context) ([]provisioning.Repository, error) {
return GetRepositoriesInNamespace(ctx, b.store)
@@ -790,29 +782,12 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
syncWorker := sync.NewSyncWorker(
b.clients,
b.repositoryResources,
b.storageStatus,
b.statusPatcher.Patch,
syncer,
metrics,
b.tracer,
10,
)
signerFactory := signature.NewSignerFactory(b.clients)
legacyResources := migrate.NewLegacyResourcesMigrator(
b.repositoryResources,
b.parsers,
b.dashboardAccess,
signerFactory,
b.clients,
export.ExportAll,
)
storageSwapper := migrate.NewStorageSwapper(b.unified, b.storageStatus)
legacyMigrator := migrate.NewLegacyMigrator(
legacyResources,
storageSwapper,
syncWorker,
stageIfPossible,
)
cleaner := migrate.NewNamespaceCleaner(b.clients)
unifiedStorageMigrator := migrate.NewUnifiedStorageMigrator(
@@ -820,12 +795,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
exportWorker,
syncWorker,
)
migrationWorker := migrate.NewMigrationWorker(
legacyMigrator,
unifiedStorageMigrator,
b.storageStatus,
)
migrationWorker := migrate.NewMigrationWorker(unifiedStorageMigrator)
deleteWorker := deletepkg.NewWorker(syncWorker, stageIfPossible, b.repositoryResources, metrics)
moveWorker := movepkg.NewWorker(syncWorker, stageIfPossible, b.repositoryResources, metrics)
@@ -897,7 +867,6 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
b.resourceLister,
b.clients,
b.jobs,
b.storageStatus,
b.GetHealthChecker(),
b.statusPatcher,
b.registry,
@@ -1306,65 +1275,6 @@ spec:
return oas, nil
}
// FIXME: This logic does not belong in provisioning! (but required for now)
// When starting an empty instance, we shift so that we never reference legacy storage
// This should run somewhere else at startup by default (dual writer? dashboards?)
func (b *APIBuilder) tryRunningOnlyUnifiedStorage() error {
ctx := context.Background()
if !b.storageStatus.ShouldManage(dashboard.DashboardResourceInfo.GroupResource()) {
return nil // not enabled
}
if !dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, b.storageStatus) {
return nil
}
// Count how many things exist - create a migrator on-demand for this
legacyMigrator := migrations.ProvideUnifiedMigrator(b.dashboardAccess, b.unified)
rsp, err := legacyMigrator.Migrate(ctx, legacy.MigrateOptions{
Namespace: "default", // FIXME! this works for single org, but need to check multi-org
Resources: []schema.GroupResource{{
Group: dashboard.GROUP, Resource: dashboard.DASHBOARD_RESOURCE,
}, {
Group: folders.GROUP, Resource: folders.RESOURCE,
}},
OnlyCount: true,
})
if err != nil {
return fmt.Errorf("error getting legacy count %w", err)
}
for _, stats := range rsp.Summary {
if stats.Count > 0 {
return nil // something exists we can not just switch
}
}
logger := logging.DefaultLogger.With("logger", "provisioning startup")
mode5 := func(gr schema.GroupResource) error {
status, _ := b.storageStatus.Status(ctx, gr)
if !status.ReadUnified {
status.ReadUnified = true
status.WriteLegacy = false
status.WriteUnified = true
status.Runtime = false
status.Migrated = time.Now().UnixMilli()
_, err = b.storageStatus.Update(ctx, status)
logger.Info("set unified storage access", "group", gr.Group, "resource", gr.Resource)
return err
}
return nil // already reading unified
}
if err = mode5(dashboard.DashboardResourceInfo.GroupResource()); err != nil {
return err
}
if err = mode5(folders.FolderResourceInfo.GroupResource()); err != nil {
return err
}
return nil
}
// Helpers for fetching valid Repository objects
// TODO: where should the helpers live?
@@ -197,6 +197,8 @@ func (fm *FolderManager) EnsureFolderTreeExists(ctx context.Context, ref, path s
if err != nil && (!errors.Is(err, repository.ErrFileNotFound) && !apierrors.IsNotFound(err)) {
return fn(folder, false, fmt.Errorf("check if folder exists before writing: %w", err))
} else if err == nil {
// Folder already exists in repository, add it to tree so resources can find it
fm.tree.Add(folder, parent)
return fn(folder, false, nil)
}
@@ -4,15 +4,9 @@ import (
"context"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"github.com/grafana/grafana/pkg/storage/unified/migrations"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
)
@@ -31,25 +25,16 @@ type ResourceStore interface {
}
type ResourceListerFromSearch struct {
store ResourceStore
migrator migrations.UnifiedMigrator
storageStatus dualwrite.Service
store ResourceStore
}
func NewResourceLister(store ResourceStore) ResourceLister {
return &ResourceListerFromSearch{store: store}
}
// FIXME: the logic about migration and storage should probably be separated from this
func NewResourceListerForMigrations(
store ResourceStore,
migrator migrations.UnifiedMigrator,
storageStatus dualwrite.Service,
) ResourceLister {
func NewResourceListerForMigrations(store ResourceStore) ResourceLister {
return &ResourceListerFromSearch{
store: store,
migrator: migrator,
storageStatus: storageStatus,
store: store,
}
}
@@ -133,37 +118,6 @@ func (o *ResourceListerFromSearch) Stats(ctx context.Context, namespace, reposit
return stats, nil
}
// Get the stats based on what a migration could support
if o.storageStatus != nil && o.migrator != nil && dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, o.storageStatus) {
rsp, err := o.migrator.Migrate(ctx, legacy.MigrateOptions{
Namespace: namespace,
Resources: []schema.GroupResource{{
Group: dashboard.GROUP, Resource: dashboard.DASHBOARD_RESOURCE,
}, {
Group: folders.GROUP, Resource: folders.RESOURCE,
}},
WithHistory: false,
OnlyCount: true,
})
if err != nil {
return nil, err
}
for _, v := range rsp.Summary {
stats.Instance = append(stats.Instance, provisioning.ResourceCount{
Group: v.Group,
Resource: v.Resource,
Count: v.Count,
})
// Everything is unmanaged in legacy storage
stats.Unmanaged = append(stats.Unmanaged, provisioning.ResourceCount{
Group: v.Group,
Resource: v.Resource,
Count: v.Count,
})
}
return stats, nil
}
// Get full instance stats
info, err := o.store.GetStats(ctx, &resourcepb.ResourceStatsRequest{
Namespace: namespace,
@@ -180,7 +180,12 @@ func (r *ResourcesManager) WriteResourceFileFromObject(ctx context.Context, obj
var ok bool
fid, ok = r.folders.Tree().DirPath(folder, rootFolder)
if !ok {
return "", fmt.Errorf("folder %s NOT found in tree with root: %s", folder, rootFolder)
// HACK: this is a hack to get the folder path without the root folder
// TODO: should we build the tree in a different way?
fid, ok = r.folders.Tree().DirPath(folder, "")
if !ok {
return "", fmt.Errorf("folder %s NOT found in tree", folder)
}
}
}
@@ -1,33 +0,0 @@
package signature
import (
"context"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/grafana/grafana/pkg/apimachinery/utils"
)
type grafanaSigner struct{}
// FIXME: where should we use this default signature?
// NewGrafanaSigner returns a Signer that uses the grafana user as the author
func NewGrafanaSigner() Signer {
return &grafanaSigner{}
}
func (s *grafanaSigner) Sign(ctx context.Context, item utils.GrafanaMetaAccessor) (context.Context, error) {
sig := repository.CommitSignature{
Name: "grafana",
// TODO: should we add email?
// Email: "grafana@grafana.com",
}
t, err := item.GetUpdatedTimestamp()
if err == nil && t != nil {
sig.When = *t
} else {
sig.When = item.GetCreationTimestamp().Time
}
return repository.WithAuthorSignature(ctx, sig), nil
}
@@ -1,90 +0,0 @@
package signature
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/grafana/grafana/pkg/apimachinery/utils"
)
func TestNewGrafanaSigner(t *testing.T) {
signer := NewGrafanaSigner()
require.NotNil(t, signer, "signer should not be nil")
require.IsType(t, &grafanaSigner{}, signer, "signer should be of type *grafanaSigner")
}
func TestGrafanaSigner_Sign(t *testing.T) {
tests := []struct {
name string
creationTimestamp time.Time
updateTimestampErr error
updatedTimestamp *time.Time
expectedTime time.Time
setupMocks func(meta *utils.MockGrafanaMetaAccessor)
}{
{
name: "should use creation timestamp when no update timestamp",
creationTimestamp: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
updatedTimestamp: ptr(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)),
updateTimestampErr: errors.New("failed"),
expectedTime: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
setupMocks: func(meta *utils.MockGrafanaMetaAccessor) {
meta.On("GetCreationTimestamp").Return(metav1.Time{Time: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)})
},
},
{
name: "should use creation timestamp when update timestamp is nil",
creationTimestamp: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
updatedTimestamp: nil,
expectedTime: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
setupMocks: func(meta *utils.MockGrafanaMetaAccessor) {
meta.On("GetCreationTimestamp").Return(metav1.Time{Time: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)})
},
},
{
name: "should use update timestamp when available",
creationTimestamp: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
updatedTimestamp: ptr(time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC)),
expectedTime: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
meta := utils.NewMockGrafanaMetaAccessor(t)
var updatedTime *time.Time
if tt.updatedTimestamp != nil {
updatedTime = tt.updatedTimestamp
}
meta.On("GetUpdatedTimestamp").Return(updatedTime, tt.updateTimestampErr)
if tt.setupMocks != nil {
tt.setupMocks(meta)
}
signer := NewGrafanaSigner()
ctx := context.Background()
signedCtx, err := signer.Sign(ctx, meta)
require.NoError(t, err)
// Verify the signature in the context
sig := repository.GetAuthorSignature(signedCtx)
require.NotNil(t, sig, "signature should be present in context")
require.Equal(t, "grafana", sig.Name)
require.Equal(t, tt.expectedTime, sig.When)
meta.AssertExpectations(t)
})
}
}
func ptr[T any](v T) *T {
return &v
}
@@ -1,52 +0,0 @@
package signature
import (
"context"
"fmt"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
)
//go:generate mockery --name Signer --structname MockSigner --inpackage --filename signer_mock.go --with-expecter
type Signer interface {
Sign(ctx context.Context, item utils.GrafanaMetaAccessor) (context.Context, error)
}
type SignOptions struct {
Namespace string
History bool
}
// SignerFactory is a factory for creating Signers
//
//go:generate mockery --name SignerFactory --structname MockSignerFactory --inpackage --filename signature_factory_mock.go --with-expecter
type SignerFactory interface {
New(ctx context.Context, opts SignOptions) (Signer, error)
}
type signerFactory struct {
clients resources.ClientFactory
}
func NewSignerFactory(clients resources.ClientFactory) SignerFactory {
return &signerFactory{clients}
}
func (f *signerFactory) New(ctx context.Context, opts SignOptions) (Signer, error) {
if !opts.History {
return NewGrafanaSigner(), nil
}
clients, err := f.clients.Clients(ctx, opts.Namespace)
if err != nil {
return nil, fmt.Errorf("get clients: %w", err)
}
userClient, err := clients.User(ctx)
if err != nil {
return nil, fmt.Errorf("get user client: %w", err)
}
return NewLoadUsersOnceSigner(userClient), nil
}
@@ -1,95 +0,0 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
package signature
import (
context "context"
mock "github.com/stretchr/testify/mock"
)
// MockSignerFactory is an autogenerated mock type for the SignerFactory type
type MockSignerFactory struct {
mock.Mock
}
type MockSignerFactory_Expecter struct {
mock *mock.Mock
}
func (_m *MockSignerFactory) EXPECT() *MockSignerFactory_Expecter {
return &MockSignerFactory_Expecter{mock: &_m.Mock}
}
// New provides a mock function with given fields: ctx, opts
func (_m *MockSignerFactory) New(ctx context.Context, opts SignOptions) (Signer, error) {
ret := _m.Called(ctx, opts)
if len(ret) == 0 {
panic("no return value specified for New")
}
var r0 Signer
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, SignOptions) (Signer, error)); ok {
return rf(ctx, opts)
}
if rf, ok := ret.Get(0).(func(context.Context, SignOptions) Signer); ok {
r0 = rf(ctx, opts)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(Signer)
}
}
if rf, ok := ret.Get(1).(func(context.Context, SignOptions) error); ok {
r1 = rf(ctx, opts)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockSignerFactory_New_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'New'
type MockSignerFactory_New_Call struct {
*mock.Call
}
// New is a helper method to define mock.On call
// - ctx context.Context
// - opts SignOptions
func (_e *MockSignerFactory_Expecter) New(ctx interface{}, opts interface{}) *MockSignerFactory_New_Call {
return &MockSignerFactory_New_Call{Call: _e.mock.On("New", ctx, opts)}
}
func (_c *MockSignerFactory_New_Call) Run(run func(ctx context.Context, opts SignOptions)) *MockSignerFactory_New_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(SignOptions))
})
return _c
}
func (_c *MockSignerFactory_New_Call) Return(_a0 Signer, _a1 error) *MockSignerFactory_New_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockSignerFactory_New_Call) RunAndReturn(run func(context.Context, SignOptions) (Signer, error)) *MockSignerFactory_New_Call {
_c.Call.Return(run)
return _c
}
// NewMockSignerFactory creates a new instance of MockSignerFactory. 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 NewMockSignerFactory(t interface {
mock.TestingT
Cleanup(func())
}) *MockSignerFactory {
mock := &MockSignerFactory{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -1,92 +0,0 @@
package signature
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
)
func TestSignerFactory_New(t *testing.T) {
tests := []struct {
name string
opts SignOptions
setupMocks func(t *testing.T, clients *resources.MockClientFactory)
expectedType interface{}
expectedError string
}{
{
name: "should return grafana signer when history is false",
opts: SignOptions{
History: false,
},
setupMocks: func(t *testing.T, clients *resources.MockClientFactory) {
// No mocks needed as we shouldn't call any clients
},
expectedType: &grafanaSigner{},
},
{
name: "should return load users once signer when history is true",
opts: SignOptions{
History: true,
Namespace: "test-ns",
},
setupMocks: func(t *testing.T, clients *resources.MockClientFactory) {
mockResourceClients := resources.NewMockResourceClients(t)
clients.On("Clients", context.Background(), "test-ns").Return(mockResourceClients, nil)
mockResourceClients.On("User", mock.Anything).Return(nil, nil)
},
expectedType: &loadUsersOnceSigner{},
},
{
name: "should return error when clients factory fails",
opts: SignOptions{
History: true,
Namespace: "test-ns",
},
setupMocks: func(t *testing.T, clients *resources.MockClientFactory) {
clients.On("Clients", context.Background(), "test-ns").Return(nil, fmt.Errorf("clients error"))
},
expectedError: "get clients: clients error",
},
{
name: "should return error when user client fails",
opts: SignOptions{
History: true,
Namespace: "test-ns",
},
setupMocks: func(t *testing.T, clients *resources.MockClientFactory) {
mockResourceClients := resources.NewMockResourceClients(t)
clients.On("Clients", context.Background(), "test-ns").Return(mockResourceClients, nil)
mockResourceClients.On("User", mock.Anything).Return(nil, fmt.Errorf("user client error"))
},
expectedError: "get user client: user client error",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClients := resources.NewMockClientFactory(t)
tt.setupMocks(t, mockClients)
factory := NewSignerFactory(mockClients)
signer, err := factory.New(context.Background(), tt.opts)
if tt.expectedError != "" {
require.Error(t, err)
require.EqualError(t, err, tt.expectedError)
require.Nil(t, signer)
} else {
require.NoError(t, err)
require.NotNil(t, signer)
require.IsType(t, tt.expectedType, signer, "signer should be of expected type")
}
mockClients.AssertExpectations(t)
})
}
}
@@ -1,96 +0,0 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
package signature
import (
context "context"
utils "github.com/grafana/grafana/pkg/apimachinery/utils"
mock "github.com/stretchr/testify/mock"
)
// MockSigner is an autogenerated mock type for the Signer type
type MockSigner struct {
mock.Mock
}
type MockSigner_Expecter struct {
mock *mock.Mock
}
func (_m *MockSigner) EXPECT() *MockSigner_Expecter {
return &MockSigner_Expecter{mock: &_m.Mock}
}
// Sign provides a mock function with given fields: ctx, item
func (_m *MockSigner) Sign(ctx context.Context, item utils.GrafanaMetaAccessor) (context.Context, error) {
ret := _m.Called(ctx, item)
if len(ret) == 0 {
panic("no return value specified for Sign")
}
var r0 context.Context
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, utils.GrafanaMetaAccessor) (context.Context, error)); ok {
return rf(ctx, item)
}
if rf, ok := ret.Get(0).(func(context.Context, utils.GrafanaMetaAccessor) context.Context); ok {
r0 = rf(ctx, item)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(context.Context)
}
}
if rf, ok := ret.Get(1).(func(context.Context, utils.GrafanaMetaAccessor) error); ok {
r1 = rf(ctx, item)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockSigner_Sign_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Sign'
type MockSigner_Sign_Call struct {
*mock.Call
}
// Sign is a helper method to define mock.On call
// - ctx context.Context
// - item utils.GrafanaMetaAccessor
func (_e *MockSigner_Expecter) Sign(ctx interface{}, item interface{}) *MockSigner_Sign_Call {
return &MockSigner_Sign_Call{Call: _e.mock.On("Sign", ctx, item)}
}
func (_c *MockSigner_Sign_Call) Run(run func(ctx context.Context, item utils.GrafanaMetaAccessor)) *MockSigner_Sign_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(utils.GrafanaMetaAccessor))
})
return _c
}
func (_c *MockSigner_Sign_Call) Return(_a0 context.Context, _a1 error) *MockSigner_Sign_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockSigner_Sign_Call) RunAndReturn(run func(context.Context, utils.GrafanaMetaAccessor) (context.Context, error)) *MockSigner_Sign_Call {
_c.Call.Return(run)
return _c
}
// NewMockSigner creates a new instance of MockSigner. 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 NewMockSigner(t interface {
mock.TestingT
Cleanup(func())
}) *MockSigner {
mock := &MockSigner{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -1,115 +0,0 @@
package signature
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/client-go/dynamic"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
)
const maxUsers = 10000
type loadUsersOnceSigner struct {
signatures map[string]repository.CommitSignature
client dynamic.ResourceInterface
once sync.Once
onceErr error
}
// NewLoadUsersOnceSigner returns a Signer that loads the signatures from users
// it will only load the signatures once and cache them
// if the user is not found, it will use the grafana user as the author
func NewLoadUsersOnceSigner(client dynamic.ResourceInterface) Signer {
return &loadUsersOnceSigner{
client: client,
once: sync.Once{},
signatures: map[string]repository.CommitSignature{},
}
}
func (s *loadUsersOnceSigner) Sign(ctx context.Context, item utils.GrafanaMetaAccessor) (context.Context, error) {
if s.onceErr != nil {
return ctx, fmt.Errorf("load signatures: %w", s.onceErr)
}
var err error
s.once.Do(func() {
s.signatures, err = s.load(ctx, s.client)
s.onceErr = err
})
if err != nil {
return ctx, fmt.Errorf("load signatures: %w", err)
}
id := item.GetUpdatedBy()
if id == "" {
id = item.GetCreatedBy()
}
if id == "" {
id = "grafana"
}
sig := s.signatures[id] // lookup
if sig.Name == "" && sig.Email == "" {
sig.Name = id
}
t, err := item.GetUpdatedTimestamp()
if err == nil && t != nil {
sig.When = *t
} else {
sig.When = item.GetCreationTimestamp().Time
}
return repository.WithAuthorSignature(ctx, sig), nil
}
func (s *loadUsersOnceSigner) load(ctx context.Context, client dynamic.ResourceInterface) (map[string]repository.CommitSignature, error) {
userInfo := make(map[string]repository.CommitSignature)
var count int
err := resources.ForEach(ctx, client, func(item *unstructured.Unstructured) error {
count++
if count > maxUsers {
return errors.New("too many users")
}
sig := repository.CommitSignature{}
// FIXME: should we improve logging here?
var (
ok bool
err error
)
sig.Name, ok, err = unstructured.NestedString(item.Object, "spec", "login")
if !ok || err != nil {
return nil
}
sig.Email, ok, err = unstructured.NestedString(item.Object, "spec", "email")
if !ok || err != nil {
return nil
}
if sig.Name == sig.Email {
if sig.Name == "" {
sig.Name = item.GetName()
} else if strings.Contains(sig.Email, "@") {
sig.Email = "" // don't use the same value for name+email
}
}
userInfo["user:"+item.GetName()] = sig
return nil
})
if err != nil {
return nil, err
}
return userInfo, nil
}
@@ -1,357 +0,0 @@
package signature
import (
"context"
"errors"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/client-go/dynamic"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/grafana/grafana/pkg/apimachinery/utils"
)
// mockDynamicInterface implements a simplified version of the dynamic.ResourceInterface
type mockDynamicInterface struct {
dynamic.ResourceInterface
items []unstructured.Unstructured
err error
}
func (m *mockDynamicInterface) List(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) {
if m.err != nil {
return nil, m.err
}
return &unstructured.UnstructuredList{
Items: m.items,
}, nil
}
type mockGrafanaMetaAccessor struct {
utils.GrafanaMetaAccessor
createdBy string
updatedBy string
creationTimestamp time.Time
updatedTimestamp *time.Time
updatedTimestampErr error
}
func (m *mockGrafanaMetaAccessor) GetCreatedBy() string {
return m.createdBy
}
func (m *mockGrafanaMetaAccessor) GetUpdatedBy() string {
return m.updatedBy
}
func (m *mockGrafanaMetaAccessor) GetCreationTimestamp() metav1.Time {
return metav1.Time{Time: m.creationTimestamp}
}
func (m *mockGrafanaMetaAccessor) GetUpdatedTimestamp() (*time.Time, error) {
if m.updatedTimestampErr != nil {
return nil, m.updatedTimestampErr
}
return m.updatedTimestamp, nil
}
func TestLoadUsersOnceSigner_Sign(t *testing.T) {
baseTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
updateTime := time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC)
tests := []struct {
name string
items []unstructured.Unstructured
meta *mockGrafanaMetaAccessor
clientErr error
expectedSig repository.CommitSignature
expectedError string
}{
{
name: "should sign with user info when user exists",
items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "user1",
},
"spec": map[string]interface{}{
"login": "johndoe",
"email": "john@example.com",
},
},
},
},
meta: &mockGrafanaMetaAccessor{
updatedBy: "user:user1",
creationTimestamp: baseTime,
updatedTimestamp: &updateTime,
},
expectedSig: repository.CommitSignature{
Name: "johndoe",
Email: "john@example.com",
When: updateTime,
},
},
{
name: "should fallback to created by when updated by is empty",
items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "user1",
},
"spec": map[string]interface{}{
"login": "johndoe",
"email": "john@example.com",
},
},
},
},
meta: &mockGrafanaMetaAccessor{
createdBy: "user:user1",
creationTimestamp: baseTime,
},
expectedSig: repository.CommitSignature{
Name: "johndoe",
Email: "john@example.com",
When: baseTime,
},
},
{
name: "should use grafana when no user info available",
meta: &mockGrafanaMetaAccessor{
creationTimestamp: baseTime,
},
expectedSig: repository.CommitSignature{
Name: "grafana",
When: baseTime,
},
},
{
name: "should handle user with same login and email",
items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "user1",
},
"spec": map[string]interface{}{
"login": "john@example.com",
"email": "john@example.com",
},
},
},
},
meta: &mockGrafanaMetaAccessor{
updatedBy: "user:user1",
creationTimestamp: baseTime,
updatedTimestamp: &updateTime,
},
expectedSig: repository.CommitSignature{
Name: "john@example.com",
Email: "",
When: updateTime,
},
},
{
name: "should handle empty login and email",
items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "user1",
},
"spec": map[string]interface{}{
"login": "",
"email": "",
},
},
},
},
meta: &mockGrafanaMetaAccessor{
updatedBy: "user:user1",
creationTimestamp: baseTime,
updatedTimestamp: &updateTime,
},
expectedSig: repository.CommitSignature{
Name: "user1",
Email: "",
When: updateTime,
},
},
{
name: "should handle empty email",
items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "user1",
},
"spec": map[string]interface{}{
"login": "johndoe",
"email": "",
},
},
},
},
meta: &mockGrafanaMetaAccessor{
updatedBy: "user:user1",
creationTimestamp: baseTime,
updatedTimestamp: &updateTime,
},
expectedSig: repository.CommitSignature{
Name: "johndoe",
Email: "",
When: updateTime,
},
},
{
name: "should fail when too many users",
items: func() []unstructured.Unstructured {
items := make([]unstructured.Unstructured, maxUsers+1)
for i := 0; i < maxUsers+1; i++ {
items[i] = unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "user1",
},
"spec": map[string]interface{}{
"login": "johndoe",
"email": "john@example.com",
},
},
}
}
return items
}(),
meta: &mockGrafanaMetaAccessor{
updatedBy: "user:user1",
creationTimestamp: baseTime,
},
expectedError: "load signatures: too many users",
},
{
name: "should handle missing user fields gracefully",
items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "user1",
},
"spec": map[string]interface{}{
// missing login and email
},
},
},
},
meta: &mockGrafanaMetaAccessor{
updatedBy: "user:user1",
creationTimestamp: baseTime,
},
expectedSig: repository.CommitSignature{
Name: "user:user1",
When: baseTime,
},
},
{
name: "should use creation timestamp when update timestamp has error",
items: []unstructured.Unstructured{
{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "user1",
},
"spec": map[string]interface{}{
"login": "johndoe",
"email": "john@example.com",
},
},
},
},
meta: &mockGrafanaMetaAccessor{
updatedBy: "user:user1",
creationTimestamp: baseTime,
updatedTimestampErr: errors.New("update timestamp error"),
},
expectedSig: repository.CommitSignature{
Name: "johndoe",
Email: "john@example.com",
When: baseTime,
},
},
{
name: "should fail when listing users fails",
meta: &mockGrafanaMetaAccessor{
updatedBy: "user:user1",
creationTimestamp: baseTime,
},
clientErr: fmt.Errorf("failed to list users"),
expectedError: "load signatures: error executing list: failed to list users",
},
{
name: "should handle empty user list",
meta: &mockGrafanaMetaAccessor{
updatedBy: "user:user1",
creationTimestamp: baseTime,
},
items: []unstructured.Unstructured{},
expectedSig: repository.CommitSignature{
Name: "user:user1",
When: baseTime,
},
},
{
name: "should handle multiple calls with error",
meta: &mockGrafanaMetaAccessor{
updatedBy: "user:user1",
creationTimestamp: baseTime,
},
clientErr: fmt.Errorf("failed to list users"),
expectedError: "load signatures: error executing list: failed to list users",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := &mockDynamicInterface{
items: tt.items,
err: tt.clientErr,
}
signer := NewLoadUsersOnceSigner(client)
ctx := context.Background()
signedCtx, err := signer.Sign(ctx, tt.meta)
if tt.expectedError != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.expectedError)
// Test that subsequent calls also fail with the same error
_, err2 := signer.Sign(ctx, tt.meta)
require.Error(t, err2)
require.Contains(t, err2.Error(), tt.expectedError)
return
}
require.NoError(t, err)
sig := repository.GetAuthorSignature(signedCtx)
require.NotNil(t, sig)
require.Equal(t, tt.expectedSig.Name, sig.Name)
require.Equal(t, tt.expectedSig.Email, sig.Email)
require.Equal(t, tt.expectedSig.When, sig.When)
// Test that subsequent calls use cached data
signedCtx2, err := signer.Sign(ctx, tt.meta)
require.NoError(t, err)
sig2 := repository.GetAuthorSignature(signedCtx2)
require.Equal(t, sig, sig2)
})
}
}
+2 -10
View File
@@ -14,7 +14,6 @@ import (
authlib "github.com/grafana/authlib/types"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"github.com/grafana/grafana/pkg/util/errhttp"
)
@@ -156,16 +155,9 @@ func (b *APIBuilder) handleSettings(w http.ResponseWriter, r *http.Request) {
return
}
legacyStorage := false
if b.storageStatus != nil {
legacyStorage = dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, b.storageStatus)
}
settings := provisioning.RepositoryViewList{
Items: make([]provisioning.RepositoryView, len(all)),
AllowedTargets: b.allowedTargets,
// FIXME: this shouldn't be here in provisioning but at the dual writer or something about the storage
LegacyStorage: legacyStorage,
Items: make([]provisioning.RepositoryView, len(all)),
AllowedTargets: b.allowedTargets,
AvailableRepositoryTypes: b.repoFactory.Types(),
AllowImageRendering: b.allowImageRendering,
}
@@ -20,6 +20,7 @@ import (
"github.com/grafana/grafana/pkg/registry/apps/alerting/notifications/timeinterval"
"github.com/grafana/grafana/pkg/services/apiserver/appinstaller"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ngalert"
ac "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
@@ -97,7 +98,12 @@ func (a AlertingNotificationsAppInstaller) GetLegacyStorage(gvr schema.GroupVers
} else if gvr == timeinterval.ResourceInfo.GroupVersionResource() {
return timeinterval.NewStorage(api.MuteTimings, namespacer)
} else if gvr == templategroup.ResourceInfo.GroupVersionResource() {
return templategroup.NewStorage(api.Templates, namespacer)
srv := api.Templates
//nolint:staticcheck // not yet migrated to OpenFeature
if a.ng.FeatureToggles.IsEnabledGlobally(featuremgmt.FlagAlertingImportAlertmanagerAPI) {
srv = srv.WithIncludeImported()
}
return templategroup.NewStorage(srv, namespacer)
} else if gvr == routingtree.ResourceInfo.GroupVersionResource() {
return routingtree.NewStorage(api.Policies, namespacer)
}
@@ -1,6 +1,7 @@
package templategroup
import (
"github.com/grafana/alerting/definition"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/types"
@@ -36,6 +37,7 @@ func convertToK8sResource(orgID int64, template definitions.NotificationTemplate
Spec: model.TemplateGroupSpec{
Title: template.Name,
Content: template.Template,
Kind: model.TemplateGroupTemplateKind(template.Kind),
},
}
result.SetProvenanceStatus(string(template.Provenance))
@@ -50,5 +52,6 @@ func convertToDomainModel(template *model.TemplateGroup) definitions.Notificatio
Template: template.Spec.Content,
ResourceVersion: template.ResourceVersion,
Provenance: definitions.Provenance(ngmodels.ProvenanceNone),
Kind: definition.TemplateKind(template.Spec.Kind),
}
}
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"github.com/grafana/alerting/definition"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -211,6 +212,7 @@ func (s *legacyStorage) defaultTemplate() (definitions.NotificationTemplate, err
UID: defaultTemplate.Name,
Provenance: definitions.Provenance("system"),
Template: defaultTemplate.Template,
Kind: definition.GrafanaTemplateKind,
}
return dto, nil
+20 -11
View File
@@ -2,16 +2,19 @@ package plugins
import (
"fmt"
"os"
authlib "github.com/grafana/authlib/types"
appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver"
pluginsapp "github.com/grafana/grafana/apps/plugins/pkg/app"
"github.com/grafana/grafana/apps/plugins/pkg/app/meta"
"github.com/grafana/grafana/pkg/configprovider"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/apiserver/appinstaller"
grafanaauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
)
var (
@@ -20,23 +23,26 @@ var (
)
type AppInstaller struct {
metaManager *meta.ProviderManager
cfgProvider configprovider.ConfigProvider
restConfigProvider apiserver.RestConfigProvider
*pluginsapp.PluginAppInstaller
}
func ProvideAppInstaller(accessControlService accesscontrol.Service, accessClient authlib.AccessClient) (*AppInstaller, error) {
func ProvideAppInstaller(
cfgProvider configprovider.ConfigProvider,
restConfigProvider apiserver.RestConfigProvider,
pluginStore pluginstore.Store,
pluginAssetsService *pluginassets.Service,
accessControlService accesscontrol.Service, accessClient authlib.AccessClient,
) (*AppInstaller, error) {
if err := registerAccessControlRoles(accessControlService); err != nil {
return nil, fmt.Errorf("registering access control roles: %w", err)
}
grafanaComAPIURL := os.Getenv("GRAFANA_COM_API_URL")
if grafanaComAPIURL == "" {
grafanaComAPIURL = "https://grafana.com/api/plugins"
}
coreProvider := meta.NewCoreProvider()
cloudProvider := meta.NewCatalogProvider(grafanaComAPIURL)
metaProviderManager := meta.NewProviderManager(coreProvider, cloudProvider)
localProvider := meta.NewLocalProvider(pluginStore, pluginAssetsService)
metaProviderManager := meta.NewProviderManager(localProvider)
authorizer := grafanaauthorizer.NewResourceAuthorizer(accessClient)
i, err := pluginsapp.ProvideAppInstaller(authorizer, metaProviderManager)
if err != nil {
@@ -44,6 +50,9 @@ func ProvideAppInstaller(accessControlService accesscontrol.Service, accessClien
}
return &AppInstaller{
metaManager: metaProviderManager,
cfgProvider: cfgProvider,
restConfigProvider: restConfigProvider,
PluginAppInstaller: i,
}, nil
}
+1 -1
View File
@@ -2,7 +2,6 @@ package appregistry
import (
"github.com/google/wire"
"github.com/grafana/grafana/pkg/registry/apps/quotas"
"github.com/grafana/grafana/pkg/registry/apps/alerting/historian"
"github.com/grafana/grafana/pkg/registry/apps/alerting/notifications"
@@ -14,6 +13,7 @@ import (
"github.com/grafana/grafana/pkg/registry/apps/logsdrilldown"
"github.com/grafana/grafana/pkg/registry/apps/playlist"
"github.com/grafana/grafana/pkg/registry/apps/plugins"
"github.com/grafana/grafana/pkg/registry/apps/quotas"
"github.com/grafana/grafana/pkg/registry/apps/shorturl"
)
+2 -2
View File
@@ -3,7 +3,7 @@ all: go markdown
.PHONY: go
go:
@docker run --rm -u 1000:1000 -v./model:/tmp/grafana/model -v./:/tmp/grafana/output -v./templates:/tmp/grafana/templates \
@docker run --rm --platform linux/amd64 -u 1000:1000 -v./model:/tmp/grafana/model -v./:/tmp/grafana/output -v./templates:/tmp/grafana/templates \
otel/semconvgen --yaml-root "/tmp/grafana/model/" \
code --template "/tmp/grafana/templates/template.j2" \
--output "/tmp/grafana/output/attributes.go"
@@ -11,7 +11,7 @@ go:
.PHONY: markdown
markdown:
@docker run --rm -u 1000:1000 -v./model:/tmp/grafana/model -v./:/tmp/grafana/output -v./templates:/tmp/grafana/templates \
@docker run --rm --platform linux/amd64 -u 1000:1000 -v./model:/tmp/grafana/model -v./:/tmp/grafana/output -v./templates:/tmp/grafana/templates \
otel/semconvgen --yaml-root "/tmp/grafana/model/" \
markdown --markdown-root "/tmp/grafana/output/"
@npx --yes -- markdown-toc --bullets "-" --no-first-h1 --no-stripHeadingTags -i README.md || exit 1
+1
View File
@@ -55,6 +55,7 @@ For more information:
| Attribute | Type | Description | Examples | [Requirement Level](https://opentelemetry.io/docs/specs/semconv/general/attribute-requirement-level/) | Stability |
|---|---|---|---|---|---|
| `grafana.plugin.id` | string | The plugin ID. | `prometheus`; `loki`; `grafana-github-datasource` | `Recommended` | ![Stable](https://img.shields.io/badge/-stable-lightgreen) |
| `grafana.plugin.source.class` | string | The plugin source class. | `core` | `Recommended` | ![Stable](https://img.shields.io/badge/-stable-lightgreen) |
| `grafana.plugin.type` | string | The plugin type. | `datasource` | `Recommended` | ![Stable](https://img.shields.io/badge/-stable-lightgreen) |
<!-- endsemconv -->
+21
View File
@@ -140,6 +140,27 @@ func GrafanaPluginId(val string) attribute.KeyValue {
return grafanaPluginIdKey.String(val)
}
// Describes Grafana plugin source attributes.
const (
// GrafanaPluginSourceClassKey is the attribute Key conforming to the
// "grafana.plugin.source.class" semantic conventions. It represents the
// plugin source class.
//
// Type: Enum
// RequirementLevel: Optional
// Stability: stable
grafanaPluginSourceClassKey = attribute.Key("grafana.plugin.source.class")
)
var (
// Core Plugin
GrafanaPluginSourceClassCore = grafanaPluginSourceClassKey.String("core")
// External Plugin
GrafanaPluginSourceClassExternal = grafanaPluginSourceClassKey.String("external")
// Unknown Plugin Source
GrafanaPluginSourceClassUnknown = grafanaPluginSourceClassKey.String("unknown")
)
// Describes Grafana service attributes.
const (
// GrafanaServiceNameKey is the attribute Key conforming to the
+23
View File
@@ -34,3 +34,26 @@ groups:
brief: The plugin type.
examples: datasource
stability: stable
- id: registry.grafana.plugin.source
type: attribute_group
display_name: Grafana Plugin Source Attributes
brief: "Describes Grafana plugin source attributes."
attributes:
- id: grafana.plugin.source.class
type:
members:
- id: core
value: "core"
brief: 'Core Plugin'
stability: stable
- id: external
value: "external"
brief: 'External Plugin'
stability: stable
- id: unknown
value: "unknown"
brief: 'Unknown Plugin Source'
stability: stable
brief: The plugin source class.
stability: stable
+2 -1
View File
@@ -5,4 +5,5 @@ groups:
stability: stable
attributes:
- ref: grafana.plugin.id
- ref: grafana.plugin.type
- ref: grafana.plugin.type
- ref: grafana.plugin.source.class
+19
View File
@@ -0,0 +1,19 @@
package semconv
import (
"fmt"
"go.opentelemetry.io/otel/attribute"
)
// PluginSourceClass converts a plugin source class to the corresponding semantic convention attribute.
func PluginSourceClass(class fmt.Stringer) attribute.KeyValue {
switch class.String() {
case "core":
return GrafanaPluginSourceClassCore
case "external":
return GrafanaPluginSourceClassExternal
default:
return GrafanaPluginSourceClassUnknown
}
}
+2 -2
View File
@@ -786,7 +786,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
if err != nil {
return nil, err
}
appInstaller, err := plugins.ProvideAppInstaller(acimplService, accessClient)
appInstaller, err := plugins.ProvideAppInstaller(configProvider, eventualRestConfigProvider, pluginstoreService, pluginassetsService, acimplService, accessClient)
if err != nil {
return nil, err
}
@@ -1447,7 +1447,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
if err != nil {
return nil, err
}
appInstaller, err := plugins.ProvideAppInstaller(acimplService, accessClient)
appInstaller, err := plugins.ProvideAppInstaller(configProvider, eventualRestConfigProvider, pluginstoreService, pluginassetsService, acimplService, accessClient)
if err != nil {
return nil, err
}
+7
View File
@@ -600,6 +600,13 @@ var (
FrontendOnly: true,
Owner: grafanaDashboardsSquad,
},
{
Name: "drilldownRecommendations",
Description: "Enables showing recently used drilldowns or recommendations given by the datasource in the AdHocFilters and GroupBy variables",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaDashboardsSquad,
},
{
Name: "perPanelNonApplicableDrilldowns",
Description: "Enables viewing non-applicable drilldowns on a panel level",
+1
View File
@@ -83,6 +83,7 @@ dashboardNewLayouts,experimental,@grafana/dashboards-squad,false,false,false
kubernetesDashboardsV2,experimental,@grafana/dashboards-squad,false,false,false
dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true
unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true
drilldownRecommendations,experimental,@grafana/dashboards-squad,false,false,true
perPanelNonApplicableDrilldowns,experimental,@grafana/dashboards-squad,false,false,true
panelGroupBy,experimental,@grafana/dashboards-squad,false,false,true
perPanelFiltering,experimental,@grafana/dashboards-squad,false,false,true
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
83 kubernetesDashboardsV2 experimental @grafana/dashboards-squad false false false
84 dashboardUndoRedo experimental @grafana/dashboards-squad false false true
85 unlimitedLayoutsNesting experimental @grafana/dashboards-squad false false true
86 drilldownRecommendations experimental @grafana/dashboards-squad false false true
87 perPanelNonApplicableDrilldowns experimental @grafana/dashboards-squad false false true
88 panelGroupBy experimental @grafana/dashboards-squad false false true
89 perPanelFiltering experimental @grafana/dashboards-squad false false true
+13
View File
@@ -1181,6 +1181,19 @@
"codeowner": "@grafana/grafana-datasources-core-services"
}
},
{
"metadata": {
"name": "drilldownRecommendations",
"resourceVersion": "1764855550769",
"creationTimestamp": "2025-12-04T13:39:10Z"
},
"spec": {
"description": "Enables showing recently used drilldowns or recommendations given by the datasource in the AdHocFilters and GroupBy variables",
"stage": "experimental",
"codeowner": "@grafana/dashboards-squad",
"frontend": true
}
},
{
"metadata": {
"name": "elasticsearchCrossClusterSearch",
@@ -439,7 +439,7 @@ func TestIntegrationFolderServiceViaUnifiedStorage(t *testing.T) {
ID: fooFolder.ID, // nolint:staticcheck
UID: fooFolder.UID,
},
}, nil).Once()
}, nil).Twice() // Called twice due to total count call
id := int64(123)
emptyString := ""
query := &folder.GetFolderQuery{
@@ -455,7 +455,7 @@ func TestIntegrationFolderServiceViaUnifiedStorage(t *testing.T) {
})
t.Run("When get folder by non existing ID should return not found error", func(t *testing.T) {
dashboardStore.On("FindDashboards", mock.Anything, mock.Anything).Return([]dashboards.DashboardSearchProjection{}, nil).Once()
dashboardStore.On("FindDashboards", mock.Anything, mock.Anything).Return([]dashboards.DashboardSearchProjection{}, nil).Twice() // Called twice due to total count call
id := int64(111111)
query := &folder.GetFolderQuery{
ID: &id,
@@ -475,7 +475,7 @@ func TestIntegrationFolderServiceViaUnifiedStorage(t *testing.T) {
ID: fooFolder.ID, // nolint:staticcheck
UID: fooFolder.UID,
},
}, nil).Once()
}, nil).Twice() // Called twice due to total count call
title := "foo"
query := &folder.GetFolderQuery{
Title: &title,
@@ -489,7 +489,7 @@ func TestIntegrationFolderServiceViaUnifiedStorage(t *testing.T) {
})
t.Run("When get folder by non existing Title should return not found error", func(t *testing.T) {
dashboardStore.On("FindDashboards", mock.Anything, mock.Anything).Return([]dashboards.DashboardSearchProjection{}, nil).Once()
dashboardStore.On("FindDashboards", mock.Anything, mock.Anything).Return([]dashboards.DashboardSearchProjection{}, nil).Twice() // Called twice due to total count call
title := "does not exists"
query := &folder.GetFolderQuery{
Title: &title,
@@ -5,7 +5,8 @@ import (
"regexp"
"strings"
"github.com/grafana/alerting/templates"
"github.com/grafana/alerting/definition"
"github.com/grafana/alerting/notify"
"go.yaml.in/yaml/v3"
)
@@ -31,11 +32,18 @@ func (t *NotificationTemplate) Validate() error {
content = fmt.Sprintf("{{ define \"%s\" }}\n%s\n{{ end }}", t.Name, content)
}
t.Template = content
def := templates.TemplateDefinition{
Name: t.Name,
Template: t.Template,
Kind: templates.GrafanaKind,
if t.Kind == "" {
t.Kind = definition.GrafanaTemplateKind
}
postable := definition.PostableApiTemplate{
Name: t.Name,
Content: t.Template,
Kind: t.Kind,
}
if err := postable.Validate(); err != nil {
return err
}
def := notify.PostableAPITemplateToTemplateDefinition(postable)
return def.Validate()
}
@@ -462,6 +462,16 @@ func TestValidateNotificationTemplates(t *testing.T) {
},
expContent: `{{ define "Alert Instance Template" }}\nFiring: {{ .Labels.alertname }}\nSilence: {{ .SilenceURL }}\n{{ end }}[what is this?]`,
},
{
name: "unknown template kind",
template: NotificationTemplate{
Name: "Alert Instance Template",
Template: `{{ define "Same name as definition" }}\nFiring: {{ .Labels.alertname }}\nSilence: {{ .SilenceURL }}\n{{ end }}`,
Provenance: "test",
Kind: "unknown",
},
expError: errors.New("unknown template kind: unknown"),
},
}
for _, tt := range tc {
@@ -1,5 +1,7 @@
package definitions
import "github.com/grafana/alerting/definition"
// swagger:route GET /v1/provisioning/templates provisioning stable RouteGetTemplates
//
// Get all notification template groups.
@@ -55,11 +57,12 @@ type RouteDeleteTemplateParam struct {
// swagger:model
type NotificationTemplate struct {
UID string `json:"-" yaml:"-"`
Name string `json:"name"`
Template string `json:"template"`
Provenance Provenance `json:"provenance,omitempty"`
ResourceVersion string `json:"version,omitempty"`
UID string `json:"-" yaml:"-"`
Name string `json:"name"`
Template string `json:"template"`
Provenance Provenance `json:"provenance,omitempty"`
ResourceVersion string `json:"version,omitempty"`
Kind definition.TemplateKind `json:"-" yaml:"-"`
}
// swagger:model
@@ -4,6 +4,7 @@ import (
"fmt"
"github.com/grafana/grafana/pkg/apimachinery/errutil"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
@@ -25,6 +26,10 @@ var (
ErrTemplateNotFound = errutil.NotFound("alerting.notifications.templates.notFound")
ErrTemplateInvalid = errutil.BadRequest("alerting.notifications.templates.invalidFormat").MustTemplate("Invalid format of the submitted template", errutil.WithPublic("Template is in invalid format. Correct the payload and try again."))
ErrTemplateExists = errutil.BadRequest("alerting.notifications.templates.nameExists", errutil.WithPublicMessage("Template file with this name already exists. Use a different name or update existing one."))
ErrTemplateOrigin = errutil.BadRequest("alerting.notifications.templates.originInvalid").MustTemplate(
"Template '{{ .Public.Name }}' cannot be {{ .Public.Action }}d because it belongs to an imported configuration.",
errutil.WithPublic("Template '{{ .Public.Name }}' cannot be {{ .Public.Action }}d because it belongs to an imported configuration. Finish the import of the configuration first."),
)
ErrContactPointReferenced = errutil.Conflict("alerting.notifications.contact-points.referenced", errutil.WithPublicMessage("Contact point is currently referenced by a notification policy."))
ErrContactPointUsedInRule = errutil.Conflict("alerting.notifications.contact-points.used-by-rule", errutil.WithPublicMessage("Contact point is currently used in the notification settings of one or many alert rules."))
@@ -129,3 +134,7 @@ func MakeErrContactPointUidExists(uid, name string) error {
},
})
}
func makeErrTemplateOrigin(t definitions.NotificationTemplate, action string) error {
return ErrTemplateOrigin.Build(errutil.TemplateData{Public: map[string]interface{}{"Action": action, "Name": t.Name}})
}
+153 -102
View File
@@ -10,6 +10,8 @@ import (
"sort"
"unsafe"
"github.com/grafana/alerting/definition"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
@@ -23,6 +25,7 @@ type TemplateService struct {
xact TransactionManager
log log.Logger
validator validation.ProvenanceStatusTransitionValidator
includeImported bool
}
func NewTemplateService(config alertmanagerConfigStore, prov ProvisioningStore, xact TransactionManager, log log.Logger) *TemplateService {
@@ -32,6 +35,18 @@ func NewTemplateService(config alertmanagerConfigStore, prov ProvisioningStore,
xact: xact,
validator: validation.ValidateProvenanceRelaxed,
log: log,
includeImported: false,
}
}
func (t *TemplateService) WithIncludeImported() *TemplateService {
return &TemplateService{
configStore: t.configStore,
provenanceStore: t.provenanceStore,
xact: t.xact,
validator: t.validator,
log: t.log,
includeImported: true,
}
}
@@ -41,35 +56,38 @@ func (t *TemplateService) GetTemplates(ctx context.Context, orgID int64) ([]defi
return nil, err
}
if len(revision.Config.TemplateFiles) == 0 {
return nil, nil
}
var templates []definitions.NotificationTemplate
provenances, err := t.provenanceStore.GetProvenances(ctx, orgID, (&definitions.NotificationTemplate{}).ResourceType())
if err != nil {
return nil, err
}
templates := make([]definitions.NotificationTemplate, 0, len(revision.Config.TemplateFiles))
names := slices.Collect(maps.Keys(revision.Config.TemplateFiles))
sort.Strings(names)
for _, name := range names {
content := revision.Config.TemplateFiles[name]
tmpl := definitions.NotificationTemplate{
UID: legacy_storage.NameToUid(name),
Name: name,
Template: content,
ResourceVersion: calculateTemplateFingerprint(content),
if len(revision.Config.TemplateFiles) > 0 {
provenances, err := t.provenanceStore.GetProvenances(ctx, orgID, (&definitions.NotificationTemplate{}).ResourceType())
if err != nil {
return nil, err
}
provenance, ok := provenances[tmpl.ResourceID()]
if !ok {
provenance = models.ProvenanceNone
templates = make([]definitions.NotificationTemplate, 0, len(revision.Config.TemplateFiles))
names := slices.Collect(maps.Keys(revision.Config.TemplateFiles))
sort.Strings(names)
for _, name := range names {
content := revision.Config.TemplateFiles[name]
provenance, ok := provenances[(&definitions.NotificationTemplate{Name: name}).ResourceID()]
if !ok {
provenance = models.ProvenanceNone
}
templates = append(templates, newNotificationTemplate(name, content, provenance, definition.GrafanaTemplateKind))
}
tmpl.Provenance = definitions.Provenance(provenance)
templates = append(templates, tmpl)
}
return templates, nil
var importedTemplates []definitions.NotificationTemplate
if t.includeImported && len(revision.Config.ExtraConfigs) > 0 && len(revision.Config.ExtraConfigs[0].TemplateFiles) > 0 {
imported := revision.Config.ExtraConfigs[0].TemplateFiles
importedTemplates = make([]definitions.NotificationTemplate, 0, len(imported))
names := slices.Collect(maps.Keys(imported))
sort.Strings(names)
for _, name := range names {
content := imported[name]
templates = append(templates, newNotificationTemplate(name, content, models.ProvenanceConvertedPrometheus, definition.MimirTemplateKind))
}
}
return append(templates, importedTemplates...), nil
}
func (t *TemplateService) GetTemplate(ctx context.Context, orgID int64, nameOrUid string) (definitions.NotificationTemplate, error) {
@@ -77,29 +95,21 @@ func (t *TemplateService) GetTemplate(ctx context.Context, orgID int64, nameOrUi
if err != nil {
return definitions.NotificationTemplate{}, err
}
existingName := nameOrUid
existingContent, ok := revision.Config.TemplateFiles[nameOrUid]
if !ok {
existingName, existingContent, ok = getTemplateByUid(revision.Config.TemplateFiles, nameOrUid)
}
if !ok {
return definitions.NotificationTemplate{}, ErrTemplateNotFound.Errorf("")
}
tmpl := definitions.NotificationTemplate{
UID: legacy_storage.NameToUid(existingName),
Name: existingName,
Template: existingContent,
ResourceVersion: calculateTemplateFingerprint(existingContent),
}
provenance, err := t.provenanceStore.GetProvenance(ctx, &tmpl, orgID)
result, found, err := t.getTemplateByName(ctx, revision, orgID, nameOrUid)
if err != nil {
return definitions.NotificationTemplate{}, err
}
tmpl.Provenance = definitions.Provenance(provenance)
return tmpl, nil
if found {
return result, nil
}
result, found, err = t.getTemplateByUID(ctx, revision, orgID, nameOrUid)
if err != nil {
return definitions.NotificationTemplate{}, err
}
if found {
return result, nil
}
return definitions.NotificationTemplate{}, ErrTemplateNotFound.Errorf("")
}
func (t *TemplateService) UpsertTemplate(ctx context.Context, orgID int64, tmpl definitions.NotificationTemplate) (definitions.NotificationTemplate, error) {
@@ -135,6 +145,10 @@ func (t *TemplateService) CreateTemplate(ctx context.Context, orgID int64, tmpl
if err != nil {
return definitions.NotificationTemplate{}, MakeErrTemplateInvalid(err)
}
if tmpl.Kind == definition.MimirTemplateKind {
return definitions.NotificationTemplate{}, MakeErrTemplateInvalid(errors.New("templates of kind 'Mimir' cannot be created"))
}
revision, err := t.configStore.Get(ctx, orgID)
if err != nil {
return definitions.NotificationTemplate{}, err
@@ -143,6 +157,10 @@ func (t *TemplateService) CreateTemplate(ctx context.Context, orgID int64, tmpl
}
func (t *TemplateService) createTemplate(ctx context.Context, revision *legacy_storage.ConfigRevision, orgID int64, tmpl definitions.NotificationTemplate) (definitions.NotificationTemplate, error) {
if tmpl.Kind == definition.MimirTemplateKind {
return definitions.NotificationTemplate{}, MakeErrTemplateInvalid(errors.New("templates of kind 'Mimir' cannot be created"))
}
if revision.Config.TemplateFiles == nil {
revision.Config.TemplateFiles = map[string]string{}
}
@@ -164,13 +182,7 @@ func (t *TemplateService) createTemplate(ctx context.Context, revision *legacy_s
return definitions.NotificationTemplate{}, err
}
return definitions.NotificationTemplate{
UID: legacy_storage.NameToUid(tmpl.Name),
Name: tmpl.Name,
Template: tmpl.Template,
Provenance: tmpl.Provenance,
ResourceVersion: calculateTemplateFingerprint(tmpl.Template),
}, nil
return newNotificationTemplate(tmpl.Name, tmpl.Template, models.Provenance(tmpl.Provenance), tmpl.Kind), nil
}
func (t *TemplateService) UpdateTemplate(ctx context.Context, orgID int64, tmpl definitions.NotificationTemplate) (definitions.NotificationTemplate, error) {
@@ -192,37 +204,39 @@ func (t *TemplateService) updateTemplate(ctx context.Context, revision *legacy_s
}
var found bool
var existingName, existingContent string
var err error
var existing definitions.NotificationTemplate
// if UID is specified, look by UID.
if tmpl.UID != "" {
existingName, existingContent, found = getTemplateByUid(revision.Config.TemplateFiles, tmpl.UID)
// do not fall back to name because we address by UID, and resource can be deleted\renamed
existing, found, err = t.getTemplateByUID(ctx, revision, orgID, tmpl.UID)
} else {
existingName = tmpl.Name
existingContent, found = revision.Config.TemplateFiles[existingName]
existing, found, err = t.getTemplateByName(ctx, revision, orgID, tmpl.Name)
}
if err != nil {
return definitions.NotificationTemplate{}, err
}
if !found {
return definitions.NotificationTemplate{}, ErrTemplateNotFound.Errorf("")
}
if existingName != tmpl.Name { // if template is renamed, check if this name is already taken
if existing.Name != tmpl.Name { // if template is renamed, check if this name is already taken
_, ok := revision.Config.TemplateFiles[tmpl.Name]
if ok {
// return error if template is being renamed to one that already exists
return definitions.NotificationTemplate{}, ErrTemplateExists.Errorf("")
}
}
// check that provenance is not changed in an invalid way
storedProvenance, err := t.provenanceStore.GetProvenance(ctx, &tmpl, orgID)
if err != nil {
return definitions.NotificationTemplate{}, err
if existing.Kind != tmpl.Kind {
return definitions.NotificationTemplate{}, MakeErrTemplateInvalid(errors.New("cannot change template kind"))
}
if err := t.validator(storedProvenance, models.Provenance(tmpl.Provenance)); err != nil {
if existing.Provenance == definitions.Provenance(models.ProvenanceConvertedPrometheus) {
return definitions.NotificationTemplate{}, makeErrTemplateOrigin(existing, "update")
}
if err := t.validator(models.Provenance(existing.Provenance), models.Provenance(tmpl.Provenance)); err != nil {
return definitions.NotificationTemplate{}, err
}
err = t.checkOptimisticConcurrency(tmpl.Name, existingContent, models.Provenance(tmpl.Provenance), tmpl.ResourceVersion, "update")
err = t.checkOptimisticConcurrency(existing.Name, existing.Template, models.Provenance(tmpl.Provenance), tmpl.ResourceVersion, "update")
if err != nil {
return definitions.NotificationTemplate{}, err
}
@@ -230,9 +244,9 @@ func (t *TemplateService) updateTemplate(ctx context.Context, revision *legacy_s
revision.Config.TemplateFiles[tmpl.Name] = tmpl.Template
err = t.xact.InTransaction(ctx, func(ctx context.Context) error {
if existingName != tmpl.Name { // if template by was found by UID and it's name is different, then this is the rename operation. Delete old resources.
delete(revision.Config.TemplateFiles, existingName)
err := t.provenanceStore.DeleteProvenance(ctx, &definitions.NotificationTemplate{Name: existingName}, orgID)
if existing.Name != tmpl.Name { // if template by was found by UID and it's name is different, then this is the rename operation. Delete old resources.
delete(revision.Config.TemplateFiles, existing.Name)
err := t.provenanceStore.DeleteProvenance(ctx, &existing, orgID)
if err != nil {
return err
}
@@ -247,13 +261,8 @@ func (t *TemplateService) updateTemplate(ctx context.Context, revision *legacy_s
return definitions.NotificationTemplate{}, err
}
return definitions.NotificationTemplate{
UID: legacy_storage.NameToUid(tmpl.Name), // if name was changed, this UID will not match the incoming one
Name: tmpl.Name,
Template: tmpl.Template,
Provenance: tmpl.Provenance,
ResourceVersion: calculateTemplateFingerprint(tmpl.Template),
}, nil
// if name was changed, this UID needs to be recalculated
return newNotificationTemplate(tmpl.Name, tmpl.Template, models.Provenance(tmpl.Provenance), tmpl.Kind), nil
}
func (t *TemplateService) DeleteTemplate(ctx context.Context, orgID int64, nameOrUid string, provenance definitions.Provenance, version string) error {
@@ -261,44 +270,39 @@ func (t *TemplateService) DeleteTemplate(ctx context.Context, orgID int64, nameO
if err != nil {
return err
}
if revision.Config.TemplateFiles == nil {
existing, found, err := t.getTemplateByName(ctx, revision, orgID, nameOrUid)
if err != nil {
return err
}
if !found {
existing, found, err = t.getTemplateByUID(ctx, revision, orgID, nameOrUid)
}
if err != nil {
return err
}
if !found {
return nil
}
existingName := nameOrUid
existing, ok := revision.Config.TemplateFiles[nameOrUid]
if !ok {
existingName, existing, ok = getTemplateByUid(revision.Config.TemplateFiles, nameOrUid)
}
if !ok {
return nil
if existing.Provenance == definitions.Provenance(models.ProvenanceConvertedPrometheus) {
return makeErrTemplateOrigin(existing, "delete")
}
err = t.checkOptimisticConcurrency(existingName, existing, models.Provenance(provenance), version, "delete")
err = t.checkOptimisticConcurrency(existing.Name, existing.Template, models.Provenance(provenance), version, "delete")
if err != nil {
return err
}
// check that provenance is not changed in an invalid way
storedProvenance, err := t.provenanceStore.GetProvenance(ctx, &definitions.NotificationTemplate{Name: existingName}, orgID)
if err != nil {
return err
}
if err = t.validator(storedProvenance, models.Provenance(provenance)); err != nil {
if err = t.validator(models.Provenance(existing.Provenance), models.Provenance(provenance)); err != nil {
return err
}
delete(revision.Config.TemplateFiles, existingName)
delete(revision.Config.TemplateFiles, existing.Name)
return t.xact.InTransaction(ctx, func(ctx context.Context) error {
if err := t.configStore.Save(ctx, revision, orgID); err != nil {
return err
}
tgt := definitions.NotificationTemplate{
Name: existingName,
}
return t.provenanceStore.DeleteProvenance(ctx, &tgt, orgID)
return t.provenanceStore.DeleteProvenance(ctx, &existing, orgID)
})
}
@@ -323,11 +327,58 @@ func calculateTemplateFingerprint(t string) string {
return fmt.Sprintf("%016x", sum.Sum64())
}
func getTemplateByUid(templates map[string]string, uid string) (string, string, bool) {
for n, tmpl := range templates {
if legacy_storage.NameToUid(n) == uid {
return n, tmpl, true
}
func newNotificationTemplate(name, content string, provenance models.Provenance, kind definition.TemplateKind) definitions.NotificationTemplate {
tmpl := definitions.NotificationTemplate{
UID: templateUID(kind, name),
Name: name,
Template: content,
Provenance: definitions.Provenance(provenance),
Kind: kind,
}
return "", "", false
tmpl.ResourceVersion = calculateTemplateFingerprint(content)
return tmpl
}
func (t *TemplateService) getTemplateByName(ctx context.Context, revision *legacy_storage.ConfigRevision, orgID int64, name string) (definitions.NotificationTemplate, bool, error) {
existingContent, ok := revision.Config.TemplateFiles[name]
if !ok {
return definitions.NotificationTemplate{}, false, nil
}
provenance, err := t.provenanceStore.GetProvenance(ctx, &definitions.NotificationTemplate{Name: name}, orgID)
if err != nil {
return definitions.NotificationTemplate{}, false, err
}
return newNotificationTemplate(name, existingContent, provenance, definition.GrafanaTemplateKind), true, nil
}
func (t *TemplateService) getTemplateByUID(ctx context.Context, revision *legacy_storage.ConfigRevision, orgID int64, uid string) (definitions.NotificationTemplate, bool, error) {
find := func(templates map[string]string, uid string, kind definition.TemplateKind) (string, string, bool) {
for n, tmpl := range templates {
if templateUID(kind, n) == uid {
return n, tmpl, true
}
}
return "", "", false
}
var provenance models.Provenance
name, content, ok := find(revision.Config.TemplateFiles, uid, definition.GrafanaTemplateKind)
if !ok {
if t.includeImported && len(revision.Config.ExtraConfigs) > 0 {
name, content, ok = find(revision.Config.ExtraConfigs[0].TemplateFiles, uid, definition.MimirTemplateKind)
if ok {
return newNotificationTemplate(name, content, models.ProvenanceConvertedPrometheus, definition.MimirTemplateKind), true, nil
}
}
return definitions.NotificationTemplate{}, false, nil
}
var err error
provenance, err = t.provenanceStore.GetProvenance(ctx, &definitions.NotificationTemplate{Name: name}, orgID)
if err != nil {
return definitions.NotificationTemplate{}, false, err
}
return newNotificationTemplate(name, content, provenance, definition.GrafanaTemplateKind), true, nil
}
func templateUID(kind definition.TemplateKind, name string) string {
return legacy_storage.NameToUid(fmt.Sprintf("%s|%s", string(kind), name))
}
@@ -6,6 +6,7 @@ import (
"fmt"
"testing"
"github.com/grafana/alerting/definition"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
@@ -27,6 +28,15 @@ func TestGetTemplates(t *testing.T) {
"template2": "test2",
"template3": "test3",
},
ExtraConfigs: []definitions.ExtraConfiguration{
{
Identifier: "1234",
TemplateFiles: map[string]string{
"template1": "imported-test1",
"template4": "imported-test4",
},
},
},
},
}
@@ -45,27 +55,24 @@ func TestGetTemplates(t *testing.T) {
require.NoError(t, err)
expected := []definitions.NotificationTemplate{
{
UID: legacy_storage.NameToUid("template1"),
Name: "template1",
Template: "test1",
Provenance: definitions.Provenance(models.ProvenanceAPI),
ResourceVersion: calculateTemplateFingerprint("test1"),
},
{
UID: legacy_storage.NameToUid("template2"),
Name: "template2",
Template: "test2",
Provenance: definitions.Provenance(models.ProvenanceFile),
ResourceVersion: calculateTemplateFingerprint("test2"),
},
{
UID: legacy_storage.NameToUid("template3"),
Name: "template3",
Template: "test3",
Provenance: definitions.Provenance(models.ProvenanceNone),
ResourceVersion: calculateTemplateFingerprint("test3"),
},
newNotificationTemplate(
"template1",
"test1",
models.ProvenanceAPI,
definition.GrafanaTemplateKind,
),
newNotificationTemplate(
"template2",
"test2",
models.ProvenanceFile,
definition.GrafanaTemplateKind,
),
newNotificationTemplate(
"template3",
"test3",
models.ProvenanceNone,
definition.GrafanaTemplateKind,
),
}
require.EqualValues(t, expected, result)
@@ -89,6 +96,60 @@ func TestGetTemplates(t *testing.T) {
prov.AssertExpectations(t)
})
t.Run("returns imported templates if enabled", func(t *testing.T) {
sut, store, prov := createTemplateServiceSut()
sut = sut.WithIncludeImported()
store.GetFn = func(ctx context.Context, org int64) (*legacy_storage.ConfigRevision, error) {
assert.Equal(t, orgID, org)
return revision, nil
}
prov.EXPECT().GetProvenances(mock.Anything, mock.Anything, mock.Anything).Return(map[string]models.Provenance{
"template1": models.ProvenanceAPI,
"template2": models.ProvenanceFile,
}, nil)
result, err := sut.GetTemplates(context.Background(), orgID)
require.NoError(t, err)
expected := []definitions.NotificationTemplate{
newNotificationTemplate(
"template1",
"test1",
models.ProvenanceAPI,
definition.GrafanaTemplateKind,
),
newNotificationTemplate(
"template2",
"test2",
models.ProvenanceFile,
definition.GrafanaTemplateKind,
),
newNotificationTemplate(
"template3",
"test3",
models.ProvenanceNone,
definition.GrafanaTemplateKind,
),
newNotificationTemplate(
"template1",
"imported-test1",
models.ProvenanceConvertedPrometheus,
definition.MimirTemplateKind,
),
newNotificationTemplate(
"template4",
"imported-test4",
models.ProvenanceConvertedPrometheus,
definition.MimirTemplateKind,
),
}
require.EqualValues(t, expected, result)
prov.AssertCalled(t, "GetProvenances", mock.Anything, orgID, (&definitions.NotificationTemplate{}).ResourceType())
prov.AssertExpectations(t)
})
t.Run("propagates errors", func(t *testing.T) {
t.Run("when unable to read config", func(t *testing.T) {
sut, store, prov := createTemplateServiceSut()
@@ -127,15 +188,25 @@ func TestGetTemplate(t *testing.T) {
orgID := int64(1)
templateName := "template1"
templateContent := "test1"
importedTemplateName := "template2"
importedTemplateContent := "imported"
revision := &legacy_storage.ConfigRevision{
Config: &definitions.PostableUserConfig{
TemplateFiles: map[string]string{
templateName: templateContent,
},
ExtraConfigs: []definitions.ExtraConfiguration{
{
Identifier: "1234",
TemplateFiles: map[string]string{
importedTemplateName: importedTemplateContent,
},
},
},
},
}
t.Run("return a template from config file by name", func(t *testing.T) {
t.Run("return a template from config by name", func(t *testing.T) {
sut, store, prov := createTemplateServiceSut()
store.GetFn = func(ctx context.Context, org int64) (*legacy_storage.ConfigRevision, error) {
assert.Equal(t, orgID, org)
@@ -146,13 +217,12 @@ func TestGetTemplate(t *testing.T) {
result, err := sut.GetTemplate(context.Background(), orgID, templateName)
require.NoError(t, err)
expected := definitions.NotificationTemplate{
UID: legacy_storage.NameToUid(templateName),
Name: templateName,
Template: templateContent,
Provenance: definitions.Provenance(models.ProvenanceAPI),
ResourceVersion: calculateTemplateFingerprint(templateContent),
}
expected := newNotificationTemplate(
templateName,
templateContent,
models.ProvenanceAPI,
definition.GrafanaTemplateKind,
)
require.Equal(t, expected, result)
@@ -162,6 +232,62 @@ func TestGetTemplate(t *testing.T) {
prov.AssertExpectations(t)
})
t.Run("imported templates cannot be retrieved by name", func(t *testing.T) {
sut, store, _ := createTemplateServiceSut()
store.GetFn = func(ctx context.Context, org int64) (*legacy_storage.ConfigRevision, error) {
assert.Equal(t, orgID, org)
return revision, nil
}
_, err := sut.GetTemplate(context.Background(), orgID, importedTemplateName)
require.ErrorIs(t, err, ErrTemplateNotFound)
})
t.Run("return a template from config by UID", func(t *testing.T) {
sut, store, prov := createTemplateServiceSut()
store.GetFn = func(ctx context.Context, org int64) (*legacy_storage.ConfigRevision, error) {
assert.Equal(t, orgID, org)
return revision, nil
}
prov.EXPECT().GetProvenance(mock.Anything, mock.Anything, mock.Anything).Return(models.ProvenanceNone, nil)
result, err := sut.GetTemplate(context.Background(), orgID, templateUID(definition.GrafanaTemplateKind, templateName))
require.NoError(t, err)
expected := newNotificationTemplate(
templateName,
templateContent,
models.ProvenanceNone,
definition.GrafanaTemplateKind,
)
require.Equal(t, expected, result)
})
t.Run("return an imported template from config by UID", func(t *testing.T) {
sut, store, prov := createTemplateServiceSut()
store.GetFn = func(ctx context.Context, org int64) (*legacy_storage.ConfigRevision, error) {
assert.Equal(t, orgID, org)
return revision, nil
}
uid := templateUID(definition.MimirTemplateKind, importedTemplateName)
t.Run("should be not found without flag enabled", func(t *testing.T) {
_, err := sut.GetTemplate(context.Background(), orgID, uid)
require.ErrorIs(t, err, ErrTemplateNotFound)
})
result, err := sut.WithIncludeImported().GetTemplate(context.Background(), orgID, uid)
require.NoError(t, err)
expected := newNotificationTemplate(
importedTemplateName,
importedTemplateContent,
models.ProvenanceConvertedPrometheus,
definition.MimirTemplateKind,
)
require.Equal(t, expected, result)
prov.AssertExpectations(t)
})
t.Run("returns ErrTemplateNotFound when template does not exist", func(t *testing.T) {
sut, store, prov := createTemplateServiceSut()
store.GetFn = func(ctx context.Context, org int64) (*legacy_storage.ConfigRevision, error) {
@@ -242,18 +368,18 @@ func TestUpsertTemplate(t *testing.T) {
Template: "{{ define \"test\"}} test {{ end }}",
Provenance: definitions.Provenance(models.ProvenanceAPI),
ResourceVersion: "",
Kind: definition.GrafanaTemplateKind,
}
result, err := sut.UpsertTemplate(context.Background(), orgID, tmpl)
require.NoError(t, err)
require.Equal(t, definitions.NotificationTemplate{
UID: legacy_storage.NameToUid(tmpl.Name),
Name: tmpl.Name,
Template: tmpl.Template,
Provenance: tmpl.Provenance,
ResourceVersion: calculateTemplateFingerprint(tmpl.Template),
}, result)
require.Equal(t, newNotificationTemplate(
tmpl.Name,
tmpl.Template,
models.Provenance(tmpl.Provenance),
tmpl.Kind,
), result)
require.Len(t, store.Calls, 2)
@@ -284,18 +410,18 @@ func TestUpsertTemplate(t *testing.T) {
Template: "{{ define \"test\"}} test {{ end }}",
Provenance: definitions.Provenance(models.ProvenanceAPI),
ResourceVersion: calculateTemplateFingerprint("test1"),
Kind: definition.GrafanaTemplateKind,
}
result, err := sut.UpsertTemplate(context.Background(), orgID, tmpl)
require.NoError(t, err)
assert.Equal(t, definitions.NotificationTemplate{
UID: legacy_storage.NameToUid(tmpl.Name),
Name: tmpl.Name,
Template: tmpl.Template,
Provenance: tmpl.Provenance,
ResourceVersion: calculateTemplateFingerprint(tmpl.Template),
}, result)
assert.Equal(t, newNotificationTemplate(
tmpl.Name,
tmpl.Template,
models.Provenance(tmpl.Provenance),
tmpl.Kind,
), result)
require.Len(t, store.Calls, 2)
require.Equal(t, "Save", store.Calls[1].Method)
@@ -326,13 +452,12 @@ func TestUpsertTemplate(t *testing.T) {
result, err := sut.UpsertTemplate(context.Background(), orgID, tmpl)
require.NoError(t, err)
assert.Equal(t, definitions.NotificationTemplate{
UID: legacy_storage.NameToUid(tmpl.Name),
Name: tmpl.Name,
Template: tmpl.Template,
Provenance: tmpl.Provenance,
ResourceVersion: calculateTemplateFingerprint(tmpl.Template),
}, result)
assert.Equal(t, newNotificationTemplate(
tmpl.Name,
tmpl.Template,
models.Provenance(tmpl.Provenance),
definition.GrafanaTemplateKind,
), result)
require.Equal(t, "Save", store.Calls[1].Method)
saved := store.Calls[1].Args[1].(*legacy_storage.ConfigRevision)
@@ -356,18 +481,18 @@ func TestUpsertTemplate(t *testing.T) {
Template: "content",
Provenance: definitions.Provenance(models.ProvenanceNone),
ResourceVersion: calculateTemplateFingerprint(currentTemplateContent),
Kind: definition.GrafanaTemplateKind,
}
result, _ := sut.UpsertTemplate(context.Background(), orgID, tmpl)
expectedContent := fmt.Sprintf("{{ define \"%s\" }}\n content\n{{ end }}", templateName)
require.Equal(t, definitions.NotificationTemplate{
UID: legacy_storage.NameToUid(tmpl.Name),
Name: tmpl.Name,
Template: expectedContent,
Provenance: tmpl.Provenance,
ResourceVersion: calculateTemplateFingerprint(expectedContent),
}, result)
require.Equal(t, newNotificationTemplate(
tmpl.Name,
expectedContent,
models.Provenance(tmpl.Provenance),
tmpl.Kind,
), result)
})
t.Run("does not reject template with unknown field", func(t *testing.T) {
@@ -489,6 +614,21 @@ func TestUpsertTemplate(t *testing.T) {
require.ErrorIs(t, err, ErrTemplateNotFound)
})
t.Run("rejects new templates of mimir kind", func(t *testing.T) {
sut, store, _ := createTemplateServiceSut()
store.GetFn = func(ctx context.Context, org int64) (*legacy_storage.ConfigRevision, error) {
return revision(), nil
}
template := definitions.NotificationTemplate{
Name: "template2",
Template: "asdf-new",
Provenance: definitions.Provenance(models.ProvenanceNone),
Kind: definition.MimirTemplateKind,
}
_, err := sut.UpsertTemplate(context.Background(), orgID, template)
require.ErrorIs(t, err, ErrTemplateInvalid)
})
t.Run("propagates errors", func(t *testing.T) {
tmpl := definitions.NotificationTemplate{
Name: templateName,
@@ -562,6 +702,7 @@ func TestCreateTemplate(t *testing.T) {
Name: "new-template",
Template: "{{ define \"test\"}} test {{ end }}",
Provenance: definitions.Provenance(models.ProvenanceAPI),
Kind: definition.GrafanaTemplateKind,
}
revision := func() *legacy_storage.ConfigRevision {
@@ -588,13 +729,12 @@ func TestCreateTemplate(t *testing.T) {
result, err := sut.CreateTemplate(context.Background(), orgID, tmpl)
require.NoError(t, err)
require.Equal(t, definitions.NotificationTemplate{
UID: legacy_storage.NameToUid(tmpl.Name),
Name: tmpl.Name,
Template: tmpl.Template,
Provenance: tmpl.Provenance,
ResourceVersion: calculateTemplateFingerprint(tmpl.Template),
}, result)
require.Equal(t, newNotificationTemplate(
tmpl.Name,
tmpl.Template,
models.Provenance(tmpl.Provenance),
tmpl.Kind,
), result)
require.Len(t, store.Calls, 2)
@@ -649,10 +789,33 @@ func TestCreateTemplate(t *testing.T) {
require.ErrorIs(t, err, ErrTemplateInvalid)
})
t.Run("invalid kind", func(t *testing.T) {
tmpl := definitions.NotificationTemplate{
Name: "new-template",
Template: "{{ define \"test\"}} test {{ end }}",
Kind: "unknown",
}
_, err := sut.CreateTemplate(context.Background(), orgID, tmpl)
require.ErrorIs(t, err, ErrTemplateInvalid)
})
require.Empty(t, store.Calls)
prov.AssertExpectations(t)
})
t.Run("rejects templates with mimir kind", func(t *testing.T) {
sut, _, _ := createTemplateServiceSut()
tmpl := definitions.NotificationTemplate{
Name: "new-template",
Template: "{{ define \"test\"}} test {{ end }}",
Kind: definition.MimirTemplateKind,
}
_, err := sut.CreateTemplate(context.Background(), orgID, tmpl)
require.ErrorIs(t, err, ErrTemplateInvalid)
})
t.Run("propagates errors", func(t *testing.T) {
t.Run("when unable to read config", func(t *testing.T) {
sut, store, _ := createTemplateServiceSut()
@@ -706,6 +869,7 @@ func TestUpdateTemplate(t *testing.T) {
Template: "{{ define \"test\"}} test {{ end }}",
Provenance: definitions.Provenance(models.ProvenanceAPI),
ResourceVersion: "",
Kind: definition.GrafanaTemplateKind,
}
amConfigToken := util.GenerateShortUID()
@@ -771,7 +935,7 @@ func TestUpdateTemplate(t *testing.T) {
},
{
name: "by uid",
templateUid: legacy_storage.NameToUid(tmpl.UID),
templateUid: templateUID(tmpl.Kind, tmpl.Name),
},
}
@@ -791,13 +955,12 @@ func TestUpdateTemplate(t *testing.T) {
result, err := sut.UpdateTemplate(context.Background(), orgID, tmpl)
require.NoError(t, err)
assert.Equal(t, definitions.NotificationTemplate{
UID: legacy_storage.NameToUid(tmpl.Name),
Name: tmpl.Name,
Template: tmpl.Template,
Provenance: tmpl.Provenance,
ResourceVersion: calculateTemplateFingerprint(tmpl.Template),
}, result)
assert.Equal(t, newNotificationTemplate(
tmpl.Name,
tmpl.Template,
models.Provenance(tmpl.Provenance),
tmpl.Kind,
), result)
require.Len(t, store.Calls, 2)
require.Equal(t, "Save", store.Calls[1].Method)
@@ -821,13 +984,12 @@ func TestUpdateTemplate(t *testing.T) {
result, err := sut.UpdateTemplate(context.Background(), orgID, tmpl)
require.NoError(t, err)
assert.Equal(t, definitions.NotificationTemplate{
UID: legacy_storage.NameToUid(tmpl.Name),
Name: tmpl.Name,
Template: tmpl.Template,
Provenance: tmpl.Provenance,
ResourceVersion: calculateTemplateFingerprint(tmpl.Template),
}, result)
assert.Equal(t, newNotificationTemplate(
tmpl.Name,
tmpl.Template,
models.Provenance(tmpl.Provenance),
tmpl.Kind,
), result)
require.Equal(t, "Save", store.Calls[1].Method)
saved := store.Calls[1].Args[1].(*legacy_storage.ConfigRevision)
@@ -853,18 +1015,17 @@ func TestUpdateTemplate(t *testing.T) {
oldName := tmpl.Name
tmpl := tmpl
tmpl.UID = legacy_storage.NameToUid(tmpl.Name) // UID matches the current template
tmpl.Name = "new-template-name" // but name is different
tmpl.UID = templateUID(tmpl.Kind, tmpl.Name) // UID matches the current template
tmpl.Name = "new-template-name" // but name is different
result, err := sut.UpdateTemplate(context.Background(), orgID, tmpl)
require.NoError(t, err)
assert.Equal(t, definitions.NotificationTemplate{
UID: legacy_storage.NameToUid(tmpl.Name),
Name: tmpl.Name,
Template: tmpl.Template,
Provenance: tmpl.Provenance,
ResourceVersion: calculateTemplateFingerprint(tmpl.Template),
}, result)
assert.Equal(t, newNotificationTemplate(
tmpl.Name,
tmpl.Template,
models.Provenance(tmpl.Provenance),
tmpl.Kind,
), result)
require.Len(t, store.Calls, 2)
require.Equal(t, "Save", store.Calls[1].Method)
@@ -882,6 +1043,7 @@ func TestUpdateTemplate(t *testing.T) {
t.Run("rejects rename operation if template with the new name exists", func(t *testing.T) {
sut, store, prov := createTemplateServiceSut()
prov.EXPECT().GetProvenance(mock.Anything, mock.Anything, mock.Anything).Return(models.ProvenanceNone, nil)
store.GetFn = func(ctx context.Context, org int64) (*legacy_storage.ConfigRevision, error) {
return &legacy_storage.ConfigRevision{
Config: &definitions.PostableUserConfig{
@@ -895,8 +1057,8 @@ func TestUpdateTemplate(t *testing.T) {
}
tmpl := tmpl
tmpl.UID = legacy_storage.NameToUid(tmpl.Name) // UID matches the current template
tmpl.Name = "new-template-name" // but name matches another existing template
tmpl.UID = templateUID(tmpl.Kind, tmpl.Name) // UID matches the current template
tmpl.Name = "new-template-name" // but name matches another existing template
_, err := sut.UpdateTemplate(context.Background(), orgID, tmpl)
require.ErrorIs(t, err, ErrTemplateExists)
@@ -925,6 +1087,16 @@ func TestUpdateTemplate(t *testing.T) {
require.ErrorIs(t, err, ErrTemplateInvalid)
})
t.Run("invalid kind", func(t *testing.T) {
tmpl := definitions.NotificationTemplate{
Name: "",
Template: "",
Kind: "unknown",
}
_, err := sut.UpdateTemplate(context.Background(), orgID, tmpl)
require.ErrorIs(t, err, ErrTemplateInvalid)
})
require.Empty(t, store.Calls)
prov.AssertExpectations(t)
})
@@ -975,6 +1147,27 @@ func TestUpdateTemplate(t *testing.T) {
prov.AssertExpectations(t)
})
t.Run("rejects existing templates if kind changes", func(t *testing.T) {
sut, store, prov := createTemplateServiceSut()
store.GetFn = func(ctx context.Context, org int64) (*legacy_storage.ConfigRevision, error) {
return revision(), nil
}
prov.EXPECT().GetProvenance(mock.Anything, mock.Anything, mock.Anything).Return(models.ProvenanceNone, nil)
template := definitions.NotificationTemplate{
Name: "template1",
Template: "asdf-new",
ResourceVersion: "bad-version",
Provenance: definitions.Provenance(models.ProvenanceNone),
Kind: definition.MimirTemplateKind,
}
_, err := sut.UpdateTemplate(context.Background(), orgID, template)
require.ErrorIs(t, err, ErrTemplateInvalid)
prov.AssertExpectations(t)
})
t.Run("propagates errors", func(t *testing.T) {
t.Run("when unable to read config", func(t *testing.T) {
sut, store, _ := createTemplateServiceSut()
@@ -1062,7 +1255,7 @@ func TestDeleteTemplate(t *testing.T) {
},
{
name: "by uid",
templateNameOrUid: legacy_storage.NameToUid(templateName),
templateNameOrUid: templateUID(definition.GrafanaTemplateKind, templateName),
},
}
for _, tt := range testCase {
@@ -1125,7 +1318,7 @@ func TestDeleteTemplate(t *testing.T) {
}
t.Run("should look by name before uid", func(t *testing.T) {
expectedToDelete := legacy_storage.NameToUid(templateName)
expectedToDelete := templateUID(definition.GrafanaTemplateKind, templateName)
sut, store, prov := createTemplateServiceSut()
store.GetFn = func(ctx context.Context, orgID int64) (*legacy_storage.ConfigRevision, error) {
return &legacy_storage.ConfigRevision{
@@ -217,7 +217,6 @@ func (s *syncer) syncNamespace(ctx context.Context, namespace string, source ins
err := s.installRegistrar.Register(ctx, namespace, &install.PluginInstall{
ID: p.ID,
Version: p.Info.Version,
Class: install.Class(p.Class),
Source: source,
})
if err != nil {
@@ -17,6 +17,7 @@ type Plugin struct {
// App fields
Parent *ParentPlugin
Children []string
IncludedInAppID string
DefaultNavURL string
Pinned bool
@@ -85,6 +86,18 @@ func ToGrafanaDTO(p *plugins.Plugin) Plugin {
dto.Parent = &ParentPlugin{ID: p.Parent.ID}
}
if len(p.Children) > 0 {
children := make([]string, 0, len(p.Children))
for _, child := range p.Children {
if child != nil {
children = append(children, child.ID)
}
}
if len(children) > 0 {
dto.Children = children
}
}
return dto
}
@@ -198,10 +198,11 @@ func addCloudMigrationsMigrations(mg *Migrator) {
Postgres("ALTER TABLE cloud_migration_resource ALTER COLUMN resource_uid TYPE VARCHAR(255);"))
mg.AddMigration("create cloud_migration_snapshot_partition table v1", NewAddTableMigration(migrationSnapshotPartitionTable))
mg.AddMigration("add cloud_migration_snapshot_partition srp_unique index", NewAddIndexMigration(migrationSnapshotPartitionTable, &Index{
srpUniqueIndex := Index{
Name: "srp_unique",
Cols: []string{"snapshot_uid", "resource_type", "partition_number"}, Type: UniqueIndex,
}))
}
mg.AddMigration("add cloud_migration_snapshot_partition srp_unique index", NewAddIndexMigration(migrationSnapshotPartitionTable, &srpUniqueIndex))
mg.AddMigration("add resource_storage_type column to cloud_migration_snapshot table", NewAddColumnMigration(migrationSnapshotTable, &Column{
Name: "resource_storage_type",
Type: DB_Varchar,
@@ -224,4 +225,16 @@ func addCloudMigrationsMigrations(mg *Migrator) {
Type: DB_Blob,
Nullable: true,
}))
updatedCloudMigrationSnapshotPartitionTable := Table{
Name: "cloud_migration_snapshot_partition",
Columns: []*Column{
{Name: "snapshot_uid", Type: DB_NVarchar, Length: 40, Nullable: false, IsPrimaryKey: true},
{Name: "partition_number", Type: DB_Int, Nullable: false, IsPrimaryKey: true},
{Name: "resource_type", Type: DB_Varchar, Length: 255, Nullable: false, IsPrimaryKey: true},
{Name: "data", Type: DB_LongBlob, Nullable: false},
},
PrimaryKeys: []string{"snapshot_uid", "resource_type", "partition_number"},
}
ConvertUniqueKeyToPrimaryKey(mg, srpUniqueIndex, updatedCloudMigrationSnapshotPartitionTable)
}
@@ -91,7 +91,7 @@ func convertFilePathHashIndexToPrimaryKey(mg *migrator.Migrator) {
mg.AddMigration("drop file_path unique index from file table if it exists (mysql)", mysqlMigration2)
mysqlMigration3 := migrator.NewRawSQLMigration("").Mysql(`ALTER TABLE file ADD PRIMARY KEY (path_hash);`)
mysqlMigration3.Condition = &migrator.IfPrimaryKeyNotExistsCondition{TableName: "file", ColumnName: "path_hash"}
mysqlMigration3.Condition = &migrator.IfPrimaryKeyNotExistsCondition{TableName: "file"}
mg.AddMigration("add primary key to file table if it doesn't exist (mysql)", mysqlMigration3)
postgres := `
@@ -162,7 +162,7 @@ func convertFileMetaPathHashKeyIndexToPrimaryKey(mg *migrator.Migrator) {
mg.AddMigration("drop file_path unique index from file_meta table if it exists (mysql)", mysqlMigration2)
mysqlMigration3 := migrator.NewRawSQLMigration("").Mysql(`ALTER TABLE file_meta ADD PRIMARY KEY (path_hash, ` + "`key`" + `);`)
mysqlMigration3.Condition = &migrator.IfPrimaryKeyNotExistsCondition{TableName: "file_meta", ColumnName: "path_hash"}
mysqlMigration3.Condition = &migrator.IfPrimaryKeyNotExistsCondition{TableName: "file_meta"}
mg.AddMigration("add primary key to file_meta table if it doesn't exist (mysql)", mysqlMigration3)
postgres := `
+1 -1
View File
@@ -253,7 +253,7 @@ func (b *BaseDialect) CopyTableData(sourceTable string, targetTable string, sour
targetColsSQL := b.QuoteColList(targetCols)
quote := b.dialect.Quote
return fmt.Sprintf("INSERT INTO %s (%s) SELECT %s FROM %s", quote(targetTable), targetColsSQL, sourceColsSQL, quote(sourceTable))
return fmt.Sprintf("INSERT INTO %s (%s)\nSELECT %s\nFROM %s", quote(targetTable), targetColsSQL, sourceColsSQL, quote(sourceTable))
}
func (b *BaseDialect) DropTable(tableName string) string {
@@ -1,6 +1,8 @@
package migrator
import (
"fmt"
"slices"
"strings"
)
@@ -271,3 +273,155 @@ func NewTableCharsetMigration(tableName string, columns []*Column) *TableCharset
func (m *TableCharsetMigration) SQL(d Dialect) string {
return d.UpdateTableSQL(m.tableName, m.columns)
}
type addPrimaryKeyMigration struct {
MigrationBase
tableName string
uniqueKey Index
// Used for Sqlite recreation of the table. Temporary table will have tableName + "_new" suffix.
table Table
}
func (m *addPrimaryKeyMigration) SQL(d Dialect) string {
if d.DriverName() == SQLite {
// Final SQL will do following in the individual statements:
// 1. Create new temporary table
// 2. Copy data from old table to temporary table
// 3. Drop old table, rename temporary table to original name
// 4. Recreate indexes for table.
//
// For example:
//
// CREATE TABLE file_new
// (
// path TEXT NOT NULL,
// path_hash TEXT NOT NULL,
// parent_folder_path_hash TEXT NOT NULL,
// contents BLOB NOT NULL,
// etag TEXT NOT NULL,
// cache_control TEXT NOT NULL,
// content_disposition TEXT NOT NULL,
// updated DATETIME NOT NULL,
// created DATETIME NOT NULL,
// size INTEGER NOT NULL,
// mime_type TEXT NOT NULL,
//
// PRIMARY KEY (path_hash)
// );
//
// INSERT INTO file_new (path, path_hash, parent_folder_path_hash, contents, etag, cache_control, content_disposition, updated, created, size, mime_type)
// SELECT path, path_hash, parent_folder_path_hash, contents, etag, cache_control, content_disposition, updated, created, size, mime_type FROM file;
//
// DROP TABLE file;
// ALTER TABLE file_new RENAME TO file;
//
// CREATE INDEX IDX_file_parent_folder_path_hash ON file (parent_folder_path_hash);
tempTable := m.table
tempTable.Name = m.tableName + "_new"
statements := strings.Builder{}
statements.WriteString(d.CreateTableSQL(&tempTable))
statements.WriteString("\n") // CreateTableSQL adds semicolon
cols := make([]string, 0, len(tempTable.Columns))
for _, col := range tempTable.Columns {
cols = append(cols, col.Name)
}
statements.WriteString(d.CopyTableData(m.tableName, tempTable.Name, cols, cols))
statements.WriteString(";\n")
statements.WriteString(d.DropTable(m.tableName))
statements.WriteString(";\n")
statements.WriteString(d.RenameTable(tempTable.Name, m.tableName))
statements.WriteString(";\n")
for _, idx := range tempTable.Indices {
// Use real table name, not temporary one now
statements.WriteString(d.CreateIndexSQL(m.tableName, idx))
statements.WriteString("\n") // CreateIndexSQL adds semicolon
}
return statements.String()
} else if d.DriverName() == Postgres {
quotesCols := make([]string, 0, len(m.uniqueKey.Cols))
for _, c := range m.uniqueKey.Cols {
quotesCols = append(quotesCols, d.Quote(c))
}
return fmt.Sprintf(`
DO $$
BEGIN
-- Drop the unique constraint if it exists
DROP INDEX IF EXISTS %s;
-- Add primary key if it doesn't already exist
IF NOT EXISTS (SELECT 1 FROM pg_index i WHERE indrelid = '%s'::regclass AND indisprimary) THEN
ALTER TABLE %s ADD PRIMARY KEY (%s);
END IF;
END $$;`, d.Quote(m.uniqueKey.XName(m.tableName)), m.tableName, d.Quote(m.tableName), strings.Join(quotesCols, ","))
} else {
return ""
}
}
// ConvertUniqueKeyToPrimaryKey adds series of migrations to convert existing unique key to PRIMARY KEY.
// For Sqlite this means recreating the table, which only works if there are no foreign keys referencing the table.
func ConvertUniqueKeyToPrimaryKey(mg *Migrator, uniqueKey Index, finalTable Table) {
tableName := finalTable.Name
if tableName == "" {
panic("invalid table name")
}
if len(uniqueKey.Cols) == 0 || uniqueKey.Type != UniqueIndex {
panic("invalid unique type")
}
if !slices.Equal(uniqueKey.Cols, finalTable.PrimaryKeys) {
panic("invalid primary key in the final table")
}
colPks := map[string]bool{}
for _, col := range finalTable.Columns {
if col.IsPrimaryKey {
colPks[col.Name] = true
}
}
for _, c := range uniqueKey.Cols {
if !colPks[c] {
panic(fmt.Sprintf("column %s is not part of primary key in the table definition", c))
}
}
columnsList := strings.Join(uniqueKey.Cols, ",")
mysqlQuote := NewDialect(MySQL).Quote
mysqlQuotedColumns := make([]string, 0, len(uniqueKey.Cols))
for _, col := range uniqueKey.Cols {
mysqlQuotedColumns = append(mysqlQuotedColumns, mysqlQuote(col))
}
// migration 1 is to handle cases where the table was created with sql_generate_invisible_primary_key = ON
// in this case we need to do the conversion in one sql statement
mysqlMigration1 := NewRawSQLMigration("").Mysql(fmt.Sprintf(`
ALTER TABLE %s
DROP PRIMARY KEY,
DROP COLUMN my_row_id,
DROP INDEX %s,
ADD PRIMARY KEY (%s);
`, tableName, uniqueKey.XName(tableName), strings.Join(mysqlQuotedColumns, ",")))
mysqlMigration1.Condition = &IfColumnExistsCondition{TableName: tableName, ColumnName: "my_row_id"}
mg.AddMigration(fmt.Sprintf("drop my_row_id and add primary key with columns %s to table %s if my_row_id exists (auto-generated mysql column)", columnsList, tableName), mysqlMigration1)
mysqlMigration2 := NewRawSQLMigration("").Mysql(fmt.Sprintf(`ALTER TABLE %s DROP INDEX %s`, tableName, uniqueKey.XName(tableName)))
mysqlMigration2.Condition = &IfIndexExistsCondition{TableName: tableName, IndexName: uniqueKey.XName(tableName)}
mg.AddMigration(fmt.Sprintf("drop unique index %s from %s table if it exists (mysql)", uniqueKey.XName(tableName), tableName), mysqlMigration2)
mysqlMigration3 := NewRawSQLMigration("").Mysql(fmt.Sprintf(`ALTER TABLE %s ADD PRIMARY KEY (%s)`, tableName, strings.Join(mysqlQuotedColumns, ",")))
mysqlMigration3.Condition = &IfPrimaryKeyNotExistsCondition{TableName: tableName}
mg.AddMigration(fmt.Sprintf("add primary key with columns %s to table %s if it doesn't exist (mysql)", columnsList, tableName), mysqlMigration3)
// postgres and sqlite statements are idempotent so we can have only one condition-less migration
mg.AddMigration(fmt.Sprintf("add primary key with columns %s to table %s (postgres and sqlite)", columnsList, tableName), &addPrimaryKeyMigration{tableName: tableName, uniqueKey: uniqueKey, table: finalTable})
}
@@ -0,0 +1,79 @@
package migrator
import (
_ "embed"
"testing"
"github.com/stretchr/testify/require"
)
//go:embed testdata/sqlite_file_migration_statement.sql
var sqliteMigrationStatement string
func TestConvertUniqueKeyToPrimaryKey(t *testing.T) {
names := []string{
"drop my_row_id and add primary key with columns path_hash,etag to table file if my_row_id exists (auto-generated mysql column)",
"drop unique index UQE_file_path_hash_etag from file table if it exists (mysql)",
"add primary key with columns path_hash,etag to table file if it doesn't exist (mysql)",
"add primary key with columns path_hash,etag to table file (postgres and sqlite)",
}
expectedMigrations := map[string][]ExpectedMigration{
MySQL: {
{Id: names[0], SQL: `
ALTER TABLE file
DROP PRIMARY KEY,
DROP COLUMN my_row_id,
DROP INDEX UQE_file_path_hash_etag,
ADD PRIMARY KEY (` + "`path_hash`" + `,` + "`etag`" + `);`},
{Id: names[1], SQL: "ALTER TABLE file DROP INDEX UQE_file_path_hash_etag"},
{Id: names[2], SQL: "ALTER TABLE file ADD PRIMARY KEY (`path_hash`,`etag`)"},
{Id: names[3], SQL: ""},
},
Postgres: {
{Id: names[0], SQL: ""},
{Id: names[1], SQL: ""},
{Id: names[2], SQL: ""},
{Id: names[3], SQL: `
DO $$
BEGIN
-- Drop the unique constraint if it exists
DROP INDEX IF EXISTS "UQE_file_path_hash_etag";
-- Add primary key if it doesn't already exist
IF NOT EXISTS (SELECT 1 FROM pg_index i WHERE indrelid = 'file'::regclass AND indisprimary) THEN
ALTER TABLE "file" ADD PRIMARY KEY ("path_hash","etag");
END IF;
END $$;`},
},
SQLite: {
{Id: names[0], SQL: ""},
{Id: names[1], SQL: ""},
{Id: names[2], SQL: ""},
{Id: names[3], SQL: sqliteMigrationStatement}, // Embed used here because sqlite statement is full of backquotes.
},
}
for dialectName, migrations := range expectedMigrations {
t.Run(dialectName, func(t *testing.T) {
err := CheckExpectedMigrations(dialectName, migrations, func(migrator *Migrator) {
ConvertUniqueKeyToPrimaryKey(migrator,
Index{Cols: []string{"path_hash", "etag"}, Type: UniqueIndex}, // Convert this unique key to primary key
Table{
Name: "file",
Columns: []*Column{
{Name: "path", Type: DB_NVarchar, Length: 1024, Nullable: false},
{Name: "path_hash", Type: DB_NVarchar, Length: 64, Nullable: false, IsPrimaryKey: true},
{Name: "parent_folder_path_hash", Type: DB_NVarchar, Length: 64, Nullable: false},
{Name: "contents", Type: DB_Blob, Nullable: false},
{Name: "etag", Type: DB_NVarchar, Length: 32, Nullable: false, IsPrimaryKey: true},
},
PrimaryKeys: []string{"path_hash", "etag"},
Indices: []*Index{
{Cols: []string{"parent_folder_path_hash"}},
},
})
})
require.NoError(t, err)
})
}
}
+7 -2
View File
@@ -8,7 +8,6 @@ import (
_ "github.com/go-sql-driver/mysql"
"github.com/golang-migrate/migrate/v4/database"
"github.com/grafana/grafana/pkg/util/sqlite"
_ "github.com/lib/pq"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel"
@@ -17,6 +16,8 @@ import (
"go.opentelemetry.io/otel/trace"
"go.uber.org/atomic"
"github.com/grafana/grafana/pkg/util/sqlite"
"github.com/grafana/grafana/pkg/util/xorm"
"github.com/grafana/grafana/pkg/infra/log"
@@ -67,12 +68,16 @@ func NewMigrator(engine *xorm.Engine, cfg *setting.Cfg) *Migrator {
// NewScopedMigrator should only be used for the transition to a new storage engine
func NewScopedMigrator(engine *xorm.Engine, cfg *setting.Cfg, scope string) *Migrator {
return newMigrator(engine, cfg, scope, NewDialect(engine.DriverName()))
}
func newMigrator(engine *xorm.Engine, cfg *setting.Cfg, scope string, dialect Dialect) *Migrator {
mg := &Migrator{
Cfg: cfg,
DBEngine: engine,
migrations: make([]Migration, 0),
migrationIds: make(map[string]struct{}),
Dialect: NewDialect(engine.DriverName()),
Dialect: dialect,
metrics: migratorMetrics{
migCount: prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: "grafana_database",
@@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS `file_new` (
`path` TEXT NOT NULL
, `path_hash` TEXT NOT NULL
, `parent_folder_path_hash` TEXT NOT NULL
, `contents` BLOB NOT NULL
, `etag` TEXT NOT NULL
, PRIMARY KEY ( `path_hash`,`etag` ));
INSERT INTO `file_new` (`path`
, `path_hash`
, `parent_folder_path_hash`
, `contents`
, `etag`)
SELECT `path`
, `path_hash`
, `parent_folder_path_hash`
, `contents`
, `etag`
FROM `file`;
DROP TABLE IF EXISTS `file`;
ALTER TABLE `file_new` RENAME TO `file`;
CREATE INDEX `IDX_file_parent_folder_path_hash` ON `file` (`parent_folder_path_hash`);
+51
View File
@@ -0,0 +1,51 @@
package migrator
import (
"fmt"
"strings"
)
type ExpectedMigration struct {
Id string
SQL string
}
// CheckExpectedMigrations verifies that given migrations exist in migrator after running addMigrations function,
// that they are in the same order and have expected SQL.
func CheckExpectedMigrations(dialectName string, expected []ExpectedMigration, addMigrations func(migrator *Migrator)) error {
d := NewDialect(dialectName)
mg := newMigrator(nil, nil, "", d)
addMigrations(mg)
migrations := mg.migrations
migrationNames := make([]string, 0, len(migrations))
for _, m := range expected {
for ; len(migrations) > 0 && migrations[0].Id() != m.Id; migrations = migrations[1:] {
migrationNames = append(migrationNames, migrations[0].Id())
}
if len(migrations) == 0 {
return fmt.Errorf("migration `%s` not found, existing migrations:\n%s", m.Id, strings.Join(migrationNames, "\n"))
}
sql := migrations[0].SQL(d)
if normalizeLines(m.SQL) != normalizeLines(sql) {
return fmt.Errorf("migration `%s` has wrong SQL:\nexpected:\n%s\nactual:\n%s", m.Id, m.SQL, sql)
}
}
return nil
}
func normalizeLines(sql string) string {
lines := strings.Split(sql, "\n")
result := strings.Builder{}
for _, l := range lines {
l := strings.TrimSpace(l)
if l == "" {
continue
}
result.WriteString(l)
result.WriteString("\n")
}
return result.String()
}
+19
View File
@@ -224,4 +224,23 @@ func (*SecretDB) AddMigration(mg *migrator.Migrator) {
mg.AddMigration("set secret_secure_value.keeper to 'system' where keeper is null in "+TableNameSecureValue, migrator.NewRawSQLMigration(
fmt.Sprintf("UPDATE %s SET keeper = '%s' WHERE keeper IS NULL", TableNameSecureValue, contracts.SystemKeeperName),
))
encryptedValueTableUniqueKey := migrator.Index{Cols: []string{"namespace", "name", "version"}, Type: migrator.UniqueIndex}
updatedEncryptedValueTable := migrator.Table{
Name: TableNameEncryptedValue,
Columns: []*migrator.Column{
{Name: "namespace", Type: migrator.DB_NVarchar, Length: 253, Nullable: false, IsPrimaryKey: true}, // Limit enforced by K8s.
{Name: "name", Type: migrator.DB_NVarchar, Length: 253, Nullable: false, IsPrimaryKey: true},
{Name: "version", Type: migrator.DB_BigInt, Nullable: false, IsPrimaryKey: true},
{Name: "encrypted_data", Type: migrator.DB_Blob, Nullable: false},
{Name: "created", Type: migrator.DB_BigInt, Nullable: false},
{Name: "updated", Type: migrator.DB_BigInt, Nullable: false},
{Name: "data_key_id", Type: migrator.DB_NVarchar, Length: 100, Nullable: false, Default: "''"},
},
PrimaryKeys: []string{"namespace", "name", "version"},
Indices: []*migrator.Index{
{Cols: []string{"data_key_id"}},
},
}
migrator.ConvertUniqueKeyToPrimaryKey(mg, encryptedValueTableUniqueKey, updatedEncryptedValueTable)
}
@@ -0,0 +1,9 @@
SELECT {{ .Ident "key_path" }}
FROM {{ .TableName }}
WHERE {{ .Ident "key_path" }} >= {{ .Arg .StartKey }}
AND {{ .Ident "key_path" }} < {{ .Arg .EndKey }}
ORDER BY {{ .Ident "key_path" }} {{ if .SortAscending }}ASC{{ else }}DESC{{ end }}
{{ if .Options.Limit }}
LIMIT {{ .Options.Limit }}
{{ end }}
;
+53 -1
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"io"
"iter"
"strings"
"text/template"
"github.com/grafana/grafana/pkg/storage/unified/sql/db"
@@ -35,6 +36,7 @@ func mustTemplate(filename string) *template.Template {
var (
sqlKVGet = mustTemplate("sqlkv_get.sql")
sqlKVDelete = mustTemplate("sqlkv_delete.sql")
sqlKVKeys = mustTemplate("sqlkv_keys.sql")
)
// sqlKVSection can be embedded in structs used when rendering query templates
@@ -114,6 +116,32 @@ func (req sqlKVDeleteRequest) Validate() error {
return req.sqlKVSectionKey.Validate()
}
type sqlKVKeysRequest struct {
sqltemplate.SQLTemplate
sqlKVSection
Options ListOptions
}
func (req sqlKVKeysRequest) Validate() error {
return req.sqlKVSection.Validate()
}
func (req sqlKVKeysRequest) StartKey() string {
return req.Section + "/" + req.Options.StartKey
}
func (req sqlKVKeysRequest) EndKey() string {
if req.Options.EndKey == "" {
req.Options.EndKey = PrefixRangeEnd(req.Section + "/")
}
return req.Section + "/" + req.Options.EndKey
}
func (req sqlKVKeysRequest) SortAscending() bool {
return req.Options.Sort != SortOrderDesc
}
var _ KV = &sqlKV{}
type sqlKV struct {
@@ -151,7 +179,31 @@ func (k *sqlKV) Ping(ctx context.Context) error {
func (k *sqlKV) Keys(ctx context.Context, section string, opt ListOptions) iter.Seq2[string, error] {
return func(yield func(string, error) bool) {
panic("not implemented!")
rows, err := dbutil.QueryRows(ctx, k.db, sqlKVKeys, sqlKVKeysRequest{
SQLTemplate: sqltemplate.New(k.dialect),
sqlKVSection: sqlKVSection{section},
Options: opt,
})
if err != nil {
yield("", err)
return
}
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
yield("", fmt.Errorf("error reading row: %w", err))
return
}
if !yield(strings.TrimPrefix(key, section+"/"), nil) {
return
}
}
if err := rows.Err(); err != nil {
yield("", fmt.Errorf("failed to read rows: %w", err))
}
}
}
@@ -204,5 +204,18 @@ func initResourceTables(mg *migrator.Migrator) string {
Name: "IDX_resource_history_key_path",
}))
oldResourceVersionUniqueKey := migrator.Index{Cols: []string{"group", "resource"}, Type: migrator.UniqueIndex}
updatedResourceVersionTable := migrator.Table{
Name: "resource_version",
Columns: []*migrator.Column{
{Name: "group", Type: migrator.DB_NVarchar, Length: 190, Nullable: false, IsPrimaryKey: true},
{Name: "resource", Type: migrator.DB_NVarchar, Length: 190, Nullable: false, IsPrimaryKey: true},
{Name: "resource_version", Type: migrator.DB_BigInt, Nullable: false},
},
PrimaryKeys: []string{"group", "resource"},
}
migrator.ConvertUniqueKeyToPrimaryKey(mg, oldResourceVersionUniqueKey, updatedResourceVersionTable)
return marker
}
+112 -52
View File
@@ -88,8 +88,17 @@ func RunKVTest(t *testing.T, newKV NewKVFunc, opts *KVTestOptions) {
}
}
func prefixKey(nsPrefix, key string) string {
return nsPrefix + "/" + key
func namespacedKeys(nsPrefix string, keys []string) []string {
prefixed := make([]string, 0, len(keys))
for _, k := range keys {
prefixed = append(prefixed, nsPrefix+"/"+k)
}
return prefixed
}
func namespacedKey(nsPrefix, key string) string {
return namespacedKeys(nsPrefix, []string{key})[0]
}
func runTestKVGet(t *testing.T, kv resource.KV, nsPrefix string) {
@@ -97,7 +106,7 @@ func runTestKVGet(t *testing.T, kv resource.KV, nsPrefix string) {
t.Run("get existing key", func(t *testing.T) {
// First save a key
existingKey := prefixKey(nsPrefix, "existing-key")
existingKey := namespacedKey(nsPrefix, "existing-key")
testValue := "test value for get"
saveKVHelper(t, kv, ctx, testSection, existingKey, strings.NewReader(testValue))
@@ -116,13 +125,13 @@ func runTestKVGet(t *testing.T, kv resource.KV, nsPrefix string) {
})
t.Run("get non-existent key", func(t *testing.T) {
_, err := kv.Get(ctx, testSection, prefixKey(nsPrefix, "non-existent-key"))
_, err := kv.Get(ctx, testSection, namespacedKey(nsPrefix, "non-existent-key"))
assert.Error(t, err)
assert.Equal(t, resource.ErrNotFound, err)
})
t.Run("get with empty section", func(t *testing.T) {
_, err := kv.Get(ctx, "", prefixKey(nsPrefix, "some-key"))
_, err := kv.Get(ctx, "", namespacedKey(nsPrefix, "some-key"))
assert.Error(t, err)
assert.Contains(t, err.Error(), "section is required")
})
@@ -215,7 +224,7 @@ func runTestKVDelete(t *testing.T, kv resource.KV, nsPrefix string) {
t.Run("delete existing key", func(t *testing.T) {
// First create a key
deleteKey := prefixKey(nsPrefix, "delete-key")
deleteKey := namespacedKey(nsPrefix, "delete-key")
saveKVHelper(t, kv, ctx, testSection, deleteKey, strings.NewReader("delete me"))
// Verify it exists
@@ -233,13 +242,13 @@ func runTestKVDelete(t *testing.T, kv resource.KV, nsPrefix string) {
})
t.Run("delete non-existent key", func(t *testing.T) {
err := kv.Delete(ctx, testSection, prefixKey(nsPrefix, "non-existent-delete-key"))
err := kv.Delete(ctx, testSection, namespacedKey(nsPrefix, "non-existent-delete-key"))
assert.Error(t, err)
assert.Equal(t, resource.ErrNotFound, err)
})
t.Run("delete with empty section", func(t *testing.T) {
err := kv.Delete(ctx, "", prefixKey(nsPrefix, "some-key"))
err := kv.Delete(ctx, "", namespacedKey(nsPrefix, "some-key"))
assert.Error(t, err)
assert.Contains(t, err.Error(), "section is required")
})
@@ -253,17 +262,16 @@ func runTestKVDelete(t *testing.T, kv resource.KV, nsPrefix string) {
func runTestKVKeys(t *testing.T, kv resource.KV, nsPrefix string) {
ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second))
section := nsPrefix + "-keys"
// Setup test data
testKeys := []string{"a1", "a2", "b1", "b2", "c1"}
testKeys := namespacedKeys(nsPrefix, []string{"a1", "a2", "b1", "b2", "c1"})
for _, key := range testKeys {
saveKVHelper(t, kv, ctx, section, key, strings.NewReader("value"+key))
saveKVHelper(t, kv, ctx, testSection, key, strings.NewReader("value"+key))
}
t.Run("list all keys", func(t *testing.T) {
var keys []string
for k, err := range kv.Keys(ctx, section, resource.ListOptions{}) {
for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{}) {
require.NoError(t, err)
keys = append(keys, k)
}
@@ -276,134 +284,186 @@ func runTestKVKeys(t *testing.T, kv resource.KV, nsPrefix string) {
for k, err := range kv.Keys(ctx, "", resource.ListOptions{}) {
if err != nil {
errors = append(errors, err)
break
continue
}
keys = append(keys, k)
}
assert.Len(t, errors, 1)
require.Len(t, errors, 1)
assert.Contains(t, errors[0].Error(), "section is required")
assert.Empty(t, keys)
})
t.Run("invalid sort option, defaults to asc", func(t *testing.T) {
var keys []string
var errors []error
for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{
Sort: resource.SortOrder(100),
}) {
if err != nil {
errors = append(errors, err)
continue
}
keys = append(keys, k)
}
assert.Empty(t, errors)
assert.Equal(t, testKeys, keys)
})
t.Run("list keys with end key < start key", func(t *testing.T) {
var keys []string
var errors []error
for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{
StartKey: namespacedKey(nsPrefix, "c"),
EndKey: namespacedKey(nsPrefix, "a"),
}) {
if err != nil {
errors = append(errors, err)
continue
}
keys = append(keys, k)
}
// Nothing is yielded
assert.Empty(t, errors)
assert.Empty(t, keys)
})
t.Run("list keys returns 0 keys", func(t *testing.T) {
// Use a different section with no keys
emptySection := nsPrefix + "-empty-keys"
// Use a key range with no keys.
startKey, endKey := "aaaaa", "aaaaz"
var keys []string
for k, err := range kv.Keys(ctx, emptySection, resource.ListOptions{}) {
for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{
StartKey: startKey,
EndKey: endKey,
}) {
require.NoError(t, err)
keys = append(keys, k)
}
assert.Empty(t, keys)
assert.Len(t, keys, 0)
})
t.Run("interrupting the iterator", func(t *testing.T) {
var keys []string
for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{}) {
require.NoError(t, err)
keys = append(keys, k)
if len(keys) == 2 {
break
}
}
assert.Equal(t, namespacedKeys(nsPrefix, []string{"a1", "a2"}), keys)
})
}
func runTestKVKeysWithLimits(t *testing.T, kv resource.KV, nsPrefix string) {
ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second))
section := nsPrefix + "-keys-limits"
// Setup test data
testKeys := []string{"a1", "a2", "b1", "b2", "c1", "c2", "d1", "d2"}
testKeys := namespacedKeys(nsPrefix, []string{"a1", "a2", "b1", "b2", "c1", "c2", "d1", "d2"})
for _, key := range testKeys {
saveKVHelper(t, kv, ctx, section, key, strings.NewReader("value"+key))
saveKVHelper(t, kv, ctx, testSection, key, strings.NewReader("value"+key))
}
t.Run("keys with limit", func(t *testing.T) {
var keys []string
for k, err := range kv.Keys(ctx, section, resource.ListOptions{Limit: 3}) {
for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{Limit: 3}) {
require.NoError(t, err)
keys = append(keys, k)
}
assert.Equal(t, []string{"a1", "a2", "b1"}, keys)
assert.Equal(t, namespacedKeys(nsPrefix, []string{"a1", "a2", "b1"}), keys)
})
t.Run("keys with range", func(t *testing.T) {
var keys []string
for k, err := range kv.Keys(ctx, section, resource.ListOptions{StartKey: "b", EndKey: "d"}) {
require.NoError(t, err)
keys = append(keys, k)
}
assert.Equal(t, []string{"b1", "b2", "c1", "c2"}, keys)
})
t.Run("keys with prefix", func(t *testing.T) {
var keys []string
for k, err := range kv.Keys(ctx, section, resource.ListOptions{
StartKey: "c",
EndKey: resource.PrefixRangeEnd("c"),
for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{
StartKey: namespacedKey(nsPrefix, "b"),
EndKey: namespacedKey(nsPrefix, "d"),
}) {
require.NoError(t, err)
keys = append(keys, k)
}
assert.Equal(t, []string{"c1", "c2"}, keys)
assert.Equal(t, namespacedKeys(nsPrefix, []string{"b1", "b2", "c1", "c2"}), keys)
})
t.Run("keys with prefix", func(t *testing.T) {
var keys []string
for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{
StartKey: namespacedKey(nsPrefix, "c"),
EndKey: namespacedKey(nsPrefix, resource.PrefixRangeEnd("c")),
}) {
require.NoError(t, err)
keys = append(keys, k)
}
assert.Equal(t, namespacedKeys(nsPrefix, []string{"c1", "c2"}), keys)
})
t.Run("keys with limit and range", func(t *testing.T) {
var keys []string
for k, err := range kv.Keys(ctx, section, resource.ListOptions{
StartKey: "a",
EndKey: "c",
for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{
StartKey: namespacedKey(nsPrefix, "a"),
EndKey: namespacedKey(nsPrefix, "c"),
Limit: 2,
}) {
require.NoError(t, err)
keys = append(keys, k)
}
assert.Equal(t, []string{"a1", "a2"}, keys)
assert.Equal(t, namespacedKeys(nsPrefix, []string{"a1", "a2"}), keys)
})
}
func runTestKVKeysWithSort(t *testing.T, kv resource.KV, nsPrefix string) {
ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second))
section := nsPrefix + "-keys-sort"
// Setup test data
testKeys := []string{"a1", "a2", "b1", "b2", "c1"}
testKeys := namespacedKeys(nsPrefix, []string{"a1", "a2", "b1", "b2", "c1"})
for _, key := range testKeys {
saveKVHelper(t, kv, ctx, section, key, strings.NewReader("value"+key))
saveKVHelper(t, kv, ctx, testSection, key, strings.NewReader("value"+key))
}
t.Run("keys in ascending order (default)", func(t *testing.T) {
var keys []string
for k, err := range kv.Keys(ctx, section, resource.ListOptions{Sort: resource.SortOrderAsc}) {
for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{Sort: resource.SortOrderAsc}) {
require.NoError(t, err)
keys = append(keys, k)
}
assert.Equal(t, []string{"a1", "a2", "b1", "b2", "c1"}, keys)
assert.Equal(t, namespacedKeys(nsPrefix, []string{"a1", "a2", "b1", "b2", "c1"}), keys)
})
t.Run("keys in descending order", func(t *testing.T) {
var keys []string
for k, err := range kv.Keys(ctx, section, resource.ListOptions{Sort: resource.SortOrderDesc}) {
for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{Sort: resource.SortOrderDesc}) {
require.NoError(t, err)
keys = append(keys, k)
}
assert.Equal(t, []string{"c1", "b2", "b1", "a2", "a1"}, keys)
assert.Equal(t, namespacedKeys(nsPrefix, []string{"c1", "b2", "b1", "a2", "a1"}), keys)
})
t.Run("keys descending with prefix", func(t *testing.T) {
var keys []string
for k, err := range kv.Keys(ctx, section, resource.ListOptions{
StartKey: "a",
EndKey: resource.PrefixRangeEnd("a"),
for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{
StartKey: namespacedKey(nsPrefix, "a"),
EndKey: namespacedKey(nsPrefix, resource.PrefixRangeEnd("a")),
Sort: resource.SortOrderDesc,
}) {
require.NoError(t, err)
keys = append(keys, k)
}
assert.Equal(t, []string{"a2", "a1"}, keys)
assert.Equal(t, namespacedKeys(nsPrefix, []string{"a2", "a1"}), keys)
})
t.Run("keys descending with limit", func(t *testing.T) {
var keys []string
for k, err := range kv.Keys(ctx, section, resource.ListOptions{
for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{
Sort: resource.SortOrderDesc,
Limit: 3,
}) {
require.NoError(t, err)
keys = append(keys, k)
}
assert.Equal(t, []string{"c1", "b2", "b1"}, keys)
assert.Equal(t, namespacedKeys(nsPrefix, []string{"c1", "b2", "b1"}), keys)
})
}
+5 -8
View File
@@ -47,14 +47,11 @@ func TestSQLKV(t *testing.T) {
}, &KVTestOptions{
NSPrefix: "sql-kv-test",
SkipTests: map[string]bool{
TestKVSave: true,
TestKVKeys: true,
TestKVKeysWithLimits: true,
TestKVKeysWithSort: true,
TestKVConcurrent: true,
TestKVUnixTimestamp: true,
TestKVBatchGet: true,
TestKVBatchDelete: true,
TestKVSave: true,
TestKVConcurrent: true,
TestKVUnixTimestamp: true,
TestKVBatchGet: true,
TestKVBatchDelete: true,
},
})
}
@@ -0,0 +1,129 @@
package templateGroup
import (
"context"
"embed"
"path"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.yaml.in/yaml/v3"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "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"
"github.com/grafana/grafana/pkg/tests/apis/alerting/notifications/common"
"github.com/grafana/grafana/pkg/tests/testinfra"
"github.com/grafana/grafana/pkg/util/testutil"
)
//go:embed test-data/*.*
var testData embed.FS
func TestIntegrationImportedTemplates(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
EnableFeatureToggles: []string{
featuremgmt.FlagAlertingImportAlertmanagerAPI,
},
})
client := common.NewTemplateGroupClient(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,
}
var amConfig apimodels.AlertmanagerUserConfig
require.NoError(t, yaml.Unmarshal(configYaml, &amConfig))
response := alertingApi.ConvertPrometheusPostAlertmanagerConfig(t, amConfig, headers)
require.Equal(t, "success", response.Status)
templates, err := client.List(context.Background(), metav1.ListOptions{})
require.NoError(t, err)
require.Len(t, templates.Items, 3)
require.Equal(t, v0alpha1.DefaultTemplateTitle, templates.Items[0].Spec.Title)
require.Equal(t, "imported", templates.Items[1].Spec.Title)
require.Equal(t, "template", templates.Items[2].Spec.Title)
t.Run("should be correct kind", func(t *testing.T) {
assert.Equal(t,
v0alpha1.TemplateGroupSpec{
Title: "imported",
Content: amConfig.TemplateFiles["imported"],
Kind: v0alpha1.TemplateGroupTemplateKindMimir,
}, templates.Items[1].Spec)
assert.Equal(t,
v0alpha1.TemplateGroupSpec{
Title: "template",
Content: amConfig.TemplateFiles["template"],
Kind: v0alpha1.TemplateGroupTemplateKindMimir,
}, templates.Items[2].Spec)
})
t.Run("should be provisioned", func(t *testing.T) {
for _, tpl := range templates.Items[1:] {
assert.EqualValues(t, models.ProvenanceConvertedPrometheus, tpl.GetProvenanceStatus())
}
})
t.Run("should not be able to update", func(t *testing.T) {
tpl := templates.Items[1]
tpl.Spec.Content = "new content"
_, err := client.Update(context.Background(), &tpl, metav1.UpdateOptions{})
require.Truef(t, errors.IsBadRequest(err), "expected bad request but got %s", err)
})
t.Run("should not be able to delete", func(t *testing.T) {
err := client.Delete(context.Background(), templates.Items[1].Name, metav1.DeleteOptions{})
require.Truef(t, errors.IsBadRequest(err), "expected bad request but got %s", err)
})
t.Run("should not conflict with Grafana kind", func(t *testing.T) {
tpl := v0alpha1.TemplateGroup{
ObjectMeta: metav1.ObjectMeta{
Namespace: "default",
},
Spec: templates.Items[1].Spec,
}
tpl.Spec.Kind = v0alpha1.TemplateGroupTemplateKindGrafana
created, err := client.Create(context.Background(), &tpl, metav1.CreateOptions{})
require.NoError(t, err)
assert.NotEqual(t, templates.Items[1].Name, created.Name)
})
t.Run("sort by kind and then name", func(t *testing.T) {
templates, err := client.List(context.Background(), metav1.ListOptions{})
require.NoError(t, err)
require.Len(t, templates.Items, 4)
assert.Equal(t, v0alpha1.DefaultTemplateTitle, templates.Items[0].Spec.Title)
assert.Equal(t, "imported", templates.Items[1].Spec.Title)
assert.Equal(t, v0alpha1.TemplateGroupTemplateKindGrafana, templates.Items[1].Spec.Kind)
assert.Equal(t, "imported", templates.Items[2].Spec.Title)
assert.Equal(t, v0alpha1.TemplateGroupTemplateKindMimir, templates.Items[2].Spec.Kind)
assert.Equal(t, "template", templates.Items[3].Spec.Title)
})
}
@@ -54,6 +54,7 @@ func TestIntegrationResourceIdentifier(t *testing.T) {
Spec: v0alpha1.TemplateGroupSpec{
Title: "templateGroup",
Content: `{{ define "test" }} test {{ end }}`,
Kind: v0alpha1.TemplateGroupTemplateKindGrafana,
},
}
@@ -112,6 +113,7 @@ func TestIntegrationResourceIdentifier(t *testing.T) {
require.Equal(t, v0alpha1.TemplateGroupSpec{
Title: v0alpha1.DefaultTemplateTitle,
Content: defaultDefn.Template,
Kind: v0alpha1.TemplateGroupTemplateKindGrafana,
}, actual.Spec)
defaultTemplateGroup = actual
})
@@ -226,6 +228,7 @@ func TestIntegrationAccessControl(t *testing.T) {
Spec: v0alpha1.TemplateGroupSpec{
Title: fmt.Sprintf("template-group-1-%s", tc.user.Identity.GetLogin()),
Content: `{{ define "test" }} test {{ end }}`,
Kind: v0alpha1.TemplateGroupTemplateKindGrafana,
},
}
expected.SetProvenanceStatus("")
@@ -385,6 +388,7 @@ func TestIntegrationProvisioning(t *testing.T) {
Spec: v0alpha1.TemplateGroupSpec{
Title: "template-group-1",
Content: `{{ define "test" }} test {{ end }}`,
Kind: v0alpha1.TemplateGroupTemplateKindGrafana,
},
}, v1.CreateOptions{})
require.NoError(t, err)
@@ -428,6 +432,7 @@ func TestIntegrationOptimisticConcurrency(t *testing.T) {
Spec: v0alpha1.TemplateGroupSpec{
Title: "template-group-1",
Content: `{{ define "test" }} test {{ end }}`,
Kind: v0alpha1.TemplateGroupTemplateKindGrafana,
},
}
@@ -510,6 +515,7 @@ func TestIntegrationPatch(t *testing.T) {
Spec: v0alpha1.TemplateGroupSpec{
Title: "template-group",
Content: `{{ define "test" }} test {{ end }}`,
Kind: v0alpha1.TemplateGroupTemplateKindGrafana,
},
}
@@ -568,6 +574,7 @@ func TestIntegrationListSelector(t *testing.T) {
Spec: v0alpha1.TemplateGroupSpec{
Title: "test1",
Content: `{{ define "test1" }} test {{ end }}`,
Kind: v0alpha1.TemplateGroupTemplateKindGrafana,
},
}
template1, err := adminClient.Create(ctx, template1, v1.CreateOptions{})
@@ -580,6 +587,7 @@ func TestIntegrationListSelector(t *testing.T) {
Spec: v0alpha1.TemplateGroupSpec{
Title: "test2",
Content: `{{ define "test2" }} test {{ end }}`,
Kind: v0alpha1.TemplateGroupTemplateKindGrafana,
},
}
template2, err = adminClient.Create(ctx, template2, v1.CreateOptions{})
@@ -655,3 +663,37 @@ func TestIntegrationListSelector(t *testing.T) {
require.NotEqualf(t, templates.DefaultTemplateName, list.Items[1].Name, "Expected non-default template but got %s", list.Items[1].Name)
})
}
func TestIntegrationKinds(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
ctx := context.Background()
helper := getTestHelper(t)
client := common.NewTemplateGroupClient(t, helper.Org1.Admin)
newTemplate := &v0alpha1.TemplateGroup{
ObjectMeta: v1.ObjectMeta{
Namespace: "default",
},
Spec: v0alpha1.TemplateGroupSpec{
Title: "templateGroup",
Content: `{{ define "test" }} test {{ end }}`,
Kind: v0alpha1.TemplateGroupTemplateKindMimir,
},
}
t.Run("should not let create Mimir template", func(t *testing.T) {
_, err := client.Create(ctx, newTemplate, v1.CreateOptions{})
require.Truef(t, errors.IsBadRequest(err), "expected bad request but got %s", err)
})
t.Run("should not let change kind", func(t *testing.T) {
newTemplate.Spec.Kind = v0alpha1.TemplateGroupTemplateKindGrafana
created, err := client.Create(ctx, newTemplate, v1.CreateOptions{})
require.NoError(t, err)
created.Spec.Kind = v0alpha1.TemplateGroupTemplateKindMimir
_, err = client.Update(ctx, created, v1.UpdateOptions{})
require.Truef(t, errors.IsBadRequest(err), "expected bad request but got %s", err)
})
}
@@ -0,0 +1,15 @@
alertmanager_config: |
route:
receiver: sinkhole
group_by:
- alertname
receivers:
- name: sinkhole
template_files:
imported: |
{{ define "imported" }}
{{ end }}
template: |
{{ define "template" }}
{{ template "imported" . }}
{{ end }}
@@ -3783,18 +3783,29 @@
"type": "object",
"required": [
"title",
"content"
"content",
"kind"
],
"properties": {
"content": {
"type": "string"
},
"kind": {
"$ref": "#/components/schemas/com.github.grafana.grafana.apps.alerting.notifications.pkg.apis.alertingnotifications.v0alpha1.TemplateGroupTemplateKind"
},
"title": {
"type": "string"
}
},
"additionalProperties": false
},
"com.github.grafana.grafana.apps.alerting.notifications.pkg.apis.alertingnotifications.v0alpha1.TemplateGroupTemplateKind": {
"type": "string",
"enum": [
"grafana",
"mimir"
]
},
"com.github.grafana.grafana.apps.alerting.notifications.pkg.apis.alertingnotifications.v0alpha1.TimeInterval": {
"type": "object",
"required": [
@@ -5295,10 +5295,6 @@
"com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.MigrateJobOptions": {
"type": "object",
"properties": {
"history": {
"description": "Preserve history (if possible)",
"type": "boolean"
},
"message": {
"description": "Message to use when committing the changes in a single commit",
"type": "string"
@@ -5828,10 +5824,6 @@
"kind": {
"description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
"type": "string"
},
"legacyStorage": {
"description": "The backend is using legacy storage FIXME: Not sure where this should be exposed... but we need it somewhere The UI should force the onboarding workflow when this is true",
"type": "boolean"
}
}
},
+7
View File
@@ -10,7 +10,9 @@ import (
"k8s.io/apimachinery/pkg/util/version"
"k8s.io/client-go/kubernetes"
"github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tests/testinfra"
"github.com/grafana/grafana/pkg/tests/testsuite"
"github.com/grafana/grafana/pkg/util/testutil"
@@ -37,6 +39,11 @@ func TestIntegrationOpenAPIs(t *testing.T) {
featuremgmt.FlagKubernetesAlertingHistorian,
featuremgmt.FlagKubernetesLogsDrilldown,
},
// Explicitly configure with mode 5 the resources supported by provisioning.
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode5},
"folders.folder.grafana.app": {DualWriterMode: rest.Mode5},
},
})
t.Run("check valid version response", func(t *testing.T) {
+14 -14
View File
@@ -28,7 +28,7 @@ func TestIntegrationPluginMeta(t *testing.T) {
"apiVersion": "plugins.grafana.app/v0alpha1",
"kind": "Plugin",
"metadata": {"name": "%s"},
"spec": {"id": "grafana-piechart-panel", "version": "1.0.0"}
"spec": {"id": "piechart", "version": "1.0.0"}
}`, plugin1Name))
_, err := client.Resource.Create(ctx, plugin1, metav1.CreateOptions{})
require.NoError(t, err)
@@ -38,7 +38,7 @@ func TestIntegrationPluginMeta(t *testing.T) {
"apiVersion": "plugins.grafana.app/v0alpha1",
"kind": "Plugin",
"metadata": {"name": "%s"},
"spec": {"id": "grafana-clock-panel", "version": "1.0.0"}
"spec": {"id": "table", "version": "1.0.0"}
}`, plugin2Name))
_, err = client.Resource.Create(ctx, plugin2, metav1.CreateOptions{})
require.NoError(t, err)
@@ -57,14 +57,14 @@ func TestIntegrationPluginMeta(t *testing.T) {
foundIDs := make(map[string]bool)
for _, item := range response.Result.Items {
require.NotNil(t, item.Spec.PluginJSON)
foundIDs[item.Spec.PluginJSON.Id] = true
require.NotEmpty(t, item.Spec.PluginJSON.Id)
require.NotEmpty(t, item.Spec.PluginJSON.Type)
require.NotEmpty(t, item.Spec.PluginJSON.Name)
require.NotNil(t, item.Spec.PluginJson)
foundIDs[item.Spec.PluginJson.Id] = true
require.NotEmpty(t, item.Spec.PluginJson.Id)
require.NotEmpty(t, item.Spec.PluginJson.Type)
require.NotEmpty(t, item.Spec.PluginJson.Name)
}
require.True(t, foundIDs["grafana-piechart-panel"])
require.True(t, foundIDs["grafana-clock-panel"])
require.True(t, foundIDs["piechart"])
require.True(t, foundIDs["table"])
})
t.Run("list plugin metas with no plugins", func(t *testing.T) {
@@ -95,7 +95,7 @@ func TestIntegrationPluginMeta(t *testing.T) {
"apiVersion": "plugins.grafana.app/v0alpha1",
"kind": "Plugin",
"metadata": {"name": "%s"},
"spec": {"id": "grafana-piechart-panel", "version": "1.0.0"}
"spec": {"id": "piechart", "version": "1.0.0"}
}`, pluginName))
_, err := client.Resource.Create(ctx, plugin, metav1.CreateOptions{})
require.NoError(t, err)
@@ -109,10 +109,10 @@ func TestIntegrationPluginMeta(t *testing.T) {
}, &pluginsv0alpha1.Meta{})
require.NotNil(t, response.Result)
require.NotNil(t, response.Result.Spec.PluginJSON)
require.Equal(t, "grafana-piechart-panel", response.Result.Spec.PluginJSON.Id)
require.NotEmpty(t, response.Result.Spec.PluginJSON.Name)
require.NotEmpty(t, response.Result.Spec.PluginJSON.Type)
require.NotNil(t, response.Result.Spec.PluginJson)
require.Equal(t, "piechart", response.Result.Spec.PluginJson.Id)
require.NotEmpty(t, response.Result.Spec.PluginJson.Name)
require.NotEmpty(t, response.Result.Spec.PluginJson.Type)
})
t.Run("get plugin meta for non-existent plugin", func(t *testing.T) {
+7 -2
View File
@@ -681,12 +681,17 @@ func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper
EnableFeatureToggles: []string{
featuremgmt.FlagProvisioning,
},
// Provisioning requires resources to be fully migrated to unified storage.
// Mode5 ensures reads/writes go to unified storage, and EnableMigration
// enables the data migration at startup to migrate legacy data.
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {
DualWriterMode: grafanarest.Mode5,
DualWriterMode: grafanarest.Mode5,
EnableMigration: true,
},
"folders.folder.grafana.app": {
DualWriterMode: grafanarest.Mode5,
DualWriterMode: grafanarest.Mode5,
EnableMigration: true,
},
},
PermittedProvisioningPaths: ".|" + provisioningPath,