Compare commits

..

5 Commits

Author SHA1 Message Date
Will Browne
c3c5229986 undo go.mod change 2026-01-13 16:22:49 +00:00
Will Browne
7a2775b1a7 make update-workspace 2026-01-13 16:06:59 +00:00
Will Browne
d25a8f5e72 fix lint issues 2026-01-13 15:59:06 +00:00
Will Browne
48d032e5aa undo stale comment 2026-01-13 15:58:25 +00:00
Will Browne
ae06690681 move loading strategy into pkg/plugins 2026-01-13 15:46:34 +00:00
73 changed files with 1288 additions and 1883 deletions

1
.github/CODEOWNERS vendored
View File

@@ -440,7 +440,6 @@ i18next.config.ts @grafana/grafana-frontend-platform
/e2e-playwright/dashboards/TestDashboard.json @grafana/dashboards-squad @grafana/grafana-search-navigate-organise
/e2e-playwright/dashboards/TestV2Dashboard.json @grafana/dashboards-squad
/e2e-playwright/dashboards/V2DashWithRepeats.json @grafana/dashboards-squad
/e2e-playwright/dashboards/V2DashWithRowRepeats.json @grafana/dashboards-squad
/e2e-playwright/dashboards/V2DashWithTabRepeats.json @grafana/dashboards-squad
/e2e-playwright/dashboards-suite/adhoc-filter-from-panel.spec.ts @grafana/datapro
/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts @grafana/grafana-search-navigate-organise

View File

