Merge remote-tracking branch 'origin/main' into ds-apiserver-schema-builder
This commit is contained in:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user