From 8a8e47fceac329abb9eebbf6dfde06893497d197 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20S=C3=BC=C3=9F?= Date: Thu, 13 Feb 2025 10:18:55 +0100 Subject: [PATCH 01/14] PluginExtensions: Added support for sharing functions (#98888) * feat: add generic plugin extension functions * updated betterer. * Fixed type issues after sync with main. * Remved extensions from datasource and panel. * Added validation for extension function registry. * Added tests and validation logic for function extensions registry. * removed prop already existing on base. * fixed lint error. --------- Co-authored-by: Marcus Andersson --- .betterer.results | 6 + packages/grafana-data/src/index.ts | 2 + packages/grafana-data/src/types/app.ts | 12 + packages/grafana-data/src/types/plugin.ts | 2 + .../src/types/pluginExtensions.ts | 19 +- .../grafana-runtime/src/services/index.ts | 3 + .../pluginExtensions/getPluginExtensions.ts | 17 +- .../pluginExtensions/usePluginFunctions.ts | 20 + .../manager/loader/finder/local_test.go | 43 +- pkg/plugins/manager/loader/loader_test.go | 25 +- pkg/plugins/models.go | 8 + pkg/plugins/plugins.go | 4 + pkg/plugins/plugins_test.go | 34 +- .../pluginsintegration/loader/loader_test.go | 91 ++- public/app/app.ts | 3 + .../unified/mocks/server/handlers/plugins.ts | 1 + .../alerting/unified/testSetup/plugins.ts | 1 + .../alerting/unified/utils/rules.test.ts | 1 + .../plugins/components/AppRootPage.test.tsx | 2 + .../plugins/components/AppRootPage.tsx | 3 + .../extensions/ExtensionRegistriesContext.tsx | 18 +- .../app/features/plugins/extensions/errors.ts | 5 + .../plugins/extensions/getPluginExtensions.ts | 2 +- .../registry/AddedComponentsRegistry.test.ts | 1 + .../registry/AddedFunctionsRegistry.test.ts | 677 ++++++++++++++++++ .../registry/AddedFunctionsRegistry.ts | 87 +++ .../registry/AddedLinksRegistry.test.ts | 1 + .../ExposedComponentsRegistry.test.ts | 1 + .../plugins/extensions/registry/setup.ts | 3 + .../plugins/extensions/registry/types.ts | 2 + .../extensions/usePluginComponent.test.tsx | 4 + .../extensions/usePluginComponents.test.tsx | 3 + .../extensions/usePluginExtensions.test.tsx | 2 + .../plugins/extensions/usePluginFunctions.tsx | 82 +++ .../extensions/usePluginLinks.test.tsx | 3 + .../plugins/extensions/utils.test.tsx | 13 + .../plugins/extensions/validators.test.tsx | 4 + .../features/plugins/extensions/validators.ts | 33 + .../app/features/plugins/importPanelPlugin.ts | 1 - public/app/features/plugins/plugin_loader.ts | 12 +- 40 files changed, 1182 insertions(+), 69 deletions(-) create mode 100644 packages/grafana-runtime/src/services/pluginExtensions/usePluginFunctions.ts create mode 100644 public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts create mode 100644 public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.ts create mode 100644 public/app/features/plugins/extensions/usePluginFunctions.tsx diff --git a/.betterer.results b/.betterer.results index ca4b89c9605..89b413d6023 100644 --- a/.betterer.results +++ b/.betterer.results @@ -491,6 +491,9 @@ exports[`better eslint`] = { "packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], + "packages/grafana-runtime/src/services/pluginExtensions/usePluginFunctions.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "packages/grafana-runtime/src/utils/DataSourceWithBackend.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -5602,6 +5605,9 @@ exports[`better eslint`] = { [0, 0, 0, "\'@grafana/runtime/src/services/pluginExtensions/getPluginExtensions\' import is restricted from being used by a pattern. Import from the public export instead.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], + "public/app/features/plugins/extensions/usePluginFunctions.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/features/plugins/extensions/usePluginLinks.tsx:5381": [ [0, 0, 0, "\'@grafana/runtime/src/services/pluginExtensions/getPluginExtensions\' import is restricted from being used by a pattern. Import from the public export instead.", "0"] ], diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 76195d7d8c0..6aa49febd08 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -549,6 +549,7 @@ export { type PluginExtensionLink, type PluginExtensionComponent, type PluginExtensionConfig, + type PluginExtensionFunction, type PluginExtensionLinkConfig, type PluginExtensionComponentConfig, type PluginExtensionEventHelpers, @@ -559,6 +560,7 @@ export { type PluginExtensionExposedComponentConfig, type PluginExtensionAddedComponentConfig, type PluginExtensionAddedLinkConfig, + type PluginExtensionAddedFunctionConfig, } from './types/pluginExtensions'; export { type ScopeDashboardBindingSpec, diff --git a/packages/grafana-data/src/types/app.ts b/packages/grafana-data/src/types/app.ts index 3b33452bd03..23e52f913b8 100644 --- a/packages/grafana-data/src/types/app.ts +++ b/packages/grafana-data/src/types/app.ts @@ -9,6 +9,7 @@ import { PluginExtensionExposedComponentConfig, PluginExtensionAddedComponentConfig, PluginExtensionAddedLinkConfig, + PluginExtensionAddedFunctionConfig, } from './pluginExtensions'; /** @@ -60,6 +61,7 @@ export class AppPlugin extends GrafanaPlugin>; @@ -113,6 +115,10 @@ export class AppPlugin extends GrafanaPlugin(linkConfig: PluginExtensionAddedLinkConfig) { this._addedLinkConfigs.push(linkConfig as PluginExtensionAddedLinkConfig); @@ -125,6 +131,12 @@ export class AppPlugin extends GrafanaPlugin(addedFunctionConfig: PluginExtensionAddedFunctionConfig) { + this._addedFunctionConfigs.push(addedFunctionConfig); + + return this; + } + exposeComponent(componentConfig: PluginExtensionExposedComponentConfig) { this._exposedComponentConfigs.push(componentConfig as PluginExtensionExposedComponentConfig); diff --git a/packages/grafana-data/src/types/plugin.ts b/packages/grafana-data/src/types/plugin.ts index ebb3da684f6..8e1be814c96 100644 --- a/packages/grafana-data/src/types/plugin.ts +++ b/packages/grafana-data/src/types/plugin.ts @@ -130,6 +130,8 @@ export interface PluginExtensions { // The component extensions that the plugin registers addedComponents: ExtensionInfo[]; + addedFunctions: ExtensionInfo[]; + // The link extensions that the plugin registers addedLinks: ExtensionInfo[]; diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index fcbbecf5f41..5b10f80f143 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -14,6 +14,7 @@ import { RawTimeRange, TimeZone } from './time'; export enum PluginExtensionTypes { link = 'link', component = 'component', + function = 'function', } type PluginExtensionBase = { @@ -36,7 +37,12 @@ export type PluginExtensionComponent = PluginExtensionBase & { component: React.ComponentType; }; -export type PluginExtension = PluginExtensionLink | PluginExtensionComponent; +export type PluginExtensionFunction void> = PluginExtensionBase & { + type: PluginExtensionTypes.function; + fn: Signature; +}; + +export type PluginExtension = PluginExtensionLink | PluginExtensionComponent | PluginExtensionFunction; // Objects used for registering extensions (in app plugins) // -------------------------------------------------------- @@ -74,6 +80,17 @@ export type PluginExtensionAddedComponentConfig = PluginExtensionCon */ component: React.ComponentType; }; +export type PluginExtensionAddedFunctionConfig = PluginExtensionConfigBase & { + /** + * The target extension points where the component will be added + */ + targets: string | string[]; + + /** + * The function to be executed + */ + fn: Signature; +}; export type PluginAddedLinksConfigureFunc = (context: Readonly | undefined) => | Partial<{ diff --git a/packages/grafana-runtime/src/services/index.ts b/packages/grafana-runtime/src/services/index.ts index 3e02e0d2863..5e8892c5cc7 100644 --- a/packages/grafana-runtime/src/services/index.ts +++ b/packages/grafana-runtime/src/services/index.ts @@ -22,6 +22,8 @@ export { type UsePluginExtensions, type UsePluginExtensionsResult, type UsePluginComponentResult, + type UsePluginFunctionsOptions, + type UsePluginFunctionsResult, } from './pluginExtensions/getPluginExtensions'; export { setPluginExtensionsHook, @@ -33,6 +35,7 @@ export { export { setPluginComponentHook, usePluginComponent } from './pluginExtensions/usePluginComponent'; export { setPluginComponentsHook, usePluginComponents } from './pluginExtensions/usePluginComponents'; export { setPluginLinksHook, usePluginLinks } from './pluginExtensions/usePluginLinks'; +export { setPluginFunctionsHook, usePluginFunctions } from './pluginExtensions/usePluginFunctions'; export { isPluginExtensionLink, isPluginExtensionComponent } from './pluginExtensions/utils'; export { setCurrentUser } from './user'; diff --git a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts index be151b05858..2f70132d5fe 100644 --- a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts +++ b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts @@ -1,4 +1,9 @@ -import type { PluginExtension, PluginExtensionLink, PluginExtensionComponent } from '@grafana/data'; +import type { + PluginExtension, + PluginExtensionLink, + PluginExtensionComponent, + PluginExtensionFunction, +} from '@grafana/data'; import { isPluginExtensionComponent, isPluginExtensionLink } from './utils'; @@ -52,6 +57,16 @@ export type UsePluginLinksResult = { links: PluginExtensionLink[]; }; +export type UsePluginFunctionsOptions = { + extensionPointId: string; + limitPerPlugin?: number; +}; + +export type UsePluginFunctionsResult = { + isLoading: boolean; + functions: Array>; +}; + let singleton: GetPluginExtensions | undefined; export function setPluginExtensionGetter(instance: GetPluginExtensions): void { diff --git a/packages/grafana-runtime/src/services/pluginExtensions/usePluginFunctions.ts b/packages/grafana-runtime/src/services/pluginExtensions/usePluginFunctions.ts new file mode 100644 index 00000000000..1eb86b70e14 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginExtensions/usePluginFunctions.ts @@ -0,0 +1,20 @@ +import { UsePluginFunctionsOptions, UsePluginFunctionsResult } from './getPluginExtensions'; + +export type UsePluginFunctions = (options: UsePluginFunctionsOptions) => UsePluginFunctionsResult; + +let singleton: UsePluginFunctions | undefined; + +export function setPluginFunctionsHook(hook: UsePluginFunctions): void { + // We allow overriding the registry in tests + if (singleton && process.env.NODE_ENV !== 'test') { + throw new Error('setUsePluginFunctionsHook() function should only be called once, when Grafana is starting.'); + } + singleton = hook; +} + +export function usePluginFunctions(options: UsePluginFunctionsOptions): UsePluginFunctionsResult { + if (!singleton) { + throw new Error('usePluginFunctions(options) can only be used after the Grafana instance has started.'); + } + return singleton(options) as UsePluginFunctionsResult; +} diff --git a/pkg/plugins/manager/loader/finder/local_test.go b/pkg/plugins/manager/loader/finder/local_test.go index 9664f824186..18946548c56 100644 --- a/pkg/plugins/manager/loader/finder/local_test.go +++ b/pkg/plugins/manager/loader/finder/local_test.go @@ -57,6 +57,7 @@ func TestFinder_Find(t *testing.T) { Extensions: plugins.Extensions{ AddedLinks: []plugins.AddedLink{}, AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -96,8 +97,10 @@ func TestFinder_Find(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -127,8 +130,10 @@ func TestFinder_Find(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -200,8 +205,10 @@ func TestFinder_Find(t *testing.T) { {Name: "Nginx Datasource", Type: "datasource", Role: "Viewer", Action: "plugins.app:access"}, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -238,8 +245,10 @@ func TestFinder_Find(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -269,8 +278,10 @@ func TestFinder_Find(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -300,8 +311,10 @@ func TestFinder_Find(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -340,8 +353,10 @@ func TestFinder_Find(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, diff --git a/pkg/plugins/manager/loader/loader_test.go b/pkg/plugins/manager/loader/loader_test.go index d39c9c21303..e5d1b260199 100644 --- a/pkg/plugins/manager/loader/loader_test.go +++ b/pkg/plugins/manager/loader/loader_test.go @@ -106,6 +106,7 @@ func TestLoader_Load(t *testing.T) { Extensions: plugins.Extensions{ AddedLinks: []plugins.AddedLink{}, AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -201,8 +202,10 @@ func TestLoader_Load(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -249,8 +252,10 @@ func TestLoader_Load(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -304,8 +309,10 @@ func TestLoader_Load(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -398,8 +405,10 @@ func TestLoader_Load(t *testing.T) { {Name: "Root Page (react)", Type: "page", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Path: "/a/my-simple-app", DefaultNav: true, AddToNav: true, Slug: "root-page-react"}, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, diff --git a/pkg/plugins/models.go b/pkg/plugins/models.go index 6b9b8e05ad2..8440cb37a11 100644 --- a/pkg/plugins/models.go +++ b/pkg/plugins/models.go @@ -63,6 +63,7 @@ type ExtensionsV2 struct { AddedComponents []AddedComponent `json:"addedComponents"` ExposedComponents []ExposedComponent `json:"exposedComponents"` ExtensionPoints []ExtensionPoint `json:"extensionPoints"` + AddedFunctions []AddedFunction `json:"addedFunctions"` } type Extensions ExtensionsV2 @@ -76,6 +77,7 @@ func (e *Extensions) UnmarshalJSON(data []byte) error { e.AddedLinks = extensionsV2.AddedLinks e.ExposedComponents = extensionsV2.ExposedComponents e.ExtensionPoints = extensionsV2.ExtensionPoints + e.AddedFunctions = extensionsV2.AddedFunctions return nil } @@ -123,6 +125,11 @@ type AddedComponent struct { Description string `json:"description"` } +type AddedFunction struct { + Targets []string `json:"targets"` + Title string `json:"title"` +} + type ExposedComponent struct { Id string `json:"id"` Title string `json:"title"` @@ -267,6 +274,7 @@ type PluginMetaDTO struct { Angular AngularMeta `json:"angular"` MultiValueFilterOperators bool `json:"multiValueFilterOperators"` LoadingStrategy LoadingStrategy `json:"loadingStrategy"` + Extensions Extensions `json:"extensions"` } type DataSourceDTO struct { diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index 3239927747b..f815d07c6b1 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -167,6 +167,10 @@ func ReadPluginJSON(reader io.Reader) (JSONData, error) { plugin.Extensions.AddedComponents = []AddedComponent{} } + if plugin.Extensions.AddedFunctions == nil { + plugin.Extensions.AddedFunctions = []AddedFunction{} + } + if plugin.Extensions.ExposedComponents == nil { plugin.Extensions.ExposedComponents = []ExposedComponent{} } diff --git a/pkg/plugins/plugins_test.go b/pkg/plugins/plugins_test.go index b3922755603..2a6f85dee07 100644 --- a/pkg/plugins/plugins_test.go +++ b/pkg/plugins/plugins_test.go @@ -56,6 +56,7 @@ func Test_ReadPluginJSON(t *testing.T) { Extensions: Extensions{ AddedLinks: []AddedLink{}, AddedComponents: []AddedComponent{}, + AddedFunctions: []AddedFunction{}, ExposedComponents: []ExposedComponent{}, ExtensionPoints: []ExtensionPoint{}, }, @@ -108,8 +109,10 @@ func Test_ReadPluginJSON(t *testing.T) { Name: "Pie Chart (old)", Extensions: Extensions{ - AddedLinks: []AddedLink{}, - AddedComponents: []AddedComponent{}, + AddedLinks: []AddedLink{}, + AddedComponents: []AddedComponent{}, + AddedFunctions: []AddedFunction{}, + ExposedComponents: []ExposedComponent{}, ExtensionPoints: []ExtensionPoint{}, }, @@ -143,8 +146,10 @@ func Test_ReadPluginJSON(t *testing.T) { Type: TypeDataSource, Extensions: Extensions{ - AddedLinks: []AddedLink{}, - AddedComponents: []AddedComponent{}, + AddedLinks: []AddedLink{}, + AddedComponents: []AddedComponent{}, + AddedFunctions: []AddedFunction{}, + ExposedComponents: []ExposedComponent{}, ExtensionPoints: []ExtensionPoint{}, }, @@ -188,6 +193,9 @@ func Test_ReadPluginJSON(t *testing.T) { "id": "myorg-extensions-app/component-1/v1" } ], + "addedFunctions": [ + {"targets": ["foo/bar"], "title":"some hook"} + ], "extensionPoints": [ { "title": "Extension point 1", @@ -209,6 +217,7 @@ func Test_ReadPluginJSON(t *testing.T) { {Title: "Added link 1", Description: "Added link 1 description", Targets: []string{"grafana/dashboard/panel/menu"}}, }, AddedComponents: []AddedComponent{ + {Title: "Added component 1", Description: "Added component 1 description", Targets: []string{"grafana/user/profile/tab"}}, }, ExposedComponents: []ExposedComponent{ @@ -217,6 +226,9 @@ func Test_ReadPluginJSON(t *testing.T) { ExtensionPoints: []ExtensionPoint{ {Id: "myorg-extensions-app/extensions-point-1/v1", Title: "Extension point 1", Description: "Extension points 1 description"}, }, + AddedFunctions: []AddedFunction{ + {Targets: []string{"foo/bar"}, Title: "some hook"}, + }, }, Dependencies: Dependencies{ @@ -271,6 +283,7 @@ func Test_ReadPluginJSON(t *testing.T) { AddedComponents: []AddedComponent{ {Title: "Added component 1", Description: "Added component 1 description", Targets: []string{"grafana/user/profile/tab"}}, }, + AddedFunctions: []AddedFunction{}, ExposedComponents: []ExposedComponent{}, ExtensionPoints: []ExtensionPoint{}, }, @@ -301,8 +314,10 @@ func Test_ReadPluginJSON(t *testing.T) { Type: TypeApp, Extensions: Extensions{ - AddedLinks: []AddedLink{}, - AddedComponents: []AddedComponent{}, + AddedLinks: []AddedLink{}, + AddedComponents: []AddedComponent{}, + AddedFunctions: []AddedFunction{}, + ExposedComponents: []ExposedComponent{}, ExtensionPoints: []ExtensionPoint{}, }, @@ -332,8 +347,10 @@ func Test_ReadPluginJSON(t *testing.T) { Type: TypeApp, Extensions: Extensions{ - AddedLinks: []AddedLink{}, - AddedComponents: []AddedComponent{}, + AddedLinks: []AddedLink{}, + AddedComponents: []AddedComponent{}, + AddedFunctions: []AddedFunction{}, + ExposedComponents: []ExposedComponent{}, ExtensionPoints: []ExtensionPoint{}, }, @@ -371,6 +388,7 @@ func Test_ReadPluginJSON(t *testing.T) { Extensions: Extensions{ AddedLinks: []AddedLink{}, AddedComponents: []AddedComponent{}, + AddedFunctions: []AddedFunction{}, ExposedComponents: []ExposedComponent{}, ExtensionPoints: []ExtensionPoint{}, }, diff --git a/pkg/services/pluginsintegration/loader/loader_test.go b/pkg/services/pluginsintegration/loader/loader_test.go index ca6fef68b50..644058909d4 100644 --- a/pkg/services/pluginsintegration/loader/loader_test.go +++ b/pkg/services/pluginsintegration/loader/loader_test.go @@ -105,6 +105,7 @@ func TestLoader_Load(t *testing.T) { Extensions: plugins.Extensions{ AddedLinks: []plugins.AddedLink{}, AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -200,8 +201,10 @@ func TestLoader_Load(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -248,8 +251,10 @@ func TestLoader_Load(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -309,8 +314,10 @@ func TestLoader_Load(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -423,8 +430,10 @@ func TestLoader_Load(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -504,8 +513,10 @@ func TestLoader_Load_ExternalRegistration(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -615,8 +626,10 @@ func TestLoader_Load_CustomSource(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -696,8 +709,10 @@ func TestLoader_Load_MultiplePlugins(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -801,8 +816,10 @@ func TestLoader_Load_RBACReady(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -883,8 +900,10 @@ func TestLoader_Load_Signature_RootURL(t *testing.T) { ExposedComponents: []string{}, }}, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -964,8 +983,10 @@ func TestLoader_Load_DuplicatePlugins(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -1060,8 +1081,10 @@ func TestLoader_Load_SkipUninitializedPlugins(t *testing.T) { {Name: "Nginx Datasource", Type: "datasource", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-datasource"}, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -1272,8 +1295,10 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -1314,8 +1339,10 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -1463,8 +1490,10 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, @@ -1512,8 +1541,10 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { }, }, Extensions: plugins.Extensions{ - AddedLinks: []plugins.AddedLink{}, - AddedComponents: []plugins.AddedComponent{}, + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + AddedFunctions: []plugins.AddedFunction{}, + ExposedComponents: []plugins.ExposedComponent{}, ExtensionPoints: []plugins.ExtensionPoint{}, }, diff --git a/public/app/app.ts b/public/app/app.ts index f4c4fd5d77c..cc3a2e5e5c5 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -40,6 +40,7 @@ import { setChromeHeaderHeightHook, setPluginLinksHook, setCorrelationsService, + setPluginFunctionsHook, } from '@grafana/runtime'; import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView'; import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer'; @@ -89,6 +90,7 @@ import { pluginExtensionRegistries } from './features/plugins/extensions/registr import { usePluginComponent } from './features/plugins/extensions/usePluginComponent'; import { usePluginComponents } from './features/plugins/extensions/usePluginComponents'; import { createUsePluginExtensions } from './features/plugins/extensions/usePluginExtensions'; +import { usePluginFunctions } from './features/plugins/extensions/usePluginFunctions'; import { usePluginLinks } from './features/plugins/extensions/usePluginLinks'; import { getAppPluginsToAwait, getAppPluginsToPreload } from './features/plugins/extensions/utils'; import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin'; @@ -229,6 +231,7 @@ export class GrafanaApp { setPluginLinksHook(usePluginLinks); setPluginComponentHook(usePluginComponent); setPluginComponentsHook(usePluginComponents); + setPluginFunctionsHook(usePluginFunctions); // initialize chrome service const queryParams = locationService.getSearchObject(); diff --git a/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts b/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts index 1d30b05ad50..e0a0142993f 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts @@ -24,6 +24,7 @@ export const getPluginsHandler = (pluginsArray: PluginMeta[] = plugins) => { addedComponents: [], extensionPoints: [], exposedComponents: [], + addedFunctions: [], }, dependencies: { grafanaVersion: '', diff --git a/public/app/features/alerting/unified/testSetup/plugins.ts b/public/app/features/alerting/unified/testSetup/plugins.ts index dd3fb82ee76..72a94187ef0 100644 --- a/public/app/features/alerting/unified/testSetup/plugins.ts +++ b/public/app/features/alerting/unified/testSetup/plugins.ts @@ -163,6 +163,7 @@ export function pluginMetaToPluginConfig(pluginMeta: PluginMeta): AppPluginConfi addedComponents: [], extensionPoints: [], exposedComponents: [], + addedFunctions: [], }, }; } diff --git a/public/app/features/alerting/unified/utils/rules.test.ts b/public/app/features/alerting/unified/utils/rules.test.ts index 4b4036c40d2..63c0cb62f04 100644 --- a/public/app/features/alerting/unified/utils/rules.test.ts +++ b/public/app/features/alerting/unified/utils/rules.test.ts @@ -55,6 +55,7 @@ describe('getRuleOrigin', () => { addedComponents: [], extensionPoints: [], exposedComponents: [], + addedFunctions: [], }, dependencies: { grafanaVersion: '', diff --git a/public/app/features/plugins/components/AppRootPage.test.tsx b/public/app/features/plugins/components/AppRootPage.test.tsx index 84736aed8de..c9e5ec616be 100644 --- a/public/app/features/plugins/components/AppRootPage.test.tsx +++ b/public/app/features/plugins/components/AppRootPage.test.tsx @@ -12,6 +12,7 @@ import { Echo } from 'app/core/services/echo/Echo'; import { ExtensionRegistriesProvider } from '../extensions/ExtensionRegistriesContext'; import { AddedComponentsRegistry } from '../extensions/registry/AddedComponentsRegistry'; +import { AddedFunctionsRegistry } from '../extensions/registry/AddedFunctionsRegistry'; import { AddedLinksRegistry } from '../extensions/registry/AddedLinksRegistry'; import { ExposedComponentsRegistry } from '../extensions/registry/ExposedComponentsRegistry'; import { getPluginSettings } from '../pluginSettings'; @@ -93,6 +94,7 @@ function renderUnderRouter(page = '') { addedComponentsRegistry: new AddedComponentsRegistry(), exposedComponentsRegistry: new ExposedComponentsRegistry(), addedLinksRegistry: new AddedLinksRegistry(), + addedFunctionsRegistry: new AddedFunctionsRegistry(), }; const pagePath = page ? `/${page}` : ''; const route = { diff --git a/public/app/features/plugins/components/AppRootPage.tsx b/public/app/features/plugins/components/AppRootPage.tsx index f0d6bd5329e..95a280027df 100644 --- a/public/app/features/plugins/components/AppRootPage.tsx +++ b/public/app/features/plugins/components/AppRootPage.tsx @@ -29,6 +29,7 @@ import { useAddedLinksRegistry, useAddedComponentsRegistry, useExposedComponentsRegistry, + useAddedFunctionsRegistry, } from '../extensions/ExtensionRegistriesContext'; import { getPluginSettings } from '../pluginSettings'; import { importAppPlugin } from '../plugin_loader'; @@ -60,6 +61,7 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) { const addedLinksRegistry = useAddedLinksRegistry(); const addedComponentsRegistry = useAddedComponentsRegistry(); const exposedComponentsRegistry = useExposedComponentsRegistry(); + const addedFunctionsRegistry = useAddedFunctionsRegistry(); const location = useLocation(); const [state, dispatch] = useReducer(stateSlice.reducer, initialState); const currentUrl = config.appSubUrl + location.pathname + location.search; @@ -104,6 +106,7 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) { addedLinksRegistry: addedLinksRegistry.readOnly(), addedComponentsRegistry: addedComponentsRegistry.readOnly(), exposedComponentsRegistry: exposedComponentsRegistry.readOnly(), + addedFunctionsRegistry: addedFunctionsRegistry.readOnly(), }} > (undefined); export const AddedComponentsRegistryContext = createContext(undefined); +export const AddedFunctionsRegistryContext = createContext(undefined); export const ExposedComponentsRegistryContext = createContext(undefined); export function useAddedLinksRegistry(): AddedLinksRegistry { @@ -31,6 +33,14 @@ export function useAddedComponentsRegistry(): AddedComponentsRegistry { return context; } +export function useAddedFunctionsRegistry(): AddedFunctionsRegistry { + const context = useContext(AddedFunctionsRegistryContext); + if (!context) { + throw new Error('No `AddedFunctionsRegistry` found.'); + } + return context; +} + export function useExposedComponentsRegistry(): ExposedComponentsRegistry { const context = useContext(ExposedComponentsRegistryContext); if (!context) { @@ -46,9 +56,11 @@ export const ExtensionRegistriesProvider = ({ return ( - - {children} - + + + {children} + + ); diff --git a/public/app/features/plugins/extensions/errors.ts b/public/app/features/plugins/extensions/errors.ts index 39c65df0f8e..cb9d22e7b46 100644 --- a/public/app/features/plugins/extensions/errors.ts +++ b/public/app/features/plugins/extensions/errors.ts @@ -8,6 +8,8 @@ export const TITLE_MISSING = 'Title is missing.'; export const DESCRIPTION_MISSING = 'Description is missing.'; +export const INVALID_EXTENSION_FUNCTION = 'The "fn" argument is invalid, it should be a function.'; + export const INVALID_CONFIGURE_FUNCTION = 'The "configure" function is invalid. It should be a function.'; export const INVALID_PATH_OR_ON_CLICK = 'Either "path" or "onClick" is required.'; @@ -33,6 +35,9 @@ export const TITLE_NOT_MATCHING_META_INFO = 'The "title" doesn\'t match the titl export const ADDED_LINK_META_INFO_MISSING = 'The extension was not recorded in the plugin.json. Added link extensions must be listed in the section "extensions.addedLinks[]". Currently, this is only required in development but will be enforced also in production builds in the future.'; +export const ADDED_FUNCTION_META_INFO_MISSING = + 'The extension was not recorded in the plugin.json. Added function extensions must be listed in the section "extensions.addedFunction[]". Currently, this is only required in development but will be enforced also in production builds in the future.'; + export const DESCRIPTION_NOT_MATCHING_META_INFO = 'The "description" doesn\'t match the description recorded in plugin.json.'; diff --git a/public/app/features/plugins/extensions/getPluginExtensions.ts b/public/app/features/plugins/extensions/getPluginExtensions.ts index a47292c8497..30f5865b259 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.ts +++ b/public/app/features/plugins/extensions/getPluginExtensions.ts @@ -1,8 +1,8 @@ import { isString } from 'lodash'; import { - type PluginExtension, PluginExtensionTypes, + type PluginExtension, type PluginExtensionLink, type PluginExtensionComponent, } from '@grafana/data'; diff --git a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts index e82c48dcee0..5a18e222431 100644 --- a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts +++ b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts @@ -52,6 +52,7 @@ describe('AddedComponentsRegistry', () => { extensions: { addedLinks: [], addedComponents: [], + addedFunctions: [], exposedComponents: [], extensionPoints: [], }, diff --git a/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts new file mode 100644 index 00000000000..ae0addc52c7 --- /dev/null +++ b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts @@ -0,0 +1,677 @@ +import { firstValueFrom } from 'rxjs'; + +import { PluginLoadingStrategy } from '@grafana/data'; +import { config } from '@grafana/runtime'; + +import { log } from '../logs/log'; +import { resetLogMock } from '../logs/testUtils'; +import { isGrafanaDevMode } from '../utils'; + +import { AddedFunctionsRegistry } from './AddedFunctionsRegistry'; +import { MSG_CANNOT_REGISTER_READ_ONLY } from './Registry'; + +jest.mock('../utils', () => ({ + ...jest.requireActual('../utils'), + + // Manually set the dev mode to false + // (to make sure that by default we are testing a production scneario) + isGrafanaDevMode: jest.fn().mockReturnValue(false), +})); + +jest.mock('../logs/log', () => { + const { createLogMock } = jest.requireActual('../logs/testUtils'); + const original = jest.requireActual('../logs/log'); + + return { + ...original, + log: createLogMock(), + }; +}); + +describe('addedFunctionsRegistry', () => { + const originalApps = config.apps; + const pluginId = 'grafana-basic-app'; + const appPluginConfig = { + id: pluginId, + path: '', + version: '', + preload: false, + angular: { + detected: false, + hideDeprecation: false, + }, + loadingStrategy: PluginLoadingStrategy.fetch, + dependencies: { + grafanaVersion: '8.0.0', + plugins: [], + extensions: { + exposedComponents: [], + }, + }, + extensions: { + addedFunctions: [], + addedLinks: [], + addedComponents: [], + exposedComponents: [], + extensionPoints: [], + }, + }; + + beforeEach(() => { + resetLogMock(log); + jest.mocked(isGrafanaDevMode).mockReturnValue(false); + config.apps = { + [pluginId]: appPluginConfig, + }; + }); + + afterEach(() => { + config.apps = originalApps; + }); + + it('should return empty registry when no extensions registered', async () => { + const addedFunctionsRegistry = new AddedFunctionsRegistry(); + const observable = addedFunctionsRegistry.asObservable(); + const registry = await firstValueFrom(observable); + expect(registry).toEqual({}); + }); + + it('should be possible to register function extensions in the registry', async () => { + const addedFunctionsRegistry = new AddedFunctionsRegistry(); + + addedFunctionsRegistry.register({ + pluginId, + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn(), + }, + { + title: 'Function 2', + description: 'Function 2 description', + targets: 'plugins/myorg-basic-app/start', + fn: jest.fn(), + }, + ], + }); + + const registry = await addedFunctionsRegistry.getState(); + + expect(registry).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId, + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + ], + 'plugins/myorg-basic-app/start': [ + { + pluginId: pluginId, + title: 'Function 2', + description: 'Function 2 description', + extensionPointId: 'plugins/myorg-basic-app/start', + fn: expect.any(Function), + }, + ], + }); + }); + it('should be possible to asynchronously register function extensions for the same placement (different plugins)', async () => { + const pluginId1 = 'grafana-basic-app'; + const pluginId2 = 'grafana-basic-app2'; + const reactiveRegistry = new AddedFunctionsRegistry(); + + // Register extensions for the first plugin + reactiveRegistry.register({ + pluginId: pluginId1, + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry1 = await reactiveRegistry.getState(); + + expect(registry1).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId1, + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + ], + }); + + // Register extensions for the second plugin to a different placement + reactiveRegistry.register({ + pluginId: pluginId2, + configs: [ + { + title: 'Function 2', + description: 'Function 2 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry2 = await reactiveRegistry.getState(); + + expect(registry2).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId1, + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + { + pluginId: pluginId2, + title: 'Function 2', + description: 'Function 2 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + ], + }); + }); + + it('should be possible to asynchronously register function extensions for a different placement (different plugin)', async () => { + const pluginId1 = 'grafana-basic-app'; + const pluginId2 = 'grafana-basic-app2'; + const reactiveRegistry = new AddedFunctionsRegistry(); + + // Register extensions for the first plugin + reactiveRegistry.register({ + pluginId: pluginId1, + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry1 = await reactiveRegistry.getState(); + + expect(registry1).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId1, + + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + ], + }); + + // Register extensions for the second plugin to a different placement + reactiveRegistry.register({ + pluginId: pluginId2, + configs: [ + { + title: 'Function 2', + description: 'Function 2 description', + targets: 'plugins/myorg-basic-app/start', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry2 = await reactiveRegistry.getState(); + + expect(registry2).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId1, + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + ], + 'plugins/myorg-basic-app/start': [ + { + pluginId: pluginId2, + title: 'Function 2', + description: 'Function 2 description', + extensionPointId: 'plugins/myorg-basic-app/start', + fn: expect.any(Function), + }, + ], + }); + }); + + it('should be possible to asynchronously register function extensions for the same placement (same plugin)', async () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedFunctionsRegistry(); + + // Register extensions for the first extension point + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + // Register extensions to a different extension point + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Function 2', + description: 'Function 2 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry2 = await reactiveRegistry.getState(); + + expect(registry2).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId, + + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + { + pluginId: pluginId, + + title: 'Function 2', + description: 'Function 2 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + ], + }); + }); + + it('should be possible to asynchronously register function extensions for a different placement (same plugin)', async () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedFunctionsRegistry(); + + // Register extensions for the first extension point + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + // Register extensions to a different extension point + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Function 2', + description: 'Function 2 description', + targets: 'plugins/myorg-basic-app/start', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry2 = await reactiveRegistry.getState(); + + expect(registry2).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId, + + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + ], + 'plugins/myorg-basic-app/start': [ + { + pluginId: pluginId, + + title: 'Function 2', + description: 'Function 2 description', + extensionPointId: 'plugins/myorg-basic-app/start', + fn: expect.any(Function), + }, + ], + }); + }); + + it('should notify subscribers when the registry changes', async () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedFunctionsRegistry(); + const observable = reactiveRegistry.asObservable(); + const subscribeCallback = jest.fn(); + + observable.subscribe(subscribeCallback); + + // Register extensions for the first plugin + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + expect(subscribeCallback).toHaveBeenCalledTimes(2); + + // Register extensions for the first plugin + reactiveRegistry.register({ + pluginId: 'another-plugin', + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + expect(subscribeCallback).toHaveBeenCalledTimes(3); + + const registry = subscribeCallback.mock.calls[2][0]; + + expect(registry).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId, + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + { + pluginId: 'another-plugin', + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + ], + }); + }); + + it('should give the last version of the registry for new subscribers', async () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedFunctionsRegistry(); + const observable = reactiveRegistry.asObservable(); + const subscribeCallback = jest.fn(); + + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + observable.subscribe(subscribeCallback); + expect(subscribeCallback).toHaveBeenCalledTimes(1); + + const registry = subscribeCallback.mock.calls[0][0]; + + expect(registry).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId, + title: 'Function 1', + description: 'Function 1 description', + extensionPointId: 'grafana/dashboard/panel/menu', + fn: expect.any(Function), + }, + ], + }); + }); + + it('should not register a function extension if it has an invalid fn function', () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedFunctionsRegistry(); + const observable = reactiveRegistry.asObservable(); + const subscribeCallback = jest.fn(); + + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + //@ts-ignore + fn: '...', + }, + ], + }); + + expect(log.error).toHaveBeenCalled(); + + observable.subscribe(subscribeCallback); + expect(subscribeCallback).toHaveBeenCalledTimes(1); + + const registry = subscribeCallback.mock.calls[0][0]; + expect(registry).toEqual({}); + }); + + it('should not register a function extension if it has invalid properties (empty title)', () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedFunctionsRegistry(); + const observable = reactiveRegistry.asObservable(); + const subscribeCallback = jest.fn(); + + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: '', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + expect(log.error).toHaveBeenCalled(); + + observable.subscribe(subscribeCallback); + expect(subscribeCallback).toHaveBeenCalledTimes(1); + + const registry = subscribeCallback.mock.calls[0][0]; + expect(registry).toEqual({}); + }); + + it('should not be possible to register a function on a read-only registry', async () => { + const pluginId = 'grafana-basic-app'; + const registry = new AddedFunctionsRegistry(); + const readOnlyRegistry = registry.readOnly(); + + expect(() => { + readOnlyRegistry.register({ + pluginId, + configs: [ + { + title: 'Function 2', + description: 'Function 2 description', + targets: 'plugins/myorg-basic-app/start', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + }).toThrow(MSG_CANNOT_REGISTER_READ_ONLY); + + const currentState = await readOnlyRegistry.getState(); + expect(Object.keys(currentState)).toHaveLength(0); + }); + + it('should pass down fresh registrations to the read-only version of the registry', async () => { + const pluginId = 'grafana-basic-app'; + const registry = new AddedFunctionsRegistry(); + const readOnlyRegistry = registry.readOnly(); + const subscribeCallback = jest.fn(); + let readOnlyState; + + // Should have no extensions registered in the beginning + readOnlyState = await readOnlyRegistry.getState(); + expect(Object.keys(readOnlyState)).toHaveLength(0); + + readOnlyRegistry.asObservable().subscribe(subscribeCallback); + + // Register an extension to the original (writable) registry + registry.register({ + pluginId, + configs: [ + { + title: 'Function 2', + description: 'Function 2 description', + targets: 'plugins/myorg-basic-app/start', + fn: jest.fn().mockReturnValue({}), + }, + ], + }); + + // The read-only registry should have received the new extension + readOnlyState = await readOnlyRegistry.getState(); + expect(Object.keys(readOnlyState)).toHaveLength(1); + + expect(subscribeCallback).toHaveBeenCalledTimes(2); + expect(Object.keys(subscribeCallback.mock.calls[1][0])).toEqual(['plugins/myorg-basic-app/start']); + }); + + it('should not register a function added by a plugin in dev-mode if the meta-info is missing from the plugin.json', async () => { + // Enabling dev mode + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + + const registry = new AddedFunctionsRegistry(); + const fnConfig = { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }; + + // Make sure that the meta-info is empty + config.apps[pluginId].extensions.addedFunctions = []; + + registry.register({ + pluginId, + configs: [fnConfig], + }); + + const currentState = await registry.getState(); + + expect(Object.keys(currentState)).toHaveLength(0); + expect(log.error).toHaveBeenCalled(); + }); + + it('should register a function added by core Grafana in dev-mode even if the meta-info is missing', async () => { + // Enabling dev mode + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + + const registry = new AddedFunctionsRegistry(); + const fnConfig = { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }; + + registry.register({ + pluginId: 'grafana', + configs: [fnConfig], + }); + + const currentState = await registry.getState(); + + expect(Object.keys(currentState)).toHaveLength(1); + expect(log.error).not.toHaveBeenCalled(); + }); + + it('should register a function added by a plugin in production mode even if the meta-info is missing', async () => { + // Production mode + jest.mocked(isGrafanaDevMode).mockReturnValue(false); + + const registry = new AddedFunctionsRegistry(); + const fnConfig = { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn().mockReturnValue({}), + }; + + // Make sure that the meta-info is empty + config.apps[pluginId].extensions.addedFunctions = []; + + registry.register({ + pluginId, + configs: [fnConfig], + }); + + const currentState = await registry.getState(); + + expect(Object.keys(currentState)).toHaveLength(1); + expect(log.error).not.toHaveBeenCalled(); + }); + + it('should register a function added by a plugin in dev-mode if the meta-info is present', async () => { + // Enabling dev mode + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + + const registry = new AddedFunctionsRegistry(); + const fnConfig = { + title: 'Function 1', + description: 'Function 1 description', + targets: ['grafana/dashboard/panel/menu'], + fn: jest.fn().mockReturnValue({}), + }; + + // Make sure that the meta-info is empty + config.apps[pluginId].extensions.addedFunctions = [fnConfig]; + + registry.register({ + pluginId, + configs: [fnConfig], + }); + + const currentState = await registry.getState(); + + expect(Object.keys(currentState)).toHaveLength(1); + expect(log.error).not.toHaveBeenCalled(); + }); +}); diff --git a/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.ts b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.ts new file mode 100644 index 00000000000..d23fe5e78b2 --- /dev/null +++ b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.ts @@ -0,0 +1,87 @@ +import { isFunction } from 'lodash'; +import { ReplaySubject } from 'rxjs'; + +import { PluginExtensionAddedFunctionConfig } from '@grafana/data'; + +import * as errors from '../errors'; +import { isGrafanaDevMode } from '../utils'; +import { isAddedFunctionMetaInfoMissing } from '../validators'; + +import { PluginExtensionConfigs, Registry, RegistryType } from './Registry'; + +const logPrefix = 'Could not register function extension. Reason:'; + +export type AddedFunctionsRegistryItem = { + pluginId: string; + title: string; + fn: unknown; + description?: string; +}; + +export class AddedFunctionsRegistry extends Registry { + constructor( + options: { + registrySubject?: ReplaySubject>; + initialState?: RegistryType; + } = {} + ) { + super(options); + } + + mapToRegistry( + registry: RegistryType, + item: PluginExtensionConfigs + ): RegistryType { + const { pluginId, configs } = item; + for (const config of configs) { + const configLog = this.logger.child({ + title: config.title, + pluginId, + }); + + if (!config.title) { + configLog.error(`${logPrefix} ${errors.TITLE_MISSING}`); + continue; + } + + if (!isFunction(config.fn)) { + configLog.error(`${logPrefix} ${errors.INVALID_EXTENSION_FUNCTION}`); + continue; + } + + if (pluginId !== 'grafana' && isGrafanaDevMode() && isAddedFunctionMetaInfoMissing(pluginId, config, configLog)) { + continue; + } + + const extensionPointIds = Array.isArray(config.targets) ? config.targets : [config.targets]; + for (const extensionPointId of extensionPointIds) { + const pointIdLog = configLog.child({ extensionPointId }); + + const result = { + pluginId, + fn: config.fn, + description: config.description, + title: config.title, + extensionPointId, + }; + + pointIdLog.debug('Added function extension successfully registered'); + + if (!(extensionPointId in registry)) { + registry[extensionPointId] = [result]; + } else { + registry[extensionPointId].push(result); + } + } + } + + return registry; + } + + // Returns a read-only version of the registry. + readOnly() { + return new AddedFunctionsRegistry({ + registrySubject: this.registrySubject, + }); + } +} diff --git a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts index 4d5ab5c084f..d3586240276 100644 --- a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts +++ b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts @@ -51,6 +51,7 @@ describe('AddedLinksRegistry', () => { extensions: { addedLinks: [], addedComponents: [], + addedFunctions: [], exposedComponents: [], extensionPoints: [], }, diff --git a/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.test.ts b/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.test.ts index 0a7036894d1..863c89f7b40 100644 --- a/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.test.ts +++ b/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.test.ts @@ -52,6 +52,7 @@ describe('ExposedComponentsRegistry', () => { extensions: { addedLinks: [], addedComponents: [], + addedFunctions: [], exposedComponents: [], extensionPoints: [], }, diff --git a/public/app/features/plugins/extensions/registry/setup.ts b/public/app/features/plugins/extensions/registry/setup.ts index 6c2fd1e6a5f..91b7badc4eb 100644 --- a/public/app/features/plugins/extensions/registry/setup.ts +++ b/public/app/features/plugins/extensions/registry/setup.ts @@ -1,6 +1,7 @@ import { getCoreExtensionConfigurations } from '../getCoreExtensionConfigurations'; import { AddedComponentsRegistry } from './AddedComponentsRegistry'; +import { AddedFunctionsRegistry } from './AddedFunctionsRegistry'; import { AddedLinksRegistry } from './AddedLinksRegistry'; import { ExposedComponentsRegistry } from './ExposedComponentsRegistry'; import { PluginExtensionRegistries } from './types'; @@ -8,10 +9,12 @@ import { PluginExtensionRegistries } from './types'; export const addedComponentsRegistry = new AddedComponentsRegistry(); export const exposedComponentsRegistry = new ExposedComponentsRegistry(); export const addedLinksRegistry = new AddedLinksRegistry(); +export const addedFunctionsRegistry = new AddedFunctionsRegistry(); export const pluginExtensionRegistries: PluginExtensionRegistries = { addedComponentsRegistry, exposedComponentsRegistry, addedLinksRegistry, + addedFunctionsRegistry, }; // Registering core extensions diff --git a/public/app/features/plugins/extensions/registry/types.ts b/public/app/features/plugins/extensions/registry/types.ts index 115e859b7d9..1927bf31b75 100644 --- a/public/app/features/plugins/extensions/registry/types.ts +++ b/public/app/features/plugins/extensions/registry/types.ts @@ -1,9 +1,11 @@ import { AddedComponentsRegistry } from './AddedComponentsRegistry'; +import { AddedFunctionsRegistry } from './AddedFunctionsRegistry'; import { AddedLinksRegistry } from './AddedLinksRegistry'; import { ExposedComponentsRegistry } from './ExposedComponentsRegistry'; export type PluginExtensionRegistries = { addedComponentsRegistry: AddedComponentsRegistry; exposedComponentsRegistry: ExposedComponentsRegistry; + addedFunctionsRegistry: AddedFunctionsRegistry; addedLinksRegistry: AddedLinksRegistry; }; diff --git a/public/app/features/plugins/extensions/usePluginComponent.test.tsx b/public/app/features/plugins/extensions/usePluginComponent.test.tsx index 6385c022027..b2a3d3d3435 100644 --- a/public/app/features/plugins/extensions/usePluginComponent.test.tsx +++ b/public/app/features/plugins/extensions/usePluginComponent.test.tsx @@ -7,6 +7,7 @@ import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext'; import { log } from './logs/log'; import { resetLogMock } from './logs/testUtils'; import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; +import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry'; import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry'; import { PluginExtensionRegistries } from './registry/types'; @@ -78,6 +79,7 @@ describe('usePluginComponent()', () => { extensions: { addedLinks: [], addedComponents: [], + addedFunctions: [], // This is necessary, so we can register exposed components to the registry during the tests // (Otherwise the registry would reject it in the imitated production mode) exposedComponents: [exposedComponentConfig], @@ -90,6 +92,7 @@ describe('usePluginComponent()', () => { addedComponentsRegistry: new AddedComponentsRegistry(), exposedComponentsRegistry: new ExposedComponentsRegistry(), addedLinksRegistry: new AddedLinksRegistry(), + addedFunctionsRegistry: new AddedFunctionsRegistry(), }; jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false }); jest.mocked(isGrafanaDevMode).mockReturnValue(false); @@ -122,6 +125,7 @@ describe('usePluginComponent()', () => { addedComponents: [], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, dependencies: { grafanaVersion: '8.0.0', diff --git a/public/app/features/plugins/extensions/usePluginComponents.test.tsx b/public/app/features/plugins/extensions/usePluginComponents.test.tsx index 75adda33f75..fcb729fdd64 100644 --- a/public/app/features/plugins/extensions/usePluginComponents.test.tsx +++ b/public/app/features/plugins/extensions/usePluginComponents.test.tsx @@ -6,6 +6,7 @@ import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext'; import { log } from './logs/log'; import { resetLogMock } from './logs/testUtils'; import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; +import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry'; import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry'; import { PluginExtensionRegistries } from './registry/types'; @@ -60,6 +61,7 @@ describe('usePluginComponents()', () => { addedComponentsRegistry: new AddedComponentsRegistry(), exposedComponentsRegistry: new ExposedComponentsRegistry(), addedLinksRegistry: new AddedLinksRegistry(), + addedFunctionsRegistry: new AddedFunctionsRegistry(), }; jest.mocked(wrapWithPluginContext).mockClear(); @@ -89,6 +91,7 @@ describe('usePluginComponents()', () => { addedComponents: [], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, dependencies: { grafanaVersion: '8.0.0', diff --git a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx index 739906dd815..479e5cd4c6c 100644 --- a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx +++ b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx @@ -1,6 +1,7 @@ import { act, renderHook } from '@testing-library/react'; import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; +import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry'; import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry'; import { PluginExtensionRegistries } from './registry/types'; @@ -19,6 +20,7 @@ describe('usePluginExtensions()', () => { addedComponentsRegistry: new AddedComponentsRegistry(), addedLinksRegistry: new AddedLinksRegistry(), exposedComponentsRegistry: new ExposedComponentsRegistry(), + addedFunctionsRegistry: new AddedFunctionsRegistry(), }; jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false }); }); diff --git a/public/app/features/plugins/extensions/usePluginFunctions.tsx b/public/app/features/plugins/extensions/usePluginFunctions.tsx new file mode 100644 index 00000000000..68acee76221 --- /dev/null +++ b/public/app/features/plugins/extensions/usePluginFunctions.tsx @@ -0,0 +1,82 @@ +import { useMemo } from 'react'; +import { useObservable } from 'react-use'; + +import { usePluginContext, PluginExtensionFunction, PluginExtensionTypes } from '@grafana/data'; +import { UsePluginFunctionsOptions, UsePluginFunctionsResult } from '@grafana/runtime'; + +import { useAddedFunctionsRegistry } from './ExtensionRegistriesContext'; +import * as errors from './errors'; +import { log } from './logs/log'; +import { useLoadAppPlugins } from './useLoadAppPlugins'; +import { generateExtensionId, getExtensionPointPluginDependencies, isGrafanaDevMode } from './utils'; +import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators'; + +// Returns an array of component extensions for the given extension point +export function usePluginFunctions({ + limitPerPlugin, + extensionPointId, +}: UsePluginFunctionsOptions): UsePluginFunctionsResult { + const registry = useAddedFunctionsRegistry(); + const registryState = useObservable(registry.asObservable()); + const pluginContext = usePluginContext(); + const deps = getExtensionPointPluginDependencies(extensionPointId); + const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(deps); + + return useMemo(() => { + // For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana. + const enableRestrictions = isGrafanaDevMode() && pluginContext; + const results: Array> = []; + const extensionsByPlugin: Record = {}; + const pluginId = pluginContext?.meta.id ?? ''; + const pointLog = log.child({ + pluginId, + extensionPointId, + }); + if (enableRestrictions && !isExtensionPointIdValid({ extensionPointId, pluginId })) { + pointLog.error(errors.INVALID_EXTENSION_POINT_ID); + } + + if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)) { + pointLog.error(errors.EXTENSION_POINT_META_INFO_MISSING); + return { + isLoading: false, + functions: [], + }; + } + + if (isLoadingAppPlugins) { + return { + isLoading: true, + functions: [], + }; + } + + for (const registryItem of registryState?.[extensionPointId] ?? []) { + const { pluginId } = registryItem; + + // Only limit if the `limitPerPlugin` is set + if (limitPerPlugin && extensionsByPlugin[pluginId] >= limitPerPlugin) { + continue; + } + + if (extensionsByPlugin[pluginId] === undefined) { + extensionsByPlugin[pluginId] = 0; + } + + results.push({ + id: generateExtensionId(pluginId, extensionPointId, registryItem.title), + type: PluginExtensionTypes.function, + title: registryItem.title, + description: registryItem.description ?? '', + pluginId: pluginId, + fn: registryItem.fn as Signature, + }); + extensionsByPlugin[pluginId] += 1; + } + + return { + isLoading: false, + functions: results, + }; + }, [extensionPointId, limitPerPlugin, pluginContext, registryState, isLoadingAppPlugins]); +} diff --git a/public/app/features/plugins/extensions/usePluginLinks.test.tsx b/public/app/features/plugins/extensions/usePluginLinks.test.tsx index 9186ca0ddf6..f9fb623f42a 100644 --- a/public/app/features/plugins/extensions/usePluginLinks.test.tsx +++ b/public/app/features/plugins/extensions/usePluginLinks.test.tsx @@ -6,6 +6,7 @@ import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext'; import { log } from './logs/log'; import { resetLogMock } from './logs/testUtils'; import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; +import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry'; import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry'; import { PluginExtensionRegistries } from './registry/types'; @@ -57,6 +58,7 @@ describe('usePluginLinks()', () => { addedComponentsRegistry: new AddedComponentsRegistry(), exposedComponentsRegistry: new ExposedComponentsRegistry(), addedLinksRegistry: new AddedLinksRegistry(), + addedFunctionsRegistry: new AddedFunctionsRegistry(), }; resetLogMock(log); @@ -85,6 +87,7 @@ describe('usePluginLinks()', () => { addedComponents: [], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, dependencies: { grafanaVersion: '8.0.0', diff --git a/public/app/features/plugins/extensions/utils.test.tsx b/public/app/features/plugins/extensions/utils.test.tsx index 32ad6215875..0e2d98f44b3 100644 --- a/public/app/features/plugins/extensions/utils.test.tsx +++ b/public/app/features/plugins/extensions/utils.test.tsx @@ -475,6 +475,7 @@ describe('Plugin Extensions / Utils', () => { extensions: { addedLinks: [], addedComponents: [], + addedFunctions: [], exposedComponents: [], extensionPoints: [], }, @@ -553,6 +554,7 @@ describe('Plugin Extensions / Utils', () => { extensions: { addedLinks: [], addedComponents: [], + addedFunctions: [], exposedComponents: [], extensionPoints: [], }, @@ -584,6 +586,7 @@ describe('Plugin Extensions / Utils', () => { addedComponents: [], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, }, 'myorg-third-app': { @@ -623,6 +626,7 @@ describe('Plugin Extensions / Utils', () => { ], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, }, }; @@ -679,6 +683,7 @@ describe('Plugin Extensions / Utils', () => { ], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, dependencies: { ...genereicAppPluginConfig.dependencies, @@ -705,6 +710,7 @@ describe('Plugin Extensions / Utils', () => { }, ], extensionPoints: [], + addedFunctions: [], }, dependencies: { ...genereicAppPluginConfig.dependencies, @@ -726,6 +732,7 @@ describe('Plugin Extensions / Utils', () => { }, ], extensionPoints: [], + addedFunctions: [], }, }, 'myorg-sixth-app': { @@ -763,6 +770,7 @@ describe('Plugin Extensions / Utils', () => { addedComponents: [], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, }; @@ -791,6 +799,7 @@ describe('Plugin Extensions / Utils', () => { }, ], extensionPoints: [], + addedFunctions: [], }, }, 'myorg-third-app': { @@ -825,6 +834,7 @@ describe('Plugin Extensions / Utils', () => { }, ], extensionPoints: [], + addedFunctions: [], }, dependencies: { ...genereicAppPluginConfig.dependencies, @@ -850,6 +860,7 @@ describe('Plugin Extensions / Utils', () => { }, ], extensionPoints: [], + addedFunctions: [], }, dependencies: { ...genereicAppPluginConfig.dependencies, @@ -871,6 +882,7 @@ describe('Plugin Extensions / Utils', () => { }, ], extensionPoints: [], + addedFunctions: [], }, }, }; @@ -902,6 +914,7 @@ describe('Plugin Extensions / Utils', () => { extensions: { addedLinks: [], addedComponents: [], + addedFunctions: [], exposedComponents: [], extensionPoints: [], }, diff --git a/public/app/features/plugins/extensions/validators.test.tsx b/public/app/features/plugins/extensions/validators.test.tsx index 12b146bbf5c..f083ff738ea 100644 --- a/public/app/features/plugins/extensions/validators.test.tsx +++ b/public/app/features/plugins/extensions/validators.test.tsx @@ -271,6 +271,7 @@ describe('Plugin Extension Validators', () => { addedComponents: [], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, }; const extensionConfig = { @@ -387,6 +388,7 @@ describe('Plugin Extension Validators', () => { addedComponents: [], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, }; const extensionConfig = { @@ -503,6 +505,7 @@ describe('Plugin Extension Validators', () => { addedComponents: [], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, }; const exposedComponentConfig = { @@ -688,6 +691,7 @@ describe('Plugin Extension Validators', () => { addedComponents: [], exposedComponents: [], extensionPoints: [], + addedFunctions: [], }, dependencies: { grafanaVersion: '8.0.0', diff --git a/public/app/features/plugins/extensions/validators.ts b/public/app/features/plugins/extensions/validators.ts index b1dbd5e9af8..cbdaf81e935 100644 --- a/public/app/features/plugins/extensions/validators.ts +++ b/public/app/features/plugins/extensions/validators.ts @@ -5,6 +5,7 @@ import type { PluginContextType, PluginExtensionAddedComponentConfig, PluginExtensionExposedComponentConfig, + PluginExtensionAddedFunctionConfig, } from '@grafana/data'; import { PluginAddedLinksConfigureFunc, PluginExtensionPoints } from '@grafana/data/src/types/pluginExtensions'; import { config, isPluginExtensionLink } from '@grafana/runtime'; @@ -160,6 +161,38 @@ export const isAddedLinkMetaInfoMissing = ( return false; }; +export const isAddedFunctionMetaInfoMissing = ( + pluginId: string, + metaInfo: PluginExtensionAddedFunctionConfig, + log: ExtensionsLog +) => { + const logPrefix = 'Could not register function extension. Reason:'; + const app = config.apps[pluginId]; + const pluginJsonMetaInfo = app ? app.extensions.addedFunctions.find(({ title }) => title === metaInfo.title) : null; + + if (!app) { + log.error(`${logPrefix} ${errors.APP_NOT_FOUND(pluginId)}`); + return true; + } + + if (!pluginJsonMetaInfo) { + log.error(`${logPrefix} ${errors.ADDED_FUNCTION_META_INFO_MISSING}`); + return true; + } + + const targets = Array.isArray(metaInfo.targets) ? metaInfo.targets : [metaInfo.targets]; + if (!targets.every((target) => pluginJsonMetaInfo.targets.includes(target))) { + log.error(`${logPrefix} ${errors.TARGET_NOT_MATCHING_META_INFO}`); + return true; + } + + if (pluginJsonMetaInfo.description !== metaInfo.description) { + log.warning(errors.DESCRIPTION_NOT_MATCHING_META_INFO); + } + + return false; +}; + export const isAddedComponentMetaInfoMissing = ( pluginId: string, metaInfo: PluginExtensionAddedComponentConfig, diff --git a/public/app/features/plugins/importPanelPlugin.ts b/public/app/features/plugins/importPanelPlugin.ts index 45732de7fb1..f6d9c1ac2a6 100644 --- a/public/app/features/plugins/importPanelPlugin.ts +++ b/public/app/features/plugins/importPanelPlugin.ts @@ -82,7 +82,6 @@ function getPanelPlugin(meta: PanelPluginMeta): Promise { if (!plugin.panel && plugin.angularPanelCtrl) { plugin.panel = getAngularPanelReactWrapper(plugin); } - return plugin; }) .catch((err) => { diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts index aa20ae5881f..98915c40f6d 100644 --- a/public/app/features/plugins/plugin_loader.ts +++ b/public/app/features/plugins/plugin_loader.ts @@ -13,7 +13,12 @@ import { DataQuery } from '@grafana/schema'; import { GenericDataSourcePlugin } from '../datasources/types'; import builtInPlugins from './built_in_plugins'; -import { addedComponentsRegistry, addedLinksRegistry, exposedComponentsRegistry } from './extensions/registry/setup'; +import { + addedComponentsRegistry, + addedFunctionsRegistry, + addedLinksRegistry, + exposedComponentsRegistry, +} from './extensions/registry/setup'; import { getPluginFromCache, registerPluginInCache } from './loader/cache'; // SystemJS has to be imported before the sharedDependenciesMap import { SystemJS } from './loader/systemjs'; @@ -153,7 +158,6 @@ export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise, @@ -205,6 +209,10 @@ export async function importAppPlugin(meta: PluginMeta): Promise { pluginId, configs: plugin.addedLinkConfigs || [], }); + addedFunctionsRegistry.register({ + pluginId, + configs: plugin.addedFunctionConfigs || [], + }); importedAppPlugins[pluginId] = plugin; From 5a6d2f2e49b8d7c3cb2032c3fe5e88eeed28d524 Mon Sep 17 00:00:00 2001 From: Misi Date: Thu, 13 Feb 2025 10:46:19 +0100 Subject: [PATCH 02/14] Auth: Add early return if `auth_token` is in the URL for JWT auth (#100539) * Add early return * Update public/app/app.ts Co-authored-by: Victor Cinaglia --------- Co-authored-by: Victor Cinaglia --- public/app/app.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/public/app/app.ts b/public/app/app.ts index cc3a2e5e5c5..86b2386dc6c 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -409,6 +409,7 @@ function handleRedirectTo(): void { if (queryParams.has('auth_token')) { // URL Login should not be redirected window.sessionStorage.removeItem(RedirectToUrlKey); + return; } if (queryParams.has(redirectToParamKey) && window.location.pathname !== '/') { From 293f514854294555670e76f910ed63c7c5514a24 Mon Sep 17 00:00:00 2001 From: Bogdan Matei Date: Thu, 13 Feb 2025 11:58:28 +0200 Subject: [PATCH 03/14] Dashboard: Fix removing row repeats having indexes ending with 0 (#100487) --- .../RowRepeaterBehavior.test.tsx | 45 +++++++++++++++++++ .../RowItemRepeaterBehavior.test.tsx | 45 +++++++++++++++++++ .../dashboard-scene/utils/clone.test.ts | 14 ++++++ .../features/dashboard-scene/utils/clone.ts | 2 +- 4 files changed, 105 insertions(+), 1 deletion(-) diff --git a/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.test.tsx b/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.test.tsx index 2e728f25a39..843f58b5640 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.test.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.test.tsx @@ -160,6 +160,51 @@ describe('RowRepeaterBehavior', () => { }); }); + describe('Given scene with variable with 15 values', () => { + let scene: DashboardScene, grid: SceneGridLayout; + let gridStateUpdates: unknown[]; + + beforeEach(async () => { + ({ scene, grid } = buildScene({ variableQueryTime: 0 }, [ + { label: 'A', value: 'A1' }, + { label: 'B', value: 'B1' }, + { label: 'C', value: 'C1' }, + { label: 'D', value: 'D1' }, + { label: 'E', value: 'E1' }, + { label: 'F', value: 'F1' }, + { label: 'G', value: 'G1' }, + { label: 'H', value: 'H1' }, + { label: 'I', value: 'I1' }, + { label: 'J', value: 'J1' }, + { label: 'K', value: 'K1' }, + { label: 'L', value: 'L1' }, + { label: 'M', value: 'M1' }, + { label: 'N', value: 'N1' }, + { label: 'O', value: 'O1' }, + ])); + + gridStateUpdates = []; + grid.subscribeToState((state) => gridStateUpdates.push(state)); + + activateFullSceneTree(scene); + await new Promise((r) => setTimeout(r, 1)); + }); + + it('Should handle second repeat cycle and update remove old repeats', async () => { + // should have 15 repeated rows (and the panel above + the row at the bottom) + expect(grid.state.children.length).toBe(17); + + // trigger another repeat cycle by changing the variable + const variable = scene.state.$variables!.state.variables[0] as TestVariable; + variable.changeValueTo(['B1', 'C1']); + + await new Promise((r) => setTimeout(r, 1)); + + // should now only have 2 repeated rows (and the panel above + the row at the bottom) + expect(grid.state.children.length).toBe(4); + }); + }); + describe('Given scene empty row', () => { let scene: DashboardScene; let grid: SceneGridLayout; diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.test.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.test.tsx index d152fed3a64..ec5b4710089 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.test.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.test.tsx @@ -104,6 +104,51 @@ describe('RowItemRepeaterBehavior', () => { }); }); + describe('Given scene with variable with 15 values', () => { + let scene: DashboardScene, layout: RowsLayoutManager; + let layoutStateUpdates: unknown[]; + + beforeEach(async () => { + ({ scene, layout } = buildScene({ variableQueryTime: 0 }, [ + { label: 'A', value: 'A1' }, + { label: 'B', value: 'B1' }, + { label: 'C', value: 'C1' }, + { label: 'D', value: 'D1' }, + { label: 'E', value: 'E1' }, + { label: 'F', value: 'F1' }, + { label: 'G', value: 'G1' }, + { label: 'H', value: 'H1' }, + { label: 'I', value: 'I1' }, + { label: 'J', value: 'J1' }, + { label: 'K', value: 'K1' }, + { label: 'L', value: 'L1' }, + { label: 'M', value: 'M1' }, + { label: 'N', value: 'N1' }, + { label: 'O', value: 'O1' }, + ])); + + layoutStateUpdates = []; + layout.subscribeToState((state) => layoutStateUpdates.push(state)); + + activateFullSceneTree(scene); + await new Promise((r) => setTimeout(r, 1)); + }); + + it('Should handle second repeat cycle and update remove old repeats', async () => { + // should have 15 repeated rows (and the panel above) + expect(layout.state.rows.length).toBe(16); + + // trigger another repeat cycle by changing the variable + const variable = scene.state.$variables!.state.variables[0] as TestVariable; + variable.changeValueTo(['B1', 'C1']); + + await new Promise((r) => setTimeout(r, 1)); + + // should now only have 2 repeated rows (and the panel above) + expect(layout.state.rows.length).toBe(3); + }); + }); + describe('Given a scene with empty variable', () => { it('Should preserve repeat row', async () => { const { scene, layout } = buildScene({ variableQueryTime: 0 }, []); diff --git a/public/app/features/dashboard-scene/utils/clone.test.ts b/public/app/features/dashboard-scene/utils/clone.test.ts index 28441c4dbe1..58dcef6fb38 100644 --- a/public/app/features/dashboard-scene/utils/clone.test.ts +++ b/public/app/features/dashboard-scene/utils/clone.test.ts @@ -48,6 +48,20 @@ describe('clone', () => { expect(isClonedKey('tab-clone-1/row-clone-2/panel')).toBe(false); expect(isClonedKey('row-clone-1/panel')).toBe(false); }); + + it('should properly handle indexes containing 0', () => { + expect(isClonedKey('tab-clone-0/row-clone-1/panel-clone-0')).toBe(false); + expect(isClonedKey('row-clone-0/panel-clone-0')).toBe(false); + expect(isClonedKey('panel-clone-0')).toBe(false); + + expect(isClonedKey('tab-clone-0/row-clone-1/panel-clone-101')).toBe(true); + expect(isClonedKey('row-clone-0/panel-clone-101')).toBe(true); + expect(isClonedKey('panel-clone-1010')).toBe(true); + + expect(isClonedKey('tab-clone-0/row-clone-1/panel-clone-10')).toBe(true); + expect(isClonedKey('row-clone-0/panel-clone-100')).toBe(true); + expect(isClonedKey('panel-clone-1000')).toBe(true); + }); }); describe('isClonedKeyOf', () => { diff --git a/public/app/features/dashboard-scene/utils/clone.ts b/public/app/features/dashboard-scene/utils/clone.ts index 4e5d2b79fab..05d9459e0e2 100644 --- a/public/app/features/dashboard-scene/utils/clone.ts +++ b/public/app/features/dashboard-scene/utils/clone.ts @@ -1,7 +1,7 @@ const CLONE_KEY = '-clone-'; const CLONE_SEPARATOR = '/'; -const CLONED_KEY_REGEX = new RegExp(`${CLONE_KEY}[1-9]+$`); +const CLONED_KEY_REGEX = new RegExp(`${CLONE_KEY}[1-9][0-9]*$`); const ORIGINAL_REGEX = new RegExp(`${CLONE_KEY}\\d+$`); /** From 95ee93a0d8d3df333ffc64742c7b525a50b640a1 Mon Sep 17 00:00:00 2001 From: Hugo Kiyodi Oshiro Date: Thu, 13 Feb 2025 11:07:24 +0100 Subject: [PATCH 04/14] Plugins: Improve plugin details UX for core plugins (#99830) --- public/app/features/plugins/admin/api.ts | 1 + .../components/PluginDetailsPage.test.tsx | 25 +++++++++++++++++++ .../plugins/admin/components/VersionList.tsx | 2 +- .../admin/hooks/usePluginDetailsTabs.tsx | 6 +++-- .../plugins/admin/hooks/usePluginInfo.tsx | 5 +++- public/app/features/plugins/admin/types.ts | 1 + 6 files changed, 36 insertions(+), 4 deletions(-) diff --git a/public/app/features/plugins/admin/api.ts b/public/app/features/plugins/admin/api.ts index ef9411bea9e..36d49c2dbd7 100644 --- a/public/app/features/plugins/admin/api.ts +++ b/public/app/features/plugins/admin/api.ts @@ -94,6 +94,7 @@ async function getPluginVersions(id: string, isPublished: boolean): Promise ({ version: v.version, createdAt: v.createdAt, + updatedAt: v.updatedAt, isCompatible: v.isCompatible, grafanaDependency: v.grafanaDependency, angularDetected: v.angularDetected, diff --git a/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx b/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx index 4f1bde85393..ad97440a183 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx @@ -57,6 +57,7 @@ const plugin: CatalogPlugin = { ], grafanaDependency: '>=9.0.0', statusContext: 'stable', + changelog: 'Test changelog', }, angularDetected: false, isFullyInstalled: true, @@ -154,4 +155,28 @@ describe('PluginDetailsPage', () => { render(); expect(screen.getByRole('tab', { name: 'Data source connections' })).toBeVisible(); }); + + it('should not show version and changelog tabs when plugin is core', () => { + mockUseGetSingle.mockReturnValue({ ...plugin, isCore: true }); + render(); + expect(screen.queryByRole('tab', { name: 'Version history' })).not.toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: 'Changelog' })).not.toBeInTheDocument(); + }); + + it('should not show last version in plugin details panel when plugin is core', () => { + config.featureToggles.pluginsDetailsRightPanel = true; + window.matchMedia = jest.fn().mockImplementation((query) => ({ + matches: query !== '(max-width: 600px)', + media: query, + onchange: null, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); + + mockUseGetSingle.mockReturnValue({ ...plugin, isCore: true, latestVersion: '1.2.0' }); + + render(); + expect(screen.queryByText('Latest Version:')).not.toBeInTheDocument(); + }); }); diff --git a/public/app/features/plugins/admin/components/VersionList.tsx b/public/app/features/plugins/admin/components/VersionList.tsx index aaf98fd183b..2e79b0d43c1 100644 --- a/public/app/features/plugins/admin/components/VersionList.tsx +++ b/public/app/features/plugins/admin/components/VersionList.tsx @@ -96,7 +96,7 @@ export const VersionList = ({ pluginId, versions = [], installedVersion, disable {/* Last updated */} - {dateTimeFormatTimeAgo(version.createdAt)} + {dateTimeFormatTimeAgo(version.updatedAt || version.createdAt)} {/* Dependency */} {version.grafanaDependency || 'N/A'} diff --git a/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx b/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx index e9c50269d15..d9f7c2e1375 100644 --- a/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx +++ b/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx @@ -42,7 +42,8 @@ export const usePluginDetailsTabs = ( const navModelChildren = useMemo(() => { const canConfigurePlugins = plugin && contextSrv.hasPermissionInMetadata(AccessControlAction.PluginsWrite, plugin); const navModelChildren: NavModelItem[] = []; - if (isPublished) { + // currently the versions available of core plugins are not consistent + if (isPublished && !plugin?.isCore) { navModelChildren.push({ text: PluginTabLabels.VERSIONS, id: PluginTabIds.VERSIONS, @@ -51,7 +52,8 @@ export const usePluginDetailsTabs = ( active: PluginTabIds.VERSIONS === currentPageId, }); } - if (isPublished && plugin?.details?.changelog) { + // currently there is not changelog available for core plugins + if (isPublished && plugin?.details?.changelog && !plugin.isCore) { navModelChildren.push({ text: PluginTabLabels.CHANGELOG, id: PluginTabIds.CHANGELOG, diff --git a/public/app/features/plugins/admin/hooks/usePluginInfo.tsx b/public/app/features/plugins/admin/hooks/usePluginInfo.tsx index 1bb1334e1ef..2c9124f1860 100644 --- a/public/app/features/plugins/admin/hooks/usePluginInfo.tsx +++ b/public/app/features/plugins/admin/hooks/usePluginInfo.tsx @@ -53,7 +53,10 @@ export const usePluginInfo = (plugin?: CatalogPlugin): PageInfoItem[] => { latestVersionValue = latestVersion; } - addInfo('latestVersion', latestVersionValue); + // latest versions of core plugins are not consistent + if (!plugin.isCore) { + addInfo('latestVersion', latestVersionValue); + } } if (Boolean(plugin.orgName)) { diff --git a/public/app/features/plugins/admin/types.ts b/public/app/features/plugins/admin/types.ts index 10e2636b2ef..031f6c1543c 100644 --- a/public/app/features/plugins/admin/types.ts +++ b/public/app/features/plugins/admin/types.ts @@ -216,6 +216,7 @@ export interface Build { export interface Version { version: string; createdAt: string; + updatedAt?: string; isCompatible: boolean; grafanaDependency: string | null; angularDetected?: boolean; From 0b4c622df8e3796d02b714f7d261215e23178eb6 Mon Sep 17 00:00:00 2001 From: Karl Persson <23356117+kalleep@users.noreply.github.com> Date: Thu, 13 Feb 2025 11:27:57 +0100 Subject: [PATCH 05/14] AuthN: Refetch user on "ErrUserAlreadyExists" (#100346) * AuthN: Refetch user on "ErrUserAlreadyExists" --- .../authn/authnimpl/sync/user_sync.go | 41 +++++++++++-------- .../authn/authnimpl/sync/user_sync_test.go | 30 ++++++++++++++ 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/pkg/services/authn/authnimpl/sync/user_sync.go b/pkg/services/authn/authnimpl/sync/user_sync.go index 23e8d579b4d..22ec16fa51a 100644 --- a/pkg/services/authn/authnimpl/sync/user_sync.go +++ b/pkg/services/authn/authnimpl/sync/user_sync.go @@ -86,29 +86,38 @@ func (s *UserSync) SyncUserHook(ctx context.Context, id *authn.Identity, _ *auth } // Does user exist in the database? - usr, userAuth, errUserInDB := s.getUser(ctx, id) - if errUserInDB != nil && !errors.Is(errUserInDB, user.ErrUserNotFound) { - s.log.FromContext(ctx).Error("Failed to fetch user", "error", errUserInDB, "auth_module", id.AuthenticatedBy, "auth_id", id.AuthID) + usr, userAuth, err := s.getUser(ctx, id) + if err != nil && !errors.Is(err, user.ErrUserNotFound) { + s.log.FromContext(ctx).Error("Failed to fetch user", "error", err, "auth_module", id.AuthenticatedBy, "auth_id", id.AuthID) return errSyncUserInternal.Errorf("unable to retrieve user") } - if errors.Is(errUserInDB, user.ErrUserNotFound) { + if errors.Is(err, user.ErrUserNotFound) { if !id.ClientParams.AllowSignUp { s.log.FromContext(ctx).Warn("Failed to create user, signup is not allowed for module", "auth_module", id.AuthenticatedBy, "auth_id", id.AuthID) return errUserSignupDisabled.Errorf("%w", errSignupNotAllowed) } // create user - var errCreate error - usr, errCreate = s.createUser(ctx, id) - if errCreate != nil { - s.log.FromContext(ctx).Error("Failed to create user", "error", errCreate, "auth_module", id.AuthenticatedBy, "auth_id", id.AuthID) - return errSyncUserInternal.Errorf("unable to create user: %w", errCreate) + usr, err = s.createUser(ctx, id) + + // There is a possibility for a race condition when creating a user. Most clients will probably not hit this + // case but others will. The one we have seen this issue for is auth proxy. First time a new user loads grafana + // several requests can get "user.ErrUserNotFound" at the same time but only one of the request will be allowed + // to actually create the user, resulting in all other requests getting "user.ErrUserAlreadyExists". So we can + // just try to fetch the user one more to make the other request work. + if errors.Is(err, user.ErrUserAlreadyExists) { + usr, _, err = s.getUser(ctx, id) + } + + if err != nil { + s.log.FromContext(ctx).Error("Failed to create user", "error", err, "auth_module", id.AuthenticatedBy, "auth_id", id.AuthID) + return errSyncUserInternal.Errorf("unable to create user: %w", err) } } else { // update user - if errUpdate := s.updateUserAttributes(ctx, usr, id, userAuth); errUpdate != nil { - s.log.FromContext(ctx).Error("Failed to update user", "error", errUpdate, "auth_module", id.AuthenticatedBy, "auth_id", id.AuthID) + if err := s.updateUserAttributes(ctx, usr, id, userAuth); err != nil { + s.log.FromContext(ctx).Error("Failed to update user", "error", err, "auth_module", id.AuthenticatedBy, "auth_id", id.AuthID) return errSyncUserInternal.Errorf("unable to update user") } } @@ -311,6 +320,7 @@ func (s *UserSync) updateUserAttributes(ctx context.Context, usr *user.User, id func (s *UserSync) createUser(ctx context.Context, id *authn.Identity) (*user.User, error) { ctx, span := s.tracer.Start(ctx, "user.sync.createUser") defer span.End() + // FIXME(jguer): this should be done in the user service // quota check: we can have quotas on both global and org level // therefore we need to query check quota for both user and org services @@ -330,19 +340,18 @@ func (s *UserSync) createUser(ctx context.Context, id *authn.Identity) (*user.Us isAdmin = *id.IsGrafanaAdmin } - usr, errCreateUser := s.userService.Create(ctx, &user.CreateUserCommand{ + usr, err := s.userService.Create(ctx, &user.CreateUserCommand{ Login: id.Login, Email: id.Email, Name: id.Name, IsAdmin: isAdmin, SkipOrgSetup: len(id.OrgRoles) > 0, }) - if errCreateUser != nil { - return nil, errCreateUser + if err != nil { + return nil, err } - err := s.upsertAuthConnection(ctx, usr.ID, id, true) - if err != nil { + if err := s.upsertAuthConnection(ctx, usr.ID, id, true); err != nil { return nil, err } diff --git a/pkg/services/authn/authnimpl/sync/user_sync_test.go b/pkg/services/authn/authnimpl/sync/user_sync_test.go index dc6cab243b7..8999a4b6979 100644 --- a/pkg/services/authn/authnimpl/sync/user_sync_test.go +++ b/pkg/services/authn/authnimpl/sync/user_sync_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" claims "github.com/grafana/authlib/types" @@ -451,6 +452,35 @@ func TestUserSync_SyncUserHook(t *testing.T) { } } +func TestUserSync_SyncUserRetryFetch(t *testing.T) { + userSrv := usertest.NewMockService(t) + userSrv.On("GetByEmail", mock.Anything, mock.Anything).Return(nil, user.ErrUserNotFound).Once() + userSrv.On("Create", mock.Anything, mock.Anything).Return(nil, user.ErrUserAlreadyExists).Once() + userSrv.On("GetByEmail", mock.Anything, mock.Anything).Return(&user.User{ID: 1}, nil).Once() + + s := ProvideUserSync( + userSrv, + authinfoimpl.ProvideOSSUserProtectionService(), + &authinfotest.FakeService{}, + "atest.FakeQuotaService{}, + tracing.NewNoopTracerService(), + featuremgmt.WithFeatures(), + ) + + email := "test@test.com" + + err := s.SyncUserHook(context.Background(), &authn.Identity{ + ClientParams: authn.ClientParams{ + SyncUser: true, + AllowSignUp: true, + LookUpParams: login.UserLookupParams{ + Email: &email, + }, + }, + }, nil) + require.NoError(t, err) +} + func TestUserSync_FetchSyncedUserHook(t *testing.T) { type testCase struct { desc string From 6db155649c255fc673a9906476bebbf4813ab48f Mon Sep 17 00:00:00 2001 From: Yulia Shanyrova Date: Thu, 13 Feb 2025 11:31:57 +0100 Subject: [PATCH 06/14] Plugins: Custom links for plugin details page (#97186) * Custom links with repository link, licence link, docs link and raise an issue link * run translation command * delete console log * delete console log * fix frontend tests * change UI with a new design * remove license, documentation, repository url calculation logic from grafana * remove unsused function from helpers * change repo icons and raise an issue icon * fix the build * remove logic for raiseAnIssueUrl * fix the build * fix lint * Delete Links title in the box of links --------- Co-authored-by: Timur Olzhabayev --- .betterer.results | 3 +- public/app/features/plugins/admin/api.ts | 2 + .../components/PluginDetailsPanel.test.tsx | 3 +- .../admin/components/PluginDetailsPanel.tsx | 260 ++++++++++++++---- .../features/plugins/admin/helpers.test.ts | 2 + public/app/features/plugins/admin/helpers.ts | 8 + public/app/features/plugins/admin/types.ts | 8 + public/locales/en-US/grafana.json | 15 +- public/locales/pseudo-LOCALE/grafana.json | 15 +- 9 files changed, 253 insertions(+), 63 deletions(-) diff --git a/.betterer.results b/.betterer.results index 89b413d6023..8db179df663 100644 --- a/.betterer.results +++ b/.betterer.results @@ -5501,7 +5501,8 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "5"] ], "public/app/features/plugins/admin/components/PluginDetailsPanel.tsx:5381": [ - [0, 0, 0, "\'@grafana/runtime/src/components/PluginPage\' import is restricted from being used by a pattern. Import from the public export instead.", "0"] + [0, 0, 0, "\'@grafana/runtime/src/components/PluginPage\' import is restricted from being used by a pattern. Import from the public export instead.", "0"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] ], "public/app/features/plugins/admin/components/PluginDetailsSignature.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], diff --git a/public/app/features/plugins/admin/api.ts b/public/app/features/plugins/admin/api.ts index 36d49c2dbd7..f79968d47ae 100644 --- a/public/app/features/plugins/admin/api.ts +++ b/public/app/features/plugins/admin/api.ts @@ -37,6 +37,8 @@ export async function getPluginDetails(id: string): Promise { it('should render report abuse section for non-core plugins', () => { render(); expect(screen.getByText('Report a concern')).toBeInTheDocument(); - expect(screen.getByText('Contact Grafana Labs')).toBeInTheDocument(); }); it('should not render report abuse section for core plugins', () => { @@ -117,6 +116,6 @@ describe('PluginDetailsPanel', () => { it('should respect custom width prop', () => { render(); const panel = screen.getByTestId('plugin-details-panel'); - expect(panel).toHaveStyle({ maxWidth: '300px' }); + expect(panel).toHaveStyle({ width: '300px' }); }); }); diff --git a/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx b/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx index 95b963097b7..7d7612dc814 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsPanel.tsx @@ -1,8 +1,22 @@ import { css } from '@emotion/css'; +import { useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; import { PageInfoItem } from '@grafana/runtime/src/components/PluginPage'; -import { Stack, Text, LinkButton, Box, TextLink, useStyles2 } from '@grafana/ui'; +import { + Stack, + Text, + LinkButton, + Box, + TextLink, + CollapsableSection, + Tooltip, + Icon, + Modal, + Button, + useStyles2, +} from '@grafana/ui'; import { Trans } from 'app/core/internationalization'; import { formatDate } from 'app/core/internationalization/dates'; @@ -16,73 +30,203 @@ type Props = { export function PluginDetailsPanel(props: Props): React.ReactElement | null { const { pluginExtentionsInfo, plugin, width = '250px' } = props; + const [reportAbuseModalOpen, setReportAbuseModalOpen] = useState(false); + + const normalizeURL = (url: string | undefined) => url?.replace(/\/$/, ''); + + const customLinks = plugin.details?.links?.filter((link) => { + const customLinksFiltered = ![plugin.url, plugin.details?.licenseUrl, plugin.details?.documentationUrl] + .map(normalizeURL) + .includes(normalizeURL(link.url)); + return customLinksFiltered; + }); + const shouldRenderLinks = plugin.url || plugin.details?.licenseUrl || plugin.details?.documentationUrl; + const styles = useStyles2(getStyles); - return ( - - - - {pluginExtentionsInfo.map((infoItem, index) => { - return ( - - {infoItem.label + ':'} -
{infoItem.value}
-
- ); - })} - {plugin.updatedAt && ( - - - Last updated: - {' '} - {formatDate(new Date(plugin.updatedAt), { day: 'numeric', month: 'short', year: 'numeric' })} - - )} - {plugin?.details?.lastCommitDate && ( - - - Last commit date: - {' '} - - {formatDate(new Date(plugin.details.lastCommitDate), { - day: 'numeric', - month: 'short', - year: 'numeric', - })} - - - )} -
-
+ const onClickReportConcern = (pluginId: string) => { + setReportAbuseModalOpen(true); + reportInteraction('plugin_detail_report_concern', { + plugin_id: pluginId, + }); + }; - {plugin?.details?.links && plugin.details?.links?.length > 0 && ( + return ( + <> + - - Links - - {plugin.details.links.map((link, index) => ( - - {link.name} - - ))} + {pluginExtentionsInfo.map((infoItem, index) => { + return ( + + {infoItem.label + ':'} +
{infoItem.value}
+
+ ); + })} + {plugin.updatedAt && ( + + + Last updated: + {' '} + + {formatDate(new Date(plugin.updatedAt), { day: 'numeric', month: 'short', year: 'numeric' })} + + + )} + {plugin?.details?.lastCommitDate && ( + + + Last commit date: + {' '} + + {formatDate(new Date(plugin.details.lastCommitDate), { + day: 'numeric', + month: 'short', + year: 'numeric', + })} + + + )}
- )} - - {!plugin?.isCore && ( - - - - Report a concern + {shouldRenderLinks && ( + <> + + + {plugin.url && ( + + Repository + + )} + {plugin.raiseAnIssueUrl && ( + + Raise an issue + + )} + {plugin.details?.licenseUrl && ( + + License + + )} + {plugin.details?.documentationUrl && ( + + Documentation + + )} + + + + )} + {customLinks && customLinks?.length > 0 && ( + + + + Custom links + + + These links are provided by the plugin developer to offer additional, developer-specific + resources and information +
+ } + placement="right-end" + > + + + + } + > + + {customLinks.map((link, index) => ( + + {link.name} + + ))} + + + + )} + {!plugin?.isCore && ( + + + + Report a concern + + + Report issues related to malicious or harmful plugins directly to Grafana Labs. +
+ } + placement="right-end" + > + + + + } + > + + + + + + )} + + {reportAbuseModalOpen && ( + Report a plugin concern
} + isOpen + onDismiss={() => setReportAbuseModalOpen(false)} + > + + + + This feature is for reporting malicious or harmful behaviour within plugins. For plugin concerns, email + us at:{' '} + + integrations@grafana.com + + + + Note: For general plugin issues like bugs or feature requests, please contact the plugin author using + the provided links.{' '} + - - Contact Grafana Labs - - + + + + + )} - + ); } diff --git a/public/app/features/plugins/admin/helpers.test.ts b/public/app/features/plugins/admin/helpers.test.ts index b47c5bf774b..b1b109f2ca5 100644 --- a/public/app/features/plugins/admin/helpers.test.ts +++ b/public/app/features/plugins/admin/helpers.test.ts @@ -217,6 +217,7 @@ describe('Plugins/Helpers', () => { updatedAt: '2021-05-18T14:53:01.000Z', isFullyInstalled: false, angularDetected: false, + url: 'https://github.com/alexanderzobnin/grafana-zabbix', }); }); @@ -354,6 +355,7 @@ describe('Plugins/Helpers', () => { installedVersion: '4.2.2', isFullyInstalled: true, angularDetected: false, + url: 'https://github.com/alexanderzobnin/grafana-zabbix', }); }); diff --git a/public/app/features/plugins/admin/helpers.ts b/public/app/features/plugins/admin/helpers.ts index 172a32e088f..2f7814cc729 100644 --- a/public/app/features/plugins/admin/helpers.ts +++ b/public/app/features/plugins/admin/helpers.ts @@ -121,6 +121,8 @@ export function mapRemoteToCatalog(plugin: RemotePlugin, error?: PluginError): C signatureType, versionSignatureType, versionSignedByOrgName, + url, + raiseAnIssueUrl, } = plugin; const isDisabled = !!error || isDisabledSecretsPlugin(typeCode); @@ -158,6 +160,8 @@ export function mapRemoteToCatalog(plugin: RemotePlugin, error?: PluginError): C angularDetected, isFullyInstalled: isDisabled, latestVersion: plugin.version, + url, + raiseAnIssueUrl, }; } @@ -174,6 +178,7 @@ export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): Cat hasUpdate, accessControl, angularDetected, + raiseAnIssueUrl, } = plugin; const isDisabled = !!error || isDisabledSecretsPlugin(type); @@ -208,6 +213,7 @@ export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): Cat isFullyInstalled: true, iam: plugin.iam, latestVersion: plugin.latestVersion, + raiseAnIssueUrl, }; } @@ -271,6 +277,8 @@ export function mapToCatalogPlugin(local?: LocalPlugin, remote?: RemotePlugin, e isFullyInstalled: Boolean(local) || isDisabled, iam: local?.iam, latestVersion: local?.latestVersion || remote?.version || '', + url: remote?.url || '', + raiseAnIssueUrl: remote?.raiseAnIssueUrl || local?.raiseAnIssueUrl, }; } diff --git a/public/app/features/plugins/admin/types.ts b/public/app/features/plugins/admin/types.ts index 031f6c1543c..f3d5783ae3c 100644 --- a/public/app/features/plugins/admin/types.ts +++ b/public/app/features/plugins/admin/types.ts @@ -64,6 +64,8 @@ export interface CatalogPlugin extends WithAccessControlMetadata { isUpdatingFromInstance?: boolean; iam?: IdentityAccessManagement; isProvisioned?: boolean; + url?: string; + raiseAnIssueUrl?: string; } export interface CatalogPluginDetails { @@ -79,6 +81,8 @@ export interface CatalogPluginDetails { iam?: IdentityAccessManagement; changelog?: string; lastCommitDate?: string; + licenseUrl?: string; + documentationUrl?: string; signatureType?: PluginSignatureType; signature?: PluginSignatureStatus; } @@ -143,6 +147,9 @@ export type RemotePlugin = { versionStatus: string; angularDetected?: boolean; lastCommitDate?: string; + licenseUrl?: string; + documentationUrl?: string; + raiseAnIssueUrl?: string; }; // The available status codes on GCOM are available here: @@ -190,6 +197,7 @@ export type LocalPlugin = WithAccessControlMetadata & { dependencies: PluginDependencies; angularDetected: boolean; iam?: IdentityAccessManagement; + raiseAnIssueUrl?: string; }; interface IdentityAccessManagement { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 2c90bf29511..b7ea5026077 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -2781,17 +2781,30 @@ }, "labels": { "contactGrafanaLabs": "Contact Grafana Labs", + "customLinks": "Custom links ", + "customLinksTooltip": "These links are provided by the plugin developer to offer additional, developer-specific resources and information", "dependencies": "Dependencies", + "documentation": "Documentation", "downloads": "Downloads", "from": "From", "installedVersion": "Installed Version", "lastCommitDate": "Last commit date:", "latestVersion": "Latest Version", - "links": "Links ", + "license": "License", + "raiseAnIssue": "Raise an issue", "reportAbuse": "Report a concern ", + "reportAbuseTooltip": "Report issues related to malicious or harmful plugins directly to Grafana Labs.", + "repository": "Repository", "signature": "Signature", "status": "Status", "updatedAt": "Last updated:" + }, + "modal": { + "cancel": "Cancel", + "copyEmail": "Copy email address", + "description": "This feature is for reporting malicious or harmful behaviour within plugins. For plugin concerns, email us at: ", + "node": "Note: For general plugin issues like bugs or feature requests, please contact the plugin author using the provided links. ", + "title": "Report a plugin concern" } }, "empty-state": { diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 61b47f9f9a5..9543a62f07e 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -2781,17 +2781,30 @@ }, "labels": { "contactGrafanaLabs": "Cőʼnŧäčŧ Ğřäƒäʼnä Ŀäþş", + "customLinks": "Cūşŧőm ľįʼnĸş ", + "customLinksTooltip": "Ŧĥęşę ľįʼnĸş äřę přővįđęđ þy ŧĥę pľūģįʼn đęvęľőpęř ŧő őƒƒęř äđđįŧįőʼnäľ, đęvęľőpęř-şpęčįƒįč řęşőūřčęş äʼnđ įʼnƒőřmäŧįőʼn", "dependencies": "Đępęʼnđęʼnčįęş", + "documentation": "Đőčūmęʼnŧäŧįőʼn", "downloads": "Đőŵʼnľőäđş", "from": "Fřőm", "installedVersion": "Ĩʼnşŧäľľęđ Vęřşįőʼn", "lastCommitDate": "Ŀäşŧ čőmmįŧ đäŧę:", "latestVersion": "Ŀäŧęşŧ Vęřşįőʼn", - "links": "Ŀįʼnĸş ", + "license": "Ŀįčęʼnşę", + "raiseAnIssue": "Ŗäįşę äʼn įşşūę", "reportAbuse": "Ŗępőřŧ ä čőʼnčęřʼn ", + "reportAbuseTooltip": "Ŗępőřŧ įşşūęş řęľäŧęđ ŧő mäľįčįőūş őř ĥäřmƒūľ pľūģįʼnş đįřęčŧľy ŧő Ğřäƒäʼnä Ŀäþş.", + "repository": "Ŗępőşįŧőřy", "signature": "Ŝįģʼnäŧūřę", "status": "Ŝŧäŧūş", "updatedAt": "Ŀäşŧ ūpđäŧęđ:" + }, + "modal": { + "cancel": "Cäʼnčęľ", + "copyEmail": "Cőpy ęmäįľ äđđřęşş", + "description": "Ŧĥįş ƒęäŧūřę įş ƒőř řępőřŧįʼnģ mäľįčįőūş őř ĥäřmƒūľ þęĥävįőūř ŵįŧĥįʼn pľūģįʼnş. Főř pľūģįʼn čőʼnčęřʼnş, ęmäįľ ūş äŧ: ", + "node": "Ńőŧę: Főř ģęʼnęřäľ pľūģįʼn įşşūęş ľįĸę þūģş őř ƒęäŧūřę řęqūęşŧş, pľęäşę čőʼnŧäčŧ ŧĥę pľūģįʼn äūŧĥőř ūşįʼnģ ŧĥę přővįđęđ ľįʼnĸş. ", + "title": "Ŗępőřŧ ä pľūģįʼn čőʼnčęřʼn" } }, "empty-state": { From ae9837b793e97f683d1517d270b719c99656e556 Mon Sep 17 00:00:00 2001 From: Tito Lins Date: Thu, 13 Feb 2025 11:36:45 +0100 Subject: [PATCH 07/14] Alerting: Add alertmanager integration tests (#100106) --- .github/CODEOWNERS | 1 + Makefile | 8 + .../docker/blocks/stateful_webhook/Dockerfile | 12 + .../stateful_webhook/docker-compose.yaml | 5 + devenv/docker/blocks/stateful_webhook/main.go | 149 +++++++ go.mod | 2 + go.sum | 4 + go.work.sum | 1 + .../alertmanager/alertmanager_scenario.go | 386 ++++++++++++++++++ pkg/tests/alertmanager/alertmanager_test.go | 97 +++++ pkg/tests/alertmanager/grafana.go | 75 ++++ pkg/tests/alertmanager/loki.go | 152 +++++++ pkg/tests/alertmanager/postgres.go | 41 ++ pkg/tests/alertmanager/webhook.go | 85 ++++ ...na_alertmanager_integration_test_images.go | 41 ++ 15 files changed, 1059 insertions(+) create mode 100644 devenv/docker/blocks/stateful_webhook/Dockerfile create mode 100644 devenv/docker/blocks/stateful_webhook/docker-compose.yaml create mode 100644 devenv/docker/blocks/stateful_webhook/main.go create mode 100644 pkg/tests/alertmanager/alertmanager_scenario.go create mode 100644 pkg/tests/alertmanager/alertmanager_test.go create mode 100644 pkg/tests/alertmanager/grafana.go create mode 100644 pkg/tests/alertmanager/loki.go create mode 100644 pkg/tests/alertmanager/postgres.go create mode 100644 pkg/tests/alertmanager/webhook.go create mode 100644 tools/setup_grafana_alertmanager_integration_test_images.go diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cbb2fa1a54f..a349655800b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -241,6 +241,7 @@ /devenv/dev-dashboards/extensions/ @grafana/plugins-platform-frontend /devenv/docker/blocks/alert_webhook_listener/ @grafana/alerting-backend +/devenv/docker/blocks/stateful_webhook/ @grafana/alerting-backend /devenv/docker/blocks/caddy_tls/ @grafana/alerting-backend /devenv/docker/blocks/clickhouse/ @grafana/partner-datasources /devenv/docker/blocks/collectd/ @grafana/observability-metrics diff --git a/Makefile b/Makefile index 2721c2830b8..11cee0a4255 100644 --- a/Makefile +++ b/Makefile @@ -271,6 +271,14 @@ test-go-integration-alertmanager: ## Run integration tests for the remote alertm AM_URL=http://localhost:8080 AM_TENANT_ID=test \ $(GO) test $(GO_RACE_FLAG) -count=1 -run "^TestIntegrationRemoteAlertmanager" -covermode=atomic -timeout=5m ./pkg/services/ngalert/... +.PHONY: test-go-integration-grafana-alertmanager +test-go-integration-grafana-alertmanager: ## Run integration tests for the grafana alertmanager + @echo "test grafana alertmanager integration tests" + @export GRAFANA_VERSION=11.5.0-81938; \ + $(GO) run tools/setup_grafana_alertmanager_integration_test_images.go; \ + $(GO) clean -testcache; \ + $(GO) test $(GO_RACE_FLAG) -count=1 -run "^TestAlertmanagerIntegration" -covermode=atomic -timeout=10m ./pkg/tests/alertmanager/... + .PHONY: test-go-integration-postgres test-go-integration-postgres: devenv-postgres ## Run integration tests for postgres backend with flags. @echo "test backend integration postgres tests" diff --git a/devenv/docker/blocks/stateful_webhook/Dockerfile b/devenv/docker/blocks/stateful_webhook/Dockerfile new file mode 100644 index 00000000000..03b50db2135 --- /dev/null +++ b/devenv/docker/blocks/stateful_webhook/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.23.5 + +ADD main.go /go/src/webhook/main.go + +WORKDIR /go/src/webhook + +RUN mkdir /tmp/logs +RUN go build -o /bin main.go + +ENV PORT=8080 + +ENTRYPOINT [ "/bin/main" ] diff --git a/devenv/docker/blocks/stateful_webhook/docker-compose.yaml b/devenv/docker/blocks/stateful_webhook/docker-compose.yaml new file mode 100644 index 00000000000..7217516c4e9 --- /dev/null +++ b/devenv/docker/blocks/stateful_webhook/docker-compose.yaml @@ -0,0 +1,5 @@ + stateful_webhook: + build: + context: docker/blocks/stateful_webhook + ports: + - "8080:8080" diff --git a/devenv/docker/blocks/stateful_webhook/main.go b/devenv/docker/blocks/stateful_webhook/main.go new file mode 100644 index 00000000000..926cf4e593e --- /dev/null +++ b/devenv/docker/blocks/stateful_webhook/main.go @@ -0,0 +1,149 @@ +package main + +import ( + "encoding/json" + "io" + "log" + "net/http" + "strings" + "sync" + "time" +) + +type Event struct { + Status string `json:"status"` + TimeNow time.Time `json:"timeNow"` + StartsAt time.Time `json:"startsAt"` + Node string `json:"node"` + DeltaLastSeconds float64 `json:"deltaLastSeconds"` + DeltaStartSeconds float64 `json:"deltaStartSeconds"` +} + +type Notification struct { + Alerts []Alert `json:"alerts"` + CommonAnnotations map[string]string `json:"commonAnnotations"` + CommonLabels map[string]string `json:"commonLabels"` + ExternalURL string `json:"externalURL"` + GroupKey string `json:"groupKey"` + GroupLabels map[string]string `json:"groupLabels"` + Message string `json:"message"` + OrgID int `json:"orgId"` + Receiver string `json:"receiver"` + State string `json:"state"` + Status string `json:"status"` + Title string `json:"title"` + TruncatedAlerts int `json:"truncatedAlerts"` + Version string `json:"version"` +} + +type Alert struct { + Annotations map[string]string `json:"annotations"` + DashboardURL string `json:"dashboardURL"` + StartsAt time.Time `json:"startsAt"` + EndsAt time.Time `json:"endsAt"` + Fingerprint string `json:"fingerprint"` + GeneratorURL string `json:"generatorURL"` + Labels map[string]string `json:"labels"` + PanelURL string `json:"panelURL"` + SilenceURL string `json:"silenceURL"` + Status string `json:"status"` + ValueString string `json:"valueString"` + Values map[string]any `json:"values"` +} + +type NotificationHandler struct { + startedAt time.Time + stats map[string]int + hist []Event + m sync.Mutex +} + +func NewNotificationHandler() *NotificationHandler { + return &NotificationHandler{ + startedAt: time.Now(), + stats: make(map[string]int), + hist: make([]Event, 0), + } +} + +func (ah *NotificationHandler) Notify(w http.ResponseWriter, r *http.Request) { + b, err := io.ReadAll(r.Body) + if err != nil { + log.Println(err) + w.WriteHeader(http.StatusBadRequest) + return + } + n := Notification{} + if err := json.Unmarshal(b, &n); err != nil { + log.Println(err) + w.WriteHeader(http.StatusBadRequest) + return + } + log.Printf("got notification from: %s. a: %v", r.RemoteAddr, n) + + ah.m.Lock() + defer ah.m.Unlock() + + addr := r.RemoteAddr + if split := strings.Split(r.RemoteAddr, ":"); len(split) > 0 { + addr = split[0] + } + + a := n.Alerts[0] + + timeNow := time.Now() + + ah.stats[n.Status]++ + + var d time.Duration + if len(ah.hist) > 0 { + last := ah.hist[len(ah.hist)-1] + d = timeNow.Sub(last.TimeNow) + } + + ah.hist = append(ah.hist, Event{ + Status: n.Status, + StartsAt: a.StartsAt, + TimeNow: timeNow, + Node: addr, + DeltaLastSeconds: d.Seconds(), + DeltaStartSeconds: timeNow.Sub(ah.startedAt).Seconds(), + }) +} + +func (ah *NotificationHandler) GetNotifications(w http.ResponseWriter, _ *http.Request) { + ah.m.Lock() + defer ah.m.Unlock() + w.Header().Set("Content-Type", "application/json") + + res, err := json.MarshalIndent(map[string]any{"stats": ah.stats, "history": ah.hist}, "", "\t") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + //nolint:errcheck + w.Write([]byte(`{"error":"failed to marshal alerts"}`)) + log.Printf("failed to marshal alerts: %v\n", err) + return + } + + log.Printf("requested current state\n%v\n", string(res)) + + _, err = w.Write(res) + if err != nil { + log.Printf("failed to write response: %v\n", err) + } +} + +func main() { + ah := NewNotificationHandler() + + http.HandleFunc("/ready", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + http.HandleFunc("/notify", ah.Notify) + http.HandleFunc("/notifications", ah.GetNotifications) + + log.Println("Listening") + //nolint:errcheck + http.ListenAndServe("0.0.0.0:8080", nil) +} diff --git a/go.mod b/go.mod index 3ded590b054..9b05a5c2234 100644 --- a/go.mod +++ b/go.mod @@ -217,6 +217,8 @@ require ( github.com/grafana/grafana/pkg/storage/unified/resource v0.0.0-20250121113133-e747350fee2d // @grafana/grafana-search-and-storage ) +require github.com/grafana/grafana-api-golang-client v0.27.0 // @grafana/alerting-backend + require ( cel.dev/expr v0.19.0 // indirect cloud.google.com/go v0.116.0 // indirect diff --git a/go.sum b/go.sum index f73dcbe3a18..8f98d32599f 100644 --- a/go.sum +++ b/go.sum @@ -1297,6 +1297,8 @@ github.com/go-xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:9wScpmSP5A3Bk github.com/go-xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:56xuuqnHyryaerycW3BfssRdxQstACi0Epw/yC5E2xM= github.com/go-zookeeper/zk v1.0.4 h1:DPzxraQx7OrPyXq2phlGlNSIyWEsAox0RJmjTseMV6I= github.com/go-zookeeper/zk v1.0.4/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw= +github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b h1:/vQ+oYKu+JoyaMPDsv5FzwuL2wwWBgBbtj/YLCi4LuA= +github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b/go.mod h1:Xo4aNUOrJnVruqWQJBtW6+bTBDTniY8yZum5rF3b5jw= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= @@ -1527,6 +1529,8 @@ github.com/grafana/gofpdf v0.0.0-20231002120153-857cc45be447 h1:jxJJ5z0GxqhWFbQU github.com/grafana/gofpdf v0.0.0-20231002120153-857cc45be447/go.mod h1:IxsY6mns6Q5sAnWcrptrgUrSglTZJXH/kXr9nbpb/9I= github.com/grafana/gomemcache v0.0.0-20240805133030-fdaf6a95408e h1:UlEET0InuoFautfaFp8lDrNF7rPHYXuBMrzwWx9XqFY= github.com/grafana/gomemcache v0.0.0-20240805133030-fdaf6a95408e/go.mod h1:IGRj8oOoxwJbHBYl1+OhS9UjQR0dv6SQOep7HqmtyFU= +github.com/grafana/grafana-api-golang-client v0.27.0 h1:zIwMXcbCB4n588i3O2N6HfNcQogCNTd/vPkEXTr7zX8= +github.com/grafana/grafana-api-golang-client v0.27.0/go.mod h1:uNLZEmgKtTjHBtCQMwNn3qsx2mpMb8zU+7T4Xv3NR9Y= github.com/grafana/grafana-app-sdk v0.31.0 h1:/mFCcx+YqG8cWAi9hePDJQxIdtXDClDIDRgZwHkksFk= github.com/grafana/grafana-app-sdk v0.31.0/go.mod h1:Xw00NL7qpRLo5r3Gn48Bl1Xn2n4eUDI5pYf/wMufKWs= github.com/grafana/grafana-app-sdk/logging v0.30.0 h1:K/P/bm7Cp7Di4tqIJ3EQz2+842JozQGRaz62r95ApME= diff --git a/go.work.sum b/go.work.sum index 1be6908c9d8..832ae031f57 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1455,6 +1455,7 @@ github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1/go.mod h1:oJDH3BJKyqBA2TXFhDs github.com/go-zookeeper/zk v1.0.2/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw= github.com/go-zookeeper/zk v1.0.3 h1:7M2kwOsc//9VeeFiPtf+uSJlVpU66x9Ba5+8XK7/TDg= github.com/go-zookeeper/zk v1.0.3/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw= +github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b/go.mod h1:Xo4aNUOrJnVruqWQJBtW6+bTBDTniY8yZum5rF3b5jw= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= diff --git a/pkg/tests/alertmanager/alertmanager_scenario.go b/pkg/tests/alertmanager/alertmanager_scenario.go new file mode 100644 index 00000000000..719cccef4ab --- /dev/null +++ b/pkg/tests/alertmanager/alertmanager_scenario.go @@ -0,0 +1,386 @@ +package alertmanager + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/grafana/e2e" + gapi "github.com/grafana/grafana-api-golang-client" + "github.com/stretchr/testify/require" +) + +const ( + defaultNetworkName = "e2e-grafana-am" +) + +type AlertRuleConfig struct { + PendingPeriod string + GroupEvaluationIntervalSeconds int64 +} + +type NotificationPolicyCfg struct { + GroupWait string + GroupInterval string + RepeatInterval string +} + +type ProvisionCfg struct { + AlertRuleConfig + NotificationPolicyCfg +} + +// AlertmanagerScenario is a helper for writing tests which require some number of AM +// configured to communicate with some number of Grafana instances. +type AlertmanagerScenario struct { + *e2e.Scenario + + Grafanas map[string]*GrafanaService + Webhook *WebhookService + Postgres *PostgresService + Loki *LokiService +} + +func NewAlertmanagerScenario() (*AlertmanagerScenario, error) { + s, err := e2e.NewScenario(getNetworkName()) + if err != nil { + return nil, err + } + + return &AlertmanagerScenario{ + Scenario: s, + Grafanas: make(map[string]*GrafanaService), + }, nil +} + +// Setup starts a Grafana AM cluster of size n and all required dependencies +func (s *AlertmanagerScenario) Start(t *testing.T, n int, peerTimeout string, stopOnExtraDedup bool) { + is := getInstances(n) + ips := mapInstancePeers(is) + + // start dependencies in one go + require.NoError( + t, + s.StartAndWaitReady([]e2e.Service{ + s.NewWebhookService("webhook"), + s.NewLokiService("loki"), + s.NewPostgresService("postgres"), + }...), + ) + + for i, ps := range ips { + require.NoError(t, s.StartAndWaitReady(s.NewGrafanaService(i, ps, peerTimeout, stopOnExtraDedup))) + } + + // wait for instances to come online and cluster to be properly configured + time.Sleep(30 * time.Second) +} + +// Provision provisions all required resources for the test +func (s *AlertmanagerScenario) Provision(t *testing.T, cfg ProvisionCfg) { //}*GrafanaClient { + c, err := s.NewGrafanaClient("grafana-1", 1) + require.NoError(t, err) + + dsUID := "integration-testdata" + + // setup resources + _, err = c.NewDataSource(&gapi.DataSource{ + Name: "grafana-testdata-datasource", + Type: "grafana-testdata-datasource", + Access: "proxy", + UID: dsUID, + }) + require.NoError(t, err) + + // setup loki for state history + _, err = c.NewDataSource(&gapi.DataSource{ + Name: "loki", + Type: "loki", + URL: "http://loki:3100", + Access: "proxy", + }) + require.NoError(t, err) + + _, err = c.NewContactPoint(&gapi.ContactPoint{ + Name: "webhook", + Type: "webhook", + Settings: map[string]any{ + "url": "http://webhook:8080/notify", + }, + }) + require.NoError(t, err) + + require.NoError(t, c.SetNotificationPolicyTree(&gapi.NotificationPolicyTree{ + Receiver: "webhook", + GroupWait: cfg.GroupWait, + GroupInterval: cfg.GroupInterval, + RepeatInterval: cfg.RepeatInterval, + })) + + f, err := c.NewFolder("integration_test") + require.NoError(t, err) + + r := &gapi.AlertRule{ + Title: "integration rule", + Condition: "C", + FolderUID: f.UID, + ExecErrState: gapi.ErrError, + NoDataState: gapi.NoData, + For: cfg.PendingPeriod, + RuleGroup: "test", + Data: []*gapi.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: gapi.RelativeTimeRange{ + From: 600, + To: 0, + }, + DatasourceUID: dsUID, + Model: json.RawMessage(fmt.Sprintf(`{ + "refId":"A", + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "%s" + }, + "hide":false, + "range":false, + "instant":true, + "intervalMs":1000, + "maxDataPoints":43200, + "pulseWave": { + "offCount": 6, + "offValue": 0, + "onCount": 10, + "onValue": 10, + "timeStep": 10 + }, + "refId": "A", + "scenarioId": "predictable_pulse", + "seriesCount": 1 + }`, dsUID)), + }, + { + RefID: "B", + RelativeTimeRange: gapi.RelativeTimeRange{ + From: 0, + To: 0, + }, + DatasourceUID: "__expr__", + Model: json.RawMessage(`{ + "conditions": [ + { + "evaluator": { + "params": [ + 0, + 0 + ], + "type": "gt" + }, + "operator": { + "type": "and" + }, + "query": { + "params": [] + }, + "reducer": { + "params": [], + "type": "avg" + }, + "type": "query" + } + ], + "datasource": { + "name": "Expression", + "type": "__expr__", + "uid": "__expr__" + }, + "expression": "A", + "intervalMs": 1000, + "maxDataPoints": 43200, + "reducer": "last", + "refId": "B", + "type": "reduce" + }`), + }, + { + RefID: "C", + RelativeTimeRange: gapi.RelativeTimeRange{ + From: 0, + To: 0, + }, + DatasourceUID: "__expr__", + Model: json.RawMessage(`{ + "conditions": [ + { + "evaluator": { + "params": [ + 0, + 0 + ], + "type": "gt" + }, + "operator": { + "type": "and" + }, + "query": { + "params": [ + "B" + ] + }, + "reducer": { + "params": [], + "type": "last" + }, + "type": "query" + } + ], + "datasource": { + "type": "__expr__", + "uid": "__expr__" + }, + "hide": false, + "isPaused": false, + "intervalMs": 1000, + "maxDataPoints": 43200, + "refId": "C", + "expression": "B", + "type": "threshold" + }`), + }, + }, + } + _, err = c.NewAlertRule(r) + require.NoError(t, err) + + require.NoError(t, c.SetAlertRuleGroup(gapi.RuleGroup{ + Title: "test", + FolderUID: f.UID, + Interval: cfg.GroupEvaluationIntervalSeconds, + Rules: []gapi.AlertRule{*r}, + })) +} + +// NewGrafanaService creates a new Grafana instance. +func (s *AlertmanagerScenario) NewGrafanaService(name string, peers []string, peerTimeout string, stopOnExtraDedup bool) *GrafanaService { + flags := map[string]string{} + + ft := []string{ + "alertStateHistoryLokiSecondary", + "alertStateHistoryLokiPrimary", + "alertStateHistoryLokiOnly", + "alertingAlertmanagerExtraDedupStage", + } + if stopOnExtraDedup { + ft = append(ft, "alertingAlertmanagerExtraDedupStageStopPipeline") + } + envVars := map[string]string{ + //"GF_LOG_MODE": "file", // disable console logging + "GF_LOG_LEVEL": "warn", + "GF_FEATURE_TOGGLES_ENABLE": strings.Join(ft, ","), + "GF_UNIFIED_ALERTING_ENABLED": "true", + "GF_UNIFIED_ALERTING_EXECUTE_ALERTS": "true", + "GF_UNIFIED_ALERTING_HA_PEER_TIMEOUT": peerTimeout, + "GF_UNIFIED_ALERTING_HA_RECONNECT_TIMEOUT": "2m", + "GF_UNIFIED_ALERTING_HA_LISTEN_ADDRESS": ":9094", + "GF_UNIFIED_ALERTING_HA_PEERS": strings.Join(peers, ","), + "GF_UNIFIED_ALERTING_STATE_HISTORY_ENABLED": "true", + "GF_UNIFIED_ALERTING_STATE_HISTORY_BACKEND": "loki", + "GF_UNIFIED_ALERTING_STATE_HISTORY_LOKI_REMOTE_URL": "http://loki:3100", + "GF_DATABASE_TYPE": "postgres", + "GF_DATABASE_HOST": "postgres:5432", + "GF_DATABASE_NAME": "grafana", + "GF_DATABASE_USER": "postgres", + "GF_DATABASE_PASSWORD": "password", + "GF_DATABASE_SSL_MODE": "disable", + } + + g := NewGrafanaService(name, flags, envVars) + + s.Grafanas[name] = g + return g +} + +// NewGrafanaService creates a new Grafana API client for the requested instance. +func (s *AlertmanagerScenario) NewGrafanaClient(grafanaName string, orgID int64) (*GrafanaClient, error) { + g, ok := s.Grafanas[grafanaName] + if !ok { + return nil, fmt.Errorf("unknown grafana instance: %s", grafanaName) + } + + return NewGrafanaClient(g.HTTPEndpoint(), orgID) +} + +func (s *AlertmanagerScenario) NewWebhookClient() (*WebhookClient, error) { + return NewWebhookClient("http://" + s.Webhook.HTTPEndpoint()) +} + +func (s *AlertmanagerScenario) NewWebhookService(name string) *WebhookService { + ws := NewWebhookService(name, nil, nil) + s.Webhook = ws + + return ws +} + +func (s *AlertmanagerScenario) NewLokiService(name string) *LokiService { + ls := NewLokiService(name, map[string]string{"--config.file": "/etc/loki/local-config.yaml"}, nil) + s.Loki = ls + + return ls +} + +func (s *AlertmanagerScenario) NewPostgresService(name string) *PostgresService { + ps := NewPostgresService(name, map[string]string{"POSTGRES_PASSWORD": "password", "POSTGRES_DB": "grafana"}) + s.Postgres = ps + + return ps +} + +func (s *AlertmanagerScenario) NewLokiClient() (*LokiClient, error) { + return NewLokiClient("http://" + s.Loki.HTTPEndpoint()) +} + +func getNetworkName() string { + // If the E2E_NETWORK_NAME is set, use that for the network name. + // Otherwise, return the default network name. + if os.Getenv("E2E_NETWORK_NAME") != "" { + return os.Getenv("E2E_NETWORK_NAME") + } + + return defaultNetworkName +} + +func getInstances(n int) []string { + is := make([]string, n) + + for i := 0; i < n; i++ { + is[i] = "grafana-" + strconv.Itoa(i+1) + } + + return is +} + +func getPeers(i string, is []string) []string { + peers := make([]string, 0, len(is)-1) + + for _, p := range is { + if p != i { + peers = append(peers, p+":9094") + } + } + + return peers +} + +func mapInstancePeers(is []string) map[string][]string { + mIs := make(map[string][]string, len(is)) + + for _, i := range is { + mIs[i] = getPeers(i, is) + } + + return mIs +} diff --git a/pkg/tests/alertmanager/alertmanager_test.go b/pkg/tests/alertmanager/alertmanager_test.go new file mode 100644 index 00000000000..0f127ea2ab6 --- /dev/null +++ b/pkg/tests/alertmanager/alertmanager_test.go @@ -0,0 +1,97 @@ +package alertmanager + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestAlertmanagerIntegration_ExtraDedupStage(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + t.Run("assert no flapping alerts when stopOnExtraDedup is enabled", func(t *testing.T) { + s, err := NewAlertmanagerScenario() + require.NoError(t, err) + defer s.Close() + + s.Start(t, 20, "15s", true) + s.Provision(t, ProvisionCfg{ + AlertRuleConfig: AlertRuleConfig{ + PendingPeriod: "30s", + GroupEvaluationIntervalSeconds: 10, + }, + NotificationPolicyCfg: NotificationPolicyCfg{ + GroupWait: "30s", + GroupInterval: "1m", + RepeatInterval: "30m", + }, + }) + + wc, err := s.NewWebhookClient() + require.NoError(t, err) + + lc, err := s.NewLokiClient() + require.NoError(t, err) + + // notifications only start arriving after 2 to 3 minutes so we wait for that + time.Sleep(time.Minute * 2) + + timeout := time.After(5 * time.Minute) + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + nr, err := wc.GetNotifications() + if err != nil { + t.Logf("failed to get alert notifications: %v\n", err) + continue + } + + // get the latest state for the alert from loki + st, err := lc.GetCurrentAlertState() + if err != nil { + t.Logf("failed to get alert state: %v\n", err) + continue + } + + // if the last state is not normal, ignore + // we might be missing other cases of flapping notifications but for now we are only interested in this one + // (alerting notification when state is already normal) + if st.State != AlertStateNormal { + continue + } + + // history is ordered - fetch the first notification that is after the last state change + var i int + for i = range nr.History { + if nr.History[i].TimeNow.After(st.Timestamp) { + break + } + } + + // if all notifications are from before the last state change, we can wait a bit more + if nr.History[i].TimeNow.Before(st.Timestamp) { + continue + } + + // for all notifications after the last state change, check if there is a firing one + for ; i < len(nr.History); i++ { + notification := nr.History[i] + if notification.Status == "firing" { + t.Errorf("flapping notifications - got firing notification when alert was resolved, state = %#v, notification = %#v", st, notification) + t.FailNow() + } + } + + case <-timeout: + // if after the timeout there are no such cases, we assume there are no flapping notifications + return + } + } + }) +} diff --git a/pkg/tests/alertmanager/grafana.go b/pkg/tests/alertmanager/grafana.go new file mode 100644 index 00000000000..7d67ffdf53c --- /dev/null +++ b/pkg/tests/alertmanager/grafana.go @@ -0,0 +1,75 @@ +package alertmanager + +import ( + _ "embed" + "fmt" + "net/url" + "os" + + "github.com/grafana/e2e" + gapi "github.com/grafana/grafana-api-golang-client" +) + +const ( + grafanaBinary = "/run.sh" + grafanaHTTPPort = 3000 +) + +// GetDefaultImage returns the Docker image to use to run the Grafana.. +func GetGrafanaImage() string { + if img := os.Getenv("GRAFANA_IMAGE"); img != "" { + return img + } + + if version := os.Getenv("GRAFANA_VERSION"); version != "" { + return "grafana/grafana-enterprise-dev:" + version + } + + panic("Provide GRAFANA_VERSION or GRAFANA_IMAGE") +} + +type GrafanaService struct { + *e2e.HTTPService +} + +func NewGrafanaService(name string, flags, envVars map[string]string) *GrafanaService { + svc := &GrafanaService{ + HTTPService: e2e.NewHTTPService( + name, + GetGrafanaImage(), + e2e.NewCommandWithoutEntrypoint(grafanaBinary, e2e.BuildArgs(flags)...), + e2e.NewHTTPReadinessProbe(grafanaHTTPPort, "/ready", 200, 299), + grafanaHTTPPort, + 9094, + ), + } + + svc.SetEnvVars(envVars) + + return svc +} + +type GrafanaClient struct { + *gapi.Client +} + +// NewGrafanaClient creates a client for using the Grafana API. Note we don't bother +// wrapping the client library, and just use it as-is, until we find a reason not to. +func NewGrafanaClient(host string, orgID int64) (*GrafanaClient, error) { + cfg := gapi.Config{ + BasicAuth: url.UserPassword("admin", "admin"), + OrgID: orgID, + HTTPHeaders: map[string]string{ + "X-Disable-Provenance": "true", + }, + } + + client, err := gapi.New(fmt.Sprintf("http://%s/", host), cfg) + if err != nil { + return nil, err + } + + return &GrafanaClient{ + Client: client, + }, nil +} diff --git a/pkg/tests/alertmanager/loki.go b/pkg/tests/alertmanager/loki.go new file mode 100644 index 00000000000..16c6fda0b90 --- /dev/null +++ b/pkg/tests/alertmanager/loki.go @@ -0,0 +1,152 @@ +package alertmanager + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "strconv" + "time" + + "github.com/grafana/e2e" +) + +const ( + defaultLokiImage = "grafana/loki:latest" + lokiBinary = "/usr/bin/loki" + lokiHTTPPort = 3100 +) + +// GetDefaultImage returns the Docker image to use to run the Loki.. +func GetLokiImage() string { + if img := os.Getenv("LOKI_IMAGE"); img != "" { + return img + } + + return defaultLokiImage +} + +type LokiService struct { + *e2e.HTTPService +} + +func NewLokiService(name string, flags, envVars map[string]string) *LokiService { + svc := &LokiService{ + HTTPService: e2e.NewHTTPService( + name, + GetLokiImage(), + e2e.NewCommandWithoutEntrypoint(lokiBinary, e2e.BuildArgs(flags)...), + e2e.NewHTTPReadinessProbe(lokiHTTPPort, "/ready", 200, 299), + lokiHTTPPort, + ), + } + + svc.SetEnvVars(envVars) + + return svc +} + +type LokiClient struct { + c http.Client + u *url.URL +} + +func NewLokiClient(u string) (*LokiClient, error) { + pu, err := url.Parse(u) + if err != nil { + return nil, err + } + + return &LokiClient{ + c: http.Client{}, + u: pu, + }, nil +} + +type LokiQueryResponse struct { + Status string `json:"status"` + Data struct { + ResultType string `json:"resultType"` + Result []struct { + Stream struct { + Condition string `json:"condition"` + Current string `json:"current"` + DashboardUID string `json:"dashboardUID"` + Fingerprint string `json:"fingerprint"` + FolderUID string `json:"folderUID"` + From string `json:"from"` + Group string `json:"group"` + LabelsAlertname string `json:"labels_alertname"` + LabelsGrafanaFolder string `json:"labels_grafana_folder"` + OrgID string `json:"orgID"` + PanelID string `json:"panelID"` + Previous string `json:"previous"` + RuleID string `json:"ruleID"` + RuleTitle string `json:"ruleTitle"` + RuleUID string `json:"ruleUID"` + SchemaVersion string `json:"schemaVersion"` + ServiceName string `json:"service_name"` + ValuesB string `json:"values_B"` + ValuesC string `json:"values_C"` + } `json:"stream"` + Values [][]string `json:"values"` + } `json:"result"` + } +} + +type AlertState string + +const ( + AlertStateNormal AlertState = "Normal" + AlertStatePending AlertState = "Pending" + AlertStateAlerting AlertState = "Alerting" +) + +type AlertStateResponse struct { + State AlertState + Timestamp time.Time +} + +// GetCurrentAlertState fetches the current alert state from loki +func (c *LokiClient) GetCurrentAlertState() (*AlertStateResponse, error) { + u := c.u.ResolveReference(&url.URL{Path: "/loki/api/v1/query_range"}) + + vs := url.Values{} + vs.Add("query", `{from="state-history"} | json`) + vs.Add("since", "60s") + + u.RawQuery = vs.Encode() + + resp, err := c.c.Get(u.String()) + if err != nil { + return nil, err + } + //nolint:errcheck + defer resp.Body.Close() + + res := LokiQueryResponse{} + + if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { + return nil, err + } + + if res.Status != "success" { + return nil, fmt.Errorf("failed to query state from loki") + } + + if len(res.Data.Result) == 0 { + return nil, fmt.Errorf("empty result from loki") + } + + r := res.Data.Result[0] + it, err := strconv.ParseInt(r.Values[0][0], 10, 0) + if err != nil { + return nil, fmt.Errorf("failed to parse timestamp: %v", err) + } + + return &AlertStateResponse{ + State: AlertState(r.Stream.Current), + Timestamp: time.Unix(0, it), + }, nil +} diff --git a/pkg/tests/alertmanager/postgres.go b/pkg/tests/alertmanager/postgres.go new file mode 100644 index 00000000000..3d3fe4577d9 --- /dev/null +++ b/pkg/tests/alertmanager/postgres.go @@ -0,0 +1,41 @@ +package alertmanager + +import ( + "os" + + "github.com/grafana/e2e" +) + +const ( + defaultPostgresImage = "postgres:16.4" + postgresHTTPPort = 5432 +) + +// GetDefaultImage returns the Docker image to use to run the Postgres.. +func GetPostgresImage() string { + if img := os.Getenv("POSTGRES_IMAGE"); img != "" { + return img + } + + return defaultPostgresImage +} + +type PostgresService struct { + *e2e.HTTPService +} + +func NewPostgresService(name string, envVars map[string]string) *PostgresService { + svc := &PostgresService{ + HTTPService: e2e.NewHTTPService( + name, + GetPostgresImage(), + nil, + nil, + postgresHTTPPort, + ), + } + + svc.SetEnvVars(envVars) + + return svc +} diff --git a/pkg/tests/alertmanager/webhook.go b/pkg/tests/alertmanager/webhook.go new file mode 100644 index 00000000000..c90c3f229eb --- /dev/null +++ b/pkg/tests/alertmanager/webhook.go @@ -0,0 +1,85 @@ +package alertmanager + +import ( + "encoding/json" + "net/http" + "net/url" + "time" + + "github.com/grafana/e2e" +) + +const ( + defaultWebhookImage = "webhook-receiver" + webhookBinary = "/bin/main" + webhookHTTPPort = 8080 +) + +type WebhookService struct { + *e2e.HTTPService +} + +func NewWebhookService(name string, flags, envVars map[string]string) *WebhookService { + svc := &WebhookService{ + HTTPService: e2e.NewHTTPService( + name, + "webhook-receiver", + e2e.NewCommandWithoutEntrypoint(webhookBinary, e2e.BuildArgs(flags)...), + e2e.NewHTTPReadinessProbe(webhookHTTPPort, "/ready", 200, 299), + webhookHTTPPort), + } + + svc.SetEnvVars(envVars) + + return svc +} + +type WebhookClient struct { + c http.Client + u *url.URL +} + +func NewWebhookClient(u string) (*WebhookClient, error) { + pu, err := url.Parse(u) + if err != nil { + return nil, err + } + + return &WebhookClient{ + c: http.Client{}, + u: pu, + }, nil +} + +type GetNotificationsResponse struct { + Stats map[string]int `json:"stats"` + History []struct { + Status string `json:"status"` + TimeNow time.Time `json:"timeNow"` + StartsAt time.Time `json:"startsAt"` + Node string `json:"node"` + DeltaLastSeconds float64 `json:"deltaLastSeconds"` + DeltaStartSeconds float64 `json:"deltaStartSeconds"` + } `json:"history"` +} + +// GetNotifications fetches notifications from the webhook server +func (c *WebhookClient) GetNotifications() (*GetNotificationsResponse, error) { + u := c.u.ResolveReference(&url.URL{Path: "/notifications"}) + + resp, err := c.c.Get(u.String()) + if err != nil { + return nil, err + } + //nolint:errcheck + defer resp.Body.Close() + + res := GetNotificationsResponse{} + + err = json.NewDecoder(resp.Body).Decode(&res) + if err != nil { + return nil, err + } + + return &res, nil +} diff --git a/tools/setup_grafana_alertmanager_integration_test_images.go b/tools/setup_grafana_alertmanager_integration_test_images.go new file mode 100644 index 00000000000..3b6e1436446 --- /dev/null +++ b/tools/setup_grafana_alertmanager_integration_test_images.go @@ -0,0 +1,41 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "sync" + + amtests "github.com/grafana/grafana/pkg/tests/alertmanager" +) + +func docker(args []string) { + cmd := exec.Command("docker", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Printf("docker pull failed: %v\n", err) + os.Exit(1) + } +} + +func main() { + var wg sync.WaitGroup + + for _, cmd := range [][]string{ + {"pull", amtests.GetGrafanaImage()}, + {"pull", amtests.GetLokiImage()}, + {"pull", amtests.GetPostgresImage()}, + {"build", "-t", "webhook-receiver", "devenv/docker/blocks/stateful_webhook"}, + } { + wg.Add(1) + + go func(cmd []string) { + defer wg.Done() + + docker(cmd) + }(cmd) + } + + wg.Wait() +} From 1b1954de2887b1b447968e6df2e1d23ca04a06ce Mon Sep 17 00:00:00 2001 From: Karl Persson <23356117+kalleep@users.noreply.github.com> Date: Thu, 13 Feb 2025 11:59:59 +0100 Subject: [PATCH 08/14] Authz: add support to use folder api to fetch folder tree (#100038) * Add FolderStore interface * Authz: add implementation to use folders api and use it inproc with loopback config * Add tracing and add rest.Config for talking with folder api using access tokens * Restructure test to get rid of circular dependencies in tests * use correct group version kind --------- Co-authored-by: gamab --- pkg/services/authz/{client.go => rbac.go} | 73 ++++++++ pkg/services/authz/rbac/service.go | 56 ++++--- pkg/services/authz/rbac/service_test.go | 3 +- pkg/services/authz/rbac/store/folder_store.go | 158 ++++++++++++++++++ pkg/services/authz/rbac/store/models.go | 18 -- pkg/services/authz/rbac/store/queries.go | 1 - pkg/services/authz/rbac/store/store.go | 39 ----- pkg/services/authz/server.go | 37 ---- pkg/storage/unified/apistore/go.mod | 28 +++- pkg/storage/unified/apistore/go.sum | 30 +++- pkg/storage/unified/apistore/prepare_test.go | 5 + pkg/storage/unified/apistore/store_test.go | 2 +- pkg/storage/unified/apistore/util.go | 40 ----- pkg/storage/unified/apistore/watcher_test.go | 49 +++++- 14 files changed, 367 insertions(+), 172 deletions(-) rename pkg/services/authz/{client.go => rbac.go} (75%) create mode 100644 pkg/services/authz/rbac/store/folder_store.go delete mode 100644 pkg/services/authz/server.go diff --git a/pkg/services/authz/client.go b/pkg/services/authz/rbac.go similarity index 75% rename from pkg/services/authz/client.go rename to pkg/services/authz/rbac.go index 506155df380..cd0465886d3 100644 --- a/pkg/services/authz/client.go +++ b/pkg/services/authz/rbac.go @@ -3,6 +3,8 @@ package authz import ( "context" "errors" + "fmt" + "net/http" "time" "github.com/fullstorydev/grpchan" @@ -11,6 +13,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" + "k8s.io/client-go/rest" authnlib "github.com/grafana/authlib/authn" authzlib "github.com/grafana/authlib/authz" @@ -22,6 +25,8 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/registry/apis/iam/legacy" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/apiserver" + authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" "github.com/grafana/grafana/pkg/services/authz/rbac" "github.com/grafana/grafana/pkg/services/authz/rbac/store" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -64,6 +69,10 @@ func ProvideAuthZClient( // Register the server server := rbac.NewService( sql, + // When running in-proc we get a injection cycle between + // authz client, resource client and apiserver so we need to use + // package level function to get rest config + store.NewAPIFolderStore(tracer, apiserver.GetRestConfig), legacy.NewLegacySQLStores(sql), store.NewUnionPermissionStore( store.NewStaticPermissionStore(acService), @@ -201,3 +210,67 @@ func newCloudLegacyClient(authCfg *Cfg, tracer tracing.Tracer) (authlib.AccessCl return client, nil } + +func RegisterRBACAuthZService( + handler grpcserver.Provider, + db legacysql.LegacyDatabaseProvider, + tracer tracing.Tracer, + reg prometheus.Registerer, + cache cache.Cache, + exchangeClient authnlib.TokenExchanger, + folderAPIURL string, +) { + var folderStore store.FolderStore + // FIXME: for now we default to using database read proxy for folders if the api url is not configured. + // we should remove this and the sql implementation once we have verified that is works correctly + if folderAPIURL == "" { + folderStore = store.NewSQLFolderStore(db, tracer) + } else { + folderStore = store.NewAPIFolderStore(tracer, func(ctx context.Context) *rest.Config { + return &rest.Config{ + Host: folderAPIURL, + WrapTransport: func(rt http.RoundTripper) http.RoundTripper { + return &tokenExhangeRoundTripper{te: exchangeClient, rt: rt} + }, + QPS: 50, + Burst: 100, + } + }) + } + + server := rbac.NewService( + db, + folderStore, + legacy.NewLegacySQLStores(db), + store.NewSQLPermissionStore(db, tracer), + log.New("authz-grpc-server"), + tracer, + reg, + cache, + ) + + srv := handler.GetServer() + authzv1.RegisterAuthzServiceServer(srv, server) + authzextv1.RegisterAuthzExtentionServiceServer(srv, server) +} + +var _ http.RoundTripper = tokenExhangeRoundTripper{} + +type tokenExhangeRoundTripper struct { + te authnlib.TokenExchanger + rt http.RoundTripper +} + +func (t tokenExhangeRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { + res, err := t.te.Exchange(r.Context(), authnlib.TokenExchangeRequest{ + Namespace: "*", + Audiences: []string{"folder.grafana.app"}, + }) + + if err != nil { + return nil, fmt.Errorf("create access token: %w", err) + } + + r.Header.Set("X-Access-Token", "Bearer "+res.Token) + return t.rt.RoundTrip(r) +} diff --git a/pkg/services/authz/rbac/service.go b/pkg/services/authz/rbac/service.go index 429b568a091..7a365c0d6f5 100644 --- a/pkg/services/authz/rbac/service.go +++ b/pkg/services/authz/rbac/service.go @@ -17,7 +17,7 @@ import ( authzv1 "github.com/grafana/authlib/authz/proto/v1" "github.com/grafana/authlib/cache" - claims "github.com/grafana/authlib/types" + "github.com/grafana/authlib/types" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" @@ -41,6 +41,7 @@ type Service struct { authzextv1.UnimplementedAuthzExtentionServiceServer store store.Store + folderStore store.FolderStore permissionStore store.PermissionStore identityStore legacy.LegacyIdentityStore @@ -63,6 +64,7 @@ type Service struct { func NewService( sql legacysql.LegacyDatabaseProvider, + folderStore store.FolderStore, identityStore legacy.LegacyIdentityStore, permissionStore store.PermissionStore, logger log.Logger, @@ -72,6 +74,7 @@ func NewService( ) *Service { return &Service{ store: store.NewStore(sql, tracer), + folderStore: folderStore, permissionStore: permissionStore, identityStore: identityStore, logger: logger, @@ -209,40 +212,42 @@ func (s *Service) validateListRequest(ctx context.Context, req *authzv1.ListRequ return listReq, nil } -func validateNamespace(ctx context.Context, nameSpace string) (claims.NamespaceInfo, error) { +func validateNamespace(ctx context.Context, nameSpace string) (types.NamespaceInfo, error) { if nameSpace == "" { - return claims.NamespaceInfo{}, status.Error(codes.InvalidArgument, "namespace is required") + return types.NamespaceInfo{}, status.Error(codes.InvalidArgument, "namespace is required") } - authInfo, has := claims.AuthInfoFrom(ctx) + authInfo, has := types.AuthInfoFrom(ctx) if !has { - return claims.NamespaceInfo{}, status.Error(codes.Internal, "could not get auth info from context") + return types.NamespaceInfo{}, status.Error(codes.Internal, "could not get auth info from context") } - if !claims.NamespaceMatches(authInfo.GetNamespace(), nameSpace) { - return claims.NamespaceInfo{}, status.Error(codes.PermissionDenied, "namespace does not match") + if !types.NamespaceMatches(authInfo.GetNamespace(), nameSpace) { + return types.NamespaceInfo{}, status.Error(codes.PermissionDenied, "namespace does not match") } - ns, err := claims.ParseNamespace(nameSpace) + ns, err := types.ParseNamespace(nameSpace) if err != nil { - return claims.NamespaceInfo{}, err + return types.NamespaceInfo{}, err } return ns, nil } -func (s *Service) validateSubject(ctx context.Context, subject string) (string, claims.IdentityType, error) { +func (s *Service) validateSubject(ctx context.Context, subject string) (string, types.IdentityType, error) { if subject == "" { return "", "", status.Error(codes.InvalidArgument, "subject is required") } ctxLogger := s.logger.FromContext(ctx) - identityType, userUID, err := claims.ParseTypeID(subject) + identityType, userUID, err := types.ParseTypeID(subject) if err != nil { return "", "", err } + // Permission check currently only checks user, anonymous user, service account and renderer permissions - if !(identityType == claims.TypeUser || identityType == claims.TypeServiceAccount || identityType == claims.TypeAnonymous || identityType == claims.TypeRenderService) { + if !types.IsIdentityType(identityType, types.TypeUser, types.TypeServiceAccount, types.TypeAnonymous, types.TypeRenderService) { ctxLogger.Error("unsupported identity type", "type", identityType) return "", "", status.Error(codes.PermissionDenied, "unsupported identity type") } + return userUID, identityType, nil } @@ -264,30 +269,29 @@ func (s *Service) validateAction(ctx context.Context, group, resource, verb stri return action, nil } -func (s *Service) getIdentityPermissions(ctx context.Context, ns claims.NamespaceInfo, idType claims.IdentityType, userID, action string) (map[string]bool, error) { +func (s *Service) getIdentityPermissions(ctx context.Context, ns types.NamespaceInfo, idType types.IdentityType, userID, action string) (map[string]bool, error) { ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.getIdentityPermissions") defer span.End() // When checking folder creation permissions, also check edit and admin action sets for folder, as the scoped folder create actions aren't stored in the DB separately var actionSets []string if action == "folders:create" { - actionSets = append(actionSets, "folders:edit") - actionSets = append(actionSets, "folders:admin") + actionSets = append(actionSets, "folders:edit", "folders:admin") } switch idType { - case claims.TypeAnonymous: + case types.TypeAnonymous: return s.getAnonymousPermissions(ctx, ns, action, actionSets) - case claims.TypeRenderService: + case types.TypeRenderService: return s.getRendererPermissions(ctx, action) - case claims.TypeUser, claims.TypeServiceAccount: + case types.TypeUser, types.TypeServiceAccount: return s.getUserPermissions(ctx, ns, userID, action, actionSets) default: return nil, fmt.Errorf("unsupported identity type: %s", idType) } } -func (s *Service) getUserPermissions(ctx context.Context, ns claims.NamespaceInfo, userID, action string, actionSets []string) (map[string]bool, error) { +func (s *Service) getUserPermissions(ctx context.Context, ns types.NamespaceInfo, userID, action string, actionSets []string) (map[string]bool, error) { ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.getUserPermissions") defer span.End() @@ -342,7 +346,7 @@ func (s *Service) getUserPermissions(ctx context.Context, ns claims.NamespaceInf return res.(map[string]bool), nil } -func (s *Service) getAnonymousPermissions(ctx context.Context, ns claims.NamespaceInfo, action string, actionSets []string) (map[string]bool, error) { +func (s *Service) getAnonymousPermissions(ctx context.Context, ns types.NamespaceInfo, action string, actionSets []string) (map[string]bool, error) { ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.getAnonymousPermissions") defer span.End() @@ -378,7 +382,7 @@ func (s *Service) getRendererPermissions(ctx context.Context, action string) (ma return map[string]bool{}, nil } -func (s *Service) GetUserIdentifiers(ctx context.Context, ns claims.NamespaceInfo, userUID string) (*store.UserIdentifiers, error) { +func (s *Service) GetUserIdentifiers(ctx context.Context, ns types.NamespaceInfo, userUID string) (*store.UserIdentifiers, error) { uidCacheKey := userIdentifierCacheKey(ns.Value, userUID) if cached, ok := s.idCache.Get(ctx, uidCacheKey); ok { return &cached, nil @@ -397,7 +401,7 @@ func (s *Service) GetUserIdentifiers(ctx context.Context, ns claims.NamespaceInf userIDQuery = store.UserIdentifierQuery{UserUID: userUID} } userIdentifiers, err := s.store.GetUserIdentifiers(ctx, userIDQuery) - if err != nil || userIdentifiers == nil { + if err != nil { return nil, fmt.Errorf("could not get user internal id: %w", err) } @@ -407,7 +411,7 @@ func (s *Service) GetUserIdentifiers(ctx context.Context, ns claims.NamespaceInf return userIdentifiers, nil } -func (s *Service) getUserTeams(ctx context.Context, ns claims.NamespaceInfo, userIdentifiers *store.UserIdentifiers) ([]int64, error) { +func (s *Service) getUserTeams(ctx context.Context, ns types.NamespaceInfo, userIdentifiers *store.UserIdentifiers) ([]int64, error) { ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.getUserTeams") defer span.End() @@ -441,7 +445,7 @@ func (s *Service) getUserTeams(ctx context.Context, ns claims.NamespaceInfo, use return teamIDs, nil } -func (s *Service) getUserBasicRole(ctx context.Context, ns claims.NamespaceInfo, userIdentifiers *store.UserIdentifiers) (store.BasicRole, error) { +func (s *Service) getUserBasicRole(ctx context.Context, ns types.NamespaceInfo, userIdentifiers *store.UserIdentifiers) (store.BasicRole, error) { ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.getUserBasicRole") defer span.End() @@ -535,7 +539,7 @@ func (s *Service) checkInheritedPermissions(ctx context.Context, scopeMap map[st return false, nil } -func (s *Service) buildFolderTree(ctx context.Context, ns claims.NamespaceInfo) (folderTree, error) { +func (s *Service) buildFolderTree(ctx context.Context, ns types.NamespaceInfo) (folderTree, error) { ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.buildFolderTree") defer span.End() @@ -545,7 +549,7 @@ func (s *Service) buildFolderTree(ctx context.Context, ns claims.NamespaceInfo) } res, err, _ := s.sf.Do(ns.Value+"_buildFolderTree", func() (interface{}, error) { - folders, err := s.store.GetFolders(ctx, ns) + folders, err := s.folderStore.ListFolders(ctx, ns) if err != nil { return nil, fmt.Errorf("could not get folders: %w", err) } diff --git a/pkg/services/authz/rbac/service_test.go b/pkg/services/authz/rbac/service_test.go index 310e7cc8cfc..930f5920e36 100644 --- a/pkg/services/authz/rbac/service_test.go +++ b/pkg/services/authz/rbac/service_test.go @@ -620,6 +620,7 @@ func setupService() *Service { folderCache: newCacheWrap[folderTree](cache, logger, shortCacheTTL), store: fStore, permissionStore: fStore, + folderStore: fStore, identityStore: &fakeIdentityStore{}, sf: new(singleflight.Group), } @@ -663,7 +664,7 @@ func (f *fakeStore) GetUserPermissions(ctx context.Context, namespace claims.Nam return f.userPermissions, nil } -func (f *fakeStore) GetFolders(ctx context.Context, namespace claims.NamespaceInfo) ([]store.Folder, error) { +func (f *fakeStore) ListFolders(ctx context.Context, namespace claims.NamespaceInfo) ([]store.Folder, error) { f.calls++ if f.err { return nil, fmt.Errorf("store error") diff --git a/pkg/services/authz/rbac/store/folder_store.go b/pkg/services/authz/rbac/store/folder_store.go new file mode 100644 index 00000000000..078cb2f4c61 --- /dev/null +++ b/pkg/services/authz/rbac/store/folder_store.go @@ -0,0 +1,158 @@ +package store + +import ( + "context" + "fmt" + + "github.com/grafana/authlib/types" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/pager" + + "github.com/grafana/grafana/pkg/apimachinery/utils" + folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/storage/legacysql" + "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" +) + +type FolderStore interface { + ListFolders(ctx context.Context, ns types.NamespaceInfo) ([]Folder, error) +} + +type Folder struct { + UID string + ParentUID *string +} + +var _ FolderStore = (*SQLFolderStore)(nil) + +func NewSQLFolderStore(sql legacysql.LegacyDatabaseProvider, tracer tracing.Tracer) *SQLFolderStore { + return &SQLFolderStore{sql, tracer} +} + +type SQLFolderStore struct { + sql legacysql.LegacyDatabaseProvider + tracer tracing.Tracer +} + +var sqlFolders = mustTemplate("folder_query.sql") + +type listFoldersQuery struct { + sqltemplate.SQLTemplate + + Query *FolderQuery + FolderTable string +} + +type FolderQuery struct { + OrgID int64 +} + +func (r listFoldersQuery) Validate() error { + return nil +} + +func newListFolders(sql *legacysql.LegacyDatabaseHelper, query *FolderQuery) listFoldersQuery { + return listFoldersQuery{ + SQLTemplate: sqltemplate.New(sql.DialectForDriver()), + Query: query, + FolderTable: sql.Table("folder"), + } +} + +func (s *SQLFolderStore) ListFolders(ctx context.Context, ns types.NamespaceInfo) ([]Folder, error) { + ctx, span := s.tracer.Start(ctx, "authz_direct_db.database.ListFolders") + defer span.End() + + sql, err := s.sql(ctx) + if err != nil { + return nil, err + } + + query := newListFolders(sql, &FolderQuery{OrgID: ns.OrgID}) + q, err := sqltemplate.Execute(sqlFolders, query) + if err != nil { + return nil, err + } + + rows, err := sql.DB.GetSqlxSession().Query(ctx, q, query.GetArgs()...) + defer func() { + if rows != nil { + _ = rows.Close() + } + }() + if err != nil { + return nil, err + } + + var folders []Folder + for rows.Next() { + var folder Folder + if err := rows.Scan(&folder.UID, &folder.ParentUID); err != nil { + return nil, err + } + folders = append(folders, folder) + } + + return folders, nil +} + +var _ FolderStore = (*APIFolderStore)(nil) + +func NewAPIFolderStore(tracer tracing.Tracer, configProvider func(ctx context.Context) *rest.Config) *APIFolderStore { + return &APIFolderStore{tracer, configProvider} +} + +type APIFolderStore struct { + tracer tracing.Tracer + configProvider func(ctx context.Context) *rest.Config +} + +func (s *APIFolderStore) ListFolders(ctx context.Context, ns types.NamespaceInfo) ([]Folder, error) { + ctx, span := s.tracer.Start(ctx, "authz.apistore.ListFolders") + defer span.End() + + client, err := s.client(ctx, ns.Value) + if err != nil { + return nil, fmt.Errorf("create resource client: %w", err) + } + + p := pager.New(func(ctx context.Context, opts metav1.ListOptions) (runtime.Object, error) { + return client.List(ctx, opts) + }) + + const defaultPageSize = 500 + folders := make([]Folder, 0, defaultPageSize) + err = p.EachListItem(ctx, metav1.ListOptions{Limit: defaultPageSize}, func(obj runtime.Object) error { + object, err := utils.MetaAccessor(obj) + if err != nil { + return err + } + + folder := Folder{UID: object.GetName()} + parent := object.GetFolder() + if parent != "" { + folder.ParentUID = &parent + } + + folders = append(folders, folder) + return nil + }) + + if err != nil { + return nil, fmt.Errorf("fetching folders: %w", err) + } + + return folders, nil +} + +func (s *APIFolderStore) client(ctx context.Context, namespace string) (dynamic.ResourceInterface, error) { + client, err := dynamic.NewForConfig(s.configProvider(ctx)) + if err != nil { + return nil, err + } + return client.Resource(folderv0alpha1.FolderResourceInfo.GroupVersionResource()).Namespace(namespace), nil +} diff --git a/pkg/services/authz/rbac/store/models.go b/pkg/services/authz/rbac/store/models.go index 9abcbb8a257..bc3e6593245 100644 --- a/pkg/services/authz/rbac/store/models.go +++ b/pkg/services/authz/rbac/store/models.go @@ -19,21 +19,3 @@ type UserIdentifierQuery struct { UserID int64 UserUID string } - -type FolderQuery struct { - OrgID int64 -} - -type DashboardQuery struct { - OrgID int64 -} - -type Folder struct { - UID string - ParentUID *string -} - -type Dashboard struct { - UID string - ParentUID *string -} diff --git a/pkg/services/authz/rbac/store/queries.go b/pkg/services/authz/rbac/store/queries.go index 4ad0a3d5e11..10c9a17ea8a 100644 --- a/pkg/services/authz/rbac/store/queries.go +++ b/pkg/services/authz/rbac/store/queries.go @@ -16,7 +16,6 @@ var ( sqlQueryBasicRoles = mustTemplate("basic_role_query.sql") sqlUserIdentifiers = mustTemplate("user_identifier_query.sql") - sqlFolders = mustTemplate("folder_query.sql") ) func mustTemplate(filename string) *template.Template { diff --git a/pkg/services/authz/rbac/store/store.go b/pkg/services/authz/rbac/store/store.go index 9b7c2832b00..124ba38331b 100644 --- a/pkg/services/authz/rbac/store/store.go +++ b/pkg/services/authz/rbac/store/store.go @@ -15,7 +15,6 @@ import ( type Store interface { GetUserIdentifiers(ctx context.Context, query UserIdentifierQuery) (*UserIdentifiers, error) GetBasicRoles(ctx context.Context, ns claims.NamespaceInfo, query BasicRoleQuery) (*BasicRole, error) - GetFolders(ctx context.Context, ns claims.NamespaceInfo) ([]Folder, error) } type StoreImpl struct { @@ -104,41 +103,3 @@ func (s *StoreImpl) GetBasicRoles(ctx context.Context, ns claims.NamespaceInfo, return &role, nil } - -func (s *StoreImpl) GetFolders(ctx context.Context, ns claims.NamespaceInfo) ([]Folder, error) { - ctx, span := s.tracer.Start(ctx, "authz_direct_db.database.GetFolders") - defer span.End() - - sql, err := s.sql(ctx) - if err != nil { - return nil, err - } - - query := FolderQuery{OrgID: ns.OrgID} - req := newGetFolders(sql, &query) - q, err := sqltemplate.Execute(sqlFolders, req) - if err != nil { - return nil, err - } - - rows, err := sql.DB.GetSqlxSession().Query(ctx, q, req.GetArgs()...) - defer func() { - if rows != nil { - _ = rows.Close() - } - }() - if err != nil { - return nil, err - } - - var folders []Folder - for rows.Next() { - var folder Folder - if err := rows.Scan(&folder.UID, &folder.ParentUID); err != nil { - return nil, err - } - folders = append(folders, folder) - } - - return folders, nil -} diff --git a/pkg/services/authz/server.go b/pkg/services/authz/server.go deleted file mode 100644 index 868e7c2816b..00000000000 --- a/pkg/services/authz/server.go +++ /dev/null @@ -1,37 +0,0 @@ -package authz - -import ( - authzv1 "github.com/grafana/authlib/authz/proto/v1" - cache "github.com/grafana/authlib/cache" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/registry/apis/iam/legacy" - authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" - "github.com/grafana/grafana/pkg/services/authz/rbac" - "github.com/grafana/grafana/pkg/services/authz/rbac/store" - "github.com/grafana/grafana/pkg/services/grpcserver" - "github.com/grafana/grafana/pkg/storage/legacysql" - "github.com/prometheus/client_golang/prometheus" -) - -func RegisterRBACAuthZService( - handler grpcserver.Provider, - db legacysql.LegacyDatabaseProvider, - tracer tracing.Tracer, - reg prometheus.Registerer, - cache cache.Cache) { - server := rbac.NewService( - db, - legacy.NewLegacySQLStores(db), - store.NewSQLPermissionStore(db, tracer), - log.New("authz-grpc-server"), - tracer, - reg, - cache, - ) - - srv := handler.GetServer() - authzv1.RegisterAuthzServiceServer(srv, server) - authzextv1.RegisterAuthzExtentionServiceServer(srv, server) -} diff --git a/pkg/storage/unified/apistore/go.mod b/pkg/storage/unified/apistore/go.mod index a409151a0d4..b18163780f8 100644 --- a/pkg/storage/unified/apistore/go.mod +++ b/pkg/storage/unified/apistore/go.mod @@ -45,14 +45,17 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.3.2 // indirect github.com/Azure/go-autorest v14.2.0+incompatible // indirect github.com/Azure/go-autorest/autorest/to v0.4.0 // indirect + github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.3.2 // indirect github.com/BurntSushi/toml v1.4.0 // indirect github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c // indirect github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.3.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/Masterminds/squirrel v1.5.4 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/NYTimes/gziphandler v1.1.1 // indirect github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect github.com/RoaringBitmap/roaring v1.9.3 // indirect github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f // indirect @@ -85,6 +88,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect github.com/aws/smithy-go v1.20.3 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/benbjohnson/clock v1.3.5 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.12.0 // indirect @@ -107,7 +111,9 @@ require ( github.com/blevesearch/zapx/v14 v14.3.10 // indirect github.com/blevesearch/zapx/v15 v15.3.16 // indirect github.com/blevesearch/zapx/v16 v16.1.8 // indirect + github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect github.com/bufbuild/protocompile v0.4.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cheekybits/genny v1.0.0 // indirect @@ -117,6 +123,8 @@ require ( github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dennwc/varint v1.0.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect github.com/dlmiddlecote/sqlstats v1.0.2 // indirect github.com/docker/go-units v0.5.0 // indirect @@ -137,8 +145,10 @@ require ( github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/gchaincl/sqlhooks v1.3.0 // indirect github.com/getkin/kin-openapi v0.129.0 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect github.com/go-jose/go-jose/v3 v3.0.3 // indirect github.com/go-kit/log v0.2.1 // indirect + github.com/go-ldap/ldap/v3 v3.4.4 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -152,6 +162,7 @@ require ( github.com/go-openapi/strfmt v0.23.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-openapi/validate v0.24.0 // indirect + github.com/go-redis/redis/v8 v8.11.5 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/go-stack/stack v1.8.1 // indirect github.com/gobwas/glob v0.2.3 // indirect @@ -160,10 +171,12 @@ require ( github.com/gogo/googleapis v1.4.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/status v1.1.1 // indirect + github.com/golang-jwt/jwt/v4 v4.5.1 // indirect github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/golang-migrate/migrate/v4 v4.7.0 // indirect github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/mock v1.7.0-rc.1 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/btree v1.1.3 // indirect @@ -178,6 +191,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/gorilla/websocket v1.5.3 // indirect github.com/grafana/alerting v0.0.0-20250207161551-04c87cf39038 // indirect github.com/grafana/authlib v0.0.0-20250206063954-bf4600a17569 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect @@ -186,8 +200,12 @@ require ( github.com/grafana/grafana-aws-sdk v0.31.5 // indirect github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6 // indirect github.com/grafana/grafana-plugin-sdk-go v0.265.0 // indirect + github.com/grafana/grafana/pkg/aggregator v0.0.0-20250121113133-e747350fee2d // indirect + github.com/grafana/grafana/pkg/promlib v0.0.8 // indirect + github.com/grafana/grafana/pkg/semconv v0.0.0-20250121113133-e747350fee2d // indirect github.com/grafana/otel-profiling-go v0.5.1 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.8 // indirect + github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect github.com/grafana/sqlds/v4 v4.1.3 // indirect github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 // indirect @@ -208,6 +226,7 @@ require ( github.com/hashicorp/yamux v0.1.1 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.2 // indirect @@ -232,6 +251,7 @@ require ( github.com/magefile/mage v1.15.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattbaird/jsonpatch v0.0.0-20240118010651-0ba75a80ca38 // indirect github.com/mattetti/filebuffer v1.0.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -253,12 +273,14 @@ require ( github.com/mithrandie/go-file/v2 v2.1.0 // indirect github.com/mithrandie/go-text v1.6.0 // indirect github.com/mithrandie/ternary v1.1.1 // indirect + github.com/moby/spdystream v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/mschoch/smat v0.2.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/natefinch/wrap v0.2.0 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/oasdiff/yaml v0.0.0-20241210131133-6b86fb107d80 // indirect @@ -287,6 +309,7 @@ require ( github.com/prometheus/common/sigv4 v0.1.0 // indirect github.com/prometheus/exporter-toolkit v0.13.2 // indirect github.com/prometheus/procfs v0.15.1 // indirect + github.com/prometheus/prometheus v0.301.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/cors v1.11.1 // indirect @@ -299,7 +322,6 @@ require ( github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c // indirect github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 // indirect github.com/sirupsen/logrus v1.9.3 // indirect - github.com/smartystreets/goconvey v1.6.4 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.7.0 // indirect @@ -317,6 +339,7 @@ require ( github.com/unknwon/com v1.0.1 // indirect github.com/unknwon/log v0.0.0-20200308114134-929b1006e34a // indirect github.com/urfave/cli v1.22.16 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect go.etcd.io/bbolt v1.3.11 // indirect @@ -366,11 +389,14 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/mail.v2 v2.3.1 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/src-d/go-errors.v1 v1.0.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.32.1 // indirect k8s.io/component-base v0.32.1 // indirect + k8s.io/kms v0.32.1 // indirect + k8s.io/kube-aggregator v0.32.0 // indirect k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect diff --git a/pkg/storage/unified/apistore/go.sum b/pkg/storage/unified/apistore/go.sum index 73e0a03e693..6d345c4151e 100644 --- a/pkg/storage/unified/apistore/go.sum +++ b/pkg/storage/unified/apistore/go.sum @@ -136,6 +136,8 @@ github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJ github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/at-wat/mqtt-go v0.19.4 h1:R2cbCU7O5PHQ38unbe1Y51ncG3KsFEJV6QeipDoqdLQ= @@ -184,6 +186,8 @@ github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE= github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3 h1:6df1vn4bBlDDo4tARvBm7l6KA9iVMnE3NWizDeWSrps= +github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3/go.mod h1:CIWtjkly68+yqLPbvwwR/fjNJA/idrtULjZWh2v1ys0= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= @@ -343,6 +347,8 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.m github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.1.0 h1:tntQDh69XqOCOZsDz0lVJQez/2L6Uu2PdjCQwWCJ3bM= github.com/envoyproxy/protoc-gen-validate v1.1.0/go.mod h1:sXRDRVmzEbkM7CVcM06s9shE/m23dg3wzjl0UWqJ2q4= +github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= +github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= @@ -387,6 +393,8 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU= github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w= @@ -549,7 +557,6 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+ github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= @@ -805,6 +812,8 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/mocktools/go-smtp-mock/v2 v2.3.1 h1:wq75NDSsOy5oHo/gEQQT0fRRaYKRqr1IdkjhIPXxagM= +github.com/mocktools/go-smtp-mock/v2 v2.3.1/go.mod h1:h9AOf/IXLSU2m/1u4zsjtOM/WddPwdOUBz56dV9f81M= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -829,6 +838,8 @@ github.com/natefinch/wrap v0.2.0 h1:IXzc/pw5KqxJv55gV0lSOcKHYuEZPGbQrOOXr/bamRk= github.com/natefinch/wrap v0.2.0/go.mod h1:6gMHlAl12DwYEfKP3TkuykYUfLSEAvHw67itm4/KAS8= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/oasdiff/yaml v0.0.0-20241210131133-6b86fb107d80 h1:nZspmSkneBbtxU9TopEAE0CY+SBJLxO8LPUlw2vG4pU= github.com/oasdiff/yaml v0.0.0-20241210131133-6b86fb107d80/go.mod h1:7tFDb+Y51LcDpn26GccuUgQXUk6t0CXZsivKjyimYX8= github.com/oasdiff/yaml3 v0.0.0-20241210130736-a94c01f36349 h1:t05Ww3DxZutOqbMN+7OIuqDwXbhl32HiZGpLy26BAPc= @@ -842,8 +853,9 @@ github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= @@ -936,6 +948,8 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/prometheus/prometheus v0.301.0 h1:0z8dgegmILivNomCd79RKvVkIols8vBGPKmcIBc7OyY= github.com/prometheus/prometheus v0.301.0/go.mod h1:BJLjWCKNfRfjp7Q48DrAjARnCi7GhfUVvUFEAWTssZM= +github.com/prometheus/sigv4 v0.1.0 h1:FgxH+m1qf9dGQ4w8Dd6VkthmpFQfGTzUeavMoQeG1LA= +github.com/prometheus/sigv4 v0.1.0/go.mod h1:doosPW9dOitMzYe2I2BN0jZqUuBrGPbXrNsTScN18iU= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= @@ -975,7 +989,6 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 h1:Jpy1PXuP99tXNrhbq2BaPz9B+jNAvH1JPQQpG/9GCXY= github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= @@ -1044,7 +1057,6 @@ github.com/unknwon/log v0.0.0-20200308114134-929b1006e34a/go.mod h1:1xEUf2abjfP9 github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli v1.22.16 h1:MH0k6uJxdwdeWQTwhSO42Pwr4YLrNLwBtg1MRgTqPdQ= github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po= -github.com/wk8/go-ordered-map v1.0.0 h1:BV7z+2PaK8LTSd/mWgY12HyMAo5CEgkHqbkVq2thqr8= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= @@ -1060,6 +1072,7 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= @@ -1155,6 +1168,7 @@ golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= @@ -1194,6 +1208,7 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -1239,6 +1254,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= @@ -1268,6 +1285,7 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= @@ -1323,6 +1341,7 @@ golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1381,7 +1400,6 @@ golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190424220101-1e8e1cfdf96b/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190425222832-ad9eeb80039a/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= @@ -1422,6 +1440,7 @@ golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= @@ -1559,6 +1578,7 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/src-d/go-errors.v1 v1.0.0 h1:cooGdZnCjYbeS1zb1s6pVAAimTdKceRrpn7aKOnNIfc= gopkg.in/src-d/go-errors.v1 v1.0.0/go.mod h1:q1cBlomlw2FnDBDNGlnh6X0jPihy+QxZfMMNxPCbdYg= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/pkg/storage/unified/apistore/prepare_test.go b/pkg/storage/unified/apistore/prepare_test.go index 330c2791cfa..d24b31a5a91 100644 --- a/pkg/storage/unified/apistore/prepare_test.go +++ b/pkg/storage/unified/apistore/prepare_test.go @@ -13,9 +13,14 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/exp/rand" "k8s.io/apimachinery/pkg/api/apitesting" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apiserver/pkg/storage" ) +var scheme = runtime.NewScheme() +var codecs = serializer.NewCodecFactory(scheme) + func TestPrepareObjectForStorage(t *testing.T) { _ = v0alpha1.AddToScheme(scheme) node, err := snowflake.NewNode(rand.Int63n(1024)) diff --git a/pkg/storage/unified/apistore/store_test.go b/pkg/storage/unified/apistore/store_test.go index 81a3b3044d4..ec1013de44a 100644 --- a/pkg/storage/unified/apistore/store_test.go +++ b/pkg/storage/unified/apistore/store_test.go @@ -3,7 +3,7 @@ // Provenance-includes-license: Apache-2.0 // Provenance-includes-copyright: The Kubernetes Authors. -package apistore +package apistore_test import ( "context" diff --git a/pkg/storage/unified/apistore/util.go b/pkg/storage/unified/apistore/util.go index fb95ed08020..e41a50b5efa 100644 --- a/pkg/storage/unified/apistore/util.go +++ b/pkg/storage/unified/apistore/util.go @@ -9,7 +9,6 @@ import ( "bytes" "fmt" "strconv" - "strings" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -19,7 +18,6 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/utils" - grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" "github.com/grafana/grafana/pkg/storage/unified/resource" ) @@ -128,41 +126,3 @@ func isUnchanged(codec runtime.Codec, obj runtime.Object, newObj runtime.Object) return bytes.Equal(buf.Bytes(), newBuf.Bytes()), nil } - -func testKeyParser(val string) (*resource.ResourceKey, error) { - k, err := grafanaregistry.ParseKey(val) - if err != nil { - if strings.HasPrefix(val, "pods/") { - parts := strings.Split(val, "/") - if len(parts) == 2 { - err = nil - k = &grafanaregistry.Key{ - Resource: parts[0], // pods - Name: parts[1], - } - } else if len(parts) == 3 { - err = nil - k = &grafanaregistry.Key{ - Resource: parts[0], // pods - Namespace: parts[1], - Name: parts[2], - } - } - } - } - if err != nil { - return nil, err - } - if k.Group == "" { - k.Group = "example.apiserver.k8s.io" - } - if k.Resource == "" { - return nil, apierrors.NewInternalError(fmt.Errorf("missing resource in request")) - } - return &resource.ResourceKey{ - Namespace: k.Namespace, - Group: k.Group, - Resource: k.Resource, - Name: k.Name, - }, err -} diff --git a/pkg/storage/unified/apistore/watcher_test.go b/pkg/storage/unified/apistore/watcher_test.go index 8a8b278577f..5b0842fa985 100644 --- a/pkg/storage/unified/apistore/watcher_test.go +++ b/pkg/storage/unified/apistore/watcher_test.go @@ -3,11 +3,13 @@ // Provenance-includes-license: Apache-2.0 // Provenance-includes-copyright: The Kubernetes Authors. -package apistore +package apistore_test import ( "context" + "fmt" "os" + "strings" "testing" "time" @@ -16,6 +18,7 @@ import ( "gocloud.dev/blob/fileblob" "gocloud.dev/blob/memblob" "k8s.io/apimachinery/pkg/api/apitesting" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -28,9 +31,11 @@ import ( "k8s.io/apiserver/pkg/storage/storagebackend" "k8s.io/apiserver/pkg/storage/storagebackend/factory" + grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" storagetesting "github.com/grafana/grafana/pkg/apiserver/storage/testing" infraDB "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/storage/unified/apistore" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/sql" "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" @@ -160,7 +165,7 @@ func testSetup(t testing.TB, opts ...setupOption) (context.Context, storage.Inte client := resource.NewLocalResourceClient(server) config := storagebackend.NewDefaultConfig(setupOpts.prefix, setupOpts.codec) - store, destroyFunc, err := NewStorage( + store, destroyFunc, err := apistore.NewStorage( config.ForResource(setupOpts.groupResource), client, func(obj runtime.Object) (string, error) { @@ -176,7 +181,7 @@ func testSetup(t testing.TB, opts ...setupOption) (context.Context, storage.Inte storage.DefaultNamespaceScopedAttr, make(map[string]storage.IndexerFunc, 0), nil, - StorageOptions{}, + apistore.StorageOptions{}, ) if err != nil { return nil, nil, nil, err @@ -371,3 +376,41 @@ func newPod() runtime.Object { func newPodList() runtime.Object { return &example.PodList{} } + +func testKeyParser(val string) (*resource.ResourceKey, error) { + k, err := grafanaregistry.ParseKey(val) + if err != nil { + if strings.HasPrefix(val, "pods/") { + parts := strings.Split(val, "/") + if len(parts) == 2 { + err = nil + k = &grafanaregistry.Key{ + Resource: parts[0], // pods + Name: parts[1], + } + } else if len(parts) == 3 { + err = nil + k = &grafanaregistry.Key{ + Resource: parts[0], // pods + Namespace: parts[1], + Name: parts[2], + } + } + } + } + if err != nil { + return nil, err + } + if k.Group == "" { + k.Group = "example.apiserver.k8s.io" + } + if k.Resource == "" { + return nil, apierrors.NewInternalError(fmt.Errorf("missing resource in request")) + } + return &resource.ResourceKey{ + Namespace: k.Namespace, + Group: k.Group, + Resource: k.Resource, + Name: k.Name, + }, err +} From 3c56e32b0c87ef303b115cc632df88d89b6bec43 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 13 Feb 2025 14:04:03 +0300 Subject: [PATCH 09/14] K8s/Utils: Find title in unstructured content (#100576) --- pkg/apimachinery/utils/meta.go | 12 ++++++++++++ pkg/apimachinery/utils/meta_test.go | 2 ++ 2 files changed, 14 insertions(+) diff --git a/pkg/apimachinery/utils/meta.go b/pkg/apimachinery/utils/meta.go index 4d39d4fcd57..5b2978533bb 100644 --- a/pkg/apimachinery/utils/meta.go +++ b/pkg/apimachinery/utils/meta.go @@ -699,6 +699,18 @@ func (m *grafanaMetaAccessor) FindTitle(defaultTitle string) string { } } + obj, ok := m.obj.(*unstructured.Unstructured) + if ok { + title, ok, _ := unstructured.NestedString(obj.Object, "spec", "title") + if ok && title != "" { + return title + } + title, ok, _ = unstructured.NestedString(obj.Object, "spec", "name") + if ok && title != "" { + return title + } + } + title := m.r.FieldByName("Title") if title.IsValid() && title.Kind() == reflect.String { return title.String() diff --git a/pkg/apimachinery/utils/meta_test.go b/pkg/apimachinery/utils/meta_test.go index 451deaa47d5..12d12cc06b5 100644 --- a/pkg/apimachinery/utils/meta_test.go +++ b/pkg/apimachinery/utils/meta_test.go @@ -194,6 +194,7 @@ func TestMetaAccessor(t *testing.T) { res.Object = map[string]any{ "spec": map[string]any{ "hello": "world", + "title": "Title", }, "status": map[string]any{ "sloth": "🦥", @@ -218,6 +219,7 @@ func TestMetaAccessor(t *testing.T) { rv, err := meta.GetResourceVersionInt64() require.NoError(t, err) require.Equal(t, int64(12345), rv) + require.Equal(t, "Title", meta.FindTitle("")) // Make sure access to spec works for Unstructured spec, err = meta.GetSpec() From 1c7a758127585029710f89e55b8d06766da06c76 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Thu, 13 Feb 2025 12:11:41 +0100 Subject: [PATCH 10/14] Frontend: Lazy load Echo Backends (#100345) feat(app): lazy load echo backends depending on config. Move lodash to sharedDependencies --- public/app/app.ts | 31 +++++++++---------- .../plugins/loader/sharedDependencies.ts | 4 ++- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/public/app/app.ts b/public/app/app.ts index 86b2386dc6c..697e909fc37 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -5,7 +5,6 @@ import 'whatwg-fetch'; // fetch polyfill needed for PhantomJs rendering import 'file-saver'; import 'jquery'; -import _ from 'lodash'; // eslint-disable-line lodash/import-scope import { createElement } from 'react'; import { createRoot } from 'react-dom/client'; @@ -46,7 +45,6 @@ import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelData import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer'; import { setPluginPage } from '@grafana/runtime/src/components/PluginPage'; import config, { updateConfig } from 'app/core/config'; -import { arrayMove } from 'app/core/utils/arrayMove'; import { getStandardTransformers } from 'app/features/transformers/standardTransformers'; import getDefaultMonacoLanguages from '../lib/monaco-languages'; @@ -67,13 +65,6 @@ import { backendSrv } from './core/services/backend_srv'; import { contextSrv, RedirectToUrlKey } from './core/services/context_srv'; import { Echo } from './core/services/echo/Echo'; import { reportPerformance } from './core/services/echo/EchoSrv'; -import { PerformanceBackend } from './core/services/echo/backends/PerformanceBackend'; -import { ApplicationInsightsBackend } from './core/services/echo/backends/analytics/ApplicationInsightsBackend'; -import { BrowserConsoleBackend } from './core/services/echo/backends/analytics/BrowseConsoleBackend'; -import { GA4EchoBackend } from './core/services/echo/backends/analytics/GA4Backend'; -import { GAEchoBackend } from './core/services/echo/backends/analytics/GABackend'; -import { RudderstackBackend } from './core/services/echo/backends/analytics/RudderstackBackend'; -import { GrafanaJavascriptAgentBackend } from './core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend'; import { KeybindingSrv } from './core/services/keybindingSrv'; import { startMeasure, stopMeasure } from './core/utils/metrics'; import { initDevFeatures } from './dev'; @@ -113,10 +104,6 @@ import { createSystemVariableAdapter } from './features/variables/system/adapter import { createTextBoxVariableAdapter } from './features/variables/textbox/adapter'; import { configureStore } from './store/configureStore'; -// add move to lodash for backward compatabilty with plugins -// @ts-ignore -_.move = arrayMove; - // import symlinked extensions const extensionsIndex = require.context('.', true, /extensions\/index.ts/); const extensionsExports = extensionsIndex.keys().map((key) => { @@ -139,7 +126,7 @@ export class GrafanaApp { initI18nPromise.then(({ language }) => updateConfig({ language })); setBackendSrv(backendSrv); - initEchoSrv(); + await initEchoSrv(); // This needs to be done after the `initEchoSrv` since it is being used under the hood. startMeasure('frontend_app_init'); @@ -295,7 +282,7 @@ function initExtensions() { } } -function initEchoSrv() { +async function initEchoSrv() { setEchoSrv(new Echo({ debug: process.env.NODE_ENV === 'development' })); window.addEventListener('load', (e) => { @@ -315,6 +302,7 @@ function initEchoSrv() { }); if (contextSrv.user.orgRole !== '') { + const { PerformanceBackend } = await import('./core/services/echo/backends/PerformanceBackend'); registerEchoBackend(new PerformanceBackend({})); } @@ -328,6 +316,10 @@ function initEchoSrv() { .filter(Boolean) .map((url) => new RegExp(`${url}.*.`)); + const { GrafanaJavascriptAgentBackend } = await import( + './core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend' + ); + registerEchoBackend( new GrafanaJavascriptAgentBackend({ ...config.grafanaJavascriptAgent, @@ -346,6 +338,7 @@ function initEchoSrv() { } if (config.googleAnalyticsId) { + const { GAEchoBackend } = await import('./core/services/echo/backends/analytics/GABackend'); registerEchoBackend( new GAEchoBackend({ googleAnalyticsId: config.googleAnalyticsId, @@ -354,6 +347,7 @@ function initEchoSrv() { } if (config.googleAnalytics4Id) { + const { GA4EchoBackend } = await import('./core/services/echo/backends/analytics/GA4Backend'); registerEchoBackend( new GA4EchoBackend({ googleAnalyticsId: config.googleAnalytics4Id, @@ -363,6 +357,7 @@ function initEchoSrv() { } if (config.rudderstackWriteKey && config.rudderstackDataPlaneUrl) { + const { RudderstackBackend } = await import('./core/services/echo/backends/analytics/RudderstackBackend'); registerEchoBackend( new RudderstackBackend({ writeKey: config.rudderstackWriteKey, @@ -377,6 +372,9 @@ function initEchoSrv() { } if (config.applicationInsightsConnectionString) { + const { ApplicationInsightsBackend } = await import( + './core/services/echo/backends/analytics/ApplicationInsightsBackend' + ); registerEchoBackend( new ApplicationInsightsBackend({ connectionString: config.applicationInsightsConnectionString, @@ -386,6 +384,7 @@ function initEchoSrv() { } if (config.analyticsConsoleReporting) { + const { BrowserConsoleBackend } = await import('./core/services/echo/backends/analytics/BrowseConsoleBackend'); registerEchoBackend(new BrowserConsoleBackend()); } } @@ -395,7 +394,7 @@ function initEchoSrv() { * like PerformanceMark or PerformancePaintTiming (e.g. created with performance.mark, or first-contentful-paint) */ function reportMetricPerformanceMark(metricName: string, prefix = '', suffix = ''): void { - const metric = _.first(performance.getEntriesByName(metricName)); + const metric = performance.getEntriesByName(metricName).at(0); if (metric) { const metricName = metric.name.replace(/-/g, '_'); reportPerformance(`${prefix}${metricName}${suffix}`, Math.round(metric.startTime) / 1000); diff --git a/public/app/features/plugins/loader/sharedDependencies.ts b/public/app/features/plugins/loader/sharedDependencies.ts index dbb8ed8692f..7059274c717 100644 --- a/public/app/features/plugins/loader/sharedDependencies.ts +++ b/public/app/features/plugins/loader/sharedDependencies.ts @@ -18,6 +18,7 @@ import { appEvents, contextSrv } from 'app/core/core'; import { BackendSrv, getBackendSrv } from 'app/core/services/backend_srv'; import impressionSrv from 'app/core/services/impression_srv'; import TimeSeries from 'app/core/time_series2'; +import { arrayMove } from 'app/core/utils/arrayMove'; import * as flatten from 'app/core/utils/flatten'; import kbn from 'app/core/utils/kbn'; import * as ticks from 'app/core/utils/ticks'; @@ -90,7 +91,8 @@ export const sharedDependenciesMap = { __useDefault: true, }, ...jQueryFlotDeps, - lodash: () => import('lodash').then((module) => ({ ...module, __useDefault: true })), + // add move to lodash for backward compatabilty with plugins + lodash: () => import('lodash').then((module) => ({ ...module, move: arrayMove, __useDefault: true })), moment: () => import('moment').then((module) => ({ ...module, __useDefault: true })), prismjs: () => import('prismjs'), react: () => import('react'), From a69fac6e16906cbf29d9708d611dc09298cdbdaa Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Thu, 13 Feb 2025 13:40:53 +0100 Subject: [PATCH 11/14] Mark grpc data source timeouts as cancelled queries (#100573) * Set up to reproduce issue locally * add check for deadline exceeded * Revert "Set up to reproduce issue locally" This reverts commit d8d9b354cab93e0e88edc739c4227cc75541b867. * Trigger build --------- Co-authored-by: Will Browne --- pkg/plugins/instrumentationutils/request_status.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/plugins/instrumentationutils/request_status.go b/pkg/plugins/instrumentationutils/request_status.go index 9d6cb7fba5e..5e77480ee77 100644 --- a/pkg/plugins/instrumentationutils/request_status.go +++ b/pkg/plugins/instrumentationutils/request_status.go @@ -35,7 +35,7 @@ func RequestStatusFromError(err error) RequestStatus { status = RequestStatusError if errors.Is(err, context.Canceled) { status = RequestStatusCancelled - } else if s, ok := grpcstatus.FromError(err); ok && s.Code() == grpccodes.Canceled { + } else if s, ok := grpcstatus.FromError(err); ok && s.Code() == grpccodes.Canceled || s.Code() == grpccodes.DeadlineExceeded { status = RequestStatusCancelled } } From be60ef0500a603e667598ef47bbab8204885aeea Mon Sep 17 00:00:00 2001 From: Karl Persson <23356117+kalleep@users.noreply.github.com> Date: Thu, 13 Feb 2025 14:10:58 +0100 Subject: [PATCH 12/14] IDToken: cache invalidation (#100592) * Make org role part of id token cache key. This way we will always sign a new token when it changes * Remove calls to remove id token --- pkg/api/http_server.go | 4 +- pkg/api/org_users.go | 6 -- pkg/api/org_users_test.go | 74 ++++++------------- pkg/services/auth/idimpl/service.go | 8 +- pkg/services/auth/idimpl/service_test.go | 31 ++++++++ .../serviceaccounts/manager/service.go | 9 --- 6 files changed, 60 insertions(+), 72 deletions(-) diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 850a4ea5fd5..21f6f2dcd56 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -207,7 +207,6 @@ type HTTPServer struct { tempUserService tempUser.Service loginAttemptService loginAttempt.Service orgService org.Service - idService auth.IDService orgDeletionService org.DeletionService TeamService team.Service accesscontrolService accesscontrol.Service @@ -273,7 +272,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi annotationRepo annotations.Repository, tagService tag.Service, searchv2HTTPService searchV2.SearchHTTPService, oauthTokenService oauthtoken.OAuthTokenService, statsService stats.Service, authnService authn.Service, pluginsCDNService *pluginscdn.Service, promGatherer prometheus.Gatherer, starApi *starApi.API, promRegister prometheus.Registerer, clientConfigProvider grafanaapiserver.DirectRestConfigProvider, anonService anonymous.Service, - userVerifier user.Verifier, pluginPreinstall plugininstaller.Preinstall, idService auth.IDService, + userVerifier user.Verifier, pluginPreinstall plugininstaller.Preinstall, ) (*HTTPServer, error) { web.Env = cfg.Env m := web.New() @@ -361,7 +360,6 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi tempUserService: tempUserService, loginAttemptService: loginAttemptService, orgService: orgService, - idService: idService, orgDeletionService: orgDeletionService, TeamService: teamService, navTreeService: navTreeService, diff --git a/pkg/api/org_users.go b/pkg/api/org_users.go index f19d4138bb8..1b83b4e3b77 100644 --- a/pkg/api/org_users.go +++ b/pkg/api/org_users.go @@ -7,11 +7,9 @@ import ( "net/http" "strconv" - claims "github.com/grafana/authlib/types" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/authn" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" @@ -434,10 +432,6 @@ func (hs *HTTPServer) updateOrgUserHelper(c *contextmodel.ReqContext, cmd org.Up } } - if err := hs.idService.RemoveIDToken(c.Req.Context(), &authn.Identity{ID: strconv.FormatInt(cmd.UserID, 10), Type: claims.TypeUser, OrgID: cmd.OrgID}); err != nil { - return response.Error(http.StatusInternalServerError, "Failed to invalidate the ID token cache", err) - } - if err := hs.orgService.UpdateOrgUser(c.Req.Context(), &cmd); err != nil { if errors.Is(err, org.ErrLastOrgAdmin) { return response.Error(http.StatusBadRequest, "Cannot change role so that there is no organization admin left", nil) diff --git a/pkg/api/org_users_test.go b/pkg/api/org_users_test.go index 324e9da2b39..59bb47330d6 100644 --- a/pkg/api/org_users_test.go +++ b/pkg/api/org_users_test.go @@ -9,12 +9,9 @@ import ( "strings" "testing" - "github.com/grafana/authlib/types" - "github.com/grafana/grafana/pkg/services/auth/idtest" "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authn/authntest" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/api/dtos" @@ -205,12 +202,11 @@ func TestOrgUsersAPIEndpoint_userLoggedIn(t *testing.T) { func TestOrgUsersAPIEndpoint_updateOrgRole(t *testing.T) { type testCase struct { - desc string - SkipOrgRoleSync bool - AuthEnabled bool - AuthModule string - shouldInvalidateIDToken bool - expectedCode int + desc string + SkipOrgRoleSync bool + AuthEnabled bool + AuthModule string + expectedCode int } permissions := []accesscontrol.Permission{ {Action: accesscontrol.ActionOrgUsersRead, Scope: "users:*"}, @@ -220,12 +216,11 @@ func TestOrgUsersAPIEndpoint_updateOrgRole(t *testing.T) { } tests := []testCase{ { - desc: "should be able to change basicRole when skip_org_role_sync true", - SkipOrgRoleSync: true, - AuthEnabled: true, - AuthModule: login.LDAPAuthModule, - shouldInvalidateIDToken: true, - expectedCode: http.StatusOK, + desc: "should be able to change basicRole when skip_org_role_sync true", + SkipOrgRoleSync: true, + AuthEnabled: true, + AuthModule: login.LDAPAuthModule, + expectedCode: http.StatusOK, }, { desc: "should not be able to change basicRole when skip_org_role_sync false", @@ -242,20 +237,18 @@ func TestOrgUsersAPIEndpoint_updateOrgRole(t *testing.T) { expectedCode: http.StatusForbidden, }, { - desc: "should be able to change basicRole with a basic Auth", - SkipOrgRoleSync: false, - AuthEnabled: false, - AuthModule: "", - shouldInvalidateIDToken: true, - expectedCode: http.StatusOK, + desc: "should be able to change basicRole with a basic Auth", + SkipOrgRoleSync: false, + AuthEnabled: false, + AuthModule: "", + expectedCode: http.StatusOK, }, { - desc: "should be able to change basicRole with a basic Auth", - SkipOrgRoleSync: true, - AuthEnabled: true, - AuthModule: "", - shouldInvalidateIDToken: true, - expectedCode: http.StatusOK, + desc: "should be able to change basicRole with a basic Auth", + SkipOrgRoleSync: true, + AuthEnabled: true, + AuthModule: "", + expectedCode: http.StatusOK, }, } @@ -286,11 +279,6 @@ func TestOrgUsersAPIEndpoint_updateOrgRole(t *testing.T) { } hs.userService = &usertest.FakeUserService{ExpectedSignedInUser: userWithPermissions} hs.orgService = &orgtest.FakeOrgService{} - idService := &idtest.MockService{} - if tt.shouldInvalidateIDToken { - idService.On("RemoveIDToken", mock.Anything, mock.Anything).Return(nil) - } - hs.idService = idService hs.SocialService = &socialtest.FakeSocialService{ ExpectedAuthInfoProvider: &social.OAuthInfo{Enabled: tt.AuthEnabled, SkipOrgRoleSync: tt.SkipOrgRoleSync}, } @@ -627,7 +615,6 @@ func TestOrgUsersAPIEndpointWithSetPerms_AccessControl(t *testing.T) { ExpectedUser: &user.User{}, ExpectedSignedInUser: userWithPermissions(1, tt.permissions), } - hs.idService = &idtest.FakeService{} hs.accesscontrolService = &actest.FakeService{} }) @@ -650,24 +637,16 @@ func TestPatchOrgUsersAPIEndpoint_AccessControl(t *testing.T) { name string role org.RoleType permissions []accesscontrol.Permission - setup func(*testing.T, *idtest.MockService) input string expectedCode int } tests := []testCase{ { - name: "user with permissions can update org role", - permissions: []accesscontrol.Permission{{Action: accesscontrol.ActionOrgUsersWrite, Scope: "users:*"}}, - role: org.RoleAdmin, - input: `{"role": "Viewer"}`, - setup: func(t *testing.T, idService *idtest.MockService) { - idService.On("RemoveIDToken", mock.Anything, mock.MatchedBy(func(id *authn.Identity) bool { - return id.GetIdentityType() == types.TypeUser && - id.GetID() == "user:1" && - id.GetOrgID() == int64(1) - })).Return(nil) - }, + name: "user with permissions can update org role", + permissions: []accesscontrol.Permission{{Action: accesscontrol.ActionOrgUsersWrite, Scope: "users:*"}}, + role: org.RoleAdmin, + input: `{"role": "Viewer"}`, expectedCode: http.StatusOK, }, { @@ -694,11 +673,6 @@ func TestPatchOrgUsersAPIEndpoint_AccessControl(t *testing.T) { AuthModule: "", }, } - idService := &idtest.MockService{} - if tt.setup != nil { - tt.setup(t, idService) - } - hs.idService = idService hs.accesscontrolService = &actest.FakeService{} hs.userService = &usertest.FakeUserService{ ExpectedUser: &user.User{}, diff --git a/pkg/services/auth/idimpl/service.go b/pkg/services/auth/idimpl/service.go index a4b89204d7b..5dec411fdee 100644 --- a/pkg/services/auth/idimpl/service.go +++ b/pkg/services/auth/idimpl/service.go @@ -63,7 +63,7 @@ func (s *Service) SignIdentity(ctx context.Context, id identity.Requester) (stri s.metrics.tokenSigningDurationHistogram.Observe(time.Since(t).Seconds()) }(time.Now()) - cacheKey := prefixCacheKey(id.GetCacheKey()) + cacheKey := getCacheKey(id) type resultType struct { token string @@ -140,7 +140,7 @@ func (s *Service) SignIdentity(ctx context.Context, id identity.Requester) (stri } func (s *Service) RemoveIDToken(ctx context.Context, id identity.Requester) error { - return s.cache.Delete(ctx, prefixCacheKey(id.GetCacheKey())) + return s.cache.Delete(ctx, getCacheKey(id)) } func (s *Service) hook(ctx context.Context, identity *authn.Identity, _ *authn.Request) error { @@ -181,8 +181,8 @@ func getAudience(orgID int64) jwt.Audience { return jwt.Audience{fmt.Sprintf("org:%d", orgID)} } -func prefixCacheKey(key string) string { - return fmt.Sprintf("%s-%s", cachePrefix, key) +func getCacheKey(ident identity.Requester) string { + return cachePrefix + ident.GetCacheKey() + string(ident.GetOrgRole()) } func shouldLogErr(err error) bool { diff --git a/pkg/services/auth/idimpl/service_test.go b/pkg/services/auth/idimpl/service_test.go index 0f3814968e3..bb7ee510e55 100644 --- a/pkg/services/auth/idimpl/service_test.go +++ b/pkg/services/auth/idimpl/service_test.go @@ -16,6 +16,7 @@ import ( "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authn/authntest" "github.com/grafana/grafana/pkg/services/login" + "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/setting" ) @@ -101,4 +102,34 @@ func TestService_SignIdentity(t *testing.T) { assert.Equal(t, claims.TypeUser, gotClaims.Rest.Type) assert.Equal(t, "edpu3nnt61se8e", gotClaims.Rest.Identifier) }) + + t.Run("should sign new token if org role has changed", func(t *testing.T) { + s := ProvideService( + setting.NewCfg(), signer, remotecache.NewFakeCacheStorage(), + &authntest.FakeService{}, nil, + ) + + ident := &authn.Identity{ + ID: "1", + Type: claims.TypeUser, + AuthenticatedBy: login.AzureADAuthModule, + Login: "U1", + UID: "edpu3nnt61se8e", + OrgID: 1, + OrgRoles: map[int64]org.RoleType{1: org.RoleAdmin}, + } + + first, _, err := s.SignIdentity(context.Background(), ident) + require.NoError(t, err) + + second, _, err := s.SignIdentity(context.Background(), ident) + require.NoError(t, err) + + assert.Equal(t, first, second) + + ident.OrgRoles[1] = org.RoleEditor + third, _, err := s.SignIdentity(context.Background(), ident) + require.NoError(t, err) + assert.NotEqual(t, first, third) + }) } diff --git a/pkg/services/serviceaccounts/manager/service.go b/pkg/services/serviceaccounts/manager/service.go index f53e7b72d9b..cde680f616d 100644 --- a/pkg/services/serviceaccounts/manager/service.go +++ b/pkg/services/serviceaccounts/manager/service.go @@ -17,8 +17,6 @@ import ( "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apikey" - "github.com/grafana/grafana/pkg/services/auth" - "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts/database" @@ -44,7 +42,6 @@ type ServiceAccountsService struct { secretScanService secretscan.Checker orgService org.Service serverLock *serverlock.ServerLockService - idService auth.IDService secretScanEnabled bool secretScanInterval time.Duration @@ -61,7 +58,6 @@ func ProvideServiceAccountsService( acService accesscontrol.Service, permissions accesscontrol.ServiceAccountPermissionsService, serverLockService *serverlock.ServerLockService, - idService auth.IDService, ) (*ServiceAccountsService, error) { serviceAccountsStore := database.ProvideServiceAccountsStore( cfg, @@ -81,7 +77,6 @@ func ProvideServiceAccountsService( backgroundLog: log.New("serviceaccounts.background"), orgService: orgService, serverLock: serverLockService, - idService: idService, } if err := RegisterRoles(acService); err != nil { @@ -271,10 +266,6 @@ func (sa *ServiceAccountsService) UpdateServiceAccount(ctx context.Context, orgI return nil, err } - if err := sa.idService.RemoveIDToken(ctx, &authn.Identity{ID: strconv.FormatInt(serviceAccountID, 10), Type: claims.TypeServiceAccount, OrgID: orgID}); err != nil { - return nil, err - } - return sa.store.UpdateServiceAccount(ctx, orgID, serviceAccountID, saForm) } From a58564a35efe8c05a21d8190b283af5bc0979d2a Mon Sep 17 00:00:00 2001 From: Karl Persson <23356117+kalleep@users.noreply.github.com> Date: Thu, 13 Feb 2025 14:24:59 +0100 Subject: [PATCH 13/14] Unified Storage: Register metrics (#100600) use seperate once struct --- pkg/storage/unified/resource/bleve_index_metrics.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/storage/unified/resource/bleve_index_metrics.go b/pkg/storage/unified/resource/bleve_index_metrics.go index 0cb52455cff..a15f36be543 100644 --- a/pkg/storage/unified/resource/bleve_index_metrics.go +++ b/pkg/storage/unified/resource/bleve_index_metrics.go @@ -12,6 +12,7 @@ import ( var ( onceIndex sync.Once + onceSprinkles sync.Once IndexMetrics *BleveIndexMetrics SprinklesIndexMetrics *SprinklesMetrics ) @@ -36,7 +37,7 @@ type SprinklesMetrics struct { var IndexCreationBuckets = []float64{1, 5, 10, 25, 50, 75, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000} func NewSprinklesMetrics() *SprinklesMetrics { - onceIndex.Do(func() { + onceSprinkles.Do(func() { SprinklesIndexMetrics = &SprinklesMetrics{ SprinklesLatency: prometheus.NewHistogram(prometheus.HistogramOpts{ Namespace: "index_server", From afe8b08a48acc695e78cc3c5d05b5ba45ad9ffad Mon Sep 17 00:00:00 2001 From: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Thu, 13 Feb 2025 15:03:54 +0100 Subject: [PATCH 14/14] MultiCombobox: Fix labels disappearing on selected items when filtering (#100602) * Fix label disappearing on filtering * Remove only from test * Fix custom value test --- .../components/Combobox/MultiCombobox.test.tsx | 17 +++++++++++++++-- .../src/components/Combobox/MultiCombobox.tsx | 4 ++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx index bee10de6783..5be7bc09098 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx @@ -156,8 +156,8 @@ describe('MultiCombobox', () => { await user.type(input, 'D'); await user.keyboard('{arrowdown}{enter}'); expect(onChange).toHaveBeenCalledWith([ - { value: 'a' }, - { value: 'c' }, + { label: 'A', value: 'a' }, + { label: 'C', value: 'c' }, { label: 'D', value: 'D', description: 'Use custom value' }, ]); }); @@ -235,6 +235,19 @@ describe('MultiCombobox', () => { await user.click(await screen.findByRole('option', { name: 'All' })); expect(onChange).toHaveBeenCalledWith([]); }); + + it('should keep label names on selected items when searching', async () => { + const options = [ + { label: 'A', value: 'a' }, + { label: 'B', value: 'b' }, + { label: 'C', value: 'c' }, + ]; + render(); + const input = screen.getByRole('combobox'); + await user.click(input); + await user.type(input, 'b'); + expect(screen.getByText('A')).toBeInTheDocument(); + }); }); describe('async', () => { diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx index 9a90dfd4588..7d18074910a 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx @@ -79,8 +79,8 @@ export const MultiCombobox = (props: MultiComboboxPro return []; } - return getSelectedItemsFromValue(value, baseOptions); - }, [value, baseOptions]); + return getSelectedItemsFromValue(value, typeof props.options !== 'function' ? props.options : baseOptions); + }, [value, props.options, baseOptions]); const { measureRef, counterMeasureRef, suffixMeasureRef, shownItems } = useMeasureMulti( selectedItems,