@@ -71,6 +71,11 @@ func convertDashboardSpec_V2alpha1_to_V1beta1(in *dashv2alpha1.DashboardSpec) (m
if err != nil {
return nil, fmt.Errorf("failed to convert panels: %w", err)
}
// Count total panels including those in collapsed rows
totalPanelsConverted := countTotalPanels(panels)
if totalPanelsConverted < len(in.Elements) {
return nil, fmt.Errorf("some panels were not converted from v2alpha1 to v1beta1")
}
if len(panels) > 0 {
dashboard["panels"] = panels
@@ -193,6 +198,29 @@ func convertLinksToV1(links []dashv2alpha1.DashboardDashboardLink) []map[string]
return result
}
// countTotalPanels counts all panels including those nested in collapsed row panels.
func countTotalPanels(panels []interface{}) int {
count := 0
for _, p := range panels {
panel, ok := p.(map[string]interface{})
if !ok {
count++
continue
}
// Check if this is a row panel with nested panels
if panelType, ok := panel["type"].(string); ok && panelType == "row" {
if nestedPanels, ok := panel["panels"].([]interface{}); ok {
count += len(nestedPanels)
}
// Don't count the row itself as a panel element
} else {
count++
}
}
return count
}
// convertPanelsFromElementsAndLayout converts V2 layout structures to V1 panel arrays.
// V1 only supports a flat array of panels with row panels for grouping.
// This function dispatches to the appropriate converter based on layout type:

View File

@@ -30,6 +30,7 @@ require (
require (
cel.dev/expr v0.25.1 // indirect
github.com/Machiel/slugify v1.0.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/NYTimes/gziphandler v1.1.1 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect

View File

@@ -9,6 +9,8 @@ github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/Machiel/slugify v1.0.1 h1:EfWSlRWstMadsgzmiV7d0yVd2IFlagWH68Q+DcYCm4E=
github.com/Machiel/slugify v1.0.1/go.mod h1:fTFGn5uWEynW4CUMG7sWkYXOf1UgDxyTM3DbR6Qfg3k=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I=
github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=

View File

@@ -4,7 +4,6 @@ import (
"context"
"time"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
)
@@ -12,26 +11,16 @@ const (
defaultLocalTTL = 1 * time.Hour
)
// PluginAssetsCalculator is an interface for calculating plugin asset information.
// LocalProvider requires this to calculate loading strategy and module hash.
type PluginAssetsCalculator interface {
LoadingStrategy(ctx context.Context, p pluginstore.Plugin) plugins.LoadingStrategy
ModuleHash(ctx context.Context, p pluginstore.Plugin) string
}
// LocalProvider retrieves plugin metadata for locally installed plugins.
// It uses the plugin store to access plugins that have already been loaded.
type LocalProvider struct {
store pluginstore.Store
pluginAssets PluginAssetsCalculator
store pluginstore.Store
}
// NewLocalProvider creates a new LocalProvider for locally installed plugins.
// pluginAssets is required for calculating loading strategy and module hash.
func NewLocalProvider(pluginStore pluginstore.Store, pluginAssets PluginAssetsCalculator) *LocalProvider {
func NewLocalProvider(pluginStore pluginstore.Store) *LocalProvider {
return &LocalProvider{
store: pluginStore,
pluginAssets: pluginAssets,
store: pluginStore,
}
}
@@ -42,10 +31,7 @@ func (p *LocalProvider) GetMeta(ctx context.Context, pluginID, version string) (
return nil, ErrMetaNotFound
}
loadingStrategy := p.pluginAssets.LoadingStrategy(ctx, plugin)
moduleHash := p.pluginAssets.ModuleHash(ctx, plugin)
spec := pluginStorePluginToMeta(plugin, loadingStrategy, moduleHash)
spec := pluginStorePluginToMeta(plugin, plugin.LoadingStrategy, plugin.ModuleHash)
return &Result{
Meta: spec,
TTL: defaultLocalTTL,

View File

@@ -35,10 +35,10 @@ For Grafana Cloud users, Grafana Support is not authorised to make org role chan
## Grafana server administrators
A Grafana server administrator (sometimes referred to as a **Grafana Admin**) manages server-wide settings and access to resources such as organizations, users, and licenses. Grafana includes a default server administrator that you can use to manage all of Grafana, or you can divide that responsibility among other server administrators that you create.
A Grafana server administrator manages server-wide settings and access to resources such as organizations, users, and licenses. Grafana includes a default server administrator that you can use to manage all of Grafana, or you can divide that responsibility among other server administrators that you create.
{{< admonition type="caution" >}}
The server administrator role is distinct from the [organization administrator](#organization-roles) role.
{{< admonition type="note" >}}
The server administrator role does not mean that the user is also a Grafana [organization administrator](#organization-roles).
{{< /admonition >}}
A server administrator can perform the following tasks:
@@ -50,7 +50,7 @@ A server administrator can perform the following tasks:
- Upgrade the server to Grafana Enterprise.
{{< admonition type="note" >}}
The server administrator (Grafana Admin) role does not exist in Grafana Cloud.
The server administrator role does not exist in Grafana Cloud.
{{< /admonition >}}
To assign or remove server administrator privileges, see [Server user management](../user-management/server-user-management/assign-remove-server-admin-privileges/).

View File

@@ -53,11 +53,6 @@ refs:
destination: /docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/access-control/custom-role-actions-scopes/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/account-management/authentication-and-permissions/access-control/custom-role-actions-scopes/
rbac-terraform-provisioning:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/access-control/rbac-terraform-provisioning/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/account-management/authentication-and-permissions/access-control/rbac-terraform-provisioning/
rbac-grafana-provisioning:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/access-control/rbac-grafana-provisioning/
@@ -150,13 +145,7 @@ Refer to the [RBAC HTTP API](ref:api-rbac-get-a-role) for more details.
## Create custom roles
This section shows you how to create a custom RBAC role using Grafana provisioning or the HTTP API.
Creating and editing custom roles is not currently possible in the Grafana UI. To manage custom roles, use one of the following methods:
- [Provisioning](ref:rbac-grafana-provisioning) (for self-managed instances)
- [HTTP API](ref:api-rbac-create-a-new-custom-role)
- [Terraform](ref:rbac-terraform-provisioning)
This section shows you how to create a custom RBAC role using Grafana provisioning and the HTTP API.
Create a custom role when basic roles and fixed roles do not meet your permissions requirements.
@@ -164,101 +153,14 @@ Create a custom role when basic roles and fixed roles do not meet your permissio
- [Plan your RBAC rollout strategy](ref:plan-rbac-rollout-strategy).
- Determine which permissions you want to add to the custom role. To see a list of actions and scope, refer to [RBAC permissions, actions, and scopes](ref:custom-role-actions-scopes).
- [Enable role provisioning](ref:rbac-grafana-provisioning).
- Ensure that you have permissions to create a custom role.
- By default, the Grafana Admin role has permission to create custom roles.
- A Grafana Admin can delegate the custom role privilege to another user by creating a custom role with the relevant permissions and adding the `permissions:type:delegate` scope.
### Create custom roles using the HTTP API
### Create custom roles using provisioning
The following examples show you how to create a custom role using the Grafana HTTP API. For more information about the HTTP API, refer to [Create a new custom role](ref:api-rbac-create-a-new-custom-role).
{{< admonition type="note" >}}
When you create a custom role you can only give it the same permissions you already have. For example, if you only have `users:create` permissions, then you can't create a role that includes other permissions.
{{< /admonition >}}
The following example creates a `custom:users:admin` role and assigns the `users:create` action to it.
**Example request**
```
curl --location --request POST '<grafana_url>/api/access-control/roles/' \
--header 'Authorization: Basic YWRtaW46cGFzc3dvcmQ=' \
--header 'Content-Type: application/json' \
--data-raw '{
"version": 1,
"uid": "jZrmlLCkGksdka",
"name": "custom:users:admin",
"displayName": "custom users admin",
"description": "My custom role which gives users permissions to create users",
"global": true,
"permissions": [
{
"action": "users:create"
}
]
}'
```
**Example response**
```
{
"version": 1,
"uid": "jZrmlLCkGksdka",
"name": "custom:users:admin",
"displayName": "custom users admin",
"description": "My custom role which gives users permissions to create users",
"global": true,
"permissions": [
{
"action": "users:create"
"updated": "2021-05-17T22:07:31.569936+02:00",
"created": "2021-05-17T22:07:31.569935+02:00"
}
],
"updated": "2021-05-17T22:07:31.564403+02:00",
"created": "2021-05-17T22:07:31.564403+02:00"
}
```
Refer to the [RBAC HTTP API](ref:api-rbac-create-a-new-custom-role) for more details.
### Create custom roles using Terraform
You can use the [Grafana Terraform provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs) to manage custom roles and their assignments. This is the recommended method for Grafana Cloud users who want to manage RBAC as code. For more information, refer to [Provisioning RBAC with Terraform](ref:rbac-terraform-provisioning).
The following example creates a custom role and assigns it to a team:
```terraform
resource "grafana_role" "custom_folder_manager" {
name = "custom:folders:manager"
description = "Custom role for reading and creating folders"
uid = "custom-folders-manager"
version = 1
global = true
permissions {
action = "folders:read"
scope = "folders:*"
}
permissions {
action = "folders:create"
scope = "folders:uid:general" # Allows creating folders at the root level
}
}
resource "grafana_role_assignment" "custom_folder_manager_assignment" {
role_uid = grafana_role.custom_folder_manager.uid
teams = ["<TEAM_UID>"]
}
```
For more information, refer to the [`grafana_role`](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/role) and [`grafana_role_assignment`](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/role_assignment) documentation in the Terraform Registry.
### Create custom roles using file-based provisioning
You can use [file-based provisioning](ref:rbac-grafana-provisioning) to create custom roles for self-managed instances.
[File-based provisioning](ref:rbac-grafana-provisioning) is one method you can use to create custom roles.
1. Open the YAML configuration file and locate the `roles` section.
@@ -349,6 +251,61 @@ roles:
state: 'absent'
```
### Create custom roles using the HTTP API
The following examples show you how to create a custom role using the Grafana HTTP API. For more information about the HTTP API, refer to [Create a new custom role](ref:api-rbac-create-a-new-custom-role).
{{< admonition type="note" >}}
You cannot create a custom role with permissions that you do not have. For example, if you only have `users:create` permissions, then you cannot create a role that includes other permissions.
{{< /admonition >}}
The following example creates a `custom:users:admin` role and assigns the `users:create` action to it.
**Example request**
```
curl --location --request POST '<grafana_url>/api/access-control/roles/' \
--header 'Authorization: Basic YWRtaW46cGFzc3dvcmQ=' \
--header 'Content-Type: application/json' \
--data-raw '{
"version": 1,
"uid": "jZrmlLCkGksdka",
"name": "custom:users:admin",
"displayName": "custom users admin",
"description": "My custom role which gives users permissions to create users",
"global": true,
"permissions": [
{
"action": "users:create"
}
]
}'
```
**Example response**
```
{
"version": 1,
"uid": "jZrmlLCkGksdka",
"name": "custom:users:admin",
"displayName": "custom users admin",
"description": "My custom role which gives users permissions to create users",
"global": true,
"permissions": [
{
"action": "users:create"
"updated": "2021-05-17T22:07:31.569936+02:00",
"created": "2021-05-17T22:07:31.569935+02:00"
}
],
"updated": "2021-05-17T22:07:31.564403+02:00",
"created": "2021-05-17T22:07:31.564403+02:00"
}
```
Refer to the [RBAC HTTP API](ref:api-rbac-create-a-new-custom-role) for more details.
## Update basic role permissions
If the default basic role definitions do not meet your requirements, you can change their permissions.

View File

@@ -6,6 +6,7 @@ description: Learn about RBAC Grafana provisioning and view an example YAML prov
file that configures Grafana role assignments.
labels:
products:
- cloud
- enterprise
menuTitle: Provisioning RBAC with Grafana
title: Provisioning RBAC with Grafana
@@ -51,13 +52,11 @@ refs:
# Provisioning RBAC with Grafana
{{< admonition type="note" >}}
Available in [Grafana Enterprise](/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise/) for self-managed instances. This feature is not available in Grafana Cloud.
Available in [Grafana Enterprise](/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise/) and [Grafana Cloud](/docs/grafana-cloud).
{{< /admonition >}}
You can create, change or remove [Custom roles](ref:manage-rbac-roles-create-custom-roles-using-provisioning) and create or remove [basic role assignments](ref:assign-rbac-roles-assign-a-fixed-role-to-a-basic-role-using-provisioning), by adding one or more YAML configuration files in the `provisioning/access-control/` directory.
Because this method requires access to the file system where Grafana is running, it's only available for self-managed Grafana instances. To provision RBAC in Grafana Cloud, use [Terraform](ref:rbac-terraform-provisioning) or the [HTTP API](ref:api-rbac-create-and-manage-custom-roles).
Grafana performs provisioning during startup. After you make a change to the configuration file, you can reload it during runtime. You do not need to restart the Grafana server for your changes to take effect.
**Before you begin:**

View File

@@ -370,7 +370,6 @@
"items": {
"type": "object",
"description": "For data source plugins. Proxy routes used for plugin authentication and adding headers to HTTP requests made by the plugin. For more information, refer to [Authentication for data source plugins](https://grafana.com/developers/plugin-tools/how-to-guides/data-source-plugins/add-authentication-for-data-source-plugins).",
"required": ["path"],
"additionalProperties": false,
"properties": {
"path": {

View File

@@ -1,7 +1,6 @@
import { test, expect } from '@grafana/plugin-e2e';
import testV2DashWithRepeats from '../dashboards/V2DashWithRepeats.json';
import testV2DashWithRowRepeats from '../dashboards/V2DashWithRowRepeats.json';
import {
checkRepeatedPanelTitles,
@@ -11,14 +10,11 @@ import {
saveDashboard,
importTestDashboard,
goToEmbeddedPanel,
goToPanelSnapshot,
} from './utils';
const repeatTitleBase = 'repeat - ';
const newTitleBase = 'edited rep - ';
const repeatOptions = [1, 2, 3, 4];
const getTitleInRepeatRow = (rowIndex: number, panelIndex: number) =>
`repeated-row-${rowIndex}-repeated-panel-${panelIndex}`;
test.use({
featureToggles: {
@@ -169,7 +165,9 @@ test.describe(
)
).toBeVisible();
await page.keyboard.press('Escape');
await dashboardPage
.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.backToDashboardButton)
.click();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.DashboardEditPaneSplitter.primaryBody)
@@ -219,7 +217,9 @@ test.describe(
)
).toBeVisible();
await page.keyboard.press('Escape');
await dashboardPage
.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.backToDashboardButton)
.click();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.DashboardEditPaneSplitter.primaryBody)
@@ -405,143 +405,5 @@ test.describe(
await dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.headerContainer).all()
).toHaveLength(3);
});
test('can view repeated panel in a repeated row', async ({ dashboardPage, selectors, page }) => {
await importTestDashboard(
page,
selectors,
'Custom grid repeats - view repeated panel in a repeated row',
JSON.stringify(testV2DashWithRowRepeats)
);
// make sure the repeated panel is present in multiple rows
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
).toBeVisible();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(2, 2)))
).toBeVisible();
await dashboardPage
.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
.hover();
await page.keyboard.press('v');
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(2, 2)))
).not.toBeVisible();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
).toBeVisible();
const repeatedPanelUrl = page.url();
await page.keyboard.press('Escape');
// load view panel directly
await page.goto(repeatedPanelUrl);
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
).toBeVisible();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(2, 2)))
).not.toBeVisible();
});
test('can view embedded panel in a repeated row', async ({ dashboardPage, selectors, page }) => {
const embedPanelTitle = 'embedded-panel';
await importTestDashboard(
page,
selectors,
'Custom grid repeats - view embedded repeated panel in a repeated row',
JSON.stringify(testV2DashWithRowRepeats)
);
await dashboardPage
.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
.hover();
await page.keyboard.press('p+e');
await goToEmbeddedPanel(page);
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
).toBeVisible();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(2, 2)))
).not.toBeVisible();
});
// there is a bug in the Snapshot feature that prevents the next two tests from passing
// tracking issue: https://github.com/grafana/grafana/issues/114509
test.skip('can view repeated panel inside snapshot', async ({ dashboardPage, selectors, page }) => {
await importTestDashboard(
page,
selectors,
'Custom grid repeats - view repeated panel inside snapshot',
JSON.stringify(testV2DashWithRowRepeats)
);
await dashboardPage
.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
.hover();
await page.keyboard.press('p+s');
// click "Publish snapshot"
await dashboardPage
.getByGrafanaSelector(selectors.pages.ShareDashboardDrawer.ShareSnapshot.publishSnapshot)
.click();
// click "Copy link" button in the snapshot drawer
await dashboardPage
.getByGrafanaSelector(selectors.pages.ShareDashboardDrawer.ShareSnapshot.copyUrlButton)
.click();
await goToPanelSnapshot(page);
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
).toBeVisible();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(2, 2)))
).not.toBeVisible();
});
test.skip('can view single panel in a repeated row inside snapshot', async ({ dashboardPage, selectors, page }) => {
await importTestDashboard(
page,
selectors,
'Custom grid repeats - view single panel inside snapshot',
JSON.stringify(testV2DashWithRowRepeats)
);
await dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('single panel row 1')).hover();
// open panel snapshot
await page.keyboard.press('p+s');
// click "Publish snapshot"
await dashboardPage
.getByGrafanaSelector(selectors.pages.ShareDashboardDrawer.ShareSnapshot.publishSnapshot)
.click();
// click "Copy link" button
await dashboardPage
.getByGrafanaSelector(selectors.pages.ShareDashboardDrawer.ShareSnapshot.copyUrlButton)
.click();
await goToPanelSnapshot(page);
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('single panel row 1'))
).toBeVisible();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
).toBeHidden();
});
}
);

View File

@@ -218,15 +218,6 @@ export async function goToEmbeddedPanel(page: Page) {
await page.goto(soloPanelUrl!);
}
export async function goToPanelSnapshot(page: Page) {
// extracting snapshot url from clipboard
const snapshotUrl = await page.evaluate(() => navigator.clipboard.readText());
expect(snapshotUrl).toBeDefined();
await page.goto(snapshotUrl);
}
export async function moveTab(
dashboardPage: DashboardPage,
page: Page,

View File

@@ -1,486 +0,0 @@
{
"apiVersion": "dashboard.grafana.app/v2beta1",
"kind": "Dashboard",
"metadata": {
"name": "ad8l8fz",
"namespace": "default",
"uid": "fLb2na54K8NZHvn8LfWGL1jhZh03Hy0xpV1KzMYgAXEX",
"resourceVersion": "1",
"generation": 2,
"creationTimestamp": "2025-11-25T15:52:42Z",
"labels": {
"grafana.app/deprecatedInternalID": "20"
},
"annotations": {
"grafana.app/createdBy": "user:aerwo725ot62od",
"grafana.app/updatedBy": "user:aerwo725ot62od",
"grafana.app/updatedTimestamp": "2025-11-25T15:52:42Z",
"grafana.app/folder": ""
}
},
"spec": {
"annotations": [
{
"kind": "AnnotationQuery",
"spec": {
"builtIn": true,
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"query": {
"datasource": {
"name": "-- Grafana --"
},
"group": "grafana",
"kind": "DataQuery",
"spec": {},
"version": "v0"
}
}
}
],
"cursorSync": "Off",
"description": "",
"editable": true,
"elements": {
"panel-1": {
"kind": "Panel",
"spec": {
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"hidden": false,
"query": {
"group": "",
"kind": "DataQuery",
"spec": {},
"version": "v0"
},
"refId": "A"
}
}
],
"queryOptions": {},
"transformations": []
}
},
"description": "",
"id": 4,
"links": [],
"title": "repeated-row-$c4-repeated-panel-$c3",
"vizConfig": {
"group": "timeseries",
"kind": "VizConfig",
"spec": {
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
}
},
"version": "12.4.0-pre"
}
}
},
"panel-2": {
"kind": "Panel",
"spec": {
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"hidden": false,
"query": {
"group": "",
"kind": "DataQuery",
"spec": {},
"version": "v0"
},
"refId": "A"
}
}
],
"queryOptions": {},
"transformations": []
}
},
"description": "",
"id": 2,
"links": [],
"title": "single panel row $c4",
"vizConfig": {
"group": "timeseries",
"kind": "VizConfig",
"spec": {
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
}
},
"version": "12.4.0-pre"
}
}
}
},
"layout": {
"kind": "RowsLayout",
"spec": {
"rows": [
{
"kind": "RowsLayoutRow",
"spec": {
"collapse": false,
"layout": {
"kind": "GridLayout",
"spec": {
"items": [
{
"kind": "GridLayoutItem",
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-1"
},
"height": 10,
"repeat": {
"direction": "h",
"mode": "variable",
"value": "c3"
},
"width": 24,
"x": 0,
"y": 0
}
},
{
"kind": "GridLayoutItem",
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-2"
},
"height": 8,
"width": 12,
"x": 0,
"y": 10
}
}
]
}
},
"repeat": {
"mode": "variable",
"value": "c4"
},
"title": "Repeated row $c4"
}
}
]
}
},
"links": [],
"liveNow": false,
"preload": false,
"tags": [],
"timeSettings": {
"autoRefresh": "",
"autoRefreshIntervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
"fiscalYearStartMonth": 0,
"from": "now-6h",
"hideTimepicker": false,
"timezone": "browser",
"to": "now"
},
"title": "test-e2e-repeats",
"variables": [
{
"kind": "CustomVariable",
"spec": {
"allowCustomValue": true,
"current": {
"text": ["1", "2", "3", "4"],
"value": ["1", "2", "3", "4"]
},
"hide": "dontHide",
"includeAll": true,
"multi": true,
"name": "c1",
"options": [
{
"selected": true,
"text": "1",
"value": "1"
},
{
"selected": true,
"text": "2",
"value": "2"
},
{
"selected": true,
"text": "3",
"value": "3"
},
{
"selected": true,
"text": "4",
"value": "4"
}
],
"query": "1,2,3,4",
"skipUrlSync": false
}
},
{
"kind": "CustomVariable",
"spec": {
"allowCustomValue": true,
"current": {
"text": ["A", "B", "C", "D"],
"value": ["A", "B", "C", "D"]
},
"hide": "dontHide",
"includeAll": true,
"multi": true,
"name": "c2",
"options": [
{
"selected": true,
"text": "A",
"value": "A"
},
{
"selected": true,
"text": "B",
"value": "B"
},
{
"selected": true,
"text": "C",
"value": "C"
},
{
"selected": true,
"text": "D",
"value": "D"
}
],
"query": "A,B,C,D",
"skipUrlSync": false
}
},
{
"kind": "CustomVariable",
"spec": {
"allowCustomValue": true,
"current": {
"text": ["1", "2", "3", "4"],
"value": ["1", "2", "3", "4"]
},
"hide": "dontHide",
"includeAll": false,
"multi": true,
"name": "c3",
"options": [
{
"selected": true,
"text": "1",
"value": "1"
},
{
"selected": true,
"text": "2",
"value": "2"
},
{
"selected": true,
"text": "3",
"value": "3"
},
{
"selected": true,
"text": "4",
"value": "4"
}
],
"query": "1, 2, 3, 4",
"skipUrlSync": false
}
},
{
"kind": "CustomVariable",
"spec": {
"allowCustomValue": true,
"current": {
"text": ["1", "2", "3", "4"],
"value": ["1", "2", "3", "4"]
},
"hide": "dontHide",
"includeAll": false,
"multi": true,
"name": "c4",
"options": [
{
"selected": true,
"text": "1",
"value": "1"
},
{
"selected": true,
"text": "2",
"value": "2"
},
{
"selected": true,
"text": "3",
"value": "3"
},
{
"selected": true,
"text": "4",
"value": "4"
}
],
"query": "1, 2, 3, 4",
"skipUrlSync": false
}
}
]
},
"status": {}
}

View File

@@ -984,11 +984,6 @@ export interface FeatureToggles {
*/
recentlyViewedDashboards?: boolean;
/**
* A/A test for recently viewed dashboards feature
* @default false
*/
experimentRecentlyViewedDashboards?: boolean;
/**
* Enable configuration of alert enrichments in Grafana Cloud.
* @default false
*/

View File

@@ -1,5 +1,4 @@
#!/bin/bash
set -e
#!/bin/bash -e
PERMISSIONS_OK=0
@@ -27,14 +26,14 @@ if [ ! -d "$GF_PATHS_PLUGINS" ]; then
fi
if [ ! -z ${GF_AWS_PROFILES+x} ]; then
:> "$GF_PATHS_HOME/.aws/credentials"
> "$GF_PATHS_HOME/.aws/credentials"
for profile in ${GF_AWS_PROFILES}; do
access_key_varname="GF_AWS_${profile}_ACCESS_KEY_ID"
secret_key_varname="GF_AWS_${profile}_SECRET_ACCESS_KEY"
region_varname="GF_AWS_${profile}_REGION"
if [ ! -z "${!access_key_varname}" ] && [ ! -z "${!secret_key_varname}" ]; then
if [ ! -z "${!access_key_varname}" -a ! -z "${!secret_key_varname}" ]; then
echo "[${profile}]" >> "$GF_PATHS_HOME/.aws/credentials"
echo "aws_access_key_id = ${!access_key_varname}" >> "$GF_PATHS_HOME/.aws/credentials"
echo "aws_secret_access_key = ${!secret_key_varname}" >> "$GF_PATHS_HOME/.aws/credentials"

View File

@@ -161,7 +161,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
AliasIDs: panel.AliasIDs,
Info: panel.Info,
Module: panel.Module,
ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), panel),
ModuleHash: panel.ModuleHash,
BaseURL: panel.BaseURL,
SkipDataQuery: panel.SkipDataQuery,
Suggestions: panel.Suggestions,
@@ -170,7 +170,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
Signature: string(panel.Signature),
Sort: getPanelSort(panel.ID),
Angular: panel.Angular,
LoadingStrategy: hs.pluginAssets.LoadingStrategy(c.Req.Context(), panel),
LoadingStrategy: panel.LoadingStrategy,
Translations: panel.Translations,
}
}
@@ -527,11 +527,11 @@ func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, availablePlug
JSONData: plugin.JSONData,
Signature: plugin.Signature,
Module: plugin.Module,
ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), plugin),
ModuleHash: plugin.ModuleHash,
BaseURL: plugin.BaseURL,
Angular: plugin.Angular,
MultiValueFilterOperators: plugin.MultiValueFilterOperators,
LoadingStrategy: hs.pluginAssets.LoadingStrategy(c.Req.Context(), plugin),
LoadingStrategy: plugin.LoadingStrategy,
Translations: plugin.Translations,
}
@@ -638,10 +638,10 @@ func (hs *HTTPServer) newAppDTO(ctx context.Context, plugin pluginstore.Plugin,
Path: plugin.Module,
Preload: false,
Angular: plugin.Angular,
LoadingStrategy: hs.pluginAssets.LoadingStrategy(ctx, plugin),
LoadingStrategy: plugin.LoadingStrategy,
Extensions: plugin.Extensions,
Dependencies: plugin.Dependencies,
ModuleHash: hs.pluginAssets.ModuleHash(ctx, plugin),
ModuleHash: plugin.ModuleHash,
Translations: plugin.Translations,
BuildMode: plugin.BuildMode,
}

View File

@@ -20,8 +20,6 @@ import (
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/manager/pluginfakes"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
@@ -33,7 +31,6 @@ import (
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/licensing"
"github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
"github.com/grafana/grafana/pkg/services/rendering"
@@ -46,7 +43,7 @@ import (
"github.com/grafana/grafana/pkg/web"
)
func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.FeatureToggles, pstore pluginstore.Store, psettings pluginsettings.Service, passets *pluginassets.Service) (*web.Mux, *HTTPServer) {
func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.FeatureToggles, pstore pluginstore.Store, psettings pluginsettings.Service) (*web.Mux, *HTTPServer) {
t.Helper()
db.InitTestDB(t)
// nolint:staticcheck
@@ -77,12 +74,6 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F
pluginsSettings = &pluginsettings.FakePluginSettings{}
}
var pluginsAssets = passets
if pluginsAssets == nil {
sig := signature.ProvideService(pluginsCfg, statickey.New())
pluginsAssets = pluginassets.ProvideService(pluginsCfg, pluginsCDN, sig, pluginStore)
}
hs := &HTTPServer{
authnService: &authntest.FakeService{},
Cfg: cfg,
@@ -99,7 +90,6 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F
AccessControl: accesscontrolmock.New(),
PluginSettings: pluginsSettings,
pluginsCDNService: pluginsCDN,
pluginAssets: pluginsAssets,
namespacer: request.GetNamespaceMapper(cfg),
SocialService: socialimpl.ProvideService(cfg, features, &usagestats.UsageStatsMock{}, supportbundlestest.NewFakeBundleService(), remotecache.NewFakeCacheStorage(), nil, ssosettingstests.NewFakeService()),
managedPluginsService: managedplugins.NewNoop(),
@@ -132,7 +122,7 @@ func TestIntegrationHTTPServer_GetFrontendSettings_hideVersionAnonymous(t *testi
cfg.BuildVersion = "7.8.9"
cfg.BuildCommit = "01234567"
m, hs := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), nil, nil, nil)
m, hs := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), nil, nil)
req := httptest.NewRequest(http.MethodGet, "/api/frontend/settings", nil)
@@ -224,7 +214,7 @@ func TestIntegrationHTTPServer_GetFrontendSettings_pluginsCDNBaseURL(t *testing.
if test.mutateCfg != nil {
test.mutateCfg(cfg)
}
m, _ := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), nil, nil, nil)
m, _ := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), nil, nil)
req := httptest.NewRequest(http.MethodGet, "/api/frontend/settings", nil)
recorder := httptest.NewRecorder()
@@ -249,7 +239,6 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) {
desc string
pluginStore func() pluginstore.Store
pluginSettings func() pluginsettings.Service
pluginAssets func() *pluginassets.Service
expected settings
}{
{
@@ -266,7 +255,8 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Type: plugins.TypeApp,
Preload: true,
},
FS: &pluginfakes.FakePluginFS{},
FS: &pluginfakes.FakePluginFS{},
LoadingStrategy: plugins.LoadingStrategyScript,
},
},
}
@@ -276,7 +266,6 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Plugins: newAppSettings("test-app", false),
}
},
pluginAssets: newPluginAssets(),
expected: settings{
Apps: map[string]*plugins.AppDTO{
"test-app": {
@@ -304,7 +293,8 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Type: plugins.TypeApp,
Preload: true,
},
FS: &pluginfakes.FakePluginFS{},
FS: &pluginfakes.FakePluginFS{},
LoadingStrategy: plugins.LoadingStrategyScript,
},
},
}
@@ -314,7 +304,6 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Plugins: newAppSettings("test-app", true),
}
},
pluginAssets: newPluginAssets(),
expected: settings{
Apps: map[string]*plugins.AppDTO{
"test-app": {
@@ -341,8 +330,9 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Type: plugins.TypeApp,
Preload: true,
},
Angular: plugins.AngularMeta{Detected: true},
FS: &pluginfakes.FakePluginFS{},
Angular: plugins.AngularMeta{Detected: true},
FS: &pluginfakes.FakePluginFS{},
LoadingStrategy: plugins.LoadingStrategyFetch,
},
},
}
@@ -352,7 +342,6 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Plugins: newAppSettings("test-app", true),
}
},
pluginAssets: newPluginAssets(),
expected: settings{
Apps: map[string]*plugins.AppDTO{
"test-app": {
@@ -379,6 +368,7 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Type: plugins.TypeApp,
Preload: true,
},
LoadingStrategy: plugins.LoadingStrategyScript,
},
},
}
@@ -388,13 +378,6 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Plugins: newAppSettings("test-app", true),
}
},
pluginAssets: newPluginAssetsWithConfig(&config.PluginManagementCfg{
PluginSettings: map[string]map[string]string{
"test-app": {
pluginassets.CreatePluginVersionCfgKey: pluginassets.CreatePluginVersionScriptSupportEnabled,
},
},
}),
expected: settings{
Apps: map[string]*plugins.AppDTO{
"test-app": {
@@ -424,6 +407,7 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) {
FS: &pluginfakes.FakePluginFS{TypeFunc: func() plugins.FSType {
return plugins.FSTypeCDN
}},
LoadingStrategy: plugins.LoadingStrategyFetch,
},
},
}
@@ -433,7 +417,6 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Plugins: newAppSettings("test-app", true),
}
},
pluginAssets: newPluginAssets(),
expected: settings{
Apps: map[string]*plugins.AppDTO{
"test-app": {
@@ -451,7 +434,7 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) {
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
cfg := setting.NewCfg()
m, _ := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), test.pluginStore(), test.pluginSettings(), test.pluginAssets())
m, _ := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), test.pluginStore(), test.pluginSettings())
req := httptest.NewRequest(http.MethodGet, "/api/frontend/settings", nil)
recorder := httptest.NewRecorder()
@@ -552,7 +535,8 @@ func TestIntegrationHTTPServer_GetFrontendSettings_translations(t *testing.T) {
"en-US": "public/plugins/test-app/locales/en-US/test-app.json",
"pt-BR": "public/plugins/test-app/locales/pt-BR/test-app.json",
},
FS: &pluginfakes.FakePluginFS{},
FS: &pluginfakes.FakePluginFS{},
LoadingStrategy: plugins.LoadingStrategyScript,
},
},
}
@@ -602,7 +586,8 @@ func TestIntegrationHTTPServer_GetFrontendSettings_translations(t *testing.T) {
"en-US": "public/plugins/test-app/locales/en-US/test-app.json",
"pt-BR": "public/plugins/test-app/locales/pt-BR/test-app.json",
},
FS: &pluginfakes.FakePluginFS{},
FS: &pluginfakes.FakePluginFS{},
LoadingStrategy: plugins.LoadingStrategyScript,
},
},
}
@@ -642,7 +627,8 @@ func TestIntegrationHTTPServer_GetFrontendSettings_translations(t *testing.T) {
"en-US": "public/plugins/test-app/locales/en-US/test-app.json",
"pt-BR": "public/plugins/test-app/locales/pt-BR/test-app.json",
},
FS: &pluginfakes.FakePluginFS{},
FS: &pluginfakes.FakePluginFS{},
LoadingStrategy: plugins.LoadingStrategyScript,
},
},
}
@@ -670,7 +656,7 @@ func TestIntegrationHTTPServer_GetFrontendSettings_translations(t *testing.T) {
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
cfg := setting.NewCfg()
m, hs := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), test.pluginStore(), nil, nil)
m, hs := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), test.pluginStore(), nil)
// Create a request with the appropriate context
req := httptest.NewRequest(http.MethodGet, "/api/frontend/settings", nil)
@@ -707,13 +693,3 @@ func TestIntegrationHTTPServer_GetFrontendSettings_translations(t *testing.T) {
})
}
}
func newPluginAssets() func() *pluginassets.Service {
return newPluginAssetsWithConfig(&config.PluginManagementCfg{})
}
func newPluginAssetsWithConfig(pCfg *config.PluginManagementCfg) func() *pluginassets.Service {
return func() *pluginassets.Service {
return pluginassets.ProvideService(pCfg, pluginscdn.ProvideService(pCfg), signature.ProvideService(pCfg, statickey.New()), &pluginstore.FakePluginStore{})
}
}

View File

@@ -82,7 +82,6 @@ import (
"github.com/grafana/grafana/pkg/services/playlist"
"github.com/grafana/grafana/pkg/services/plugindashboards"
"github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginchecker"
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext"
pluginSettings "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings"
@@ -151,7 +150,6 @@ type HTTPServer struct {
pluginDashboardService plugindashboards.Service
pluginStaticRouteResolver plugins.StaticRouteResolver
pluginErrorResolver plugins.ErrorResolver
pluginAssets *pluginassets.Service
pluginPreinstall pluginchecker.Preinstall
SearchService search.Service
ShortURLService shorturls.Service
@@ -255,7 +253,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
encryptionService encryption.Internal, grafanaUpdateChecker *updatemanager.GrafanaService,
pluginsUpdateChecker *updatemanager.PluginsService, searchUsersService searchusers.Service,
dataSourcesService datasources.DataSourceService, queryDataService query.Service, pluginFileStore plugins.FileStore,
serviceaccountsService serviceaccounts.Service, pluginAssets *pluginassets.Service,
serviceaccountsService serviceaccounts.Service,
authInfoService login.AuthInfoService, storageService store.StorageService,
notificationService notifications.Service, dashboardService dashboards.DashboardService,
dashboardProvisioningService dashboards.DashboardProvisioningService, folderService folder.Service,
@@ -294,7 +292,6 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
pluginStore: pluginStore,
pluginStaticRouteResolver: pluginStaticRouteResolver,
pluginDashboardService: pluginDashboardService,
pluginAssets: pluginAssets,
pluginErrorResolver: pluginErrorResolver,
pluginFileStore: pluginFileStore,
grafanaUpdateChecker: grafanaUpdateChecker,

View File

@@ -201,7 +201,7 @@ func (hs *HTTPServer) GetPluginSettingByID(c *contextmodel.ReqContext) response.
Includes: plugin.Includes,
BaseUrl: plugin.BaseURL,
Module: plugin.Module,
ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), plugin),
ModuleHash: plugin.ModuleHash,
DefaultNavUrl: path.Join(hs.Cfg.AppSubURL, plugin.DefaultNavURL),
State: plugin.State,
Signature: plugin.Signature,
@@ -209,7 +209,7 @@ func (hs *HTTPServer) GetPluginSettingByID(c *contextmodel.ReqContext) response.
SignatureOrg: plugin.SignatureOrg,
SecureJsonFields: map[string]bool{},
AngularDetected: plugin.Angular.Detected,
LoadingStrategy: hs.pluginAssets.LoadingStrategy(c.Req.Context(), plugin),
LoadingStrategy: plugin.LoadingStrategy,
Extensions: plugin.Extensions,
Translations: plugin.Translations,
}

View File

@@ -28,8 +28,6 @@ import (
"github.com/grafana/grafana/pkg/plugins/manager/filestore"
"github.com/grafana/grafana/pkg/plugins/manager/pluginfakes"
"github.com/grafana/grafana/pkg/plugins/manager/registry"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
"github.com/grafana/grafana/pkg/plugins/pluginerrs"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
@@ -43,7 +41,6 @@ import (
"github.com/grafana/grafana/pkg/services/org/orgtest"
"github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginchecker"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
@@ -677,9 +674,10 @@ func Test_PluginsList_AccessControl(t *testing.T) {
func createPlugin(jd plugins.JSONData, class plugins.Class, files plugins.FS) *plugins.Plugin {
return &plugins.Plugin{
JSONData: jd,
Class: class,
FS: files,
JSONData: jd,
Class: class,
FS: files,
LoadingStrategy: plugins.LoadingStrategyScript,
}
}
@@ -846,10 +844,6 @@ func Test_PluginsSettings(t *testing.T) {
ErrorCode: tc.errCode,
})
}
pCfg := &config.PluginManagementCfg{}
pluginCDN := pluginscdn.ProvideService(pCfg)
sig := signature.ProvideService(pCfg, statickey.New())
hs.pluginAssets = pluginassets.ProvideService(pCfg, pluginCDN, sig, hs.pluginStore)
hs.pluginErrorResolver = pluginerrs.ProvideStore(errTracker)
hs.pluginsUpdateChecker, err = updatemanager.ProvidePluginsService(
hs.Cfg,

View File

@@ -4,6 +4,7 @@ go 1.25.5
require (
github.com/Machiel/slugify v1.0.1
github.com/Masterminds/semver/v3 v3.4.0
github.com/ProtonMail/go-crypto v1.3.0
github.com/gobwas/glob v0.2.3
github.com/google/go-cmp v0.7.0

View File

@@ -2,6 +2,8 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Machiel/slugify v1.0.1 h1:EfWSlRWstMadsgzmiV7d0yVd2IFlagWH68Q+DcYCm4E=
github.com/Machiel/slugify v1.0.1/go.mod h1:fTFGn5uWEynW4CUMG7sWkYXOf1UgDxyTM3DbR6Qfg3k=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=

View File

@@ -140,7 +140,9 @@ type Licensing interface {
}
type SignatureCalculator interface {
Calculate(ctx context.Context, src PluginSource, plugin FoundPlugin) (Signature, error)
// Calculate calculates the signature and returns both the signature and the manifest.
// The manifest may be nil if the plugin is unsigned or if an error occurred.
Calculate(ctx context.Context, src PluginSource, plugin FoundPlugin) (Signature, *PluginManifest, error)
}
type KeyStore interface {

View File

@@ -129,6 +129,7 @@ func TestLoader_Load(t *testing.T) {
Class: plugins.ClassCore,
SkipHostEnvVars: true,
Translations: map[string]string{},
LoadingStrategy: plugins.LoadingStrategyScript,
},
},
},
@@ -216,15 +217,33 @@ func TestLoader_Load(t *testing.T) {
ExtensionPoints: []plugins.ExtensionPoint{},
},
},
Class: plugins.ClassExternal,
Module: "public/plugins/test-app/module.js",
BaseURL: "public/plugins/test-app",
FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/includes-symlinks")),
Class: plugins.ClassExternal,
Module: "public/plugins/test-app/module.js",
BaseURL: "public/plugins/test-app",
FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/includes-symlinks")),
Manifest: &plugins.PluginManifest{
Plugin: "test-app",
Version: "1.0.0",
KeyID: "7e4d0c6a708866e7",
Time: 1622547655175,
Files: map[string]string{
"dashboards/connections.json": "bea86da4be970b98dc4681802ab55cdef3441dc3eb3c654cb207948d17b25303",
"dashboards/extra/memory.json": "7c042464941084caa91d0a9a2f188b05315a9796308a652ccdee31ca4fbcbfee",
"plugin.json": "c59a51bf6d7ecd7a99608ccb99353390c8b973672a938a0247164324005c0caf",
"symlink_to_txt": "9f32c171bf78a85d5cb77a48ab44f85578ee2942a1fc9f9ec4fde194ae4ff048",
"text.txt": "9f32c171bf78a85d5cb77a48ab44f85578ee2942a1fc9f9ec4fde194ae4ff048",
},
ManifestVersion: "2.0.0",
SignatureType: plugins.SignatureTypeGrafana,
SignedByOrg: "grafana",
SignedByOrgName: "Grafana Labs",
},
Signature: "valid",
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
SkipHostEnvVars: true,
Translations: map[string]string{},
LoadingStrategy: plugins.LoadingStrategyScript,
},
},
},
@@ -277,6 +296,7 @@ func TestLoader_Load(t *testing.T) {
Signature: "unsigned",
SkipHostEnvVars: true,
Translations: map[string]string{},
LoadingStrategy: plugins.LoadingStrategyScript,
},
},
},
@@ -336,6 +356,7 @@ func TestLoader_Load(t *testing.T) {
Signature: plugins.SignatureStatusUnsigned,
SkipHostEnvVars: true,
Translations: map[string]string{},
LoadingStrategy: plugins.LoadingStrategyScript,
},
},
},
@@ -434,6 +455,7 @@ func TestLoader_Load(t *testing.T) {
BaseURL: "public/plugins/test-app",
SkipHostEnvVars: true,
Translations: map[string]string{},
LoadingStrategy: plugins.LoadingStrategyScript,
},
},
},

View File

@@ -11,6 +11,7 @@ import (
"github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/pluginassets"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/grafana/grafana/pkg/plugins/tracing"
"github.com/grafana/grafana/pkg/semconv"
)
@@ -54,7 +55,7 @@ func New(cfg *config.PluginManagementCfg, opts Opts) *Bootstrap {
}
if opts.DecorateFuncs == nil {
opts.DecorateFuncs = DefaultDecorateFuncs(cfg)
opts.DecorateFuncs = DefaultDecorateFuncs(cfg, pluginscdn.ProvideService(cfg))
}
return &Bootstrap{

View File

@@ -11,6 +11,7 @@ import (
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/plugins/pluginassets"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
)
// DefaultConstructor implements the default ConstructFunc used for the Construct step of the Bootstrap stage.
@@ -28,12 +29,14 @@ func DefaultConstructFunc(cfg *config.PluginManagementCfg, signatureCalculator p
}
// DefaultDecorateFuncs are the default DecorateFuncs used for the Decorate step of the Bootstrap stage.
func DefaultDecorateFuncs(cfg *config.PluginManagementCfg) []DecorateFunc {
func DefaultDecorateFuncs(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service) []DecorateFunc {
return []DecorateFunc{
AppDefaultNavURLDecorateFunc,
TemplateDecorateFunc,
AppChildDecorateFunc(),
SkipHostEnvVarsDecorateFunc(cfg),
ModuleHashDecorateFunc(cfg, cdn),
LoadingStrategyDecorateFunc(cfg, cdn),
}
}
@@ -48,19 +51,30 @@ func NewDefaultConstructor(cfg *config.PluginManagementCfg, signatureCalculator
// Construct will calculate the plugin's signature state and create the plugin using the pluginFactoryFunc.
func (c *DefaultConstructor) Construct(ctx context.Context, src plugins.PluginSource, bundle *plugins.FoundBundle) ([]*plugins.Plugin, error) {
sig, err := c.signatureCalculator.Calculate(ctx, src, bundle.Primary)
// Calculate signature and cache manifest
sig, manifest, err := c.signatureCalculator.Calculate(ctx, src, bundle.Primary)
if err != nil {
c.log.Warn("Could not calculate plugin signature state", "pluginId", bundle.Primary.JSONData.ID, "error", err)
return nil, err
}
plugin, err := c.pluginFactoryFunc(bundle, src.PluginClass(ctx), sig)
if err != nil {
c.log.Error("Could not create primary plugin base", "pluginId", bundle.Primary.JSONData.ID, "error", err)
return nil, err
}
plugin.Manifest = manifest
res := make([]*plugins.Plugin, 0, len(plugin.Children)+1)
res = append(res, plugin)
res = append(res, plugin.Children...)
for _, child := range plugin.Children {
// Child plugins use the parent's manifest
if child.Parent != nil && child.Parent.Manifest != nil {
child.Manifest = child.Parent.Manifest
}
res = append(res, child)
}
return res, nil
}
@@ -145,3 +159,19 @@ func SkipHostEnvVarsDecorateFunc(cfg *config.PluginManagementCfg) DecorateFunc {
return p, nil
}
}
// ModuleHashDecorateFunc returns a DecorateFunc that calculates and sets the module hash for the plugin.
func ModuleHashDecorateFunc(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service) DecorateFunc {
return func(_ context.Context, p *plugins.Plugin) (*plugins.Plugin, error) {
p.ModuleHash = pluginassets.CalculateModuleHash(p, cfg, cdn)
return p, nil
}
}
// LoadingStrategyDecorateFunc returns a DecorateFunc that calculates and sets the loading strategy for the plugin.
func LoadingStrategyDecorateFunc(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service) DecorateFunc {
return func(_ context.Context, p *plugins.Plugin) (*plugins.Plugin, error) {
p.LoadingStrategy = pluginassets.CalculateLoadingStrategy(p, cfg, cdn)
return p, nil
}
}

View File

@@ -14,7 +14,6 @@ import (
"path"
"path/filepath"
"runtime"
"strings"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/clearsign"
@@ -37,26 +36,6 @@ var (
fromSlash = filepath.FromSlash
)
// PluginManifest holds details for the file manifest
type PluginManifest struct {
Plugin string `json:"plugin"`
Version string `json:"version"`
KeyID string `json:"keyId"`
Time int64 `json:"time"`
Files map[string]string `json:"files"`
// V2 supported fields
ManifestVersion string `json:"manifestVersion"`
SignatureType plugins.SignatureType `json:"signatureType"`
SignedByOrg string `json:"signedByOrg"`
SignedByOrgName string `json:"signedByOrgName"`
RootURLs []string `json:"rootUrls"`
}
func (m *PluginManifest) IsV2() bool {
return strings.HasPrefix(m.ManifestVersion, "2.")
}
type Signature struct {
kr plugins.KeyRetriever
cfg *config.PluginManagementCfg
@@ -87,14 +66,14 @@ func DefaultCalculator(cfg *config.PluginManagementCfg) *Signature {
// readPluginManifest attempts to read and verify the plugin manifest
// if any error occurs or the manifest is not valid, this will return an error
func (s *Signature) readPluginManifest(ctx context.Context, body []byte) (*PluginManifest, error) {
func (s *Signature) readPluginManifest(ctx context.Context, body []byte) (*plugins.PluginManifest, error) {
block, _ := clearsign.Decode(body)
if block == nil {
return nil, errors.New("unable to decode manifest")
}
// Convert to a well typed object
var manifest PluginManifest
var manifest plugins.PluginManifest
err := json.Unmarshal(block.Plaintext, &manifest)
if err != nil {
return nil, fmt.Errorf("%v: %w", "Error parsing manifest JSON", err)
@@ -111,7 +90,7 @@ var ErrSignatureTypeUnsigned = errors.New("plugin is unsigned")
// ReadPluginManifestFromFS reads the plugin manifest from the provided plugins.FS.
// If the manifest is not found, it will return an error wrapping ErrSignatureTypeUnsigned.
func (s *Signature) ReadPluginManifestFromFS(ctx context.Context, pfs plugins.FS) (*PluginManifest, error) {
func (s *Signature) ReadPluginManifestFromFS(ctx context.Context, pfs plugins.FS) (*plugins.PluginManifest, error) {
f, err := pfs.Open("MANIFEST.txt")
if err != nil {
if errors.Is(err, plugins.ErrFileNotExist) {
@@ -140,9 +119,9 @@ func (s *Signature) ReadPluginManifestFromFS(ctx context.Context, pfs plugins.FS
return manifest, nil
}
func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plugin plugins.FoundPlugin) (plugins.Signature, error) {
func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plugin plugins.FoundPlugin) (plugins.Signature, *plugins.PluginManifest, error) {
if defaultSignature, exists := src.DefaultSignature(ctx, plugin.JSONData.ID); exists {
return defaultSignature, nil
return defaultSignature, nil, nil
}
manifest, err := s.ReadPluginManifestFromFS(ctx, plugin.FS)
@@ -151,29 +130,29 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu
s.log.Warn("Plugin is unsigned", "id", plugin.JSONData.ID, "err", err)
return plugins.Signature{
Status: plugins.SignatureStatusUnsigned,
}, nil
}, nil, nil
case err != nil:
s.log.Warn("Plugin signature is invalid", "id", plugin.JSONData.ID, "err", err)
return plugins.Signature{
Status: plugins.SignatureStatusInvalid,
}, nil
}, nil, nil
}
if !manifest.IsV2() {
return plugins.Signature{
Status: plugins.SignatureStatusInvalid,
}, nil
}, nil, nil
}
fsFiles, err := plugin.FS.Files()
if err != nil {
return plugins.Signature{}, fmt.Errorf("files: %w", err)
return plugins.Signature{}, nil, fmt.Errorf("files: %w", err)
}
if len(fsFiles) == 0 {
s.log.Warn("No plugin file information in directory", "pluginId", plugin.JSONData.ID)
return plugins.Signature{
Status: plugins.SignatureStatusInvalid,
}, nil
}, nil, nil
}
// Make sure the versions all match
@@ -181,20 +160,20 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu
s.log.Debug("Plugin signature invalid because ID or Version mismatch", "pluginId", plugin.JSONData.ID, "manifestPluginId", manifest.Plugin, "pluginVersion", plugin.JSONData.Info.Version, "manifestPluginVersion", manifest.Version)
return plugins.Signature{
Status: plugins.SignatureStatusModified,
}, nil
}, nil, nil
}
// Validate that plugin is running within defined root URLs
if len(manifest.RootURLs) > 0 {
if match, err := urlMatch(manifest.RootURLs, s.cfg.GrafanaAppURL, manifest.SignatureType); err != nil {
s.log.Warn("Could not verify if root URLs match", "plugin", plugin.JSONData.ID, "rootUrls", manifest.RootURLs)
return plugins.Signature{}, err
return plugins.Signature{}, nil, err
} else if !match {
s.log.Warn("Could not find root URL that matches running application URL", "plugin", plugin.JSONData.ID,
"appUrl", s.cfg.GrafanaAppURL, "rootUrls", manifest.RootURLs)
return plugins.Signature{
Status: plugins.SignatureStatusInvalid,
}, nil
}, nil, nil
}
}
@@ -207,7 +186,7 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu
s.log.Debug("Plugin signature invalid", "pluginId", plugin.JSONData.ID, "error", err)
return plugins.Signature{
Status: plugins.SignatureStatusModified,
}, nil
}, nil, nil
}
manifestFiles[p] = struct{}{}
@@ -236,7 +215,7 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu
s.log.Warn("The following files were not included in the signature", "plugin", plugin.JSONData.ID, "files", unsignedFiles)
return plugins.Signature{
Status: plugins.SignatureStatusModified,
}, nil
}, nil, nil
}
s.log.Debug("Plugin signature valid", "id", plugin.JSONData.ID)
@@ -244,7 +223,7 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu
Status: plugins.SignatureStatusValid,
Type: manifest.SignatureType,
SigningOrg: manifest.SignedByOrgName,
}, nil
}, manifest, nil
}
func verifyHash(mlog log.Logger, plugin plugins.FoundPlugin, path, hash string) error {
@@ -321,7 +300,7 @@ func (r invalidFieldErr) Error() string {
return fmt.Sprintf("valid manifest field %s is required", r.field)
}
func (s *Signature) validateManifest(ctx context.Context, m PluginManifest, block *clearsign.Block) error {
func (s *Signature) validateManifest(ctx context.Context, m plugins.PluginManifest, block *clearsign.Block) error {
if len(m.Plugin) == 0 {
return invalidFieldErr{field: "plugin"}
}

View File

@@ -164,7 +164,7 @@ func TestCalculate(t *testing.T) {
for _, tc := range tcs {
basePath := filepath.Join(parentDir, "testdata/non-pvt-with-root-url/plugin")
s := provideTestServiceWithConfig(&config.PluginManagementCfg{GrafanaAppURL: tc.appURL})
sig, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{
sig, _, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{
PluginClassFunc: func(ctx context.Context) plugins.Class {
return plugins.ClassExternal
},
@@ -192,7 +192,7 @@ func TestCalculate(t *testing.T) {
runningWindows = true
s := provideDefaultTestService()
sig, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{
sig, _, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{
PluginClassFunc: func(ctx context.Context) plugins.Class {
return plugins.ClassExternal
},
@@ -260,7 +260,7 @@ func TestCalculate(t *testing.T) {
require.NoError(t, err)
pfs, err = newPathSeparatorOverrideFS(string(tc.platform.separator), pfs)
require.NoError(t, err)
sig, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{
sig, _, err := s.Calculate(context.Background(), &pluginfakes.FakePluginSource{
PluginClassFunc: func(ctx context.Context) plugins.Class {
return plugins.ClassExternal
},
@@ -396,7 +396,7 @@ func TestFSPathSeparatorFiles(t *testing.T) {
}
}
func fileList(manifest *PluginManifest) []string {
func fileList(manifest *plugins.PluginManifest) []string {
keys := make([]string, 0, len(manifest.Files))
for k := range manifest.Files {
keys = append(keys, k)
@@ -682,52 +682,52 @@ func Test_urlMatch_private(t *testing.T) {
func Test_validateManifest(t *testing.T) {
tcs := []struct {
name string
manifest *PluginManifest
manifest *plugins.PluginManifest
expectedErr string
}{
{
name: "Empty plugin field",
manifest: createV2Manifest(t, func(m *PluginManifest) { m.Plugin = "" }),
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Plugin = "" }),
expectedErr: "valid manifest field plugin is required",
},
{
name: "Empty keyId field",
manifest: createV2Manifest(t, func(m *PluginManifest) { m.KeyID = "" }),
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.KeyID = "" }),
expectedErr: "valid manifest field keyId is required",
},
{
name: "Empty signedByOrg field",
manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignedByOrg = "" }),
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignedByOrg = "" }),
expectedErr: "valid manifest field signedByOrg is required",
},
{
name: "Empty signedByOrgName field",
manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignedByOrgName = "" }),
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignedByOrgName = "" }),
expectedErr: "valid manifest field SignedByOrgName is required",
},
{
name: "Empty signatureType field",
manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignatureType = "" }),
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignatureType = "" }),
expectedErr: "valid manifest field signatureType is required",
},
{
name: "Invalid signatureType field",
manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignatureType = "invalidSignatureType" }),
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignatureType = "invalidSignatureType" }),
expectedErr: "valid manifest field signatureType is required",
},
{
name: "Empty files field",
manifest: createV2Manifest(t, func(m *PluginManifest) { m.Files = map[string]string{} }),
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Files = map[string]string{} }),
expectedErr: "valid manifest field files is required",
},
{
name: "Empty time field",
manifest: createV2Manifest(t, func(m *PluginManifest) { m.Time = 0 }),
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Time = 0 }),
expectedErr: "valid manifest field time is required",
},
{
name: "Empty version field",
manifest: createV2Manifest(t, func(m *PluginManifest) { m.Version = "" }),
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Version = "" }),
expectedErr: "valid manifest field version is required",
},
}
@@ -740,10 +740,10 @@ func Test_validateManifest(t *testing.T) {
}
}
func createV2Manifest(t *testing.T, cbs ...func(*PluginManifest)) *PluginManifest {
func createV2Manifest(t *testing.T, cbs ...func(*plugins.PluginManifest)) *plugins.PluginManifest {
t.Helper()
m := &PluginManifest{
m := &plugins.PluginManifest{
Plugin: "grafana-test-app",
Version: "2.5.3",
KeyID: "7e4d0c6a708866e7",

View File

@@ -0,0 +1,67 @@
package pluginassets
import (
"github.com/Masterminds/semver/v3"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
)
const (
CreatePluginVersionCfgKey = "create_plugin_version"
CreatePluginVersionScriptSupportEnabled = "4.15.0"
)
var (
scriptLoadingMinSupportedVersion = semver.MustParse(CreatePluginVersionScriptSupportEnabled)
)
// CalculateLoadingStrategy calculates the loading strategy for a plugin.
// If a plugin has plugin setting `create_plugin_version` >= 4.15.0, set loadingStrategy to "script".
// If a plugin is not loaded via the CDN and is not Angular, set loadingStrategy to "script".
// Otherwise, set loadingStrategy to "fetch".
func CalculateLoadingStrategy(p *plugins.Plugin, cfg *config.PluginManagementCfg, cdn *pluginscdn.Service) plugins.LoadingStrategy {
if cfg != nil && cfg.PluginSettings != nil {
if pCfg, ok := cfg.PluginSettings[p.ID]; ok {
if compatibleCreatePluginVersion(pCfg) {
return plugins.LoadingStrategyScript
}
}
// If the plugin has a parent
if p.Parent != nil {
// Check the parent's create_plugin_version setting
if pCfg, ok := cfg.PluginSettings[p.Parent.ID]; ok {
if compatibleCreatePluginVersion(pCfg) {
return plugins.LoadingStrategyScript
}
}
// Since the parent plugin is not explicitly configured as script loading compatible,
// If the plugin is either loaded from the CDN (via its parent) or contains Angular, we should use fetch
if cdnEnabled(p.Parent, cdn) || p.Angular.Detected {
return plugins.LoadingStrategyFetch
}
}
}
if !cdnEnabled(p, cdn) && !p.Angular.Detected {
return plugins.LoadingStrategyScript
}
return plugins.LoadingStrategyFetch
}
// compatibleCreatePluginVersion checks if the create_plugin_version setting is >= 4.15.0
func compatibleCreatePluginVersion(ps map[string]string) bool {
if cpv, ok := ps[CreatePluginVersionCfgKey]; ok {
createPluginVer, err := semver.NewVersion(cpv)
if err != nil {
// Invalid semver, treat as incompatible
return false
}
return !createPluginVer.LessThan(scriptLoadingMinSupportedVersion)
}
return false
}

View File

@@ -0,0 +1,216 @@
package pluginassets
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
)
// cdnFS is a simple mock FS that returns CDN type
type cdnFS struct {
plugins.FS
}
func (f *cdnFS) Type() plugins.FSType {
return plugins.FSTypeCDN
}
func TestCalculateLoadingStrategy(t *testing.T) {
const pluginID = "grafana-test-datasource"
const (
incompatVersion = "4.14.0"
compatVersion = CreatePluginVersionScriptSupportEnabled
futureVersion = "5.0.0"
)
tcs := []struct {
name string
pluginSettings config.PluginSettings
plugin *plugins.Plugin
expected plugins.LoadingStrategy
}{
{
name: "Expected LoadingStrategyScript when create-plugin version is compatible and plugin is not angular",
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: compatVersion,
}),
plugin: newPluginForLoadingStrategy(pluginID, withAngularForLoadingStrategy(false)),
expected: plugins.LoadingStrategyScript,
},
{
name: "Expected LoadingStrategyScript when parent create-plugin version is compatible and plugin is not angular",
pluginSettings: newPluginSettings("parent-datasource", map[string]string{
CreatePluginVersionCfgKey: compatVersion,
}),
plugin: newPluginForLoadingStrategy(pluginID, withAngularForLoadingStrategy(false), func(p *plugins.Plugin) {
p.Parent = &plugins.Plugin{
JSONData: plugins.JSONData{ID: "parent-datasource"},
FS: plugins.NewFakeFS(),
}
}),
expected: plugins.LoadingStrategyScript,
},
{
name: "Expected LoadingStrategyScript when create-plugin version is future compatible and plugin is not angular",
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: futureVersion,
}),
plugin: newPluginForLoadingStrategy(pluginID, withAngularForLoadingStrategy(false), withFSForLoadingStrategy(plugins.NewFakeFS())),
expected: plugins.LoadingStrategyScript,
},
{
name: "Expected LoadingStrategyScript when create-plugin version is not provided, plugin is not angular and is not configured as CDN enabled",
pluginSettings: newPluginSettings(pluginID, map[string]string{}),
plugin: newPluginForLoadingStrategy(pluginID, withAngularForLoadingStrategy(false), withFSForLoadingStrategy(plugins.NewFakeFS())),
expected: plugins.LoadingStrategyScript,
},
{
name: "Expected LoadingStrategyScript when create-plugin version is not compatible, plugin is not angular, is not configured as CDN enabled and does not have a CDN fs",
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: incompatVersion,
}),
plugin: newPluginForLoadingStrategy(pluginID, withAngularForLoadingStrategy(false), withClassForLoadingStrategy(plugins.ClassExternal), withFSForLoadingStrategy(plugins.NewFakeFS())),
expected: plugins.LoadingStrategyScript,
},
{
name: "Expected LoadingStrategyFetch when parent create-plugin version is not set, is configured as CDN enabled and plugin is not angular",
pluginSettings: config.PluginSettings{
"parent-datasource": {
"cdn": "true",
},
},
plugin: newPluginForLoadingStrategy(pluginID, withAngularForLoadingStrategy(false), func(p *plugins.Plugin) {
p.Parent = &plugins.Plugin{
JSONData: plugins.JSONData{ID: "parent-datasource"},
FS: plugins.NewFakeFS(),
}
}),
expected: plugins.LoadingStrategyFetch,
},
{
name: "Expected LoadingStrategyFetch when parent create-plugin version is not set, is configured as CDN enabled and plugin is angular",
pluginSettings: config.PluginSettings{
"parent-datasource": {
"cdn": "true",
},
},
plugin: newPluginForLoadingStrategy(pluginID, withAngularForLoadingStrategy(true), func(p *plugins.Plugin) {
p.Parent = &plugins.Plugin{
JSONData: plugins.JSONData{ID: "parent-datasource"},
FS: plugins.NewFakeFS(),
}
}),
expected: plugins.LoadingStrategyFetch,
},
{
name: "Expected LoadingStrategyFetch when parent create-plugin version is not set, is not configured as CDN enabled and plugin is angular",
pluginSettings: config.PluginSettings{},
plugin: newPluginForLoadingStrategy(pluginID, withAngularForLoadingStrategy(true), withFSForLoadingStrategy(plugins.NewFakeFS()), func(p *plugins.Plugin) {
p.Parent = &plugins.Plugin{
JSONData: plugins.JSONData{ID: "parent-datasource"},
FS: plugins.NewFakeFS(),
}
}),
expected: plugins.LoadingStrategyFetch,
},
{
name: "Expected LoadingStrategyFetch when create-plugin version is not compatible, plugin is not angular, and plugin is configured as CDN enabled",
pluginSettings: newPluginSettings(pluginID, map[string]string{
"cdn": "true",
CreatePluginVersionCfgKey: incompatVersion,
}),
plugin: newPluginForLoadingStrategy(pluginID, withAngularForLoadingStrategy(false), withClassForLoadingStrategy(plugins.ClassExternal), withFSForLoadingStrategy(plugins.NewFakeFS())),
expected: plugins.LoadingStrategyFetch,
},
{
name: "Expected LoadingStrategyFetch when create-plugin version is not compatible and plugin is angular",
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: incompatVersion,
}),
plugin: newPluginForLoadingStrategy(pluginID, withAngularForLoadingStrategy(true), withFSForLoadingStrategy(plugins.NewFakeFS())),
expected: plugins.LoadingStrategyFetch,
},
{
name: "Expected LoadingStrategyFetch when create-plugin version is not compatible, plugin is not angular and plugin is configured as CDN enabled",
pluginSettings: newPluginSettings(pluginID, map[string]string{
"cdn": "true",
CreatePluginVersionCfgKey: incompatVersion,
}),
plugin: newPluginForLoadingStrategy(pluginID, withAngularForLoadingStrategy(false), withFSForLoadingStrategy(plugins.NewFakeFS())),
expected: plugins.LoadingStrategyFetch,
},
{
name: "Expected LoadingStrategyFetch when create-plugin version is not compatible, plugin is not angular and has a CDN fs",
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: incompatVersion,
}),
plugin: newPluginForLoadingStrategy(pluginID, withAngularForLoadingStrategy(false), withFSForLoadingStrategy(
&cdnFS{FS: plugins.NewFakeFS()},
)),
expected: plugins.LoadingStrategyFetch,
},
{
name: "Expected LoadingStrategyScript when plugin setting create-plugin version is badly formatted, plugin is not configured as CDN enabled and does not have a CDN fs",
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: "invalidSemver",
}),
plugin: newPluginForLoadingStrategy(pluginID, withAngularForLoadingStrategy(false), withFSForLoadingStrategy(plugins.NewFakeFS())),
expected: plugins.LoadingStrategyScript,
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
cfg := &config.PluginManagementCfg{
PluginSettings: tc.pluginSettings,
}
cdn := pluginscdn.ProvideService(&config.PluginManagementCfg{
PluginsCDNURLTemplate: "http://cdn.example.com", // required for cdn.PluginSupported check
PluginSettings: tc.pluginSettings,
})
got := CalculateLoadingStrategy(tc.plugin, cfg, cdn)
assert.Equal(t, tc.expected, got, "unexpected loading strategy")
})
}
}
func newPluginForLoadingStrategy(pluginID string, cbs ...func(*plugins.Plugin)) *plugins.Plugin {
p := &plugins.Plugin{
JSONData: plugins.JSONData{
ID: pluginID,
},
}
for _, cb := range cbs {
cb(p)
}
return p
}
func withAngularForLoadingStrategy(angular bool) func(*plugins.Plugin) {
return func(p *plugins.Plugin) {
p.Angular = plugins.AngularMeta{Detected: angular}
}
}
func withFSForLoadingStrategy(fs plugins.FS) func(*plugins.Plugin) {
return func(p *plugins.Plugin) {
p.FS = fs
}
}
func withClassForLoadingStrategy(class plugins.Class) func(*plugins.Plugin) {
return func(p *plugins.Plugin) {
p.Class = class
}
}
func newPluginSettings(pluginID string, kv map[string]string) config.PluginSettings {
return config.PluginSettings{
pluginID: kv,
}
}

View File

@@ -0,0 +1,90 @@
package pluginassets
import (
"encoding/base64"
"encoding/hex"
"path"
"path/filepath"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
)
// CalculateModuleHash calculates the module.js SHA256 hash for a plugin in the format expected by the browser for SRI checks.
// The module hash is read from the plugin's cached manifest.
// For nested plugins, the module hash is read from the root parent plugin's manifest.
// If the plugin is unsigned or not a CDN plugin, an empty string is returned.
func CalculateModuleHash(p *plugins.Plugin, cfg *config.PluginManagementCfg, cdn *pluginscdn.Service) string {
if cfg == nil || !cfg.Features.SriChecksEnabled {
return ""
}
if !p.Signature.IsValid() {
return ""
}
rootParent := findRootParent(p)
if rootParent.Manifest == nil {
return ""
}
if !rootParent.Manifest.IsV2() {
return ""
}
if !cdnEnabled(rootParent, cdn) {
return ""
}
modulePath := getModulePathInManifest(p, rootParent)
moduleHash, ok := rootParent.Manifest.Files[modulePath]
if !ok {
return ""
}
return convertHashForSRI(moduleHash)
}
// findRootParent returns the root parent plugin (the one that contains the manifest).
// For non-nested plugins, it returns the plugin itself.
func findRootParent(p *plugins.Plugin) *plugins.Plugin {
root := p
for root.Parent != nil {
root = root.Parent
}
return root
}
// getModulePathInManifest returns the path to module.js as it appears in the manifest.
// For nested plugins, this is the relative path from the root parent to the plugin's module.js.
// For non-nested plugins, this is simply "module.js".
func getModulePathInManifest(p *plugins.Plugin, rootParent *plugins.Plugin) string {
if p == rootParent {
return "module.js"
}
// Calculate the relative path from root parent to this plugin
relPath, err := rootParent.FS.Rel(p.FS.Base())
if err != nil {
return ""
}
// MANIFEST.txt uses forward slashes as path separators
pluginRootPath := filepath.ToSlash(relPath)
return path.Join(pluginRootPath, "module.js")
}
// convertHashForSRI takes a SHA256 hash string and returns it as expected by the browser for SRI checks.
func convertHashForSRI(h string) string {
hb, err := hex.DecodeString(h)
if err != nil {
return ""
}
return "sha256-" + base64.StdEncoding.EncodeToString(hb)
}
// cdnEnabled checks if a plugin is loaded via CDN
func cdnEnabled(p *plugins.Plugin, cdn *pluginscdn.Service) bool {
return p.FS.Type().CDN() || cdn.PluginSupported(p.ID)
}

View File

@@ -0,0 +1,356 @@
package pluginassets
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
)
func TestConvertHashForSRI(t *testing.T) {
for _, tc := range []struct {
hash string
expHash string
expErr bool
}{
{
hash: "ddfcb449445064e6c39f0c20b15be3cb6a55837cf4781df23d02de005f436811",
expHash: "sha256-3fy0SURQZObDnwwgsVvjy2pVg3z0eB3yPQLeAF9DaBE=",
},
{
hash: "not-a-valid-hash",
expErr: true,
},
} {
t.Run(tc.hash, func(t *testing.T) {
r := convertHashForSRI(tc.hash)
if tc.expErr {
// convertHashForSRI returns empty string on error
require.Empty(t, r)
} else {
require.Equal(t, tc.expHash, r)
}
})
}
}
func TestCalculateModuleHash(t *testing.T) {
const (
pluginID = "grafana-test-datasource"
parentPluginID = "grafana-test-app"
)
// Helper to create a plugin with manifest
createPluginWithManifest := func(id string, manifest *plugins.PluginManifest, parent *plugins.Plugin) *plugins.Plugin {
p := &plugins.Plugin{
JSONData: plugins.JSONData{
ID: id,
},
Signature: plugins.SignatureStatusValid,
Manifest: manifest,
}
if parent != nil {
p.Parent = parent
}
return p
}
// Helper to create a v2 manifest
createV2Manifest := func(files map[string]string) *plugins.PluginManifest {
return &plugins.PluginManifest{
ManifestVersion: "2.0.0",
Files: files,
}
}
for _, tc := range []struct {
name string
plugin *plugins.Plugin
cfg *config.PluginManagementCfg
cdn *pluginscdn.Service
expModuleHash string
}{
{
name: "should return empty string when cfg is nil",
plugin: createPluginWithManifest(pluginID, createV2Manifest(map[string]string{
"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03",
}), nil),
cfg: nil,
cdn: nil,
expModuleHash: "",
},
{
name: "should return empty string when SRI checks are disabled",
plugin: createPluginWithManifest(pluginID, createV2Manifest(map[string]string{
"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03",
}), nil),
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: false}},
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
expModuleHash: "",
},
{
name: "should return empty string for unsigned plugin",
plugin: &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusUnsigned,
Manifest: createV2Manifest(map[string]string{"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"}),
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")),
},
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
expModuleHash: "",
},
{
name: "should return module hash for valid plugin",
plugin: &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusValid,
Manifest: createV2Manifest(map[string]string{"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"}),
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")),
},
cfg: &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
Features: config.Features{SriChecksEnabled: true},
PluginSettings: config.PluginSettings{
pluginID: {"cdn": "true"},
},
},
cdn: func() *pluginscdn.Service {
cfg := &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
PluginSettings: config.PluginSettings{
pluginID: {"cdn": "true"},
},
}
return pluginscdn.ProvideService(cfg)
}(),
expModuleHash: "sha256-WJG1tSLV3whtD/CxEPvZ0hu0/HFjrzTQgoai6Eb2vgM=",
},
{
name: "should return empty string when manifest is nil",
plugin: &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusValid,
Manifest: nil,
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")),
},
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
expModuleHash: "",
},
{
name: "should return empty string for v1 manifest",
plugin: &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusValid,
Manifest: &plugins.PluginManifest{
ManifestVersion: "1.0.0",
Files: map[string]string{"module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"},
},
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")),
},
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
expModuleHash: "",
},
{
name: "should return empty string when module.js is not in manifest",
plugin: &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusValid,
Manifest: createV2Manifest(map[string]string{"plugin.json": "129fab4e0584d18c778ebdfa5fe1a68edf2e5c5aeb8290b2c68182c857cb59f8"}),
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid")),
},
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
expModuleHash: "",
},
{
name: "missing module.js entry from MANIFEST.txt should not return module hash",
plugin: &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusValid,
Manifest: createV2Manifest(map[string]string{"plugin.json": "129fab4e0584d18c778ebdfa5fe1a68edf2e5c5aeb8290b2c68182c857cb59f8"}),
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-module-js")),
},
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
expModuleHash: "",
},
{
name: "signed status but missing MANIFEST.txt should not return module hash",
plugin: &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusValid,
Manifest: nil,
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-manifest-txt")),
},
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
expModuleHash: "",
},
{
// parentPluginID (/)
// └── pluginID (/datasource)
name: "nested plugin should return module hash from parent MANIFEST.txt",
plugin: func() *plugins.Plugin {
parent := &plugins.Plugin{
JSONData: plugins.JSONData{ID: parentPluginID},
Signature: plugins.SignatureStatusValid,
Manifest: createV2Manifest(map[string]string{
"module.js": "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a",
"datasource/module.js": "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711",
}),
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested")),
}
return &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusValid,
Parent: parent,
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "datasource")),
}
}(),
cfg: &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
Features: config.Features{SriChecksEnabled: true},
PluginSettings: config.PluginSettings{
pluginID: {"cdn": "true"},
parentPluginID: {"cdn": "true"},
},
},
cdn: func() *pluginscdn.Service {
cfg := &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
PluginSettings: config.PluginSettings{
pluginID: {"cdn": "true"},
parentPluginID: {"cdn": "true"},
},
}
return pluginscdn.ProvideService(cfg)
}(),
expModuleHash: "sha256-BNcNsJHZbEd1+zK6Wo+EzCKJPrQ6/bZJcmZh1EJcZxE=",
},
{
// parentPluginID (/)
// └── pluginID (/panels/one)
name: "nested plugin deeper than one subfolder should return module hash from parent MANIFEST.txt",
plugin: func() *plugins.Plugin {
parent := &plugins.Plugin{
JSONData: plugins.JSONData{ID: parentPluginID},
Signature: plugins.SignatureStatusValid,
Manifest: createV2Manifest(map[string]string{
"module.js": "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a",
"panels/one/module.js": "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f",
"datasource/module.js": "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711",
}),
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested")),
}
return &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusValid,
Parent: parent,
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one")),
}
}(),
cfg: &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
Features: config.Features{SriChecksEnabled: true},
PluginSettings: config.PluginSettings{
pluginID: {"cdn": "true"},
parentPluginID: {"cdn": "true"},
},
},
cdn: func() *pluginscdn.Service {
cfg := &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
PluginSettings: config.PluginSettings{
pluginID: {"cdn": "true"},
parentPluginID: {"cdn": "true"},
},
}
return pluginscdn.ProvideService(cfg)
}(),
expModuleHash: "sha256-y9GsIoRkWg4emocipyn1vN0rgxIicocJxjYL7s3WFD8=",
},
{
// grand-parent-app (/)
// ├── parent-datasource (/datasource)
// │ └── child-panel (/datasource/panels/one)
name: "nested plugin of a nested plugin should return module hash from grandparent MANIFEST.txt",
plugin: func() *plugins.Plugin {
grandparent := &plugins.Plugin{
JSONData: plugins.JSONData{ID: "grand-parent-app"},
Signature: plugins.SignatureStatusValid,
Manifest: createV2Manifest(map[string]string{
"module.js": "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a",
"datasource/module.js": "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711",
"datasource/panels/one/module.js": "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f",
}),
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested")),
}
parent := &plugins.Plugin{
JSONData: plugins.JSONData{ID: "parent-datasource"},
Signature: plugins.SignatureStatusValid,
Parent: grandparent,
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource")),
}
return &plugins.Plugin{
JSONData: plugins.JSONData{ID: "child-panel"},
Signature: plugins.SignatureStatusValid,
Parent: parent,
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource", "panels", "one")),
}
}(),
cfg: &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
Features: config.Features{SriChecksEnabled: true},
PluginSettings: config.PluginSettings{
"child-panel": {"cdn": "true"},
"parent-datasource": {"cdn": "true"},
"grand-parent-app": {"cdn": "true"},
},
},
cdn: func() *pluginscdn.Service {
cfg := &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
PluginSettings: config.PluginSettings{
"child-panel": {"cdn": "true"},
"parent-datasource": {"cdn": "true"},
"grand-parent-app": {"cdn": "true"},
},
}
return pluginscdn.ProvideService(cfg)
}(),
expModuleHash: "sha256-y9GsIoRkWg4emocipyn1vN0rgxIicocJxjYL7s3WFD8=",
},
{
name: "nested plugin should not return module hash when parent manifest is nil",
plugin: func() *plugins.Plugin {
parent := &plugins.Plugin{
JSONData: plugins.JSONData{ID: parentPluginID},
Signature: plugins.SignatureStatusValid,
Manifest: nil, // Parent has no manifest
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested")),
}
return &plugins.Plugin{
JSONData: plugins.JSONData{ID: pluginID},
Signature: plugins.SignatureStatusValid,
Parent: parent,
FS: plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one")),
}
}(),
cfg: &config.PluginManagementCfg{Features: config.Features{SriChecksEnabled: true}},
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{}),
expModuleHash: "",
},
} {
t.Run(tc.name, func(t *testing.T) {
result := CalculateModuleHash(tc.plugin, tc.cfg, tc.cdn)
require.Equal(t, tc.expModuleHash, result)
})
}
}

View File

@@ -40,6 +40,7 @@ type Plugin struct {
Pinned bool
// Signature fields
Manifest *PluginManifest
Signature SignatureStatus
SignatureType SignatureType
SignatureOrg string
@@ -48,8 +49,10 @@ type Plugin struct {
Error *Error
// SystemJS fields
Module string
BaseURL string
Module string
ModuleHash string
LoadingStrategy LoadingStrategy
BaseURL string
Angular AngularMeta
@@ -532,3 +535,24 @@ func (pt Type) IsValid() bool {
}
return false
}
// PluginManifest holds details for the file manifest
type PluginManifest struct {
Plugin string `json:"plugin"`
Version string `json:"version"`
KeyID string `json:"keyId"`
Time int64 `json:"time"`
Files map[string]string `json:"files"`
// V2 supported fields
ManifestVersion string `json:"manifestVersion"`
SignatureType SignatureType `json:"signatureType"`
SignedByOrg string `json:"signedByOrg"`
SignedByOrgName string `json:"signedByOrgName"`
RootURLs []string `json:"rootUrls"`
}
// IsV2 returns true if the manifest is version 2.x
func (m *PluginManifest) IsV2() bool {
return strings.HasPrefix(m.ManifestVersion, "2.")
}

View File

@@ -14,7 +14,6 @@ import (
"github.com/grafana/grafana/pkg/services/apiserver/appinstaller"
grafanaauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
)
@@ -35,7 +34,6 @@ func ProvideAppInstaller(
cfgProvider configprovider.ConfigProvider,
restConfigProvider apiserver.RestConfigProvider,
pluginStore pluginstore.Store,
pluginAssetsService *pluginassets.Service,
accessControlService accesscontrol.Service, accessClient authlib.AccessClient,
features featuremgmt.FeatureToggles,
) (*AppInstaller, error) {
@@ -46,7 +44,7 @@ func ProvideAppInstaller(
}
}
localProvider := meta.NewLocalProvider(pluginStore, pluginAssetsService)
localProvider := meta.NewLocalProvider(pluginStore)
metaProviderManager := meta.NewProviderManager(localProvider)
authorizer := grafanaauthorizer.NewResourceAuthorizer(accessClient)
i, err := pluginsapp.ProvideAppInstaller(authorizer, metaProviderManager)

19
pkg/server/wire_gen.go generated
View File

@@ -187,7 +187,6 @@ import (
"github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol"
pluginassets2 "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginchecker"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig"
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext"
@@ -376,7 +375,8 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
keyretrieverService := keyretriever.ProvideService(keyRetriever)
signatureSignature := signature.ProvideService(pluginManagementCfg, keyretrieverService)
localProvider := pluginassets.NewLocalProvider()
bootstrap := pipeline.ProvideBootstrapStage(pluginManagementCfg, signatureSignature, localProvider)
pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg)
bootstrap := pipeline.ProvideBootstrapStage(pluginManagementCfg, signatureSignature, localProvider, pluginscdnService)
unsignedPluginAuthorizer := signature.ProvideOSSAuthorizer(pluginManagementCfg)
validation := signature.ProvideValidatorService(unsignedPluginAuthorizer)
angularpatternsstoreService := angularpatternsstore.ProvideService(kvStore)
@@ -714,8 +714,6 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
if err != nil {
return nil, err
}
pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg)
pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, signatureSignature, pluginstoreService)
avatarCacheServer := avatar.ProvideAvatarCacheServer(cfg)
prefService := prefimpl.ProvideService(sqlStore, cfg)
dashboardPermissionsService, err := ossaccesscontrol.ProvideDashboardPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, dashboardService, folderimplService, acimplService, teamService, userService, actionSetService, dashboardServiceImpl, eventualRestConfigProvider)
@@ -753,7 +751,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
}
idimplService := idimpl.ProvideService(cfg, localSigner, remoteCache, authnService, registerer, tracer)
verifier := userimpl.ProvideVerifier(cfg, userService, tempuserService, notificationService, idimplService)
httpServer, err := api.ProvideHTTPServer(apiOpts, cfg, routeRegisterImpl, inProcBus, renderingService, ossLicensingService, hooksService, cacheService, sqlStore, ossDataSourceRequestValidator, pluginstoreService, service14, pluginstoreService, middlewareHandler, pluginerrsStore, pluginInstaller, ossImpl, cacheServiceImpl, userAuthTokenService, cleanUpService, shortURLService, queryHistoryService, correlationsService, remoteCache, provisioningServiceImpl, accessControl, dataSourceProxyService, searchSearchService, grafanaLive, gateway, plugincontextProvider, contexthandlerContextHandler, logger, featureToggles, alertNG, libraryPanelService, libraryElementService, quotaService, socialService, tracingService, serviceService, grafanaService, pluginsService, ossService, service15, queryServiceImpl, filestoreService, serviceAccountsProxy, pluginassetsService, authinfoimplService, storageService, notificationService, dashboardService, dashboardProvisioningService, folderimplService, ossProvider, serviceImpl, service13, avatarCacheServer, prefService, folderPermissionsService, dashboardPermissionsService, dashverService, starService, csrfCSRF, managedpluginsNoop, playlistService, apikeyService, kvStore, secretsMigrator, secretsService, secretMigrationProviderImpl, secretsKVStore, apiApi, userService, tempuserService, loginattemptimplService, orgService, deletionService, teamService, acimplService, navtreeService, repositoryImpl, tagimplService, searchHTTPService, oauthtokenService, statsService, authnService, pluginscdnService, gatherer, apiAPI, registerer, eventualRestConfigProvider, anonDeviceService, verifier, preinstallImpl)
httpServer, err := api.ProvideHTTPServer(apiOpts, cfg, routeRegisterImpl, inProcBus, renderingService, ossLicensingService, hooksService, cacheService, sqlStore, ossDataSourceRequestValidator, pluginstoreService, service14, pluginstoreService, middlewareHandler, pluginerrsStore, pluginInstaller, ossImpl, cacheServiceImpl, userAuthTokenService, cleanUpService, shortURLService, queryHistoryService, correlationsService, remoteCache, provisioningServiceImpl, accessControl, dataSourceProxyService, searchSearchService, grafanaLive, gateway, plugincontextProvider, contexthandlerContextHandler, logger, featureToggles, alertNG, libraryPanelService, libraryElementService, quotaService, socialService, tracingService, serviceService, grafanaService, pluginsService, ossService, service15, queryServiceImpl, filestoreService, serviceAccountsProxy, authinfoimplService, storageService, notificationService, dashboardService, dashboardProvisioningService, folderimplService, ossProvider, serviceImpl, service13, avatarCacheServer, prefService, folderPermissionsService, dashboardPermissionsService, dashverService, starService, csrfCSRF, managedpluginsNoop, playlistService, apikeyService, kvStore, secretsMigrator, secretsService, secretMigrationProviderImpl, secretsKVStore, apiApi, userService, tempuserService, loginattemptimplService, orgService, deletionService, teamService, acimplService, navtreeService, repositoryImpl, tagimplService, searchHTTPService, oauthtokenService, statsService, authnService, pluginscdnService, gatherer, apiAPI, registerer, eventualRestConfigProvider, anonDeviceService, verifier, preinstallImpl)
if err != nil {
return nil, err
}
@@ -786,7 +784,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
if err != nil {
return nil, err
}
appInstaller, err := plugins.ProvideAppInstaller(configProvider, eventualRestConfigProvider, pluginstoreService, pluginassetsService, acimplService, accessClient, featureToggles)
appInstaller, err := plugins.ProvideAppInstaller(configProvider, eventualRestConfigProvider, pluginstoreService, acimplService, accessClient, featureToggles)
if err != nil {
return nil, err
}
@@ -1042,7 +1040,8 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
keyretrieverService := keyretriever.ProvideService(keyRetriever)
signatureSignature := signature.ProvideService(pluginManagementCfg, keyretrieverService)
localProvider := pluginassets.NewLocalProvider()
bootstrap := pipeline.ProvideBootstrapStage(pluginManagementCfg, signatureSignature, localProvider)
pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg)
bootstrap := pipeline.ProvideBootstrapStage(pluginManagementCfg, signatureSignature, localProvider, pluginscdnService)
unsignedPluginAuthorizer := signature.ProvideOSSAuthorizer(pluginManagementCfg)
validation := signature.ProvideValidatorService(unsignedPluginAuthorizer)
angularpatternsstoreService := angularpatternsstore.ProvideService(kvStore)
@@ -1382,8 +1381,6 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
if err != nil {
return nil, err
}
pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg)
pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, signatureSignature, pluginstoreService)
avatarCacheServer := avatar.ProvideAvatarCacheServer(cfg)
prefService := prefimpl.ProvideService(sqlStore, cfg)
dashboardPermissionsService, err := ossaccesscontrol.ProvideDashboardPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, dashboardService, folderimplService, acimplService, teamService, userService, actionSetService, dashboardServiceImpl, eventualRestConfigProvider)
@@ -1421,7 +1418,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
}
idimplService := idimpl.ProvideService(cfg, localSigner, remoteCache, authnService, registerer, tracer)
verifier := userimpl.ProvideVerifier(cfg, userService, tempuserService, notificationServiceMock, idimplService)
httpServer, err := api.ProvideHTTPServer(apiOpts, cfg, routeRegisterImpl, inProcBus, renderingService, ossLicensingService, hooksService, cacheService, sqlStore, ossDataSourceRequestValidator, pluginstoreService, service14, pluginstoreService, middlewareHandler, pluginerrsStore, pluginInstaller, ossImpl, cacheServiceImpl, userAuthTokenService, cleanUpService, shortURLService, queryHistoryService, correlationsService, remoteCache, provisioningServiceImpl, accessControl, dataSourceProxyService, searchSearchService, grafanaLive, gateway, plugincontextProvider, contexthandlerContextHandler, logger, featureToggles, alertNG, libraryPanelService, libraryElementService, quotaService, socialService, tracingService, serviceService, grafanaService, pluginsService, ossService, service15, queryServiceImpl, filestoreService, serviceAccountsProxy, pluginassetsService, authinfoimplService, storageService, notificationServiceMock, dashboardService, dashboardProvisioningService, folderimplService, ossProvider, serviceImpl, service13, avatarCacheServer, prefService, folderPermissionsService, dashboardPermissionsService, dashverService, starService, csrfCSRF, managedpluginsNoop, playlistService, apikeyService, kvStore, secretsMigrator, secretsService, secretMigrationProviderImpl, secretsKVStore, apiApi, userService, tempuserService, loginattemptimplService, orgService, deletionService, teamService, acimplService, navtreeService, repositoryImpl, tagimplService, searchHTTPService, oauthtokentestService, statsService, authnService, pluginscdnService, gatherer, apiAPI, registerer, eventualRestConfigProvider, anonDeviceService, verifier, preinstallImpl)
httpServer, err := api.ProvideHTTPServer(apiOpts, cfg, routeRegisterImpl, inProcBus, renderingService, ossLicensingService, hooksService, cacheService, sqlStore, ossDataSourceRequestValidator, pluginstoreService, service14, pluginstoreService, middlewareHandler, pluginerrsStore, pluginInstaller, ossImpl, cacheServiceImpl, userAuthTokenService, cleanUpService, shortURLService, queryHistoryService, correlationsService, remoteCache, provisioningServiceImpl, accessControl, dataSourceProxyService, searchSearchService, grafanaLive, gateway, plugincontextProvider, contexthandlerContextHandler, logger, featureToggles, alertNG, libraryPanelService, libraryElementService, quotaService, socialService, tracingService, serviceService, grafanaService, pluginsService, ossService, service15, queryServiceImpl, filestoreService, serviceAccountsProxy, authinfoimplService, storageService, notificationServiceMock, dashboardService, dashboardProvisioningService, folderimplService, ossProvider, serviceImpl, service13, avatarCacheServer, prefService, folderPermissionsService, dashboardPermissionsService, dashverService, starService, csrfCSRF, managedpluginsNoop, playlistService, apikeyService, kvStore, secretsMigrator, secretsService, secretMigrationProviderImpl, secretsKVStore, apiApi, userService, tempuserService, loginattemptimplService, orgService, deletionService, teamService, acimplService, navtreeService, repositoryImpl, tagimplService, searchHTTPService, oauthtokentestService, statsService, authnService, pluginscdnService, gatherer, apiAPI, registerer, eventualRestConfigProvider, anonDeviceService, verifier, preinstallImpl)
if err != nil {
return nil, err
}
@@ -1454,7 +1451,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
if err != nil {
return nil, err
}
appInstaller, err := plugins.ProvideAppInstaller(configProvider, eventualRestConfigProvider, pluginstoreService, pluginassetsService, acimplService, accessClient, featureToggles)
appInstaller, err := plugins.ProvideAppInstaller(configProvider, eventualRestConfigProvider, pluginstoreService, acimplService, accessClient, featureToggles)
if err != nil {
return nil, err
}

View File

@@ -49,7 +49,7 @@ var (
Name: "lokiExperimentalStreaming",
Description: "Support new streaming approach for loki (prototype, needs special loki build)",
Stage: FeatureStageExperimental,
Owner: grafanaOSSBigTent,
Owner: grafanaObservabilityLogsSquad,
},
{
Name: "featureHighlights",
@@ -177,7 +177,7 @@ var (
Name: "lokiLogsDataplane",
Description: "Changes logs responses from Loki to be compliant with the dataplane specification.",
Stage: FeatureStageExperimental,
Owner: grafanaOSSBigTent,
Owner: grafanaObservabilityLogsSquad,
},
{
Name: "disableSSEDataplane",
@@ -340,7 +340,7 @@ var (
Description: "Enables running Loki queries in parallel",
Stage: FeatureStagePrivatePreview,
FrontendOnly: false,
Owner: grafanaOSSBigTent,
Owner: grafanaObservabilityLogsSquad,
},
{
Name: "externalServiceAccounts",
@@ -745,7 +745,7 @@ var (
Name: "logQLScope",
Description: "In-development feature that will allow injection of labels into loki queries.",
Stage: FeatureStagePrivatePreview,
Owner: grafanaOSSBigTent,
Owner: grafanaObservabilityLogsSquad,
Expression: "false",
HideFromDocs: true,
},
@@ -1260,7 +1260,7 @@ var (
Name: "lokiLabelNamesQueryApi",
Description: "Defaults to using the Loki `/labels` API instead of `/series`",
Stage: FeatureStageGeneralAvailability,
Owner: grafanaOSSBigTent,
Owner: grafanaObservabilityLogsSquad,
Expression: "true",
},
{
@@ -1625,15 +1625,6 @@ var (
FrontendOnly: true,
Expression: "false",
},
{
Name: "experimentRecentlyViewedDashboards",
Description: "A/A test for recently viewed dashboards feature",
Stage: FeatureStageExperimental,
Owner: grafanaFrontendSearchNavOrganise,
FrontendOnly: true,
HideFromDocs: true,
Expression: "false",
},
{
Name: "alertEnrichment",
Description: "Enable configuration of alert enrichments in Grafana Cloud.",

View File

@@ -3,7 +3,7 @@ disableEnvelopeEncryption,GA,@grafana/grafana-operator-experience-squad,false,fa
panelTitleSearch,preview,@grafana/search-and-storage,false,false,false
publicDashboardsEmailSharing,preview,@grafana/grafana-operator-experience-squad,false,false,false
publicDashboardsScene,GA,@grafana/grafana-operator-experience-squad,false,false,true
lokiExperimentalStreaming,experimental,@grafana/oss-big-tent,false,false,false
lokiExperimentalStreaming,experimental,@grafana/observability-logs,false,false,false
featureHighlights,GA,@grafana/grafana-operator-experience-squad,false,false,false
storage,experimental,@grafana/search-and-storage,false,false,false
canvasPanelNesting,experimental,@grafana/dataviz-squad,false,false,true
@@ -22,7 +22,7 @@ starsFromAPIServer,experimental,@grafana/grafana-search-navigate-organise,false,
kubernetesStars,experimental,@grafana/grafana-app-platform-squad,false,true,false
influxqlStreamingParser,experimental,@grafana/partner-datasources,false,false,false
influxdbRunQueriesInParallel,privatePreview,@grafana/partner-datasources,false,false,false
lokiLogsDataplane,experimental,@grafana/oss-big-tent,false,false,false
lokiLogsDataplane,experimental,@grafana/observability-logs,false,false,false
disableSSEDataplane,experimental,@grafana/grafana-datasources-core-services,false,false,false
renderAuthJWT,preview,@grafana/grafana-operator-experience-squad,false,false,false
refactorVariablesTimeRange,preview,@grafana/dashboards-squad,false,false,false
@@ -45,7 +45,7 @@ aiGeneratedDashboardChanges,experimental,@grafana/dashboards-squad,false,false,t
reportingRetries,preview,@grafana/grafana-operator-experience-squad,false,true,false
reportingCsvEncodingOptions,experimental,@grafana/grafana-operator-experience-squad,false,false,false
sseGroupByDatasource,experimental,@grafana/grafana-datasources-core-services,false,false,false
lokiRunQueriesInParallel,privatePreview,@grafana/oss-big-tent,false,false,false
lokiRunQueriesInParallel,privatePreview,@grafana/observability-logs,false,false,false
externalServiceAccounts,preview,@grafana/identity-access-team,false,false,false
enableNativeHTTPHistogram,experimental,@grafana/grafana-backend-services-squad,false,true,false
disableClassicHTTPHistogram,experimental,@grafana/grafana-backend-services-squad,false,true,false
@@ -102,7 +102,7 @@ alertingSaveStateCompressed,preview,@grafana/alerting-squad,false,false,false
scopeApi,experimental,@grafana/grafana-app-platform-squad,false,false,false
useScopeSingleNodeEndpoint,experimental,@grafana/grafana-operator-experience-squad,false,false,true
useMultipleScopeNodesEndpoint,experimental,@grafana/grafana-operator-experience-squad,false,false,true
logQLScope,privatePreview,@grafana/oss-big-tent,false,false,false
logQLScope,privatePreview,@grafana/observability-logs,false,false,false
sqlExpressions,preview,@grafana/grafana-datasources-core-services,false,false,false
sqlExpressionsColumnAutoComplete,experimental,@grafana/datapro,false,false,true
kubernetesAggregator,experimental,@grafana/grafana-app-platform-squad,false,true,false
@@ -173,7 +173,7 @@ alertingAIAnalyzeCentralStateHistory,experimental,@grafana/alerting-squad,false,
alertingNotificationsStepMode,GA,@grafana/alerting-squad,false,false,true
unifiedStorageSearchUI,experimental,@grafana/search-and-storage,false,false,false
elasticsearchCrossClusterSearch,GA,@grafana/partner-datasources,false,false,false
lokiLabelNamesQueryApi,GA,@grafana/oss-big-tent,false,false,false
lokiLabelNamesQueryApi,GA,@grafana/observability-logs,false,false,false
k8SFolderCounts,experimental,@grafana/search-and-storage,false,false,false
k8SFolderMove,experimental,@grafana/search-and-storage,false,false,false
improvedExternalSessionHandlingSAML,GA,@grafana/identity-access-team,false,false,false
@@ -223,7 +223,6 @@ kubernetesAuthnMutation,experimental,@grafana/identity-access-team,false,false,f
kubernetesExternalGroupMapping,experimental,@grafana/identity-access-team,false,false,false
restoreDashboards,experimental,@grafana/grafana-search-navigate-organise,false,false,false
recentlyViewedDashboards,experimental,@grafana/grafana-search-navigate-organise,false,false,true
experimentRecentlyViewedDashboards,experimental,@grafana/grafana-search-navigate-organise,false,false,true
alertEnrichment,experimental,@grafana/alerting-squad,false,false,false
alertEnrichmentMultiStep,experimental,@grafana/alerting-squad,false,false,false
alertEnrichmentConditional,experimental,@grafana/alerting-squad,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
3 panelTitleSearch preview @grafana/search-and-storage false false false
4 publicDashboardsEmailSharing preview @grafana/grafana-operator-experience-squad false false false
5 publicDashboardsScene GA @grafana/grafana-operator-experience-squad false false true
6 lokiExperimentalStreaming experimental @grafana/oss-big-tent @grafana/observability-logs false false false
7 featureHighlights GA @grafana/grafana-operator-experience-squad false false false
8 storage experimental @grafana/search-and-storage false false false
9 canvasPanelNesting experimental @grafana/dataviz-squad false false true
22 kubernetesStars experimental @grafana/grafana-app-platform-squad false true false
23 influxqlStreamingParser experimental @grafana/partner-datasources false false false
24 influxdbRunQueriesInParallel privatePreview @grafana/partner-datasources false false false
25 lokiLogsDataplane experimental @grafana/oss-big-tent @grafana/observability-logs false false false
26 disableSSEDataplane experimental @grafana/grafana-datasources-core-services false false false
27 renderAuthJWT preview @grafana/grafana-operator-experience-squad false false false
28 refactorVariablesTimeRange preview @grafana/dashboards-squad false false false
45 reportingRetries preview @grafana/grafana-operator-experience-squad false true false
46 reportingCsvEncodingOptions experimental @grafana/grafana-operator-experience-squad false false false
47 sseGroupByDatasource experimental @grafana/grafana-datasources-core-services false false false
48 lokiRunQueriesInParallel privatePreview @grafana/oss-big-tent @grafana/observability-logs false false false
49 externalServiceAccounts preview @grafana/identity-access-team false false false
50 enableNativeHTTPHistogram experimental @grafana/grafana-backend-services-squad false true false
51 disableClassicHTTPHistogram experimental @grafana/grafana-backend-services-squad false true false
102 scopeApi experimental @grafana/grafana-app-platform-squad false false false
103 useScopeSingleNodeEndpoint experimental @grafana/grafana-operator-experience-squad false false true
104 useMultipleScopeNodesEndpoint experimental @grafana/grafana-operator-experience-squad false false true
105 logQLScope privatePreview @grafana/oss-big-tent @grafana/observability-logs false false false
106 sqlExpressions preview @grafana/grafana-datasources-core-services false false false
107 sqlExpressionsColumnAutoComplete experimental @grafana/datapro false false true
108 kubernetesAggregator experimental @grafana/grafana-app-platform-squad false true false
173 alertingNotificationsStepMode GA @grafana/alerting-squad false false true
174 unifiedStorageSearchUI experimental @grafana/search-and-storage false false false
175 elasticsearchCrossClusterSearch GA @grafana/partner-datasources false false false
176 lokiLabelNamesQueryApi GA @grafana/oss-big-tent @grafana/observability-logs false false false
177 k8SFolderCounts experimental @grafana/search-and-storage false false false
178 k8SFolderMove experimental @grafana/search-and-storage false false false
179 improvedExternalSessionHandlingSAML GA @grafana/identity-access-team false false false
223 kubernetesExternalGroupMapping experimental @grafana/identity-access-team false false false
224 restoreDashboards experimental @grafana/grafana-search-navigate-organise false false false
225 recentlyViewedDashboards experimental @grafana/grafana-search-navigate-organise false false true
experimentRecentlyViewedDashboards experimental @grafana/grafana-search-navigate-organise false false true
226 alertEnrichment experimental @grafana/alerting-squad false false false
227 alertEnrichmentMultiStep experimental @grafana/alerting-squad false false false
228 alertEnrichmentConditional experimental @grafana/alerting-squad false false false

View File

@@ -1365,21 +1365,6 @@
"hideFromDocs": true
}
},
{
"metadata": {
"name": "experimentRecentlyViewedDashboards",
"resourceVersion": "1768214542023",
"creationTimestamp": "2026-01-12T10:42:22Z"
},
"spec": {
"description": "A/A test for recently viewed dashboards feature",
"stage": "experimental",
"codeowner": "@grafana/grafana-search-navigate-organise",
"frontend": true,
"hideFromDocs": true,
"expression": "false"
}
},
{
"metadata": {
"name": "exploreLogsAggregatedMetrics",
@@ -2222,16 +2207,13 @@
{
"metadata": {
"name": "logQLScope",
"resourceVersion": "1768317398145",
"creationTimestamp": "2024-11-11T11:53:24Z",
"annotations": {
"grafana.app/updatedTimestamp": "2026-01-13 15:16:38.145488 +0000 UTC"
}
"resourceVersion": "1764664939750",
"creationTimestamp": "2024-11-11T11:53:24Z"
},
"spec": {
"description": "In-development feature that will allow injection of labels into loki queries.",
"stage": "privatePreview",
"codeowner": "@grafana/oss-big-tent",
"codeowner": "@grafana/observability-logs",
"hideFromDocs": true,
"expression": "false"
}
@@ -2307,47 +2289,38 @@
{
"metadata": {
"name": "lokiExperimentalStreaming",
"resourceVersion": "1768317398145",
"creationTimestamp": "2023-06-19T10:03:51Z",
"annotations": {
"grafana.app/updatedTimestamp": "2026-01-13 15:16:38.145488 +0000 UTC"
}
"resourceVersion": "1764664939750",
"creationTimestamp": "2023-06-19T10:03:51Z"
},
"spec": {
"description": "Support new streaming approach for loki (prototype, needs special loki build)",
"stage": "experimental",
"codeowner": "@grafana/oss-big-tent"
"codeowner": "@grafana/observability-logs"
}
},
{
"metadata": {
"name": "lokiLabelNamesQueryApi",
"resourceVersion": "1768317398145",
"creationTimestamp": "2024-12-13T14:31:41Z",
"annotations": {
"grafana.app/updatedTimestamp": "2026-01-13 15:16:38.145488 +0000 UTC"
}
"resourceVersion": "1764664939750",
"creationTimestamp": "2024-12-13T14:31:41Z"
},
"spec": {
"description": "Defaults to using the Loki `/labels` API instead of `/series`",
"stage": "GA",
"codeowner": "@grafana/oss-big-tent",
"codeowner": "@grafana/observability-logs",
"expression": "true"
}
},
{
"metadata": {
"name": "lokiLogsDataplane",
"resourceVersion": "1768317398145",
"creationTimestamp": "2023-07-13T07:58:00Z",
"annotations": {
"grafana.app/updatedTimestamp": "2026-01-13 15:16:38.145488 +0000 UTC"
}
"resourceVersion": "1764664939750",
"creationTimestamp": "2023-07-13T07:58:00Z"
},
"spec": {
"description": "Changes logs responses from Loki to be compliant with the dataplane specification.",
"stage": "experimental",
"codeowner": "@grafana/oss-big-tent"
"codeowner": "@grafana/observability-logs"
}
},
{
@@ -2380,16 +2353,13 @@
{
"metadata": {
"name": "lokiRunQueriesInParallel",
"resourceVersion": "1768317398145",
"creationTimestamp": "2023-09-19T09:34:01Z",
"annotations": {
"grafana.app/updatedTimestamp": "2026-01-13 15:16:38.145488 +0000 UTC"
}
"resourceVersion": "1764664939750",
"creationTimestamp": "2023-09-19T09:34:01Z"
},
"spec": {
"description": "Enables running Loki queries in parallel",
"stage": "privatePreview",
"codeowner": "@grafana/oss-big-tent"
"codeowner": "@grafana/observability-logs"
}
},
{

View File

@@ -26,6 +26,7 @@ import (
"github.com/grafana/grafana/pkg/plugins/manager/sources"
"github.com/grafana/grafana/pkg/plugins/pluginassets"
"github.com/grafana/grafana/pkg/plugins/pluginerrs"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline"
"github.com/grafana/grafana/pkg/services/pluginsintegration/provisionedplugins"
@@ -126,6 +127,7 @@ func TestLoader_Load(t *testing.T) {
Signature: plugins.SignatureStatusInternal,
SkipHostEnvVars: true,
Translations: map[string]string{},
LoadingStrategy: plugins.LoadingStrategyScript,
},
},
},
@@ -213,15 +215,33 @@ func TestLoader_Load(t *testing.T) {
ExtensionPoints: []plugins.ExtensionPoint{},
},
},
Class: plugins.ClassExternal,
Module: "public/plugins/test-app/module.js",
BaseURL: "public/plugins/test-app",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "includes-symlinks")),
Class: plugins.ClassExternal,
Module: "public/plugins/test-app/module.js",
BaseURL: "public/plugins/test-app",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "includes-symlinks")),
Manifest: &plugins.PluginManifest{
Plugin: "test-app",
Version: "1.0.0",
KeyID: "7e4d0c6a708866e7",
Time: 1622547655175,
Files: map[string]string{
"dashboards/connections.json": "bea86da4be970b98dc4681802ab55cdef3441dc3eb3c654cb207948d17b25303",
"dashboards/extra/memory.json": "7c042464941084caa91d0a9a2f188b05315a9796308a652ccdee31ca4fbcbfee",
"plugin.json": "c59a51bf6d7ecd7a99608ccb99353390c8b973672a938a0247164324005c0caf",
"symlink_to_txt": "9f32c171bf78a85d5cb77a48ab44f85578ee2942a1fc9f9ec4fde194ae4ff048",
"text.txt": "9f32c171bf78a85d5cb77a48ab44f85578ee2942a1fc9f9ec4fde194ae4ff048",
},
ManifestVersion: "2.0.0",
SignatureType: plugins.SignatureTypeGrafana,
SignedByOrg: "grafana",
SignedByOrgName: "Grafana Labs",
},
Signature: "valid",
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
SkipHostEnvVars: true,
Translations: map[string]string{},
LoadingStrategy: plugins.LoadingStrategyScript,
},
},
},
@@ -274,6 +294,7 @@ func TestLoader_Load(t *testing.T) {
Signature: "unsigned",
SkipHostEnvVars: true,
Translations: map[string]string{},
LoadingStrategy: plugins.LoadingStrategyScript,
},
},
},
@@ -339,6 +360,7 @@ func TestLoader_Load(t *testing.T) {
Signature: plugins.SignatureStatusUnsigned,
SkipHostEnvVars: true,
Translations: map[string]string{},
LoadingStrategy: plugins.LoadingStrategyScript,
},
},
},
@@ -461,6 +483,7 @@ func TestLoader_Load(t *testing.T) {
BaseURL: "public/plugins/test-app",
SkipHostEnvVars: true,
Translations: map[string]string{},
LoadingStrategy: plugins.LoadingStrategyScript,
},
},
},
@@ -553,6 +576,7 @@ func TestLoader_Load_ExternalRegistration(t *testing.T) {
},
SkipHostEnvVars: true,
Translations: map[string]string{},
LoadingStrategy: plugins.LoadingStrategyScript,
},
}
@@ -647,15 +671,30 @@ func TestLoader_Load_MultiplePlugins(t *testing.T) {
Executable: "test",
State: plugins.ReleaseStateAlpha,
},
Class: plugins.ClassExternal,
Module: "public/plugins/test-datasource/module.js",
BaseURL: "public/plugins/test-datasource",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature/plugin")),
Class: plugins.ClassExternal,
Module: "public/plugins/test-datasource/module.js",
BaseURL: "public/plugins/test-datasource",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature/plugin")),
Manifest: &plugins.PluginManifest{
Plugin: "test-datasource",
Version: "1.0.0",
KeyID: "7e4d0c6a708866e7",
Time: 1661171417046,
Files: map[string]string{
"plugin.json": "203ef4a613c5693c437a665cd67f95e2756a0f71b336b2ffb265db7c180d0b19",
},
ManifestVersion: "2.0.0",
SignatureType: plugins.SignatureTypePrivate,
SignedByOrg: "willbrowne",
SignedByOrgName: "Will Browne",
RootURLs: []string{"http://localhost:3000/"},
},
Signature: "valid",
SignatureType: plugins.SignatureTypePrivate,
SignatureOrg: "Will Browne",
SkipHostEnvVars: true,
Translations: map[string]string{},
LoadingStrategy: plugins.LoadingStrategyScript,
},
},
pluginErrors: map[string]*plugins.Error{
@@ -767,8 +806,22 @@ func TestLoader_Load_RBACReady(t *testing.T) {
},
Backend: false,
},
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app-with-roles")),
Class: plugins.ClassExternal,
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app-with-roles")),
Class: plugins.ClassExternal,
Manifest: &plugins.PluginManifest{
Plugin: "test-app",
Version: "1.0.0",
KeyID: "7e4d0c6a708866e7",
Time: 1667484928676,
Files: map[string]string{
"plugin.json": "3348335ec100392b325f3eeb882a07c729e9cbf0f1ae331239f46840bb1a01eb",
},
ManifestVersion: "2.0.0",
SignatureType: plugins.SignatureTypePrivate,
SignedByOrg: "gabrielmabille",
SignedByOrgName: "gabrielmabille",
RootURLs: []string{"http://localhost:3000/"},
},
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypePrivate,
SignatureOrg: "gabrielmabille",
@@ -776,6 +829,7 @@ func TestLoader_Load_RBACReady(t *testing.T) {
BaseURL: "public/plugins/test-app",
SkipHostEnvVars: true,
Translations: map[string]string{},
LoadingStrategy: plugins.LoadingStrategyScript,
},
},
},
@@ -837,8 +891,22 @@ func TestLoader_Load_Signature_RootURL(t *testing.T) {
Backend: true,
Executable: "test",
},
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature-root-url-uri/plugin")),
Class: plugins.ClassExternal,
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature-root-url-uri/plugin")),
Class: plugins.ClassExternal,
Manifest: &plugins.PluginManifest{
Plugin: "test-datasource",
Version: "1.0.0",
KeyID: "7e4d0c6a708866e7",
Time: 1661171981629,
Files: map[string]string{
"plugin.json": "203ef4a613c5693c437a665cd67f95e2756a0f71b336b2ffb265db7c180d0b19",
},
ManifestVersion: "2.0.0",
SignatureType: plugins.SignatureTypePrivate,
SignedByOrg: "willbrowne",
SignedByOrgName: "Will Browne",
RootURLs: []string{"http://localhost:3000/grafana"},
},
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypePrivate,
SignatureOrg: "Will Browne",
@@ -846,6 +914,7 @@ func TestLoader_Load_Signature_RootURL(t *testing.T) {
Module: "public/plugins/test-datasource/module.js",
BaseURL: "public/plugins/test-datasource",
Translations: map[string]string{},
LoadingStrategy: plugins.LoadingStrategyScript,
},
}
@@ -925,8 +994,24 @@ func TestLoader_Load_DuplicatePlugins(t *testing.T) {
},
Backend: false,
},
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app")),
Class: plugins.ClassExternal,
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app")),
Class: plugins.ClassExternal,
Manifest: &plugins.PluginManifest{
Plugin: "test-app",
Version: "1.0.0",
KeyID: "7e4d0c6a708866e7",
Time: 1621356785895,
Files: map[string]string{
"plugin.json": "c59a51bf6d7ecd7a99608ccb99353390c8b973672a938a0247164324005c0caf",
"dashboards/connections.json": "bea86da4be970b98dc4681802ab55cdef3441dc3eb3c654cb207948d17b25303",
"dashboards/memory.json": "7c042464941084caa91d0a9a2f188b05315a9796308a652ccdee31ca4fbcbfee",
"dashboards/connections_result.json": "124d85c9c2e40214b83273f764574937a79909cfac3f925276fbb72543c224dc",
},
ManifestVersion: "2.0.0",
SignatureType: plugins.SignatureTypeGrafana,
SignedByOrg: "grafana",
SignedByOrgName: "Grafana Labs",
},
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
@@ -934,6 +1019,7 @@ func TestLoader_Load_DuplicatePlugins(t *testing.T) {
BaseURL: "public/plugins/test-app",
SkipHostEnvVars: true,
Translations: map[string]string{},
LoadingStrategy: plugins.LoadingStrategyScript,
},
}
@@ -1017,8 +1103,24 @@ func TestLoader_Load_SkipUninitializedPlugins(t *testing.T) {
},
Backend: false,
},
FS: mustNewStaticFSForTests(t, pluginDir1),
Class: plugins.ClassExternal,
FS: mustNewStaticFSForTests(t, pluginDir1),
Class: plugins.ClassExternal,
Manifest: &plugins.PluginManifest{
Plugin: "test-app",
Version: "1.0.0",
KeyID: "7e4d0c6a708866e7",
Time: 1621356785895,
Files: map[string]string{
"plugin.json": "c59a51bf6d7ecd7a99608ccb99353390c8b973672a938a0247164324005c0caf",
"dashboards/connections.json": "bea86da4be970b98dc4681802ab55cdef3441dc3eb3c654cb207948d17b25303",
"dashboards/memory.json": "7c042464941084caa91d0a9a2f188b05315a9796308a652ccdee31ca4fbcbfee",
"dashboards/connections_result.json": "124d85c9c2e40214b83273f764574937a79909cfac3f925276fbb72543c224dc",
},
ManifestVersion: "2.0.0",
SignatureType: plugins.SignatureTypeGrafana,
SignedByOrg: "grafana",
SignedByOrgName: "Grafana Labs",
},
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
@@ -1026,6 +1128,7 @@ func TestLoader_Load_SkipUninitializedPlugins(t *testing.T) {
BaseURL: "public/plugins/test-app",
SkipHostEnvVars: true,
Translations: map[string]string{},
LoadingStrategy: plugins.LoadingStrategyScript,
},
}
@@ -1180,15 +1283,30 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
},
Backend: true,
},
Module: "public/plugins/test-datasource/module.js",
BaseURL: "public/plugins/test-datasource",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent")),
Module: "public/plugins/test-datasource/module.js",
BaseURL: "public/plugins/test-datasource",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent")),
Manifest: &plugins.PluginManifest{
Plugin: "test-datasource",
Version: "1.0.0",
KeyID: "7e4d0c6a708866e7",
Time: 1661172777367,
Files: map[string]string{
"plugin.json": "a029469ace740e9502bfb0d40924d1cccae73d0b18adcd8f1ceb7f17bf36beb8",
"nested/plugin.json": "e64abd35cd211e0e4682974ad5cdd1be7a0b7cd24951d302a16d9e2cb6cefea4",
},
ManifestVersion: "2.0.0",
SignatureType: plugins.SignatureTypeGrafana,
SignedByOrg: "grafana",
SignedByOrgName: "Grafana Labs",
},
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
Class: plugins.ClassExternal,
SkipHostEnvVars: true,
Translations: map[string]string{},
LoadingStrategy: plugins.LoadingStrategyScript,
}
child := &plugins.Plugin{
@@ -1225,15 +1343,30 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
ExtensionPoints: []plugins.ExtensionPoint{},
},
},
Module: "public/plugins/test-panel/module.js",
BaseURL: "public/plugins/test-panel",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent/nested")),
Module: "public/plugins/test-panel/module.js",
BaseURL: "public/plugins/test-panel",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent/nested")),
Manifest: &plugins.PluginManifest{
Plugin: "test-datasource",
Version: "1.0.0",
KeyID: "7e4d0c6a708866e7",
Time: 1661172777367,
Files: map[string]string{
"plugin.json": "a029469ace740e9502bfb0d40924d1cccae73d0b18adcd8f1ceb7f17bf36beb8",
"nested/plugin.json": "e64abd35cd211e0e4682974ad5cdd1be7a0b7cd24951d302a16d9e2cb6cefea4",
},
ManifestVersion: "2.0.0",
SignatureType: plugins.SignatureTypeGrafana,
SignedByOrg: "grafana",
SignedByOrgName: "Grafana Labs",
},
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
Class: plugins.ClassExternal,
SkipHostEnvVars: true,
Translations: map[string]string{},
LoadingStrategy: plugins.LoadingStrategyScript,
}
parent.Children = []*plugins.Plugin{child}
@@ -1375,16 +1508,32 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
},
Backend: false,
},
Module: "public/plugins/myorgid-simple-app/module.js",
BaseURL: "public/plugins/myorgid-simple-app",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "app-with-child/dist")),
DefaultNavURL: "/plugins/myorgid-simple-app/page/root-page-react",
Module: "public/plugins/myorgid-simple-app/module.js",
BaseURL: "public/plugins/myorgid-simple-app",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "app-with-child/dist")),
DefaultNavURL: "/plugins/myorgid-simple-app/page/root-page-react",
Manifest: &plugins.PluginManifest{
Plugin: "myorgid-simple-app",
Version: "%VERSION%",
KeyID: "7e4d0c6a708866e7",
Time: 1642614241713,
Files: map[string]string{
"plugin.json": "1abecfd0229814f6c284ff3c8dd744548f8d676ab3250cd7902c99dabf11480e",
"child/plugin.json": "66ba0dffaf3b1bfa17eb9a8672918fc66d1001f465b1061f4fc19c2f2c100f51",
},
ManifestVersion: "2.0.0",
SignatureType: plugins.SignatureTypeGrafana,
SignedByOrg: "grafana",
SignedByOrgName: "Grafana Labs",
RootURLs: []string{},
},
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
Class: plugins.ClassExternal,
SkipHostEnvVars: true,
Translations: map[string]string{},
LoadingStrategy: plugins.LoadingStrategyScript,
}
child := &plugins.Plugin{
@@ -1431,12 +1580,28 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
BaseURL: "public/plugins/myorgid-simple-panel",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "app-with-child/dist/child")),
IncludedInAppID: parent.ID,
Manifest: &plugins.PluginManifest{
Plugin: "myorgid-simple-app",
Version: "%VERSION%",
KeyID: "7e4d0c6a708866e7",
Time: 1642614241713,
Files: map[string]string{
"plugin.json": "1abecfd0229814f6c284ff3c8dd744548f8d676ab3250cd7902c99dabf11480e",
"child/plugin.json": "66ba0dffaf3b1bfa17eb9a8672918fc66d1001f465b1061f4fc19c2f2c100f51",
},
ManifestVersion: "2.0.0",
SignatureType: plugins.SignatureTypeGrafana,
SignedByOrg: "grafana",
SignedByOrgName: "Grafana Labs",
RootURLs: []string{},
},
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
Class: plugins.ClassExternal,
SkipHostEnvVars: true,
Translations: map[string]string{},
LoadingStrategy: plugins.LoadingStrategyScript,
}
parent.Children = []*plugins.Plugin{child}
@@ -1484,7 +1649,7 @@ func newLoader(t *testing.T, cfg *config.PluginManagementCfg, reg registry.Servi
require.NoError(t, err)
return ProvideService(cfg, pipeline.ProvideDiscoveryStage(cfg, reg),
pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), pluginAssetsProvider),
pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), pluginAssetsProvider, pluginscdn.ProvideService(cfg)),
pipeline.ProvideValidationStage(cfg, signature.NewValidator(signature.NewUnsignedAuthorizer(cfg)), angularInspector),
pipeline.ProvideInitializationStage(cfg, reg, backendFactory, proc, &pluginfakes.FakeAuthService{}, pluginfakes.NewFakeRoleRegistry(), pluginfakes.NewFakeActionSetRegistry(), pluginfakes.NewFakePluginEnvProvider(), tracing.InitializeTracerForTest(), provisionedplugins.NewNoop()),
terminate, errTracker)
@@ -1514,7 +1679,7 @@ func newLoaderWithOpts(t *testing.T, cfg *config.PluginManagementCfg, opts loade
}
return ProvideService(cfg, pipeline.ProvideDiscoveryStage(cfg, reg),
pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), pluginassets.NewLocalProvider()),
pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), pluginassets.NewLocalProvider(), pluginscdn.ProvideService(cfg)),
pipeline.ProvideValidationStage(cfg, signature.NewValidator(signature.NewUnsignedAuthorizer(cfg)), angularInspector),
pipeline.ProvideInitializationStage(cfg, reg, backendFactoryProvider, proc, authServiceRegistry, pluginfakes.NewFakeRoleRegistry(), pluginfakes.NewFakeActionSetRegistry(), pluginfakes.NewFakePluginEnvProvider(), tracing.InitializeTracerForTest(), provisionedplugins.NewNoop()),
terminate, errTracker)

View File

@@ -18,6 +18,7 @@ import (
"github.com/grafana/grafana/pkg/plugins/manager/registry"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/pluginassets"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/grafana/grafana/pkg/services/pluginsintegration/coreplugin"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol"
"github.com/grafana/grafana/pkg/services/pluginsintegration/provisionedplugins"
@@ -42,7 +43,7 @@ func ProvideDiscoveryStage(cfg *config.PluginManagementCfg, pr registry.Service)
})
}
func ProvideBootstrapStage(cfg *config.PluginManagementCfg, sc plugins.SignatureCalculator, ap pluginassets.Provider) *bootstrap.Bootstrap {
func ProvideBootstrapStage(cfg *config.PluginManagementCfg, sc plugins.SignatureCalculator, ap pluginassets.Provider, cdn *pluginscdn.Service) *bootstrap.Bootstrap {
disableAlertingForTempoDecorateFunc := func(ctx context.Context, p *plugins.Plugin) (*plugins.Plugin, error) {
if p.ID == coreplugin.Tempo && !cfg.Features.TempoAlertingEnabled {
p.Alerting = false
@@ -52,7 +53,7 @@ func ProvideBootstrapStage(cfg *config.PluginManagementCfg, sc plugins.Signature
return bootstrap.New(cfg, bootstrap.Opts{
ConstructFunc: bootstrap.DefaultConstructFunc(cfg, sc, ap),
DecorateFuncs: append(bootstrap.DefaultDecorateFuncs(cfg), disableAlertingForTempoDecorateFunc),
DecorateFuncs: append(bootstrap.DefaultDecorateFuncs(cfg, cdn), disableAlertingForTempoDecorateFunc),
})
}

View File

@@ -1,204 +0,0 @@
package pluginassets
import (
"context"
"encoding/base64"
"encoding/hex"
"fmt"
"path"
"path/filepath"
"sync"
"github.com/Masterminds/semver/v3"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
)
const (
CreatePluginVersionCfgKey = "create_plugin_version"
CreatePluginVersionScriptSupportEnabled = "4.15.0"
)
var (
scriptLoadingMinSupportedVersion = semver.MustParse(CreatePluginVersionScriptSupportEnabled)
)
func ProvideService(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service, sig *signature.Signature, store pluginstore.Store) *Service {
return &Service{
cfg: cfg,
cdn: cdn,
signature: sig,
store: store,
log: log.New("pluginassets"),
}
}
type Service struct {
cfg *config.PluginManagementCfg
cdn *pluginscdn.Service
signature *signature.Signature
store pluginstore.Store
log log.Logger
moduleHashCache sync.Map
}
// LoadingStrategy calculates the loading strategy for a plugin.
// If a plugin has plugin setting `create_plugin_version` >= 4.15.0, set loadingStrategy to "script".
// If a plugin is not loaded via the CDN and is not Angular, set loadingStrategy to "script".
// Otherwise, set loadingStrategy to "fetch".
func (s *Service) LoadingStrategy(_ context.Context, p pluginstore.Plugin) plugins.LoadingStrategy {
if pCfg, ok := s.cfg.PluginSettings[p.ID]; ok {
if s.compatibleCreatePluginVersion(pCfg) {
return plugins.LoadingStrategyScript
}
}
// If the plugin has a parent
if p.Parent != nil {
// Check the parent's create_plugin_version setting
if pCfg, ok := s.cfg.PluginSettings[p.Parent.ID]; ok {
if s.compatibleCreatePluginVersion(pCfg) {
return plugins.LoadingStrategyScript
}
}
// Since the parent plugin is not explicitly configured as script loading compatible,
// If the plugin is either loaded from the CDN (via its parent) or contains Angular, we should use fetch
if s.cdnEnabled(p.Parent.ID, p.FS) || p.Angular.Detected {
return plugins.LoadingStrategyFetch
}
}
if !s.cdnEnabled(p.ID, p.FS) && !p.Angular.Detected {
return plugins.LoadingStrategyScript
}
return plugins.LoadingStrategyFetch
}
// ModuleHash returns the module.js SHA256 hash for a plugin in the format expected by the browser for SRI checks.
// The module hash is read from the plugin's MANIFEST.txt file.
// The plugin can also be a nested plugin.
// If the plugin is unsigned, an empty string is returned.
// The results are cached to avoid repeated reads from the MANIFEST.txt file.
func (s *Service) ModuleHash(ctx context.Context, p pluginstore.Plugin) string {
k := s.moduleHashCacheKey(p)
cachedValue, ok := s.moduleHashCache.Load(k)
if ok {
return cachedValue.(string)
}
mh, err := s.moduleHash(ctx, p, "")
if err != nil {
s.log.Error("Failed to calculate module hash", "plugin", p.ID, "error", err)
}
s.moduleHashCache.Store(k, mh)
return mh
}
// moduleHash is the underlying function for ModuleHash. See its documentation for more information.
// If the plugin is not a CDN plugin, the function will return an empty string.
// It will read the module hash from the MANIFEST.txt in the [[plugins.FS]] of the provided plugin.
// If childFSBase is provided, the function will try to get the hash from MANIFEST.txt for the provided children's
// module.js file, rather than for the provided plugin.
func (s *Service) moduleHash(ctx context.Context, p pluginstore.Plugin, childFSBase string) (r string, err error) {
if !s.cfg.Features.SriChecksEnabled {
return "", nil
}
// Ignore unsigned plugins
if !p.Signature.IsValid() {
return "", nil
}
if p.Parent != nil {
// Nested plugin
parent, ok := s.store.Plugin(ctx, p.Parent.ID)
if !ok {
return "", fmt.Errorf("parent plugin plugin %q for child plugin %q not found", p.Parent.ID, p.ID)
}
// The module hash is contained within the parent's MANIFEST.txt file.
// For example, the parent's MANIFEST.txt will contain an entry similar to this:
//
// ```
// "datasource/module.js": "1234567890abcdef..."
// ```
//
// Recursively call moduleHash with the parent plugin and with the children plugin folder path
// to get the correct module hash for the nested plugin.
if childFSBase == "" {
childFSBase = p.Base()
}
return s.moduleHash(ctx, parent, childFSBase)
}
// Only CDN plugins are supported for SRI checks.
// CDN plugins have the version as part of the URL, which acts as a cache-buster.
// Needed due to: https://github.com/grafana/plugin-tools/pull/1426
// FS plugins build before this change will have SRI mismatch issues.
if !s.cdnEnabled(p.ID, p.FS) {
return "", nil
}
manifest, err := s.signature.ReadPluginManifestFromFS(ctx, p.FS)
if err != nil {
return "", fmt.Errorf("read plugin manifest: %w", err)
}
if !manifest.IsV2() {
return "", nil
}
var childPath string
if childFSBase != "" {
// Calculate the relative path of the child plugin folder from the parent plugin folder.
childPath, err = p.FS.Rel(childFSBase)
if err != nil {
return "", fmt.Errorf("rel path: %w", err)
}
// MANIFETS.txt uses forward slashes as path separators.
childPath = filepath.ToSlash(childPath)
}
moduleHash, ok := manifest.Files[path.Join(childPath, "module.js")]
if !ok {
return "", nil
}
return convertHashForSRI(moduleHash)
}
func (s *Service) compatibleCreatePluginVersion(ps map[string]string) bool {
if cpv, ok := ps[CreatePluginVersionCfgKey]; ok {
createPluginVer, err := semver.NewVersion(cpv)
if err != nil {
s.log.Warn("Failed to parse create plugin version setting as semver", "version", cpv, "error", err)
} else {
if !createPluginVer.LessThan(scriptLoadingMinSupportedVersion) {
return true
}
}
}
return false
}
func (s *Service) cdnEnabled(pluginID string, fs plugins.FS) bool {
return s.cdn.PluginSupported(pluginID) || fs.Type().CDN()
}
// convertHashForSRI takes a SHA256 hash string and returns it as expected by the browser for SRI checks.
func convertHashForSRI(h string) (string, error) {
hb, err := hex.DecodeString(h)
if err != nil {
return "", fmt.Errorf("hex decode string: %w", err)
}
return "sha256-" + base64.StdEncoding.EncodeToString(hb), nil
}
// moduleHashCacheKey returns a unique key for the module hash cache.
func (s *Service) moduleHashCacheKey(p pluginstore.Plugin) string {
return p.ID + ":" + p.Info.Version
}

View File

@@ -1,595 +0,0 @@
package pluginassets
import (
"context"
"fmt"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/manager/pluginfakes"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
)
func TestService_Calculate(t *testing.T) {
const pluginID = "grafana-test-datasource"
const (
incompatVersion = "4.14.0"
compatVersion = CreatePluginVersionScriptSupportEnabled
futureVersion = "5.0.0"
)
tcs := []struct {
name string
pluginSettings config.PluginSettings
plugin pluginstore.Plugin
expected plugins.LoadingStrategy
}{
{
name: "Expected LoadingStrategyScript when create-plugin version is compatible and plugin is not angular",
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: compatVersion,
}),
plugin: newPlugin(pluginID, withAngular(false)),
expected: plugins.LoadingStrategyScript,
},
{
name: "Expected LoadingStrategyScript when parent create-plugin version is compatible and plugin is not angular",
pluginSettings: newPluginSettings("parent-datasource", map[string]string{
CreatePluginVersionCfgKey: compatVersion,
}),
plugin: newPlugin(pluginID, withAngular(false), func(p pluginstore.Plugin) pluginstore.Plugin {
p.Parent = &pluginstore.ParentPlugin{ID: "parent-datasource"}
return p
}),
expected: plugins.LoadingStrategyScript,
},
{
name: "Expected LoadingStrategyScript when create-plugin version is future compatible and plugin is not angular",
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: futureVersion,
}),
plugin: newPlugin(pluginID, withAngular(false), withFS(plugins.NewFakeFS())),
expected: plugins.LoadingStrategyScript,
},
{
name: "Expected LoadingStrategyScript when create-plugin version is not provided, plugin is not angular and is not configured as CDN enabled",
pluginSettings: newPluginSettings(pluginID, map[string]string{
// NOTE: cdn key is not set
}),
plugin: newPlugin(pluginID, withAngular(false), withFS(plugins.NewFakeFS())),
expected: plugins.LoadingStrategyScript,
},
{
name: "Expected LoadingStrategyScript when create-plugin version is not compatible, plugin is not angular, is not configured as CDN enabled and does not have a CDN fs",
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: incompatVersion,
// NOTE: cdn key is not set
}),
plugin: newPlugin(pluginID, withAngular(false), withClass(plugins.ClassExternal), withFS(plugins.NewFakeFS())),
expected: plugins.LoadingStrategyScript,
},
{
name: "Expected LoadingStrategyFetch when parent create-plugin version is not set, is configured as CDN enabled and plugin is not angular",
pluginSettings: config.PluginSettings{
"parent-datasource": {
"cdn": "true",
},
},
plugin: newPlugin(pluginID, withAngular(false), func(p pluginstore.Plugin) pluginstore.Plugin {
p.Parent = &pluginstore.ParentPlugin{ID: "parent-datasource"}
return p
}),
expected: plugins.LoadingStrategyFetch,
},
{
name: "Expected LoadingStrategyFetch when parent create-plugin version is not set, is configured as CDN enabled and plugin is angular",
pluginSettings: config.PluginSettings{
"parent-datasource": {
"cdn": "true",
},
},
plugin: newPlugin(pluginID, withAngular(true), func(p pluginstore.Plugin) pluginstore.Plugin {
p.Parent = &pluginstore.ParentPlugin{ID: "parent-datasource"}
return p
}),
expected: plugins.LoadingStrategyFetch,
},
{
name: "Expected LoadingStrategyFetch when parent create-plugin version is not set, is not configured as CDN enabled and plugin is angular",
pluginSettings: config.PluginSettings{},
plugin: newPlugin(pluginID, withAngular(true), withFS(plugins.NewFakeFS()), func(p pluginstore.Plugin) pluginstore.Plugin {
p.Parent = &pluginstore.ParentPlugin{ID: "parent-datasource"}
return p
}),
expected: plugins.LoadingStrategyFetch,
},
{
name: "Expected LoadingStrategyFetch when create-plugin version is not compatible, plugin is not angular, and plugin is configured as CDN enabled",
pluginSettings: newPluginSettings(pluginID, map[string]string{
"cdn": "true",
CreatePluginVersionCfgKey: incompatVersion,
}),
plugin: newPlugin(pluginID, withAngular(false), withClass(plugins.ClassExternal)),
expected: plugins.LoadingStrategyFetch,
},
{
name: "Expected LoadingStrategyFetch when create-plugin version is not compatible and plugin is angular",
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: incompatVersion,
}),
plugin: newPlugin(pluginID, withAngular(true), withFS(plugins.NewFakeFS())),
expected: plugins.LoadingStrategyFetch,
},
{
name: "Expected LoadingStrategyFetch when create-plugin version is not compatible, plugin is not angular and plugin is configured as CDN enabled",
pluginSettings: newPluginSettings(pluginID, map[string]string{
"cdn": "true",
CreatePluginVersionCfgKey: incompatVersion,
}),
plugin: newPlugin(pluginID, withAngular(false)),
expected: plugins.LoadingStrategyFetch,
},
{
name: "Expected LoadingStrategyFetch when create-plugin version is not compatible, plugin is not angular and has a CDN fs",
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: incompatVersion,
}),
plugin: newPlugin(pluginID, withAngular(false), withFS(
&pluginfakes.FakePluginFS{
TypeFunc: func() plugins.FSType {
return plugins.FSTypeCDN
},
},
)),
expected: plugins.LoadingStrategyFetch,
},
{
name: "Expected LoadingStrategyScript when plugin setting create-plugin version is badly formatted, plugin is not configured as CDN enabled and does not have a CDN fs",
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: "invalidSemver",
}),
plugin: newPlugin(pluginID, withAngular(false), withFS(plugins.NewFakeFS())),
expected: plugins.LoadingStrategyScript,
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
s := &Service{
cfg: newCfg(tc.pluginSettings),
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{
PluginsCDNURLTemplate: "http://cdn.example.com", // required for cdn.PluginSupported check
PluginSettings: tc.pluginSettings,
}),
log: log.NewNopLogger(),
}
got := s.LoadingStrategy(context.Background(), tc.plugin)
assert.Equal(t, tc.expected, got, "unexpected loading strategy")
})
}
}
func TestService_ModuleHash(t *testing.T) {
const (
pluginID = "grafana-test-datasource"
parentPluginID = "grafana-test-app"
)
for _, tc := range []struct {
name string
features *config.Features
store []pluginstore.Plugin
// Can be used to configure plugin's fs
// fs cdn type = loaded from CDN with no files on disk
// fs local type = files on disk but served from CDN only if cdn=true
plugin pluginstore.Plugin
// When true, set cdn=true in config
cdn bool
expModuleHash string
}{
{
name: "unsigned should not return module hash",
plugin: newPlugin(pluginID, withSignatureStatus(plugins.SignatureStatusUnsigned)),
cdn: false,
features: &config.Features{SriChecksEnabled: false},
expModuleHash: "",
},
{
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
withClass(plugins.ClassExternal),
),
cdn: true,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"),
},
{
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
withClass(plugins.ClassExternal),
),
cdn: true,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"),
},
{
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
),
cdn: false,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: "",
},
{
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
),
cdn: true,
features: &config.Features{SriChecksEnabled: false},
expModuleHash: "",
},
{
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
),
cdn: false,
features: &config.Features{SriChecksEnabled: false},
expModuleHash: "",
},
{
// parentPluginID (/)
// └── pluginID (/datasource)
name: "nested plugin should return module hash from parent MANIFEST.txt",
store: []pluginstore.Plugin{
newPlugin(
parentPluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))),
),
},
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "datasource"))),
withParent(parentPluginID),
),
cdn: true,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: newSRIHash(t, "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711"),
},
{
// parentPluginID (/)
// └── pluginID (/panels/one)
name: "nested plugin deeper than one subfolder should return module hash from parent MANIFEST.txt",
store: []pluginstore.Plugin{
newPlugin(
parentPluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))),
),
},
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one"))),
withParent(parentPluginID),
),
cdn: true,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: newSRIHash(t, "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f"),
},
{
// grand-parent-app (/)
// ├── parent-datasource (/datasource)
// │ └── child-panel (/datasource/panels/one)
name: "nested plugin of a nested plugin should return module hash from parent MANIFEST.txt",
store: []pluginstore.Plugin{
newPlugin(
"grand-parent-app",
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested"))),
),
newPlugin(
"parent-datasource",
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource"))),
withParent("grand-parent-app"),
),
},
plugin: newPlugin(
"child-panel",
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource", "panels", "one"))),
withParent("parent-datasource"),
),
cdn: true,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: newSRIHash(t, "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f"),
},
{
name: "nested plugin should not return module hash from parent if it's not registered in the store",
store: []pluginstore.Plugin{},
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one"))),
withParent(parentPluginID),
),
cdn: false,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: "",
},
{
name: "missing module.js entry from MANIFEST.txt should not return module hash",
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-module-js"))),
),
cdn: false,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: "",
},
{
name: "signed status but missing MANIFEST.txt should not return module hash",
plugin: newPlugin(
pluginID,
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-manifest-txt"))),
),
cdn: false,
features: &config.Features{SriChecksEnabled: true},
expModuleHash: "",
},
} {
if tc.name == "" {
var expS string
if tc.expModuleHash == "" {
expS = "should not return module hash"
} else {
expS = "should return module hash"
}
tc.name = fmt.Sprintf("feature=%v, cdn_config=%v, class=%v %s", tc.features.SriChecksEnabled, tc.cdn, tc.plugin.Class, expS)
}
t.Run(tc.name, func(t *testing.T) {
var pluginSettings config.PluginSettings
if tc.cdn {
pluginSettings = config.PluginSettings{
pluginID: {
"cdn": "true",
},
parentPluginID: map[string]string{
"cdn": "true",
},
"grand-parent-app": map[string]string{
"cdn": "true",
},
}
}
features := tc.features
if features == nil {
features = &config.Features{}
}
pCfg := &config.PluginManagementCfg{
PluginsCDNURLTemplate: "http://cdn.example.com",
PluginSettings: pluginSettings,
Features: *features,
}
svc := ProvideService(
pCfg,
pluginscdn.ProvideService(pCfg),
signature.ProvideService(pCfg, statickey.New()),
pluginstore.NewFakePluginStore(tc.store...),
)
mh := svc.ModuleHash(context.Background(), tc.plugin)
require.Equal(t, tc.expModuleHash, mh)
})
}
}
func TestService_ModuleHash_Cache(t *testing.T) {
pCfg := &config.PluginManagementCfg{
PluginSettings: config.PluginSettings{},
Features: config.Features{SriChecksEnabled: true},
}
svc := ProvideService(
pCfg,
pluginscdn.ProvideService(pCfg),
signature.ProvideService(pCfg, statickey.New()),
pluginstore.NewFakePluginStore(),
)
const pluginID = "grafana-test-datasource"
t.Run("cache key", func(t *testing.T) {
t.Run("with version", func(t *testing.T) {
const pluginVersion = "1.0.0"
p := newPlugin(pluginID, withInfo(plugins.Info{Version: pluginVersion}))
k := svc.moduleHashCacheKey(p)
require.Equal(t, pluginID+":"+pluginVersion, k, "cache key should be correct")
})
t.Run("without version", func(t *testing.T) {
p := newPlugin(pluginID)
k := svc.moduleHashCacheKey(p)
require.Equal(t, pluginID+":", k, "cache key should be correct")
})
})
t.Run("ModuleHash usage", func(t *testing.T) {
pV1 := newPlugin(
pluginID,
withInfo(plugins.Info{Version: "1.0.0"}),
withSignatureStatus(plugins.SignatureStatusValid),
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
)
pCfg = &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.grafana.com",
PluginSettings: config.PluginSettings{
pluginID: {
"cdn": "true",
},
},
Features: config.Features{SriChecksEnabled: true},
}
svc = ProvideService(
pCfg,
pluginscdn.ProvideService(pCfg),
signature.ProvideService(pCfg, statickey.New()),
pluginstore.NewFakePluginStore(),
)
k := svc.moduleHashCacheKey(pV1)
_, ok := svc.moduleHashCache.Load(k)
require.False(t, ok, "cache should initially be empty")
mhV1 := svc.ModuleHash(context.Background(), pV1)
pV1Exp := newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03")
require.Equal(t, pV1Exp, mhV1, "returned value should be correct")
cachedMh, ok := svc.moduleHashCache.Load(k)
require.True(t, ok)
require.Equal(t, pV1Exp, cachedMh, "cache should contain the returned value")
t.Run("different version uses different cache key", func(t *testing.T) {
pV2 := newPlugin(
pluginID,
withInfo(plugins.Info{Version: "2.0.0"}),
withSignatureStatus(plugins.SignatureStatusValid),
// different fs for different hash
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))),
)
mhV2 := svc.ModuleHash(context.Background(), pV2)
require.NotEqual(t, mhV2, mhV1, "different version should have different hash")
require.Equal(t, newSRIHash(t, "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a"), mhV2)
})
t.Run("cache should be used", func(t *testing.T) {
// edit cache directly
svc.moduleHashCache.Store(k, "hax")
require.Equal(t, "hax", svc.ModuleHash(context.Background(), pV1))
})
})
}
func TestConvertHashFromSRI(t *testing.T) {
for _, tc := range []struct {
hash string
expHash string
expErr bool
}{
{
hash: "ddfcb449445064e6c39f0c20b15be3cb6a55837cf4781df23d02de005f436811",
expHash: "sha256-3fy0SURQZObDnwwgsVvjy2pVg3z0eB3yPQLeAF9DaBE=",
},
{
hash: "not-a-valid-hash",
expErr: true,
},
} {
t.Run(tc.hash, func(t *testing.T) {
r, err := convertHashForSRI(tc.hash)
if tc.expErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tc.expHash, r)
}
})
}
}
func newPlugin(pluginID string, cbs ...func(p pluginstore.Plugin) pluginstore.Plugin) pluginstore.Plugin {
p := pluginstore.Plugin{
JSONData: plugins.JSONData{
ID: pluginID,
},
}
for _, cb := range cbs {
p = cb(p)
}
return p
}
func withInfo(info plugins.Info) func(p pluginstore.Plugin) pluginstore.Plugin {
return func(p pluginstore.Plugin) pluginstore.Plugin {
p.Info = info
return p
}
}
func withFS(fs plugins.FS) func(p pluginstore.Plugin) pluginstore.Plugin {
return func(p pluginstore.Plugin) pluginstore.Plugin {
p.FS = fs
return p
}
}
func withSignatureStatus(status plugins.SignatureStatus) func(p pluginstore.Plugin) pluginstore.Plugin {
return func(p pluginstore.Plugin) pluginstore.Plugin {
p.Signature = status
return p
}
}
func withAngular(angular bool) func(p pluginstore.Plugin) pluginstore.Plugin {
return func(p pluginstore.Plugin) pluginstore.Plugin {
p.Angular = plugins.AngularMeta{Detected: angular}
return p
}
}
func withParent(parentID string) func(p pluginstore.Plugin) pluginstore.Plugin {
return func(p pluginstore.Plugin) pluginstore.Plugin {
p.Parent = &pluginstore.ParentPlugin{ID: parentID}
return p
}
}
func withClass(class plugins.Class) func(p pluginstore.Plugin) pluginstore.Plugin {
return func(p pluginstore.Plugin) pluginstore.Plugin {
p.Class = class
return p
}
}
func newCfg(ps config.PluginSettings) *config.PluginManagementCfg {
return &config.PluginManagementCfg{
PluginSettings: ps,
}
}
func newPluginSettings(pluginID string, kv map[string]string) config.PluginSettings {
return config.PluginSettings{
pluginID: kv,
}
}
func newSRIHash(t *testing.T, s string) string {
r, err := convertHashForSRI(s)
require.NoError(t, err)
return r
}

