K8s: Fix legacy fallback provisioning (#100566)

This commit is contained in:
Stephanie Hingtgen
2025-02-13 21:32:25 +02:00
committed by GitHub
parent 3b694785f3
commit e2081c3e0c
14 changed files with 744 additions and 153 deletions
@@ -23,7 +23,6 @@ import (
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/dashboards/service"
"github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/storage/legacysql"
"github.com/grafana/grafana/pkg/storage/unified/resource"
@@ -312,7 +311,7 @@ func (a *dashboardSqlAccess) scanRow(rows *sql.Rows, history bool) (*dashboardRo
ts := time.Unix(origin_ts.Int64, 0)
repo := &utils.ResourceRepositoryInfo{
Name: origin_name.String,
Name: dashboard.ProvisionedFileNameWithPrefix(origin_name.String),
Hash: origin_hash.String,
Timestamp: &ts,
}
@@ -331,7 +330,7 @@ func (a *dashboardSqlAccess) scanRow(rows *sql.Rows, history bool) (*dashboardRo
meta.SetRepositoryInfo(repo)
} else if plugin_id.String != "" {
meta.SetRepositoryInfo(&utils.ResourceRepositoryInfo{
Name: "plugin",
Name: dashboard.PluginIDRepoName,
Path: plugin_id.String,
})
}
@@ -427,7 +426,7 @@ func (a *dashboardSqlAccess) SaveDashboard(ctx context.Context, orgId int64, das
out, err := a.dashStore.SaveDashboard(ctx, dashboards.SaveDashboardCommand{
OrgID: orgId,
Message: meta.GetMessage(),
PluginID: service.GetPluginIDFromMeta(meta),
PluginID: dashboard.GetPluginIDFromMeta(meta),
Dashboard: simplejson.NewFromAny(dash.Spec.UnstructuredContent()),
FolderUID: meta.GetFolder(),
Overwrite: true, // already passed the revisionVersion checks!
@@ -0,0 +1,110 @@
package legacy
import (
"context"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/services/provisioning"
"github.com/stretchr/testify/require"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestScanRow(t *testing.T) {
mockDB, mock, err := sqlmock.New()
require.NoError(t, err)
defer mockDB.Close() // nolint:errcheck
pathToFile := "path/to/file"
provisioner := provisioning.NewProvisioningServiceMock(context.Background())
provisioner.GetDashboardProvisionerResolvedPathFunc = func(name string) string { return "provisioner" }
store := &dashboardSqlAccess{
namespacer: func(_ int64) string { return "default" },
provisioning: provisioner,
}
columns := []string{"orgId", "dashboard_id", "name", "folder_uid", "deleted", "plugin_id", "origin_name", "origin_path", "origin_hash", "origin_ts", "created", "createdBy", "createdByID", "updated", "updatedBy", "updatedByID", "version", "message", "data"}
id := int64(100)
title := "Test Dashboard"
folderUID := "folder123"
timestamp := time.Now()
k8sTimestamp := v1.Time{Time: timestamp}
version := int64(2)
message := "updated message"
createdUser := "creator"
updatedUser := "updator"
t.Run("Should scan a valid row correctly", func(t *testing.T) {
rows := sqlmock.NewRows(columns).AddRow(1, id, title, folderUID, nil, "", "", "", "", 0, timestamp, createdUser, 0, timestamp, updatedUser, 0, version, message, []byte(`{"key": "value"}`))
mock.ExpectQuery("SELECT *").WillReturnRows(rows)
resultRows, err := mockDB.Query("SELECT *")
require.NoError(t, err)
defer resultRows.Close() // nolint:errcheck
resultRows.Next()
row, err := store.scanRow(resultRows, false)
require.NoError(t, err)
require.NotNil(t, row)
require.Equal(t, "Test Dashboard", row.Dash.Name)
require.Equal(t, version, row.RV) // rv should be the dashboard version
require.Equal(t, v0alpha1.Unstructured{
Object: map[string]interface{}{"key": "value"},
}, row.Dash.Spec)
require.Equal(t, "default", row.Dash.Namespace)
require.Equal(t, &continueToken{orgId: int64(1), id: id}, row.token)
meta, err := utils.MetaAccessor(row.Dash)
require.NoError(t, err)
require.Equal(t, id, meta.GetDeprecatedInternalID()) // nolint:staticcheck
require.Equal(t, version, meta.GetGeneration()) // generation should be dash version
require.Equal(t, k8sTimestamp, meta.GetCreationTimestamp())
require.Equal(t, "user:"+createdUser, meta.GetCreatedBy()) // should be prefixed by user:
require.Equal(t, "user:"+updatedUser, meta.GetUpdatedBy()) // should be prefixed by user:
require.Equal(t, message, meta.GetMessage())
require.Equal(t, folderUID, meta.GetFolder())
})
t.Run("File provisioned dashboard should have annotations", func(t *testing.T) {
rows := sqlmock.NewRows(columns).AddRow(1, id, title, folderUID, nil, "", "provisioner", pathToFile, "hashing", 100000, timestamp, createdUser, 0, timestamp, updatedUser, 0, version, message, []byte(`{"key": "value"}`))
mock.ExpectQuery("SELECT *").WillReturnRows(rows)
resultRows, err := mockDB.Query("SELECT *")
require.NoError(t, err)
defer resultRows.Close() // nolint:errcheck
resultRows.Next()
row, err := store.scanRow(resultRows, false)
require.NoError(t, err)
require.NotNil(t, row)
meta, err := utils.MetaAccessor(row.Dash)
require.NoError(t, err)
require.Equal(t, "file:provisioner", meta.GetRepositoryName()) // should be prefixed by file:
require.Equal(t, "../"+pathToFile, meta.GetRepositoryPath()) // relative to provisioner
require.Equal(t, "hashing", meta.GetRepositoryHash())
ts, err := meta.GetRepositoryTimestamp()
require.NoError(t, err)
require.Equal(t, int64(100000), ts.Unix())
})
t.Run("Plugin provisioned dashboard should have annotations", func(t *testing.T) {
rows := sqlmock.NewRows(columns).AddRow(1, id, title, folderUID, nil, "slo", "", "", "", 0, timestamp, createdUser, 0, timestamp, updatedUser, 0, version, message, []byte(`{"key": "value"}`))
mock.ExpectQuery("SELECT *").WillReturnRows(rows)
resultRows, err := mockDB.Query("SELECT *")
require.NoError(t, err)
defer resultRows.Close() // nolint:errcheck
resultRows.Next()
row, err := store.scanRow(resultRows, false)
require.NoError(t, err)
require.NotNil(t, row)
meta, err := utils.MetaAccessor(row.Dash)
require.NoError(t, err)
require.Equal(t, "plugin", meta.GetRepositoryName())
require.Equal(t, "slo", meta.GetRepositoryPath()) // the ID of the plugin
require.Equal(t, "", meta.GetRepositoryHash()) // hash is not used on plugins
})
}
@@ -18,6 +18,7 @@ import (
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"google.golang.org/grpc"
"k8s.io/apimachinery/pkg/selection"
)
type DashboardSearchClient struct {
@@ -29,6 +30,7 @@ func NewDashboardSearchClient(dashboardStore dashboards.Store) *DashboardSearchC
return &DashboardSearchClient{dashboardStore: dashboardStore}
}
// nolint:gocyclo
func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.ResourceSearchRequest, opts ...grpc.CallOption) (*resource.ResourceSearchResponse, error) {
user, err := identity.GetRequester(ctx)
if err != nil {
@@ -103,6 +105,8 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
}
for _, field := range req.Options.Fields {
vals := field.GetValues()
switch field.Key {
case resource.SEARCH_FIELD_TAGS:
query.Tags = field.GetValues()
@@ -110,7 +114,6 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
query.DashboardUIDs = field.GetValues()
query.DashboardIds = nil
case resource.SEARCH_FIELD_FOLDER:
vals := field.GetValues()
folders := make([]string, len(vals))
for i, val := range vals {
@@ -122,12 +125,28 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
}
query.FolderUIDs = folders
}
}
case resource.SEARCH_FIELD_REPOSITORY_PATH:
// only one value is supported in legacy search
if len(vals) != 1 {
return nil, fmt.Errorf("only one repo path query is supported")
}
query.ProvisionedPath = vals[0]
case resource.SEARCH_FIELD_REPOSITORY_NAME:
if field.Operator == string(selection.NotIn) {
for _, val := range vals {
name, _ := dashboard.GetProvisionedFileNameFromMeta(val)
query.ProvisionedReposNotIn = append(query.ProvisionedReposNotIn, name)
}
continue
}
res, err := c.dashboardStore.FindDashboards(ctx, query)
if err != nil {
return nil, err
// only one value is supported in legacy search
if len(vals) != 1 {
return nil, fmt.Errorf("only one repo name is supported")
}
query.ProvisionedRepo, _ = dashboard.GetProvisionedFileNameFromMeta(vals[0])
}
}
searchFields := resource.StandardSearchFields()
@@ -141,6 +160,41 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
},
}
// if we are querying for provisioning information, we need to use a different
// legacy sql query, since legacy search does not support this
if query.ProvisionedRepo != "" || len(query.ProvisionedReposNotIn) > 0 {
var dashes []*dashboards.Dashboard
if query.ProvisionedRepo == dashboard.PluginIDRepoName {
dashes, err = c.dashboardStore.GetDashboardsByPluginID(ctx, &dashboards.GetDashboardsByPluginIDQuery{
PluginID: query.ProvisionedPath,
OrgID: user.GetOrgID(),
})
} else if query.ProvisionedRepo != "" {
dashes, err = c.dashboardStore.GetProvisionedDashboardsByName(ctx, query.ProvisionedRepo)
} else if len(query.ProvisionedReposNotIn) > 0 {
dashes, err = c.dashboardStore.GetOrphanedProvisionedDashboards(ctx, query.ProvisionedReposNotIn)
}
if err != nil {
return nil, err
}
for _, dashboard := range dashes {
list.Results.Rows = append(list.Results.Rows, &resource.ResourceTableRow{
Key: getResourceKey(&dashboards.DashboardSearchProjection{
UID: dashboard.UID,
}, req.Options.Key.Namespace),
Cells: [][]byte{[]byte(dashboard.Title), []byte(dashboard.FolderUID), []byte{}},
})
}
return list, nil
}
res, err := c.dashboardStore.FindDashboards(ctx, query)
if err != nil {
return nil, err
}
hits := formatQueryResult(res)
for _, dashboard := range hits {
@@ -0,0 +1,263 @@
package legacysearcher
import (
"context"
"encoding/json"
"testing"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/apis/dashboard"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/selection"
)
func TestDashboardSearchClient_Search(t *testing.T) {
mockStore := dashboards.NewFakeDashboardStore(t)
client := NewDashboardSearchClient(mockStore)
ctx := context.Background()
user := &user.SignedInUser{OrgID: 2}
ctx = identity.WithRequester(ctx, user)
dashboardKey := &resource.ResourceKey{
Name: "uid",
Resource: dashboard.DASHBOARD_RESOURCE,
}
t.Run("Should parse results into GRPC", func(t *testing.T) {
mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{
SignedInUser: user, // user from context should be used
Type: "dash-db", // should set type based off of key
}).Return([]dashboards.DashboardSearchProjection{
{UID: "uid", Title: "Test Dashboard", FolderUID: "folder1", Term: "term"},
{UID: "uid2", Title: "Test Dashboard2", FolderUID: "folder2"},
}, nil).Once()
req := &resource.ResourceSearchRequest{
Options: &resource.ListOptions{
Key: dashboardKey,
},
}
resp, err := client.Search(ctx, req)
require.NoError(t, err)
tags, err := json.Marshal([]string{"term"})
require.NoError(t, err)
emptyTags, err := json.Marshal([]string{})
require.NoError(t, err)
require.NotNil(t, resp)
searchFields := resource.StandardSearchFields()
require.Equal(t, &resource.ResourceSearchResponse{
Results: &resource.ResourceTable{
Columns: []*resource.ResourceTableColumnDefinition{
searchFields.Field(resource.SEARCH_FIELD_TITLE),
searchFields.Field(resource.SEARCH_FIELD_FOLDER),
searchFields.Field(resource.SEARCH_FIELD_TAGS),
},
Rows: []*resource.ResourceTableRow{
{
Key: &resource.ResourceKey{
Name: "uid",
Group: dashboard.GROUP,
Resource: dashboard.DASHBOARD_RESOURCE,
},
Cells: [][]byte{
[]byte("Test Dashboard"),
[]byte("folder1"),
tags,
},
},
{
Key: &resource.ResourceKey{
Name: "uid2",
Group: dashboard.GROUP,
Resource: dashboard.DASHBOARD_RESOURCE,
},
Cells: [][]byte{
[]byte("Test Dashboard2"),
[]byte("folder2"),
emptyTags,
},
},
},
},
}, resp)
mockStore.AssertExpectations(t)
})
t.Run("Query should be set as the title, and * should be removed", func(t *testing.T) {
mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{
Title: "test",
SignedInUser: user, // user from context should be used
Type: "dash-db", // should set type based off of key
}).Return([]dashboards.DashboardSearchProjection{
{UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"},
}, nil).Once()
req := &resource.ResourceSearchRequest{
Options: &resource.ListOptions{
Key: dashboardKey,
},
Query: "*test*",
}
resp, err := client.Search(ctx, req)
require.NoError(t, err)
require.NotNil(t, resp)
mockStore.AssertExpectations(t)
})
t.Run("Should read labels for the dashboard ids", func(t *testing.T) {
mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{
DashboardIds: []int64{1, 2},
SignedInUser: user, // user from context should be used
Type: "dash-db", // should set type based off of key
}).Return([]dashboards.DashboardSearchProjection{
{UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"},
}, nil).Once()
req := &resource.ResourceSearchRequest{
Options: &resource.ListOptions{
Key: dashboardKey,
Labels: []*resource.Requirement{
{
Key: utils.LabelKeyDeprecatedInternalID,
Operator: "in",
Values: []string{"1", "2"},
},
},
},
}
resp, err := client.Search(ctx, req)
require.NoError(t, err)
require.NotNil(t, resp)
mockStore.AssertExpectations(t)
})
t.Run("Should modify fields to legacy compatible queries", func(t *testing.T) {
mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{
DashboardUIDs: []string{"uid1", "uid2"},
Tags: []string{"tag1", "tag2"},
FolderUIDs: []string{"general", "folder1"},
SignedInUser: user, // user from context should be used
Type: "dash-db", // should set type based off of key
}).Return([]dashboards.DashboardSearchProjection{
{UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"},
}, nil).Once()
req := &resource.ResourceSearchRequest{
Options: &resource.ListOptions{
Key: dashboardKey,
Fields: []*resource.Requirement{
{
Key: resource.SEARCH_FIELD_TAGS,
Operator: "in",
Values: []string{"tag1", "tag2"},
},
{
Key: resource.SEARCH_FIELD_NAME, // name should be used as uid
Operator: "in",
Values: []string{"uid1", "uid2"},
},
{
Key: resource.SEARCH_FIELD_FOLDER,
Operator: "in",
Values: []string{"", "folder1"}, // empty folder should be general
},
},
},
}
resp, err := client.Search(ctx, req)
require.NoError(t, err)
require.NotNil(t, resp)
mockStore.AssertExpectations(t)
})
t.Run("Should retrieve dashboards by plugin through a different function", func(t *testing.T) {
mockStore.On("GetDashboardsByPluginID", mock.Anything, &dashboards.GetDashboardsByPluginIDQuery{
PluginID: "slo",
OrgID: 2, // retrieved from the signed in user
}).Return([]*dashboards.Dashboard{
{UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"},
}, nil).Once()
req := &resource.ResourceSearchRequest{
Options: &resource.ListOptions{
Key: dashboardKey,
Fields: []*resource.Requirement{
{
Key: resource.SEARCH_FIELD_REPOSITORY_PATH,
Operator: "in",
Values: []string{"slo"},
},
{
Key: resource.SEARCH_FIELD_REPOSITORY_NAME,
Operator: "in",
Values: []string{"plugin"},
},
},
},
}
resp, err := client.Search(ctx, req)
require.NoError(t, err)
require.NotNil(t, resp)
mockStore.AssertExpectations(t)
})
t.Run("Should retrieve dashboards by provisioner name through a different function", func(t *testing.T) {
mockStore.On("GetProvisionedDashboardsByName", mock.Anything, "test").Return([]*dashboards.Dashboard{
{UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"},
}, nil).Once()
req := &resource.ResourceSearchRequest{
Options: &resource.ListOptions{
Key: dashboardKey,
Fields: []*resource.Requirement{
{
Key: resource.SEARCH_FIELD_REPOSITORY_NAME,
Operator: "in",
Values: []string{"file:test"}, // file prefix should be removed before going to legacy
},
},
},
}
resp, err := client.Search(ctx, req)
require.NoError(t, err)
require.NotNil(t, resp)
mockStore.AssertExpectations(t)
})
t.Run("Should retrieve orphaned dashboards if provisioner not in is specified", func(t *testing.T) {
mockStore.On("GetOrphanedProvisionedDashboards", mock.Anything, []string{"test", "test2"}).Return([]*dashboards.Dashboard{
{UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"},
}, nil).Once()
req := &resource.ResourceSearchRequest{
Options: &resource.ListOptions{
Key: dashboardKey,
Fields: []*resource.Requirement{
{
Key: resource.SEARCH_FIELD_REPOSITORY_NAME,
Operator: string(selection.NotIn),
Values: []string{"file:test", "file:test2"}, // file prefix should be removed before going to legacy
},
},
},
}
resp, err := client.Search(ctx, req)
require.NoError(t, err)
require.NotNil(t, resp)
mockStore.AssertExpectations(t)
})
}
+1 -1
View File
@@ -125,7 +125,7 @@ func (r *DTOConnector) Connect(ctx context.Context, name string, opts runtime.Ob
if err != nil {
return nil, err
}
if repo != nil && repo.Name == "plugin" {
if repo != nil && repo.Name == dashboard.PluginIDRepoName {
dto.PluginID = repo.Path
}