From f92ef0e126beacaafc968393d616a2c352f9de26 Mon Sep 17 00:00:00 2001 From: Laura Benz <48948963+L-M-K-B@users.noreply.github.com> Date: Thu, 3 Oct 2024 15:13:29 +0200 Subject: [PATCH 01/16] RestoreDashboards: Add link to dashboards browse view (#94000) * feat: add button * refactor: clean up * feat: add tracking * refactor: change button copy * refactor: add payload to tracking * refactor: add subURL * refactor: user permissions * refactor: group conditions Co-authored-by: Alex Khomenko --------- Co-authored-by: Alex Khomenko --- pkg/services/navtree/navtreeimpl/navtree.go | 2 +- .../BrowseDashboardsPage.tsx | 23 +++++++++++++++++-- public/locales/en-US/grafana.json | 3 +++ public/locales/pseudo-LOCALE/grafana.json | 3 +++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 47095e0c1ef..d89454d5ee0 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -387,7 +387,7 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *contextmodel.ReqContext) []*navt }) } - if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagDashboardRestoreUI) && c.SignedInUser.GetOrgRole() == org.RoleAdmin { + if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagDashboardRestoreUI) && (c.SignedInUser.GetOrgRole() == org.RoleAdmin || c.IsGrafanaAdmin) { dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ Text: "Recently deleted", SubTitle: "Any items listed here for more than 30 days will be automatically deleted.", diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx index cce7bfdcdc3..6d8d2b14e26 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx @@ -4,11 +4,14 @@ import { useLocation, useParams } from 'react-router-dom-v5-compat'; import AutoSizer from 'react-virtualized-auto-sizer'; import { GrafanaTheme2 } from '@grafana/data'; -import { reportInteraction } from '@grafana/runtime'; -import { FilterInput, useStyles2 } from '@grafana/ui'; +import { config, reportInteraction } from '@grafana/runtime'; +import { LinkButton, FilterInput, useStyles2 } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; +import { getConfig } from 'app/core/config'; +import { Trans } from 'app/core/internationalization'; import { useDispatch } from 'app/types'; +import { contextSrv } from '../../core/services/context_srv'; import { buildNavModel, getDashboardsTabID } from '../folders/state/navModel'; import { useSearchStateManager } from '../search/state/SearchStateManager'; import { getSearchPlaceholder } from '../search/tempI18nPhrases'; @@ -81,6 +84,7 @@ const BrowseDashboardsPage = memo(() => { const { data: rootFolder } = useGetFolderQuery('general'); let folder = folderDTO ? folderDTO : rootFolder; const { canEditFolders, canEditDashboards, canCreateDashboards, canCreateFolders } = getFolderPermissions(folder); + const hasAdminRights = contextSrv.hasRole('Admin') || contextSrv.isGrafanaAdmin; const showEditTitle = canEditFolders && folderUID; const canSelect = canEditFolders || canEditDashboards; @@ -104,6 +108,12 @@ const BrowseDashboardsPage = memo(() => { } }; + const handleButtonClickToRecentlyDeleted = () => { + reportInteraction('grafana_browse_dashboards_page_button_to_recently_deleted', { + origin: window.location.pathname === getConfig().appSubUrl + '/dashboards' ? 'Dashboards' : 'Folder view', + }); + }; + return ( { onEditTitle={showEditTitle ? onEditTitle : undefined} actions={ <> + {config.featureToggles.dashboardRestore && config.featureToggles.dashboardRestoreUI && hasAdminRights && ( + + Recently deleted + + )} {folderDTO && } {(canCreateDashboards || canCreateFolders) && ( Date: Thu, 3 Oct 2024 15:43:15 +0200 Subject: [PATCH 02/16] Auth: Replace jmespath/go-jmespath with jmespath-community/go-jmespath (#94203) Replace jmespath/go-jmespath with jmespath-community/go-jmespath --- go.mod | 4 +++- go.sum | 2 ++ pkg/util/json.go | 4 ++-- pkg/util/json_test.go | 31 +++++++++++++++++++++++++++++++ 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 2454c952fd3..2687feeb236 100644 --- a/go.mod +++ b/go.mod @@ -111,7 +111,7 @@ require ( github.com/huandu/xstrings v1.3.3 // @grafana/partner-datasources github.com/influxdata/influxdb-client-go/v2 v2.13.0 // @grafana/observability-metrics github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf // @grafana/grafana-app-platform-squad - github.com/jmespath/go-jmespath v0.4.0 // @grafana/grafana-backend-group + github.com/jmespath/go-jmespath v0.4.0 // indirect; @grafana/grafana-backend-group github.com/jmoiron/sqlx v1.3.5 // @grafana/grafana-backend-group github.com/json-iterator/go v1.1.12 // @grafana/grafana-backend-group github.com/lib/pq v1.10.9 // @grafana/grafana-backend-group @@ -478,6 +478,8 @@ require ( github.com/grafana/grafana/apps/playlist v0.0.0-20240917082838-e2bce38a7990 // @grafana/grafana-app-platform-squad ) +require github.com/jmespath-community/go-jmespath v1.1.1 // @grafana/identity-access-team + require ( cloud.google.com/go/longrunning v0.5.12 // indirect github.com/at-wat/mqtt-go v0.19.4 // indirect diff --git a/go.sum b/go.sum index 45fa6bd2fce..3a19b85e733 100644 --- a/go.sum +++ b/go.sum @@ -2524,6 +2524,8 @@ github.com/jhump/protoreflect v1.11.0/go.mod h1:U7aMIjN0NWq9swDP7xDdoMfRHb35uiuT github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= github.com/jmattheis/goverter v1.4.0/go.mod h1:iVIl/4qItWjWj2g3vjouGoYensJbRqDHpzlEVMHHFeY= +github.com/jmespath-community/go-jmespath v1.1.1 h1:bFikPhsi/FdmlZhVgSCd2jj1e7G/rw+zyQfyg5UF+L4= +github.com/jmespath-community/go-jmespath v1.1.1/go.mod h1:4gOyFJsR/Gk+05RgTKYrifT7tBPWD8Lubtb5jRrfy9I= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= diff --git a/pkg/util/json.go b/pkg/util/json.go index cb1b0fef76d..dd018d80d32 100644 --- a/pkg/util/json.go +++ b/pkg/util/json.go @@ -3,7 +3,7 @@ package util import ( "encoding/json" - "github.com/jmespath/go-jmespath" + "github.com/jmespath-community/go-jmespath" "github.com/grafana/grafana/pkg/apimachinery/errutil" ) @@ -86,7 +86,7 @@ func searchJSONForAttr(attributePath string, data any) (any, error) { } // Copy the data to a new variable - var jsonData = data + jsonData := data // If the data is a byte slice, try to unmarshal it into a JSON object if dataBytes, ok := data.([]byte); ok { diff --git a/pkg/util/json_test.go b/pkg/util/json_test.go index 232043a05f5..9d84ab64324 100644 --- a/pkg/util/json_test.go +++ b/pkg/util/json_test.go @@ -153,3 +153,34 @@ func TestSearchJSONForEmail(t *testing.T) { }) } } + +func TestSearchJSONForStringAttr(t *testing.T) { + t.Parallel() + tests := []struct { + Name string + SearchObject any + AttributePath string + ExpectedResult string + }{ + { + Name: "Case insensitive contains using lower function from works correctly", + SearchObject: map[string]any{ + "groups": []string{ + "fOO", + }, + }, + AttributePath: "contains(groups[*].lower(@) ,lower('FOO')) && 'success' || 'failure'", + ExpectedResult: "success", + }, + } + + for _, test := range tests { + test := test + t.Run(test.Name, func(t *testing.T) { + t.Parallel() + actualResult, err := util.SearchJSONForStringAttr(test.AttributePath, test.SearchObject) + require.NoError(t, err) + require.Equal(t, test.ExpectedResult, actualResult) + }) + } +} From 4623a6471bf77036ea3f0f17b0ff4e94272bb01a Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Thu, 3 Oct 2024 07:58:33 -0600 Subject: [PATCH 03/16] SQLStore: Fix parseTime check (#94175) --- pkg/services/sqlstore/sqlstore.go | 2 +- pkg/services/sqlstore/sqlstore_test.go | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index 9533aa6c512..4e314f21ec7 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -289,7 +289,7 @@ func (ss *SQLStore) initEngine(engine *xorm.Engine) error { } if engine == nil { // Ensure that parseTime is enabled for MySQL - if ss.features.IsEnabledGlobally(featuremgmt.FlagMysqlParseTime) && ss.dbCfg.Type == migrator.MySQL && !strings.Contains(ss.dbCfg.ConnectionString, "parseTime=") { + if ss.features.IsEnabledGlobally(featuremgmt.FlagMysqlParseTime) && strings.Contains(ss.dbCfg.Type, migrator.MySQL) && !strings.Contains(ss.dbCfg.ConnectionString, "parseTime=") { if strings.Contains(ss.dbCfg.ConnectionString, "?") { ss.dbCfg.ConnectionString += "&parseTime=true" } else { diff --git a/pkg/services/sqlstore/sqlstore_test.go b/pkg/services/sqlstore/sqlstore_test.go index 439c9f893f2..f08eb345570 100644 --- a/pkg/services/sqlstore/sqlstore_test.go +++ b/pkg/services/sqlstore/sqlstore_test.go @@ -93,6 +93,13 @@ func TestInitEngine_ParseTimeInConnectionString(t *testing.T) { featureEnabled: true, expectedConnection: "user:password@tcp(localhost:3306)/existingparams?collation=utf8mb4_unicode_ci&allowNativePasswords=true&clientFoundRows=true&charset=utf8&parseTime=true", }, + { + name: "MySQL with feature enabled", + connectionString: "mysql://user:password@localhost:3306/existingparams?charset=utf8", + dbType: "mysqlWithHooks", + featureEnabled: true, + expectedConnection: "user:password@tcp(localhost:3306)/existingparams?collation=utf8mb4_unicode_ci&allowNativePasswords=true&clientFoundRows=true&charset=utf8&parseTime=true", + }, { name: "MySQL with feature disabled", connectionString: "mysql://user:password@localhost:3306/disabled", From a38d6e57c2c6d187aa14ea4dfea9b2555776d776 Mon Sep 17 00:00:00 2001 From: Kyle Cunningham Date: Thu, 3 Oct 2024 13:38:18 -0500 Subject: [PATCH 04/16] Get help: Make quality of life updates (#93594) * Get help updates * Remove beta badge * Further cleanups * Some updates * Fix tests * Prettier * Update betterer results * i18n * i18n extraction * Bettererest --- .betterer.results | 11 ++----- .../components/HelpWizard/HelpWizard.test.tsx | 2 +- .../components/HelpWizard/HelpWizard.tsx | 32 ++++++++++--------- public/locales/en-US/grafana.json | 7 ++++ public/locales/pseudo-LOCALE/grafana.json | 7 ++++ 5 files changed, 34 insertions(+), 25 deletions(-) diff --git a/.betterer.results b/.betterer.results index 12d1729d2e1..867fddaac20 100644 --- a/.betterer.results +++ b/.betterer.results @@ -3049,19 +3049,12 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], "public/app/features/dashboard/components/HelpWizard/HelpWizard.tsx:5381": [ - [0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], [0, 0, 0, "No untranslated strings. Wrap text with ", "3"], [0, 0, 0, "No untranslated strings. Wrap text with ", "4"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "5"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "6"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "7"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "8"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "9"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "10"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "11"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "12"] + [0, 0, 0, "No untranslated strings. Wrap text with ", "5"] ], "public/app/features/dashboard/components/Inspector/PanelInspector.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] diff --git a/public/app/features/dashboard/components/HelpWizard/HelpWizard.test.tsx b/public/app/features/dashboard/components/HelpWizard/HelpWizard.test.tsx index 26f51c03999..8fdc82a5dd8 100644 --- a/public/app/features/dashboard/components/HelpWizard/HelpWizard.test.tsx +++ b/public/app/features/dashboard/components/HelpWizard/HelpWizard.test.tsx @@ -30,6 +30,6 @@ function setup() { describe('SupportSnapshot', () => { it('Can render', async () => { setup(); - expect(await screen.findByRole('button', { name: /Dashboard \([\d\.]+ KiB\)/ })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: /Download snapshot \([\d\.]+ KiB\)/ })).toBeInTheDocument(); }); }); diff --git a/public/app/features/dashboard/components/HelpWizard/HelpWizard.tsx b/public/app/features/dashboard/components/HelpWizard/HelpWizard.tsx index 3b511c024be..f6058b74d61 100644 --- a/public/app/features/dashboard/components/HelpWizard/HelpWizard.tsx +++ b/public/app/features/dashboard/components/HelpWizard/HelpWizard.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import { useMemo, useEffect } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; -import { PanelPlugin, GrafanaTheme2, FeatureState } from '@grafana/data'; +import { PanelPlugin, GrafanaTheme2 } from '@grafana/data'; import { config } from '@grafana/runtime'; import { Drawer, @@ -11,17 +11,16 @@ import { CodeEditor, useStyles2, Field, - HorizontalGroup, InlineSwitch, Button, Spinner, Alert, - FeatureBadge, Select, ClipboardButton, Icon, Stack, } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; import { contextSrv } from 'app/core/services/context_srv'; import { PanelModel } from 'app/features/dashboard/state'; import { AccessControlAction } from 'app/types'; @@ -76,7 +75,6 @@ export function HelpWizard({ panel, plugin, onClose }: Props) { subtitle={ - - To request troubleshooting help, send a snapshot of this panel to Grafana Labs Technical Support. The - snapshot contains query response data and panel settings. + + To request troubleshooting help, send a snapshot of this panel to Grafana Labs Technical Support. The + snapshot contains query response data and panel settings. + {hasSupportBundleAccess && ( - You can also retrieve a support bundle containing information concerning your Grafana instance and - configured datasources in the support bundles section. + + You can also retrieve a support bundle containing information concerning your Grafana instance and + configured datasources in the support bundles section. + )} @@ -150,10 +152,10 @@ export function HelpWizard({ panel, plugin, onClose }: Props) { {currentTab === SnapshotTab.Support && ( <> - + service.onToggleRandomize('values')} /> - + - Copy to clipboard + Copy Github comment diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 424473b35e3..deec9871d8b 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1093,6 +1093,13 @@ }, "title": "Shortcuts" }, + "help-wizard": { + "download-snapshot": "Download snapshot", + "github-comment": "Copy Github comment", + "preview-snapshot": "Preview snapshot", + "support-bundle": "You can also retrieve a support bundle containing information concerning your Grafana instance and configured datasources in the <1>support bundles section.", + "troubleshooting-help": "To request troubleshooting help, send a snapshot of this panel to Grafana Labs Technical Support. The snapshot contains query response data and panel settings." + }, "inspector": { "query": { "collapse-all": "Collapse all", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 04ea3c6e2ac..3fc58f3e489 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -1093,6 +1093,13 @@ }, "title": "Ŝĥőřŧčūŧş" }, + "help-wizard": { + "download-snapshot": "Đőŵʼnľőäđ şʼnäpşĥőŧ", + "github-comment": "Cőpy Ğįŧĥūþ čőmmęʼnŧ", + "preview-snapshot": "Přęvįęŵ şʼnäpşĥőŧ", + "support-bundle": "Ÿőū čäʼn äľşő řęŧřįęvę ä şūppőřŧ þūʼnđľę čőʼnŧäįʼnįʼnģ įʼnƒőřmäŧįőʼn čőʼnčęřʼnįʼnģ yőūř Ğřäƒäʼnä įʼnşŧäʼnčę äʼnđ čőʼnƒįģūřęđ đäŧäşőūřčęş įʼn ŧĥę <1>şūppőřŧ þūʼnđľęş şęčŧįőʼn.", + "troubleshooting-help": "Ŧő řęqūęşŧ ŧřőūþľęşĥőőŧįʼnģ ĥęľp, şęʼnđ ä şʼnäpşĥőŧ őƒ ŧĥįş päʼnęľ ŧő Ğřäƒäʼnä Ŀäþş Ŧęčĥʼnįčäľ Ŝūppőřŧ. Ŧĥę şʼnäpşĥőŧ čőʼnŧäįʼnş qūęřy řęşpőʼnşę đäŧä äʼnđ päʼnęľ şęŧŧįʼnģş." + }, "inspector": { "query": { "collapse-all": "Cőľľäpşę äľľ", From a4d919c1573048e06d881ba50d53562314cf590b Mon Sep 17 00:00:00 2001 From: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Thu, 3 Oct 2024 15:50:15 -0400 Subject: [PATCH 05/16] Chore: Update k8s.io dependencies to v0.31.1 (#93696) --- apps/playlist/go.mod | 2 +- apps/playlist/go.sum | 4 ++-- go.mod | 14 +++++++------- go.sum | 28 ++++++++++++++-------------- go.work.sum | 28 +++++++++++++++++----------- hack/go.mod | 2 +- hack/go.sum | 4 ++-- hack/update-codegen.sh | 2 +- pkg/aggregator/go.mod | 10 +++++----- pkg/aggregator/go.sum | 20 ++++++++++---------- pkg/apimachinery/go.mod | 4 ++-- pkg/apimachinery/go.sum | 8 ++++---- pkg/apiserver/go.mod | 10 +++++----- pkg/apiserver/go.sum | 20 ++++++++++---------- pkg/promlib/go.mod | 2 +- pkg/promlib/go.sum | 4 ++-- pkg/storage/unified/apistore/go.mod | 10 +++++----- pkg/storage/unified/apistore/go.sum | 20 ++++++++++---------- pkg/storage/unified/resource/go.mod | 4 ++-- 19 files changed, 101 insertions(+), 95 deletions(-) diff --git a/apps/playlist/go.mod b/apps/playlist/go.mod index b6dd9d23beb..0d57d7f874a 100644 --- a/apps/playlist/go.mod +++ b/apps/playlist/go.mod @@ -4,7 +4,7 @@ go 1.23.1 require ( github.com/grafana/grafana-app-sdk v0.19.0 - k8s.io/apimachinery v0.31.0 + k8s.io/apimachinery v0.31.1 k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 ) diff --git a/apps/playlist/go.sum b/apps/playlist/go.sum index ca1c932647b..57cf4a06b53 100644 --- a/apps/playlist/go.sum +++ b/apps/playlist/go.sum @@ -101,8 +101,8 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/apimachinery v0.31.0 h1:m9jOiSr3FoSSL5WO9bjm1n6B9KROYYgNZOb4tyZ1lBc= -k8s.io/apimachinery v0.31.0/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= +k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U= +k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= diff --git a/go.mod b/go.mod index 2687feeb236..7d9993fcdc4 100644 --- a/go.mod +++ b/go.mod @@ -187,13 +187,13 @@ require ( gopkg.in/ini.v1 v1.67.0 // @grafana/alerting-backend gopkg.in/mail.v2 v2.3.1 // @grafana/grafana-backend-group gopkg.in/yaml.v3 v3.0.1 // @grafana/alerting-backend - k8s.io/api v0.31.0 // @grafana/grafana-app-platform-squad - k8s.io/apimachinery v0.31.0 // @grafana/grafana-app-platform-squad - k8s.io/apiserver v0.31.0 // @grafana/grafana-app-platform-squad - k8s.io/client-go v0.31.0 // @grafana/grafana-app-platform-squad - k8s.io/component-base v0.31.0 // @grafana/grafana-app-platform-squad + k8s.io/api v0.31.1 // @grafana/grafana-app-platform-squad + k8s.io/apimachinery v0.31.1 // @grafana/grafana-app-platform-squad + k8s.io/apiserver v0.31.1 // @grafana/grafana-app-platform-squad + k8s.io/client-go v0.31.1 // @grafana/grafana-app-platform-squad + k8s.io/component-base v0.31.1 // @grafana/grafana-app-platform-squad k8s.io/klog/v2 v2.130.1 // @grafana/grafana-app-platform-squad - k8s.io/kube-aggregator v0.31.0 // @grafana/grafana-app-platform-squad + k8s.io/kube-aggregator v0.31.1 // @grafana/grafana-app-platform-squad k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // @grafana/grafana-app-platform-squad k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // @grafana/partner-datasources sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // @grafana-app-platform-squad @@ -447,7 +447,7 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - k8s.io/kms v0.31.0 // indirect + k8s.io/kms v0.31.1 // indirect modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect modernc.org/libc v1.41.0 // indirect modernc.org/mathutil v1.6.0 // indirect diff --git a/go.sum b/go.sum index 3a19b85e733..58deaa35524 100644 --- a/go.sum +++ b/go.sum @@ -4549,18 +4549,18 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= k8s.io/api v0.29.3/go.mod h1:y2yg2NTyHUUkIoTC+phinTnEa3KFM6RZ3szxt014a80= -k8s.io/api v0.31.0 h1:b9LiSjR2ym/SzTOlfMHm1tr7/21aD7fSkqgD/CVJBCo= -k8s.io/api v0.31.0/go.mod h1:0YiFF+JfFxMM6+1hQei8FY8M7s1Mth+z/q7eF1aJkTE= +k8s.io/api v0.31.1 h1:Xe1hX/fPW3PXYYv8BlozYqw63ytA92snr96zMW9gWTU= +k8s.io/api v0.31.1/go.mod h1:sbN1g6eY6XVLeqNsZGLnI5FwVseTrZX7Fv3O26rhAaI= k8s.io/apimachinery v0.29.3/go.mod h1:hx/S4V2PNW4OMg3WizRrHutyB5la0iCUbZym+W0EQIU= -k8s.io/apimachinery v0.31.0 h1:m9jOiSr3FoSSL5WO9bjm1n6B9KROYYgNZOb4tyZ1lBc= -k8s.io/apimachinery v0.31.0/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= -k8s.io/apiserver v0.31.0 h1:p+2dgJjy+bk+B1Csz+mc2wl5gHwvNkC9QJV+w55LVrY= -k8s.io/apiserver v0.31.0/go.mod h1:KI9ox5Yu902iBnnyMmy7ajonhKnkeZYJhTZ/YI+WEMk= +k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U= +k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= +k8s.io/apiserver v0.31.1 h1:Sars5ejQDCRBY5f7R3QFHdqN3s61nhkpaX8/k1iEw1c= +k8s.io/apiserver v0.31.1/go.mod h1:lzDhpeToamVZJmmFlaLwdYZwd7zB+WYRYIboqA1kGxM= k8s.io/client-go v0.29.3/go.mod h1:tkDisCvgPfiRpxGnOORfkljmS+UrW+WtXAy2fTvXJB0= -k8s.io/client-go v0.31.0 h1:QqEJzNjbN2Yv1H79SsS+SWnXkBgVu4Pj3CJQgbx0gI8= -k8s.io/client-go v0.31.0/go.mod h1:Y9wvC76g4fLjmU0BA+rV+h2cncoadjvjjkkIGoTLcGU= -k8s.io/component-base v0.31.0 h1:/KIzGM5EvPNQcYgwq5NwoQBaOlVFrghoVGr8lG6vNRs= -k8s.io/component-base v0.31.0/go.mod h1:TYVuzI1QmN4L5ItVdMSXKvH7/DtvIuas5/mm8YT3rTo= +k8s.io/client-go v0.31.1 h1:f0ugtWSbWpxHR7sjVpQwuvw9a3ZKLXX0u0itkFXufb0= +k8s.io/client-go v0.31.1/go.mod h1:sKI8871MJN2OyeqRlmA4W4KM9KBdBUpDLu/43eGemCg= +k8s.io/component-base v0.31.1 h1:UpOepcrX3rQ3ab5NB6g5iP0tvsgJWzxTyAo20sgYSy8= +k8s.io/component-base v0.31.1/go.mod h1:WGeaw7t/kTsqpVTaCoVEtillbqAhF2/JgvO0LDOMa0w= k8s.io/gengo v0.0.0-20230829151522-9cce18d56c01/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAEV2be7d5xI0vBa/VySYy3E= k8s.io/gengo/v2 v2.0.0-20240228010128-51d4e06bde70/go.mod h1:VH3AT8AaQOqiGjMF9p0/IM1Dj+82ZwjfxUP1IxaHE+8= k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I= @@ -4571,10 +4571,10 @@ k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo= k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kms v0.31.0 h1:KchILPfB1ZE+ka7223mpU5zeFNkmb45jl7RHnlImUaI= -k8s.io/kms v0.31.0/go.mod h1:OZKwl1fan3n3N5FFxnW5C4V3ygrah/3YXeJWS3O6+94= -k8s.io/kube-aggregator v0.31.0 h1:3DqSpmqHF8rey7fY+qYXLJms0tYPhxrgWvjpnKVnS0Y= -k8s.io/kube-aggregator v0.31.0/go.mod h1:Fa+OVSpMQC7zbTTz7/QG7FXe9jZ8usuJQej5sMdCrkM= +k8s.io/kms v0.31.1 h1:cGLyV3cIwb0ovpP/jtyIe2mEuQ/MkbhmeBF2IYCA9Io= +k8s.io/kms v0.31.1/go.mod h1:OZKwl1fan3n3N5FFxnW5C4V3ygrah/3YXeJWS3O6+94= +k8s.io/kube-aggregator v0.31.1 h1:vrYBTTs3xMrpiEsmBjsLETZE9uuX67oQ8B3i1BFfMPw= +k8s.io/kube-aggregator v0.31.1/go.mod h1:+aW4NX50uneozN+BtoCxI4g7ND922p8Wy3tWKFDiWVk= k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA= k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= diff --git a/go.work.sum b/go.work.sum index c0989dbc926..95e844c912a 100644 --- a/go.work.sum +++ b/go.work.sum @@ -495,8 +495,6 @@ github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8 github.com/ettle/strcase v0.1.1 h1:htFueZyVeE1XNnMEfbqp5r67qAN/4r6ya1ysq8Q+Zcw= github.com/expr-lang/expr v1.16.2 h1:JvMnzUs3LeVHBvGFcXYmXo+Q6DPDmzrlcSBO6Wy3w4s= github.com/expr-lang/expr v1.16.2/go.mod h1:uCkhfG+x7fcZ5A5sXHKuQ07jGZRl6J0FCAaf2k4PtVQ= -github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= -github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= @@ -534,6 +532,7 @@ github.com/go-kit/kit v0.13.0 h1:OoneCcHKHQ03LfBpoQCUfCluwd2Vt3ohz+kvbJneZAU= github.com/go-kit/kit v0.13.0/go.mod h1:phqEHMMUbyrCFCTgH48JueqrM3md2HcAZ8N3XE4FKDg= github.com/go-latex/latex v0.0.0-20230307184459-12ec69307ad9 h1:NxXI5pTAtpEaU49bpLpQoDsu1zrteW/vxzTz8Cd2UAs= github.com/go-latex/latex v0.0.0-20230307184459-12ec69307ad9/go.mod h1:gWuR/CrFDDeVRFQwHPvsv9soJVB/iqymhuZQuJ3a9OM= +github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.22.8/go.mod h1:6QT22icPLEqAM/z/TChgb4WAveCHF92+2gF0CNjHpPI= github.com/go-pdf/fpdf v0.6.0 h1:MlgtGIfsdMEEQJr2le6b/HNr1ZlQwxyWr77r2aj2U/8= github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= @@ -550,8 +549,6 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/ws v1.2.1 h1:F2aeBZrm2NDsc7vbovKrWSogd4wvfAxg0FQ89/iqOTk= github.com/goccmack/gocc v0.0.0-20230228185258-2292f9e40198 h1:FSii2UQeSLngl3jFoR4tUKZLprO7qUlh/TKKticc0BM= github.com/goccmack/gocc v0.0.0-20230228185258-2292f9e40198/go.mod h1:DTh/Y2+NbnOVVoypCCQrovMPDKUGp4yZpSbWg5D0XIM= -github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= -github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.11.0 h1:n7Z+zx8S9f9KgzG6KtQKf+kwqXZlLNR2F6018Dgau54= github.com/gocql/gocql v0.0.0-20190301043612-f6df8288f9b4 h1:vF83LI8tAakwEwvWZtrIEx7pOySacl2TOxx6eXk4ePo= github.com/godbus/dbus/v5 v5.0.4 h1:9349emZab16e7zQvpmsbtjc18ykshndd8y2PG3sgJbA= @@ -595,8 +592,6 @@ github.com/grafana/alerting v0.0.0-20240926233713-446ddd356f8d/go.mod h1:GMLi6d0 github.com/grafana/gomemcache v0.0.0-20240229205252-cd6a66d6fb56/go.mod h1:PGk3RjYHpxMM8HFPhKKo+vve3DdlPUELZLSDEFehPuU= github.com/grafana/prometheus-alertmanager v0.25.1-0.20240625192351-66ec17e3aa45 h1:AJKOtDKAOg8XNFnIZSmqqqutoTSxVlRs6vekL2p2KEY= github.com/grafana/prometheus-alertmanager v0.25.1-0.20240625192351-66ec17e3aa45/go.mod h1:01sXtHoRwI8W324IPAzuxDFOmALqYLCOhvSC2fUHWXc= -github.com/grafana/grafana-aws-sdk v0.31.3 h1:QlgIwyyozYYQf/dL279Baicyax+SuE99Set5chMnq1s= -github.com/grafana/grafana-aws-sdk v0.31.3/go.mod h1:5nt5Gmp6+GyM+Jr7xsXKJtbizxbYXXLmEac6kw5paQI= github.com/grafana/pyroscope-go/godeltaprof v0.1.6/go.mod h1:Tk376Nbldo4Cha9RgiU7ik8WKFkNpfds98aUzS8omLE= github.com/grafana/thema v0.0.0-20230511182720-3146087fcc26 h1:HX927q4X1n451pnGb8U0wq74i8PCzuxVjzv7TyD10kc= github.com/grafana/thema v0.0.0-20230511182720-3146087fcc26/go.mod h1:Pn9nfzCk7nV0mvNgwusgCjCROZP6nm4GpwTnmEhLT24= @@ -675,8 +670,6 @@ github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46 h1:veS9QfglfvqAw github.com/klauspost/compress v1.17.3/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/klauspost/cpuid v1.3.1 h1:5JNjFYYQrZeKRJ0734q51WCEEn2huer72Dc7K+R/b6s= github.com/klauspost/cpuid v1.3.1/go.mod h1:bYW4mA6ZgKPob1/Dlai2LviZJO7KGI3uoWLd42rAQw4= -github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= -github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/knadh/koanf v1.5.0 h1:q2TSd/3Pyc/5yP9ldIrSdIz26MCcyNQzW0pEAugLPNs= github.com/knadh/koanf v1.5.0/go.mod h1:Hgyjp4y8v44hpZtPzs7JZfRAW5AhN7KfZcwv1RYggDs= github.com/knadh/koanf/v2 v2.1.0 h1:eh4QmHHBuU8BybfIJ8mB8K8gsGCD/AUQTdwGq/GzId8= @@ -710,8 +703,6 @@ github.com/matryer/moq v0.3.3 h1:pScMH9VyrdT4S93yiLpVyU8rCDqGQr24uOyBxmktG5Q= github.com/matryer/moq v0.3.3/go.mod h1:RJ75ZZZD71hejp39j4crZLsEDszGk6iH4v4YsWFKH4s= github.com/matryer/moq v0.5.0 h1:h2PJUYjZSiyEahzVogDRmrgL9Bsx9xYAl8l+LPfmwL8= github.com/matryer/moq v0.5.0/go.mod h1:39GTnrD0mVWHPvWdYj5ki/lxfhLQEtHcLh+tWoYF/iE= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g= github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg= @@ -742,6 +733,7 @@ github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/nsf/jsondiff v0.0.0-20230430225905-43f6cf3098c1 h1:dOYG7LS/WK00RWZc8XGgcUTlTxpp3mKhdR2Q9z9HbXM= github.com/oklog/oklog v0.3.2 h1:wVfs8F+in6nTBMkA7CbRw+zZMIB7nNM825cM1wuzoTk= +github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88= github.com/open-telemetry/opentelemetry-collector-contrib/exporter/kafkaexporter v0.97.0 h1:8GH8y3Cq54Ey6He9tyhcVYLfG4TEs/7pp3s6934zNKA= github.com/open-telemetry/opentelemetry-collector-contrib/exporter/kafkaexporter v0.97.0/go.mod h1:QBHXt+tHds39B4xGyBkbOx2TST+p8JLWBiXbKKAhNss= @@ -830,7 +822,6 @@ github.com/relvacode/iso8601 v1.4.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4 h1:BN/Nyn2nWMoqGRA7G7paDNDqTXE30mXGqzzybrfo05w= github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc= -github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245 h1:K1Xf3bKttbF+koVGaX5xngRIZ5bVjbmPnaxE/dR08uY= @@ -855,6 +846,7 @@ github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0b github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad h1:fiWzISvDn0Csy5H0iwgAuJGQTUpVfEMJJd4nRFXogbc= +github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stoewer/parquet-cli v0.0.7 h1:rhdZODIbyMS3twr4OM3am8BPPT5pbfMcHLH93whDM5o= github.com/stoewer/parquet-cli v0.0.7/go.mod h1:bskxHdj8q3H1EmfuCqjViFoeO3NEvs5lzZAQvI8Nfjk= github.com/streadway/amqp v1.0.0 h1:kuuDrUJFZL1QYL9hUNuCxNObNzB0bV/ZG5jV3RWAQgo= @@ -1007,6 +999,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.23.1 h1:ZqR go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.23.1/go.mod h1:D7ynngPWlGJrqyGSDOdscuv7uqttfCE3jcBvffDv9y4= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.23.1 h1:q/Nj5/2TZRIt6PderQ9oU0M00fzoe8UZuINGw6ETGTw= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.23.1/go.mod h1:DTE9yAu6r08jU3xa68GiSeI7oRcSEQ2RpKbbQGO+dWM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0/go.mod h1:MOiCmryaYtc+V0Ei+Tx9o5S1ZjA7kzLucuVuyzBZloQ= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0/go.mod h1:QWFXnDavXWwMx2EEcZsf3yxgEKAqsxQ+Syjp+seyInw= go.opentelemetry.io/otel/exporters/prometheus v0.46.0 h1:I8WIFXR351FoLJYuloU4EgXbtNX2URfU/85pUPheIEQ= go.opentelemetry.io/otel/exporters/prometheus v0.46.0/go.mod h1:ztwVUHe5DTR/1v7PeuGRnU5Bbd4QKYwApWmuutKsJSs= @@ -1016,6 +1010,7 @@ go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0 h1:s0PHtIkN+3xrbDO go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0/go.mod h1:hZlFbDbRt++MMPCCfSJfmhkGIWnX1h3XjkfxZUjLrIA= go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s= go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg= +go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg= go.opentelemetry.io/otel/sdk/metric v1.26.0 h1:cWSks5tfriHPdWFnl+qpX3P681aAYqlZHcAyHw5aU9Y= go.opentelemetry.io/otel/sdk/metric v1.26.0/go.mod h1:ClMFFknnThJCksebJwz7KIyEDHO+nTB6gK8obLy8RyE= go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= @@ -1024,11 +1019,13 @@ go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8= go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= golang.org/x/arch v0.4.0 h1:A8WCeEWhLwPBKNbFi5Wv5UTCBx5zzubnXDlMOFAzFMc= golang.org/x/arch v0.4.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/image v0.6.0 h1:bR8b5okrPI3g/gyZakLZHeWxAR8Dn5CyxXv1hLH5g/4= golang.org/x/image v0.6.0/go.mod h1:MXLdDR43H7cDJq5GEGXEVeeNhPgi+YYEQ2pC1byI1x0= @@ -1036,7 +1033,9 @@ golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhp golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028 h1:4+4C/Iv2U4fMZBiMCc98MG1In4gJY5YRhtpDNeDeHWs= golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190921015927-1a5e07d1ff72/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211123203042-d83791d6bcd9/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= @@ -1052,6 +1051,7 @@ golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457 h1:zf5N6UOrA487eEFacMePxjXAJctxKmyjKUsjA11Uzuk= golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= @@ -1063,6 +1063,7 @@ gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0 h1:OE9mWmgKkjJyEmDAAtGMPj gonum.org/v1/plot v0.10.1 h1:dnifSs43YJuNMDzB7v8wV64O4ABBHReuAVAoBxqBqS4= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/genproto v0.0.0-20240730163845-b1a4ccb954bf/go.mod h1:mCr1K1c8kX+1iSBREvU3Juo11CB+QOEWxbRS01wWl5M= +google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157/go.mod h1:99sLkeliLXfdj2J75X3Ho+rrVCaJze0uwN7zDDkjPVU= google.golang.org/genproto/googleapis/api v0.0.0-20240604185151-ef581f913117/go.mod h1:OimBR/bc1wPO9iV4NC2bpyjy3VnAwZh5EBPQdtaE5oo= google.golang.org/genproto/googleapis/api v0.0.0-20240725223205-93522f1f2a9f/go.mod h1:AHT0dDg3SoMOgZGnZk29b5xTbPHMoEC8qthmBLJCpys= google.golang.org/genproto/googleapis/api v0.0.0-20240730163845-b1a4ccb954bf/go.mod h1:OFMYQFHJ4TM3JRlWDZhJbZfra2uqc3WLBZiaaqP4DtU= @@ -1093,10 +1094,15 @@ gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.1.3 h1:qTakTkI6ni6LFD5sBwwsdSO+AQqbSIxOauHTTQKZ/7o= howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +k8s.io/api v0.31.0/go.mod h1:0YiFF+JfFxMM6+1hQei8FY8M7s1Mth+z/q7eF1aJkTE= k8s.io/apiextensions-apiserver v0.31.0 h1:fZgCVhGwsclj3qCw1buVXCV6khjRzKC5eCFt24kyLSk= k8s.io/apiextensions-apiserver v0.31.0/go.mod h1:b9aMDEYaEe5sdK+1T0KU78ApR/5ZVp4i56VacZYEHxk= +k8s.io/client-go v0.31.0/go.mod h1:Y9wvC76g4fLjmU0BA+rV+h2cncoadjvjjkkIGoTLcGU= k8s.io/code-generator v0.31.0 h1:w607nrMi1KeDKB3/F/J4lIoOgAwc+gV9ZKew4XRfMp8= k8s.io/code-generator v0.31.0/go.mod h1:84y4w3es8rOJOUUP1rLsIiGlO1JuEaPFXQPA9e/K6U0= +k8s.io/code-generator v0.31.1 h1:GvkRZEP2g2UnB2QKT2Dgc/kYxIkDxCHENv2Q1itioVs= +k8s.io/code-generator v0.31.1/go.mod h1:oL2ky46L48osNqqZAeOcWWy0S5BXj50vVdwOtTefqIs= +k8s.io/component-base v0.31.0/go.mod h1:TYVuzI1QmN4L5ItVdMSXKvH7/DtvIuas5/mm8YT3rTo= k8s.io/gengo v0.0.0-20230829151522-9cce18d56c01 h1:pWEwq4Asjm4vjW7vcsmijwBhOr1/shsbSYiWXmNGlks= k8s.io/gengo/v2 v2.0.0-20240228010128-51d4e06bde70 h1:NGrVE502P0s0/1hudf8zjgwki1X/TByhmAoILTarmzo= k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8= diff --git a/hack/go.mod b/hack/go.mod index e85ea1da8b9..c575e4af57e 100644 --- a/hack/go.mod +++ b/hack/go.mod @@ -2,7 +2,7 @@ module github.com/grafana/grafana/hack go 1.23.1 -require k8s.io/code-generator v0.31.0 +require k8s.io/code-generator v0.31.1 require ( github.com/go-logr/logr v1.4.2 // indirect diff --git a/hack/go.sum b/hack/go.sum index bb36abd861f..5bda2acd34d 100644 --- a/hack/go.sum +++ b/hack/go.sum @@ -10,8 +10,8 @@ golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -k8s.io/code-generator v0.31.0 h1:w607nrMi1KeDKB3/F/J4lIoOgAwc+gV9ZKew4XRfMp8= -k8s.io/code-generator v0.31.0/go.mod h1:84y4w3es8rOJOUUP1rLsIiGlO1JuEaPFXQPA9e/K6U0= +k8s.io/code-generator v0.31.1 h1:GvkRZEP2g2UnB2QKT2Dgc/kYxIkDxCHENv2Q1itioVs= +k8s.io/code-generator v0.31.1/go.mod h1:oL2ky46L48osNqqZAeOcWWy0S5BXj50vVdwOtTefqIs= k8s.io/gengo/v2 v2.0.0-20240228010128-51d4e06bde70 h1:NGrVE502P0s0/1hudf8zjgwki1X/TByhmAoILTarmzo= k8s.io/gengo/v2 v2.0.0-20240228010128-51d4e06bde70/go.mod h1:VH3AT8AaQOqiGjMF9p0/IM1Dj+82ZwjfxUP1IxaHE+8= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= diff --git a/hack/update-codegen.sh b/hack/update-codegen.sh index 54e1efcef46..9227edad964 100755 --- a/hack/update-codegen.sh +++ b/hack/update-codegen.sh @@ -11,7 +11,7 @@ set -o pipefail SCRIPT_ROOT=$(dirname "${BASH_SOURCE[0]}")/.. pushd "${SCRIPT_ROOT}/hack" && GO111MODULE=on go mod tidy && popd -CODEGEN_PKG=${CODEGEN_PKG:-$(cd "${SCRIPT_ROOT}"; ls -d -1 ./vendor/k8s.io/code-generator 2>/dev/null || echo $(go env GOPATH)/pkg/mod/k8s.io/code-generator@v0.31.0)} +CODEGEN_PKG=${CODEGEN_PKG:-$(cd "${SCRIPT_ROOT}"; ls -d -1 ./vendor/k8s.io/code-generator 2>/dev/null || echo $(go env GOPATH)/pkg/mod/k8s.io/code-generator@v0.31.1)} OUTDIR="${HOME}/go/src" OPENAPI_VIOLATION_EXCEPTIONS_FILENAME="zz_generated.openapi_violation_exceptions.list" diff --git a/pkg/aggregator/go.mod b/pkg/aggregator/go.mod index c57a765062d..fd0a44d51e4 100644 --- a/pkg/aggregator/go.mod +++ b/pkg/aggregator/go.mod @@ -10,11 +10,11 @@ require ( github.com/mattbaird/jsonpatch v0.0.0-20240118010651-0ba75a80ca38 github.com/stretchr/testify v1.9.0 go.opentelemetry.io/otel v1.30.0 - k8s.io/api v0.31.0 - k8s.io/apimachinery v0.31.0 - k8s.io/apiserver v0.31.0 - k8s.io/client-go v0.31.0 - k8s.io/component-base v0.31.0 + k8s.io/api v0.31.1 + k8s.io/apimachinery v0.31.1 + k8s.io/apiserver v0.31.1 + k8s.io/client-go v0.31.1 + k8s.io/component-base v0.31.1 k8s.io/klog/v2 v2.130.1 k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 sigs.k8s.io/structured-merge-diff/v4 v4.4.1 diff --git a/pkg/aggregator/go.sum b/pkg/aggregator/go.sum index 99753ff55f8..c52f20d6294 100644 --- a/pkg/aggregator/go.sum +++ b/pkg/aggregator/go.sum @@ -508,16 +508,16 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -k8s.io/api v0.31.0 h1:b9LiSjR2ym/SzTOlfMHm1tr7/21aD7fSkqgD/CVJBCo= -k8s.io/api v0.31.0/go.mod h1:0YiFF+JfFxMM6+1hQei8FY8M7s1Mth+z/q7eF1aJkTE= -k8s.io/apimachinery v0.31.0 h1:m9jOiSr3FoSSL5WO9bjm1n6B9KROYYgNZOb4tyZ1lBc= -k8s.io/apimachinery v0.31.0/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= -k8s.io/apiserver v0.31.0 h1:p+2dgJjy+bk+B1Csz+mc2wl5gHwvNkC9QJV+w55LVrY= -k8s.io/apiserver v0.31.0/go.mod h1:KI9ox5Yu902iBnnyMmy7ajonhKnkeZYJhTZ/YI+WEMk= -k8s.io/client-go v0.31.0 h1:QqEJzNjbN2Yv1H79SsS+SWnXkBgVu4Pj3CJQgbx0gI8= -k8s.io/client-go v0.31.0/go.mod h1:Y9wvC76g4fLjmU0BA+rV+h2cncoadjvjjkkIGoTLcGU= -k8s.io/component-base v0.31.0 h1:/KIzGM5EvPNQcYgwq5NwoQBaOlVFrghoVGr8lG6vNRs= -k8s.io/component-base v0.31.0/go.mod h1:TYVuzI1QmN4L5ItVdMSXKvH7/DtvIuas5/mm8YT3rTo= +k8s.io/api v0.31.1 h1:Xe1hX/fPW3PXYYv8BlozYqw63ytA92snr96zMW9gWTU= +k8s.io/api v0.31.1/go.mod h1:sbN1g6eY6XVLeqNsZGLnI5FwVseTrZX7Fv3O26rhAaI= +k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U= +k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= +k8s.io/apiserver v0.31.1 h1:Sars5ejQDCRBY5f7R3QFHdqN3s61nhkpaX8/k1iEw1c= +k8s.io/apiserver v0.31.1/go.mod h1:lzDhpeToamVZJmmFlaLwdYZwd7zB+WYRYIboqA1kGxM= +k8s.io/client-go v0.31.1 h1:f0ugtWSbWpxHR7sjVpQwuvw9a3ZKLXX0u0itkFXufb0= +k8s.io/client-go v0.31.1/go.mod h1:sKI8871MJN2OyeqRlmA4W4KM9KBdBUpDLu/43eGemCg= +k8s.io/component-base v0.31.1 h1:UpOepcrX3rQ3ab5NB6g5iP0tvsgJWzxTyAo20sgYSy8= +k8s.io/component-base v0.31.1/go.mod h1:WGeaw7t/kTsqpVTaCoVEtillbqAhF2/JgvO0LDOMa0w= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= diff --git a/pkg/apimachinery/go.mod b/pkg/apimachinery/go.mod index 22e1be6cc40..481dbeb1b39 100644 --- a/pkg/apimachinery/go.mod +++ b/pkg/apimachinery/go.mod @@ -6,8 +6,8 @@ require ( github.com/grafana/authlib v0.0.0-20240919120951-58259833c564 // @grafana/identity-access-team github.com/grafana/authlib/claims v0.0.0-20240903121118-16441568af1e // @grafana/identity-access-team github.com/stretchr/testify v1.9.0 - k8s.io/apimachinery v0.31.0 - k8s.io/apiserver v0.31.0 + k8s.io/apimachinery v0.31.1 + k8s.io/apiserver v0.31.1 k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 ) diff --git a/pkg/apimachinery/go.sum b/pkg/apimachinery/go.sum index fbd7b2d0aba..3e4397d878d 100644 --- a/pkg/apimachinery/go.sum +++ b/pkg/apimachinery/go.sum @@ -152,10 +152,10 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/apimachinery v0.31.0 h1:m9jOiSr3FoSSL5WO9bjm1n6B9KROYYgNZOb4tyZ1lBc= -k8s.io/apimachinery v0.31.0/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= -k8s.io/apiserver v0.31.0 h1:p+2dgJjy+bk+B1Csz+mc2wl5gHwvNkC9QJV+w55LVrY= -k8s.io/apiserver v0.31.0/go.mod h1:KI9ox5Yu902iBnnyMmy7ajonhKnkeZYJhTZ/YI+WEMk= +k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U= +k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= +k8s.io/apiserver v0.31.1 h1:Sars5ejQDCRBY5f7R3QFHdqN3s61nhkpaX8/k1iEw1c= +k8s.io/apiserver v0.31.1/go.mod h1:lzDhpeToamVZJmmFlaLwdYZwd7zB+WYRYIboqA1kGxM= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= diff --git a/pkg/apiserver/go.mod b/pkg/apiserver/go.mod index df18223a271..4ec79bb6de8 100644 --- a/pkg/apiserver/go.mod +++ b/pkg/apiserver/go.mod @@ -11,9 +11,9 @@ require ( go.opentelemetry.io/contrib/propagators/jaeger v1.30.0 go.opentelemetry.io/otel v1.30.0 go.opentelemetry.io/otel/trace v1.30.0 - k8s.io/apimachinery v0.31.0 - k8s.io/apiserver v0.31.0 - k8s.io/component-base v0.31.0 + k8s.io/apimachinery v0.31.1 + k8s.io/apiserver v0.31.1 + k8s.io/component-base v0.31.1 k8s.io/klog/v2 v2.130.1 k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 sigs.k8s.io/structured-merge-diff/v4 v4.4.1 @@ -89,8 +89,8 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.31.0 // indirect - k8s.io/client-go v0.31.0 // indirect + k8s.io/api v0.31.1 // indirect + k8s.io/client-go v0.31.1 // indirect k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect diff --git a/pkg/apiserver/go.sum b/pkg/apiserver/go.sum index 49e6ba493c6..ca022eacc5f 100644 --- a/pkg/apiserver/go.sum +++ b/pkg/apiserver/go.sum @@ -325,16 +325,16 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -k8s.io/api v0.31.0 h1:b9LiSjR2ym/SzTOlfMHm1tr7/21aD7fSkqgD/CVJBCo= -k8s.io/api v0.31.0/go.mod h1:0YiFF+JfFxMM6+1hQei8FY8M7s1Mth+z/q7eF1aJkTE= -k8s.io/apimachinery v0.31.0 h1:m9jOiSr3FoSSL5WO9bjm1n6B9KROYYgNZOb4tyZ1lBc= -k8s.io/apimachinery v0.31.0/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= -k8s.io/apiserver v0.31.0 h1:p+2dgJjy+bk+B1Csz+mc2wl5gHwvNkC9QJV+w55LVrY= -k8s.io/apiserver v0.31.0/go.mod h1:KI9ox5Yu902iBnnyMmy7ajonhKnkeZYJhTZ/YI+WEMk= -k8s.io/client-go v0.31.0 h1:QqEJzNjbN2Yv1H79SsS+SWnXkBgVu4Pj3CJQgbx0gI8= -k8s.io/client-go v0.31.0/go.mod h1:Y9wvC76g4fLjmU0BA+rV+h2cncoadjvjjkkIGoTLcGU= -k8s.io/component-base v0.31.0 h1:/KIzGM5EvPNQcYgwq5NwoQBaOlVFrghoVGr8lG6vNRs= -k8s.io/component-base v0.31.0/go.mod h1:TYVuzI1QmN4L5ItVdMSXKvH7/DtvIuas5/mm8YT3rTo= +k8s.io/api v0.31.1 h1:Xe1hX/fPW3PXYYv8BlozYqw63ytA92snr96zMW9gWTU= +k8s.io/api v0.31.1/go.mod h1:sbN1g6eY6XVLeqNsZGLnI5FwVseTrZX7Fv3O26rhAaI= +k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U= +k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= +k8s.io/apiserver v0.31.1 h1:Sars5ejQDCRBY5f7R3QFHdqN3s61nhkpaX8/k1iEw1c= +k8s.io/apiserver v0.31.1/go.mod h1:lzDhpeToamVZJmmFlaLwdYZwd7zB+WYRYIboqA1kGxM= +k8s.io/client-go v0.31.1 h1:f0ugtWSbWpxHR7sjVpQwuvw9a3ZKLXX0u0itkFXufb0= +k8s.io/client-go v0.31.1/go.mod h1:sKI8871MJN2OyeqRlmA4W4KM9KBdBUpDLu/43eGemCg= +k8s.io/component-base v0.31.1 h1:UpOepcrX3rQ3ab5NB6g5iP0tvsgJWzxTyAo20sgYSy8= +k8s.io/component-base v0.31.1/go.mod h1:WGeaw7t/kTsqpVTaCoVEtillbqAhF2/JgvO0LDOMa0w= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= diff --git a/pkg/promlib/go.mod b/pkg/promlib/go.mod index aaf7c74f960..8d79b5ff460 100644 --- a/pkg/promlib/go.mod +++ b/pkg/promlib/go.mod @@ -14,7 +14,7 @@ require ( go.opentelemetry.io/otel v1.30.0 go.opentelemetry.io/otel/trace v1.30.0 golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa - k8s.io/apimachinery v0.31.0 + k8s.io/apimachinery v0.31.1 ) require ( diff --git a/pkg/promlib/go.sum b/pkg/promlib/go.sum index 62dd38a48aa..2d878a7363f 100644 --- a/pkg/promlib/go.sum +++ b/pkg/promlib/go.sum @@ -373,8 +373,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/apimachinery v0.31.0 h1:m9jOiSr3FoSSL5WO9bjm1n6B9KROYYgNZOb4tyZ1lBc= -k8s.io/apimachinery v0.31.0/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= +k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U= +k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= diff --git a/pkg/storage/unified/apistore/go.mod b/pkg/storage/unified/apistore/go.mod index bf5c9e63192..7a45cb831b0 100644 --- a/pkg/storage/unified/apistore/go.mod +++ b/pkg/storage/unified/apistore/go.mod @@ -10,9 +10,9 @@ require ( github.com/stretchr/testify v1.9.0 gocloud.dev v0.39.0 google.golang.org/grpc v1.66.0 - k8s.io/apimachinery v0.31.0 - k8s.io/apiserver v0.31.0 - k8s.io/client-go v0.31.0 + k8s.io/apimachinery v0.31.1 + k8s.io/apiserver v0.31.1 + k8s.io/client-go v0.31.1 k8s.io/klog/v2 v2.130.1 ) @@ -98,8 +98,8 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.31.0 // indirect - k8s.io/component-base v0.31.0 // indirect + k8s.io/api v0.31.1 // indirect + k8s.io/component-base v0.31.1 // indirect k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3 // indirect diff --git a/pkg/storage/unified/apistore/go.sum b/pkg/storage/unified/apistore/go.sum index ed1f647957c..4a23793f0a1 100644 --- a/pkg/storage/unified/apistore/go.sum +++ b/pkg/storage/unified/apistore/go.sum @@ -467,16 +467,16 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -k8s.io/api v0.31.0 h1:b9LiSjR2ym/SzTOlfMHm1tr7/21aD7fSkqgD/CVJBCo= -k8s.io/api v0.31.0/go.mod h1:0YiFF+JfFxMM6+1hQei8FY8M7s1Mth+z/q7eF1aJkTE= -k8s.io/apimachinery v0.31.0 h1:m9jOiSr3FoSSL5WO9bjm1n6B9KROYYgNZOb4tyZ1lBc= -k8s.io/apimachinery v0.31.0/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= -k8s.io/apiserver v0.31.0 h1:p+2dgJjy+bk+B1Csz+mc2wl5gHwvNkC9QJV+w55LVrY= -k8s.io/apiserver v0.31.0/go.mod h1:KI9ox5Yu902iBnnyMmy7ajonhKnkeZYJhTZ/YI+WEMk= -k8s.io/client-go v0.31.0 h1:QqEJzNjbN2Yv1H79SsS+SWnXkBgVu4Pj3CJQgbx0gI8= -k8s.io/client-go v0.31.0/go.mod h1:Y9wvC76g4fLjmU0BA+rV+h2cncoadjvjjkkIGoTLcGU= -k8s.io/component-base v0.31.0 h1:/KIzGM5EvPNQcYgwq5NwoQBaOlVFrghoVGr8lG6vNRs= -k8s.io/component-base v0.31.0/go.mod h1:TYVuzI1QmN4L5ItVdMSXKvH7/DtvIuas5/mm8YT3rTo= +k8s.io/api v0.31.1 h1:Xe1hX/fPW3PXYYv8BlozYqw63ytA92snr96zMW9gWTU= +k8s.io/api v0.31.1/go.mod h1:sbN1g6eY6XVLeqNsZGLnI5FwVseTrZX7Fv3O26rhAaI= +k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U= +k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= +k8s.io/apiserver v0.31.1 h1:Sars5ejQDCRBY5f7R3QFHdqN3s61nhkpaX8/k1iEw1c= +k8s.io/apiserver v0.31.1/go.mod h1:lzDhpeToamVZJmmFlaLwdYZwd7zB+WYRYIboqA1kGxM= +k8s.io/client-go v0.31.1 h1:f0ugtWSbWpxHR7sjVpQwuvw9a3ZKLXX0u0itkFXufb0= +k8s.io/client-go v0.31.1/go.mod h1:sKI8871MJN2OyeqRlmA4W4KM9KBdBUpDLu/43eGemCg= +k8s.io/component-base v0.31.1 h1:UpOepcrX3rQ3ab5NB6g5iP0tvsgJWzxTyAo20sgYSy8= +k8s.io/component-base v0.31.1/go.mod h1:WGeaw7t/kTsqpVTaCoVEtillbqAhF2/JgvO0LDOMa0w= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= diff --git a/pkg/storage/unified/resource/go.mod b/pkg/storage/unified/resource/go.mod index 867a2154438..ac411a5fcd9 100644 --- a/pkg/storage/unified/resource/go.mod +++ b/pkg/storage/unified/resource/go.mod @@ -14,7 +14,7 @@ require ( gocloud.dev v0.39.0 google.golang.org/grpc v1.66.0 google.golang.org/protobuf v1.34.2 - k8s.io/apimachinery v0.31.0 + k8s.io/apimachinery v0.31.1 ) require ( @@ -56,7 +56,7 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiserver v0.31.0 // indirect + k8s.io/apiserver v0.31.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect From 2ea662854a23f378de7602a865d56ddb67f5512f Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Thu, 3 Oct 2024 16:24:39 -0600 Subject: [PATCH 06/16] QueryLibrary: Don't use description when generating name (#94236) --- public/app/features/query-library/api/mappers.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/public/app/features/query-library/api/mappers.ts b/public/app/features/query-library/api/mappers.ts index 6c0af6c8539..9dfb2fe4439 100644 --- a/public/app/features/query-library/api/mappers.ts +++ b/public/app/features/query-library/api/mappers.ts @@ -1,3 +1,5 @@ +import { v4 as uuidv4 } from 'uuid'; + import { AddQueryTemplateCommand, QueryTemplate } from '../types'; import { API_VERSION, QueryTemplateKinds } from './query'; @@ -48,7 +50,11 @@ export const convertAddQueryTemplateCommandToDataQuerySpec = ( apiVersion: API_VERSION, kind: QueryTemplateKinds.QueryTemplate, metadata: { - generateName: 'A' + title.replaceAll(' ', '-'), + /** + * Server will append to whatever is passed here, but just to be safe we generate a uuid + * More info https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#idempotency + */ + generateName: uuidv4(), }, spec: { title: title, From 7188c13d2251fc33b771728dd0cd984a2ed6a416 Mon Sep 17 00:00:00 2001 From: Nathan Marrs Date: Thu, 3 Oct 2024 16:52:19 -0600 Subject: [PATCH 07/16] ElasticSearch: Add keywords to plugin.json (#94174) * add keywords * add additional keywords --- .../api/plugins/data/expectedListResp.json | 58 ++++++++++++++++++- .../datasource/elasticsearch/plugin.json | 12 ++-- 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/pkg/tests/api/plugins/data/expectedListResp.json b/pkg/tests/api/plugins/data/expectedListResp.json index e2a4e1ccafb..f83e6110626 100644 --- a/pkg/tests/api/plugins/data/expectedListResp.json +++ b/pkg/tests/api/plugins/data/expectedListResp.json @@ -18,6 +18,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -60,6 +61,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": [ "alerts", @@ -104,6 +106,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -163,6 +166,7 @@ "path": "public/app/plugins/datasource/azuremonitor/img/azure_monitor_cpu.png" } ], + "version": "11.3.0-pre", "updated": "", "keywords": [ "azure", @@ -206,6 +210,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -243,6 +248,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -280,6 +286,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": [ "financial", @@ -322,6 +329,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -359,6 +367,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": [ "aws", @@ -399,6 +408,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -436,6 +446,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -478,9 +489,15 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": [ - "elasticsearch" + "elasticsearch", + "datasource", + "database", + "logs", + "nosql", + "traces" ] }, "dependencies": { @@ -517,6 +534,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -554,6 +572,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -591,6 +610,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -628,6 +648,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -665,6 +686,7 @@ }, "build": {}, "screenshots": null, + "version": "11.3.0-pre", "updated": "", "keywords": null }, @@ -707,6 +729,7 @@ }, "build": {}, "screenshots": null, + "version": "11.3.0-pre", "updated": "", "keywords": [ "grafana", @@ -752,6 +775,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -798,6 +822,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -835,6 +860,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -872,6 +898,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": [ "distribution", @@ -914,6 +941,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -960,6 +988,7 @@ }, "build": {}, "screenshots": null, + "version": "11.3.0-pre", "updated": "", "keywords": null }, @@ -997,6 +1026,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -1043,6 +1073,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -1080,6 +1111,7 @@ }, "build": {}, "screenshots": null, + "version": "11.3.0-pre", "updated": "", "keywords": null }, @@ -1117,11 +1149,12 @@ }, "build": {}, "screenshots": null, + "version": "11.3.0-pre", "updated": "", "keywords": null }, "dependencies": { - "grafanaDependency": ">=10.4.0", + "grafanaDependency": "\u003e=10.4.0", "grafanaVersion": "*", "plugins": [] }, @@ -1154,6 +1187,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -1191,6 +1225,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -1228,6 +1263,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -1270,6 +1306,7 @@ }, "build": {}, "screenshots": null, + "version": "11.3.0-pre", "updated": "", "keywords": [ "grafana", @@ -1312,6 +1349,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -1349,6 +1387,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -1391,6 +1430,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -1428,6 +1468,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -1465,6 +1506,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -1502,6 +1544,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -1539,6 +1582,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -1576,6 +1620,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -1618,6 +1663,7 @@ }, "build": {}, "screenshots": null, + "version": "11.3.0-pre", "updated": "", "keywords": null }, @@ -1655,6 +1701,7 @@ }, "build": {}, "screenshots": null, + "version": "11.3.0-pre", "updated": "", "keywords": null }, @@ -1692,6 +1739,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -1729,6 +1777,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -1766,6 +1815,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -1803,6 +1853,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -1840,6 +1891,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": null }, @@ -1877,6 +1929,7 @@ }, "build": {}, "screenshots": null, + "version": "", "updated": "", "keywords": [ "scatter", @@ -1922,6 +1975,7 @@ }, "build": {}, "screenshots": null, + "version": "11.3.0-pre", "updated": "", "keywords": null }, diff --git a/public/app/plugins/datasource/elasticsearch/plugin.json b/public/app/plugins/datasource/elasticsearch/plugin.json index 5d1d43c4c84..96b0d4323fa 100644 --- a/public/app/plugins/datasource/elasticsearch/plugin.json +++ b/public/app/plugins/datasource/elasticsearch/plugin.json @@ -3,27 +3,29 @@ "name": "Elasticsearch", "id": "elasticsearch", "category": "logging", - "info": { "description": "Open source logging & analytics database", "author": { "name": "Grafana Labs", "url": "https://grafana.com" }, - "keywords": ["elasticsearch"], + "keywords": ["elasticsearch", "datasource", "database", "logs", "nosql", "traces"], "logos": { "small": "img/elasticsearch.svg", "large": "img/elasticsearch.svg" }, - "links": [{ "name": "Learn more", "url": "https://grafana.com/docs/features/datasources/elasticsearch/" }] + "links": [ + { + "name": "Learn more", + "url": "https://grafana.com/docs/features/datasources/elasticsearch/" + } + ] }, - "alerting": true, "annotations": true, "metrics": true, "logs": true, "backend": true, - "queryOptions": { "minInterval": true } From 6096f4677483e433644b40e93f6cce3c1f3482e4 Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Fri, 4 Oct 2024 08:41:26 +0200 Subject: [PATCH 08/16] Plugin Extensions: Require meta-data to be defined in `plugin.json` during development mode (#93429) * feat: add extensions to the backend plugin model * feat: update the frontend plugin types * feat(pluginContext): return a `null` if there is no context found This will be necessary to understand if a certain hook is running inside a plugin context or not. * feat: add utility functions for checking extension configs * tests: fix failing tests due to the type updates * feat(AddedComponentsRegistry): validate plugin meta-info * feat(AddedLinksRegistry): validate meta-info * feat(ExposedComponentsRegistry): validate meta-info * feat(usePluginComponent): add meta-info validation * feat(usePluginComponents): add meta-info validation * feat(usePluginLinks): add meta-info validation * fix: only validate meta-info in registries if dev mode is enabled * tests: add unit tests for the restrictions functionality * tests: fix Go tests * fix(tests): revert accidental changes * fix: run goimports * fix: api tests * add nested app so that meta data can bested e2e tested * refactor(types): extract the ExtensionInfo into a separate type * refactor(extensions/utils): use Array.prototype.some() instead of .find() * refactor(usePluginLinks): update warning message * feat(usePluginExtensions()): validate plugin meta-info * Wip * fix(e2e): E2E tests for extensions * fix(extensions): allow multiple "/" slashes in the extension point id * fix(extensions/validators): stop validating the plugin id pattern --------- Co-authored-by: Erik Sundell --- devenv/plugins.yaml | 4 + .../pages/AddedComponents.tsx | 2 +- .../pages/LegacyGetters.tsx | 2 +- .../pages/LegacyHooks.tsx | 2 +- .../grafana-extensionstest-app/plugin.json | 37 +- .../grafana-extensionexample1-app/plugin.json | 24 + .../grafana-extensionexample2-app/module.tsx | 4 +- .../grafana-extensionexample2-app/plugin.json | 24 + .../components/App/AddedLinks.tsx | 24 + .../components/App/App.tsx | 15 + .../img/logo.svg | 1 + .../grafana-extensionexample3-app/module.tsx | 44 ++ .../grafana-extensionexample3-app/plugin.json | 35 ++ .../grafana-extensionstest-app/testIds.ts | 5 + .../legacy/extensionPoints.hooks.spec.ts | 22 + .../tests/usePluginLinks.spec.ts | 9 + .../src/context/plugins/usePluginContext.tsx | 8 +- packages/grafana-data/src/index.ts | 1 + packages/grafana-data/src/types/plugin.ts | 33 ++ .../plugins/usePluginInteractionReporter.ts | 7 + packages/grafana-runtime/src/config.ts | 4 + pkg/api/dtos/plugins.go | 1 + pkg/api/frontendsettings.go | 2 + pkg/api/plugins.go | 1 + .../manager/loader/finder/local_test.go | 72 +++ pkg/plugins/manager/loader/loader_test.go | 54 ++ pkg/plugins/models.go | 100 +++- pkg/plugins/plugins.go | 23 +- pkg/plugins/plugins_test.go | 274 +++++++++++ .../pluginsintegration/loader/loader_test.go | 160 +++++- .../api/plugins/data/expectedListResp.json | 245 +++++++-- .../unified/mocks/server/handlers/plugins.ts | 13 + .../alerting/unified/utils/rules.test.ts | 13 + .../admin/__mocks__/remotePlugin.mock.ts | 3 + .../registry/AddedComponentsRegistry.test.ts | 250 +++++++--- .../registry/AddedComponentsRegistry.ts | 22 +- .../registry/AddedLinksRegistry.test.ts | 153 +++++- .../extensions/registry/AddedLinksRegistry.ts | 16 +- .../ExportedComponentsRegistry.test.ts | 148 ++++++ .../registry/ExposedComponentsRegistry.ts | 6 +- .../extensions/usePluginComponent.test.tsx | 250 ++++++++-- .../plugins/extensions/usePluginComponent.tsx | 21 +- .../extensions/usePluginComponents.test.tsx | 266 +++++++++- .../extensions/usePluginComponents.tsx | 29 +- .../extensions/usePluginExtensions.test.tsx | 22 +- .../extensions/usePluginExtensions.tsx | 43 +- .../extensions/usePluginLinks.test.tsx | 236 ++++++++- .../plugins/extensions/usePluginLinks.tsx | 33 +- .../plugins/extensions/utils.test.tsx | 463 +++++++++++++++++- .../app/features/plugins/extensions/utils.tsx | 147 +++++- .../plugins/extensions/validators.test.tsx | 47 ++ .../features/plugins/extensions/validators.ts | 18 +- scripts/grafana-server/custom.ini | 2 +- 53 files changed, 3197 insertions(+), 243 deletions(-) create mode 100644 e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/components/App/AddedLinks.tsx create mode 100644 e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/components/App/App.tsx create mode 100644 e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/img/logo.svg create mode 100644 e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/module.tsx create mode 100644 e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/plugin.json diff --git a/devenv/plugins.yaml b/devenv/plugins.yaml index 7abef7b8789..554a4828cff 100644 --- a/devenv/plugins.yaml +++ b/devenv/plugins.yaml @@ -17,3 +17,7 @@ apps: org_id: 1 org_name: Main Org. disabled: false + - type: grafana-extensionexample3-app + org_id: 1 + org_name: Main Org. + disabled: false diff --git a/e2e/test-plugins/grafana-extensionstest-app/pages/AddedComponents.tsx b/e2e/test-plugins/grafana-extensionstest-app/pages/AddedComponents.tsx index b7f3242f3a6..6e8d705d0dd 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/pages/AddedComponents.tsx +++ b/e2e/test-plugins/grafana-extensionstest-app/pages/AddedComponents.tsx @@ -9,7 +9,7 @@ type ReusableComponentProps = { export function AddedComponents() { const { components } = usePluginComponents({ - extensionPointId: 'plugins/grafana-extensionexample2-app/addComponent/v1', + extensionPointId: 'plugins/grafana-extensionstest-app/addComponent/v1', }); return ( diff --git a/e2e/test-plugins/grafana-extensionstest-app/pages/LegacyGetters.tsx b/e2e/test-plugins/grafana-extensionstest-app/pages/LegacyGetters.tsx index 910eed92e5b..5cebc74d744 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/pages/LegacyGetters.tsx +++ b/e2e/test-plugins/grafana-extensionstest-app/pages/LegacyGetters.tsx @@ -16,7 +16,7 @@ type ReusableComponentProps = { export function LegacyGetters() { const extensionPointId1 = 'plugins/grafana-extensionstest-app/actions'; - const extensionPointId2 = 'plugins/grafana-extensionexample2-app/configure-extension-component/v1'; + const extensionPointId2 = 'plugins/grafana-extensionstest-app/configure-extension-component/v1'; const context: AppExtensionContext = {}; const { extensions } = getPluginExtensions({ diff --git a/e2e/test-plugins/grafana-extensionstest-app/pages/LegacyHooks.tsx b/e2e/test-plugins/grafana-extensionstest-app/pages/LegacyHooks.tsx index ab963f07169..ef63caf9244 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/pages/LegacyHooks.tsx +++ b/e2e/test-plugins/grafana-extensionstest-app/pages/LegacyHooks.tsx @@ -16,7 +16,7 @@ type ReusableComponentProps = { export function LegacyHooks() { const extensionPointId1 = 'plugins/grafana-extensionstest-app/actions'; - const extensionPointId2 = 'plugins/grafana-extensionexample2-app/configure-extension-component/v1'; + const extensionPointId2 = 'plugins/grafana-extensionstest-app/configure-extension-component/v1'; const context: AppExtensionContext = {}; const { extensions } = usePluginExtensions({ diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugin.json b/e2e/test-plugins/grafana-extensionstest-app/plugin.json index 0924f5aad10..39252838461 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/plugin.json +++ b/e2e/test-plugins/grafana-extensionstest-app/plugin.json @@ -60,8 +60,43 @@ "defaultNav": false } ], + "extensions": { + "addedLinks": [ + { + "targets": ["grafana/dashboard/panel/menu"], + "title": "Open from time series or pie charts (path)", + "description": "This link will only be visible on time series and pie charts" + }, + { + "targets": ["grafana/dashboard/panel/menu"], + "title": "Open from time series or pie charts (onClick)", + "description": "This link will only be visible on time series and pie charts" + } + ], + "extensionPoints": [ + { + "id": "plugins/grafana-extensionstest-app/use-plugin-links/v1", + "title": "Extension point - links" + }, + { + "id": "plugins/grafana-extensionstest-app/addComponent/v1", + "title": "Extension point - components" + }, + { + "id": "plugins/grafana-extensionstest-app/actions", + "title": "Legacy extension point - usePluginExtensions() and usePluginLinkExtensions()" + }, + { + "id": "plugins/grafana-extensionstest-app/configure-extension-component/v1", + "title": "Legacy extension point - usePluginComponentExtensions()" + } + ] + }, "dependencies": { "grafanaDependency": ">=10.4.0", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": ["grafana-extensionexample1-app/reusable-component/v1"] + } } } diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample1-app/plugin.json b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample1-app/plugin.json index 3d012351bbe..bb40c05b9f2 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample1-app/plugin.json +++ b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample1-app/plugin.json @@ -28,6 +28,30 @@ "defaultNav": false } ], + "extensions": { + "exposedComponents": [ + { + "id": "grafana-extensionexample1-app/reusable-component/v1", + "title": "Exposed component", + "description": "A component that can be reused by other app plugins." + } + ], + "addedLinks": [ + { + "targets": [ + "plugins/grafana-extensionstest-app/actions", + "plugins/grafana-extensionstest-app/use-plugin-links/v1" + ], + "title": "Go to A", + "description": "Navigating to pluging A" + }, + { + "targets": ["plugins/grafana-extensionstest-app/use-plugin-links/v1"], + "title": "Basic link", + "description": "..." + } + ] + }, "dependencies": { "grafanaDependency": ">=10.3.3", "plugins": [] diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/module.tsx b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/module.tsx index 071370cf592..5a75974e1ed 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/module.tsx +++ b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/module.tsx @@ -19,13 +19,13 @@ export const plugin = new AppPlugin<{}>() }, }) .configureExtensionComponent({ - extensionPointId: 'plugins/grafana-extensionexample2-app/configure-extension-component/v1', + extensionPointId: 'plugins/grafana-extensionstest-app/configure-extension-component/v1', title: 'Configure extension component from B', description: 'A component that can be reused by other app plugins. Shared using configureExtensionComponent api', component: ({ name }: { name: string }) =>
Hello {name}!
, }) .addComponent<{ name: string }>({ - targets: 'plugins/grafana-extensionexample2-app/addComponent/v1', + targets: 'plugins/grafana-extensionstest-app/addComponent/v1', title: 'Added component from B', description: 'A component that can be reused by other app plugins. Shared using addComponent api', component: ({ name }: { name: string }) => ( diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/plugin.json b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/plugin.json index f27f9e1113b..9585ba8761e 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/plugin.json +++ b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/plugin.json @@ -18,6 +18,30 @@ "version": "%VERSION%", "updated": "%TODAY%" }, + "extensions": { + "addedLinks": [ + { + "targets": [ + "plugins/grafana-extensionstest-app/actions", + "plugins/grafana-extensionstest-app/use-plugin-links/v1" + ], + "title": "Open from B", + "description": "Open a modal from plugin B" + } + ], + "addedComponents": [ + { + "targets": ["plugins/grafana-extensionstest-app/configure-extension-component/v1"], + "title": "Configure extension component from B", + "description": "A component that can be reused by other app plugins. Shared using configureExtensionComponent api" + }, + { + "targets": ["plugins/grafana-extensionstest-app/addComponent/v1"], + "title": "Added component from B", + "description": "A component that can be reused by other app plugins. Shared using addComponent api" + } + ] + }, "includes": [ { "type": "page", diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/components/App/AddedLinks.tsx b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/components/App/AddedLinks.tsx new file mode 100644 index 00000000000..64df8da1575 --- /dev/null +++ b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/components/App/AddedLinks.tsx @@ -0,0 +1,24 @@ +import { usePluginExtensions, usePluginLinks } from '@grafana/runtime'; +import { Stack } from '@grafana/ui'; +import { testIds } from '../../../../testIds'; +import { ActionButton } from '../../../../components/ActionButton'; + +export const LINKS_EXTENSION_POINT_ID = 'plugins/grafana-extensionstest-app/use-plugin-links/v1'; + +export function AddedLinks() { + const { links } = usePluginLinks({ extensionPointId: LINKS_EXTENSION_POINT_ID }); + const { extensions } = usePluginExtensions({ extensionPointId: LINKS_EXTENSION_POINT_ID }); + + return ( + +
+

Link extensions defined with addLink and retrieved using usePluginLinks

+ +
+
+

Link extensions defined with addLink and retrieved using usePluginExtensions

+ +
+
+ ); +} diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/components/App/App.tsx b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/components/App/App.tsx new file mode 100644 index 00000000000..3ee8ecc33ba --- /dev/null +++ b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/components/App/App.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; +import { AppRootProps } from '@grafana/data'; +import { AddedLinks } from './AddedLinks'; +import { testIds } from '../../../../testIds'; + +export class App extends React.PureComponent { + render() { + return ( +
+ Hello Grafana! + +
+ ); + } +} diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/img/logo.svg b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/img/logo.svg new file mode 100644 index 00000000000..3d284dea3af --- /dev/null +++ b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/img/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/module.tsx b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/module.tsx new file mode 100644 index 00000000000..bba11717f86 --- /dev/null +++ b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/module.tsx @@ -0,0 +1,44 @@ +import { AppPlugin } from '@grafana/data'; + +import { LINKS_EXTENSION_POINT_ID } from '../../pages/AddedLinks'; +import { testIds } from '../../testIds'; +import { App } from '../../components/App'; + +export const plugin = new AppPlugin<{}>() + .setRootPage(App) + .configureExtensionLink({ + title: 'configureExtensionLink (where meta data is missing)', + description: 'Open a modal from plugin B', + extensionPointId: 'plugins/grafana-extensionstest-app/actions', + onClick: (_, { openModal }) => { + openModal({ + title: 'Modal from app B', + body: () =>
From plugin B
, + }); + }, + }) + .configureExtensionComponent({ + extensionPointId: 'plugins/grafana-extensionstest-app/configure-extension-component/v1', + title: 'configureExtensionComponent (where meta data is missing)', + description: 'A component that can be reused by other app plugins. Shared using configureExtensionComponent api', + component: ({ name }: { name: string }) =>
Hello {name}!
, + }) + .addComponent<{ name: string }>({ + targets: ['plugins/grafana-extensionstest-app/addComponent/v1'], + title: 'Added component (where meta data is missing)', + description: '.', + component: ({ name }: { name: string }) => ( +
Hello {name}!
+ ), + }) + .addLink({ + title: 'Added link (where meta data is missing)', + description: '.', + targets: [LINKS_EXTENSION_POINT_ID], + onClick: (_, { openModal }) => { + openModal({ + title: 'Modal from app C', + body: () =>
From plugin B
, + }); + }, + }); diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/plugin.json b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/plugin.json new file mode 100644 index 00000000000..dc50b7cd483 --- /dev/null +++ b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/plugin.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json", + "type": "app", + "name": "D App", + "id": "grafana-extensionexample3-app", + "preload": true, + "info": { + "keywords": ["app"], + "description": "Will extend root app with ui extensions", + "author": { + "name": "grafana" + }, + "logos": { + "small": "img/logo.svg", + "large": "img/logo.svg" + }, + "screenshots": [], + "version": "%VERSION%", + "updated": "%TODAY%" + }, + "includes": [ + { + "type": "page", + "name": "Default", + "path": "/a/grafana-extensionexample3-app", + "role": "Admin", + "addToNav": false, + "defaultNav": false + } + ], + "dependencies": { + "grafanaDependency": ">=10.3.3", + "plugins": [] + } +} diff --git a/e2e/test-plugins/grafana-extensionstest-app/testIds.ts b/e2e/test-plugins/grafana-extensionstest-app/testIds.ts index debed95957b..dda567237af 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/testIds.ts +++ b/e2e/test-plugins/grafana-extensionstest-app/testIds.ts @@ -16,6 +16,11 @@ export const testIds = { reusableAddedComponent: 'b-app-add-component', exposedComponent: 'b-app-exposed-component', }, + appC: { + container: 'c-app-body', + section1: 'use-plugin-links', + section2: 'use-plugin-extensions', + }, legacyGettersPage: { container: 'data-testid pg-legacy-getters-container', section1: 'get-plugin-extensions', diff --git a/e2e/test-plugins/grafana-extensionstest-app/tests/legacy/extensionPoints.hooks.spec.ts b/e2e/test-plugins/grafana-extensionstest-app/tests/legacy/extensionPoints.hooks.spec.ts index d5c96eefa31..c4ae9d2d23a 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/tests/legacy/extensionPoints.hooks.spec.ts +++ b/e2e/test-plugins/grafana-extensionstest-app/tests/legacy/extensionPoints.hooks.spec.ts @@ -2,6 +2,7 @@ import { test, expect } from '@grafana/plugin-e2e'; import { testIds } from '../../testIds'; import pluginJson from '../../plugin.json'; +import testApp3pluginJson from '../../plugins/grafana-extensionexample3-app/plugin.json'; test.describe('usePluginExtensions + configureExtensionLink', () => { test('should extend the actions menu with a link to a-app plugin', async ({ page }) => { @@ -12,6 +13,17 @@ test.describe('usePluginExtensions + configureExtensionLink', () => { await page.getByTestId(testIds.modal.open).click(); await expect(page.getByTestId(testIds.appA.container)).toBeVisible(); }); + + test('should not display extensions that have not been declared in plugin.json when in development mode', async ({ + page, + }) => { + await page.goto(`/a/${pluginJson.id}/legacy-hooks`); + const section = await page.getByTestId(testIds.legacyHooksPage.section1); + await section.getByTestId(testIds.actions.button).click(); + await expect( + page.getByTestId(testIds.container).getByText('configureExtensionLink (where meta data is missing)') + ).not.toBeVisible(); + }); }); test.describe('usePluginExtensions + configureExtensionComponent', () => { @@ -43,3 +55,13 @@ test.describe('usePluginComponentExtensions + configureExtensionComponent', () = ).toHaveText('Hello World!'); }); }); + +test.describe('usePluginExtensions + addLink', () => { + test('should not display extensions in case extension point has not been declared in plugin json (dev mode only)', async ({ + page, + }) => { + await page.goto(`/a/${testApp3pluginJson.id}/legacy-hooks`); + const section = await page.getByTestId(testIds.appC.section2); + await expect(section.getByTestId(testIds.actions.button)).not.toBeVisible(); + }); +}); diff --git a/e2e/test-plugins/grafana-extensionstest-app/tests/usePluginLinks.spec.ts b/e2e/test-plugins/grafana-extensionstest-app/tests/usePluginLinks.spec.ts index df5f64796d4..2d471db1898 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/tests/usePluginLinks.spec.ts +++ b/e2e/test-plugins/grafana-extensionstest-app/tests/usePluginLinks.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from '@grafana/plugin-e2e'; import pluginJson from '../plugin.json'; +import testApp3pluginJson from '../plugins/grafana-extensionexample3-app/plugin.json'; import { testIds } from '../testIds'; test('should extend the actions menu with a link to a-app plugin', async ({ page }) => { @@ -28,3 +29,11 @@ test('should extend main app with basic link extension from app A', async ({ pag await page.getByTestId(testIds.modal.open).click(); await expect(page.getByTestId(testIds.appA.container)).toBeVisible(); }); + +test('should not display any extensions when extension point is not declared in plugin json when in development mode', async ({ + page, +}) => { + await page.goto(`/a/${testApp3pluginJson.id}`); + const container = await page.getByTestId(testIds.appC.section1); + await expect(container.getByTestId(testIds.actions.button)).not.toBeVisible(); +}); diff --git a/packages/grafana-data/src/context/plugins/usePluginContext.tsx b/packages/grafana-data/src/context/plugins/usePluginContext.tsx index 51723446b81..58190a7e4e1 100644 --- a/packages/grafana-data/src/context/plugins/usePluginContext.tsx +++ b/packages/grafana-data/src/context/plugins/usePluginContext.tsx @@ -2,10 +2,14 @@ import { useContext } from 'react'; import { Context, PluginContextType } from './PluginContext'; -export function usePluginContext(): PluginContextType { +export function usePluginContext(): PluginContextType | null { const context = useContext(Context); + + // The extensions hooks (e.g. `usePluginLinks()`) are using this hook to check + // if they are inside a plugin or not (core Grafana), so we should be able to return an empty state as well (`null`). if (!context) { - throw new Error('usePluginContext must be used within a PluginContextProvider'); + return null; } + return context; } diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 890123bcd90..d6d414a81c5 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -586,6 +586,7 @@ export { type AngularMeta, type PluginMeta, type PluginDependencies, + type PluginExtensions, type PluginInclude, type PluginBuildInfo, type ScreenshotInfo, diff --git a/packages/grafana-data/src/types/plugin.ts b/packages/grafana-data/src/types/plugin.ts index 56ec6168d0b..4233771dbe6 100644 --- a/packages/grafana-data/src/types/plugin.ts +++ b/packages/grafana-data/src/types/plugin.ts @@ -98,6 +98,7 @@ export interface PluginMeta { angular?: AngularMeta; angularDetected?: boolean; loadingStrategy?: PluginLoadingStrategy; + extensions?: PluginExtensions; } interface PluginDependencyInfo { @@ -111,6 +112,38 @@ export interface PluginDependencies { grafanaDependency?: string; grafanaVersion: string; plugins: PluginDependencyInfo[]; + extensions: { + // A list of exposed component IDs + exposedComponents: string[]; + }; +} + +export type ExtensionInfo = { + targets: string | string[]; + title: string; + description?: string; +}; + +export interface PluginExtensions { + // The component extensions that the plugin registers + addedComponents: ExtensionInfo[]; + + // The link extensions that the plugin registers + addedLinks: ExtensionInfo[]; + + // The React components that the plugin exposes + exposedComponents: Array<{ + id: string; + title: string; + description?: string; + }>; + + // The extension points that the plugin provides + extensionPoints: Array<{ + id: string; + title: string; + description?: string; + }>; } export enum PluginIncludeType { diff --git a/packages/grafana-runtime/src/analytics/plugins/usePluginInteractionReporter.ts b/packages/grafana-runtime/src/analytics/plugins/usePluginInteractionReporter.ts index d9d89b76770..29da46165ac 100644 --- a/packages/grafana-runtime/src/analytics/plugins/usePluginInteractionReporter.ts +++ b/packages/grafana-runtime/src/analytics/plugins/usePluginInteractionReporter.ts @@ -12,6 +12,13 @@ export function usePluginInteractionReporter(): typeof reportInteraction { const context = usePluginContext(); return useMemo(() => { + // Happens when the hook is not used inside a plugin (e.g. in core Grafana) + if (!context) { + throw new Error( + `No PluginContext found. The usePluginInteractionReporter() hook can only be used from a plugin.` + ); + } + const info = isDataSourcePluginContext(context) ? createDataSourcePluginEventProperties(context.instanceSettings) : createPluginEventProperties(context.meta); diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 8c1c1c93d73..71ec381d45b 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -18,6 +18,8 @@ import { getThemeById, AngularMeta, PluginLoadingStrategy, + PluginDependencies, + PluginExtensions, } from '@grafana/data'; export interface AzureSettings { @@ -42,6 +44,8 @@ export type AppPluginConfig = { preload: boolean; angular: AngularMeta; loadingStrategy: PluginLoadingStrategy; + dependencies: PluginDependencies; + extensions: PluginExtensions; }; export type PreinstalledPlugin = { diff --git a/pkg/api/dtos/plugins.go b/pkg/api/dtos/plugins.go index 0d882bce28f..b14b915e5ff 100644 --- a/pkg/api/dtos/plugins.go +++ b/pkg/api/dtos/plugins.go @@ -17,6 +17,7 @@ type PluginSetting struct { Info plugins.Info `json:"info"` Includes []*plugins.Includes `json:"includes"` Dependencies plugins.Dependencies `json:"dependencies"` + Extensions plugins.Extensions `json:"extensions"` JsonData map[string]any `json:"jsonData"` SecureJsonFields map[string]bool `json:"secureJsonFields"` DefaultNavUrl string `json:"defaultNavUrl"` diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 6566b02acfc..511d673ca6b 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -561,6 +561,8 @@ func (hs *HTTPServer) newAppDTO(ctx context.Context, plugin pluginstore.Plugin, Preload: false, Angular: plugin.Angular, LoadingStrategy: hs.pluginAssets.LoadingStrategy(ctx, plugin), + Extensions: plugin.Extensions, + Dependencies: plugin.Dependencies, } if settings.Enabled { diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index f10f1a48d2b..e690dc91709 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -209,6 +209,7 @@ func (hs *HTTPServer) GetPluginSettingByID(c *contextmodel.ReqContext) response. SecureJsonFields: map[string]bool{}, AngularDetected: plugin.Angular.Detected, LoadingStrategy: hs.pluginAssets.LoadingStrategy(c.Req.Context(), plugin), + Extensions: plugin.Extensions, } if plugin.IsApp() { diff --git a/pkg/plugins/manager/loader/finder/local_test.go b/pkg/plugins/manager/loader/finder/local_test.go index 4f84d8bb4f4..9664f824186 100644 --- a/pkg/plugins/manager/loader/finder/local_test.go +++ b/pkg/plugins/manager/loader/finder/local_test.go @@ -50,6 +50,15 @@ func TestFinder_Find(t *testing.T) { Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, }, State: plugins.ReleaseStateAlpha, Backend: true, @@ -82,6 +91,15 @@ func TestFinder_Find(t *testing.T) { Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, }, }, FS: mustNewStaticFSForTests(t, filepath.Join(testData, "duplicate-plugins/nested")), @@ -104,6 +122,15 @@ func TestFinder_Find(t *testing.T) { Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, }, }, FS: mustNewStaticFSForTests(t, filepath.Join(testData, "duplicate-plugins/nested/nested")), @@ -150,6 +177,9 @@ func TestFinder_Find(t *testing.T) { {ID: "graphite", Type: "datasource", Name: "Graphite", Version: "1.0.0"}, {ID: "graph", Type: "panel", Name: "Graph", Version: "1.0.0"}, }, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, }, Includes: []*plugins.Includes{ { @@ -169,6 +199,12 @@ func TestFinder_Find(t *testing.T) { {Name: "Nginx Panel", Type: "panel", Role: "Viewer", Action: "plugins.app:access"}, {Name: "Nginx Datasource", Type: "datasource", Role: "Viewer", Action: "plugins.app:access"}, }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, + }, }, FS: mustNewStaticFSForTests(t, filepath.Join(testData, "includes-symlinks")), }, @@ -197,6 +233,15 @@ func TestFinder_Find(t *testing.T) { Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, }, }, FS: mustNewStaticFSForTests(t, filepath.Join(testData, "duplicate-plugins/nested")), @@ -219,6 +264,15 @@ func TestFinder_Find(t *testing.T) { Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, }, }, FS: mustNewStaticFSForTests(t, filepath.Join(testData, "duplicate-plugins/nested/nested")), @@ -241,6 +295,15 @@ func TestFinder_Find(t *testing.T) { Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, }, State: plugins.ReleaseStateAlpha, Backend: true, @@ -272,6 +335,15 @@ func TestFinder_Find(t *testing.T) { Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, }, State: plugins.ReleaseStateAlpha, Backend: true, diff --git a/pkg/plugins/manager/loader/loader_test.go b/pkg/plugins/manager/loader/loader_test.go index 7e1f2ecaafc..c4cf2d4faf7 100644 --- a/pkg/plugins/manager/loader/loader_test.go +++ b/pkg/plugins/manager/loader/loader_test.go @@ -98,6 +98,15 @@ func TestLoader_Load(t *testing.T) { Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, }, Category: "cloud", Annotations: true, @@ -141,6 +150,15 @@ func TestLoader_Load(t *testing.T) { Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, }, Executable: "test", Backend: true, @@ -195,6 +213,9 @@ func TestLoader_Load(t *testing.T) { {Type: "datasource", ID: "graphite", Name: "Graphite", Version: "1.0.0"}, {Type: "panel", ID: "graph", Name: "Graph", Version: "1.0.0"}, }, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, }, Includes: []*plugins.Includes{ { @@ -228,6 +249,12 @@ func TestLoader_Load(t *testing.T) { Slug: "nginx-datasource", }, }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, + }, }, Class: plugins.ClassExternal, Module: "public/plugins/test-app/module.js", @@ -266,6 +293,15 @@ func TestLoader_Load(t *testing.T) { Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, }, Backend: true, State: plugins.ReleaseStateAlpha, @@ -312,6 +348,15 @@ func TestLoader_Load(t *testing.T) { Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, }, Backend: true, State: plugins.ReleaseStateAlpha, @@ -393,11 +438,20 @@ func TestLoader_Load(t *testing.T) { GrafanaDependency: ">=8.0.0", GrafanaVersion: "*", Plugins: []plugins.Dependency{}, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, }, Includes: []*plugins.Includes{ {Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-memory"}, {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{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, + }, Backend: false, }, DefaultNavURL: "/plugins/test-app/page/root-page-react", diff --git a/pkg/plugins/models.go b/pkg/plugins/models.go index 2f3a857d1cd..c14cc44dd17 100644 --- a/pkg/plugins/models.go +++ b/pkg/plugins/models.go @@ -1,6 +1,7 @@ package plugins import ( + "encoding/json" "errors" "fmt" @@ -42,9 +43,100 @@ func (e DuplicateError) Is(err error) bool { } type Dependencies struct { - GrafanaDependency string `json:"grafanaDependency"` - GrafanaVersion string `json:"grafanaVersion"` - Plugins []Dependency `json:"plugins"` + GrafanaDependency string `json:"grafanaDependency"` + GrafanaVersion string `json:"grafanaVersion"` + Plugins []Dependency `json:"plugins"` + Extensions ExtensionsDependencies `json:"extensions"` +} + +// We need different versions for the Extensions struct because there is a now deprecated plugin.json schema out there, where the "extensions" prop +// is in a different format (Extensions V1). In order to support those as well while reading the plugin.json, we need to add a custom unmarshaling logic for extensions. +type ExtensionV1 struct { + ExtensionPointID string `json:"extensionPointId"` + Title string `json:"title"` + Description string `json:"description"` + Type string `json:"type"` +} + +type ExtensionsV2 struct { + AddedLinks []AddedLink `json:"addedLinks"` + AddedComponents []AddedComponent `json:"addedComponents"` + ExposedComponents []ExposedComponent `json:"exposedComponents"` + ExtensionPoints []ExtensionPoint `json:"extensionPoints"` +} + +type Extensions ExtensionsV2 + +func (e *Extensions) UnmarshalJSON(data []byte) error { + var err error + var extensionsV2 ExtensionsV2 + + if err = json.Unmarshal(data, &extensionsV2); err == nil { + e.AddedComponents = extensionsV2.AddedComponents + e.AddedLinks = extensionsV2.AddedLinks + e.ExposedComponents = extensionsV2.ExposedComponents + e.ExtensionPoints = extensionsV2.ExtensionPoints + + return nil + } + + // Fallback (V1) + var extensionsV1 []ExtensionV1 + if err = json.Unmarshal(data, &extensionsV1); err == nil { + // Trying to process old format and add them to `AddedLinks` and `AddedComponents` + for _, extensionV1 := range extensionsV1 { + if extensionV1.Type == "link" { + extensionV2 := AddedLink{ + Targets: []string{extensionV1.ExtensionPointID}, + Title: extensionV1.Title, + Description: extensionV1.Description, + } + e.AddedLinks = append(e.AddedLinks, extensionV2) + } + + if extensionV1.Type == "component" { + extensionV2 := AddedComponent{ + Targets: []string{extensionV1.ExtensionPointID}, + Title: extensionV1.Title, + Description: extensionV1.Description, + } + + e.AddedComponents = append(e.AddedComponents, extensionV2) + } + } + + return nil + } + + return err +} + +type AddedLink struct { + Targets []string `json:"targets"` + Title string `json:"title"` + Description string `json:"description"` +} + +type AddedComponent struct { + Targets []string `json:"targets"` + Title string `json:"title"` + Description string `json:"description"` +} + +type ExposedComponent struct { + Id string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` +} + +type ExtensionPoint struct { + Id string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` +} + +type ExtensionsDependencies struct { + ExposedComponents []string `json:"exposedComponents"` } type Includes struct { @@ -231,6 +323,8 @@ type AppDTO struct { Preload bool `json:"preload"` Angular AngularMeta `json:"angular"` LoadingStrategy LoadingStrategy `json:"loadingStrategy"` + Extensions Extensions `json:"extensions"` + Dependencies Dependencies `json:"dependencies"` } const ( diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index 5a91ea24b69..ee69c10f32b 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -109,7 +109,8 @@ type JSONData struct { SkipDataQuery bool `json:"skipDataQuery"` // App settings - AutoEnabled bool `json:"autoEnabled"` + AutoEnabled bool `json:"autoEnabled"` + Extensions Extensions `json:"extensions"` // Datasource settings Annotations bool `json:"annotations"` @@ -173,6 +174,26 @@ func ReadPluginJSON(reader io.Reader) (JSONData, error) { plugin.Dependencies.GrafanaVersion = "*" } + if len(plugin.Dependencies.Extensions.ExposedComponents) == 0 { + plugin.Dependencies.Extensions.ExposedComponents = make([]string, 0) + } + + if plugin.Extensions.AddedLinks == nil { + plugin.Extensions.AddedLinks = []AddedLink{} + } + + if plugin.Extensions.AddedComponents == nil { + plugin.Extensions.AddedComponents = []AddedComponent{} + } + + if plugin.Extensions.ExposedComponents == nil { + plugin.Extensions.ExposedComponents = []ExposedComponent{} + } + + if plugin.Extensions.ExtensionPoints == nil { + plugin.Extensions.ExtensionPoints = []ExtensionPoint{} + } + for _, include := range plugin.Includes { if include.Role == "" { include.Role = org.RoleViewer diff --git a/pkg/plugins/plugins_test.go b/pkg/plugins/plugins_test.go index 62753a56ae8..797c39a8ef0 100644 --- a/pkg/plugins/plugins_test.go +++ b/pkg/plugins/plugins_test.go @@ -52,13 +52,25 @@ func Test_ReadPluginJSON(t *testing.T) { Updated: "2015-02-10", Keywords: []string{"test"}, }, + + Extensions: Extensions{ + AddedLinks: []AddedLink{}, + AddedComponents: []AddedComponent{}, + ExposedComponents: []ExposedComponent{}, + ExtensionPoints: []ExtensionPoint{}, + }, + Dependencies: Dependencies{ GrafanaVersion: "3.x.x", Plugins: []Dependency{ {Type: "datasource", ID: "graphite", Name: "Graphite", Version: "1.0.0"}, {Type: "panel", ID: "graph", Name: "Graph", Version: "1.0.0"}, }, + Extensions: ExtensionsDependencies{ + ExposedComponents: []string{}, + }, }, + Includes: []*Includes{ {Name: "Nginx Connections", Path: "dashboards/connections.json", Type: "dashboard", Role: org.RoleViewer, Action: ActionAppAccess}, {Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: org.RoleViewer, Action: ActionAppAccess}, @@ -94,10 +106,22 @@ func Test_ReadPluginJSON(t *testing.T) { ID: "grafana-piechart-panel", Type: TypePanel, Name: "Pie Chart (old)", + + Extensions: Extensions{ + AddedLinks: []AddedLink{}, + AddedComponents: []AddedComponent{}, + ExposedComponents: []ExposedComponent{}, + ExtensionPoints: []ExtensionPoint{}, + }, + Dependencies: Dependencies{ GrafanaVersion: "*", Plugins: []Dependency{}, + Extensions: ExtensionsDependencies{ + ExposedComponents: []string{}, + }, }, + Includes: []*Includes{ {Name: "Pie Charts", Path: "dashboards/demo.json", Type: "dashboard", Role: org.RoleViewer}, }, @@ -117,10 +141,21 @@ func Test_ReadPluginJSON(t *testing.T) { ID: "grafana-pyroscope-datasource", AliasIDs: []string{"phlare"}, // Hardcoded from the parser Type: TypeDataSource, + + Extensions: Extensions{ + AddedLinks: []AddedLink{}, + AddedComponents: []AddedComponent{}, + ExposedComponents: []ExposedComponent{}, + ExtensionPoints: []ExtensionPoint{}, + }, + Dependencies: Dependencies{ GrafanaDependency: "", GrafanaVersion: "*", Plugins: []Dependency{}, + Extensions: ExtensionsDependencies{ + ExposedComponents: []string{}, + }, }, }, }, @@ -142,18 +177,257 @@ func Test_ReadPluginJSON(t *testing.T) { Dependencies: Dependencies{}, }, }, + { + name: "can read the latest versions of extensions information (v2)", + pluginJSON: func(t *testing.T) io.ReadCloser { + pJSON := `{ + "id": "myorg-extensions-app", + "name": "Extensions App", + "type": "app", + "extensions": { + "addedLinks": [ + { + "title": "Added link 1", + "description": "Added link 1 description", + "targets": ["grafana/dashboard/panel/menu"] + } + ], + "addedComponents": [ + { + "title": "Added component 1", + "description": "Added component 1 description", + "targets": ["grafana/user/profile/tab"] + } + ], + "exposedComponents": [ + { + "title": "Exposed component 1", + "description": "Exposed component 1 description", + "id": "myorg-extensions-app/component-1/v1" + } + ], + "extensionPoints": [ + { + "title": "Extension point 1", + "description": "Extension points 1 description", + "id": "myorg-extensions-app/extensions-point-1/v1" + } + ] + } + }` + return io.NopCloser(strings.NewReader(pJSON)) + }, + expected: JSONData{ + ID: "myorg-extensions-app", + Name: "Extensions App", + Type: TypeApp, + + Extensions: Extensions{ + AddedLinks: []AddedLink{ + {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{ + {Id: "myorg-extensions-app/component-1/v1", Title: "Exposed component 1", Description: "Exposed component 1 description"}, + }, + ExtensionPoints: []ExtensionPoint{ + {Id: "myorg-extensions-app/extensions-point-1/v1", Title: "Extension point 1", Description: "Extension points 1 description"}, + }, + }, + + Dependencies: Dependencies{ + GrafanaVersion: "*", + Plugins: []Dependency{}, + Extensions: ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + }, + }, + { + name: "can read deprecated extensions info (v1) and parse it as v2", + pluginJSON: func(t *testing.T) io.ReadCloser { + pJSON := `{ + "id": "myorg-extensions-app", + "name": "Extensions App", + "type": "app", + "extensions": [ + { + "extensionPointId": "grafana/dashboard/panel/menu", + "title": "Added link 1", + "description": "Added link 1 description", + "type": "link" + }, + { + "extensionPointId": "grafana/dashboard/panel/menu", + "title": "Added link 2", + "description": "Added link 2 description", + "type": "link" + }, + { + "extensionPointId": "grafana/user/profile/tab", + "title": "Added component 1", + "description": "Added component 1 description", + "type": "component" + } + ] + }` + return io.NopCloser(strings.NewReader(pJSON)) + }, + expected: JSONData{ + ID: "myorg-extensions-app", + Name: "Extensions App", + Type: TypeApp, + + Extensions: Extensions{ + AddedLinks: []AddedLink{ + {Title: "Added link 1", Description: "Added link 1 description", Targets: []string{"grafana/dashboard/panel/menu"}}, + {Title: "Added link 2", Description: "Added link 2 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{}, + ExtensionPoints: []ExtensionPoint{}, + }, + + Dependencies: Dependencies{ + GrafanaVersion: "*", + Plugins: []Dependency{}, + Extensions: ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + }, + }, + { + name: "works if extensions info is empty", + pluginJSON: func(t *testing.T) io.ReadCloser { + pJSON := `{ + "id": "myorg-extensions-app", + "name": "Extensions App", + "type": "app", + "extensions": [] + }` + return io.NopCloser(strings.NewReader(pJSON)) + }, + expected: JSONData{ + ID: "myorg-extensions-app", + Name: "Extensions App", + Type: TypeApp, + + Extensions: Extensions{ + AddedLinks: []AddedLink{}, + AddedComponents: []AddedComponent{}, + ExposedComponents: []ExposedComponent{}, + ExtensionPoints: []ExtensionPoint{}, + }, + + Dependencies: Dependencies{ + GrafanaVersion: "*", + Plugins: []Dependency{}, + Extensions: ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + }, + }, + { + name: "works if extensions info is completely missing", + pluginJSON: func(t *testing.T) io.ReadCloser { + pJSON := `{ + "id": "myorg-extensions-app", + "name": "Extensions App", + "type": "app" + }` + return io.NopCloser(strings.NewReader(pJSON)) + }, + expected: JSONData{ + ID: "myorg-extensions-app", + Name: "Extensions App", + Type: TypeApp, + + Extensions: Extensions{ + AddedLinks: []AddedLink{}, + AddedComponents: []AddedComponent{}, + ExposedComponents: []ExposedComponent{}, + ExtensionPoints: []ExtensionPoint{}, + }, + + Dependencies: Dependencies{ + GrafanaVersion: "*", + Plugins: []Dependency{}, + Extensions: ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + }, + }, + { + name: "can read extensions related dependencies", + pluginJSON: func(t *testing.T) io.ReadCloser { + pJSON := `{ + "id": "myorg-extensions-app", + "name": "Extensions App", + "type": "app", + "dependencies": { + "grafanaDependency": "10.0.0", + "extensions": { + "exposedComponents": ["myorg-extensions-app/component-1/v1"] + } + } + }` + return io.NopCloser(strings.NewReader(pJSON)) + }, + expected: JSONData{ + ID: "myorg-extensions-app", + Name: "Extensions App", + Type: TypeApp, + + Extensions: Extensions{ + AddedLinks: []AddedLink{}, + AddedComponents: []AddedComponent{}, + ExposedComponents: []ExposedComponent{}, + ExtensionPoints: []ExtensionPoint{}, + }, + + Dependencies: Dependencies{ + GrafanaVersion: "*", + GrafanaDependency: "10.0.0", + Plugins: []Dependency{}, + Extensions: ExtensionsDependencies{ + ExposedComponents: []string{"myorg-extensions-app/component-1/v1"}, + }, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := tt.pluginJSON(t) got, err := ReadPluginJSON(p) + + // Check if the test returns the same error as expected + // (unneccary to check further if there is an error at this point) + if tt.err == nil && err != nil { + t.Errorf("Error while reading pluginJSON: %+v", err) + return + } + + // Check if the test returns the same error as expected if tt.err != nil { require.ErrorIs(t, err, tt.err) } + + // Check if the test returns the expected pluginJSONData if !cmp.Equal(got, tt.expected) { t.Errorf("Unexpected pluginJSONData: %v", cmp.Diff(got, tt.expected)) } + + // Should be able to close the reader require.NoError(t, p.Close()) }) } diff --git a/pkg/services/pluginsintegration/loader/loader_test.go b/pkg/services/pluginsintegration/loader/loader_test.go index 2fed29e25a4..4eafd7db235 100644 --- a/pkg/services/pluginsintegration/loader/loader_test.go +++ b/pkg/services/pluginsintegration/loader/loader_test.go @@ -98,6 +98,15 @@ func TestLoader_Load(t *testing.T) { Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, }, Category: "cloud", Annotations: true, @@ -141,6 +150,15 @@ func TestLoader_Load(t *testing.T) { Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, }, Executable: "test", Backend: true, @@ -195,6 +213,9 @@ func TestLoader_Load(t *testing.T) { {Type: "datasource", ID: "graphite", Name: "Graphite", Version: "1.0.0"}, {Type: "panel", ID: "graph", Name: "Graph", Version: "1.0.0"}, }, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, }, Includes: []*plugins.Includes{ { @@ -228,6 +249,12 @@ func TestLoader_Load(t *testing.T) { Slug: "nginx-datasource", }, }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, + }, }, Class: plugins.ClassExternal, Module: "public/plugins/test-app/module.js", @@ -266,6 +293,15 @@ func TestLoader_Load(t *testing.T) { Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, }, Backend: true, State: plugins.ReleaseStateAlpha, @@ -318,6 +354,15 @@ func TestLoader_Load(t *testing.T) { Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, }, Backend: true, State: plugins.ReleaseStateAlpha, @@ -423,6 +468,15 @@ func TestLoader_Load(t *testing.T) { GrafanaDependency: ">=8.0.0", GrafanaVersion: "*", Plugins: []plugins.Dependency{}, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, }, Includes: []*plugins.Includes{ {Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-memory"}, @@ -497,6 +551,15 @@ func TestLoader_Load_ExternalRegistration(t *testing.T) { Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, }, IAM: &pfs.IAM{ Permissions: []pfs.Permission{ @@ -599,6 +662,15 @@ func TestLoader_Load_CustomSource(t *testing.T) { Dependencies: plugins.Dependencies{ GrafanaVersion: "3.x.x", Plugins: []plugins.Dependency{}, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, }, }, FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "cdn/plugin")), @@ -671,6 +743,15 @@ func TestLoader_Load_MultiplePlugins(t *testing.T) { Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, }, Backend: true, Executable: "test", @@ -767,6 +848,15 @@ func TestLoader_Load_RBACReady(t *testing.T) { GrafanaVersion: "*", GrafanaDependency: ">=8.0.0", Plugins: []plugins.Dependency{}, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, }, Includes: []*plugins.Includes{}, Roles: []plugins.RoleRegistration{ @@ -840,10 +930,18 @@ func TestLoader_Load_Signature_RootURL(t *testing.T) { }, Version: "1.0.0", }, - State: plugins.ReleaseStateAlpha, - Dependencies: plugins.Dependencies{GrafanaVersion: "*", Plugins: []plugins.Dependency{}}, - Backend: true, - Executable: "test", + State: plugins.ReleaseStateAlpha, + Dependencies: plugins.Dependencies{GrafanaVersion: "*", Plugins: []plugins.Dependency{}, Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }}, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, + }, + Backend: true, + Executable: "test", }, FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature-root-url-uri/plugin")), Class: plugins.ClassExternal, @@ -913,6 +1011,15 @@ func TestLoader_Load_DuplicatePlugins(t *testing.T) { {Type: "datasource", ID: "graphite", Name: "Graphite", Version: "1.0.0"}, {Type: "panel", ID: "graph", Name: "Graph", Version: "1.0.0"}, }, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, }, Includes: []*plugins.Includes{ {Name: "Nginx Connections", Path: "dashboards/connections.json", Type: "dashboard", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-connections"}, @@ -994,6 +1101,9 @@ func TestLoader_Load_SkipUninitializedPlugins(t *testing.T) { {Type: "datasource", ID: "graphite", Name: "Graphite", Version: "1.0.0"}, {Type: "panel", ID: "graph", Name: "Graph", Version: "1.0.0"}, }, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, }, Includes: []*plugins.Includes{ {Name: "Nginx Connections", Path: "dashboards/connections.json", Type: "dashboard", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-connections"}, @@ -1001,6 +1111,12 @@ func TestLoader_Load_SkipUninitializedPlugins(t *testing.T) { {Name: "Nginx Panel", Type: "panel", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-panel"}, {Name: "Nginx Datasource", Type: "datasource", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-datasource"}, }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, + }, Backend: false, }, FS: mustNewStaticFSForTests(t, pluginDir1), @@ -1208,6 +1324,15 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, }, Backend: true, }, @@ -1241,6 +1366,15 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { Dependencies: plugins.Dependencies{ GrafanaVersion: "*", Plugins: []plugins.Dependency{}, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, }, }, Module: "public/plugins/test-datasource/nested/module.js", @@ -1336,6 +1470,9 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { GrafanaVersion: "7.0.0", GrafanaDependency: ">=7.0.0", Plugins: []plugins.Dependency{}, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, }, Includes: []*plugins.Includes{ { @@ -1382,6 +1519,12 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { Slug: "lots-of-stats", }, }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, + }, Backend: false, }, Module: "public/plugins/myorgid-simple-app/module.js", @@ -1421,6 +1564,15 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { GrafanaDependency: ">=7.0.0", GrafanaVersion: "*", Plugins: []plugins.Dependency{}, + Extensions: plugins.ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + Extensions: plugins.Extensions{ + AddedLinks: []plugins.AddedLink{}, + AddedComponents: []plugins.AddedComponent{}, + ExposedComponents: []plugins.ExposedComponent{}, + ExtensionPoints: []plugins.ExtensionPoint{}, }, }, Module: "public/plugins/myorgid-simple-app/child/module.js", diff --git a/pkg/tests/api/plugins/data/expectedListResp.json b/pkg/tests/api/plugins/data/expectedListResp.json index f83e6110626..dcbe8301979 100644 --- a/pkg/tests/api/plugins/data/expectedListResp.json +++ b/pkg/tests/api/plugins/data/expectedListResp.json @@ -25,7 +25,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -75,7 +78,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -113,7 +119,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -179,7 +188,10 @@ "dependencies": { "grafanaDependency": "\u003e=10.3.0", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -217,7 +229,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -255,7 +270,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -298,7 +316,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -336,7 +357,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -377,7 +401,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -415,7 +442,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -453,7 +483,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -503,7 +536,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -541,7 +577,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -579,7 +618,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -617,7 +659,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -655,7 +700,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -693,7 +741,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -744,7 +795,10 @@ "dependencies": { "grafanaDependency": "\u003e=10.3.0-0", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -782,7 +836,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -829,7 +886,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -867,7 +927,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -910,7 +973,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -948,7 +1014,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -995,7 +1064,10 @@ "dependencies": { "grafanaDependency": "\u003e=10.3.0-0", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -1033,7 +1105,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -1080,7 +1155,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -1118,7 +1196,10 @@ "dependencies": { "grafanaDependency": "\u003e=10.4.0", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -1156,7 +1237,10 @@ "dependencies": { "grafanaDependency": "\u003e=10.4.0", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -1194,7 +1278,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -1232,7 +1319,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -1270,7 +1360,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -1318,7 +1411,10 @@ "dependencies": { "grafanaDependency": "\u003e=10.3.0-0", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -1356,7 +1452,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -1394,7 +1493,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -1437,7 +1539,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -1475,7 +1580,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -1513,7 +1621,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -1551,7 +1662,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -1589,7 +1703,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -1627,7 +1744,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -1670,7 +1790,10 @@ "dependencies": { "grafanaDependency": "\u003e=10.3.0-0", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -1708,7 +1831,10 @@ "dependencies": { "grafanaDependency": "\u003e=10.3.0-0", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -1746,7 +1872,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -1784,7 +1913,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -1822,7 +1954,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -1860,7 +1995,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -1898,7 +2036,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -1939,7 +2080,10 @@ "dependencies": { "grafanaDependency": "", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, @@ -1982,7 +2126,10 @@ "dependencies": { "grafanaDependency": "\u003e=10.3.0-0", "grafanaVersion": "*", - "plugins": [] + "plugins": [], + "extensions": { + "exposedComponents": [] + } }, "latestVersion": "", "hasUpdate": false, 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 60f641dc089..f5bdac45050 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts @@ -19,6 +19,19 @@ export const getPluginsHandler = (pluginsArray: PluginMeta[] = plugins) => { version: info.version, angular: angular ?? { detected: false, hideDeprecation: false }, loadingStrategy: PluginLoadingStrategy.script, + extensions: { + addedLinks: [], + addedComponents: [], + extensionPoints: [], + exposedComponents: [], + }, + dependencies: { + grafanaVersion: '', + plugins: [], + extensions: { + exposedComponents: [], + }, + }, }; }); diff --git a/public/app/features/alerting/unified/utils/rules.test.ts b/public/app/features/alerting/unified/utils/rules.test.ts index 59d77e3b3d3..fb1477d038c 100644 --- a/public/app/features/alerting/unified/utils/rules.test.ts +++ b/public/app/features/alerting/unified/utils/rules.test.ts @@ -49,6 +49,19 @@ describe('getRuleOrigin', () => { preload: true, angular: { detected: false, hideDeprecation: false }, loadingStrategy: PluginLoadingStrategy.script, + extensions: { + addedLinks: [], + addedComponents: [], + extensionPoints: [], + exposedComponents: [], + }, + dependencies: { + grafanaVersion: '', + plugins: [], + extensions: { + exposedComponents: [], + }, + }, }, }; const rule = mockCombinedRule({ diff --git a/public/app/features/plugins/admin/__mocks__/remotePlugin.mock.ts b/public/app/features/plugins/admin/__mocks__/remotePlugin.mock.ts index d649c990042..f95a40df8f1 100644 --- a/public/app/features/plugins/admin/__mocks__/remotePlugin.mock.ts +++ b/public/app/features/plugins/admin/__mocks__/remotePlugin.mock.ts @@ -43,6 +43,9 @@ export default { grafanaDependency: '>=7.3.0', grafanaVersion: '7.3', plugins: [], + extensions: { + exposedComponents: [], + }, }, info: { links: [], diff --git a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts index ac0dc845a0a..8e0041d185c 100644 --- a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts +++ b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts @@ -1,15 +1,62 @@ import React from 'react'; import { firstValueFrom } from 'rxjs'; +import { PluginLoadingStrategy } from '@grafana/data'; +import { config } from '@grafana/runtime'; + +import { isGrafanaDevMode } from '../utils'; + import { AddedComponentsRegistry } from './AddedComponentsRegistry'; 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), +})); + describe('AddedComponentsRegistry', () => { const consoleWarn = jest.fn(); + 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: { + addedLinks: [], + addedComponents: [], + exposedComponents: [], + extensionPoints: [], + }, + }; beforeEach(() => { global.console.warn = consoleWarn; consoleWarn.mockReset(); + jest.mocked(isGrafanaDevMode).mockReturnValue(false); + config.apps = { + [pluginId]: appPluginConfig, + }; + }); + + afterEach(() => { + config.apps = originalApps; }); it('should return empty registry when no extensions registered', async () => { @@ -20,15 +67,14 @@ describe('AddedComponentsRegistry', () => { }); it('should be possible to register added components in the registry', async () => { - const pluginId = 'grafana-basic-app'; - const id = `${pluginId}/hello-world/v1`; + const extensionPointId = `${pluginId}/hello-world/v1`; const reactiveRegistry = new AddedComponentsRegistry(); reactiveRegistry.register({ pluginId, configs: [ { - targets: [id], + targets: [extensionPointId], title: 'not important', description: 'not important', component: () => React.createElement('div', null, 'Hello World'), @@ -39,15 +85,17 @@ describe('AddedComponentsRegistry', () => { const registry = await reactiveRegistry.getState(); expect(Object.keys(registry)).toHaveLength(1); - expect(registry[id][0]).toMatchObject({ + expect(registry[extensionPointId][0]).toMatchObject({ pluginId, title: 'not important', description: 'not important', }); }); + it('should be possible to asynchronously register component extensions for the same extension point (different plugins)', async () => { const pluginId1 = 'grafana-basic-app'; const pluginId2 = 'grafana-basic-app2'; + const extensionPointId = 'grafana/alerting/home'; const reactiveRegistry = new AddedComponentsRegistry(); // Register extensions for the first plugin @@ -65,7 +113,7 @@ describe('AddedComponentsRegistry', () => { const registry1 = await reactiveRegistry.getState(); expect(Object.keys(registry1)).toHaveLength(1); - expect(registry1['grafana/alerting/home'][0]).toMatchObject({ + expect(registry1[extensionPointId][0]).toMatchObject({ pluginId: pluginId1, title: 'Component 1 title', description: 'Component 1 description', @@ -78,7 +126,7 @@ describe('AddedComponentsRegistry', () => { { title: 'Component 2 title', description: 'Component 2 description', - targets: ['grafana/alerting/home'], + targets: [extensionPointId], component: () => React.createElement('div', null, 'Hello World1'), }, ], @@ -86,7 +134,7 @@ describe('AddedComponentsRegistry', () => { const registry2 = await reactiveRegistry.getState(); expect(Object.keys(registry2)).toHaveLength(1); - expect(registry2['grafana/alerting/home']).toEqual( + expect(registry2[extensionPointId]).toEqual( expect.arrayContaining([ expect.objectContaining({ pluginId: pluginId1, @@ -105,6 +153,8 @@ describe('AddedComponentsRegistry', () => { it('should be possible to asynchronously register component extensions for a different extension points (different plugin)', async () => { const pluginId1 = 'grafana-basic-app'; const pluginId2 = 'grafana-basic-app2'; + const extensionPointId1 = 'grafana/alerting/home'; + const extensionPointId2 = 'grafana/user/profile/tab'; const reactiveRegistry = new AddedComponentsRegistry(); // Register extensions for the first plugin @@ -114,7 +164,7 @@ describe('AddedComponentsRegistry', () => { { title: 'Component 1 title', description: 'Component 1 description', - targets: ['grafana/alerting/home'], + targets: [extensionPointId1], component: () => React.createElement('div', null, 'Hello World1'), }, ], @@ -122,7 +172,7 @@ describe('AddedComponentsRegistry', () => { const registry1 = await reactiveRegistry.getState(); expect(registry1).toEqual({ - 'grafana/alerting/home': expect.arrayContaining([ + [extensionPointId1]: expect.arrayContaining([ expect.objectContaining({ pluginId: pluginId1, title: 'Component 1 title', @@ -138,7 +188,7 @@ describe('AddedComponentsRegistry', () => { { title: 'Component 2 title', description: 'Component 2 description', - targets: ['grafana/user/profile/tab'], + targets: [extensionPointId2], component: () => React.createElement('div', null, 'Hello World1'), }, ], @@ -147,14 +197,14 @@ describe('AddedComponentsRegistry', () => { const registry2 = await reactiveRegistry.getState(); expect(registry2).toEqual({ - 'grafana/alerting/home': expect.arrayContaining([ + [extensionPointId1]: expect.arrayContaining([ expect.objectContaining({ pluginId: pluginId1, title: 'Component 1 title', description: 'Component 1 description', }), ]), - 'grafana/user/profile/tab': expect.arrayContaining([ + [extensionPointId2]: expect.arrayContaining([ expect.objectContaining({ pluginId: pluginId2, title: 'Component 2 title', @@ -165,8 +215,8 @@ describe('AddedComponentsRegistry', () => { }); it('should be possible to asynchronously register component extensions for the same extension point (same plugin)', async () => { - const pluginId = 'grafana-basic-app'; const reactiveRegistry = new AddedComponentsRegistry(); + const extensionPointId = 'grafana/alerting/home'; // Register extensions for the first extension point reactiveRegistry.register({ @@ -175,20 +225,20 @@ describe('AddedComponentsRegistry', () => { { title: 'Component 1 title', description: 'Component 1 description', - targets: ['grafana/alerting/home'], + targets: [extensionPointId], component: () => React.createElement('div', null, 'Hello World1'), }, { title: 'Component 2 title', description: 'Component 2 description', - targets: ['grafana/alerting/home'], + targets: [extensionPointId], component: () => React.createElement('div', null, 'Hello World2'), }, ], }); const registry1 = await reactiveRegistry.getState(); expect(registry1).toEqual({ - 'grafana/alerting/home': expect.arrayContaining([ + [extensionPointId]: expect.arrayContaining([ expect.objectContaining({ pluginId: pluginId, title: 'Component 1 title', @@ -204,8 +254,9 @@ describe('AddedComponentsRegistry', () => { }); it('should be possible to register one extension component targeting multiple extension points', async () => { - const pluginId = 'grafana-basic-app'; const reactiveRegistry = new AddedComponentsRegistry(); + const extensionPointId1 = 'grafana/alerting/home'; + const extensionPointId2 = 'grafana/user/profile/tab'; reactiveRegistry.register({ pluginId: pluginId, @@ -213,21 +264,21 @@ describe('AddedComponentsRegistry', () => { { title: 'Component 1 title', description: 'Component 1 description', - targets: ['grafana/alerting/home', 'grafana/user/profile/tab'], + targets: [extensionPointId1, extensionPointId2], component: () => React.createElement('div', null, 'Hello World1'), }, ], }); const registry1 = await reactiveRegistry.getState(); expect(registry1).toEqual({ - 'grafana/alerting/home': expect.arrayContaining([ + [extensionPointId1]: expect.arrayContaining([ expect.objectContaining({ pluginId: pluginId, title: 'Component 1 title', description: 'Component 1 description', }), ]), - 'grafana/user/profile/tab': expect.arrayContaining([ + [extensionPointId2]: expect.arrayContaining([ expect.objectContaining({ pluginId: pluginId, title: 'Component 1 title', @@ -239,7 +290,9 @@ describe('AddedComponentsRegistry', () => { it('should notify subscribers when the registry changes', async () => { const pluginId1 = 'grafana-basic-app'; - const pluginId2 = 'another-plugin'; + const pluginId2 = 'myorg-extensions-app'; + const extensionPointId1 = 'grafana/alerting/home'; + const extensionPointId2 = 'grafana/user/profile/tab'; const reactiveRegistry = new AddedComponentsRegistry(); const observable = reactiveRegistry.asObservable(); const subscribeCallback = jest.fn(); @@ -252,7 +305,7 @@ describe('AddedComponentsRegistry', () => { { title: 'Component 1 title', description: 'Component 1 description', - targets: ['grafana/alerting/home'], + targets: [extensionPointId1], component: () => React.createElement('div', null, 'Hello World1'), }, ], @@ -266,7 +319,7 @@ describe('AddedComponentsRegistry', () => { { title: 'Component 2 title', description: 'Component 2 description', - targets: ['grafana/user/profile/tab'], + targets: [extensionPointId2], component: () => React.createElement('div', null, 'Hello World2'), }, ], @@ -277,14 +330,14 @@ describe('AddedComponentsRegistry', () => { const registry = subscribeCallback.mock.calls[2][0]; expect(registry).toEqual({ - 'grafana/alerting/home': expect.arrayContaining([ + [extensionPointId1]: expect.arrayContaining([ expect.objectContaining({ pluginId: pluginId1, title: 'Component 1 title', description: 'Component 1 description', }), ]), - 'grafana/user/profile/tab': expect.arrayContaining([ + [extensionPointId2]: expect.arrayContaining([ expect.objectContaining({ pluginId: pluginId2, title: 'Component 2 title', @@ -294,43 +347,24 @@ describe('AddedComponentsRegistry', () => { }); }); - it('should skip registering component and log a warning when id is not prefixed with plugin id or grafana', async () => { - const registry = new AddedComponentsRegistry(); - registry.register({ - pluginId: 'grafana-basic-app', - configs: [ - { - title: 'Component 1 title', - description: 'Component 1 description', - targets: ['alerting/home'], - component: () => React.createElement('div', null, 'Hello World1'), - }, - ], - }); - - expect(consoleWarn).toHaveBeenCalledWith( - "[Plugin Extensions] Could not register added component with id 'alerting/home'. Reason: The component id does not match the id naming convention. Id should be prefixed with plugin id or grafana. e.g '/my-component-id/v1'." - ); - const currentState = await registry.getState(); - expect(Object.keys(currentState)).toHaveLength(0); - }); - it('should log a warning when added component id is not suffixed with component version', async () => { const registry = new AddedComponentsRegistry(); + const extensionPointId = 'grafana/test/home'; + registry.register({ - pluginId: 'grafana-basic-app', + pluginId, configs: [ { title: 'Component 1 title', description: 'Component 1 description', - targets: ['grafana/test/home'], + targets: [extensionPointId], component: () => React.createElement('div', null, 'Hello World1'), }, ], }); expect(consoleWarn).toHaveBeenCalledWith( - "[Plugin Extensions] Added component with id 'grafana/test/home' does not match the convention. It's recommended to suffix the id with the component version. e.g 'myorg-basic-app/my-component-id/v1'." + `[Plugin Extensions] Added component "Component 1 title": it's recommended to suffix the extension point id ("${extensionPointId}") with a version, e.g 'myorg-basic-app/extension-point/v1'.` ); const currentState = await registry.getState(); expect(Object.keys(currentState)).toHaveLength(1); @@ -338,13 +372,15 @@ describe('AddedComponentsRegistry', () => { it('should not register component when description is missing', async () => { const registry = new AddedComponentsRegistry(); + const extensionPointId = 'grafana/alerting/home'; + registry.register({ - pluginId: 'grafana-basic-app', + pluginId, configs: [ { title: 'Component 1 title', description: '', - targets: ['grafana/alerting/home'], + targets: [extensionPointId], component: () => React.createElement('div', null, 'Hello World1'), }, ], @@ -359,13 +395,15 @@ describe('AddedComponentsRegistry', () => { it('should not register component when title is missing', async () => { const registry = new AddedComponentsRegistry(); + const extensionPointId = 'grafana/alerting/home'; + registry.register({ - pluginId: 'grafana-basic-app', + pluginId, configs: [ { title: 'Component 1 title', description: '', - targets: ['grafana/alerting/home'], + targets: [extensionPointId], component: () => React.createElement('div', null, 'Hello World1'), }, ], @@ -382,15 +420,16 @@ describe('AddedComponentsRegistry', () => { it('should not be possible to register a component on a read-only registry', async () => { const registry = new AddedComponentsRegistry(); const readOnlyRegistry = registry.readOnly(); + const extensionPointId = 'grafana/alerting/home'; expect(() => { readOnlyRegistry.register({ - pluginId: 'grafana-basic-app', + pluginId, configs: [ { title: 'Component 1 title', description: '', - targets: ['grafana/alerting/home'], + targets: [extensionPointId], component: () => React.createElement('div', null, 'Hello World1'), }, ], @@ -434,4 +473,105 @@ describe('AddedComponentsRegistry', () => { expect(subscribeCallback).toHaveBeenCalledTimes(2); expect(Object.keys(subscribeCallback.mock.calls[1][0])).toEqual(['grafana/alerting/home']); }); + + it('should not register a component 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 AddedComponentsRegistry(); + const componentConfig = { + title: 'Component title', + description: 'Component description', + targets: ['grafana/alerting/home'], + component: () => React.createElement('div', null, 'Hello World1'), + }; + + // Make sure that the meta-info is empty + config.apps[pluginId].extensions.addedComponents = []; + + registry.register({ + pluginId, + configs: [componentConfig], + }); + + const currentState = await registry.getState(); + + expect(Object.keys(currentState)).toHaveLength(0); + expect(consoleWarn).toHaveBeenCalled(); + }); + + it('should register a component added by a core Grafana in dev-mode even if the meta-info is missing', async () => { + // Enabling dev mode + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + + const registry = new AddedComponentsRegistry(); + const componentConfig = { + title: 'Component title', + description: 'Component description', + targets: ['grafana/alerting/home'], + component: () => React.createElement('div', null, 'Hello World1'), + }; + + registry.register({ + pluginId: 'grafana', + configs: [componentConfig], + }); + + const currentState = await registry.getState(); + + expect(Object.keys(currentState)).toHaveLength(1); + expect(consoleWarn).not.toHaveBeenCalled(); + }); + + it('should register a component 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 AddedComponentsRegistry(); + const componentConfig = { + title: 'Component title', + description: 'Component description', + targets: ['grafana/alerting/home'], + component: () => React.createElement('div', null, 'Hello World1'), + }; + + // Make sure that the meta-info is empty + config.apps[pluginId].extensions.addedComponents = []; + + registry.register({ + pluginId, + configs: [componentConfig], + }); + + const currentState = await registry.getState(); + + expect(Object.keys(currentState)).toHaveLength(1); + expect(consoleWarn).not.toHaveBeenCalled(); + }); + + it('should register a component 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 AddedComponentsRegistry(); + const componentConfig = { + title: 'Component title', + description: 'Component description', + targets: ['grafana/alerting/home'], + component: () => React.createElement('div', null, 'Hello World1'), + }; + + // Make sure that the meta-info is empty + config.apps[pluginId].extensions.addedComponents = [componentConfig]; + + registry.register({ + pluginId, + configs: [componentConfig], + }); + + const currentState = await registry.getState(); + + expect(Object.keys(currentState)).toHaveLength(1); + expect(consoleWarn).not.toHaveBeenCalled(); + }); }); diff --git a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts index f95f3167fe6..1760f3d36cf 100644 --- a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts +++ b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts @@ -2,13 +2,8 @@ import { ReplaySubject } from 'rxjs'; import { PluginExtensionAddedComponentConfig } from '@grafana/data'; -import { logWarning, wrapWithPluginContext } from '../utils'; -import { - extensionPointEndsWithVersion, - isExtensionPointIdValid, - isGrafanaCoreExtensionPoint, - isReactComponent, -} from '../validators'; +import { isAddedComponentMetaInfoMissing, isGrafanaDevMode, logWarning, wrapWithPluginContext } from '../utils'; +import { extensionPointEndsWithVersion, isGrafanaCoreExtensionPoint, isReactComponent } from '../validators'; import { PluginExtensionConfigs, Registry, RegistryType } from './Registry'; @@ -56,18 +51,15 @@ export class AddedComponentsRegistry extends Registry< continue; } + if (pluginId !== 'grafana' && isGrafanaDevMode() && isAddedComponentMetaInfoMissing(pluginId, config)) { + continue; + } + const extensionPointIds = Array.isArray(config.targets) ? config.targets : [config.targets]; for (const extensionPointId of extensionPointIds) { - if (!isExtensionPointIdValid(pluginId, extensionPointId)) { - logWarning( - `Could not register added component with id '${extensionPointId}'. Reason: The component id does not match the id naming convention. Id should be prefixed with plugin id or grafana. e.g '/my-component-id/v1'.` - ); - continue; - } - if (!isGrafanaCoreExtensionPoint(extensionPointId) && !extensionPointEndsWithVersion(extensionPointId)) { logWarning( - `Added component with id '${extensionPointId}' does not match the convention. It's recommended to suffix the id with the component version. e.g 'myorg-basic-app/my-component-id/v1'.` + `Added component "${config.title}": it's recommended to suffix the extension point id ("${extensionPointId}") with a version, e.g 'myorg-basic-app/extension-point/v1'.` ); } diff --git a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts index a77eddb2a7d..45735806af9 100644 --- a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts +++ b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts @@ -1,14 +1,61 @@ import { firstValueFrom } from 'rxjs'; +import { PluginLoadingStrategy } from '@grafana/data'; +import { config } from '@grafana/runtime'; + +import { isGrafanaDevMode } from '../utils'; + import { AddedLinksRegistry } from './AddedLinksRegistry'; 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), +})); + describe('AddedLinksRegistry', () => { + const originalApps = config.apps; const consoleWarn = jest.fn(); + 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: { + addedLinks: [], + addedComponents: [], + exposedComponents: [], + extensionPoints: [], + }, + }; beforeEach(() => { global.console.warn = consoleWarn; consoleWarn.mockReset(); + jest.mocked(isGrafanaDevMode).mockReturnValue(false); + config.apps = { + [pluginId]: appPluginConfig, + }; + }); + + afterEach(() => { + config.apps = originalApps; }); it('should return empty registry when no extensions registered', async () => { @@ -19,7 +66,6 @@ describe('AddedLinksRegistry', () => { }); it('should be possible to register link extensions in the registry', async () => { - const pluginId = 'grafana-basic-app'; const addedLinksRegistry = new AddedLinksRegistry(); addedLinksRegistry.register({ @@ -580,4 +626,109 @@ describe('AddedLinksRegistry', () => { expect(subscribeCallback).toHaveBeenCalledTimes(2); expect(Object.keys(subscribeCallback.mock.calls[1][0])).toEqual(['plugins/myorg-basic-app/start']); }); + + it('should not register a link 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 AddedLinksRegistry(); + const linkConfig = { + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident`, + targets: 'grafana/dashboard/panel/menu', + configure: jest.fn().mockReturnValue({}), + }; + + // Make sure that the meta-info is empty + config.apps[pluginId].extensions.addedLinks = []; + + registry.register({ + pluginId, + configs: [linkConfig], + }); + + const currentState = await registry.getState(); + + expect(Object.keys(currentState)).toHaveLength(0); + expect(consoleWarn).toHaveBeenCalled(); + }); + + it('should register a link 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 AddedLinksRegistry(); + const linkConfig = { + title: 'Link 1', + description: 'Link 1 description', + path: `/a/grafana/declare-incident`, + targets: 'grafana/dashboard/panel/menu', + configure: jest.fn().mockReturnValue({}), + }; + + registry.register({ + pluginId: 'grafana', + configs: [linkConfig], + }); + + const currentState = await registry.getState(); + + expect(Object.keys(currentState)).toHaveLength(1); + expect(consoleWarn).not.toHaveBeenCalled(); + }); + + it('should register a link 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 AddedLinksRegistry(); + const linkConfig = { + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident`, + targets: 'grafana/dashboard/panel/menu', + configure: jest.fn().mockReturnValue({}), + }; + + // Make sure that the meta-info is empty + config.apps[pluginId].extensions.addedLinks = []; + + registry.register({ + pluginId, + configs: [linkConfig], + }); + + const currentState = await registry.getState(); + + expect(Object.keys(currentState)).toHaveLength(1); + expect(consoleWarn).not.toHaveBeenCalled(); + }); + + it('should register a link 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 AddedLinksRegistry(); + const linkConfig = { + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident`, + targets: ['grafana/dashboard/panel/menu'], + configure: jest.fn().mockReturnValue({}), + }; + + // Make sure that the meta-info is empty + config.apps[pluginId].extensions.addedLinks = [linkConfig]; + + registry.register({ + pluginId, + configs: [linkConfig], + }); + + const currentState = await registry.getState(); + + expect(Object.keys(currentState)).toHaveLength(1); + expect(consoleWarn).not.toHaveBeenCalled(); + }); }); diff --git a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts index d547f426258..d2f6207e496 100644 --- a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts +++ b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts @@ -3,11 +3,10 @@ import { ReplaySubject } from 'rxjs'; import { IconName, PluginExtensionAddedLinkConfig } from '@grafana/data'; import { PluginAddedLinksConfigureFunc, PluginExtensionEventHelpers } from '@grafana/data/src/types/pluginExtensions'; -import { logWarning } from '../utils'; +import { isAddedLinkMetaInfoMissing, isGrafanaDevMode, logWarning } from '../utils'; import { extensionPointEndsWithVersion, isConfigureFnValid, - isExtensionPointIdValid, isGrafanaCoreExtensionPoint, isLinkPathValid, } from '../validators'; @@ -73,18 +72,15 @@ export class AddedLinksRegistry extends Registry ({ + ...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), +})); + describe('ExposedComponentsRegistry', () => { const consoleWarn = jest.fn(); + 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: { + addedLinks: [], + addedComponents: [], + exposedComponents: [], + extensionPoints: [], + }, + }; beforeEach(() => { global.console.warn = consoleWarn; consoleWarn.mockReset(); + jest.mocked(isGrafanaDevMode).mockReturnValue(false); + config.apps = { + [pluginId]: appPluginConfig, + }; + }); + + afterEach(() => { + config.apps = originalApps; }); it('should return empty registry when no exposed components have been registered', async () => { @@ -397,4 +444,105 @@ describe('ExposedComponentsRegistry', () => { expect(subscribeCallback).toHaveBeenCalledTimes(2); expect(Object.keys(subscribeCallback.mock.calls[1][0])).toEqual([`${pluginId}/hello-world/v1`]); }); + + it('should not register an exposed component 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 ExposedComponentsRegistry(); + const componentConfig = { + id: `${pluginId}/exposed-component/v1`, + title: 'Component title', + description: 'Component description', + component: () => React.createElement('div', null, 'Hello World1'), + }; + + // Make sure that the meta-info is empty + config.apps[pluginId].extensions.exposedComponents = []; + + registry.register({ + pluginId, + configs: [componentConfig], + }); + + const currentState = await registry.getState(); + + expect(Object.keys(currentState)).toHaveLength(0); + expect(consoleWarn).toHaveBeenCalled(); + }); + + it('should register an exposed component added by a core Grafana in dev-mode even if the meta-info is missing', async () => { + // Enabling dev mode + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + + const registry = new ExposedComponentsRegistry(); + const componentConfig = { + id: `${pluginId}/exposed-component/v1`, + title: 'Component title', + description: 'Component description', + component: () => React.createElement('div', null, 'Hello World1'), + }; + + registry.register({ + pluginId: 'grafana', + configs: [componentConfig], + }); + + const currentState = await registry.getState(); + + expect(Object.keys(currentState)).toHaveLength(1); + expect(consoleWarn).not.toHaveBeenCalled(); + }); + + it('should register an exposed component 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 ExposedComponentsRegistry(); + const componentConfig = { + id: `${pluginId}/exposed-component/v1`, + title: 'Component title', + description: 'Component description', + component: () => React.createElement('div', null, 'Hello World1'), + }; + + // Make sure that the meta-info is empty + config.apps[pluginId].extensions.exposedComponents = []; + + registry.register({ + pluginId, + configs: [componentConfig], + }); + + const currentState = await registry.getState(); + + expect(Object.keys(currentState)).toHaveLength(1); + expect(consoleWarn).not.toHaveBeenCalled(); + }); + + it('should register an exposed component 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 ExposedComponentsRegistry(); + const componentConfig = { + id: `${pluginId}/exposed-component/v1`, + title: 'Component title', + description: 'Component description', + component: () => React.createElement('div', null, 'Hello World1'), + }; + + // Make sure that the meta-info is empty + config.apps[pluginId].extensions.exposedComponents = [componentConfig]; + + registry.register({ + pluginId, + configs: [componentConfig], + }); + + const currentState = await registry.getState(); + + expect(Object.keys(currentState)).toHaveLength(1); + expect(consoleWarn).not.toHaveBeenCalled(); + }); }); diff --git a/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts b/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts index 90bbca13c73..c84c89ab199 100644 --- a/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts +++ b/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts @@ -2,7 +2,7 @@ import { ReplaySubject } from 'rxjs'; import { PluginExtensionExposedComponentConfig } from '@grafana/data'; -import { logWarning } from '../utils'; +import { isExposedComponentMetaInfoMissing, isGrafanaDevMode, logWarning } from '../utils'; import { extensionPointEndsWithVersion } from '../validators'; import { Registry, RegistryType, PluginExtensionConfigs } from './Registry'; @@ -68,6 +68,10 @@ export class ExposedComponentsRegistry extends Registry< continue; } + if (pluginId !== 'grafana' && isGrafanaDevMode() && isExposedComponentMetaInfoMissing(pluginId, config)) { + continue; + } + registry[id] = { ...config, pluginId }; } diff --git a/public/app/features/plugins/extensions/usePluginComponent.test.tsx b/public/app/features/plugins/extensions/usePluginComponent.test.tsx index 8b5e644e040..7cf467b5e38 100644 --- a/public/app/features/plugins/extensions/usePluginComponent.test.tsx +++ b/public/app/features/plugins/extensions/usePluginComponent.test.tsx @@ -1,13 +1,14 @@ import { act, render, screen, waitFor } from '@testing-library/react'; import { renderHook } from '@testing-library/react-hooks'; +import { PluginContextProvider, PluginLoadingStrategy, PluginMeta, PluginType } from '@grafana/data'; +import { config } from '@grafana/runtime'; + import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext'; import { setupPluginExtensionRegistries } from './registry/setup'; import { PluginExtensionRegistries } from './registry/types'; import { usePluginComponent } from './usePluginComponent'; -import * as utils from './utils'; - -const wrapWithPluginContext = jest.spyOn(utils, 'wrapWithPluginContext'); +import { isGrafanaDevMode, wrapWithPluginContext } from './utils'; jest.mock('app/features/plugins/pluginSettings', () => ({ getPluginSettings: jest.fn().mockResolvedValue({ @@ -20,20 +21,111 @@ jest.mock('app/features/plugins/pluginSettings', () => ({ }), })); +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), + wrapWithPluginContext: jest.fn().mockImplementation((_, component: React.ReactNode) => component), +})); + describe('usePluginComponent()', () => { let registries: PluginExtensionRegistries; let wrapper: ({ children }: { children: React.ReactNode }) => JSX.Element; + let pluginMeta: PluginMeta; + let consoleWarnSpy: jest.SpyInstance; + const originalApps = config.apps; + const pluginId = 'myorg-extensions-app'; + const exposedComponentId = `${pluginId}/exposed-component/v1`; + const exposedComponentConfig = { + id: exposedComponentId, + title: 'Exposed component', + description: 'Exposed component description', + component: () =>
Hello World
, + }; + 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: { + addedLinks: [], + addedComponents: [], + // 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], + extensionPoints: [], + }, + }; beforeEach(() => { registries = setupPluginExtensionRegistries(); + jest.mocked(isGrafanaDevMode).mockReturnValue(false); + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); - wrapWithPluginContext.mockClear(); + jest.mocked(wrapWithPluginContext).mockClear(); + + pluginMeta = { + id: pluginId, + name: 'Extensions App', + type: PluginType.app, + module: '', + baseUrl: '', + info: { + author: { + name: 'MyOrg', + }, + description: 'App for testing extensions', + links: [], + logos: { + large: '', + small: '', + }, + screenshots: [], + updated: '2023-10-26T18:25:01Z', + version: '1.0.0', + }, + extensions: { + addedLinks: [], + addedComponents: [], + exposedComponents: [], + extensionPoints: [], + }, + dependencies: { + grafanaVersion: '8.0.0', + plugins: [], + extensions: { + exposedComponents: [], + }, + }, + }; + + config.apps = { + [pluginId]: appPluginConfig, + }; wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); }); + afterEach(() => { + config.apps = originalApps; + }); + it('should return null if there are no component exposed for the id', () => { const { result } = renderHook(() => usePluginComponent('foo/bar'), { wrapper }); @@ -42,15 +134,12 @@ describe('usePluginComponent()', () => { }); it('should return component, that can be rendered, from the registry', async () => { - const id = 'my-app-plugin/foo/bar/v1'; - const pluginId = 'my-app-plugin'; - registries.exposedComponentsRegistry.register({ pluginId, - configs: [{ id, title: 'not important', description: 'not important', component: () =>
Hello World
}], + configs: [exposedComponentConfig], }); - const { result } = renderHook(() => usePluginComponent(id), { wrapper }); + const { result } = renderHook(() => usePluginComponent(exposedComponentId), { wrapper }); const Component = result.current.component; act(() => { @@ -63,9 +152,7 @@ describe('usePluginComponent()', () => { }); it('should dynamically update when component is registered to the registry', async () => { - const id = 'my-app-plugin/foo/bar/v1'; - const pluginId = 'my-app-plugin'; - const { result, rerender } = renderHook(() => usePluginComponent(id), { wrapper }); + const { result, rerender } = renderHook(() => usePluginComponent(exposedComponentId), { wrapper }); // No extensions yet expect(result.current.component).toBeNull(); @@ -75,14 +162,7 @@ describe('usePluginComponent()', () => { act(() => { registries.exposedComponentsRegistry.register({ pluginId, - configs: [ - { - id, - title: 'not important', - description: 'not important', - component: () =>
Hello World
, - }, - ], + configs: [exposedComponentConfig], }); }); @@ -101,26 +181,132 @@ describe('usePluginComponent()', () => { }); it('should only render the hook once', async () => { - const pluginId = 'my-app-plugin'; - const id = `${pluginId}/foo/v1`; - // Add extensions to the registry act(() => { registries.exposedComponentsRegistry.register({ pluginId, - configs: [ - { - id, - title: 'not important', - description: 'not important', - component: () =>
Hello World
, - }, - ], + configs: [exposedComponentConfig], }); }); expect(wrapWithPluginContext).toHaveBeenCalledTimes(0); - renderHook(() => usePluginComponent(id), { wrapper }); + renderHook(() => usePluginComponent(exposedComponentId), { wrapper }); await waitFor(() => expect(wrapWithPluginContext).toHaveBeenCalledTimes(1)); }); + + it('should not validate the meta-info in production mode', () => { + // Empty list of exposed component ids in the plugin meta (from plugin.json) + wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + registries.exposedComponentsRegistry.register({ + pluginId, + configs: [exposedComponentConfig], + }); + + // Trying to render an exposed component that is not defined in the plugin meta + // (No restrictions due to isGrafanaDevMode() = false) + let { result } = renderHook(() => usePluginComponent(exposedComponentId), { wrapper }); + expect(result.current.component).not.toBe(null); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should not validate the meta-info in core Grafana', () => { + // Imitate running in dev mode + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + + // No plugin context -> used in Grafana core + wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + registries.exposedComponentsRegistry.register({ + pluginId, + configs: [exposedComponentConfig], + }); + + // Trying to render an extension point that is not defined in the plugin meta + // (No restrictions due to isGrafanaDevMode() = false) + let { result } = renderHook(() => usePluginComponent(exposedComponentId), { + wrapper, + }); + + expect(result.current.component).not.toBe(null); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should validate the meta-info in dev mode and if inside a plugin', () => { + // Imitate running in dev mode + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + + // Empty list of exposed component ids in the plugin meta (from plugin.json) + wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + registries.exposedComponentsRegistry.register({ + pluginId, + configs: [exposedComponentConfig], + }); + + // Shouldn't return the component, as it's not present in the plugin.json dependencies + let { result } = renderHook(() => usePluginComponent(exposedComponentId), { wrapper }); + expect(result.current.component).toBe(null); + expect(consoleWarnSpy).toHaveBeenCalled(); + }); + + it('should return the exposed component if the meta-info is correct and in dev mode', () => { + // Imitate running in dev mode + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + + wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + registries.exposedComponentsRegistry.register({ + pluginId, + configs: [exposedComponentConfig], + }); + + let { result } = renderHook(() => usePluginComponent(exposedComponentId), { wrapper }); + expect(result.current.component).not.toBe(null); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); }); diff --git a/public/app/features/plugins/extensions/usePluginComponent.tsx b/public/app/features/plugins/extensions/usePluginComponent.tsx index 7dfeb3e1f67..9ae43c27bbf 100644 --- a/public/app/features/plugins/extensions/usePluginComponent.tsx +++ b/public/app/features/plugins/extensions/usePluginComponent.tsx @@ -1,18 +1,33 @@ import { useMemo } from 'react'; import { useObservable } from 'react-use'; -import { UsePluginComponentResult } from '@grafana/runtime'; +import { usePluginContext } from '@grafana/data'; +import { logWarning, UsePluginComponentResult } from '@grafana/runtime'; import { useExposedComponentsRegistry } from './ExtensionRegistriesContext'; -import { wrapWithPluginContext } from './utils'; +import { isExposedComponentDependencyMissing, isGrafanaDevMode, wrapWithPluginContext } from './utils'; // Returns a component exposed by a plugin. // (Exposed components can be defined in plugins by calling .exposeComponent() on the AppPlugin instance.) export function usePluginComponent(id: string): UsePluginComponentResult { const registry = useExposedComponentsRegistry(); const registryState = useObservable(registry.asObservable()); + const pluginContext = usePluginContext(); 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; + + if (enableRestrictions && isExposedComponentDependencyMissing(id, pluginContext)) { + logWarning( + `usePluginComponent("${id}") - The exposed component ("${id}") is missing from the dependencies[] in the "plugin.json" file.` + ); + return { + isLoading: false, + component: null, + }; + } + if (!registryState?.[id]) { return { isLoading: false, @@ -26,5 +41,5 @@ export function usePluginComponent(id: string): UsePl isLoading: false, component: wrapWithPluginContext(registryItem.pluginId, registryItem.component), }; - }, [id, registryState]); + }, [id, pluginContext, registryState]); } diff --git a/public/app/features/plugins/extensions/usePluginComponents.test.tsx b/public/app/features/plugins/extensions/usePluginComponents.test.tsx index a9855f8006a..0c37e90e549 100644 --- a/public/app/features/plugins/extensions/usePluginComponents.test.tsx +++ b/public/app/features/plugins/extensions/usePluginComponents.test.tsx @@ -1,13 +1,13 @@ import { act, render, screen } from '@testing-library/react'; import { renderHook } from '@testing-library/react-hooks'; +import { PluginContextProvider, PluginMeta, PluginType } from '@grafana/data'; + import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext'; import { setupPluginExtensionRegistries } from './registry/setup'; import { PluginExtensionRegistries } from './registry/types'; import { usePluginComponents } from './usePluginComponents'; -import * as utils from './utils'; - -const wrapWithPluginContext = jest.spyOn(utils, 'wrapWithPluginContext'); +import { isGrafanaDevMode, wrapWithPluginContext } from './utils'; jest.mock('app/features/plugins/pluginSettings', () => ({ getPluginSettings: jest.fn().mockResolvedValue({ @@ -20,17 +20,69 @@ jest.mock('app/features/plugins/pluginSettings', () => ({ }), })); +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), + wrapWithPluginContext: jest.fn().mockImplementation((_, component: React.ReactNode) => component), +})); + describe('usePluginComponents()', () => { let registries: PluginExtensionRegistries; let wrapper: ({ children }: { children: React.ReactNode }) => JSX.Element; + let pluginMeta: PluginMeta; + let consoleWarnSpy: jest.SpyInstance; + const pluginId = 'myorg-extensions-app'; + const extensionPointId = `${pluginId}/extension-point/v1`; beforeEach(() => { + jest.mocked(isGrafanaDevMode).mockReturnValue(false); registries = setupPluginExtensionRegistries(); + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); - wrapWithPluginContext.mockClear(); + jest.mocked(wrapWithPluginContext).mockClear(); + + pluginMeta = { + id: pluginId, + name: 'Extensions App', + type: PluginType.app, + module: '', + baseUrl: '', + info: { + author: { + name: 'MyOrg', + }, + description: 'App for testing extensions', + links: [], + logos: { + large: '', + small: '', + }, + screenshots: [], + updated: '2023-10-26T18:25:01Z', + version: '1.0.0', + }, + extensions: { + addedLinks: [], + addedComponents: [], + exposedComponents: [], + extensionPoints: [], + }, + dependencies: { + grafanaVersion: '8.0.0', + plugins: [], + extensions: { + exposedComponents: [], + }, + }, + }; wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} + + {children} + ); }); @@ -47,9 +99,6 @@ describe('usePluginComponents()', () => { }); it('should only return the plugin extension components for the given extension point ids', async () => { - const extensionPointId = 'plugins/foo/bar/v1'; - const pluginId = 'my-app-plugin'; - registries.addedComponentsRegistry.register({ pluginId, configs: [ @@ -81,14 +130,13 @@ describe('usePluginComponents()', () => { act(() => { render(result.current.components.map((Component, index) => )); }); + expect(await screen.findByText('Hello World1')).toBeVisible(); expect(await screen.findByText('Hello World2')).toBeVisible(); - expect(await screen.queryByText('Hello World3')).toBeNull(); + expect(screen.queryByText('Hello World3')).toBeNull(); }); it('should dynamically update the extensions registered for a certain extension point', () => { - const extensionPointId = 'plugins/foo/bar/v1'; - const pluginId = 'my-app-plugin'; let { result, rerender } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper }); // No extensions yet @@ -128,8 +176,7 @@ describe('usePluginComponents()', () => { }); it('should honour the limitPerPlugin arg if its set', () => { - const extensionPointId = 'plugins/foo/bar/v1'; - const plugins = ['my-app-plugin1', 'my-app-plugin2', 'my-app-plugin3']; + const plugins = ['my-awesome1-app', 'my-awesome2-app', 'my-awesome3-app']; let { result, rerender } = renderHook(() => usePluginComponents({ extensionPointId, limitPerPlugin: 2 }), { wrapper, }); @@ -144,19 +191,19 @@ describe('usePluginComponents()', () => { pluginId, configs: [ { - targets: extensionPointId, + targets: [extensionPointId], title: '1', description: '1', component: () =>
Hello World1
, }, { - targets: extensionPointId, + targets: [extensionPointId], title: '2', description: '2', component: () =>
Hello World2
, }, { - targets: extensionPointId, + targets: [extensionPointId], title: '3', description: '3', component: () =>
Hello World3
, @@ -171,4 +218,191 @@ describe('usePluginComponents()', () => { expect(result.current.components.length).toBe(6); }); + + it('should not validate the extension point meta-info in production mode', () => { + // Empty list of extension points in the plugin meta (from plugin.json) + wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + registries.addedComponentsRegistry.register({ + pluginId, + configs: [ + { + targets: extensionPointId, + title: '1', + description: '1', + component: () =>
Component
, + }, + ], + }); + + // Trying to render an extension point that is not defined in the plugin meta + // (No restrictions due to isGrafanaDevMode() = false) + let { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper }); + expect(result.current.components.length).toBe(1); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should not validate the extension point id in production mode', () => { + // Empty list of extension points in the plugin meta (from plugin.json) + wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + // Trying to render an extension point that is not defined in the plugin meta + // (No restrictions due to isGrafanaDevMode() = false) + let { result } = renderHook(() => usePluginComponents({ extensionPointId: 'invalid-extension-point-id' }), { + wrapper, + }); + expect(result.current.components.length).toBe(0); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should not validate the extension point meta-info if used in Grafana core (no plugin context)', () => { + // Imitate running in dev mode + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + + // No plugin context -> used in Grafana core + wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + // Adding an extension to the extension point + registries.addedComponentsRegistry.register({ + pluginId: 'grafana', // Only core Grafana can register extensions without a plugin context + configs: [ + { + targets: 'grafana/extension-point/v1', + title: '1', + description: '1', + component: () =>
Component
, + }, + ], + }); + + let { result } = renderHook(() => usePluginComponents({ extensionPointId: 'grafana/extension-point/v1' }), { + wrapper, + }); + expect(result.current.components.length).toBe(1); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should not validate the extension point id if used in Grafana core (no plugin context)', () => { + // Imitate running in dev mode + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + + // No plugin context -> used in Grafana core + wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + let { result } = renderHook(() => usePluginComponents({ extensionPointId: 'invalid-extension-point-id' }), { + wrapper, + }); + expect(result.current.components.length).toBe(0); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should validate if the extension point meta-info is correct if in dev-mode and used by a plugin', () => { + // Imitate running in dev mode + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + + // Empty list of extension points in the plugin meta (from plugin.json) + wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + // Adding an extension to the extension point - it should not be returned later + registries.addedComponentsRegistry.register({ + pluginId, + configs: [ + { + targets: extensionPointId, + title: '1', + description: '1', + component: () =>
Component
, + }, + ], + }); + + // Trying to render an extension point that is not defined in the plugin meta + let { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper }); + expect(result.current.components.length).toBe(0); + expect(consoleWarnSpy).toHaveBeenCalled(); + }); + + it('should not log a warning if the extension point meta-info is correct if in dev-mode and used by a plugin', () => { + // Imitate running in dev mode + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + + // The extension point is listed in the plugin meta (from plugin.json) + wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + // Adding an extension to the extension point - it should not be returned later + registries.addedComponentsRegistry.register({ + pluginId, + configs: [ + { + targets: extensionPointId, + title: '1', + description: '1', + component: () =>
Component
, + }, + ], + }); + + // Trying to render an extension point that is not defined in the plugin meta + let { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper }); + expect(result.current.components.length).toBe(0); + expect(consoleWarnSpy).toHaveBeenCalled(); + }); }); diff --git a/public/app/features/plugins/extensions/usePluginComponents.tsx b/public/app/features/plugins/extensions/usePluginComponents.tsx index bb19a09608f..92dbc4e676c 100644 --- a/public/app/features/plugins/extensions/usePluginComponents.tsx +++ b/public/app/features/plugins/extensions/usePluginComponents.tsx @@ -1,12 +1,15 @@ import { useMemo } from 'react'; import { useObservable } from 'react-use'; +import { usePluginContext } from '@grafana/data'; import { UsePluginComponentOptions, UsePluginComponentsResult, } from '@grafana/runtime/src/services/pluginExtensions/getPluginExtensions'; import { useAddedComponentsRegistry } from './ExtensionRegistriesContext'; +import { isExtensionPointMetaInfoMissing, isGrafanaDevMode, logWarning } from './utils'; +import { isExtensionPointIdValid } from './validators'; // Returns an array of component extensions for the given extension point export function usePluginComponents({ @@ -15,10 +18,34 @@ export function usePluginComponents({ }: UsePluginComponentOptions): UsePluginComponentsResult { const registry = useAddedComponentsRegistry(); const registryState = useObservable(registry.asObservable()); + const pluginContext = usePluginContext(); 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 components: Array> = []; const extensionsByPlugin: Record = {}; + const pluginId = pluginContext?.meta.id ?? ''; + + if (enableRestrictions && !isExtensionPointIdValid({ extensionPointId, pluginId })) { + logWarning( + `Extension point usePluginComponents("${extensionPointId}") - the id should be prefixed with your plugin id ("${pluginId}/").` + ); + return { + isLoading: false, + components: [], + }; + } + + if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)) { + logWarning( + `usePluginComponents("${extensionPointId}") - The extension point is missing from the "plugin.json" file.` + ); + return { + isLoading: false, + components: [], + }; + } for (const registryItem of registryState?.[extensionPointId] ?? []) { const { pluginId } = registryItem; @@ -40,5 +67,5 @@ export function usePluginComponents({ isLoading: false, components, }; - }, [extensionPointId, limitPerPlugin, registryState]); + }, [extensionPointId, limitPerPlugin, pluginContext, registryState]); } diff --git a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx index 17ff510f8ef..71254272588 100644 --- a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx +++ b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx @@ -9,6 +9,8 @@ import { createUsePluginExtensions } from './usePluginExtensions'; describe('usePluginExtensions()', () => { let registries: PluginExtensionRegistries; + const pluginId = 'myorg-extensions-app'; + const extensionPointId = `${pluginId}/extension-point/v1`; beforeEach(() => { registries = { @@ -30,9 +32,6 @@ describe('usePluginExtensions()', () => { }); it('should return the plugin link extensions from the registry', () => { - const extensionPointId = 'plugins/foo/bar/v1'; - const pluginId = 'my-app-plugin'; - registries.addedLinksRegistry.register({ pluginId, configs: [ @@ -60,21 +59,19 @@ describe('usePluginExtensions()', () => { }); it('should return the plugin component extensions from the registry', () => { - const linkExtensionPointId = 'plugins/foo/bar/v1'; - const componentExtensionPointId = 'plugins/component/bar/v1'; - const pluginId = 'my-app-plugin'; + const componentExtensionPointId = `${pluginId}/component/v1`; registries.addedLinksRegistry.register({ pluginId, configs: [ { - targets: linkExtensionPointId, + targets: extensionPointId, title: '1', description: '1', path: `/a/${pluginId}/2`, }, { - targets: linkExtensionPointId, + targets: extensionPointId, title: '2', description: '2', path: `/a/${pluginId}/2`, @@ -109,8 +106,6 @@ describe('usePluginExtensions()', () => { }); it('should dynamically update the extensions registered for a certain extension point', () => { - const extensionPointId = 'plugins/foo/bar/v1'; - const pluginId = 'my-app-plugin'; const usePluginExtensions = createUsePluginExtensions(registries); let { result, rerender } = renderHook(() => usePluginExtensions({ extensionPointId })); @@ -149,7 +144,6 @@ describe('usePluginExtensions()', () => { it('should only render the hook once', () => { const addedComponentsRegistrySpy = jest.spyOn(registries.addedComponentsRegistry, 'asObservable'); const addedLinksRegistrySpy = jest.spyOn(registries.addedLinksRegistry, 'asObservable'); - const extensionPointId = 'plugins/foo/bar/v1'; const usePluginExtensions = createUsePluginExtensions(registries); renderHook(() => usePluginExtensions({ extensionPointId })); @@ -158,8 +152,6 @@ describe('usePluginExtensions()', () => { }); it('should return the same extensions object if the context object is the same', async () => { - const extensionPointId = 'plugins/foo/bar/v1'; - const pluginId = 'my-app-plugin'; const usePluginExtensions = createUsePluginExtensions(registries); // Add extensions to the registry @@ -200,8 +192,6 @@ describe('usePluginExtensions()', () => { }); it('should return a new extensions object if the context object is different', () => { - const extensionPointId = 'plugins/foo/bar/v1'; - const pluginId = 'my-app-plugin'; const usePluginExtensions = createUsePluginExtensions(registries); // Add extensions to the registry @@ -232,8 +222,6 @@ describe('usePluginExtensions()', () => { }); it('should return a new extensions object if the registry changes but the context object is the same', () => { - const extensionPointId = 'plugins/foo/bar/v1'; - const pluginId = 'my-app-plugin'; const context = {}; const usePluginExtensions = createUsePluginExtensions(registries); diff --git a/public/app/features/plugins/extensions/usePluginExtensions.tsx b/public/app/features/plugins/extensions/usePluginExtensions.tsx index f67c24ba5c5..8b5c3e69f37 100644 --- a/public/app/features/plugins/extensions/usePluginExtensions.tsx +++ b/public/app/features/plugins/extensions/usePluginExtensions.tsx @@ -1,31 +1,59 @@ import { useMemo } from 'react'; import { useObservable } from 'react-use'; -import { PluginExtension } from '@grafana/data'; +import { PluginExtension, usePluginContext } from '@grafana/data'; import { GetPluginExtensionsOptions, UsePluginExtensionsResult } from '@grafana/runtime'; import { useSidecar } from 'app/core/context/SidecarContext'; import { getPluginExtensions } from './getPluginExtensions'; import { PluginExtensionRegistries } from './registry/types'; +import { isExtensionPointMetaInfoMissing, isGrafanaDevMode, logWarning } from './utils'; +import { isExtensionPointIdValid } from './validators'; export function createUsePluginExtensions(registries: PluginExtensionRegistries) { const observableAddedComponentsRegistry = registries.addedComponentsRegistry.asObservable(); const observableAddedLinksRegistry = registries.addedLinksRegistry.asObservable(); return function usePluginExtensions(options: GetPluginExtensionsOptions): UsePluginExtensionsResult { + const pluginContext = usePluginContext(); const addedComponentsRegistry = useObservable(observableAddedComponentsRegistry); const addedLinksRegistry = useObservable(observableAddedLinksRegistry); const { activePluginId } = useSidecar(); + const { extensionPointId, context, limitPerPlugin } = options; const { extensions } = useMemo(() => { + // For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana. + const enableRestrictions = isGrafanaDevMode() && pluginContext !== null; + const pluginId = pluginContext?.meta.id ?? ''; + if (!addedLinksRegistry && !addedComponentsRegistry) { return { extensions: [], isLoading: false }; } + if (enableRestrictions && !isExtensionPointIdValid({ extensionPointId, pluginId })) { + logWarning( + `Extension point usePluginExtensions("${extensionPointId}") - the id should be prefixed with your plugin id ("${pluginId}/").` + ); + return { + isLoading: false, + extensions: [], + }; + } + + if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)) { + logWarning( + `Invalid extension point. Reason: The extension point is not declared in the "plugin.json" file. ExtensionPointId: "${extensionPointId}"` + ); + return { + isLoading: false, + extensions: [], + }; + } + return getPluginExtensions({ - extensionPointId: options.extensionPointId, - context: options.context, - limitPerPlugin: options.limitPerPlugin, + extensionPointId, + context, + limitPerPlugin, addedComponentsRegistry, addedLinksRegistry, }); @@ -36,10 +64,11 @@ export function createUsePluginExtensions(registries: PluginExtensionRegistries) }, [ addedLinksRegistry, addedComponentsRegistry, - options.extensionPointId, - options.context, - options.limitPerPlugin, + extensionPointId, + context, + limitPerPlugin, activePluginId, + pluginContext, ]); return { extensions, isLoading: false }; diff --git a/public/app/features/plugins/extensions/usePluginLinks.test.tsx b/public/app/features/plugins/extensions/usePluginLinks.test.tsx index c061932ac73..d4d0cc6a843 100644 --- a/public/app/features/plugins/extensions/usePluginLinks.test.tsx +++ b/public/app/features/plugins/extensions/usePluginLinks.test.tsx @@ -1,10 +1,13 @@ import { act } from '@testing-library/react'; import { renderHook } from '@testing-library/react-hooks'; +import { PluginContextProvider, PluginMeta, PluginType } from '@grafana/data'; + import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext'; import { setupPluginExtensionRegistries } from './registry/setup'; import { PluginExtensionRegistries } from './registry/types'; import { usePluginLinks } from './usePluginLinks'; +import { isGrafanaDevMode } from './utils'; jest.mock('app/features/plugins/pluginSettings', () => ({ getPluginSettings: jest.fn().mockResolvedValue({ @@ -17,15 +20,66 @@ jest.mock('app/features/plugins/pluginSettings', () => ({ }), })); +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), +})); + describe('usePluginLinks()', () => { let registries: PluginExtensionRegistries; let wrapper: ({ children }: { children: React.ReactNode }) => JSX.Element; + let pluginMeta: PluginMeta; + let consoleWarnSpy: jest.SpyInstance; + const pluginId = 'myorg-extensions-app'; + const extensionPointId = `${pluginId}/extension-point/v1`; beforeEach(() => { + jest.mocked(isGrafanaDevMode).mockReturnValue(false); registries = setupPluginExtensionRegistries(); + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + pluginMeta = { + id: pluginId, + name: 'Extensions App', + type: PluginType.app, + module: '', + baseUrl: '', + info: { + author: { + name: 'MyOrg', + }, + description: 'App for testing extensions', + links: [], + logos: { + large: '', + small: '', + }, + screenshots: [], + updated: '2023-10-26T18:25:01Z', + version: '1.0.0', + }, + extensions: { + addedLinks: [], + addedComponents: [], + exposedComponents: [], + extensionPoints: [], + }, + dependencies: { + grafanaVersion: '8.0.0', + plugins: [], + extensions: { + exposedComponents: [], + }, + }, + }; wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} + + {children} + ); }); @@ -42,9 +96,6 @@ describe('usePluginLinks()', () => { }); it('should only return the link extensions for the given extension point ids', async () => { - const extensionPointId = 'plugins/foo/bar/v1'; - const pluginId = 'my-app-plugin'; - registries.addedLinksRegistry.register({ pluginId, configs: [ @@ -77,8 +128,6 @@ describe('usePluginLinks()', () => { }); it('should dynamically update the extensions registered for a certain extension point', () => { - const extensionPointId = 'plugins/foo/bar/v1'; - const pluginId = 'my-app-plugin'; let { result, rerender } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper }); // No extensions yet @@ -112,4 +161,179 @@ describe('usePluginLinks()', () => { expect(result.current.links[0].title).toBe('1'); expect(result.current.links[1].title).toBe('2'); }); + + it('should not validate the extension point meta-info in production mode', () => { + // Empty list of extension points in the plugin meta (from plugin.json) + wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + registries.addedLinksRegistry.register({ + pluginId, + configs: [ + { + targets: extensionPointId, + title: '1', + description: '1', + path: `/a/${pluginId}/2`, + }, + ], + }); + + // Trying to render an extension point that is not defined in the plugin meta + // (No restrictions due to isGrafanaDevMode() = false) + let { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper }); + expect(result.current.links.length).toBe(1); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should not validate the extension point id in production mode', () => { + // Empty list of extension points in the plugin meta (from plugin.json) + wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + // Trying to render an extension point that is not defined in the plugin meta + // (No restrictions due to isGrafanaDevMode() = false) + let { result } = renderHook(() => usePluginLinks({ extensionPointId: 'invalid-extension-point-id' }), { wrapper }); + expect(result.current.links.length).toBe(0); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should not validate the extension point meta-info if used in Grafana core (no plugin context)', () => { + // Imitate running in dev mode + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + + // No plugin context -> used in Grafana core + wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + // Adding an extension to the extension point + registries.addedLinksRegistry.register({ + pluginId: 'grafana', // Only core Grafana can register extensions without a plugin context + configs: [ + { + targets: 'grafana/extension-point/v1', + title: '1', + description: '1', + path: `/a/grafana/${pluginId}/2`, + }, + ], + }); + + let { result } = renderHook(() => usePluginLinks({ extensionPointId: 'grafana/extension-point/v1' }), { wrapper }); + expect(result.current.links.length).toBe(1); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should not validate the extension point id if used in Grafana core (no plugin context)', () => { + // Imitate running in dev mode + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + + // No plugin context -> used in Grafana core + wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + let { result } = renderHook(() => usePluginLinks({ extensionPointId: 'invalid-extension-point-id' }), { wrapper }); + expect(result.current.links.length).toBe(0); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should validate if the extension point meta-info is correct if in dev-mode and used by a plugin', () => { + // Imitate running in dev mode + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + + // Empty list of extension points in the plugin meta (from plugin.json) + wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + // Adding an extension to the extension point - it should not be returned later + registries.addedLinksRegistry.register({ + pluginId, + configs: [ + { + targets: extensionPointId, + title: '1', + description: '1', + path: `/a/${pluginId}/2`, + }, + ], + }); + + // Trying to render an extension point that is not defined in the plugin meta + let { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper }); + expect(result.current.links.length).toBe(0); + expect(consoleWarnSpy).toHaveBeenCalled(); + }); + + it('should not log a warning if the extension point meta-info is correct if in dev-mode and used by a plugin', () => { + // Imitate running in dev mode + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + + // Empty list of extension points in the plugin meta (from plugin.json) + wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + // Adding an extension to the extension point - it should not be returned later + registries.addedLinksRegistry.register({ + pluginId, + configs: [ + { + targets: extensionPointId, + title: '1', + description: '1', + path: `/a/${pluginId}/2`, + }, + ], + }); + + // Trying to render an extension point that is not defined in the plugin meta + let { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper }); + expect(result.current.links.length).toBe(0); + expect(consoleWarnSpy).toHaveBeenCalled(); + }); }); diff --git a/public/app/features/plugins/extensions/usePluginLinks.tsx b/public/app/features/plugins/extensions/usePluginLinks.tsx index 723ea6cd9bf..1f868b01c77 100644 --- a/public/app/features/plugins/extensions/usePluginLinks.tsx +++ b/public/app/features/plugins/extensions/usePluginLinks.tsx @@ -2,7 +2,7 @@ import { isString } from 'lodash'; import { useMemo } from 'react'; import { useObservable } from 'react-use'; -import { PluginExtensionLink, PluginExtensionTypes } from '@grafana/data'; +import { PluginExtensionLink, PluginExtensionTypes, usePluginContext } from '@grafana/data'; import { UsePluginLinksOptions, UsePluginLinksResult, @@ -15,7 +15,11 @@ import { getLinkExtensionOverrides, getLinkExtensionPathWithTracking, getReadOnlyProxy, + isExtensionPointMetaInfoMissing, + isGrafanaDevMode, + logWarning, } from './utils'; +import { isExtensionPointIdValid } from './validators'; // Returns an array of component extensions for the given extension point export function usePluginLinks({ @@ -24,9 +28,34 @@ export function usePluginLinks({ context, }: UsePluginLinksOptions): UsePluginLinksResult { const registry = useAddedLinksRegistry(); + const pluginContext = usePluginContext(); const registryState = useObservable(registry.asObservable()); 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 !== null; + const pluginId = pluginContext?.meta.id ?? ''; + + if (enableRestrictions && !isExtensionPointIdValid({ extensionPointId, pluginId })) { + logWarning( + `Extension point usePluginLinks("${extensionPointId}") - the id should be prefixed with your plugin id ("${pluginId}/").` + ); + return { + isLoading: false, + links: [], + }; + } + + if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)) { + logWarning( + `Invalid extension point. Reason: The extension point is not declared in the "plugin.json" file. ExtensionPointId: "${extensionPointId}"` + ); + return { + isLoading: false, + links: [], + }; + } + if (!registryState || !registryState[extensionPointId]) { return { isLoading: false, @@ -80,5 +109,5 @@ export function usePluginLinks({ isLoading: false, links: extensions, }; - }, [context, extensionPointId, limitPerPlugin, registryState]); + }, [context, extensionPointId, limitPerPlugin, registryState, pluginContext]); } diff --git a/public/app/features/plugins/extensions/utils.test.tsx b/public/app/features/plugins/extensions/utils.test.tsx index aca0f0797cf..3697fe0b71f 100644 --- a/public/app/features/plugins/extensions/utils.test.tsx +++ b/public/app/features/plugins/extensions/utils.test.tsx @@ -1,7 +1,15 @@ import { render, screen } from '@testing-library/react'; import { type Unsubscribable } from 'rxjs'; -import { dateTime, usePluginContext } from '@grafana/data'; +import { + dateTime, + PluginContextType, + PluginExtensionPoints, + PluginLoadingStrategy, + PluginType, + usePluginContext, +} from '@grafana/data'; +import { config } from '@grafana/runtime'; import appEvents from 'app/core/app_events'; import { ShowModalReactEvent } from 'app/types/events'; @@ -11,6 +19,11 @@ import { getReadOnlyProxy, createOpenModalFunction, wrapWithPluginContext, + isAddedLinkMetaInfoMissing, + isAddedComponentMetaInfoMissing, + isExposedComponentMetaInfoMissing, + isExposedComponentDependencyMissing, + isExtensionPointMetaInfoMissing, } from './utils'; jest.mock('app/features/plugins/pluginSettings', () => ({ @@ -396,7 +409,7 @@ describe('Plugin Extensions / Utils', () => { const ModalContent = () => { const context = usePluginContext(); - return
Version: {context.meta.info.version}
; + return
Version: {context!.meta.info.version}
; }; openModal({ @@ -415,13 +428,13 @@ describe('Plugin Extensions / Utils', () => { }; const ExampleComponent = (props: ExampleComponentProps) => { - const { meta } = usePluginContext(); + const pluginContext = usePluginContext(); const audience = props.audience || 'Grafana'; return (
-

Hello {audience}!

Version: {meta.info.version} +

Hello {audience}!

Version: {pluginContext!.meta.info.version}
); }; @@ -446,4 +459,446 @@ describe('Plugin Extensions / Utils', () => { expect(screen.getByText('Version: 1.0.0')).toBeVisible(); }); }); + + describe('isAddedLinkMetaInfoMissing()', () => { + let consoleWarnSpy: jest.SpyInstance; + const originalApps = config.apps; + const pluginId = 'myorg-extensions-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: { + addedLinks: [], + addedComponents: [], + exposedComponents: [], + extensionPoints: [], + }, + }; + const extensionConfig = { + targets: [PluginExtensionPoints.DashboardPanelMenu], + title: 'Link title', + description: 'Link description', + }; + + beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + config.apps = { + [pluginId]: appPluginConfig, + }; + }); + + afterEach(() => { + config.apps = originalApps; + }); + + it('should return FALSE if the meta-info in the plugin.json is correct', () => { + config.apps[pluginId].extensions.addedLinks.push(extensionConfig); + + const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig); + + expect(returnValue).toBe(false); + expect(consoleWarnSpy).toHaveBeenCalledTimes(0); + }); + + it('should return TRUE and log a warning if the app config is not found', () => { + delete config.apps[pluginId]; + + const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig); + + expect(returnValue).toBe(true); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy.mock.calls[0][0]).toMatch("couldn't find app plugin"); + }); + + it('should return TRUE and log a warning if the link has no meta-info in the plugin.json', () => { + config.apps[pluginId].extensions.addedLinks = []; + + const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig); + + expect(returnValue).toBe(true); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy.mock.calls[0][0]).toMatch('not registered in the plugin.json'); + }); + + it('should return TRUE and log a warning if the "targets" do not match', () => { + config.apps[pluginId].extensions.addedLinks.push(extensionConfig); + + const returnValue = isAddedLinkMetaInfoMissing(pluginId, { + ...extensionConfig, + targets: [PluginExtensionPoints.DashboardPanelMenu, PluginExtensionPoints.ExploreToolbarAction], + }); + + expect(returnValue).toBe(true); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy.mock.calls[0][0]).toMatch('"targets" don\'t match'); + }); + + it('should return TRUE and log a warning if the "description" does not match', () => { + config.apps[pluginId].extensions.addedLinks.push(extensionConfig); + + const returnValue = isAddedLinkMetaInfoMissing(pluginId, { + ...extensionConfig, + description: 'Link description UPDATED', + }); + + expect(returnValue).toBe(true); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy.mock.calls[0][0]).toMatch('"description" doesn\'t match'); + }); + }); + + describe('isAddedComponentMetaInfoMissing()', () => { + let consoleWarnSpy: jest.SpyInstance; + const originalApps = config.apps; + const pluginId = 'myorg-extensions-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: { + addedLinks: [], + addedComponents: [], + exposedComponents: [], + extensionPoints: [], + }, + }; + const extensionConfig = { + targets: [PluginExtensionPoints.DashboardPanelMenu], + title: 'Component title', + description: 'Component description', + component: () =>
Component content
, + }; + + beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + config.apps = { + [pluginId]: appPluginConfig, + }; + }); + + afterEach(() => { + config.apps = originalApps; + }); + + it('should return FALSE if the meta-info in the plugin.json is correct', () => { + config.apps[pluginId].extensions.addedComponents.push(extensionConfig); + + const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig); + + expect(returnValue).toBe(false); + expect(consoleWarnSpy).toHaveBeenCalledTimes(0); + }); + + it('should return TRUE and log a warning if the app config is not found', () => { + delete config.apps[pluginId]; + + const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig); + + expect(returnValue).toBe(true); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy.mock.calls[0][0]).toMatch("couldn't find app plugin"); + }); + + it('should return TRUE and log a warning if the Component has no meta-info in the plugin.json', () => { + config.apps[pluginId].extensions.addedComponents = []; + + const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig); + + expect(returnValue).toBe(true); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy.mock.calls[0][0]).toMatch('not registered in the plugin.json'); + }); + + it('should return TRUE and log a warning if the "targets" do not match', () => { + config.apps[pluginId].extensions.addedComponents.push(extensionConfig); + + const returnValue = isAddedComponentMetaInfoMissing(pluginId, { + ...extensionConfig, + targets: [PluginExtensionPoints.ExploreToolbarAction], + }); + + expect(returnValue).toBe(true); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy.mock.calls[0][0]).toMatch('"targets" don\'t match'); + }); + + it('should return TRUE and log a warning if the "description" does not match', () => { + config.apps[pluginId].extensions.addedComponents.push(extensionConfig); + + const returnValue = isAddedComponentMetaInfoMissing(pluginId, { + ...extensionConfig, + description: 'UPDATED', + }); + + expect(returnValue).toBe(true); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy.mock.calls[0][0]).toMatch('"description" doesn\'t match'); + }); + }); + + describe('isExposedComponentMetaInfoMissing()', () => { + let consoleWarnSpy: jest.SpyInstance; + const originalApps = config.apps; + const pluginId = 'myorg-extensions-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: { + addedLinks: [], + addedComponents: [], + exposedComponents: [], + extensionPoints: [], + }, + }; + const exposedComponentConfig = { + id: `${pluginId}/component/v1`, + title: 'Exposed component', + description: 'Exposed component description', + component: () =>
Component content
, + }; + + beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + config.apps = { + [pluginId]: appPluginConfig, + }; + }); + + afterEach(() => { + config.apps = originalApps; + }); + + it('should return FALSE if the meta-info in the plugin.json is correct', () => { + config.apps[pluginId].extensions.exposedComponents.push(exposedComponentConfig); + + const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig); + + expect(returnValue).toBe(false); + expect(consoleWarnSpy).toHaveBeenCalledTimes(0); + }); + + it('should return TRUE and log a warning if the app config is not found', () => { + delete config.apps[pluginId]; + + const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig); + + expect(returnValue).toBe(true); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy.mock.calls[0][0]).toMatch("couldn't find app plugin"); + }); + + it('should return TRUE and log a warning if the exposed component has no meta-info in the plugin.json', () => { + config.apps[pluginId].extensions.exposedComponents = []; + + const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig); + + expect(returnValue).toBe(true); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy.mock.calls[0][0]).toMatch('not registered in the plugin.json'); + }); + + it('should return TRUE and log a warning if the title does not match', () => { + config.apps[pluginId].extensions.exposedComponents.push(exposedComponentConfig); + + const returnValue = isExposedComponentMetaInfoMissing(pluginId, { + ...exposedComponentConfig, + title: 'UPDATED', + }); + + expect(returnValue).toBe(true); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy.mock.calls[0][0]).toMatch('"title" doesn\'t match'); + }); + + it('should return TRUE and log a warning if the "description" does not match', () => { + config.apps[pluginId].extensions.exposedComponents.push(exposedComponentConfig); + + const returnValue = isExposedComponentMetaInfoMissing(pluginId, { + ...exposedComponentConfig, + description: 'UPDATED', + }); + + expect(returnValue).toBe(true); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy.mock.calls[0][0]).toMatch('"description" doesn\'t match'); + }); + }); + + describe('isExposedComponentDependencyMissing()', () => { + let consoleWarnSpy: jest.SpyInstance; + let pluginContext: PluginContextType; + const pluginId = 'myorg-extensions-app'; + const exposedComponentId = `${pluginId}/component/v1`; + + beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + pluginContext = { + meta: { + id: pluginId, + name: 'Extensions App', + type: PluginType.app, + module: '', + baseUrl: '', + info: { + author: { + name: 'MyOrg', + }, + description: 'App for testing extensions', + links: [], + logos: { + large: '', + small: '', + }, + screenshots: [], + updated: '2023-10-26T18:25:01Z', + version: '1.0.0', + }, + dependencies: { + grafanaVersion: '8.0.0', + plugins: [], + extensions: { + exposedComponents: [], + }, + }, + }, + }; + }); + + it('should return FALSE if the meta-info in the plugin.json is correct', () => { + pluginContext.meta.dependencies?.extensions.exposedComponents.push(exposedComponentId); + + const returnValue = isExposedComponentDependencyMissing(exposedComponentId, pluginContext); + + expect(returnValue).toBe(false); + expect(consoleWarnSpy).toHaveBeenCalledTimes(0); + }); + + it('should return TRUE and log a warning if the dependencies are missing', () => { + delete pluginContext.meta.dependencies; + + const returnValue = isExposedComponentDependencyMissing(exposedComponentId, pluginContext); + + expect(returnValue).toBe(true); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy.mock.calls[0][0]).toMatch(`Using exposed component "${exposedComponentId}"`); + }); + + it('should return TRUE and log a warning if the exposed component id is not specified in the list of dependencies', () => { + const returnValue = isExposedComponentDependencyMissing(exposedComponentId, pluginContext); + + expect(returnValue).toBe(true); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy.mock.calls[0][0]).toMatch(`Using exposed component "${exposedComponentId}"`); + }); + }); + + describe('isExtensionPointMetaInfoMissing()', () => { + let consoleWarnSpy: jest.SpyInstance; + let pluginContext: PluginContextType; + const pluginId = 'myorg-extensions-app'; + const extensionPointId = `${pluginId}/extension-point/v1`; + const extensionPointConfig = { + id: extensionPointId, + title: 'Extension point title', + description: 'Extension point description', + }; + + beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + pluginContext = { + meta: { + id: pluginId, + name: 'Extensions App', + type: PluginType.app, + module: '', + baseUrl: '', + info: { + author: { + name: 'MyOrg', + }, + description: 'App for testing extensions', + links: [], + logos: { + large: '', + small: '', + }, + screenshots: [], + updated: '2023-10-26T18:25:01Z', + version: '1.0.0', + }, + extensions: { + addedLinks: [], + addedComponents: [], + exposedComponents: [], + extensionPoints: [], + }, + dependencies: { + grafanaVersion: '8.0.0', + plugins: [], + extensions: { + exposedComponents: [], + }, + }, + }, + }; + }); + + it('should return FALSE if the meta-info in the plugin.json is correct', () => { + pluginContext.meta.extensions?.extensionPoints.push(extensionPointConfig); + + const returnValue = isExtensionPointMetaInfoMissing(extensionPointId, pluginContext); + + expect(returnValue).toBe(false); + expect(consoleWarnSpy).toHaveBeenCalledTimes(0); + }); + + it('should return TRUE and log a warning if the extension point id is not recorded in the plugin.json', () => { + const returnValue = isExtensionPointMetaInfoMissing(extensionPointId, pluginContext); + + expect(returnValue).toBe(true); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy.mock.calls[0][0]).toMatch(`Extension point "${extensionPointId}"`); + }); + }); }); diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index ade21ad0962..0938c1df666 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -16,8 +16,11 @@ import { PanelMenuItem, PluginExtensionAddedLinkConfig, urlUtil, + PluginContextType, + PluginExtensionExposedComponentConfig, + PluginExtensionAddedComponentConfig, } from '@grafana/data'; -import { reportInteraction } from '@grafana/runtime'; +import { reportInteraction, config } from '@grafana/runtime'; import { Modal } from '@grafana/ui'; import appEvents from 'app/core/app_events'; // TODO: instead of depending on the service as a singleton, inject it as an argument from the React context @@ -408,3 +411,145 @@ export const openAppInSideview = (pluginId: string) => sidecarService.openApp(pl export const closeAppInSideview = (pluginId: string) => sidecarService.closeApp(pluginId); export const isAppOpened = (pluginId: string) => sidecarService.isAppOpened(pluginId); + +// Comes from the `app_mode` setting in the Grafana config (defaults to "development") +// Can be set with the `GF_DEFAULT_APP_MODE` environment variable +export const isGrafanaDevMode = () => config.buildInfo.env === 'development'; + +// Checks if the meta information is missing from the plugin's plugin.json file +export const isExtensionPointMetaInfoMissing = (extensionPointId: string, pluginContext: PluginContextType) => { + const pluginId = pluginContext.meta?.id; + const extensionPoints = pluginContext.meta?.extensions?.extensionPoints; + + if (!extensionPoints || !extensionPoints.some((ep) => ep.id === extensionPointId)) { + logWarning( + `Extension point "${extensionPointId}" - it's not recorded in the "plugin.json" for "${pluginId}". Please add it under "extensions.extensionPoints[]".` + ); + return true; + } + + return false; +}; + +// Checks if an exposed component that the plugin is depending on is missing from the `dependencies` in the plugin.json file +export const isExposedComponentDependencyMissing = (id: string, pluginContext: PluginContextType) => { + const pluginId = pluginContext.meta?.id; + const exposedComponentsDependencies = pluginContext.meta?.dependencies?.extensions?.exposedComponents; + + if (!exposedComponentsDependencies || !exposedComponentsDependencies.includes(id)) { + logWarning( + `Using exposed component "${id}" - it's not recorded in the "plugin.json" for "${pluginId}". Please add it under "dependencies.extensions.exposedComponents[]".` + ); + return true; + } + + return false; +}; + +export const isAddedLinkMetaInfoMissing = (pluginId: string, metaInfo: PluginExtensionAddedLinkConfig) => { + const app = config.apps[pluginId]; + const logPrefix = `Added-link "${metaInfo.title}" from "${pluginId}" -`; + const pluginJsonMetaInfo = app ? app.extensions.addedLinks.find(({ title }) => title === metaInfo.title) : null; + + if (!app) { + logWarning(`${logPrefix} couldn't find app plugin "${pluginId}"`); + return true; + } + + if (!pluginJsonMetaInfo) { + logWarning(`${logPrefix} not registered in the plugin.json under "extensions.addedLinks[]".`); + + return true; + } + + const targets = Array.isArray(metaInfo.targets) ? metaInfo.targets : [metaInfo.targets]; + if (!targets.every((target) => pluginJsonMetaInfo.targets.includes(target))) { + logWarning(`${logPrefix} the "targets" don't match with ones in the plugin.json under "extensions.addedLinks[]".`); + + return true; + } + + if (pluginJsonMetaInfo.description !== metaInfo.description) { + logWarning( + `${logPrefix} the "description" doesn't match with one in the plugin.json under "extensions.addedLinks[]".` + ); + + return true; + } + + return false; +}; + +export const isAddedComponentMetaInfoMissing = (pluginId: string, metaInfo: PluginExtensionAddedComponentConfig) => { + const app = config.apps[pluginId]; + const logPrefix = `Added component "${metaInfo.title}" -`; + const pluginJsonMetaInfo = app ? app.extensions.addedComponents.find(({ title }) => title === metaInfo.title) : null; + + if (!app) { + logWarning(`${logPrefix} couldn't find app plugin "${pluginId}"`); + return true; + } + + if (!pluginJsonMetaInfo) { + logWarning(`${logPrefix} not registered in the plugin.json under "extensions.addedComponents[]".`); + + return true; + } + + const targets = Array.isArray(metaInfo.targets) ? metaInfo.targets : [metaInfo.targets]; + if (!targets.every((target) => pluginJsonMetaInfo.targets.includes(target))) { + logWarning( + `${logPrefix} the "targets" don't match with ones in the plugin.json under "extensions.addedComponents[]".` + ); + + return true; + } + + if (pluginJsonMetaInfo.description !== metaInfo.description) { + logWarning( + `${logPrefix} the "description" doesn't match with one in the plugin.json under "extensions.addedComponents[]".` + ); + + return true; + } + + return false; +}; + +export const isExposedComponentMetaInfoMissing = ( + pluginId: string, + metaInfo: PluginExtensionExposedComponentConfig +) => { + const app = config.apps[pluginId]; + const logPrefix = `Exposed component "${metaInfo.id}" -`; + const pluginJsonMetaInfo = app ? app.extensions.exposedComponents.find(({ id }) => id === metaInfo.id) : null; + + if (!app) { + logWarning(`${logPrefix} couldn't find app plugin: "${pluginId}"`); + return true; + } + + if (!pluginJsonMetaInfo) { + logWarning(`${logPrefix} not registered in the plugin.json under "extensions.exposedComponents[]".`); + + return true; + } + + if (pluginJsonMetaInfo.title !== metaInfo.title) { + logWarning( + `${logPrefix} the "title" doesn't match with one in the plugin.json under "extensions.exposedComponents[]".` + ); + + return true; + } + + if (pluginJsonMetaInfo.description !== metaInfo.description) { + logWarning( + `${logPrefix} the "description" doesn't match with one in the plugin.json under "extensions.exposedComponents[]".` + ); + + return true; + } + + return false; +}; diff --git a/public/app/features/plugins/extensions/validators.test.tsx b/public/app/features/plugins/extensions/validators.test.tsx index efb329af5cf..2ce296c7210 100644 --- a/public/app/features/plugins/extensions/validators.test.tsx +++ b/public/app/features/plugins/extensions/validators.test.tsx @@ -6,6 +6,7 @@ import { assertConfigureIsValid, assertLinkPathIsValid, assertStringProps, + isExtensionPointIdValid, isGrafanaCoreExtensionPoint, isReactComponent, } from './validators'; @@ -184,4 +185,50 @@ describe('Plugin Extension Validators', () => { expect(isGrafanaCoreExtensionPoint('grafana/dashboard/alertingrule/action')).toBe(false); }); }); + + describe('isExtensionPointIdValid()', () => { + test.each([ + // We (for now allow core Grafana extension points to run without a version) + ['grafana/extension-point', ''], + ['grafana/extension-point', 'grafana'], + ['myorg-extensions-app/extension-point', 'myorg-extensions-app'], + ['myorg-extensions-app/extension-point/v1', 'myorg-extensions-app'], + ['plugins/myorg-extensions-app/extension-point/v1', 'myorg-extensions-app'], + ['plugins/myorg-basic-app/start', 'myorg-basic-app'], + ['myorg-extensions-app/extension-point/v1', 'myorg-extensions-app'], + ['plugins/myorg-extensions-app/extension-point/v1', 'myorg-extensions-app'], + ['plugins/grafana-app-observability-app/service/action', 'grafana-app-observability-app'], + ['plugins/grafana-k8s-app/cluster/action', 'grafana-k8s-app'], + ['plugins/grafana-oncall-app/alert-group/action', 'grafana-oncall-app'], + ['plugins/grafana-oncall-app/alert-group/action/v1', 'grafana-oncall-app'], + ['plugins/grafana-oncall-app/alert-group/action/v1.0.0', 'grafana-oncall-app'], + ])('should return TRUE if the extension point id is valid ("%s", "%s")', (extensionPointId, pluginId) => { + expect( + isExtensionPointIdValid({ + extensionPointId, + pluginId, + }) + ).toBe(true); + }); + + test.each([ + [ + // Plugin id mismatch + 'myorg-extensions-app/extension-point/v1', + 'myorgs-other-app', + ], + [ + // Missing plugin id prefix + 'extension-point/v1', + 'myorgs-extensions-app', + ], + ])('should return FALSE if the extension point id is invalid ("%s", "%s")', (extensionPointId, pluginId) => { + expect( + isExtensionPointIdValid({ + extensionPointId, + pluginId, + }) + ).toBe(false); + }); + }); }); diff --git a/public/app/features/plugins/extensions/validators.ts b/public/app/features/plugins/extensions/validators.ts index 96970860973..b03df733d83 100644 --- a/public/app/features/plugins/extensions/validators.ts +++ b/public/app/features/plugins/extensions/validators.ts @@ -53,12 +53,18 @@ export function isLinkPathValid(pluginId: string, path: string) { return Boolean(typeof path === 'string' && path.length > 0 && path.startsWith(`/a/${pluginId}/`)); } -export function isExtensionPointIdValid(pluginId: string, extensionPointId: string) { - return Boolean( - extensionPointId.startsWith('grafana/') || - extensionPointId?.startsWith('plugins/') || - extensionPointId?.startsWith(pluginId) - ); +export function isExtensionPointIdValid({ + extensionPointId, + pluginId, +}: { + extensionPointId: string; + pluginId: string; +}) { + if (extensionPointId.startsWith('grafana/')) { + return true; + } + + return Boolean(extensionPointId.startsWith(`plugins/${pluginId}/`) || extensionPointId.startsWith(`${pluginId}/`)); } export function extensionPointEndsWithVersion(extensionPointId: string) { diff --git a/scripts/grafana-server/custom.ini b/scripts/grafana-server/custom.ini index 5d09d40ac63..95499fabb85 100644 --- a/scripts/grafana-server/custom.ini +++ b/scripts/grafana-server/custom.ini @@ -7,7 +7,7 @@ content_security_policy_template = """require-trusted-types-for 'script'; script enable = publicDashboards [plugins] -allow_loading_unsigned_plugins=grafana-extensionstest-app,grafana-extensionexample1-app,grafana-extensionexample2-app, +allow_loading_unsigned_plugins=grafana-extensionstest-app,grafana-extensionexample1-app,grafana-extensionexample2-app,grafana-extensionexample3-app [database] type=sqlite3 From 943525391ed89be07f7a3e4690221227d46a6e2e Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Fri, 4 Oct 2024 08:48:38 +0200 Subject: [PATCH 09/16] AuthN: Managed plugin Service Accounts general availability (#93384) External Service Accounts: General availability --- .../configure-grafana/feature-toggles/index.md | 2 +- pkg/services/featuremgmt/registry.go | 3 ++- pkg/services/featuremgmt/toggles_gen.csv | 2 +- pkg/services/featuremgmt/toggles_gen.json | 12 ++++++++---- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 82fe792d360..65e636dfa77 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -48,6 +48,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `angularDeprecationUI` | Display Angular warnings in dashboards and panels | Yes | | `dashgpt` | Enable AI powered features in dashboards | Yes | | `alertingInsights` | Show the new alerting insights landing page | Yes | +| `externalServiceAccounts` | Automatic service account and token setup for plugins | Yes | | `panelMonitoring` | Enables panel monitoring through logs and measurements | Yes | | `formatString` | Enable format string transformer | Yes | | `transformationsVariableSupport` | Allows using variables in transformations | Yes | @@ -100,7 +101,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `enableDatagridEditing` | Enables the edit functionality in the datagrid panel | | `sqlDatasourceDatabaseSelection` | Enables previous SQL data source dataset dropdown behavior | | `reportingRetries` | Enables rendering retries for the reporting feature | -| `externalServiceAccounts` | Automatic service account and token setup for plugins | | `cloudWatchBatchQueries` | Runs CloudWatch metrics queries as separate batches | | `teamHttpHeaders` | Enables LBAC for datasources to apply LogQL filtering of logs to the client requests for users in teams | | `pdfTables` | Enables generating table data as PDF in reporting | diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 040fae2c88a..60e78e82db3 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -675,8 +675,9 @@ var ( Name: "externalServiceAccounts", Description: "Automatic service account and token setup for plugins", HideFromAdminPage: true, - Stage: FeatureStagePublicPreview, + Stage: FeatureStageGeneralAvailability, Owner: identityAccessTeam, + Expression: "true", // enabled by default }, { Name: "panelMonitoring", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 2be708e1aa2..800f41a8e6f 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -88,7 +88,7 @@ wargamesTesting,experimental,@grafana/hosted-grafana-team,false,false,false alertingInsights,GA,@grafana/alerting-squad,false,false,true externalCorePlugins,experimental,@grafana/plugins-platform-backend,false,false,false pluginsAPIMetrics,experimental,@grafana/plugins-platform-backend,false,false,true -externalServiceAccounts,preview,@grafana/identity-access-team,false,false,false +externalServiceAccounts,GA,@grafana/identity-access-team,false,false,false panelMonitoring,GA,@grafana/dataviz-squad,false,false,true enableNativeHTTPHistogram,experimental,@grafana/grafana-backend-services-squad,false,true,false disableClassicHTTPHistogram,experimental,@grafana/grafana-backend-services-squad,false,true,false diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 5ae6b05eb1e..eff0b8b4b6f 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1234,14 +1234,18 @@ { "metadata": { "name": "externalServiceAccounts", - "resourceVersion": "1718727528075", - "creationTimestamp": "2023-09-28T07:26:37Z" + "resourceVersion": "1726562284896", + "creationTimestamp": "2023-09-28T07:26:37Z", + "annotations": { + "grafana.app/updatedTimestamp": "2024-09-17 08:38:04.896869045 +0000 UTC" + } }, "spec": { "description": "Automatic service account and token setup for plugins", - "stage": "preview", + "stage": "GA", "codeowner": "@grafana/identity-access-team", - "hideFromAdminPage": true + "hideFromAdminPage": true, + "expression": "true" } }, { From c7ca2bfcf527df3a35f0bf95ebe48a6e10b563df Mon Sep 17 00:00:00 2001 From: Karl Persson Date: Fri, 4 Oct 2024 09:01:09 +0200 Subject: [PATCH 10/16] ServiceAccounts: Remove permissions to service account when it is deleted (#93877) * Service account: clean up permissions related to service accounts when deleted * Add migration for deleting orphaned service account permissions Co-authored-by: Ieva --- .../serviceaccounts/manager/service.go | 17 +++- .../serviceaccounts/manager/service_test.go | 3 +- .../serviceaccounts/manager/stats_test.go | 3 +- .../migrations/accesscontrol/orphaned.go | 96 +++++++++++++++++++ .../sqlstore/migrations/migrations.go | 2 + 5 files changed, 114 insertions(+), 7 deletions(-) create mode 100644 pkg/services/sqlstore/migrations/accesscontrol/orphaned.go diff --git a/pkg/services/serviceaccounts/manager/service.go b/pkg/services/serviceaccounts/manager/service.go index b225cc6f0a1..5fbae6c88ef 100644 --- a/pkg/services/serviceaccounts/manager/service.go +++ b/pkg/services/serviceaccounts/manager/service.go @@ -26,7 +26,9 @@ const ( ) type ServiceAccountsService struct { - acService accesscontrol.Service + acService accesscontrol.Service + permissions accesscontrol.ServiceAccountPermissionsService + store store log log.Logger backgroundLog log.Logger @@ -44,7 +46,8 @@ func ProvideServiceAccountsService( kvStore kvstore.KVStore, userService user.Service, orgService org.Service, - accesscontrolService accesscontrol.Service, + acService accesscontrol.Service, + permissions accesscontrol.ServiceAccountPermissionsService, ) (*ServiceAccountsService, error) { serviceAccountsStore := database.ProvideServiceAccountsStore( cfg, @@ -55,13 +58,14 @@ func ProvideServiceAccountsService( orgService, ) s := &ServiceAccountsService{ - acService: accesscontrolService, + acService: acService, + permissions: permissions, store: serviceAccountsStore, log: log.New("serviceaccounts"), backgroundLog: log.New("serviceaccounts.background"), } - if err := RegisterRoles(accesscontrolService); err != nil { + if err := RegisterRoles(acService); err != nil { s.log.Error("Failed to register roles", "error", err) } @@ -179,7 +183,10 @@ func (sa *ServiceAccountsService) DeleteServiceAccount(ctx context.Context, orgI if err := sa.store.DeleteServiceAccount(ctx, orgID, serviceAccountID); err != nil { return err } - return sa.acService.DeleteUserPermissions(ctx, orgID, serviceAccountID) + if err := sa.acService.DeleteUserPermissions(ctx, orgID, serviceAccountID); err != nil { + return err + } + return sa.permissions.DeleteResourcePermissions(ctx, orgID, fmt.Sprintf("%d", serviceAccountID)) } func (sa *ServiceAccountsService) EnableServiceAccount(ctx context.Context, orgID, serviceAccountID int64, enable bool) error { diff --git a/pkg/services/serviceaccounts/manager/service_test.go b/pkg/services/serviceaccounts/manager/service_test.go index 9345235039e..579ee7ff598 100644 --- a/pkg/services/serviceaccounts/manager/service_test.go +++ b/pkg/services/serviceaccounts/manager/service_test.go @@ -119,7 +119,8 @@ func (f *SecretsCheckerFake) CheckTokens(ctx context.Context) error { func TestProvideServiceAccount_DeleteServiceAccount(t *testing.T) { storeMock := newServiceAccountStoreFake() acSvc := actest.FakeService{} - svc := ServiceAccountsService{acSvc, storeMock, log.New("test"), log.New("background.test"), &SecretsCheckerFake{}, false, 0} + pSvc := &actest.FakePermissionsService{} + svc := ServiceAccountsService{acSvc, pSvc, storeMock, log.NewNopLogger(), log.NewNopLogger(), &SecretsCheckerFake{}, false, 0} testOrgId := 1 t.Run("should create service account", func(t *testing.T) { diff --git a/pkg/services/serviceaccounts/manager/stats_test.go b/pkg/services/serviceaccounts/manager/stats_test.go index e85e9adb129..43698a18494 100644 --- a/pkg/services/serviceaccounts/manager/stats_test.go +++ b/pkg/services/serviceaccounts/manager/stats_test.go @@ -14,8 +14,9 @@ import ( func Test_UsageStats(t *testing.T) { acSvc := actest.FakeService{} + pSvc := actest.FakePermissionsService{} storeMock := newServiceAccountStoreFake() - svc := ServiceAccountsService{acSvc, storeMock, log.New("test"), log.New("background-test"), &SecretsCheckerFake{}, true, 5} + svc := ServiceAccountsService{acSvc, &pSvc, storeMock, log.NewNopLogger(), log.NewNopLogger(), &SecretsCheckerFake{}, true, 5} err := svc.DeleteServiceAccount(context.Background(), 1, 1) require.NoError(t, err) diff --git a/pkg/services/sqlstore/migrations/accesscontrol/orphaned.go b/pkg/services/sqlstore/migrations/accesscontrol/orphaned.go new file mode 100644 index 00000000000..05ade14da83 --- /dev/null +++ b/pkg/services/sqlstore/migrations/accesscontrol/orphaned.go @@ -0,0 +1,96 @@ +package accesscontrol + +import ( + "fmt" + "strconv" + "strings" + + "xorm.io/xorm" + + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +) + +const ( + orphanedServiceAccountsPermissions = "delete orphaned service account permissions" +) + +func AddOrphanedMigrations(mg *migrator.Migrator) { + mg.AddMigration(orphanedServiceAccountsPermissions, &orphanedServiceAccountPermissions{}) +} + +var _ migrator.CodeMigration = new(alertingScopeRemovalMigrator) + +type orphanedServiceAccountPermissions struct { + migrator.MigrationBase +} + +func (m *orphanedServiceAccountPermissions) SQL(dialect migrator.Dialect) string { + return CodeMigrationSQL +} + +func (m *orphanedServiceAccountPermissions) Exec(sess *xorm.Session, mg *migrator.Migrator) error { + var idents []string + + // find all permissions that are scopes directly to a service account + err := sess.SQL("SELECT DISTINCT p.identifier FROM permission AS p WHERE p.kind = 'serviceaccounts' AND NOT p.identifier = '*'").Find(&idents) + if err != nil { + return fmt.Errorf("failed to fetch permissinos scoped to service accounts: %w", err) + } + + ids := make([]int64, 0, len(idents)) + for _, id := range idents { + id, err := strconv.ParseInt(id, 10, 64) + if err == nil { + ids = append(ids, id) + } + } + + if len(ids) == 0 { + return nil + } + + // Then find all existing service accounts + raw := "SELECT u.id FROM " + mg.Dialect.Quote("user") + " AS u WHERE u.is_service_account AND u.id IN(?" + strings.Repeat(",?", len(ids)-1) + ")" + args := make([]any, 0, len(ids)) + for _, id := range ids { + args = append(args, id) + } + + var existingIDs []int64 + err = sess.SQL(raw, args...).Find(&existingIDs) + if err != nil { + return fmt.Errorf("failed to fetch existing service accounts: %w", err) + } + + existing := make(map[int64]struct{}, len(existingIDs)) + for _, id := range existingIDs { + existing[id] = struct{}{} + } + + // filter out orphaned permissions + var orphaned []string + for _, id := range ids { + if _, ok := existing[id]; !ok { + orphaned = append(orphaned, strconv.FormatInt(id, 10)) + } + } + + if len(orphaned) == 0 { + return nil + } + + // delete all orphaned permissions + rawDelete := "DELETE FROM permission AS p WHERE p.kind = 'serviceaccounts' AND p.identifier IN(?" + strings.Repeat(",?", len(orphaned)-1) + ")" + deleteArgs := make([]any, 0, len(orphaned)+1) + deleteArgs = append(deleteArgs, rawDelete) + for _, id := range orphaned { + deleteArgs = append(deleteArgs, id) + } + + _, err = sess.Exec(deleteArgs...) + if err != nil { + return fmt.Errorf("failed to delete orphaned service accounts: %w", err) + } + + return nil +} diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 216f2ad6f89..f8de7ec326e 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -131,6 +131,8 @@ func (oss *OSSMigrations) AddMigration(mg *Migrator) { ualert.AddReceiverActionScopesMigration(mg) ualert.AddRuleMetadata(mg) + + accesscontrol.AddOrphanedMigrations(mg) } func addStarMigrations(mg *Migrator) { From d4bb8122cbf975a02a709f63bc6cce3b9c8a3d82 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Fri, 4 Oct 2024 09:54:17 +0100 Subject: [PATCH 11/16] SingleTopNav: Tweaks for mobile responsiveness (#94188) tweaks for mobile responsiveness --- .../components/AppChrome/QuickAdd/QuickAdd.tsx | 6 ++++-- .../AppChrome/TopBar/SingleTopBar.tsx | 17 ++++++++--------- .../TopSearchBarCommandPaletteTrigger.tsx | 4 +++- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx b/public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx index f097d0073a1..f2d3d7d0808 100644 --- a/public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx +++ b/public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import { useMemo, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { reportInteraction } from '@grafana/runtime'; +import { config, reportInteraction } from '@grafana/runtime'; import { Menu, Dropdown, useStyles2, useTheme2, ToolbarButton } from '@grafana/ui'; import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange'; import { useSelector } from 'app/types'; @@ -22,6 +22,8 @@ export const QuickAdd = ({}: Props) => { const [isOpen, setIsOpen] = useState(false); const [isSmallScreen, setIsSmallScreen] = useState(!window.matchMedia(`(min-width: ${breakpoint}px)`).matches); const createActions = useMemo(() => findCreateActions(navBarTree), [navBarTree]); + const isSingleTopNav = config.featureToggles.singleTopNav; + const showQuickAdd = createActions.length > 0 && (!isSingleTopNav || !isSmallScreen); useMediaQueryChange({ breakpoint, @@ -45,7 +47,7 @@ export const QuickAdd = ({}: Props) => { ); }; - return createActions.length > 0 ? ( + return showQuickAdd ? ( <> - + {!menuDockedAndOpen && ( @@ -63,9 +62,9 @@ export const SingleTopBar = memo(function SingleTopBar({ )} - + - + {enrichedHelpNode && ( @@ -88,7 +87,7 @@ export const SingleTopBar = memo(function SingleTopBar({ - + ); }); @@ -97,15 +96,15 @@ const getStyles = (theme: GrafanaTheme2, menuDockedAndOpen: boolean) => ({ layout: css({ height: TOP_BAR_LEVEL_HEIGHT, display: 'flex', - gap: theme.spacing(1), + gap: theme.spacing(2), alignItems: 'center', padding: theme.spacing(0, 1), paddingLeft: menuDockedAndOpen ? theme.spacing(3.5) : theme.spacing(0.75), borderBottom: `1px solid ${theme.colors.border.weak}`, justifyContent: 'space-between', - [theme.breakpoints.up('sm')]: { - gridTemplateColumns: '2fr minmax(240px, 1fr)', // TODO probably change these values + [theme.breakpoints.up('lg')]: { + gridTemplateColumns: '2fr minmax(440px, 1fr)', display: 'grid', justifyContent: 'flex-start', @@ -115,7 +114,7 @@ const getStyles = (theme: GrafanaTheme2, menuDockedAndOpen: boolean) => ({ display: 'flex', overflow: 'hidden', [theme.breakpoints.down('sm')]: { - minWidth: '50%', + minWidth: '40%', }, }), img: css({ diff --git a/public/app/core/components/AppChrome/TopBar/TopSearchBarCommandPaletteTrigger.tsx b/public/app/core/components/AppChrome/TopBar/TopSearchBarCommandPaletteTrigger.tsx index 8ad8eac711c..40092260310 100644 --- a/public/app/core/components/AppChrome/TopBar/TopSearchBarCommandPaletteTrigger.tsx +++ b/public/app/core/components/AppChrome/TopBar/TopSearchBarCommandPaletteTrigger.tsx @@ -3,6 +3,7 @@ import { useKBar, VisualState } from 'kbar'; import { useMemo, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { getInputStyles, Icon, Text, ToolbarButton, useStyles2, useTheme2 } from '@grafana/ui'; import { focusCss } from '@grafana/ui/src/themes/mixins'; import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange'; @@ -11,12 +12,13 @@ import { getModKey } from 'app/core/utils/browser'; export function TopSearchBarCommandPaletteTrigger() { const theme = useTheme2(); + const isSingleTopNav = config.featureToggles.singleTopNav; const { query: kbar } = useKBar((kbarState) => ({ kbarSearchQuery: kbarState.searchQuery, kbarIsOpen: kbarState.visualState === VisualState.showing, })); - const breakpoint = theme.breakpoints.values.sm; + const breakpoint = isSingleTopNav ? theme.breakpoints.values.lg : theme.breakpoints.values.sm; const [isSmallScreen, setIsSmallScreen] = useState(!window.matchMedia(`(min-width: ${breakpoint}px)`).matches); From 788ef7fec3a074de27dad47ee8cf01d76c6e678b Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Fri, 4 Oct 2024 10:00:30 +0100 Subject: [PATCH 12/16] I18n: Download translations from Crowdin (#94210) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- public/locales/de-DE/grafana.json | 11 +++++++++++ public/locales/es-ES/grafana.json | 11 +++++++++++ public/locales/fr-FR/grafana.json | 11 +++++++++++ public/locales/pt-BR/grafana.json | 11 +++++++++++ public/locales/zh-Hans/grafana.json | 11 +++++++++++ 5 files changed, 55 insertions(+) diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 61c7f4d98d9..d2faff4ca30 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -329,6 +329,9 @@ "moving": "Verschieben...", "new-folder-name-required-phrase": "Ordnername ist erforderlich." }, + "actions": { + "button-to-recently-deleted": "" + }, "counts": { "alertRule_one": "{{count}} Alarmierungsregel", "alertRule_other": "{{count}} Alarmierungsregeln", @@ -1090,6 +1093,13 @@ }, "title": "Shortcuts" }, + "help-wizard": { + "download-snapshot": "", + "github-comment": "", + "preview-snapshot": "", + "support-bundle": "", + "troubleshooting-help": "" + }, "inspector": { "query": { "collapse-all": "Alle einklappen", @@ -1855,6 +1865,7 @@ "close": "Menü schließen", "dock": "Menü andocken", "list-label": "Navigation", + "open": "", "undock": "Menü abdocken" }, "toolbar": { diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 561c1a99a52..2eb022cd6ac 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -329,6 +329,9 @@ "moving": "Moviendo...", "new-folder-name-required-phrase": "Se requiere el nombre de la carpeta." }, + "actions": { + "button-to-recently-deleted": "" + }, "counts": { "alertRule_one": "{{count}} regla de alerta", "alertRule_other": "{{count}} reglas de alerta", @@ -1090,6 +1093,13 @@ }, "title": "Accesos directos" }, + "help-wizard": { + "download-snapshot": "", + "github-comment": "", + "preview-snapshot": "", + "support-bundle": "", + "troubleshooting-help": "" + }, "inspector": { "query": { "collapse-all": "Contraer todo", @@ -1855,6 +1865,7 @@ "close": "Cerrar menú", "dock": "Anclar el menú", "list-label": "Navegación", + "open": "", "undock": "Desanclar el menú" }, "toolbar": { diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 7d1ad94293c..b30b2ff11e9 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -329,6 +329,9 @@ "moving": "Déplacement...", "new-folder-name-required-phrase": "Vous devez saisir le nom du dossier." }, + "actions": { + "button-to-recently-deleted": "" + }, "counts": { "alertRule_one": "{{count}} règle d'alerte", "alertRule_other": "{{count}} règles d'alerte", @@ -1090,6 +1093,13 @@ }, "title": "Raccourcis" }, + "help-wizard": { + "download-snapshot": "", + "github-comment": "", + "preview-snapshot": "", + "support-bundle": "", + "troubleshooting-help": "" + }, "inspector": { "query": { "collapse-all": "Tout réduire", @@ -1855,6 +1865,7 @@ "close": "Fermer le menu", "dock": "Ancrer le menu", "list-label": "Navigation", + "open": "", "undock": "Ancrer le menu" }, "toolbar": { diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index b36da1c5e60..d863d06e3e9 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -329,6 +329,9 @@ "moving": "Movendo...", "new-folder-name-required-phrase": "O nome da pasta é obrigatório." }, + "actions": { + "button-to-recently-deleted": "" + }, "counts": { "alertRule_one": "{{count}} regra de alerta", "alertRule_other": "{{count}} regras de alerta", @@ -1090,6 +1093,13 @@ }, "title": "Atalhos" }, + "help-wizard": { + "download-snapshot": "", + "github-comment": "", + "preview-snapshot": "", + "support-bundle": "", + "troubleshooting-help": "" + }, "inspector": { "query": { "collapse-all": "Recolher tudo", @@ -1855,6 +1865,7 @@ "close": "Fechar menu", "dock": "Menu da dock", "list-label": "Navegação", + "open": "", "undock": "Desacoplar menu" }, "toolbar": { diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 7302ac50a23..8d63012a79d 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -325,6 +325,9 @@ "moving": "正在移动...", "new-folder-name-required-phrase": "文件夹名称是必需项。" }, + "actions": { + "button-to-recently-deleted": "" + }, "counts": { "alertRule_other": "{{count}} 条警告规则", "dashboard_other": "{{count}} 个仪表板", @@ -1081,6 +1084,13 @@ }, "title": "快捷键" }, + "help-wizard": { + "download-snapshot": "", + "github-comment": "", + "preview-snapshot": "", + "support-bundle": "", + "troubleshooting-help": "" + }, "inspector": { "query": { "collapse-all": "折叠全部", @@ -1845,6 +1855,7 @@ "close": "关闭菜单", "dock": "停靠菜单", "list-label": "导航", + "open": "", "undock": "取消停靠菜单" }, "toolbar": { From a766b378d0a26881cbb1d7e67f083d39aa967365 Mon Sep 17 00:00:00 2001 From: Sven Grossmann Date: Fri, 4 Oct 2024 11:15:29 +0200 Subject: [PATCH 13/16] Explore Logs: Preinstall for onprem Grafana instances (#94221) --- conf/defaults.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index 8baa6296110..cb3965cc554 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -1740,7 +1740,7 @@ hide_angular_deprecation = # Comma separated list of plugin ids for which environment variables should be forwarded. Used only when feature flag pluginsSkipHostEnvVars is enabled. forward_host_env_vars = # Comma separated list of plugin ids to install as part of the startup process. -preinstall = +preinstall = grafana-lokiexplore-app # Controls whether preinstall plugins asynchronously (in the background) or synchronously (blocking). Useful when preinstalled plugins are used with provisioning. preinstall_async = true # Disables preinstall feature. It has the same effect as setting preinstall to an empty list. From f403bc57d542cd07b27da0a24c4d94d32f452f97 Mon Sep 17 00:00:00 2001 From: "Arati R." <33031346+suntala@users.noreply.github.com> Date: Fri, 4 Oct 2024 11:26:36 +0200 Subject: [PATCH 14/16] K8s/Folders: Clear permissions cache on create (#94214) * Clear user permissions cache after folder creation * Use k8s client only for create --- pkg/api/folder.go | 206 +++++++++++++++++++++++----------------------- 1 file changed, 102 insertions(+), 104 deletions(-) diff --git a/pkg/api/folder.go b/pkg/api/folder.go index c8d7949b48f..ca1cfe041cf 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -4,7 +4,6 @@ import ( "errors" "net/http" "strconv" - "strings" k8sErrors "k8s.io/apimachinery/pkg/api/errors" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -39,35 +38,29 @@ const REDACTED = "redacted" func (hs *HTTPServer) registerFolderAPI(apiRoute routing.RouteRegister, authorize func(accesscontrol.Evaluator) web.Handler) { // #TODO add back auth part apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) { + idScope := dashboards.ScopeFoldersProvider.GetResourceScope(accesscontrol.Parameter(":id")) + uidScope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(accesscontrol.Parameter(":uid")) + folderRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead)), routing.Wrap(hs.GetFolders)) + folderRoute.Get("/id/:id", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, idScope)), routing.Wrap(hs.GetFolderByID)) + + folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { + folderUidRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderByUID)) + folderUidRoute.Put("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.UpdateFolder)) + folderUidRoute.Post("/move", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.MoveFolder)) + folderUidRoute.Delete("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersDelete, uidScope)), routing.Wrap(hs.DeleteFolder)) + folderUidRoute.Get("/counts", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderDescendantCounts)) + + folderUidRoute.Group("/permissions", func(folderPermissionRoute routing.RouteRegister) { + folderPermissionRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersPermissionsRead, uidScope)), routing.Wrap(hs.GetFolderPermissionList)) + folderPermissionRoute.Post("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersPermissionsWrite, uidScope)), routing.Wrap(hs.UpdateFolderPermissions)) + }) + }) if hs.Features.IsEnabledGlobally(featuremgmt.FlagKubernetesFolders) { // Use k8s client to implement legacy API handler := newFolderK8sHandler(hs) - folderRoute.Get("/", handler.searchFolders) folderRoute.Post("/", handler.createFolder) - folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { - folderUidRoute.Get("/", handler.getFolder) - folderUidRoute.Delete("/", handler.deleteFolder) - folderUidRoute.Put("/:uid", handler.updateFolder) - }) } else { - idScope := dashboards.ScopeFoldersProvider.GetResourceScope(accesscontrol.Parameter(":id")) - uidScope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(accesscontrol.Parameter(":uid")) - folderRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead)), routing.Wrap(hs.GetFolders)) - folderRoute.Get("/id/:id", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, idScope)), routing.Wrap(hs.GetFolderByID)) folderRoute.Post("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersCreate)), routing.Wrap(hs.CreateFolder)) - - folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { - folderUidRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderByUID)) - folderUidRoute.Put("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.UpdateFolder)) - folderUidRoute.Post("/move", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.MoveFolder)) - folderUidRoute.Delete("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersDelete, uidScope)), routing.Wrap(hs.DeleteFolder)) - folderUidRoute.Get("/counts", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderDescendantCounts)) - - folderUidRoute.Group("/permissions", func(folderPermissionRoute routing.RouteRegister) { - folderPermissionRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersPermissionsRead, uidScope)), routing.Wrap(hs.GetFolderPermissionList)) - folderPermissionRoute.Post("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersPermissionsWrite, uidScope)), routing.Wrap(hs.UpdateFolderPermissions)) - }) - }) } }) } @@ -634,6 +627,8 @@ type folderK8sHandler struct { namespacer request.NamespaceMapper gvr schema.GroupVersionResource clientConfigProvider grafanaapiserver.DirectRestConfigProvider + // #TODO check if it makes more sense to move this to FolderAPIBuilder + accesscontrolService accesscontrol.Service } //----------------------------------------------------------------------------------------- @@ -645,34 +640,36 @@ func newFolderK8sHandler(hs *HTTPServer) *folderK8sHandler { gvr: folderalpha1.FolderResourceInfo.GroupVersionResource(), namespacer: request.GetNamespaceMapper(hs.Cfg), clientConfigProvider: hs.clientConfigProvider, + accesscontrolService: hs.accesscontrolService, } } -func (fk8s *folderK8sHandler) searchFolders(c *contextmodel.ReqContext) { - client, ok := fk8s.getClient(c) - if !ok { - return // error is already sent - } - out, err := client.List(c.Req.Context(), v1.ListOptions{}) - if err != nil { - fk8s.writeError(c, err) - return - } +// #TODO uncomment when we reinstate their corresponding routes +// func (fk8s *folderK8sHandler) searchFolders(c *contextmodel.ReqContext) { +// client, ok := fk8s.getClient(c) +// if !ok { +// return // error is already sent +// } +// out, err := client.List(c.Req.Context(), v1.ListOptions{}) +// if err != nil { +// fk8s.writeError(c, err) +// return +// } - query := strings.ToUpper(c.Query("query")) - folders := []folder.Folder{} - for _, item := range out.Items { - p := internalfolders.UnstructuredToLegacyFolder(item) - if p == nil { - continue - } - if query != "" && !strings.Contains(strings.ToUpper(p.Title), query) { - continue // query filter - } - folders = append(folders, *p) - } - c.JSON(http.StatusOK, folders) -} +// query := strings.ToUpper(c.Query("query")) +// folders := []folder.Folder{} +// for _, item := range out.Items { +// p := internalfolders.UnstructuredToLegacyFolder(item) +// if p == nil { +// continue +// } +// if query != "" && !strings.Contains(strings.ToUpper(p.Title), query) { +// continue // query filter +// } +// folders = append(folders, *p) +// } +// c.JSON(http.StatusOK, folders) +// } func (fk8s *folderK8sHandler) createFolder(c *contextmodel.ReqContext) { client, ok := fk8s.getClient(c) @@ -695,6 +692,7 @@ func (fk8s *folderK8sHandler) createFolder(c *contextmodel.ReqContext) { return } + fk8s.accesscontrolService.ClearUserPermissionCache(c.SignedInUser) f, err := internalfolders.UnstructuredToLegacyFolderDTO(*out) if err != nil { fk8s.writeError(c, err) @@ -703,68 +701,68 @@ func (fk8s *folderK8sHandler) createFolder(c *contextmodel.ReqContext) { c.JSON(http.StatusOK, f) } -func (fk8s *folderK8sHandler) getFolder(c *contextmodel.ReqContext) { - client, ok := fk8s.getClient(c) - if !ok { - return // error is already sent - } - uid := web.Params(c.Req)[":uid"] - out, err := client.Get(c.Req.Context(), uid, v1.GetOptions{}) - if err != nil { - fk8s.writeError(c, err) - return - } +// func (fk8s *folderK8sHandler) getFolder(c *contextmodel.ReqContext) { +// client, ok := fk8s.getClient(c) +// if !ok { +// return // error is already sent +// } +// uid := web.Params(c.Req)[":uid"] +// out, err := client.Get(c.Req.Context(), uid, v1.GetOptions{}) +// if err != nil { +// fk8s.writeError(c, err) +// return +// } - f, err := internalfolders.UnstructuredToLegacyFolderDTO(*out) - if err != nil { - fk8s.writeError(c, err) - return - } +// f, err := internalfolders.UnstructuredToLegacyFolderDTO(*out) +// if err != nil { +// fk8s.writeError(c, err) +// return +// } - c.JSON(http.StatusOK, f) -} +// c.JSON(http.StatusOK, f) +// } -func (fk8s *folderK8sHandler) deleteFolder(c *contextmodel.ReqContext) { - client, ok := fk8s.getClient(c) - if !ok { - return // error is already sent - } - uid := web.Params(c.Req)[":uid"] - err := client.Delete(c.Req.Context(), uid, v1.DeleteOptions{}) - if err != nil { - fk8s.writeError(c, err) - return - } - c.JSON(http.StatusOK, "") -} +// func (fk8s *folderK8sHandler) deleteFolder(c *contextmodel.ReqContext) { +// client, ok := fk8s.getClient(c) +// if !ok { +// return // error is already sent +// } +// uid := web.Params(c.Req)[":uid"] +// err := client.Delete(c.Req.Context(), uid, v1.DeleteOptions{}) +// if err != nil { +// fk8s.writeError(c, err) +// return +// } +// c.JSON(http.StatusOK, "") +// } -func (fk8s *folderK8sHandler) updateFolder(c *contextmodel.ReqContext) { - client, ok := fk8s.getClient(c) - if !ok { - return // error is already sent - } - uid := web.Params(c.Req)[":uid"] - cmd := folder.UpdateFolderCommand{} - if err := web.Bind(c.Req, &cmd); err != nil { - c.JsonApiErr(http.StatusBadRequest, "bad request data", err) - return - } - obj := internalfolders.LegacyUpdateCommandToUnstructured(cmd) - obj.SetName(uid) - out, err := client.Update(c.Req.Context(), &obj, v1.UpdateOptions{}) - if err != nil { - fk8s.writeError(c, err) - return - } +// func (fk8s *folderK8sHandler) updateFolder(c *contextmodel.ReqContext) { +// client, ok := fk8s.getClient(c) +// if !ok { +// return // error is already sent +// } +// uid := web.Params(c.Req)[":uid"] +// cmd := folder.UpdateFolderCommand{} +// if err := web.Bind(c.Req, &cmd); err != nil { +// c.JsonApiErr(http.StatusBadRequest, "bad request data", err) +// return +// } +// obj := internalfolders.LegacyUpdateCommandToUnstructured(cmd) +// obj.SetName(uid) +// out, err := client.Update(c.Req.Context(), &obj, v1.UpdateOptions{}) +// if err != nil { +// fk8s.writeError(c, err) +// return +// } - f, err := internalfolders.UnstructuredToLegacyFolderDTO(*out) - if err != nil { - fk8s.writeError(c, err) - return - } +// f, err := internalfolders.UnstructuredToLegacyFolderDTO(*out) +// if err != nil { +// fk8s.writeError(c, err) +// return +// } - c.JSON(http.StatusOK, f) -} +// c.JSON(http.StatusOK, f) +// } //----------------------------------------------------------------------------------------- // Utility functions From 5d724c2482eae67ca8d8540ed3a9e774be65ccd5 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Fri, 4 Oct 2024 12:27:10 +0200 Subject: [PATCH 15/16] Zanzana: Initial dashboard search (#93093) * Zanzana: Search in a background and compare results * refactor * Search with check * instrument zanzana client * add single_read option * refactor * refactor move check into separate function * Fix tests * refactor * refactor getFindDashboardsFn * add resource type to span attributes * run ListObjects concurrently * Use list and search in less cases * adjust metrics buckets * refactor: move Check and ListObjects to AccessControl implementation * Revert "Fix tests" This reverts commit b0c2f072a25029905fdbd26625fdc7a243d4a308. * refactor: use own types for Check and ListObjects inside accesscontrol package * Fix search scenario with low limit and empty query string * more accurate search with checks * revert * fix linter * Revert "revert" This reverts commit ee5f14eea8c2f69e0b59f4a5094d708ac58b0169. * add search errors metric * fix query performance under some conditions * simplify check strategy * fix pagination * refactor findDashboardsZanzanaList * Iterate over multiple pages while making check request * refactor listUserResources * avoid unnecessary db call * remove unused zclient * Add notes for SkipAccessControlFilter * use more accurate check loop * always use check for search with provided UIDs * rename single_read to zanzana_only_evaluation * refactor * update go workspace * fix linter * don't use deprecated fields * refactor * fail if no org specified * refactor * initial integration tests * Fix tests * fix linter errors * fix linter * Fix tests * review suggestions Co-authored-by: Gabriel MABILLE * fix limit * refactor * refactor tests * fix db config in tests * fix migrator (postgres) --------- Co-authored-by: Gabriel MABILLE --- pkg/services/accesscontrol/accesscontrol.go | 5 +- .../accesscontrol/acimpl/accesscontrol.go | 43 ++- pkg/services/accesscontrol/acimpl/service.go | 3 +- pkg/services/accesscontrol/actest/fake.go | 8 + .../accesscontrol/migrator/zanzana.go | 22 +- pkg/services/accesscontrol/mock/mock.go | 8 + pkg/services/accesscontrol/models.go | 12 + pkg/services/authz/zanzana/client/client.go | 11 + pkg/services/dashboards/database/database.go | 7 +- pkg/services/dashboards/errors.go | 1 + pkg/services/dashboards/models.go | 4 + .../dashboards/service/dashboard_service.go | 11 +- pkg/services/dashboards/service/metrics.go | 26 +- pkg/services/dashboards/service/zanzana.go | 342 ++++++++++++++++++ .../service/zanzana_integration_test.go | 121 +++++++ pkg/services/ngalert/accesscontrol/testing.go | 8 + pkg/services/ngalert/api/testing.go | 9 +- pkg/setting/settings_zanzana.go | 7 + 18 files changed, 619 insertions(+), 29 deletions(-) create mode 100644 pkg/services/dashboards/service/zanzana.go create mode 100644 pkg/services/dashboards/service/zanzana_integration_test.go diff --git a/pkg/services/accesscontrol/accesscontrol.go b/pkg/services/accesscontrol/accesscontrol.go index 3ee940ded3d..64504e2d957 100644 --- a/pkg/services/accesscontrol/accesscontrol.go +++ b/pkg/services/accesscontrol/accesscontrol.go @@ -6,12 +6,11 @@ import ( "strconv" "strings" + "github.com/grafana/authlib/claims" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" - "github.com/grafana/authlib/claims" - "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/services/authn" @@ -32,6 +31,8 @@ type AccessControl interface { // This is useful when we don't want to reuse any pre-configured resolvers // for a authorization call. WithoutResolvers() AccessControl + Check(ctx context.Context, req CheckRequest) (bool, error) + ListObjects(ctx context.Context, req ListObjectsRequest) ([]string, error) } type Service interface { diff --git a/pkg/services/accesscontrol/acimpl/accesscontrol.go b/pkg/services/accesscontrol/acimpl/accesscontrol.go index acd1edc7149..d540474abfc 100644 --- a/pkg/services/accesscontrol/acimpl/accesscontrol.go +++ b/pkg/services/accesscontrol/acimpl/accesscontrol.go @@ -119,26 +119,24 @@ func (a *AccessControl) evaluateZanzana(ctx context.Context, user identity.Reque return eval.EvaluateCustom(func(action, scope string) (bool, error) { kind, _, identifier := accesscontrol.SplitScope(scope) - key, ok := zanzana.TranslateToTuple(user.GetUID(), action, kind, identifier, user.GetOrgID()) + tupleKey, ok := zanzana.TranslateToTuple(user.GetUID(), action, kind, identifier, user.GetOrgID()) if !ok { // unsupported translation return false, errAccessNotImplemented } - a.log.Debug("evaluating zanzana", "user", key.User, "relation", key.Relation, "object", key.Object) - res, err := a.zclient.Check(ctx, &openfgav1.CheckRequest{ - TupleKey: &openfgav1.CheckRequestTupleKey{ - User: key.User, - Relation: key.Relation, - Object: key.Object, - }, + a.log.Debug("evaluating zanzana", "user", tupleKey.User, "relation", tupleKey.Relation, "object", tupleKey.Object) + allowed, err := a.Check(ctx, accesscontrol.CheckRequest{ + User: tupleKey.User, + Relation: tupleKey.Relation, + Object: tupleKey.Object, }) if err != nil { return false, err } - return res.Allowed, nil + return allowed, nil }) } @@ -221,3 +219,30 @@ func (a *AccessControl) debug(ctx context.Context, ident identity.Requester, msg a.log.FromContext(ctx).Debug(msg, "id", ident.GetID(), "orgID", ident.GetOrgID(), "permissions", eval.GoString()) } + +func (a *AccessControl) Check(ctx context.Context, req accesscontrol.CheckRequest) (bool, error) { + key := &openfgav1.CheckRequestTupleKey{ + User: req.User, + Relation: req.Relation, + Object: req.Object, + } + in := &openfgav1.CheckRequest{TupleKey: key} + res, err := a.zclient.Check(ctx, in) + if err != nil { + return false, err + } + return res.Allowed, err +} + +func (a *AccessControl) ListObjects(ctx context.Context, req accesscontrol.ListObjectsRequest) ([]string, error) { + in := &openfgav1.ListObjectsRequest{ + Type: req.Type, + User: req.User, + Relation: req.Relation, + } + res, err := a.zclient.ListObjects(ctx, in) + if err != nil { + return nil, err + } + return res.Objects, err +} diff --git a/pkg/services/accesscontrol/acimpl/service.go b/pkg/services/accesscontrol/acimpl/service.go index f9300fc1545..8127ea6d074 100644 --- a/pkg/services/accesscontrol/acimpl/service.go +++ b/pkg/services/accesscontrol/acimpl/service.go @@ -8,11 +8,10 @@ import ( "strings" "time" + "github.com/grafana/authlib/claims" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel/attribute" - "github.com/grafana/authlib/claims" - "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/db" diff --git a/pkg/services/accesscontrol/actest/fake.go b/pkg/services/accesscontrol/actest/fake.go index ce23a52544e..537002d077a 100644 --- a/pkg/services/accesscontrol/actest/fake.go +++ b/pkg/services/accesscontrol/actest/fake.go @@ -75,6 +75,14 @@ func (f FakeAccessControl) Evaluate(ctx context.Context, user identity.Requester func (f FakeAccessControl) RegisterScopeAttributeResolver(prefix string, resolver accesscontrol.ScopeAttributeResolver) { } +func (f FakeAccessControl) Check(ctx context.Context, in accesscontrol.CheckRequest) (bool, error) { + return false, nil +} + +func (f FakeAccessControl) ListObjects(ctx context.Context, in accesscontrol.ListObjectsRequest) ([]string, error) { + return nil, nil +} + func (f FakeAccessControl) WithoutResolvers() accesscontrol.AccessControl { return f } diff --git a/pkg/services/accesscontrol/migrator/zanzana.go b/pkg/services/accesscontrol/migrator/zanzana.go index fe21f0101fe..f27316f2630 100644 --- a/pkg/services/accesscontrol/migrator/zanzana.go +++ b/pkg/services/accesscontrol/migrator/zanzana.go @@ -94,12 +94,12 @@ func (z *ZanzanaSynchroniser) Sync(ctx context.Context) error { func managedPermissionsCollector(store db.DB) TupleCollector { return func(ctx context.Context, tuples map[string][]*openfgav1.TupleKey) error { const collectorID = "managed" - const query = ` + query := ` SELECT u.uid as user_uid, t.uid as team_uid, p.action, p.kind, p.identifier, r.org_id FROM permission p INNER JOIN role r ON p.role_id = r.id LEFT JOIN user_role ur ON r.id = ur.role_id - LEFT JOIN user u ON u.id = ur.user_id + LEFT JOIN ` + store.GetDialect().Quote("user") + ` u ON u.id = ur.user_id LEFT JOIN team_role tr ON r.id = tr.role_id LEFT JOIN team t ON tr.team_id = t.id LEFT JOIN builtin_role br ON r.id = br.role_id @@ -156,11 +156,11 @@ func teamMembershipCollector(store db.DB) TupleCollector { defer span.End() const collectorID = "team_membership" - const query = ` + query := ` SELECT t.uid as team_uid, u.uid as user_uid, tm.permission FROM team_member tm INNER JOIN team t ON tm.team_id = t.id - INNER JOIN user u ON tm.user_id = u.id + INNER JOIN ` + store.GetDialect().Quote("user") + ` u ON tm.user_id = u.id ` type membership struct { @@ -253,8 +253,10 @@ func dashboardFolderCollector(store db.DB) TupleCollector { defer span.End() const collectorID = "folder" - const query = ` - SELECT org_id, uid, folder_uid, is_folder FROM dashboard WHERE is_folder = 0 AND folder_uid IS NOT NULL + query := ` + SELECT org_id, uid, folder_uid, is_folder FROM dashboard + WHERE is_folder = ` + store.GetDialect().BooleanStr(false) + ` + AND folder_uid IS NOT NULL ` type dashboard struct { OrgID int64 `xorm:"org_id"` @@ -426,10 +428,10 @@ func customRolesCollector(store db.DB) TupleCollector { func basicRoleAssignemtCollector(store db.DB) TupleCollector { return func(ctx context.Context, tuples map[string][]*openfgav1.TupleKey) error { const collectorID = "basic_role_assignment" - const query = ` + query := ` SELECT ou.org_id, u.uid as user_uid, ou.role as org_role, u.is_admin FROM org_user ou - LEFT JOIN user u ON u.id = ou.user_id + LEFT JOIN ` + store.GetDialect().Quote("user") + ` u ON u.id = ou.user_id ` type Assignment struct { OrgID int64 `xorm:"org_id"` @@ -474,11 +476,11 @@ func basicRoleAssignemtCollector(store db.DB) TupleCollector { func userRoleAssignemtCollector(store db.DB) TupleCollector { return func(ctx context.Context, tuples map[string][]*openfgav1.TupleKey) error { const collectorID = "user_role_assignment" - const query = ` + query := ` SELECT ur.org_id, u.uid AS user_uid, r.uid AS role_uid, r.name AS role_name FROM user_role ur LEFT JOIN role r ON r.id = ur.role_id - LEFT JOIN user u ON u.id = ur.user_id + LEFT JOIN ` + store.GetDialect().Quote("user") + ` u ON u.id = ur.user_id WHERE r.name NOT LIKE 'managed:%' ` diff --git a/pkg/services/accesscontrol/mock/mock.go b/pkg/services/accesscontrol/mock/mock.go index 747df53d23e..459425addaf 100644 --- a/pkg/services/accesscontrol/mock/mock.go +++ b/pkg/services/accesscontrol/mock/mock.go @@ -266,6 +266,14 @@ func (m *Mock) SyncUserRoles(ctx context.Context, orgID int64, cmd accesscontrol return nil } +func (m *Mock) Check(ctx context.Context, in accesscontrol.CheckRequest) (bool, error) { + return false, nil +} + +func (m *Mock) ListObjects(ctx context.Context, in accesscontrol.ListObjectsRequest) ([]string, error) { + return nil, nil +} + // WithoutResolvers implements fullAccessControl. func (m *Mock) WithoutResolvers() accesscontrol.AccessControl { return m diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index 83e7b390f73..9c09c77ae9f 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -585,3 +585,15 @@ type QueryWithOrg struct { OrgId *int64 `json:"orgId"` Global bool `json:"global"` } + +type CheckRequest struct { + User string + Relation string + Object string +} + +type ListObjectsRequest struct { + Type string + Relation string + User string +} diff --git a/pkg/services/authz/zanzana/client/client.go b/pkg/services/authz/zanzana/client/client.go index 6bd77f8afe8..8be64a91217 100644 --- a/pkg/services/authz/zanzana/client/client.go +++ b/pkg/services/authz/zanzana/client/client.go @@ -7,6 +7,8 @@ import ( openfgav1 "github.com/openfga/api/proto/openfga/v1" "github.com/openfga/language/pkg/go/transformer" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" "google.golang.org/grpc" "google.golang.org/protobuf/types/known/wrapperspb" @@ -14,6 +16,8 @@ import ( "github.com/grafana/grafana/pkg/services/authz/zanzana/schema" ) +var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/authz/zanzana/client") + type ClientOption func(c *Client) func WithTenantID(tenantID string) ClientOption { @@ -82,12 +86,19 @@ func New(ctx context.Context, cc grpc.ClientConnInterface, opts ...ClientOption) } func (c *Client) Check(ctx context.Context, in *openfgav1.CheckRequest) (*openfgav1.CheckResponse, error) { + ctx, span := tracer.Start(ctx, "authz.zanzana.client.Check") + defer span.End() + in.StoreId = c.storeID in.AuthorizationModelId = c.modelID return c.client.Check(ctx, in) } func (c *Client) ListObjects(ctx context.Context, in *openfgav1.ListObjectsRequest) (*openfgav1.ListObjectsResponse, error) { + ctx, span := tracer.Start(ctx, "authz.zanzana.client.ListObjects") + span.SetAttributes(attribute.String("resource.type", in.Type)) + defer span.End() + in.StoreId = c.storeID in.AuthorizationModelId = c.modelID return c.client.ListObjects(ctx, in) diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index 1eeb5fa5569..5e33ddcca73 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -8,6 +8,8 @@ import ( "time" "github.com/grafana/authlib/claims" + "go.opentelemetry.io/otel" + "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/metrics" @@ -25,7 +27,6 @@ import ( "github.com/grafana/grafana/pkg/services/tag" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" - "go.opentelemetry.io/otel" ) var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/dashboard/database") @@ -942,7 +943,9 @@ func (d *dashboardStore) FindDashboards(ctx context.Context, query *dashboards.F filters = append(filters, searchstore.K6FolderFilter{}) } - filters = append(filters, permissions.NewAccessControlDashboardPermissionFilter(query.SignedInUser, query.Permission, query.Type, d.features, recursiveQueriesAreSupported)) + if !query.SkipAccessControlFilter { + filters = append(filters, permissions.NewAccessControlDashboardPermissionFilter(query.SignedInUser, query.Permission, query.Type, d.features, recursiveQueriesAreSupported)) + } filters = append(filters, searchstore.DeletedFilter{Deleted: query.IsDeleted}) diff --git a/pkg/services/dashboards/errors.go b/pkg/services/dashboards/errors.go index 6e1528124cf..0dafc5aebc0 100644 --- a/pkg/services/dashboards/errors.go +++ b/pkg/services/dashboards/errors.go @@ -130,6 +130,7 @@ var ( ErrFolderInvalidUID = errors.New("invalid uid for folder provided") ErrFolderSameNameExists = errors.New("a folder with the same name already exists in the current location") ErrFolderAccessDenied = errors.New("access denied to folder") + ErrUserIsNotSignedInToOrg = errors.New("user is not signed in to organization") ErrMoveAccessDenied = errutil.Forbidden("folders.forbiddenMove", errutil.WithPublicMessage("Access denied to the destination folder")) ErrFolderAccessEscalation = errutil.Forbidden("folders.accessEscalation", errutil.WithPublicMessage("Cannot move a folder to a folder where you have higher permissions")) ErrFolderCreationAccessDenied = errutil.Forbidden("folders.forbiddenCreation", errutil.WithPublicMessage("not enough permissions to create a folder in the selected location")) diff --git a/pkg/services/dashboards/models.go b/pkg/services/dashboards/models.go index 9428fd961e5..48e10536d55 100644 --- a/pkg/services/dashboards/models.go +++ b/pkg/services/dashboards/models.go @@ -423,4 +423,8 @@ type FindPersistedDashboardsQuery struct { IsDeleted bool Filters []any + + // Skip access control checks. This field is used by OpenFGA search implementation. + // Should not be used anywhere else. + SkipAccessControlFilter bool } diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 90653059b10..54ef55b4446 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -8,12 +8,13 @@ import ( "time" "github.com/grafana/authlib/claims" - "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel" "golang.org/x/exp/slices" + "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/metrics" @@ -716,7 +717,13 @@ func (dr *DashboardServiceImpl) SearchDashboards(ctx context.Context, query *das ctx, span := tracer.Start(ctx, "dashboards.service.SearchDashboards") defer span.End() - res, err := dr.FindDashboards(ctx, query) + var res []dashboards.DashboardSearchProjection + var err error + if dr.features.IsEnabled(ctx, featuremgmt.FlagZanzana) { + res, err = dr.FindDashboardsZanzana(ctx, query) + } else { + res, err = dr.FindDashboards(ctx, query) + } if err != nil { return nil, err } diff --git a/pkg/services/dashboards/service/metrics.go b/pkg/services/dashboards/service/metrics.go index 2fb45131a60..d1fb59b70cc 100644 --- a/pkg/services/dashboards/service/metrics.go +++ b/pkg/services/dashboards/service/metrics.go @@ -10,8 +10,12 @@ const ( metricsSubSystem = "dashboards" ) +var defaultBuckets = []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 25} + type dashboardsMetrics struct { sharedWithMeFetchDashboardsRequestsDuration *prometheus.HistogramVec + searchRequestsDuration *prometheus.HistogramVec + searchRequestStatusTotal *prometheus.CounterVec } func newDashboardsMetrics(r prometheus.Registerer) *dashboardsMetrics { @@ -20,7 +24,27 @@ func newDashboardsMetrics(r prometheus.Registerer) *dashboardsMetrics { prometheus.HistogramOpts{ Name: "sharedwithme_fetch_dashboards_duration_seconds", Help: "Duration of fetching dashboards with permissions directly assigned to user", - Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 25, 50, 100}, + Buckets: defaultBuckets, + Namespace: metricsNamespace, + Subsystem: metricsSubSystem, + }, + []string{"status"}, + ), + searchRequestsDuration: promauto.With(r).NewHistogramVec( + prometheus.HistogramOpts{ + Name: "search_dashboards_duration_seconds", + Help: "Duration of dashboards search (by authorization engine)", + Buckets: defaultBuckets, + Namespace: metricsNamespace, + Subsystem: metricsSubSystem, + }, + []string{"engine"}, + ), + + searchRequestStatusTotal: promauto.With(r).NewCounterVec( + prometheus.CounterOpts{ + Name: "search_dashboards_status_total", + Help: "Search status (success or error) for zanzana", Namespace: metricsNamespace, Subsystem: metricsSubSystem, }, diff --git a/pkg/services/dashboards/service/zanzana.go b/pkg/services/dashboards/service/zanzana.go new file mode 100644 index 00000000000..f7bfb4d23f6 --- /dev/null +++ b/pkg/services/dashboards/service/zanzana.go @@ -0,0 +1,342 @@ +package service + +import ( + "context" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" + + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/authz/zanzana" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" +) + +const ( + // If search query string shorter than this value, then "List, then check" strategy will be used + listQueryLengthThreshold = 8 + // If query limit set to value higher than this value, then "List, then check" strategy will be used + listQueryLimitThreshold = 50 + defaultQueryLimit = 1000 +) + +type searchResult struct { + runner string + result []dashboards.DashboardSearchProjection + err error + duration time.Duration +} + +func (dr *DashboardServiceImpl) FindDashboardsZanzana(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) { + if dr.cfg.Zanzana.ZanzanaOnlyEvaluation { + return dr.findDashboardsZanzanaOnly(ctx, *query) + } + return dr.findDashboardsZanzanaCompare(ctx, *query) +} + +func (dr *DashboardServiceImpl) findDashboardsZanzanaOnly(ctx context.Context, query dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) { + timer := prometheus.NewTimer(dr.metrics.searchRequestsDuration.WithLabelValues("zanzana")) + defer timer.ObserveDuration() + + return dr.findDashboardsZanzana(ctx, query) +} + +func (dr *DashboardServiceImpl) findDashboardsZanzanaCompare(ctx context.Context, query dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) { + result := make(chan searchResult, 2) + + go func() { + timer := prometheus.NewTimer(dr.metrics.searchRequestsDuration.WithLabelValues("zanzana")) + defer timer.ObserveDuration() + start := time.Now() + + queryZanzana := query + res, err := dr.findDashboardsZanzana(ctx, queryZanzana) + result <- searchResult{"zanzana", res, err, time.Since(start)} + }() + + go func() { + timer := prometheus.NewTimer(dr.metrics.searchRequestsDuration.WithLabelValues("grafana")) + defer timer.ObserveDuration() + start := time.Now() + + res, err := dr.FindDashboards(ctx, &query) + result <- searchResult{"grafana", res, err, time.Since(start)} + }() + + first, second := <-result, <-result + close(result) + + if second.runner == "grafana" { + first, second = second, first + } + + if second.err != nil { + dr.log.Error("zanzana search failed", "error", second.err) + dr.metrics.searchRequestStatusTotal.WithLabelValues("error").Inc() + } else if len(first.result) != len(second.result) { + dr.metrics.searchRequestStatusTotal.WithLabelValues("error").Inc() + dr.log.Warn( + "zanzana search result does not match grafana", + "grafana_result_len", len(first.result), + "zanana_result_len", len(second.result), + "grafana_duration", first.duration, + "zanzana_duration", second.duration, + ) + } else { + dr.metrics.searchRequestStatusTotal.WithLabelValues("success").Inc() + dr.log.Debug("zanzana search is correct", "result_len", len(first.result), "grafana_duration", first.duration, "zanzana_duration", second.duration) + } + + return first.result, first.err +} + +func (dr *DashboardServiceImpl) findDashboardsZanzana(ctx context.Context, query dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) { + findDashboards := dr.getFindDashboardsFn(query) + return findDashboards(ctx, query) +} + +type findDashboardsFn func(ctx context.Context, query dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) + +// getFindDashboardsFn makes a decision which search method should be used +func (dr *DashboardServiceImpl) getFindDashboardsFn(query dashboards.FindPersistedDashboardsQuery) findDashboardsFn { + if query.Limit > 0 && query.Limit < listQueryLimitThreshold && len(query.Title) > 0 { + return dr.findDashboardsZanzanaCheck + } + if len(query.DashboardUIDs) > 0 || len(query.DashboardIds) > 0 { + return dr.findDashboardsZanzanaCheck + } + if len(query.FolderUIDs) > 0 { + return dr.findDashboardsZanzanaCheck + } + if len(query.Title) <= listQueryLengthThreshold { + return dr.findDashboardsZanzanaList + } + return dr.findDashboardsZanzanaCheck +} + +// findDashboardsZanzanaCheck implements "Search, then check" strategy. It first performs search query, then filters out results +// by checking access to each item. +func (dr *DashboardServiceImpl) findDashboardsZanzanaCheck(ctx context.Context, query dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) { + ctx, span := tracer.Start(ctx, "dashboards.service.findDashboardsZanzanaCheck") + defer span.End() + + result := make([]dashboards.DashboardSearchProjection, 0, query.Limit) + var page int64 = 1 + query.SkipAccessControlFilter = true + // Remember initial query limit + limit := query.Limit + // Set limit to default to prevent pagination issues + query.Limit = defaultQueryLimit + + for len(result) < int(limit) { + query.Page = page + findRes, err := dr.dashboardStore.FindDashboards(ctx, &query) + if err != nil { + return nil, err + } + + remains := limit - int64(len(result)) + res, err := dr.checkDashboards(ctx, query, findRes, remains) + if err != nil { + return nil, err + } + + result = append(result, res...) + page++ + + // Stop when last page reached + if len(findRes) < defaultQueryLimit { + break + } + } + + return result, nil +} + +func (dr *DashboardServiceImpl) checkDashboards(ctx context.Context, query dashboards.FindPersistedDashboardsQuery, searchRes []dashboards.DashboardSearchProjection, remains int64) ([]dashboards.DashboardSearchProjection, error) { + ctx, span := tracer.Start(ctx, "dashboards.service.checkDashboards") + defer span.End() + + if len(searchRes) == 0 { + return nil, nil + } + + orgId := query.OrgId + if orgId == 0 && query.SignedInUser.GetOrgID() != 0 { + orgId = query.SignedInUser.GetOrgID() + } else { + return nil, dashboards.ErrUserIsNotSignedInToOrg + } + + concurrentRequests := dr.cfg.Zanzana.ConcurrentChecks + var wg sync.WaitGroup + res := make([]dashboards.DashboardSearchProjection, 0) + resToCheck := make(chan dashboards.DashboardSearchProjection, concurrentRequests) + allowedResults := make(chan dashboards.DashboardSearchProjection, len(searchRes)) + + for i := 0; i < int(concurrentRequests); i++ { + wg.Add(1) + go func() { + defer wg.Done() + for d := range resToCheck { + if int64(len(allowedResults)) >= remains { + return + } + + objectType := zanzana.TypeDashboard + if d.IsFolder { + objectType = zanzana.TypeFolder + } + + req := accesscontrol.CheckRequest{ + User: query.SignedInUser.GetUID(), + Relation: "read", + Object: zanzana.NewScopedTupleEntry(objectType, d.UID, "", strconv.FormatInt(orgId, 10)), + } + + allowed, err := dr.ac.Check(ctx, req) + if err != nil { + dr.log.Error("error checking access", "error", err) + } else if allowed { + allowedResults <- d + } + } + }() + } + + for _, r := range searchRes { + resToCheck <- r + } + close(resToCheck) + + wg.Wait() + close(allowedResults) + + for r := range allowedResults { + if int64(len(res)) >= remains { + break + } + res = append(res, r) + } + + return res, nil +} + +// findDashboardsZanzanaList implements "List, then search" strategy. It first retrieve a list of resources +// with given type available to the user and then passes that list as a filter to the search query. +func (dr *DashboardServiceImpl) findDashboardsZanzanaList(ctx context.Context, query dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) { + // Always use "search, then check" if dashboard or folder UIDs provided. Otherwise we should make intersection + // of user's resources and provided UIDs which might not be correct if ListObjects() request is limited by OpenFGA. + if len(query.DashboardUIDs) > 0 || len(query.DashboardIds) > 0 || len(query.FolderUIDs) > 0 { + return dr.findDashboardsZanzanaCheck(ctx, query) + } + + ctx, span := tracer.Start(ctx, "dashboards.service.findDashboardsZanzanaList") + defer span.End() + + resourceUIDs, err := dr.listUserResources(ctx, query) + if err != nil { + return nil, err + } + if len(resourceUIDs) == 0 { + return []dashboards.DashboardSearchProjection{}, nil + } + + query.DashboardUIDs = resourceUIDs + query.SkipAccessControlFilter = true + return dr.dashboardStore.FindDashboards(ctx, &query) +} + +func (dr *DashboardServiceImpl) listUserResources(ctx context.Context, query dashboards.FindPersistedDashboardsQuery) ([]string, error) { + tasks := make([]func() ([]string, error), 0) + var resourceTypes []string + + // For some search types we need dashboards or folders only + switch query.Type { + case searchstore.TypeDashboard: + resourceTypes = []string{zanzana.TypeDashboard} + case searchstore.TypeFolder, searchstore.TypeAlertFolder: + resourceTypes = []string{zanzana.TypeFolder} + default: + resourceTypes = []string{zanzana.TypeDashboard, zanzana.TypeFolder} + } + + for _, resourceType := range resourceTypes { + tasks = append(tasks, func() ([]string, error) { + return dr.listAllowedResources(ctx, query, resourceType) + }) + } + + uids, err := runBatch(tasks) + if err != nil { + return nil, err + } + + return uids, nil +} + +func (dr *DashboardServiceImpl) listAllowedResources(ctx context.Context, query dashboards.FindPersistedDashboardsQuery, resourceType string) ([]string, error) { + res, err := dr.ac.ListObjects(ctx, accesscontrol.ListObjectsRequest{ + User: query.SignedInUser.GetUID(), + Type: resourceType, + Relation: "read", + }) + if err != nil { + return nil, err + } + + orgId := query.OrgId + if orgId == 0 && query.SignedInUser.GetOrgID() != 0 { + orgId = query.SignedInUser.GetOrgID() + } else { + return nil, dashboards.ErrUserIsNotSignedInToOrg + } + // dashboard:- + prefix := fmt.Sprintf("%s:%d-", resourceType, orgId) + + resourceUIDs := make([]string, 0) + for _, d := range res { + if uid, found := strings.CutPrefix(d, prefix); found { + resourceUIDs = append(resourceUIDs, uid) + } + } + + return resourceUIDs, nil +} + +func runBatch(tasks []func() ([]string, error)) ([]string, error) { + var wg sync.WaitGroup + tasksNum := len(tasks) + resChan := make(chan []string, tasksNum) + errChan := make(chan error, tasksNum) + + for _, task := range tasks { + wg.Add(1) + go func() { + defer wg.Done() + res, err := task() + resChan <- res + errChan <- err + }() + } + + wg.Wait() + close(resChan) + close(errChan) + + for err := range errChan { + if err != nil { + return nil, err + } + } + + result := make([]string, 0) + for res := range resChan { + result = append(result, res...) + } + return result, nil +} diff --git a/pkg/services/dashboards/service/zanzana_integration_test.go b/pkg/services/dashboards/service/zanzana_integration_test.go new file mode 100644 index 00000000000..e65e73de154 --- /dev/null +++ b/pkg/services/dashboards/service/zanzana_integration_test.go @@ -0,0 +1,121 @@ +package service + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" + "github.com/grafana/grafana/pkg/services/accesscontrol/migrator" + accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + "github.com/grafana/grafana/pkg/services/authz" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/dashboards/database" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder/folderimpl" + "github.com/grafana/grafana/pkg/services/folder/foldertest" + "github.com/grafana/grafana/pkg/services/guardian" + "github.com/grafana/grafana/pkg/services/quota/quotatest" + "github.com/grafana/grafana/pkg/services/tag/tagimpl" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" +) + +func TestIntegrationDashboardServiceZanzana(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + t.Run("Zanzana enabled", func(t *testing.T) { + // t.Helper() + + features := featuremgmt.WithFeatures(featuremgmt.FlagZanzana) + + db, cfg := db.InitTestDBWithCfg(t) + + // Enable zanzana and run in embedded mode (part of grafana server) + cfg.Zanzana.ZanzanaOnlyEvaluation = true + cfg.Zanzana.Mode = setting.ZanzanaModeEmbedded + cfg.Zanzana.ConcurrentChecks = 10 + + _, err := cfg.Raw.Section("rbac").NewKey("resources_with_managed_permissions_on_creation", "dashboard, folder") + require.NoError(t, err) + + quotaService := quotatest.New(false, nil) + tagService := tagimpl.ProvideService(db) + folderStore := folderimpl.ProvideDashboardFolderStore(db) + fStore := folderimpl.ProvideStore(db) + dashboardStore, err := database.ProvideDashboardStore(db, cfg, features, tagService, quotaService) + require.NoError(t, err) + + zclient, err := authz.ProvideZanzana(cfg, db, features) + require.NoError(t, err) + ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zclient) + + service, err := ProvideDashboardServiceImpl( + cfg, dashboardStore, folderStore, + featuremgmt.WithFeatures(), + accesscontrolmock.NewMockedPermissionsService(), + accesscontrolmock.NewMockedPermissionsService(), + ac, + foldertest.NewFakeService(), + fStore, + nil, + ) + require.NoError(t, err) + + guardianMock := &guardian.FakeDashboardGuardian{ + CanSaveValue: true, + } + guardian.MockDashboardGuardian(guardianMock) + + createDashboards(t, service, 100, "test-a") + createDashboards(t, service, 100, "test-b") + + // Sync Grafana DB with zanzana (migrate data) + zanzanaSyncronizer := migrator.NewZanzanaSynchroniser(zclient, db) + err = zanzanaSyncronizer.Sync(context.Background()) + require.NoError(t, err) + + query := &dashboards.FindPersistedDashboardsQuery{ + Title: "test-a", + Limit: 1000, + SignedInUser: &user.SignedInUser{ + OrgID: 1, + UserID: 1, + }, + } + res, err := service.FindDashboardsZanzana(context.Background(), query) + + require.NoError(t, err) + assert.Equal(t, 0, len(res)) + }) +} + +func createDashboard(t *testing.T, service dashboards.DashboardService, uid, title string) { + dto := &dashboards.SaveDashboardDTO{ + OrgID: 1, + // User: user, + User: &user.SignedInUser{ + OrgID: 1, + UserID: 1, + }, + } + dto.Dashboard = dashboards.NewDashboard(title) + dto.Dashboard.SetUID(uid) + + _, err := service.SaveDashboard(context.Background(), dto, false) + require.NoError(t, err) +} + +func createDashboards(t *testing.T, service dashboards.DashboardService, number int, prefix string) { + for i := 0; i < number; i++ { + title := fmt.Sprintf("%s-%d", prefix, i) + uid := fmt.Sprintf("dash-%s", title) + createDashboard(t, service, uid, title) + } +} diff --git a/pkg/services/ngalert/accesscontrol/testing.go b/pkg/services/ngalert/accesscontrol/testing.go index 7b79a533a8d..1ea8c64be98 100644 --- a/pkg/services/ngalert/accesscontrol/testing.go +++ b/pkg/services/ngalert/accesscontrol/testing.go @@ -36,4 +36,12 @@ func (a *recordingAccessControlFake) WithoutResolvers() accesscontrol.AccessCont panic("unimplemented") } +func (a *recordingAccessControlFake) Check(ctx context.Context, in accesscontrol.CheckRequest) (bool, error) { + return false, nil +} + +func (a *recordingAccessControlFake) ListObjects(ctx context.Context, in accesscontrol.ListObjectsRequest) ([]string, error) { + return nil, nil +} + var _ accesscontrol.AccessControl = &recordingAccessControlFake{} diff --git a/pkg/services/ngalert/api/testing.go b/pkg/services/ngalert/api/testing.go index c219a96610e..1f87a5336f5 100644 --- a/pkg/services/ngalert/api/testing.go +++ b/pkg/services/ngalert/api/testing.go @@ -8,7 +8,6 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/apimachinery/identity" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/ngalert/eval" @@ -138,6 +137,14 @@ func (a *recordingAccessControlFake) IsDisabled() bool { return a.Disabled } +func (a *recordingAccessControlFake) Check(ctx context.Context, in ac.CheckRequest) (bool, error) { + return false, nil +} + +func (a *recordingAccessControlFake) ListObjects(ctx context.Context, in ac.ListObjectsRequest) ([]string, error) { + return nil, nil +} + var _ ac.AccessControl = &recordingAccessControlFake{} type fakeRuleAccessControlService struct { diff --git a/pkg/setting/settings_zanzana.go b/pkg/setting/settings_zanzana.go index fd692cd32b1..158d71d191f 100644 --- a/pkg/setting/settings_zanzana.go +++ b/pkg/setting/settings_zanzana.go @@ -20,6 +20,11 @@ type ZanzanaSettings struct { ListenHTTP bool // OpenFGA http server address which allows to connect with fga cli HttpAddr string + // Number of check requests running concurrently + ConcurrentChecks int64 + // If enabled, authorization cheks will be only performed by zanzana. + // This bypasses the performance comparison with the legacy system. + ZanzanaOnlyEvaluation bool } func (cfg *Cfg) readZanzanaSettings() { @@ -38,6 +43,8 @@ func (cfg *Cfg) readZanzanaSettings() { s.Addr = sec.Key("address").MustString("") s.ListenHTTP = sec.Key("listen_http").MustBool(false) s.HttpAddr = sec.Key("http_addr").MustString("127.0.0.1:8080") + s.ConcurrentChecks = sec.Key("concurrent_checks").MustInt64(10) + s.ZanzanaOnlyEvaluation = sec.Key("zanzana_only_evaluation").MustBool(false) cfg.Zanzana = s } From e37b43117b29bb5e008f1a70347b864bca0cba63 Mon Sep 17 00:00:00 2001 From: linoman <2051016+linoman@users.noreply.github.com> Date: Fri, 4 Oct 2024 12:33:47 +0200 Subject: [PATCH 16/16] SSO LDAP: Implement documentation (#94215) * Add documentation for LDAP UI * Add hyphen to string * Polish writing --------- Co-authored-by: Irene Rodriguez --- .../ldap-ui/_index.md | 121 ++++++++++++++++++ public/app/features/admin/ldap/LdapDrawer.tsx | 2 +- public/locales/en-US/grafana.json | 2 +- public/locales/pseudo-LOCALE/grafana.json | 2 +- 4 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 docs/sources/setup-grafana/configure-security/configure-authentication/ldap-ui/_index.md diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/ldap-ui/_index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/ldap-ui/_index.md new file mode 100644 index 00000000000..89ae937a995 --- /dev/null +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/ldap-ui/_index.md @@ -0,0 +1,121 @@ +--- +aliases: + - ../../../auth/enhanced-ldap/ +description: Learn about configuring LDAP authentication in Grafana using the Grafana UI. +labels: + products: + - cloud + - enterprise + - oss +menuTitle: LDAP user interface +title: Configure LDAP authentication using the Grafana user interface +weight: 300 +--- + +# Configure LDAP authentication using the Grafana user interface + +This page explains how to configure LDAP authentication in Grafana using the Grafana user interface. For more detailed information about configuring LDAP authentication using the configuration file, refer to [LDAP authentication]({{< relref "../ldap" >}}). + +Benefits of using the Grafana user interface to configure LDAP authentication include: + +- There is no need to edit the configuration file manually. +- Quickly test the connection to the LDAP server. +- There is no need to restart Grafana after making changes. + +{{% admonition type="note" %}} +Any configuration changes made through the Grafana user interface (UI) will take precedence over settings specified in the Grafana configuration file or through environment variables. If you modify any configuration settings in the UI, they will override any corresponding settings set via environment variables or defined in the configuration file. +{{% /admonition %}} + +## Before you begin + +Prerequisites: + +- Knowledge of LDAP authentication and how it works. +- Grafana instance v11.3.0 or later. +- Permissions `settings:read` and `settings:write` with `settings:auth.ldap:*` scope. +- This feature requires the `ssoSettingsLDAP` feature toggle to be enabled. + +## Steps to configure LDAP authentication + +Sign in to Grafana and navigate to **Administration > Authentication > LDAP**. + +### 1. Complete mandatory fields + +The mandatory fields have an asterisk (**\***) next to them. Complete the following fields: + +1. **Server host**: Host name or IP address of the LDAP server. +1. **Search filter**: The LDAP search filter finds entries within the directory. +1. **Search base DNS**: List of base DNs to search through. + +### 2. Complete optional fields + +Complete the optional fields as needed: + +1. **Bind DN**: Distinguished name (DN) of the user to bind to. +1. **Bind password**: Password for the server. + +### 3. Advanced settings + +Click the **Edit** button in the **Advanced settings** section to configure the following settings: + +#### 1. Miscellaneous settings + +Complementary settings for LDAP authentication. + +1. **Allow sign-up**: Allows new users to register upon logging in. +1. **Port**: Port number of the LDAP server. The default is 389. +1. **Timeout**: Time in seconds to wait for a response from the LDAP server. + +#### 2. Attributes + +Attributes used to map LDAP user assertion to Grafana user attributes. + +1. **Name**: Name of the assertion attribute to map to the Grafana user name. +1. **Surname**: Name of the assertion attribute to map to the Grafana user surname. +1. **Username**: Name of the assertion attribute to map to the Grafana user username. +1. **Member Of**: Name of the assertion attribute to map to the Grafana user membership. +1. **Email**: Name of the assertion attribute to map to the Grafana user email. + +#### 3. Group mapping + +Map LDAP groups to Grafana roles. + +1. **Skip organization role sync**: This option avoids syncing organization roles. It is useful when you want to manage roles manually. +1. **Group search filter**: The LDAP search filter finds groups within the directory. +1. **Group search base DNS**: List of base DNS to specify the matching groups' locations. +1. **Group name attribute**: Identifies users within group entries. +1. **Manage group mappings**: + + When managing group mappings, the following fields will become available. To add a new group mapping, click the **Add group mapping** button. + + 1. **Add a group DN mapping**: The name of the key used to extract the ID token. + 1. **Add an organization role mapping**: Select the Basic Role mapped to this group. + 1. **Add the organization ID membership mapping**: Map the group to an organization ID. + 1. **Define Grafana Admin membership**: Enable Grafana Admin privileges to the group. + +#### 4. Extra security settings + +Additional security settings options for LDAP authentication. + +1. **Enable SSL**: This option will enable SSL to connect to the LDAP server. +1. **Start TLS**: Use StartTLS to secure the connection to the LDAP server. +1. **Min TLS version**: Choose the minimum TLS version to use. TLS1.2 or TLS1.3 +1. **TLS ciphers**: List the ciphers to use for the connection. For a complete list of ciphers, refer to the [Cipher Go library](https://go.dev/src/crypto/tls/cipher_suites.go). +1. **Encryption key and certificate provision specification**: + This section allows you to specify the key and certificate for the LDAP server. You can provide the key and certificate in two ways: **base-64** encoded or **path to files**. + 1. **Base-64 encoded certificate**: + All values used in this section must be base-64 encoded. + 1. **Root CA certificate content**: List of root CA certificates. + 1. **Client certificate content**: Client certificate content. + 1. **Client key content**: Client key content. + 1. **Path to files**: + Path in the file system to the key and certificate files + 1. **Root CA certificate path**: Path to the root CA certificate. + 1. **Client certificate path**: Path to the client certificate. + 1. **Client key path**: Path to the client key. + +### 4. Persisting the configuration + +Once you have configured the LDAP settings, click **Save** to persist the configuration. + +If you want to delete all the changes made through the UI and revert to the configuration file settings, click the three dots menu icon and click **Reset to default values**. diff --git a/public/app/features/admin/ldap/LdapDrawer.tsx b/public/app/features/admin/ldap/LdapDrawer.tsx index a42cb0c08a0..dc84858191b 100644 --- a/public/app/features/admin/ldap/LdapDrawer.tsx +++ b/public/app/features/admin/ldap/LdapDrawer.tsx @@ -127,7 +127,7 @@ export const LdapDrawerComponent = ({