View File

@@ -46,7 +46,6 @@ import (
"github.com/grafana/grafana/pkg/services/pluginsintegration/loader"
"github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginchecker"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig"
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext"
@@ -131,7 +130,6 @@ var WireSet = wire.NewSet(
plugincontext.ProvideBaseService,
wire.Bind(new(plugincontext.BasePluginContextProvider), new(*plugincontext.BaseProvider)),
plugininstaller.ProvideService,
pluginassets.ProvideService,
pluginchecker.ProvidePreinstall,
wire.Bind(new(pluginchecker.Preinstall), new(*pluginchecker.PreinstallImpl)),
advisor.ProvideService,

View File

@@ -30,8 +30,10 @@ type Plugin struct {
Error *plugins.Error
// SystemJS fields
Module string
BaseURL string
Module string
LoadingStrategy plugins.LoadingStrategy
BaseURL string
ModuleHash string
Angular plugins.AngularMeta
@@ -76,10 +78,12 @@ func ToGrafanaDTO(p *plugins.Plugin) Plugin {
SignatureOrg: p.SignatureOrg,
Error: p.Error,
Module: p.Module,
LoadingStrategy: p.LoadingStrategy,
BaseURL: p.BaseURL,
ExternalService: p.ExternalService,
Angular: p.Angular,
Translations: p.Translations,
ModuleHash: p.ModuleHash,
}
if p.Parent != nil {

View File

@@ -24,6 +24,7 @@ import (
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
"github.com/grafana/grafana/pkg/plugins/pluginassets"
"github.com/grafana/grafana/pkg/plugins/pluginerrs"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/pluginsintegration/coreplugin"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline"
@@ -49,7 +50,7 @@ func CreateIntegrationTestCtx(t *testing.T, cfg *setting.Cfg, coreRegistry *core
proc := process.ProvideService()
disc := pipeline.ProvideDiscoveryStage(pCfg, reg)
boot := pipeline.ProvideBootstrapStage(pCfg, signature.ProvideService(pCfg, statickey.New()), pluginassets.NewLocalProvider())
boot := pipeline.ProvideBootstrapStage(pCfg, signature.ProvideService(pCfg, statickey.New()), pluginassets.NewLocalProvider(), pluginscdn.ProvideService(pCfg))
valid := pipeline.ProvideValidationStage(pCfg, signature.NewValidator(signature.NewUnsignedAuthorizer(pCfg)), angularInspector)
init := pipeline.ProvideInitializationStage(pCfg, reg, coreplugin.ProvideCoreProvider(coreRegistry), proc, &pluginfakes.FakeAuthService{}, pluginfakes.NewFakeRoleRegistry(), pluginfakes.NewFakeActionSetRegistry(), nil, tracing.InitializeTracerForTest(), provisionedplugins.NewNoop())
term, err := pipeline.ProvideTerminationStage(pCfg, reg, proc)
@@ -87,7 +88,7 @@ func CreateTestLoader(t *testing.T, cfg *pluginsCfg.PluginManagementCfg, opts Lo
}
if opts.Bootstrapper == nil {
opts.Bootstrapper = pipeline.ProvideBootstrapStage(cfg, signature.ProvideService(cfg, statickey.New()), pluginassets.NewLocalProvider())
opts.Bootstrapper = pipeline.ProvideBootstrapStage(cfg, signature.ProvideService(cfg, statickey.New()), pluginassets.NewLocalProvider(), pluginscdn.ProvideService(cfg))
}
if opts.Validator == nil {

View File

@@ -1,12 +1,11 @@
import { css } from '@emotion/css';
import { memo, useEffect, useMemo, useRef } from 'react';
import { memo, useEffect, useMemo } from 'react';
import { useLocation, useParams } from 'react-router-dom-v5-compat';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { config, reportInteraction } from '@grafana/runtime';
import { evaluateBooleanFlag } from '@grafana/runtime/internal';
import { LinkButton, FilterInput, useStyles2, Text, Stack } from '@grafana/ui';
import { useGetFolderQueryFacade, useUpdateFolder } from 'app/api/clients/folder/v1beta1/hooks';
import { Page } from 'app/core/components/Page/Page';
@@ -45,7 +44,6 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
const location = useLocation();
const search = useMemo(() => new URLSearchParams(location.search), [location.search]);
const { isReadOnlyRepo, repoType } = useGetResourceRepositoryView({ folderName: folderUID });
const isRecentlyViewedEnabled = !folderUID && evaluateBooleanFlag('recentlyViewedDashboards', false);
useEffect(() => {
stateManager.initStateFromUrl(folderUID);
@@ -75,23 +73,6 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
}
}, [isSearching, searchState.result, stateManager]);
// Emit exposure event for A/A test once when page loads
const hasEmittedExposureEvent = useRef(false);
useEffect(() => {
if (!isRecentlyViewedEnabled || hasEmittedExposureEvent.current) {
return;
}
hasEmittedExposureEvent.current = true;
const isExperimentTreatment = evaluateBooleanFlag('experimentRecentlyViewedDashboards', false);
reportInteraction('dashboards_browse_list_viewed', {
experiment_dashboard_list_recently_viewed: isExperimentTreatment ? 'treatment' : 'control',
has_recently_viewed_component: isExperimentTreatment,
});
}, [isRecentlyViewedEnabled]);
const { data: folderDTO } = useGetFolderQueryFacade(folderUID);
const [saveFolder] = useUpdateFolder();
const navModel = useMemo(() => {
@@ -198,8 +179,8 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
>
<Page.Contents className={styles.pageContents}>
<ProvisionedFolderPreviewBanner queryParams={queryParams} />
{/* only show recently viewed dashboards when in root and flag is enabled */}
{isRecentlyViewedEnabled && <RecentlyViewedDashboards />}
{/* only show recently viewed dashboards when in root */}
{!folderUID && <RecentlyViewedDashboards />}
<div>
<FilterInput
placeholder={getSearchPlaceholder(searchState.includePanels)}

View File

@@ -5,6 +5,7 @@ import { useAsyncRetry } from 'react-use';
import { GrafanaTheme2, store } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { evaluateBooleanFlag } from '@grafana/runtime/internal';
import { Button, CollapsableSection, Spinner, Stack, Text, useStyles2, Grid } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { useDashboardLocationInfo } from 'app/features/search/hooks/useDashboardLocationInfo';
@@ -27,6 +28,9 @@ export function RecentlyViewedDashboards() {
retry,
error,
} = useAsyncRetry(async () => {
if (!evaluateBooleanFlag('recentlyViewedDashboards', false)) {
return [];
}
return getRecentlyViewedDashboards(MAX_RECENT);
}, []);
const { foldersByUid } = useDashboardLocationInfo(recentDashboards.length > 0);
@@ -44,7 +48,7 @@ export function RecentlyViewedDashboards() {
setIsOpen(!isOpen);
};
if (recentDashboards.length === 0) {
if (!evaluateBooleanFlag('recentlyViewedDashboards', false) || recentDashboards.length === 0) {
return null;
}
@@ -119,7 +123,6 @@ const getStyles = (theme: GrafanaTheme2) => {
color: 'transparent',
cursor: 'pointer',
},
padding: 0,
}),
content: css({
paddingTop: theme.spacing(0),

View File

@@ -21,7 +21,7 @@ export function initSystemJSHooks() {
// This instructs SystemJS to load plugin assets using fetch and eval if it returns a truthy value, otherwise
// it will load the plugin using a script tag. The logic that sets loadingStrategy comes from the backend.
// See: pkg/services/pluginsintegration/pluginassets/pluginassets.go
// Loading strategy is calculated during bootstrap in pkg/plugins/pluginassets/loadingstrategy.go
systemJSPrototype.shouldFetch = function (url) {
const pluginInfo = getPluginInfoFromCache(url);
const jsTypeRegEx = /^[^#?]+\.(js)([?#].*)?$/;

View File

@@ -36,4 +36,9 @@ describe('ConfigEditor', () => {
expect(screen.getByTestId('url-auth-section')).toBeInTheDocument();
expect(screen.getByTestId('db-connection-section')).toBeInTheDocument();
});
it('shows the informational alert', () => {
render(<ConfigEditor {...defaultProps} />);
expect(screen.getByText(/You are viewing a new design/i)).toBeInTheDocument();
});
});

View File

@@ -2,12 +2,13 @@ import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Box, Stack, Text, useStyles2 } from '@grafana/ui';
import { Alert, Box, Stack, TextLink, Text, useStyles2 } from '@grafana/ui';
import { DatabaseConnectionSection } from './DatabaseConnectionSection';
import { LeftSideBar } from './LeftSideBar';
import { UrlAndAuthenticationSection } from './UrlAndAuthenticationSection';
import { CONTAINER_MIN_WIDTH } from './constants';
import { trackInfluxDBConfigV2FeedbackButtonClicked } from './tracking';
import { Props } from './types';
export const ConfigEditor: React.FC<Props> = ({ onOptionsChange, options }: Props) => {
@@ -21,6 +22,22 @@ export const ConfigEditor: React.FC<Props> = ({ onOptionsChange, options }: Prop
</div>
<Box width="60%" flex="1 1 auto" minWidth={CONTAINER_MIN_WIDTH}>
<Stack direction="column">
<Alert
severity="info"
title="You are viewing a new design for the InfluxDB configuration settings."
className={styles.alertHeight}
>
<>
<TextLink
href="https://docs.google.com/forms/d/e/1FAIpQLSdi-zyX3c51vh937UKhNYYxhljUnFi6dQSlZv50mES9NrK-ig/viewform"
external
onClick={trackInfluxDBConfigV2FeedbackButtonClicked}
>
Share your thoughts
</TextLink>{' '}
to help us make it even better.
</>
</Alert>
<Text variant="bodySmall" color="secondary">
Fields marked with * are required
</Text>

View File

@@ -1,5 +1,3 @@
import { truncate } from 'lodash';
import { reportInteraction } from '@grafana/runtime';
import { Box, Card, Icon, Link, Stack, Text, useStyles2 } from '@grafana/ui';
import { LocationInfo } from 'app/features/search/service/types';
@@ -27,7 +25,6 @@ export function DashListItem({
onStarChange,
}: Props) {
const css = useStyles2(getStyles);
const shortTitle = truncate(dashboard.name, { length: 40, omission: '…' });
const onCardLinkClick = () => {
reportInteraction('grafana_recently_viewed_dashboards_click_card', {
@@ -57,35 +54,27 @@ export function DashListItem({
</div>
) : (
<Card className={css.dashlistCard} noMargin>
<Stack direction="column" justifyContent="space-between" height="100%">
<Stack justifyContent="space-between" alignItems="start">
<Link
className={css.dashlistCardLink}
href={url}
aria-label={dashboard.name}
title={dashboard.name}
onClick={onCardLinkClick}
>
{shortTitle}
</Link>
<StarToolbarButton
title={dashboard.name}
group="dashboard.grafana.app"
kind="Dashboard"
id={dashboard.uid}
onStarChange={onStarChange}
/>
</Stack>
{showFolderNames && locationInfo && (
<Stack alignItems="start" direction="row" gap={0.5} height="25%">
<Icon name="folder" size="sm" className={css.dashlistCardIcon} aria-hidden="true" />
<Text color="secondary" variant="bodySmall" element="p">
{locationInfo?.name}
</Text>
</Stack>
)}
<Stack justifyContent="space-between" alignItems="center">
<Link href={url} onClick={onCardLinkClick}>
{dashboard.name}
</Link>
<StarToolbarButton
title={dashboard.name}
group="dashboard.grafana.app"
kind="Dashboard"
id={dashboard.uid}
onStarChange={onStarChange}
/>
</Stack>
{showFolderNames && locationInfo && (
<Stack alignItems="center" direction="row" gap={0}>
<Icon name="folder" size="sm" className={css.dashlistCardIcon} aria-hidden="true" />
<Text color="secondary" variant="bodySmall" element="p">
{locationInfo?.name}
</Text>
</Stack>
)}
</Card>
)}
</>

View File

@@ -32,7 +32,6 @@ export const getStyles = (theme: GrafanaTheme2) => {
textDecoration: 'underline',
},
height: '100%',
paddingTop: theme.spacing(1.5),
'&:hover': {
backgroundImage: gradient,
@@ -42,8 +41,5 @@ export const getStyles = (theme: GrafanaTheme2) => {
dashlistCardIcon: css({
marginRight: theme.spacing(0.5),
}),
dashlistCardLink: css({
paddingTop: theme.spacing(0.5),
}),
};
};