Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync
This commit is contained in:
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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -37,7 +37,7 @@ func ProvideRegistryServiceSink(
|
||||
_ *query.QueryAPIBuilder,
|
||||
_ *notifications.NotificationsAPIBuilder,
|
||||
_ *userstorage.UserStorageAPIBuilder,
|
||||
_ *provisioning.ProvisioningAPIBuilder,
|
||||
_ *provisioning.APIBuilder,
|
||||
) *Service {
|
||||
return &Service{}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user