Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync

This commit is contained in:
Mariell Hoversholm committed 2025-01-30 08:35:14 +01:00
commit 69027482b7
49 files changed
+778 -242

No files matched your search

@@ -479,6 +479,7 @@ The following sections detail the supported settings and secure settings for eac
| mentionGroups | |
| mentionChannel | |
| token | yes |
| color | |
#### Alert notification `victorops`
@@ -511,6 +511,8 @@ settings:
# <string>
endpointUrl: https://custom_url/api/chat.postMessage
# <string>
color: {{ if eq .Status "firing" }}#D63232{{ else }}#36a64f{{ end }}
# <string>
title: |
{{ template "slack.default.title" . }}
text: |
+1 -1
View File
@@ -70,7 +70,7 @@ require (
github.com/googleapis/gax-go/v2 v2.14.1 // @grafana/grafana-backend-group
github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group
github.com/gorilla/websocket v1.5.3 // @grafana/grafana-app-platform-squad
github.com/grafana/alerting v0.0.0-20250117230852-a5e8136407d4 // @grafana/alerting-backend
github.com/grafana/alerting v0.0.0-20250129195454-3e5b80036b7a // @grafana/alerting-backend
github.com/grafana/authlib v0.0.0-20250123104008-e99947858901 // @grafana/identity-access-team
github.com/grafana/authlib/types v0.0.0-20250120145936-5f0e28e7a87c // @grafana/identity-access-team
github.com/grafana/dashboard-linter v0.0.0-20241106223805-1e7999311752 // @grafana/grafana-app-platform-squad
+2 -2
View File
@@ -1500,8 +1500,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grafana/alerting v0.0.0-20250117230852-a5e8136407d4 h1:616HUg7WVyLJLtVvk2pZ845M4Gk0fswgTwEZSuAZbJU=
github.com/grafana/alerting v0.0.0-20250117230852-a5e8136407d4/go.mod h1:QsnoKX/iYZxA4Cv+H+wC7uxutBD8qi8ZW5UJvD2TYmU=
github.com/grafana/alerting v0.0.0-20250129195454-3e5b80036b7a h1:44E+I3EPdh/W02Uyfyig86EJKPjvzcF3y0A+FEi1fBk=
github.com/grafana/alerting v0.0.0-20250129195454-3e5b80036b7a/go.mod h1:QsnoKX/iYZxA4Cv+H+wC7uxutBD8qi8ZW5UJvD2TYmU=
github.com/grafana/authlib v0.0.0-20250123104008-e99947858901 h1:nqV1YrtX+ZG+EYB5dcmFMWhg2Y038OMaAHAADbOC9RA=
github.com/grafana/authlib v0.0.0-20250123104008-e99947858901/go.mod h1:/gYfphsNu9v1qYWXxpv1NSvMEMSwvdf8qb8YlgwIRl8=
github.com/grafana/authlib/types v0.0.0-20250120145936-5f0e28e7a87c h1:b0sPDtt33uFdmvUJjSCld3kwE2E49dUvevuUDSJsEuo=
+1 -6
View File
@@ -1458,8 +1458,6 @@ github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/cel-go v0.22.0/go.mod h1:BuznPXXfQDpXKWQ9sPW3TzlAJN5zzFe+i9tIs0yC4s8=
github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-github/v66 v66.0.0 h1:ADJsaXj9UotwdgK8/iFZtv7MLc8E8WBl62WLd/D/9+M=
github.com/google/go-github/v66 v66.0.0/go.mod h1:+4SO9Zkuyf8ytMj0csN1NR/5OTR+MfqPp8P8dVlcvY4=
github.com/google/go-jsonnet v0.18.0 h1:/6pTy6g+Jh1a1I2UMoAODkqELFiVIdOxbNwv0DDzoOg=
github.com/google/go-jsonnet v0.18.0/go.mod h1:C3fTzyVJDslXdiTqw/bTFk7vSGyCtH3MGRbDfvEwGd0=
github.com/google/go-pkcs11 v0.2.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY=
@@ -1504,19 +1502,16 @@ github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB7
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grafana/alerting v0.0.0-20250115195200-209e052dba64/go.mod h1:QsnoKX/iYZxA4Cv+H+wC7uxutBD8qi8ZW5UJvD2TYmU=
github.com/grafana/alerting v0.0.0-20250117230852-a5e8136407d4/go.mod h1:QsnoKX/iYZxA4Cv+H+wC7uxutBD8qi8ZW5UJvD2TYmU=
github.com/grafana/authlib v0.0.0-20250120144156-d6737a7dc8f5/go.mod h1:V63rh3udd7sqXJeaG+nGUmViwVnM/bY6t8U9Tols2GU=
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 h1:3DHH81RJCi8Bcgn2MdBh7vgWUshmAFjZzBCVuxiQ0uk=
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335/go.mod h1:r+F8H6awwjNQt/KPZ2GNwjk8TvsJ7/gxzkXN26GlL/A=
github.com/grafana/authlib/types v0.0.0-20250120144156-d6737a7dc8f5/go.mod h1:qYjSd1tmJiuVoSICp7Py9/zD54O9uQQA3wuM6Gg4DFM=
github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2 h1:qhugDMdQ4Vp68H0tp/0iN17DM2ehRo1rLEdOFe/gB8I=
github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2/go.mod h1:w/aiO1POVIeXUQyl0VQSZjl5OAGDTL5aX+4v0RA1tcw=
github.com/grafana/dashboard-linter v0.0.0-20241106223805-1e7999311752 h1:pIEPQPua6kCkMib/nOn//WLgtiA4urf2RPBViSF6CcE=
github.com/grafana/dashboard-linter v0.0.0-20241106223805-1e7999311752/go.mod h1:QRxC8NNAbpGx2vz9/XckJcWXtHU3My57byeudPrf0WU=
github.com/grafana/go-gelf/v2 v2.0.1 h1:BOChP0h/jLeD+7F9mL7tq10xVkDG15he3T1zHuQaWak=
github.com/grafana/go-gelf/v2 v2.0.1/go.mod h1:lexHie0xzYGwCgiRGcvZ723bSNyNI8ZRD4s0CLobh90=
github.com/grafana/grafana-app-sdk v0.29.0/go.mod h1:XLt308EmK6kvqPlzjUyXxbwZKEk2vur/eiypUNDay5I=
github.com/grafana/grafana-foundation-sdk/go v0.0.0-20241101005901-83e3491f2a70 h1:69GI3KsF851YnwYp6zHdsskcGp3ZnGsWc+ve8vMp1mc=
github.com/grafana/grafana-foundation-sdk/go v0.0.0-20241101005901-83e3491f2a70/go.mod h1:WtWosval1KCZP9BGa42b8aVoJmVXSg0EvQXi9LDSVZQ=
github.com/grafana/regexp v0.0.0-20221122212121-6b5c0a4cb7fd/go.mod h1:M5qHK+eWfAv8VR/265dIuEpL3fNfeC21tXXp9itM24A=
github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0 h1:bjh0PVYSVVFxzINqPFYJmAmJNrWPgnVjuSdYJGHmtFU=
github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0/go.mod h1:7t5XR+2IA8P2qggOAHTj/GCZfoLBle3OvNSYh1VkRBU=
+1 -1
View File
@@ -490,7 +490,7 @@ func setupServer(b testing.TB, sc benchScenario, features featuremgmt.FeatureTog
SQLStore: sc.db,
Features: features,
QuotaService: quotaSrv,
SearchService: search.ProvideService(sc.cfg, sc.db, starSvc, dashboardSvc),
SearchService: search.ProvideService(sc.cfg, sc.db, starSvc, dashboardSvc, folderServiceWithFlagOn, features),
folderService: folderServiceWithFlagOn,
DashboardService: dashboardSvc,
}
+4 -1
View File
@@ -26,7 +26,10 @@ type StatusREST struct {
store *genericregistry.Store
}
var _ = rest.Patcher(&StatusREST{})
var (
_ rest.Patcher = (*StatusREST)(nil)
_ rest.Storage = (*StatusREST)(nil)
)
// New creates a new DataPlaneService object.
func (r *StatusREST) New() runtime.Object {
+1 -1
View File
@@ -37,7 +37,7 @@ func ProvideRegistryServiceSink(
_ *query.QueryAPIBuilder,
_ *notifications.NotificationsAPIBuilder,
_ *userstorage.UserStorageAPIBuilder,
_ *provisioning.ProvisioningAPIBuilder,
_ *provisioning.APIBuilder,
) *Service {
return &Service{}
}
+35 -34
View File
@@ -48,12 +48,10 @@ import (
const repoControllerWorkers = 1
var (
_ builder.APIGroupBuilder = (*ProvisioningAPIBuilder)(nil)
_ RepoGetter = (*ProvisioningAPIBuilder)(nil)
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
)
// This is used just so wire has something unique to return
type ProvisioningAPIBuilder struct {
type APIBuilder struct {
urlProvider func(namespace string) string
webhookSecretKey string
@@ -71,10 +69,10 @@ type ProvisioningAPIBuilder struct {
lister resources.ResourceLister
}
// This constructor will be called when building a multi-tenant apiserveer
// Avoid adding anything that secretly requires additional hidden dependencies
// like *settings.Cfg or core grafana services that depend on database connections
func NewProvisioningAPIBuilder(
// NewAPIBuilder creates an API builder.
// It avoids anything that is core to Grafana, such that it can be used in a multi-tenant service down the line.
// This means there are no hidden dependencies, and no use of e.g. *settings.Cfg.
func NewAPIBuilder(
local *repository.LocalFolderResolver,
urlProvider func(namespace string) string,
webhookSecretKey string,
@@ -85,9 +83,9 @@ func NewProvisioningAPIBuilder(
blobstore blob.PublicBlobStore,
configProvider apiserver.RestConfigProvider,
ghFactory github.ClientFactory,
) *ProvisioningAPIBuilder {
) *APIBuilder {
clientFactory := resources.NewFactory(identities)
builder := &ProvisioningAPIBuilder{
return &APIBuilder{
urlProvider: urlProvider,
localFileResolver: local,
webhookSecretKey: webhookSecretKey,
@@ -103,10 +101,10 @@ func NewProvisioningAPIBuilder(
blobstore: blobstore,
jobs: jobs.NewJobQueue(50), // in memory for now
}
return builder
}
// RegisterAPIService returns an API builder, from [NewAPIBuilder]. It is called by Wire.
// This function happily uses services core to Grafana, and does not need to be multi-tenancy-compatible.
func RegisterAPIService(
// It is OK to use setting.Cfg here -- this is only used when running single tenant with a full setup
cfg *setting.Cfg,
@@ -118,9 +116,9 @@ func RegisterAPIService(
client resource.ResourceClient, // implements resource.RepositoryClient
configProvider apiserver.RestConfigProvider,
ghFactory github.ClientFactory,
) (*ProvisioningAPIBuilder, error) {
if !(features.IsEnabledGlobally(featuremgmt.FlagProvisioning) ||
features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs)) {
) (*APIBuilder, error) {
if !features.IsEnabledGlobally(featuremgmt.FlagProvisioning) &&
!features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
return nil, nil // skip registration unless opting into experimental apis OR the feature specifically
}
@@ -130,19 +128,23 @@ func RegisterAPIService(
return nil, err
}
builder := NewProvisioningAPIBuilder(&repository.LocalFolderResolver{
folderResolver := &repository.LocalFolderResolver{
PermittedPrefixes: cfg.PermittedProvisioningPaths,
HomePath: safepath.Clean(cfg.HomePath),
}, func(namespace string) string {
}
urlProvider := func(namespace string) string {
return cfg.AppURL
}, cfg.SecretKey, identities, features, render, client, store, configProvider, ghFactory)
}
builder := NewAPIBuilder(folderResolver, urlProvider, cfg.SecretKey, identities, features, render, client, store, configProvider, ghFactory)
apiregistration.RegisterAPI(builder)
return builder, nil
}
func (b *ProvisioningAPIBuilder) GetAuthorizer() authorizer.Authorizer {
func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer {
return authorizer.AuthorizerFunc(
func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
// TODO: Implement a better webhook authoriser somehow.
if a.GetSubresource() == "webhook" {
// for now????
return authorizer.DecisionAllow, "", nil
@@ -153,11 +155,11 @@ func (b *ProvisioningAPIBuilder) GetAuthorizer() authorizer.Authorizer {
})
}
func (b *ProvisioningAPIBuilder) GetGroupVersion() schema.GroupVersion {
func (b *APIBuilder) GetGroupVersion() schema.GroupVersion {
return provisioning.SchemeGroupVersion
}
func (b *ProvisioningAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
func (b *APIBuilder) InstallSchema(scheme *runtime.Scheme) error {
err := provisioning.AddToScheme(scheme)
if err != nil {
return err
@@ -174,19 +176,18 @@ func (b *ProvisioningAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
return scheme.SetVersionPriority(provisioning.SchemeGroupVersion)
}
func (b *ProvisioningAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
repositoryStorage, err := grafanaregistry.NewRegistryStore(opts.Scheme, provisioning.RepositoryResourceInfo, opts.OptsGetter)
if err != nil {
return fmt.Errorf("failed to create repository storage: %w", err)
}
b.getter = repositoryStorage
repositoryStatusStorage := grafanaregistry.NewRegistryStatusStore(opts.Scheme, repositoryStorage)
b.getter = repositoryStorage
storage := map[string]rest.Storage{}
storage[provisioning.JobResourceInfo.StoragePath()] = b.jobs
storage[provisioning.RepositoryResourceInfo.StoragePath()] = repositoryStorage
// Can be used by kubectl: kubectl --kubeconfig grafana.kubeconfig patch Repository local-devenv --type=merge --subresource=status --patch='status: {"currentGitCommit": "hello"}'
storage[provisioning.RepositoryResourceInfo.StoragePath("status")] = repositoryStatusStorage
storage[provisioning.RepositoryResourceInfo.StoragePath("webhook")] = &webhookConnector{
getter: b,
@@ -219,7 +220,7 @@ func (b *ProvisioningAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserv
return nil
}
func (b *ProvisioningAPIBuilder) GetRepository(ctx context.Context, name string) (repository.Repository, error) {
func (b *APIBuilder) GetRepository(ctx context.Context, name string) (repository.Repository, error) {
obj, err := b.getter.Get(ctx, name, &metav1.GetOptions{})
if err != nil {
return nil, err
@@ -231,7 +232,7 @@ func timeSince(when int64) time.Duration {
return time.Duration(time.Now().UnixMilli()-when) * time.Millisecond
}
func (b *ProvisioningAPIBuilder) GetHealthyRepository(ctx context.Context, name string) (repository.Repository, error) {
func (b *APIBuilder) GetHealthyRepository(ctx context.Context, name string) (repository.Repository, error) {
repo, err := b.GetRepository(ctx, name)
if err != nil {
return nil, err
@@ -274,7 +275,7 @@ func (b *ProvisioningAPIBuilder) GetHealthyRepository(ctx context.Context, name
return repo, err
}
func (b *ProvisioningAPIBuilder) asRepository(ctx context.Context, obj runtime.Object) (repository.Repository, error) {
func (b *APIBuilder) asRepository(ctx context.Context, obj runtime.Object) (repository.Repository, error) {
if obj == nil {
return nil, fmt.Errorf("missing repository object")
}
@@ -285,7 +286,7 @@ func (b *ProvisioningAPIBuilder) asRepository(ctx context.Context, obj runtime.O
return b.AsRepository(ctx, r)
}
func (b *ProvisioningAPIBuilder) AsRepository(ctx context.Context, r *provisioning.Repository) (repository.Repository, error) {
func (b *APIBuilder) AsRepository(ctx context.Context, r *provisioning.Repository) (repository.Repository, error) {
switch r.Spec.Type {
case provisioning.LocalRepositoryType:
return repository.NewLocal(r, b.localFileResolver), nil
@@ -309,7 +310,7 @@ func (b *ProvisioningAPIBuilder) AsRepository(ctx context.Context, r *provisioni
}
}
func (b *ProvisioningAPIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error {
func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error {
obj := a.GetObject()
if obj == nil || a.GetOperation() == admission.Connect {
@@ -342,7 +343,7 @@ func (b *ProvisioningAPIBuilder) Mutate(ctx context.Context, a admission.Attribu
return nil
}
func (b *ProvisioningAPIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
obj := a.GetObject()
if obj == nil || a.GetOperation() == admission.Connect {
return nil // This is normal for sub-resource
@@ -376,11 +377,11 @@ func (b *ProvisioningAPIBuilder) Validate(ctx context.Context, a admission.Attri
return nil
}
func (b *ProvisioningAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
func (b *APIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
return provisioning.GetOpenAPIDefinitions
}
func (b *ProvisioningAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
func (b *APIBuilder) GetAPIRoutes() *builder.APIRoutes {
defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} })
statsResult := defs["github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceStats"].Schema
@@ -447,7 +448,7 @@ func (b *ProvisioningAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
}
}
func (b *ProvisioningAPIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartHookFunc, error) {
func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartHookFunc, error) {
postStartHooks := map[string]genericapiserver.PostStartHookFunc{
"grafana-provisioning": func(postStartHookCtx genericapiserver.PostStartHookContext) error {
c, err := clientset.NewForConfig(postStartHookCtx.LoopbackClientConfig)
@@ -501,7 +502,7 @@ func (b *ProvisioningAPIBuilder) GetPostStartHooks() (map[string]genericapiserve
return postStartHooks, nil
}
func (b *ProvisioningAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) {
func (b *APIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) {
oas.Info.Description = "Provisioning"
root := "/apis/" + b.GetGroupVersion().String() + "/"
@@ -1702,6 +1702,8 @@ func (dr *DashboardServiceImpl) searchDashboardsThroughK8sRaw(ctx context.Contex
if query.Title != "" {
// allow wildcard search
request.Query = "*" + strings.ToLower(query.Title) + "*"
// if using query, you need to specify the fields you want
request.Fields = dashboardsearch.IncludeFields
}
if len(query.Tags) > 0 {
@@ -19,6 +19,21 @@ var (
resource.SEARCH_FIELD_FOLDER: "",
resource.SEARCH_FIELD_TAGS: "",
}
IncludeFields = []string{
resource.SEARCH_FIELD_TITLE,
resource.SEARCH_FIELD_TAGS,
resource.SEARCH_FIELD_LABELS,
resource.SEARCH_FIELD_FOLDER,
resource.SEARCH_FIELD_CREATED,
resource.SEARCH_FIELD_CREATED_BY,
resource.SEARCH_FIELD_UPDATED,
resource.SEARCH_FIELD_UPDATED_BY,
resource.SEARCH_FIELD_REPOSITORY_NAME,
resource.SEARCH_FIELD_REPOSITORY_PATH,
resource.SEARCH_FIELD_REPOSITORY_HASH,
resource.SEARCH_FIELD_REPOSITORY_TIME,
}
)
func ParseResults(result *resource.ResourceSearchResponse, offset int64) (*v0alpha1.SearchResults, error) {
@@ -31,7 +46,7 @@ func ParseResults(result *resource.ResourceSearchResponse, offset int64) (*v0alp
}
titleIDX := 0
folderIDX := 1
folderIDX := -1
tagsIDX := -1
scoreIDX := 0
explainIDX := 0
@@ -80,9 +95,11 @@ func ParseResults(result *resource.ResourceSearchResponse, offset int64) (*v0alp
Resource: row.Key.Resource, // folders | dashboards
Name: row.Key.Name, // The Grafana UID
Title: string(row.Cells[titleIDX]),
Folder: string(row.Cells[folderIDX]),
Field: fields,
}
if folderIDX > 0 && row.Cells[folderIDX] != nil {
hit.Folder = string(row.Cells[folderIDX])
}
if tagsIDX > 0 && row.Cells[tagsIDX] != nil {
_ = json.Unmarshal(row.Cells[tagsIDX], &hit.Tags)
}
+12
View File
@@ -34,6 +34,7 @@ import (
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/publicdashboards"
"github.com/grafana/grafana/pkg/services/search/model"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/services/store/entity"
@@ -167,6 +168,17 @@ func (s *Service) DBMigration(db db.DB) {
s.log.Debug("syncing dashboard and folder tables finished")
}
func (s *Service) SearchFolders(ctx context.Context, q folder.SearchFoldersQuery) (model.HitList, error) {
if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesFoldersServiceV2) {
// TODO:
// - implement filtering by alerting folders and k6 folders (see the dashboards store `FindDashboards` method for reference)
// - implement fallback on search client in unistore to go to legacy store (will need to read from dashboard store)
return s.searchFoldersFromApiServer(ctx, q)
}
return nil, fmt.Errorf("cannot be called on the legacy folder service")
}
func (s *Service) GetFolders(ctx context.Context, q folder.GetFoldersQuery) ([]*folder.Folder, error) {
if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesFoldersServiceV2) {
return s.getFoldersFromApiServer(ctx, q)
@@ -3,6 +3,7 @@ package folderimpl
import (
"context"
"fmt"
"strconv"
"strings"
"time"
@@ -20,6 +21,7 @@ import (
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
"github.com/grafana/grafana/pkg/events"
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/infra/slugify"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/dashboards"
@@ -28,9 +30,11 @@ import (
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/search/model"
"github.com/grafana/grafana/pkg/services/store/entity"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/search"
"github.com/grafana/grafana/pkg/util"
)
@@ -169,6 +173,103 @@ func (s *Service) getFromApiServer(ctx context.Context, q *folder.GetFolderQuery
return f, err
}
// searchFoldesFromApiServer uses the search grpc connection to search folders and returns the hit list
func (s *Service) searchFoldersFromApiServer(ctx context.Context, query folder.SearchFoldersQuery) (model.HitList, error) {
if query.OrgID == 0 {
requester, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
query.OrgID = requester.GetOrgID()
}
request := &resource.ResourceSearchRequest{
Options: &resource.ListOptions{
Key: &resource.ResourceKey{
Namespace: s.k8sclient.getNamespace(query.OrgID),
Group: v0alpha1.FolderResourceInfo.GroupVersionResource().Group,
Resource: v0alpha1.FolderResourceInfo.GroupVersionResource().Resource,
},
Fields: []*resource.Requirement{},
Labels: []*resource.Requirement{},
},
Limit: 100000}
if len(query.UIDs) > 0 {
request.Options.Fields = []*resource.Requirement{{
Key: resource.SEARCH_FIELD_NAME,
Operator: string(selection.In),
Values: query.UIDs,
}}
} else if len(query.IDs) > 0 {
values := make([]string, len(query.IDs))
for i, id := range query.IDs {
values[i] = strconv.FormatInt(id, 10)
}
request.Options.Labels = append(request.Options.Labels, &resource.Requirement{
Key: utils.LabelKeyDeprecatedInternalID, // nolint:staticcheck
Operator: string(selection.In),
Values: values,
})
}
if query.Title != "" {
// allow wildcard search
request.Query = "*" + strings.ToLower(query.Title) + "*"
// if using query, you need to specify the fields you want
request.Fields = dashboardsearch.IncludeFields
}
if query.Limit > 0 {
request.Limit = query.Limit
}
client := s.k8sclient.getSearcher(ctx)
res, err := client.Search(ctx, request)
if err != nil {
return nil, err
}
parsedResults, err := dashboardsearch.ParseResults(res, 0)
if err != nil {
return nil, err
}
hitList := make([]*model.Hit, len(parsedResults.Hits))
foldersMap := map[string]*folder.Folder{}
for i, item := range parsedResults.Hits {
f, ok := foldersMap[item.Folder]
if !ok {
f, err = s.Get(ctx, &folder.GetFolderQuery{
UID: &item.Folder,
OrgID: query.OrgID,
SignedInUser: query.SignedInUser,
})
if err != nil {
return nil, err
}
foldersMap[item.Folder] = f
}
slug := slugify.Slugify(item.Title)
hitList[i] = &model.Hit{
ID: item.Field.GetNestedInt64(search.DASHBOARD_LEGACY_ID),
UID: item.Name,
OrgID: query.OrgID,
Title: item.Title,
URI: "db/" + slug,
URL: dashboards.GetFolderURL(item.Name, slug),
Type: model.DashHitFolder,
FolderUID: item.Folder,
FolderTitle: f.Title,
FolderID: f.ID, // nolint:staticcheck
}
}
return hitList, nil
}
func (s *Service) getFolderByIDFromApiServer(ctx context.Context, id int64, orgID int64) (*folder.Folder, error) {
if id == 0 {
return &folder.GeneralFolder, nil
@@ -240,9 +341,9 @@ func (s *Service) getFolderByTitleFromApiServer(ctx context.Context, orgID int64
Key: folderkey,
Fields: []*resource.Requirement{
{
Key: resource.SEARCH_FIELD_TITLE_SORT,
Key: resource.SEARCH_FIELD_TITLE_PHRASE,
Operator: string(selection.In),
Values: []string{strings.ToLower(title)},
Values: []string{title},
},
},
Labels: []*resource.Requirement{},
@@ -13,6 +13,7 @@ import (
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"k8s.io/client-go/dynamic"
clientrest "k8s.io/client-go/rest"
"github.com/grafana/grafana/pkg/apimachinery/identity"
@@ -33,6 +34,7 @@ import (
"github.com/grafana/grafana/pkg/services/guardian"
ngstore "github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/publicdashboards"
"github.com/grafana/grafana/pkg/services/search/model"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/usertest"
@@ -567,7 +569,7 @@ func (r resourceClientMock) Search(ctx context.Context, in *resource.ResourceSea
}
if len(in.Options.Fields) > 0 &&
in.Options.Fields[0].Key == resource.SEARCH_FIELD_TITLE_SORT &&
in.Options.Fields[0].Key == resource.SEARCH_FIELD_TITLE_PHRASE &&
in.Options.Fields[0].Operator == "in" &&
len(in.Options.Fields[0].Values) > 0 &&
in.Options.Fields[0].Values[0] == "foo" {
@@ -605,6 +607,81 @@ func (r resourceClientMock) Search(ctx context.Context, in *resource.ResourceSea
}, nil
}
if in.Query == "*test*" {
return &resource.ResourceSearchResponse{
Results: &resource.ResourceTable{
Columns: []*resource.ResourceTableColumnDefinition{
{
Name: "_id",
Type: resource.ResourceTableColumnDefinition_STRING,
},
{
Name: "title",
Type: resource.ResourceTableColumnDefinition_STRING,
},
{
Name: "folder",
Type: resource.ResourceTableColumnDefinition_STRING,
},
},
Rows: []*resource.ResourceTableRow{
{
Key: &resource.ResourceKey{
Name: "uid",
Resource: "folders",
},
Cells: [][]byte{
[]byte("123"),
[]byte("testing-123"),
[]byte("parent-uid"),
},
},
},
},
TotalHits: 1,
}, nil
}
if len(in.Options.Fields) > 0 &&
in.Options.Fields[0].Key == resource.SEARCH_FIELD_NAME &&
in.Options.Fields[0].Operator == "in" &&
len(in.Options.Fields[0].Values) > 0 {
rows := []*resource.ResourceTableRow{}
for i, row := range in.Options.Fields[0].Values {
rows = append(rows, &resource.ResourceTableRow{
Key: &resource.ResourceKey{
Name: row,
Resource: "folders",
},
Cells: [][]byte{
[]byte(fmt.Sprintf("%d", i)), // set legacy id as the row id
[]byte(fmt.Sprintf("folder%d", i)), // set title as folder + row id
[]byte(""),
},
})
}
return &resource.ResourceSearchResponse{
Results: &resource.ResourceTable{
Columns: []*resource.ResourceTableColumnDefinition{
{
Name: "_id",
Type: resource.ResourceTableColumnDefinition_STRING,
},
{
Name: "title",
Type: resource.ResourceTableColumnDefinition_STRING,
},
{
Name: "folder",
Type: resource.ResourceTableColumnDefinition_STRING,
},
},
Rows: rows,
},
TotalHits: int64(len(rows)),
}, nil
}
// not found
return &resource.ResourceSearchResponse{
Results: &resource.ResourceTable{},
@@ -628,3 +705,132 @@ func (r resourceClientMock) GetBlob(ctx context.Context, in *resource.GetBlobReq
func (r resourceClientMock) IsHealthy(ctx context.Context, in *resource.HealthCheckRequest, opts ...grpc.CallOption) (*resource.HealthCheckResponse, error) {
return nil, nil
}
type mockFoldersK8sCli struct {
mock.Mock
searcher resourceClientMock
}
func (m *mockFoldersK8sCli) getClient(ctx context.Context, orgID int64) (dynamic.ResourceInterface, bool) {
args := m.Called(ctx, orgID)
return args.Get(0).(dynamic.ResourceInterface), args.Bool(1)
}
func (m *mockFoldersK8sCli) getNamespace(orgID int64) string {
if orgID == 1 {
return "default"
}
return fmt.Sprintf("orgs-%d", orgID)
}
func (m *mockFoldersK8sCli) getSearcher(ctx context.Context) resource.ResourceClient {
return m.searcher
}
func TestSearchFoldersFromApiServer(t *testing.T) {
fakeK8sClient := new(mockFoldersK8sCli)
service := Service{
k8sclient: fakeK8sClient,
features: featuremgmt.WithFeatures(featuremgmt.FlagKubernetesFoldersServiceV2),
}
fakeK8sClient.On("getSearcher", mock.Anything).Return(fakeK8sClient)
user := &user.SignedInUser{OrgID: 1}
ctx := identity.WithRequester(context.Background(), user)
t.Run("Should search by uids if provided", func(t *testing.T) {
query := folder.SearchFoldersQuery{
UIDs: []string{"uid1", "uid2"},
IDs: []int64{1, 2}, // will ignore these because uid is passed in
SignedInUser: user,
}
result, err := service.searchFoldersFromApiServer(ctx, query)
require.NoError(t, err)
expectedResult := model.HitList{
{
UID: "uid1",
// no parent folder is returned, so the general folder should be set
FolderID: 0,
FolderTitle: "General",
// orgID should be taken from signed in user
OrgID: 1,
// the rest should be automatically set when parsing the hit results from search
Type: model.DashHitFolder,
URI: "db/folder0",
Title: "folder0",
URL: "/dashboards/f/uid1/folder0",
},
{
UID: "uid2",
FolderID: 0,
FolderTitle: "General",
OrgID: 1,
Type: model.DashHitFolder,
URI: "db/folder1",
Title: "folder1",
URL: "/dashboards/f/uid2/folder1",
},
}
require.Equal(t, expectedResult, result)
})
t.Run("Search by ID if uids are not provided", func(t *testing.T) {
query := folder.SearchFoldersQuery{
IDs: []int64{123},
SignedInUser: user,
}
result, err := service.searchFoldersFromApiServer(ctx, query)
require.NoError(t, err)
expectedResult := model.HitList{
{
UID: "foo",
FolderID: 0,
FolderTitle: "General",
OrgID: 1,
Type: model.DashHitFolder,
URI: "db/folder1",
Title: "folder1",
URL: "/dashboards/f/foo/folder1",
},
}
require.Equal(t, expectedResult, result)
})
t.Run("Search by title, wildcard should be added to search request (won't match in search mock if not)", func(t *testing.T) {
// the search here will return a parent, this will be the parent folder returned when we query for it to add to the hit info
fakeFolderStore := folder.NewFakeStore()
fakeFolderStore.ExpectedFolder = &folder.Folder{
UID: "parent-uid",
ID: 2,
Title: "parent title",
}
service.unifiedStore = fakeFolderStore
guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{
CanSaveValue: true,
CanViewValue: true,
})
query := folder.SearchFoldersQuery{
Title: "test",
SignedInUser: user,
}
result, err := service.searchFoldersFromApiServer(ctx, query)
require.NoError(t, err)
expectedResult := model.HitList{
{
UID: "uid",
FolderID: 2,
FolderTitle: "parent title",
FolderUID: "parent-uid",
OrgID: 1,
Type: model.DashHitFolder,
URI: "db/testing-123",
Title: "testing-123",
URL: "/dashboards/f/uid/testing-123",
},
}
require.Equal(t, expectedResult, result)
})
}
@@ -4,11 +4,13 @@ import (
"context"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/search/model"
)
type FakeService struct {
ExpectedFolders []*folder.Folder
ExpectedFolder *folder.Folder
ExpectedHitList model.HitList
ExpectedError error
ExpectedDescendantCounts map[string]int64
}
@@ -82,6 +84,11 @@ func (s *FakeService) GetDescendantCountsLegacy(ctx context.Context, q *folder.G
func (s *FakeService) GetFolders(ctx context.Context, q folder.GetFoldersQuery) ([]*folder.Folder, error) {
return s.ExpectedFolders, s.ExpectedError
}
func (s *FakeService) SearchFolders(ctx context.Context, q folder.SearchFoldersQuery) (model.HitList, error) {
return s.ExpectedHitList, s.ExpectedError
}
func (s *FakeService) GetFoldersLegacy(ctx context.Context, q folder.GetFoldersQuery) ([]*folder.Folder, error) {
return s.ExpectedFolders, s.ExpectedError
}
+9
View File
@@ -179,6 +179,15 @@ type GetFoldersQuery struct {
SignedInUser identity.Requester `json:"-"`
}
type SearchFoldersQuery struct {
OrgID int64
UIDs []string
IDs []int64
Title string
Limit int64
SignedInUser identity.Requester `json:"-"`
}
// GetParentsQuery captures the information required by the folder service to
// return a list of all parent folders of a given folder.
type GetParentsQuery struct {
+5
View File
@@ -2,6 +2,8 @@ package folder
import (
"context"
"github.com/grafana/grafana/pkg/services/search/model"
)
type Service interface {
@@ -40,6 +42,9 @@ type Service interface {
GetFolders(ctx context.Context, q GetFoldersQuery) ([]*Folder, error)
GetFoldersLegacy(ctx context.Context, q GetFoldersQuery) ([]*Folder, error)
// SearchFolders returns a list of folders that match the query.
SearchFolders(ctx context.Context, q SearchFoldersQuery) (model.HitList, error)
// GetChildren returns an array containing all child folders.
GetChildren(ctx context.Context, q *GetChildrenQuery) ([]*Folder, error)
GetChildrenLegacy(ctx context.Context, q *GetChildrenQuery) ([]*Folder, error)
@@ -227,6 +227,7 @@ type SlackIntegration struct {
MentionChannel *string `json:"mentionChannel,omitempty" yaml:"mentionChannel,omitempty" hcl:"mention_channel"`
MentionUsers *string `json:"mentionUsers,omitempty" yaml:"mentionUsers,omitempty" hcl:"mention_users"`
MentionGroups *string `json:"mentionGroups,omitempty" yaml:"mentionGroups,omitempty" hcl:"mention_groups"`
Color *string `json:"color,omitempty" yaml:"color,omitempty" hcl:"color"`
}
type TelegramIntegration struct {
@@ -267,7 +268,7 @@ type ThreemaIntegration struct {
type VictoropsIntegration struct {
DisableResolveMessage *bool `json:"-" yaml:"-" hcl:"disable_resolve_message"`
URL string `json:"url" yaml:"url" hcl:"url"`
URL Secret `json:"url" yaml:"url" hcl:"url"`
MessageType *string `json:"messageType,omitempty" yaml:"messageType,omitempty" hcl:"message_type"`
Title *string `json:"title,omitempty" yaml:"title,omitempty" hcl:"title"`
+1 -1
View File
@@ -9,7 +9,7 @@ type AlertConfiguration struct {
AlertmanagerConfiguration string
ConfigurationHash string
ConfigurationVersion string
CreatedAt int64 `xorm:"created"`
CreatedAt int64 `xorm:"created_at"`
Default bool
OrgID int64 `xorm:"org_id"`
}
@@ -331,7 +331,7 @@ func TestReceiver_Fingerprint(t *testing.T) {
completelyDifferentReceiver.Integrations[0].Config = IntegrationConfig{Type: completelyDifferentReceiver.Integrations[0].Config.Type} // Remove all fields except Type.
t.Run("stable across code changes", func(t *testing.T) {
expectedFingerprint := "a3402fdaba03030c" // If this is a valid fingerprint generation change, update the expected value.
expectedFingerprint := "c0c82936be34b183" // If this is a valid fingerprint generation change, update the expected value.
assert.Equal(t, expectedFingerprint, baseReceiver.Fingerprint())
})
t.Run("stable across clones", func(t *testing.T) {
@@ -388,6 +388,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
Placeholder: "VictorOps url",
PropertyName: "url",
Required: true,
Secure: true,
},
{ // New in 8.0.
Label: "Message Type",
@@ -691,6 +692,14 @@ func GetAvailableNotifiers() []*NotifierPlugin {
Placeholder: "Slack endpoint url",
PropertyName: "endpointUrl",
},
{
Label: "Color",
Element: ElementTypeInput,
InputType: InputTypeText,
Description: "Templated color of the slack message",
Placeholder: alertingTemplates.DefaultMessageColor,
PropertyName: "color",
},
{ // New in 8.0.
Label: "Title",
Element: ElementTypeInput,
@@ -15,7 +15,7 @@ func TestGetSecretKeysForContactPointType(t *testing.T) {
{receiverType: "kafka", expectedSecretFields: []string{"password"}},
{receiverType: "email", expectedSecretFields: []string{}},
{receiverType: "pagerduty", expectedSecretFields: []string{"integrationKey"}},
{receiverType: "victorops", expectedSecretFields: []string{}},
{receiverType: "victorops", expectedSecretFields: []string{"url"}},
{receiverType: "oncall", expectedSecretFields: []string{"password", "authorization_credentials"}},
{receiverType: "pushover", expectedSecretFields: []string{"apiToken", "userKey"}},
{receiverType: "slack", expectedSecretFields: []string{"token", "url"}},
@@ -485,6 +485,14 @@ func (rs *ReceiverService) UpdateReceiver(ctx context.Context, r *models.Receive
return nil, err
}
// We re-encrypt the existing receiver to ensure any unencrypted secure fields that are correctly encrypted, note this should NOT re-encrypt secure fields that are already encrypted.
// This is rare, but can happen if a receiver is created with unencrypted secure fields and then the secure option is added later.
// Preferably, this would be handled by receiver config versions and migrations but for now this is a good safety net.
err = existing.Encrypt(rs.encryptor(ctx))
if err != nil {
return nil, err
}
span.AddEvent("Loaded current receiver", trace.WithAttributes(
attribute.String("concurrency_token", revision.ConcurrencyToken),
attribute.String("receiver", existing.Name),
@@ -583,6 +583,44 @@ func TestReceiverService_Update(t *testing.T) {
), rm.Encrypted(models.Base64Enrypt)),
expectedProvenances: map[string]models.Provenance{slackIntegration.UID: models.ProvenanceNone},
},
{
name: "encrypts previously unencrypted secure fields",
user: writer,
receiver: models.CopyReceiverWith(baseReceiver, rm.WithIntegrations(
models.CopyIntegrationWith(slackIntegration, im.AddSetting("token", "unencryptedValue"))),
),
existing: util.Pointer(models.CopyReceiverWith(baseReceiver, rm.WithIntegrations(
models.CopyIntegrationWith(slackIntegration,
im.AddSetting("token", "unencryptedValue"), // This will get encrypted.
),
))),
expectedUpdate: models.CopyReceiverWith(baseReceiver, rm.WithIntegrations(
models.CopyIntegrationWith(slackIntegration,
im.AddSecureSetting("token", "dW5lbmNyeXB0ZWRWYWx1ZQ==")),
), rm.Encrypted(models.Base64Enrypt)),
expectedProvenances: map[string]models.Provenance{slackIntegration.UID: models.ProvenanceNone},
},
{
// This test is important for covering the rare case when an existing field is marked as secure.
// The UI will receive the field as secure and, if unchanged, will pass it back on update as a secureField instead of a Setting.
name: "encrypts previously unencrypted secure fields when passed in as secureFields",
user: writer,
receiver: models.CopyReceiverWith(baseReceiver, rm.WithIntegrations(
models.CopyIntegrationWith(slackIntegration, im.AddSetting("newField", "newValue"))),
),
secureFields: map[string][]string{slackIntegration.UID: {"token"}},
existing: util.Pointer(models.CopyReceiverWith(baseReceiver, rm.WithIntegrations(
models.CopyIntegrationWith(slackIntegration,
im.AddSetting("token", "unencryptedValue"), // This will get encrypted.
),
))),
expectedUpdate: models.CopyReceiverWith(baseReceiver, rm.WithIntegrations(
models.CopyIntegrationWith(slackIntegration,
im.AddSetting("newField", "newValue"),
im.AddSecureSetting("token", "dW5lbmNyeXB0ZWRWYWx1ZQ==")),
), rm.Encrypted(models.Base64Enrypt)),
expectedProvenances: map[string]models.Provenance{slackIntegration.UID: models.ProvenanceNone},
},
{
name: "doesn't copy existing unsecure fields",
user: writer,
@@ -684,8 +722,22 @@ func TestReceiverService_Update(t *testing.T) {
sut := createReceiverServiceSut(t, &secretsService)
if tc.existing != nil {
created, err := sut.CreateReceiver(context.Background(), tc.existing, tc.user.GetOrgID(), tc.user)
// Create route after receivers as they will be referenced.
revision, err := sut.cfgStore.Get(context.Background(), tc.user.GetOrgID())
require.NoError(t, err)
result, err := revision.CreateReceiver(tc.existing)
require.NoError(t, err)
created, err := PostableApiReceiverToReceiver(result, tc.existing.Provenance)
require.NoError(t, err)
err = sut.cfgStore.Save(context.Background(), revision, tc.user.GetOrgID())
require.NoError(t, err)
for _, integration := range created.Integrations {
target := definitions.EmbeddedContactPoint{UID: integration.UID}
err = sut.provisioningStore.SetProvenance(context.Background(), &target, tc.user.GetOrgID(), created.Provenance)
require.NoError(t, err)
}
if tc.version == "" {
tc.version = created.Version
+16 -1
View File
@@ -110,9 +110,24 @@ func (st DBstore) SaveAlertmanagerConfigurationWithCallback(ctx context.Context,
// UpdateAlertmanagerConfiguration replaces an alertmanager configuration with optimistic locking. It assumes that an existing revision of the configuration exists in the store, and will return an error otherwise.
func (st *DBstore) UpdateAlertmanagerConfiguration(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd) error {
return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
newConfigHash := fmt.Sprintf("%x", md5.Sum([]byte(cmd.AlertmanagerConfiguration)))
// check for no-op update
if newConfigHash == cmd.FetchedConfigurationHash {
// double check that the configuration with this hash is in the db
ok, err := sess.Table("alert_configuration").
Where("org_id = ? AND configuration_hash = ?", cmd.OrgID, cmd.FetchedConfigurationHash).
Exist()
if err != nil {
return err
}
if !ok {
return ErrVersionLockedObjectNotFound
}
return nil
}
config := models.AlertConfiguration{
AlertmanagerConfiguration: cmd.AlertmanagerConfiguration,
ConfigurationHash: fmt.Sprintf("%x", md5.Sum([]byte(cmd.AlertmanagerConfiguration))),
ConfigurationHash: newConfigHash,
ConfigurationVersion: cmd.ConfigurationVersion,
Default: cmd.Default,
OrgID: cmd.OrgID,
@@ -114,6 +114,32 @@ func TestIntegrationAlertmanagerStore(t *testing.T) {
require.ErrorIs(t, err, ErrVersionLockedObjectNotFound)
})
t.Run("UpdateAlertmanagerConfiguration doesn't update the db if the update is a no-op", func(t *testing.T) {
_, configMD5 := setupConfig(t, "my-config", store)
originalConfig, err := store.GetLatestAlertmanagerConfiguration(context.Background(), 1)
require.NoError(t, err)
cmd := buildSaveConfigCmd(t, "my-config", 1)
cmd.FetchedConfigurationHash = configMD5
err = store.UpdateAlertmanagerConfiguration(context.Background(), &cmd)
require.NoError(t, err)
config, err := store.GetLatestAlertmanagerConfiguration(context.Background(), 1)
require.NoError(t, err)
require.Equal(t, "my-config", config.AlertmanagerConfiguration)
require.Equal(t, configMD5, config.ConfigurationHash)
// CreatedAt should not have changed as we didn't touch the config in the DB
require.Equal(t, originalConfig.CreatedAt, config.CreatedAt)
})
t.Run("UpdateAlertmanagerConfiguration fails if the config doesn't exist and the hashes in the cmd match", func(t *testing.T) {
configRaw := "my-non-existent-config"
configHash := fmt.Sprintf("%x", md5.Sum([]byte(configRaw)))
cmd := buildSaveConfigCmd(t, configRaw, 1)
cmd.FetchedConfigurationHash = configHash
err := store.UpdateAlertmanagerConfiguration(context.Background(), &cmd)
require.Error(t, err)
require.EqualError(t, err, ErrVersionLockedObjectNotFound.Error())
})
}
func TestIntegrationAlertmanagerHash(t *testing.T) {
+22 -1
View File
@@ -8,7 +8,10 @@ import (
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/search/model"
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
"github.com/grafana/grafana/pkg/services/star"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
@@ -17,7 +20,7 @@ import (
var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/search")
func ProvideService(cfg *setting.Cfg, sqlstore db.DB, starService star.Service, dashboardService dashboards.DashboardService) *SearchService {
func ProvideService(cfg *setting.Cfg, sqlstore db.DB, starService star.Service, dashboardService dashboards.DashboardService, folderService folder.Service, features featuremgmt.FeatureToggles) *SearchService {
s := &SearchService{
Cfg: cfg,
sortOptions: map[string]model.SortOption{
@@ -26,6 +29,8 @@ func ProvideService(cfg *setting.Cfg, sqlstore db.DB, starService star.Service,
},
sqlstore: sqlstore,
starService: starService,
folderService: folderService,
features: features,
dashboardService: dashboardService,
}
return s
@@ -61,6 +66,8 @@ type SearchService struct {
sqlstore db.DB
starService star.Service
dashboardService dashboards.DashboardService
folderService folder.Service
features featuremgmt.FeatureToggles
}
func (s *SearchService) SearchHandler(ctx context.Context, query *Query) (model.HitList, error) {
@@ -107,6 +114,20 @@ func (s *SearchService) SearchHandler(ctx context.Context, query *Query) (model.
dashboardQuery.Sort = sortOpt
}
// if folders are stored in unified storage, we need to use the folder service to query for folders
if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesFoldersServiceV2) && (query.Type == searchstore.TypeFolder || query.Type == searchstore.TypeAlertFolder) {
hits, err := s.folderService.SearchFolders(ctx, folder.SearchFoldersQuery{
OrgID: query.OrgId,
UIDs: query.FolderUIDs,
IDs: query.FolderIds,
Title: query.Title,
Limit: query.Limit,
SignedInUser: query.SignedInUser,
})
return sortedHits(hits), err
}
hits, err := s.dashboardService.SearchDashboards(ctx, &dashboardQuery)
if err != nil {
return nil, err
+3
View File
@@ -10,6 +10,7 @@ import (
"github.com/grafana/grafana/pkg/infra/db/dbtest"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/search/model"
"github.com/grafana/grafana/pkg/services/star"
"github.com/grafana/grafana/pkg/services/star/startest"
@@ -35,6 +36,7 @@ func TestSearch_SortedResults(t *testing.T) {
sqlstore: db,
starService: ss,
dashboardService: ds,
features: &featuremgmt.FeatureManager{},
}
query := &Query{
@@ -76,6 +78,7 @@ func TestSearch_StarredResults(t *testing.T) {
sqlstore: db,
starService: ss,
dashboardService: ds,
features: &featuremgmt.FeatureManager{},
}
query := &Query{
+1 -1
View File
@@ -170,7 +170,7 @@ require (
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/grafana/alerting v0.0.0-20250117230852-a5e8136407d4 // indirect
github.com/grafana/alerting v0.0.0-20250129195454-3e5b80036b7a // indirect
github.com/grafana/authlib v0.0.0-20250123104008-e99947858901 // indirect
github.com/grafana/dataplane/sdata v0.0.9 // indirect
github.com/grafana/dskit v0.0.0-20241105154643-a6b453a88040 // indirect
+2 -2
View File
@@ -547,8 +547,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grafana/alerting v0.0.0-20250117230852-a5e8136407d4 h1:616HUg7WVyLJLtVvk2pZ845M4Gk0fswgTwEZSuAZbJU=
github.com/grafana/alerting v0.0.0-20250117230852-a5e8136407d4/go.mod h1:QsnoKX/iYZxA4Cv+H+wC7uxutBD8qi8ZW5UJvD2TYmU=
github.com/grafana/alerting v0.0.0-20250129195454-3e5b80036b7a h1:44E+I3EPdh/W02Uyfyig86EJKPjvzcF3y0A+FEi1fBk=
github.com/grafana/alerting v0.0.0-20250129195454-3e5b80036b7a/go.mod h1:QsnoKX/iYZxA4Cv+H+wC7uxutBD8qi8ZW5UJvD2TYmU=
github.com/grafana/authlib v0.0.0-20250123104008-e99947858901 h1:nqV1YrtX+ZG+EYB5dcmFMWhg2Y038OMaAHAADbOC9RA=
github.com/grafana/authlib v0.0.0-20250123104008-e99947858901/go.mod h1:/gYfphsNu9v1qYWXxpv1NSvMEMSwvdf8qb8YlgwIRl8=
github.com/grafana/authlib/types v0.0.0-20250120145936-5f0e28e7a87c h1:b0sPDtt33uFdmvUJjSCld3kwE2E49dUvevuUDSJsEuo=
+11 -11
View File
@@ -63,7 +63,7 @@ type IndexableDocument struct {
Title string `json:"title,omitempty"`
// internal sort field for title ( don't set this directly )
TitleSort string `json:"title_sort,omitempty"`
TitlePhrase string `json:"title_phrase,omitempty"`
// A generic description -- helpful in global search
Description string `json:"description,omitempty"`
@@ -163,15 +163,15 @@ func NewIndexableDocument(key *ResourceKey, rv int64, obj utils.GrafanaMetaAcces
}
}
doc := &IndexableDocument{
Key: key,
RV: rv,
Name: key.Name,
Title: title, // We always want *something* to display
TitleSort: strings.ToLower(title), // Lowercase for case-insensitive sorting
Labels: obj.GetLabels(),
Folder: obj.GetFolder(),
CreatedBy: obj.GetCreatedBy(),
UpdatedBy: obj.GetUpdatedBy(),
Key: key,
RV: rv,
Name: key.Name,
Title: title, // We always want *something* to display
TitlePhrase: strings.ToLower(title), // Lowercase for case-insensitive sorting
Labels: obj.GetLabels(),
Folder: obj.GetFolder(),
CreatedBy: obj.GetCreatedBy(),
UpdatedBy: obj.GetUpdatedBy(),
}
doc.RepoInfo, _ = obj.GetRepositoryInfo()
ts := obj.GetCreationTimestamp()
@@ -253,7 +253,7 @@ const SEARCH_FIELD_NAMESPACE = "namespace"
const SEARCH_FIELD_NAME = "name"
const SEARCH_FIELD_RV = "rv"
const SEARCH_FIELD_TITLE = "title"
const SEARCH_FIELD_TITLE_SORT = "title_sort"
const SEARCH_FIELD_TITLE_PHRASE = "title_phrase" // filtering/sorting on title by full phrase
const SEARCH_FIELD_DESCRIPTION = "description"
const SEARCH_FIELD_TAGS = "tags"
const SEARCH_FIELD_LABELS = "labels" // All labels, not a specific one
@@ -35,7 +35,7 @@ func TestStandardDocumentBuilder(t *testing.T) {
},
"rv": 10,
"title": "test playlist unified storage",
"title_sort": "test playlist unified storage",
"title_phrase": "test playlist unified storage",
"created": 1717236672000,
"createdBy": "user:ABC",
"updatedBy": "user:XYZ",
+1 -1
View File
@@ -116,7 +116,7 @@ require (
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/grafana/alerting v0.0.0-20250117230852-a5e8136407d4 // indirect
github.com/grafana/alerting v0.0.0-20250129195454-3e5b80036b7a // indirect
github.com/grafana/dataplane/sdata v0.0.9 // indirect
github.com/grafana/grafana-app-sdk/logging v0.29.0 // indirect
github.com/grafana/grafana-aws-sdk v0.31.5 // indirect
+2 -2
View File
@@ -411,8 +411,8 @@ github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z
github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/grafana/alerting v0.0.0-20250117230852-a5e8136407d4 h1:616HUg7WVyLJLtVvk2pZ845M4Gk0fswgTwEZSuAZbJU=
github.com/grafana/alerting v0.0.0-20250117230852-a5e8136407d4/go.mod h1:QsnoKX/iYZxA4Cv+H+wC7uxutBD8qi8ZW5UJvD2TYmU=
github.com/grafana/alerting v0.0.0-20250129195454-3e5b80036b7a h1:44E+I3EPdh/W02Uyfyig86EJKPjvzcF3y0A+FEi1fBk=
github.com/grafana/alerting v0.0.0-20250129195454-3e5b80036b7a/go.mod h1:QsnoKX/iYZxA4Cv+H+wC7uxutBD8qi8ZW5UJvD2TYmU=
github.com/grafana/authlib v0.0.0-20250123104008-e99947858901 h1:nqV1YrtX+ZG+EYB5dcmFMWhg2Y038OMaAHAADbOC9RA=
github.com/grafana/authlib v0.0.0-20250123104008-e99947858901/go.mod h1:/gYfphsNu9v1qYWXxpv1NSvMEMSwvdf8qb8YlgwIRl8=
github.com/grafana/authlib/types v0.0.0-20250120145936-5f0e28e7a87c h1:b0sPDtt33uFdmvUJjSCld3kwE2E49dUvevuUDSJsEuo=
+4 -4
View File
@@ -1284,12 +1284,12 @@ type ListOptions struct {
// Group+Namespace+Resource (not name)
Key *ResourceKey `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"`
// (best effort) Match label
// Allowed to send more results than actually match because the filter will be appled
// to the resutls agin in the client. That time with the full field selector
// Allowed to send more results than actually match because the filter will be applied
// to the results again in the client. That time with the full field selector
Labels []*Requirement `protobuf:"bytes,2,rep,name=labels,proto3" json:"labels,omitempty"`
// (best effort) fields matcher
// Allowed to send more results than actually match because the filter will be appled
// to the resutls agin in the client. That time with the full field selector
// Allowed to send more results than actually match because the filter will be applied
// to the results again in the client. That time with the full field selector
Fields []*Requirement `protobuf:"bytes,3,rep,name=fields,proto3" json:"fields,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
+4 -4
View File
@@ -209,13 +209,13 @@ message ListOptions {
ResourceKey key = 1;
// (best effort) Match label
// Allowed to send more results than actually match because the filter will be appled
// to the resutls agin in the client. That time with the full field selector
// Allowed to send more results than actually match because the filter will be applied
// to the results again in the client. That time with the full field selector
repeated Requirement labels = 2;
// (best effort) fields matcher
// Allowed to send more results than actually match because the filter will be appled
// to the resutls agin in the client. That time with the full field selector
// Allowed to send more results than actually match because the filter will be applied
// to the results again in the client. That time with the full field selector
repeated Requirement fields = 3;
}
+16 -6
View File
@@ -684,9 +684,11 @@ func getSortFields(req *resource.ResourceSearchRequest) []string {
// fields that we went to sort by the full text
var textSortFields = map[string]string{
resource.SEARCH_FIELD_TITLE: resource.SEARCH_FIELD_TITLE + "_sort",
resource.SEARCH_FIELD_TITLE: resource.SEARCH_FIELD_TITLE_PHRASE,
}
const lowerCase = "phrase"
// Convert a "requirement" into a bleve query
func requirementQuery(req *resource.Requirement, prefix string) (query.Query, *resource.ErrorResult) {
switch selection.Operator(req.Operator) {
@@ -696,14 +698,14 @@ func requirementQuery(req *resource.Requirement, prefix string) (query.Query, *r
}
if len(req.Values[0]) == 1 {
q := query.NewMatchQuery(req.Values[0])
q := query.NewMatchQuery(filterValue(req.Key, req.Values[0]))
q.FieldVal = prefix + req.Key
return q, nil
}
conjuncts := []query.Query{}
for _, v := range req.Values {
q := query.NewMatchQuery(v)
q := query.NewMatchQuery(filterValue(req.Key, v))
q.FieldVal = prefix + req.Key
conjuncts = append(conjuncts, q)
}
@@ -720,14 +722,14 @@ func requirementQuery(req *resource.Requirement, prefix string) (query.Query, *r
return query.NewMatchAllQuery(), nil
}
if len(req.Values) == 1 {
q := query.NewMatchQuery(req.Values[0])
q := query.NewMatchQuery(filterValue(req.Key, req.Values[0]))
q.FieldVal = prefix + req.Key
return q, nil
}
disjuncts := []query.Query{}
for _, v := range req.Values {
q := query.NewMatchQuery(v)
q := query.NewMatchQuery(filterValue(req.Key, v))
q.FieldVal = prefix + req.Key
disjuncts = append(disjuncts, q)
}
@@ -739,7 +741,7 @@ func requirementQuery(req *resource.Requirement, prefix string) (query.Query, *r
var mustNotQueries []query.Query
for _, value := range req.Values {
mustNotQueries = append(mustNotQueries, bleve.NewMatchQuery(value))
mustNotQueries = append(mustNotQueries, bleve.NewMatchQuery(filterValue(req.Key, value)))
}
boolQuery.AddMustNot(mustNotQueries...)
@@ -754,6 +756,14 @@ func requirementQuery(req *resource.Requirement, prefix string) (query.Query, *r
)
}
// filterValue will convert the value to lower case if the field is a phrase field
func filterValue(field string, v string) string {
if strings.HasSuffix(field, lowerCase) {
return strings.ToLower(v)
}
return v
}
func (b *bleveIndex) hitsToTable(ctx context.Context, selectFields []string, hits search.DocumentMatchCollection, explain bool) (*resource.ResourceTable, error) {
_, span := b.tracing.Start(ctx, tracingPrexfixBleve+"hitsToTable")
defer span.End()
+4 -3
View File
@@ -24,11 +24,12 @@ func getBleveDocMappings(_ resource.SearchableDocumentFields) *mapping.DocumentM
}
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_NAME, nameMapping)
// for sorting by title
titleSortMapping := bleve.NewKeywordFieldMapping()
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TITLE_SORT, titleSortMapping)
// for filtering/sorting by title full phrase
titlePhraseMapping := bleve.NewKeywordFieldMapping()
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TITLE_PHRASE, titlePhraseMapping)
// for searching by title
// TODO: do we still need this since we have SEARCH_FIELD_TITLE_PHRASE?
titleSearchMapping := bleve.NewTextFieldMapping()
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TITLE, titleSearchMapping)
+14 -14
View File
@@ -78,9 +78,9 @@ func TestBleveBackend(t *testing.T) {
Group: "dashboard.grafana.app",
Resource: "dashboards",
},
Title: "aaa (dash)",
TitleSort: "aaa (dash)",
Folder: "xxx",
Title: "aaa (dash)",
TitlePhrase: "aaa (dash)",
Folder: "xxx",
Fields: map[string]any{
DASHBOARD_PANEL_TYPES: []string{"timeseries", "table"},
DASHBOARD_ERRORS_TODAY: 25,
@@ -106,9 +106,9 @@ func TestBleveBackend(t *testing.T) {
Group: "dashboard.grafana.app",
Resource: "dashboards",
},
Title: "bbb (dash)",
TitleSort: "bbb (dash)",
Folder: "xxx",
Title: "bbb (dash)",
TitlePhrase: "bbb (dash)",
Folder: "xxx",
Fields: map[string]any{
DASHBOARD_PANEL_TYPES: []string{"timeseries"},
DASHBOARD_ERRORS_TODAY: 40,
@@ -134,10 +134,10 @@ func TestBleveBackend(t *testing.T) {
Group: "dashboard.grafana.app",
Resource: "dashboards",
},
Name: "ccc",
Title: "ccc (dash)",
TitleSort: "ccc (dash)",
Folder: "zzz",
Name: "ccc",
Title: "ccc (dash)",
TitlePhrase: "ccc (dash)",
Folder: "zzz",
RepoInfo: &utils.ResourceRepositoryInfo{
Name: "repo2",
Path: "path/in/repo2.yaml",
@@ -332,8 +332,8 @@ func TestBleveBackend(t *testing.T) {
Group: "folder.grafana.app",
Resource: "folders",
},
Title: "zzz (folder)",
TitleSort: "zzz (folder)",
Title: "zzz (folder)",
TitlePhrase: "zzz (folder)",
RepoInfo: &utils.ResourceRepositoryInfo{
Name: "repo-1",
Path: "path/to/folder.json",
@@ -349,8 +349,8 @@ func TestBleveBackend(t *testing.T) {
Group: "folder.grafana.app",
Resource: "folders",
},
Title: "yyy (folder)",
TitleSort: "yyy (folder)",
Title: "yyy (folder)",
TitlePhrase: "yyy (folder)",
Labels: map[string]string{
"region": "west",
},
@@ -8,7 +8,7 @@
"name": "aaa",
"rv": 1234,
"title": "Test title",
"title_sort": "test title",
"title_phrase": "test title",
"description": "test description",
"tags": [
"a",
@@ -8,7 +8,7 @@
"name": "aaa",
"rv": 1234,
"title": "test-aaa",
"title_sort": "test-aaa",
"title_phrase": "test-aaa",
"created": 1730490142000,
"createdBy": "user:1",
"repo": {
@@ -8,7 +8,7 @@
"name": "bbb",
"rv": 1234,
"title": "test-bbb",
"title_sort": "test-bbb",
"title_phrase": "test-bbb",
"created": 1730490142000,
"createdBy": "user:1",
"repo": {
@@ -8,7 +8,7 @@
"name": "aaa",
"rv": 1234,
"title": "Test AAA",
"title_sort": "test aaa",
"title_phrase": "test aaa",
"created": 1731336353000,
"createdBy": "user:t000000001",
"repo": {
@@ -8,7 +8,7 @@
"name": "aaa",
"rv": 1234,
"title": "Test AAA",
"title_sort": "test aaa",
"title_phrase": "test aaa",
"labels": {
"grafana.app/deprecatedInternalID": "123"
},
@@ -903,6 +903,9 @@ func TestIntegrationExportFileProvision(t *testing.T) {
require.Len(t, export.MuteTimings, 1)
require.YAMLEq(t, expectedYaml, exportRaw)
})
t.Run("reloading provisioning should not fail", func(t *testing.T) {
apiClient.ReloadAlertingFileProvisioning(t)
})
})
}
@@ -1004,6 +1007,17 @@ func TestIntegrationExportFileProvisionContactPoints(t *testing.T) {
var export definitions.AlertingFileExport
require.NoError(t, yaml.Unmarshal([]byte(exportRaw), &export))
// verify the file exported matches the file provisioned thing
require.Len(t, export.ContactPoints, 1)
require.YAMLEq(t, string(expectedYaml), exportRaw)
})
t.Run("reloading provisioning should not change things", func(t *testing.T) {
apiClient.ReloadAlertingFileProvisioning(t)
exportRaw := apiClient.ExportReceiver(t, "cp_1_$escaped", "yaml", true)
var export definitions.AlertingFileExport
require.NoError(t, yaml.Unmarshal([]byte(exportRaw), &export))
// verify the file exported matches the file provisioned thing
require.Len(t, export.ContactPoints, 1)
require.YAMLEq(t, string(expectedYaml), exportRaw)
+95
View File
@@ -0,0 +1,95 @@
package apis
import (
"context"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/dynamic"
)
// TypedClient is the struct that implements a typed interface for resource operations
type TypedClient[T any, L any] struct {
Client dynamic.ResourceInterface
}
func (c *TypedClient[T, L]) Create(ctx context.Context, resource *T, opts metav1.CreateOptions) (*T, error) {
unstructuredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(resource)
if err != nil {
return nil, err
}
u := &unstructured.Unstructured{Object: unstructuredObj}
result, err := c.Client.Create(ctx, u, opts)
if err != nil {
return nil, err
}
createdObj := new(T)
err = runtime.DefaultUnstructuredConverter.FromUnstructured(result.Object, createdObj)
if err != nil {
return nil, err
}
return createdObj, nil
}
func (c *TypedClient[T, L]) Update(ctx context.Context, resource *T, opts metav1.UpdateOptions) (*T, error) {
unstructuredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(resource)
if err != nil {
return nil, err
}
u := &unstructured.Unstructured{Object: unstructuredObj}
result, err := c.Client.Update(ctx, u, opts)
if err != nil {
return nil, err
}
updatedObj := new(T)
err = runtime.DefaultUnstructuredConverter.FromUnstructured(result.Object, updatedObj)
if err != nil {
return nil, err
}
return updatedObj, nil
}
func (c *TypedClient[T, L]) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error {
return c.Client.Delete(ctx, name, opts)
}
func (c *TypedClient[T, L]) Get(ctx context.Context, name string, opts metav1.GetOptions) (*T, error) {
result, err := c.Client.Get(ctx, name, opts)
if err != nil {
return nil, err
}
retrievedObj := new(T)
err = runtime.DefaultUnstructuredConverter.FromUnstructured(result.Object, retrievedObj)
if err != nil {
return nil, err
}
return retrievedObj, nil
}
func (c *TypedClient[T, L]) List(ctx context.Context, opts metav1.ListOptions) (*L, error) {
result, err := c.Client.List(ctx, opts)
if err != nil {
return nil, err
}
listObj := new(L)
err = runtime.DefaultUnstructuredConverter.FromUnstructured(result.UnstructuredContent(), listObj)
if err != nil {
return nil, err
}
return listObj, nil
}
func (c *TypedClient[T, L]) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (*T, error) {
result, err := c.Client.Patch(ctx, name, pt, data, opts, subresources...)
if err != nil {
return nil, err
}
patchedObj := new(T)
err = runtime.DefaultUnstructuredConverter.FromUnstructured(result.Object, patchedObj)
if err != nil {
return nil, err
}
return patchedObj, nil
}
+42 -78
View File
@@ -8,10 +8,12 @@ import (
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -19,7 +21,6 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer/yaml"
"k8s.io/apimachinery/pkg/types"
yamlutil "k8s.io/apimachinery/pkg/util/yaml"
"k8s.io/client-go/discovery"
"k8s.io/client-go/dynamic"
@@ -661,86 +662,49 @@ func (c *K8sTestHelper) CreateTeam(name, email string, orgID int64) team.Team {
return team
}
// TypedClient is the struct that implements a typed interface for resource operations
type TypedClient[T any, L any] struct {
Client dynamic.ResourceInterface
}
// Compare the OpenAPI schema from one api against a cached snapshot
func VerifyOpenAPISnapshots(t *testing.T, dir string, gv schema.GroupVersion, h *K8sTestHelper) {
if gv.Group == "" {
return // skip invalid groups
}
path := fmt.Sprintf("/openapi/v3/apis/%s/%s", gv.Group, gv.Version)
t.Run(path, func(t *testing.T) {
rsp := DoRequest(h, RequestParams{
Method: http.MethodGet,
Path: path,
User: h.Org1.Admin,
}, &AnyResource{})
func (c *TypedClient[T, L]) Create(ctx context.Context, resource *T, opts metav1.CreateOptions) (*T, error) {
unstructuredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(resource)
if err != nil {
return nil, err
}
u := &unstructured.Unstructured{Object: unstructuredObj}
result, err := c.Client.Create(ctx, u, opts)
if err != nil {
return nil, err
}
createdObj := new(T)
err = runtime.DefaultUnstructuredConverter.FromUnstructured(result.Object, createdObj)
if err != nil {
return nil, err
}
return createdObj, nil
}
require.NotNil(t, rsp.Response)
require.Equal(t, 200, rsp.Response.StatusCode, path)
func (c *TypedClient[T, L]) Update(ctx context.Context, resource *T, opts metav1.UpdateOptions) (*T, error) {
unstructuredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(resource)
if err != nil {
return nil, err
}
u := &unstructured.Unstructured{Object: unstructuredObj}
result, err := c.Client.Update(ctx, u, opts)
if err != nil {
return nil, err
}
updatedObj := new(T)
err = runtime.DefaultUnstructuredConverter.FromUnstructured(result.Object, updatedObj)
if err != nil {
return nil, err
}
return updatedObj, nil
}
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, rsp.Body, "", " ")
require.NoError(t, err)
pretty := prettyJSON.String()
func (c *TypedClient[T, L]) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error {
return c.Client.Delete(ctx, name, opts)
}
write := false
fpath := filepath.Join(dir, fmt.Sprintf("%s-%s.json", gv.Group, gv.Version))
func (c *TypedClient[T, L]) Get(ctx context.Context, name string, opts metav1.GetOptions) (*T, error) {
result, err := c.Client.Get(ctx, name, opts)
if err != nil {
return nil, err
}
retrievedObj := new(T)
err = runtime.DefaultUnstructuredConverter.FromUnstructured(result.Object, retrievedObj)
if err != nil {
return nil, err
}
return retrievedObj, nil
}
// nolint:gosec
// We can ignore the gosec G304 warning since this is a test and the function is only called with explicit paths
body, err := os.ReadFile(fpath)
if err == nil {
if !assert.JSONEq(t, string(body), pretty) {
t.Logf("openapi spec has changed: %s", path)
t.Fail()
write = true
}
} else {
t.Errorf("missing openapi spec for: %s", path)
write = true
}
func (c *TypedClient[T, L]) List(ctx context.Context, opts metav1.ListOptions) (*L, error) {
result, err := c.Client.List(ctx, opts)
if err != nil {
return nil, err
}
listObj := new(L)
err = runtime.DefaultUnstructuredConverter.FromUnstructured(result.UnstructuredContent(), listObj)
if err != nil {
return nil, err
}
return listObj, nil
}
func (c *TypedClient[T, L]) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (*T, error) {
result, err := c.Client.Patch(ctx, name, pt, data, opts, subresources...)
if err != nil {
return nil, err
}
patchedObj := new(T)
err = runtime.DefaultUnstructuredConverter.FromUnstructured(result.Object, patchedObj)
if err != nil {
return nil, err
}
return patchedObj, nil
if write {
e2 := os.WriteFile(fpath, []byte(pretty), 0644)
if e2 != nil {
t.Errorf("error writing file: %s", e2.Error())
}
}
})
}
-52
View File
@@ -1,16 +1,11 @@
package apis
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/version"
@@ -80,50 +75,3 @@ func TestIntegrationOpenAPIs(t *testing.T) {
VerifyOpenAPISnapshots(t, dir, gv, h)
}
}
// This function should be moved to oss (it is now a duplicate)
func VerifyOpenAPISnapshots(t *testing.T, dir string, gv schema.GroupVersion, h *K8sTestHelper) {
if gv.Group == "" {
return // skip invalid groups
}
path := fmt.Sprintf("/openapi/v3/apis/%s/%s", gv.Group, gv.Version)
t.Run(path, func(t *testing.T) {
rsp := DoRequest(h, RequestParams{
Method: http.MethodGet,
Path: path,
User: h.Org1.Admin,
}, &AnyResource{})
require.NotNil(t, rsp.Response)
require.Equal(t, 200, rsp.Response.StatusCode, path)
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, rsp.Body, "", " ")
require.NoError(t, err)
pretty := prettyJSON.String()
write := false
fpath := filepath.Join(dir, fmt.Sprintf("%s-%s.json", gv.Group, gv.Version))
// nolint:gosec
// We can ignore the gosec G304 warning since this is a test and the function is only called with explicit paths
body, err := os.ReadFile(fpath)
if err == nil {
if !assert.JSONEq(t, string(body), pretty) {
t.Logf("openapi spec has changed: %s", path)
t.Fail()
write = true
}
} else {
t.Errorf("missing openapi spec for: %s", path)
write = true
}
if write {
e2 := os.WriteFile(fpath, []byte(pretty), 0644)
if e2 != nil {
t.Errorf("error writing file: %s", e2.Error())
}
}
})
}
+8
View File
@@ -295,6 +295,13 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) {
_, err = alertingSect.NewKey("max_attempts", "3")
require.NoError(t, err)
if opts.LicensePath != "" {
section, err := cfg.NewSection("enterprise")
require.NoError(t, err)
_, err = section.NewKey("license_path", opts.LicensePath)
require.NoError(t, err)
}
rbacSect, err := cfg.NewSection("rbac")
require.NoError(t, err)
_, err = rbacSect.NewKey("permission_cache", "false")
@@ -534,6 +541,7 @@ type GrafanaOpts struct {
UnifiedStorageConfig map[string]setting.UnifiedStorageConfig
PermittedProvisioningPaths string
GrafanaComSSOAPIToken string
LicensePath string
// When "unified-grpc" is selected it will also start the grpc server
APIServerStorageType options.StorageType