Merge remote-tracking branch 'origin/main' into ds-apiserver-schema-builder
This commit is contained in:
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |  |
|
||||
| `grafana.plugin.source.class` | string | The plugin source class. | `core` | `Recommended` |  |
|
||||
| `grafana.plugin.type` | string | The plugin type. | `datasource` | `Recommended` |  |
|
||||
<!-- endsemconv -->
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Generated
+2
-2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+1
@@ -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
|
||||
|
||||
|
+13
@@ -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}})
|
||||
}
|
||||
|
||||
@@ -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 := `
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 }}
|
||||
;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
+12
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user