Compare commits

...

27 Commits

Author SHA1 Message Date
Ryan McKinley
d5df03e531 update snapshot 2026-01-14 08:42:00 +03:00
Ryan McKinley
2308b5458e Merge remote-tracking branch 'origin/main' into dual-write-inline-secure-values 2026-01-14 08:40:43 +03:00
Hugo Häggmark
bd0140b6f0 GrafanaBootData: Deprecate config.apps (#115610)
* GrafanaBootData: decouple `config.apps` from boot data IV

* chore: changed to openfeature flags eval

* chore: updates after PR feedback

* chore: updates after PR feedback

* chore: copy types to runtime package

* chore: add code ownership

* chore: deprecate in interface too

* chore: add important notice to comments

* chore: deprecate the whole interface
2026-01-14 06:30:05 +01:00
grafana-pr-automation[bot]
215d25ef69 I18n: Download translations from Crowdin (#116232)
New Crowdin translations by GitHub Action

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-14 00:43:07 +00:00
sabithamuppuri
d3beed7dd2 Docs: add unified_alerting.state_history configuration section (fixes #114670) (#115607)
Co-authored-by: Pepe Cano <825430+ppcano@users.noreply.github.com>
Co-authored-by: Johnny Kartheiser <140559259+JohnnyK-Grafana@users.noreply.github.com>
2026-01-13 21:37:09 +00:00
Anton Chimrov
e2f2011d9e Restore Canvas element key simplification to prevent blinking icons (#113693)
* Simplify Canvas element key to prevent blinking icons

* Fix formatting with prettier
2026-01-13 13:01:22 -08:00
Paul Marbach
6db51cbdb9 Legends: Revert scrolled truncated legend for now (#116217)
* Revert "PieChart: Fix right-oriented legends (#116084)"

This reverts commit 0c8c886930.

* Revert "TimeSeries: Fix truncated label text in legend table mode (#115647)"

This reverts commit f91efcfe2c.
2026-01-13 19:54:42 +00:00
Haris Rozajac
82d8d44977 Dashboard Conversion: Remove duplicated data loss function (#116214)
remove duplicated dataloss function
2026-01-13 11:44:36 -07:00
Ida Štambuk
60abd9a159 Dynamic dashboards: Add tests for custom grid repeats (#114545) 2026-01-13 19:42:47 +01:00
Will Browne
6186aac5d4 Revert "Plugins: Add module hash field to plugin model" (#116211)
* Revert "Plugins: Add module hash field to plugin model (#116119)"

This reverts commit aa9b587cc1.

* trigger

* trigger
2026-01-13 18:34:39 +00:00
Galen Kistler
a28076ef5e Logs: Feature flag clean up (#116205)
* chore: reassign flags to big tent
2026-01-13 11:41:35 -06:00
Motte
b687ca6b6d Chore: Improve packaging/docker/run.sh (#114012)
* Chore: set -e line in packaging/docker/run.sh

* Chore: fix ShellCheck SC2188 in packaging/docker/run.sh

* Chore: fix ShellCheck SC2166 in packaging/docker/run.sh
2026-01-13 15:48:50 +00:00
Ryan McKinley
1a2c3bdbc9 update snapshot 2026-01-13 16:49:23 +03:00
Ryan McKinley
8af89b1210 ensure GVK is configured 2026-01-13 16:34:59 +03:00
Ryan McKinley
4b24e63e0b Merge remote-tracking branch 'origin/main' into dual-write-inline-secure-values 2026-01-13 16:18:04 +03:00
Ryan McKinley
68e7d66e54 merge main 2026-01-13 16:11:24 +03:00
Ryan McKinley
ef0601b85e add delete to integration tests 2026-01-12 18:22:50 +03:00
Ryan McKinley
e25d09ff3e support generic response 2026-01-12 18:08:56 +03:00
Ryan McKinley
b7269073b2 lint fix 2026-01-12 15:30:07 +03:00
Ryan McKinley
29dfdafad5 Merge remote-tracking branch 'origin/main' into dual-write-inline-secure-values 2026-01-12 15:03:00 +03:00
Ryan McKinley
b7da61c260 testing CRUD 2026-01-12 14:41:31 +03:00
Ryan McKinley
0e6ad2e7c8 cleanup 2026-01-12 14:22:07 +03:00
Ryan McKinley
aae69c1e75 revert signature change 2026-01-12 11:05:39 +03:00
Ryan McKinley
89dd3870b3 with context 2026-01-12 10:47:05 +03:00
Ryan McKinley
3b577e2c42 with context 2026-01-12 10:46:52 +03:00
Ryan McKinley
7ff004f775 dual write secure values for create 2026-01-12 09:37:39 +03:00
Ryan McKinley
4d26d0cd5c dual write secure values 2026-01-12 09:27:36 +03:00
123 changed files with 10184 additions and 1351 deletions

2
.github/CODEOWNERS vendored
View File

@@ -440,6 +440,7 @@ 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
@@ -657,6 +658,7 @@ i18next.config.ts @grafana/grafana-frontend-platform
/packages/grafana-runtime/src/services/LocationService.tsx @grafana/grafana-search-navigate-organise
/packages/grafana-runtime/src/services/LocationSrv.ts @grafana/grafana-search-navigate-organise
/packages/grafana-runtime/src/services/live.ts @grafana/dashboards-squad
/packages/grafana-runtime/src/services/pluginMeta @grafana/plugins-platform-frontend
/packages/grafana-runtime/src/utils/chromeHeaderHeight.ts @grafana/grafana-search-navigate-organise
/packages/grafana-runtime/src/utils/DataSourceWithBackend* @grafana/grafana-datasources-core-services
/packages/grafana-runtime/src/utils/licensing.ts @grafana/grafana-operator-experience-squad

View File

@@ -71,11 +71,6 @@ 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
@@ -198,29 +193,6 @@ 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

@@ -290,7 +290,7 @@
],
"legend": {
"displayMode": "table",
"placement": "right",
"placement": "bottom",
"showLegend": true,
"values": [
"percent"
@@ -304,7 +304,7 @@
"fields": "",
"values": false
},
"showLegend": true,
"showLegend": false,
"strokeWidth": 1,
"text": {}
},
@@ -323,15 +323,6 @@
}
],
"title": "Percent",
"transformations": [
{
"id": "renameByRegex",
"options": {
"regex": "^Backend-(.*)$",
"renamePattern": "b-$1"
}
}
],
"type": "piechart"
},
{
@@ -375,7 +366,7 @@
],
"legend": {
"displayMode": "table",
"placement": "right",
"placement": "bottom",
"showLegend": true,
"values": [
"value"
@@ -389,7 +380,7 @@
"fields": "",
"values": false
},
"showLegend": true,
"showLegend": false,
"strokeWidth": 1,
"text": {}
},
@@ -408,15 +399,6 @@
}
],
"title": "Value",
"transformations": [
{
"id": "renameByRegex",
"options": {
"regex": "(.*)",
"renamePattern": "$1-how-much-wood-could-a-woodchuck-chuck-if-a-woodchuck-could-chuck-wood"
}
}
],
"type": "piechart"
},
{

View File

@@ -1,9 +1,16 @@
include ../sdk.mk
.PHONY: generate # Run Grafana App SDK code generation
generate: install-app-sdk update-app-sdk
.PHONY: internal-generate # Run Grafana App SDK code generation
internal-generate: install-app-sdk update-app-sdk
@$(APP_SDK_BIN) generate \
--source=./kinds/ \
--gogenpath=./pkg/apis \
--grouping=group \
--defencoding=none
--defencoding=none
.PHONY: generate
generate: internal-generate # copy files to packages/grafana-runtime/src/services/pluginMeta/types
rm -f ./packages/grafana-runtime/src/services/pluginMeta/types/*.ts
cp plugin/src/generated/meta/v0alpha1/meta_object_gen.ts ../../packages/grafana-runtime/src/services/pluginMeta/types/meta_object_gen.ts
cp plugin/src/generated/meta/v0alpha1/types.spec.gen.ts ../../packages/grafana-runtime/src/services/pluginMeta/types/types.spec.gen.ts
cp plugin/src/generated/meta/v0alpha1/types.status.gen.ts ../../packages/grafana-runtime/src/services/pluginMeta/types/types.status.gen.ts

View File

@@ -4,8 +4,7 @@ API documentation is available at http://localhost:3000/swagger?api=plugins.graf
## Codegen
- Go: `make generate`
- Frontend: Follow instructions in this [README](../..//packages/grafana-api-clients/README.md)
- Go and TypeScript: `make generate`
## Plugin sync

View File

@@ -11,7 +11,7 @@ manifest: {
v0alpha1Version: {
served: true
codegen: {
ts: {enabled: false}
ts: {enabled: true}
go: {enabled: true}
}
kinds: [

View File

@@ -13,9 +13,10 @@ const (
)
// PluginAssetsCalculator is an interface for calculating plugin asset information.
// LocalProvider requires this to calculate loading strategy.
// 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.
@@ -26,7 +27,7 @@ type LocalProvider struct {
}
// NewLocalProvider creates a new LocalProvider for locally installed plugins.
// pluginAssets is required for calculating loading strategy.
// pluginAssets is required for calculating loading strategy and module hash.
func NewLocalProvider(pluginStore pluginstore.Store, pluginAssets PluginAssetsCalculator) *LocalProvider {
return &LocalProvider{
store: pluginStore,
@@ -42,7 +43,7 @@ func (p *LocalProvider) GetMeta(ctx context.Context, pluginID, version string) (
}
loadingStrategy := p.pluginAssets.LoadingStrategy(ctx, plugin)
moduleHash := plugin.ModuleHash
moduleHash := p.pluginAssets.ModuleHash(ctx, plugin)
spec := pluginStorePluginToMeta(plugin, loadingStrategy, moduleHash)
return &Result{

View File

@@ -0,0 +1,49 @@
/*
* This file was generated by grafana-app-sdk. DO NOT EDIT.
*/
import { Spec } from './types.spec.gen';
import { Status } from './types.status.gen';
export interface Metadata {
name: string;
namespace: string;
generateName?: string;
selfLink?: string;
uid?: string;
resourceVersion?: string;
generation?: number;
creationTimestamp?: string;
deletionTimestamp?: string;
deletionGracePeriodSeconds?: number;
labels?: Record<string, string>;
annotations?: Record<string, string>;
ownerReferences?: OwnerReference[];
finalizers?: string[];
managedFields?: ManagedFieldsEntry[];
}
export interface OwnerReference {
apiVersion: string;
kind: string;
name: string;
uid: string;
controller?: boolean;
blockOwnerDeletion?: boolean;
}
export interface ManagedFieldsEntry {
manager?: string;
operation?: string;
apiVersion?: string;
time?: string;
fieldsType?: string;
subresource?: string;
}
export interface Meta {
kind: string;
apiVersion: string;
metadata: Metadata;
spec: Spec;
status: Status;
}

View File

@@ -0,0 +1,30 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
// metadata contains embedded CommonMetadata and can be extended with custom string fields
// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here
// without external reference as using the CommonMetadata reference breaks thema codegen.
export interface Metadata {
updateTimestamp: string;
createdBy: string;
uid: string;
creationTimestamp: string;
deletionTimestamp?: string;
finalizers: string[];
resourceVersion: string;
generation: number;
updatedBy: string;
labels: Record<string, string>;
}
export const defaultMetadata = (): Metadata => ({
updateTimestamp: "",
createdBy: "",
uid: "",
creationTimestamp: "",
finalizers: [],
resourceVersion: "",
generation: 0,
updatedBy: "",
labels: {},
});

View File

@@ -0,0 +1,278 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
// JSON configuration schema for Grafana plugins
// Converted from: https://github.com/grafana/grafana/blob/main/docs/sources/developers/plugins/plugin.schema.json
export interface JSONData {
// Unique name of the plugin
id: string;
// Plugin type
type: "app" | "datasource" | "panel" | "renderer";
// Human-readable name of the plugin
name: string;
// Metadata for the plugin
info: Info;
// Dependency information
dependencies: Dependencies;
// Optional fields
alerting?: boolean;
annotations?: boolean;
autoEnabled?: boolean;
backend?: boolean;
buildMode?: string;
builtIn?: boolean;
category?: "tsdb" | "logging" | "cloud" | "tracing" | "profiling" | "sql" | "enterprise" | "iot" | "other";
enterpriseFeatures?: EnterpriseFeatures;
executable?: string;
hideFromList?: boolean;
// +listType=atomic
includes?: Include[];
logs?: boolean;
metrics?: boolean;
multiValueFilterOperators?: boolean;
pascalName?: string;
preload?: boolean;
queryOptions?: QueryOptions;
// +listType=atomic
routes?: Route[];
skipDataQuery?: boolean;
state?: "alpha" | "beta";
streaming?: boolean;
suggestions?: boolean;
tracing?: boolean;
iam?: IAM;
// +listType=atomic
roles?: Role[];
extensions?: Extensions;
}
export const defaultJSONData = (): JSONData => ({
id: "",
type: "app",
name: "",
info: defaultInfo(),
dependencies: defaultDependencies(),
});
export interface Info {
// Required fields
// +listType=set
keywords: string[];
logos: {
small: string;
large: string;
};
updated: string;
version: string;
// Optional fields
author?: {
name?: string;
email?: string;
url?: string;
};
description?: string;
// +listType=atomic
links?: {
name?: string;
url?: string;
}[];
// +listType=atomic
screenshots?: {
name?: string;
path?: string;
}[];
}
export const defaultInfo = (): Info => ({
keywords: [],
logos: {
small: "",
large: "",
},
updated: "",
version: "",
});
export interface Dependencies {
// Required field
grafanaDependency: string;
// Optional fields
grafanaVersion?: string;
// +listType=set
// +listMapKey=id
plugins?: {
id: string;
type: "app" | "datasource" | "panel";
name: string;
}[];
extensions?: {
// +listType=set
exposedComponents?: string[];
};
}
export const defaultDependencies = (): Dependencies => ({
grafanaDependency: "",
});
export interface EnterpriseFeatures {
// Allow additional properties
healthDiagnosticsErrors?: boolean;
}
export const defaultEnterpriseFeatures = (): EnterpriseFeatures => ({
healthDiagnosticsErrors: false,
});
export interface Include {
uid?: string;
type?: "dashboard" | "page" | "panel" | "datasource";
name?: string;
component?: string;
role?: "Admin" | "Editor" | "Viewer" | "None";
action?: string;
path?: string;
addToNav?: boolean;
defaultNav?: boolean;
icon?: string;
}
export const defaultInclude = (): Include => ({
});
export interface QueryOptions {
maxDataPoints?: boolean;
minInterval?: boolean;
cacheTimeout?: boolean;
}
export const defaultQueryOptions = (): QueryOptions => ({
});
export interface Route {
path?: string;
method?: string;
url?: string;
reqSignedIn?: boolean;
reqRole?: string;
reqAction?: string;
// +listType=atomic
headers?: string[];
body?: Record<string, any>;
tokenAuth?: {
url?: string;
// +listType=set
scopes?: string[];
params?: Record<string, any>;
};
jwtTokenAuth?: {
url?: string;
// +listType=set
scopes?: string[];
params?: Record<string, any>;
};
// +listType=atomic
urlParams?: {
name?: string;
content?: string;
}[];
}
export const defaultRoute = (): Route => ({
});
export interface IAM {
// +listType=atomic
permissions?: {
action?: string;
scope?: string;
}[];
}
export const defaultIAM = (): IAM => ({
});
export interface Role {
role?: {
name?: string;
description?: string;
// +listType=atomic
permissions?: {
action?: string;
scope?: string;
}[];
};
// +listType=set
grants?: string[];
}
export const defaultRole = (): Role => ({
});
export interface Extensions {
// +listType=atomic
addedComponents?: {
// +listType=set
targets: string[];
title: string;
description?: string;
}[];
// +listType=atomic
addedLinks?: {
// +listType=set
targets: string[];
title: string;
description?: string;
}[];
// +listType=atomic
addedFunctions?: {
// +listType=set
targets: string[];
title: string;
description?: string;
}[];
// +listType=set
// +listMapKey=id
exposedComponents?: {
id: string;
title?: string;
description?: string;
}[];
// +listType=set
// +listMapKey=id
extensionPoints?: {
id: string;
title?: string;
description?: string;
}[];
}
export const defaultExtensions = (): Extensions => ({
});
export interface Spec {
pluginJson: JSONData;
class: "core" | "external";
module?: {
path: string;
hash?: string;
loadingStrategy?: "fetch" | "script";
};
baseURL?: string;
signature?: {
status: "internal" | "valid" | "invalid" | "modified" | "unsigned";
type?: "grafana" | "commercial" | "community" | "private" | "private-glob";
org?: string;
};
angular?: {
detected: boolean;
};
translations?: Record<string, string>;
// +listType=atomic
children?: string[];
}
export const defaultSpec = (): Spec => ({
pluginJson: defaultJSONData(),
class: "core",
});

View File

@@ -0,0 +1,30 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
export interface OperatorState {
// lastEvaluation is the ResourceVersion last evaluated
lastEvaluation: string;
// state describes the state of the lastEvaluation.
// It is limited to three possible states for machine evaluation.
state: "success" | "in_progress" | "failed";
// descriptiveState is an optional more descriptive state field which has no requirements on format
descriptiveState?: string;
// details contains any extra information that is operator-specific
details?: Record<string, any>;
}
export const defaultOperatorState = (): OperatorState => ({
lastEvaluation: "",
state: "success",
});
export interface Status {
// operatorStates is a map of operator ID to operator state evaluations.
// Any operator which consumes this kind SHOULD add its state evaluation information to this field.
operatorStates?: Record<string, OperatorState>;
// additionalFields is reserved for future use
additionalFields?: Record<string, any>;
}
export const defaultStatus = (): Status => ({
});

View File

@@ -0,0 +1,49 @@
/*
* This file was generated by grafana-app-sdk. DO NOT EDIT.
*/
import { Spec } from './types.spec.gen';
import { Status } from './types.status.gen';
export interface Metadata {
name: string;
namespace: string;
generateName?: string;
selfLink?: string;
uid?: string;
resourceVersion?: string;
generation?: number;
creationTimestamp?: string;
deletionTimestamp?: string;
deletionGracePeriodSeconds?: number;
labels?: Record<string, string>;
annotations?: Record<string, string>;
ownerReferences?: OwnerReference[];
finalizers?: string[];
managedFields?: ManagedFieldsEntry[];
}
export interface OwnerReference {
apiVersion: string;
kind: string;
name: string;
uid: string;
controller?: boolean;
blockOwnerDeletion?: boolean;
}
export interface ManagedFieldsEntry {
manager?: string;
operation?: string;
apiVersion?: string;
time?: string;
fieldsType?: string;
subresource?: string;
}
export interface Plugin {
kind: string;
apiVersion: string;
metadata: Metadata;
spec: Spec;
status: Status;
}

View File

@@ -0,0 +1,30 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
// metadata contains embedded CommonMetadata and can be extended with custom string fields
// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here
// without external reference as using the CommonMetadata reference breaks thema codegen.
export interface Metadata {
updateTimestamp: string;
createdBy: string;
uid: string;
creationTimestamp: string;
deletionTimestamp?: string;
finalizers: string[];
resourceVersion: string;
generation: number;
updatedBy: string;
labels: Record<string, string>;
}
export const defaultMetadata = (): Metadata => ({
updateTimestamp: "",
createdBy: "",
uid: "",
creationTimestamp: "",
finalizers: [],
resourceVersion: "",
generation: 0,
updatedBy: "",
labels: {},
});

View File

@@ -0,0 +1,13 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
export interface Spec {
id: string;
version: string;
url?: string;
}
export const defaultSpec = (): Spec => ({
id: "",
version: "",
});

View File

@@ -0,0 +1,30 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
export interface OperatorState {
// lastEvaluation is the ResourceVersion last evaluated
lastEvaluation: string;
// state describes the state of the lastEvaluation.
// It is limited to three possible states for machine evaluation.
state: "success" | "in_progress" | "failed";
// descriptiveState is an optional more descriptive state field which has no requirements on format
descriptiveState?: string;
// details contains any extra information that is operator-specific
details?: Record<string, any>;
}
export const defaultOperatorState = (): OperatorState => ({
lastEvaluation: "",
state: "success",
});
export interface Status {
// operatorStates is a map of operator ID to operator state evaluations.
// Any operator which consumes this kind SHOULD add its state evaluation information to this field.
operatorStates?: Record<string, OperatorState>;
// additionalFields is reserved for future use
additionalFields?: Record<string, any>;
}
export const defaultStatus = (): Status => ({
});

View File

@@ -248,7 +248,7 @@
"legend": {
"values": ["percent"],
"displayMode": "table",
"placement": "right"
"placement": "bottom"
},
"pieType": "pie",
"reduceOptions": {
@@ -256,7 +256,7 @@
"fields": "",
"values": false
},
"showLegend": true,
"showLegend": false,
"strokeWidth": 1,
"text": {}
},
@@ -272,15 +272,6 @@
"timeFrom": null,
"timeShift": null,
"title": "Percent",
"transformations": [
{
"id": "renameByRegex",
"options": {
"regex": "^Backend-(.*)$",
"renamePattern": "b-$1"
}
}
],
"type": "piechart"
},
{
@@ -320,7 +311,7 @@
"legend": {
"values": ["value"],
"displayMode": "table",
"placement": "right"
"placement": "bottom"
},
"pieType": "pie",
"reduceOptions": {
@@ -328,7 +319,7 @@
"fields": "",
"values": false
},
"showLegend": true,
"showLegend": false,
"strokeWidth": 1,
"text": {}
},
@@ -344,15 +335,6 @@
"timeFrom": null,
"timeShift": null,
"title": "Value",
"transformations": [
{
"id": "renameByRegex",
"options": {
"regex": "(.*)",
"renamePattern": "$1-how-much-wood-could-a-woodchuck-chuck-if-a-woodchuck-could-chuck-wood"
}
}
],
"type": "piechart"
},
{

View File

@@ -2030,6 +2030,44 @@ For example: `disabled_labels=grafana_folder`
<hr>
### `[unified_alerting.state_history]`
This section configures where Grafana Alerting writes alert state history. Refer to [Configure alert state history](/docs/grafana/<GRAFANA_VERSION>/alerting/set-up/configure-alert-state-history/) for end-to-end setup and examples.
#### `enabled `
Enables recording alert state history. Default is `false`.
#### `backend `
Select the backend used to store alert state history. Supported values: `loki`, `prometheus`, `multiple`.
#### `loki_remote_url `
The URL of the Loki server used when `backend = loki` (or when `backend = multiple` and Loki is a primary/secondary).
#### `prometheus_target_datasource_uid `
Target Prometheus data source UID used for writing alert state changes when `backend = prometheus` (or when `backend = multiple` and Prometheus is a secondary).
#### `prometheus_metric_name `
Optional. Metric name for the alert state metric. Default is `GRAFANA_ALERTS`.
#### `prometheus_write_timeout `
Optional. Timeout for writing alert state data to the target data source. Default is `10s`.
#### `primary `
Used only when `backend = multiple`. Selects the primary backend (for example `loki`).
#### `secondaries `
Used only when `backend = multiple`. Comma-separated list of secondary backends (for example `prometheus`).
<hr>
### `[unified_alerting.state_history.annotations]`
This section controls retention of annotations automatically created while evaluating alert rules when alerting state history backend is configured to be annotations (see setting [unified_alerting.state_history].backend)

View File

@@ -1,6 +1,7 @@
import { test, expect } from '@grafana/plugin-e2e';
import testV2DashWithRepeats from '../dashboards/V2DashWithRepeats.json';
import testV2DashWithRowRepeats from '../dashboards/V2DashWithRowRepeats.json';
import {
checkRepeatedPanelTitles,
@@ -10,11 +11,14 @@ 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: {
@@ -165,9 +169,7 @@ test.describe(
)
).toBeVisible();
await dashboardPage
.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.backToDashboardButton)
.click();
await page.keyboard.press('Escape');
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.DashboardEditPaneSplitter.primaryBody)
@@ -217,9 +219,7 @@ test.describe(
)
).toBeVisible();
await dashboardPage
.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.backToDashboardButton)
.click();
await page.keyboard.press('Escape');
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.DashboardEditPaneSplitter.primaryBody)
@@ -405,5 +405,143 @@ 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,6 +218,15 @@ 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

@@ -0,0 +1,486 @@
{
"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

@@ -1337,6 +1337,11 @@
"count": 2
}
},
"public/app/features/alerting/unified/api/onCallApi.test.ts": {
"no-restricted-syntax": {
"count": 2
}
},
"public/app/features/alerting/unified/components/AnnotationDetailsField.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
@@ -1377,6 +1382,11 @@
"count": 1
}
},
"public/app/features/alerting/unified/components/import-to-gma/ConfirmConvertModal.test.tsx": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/alerting/unified/components/import-to-gma/NamespaceAndGroupFilter.tsx": {
"no-restricted-syntax": {
"count": 2
@@ -1617,11 +1627,31 @@
"count": 1
}
},
"public/app/features/alerting/unified/mocks/server/configure.ts": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/alerting/unified/mocks/server/handlers/plugins.ts": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/alerting/unified/rule-editor/clone.utils.test.tsx": {
"no-restricted-syntax": {
"count": 2
}
},
"public/app/features/alerting/unified/rule-editor/formDefaults.ts": {
"no-restricted-syntax": {
"count": 6
}
},
"public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/alerting/unified/types/alerting.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 5
@@ -1632,6 +1662,16 @@
"count": 1
}
},
"public/app/features/alerting/unified/utils/config.test.ts": {
"no-restricted-syntax": {
"count": 6
}
},
"public/app/features/alerting/unified/utils/config.ts": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/alerting/unified/utils/datasource.ts": {
"no-restricted-syntax": {
"count": 2
@@ -1663,12 +1703,20 @@
"count": 1
}
},
"public/app/features/alerting/unified/utils/rules.test.ts": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/alerting/unified/utils/rules.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 3
},
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx": {
@@ -1724,6 +1772,16 @@
"count": 1
}
},
"public/app/features/connections/components/AdvisorRedirectNotice/AdvisorRedirectNotice.test.tsx": {
"no-restricted-syntax": {
"count": 2
}
},
"public/app/features/connections/components/AdvisorRedirectNotice/AdvisorRedirectNotice.tsx": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/connections/tabs/ConnectData/ConnectData.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
@@ -2063,6 +2121,11 @@
"count": 1
}
},
"public/app/features/dashboard/components/GenAI/utils.ts": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/dashboard/components/HelpWizard/HelpWizard.tsx": {
"no-restricted-syntax": {
"count": 3
@@ -2889,6 +2952,71 @@
"count": 1
}
},
"public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts": {
"no-restricted-syntax": {
"count": 6
}
},
"public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts": {
"no-restricted-syntax": {
"count": 6
}
},
"public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts": {
"no-restricted-syntax": {
"count": 6
}
},
"public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.test.ts": {
"no-restricted-syntax": {
"count": 6
}
},
"public/app/features/plugins/extensions/usePluginComponent.test.tsx": {
"no-restricted-syntax": {
"count": 3
}
},
"public/app/features/plugins/extensions/usePluginComponents.test.tsx": {
"no-restricted-syntax": {
"count": 2
}
},
"public/app/features/plugins/extensions/usePluginFunctions.test.tsx": {
"no-restricted-syntax": {
"count": 2
}
},
"public/app/features/plugins/extensions/usePluginLinks.test.tsx": {
"no-restricted-syntax": {
"count": 2
}
},
"public/app/features/plugins/extensions/utils.test.tsx": {
"no-restricted-syntax": {
"count": 27
}
},
"public/app/features/plugins/extensions/utils.tsx": {
"no-restricted-syntax": {
"count": 7
}
},
"public/app/features/plugins/extensions/validators.test.tsx": {
"no-restricted-syntax": {
"count": 30
}
},
"public/app/features/plugins/extensions/validators.ts": {
"no-restricted-syntax": {
"count": 4
}
},
"public/app/features/plugins/sandbox/codeLoader.ts": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/plugins/sandbox/distortions.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1

View File

@@ -117,6 +117,8 @@ module.exports = [
'scripts/grafana-server/tmp',
'packages/grafana-ui/src/graveyard', // deprecated UI components slated for removal
'public/build-swagger', // swagger build output
'apps/plugins/plugin/src/generated/meta/v0alpha1',
'apps/plugins/plugin/src/generated/plugin/v0alpha1',
],
},
...grafanaConfig,
@@ -575,6 +577,42 @@ module.exports = [
"Property[key.name='a11y'][value.type='ObjectExpression'] Property[key.name='test'][value.value='off']",
message: 'Skipping a11y tests is not allowed. Please fix the component or story instead.',
},
{
selector: 'MemberExpression[object.name="config"][property.name="apps"]',
message:
'Usage of config.apps is not allowed. Use the function getAppPluginMetas or useAppPluginMetas from @grafana/runtime instead',
},
],
},
},
{
files: [...commonTestIgnores],
ignores: [
// FIXME: Remove once all enterprise issues are fixed -
// we don't have a suppressions file/approach for enterprise code yet
...enterpriseIgnores,
],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'MemberExpression[object.name="config"][property.name="apps"]',
message:
'Usage of config.apps is not allowed. Use the function getAppPluginMetas or useAppPluginMetas from @grafana/runtime instead',
},
],
},
},
{
files: [...enterpriseIgnores],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'MemberExpression[object.name="config"][property.name="apps"]',
message:
'Usage of config.apps is not allowed. Use the function getAppPluginMetas or useAppPluginMetas from @grafana/runtime instead',
},
],
},
},

View File

@@ -32,6 +32,7 @@ export type AppPluginConfig = {
path: string;
version: string;
preload: boolean;
/** @deprecated it will be removed in a future release */
angular: AngularMeta;
loadingStrategy: PluginLoadingStrategy;
dependencies: PluginDependencies;
@@ -219,6 +220,7 @@ export interface GrafanaConfig {
snapshotEnabled: boolean;
datasources: { [str: string]: DataSourceInstanceSettings };
panels: { [key: string]: PanelPluginMeta };
/** @deprecated it will be removed in a future release */
apps: Record<string, AppPluginConfig>;
auth: AuthSettings;
minRefreshInterval: string;

View File

@@ -53,6 +53,7 @@ export interface PluginError {
pluginType?: PluginType;
}
/** @deprecated it will be removed in a future release */
export interface AngularMeta {
detected: boolean;
hideDeprecation: boolean;

View File

@@ -86,6 +86,7 @@ export class GrafanaBootConfig {
snapshotEnabled = true;
datasources: { [str: string]: DataSourceInstanceSettings } = {};
panels: { [key: string]: PanelPluginMeta } = {};
/** @deprecated it will be removed in a future release, use isAppPluginInstalled or getAppPluginVersion instead */
apps: Record<string, AppPluginConfigGrafanaData> = {};
auth: AuthSettings = {};
minRefreshInterval = '';

View File

@@ -77,3 +77,5 @@ export {
getCorrelationsService,
setCorrelationsService,
} from './services/CorrelationsService';
export { getAppPluginVersion, isAppPluginInstalled } from './services/pluginMeta/apps';
export { useAppPluginInstalled, useAppPluginVersion } from './services/pluginMeta/hooks';

View File

@@ -29,3 +29,5 @@ export {
export { UserStorage } from '../utils/userStorage';
export { initOpenFeature, evaluateBooleanFlag } from './openFeature';
export { getAppPluginMeta, getAppPluginMetas, setAppPluginMetas } from '../services/pluginMeta/apps';
export { useAppPluginMeta, useAppPluginMetas } from '../services/pluginMeta/hooks';

View File

@@ -0,0 +1,258 @@
import { evaluateBooleanFlag } from '../../internal/openFeature';
import {
getAppPluginMeta,
getAppPluginMetas,
getAppPluginVersion,
isAppPluginInstalled,
setAppPluginMetas,
} from './apps';
import { initPluginMetas } from './plugins';
import { app } from './test-fixtures/config.apps';
jest.mock('./plugins', () => ({ ...jest.requireActual('./plugins'), initPluginMetas: jest.fn() }));
jest.mock('../../internal/openFeature', () => ({
...jest.requireActual('../../internal/openFeature'),
evaluateBooleanFlag: jest.fn(),
}));
const initPluginMetasMock = jest.mocked(initPluginMetas);
const evaluateBooleanFlagMock = jest.mocked(evaluateBooleanFlag);
describe('when useMTPlugins flag is enabled and apps is not initialized', () => {
beforeEach(() => {
setAppPluginMetas({});
jest.resetAllMocks();
initPluginMetasMock.mockResolvedValue({ items: [] });
evaluateBooleanFlagMock.mockReturnValue(true);
});
it('getAppPluginMetas should call initPluginMetas and return correct result', async () => {
const apps = await getAppPluginMetas();
expect(apps).toEqual([]);
expect(initPluginMetasMock).toHaveBeenCalledTimes(1);
});
it('getAppPluginMeta should call initPluginMetas and return correct result', async () => {
const result = await getAppPluginMeta('myorg-someplugin-app');
expect(result).toEqual(null);
expect(initPluginMetasMock).toHaveBeenCalledTimes(1);
});
it('isAppPluginInstalled should call initPluginMetas and return false', async () => {
const installed = await isAppPluginInstalled('myorg-someplugin-app');
expect(installed).toEqual(false);
expect(initPluginMetasMock).toHaveBeenCalledTimes(1);
});
it('getAppPluginVersion should call initPluginMetas and return null', async () => {
const result = await getAppPluginVersion('myorg-someplugin-app');
expect(result).toEqual(null);
expect(initPluginMetasMock).toHaveBeenCalledTimes(1);
});
});
describe('when useMTPlugins flag is enabled and apps is initialized', () => {
beforeEach(() => {
setAppPluginMetas({ 'myorg-someplugin-app': app });
jest.resetAllMocks();
evaluateBooleanFlagMock.mockReturnValue(true);
});
it('getAppPluginMetas should not call initPluginMetas and return correct result', async () => {
const apps = await getAppPluginMetas();
expect(apps).toEqual([app]);
expect(initPluginMetasMock).not.toHaveBeenCalled();
});
it('getAppPluginMeta should not call initPluginMetas and return correct result', async () => {
const result = await getAppPluginMeta('myorg-someplugin-app');
expect(result).toEqual(app);
expect(initPluginMetasMock).not.toHaveBeenCalled();
});
it('getAppPluginMeta should return null if the pluginId is not found', async () => {
const result = await getAppPluginMeta('otherorg-otherplugin-app');
expect(result).toEqual(null);
});
it('isAppPluginInstalled should not call initPluginMetas and return true', async () => {
const installed = await isAppPluginInstalled('myorg-someplugin-app');
expect(installed).toEqual(true);
expect(initPluginMetasMock).not.toHaveBeenCalled();
});
it('isAppPluginInstalled should return false if the pluginId is not found', async () => {
const result = await isAppPluginInstalled('otherorg-otherplugin-app');
expect(result).toEqual(false);
});
it('getAppPluginVersion should not call initPluginMetas and return correct result', async () => {
const result = await getAppPluginVersion('myorg-someplugin-app');
expect(result).toEqual('1.0.0');
expect(initPluginMetasMock).not.toHaveBeenCalled();
});
it('getAppPluginVersion should return null if the pluginId is not found', async () => {
const result = await getAppPluginVersion('otherorg-otherplugin-app');
expect(result).toEqual(null);
});
});
describe('when useMTPlugins flag is disabled and apps is not initialized', () => {
beforeEach(() => {
setAppPluginMetas({});
jest.resetAllMocks();
evaluateBooleanFlagMock.mockReturnValue(false);
});
it('getAppPluginMetas should not call initPluginMetas and return correct result', async () => {
const apps = await getAppPluginMetas();
expect(apps).toEqual([]);
expect(initPluginMetasMock).not.toHaveBeenCalled();
});
it('getAppPluginMeta should not call initPluginMetas and return correct result', async () => {
const result = await getAppPluginMeta('myorg-someplugin-app');
expect(result).toEqual(null);
expect(initPluginMetasMock).not.toHaveBeenCalled();
});
it('isAppPluginInstalled should not call initPluginMetas and return false', async () => {
const result = await isAppPluginInstalled('myorg-someplugin-app');
expect(result).toEqual(false);
expect(initPluginMetasMock).not.toHaveBeenCalled();
});
it('getAppPluginVersion should not call initPluginMetas and return correct result', async () => {
const result = await getAppPluginVersion('myorg-someplugin-app');
expect(result).toEqual(null);
expect(initPluginMetasMock).not.toHaveBeenCalled();
});
});
describe('when useMTPlugins flag is disabled and apps is initialized', () => {
beforeEach(() => {
setAppPluginMetas({ 'myorg-someplugin-app': app });
jest.resetAllMocks();
evaluateBooleanFlagMock.mockReturnValue(false);
});
it('getAppPluginMetas should not call initPluginMetas and return correct result', async () => {
const apps = await getAppPluginMetas();
expect(apps).toEqual([app]);
expect(initPluginMetasMock).not.toHaveBeenCalled();
});
it('getAppPluginMeta should not call initPluginMetas and return correct result', async () => {
const result = await getAppPluginMeta('myorg-someplugin-app');
expect(result).toEqual(app);
expect(initPluginMetasMock).not.toHaveBeenCalled();
});
it('getAppPluginMeta should return null if the pluginId is not found', async () => {
const result = await getAppPluginMeta('otherorg-otherplugin-app');
expect(result).toEqual(null);
});
it('isAppPluginInstalled should not call initPluginMetas and return true', async () => {
const result = await isAppPluginInstalled('myorg-someplugin-app');
expect(result).toEqual(true);
expect(initPluginMetasMock).not.toHaveBeenCalled();
});
it('isAppPluginInstalled should return false if the pluginId is not found', async () => {
const result = await isAppPluginInstalled('otherorg-otherplugin-app');
expect(result).toEqual(false);
});
it('getAppPluginVersion should not call initPluginMetas and return correct result', async () => {
const result = await getAppPluginVersion('myorg-someplugin-app');
expect(result).toEqual('1.0.0');
expect(initPluginMetasMock).not.toHaveBeenCalled();
});
it('getAppPluginVersion should return null if the pluginId is not found', async () => {
const result = await getAppPluginVersion('otherorg-otherplugin-app');
expect(result).toEqual(null);
});
});
describe('immutability', () => {
beforeEach(() => {
setAppPluginMetas({ 'myorg-someplugin-app': app });
jest.resetAllMocks();
evaluateBooleanFlagMock.mockReturnValue(false);
});
it('getAppPluginMetas should return a deep clone', async () => {
const mutatedApps = await getAppPluginMetas();
// assert we have correct props
expect(mutatedApps).toHaveLength(1);
expect(mutatedApps[0].dependencies.grafanaDependency).toEqual('>=10.4.0');
expect(mutatedApps[0].extensions.addedLinks).toHaveLength(0);
// mutate deep props
mutatedApps[0].dependencies.grafanaDependency = '';
mutatedApps[0].extensions.addedLinks.push({ targets: [], title: '', description: '' });
// assert we have mutated props
expect(mutatedApps[0].dependencies.grafanaDependency).toEqual('');
expect(mutatedApps[0].extensions.addedLinks).toHaveLength(1);
expect(mutatedApps[0].extensions.addedLinks[0]).toEqual({ targets: [], title: '', description: '' });
const apps = await getAppPluginMetas();
// assert that we have not mutated the source
expect(apps[0].dependencies.grafanaDependency).toEqual('>=10.4.0');
expect(apps[0].extensions.addedLinks).toHaveLength(0);
});
it('getAppPluginMeta should return a deep clone', async () => {
const mutatedApp = await getAppPluginMeta('myorg-someplugin-app');
// assert we have correct props
expect(mutatedApp).toBeDefined();
expect(mutatedApp!.dependencies.grafanaDependency).toEqual('>=10.4.0');
expect(mutatedApp!.extensions.addedLinks).toHaveLength(0);
// mutate deep props
mutatedApp!.dependencies.grafanaDependency = '';
mutatedApp!.extensions.addedLinks.push({ targets: [], title: '', description: '' });
// assert we have mutated props
expect(mutatedApp!.dependencies.grafanaDependency).toEqual('');
expect(mutatedApp!.extensions.addedLinks).toHaveLength(1);
expect(mutatedApp!.extensions.addedLinks[0]).toEqual({ targets: [], title: '', description: '' });
const result = await getAppPluginMeta('myorg-someplugin-app');
// assert that we have not mutated the source
expect(result).toBeDefined();
expect(result!.dependencies.grafanaDependency).toEqual('>=10.4.0');
expect(result!.extensions.addedLinks).toHaveLength(0);
});
});

View File

@@ -0,0 +1,71 @@
import type { AppPluginConfig } from '@grafana/data';
import { config } from '../../config';
import { evaluateBooleanFlag } from '../../internal/openFeature';
import { getAppPluginMapper } from './mappers/mappers';
import { initPluginMetas } from './plugins';
import type { AppPluginMetas } from './types';
let apps: AppPluginMetas = {};
function initialized(): boolean {
return Boolean(Object.keys(apps).length);
}
async function initAppPluginMetas(): Promise<void> {
if (!evaluateBooleanFlag('useMTPlugins', false)) {
// eslint-disable-next-line no-restricted-syntax
apps = config.apps;
return;
}
const metas = await initPluginMetas();
const mapper = getAppPluginMapper();
apps = mapper(metas);
}
export async function getAppPluginMetas(): Promise<AppPluginConfig[]> {
if (!initialized()) {
await initAppPluginMetas();
}
return Object.values(structuredClone(apps));
}
export async function getAppPluginMeta(pluginId: string): Promise<AppPluginConfig | null> {
if (!initialized()) {
await initAppPluginMetas();
}
const app = apps[pluginId];
return app ? structuredClone(app) : null;
}
/**
* Check if an app plugin is installed. The function does not check if the app plugin is enabled.
* @param pluginId - The id of the app plugin.
* @returns True if the app plugin is installed, false otherwise.
*/
export async function isAppPluginInstalled(pluginId: string): Promise<boolean> {
const app = await getAppPluginMeta(pluginId);
return Boolean(app);
}
/**
* Get the version of an app plugin.
* @param pluginId - The id of the app plugin.
* @returns The version of the app plugin, or null if the plugin is not installed.
*/
export async function getAppPluginVersion(pluginId: string): Promise<string | null> {
const app = await getAppPluginMeta(pluginId);
return app?.version ?? null;
}
export function setAppPluginMetas(override: AppPluginMetas): void {
if (process.env.NODE_ENV !== 'test') {
throw new Error('setAppPluginMetas() function can only be called from tests.');
}
apps = structuredClone(override);
}

View File

@@ -0,0 +1,214 @@
import { renderHook, waitFor } from '@testing-library/react';
import {
getAppPluginMeta,
getAppPluginMetas,
getAppPluginVersion,
isAppPluginInstalled,
setAppPluginMetas,
} from './apps';
import { useAppPluginMeta, useAppPluginMetas, useAppPluginInstalled, useAppPluginVersion } from './hooks';
import { apps } from './test-fixtures/config.apps';
const actualApps = jest.requireActual<typeof import('./apps')>('./apps');
jest.mock('./apps', () => ({
...jest.requireActual('./apps'),
getAppPluginMetas: jest.fn(),
getAppPluginMeta: jest.fn(),
isAppPluginInstalled: jest.fn(),
getAppPluginVersion: jest.fn(),
}));
const getAppPluginMetaMock = jest.mocked(getAppPluginMeta);
const getAppPluginMetasMock = jest.mocked(getAppPluginMetas);
const isAppPluginInstalledMock = jest.mocked(isAppPluginInstalled);
const getAppPluginVersionMock = jest.mocked(getAppPluginVersion);
describe('useAppPluginMeta', () => {
beforeEach(() => {
setAppPluginMetas(apps);
jest.resetAllMocks();
getAppPluginMetaMock.mockImplementation(actualApps.getAppPluginMeta);
});
it('should return correct default values', async () => {
const { result } = renderHook(() => useAppPluginMeta('grafana-exploretraces-app'));
expect(result.current.loading).toEqual(true);
expect(result.current.error).toBeUndefined();
expect(result.current.value).toBeUndefined();
await waitFor(() => expect(result.current.loading).toEqual(true));
});
it('should return correct values after loading', async () => {
const { result } = renderHook(() => useAppPluginMeta('grafana-exploretraces-app'));
await waitFor(() => expect(result.current.loading).toEqual(false));
expect(result.current.loading).toEqual(false);
expect(result.current.error).toBeUndefined();
expect(result.current.value).toEqual(apps['grafana-exploretraces-app']);
});
it('should return correct values if the pluginId does not exist', async () => {
const { result } = renderHook(() => useAppPluginMeta('otherorg-otherplugin-app'));
await waitFor(() => expect(result.current.loading).toEqual(false));
expect(result.current.loading).toEqual(false);
expect(result.current.error).toBeUndefined();
expect(result.current.value).toEqual(null);
});
it('should return correct values if useAppPluginMeta throws', async () => {
getAppPluginMetaMock.mockRejectedValue(new Error('Some error'));
const { result } = renderHook(() => useAppPluginMeta('otherorg-otherplugin-app'));
await waitFor(() => expect(result.current.loading).toEqual(false));
expect(result.current.loading).toEqual(false);
expect(result.current.error).toEqual(new Error('Some error'));
expect(result.current.value).toBeUndefined();
});
});
describe('useAppPluginMetas', () => {
beforeEach(() => {
setAppPluginMetas(apps);
jest.resetAllMocks();
getAppPluginMetasMock.mockImplementation(actualApps.getAppPluginMetas);
});
it('should return correct default values', async () => {
const { result } = renderHook(() => useAppPluginMetas());
expect(result.current.loading).toEqual(true);
expect(result.current.error).toBeUndefined();
expect(result.current.value).toBeUndefined();
await waitFor(() => expect(result.current.loading).toEqual(true));
});
it('should return correct values after loading', async () => {
const { result } = renderHook(() => useAppPluginMetas());
await waitFor(() => expect(result.current.loading).toEqual(false));
expect(result.current.loading).toEqual(false);
expect(result.current.error).toBeUndefined();
expect(result.current.value).toEqual(Object.values(apps));
});
it('should return correct values if useAppPluginMetas throws', async () => {
getAppPluginMetasMock.mockRejectedValue(new Error('Some error'));
const { result } = renderHook(() => useAppPluginMetas());
await waitFor(() => expect(result.current.loading).toEqual(false));
expect(result.current.loading).toEqual(false);
expect(result.current.error).toEqual(new Error('Some error'));
expect(result.current.value).toBeUndefined();
});
});
describe('useAppPluginInstalled', () => {
beforeEach(() => {
setAppPluginMetas(apps);
jest.resetAllMocks();
isAppPluginInstalledMock.mockImplementation(actualApps.isAppPluginInstalled);
});
it('should return correct default values', async () => {
const { result } = renderHook(() => useAppPluginInstalled('grafana-exploretraces-app'));
expect(result.current.loading).toEqual(true);
expect(result.current.error).toBeUndefined();
expect(result.current.value).toBeUndefined();
await waitFor(() => expect(result.current.loading).toEqual(true));
});
it('should return correct values after loading', async () => {
const { result } = renderHook(() => useAppPluginInstalled('grafana-exploretraces-app'));
await waitFor(() => expect(result.current.loading).toEqual(false));
expect(result.current.loading).toEqual(false);
expect(result.current.error).toBeUndefined();
expect(result.current.value).toEqual(true);
});
it('should return correct values if the pluginId does not exist', async () => {
const { result } = renderHook(() => useAppPluginInstalled('otherorg-otherplugin-app'));
await waitFor(() => expect(result.current.loading).toEqual(false));
expect(result.current.loading).toEqual(false);
expect(result.current.error).toBeUndefined();
expect(result.current.value).toEqual(false);
});
it('should return correct values if isAppPluginInstalled throws', async () => {
isAppPluginInstalledMock.mockRejectedValue(new Error('Some error'));
const { result } = renderHook(() => useAppPluginInstalled('otherorg-otherplugin-app'));
await waitFor(() => expect(result.current.loading).toEqual(false));
expect(result.current.loading).toEqual(false);
expect(result.current.error).toEqual(new Error('Some error'));
expect(result.current.value).toBeUndefined();
});
});
describe('useAppPluginVersion', () => {
beforeEach(() => {
setAppPluginMetas(apps);
jest.resetAllMocks();
getAppPluginVersionMock.mockImplementation(actualApps.getAppPluginVersion);
});
it('should return correct default values', async () => {
const { result } = renderHook(() => useAppPluginVersion('grafana-exploretraces-app'));
expect(result.current.loading).toEqual(true);
expect(result.current.error).toBeUndefined();
expect(result.current.value).toBeUndefined();
await waitFor(() => expect(result.current.loading).toEqual(true));
});
it('should return correct values after loading', async () => {
const { result } = renderHook(() => useAppPluginVersion('grafana-exploretraces-app'));
await waitFor(() => expect(result.current.loading).toEqual(false));
expect(result.current.loading).toEqual(false);
expect(result.current.error).toBeUndefined();
expect(result.current.value).toEqual('1.2.2');
});
it('should return correct values if the pluginId does not exist', async () => {
const { result } = renderHook(() => useAppPluginVersion('otherorg-otherplugin-app'));
await waitFor(() => expect(result.current.loading).toEqual(false));
expect(result.current.loading).toEqual(false);
expect(result.current.error).toBeUndefined();
expect(result.current.value).toEqual(null);
});
it('should return correct values if getAppPluginVersion throws', async () => {
getAppPluginVersionMock.mockRejectedValue(new Error('Some error'));
const { result } = renderHook(() => useAppPluginVersion('otherorg-otherplugin-app'));
await waitFor(() => expect(result.current.loading).toEqual(false));
expect(result.current.loading).toEqual(false);
expect(result.current.error).toEqual(new Error('Some error'));
expect(result.current.value).toBeUndefined();
});
});

View File

@@ -0,0 +1,35 @@
import { useAsync } from 'react-use';
import { getAppPluginMeta, getAppPluginMetas, getAppPluginVersion, isAppPluginInstalled } from './apps';
export function useAppPluginMetas() {
const { loading, error, value } = useAsync(async () => getAppPluginMetas());
return { loading, error, value };
}
export function useAppPluginMeta(pluginId: string) {
const { loading, error, value } = useAsync(async () => getAppPluginMeta(pluginId));
return { loading, error, value };
}
/**
* Hook that checks if an app plugin is installed. The hook does not check if the app plugin is enabled.
* @param pluginId - The ID of the app plugin.
* @returns loading, error, value of the app plugin installed status.
* The value is true if the app plugin is installed, false otherwise.
*/
export function useAppPluginInstalled(pluginId: string) {
const { loading, error, value } = useAsync(async () => isAppPluginInstalled(pluginId));
return { loading, error, value };
}
/**
* Hook that gets the version of an app plugin.
* @param pluginId - The ID of the app plugin.
* @returns loading, error, value of the app plugin version.
* The value is the version of the app plugin, or null if the plugin is not installed.
*/
export function useAppPluginVersion(pluginId: string) {
const { loading, error, value } = useAsync(async () => getAppPluginVersion(pluginId));
return { loading, error, value };
}

View File

@@ -0,0 +1,7 @@
import { AppPluginMetasMapper, PluginMetasResponse } from '../types';
import { v0alpha1AppMapper } from './v0alpha1AppMapper';
export function getAppPluginMapper(): AppPluginMetasMapper<PluginMetasResponse> {
return v0alpha1AppMapper;
}

View File

@@ -0,0 +1,84 @@
import { apps } from '../test-fixtures/config.apps';
import { v0alpha1Response } from '../test-fixtures/v0alpha1Response';
import { v0alpha1AppMapper } from './v0alpha1AppMapper';
const PLUGIN_IDS = v0alpha1Response.items
.filter((i) => i.spec.pluginJson.type === 'app')
.map((i) => ({ pluginId: i.spec.pluginJson.id }));
describe('v0alpha1AppMapper', () => {
describe.each(PLUGIN_IDS)('when called for pluginId:$pluginId', ({ pluginId }) => {
it('should map id property correctly', () => {
const result = v0alpha1AppMapper(v0alpha1Response);
expect(result[pluginId].id).toEqual(apps[pluginId].id);
});
it('should map path property correctly', () => {
const result = v0alpha1AppMapper(v0alpha1Response);
expect(result[pluginId].path).toEqual(apps[pluginId].path);
});
it('should map version property correctly', () => {
const result = v0alpha1AppMapper(v0alpha1Response);
expect(result[pluginId].version).toEqual(apps[pluginId].version);
});
it('should map preload property correctly', () => {
const result = v0alpha1AppMapper(v0alpha1Response);
expect(result[pluginId].preload).toEqual(apps[pluginId].preload);
});
it('should map angular property correctly', () => {
const result = v0alpha1AppMapper(v0alpha1Response);
expect(result[pluginId].angular).toEqual({});
});
it('should map loadingStrategy property correctly', () => {
const result = v0alpha1AppMapper(v0alpha1Response);
expect(result[pluginId].loadingStrategy).toEqual(apps[pluginId].loadingStrategy);
});
it('should map dependencies property correctly', () => {
const result = v0alpha1AppMapper(v0alpha1Response);
expect(result[pluginId].dependencies).toEqual(apps[pluginId].dependencies);
});
it('should map extensions property correctly', () => {
const result = v0alpha1AppMapper(v0alpha1Response);
expect(result[pluginId].extensions.addedComponents).toEqual(apps[pluginId].extensions.addedComponents);
expect(result[pluginId].extensions.addedFunctions).toEqual(apps[pluginId].extensions.addedFunctions);
expect(result[pluginId].extensions.addedLinks).toEqual(apps[pluginId].extensions.addedLinks);
expect(result[pluginId].extensions.exposedComponents).toEqual(apps[pluginId].extensions.exposedComponents);
expect(result[pluginId].extensions.extensionPoints).toEqual(apps[pluginId].extensions.extensionPoints);
});
it('should map moduleHash property correctly', () => {
const result = v0alpha1AppMapper(v0alpha1Response);
expect(result[pluginId].moduleHash).toEqual(apps[pluginId].moduleHash);
});
it('should map buildMode property correctly', () => {
const result = v0alpha1AppMapper(v0alpha1Response);
expect(result[pluginId].buildMode).toEqual(apps[pluginId].buildMode);
});
});
it('should only map specs with type app', () => {
const result = v0alpha1AppMapper(v0alpha1Response);
expect(v0alpha1Response.items).toHaveLength(58);
expect(Object.keys(result)).toHaveLength(5);
expect(Object.keys(result)).toEqual(Object.keys(apps));
});
});

View File

@@ -0,0 +1,111 @@
import {
type AngularMeta,
type AppPluginConfig,
type PluginDependencies,
type PluginExtensions,
PluginLoadingStrategy,
type PluginType,
} from '@grafana/data';
import type { AppPluginMetas, AppPluginMetasMapper, PluginMetasResponse } from '../types';
import type { Spec as v0alpha1Spec } from '../types/types.spec.gen';
function angularyMapper(spec: v0alpha1Spec): AngularMeta {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return {} as AngularMeta;
}
function dependenciesMapper(spec: v0alpha1Spec): PluginDependencies {
const plugins = (spec.pluginJson.dependencies?.plugins ?? []).map((v) => ({
...v,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
type: v.type as PluginType,
version: '',
}));
const dependencies: PluginDependencies = {
...spec.pluginJson.dependencies,
extensions: {
exposedComponents: spec.pluginJson.dependencies.extensions?.exposedComponents ?? [],
},
grafanaDependency: spec.pluginJson.dependencies.grafanaDependency,
grafanaVersion: spec.pluginJson.dependencies.grafanaVersion ?? '',
plugins,
};
return dependencies;
}
function extensionsMapper(spec: v0alpha1Spec): PluginExtensions {
const addedComponents = spec.pluginJson.extensions?.addedComponents ?? [];
const addedFunctions = spec.pluginJson.extensions?.addedFunctions ?? [];
const addedLinks = spec.pluginJson.extensions?.addedLinks ?? [];
const exposedComponents = (spec.pluginJson.extensions?.exposedComponents ?? []).map((v) => ({
...v,
description: v.description ?? '',
title: v.title ?? '',
}));
const extensionPoints = (spec.pluginJson.extensions?.extensionPoints ?? []).map((v) => ({
...v,
description: v.description ?? '',
title: v.title ?? '',
}));
const extensions: PluginExtensions = {
addedComponents,
addedFunctions,
addedLinks,
exposedComponents,
extensionPoints,
};
return extensions;
}
function loadingStrategyMapper(spec: v0alpha1Spec): PluginLoadingStrategy {
const loadingStrategy = spec.module?.loadingStrategy ?? PluginLoadingStrategy.fetch;
if (loadingStrategy === PluginLoadingStrategy.script) {
return PluginLoadingStrategy.script;
}
return PluginLoadingStrategy.fetch;
}
function specMapper(spec: v0alpha1Spec): AppPluginConfig {
const { id, info, preload = false } = spec.pluginJson;
const angular = angularyMapper(spec);
const dependencies = dependenciesMapper(spec);
const extensions = extensionsMapper(spec);
const loadingStrategy = loadingStrategyMapper(spec);
const path = spec.module?.path ?? '';
const version = info.version;
const buildMode = spec.pluginJson.buildMode ?? 'production';
const moduleHash = spec.module?.hash;
return {
id,
angular,
dependencies,
extensions,
loadingStrategy,
path,
preload,
version,
buildMode,
moduleHash,
};
}
export const v0alpha1AppMapper: AppPluginMetasMapper<PluginMetasResponse> = (response) => {
const result: AppPluginMetas = {};
return response.items.reduce((acc, curr) => {
if (curr.spec.pluginJson.type !== 'app') {
return acc;
}
const config = specMapper(curr.spec);
acc[config.id] = config;
return acc;
}, result);
};

View File

@@ -0,0 +1,153 @@
import { evaluateBooleanFlag } from '../../internal/openFeature';
import { clearCache, initPluginMetas } from './plugins';
import { v0alpha1Meta } from './test-fixtures/v0alpha1Response';
jest.mock('../../internal/openFeature', () => ({
...jest.requireActual('../../internal/openFeature'),
evaluateBooleanFlag: jest.fn(),
}));
const evaluateBooleanFlagMock = jest.mocked(evaluateBooleanFlag);
describe('when useMTPlugins toggle is enabled and cache is not initialized', () => {
const originalFetch = global.fetch;
beforeEach(() => {
jest.resetAllMocks();
clearCache();
evaluateBooleanFlagMock.mockReturnValue(true);
});
afterEach(() => {
global.fetch = originalFetch;
});
it('initPluginMetas should call loadPluginMetas and return correct result if response is ok', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ items: [v0alpha1Meta] }),
});
const response = await initPluginMetas();
expect(response.items).toHaveLength(1);
expect(response.items[0]).toEqual(v0alpha1Meta);
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenCalledWith('/apis/plugins.grafana.app/v0alpha1/namespaces/default/metas');
});
it('initPluginMetas should call loadPluginMetas and return correct result if response is not ok', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not found',
});
await expect(initPluginMetas()).rejects.toThrow(new Error(`Failed to load plugin metas 404:Not found`));
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenCalledWith('/apis/plugins.grafana.app/v0alpha1/namespaces/default/metas');
});
});
describe('when useMTPlugins toggle is enabled and cache is initialized', () => {
const originalFetch = global.fetch;
beforeEach(() => {
jest.resetAllMocks();
clearCache();
evaluateBooleanFlagMock.mockReturnValue(true);
});
afterEach(() => {
global.fetch = originalFetch;
});
it('initPluginMetas should return cache', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ items: [v0alpha1Meta] }),
});
const original = await initPluginMetas();
const cached = await initPluginMetas();
expect(original).toEqual(cached);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it('initPluginMetas should return inflight promise', async () => {
jest.useFakeTimers();
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ items: [v0alpha1Meta] }),
});
const original = initPluginMetas();
const cached = initPluginMetas();
await jest.runAllTimersAsync();
expect(original).toEqual(cached);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});
describe('when useMTPlugins toggle is disabled and cache is not initialized', () => {
const originalFetch = global.fetch;
beforeEach(() => {
jest.resetAllMocks();
clearCache();
global.fetch = jest.fn();
evaluateBooleanFlagMock.mockReturnValue(false);
});
afterEach(() => {
global.fetch = originalFetch;
});
it('initPluginMetas should call loadPluginMetas and return correct result if response is ok', async () => {
const response = await initPluginMetas();
expect(response.items).toHaveLength(0);
expect(global.fetch).not.toHaveBeenCalled();
});
});
describe('when useMTPlugins toggle is disabled and cache is initialized', () => {
const originalFetch = global.fetch;
beforeEach(() => {
jest.resetAllMocks();
clearCache();
global.fetch = jest.fn();
evaluateBooleanFlagMock.mockReturnValue(false);
});
afterEach(() => {
global.fetch = originalFetch;
});
it('initPluginMetas should return cache', async () => {
const original = await initPluginMetas();
const cached = await initPluginMetas();
expect(original).toEqual(cached);
expect(global.fetch).not.toHaveBeenCalled();
});
it('initPluginMetas should return inflight promise', async () => {
jest.useFakeTimers();
const original = initPluginMetas();
const cached = initPluginMetas();
await jest.runAllTimersAsync();
expect(original).toEqual(cached);
expect(global.fetch).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,41 @@
import { config } from '../../config';
import { evaluateBooleanFlag } from '../../internal/openFeature';
import type { PluginMetasResponse } from './types';
let initPromise: Promise<PluginMetasResponse> | null = null;
function getApiVersion(): string {
return 'v0alpha1';
}
async function loadPluginMetas(): Promise<PluginMetasResponse> {
if (!evaluateBooleanFlag('useMTPlugins', false)) {
const result = { items: [] };
return result;
}
const metas = await fetch(`/apis/plugins.grafana.app/${getApiVersion()}/namespaces/${config.namespace}/metas`);
if (!metas.ok) {
throw new Error(`Failed to load plugin metas ${metas.status}:${metas.statusText}`);
}
const result = await metas.json();
return result;
}
export function initPluginMetas(): Promise<PluginMetasResponse> {
if (!initPromise) {
initPromise = loadPluginMetas();
}
return initPromise;
}
export function clearCache() {
if (process.env.NODE_ENV !== 'test') {
throw new Error('clearCache() function can only be called from tests.');
}
initPromise = null;
}

View File

@@ -0,0 +1,303 @@
import { cloneDeep } from 'lodash';
import { AngularMeta, AppPluginConfig, PluginLoadingStrategy } from '@grafana/data';
import { AppPluginMetas } from '../types';
export const app: AppPluginConfig = cloneDeep({
id: 'myorg-someplugin-app',
path: 'public/plugins/myorg-someplugin-app/module.js',
version: '1.0.0',
preload: false,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
angular: { detected: false } as AngularMeta,
loadingStrategy: PluginLoadingStrategy.script,
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
addedFunctions: [],
},
dependencies: {
grafanaDependency: '>=10.4.0',
grafanaVersion: '*',
plugins: [],
extensions: {
exposedComponents: [],
},
},
buildMode: 'production',
});
export const apps: AppPluginMetas = cloneDeep({
'grafana-exploretraces-app': {
id: 'grafana-exploretraces-app',
path: 'public/plugins/grafana-exploretraces-app/module.js',
version: '1.2.2',
preload: true,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
angular: { detected: false } as AngularMeta,
loadingStrategy: PluginLoadingStrategy.script,
extensions: {
addedLinks: [
{
targets: ['grafana/dashboard/panel/menu'],
title: 'Open in Traces Drilldown',
description: 'Open current query in the Traces Drilldown app',
},
{
targets: ['grafana/explore/toolbar/action'],
title: 'Open in Grafana Traces Drilldown',
description: 'Try our new queryless experience for traces',
},
],
addedComponents: [
{
targets: ['grafana-asserts-app/entity-assertions-widget/v1'],
title: 'Asserts widget',
description: 'A block with assertions for a given service',
},
{
targets: ['grafana-asserts-app/insights-timeline-widget/v1'],
title: 'Insights Timeline Widget',
description: 'Widget for displaying insights timeline in other apps',
},
],
exposedComponents: [
{
id: 'grafana-exploretraces-app/open-in-explore-traces-button/v1',
title: 'Open in Traces Drilldown button',
description: 'A button that opens a traces view in the Traces Drilldown app.',
},
{
id: 'grafana-exploretraces-app/embedded-trace-exploration/v1',
title: 'Embedded Trace Exploration',
description:
'A component that renders a trace exploration view that can be embedded in other parts of Grafana.',
},
],
extensionPoints: [
{
id: 'grafana-exploretraces-app/investigation/v1',
title: '',
description: '',
},
{
id: 'grafana-exploretraces-app/get-logs-drilldown-link/v1',
title: '',
description: '',
},
],
addedFunctions: [],
},
dependencies: {
grafanaDependency: '>=11.5.0',
grafanaVersion: '*',
plugins: [],
extensions: {
exposedComponents: [
'grafana-asserts-app/entity-assertions-widget/v1',
'grafana-asserts-app/insights-timeline-widget/v1',
],
},
},
buildMode: 'production',
},
'grafana-lokiexplore-app': {
id: 'grafana-lokiexplore-app',
path: 'public/plugins/grafana-lokiexplore-app/module.js',
version: '1.0.32',
preload: true,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
angular: { detected: false } as AngularMeta,
loadingStrategy: PluginLoadingStrategy.script,
extensions: {
addedLinks: [
{
targets: [
'grafana/dashboard/panel/menu',
'grafana/explore/toolbar/action',
'grafana-metricsdrilldown-app/open-in-logs-drilldown/v1',
'grafana-assistant-app/navigateToDrilldown/v1',
],
title: 'Open in Grafana Logs Drilldown',
description: 'Open current query in the Grafana Logs Drilldown view',
},
],
addedComponents: [
{
targets: ['grafana-asserts-app/insights-timeline-widget/v1'],
title: 'Insights Timeline Widget',
description: 'Widget for displaying insights timeline in other apps',
},
],
exposedComponents: [
{
id: 'grafana-lokiexplore-app/open-in-explore-logs-button/v1',
title: 'Open in Logs Drilldown button',
description: 'A button that opens a logs view in the Logs Drilldown app.',
},
{
id: 'grafana-lokiexplore-app/embedded-logs-exploration/v1',
title: 'Embedded Logs Exploration',
description:
'A component that renders a logs exploration view that can be embedded in other parts of Grafana.',
},
],
extensionPoints: [
{
id: 'grafana-lokiexplore-app/investigation/v1',
title: '',
description: '',
},
],
addedFunctions: [
{
targets: ['grafana-exploretraces-app/get-logs-drilldown-link/v1'],
title: 'Open Logs Drilldown',
description: 'Returns url to logs drilldown app',
},
],
},
dependencies: {
grafanaDependency: '>=11.6.0',
grafanaVersion: '*',
plugins: [],
extensions: {
exposedComponents: [
'grafana-adaptivelogs-app/temporary-exemptions/v1',
'grafana-lokiexplore-app/embedded-logs-exploration/v1',
'grafana-asserts-app/insights-timeline-widget/v1',
'grafana/add-to-dashboard-form/v1',
],
},
},
buildMode: 'production',
},
'grafana-metricsdrilldown-app': {
id: 'grafana-metricsdrilldown-app',
path: 'public/plugins/grafana-metricsdrilldown-app/module.js',
version: '1.0.26',
preload: true,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
angular: { detected: false } as AngularMeta,
loadingStrategy: PluginLoadingStrategy.script,
extensions: {
addedLinks: [
{
targets: [
'grafana/dashboard/panel/menu',
'grafana/explore/toolbar/action',
'grafana-assistant-app/navigateToDrilldown/v1',
'grafana/alerting/alertingrule/queryeditor',
],
title: 'Open in Grafana Metrics Drilldown',
description: 'Open current query in the Grafana Metrics Drilldown view',
},
{
targets: ['grafana-metricsdrilldown-app/grafana-assistant-app/navigateToDrilldown/v0-alpha'],
title: 'Navigate to metrics drilldown',
description: 'Build a url path to the metrics drilldown',
},
{
targets: ['grafana/datasources/config/actions', 'grafana/datasources/config/status'],
title: 'Open in Metrics Drilldown',
description: 'Browse metrics in Grafana Metrics Drilldown',
},
],
addedComponents: [],
exposedComponents: [
{
id: 'grafana-metricsdrilldown-app/label-breakdown-component/v1',
title: 'Label Breakdown',
description: 'A metrics label breakdown view from the Metrics Drilldown app.',
},
{
id: 'grafana-metricsdrilldown-app/knowledge-graph-insight-metrics/v1',
title: 'Knowledge Graph Source Metrics',
description: 'Explore the underlying metrics related to a Knowledge Graph insight',
},
],
extensionPoints: [
{
id: 'grafana-exploremetrics-app/investigation/v1',
title: '',
description: '',
},
{
id: 'grafana-metricsdrilldown-app/open-in-logs-drilldown/v1',
title: '',
description: '',
},
],
addedFunctions: [],
},
dependencies: {
grafanaDependency: '>=11.6.0',
grafanaVersion: '*',
plugins: [],
extensions: {
exposedComponents: ['grafana/add-to-dashboard-form/v1'],
},
},
buildMode: 'production',
},
'grafana-pyroscope-app': {
id: 'grafana-pyroscope-app',
path: 'public/plugins/grafana-pyroscope-app/module.js',
version: '1.14.2',
preload: true,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
angular: { detected: false } as AngularMeta,
loadingStrategy: PluginLoadingStrategy.script,
extensions: {
addedLinks: [
{
targets: [
'grafana/explore/toolbar/action',
'grafana/traceview/details',
'grafana-assistant-app/navigateToDrilldown/v1',
],
title: 'Open in Grafana Profiles Drilldown',
description: 'Try our new queryless experience for profiles',
},
],
addedComponents: [],
exposedComponents: [
{
id: 'grafana-pyroscope-app/embedded-profiles-exploration/v1',
title: 'Embedded Profiles Exploration',
description:
'A component that renders a profiles exploration view that can be embedded in other parts of Grafana.',
},
],
extensionPoints: [
{
id: 'grafana-pyroscope-app/investigation/v1',
title: '',
description: '',
},
{
id: 'grafana-pyroscope-app/settings/v1',
title: '',
description: '',
},
],
addedFunctions: [],
},
dependencies: {
grafanaDependency: '>=11.5.0',
grafanaVersion: '*',
plugins: [],
extensions: {
exposedComponents: [
'grafana-o11yinsights-app/insights-launcher/v1',
'grafana-adaptiveprofiles-app/resolution-boost/v1',
],
},
},
buildMode: 'production',
},
[app.id]: app,
});

View File

@@ -0,0 +1,10 @@
import type { AppPluginConfig } from '@grafana/data';
import type { Meta } from './types/meta_object_gen';
export type AppPluginMetas = Record<string, AppPluginConfig>;
export type AppPluginMetasMapper<T> = (response: T) => AppPluginMetas;
export interface PluginMetasResponse {
items: Meta[];
}

View File

@@ -0,0 +1,49 @@
/*
* This file was generated by grafana-app-sdk. DO NOT EDIT.
*/
import { Spec } from './types.spec.gen';
import { Status } from './types.status.gen';
export interface Metadata {
name: string;
namespace: string;
generateName?: string;
selfLink?: string;
uid?: string;
resourceVersion?: string;
generation?: number;
creationTimestamp?: string;
deletionTimestamp?: string;
deletionGracePeriodSeconds?: number;
labels?: Record<string, string>;
annotations?: Record<string, string>;
ownerReferences?: OwnerReference[];
finalizers?: string[];
managedFields?: ManagedFieldsEntry[];
}
export interface OwnerReference {
apiVersion: string;
kind: string;
name: string;
uid: string;
controller?: boolean;
blockOwnerDeletion?: boolean;
}
export interface ManagedFieldsEntry {
manager?: string;
operation?: string;
apiVersion?: string;
time?: string;
fieldsType?: string;
subresource?: string;
}
export interface Meta {
kind: string;
apiVersion: string;
metadata: Metadata;
spec: Spec;
status: Status;
}

View File

@@ -0,0 +1,278 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
// JSON configuration schema for Grafana plugins
// Converted from: https://github.com/grafana/grafana/blob/main/docs/sources/developers/plugins/plugin.schema.json
export interface JSONData {
// Unique name of the plugin
id: string;
// Plugin type
type: "app" | "datasource" | "panel" | "renderer";
// Human-readable name of the plugin
name: string;
// Metadata for the plugin
info: Info;
// Dependency information
dependencies: Dependencies;
// Optional fields
alerting?: boolean;
annotations?: boolean;
autoEnabled?: boolean;
backend?: boolean;
buildMode?: string;
builtIn?: boolean;
category?: "tsdb" | "logging" | "cloud" | "tracing" | "profiling" | "sql" | "enterprise" | "iot" | "other";
enterpriseFeatures?: EnterpriseFeatures;
executable?: string;
hideFromList?: boolean;
// +listType=atomic
includes?: Include[];
logs?: boolean;
metrics?: boolean;
multiValueFilterOperators?: boolean;
pascalName?: string;
preload?: boolean;
queryOptions?: QueryOptions;
// +listType=atomic
routes?: Route[];
skipDataQuery?: boolean;
state?: "alpha" | "beta";
streaming?: boolean;
suggestions?: boolean;
tracing?: boolean;
iam?: IAM;
// +listType=atomic
roles?: Role[];
extensions?: Extensions;
}
export const defaultJSONData = (): JSONData => ({
id: "",
type: "app",
name: "",
info: defaultInfo(),
dependencies: defaultDependencies(),
});
export interface Info {
// Required fields
// +listType=set
keywords: string[];
logos: {
small: string;
large: string;
};
updated: string;
version: string;
// Optional fields
author?: {
name?: string;
email?: string;
url?: string;
};
description?: string;
// +listType=atomic
links?: {
name?: string;
url?: string;
}[];
// +listType=atomic
screenshots?: {
name?: string;
path?: string;
}[];
}
export const defaultInfo = (): Info => ({
keywords: [],
logos: {
small: "",
large: "",
},
updated: "",
version: "",
});
export interface Dependencies {
// Required field
grafanaDependency: string;
// Optional fields
grafanaVersion?: string;
// +listType=set
// +listMapKey=id
plugins?: {
id: string;
type: "app" | "datasource" | "panel";
name: string;
}[];
extensions?: {
// +listType=set
exposedComponents?: string[];
};
}
export const defaultDependencies = (): Dependencies => ({
grafanaDependency: "",
});
export interface EnterpriseFeatures {
// Allow additional properties
healthDiagnosticsErrors?: boolean;
}
export const defaultEnterpriseFeatures = (): EnterpriseFeatures => ({
healthDiagnosticsErrors: false,
});
export interface Include {
uid?: string;
type?: "dashboard" | "page" | "panel" | "datasource";
name?: string;
component?: string;
role?: "Admin" | "Editor" | "Viewer" | "None";
action?: string;
path?: string;
addToNav?: boolean;
defaultNav?: boolean;
icon?: string;
}
export const defaultInclude = (): Include => ({
});
export interface QueryOptions {
maxDataPoints?: boolean;
minInterval?: boolean;
cacheTimeout?: boolean;
}
export const defaultQueryOptions = (): QueryOptions => ({
});
export interface Route {
path?: string;
method?: string;
url?: string;
reqSignedIn?: boolean;
reqRole?: string;
reqAction?: string;
// +listType=atomic
headers?: string[];
body?: Record<string, any>;
tokenAuth?: {
url?: string;
// +listType=set
scopes?: string[];
params?: Record<string, any>;
};
jwtTokenAuth?: {
url?: string;
// +listType=set
scopes?: string[];
params?: Record<string, any>;
};
// +listType=atomic
urlParams?: {
name?: string;
content?: string;
}[];
}
export const defaultRoute = (): Route => ({
});
export interface IAM {
// +listType=atomic
permissions?: {
action?: string;
scope?: string;
}[];
}
export const defaultIAM = (): IAM => ({
});
export interface Role {
role?: {
name?: string;
description?: string;
// +listType=atomic
permissions?: {
action?: string;
scope?: string;
}[];
};
// +listType=set
grants?: string[];
}
export const defaultRole = (): Role => ({
});
export interface Extensions {
// +listType=atomic
addedComponents?: {
// +listType=set
targets: string[];
title: string;
description?: string;
}[];
// +listType=atomic
addedLinks?: {
// +listType=set
targets: string[];
title: string;
description?: string;
}[];
// +listType=atomic
addedFunctions?: {
// +listType=set
targets: string[];
title: string;
description?: string;
}[];
// +listType=set
// +listMapKey=id
exposedComponents?: {
id: string;
title?: string;
description?: string;
}[];
// +listType=set
// +listMapKey=id
extensionPoints?: {
id: string;
title?: string;
description?: string;
}[];
}
export const defaultExtensions = (): Extensions => ({
});
export interface Spec {
pluginJson: JSONData;
class: "core" | "external";
module?: {
path: string;
hash?: string;
loadingStrategy?: "fetch" | "script";
};
baseURL?: string;
signature?: {
status: "internal" | "valid" | "invalid" | "modified" | "unsigned";
type?: "grafana" | "commercial" | "community" | "private" | "private-glob";
org?: string;
};
angular?: {
detected: boolean;
};
translations?: Record<string, string>;
// +listType=atomic
children?: string[];
}
export const defaultSpec = (): Spec => ({
pluginJson: defaultJSONData(),
class: "core",
});

View File

@@ -0,0 +1,30 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
export interface OperatorState {
// lastEvaluation is the ResourceVersion last evaluated
lastEvaluation: string;
// state describes the state of the lastEvaluation.
// It is limited to three possible states for machine evaluation.
state: "success" | "in_progress" | "failed";
// descriptiveState is an optional more descriptive state field which has no requirements on format
descriptiveState?: string;
// details contains any extra information that is operator-specific
details?: Record<string, any>;
}
export const defaultOperatorState = (): OperatorState => ({
lastEvaluation: "",
state: "success",
});
export interface Status {
// operatorStates is a map of operator ID to operator state evaluations.
// Any operator which consumes this kind SHOULD add its state evaluation information to this field.
operatorStates?: Record<string, OperatorState>;
// additionalFields is reserved for future use
additionalFields?: Record<string, any>;
}
export const defaultStatus = (): Status => ({
});

View File

@@ -1,78 +0,0 @@
import { render, screen } from '@testing-library/react';
import { VizLegendTable } from './VizLegendTable';
import { VizLegendItem } from './types';
describe('VizLegendTable', () => {
const mockItems: VizLegendItem[] = [
{ label: 'Series 1', color: 'red', yAxis: 1 },
{ label: 'Series 2', color: 'blue', yAxis: 1 },
{ label: 'Series 3', color: 'green', yAxis: 1 },
];
it('renders without crashing', () => {
const { container } = render(<VizLegendTable items={mockItems} placement="bottom" />);
expect(container.querySelector('table')).toBeInTheDocument();
});
it('renders all items', () => {
render(<VizLegendTable items={mockItems} placement="bottom" />);
expect(screen.getByText('Series 1')).toBeInTheDocument();
expect(screen.getByText('Series 2')).toBeInTheDocument();
expect(screen.getByText('Series 3')).toBeInTheDocument();
});
it('renders table headers when items have display values', () => {
const itemsWithStats: VizLegendItem[] = [
{
label: 'Series 1',
color: 'red',
yAxis: 1,
getDisplayValues: () => [
{ numeric: 100, text: '100', title: 'Max' },
{ numeric: 50, text: '50', title: 'Min' },
],
},
];
render(<VizLegendTable items={itemsWithStats} placement="bottom" />);
expect(screen.getByText('Max')).toBeInTheDocument();
expect(screen.getByText('Min')).toBeInTheDocument();
});
it('renders sort icon when sorted', () => {
const { container } = render(
<VizLegendTable items={mockItems} placement="bottom" sortBy="Name" sortDesc={false} />
);
expect(container.querySelector('svg')).toBeInTheDocument();
});
it('calls onToggleSort when header is clicked', () => {
const onToggleSort = jest.fn();
render(<VizLegendTable items={mockItems} placement="bottom" onToggleSort={onToggleSort} isSortable={true} />);
const header = screen.getByText('Name');
header.click();
expect(onToggleSort).toHaveBeenCalledWith('Name');
});
it('does not call onToggleSort when not sortable', () => {
const onToggleSort = jest.fn();
render(<VizLegendTable items={mockItems} placement="bottom" onToggleSort={onToggleSort} isSortable={false} />);
const header = screen.getByText('Name');
header.click();
expect(onToggleSort).not.toHaveBeenCalled();
});
it('renders with long labels', () => {
const itemsWithLongLabels: VizLegendItem[] = [
{
label: 'This is a very long series name that should be scrollable within its table cell',
color: 'red',
yAxis: 1,
},
];
render(<VizLegendTable items={itemsWithLongLabels} placement="bottom" />);
expect(
screen.getByText('This is a very long series name that should be scrollable within its table cell')
).toBeInTheDocument();
});
});

View File

@@ -1,112 +0,0 @@
import { render, screen } from '@testing-library/react';
import { LegendTableItem } from './VizLegendTableItem';
import { VizLegendItem } from './types';
describe('LegendTableItem', () => {
const mockItem: VizLegendItem = {
label: 'Series 1',
color: 'red',
yAxis: 1,
};
it('renders without crashing', () => {
const { container } = render(
<table>
<tbody>
<LegendTableItem item={mockItem} />
</tbody>
</table>
);
expect(container.querySelector('tr')).toBeInTheDocument();
});
it('renders label text', () => {
render(
<table>
<tbody>
<LegendTableItem item={mockItem} />
</tbody>
</table>
);
expect(screen.getByText('Series 1')).toBeInTheDocument();
});
it('renders with long label text', () => {
const longLabelItem: VizLegendItem = {
...mockItem,
label: 'This is a very long series name that should be scrollable in the table cell',
};
render(
<table>
<tbody>
<LegendTableItem item={longLabelItem} />
</tbody>
</table>
);
expect(
screen.getByText('This is a very long series name that should be scrollable in the table cell')
).toBeInTheDocument();
});
it('renders stat values when provided', () => {
const itemWithStats: VizLegendItem = {
...mockItem,
getDisplayValues: () => [
{ numeric: 100, text: '100', title: 'Max' },
{ numeric: 50, text: '50', title: 'Min' },
],
};
render(
<table>
<tbody>
<LegendTableItem item={itemWithStats} />
</tbody>
</table>
);
expect(screen.getByText('100')).toBeInTheDocument();
expect(screen.getByText('50')).toBeInTheDocument();
});
it('renders right y-axis indicator when yAxis is 2', () => {
const rightAxisItem: VizLegendItem = {
...mockItem,
yAxis: 2,
};
render(
<table>
<tbody>
<LegendTableItem item={rightAxisItem} />
</tbody>
</table>
);
expect(screen.getByText('(right y-axis)')).toBeInTheDocument();
});
it('calls onLabelClick when label is clicked', () => {
const onLabelClick = jest.fn();
render(
<table>
<tbody>
<LegendTableItem item={mockItem} onLabelClick={onLabelClick} />
</tbody>
</table>
);
const button = screen.getByRole('button');
button.click();
expect(onLabelClick).toHaveBeenCalledWith(mockItem, expect.any(Object));
});
it('does not call onClick when readonly', () => {
const onLabelClick = jest.fn();
render(
<table>
<tbody>
<LegendTableItem item={mockItem} onLabelClick={onLabelClick} readonly={true} />
</tbody>
</table>
);
const button = screen.getByRole('button');
expect(button).toBeDisabled();
});
});

View File

@@ -69,7 +69,7 @@ export const LegendTableItem = ({
return (
<tr className={cx(styles.row, className)}>
<td className={styles.labelCell}>
<td>
<span className={styles.itemWrapper}>
<VizLegendSeriesIcon
color={item.color}
@@ -77,26 +77,24 @@ export const LegendTableItem = ({
readonly={readonly}
lineStyle={item.lineStyle}
/>
<div className={styles.labelCellInner}>
<button
disabled={readonly}
type="button"
title={item.label}
onBlur={onMouseOut}
onFocus={onMouseOver}
onMouseOver={onMouseOver}
onMouseOut={onMouseOut}
onClick={!readonly ? onClick : undefined}
className={cx(styles.label, item.disabled && styles.labelDisabled)}
>
{item.label}{' '}
{item.yAxis === 2 && (
<span className={styles.yAxisLabel}>
<Trans i18nKey="grafana-ui.viz-legend.right-axis-indicator">(right y-axis)</Trans>
</span>
)}
</button>
</div>
<button
disabled={readonly}
type="button"
title={item.label}
onBlur={onMouseOut}
onFocus={onMouseOver}
onMouseOver={onMouseOver}
onMouseOut={onMouseOut}
onClick={!readonly ? onClick : undefined}
className={cx(styles.label, item.disabled && styles.labelDisabled)}
>
{item.label}{' '}
{item.yAxis === 2 && (
<span className={styles.yAxisLabel}>
<Trans i18nKey="grafana-ui.viz-legend.right-axis-indicator">(right y-axis)</Trans>
</span>
)}
</button>
</span>
</td>
{item.getDisplayValues &&
@@ -130,28 +128,6 @@ const getStyles = (theme: GrafanaTheme2) => {
background: rowHoverBg,
},
}),
labelCell: css({
label: 'LegendLabelCell',
maxWidth: 0,
width: '100%',
minWidth: theme.spacing(16),
}),
labelCellInner: css({
label: 'LegendLabelCellInner',
display: 'block',
flex: 1,
minWidth: 0,
overflowX: 'auto',
overflowY: 'hidden',
paddingRight: theme.spacing(3),
scrollbarWidth: 'none',
msOverflowStyle: 'none',
maskImage: `linear-gradient(to right, black calc(100% - ${theme.spacing(3)}), transparent 100%)`,
WebkitMaskImage: `linear-gradient(to right, black calc(100% - ${theme.spacing(3)}), transparent 100%)`,
'&::-webkit-scrollbar': {
display: 'none',
},
}),
label: css({
label: 'LegendLabel',
whiteSpace: 'nowrap',
@@ -159,6 +135,9 @@ const getStyles = (theme: GrafanaTheme2) => {
border: 'none',
fontSize: 'inherit',
padding: 0,
maxWidth: '600px',
textOverflow: 'ellipsis',
overflow: 'hidden',
userSelect: 'text',
}),
labelDisabled: css({

View File

@@ -1,4 +1,5 @@
#!/bin/bash -e
#!/bin/bash
set -e
PERMISSIONS_OK=0
@@ -26,14 +27,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}" -a ! -z "${!secret_key_varname}" ]; then
if [ ! -z "${!access_key_varname}" ] && [ ! -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: panel.ModuleHash,
ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), panel),
BaseURL: panel.BaseURL,
SkipDataQuery: panel.SkipDataQuery,
Suggestions: panel.Suggestions,
@@ -527,7 +527,7 @@ func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, availablePlug
JSONData: plugin.JSONData,
Signature: plugin.Signature,
Module: plugin.Module,
ModuleHash: plugin.ModuleHash,
ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), plugin),
BaseURL: plugin.BaseURL,
Angular: plugin.Angular,
MultiValueFilterOperators: plugin.MultiValueFilterOperators,
@@ -641,7 +641,7 @@ func (hs *HTTPServer) newAppDTO(ctx context.Context, plugin pluginstore.Plugin,
LoadingStrategy: hs.pluginAssets.LoadingStrategy(ctx, plugin),
Extensions: plugin.Extensions,
Dependencies: plugin.Dependencies,
ModuleHash: plugin.ModuleHash,
ModuleHash: hs.pluginAssets.ModuleHash(ctx, plugin),
Translations: plugin.Translations,
BuildMode: plugin.BuildMode,
}

View File

@@ -20,6 +20,8 @@ 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"
@@ -77,7 +79,8 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F
var pluginsAssets = passets
if pluginsAssets == nil {
pluginsAssets = pluginassets.ProvideService(pluginsCfg, pluginsCDN, pluginStore)
sig := signature.ProvideService(pluginsCfg, statickey.New())
pluginsAssets = pluginassets.ProvideService(pluginsCfg, pluginsCDN, sig, pluginStore)
}
hs := &HTTPServer{
@@ -711,6 +714,6 @@ func newPluginAssets() func() *pluginassets.Service {
func newPluginAssetsWithConfig(pCfg *config.PluginManagementCfg) func() *pluginassets.Service {
return func() *pluginassets.Service {
return pluginassets.ProvideService(pCfg, pluginscdn.ProvideService(pCfg), &pluginstore.FakePluginStore{})
return pluginassets.ProvideService(pCfg, pluginscdn.ProvideService(pCfg), signature.ProvideService(pCfg, statickey.New()), &pluginstore.FakePluginStore{})
}
}

View File

@@ -201,7 +201,7 @@ func (hs *HTTPServer) GetPluginSettingByID(c *contextmodel.ReqContext) response.
Includes: plugin.Includes,
BaseUrl: plugin.BaseURL,
Module: plugin.Module,
ModuleHash: plugin.ModuleHash,
ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), plugin),
DefaultNavUrl: path.Join(hs.Cfg.AppSubURL, plugin.DefaultNavURL),
State: plugin.State,
Signature: plugin.Signature,

View File

@@ -28,6 +28,8 @@ 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"
@@ -846,7 +848,8 @@ func Test_PluginsSettings(t *testing.T) {
}
pCfg := &config.PluginManagementCfg{}
pluginCDN := pluginscdn.ProvideService(pCfg)
hs.pluginAssets = pluginassets.ProvideService(pCfg, pluginCDN, hs.pluginStore)
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

@@ -140,9 +140,7 @@ type Licensing interface {
}
type SignatureCalculator interface {
// 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)
Calculate(ctx context.Context, src PluginSource, plugin FoundPlugin) (Signature, error)
}
type KeyStore interface {

View File

@@ -216,27 +216,10 @@ 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")),
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",
},
Class: plugins.ClassExternal,
Module: "public/plugins/test-app/module.js",
BaseURL: "public/plugins/test-app",
FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/includes-symlinks")),
Signature: "valid",
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",

View File

@@ -11,7 +11,6 @@ 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"
)
@@ -55,7 +54,7 @@ func New(cfg *config.PluginManagementCfg, opts Opts) *Bootstrap {
}
if opts.DecorateFuncs == nil {
opts.DecorateFuncs = DefaultDecorateFuncs(cfg, pluginscdn.ProvideService(cfg))
opts.DecorateFuncs = DefaultDecorateFuncs(cfg)
}
return &Bootstrap{

View File

@@ -11,7 +11,6 @@ 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.
@@ -29,13 +28,12 @@ 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, cdn *pluginscdn.Service) []DecorateFunc {
func DefaultDecorateFuncs(cfg *config.PluginManagementCfg) []DecorateFunc {
return []DecorateFunc{
AppDefaultNavURLDecorateFunc,
TemplateDecorateFunc,
AppChildDecorateFunc(),
SkipHostEnvVarsDecorateFunc(cfg),
ModuleHashDecorateFunc(cfg, cdn),
}
}
@@ -50,30 +48,19 @@ 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) {
// Calculate signature and cache manifest
sig, manifest, err := c.signatureCalculator.Calculate(ctx, src, bundle.Primary)
sig, 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)
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)
}
res = append(res, plugin.Children...)
return res, nil
}
@@ -158,11 +145,3 @@ 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
}
}

View File

@@ -14,6 +14,7 @@ import (
"path"
"path/filepath"
"runtime"
"strings"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/clearsign"
@@ -36,6 +37,26 @@ 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
@@ -66,14 +87,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) (*plugins.PluginManifest, error) {
func (s *Signature) readPluginManifest(ctx context.Context, body []byte) (*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 plugins.PluginManifest
var manifest PluginManifest
err := json.Unmarshal(block.Plaintext, &manifest)
if err != nil {
return nil, fmt.Errorf("%v: %w", "Error parsing manifest JSON", err)
@@ -90,7 +111,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) (*plugins.PluginManifest, error) {
func (s *Signature) ReadPluginManifestFromFS(ctx context.Context, pfs plugins.FS) (*PluginManifest, error) {
f, err := pfs.Open("MANIFEST.txt")
if err != nil {
if errors.Is(err, plugins.ErrFileNotExist) {
@@ -119,9 +140,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, *plugins.PluginManifest, error) {
func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plugin plugins.FoundPlugin) (plugins.Signature, error) {
if defaultSignature, exists := src.DefaultSignature(ctx, plugin.JSONData.ID); exists {
return defaultSignature, nil, nil
return defaultSignature, nil
}
manifest, err := s.ReadPluginManifestFromFS(ctx, plugin.FS)
@@ -130,29 +151,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{}, nil, fmt.Errorf("files: %w", err)
return plugins.Signature{}, 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
@@ -160,20 +181,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{}, nil, err
return plugins.Signature{}, 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
}
}
@@ -186,7 +207,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{}{}
@@ -215,7 +236,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)
@@ -223,7 +244,7 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu
Status: plugins.SignatureStatusValid,
Type: manifest.SignatureType,
SigningOrg: manifest.SignedByOrgName,
}, manifest, nil
}, nil
}
func verifyHash(mlog log.Logger, plugin plugins.FoundPlugin, path, hash string) error {
@@ -300,7 +321,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 plugins.PluginManifest, block *clearsign.Block) error {
func (s *Signature) validateManifest(ctx context.Context, m 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 *plugins.PluginManifest) []string {
func fileList(manifest *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 *plugins.PluginManifest
manifest *PluginManifest
expectedErr string
}{
{
name: "Empty plugin field",
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Plugin = "" }),
manifest: createV2Manifest(t, func(m *PluginManifest) { m.Plugin = "" }),
expectedErr: "valid manifest field plugin is required",
},
{
name: "Empty keyId field",
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.KeyID = "" }),
manifest: createV2Manifest(t, func(m *PluginManifest) { m.KeyID = "" }),
expectedErr: "valid manifest field keyId is required",
},
{
name: "Empty signedByOrg field",
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignedByOrg = "" }),
manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignedByOrg = "" }),
expectedErr: "valid manifest field signedByOrg is required",
},
{
name: "Empty signedByOrgName field",
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignedByOrgName = "" }),
manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignedByOrgName = "" }),
expectedErr: "valid manifest field SignedByOrgName is required",
},
{
name: "Empty signatureType field",
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignatureType = "" }),
manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignatureType = "" }),
expectedErr: "valid manifest field signatureType is required",
},
{
name: "Invalid signatureType field",
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.SignatureType = "invalidSignatureType" }),
manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignatureType = "invalidSignatureType" }),
expectedErr: "valid manifest field signatureType is required",
},
{
name: "Empty files field",
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Files = map[string]string{} }),
manifest: createV2Manifest(t, func(m *PluginManifest) { m.Files = map[string]string{} }),
expectedErr: "valid manifest field files is required",
},
{
name: "Empty time field",
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Time = 0 }),
manifest: createV2Manifest(t, func(m *PluginManifest) { m.Time = 0 }),
expectedErr: "valid manifest field time is required",
},
{
name: "Empty version field",
manifest: createV2Manifest(t, func(m *plugins.PluginManifest) { m.Version = "" }),
manifest: createV2Manifest(t, func(m *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(*plugins.PluginManifest)) *plugins.PluginManifest {
func createV2Manifest(t *testing.T, cbs ...func(*PluginManifest)) *PluginManifest {
t.Helper()
m := &plugins.PluginManifest{
m := &PluginManifest{
Plugin: "grafana-test-app",
Version: "2.5.3",
KeyID: "7e4d0c6a708866e7",

View File

@@ -1,90 +0,0 @@
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

@@ -1,356 +0,0 @@
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,7 +40,6 @@ type Plugin struct {
Pinned bool
// Signature fields
Manifest *PluginManifest
Signature SignatureStatus
SignatureType SignatureType
SignatureOrg string
@@ -49,9 +48,8 @@ type Plugin struct {
Error *Error
// SystemJS fields
Module string
ModuleHash string
BaseURL string
Module string
BaseURL string
Angular AngularMeta
@@ -534,24 +532,3 @@ 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

@@ -42,7 +42,7 @@ func (r *converter) asDataSource(ds *datasources.DataSource) (*datasourceV0.Data
Generation: int64(ds.Version),
},
Spec: datasourceV0.UnstructuredSpec{},
Secure: ToInlineSecureValues(ds.Type, ds.UID, maps.Keys(ds.SecureJsonData)),
Secure: ToInlineSecureValues("", ds.UID, maps.Keys(ds.SecureJsonData)),
}
obj.UID = gapiutil.CalculateClusterWideUID(obj)
obj.Spec.SetTitle(ds.Name).
@@ -82,18 +82,11 @@ func (r *converter) asDataSource(ds *datasources.DataSource) (*datasourceV0.Data
// ToInlineSecureValues converts secure json into InlineSecureValues with reference names
// The names are predictable and can be used while we implement dual writing for secrets
func ToInlineSecureValues(dsType string, dsUID string, keys iter.Seq[string]) common.InlineSecureValues {
func ToInlineSecureValues(_ string, dsUID string, keys iter.Seq[string]) common.InlineSecureValues {
values := make(common.InlineSecureValues)
for k := range keys {
h := sha256.New()
h.Write([]byte(dsType)) // plugin id
h.Write([]byte("|"))
h.Write([]byte(dsUID)) // unique identifier
h.Write([]byte("|"))
h.Write([]byte(k)) // property name
n := hex.EncodeToString(h.Sum(nil))
values[k] = common.InlineSecureValue{
Name: "ds-" + n[0:10], // predictable name for dual writing
Name: getLegacySecureValueName(dsUID, k),
}
}
if len(values) == 0 {
@@ -102,6 +95,15 @@ func ToInlineSecureValues(dsType string, dsUID string, keys iter.Seq[string]) co
return values
}
func getLegacySecureValueName(dsUID string, key string) string {
h := sha256.New()
h.Write([]byte(dsUID)) // unique identifier
h.Write([]byte("|"))
h.Write([]byte(key)) // property name
n := hex.EncodeToString(h.Sum(nil))
return "ds-" + n[0:10] // predictable name for dual writing
}
func (r *converter) toAddCommand(ds *datasourceV0.DataSource) (*datasources.AddDataSourceCommand, error) {
if r.group != "" && ds.APIVersion != "" && !strings.HasPrefix(ds.APIVersion, r.group) {
return nil, fmt.Errorf("expecting APIGroup: %s", r.group)

View File

@@ -11,9 +11,11 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
"github.com/grafana/grafana/pkg/infra/metrics/metricutil"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
)
var (
@@ -90,6 +92,20 @@ func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createVa
if !ok {
return nil, fmt.Errorf("expected a datasource object")
}
// Verify the secure value commands
for _, v := range ds.Secure {
if v.Create.IsZero() {
return nil, fmt.Errorf("secure values must use create when creating a new datasource")
}
if v.Remove {
return nil, fmt.Errorf("secure values can not use remove when creating a new datasource")
}
if v.Name != "" {
return nil, fmt.Errorf("secure values can not specify a name when creating a new datasource")
}
}
return s.datasources.CreateDataSource(ctx, ds)
}
@@ -122,6 +138,26 @@ func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.Up
return nil, false, fmt.Errorf("expected a datasource object (old)")
}
// Expose any secure value changes to the dual writer
var secureChanges common.InlineSecureValues
for k, v := range ds.Secure {
if v.Remove || v.Create != "" {
if secureChanges == nil {
secureChanges = make(common.InlineSecureValues)
}
secureChanges[k] = v
dualwrite.SetUpdatedSecureValues(ctx, ds.Secure)
continue
}
// The legacy store must use fixed names generated by the internal system
// we can not support external shared secrets when using the SQL backing for datasources
validName := getLegacySecureValueName(name, k)
if v.Name != validName {
return nil, false, fmt.Errorf("invalid secure value name %q, expected %q", v.Name, validName)
}
}
// Keep all the old secure values
if len(oldDS.Secure) > 0 {
for k, v := range oldDS.Secure {

View File

@@ -30,6 +30,7 @@ import (
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/storage/unified/apistore"
"github.com/grafana/grafana/pkg/tsdb/grafana-testdata-datasource/kinds"
)
@@ -102,10 +103,10 @@ func RegisterAPIService(
datasources.GetDatasourceProvider(pluginJSON),
contextProvider,
accessControl,
//nolint:staticcheck // not yet migrated to OpenFeature
DataSourceAPIBuilderConfig{
//nolint:staticcheck // not yet migrated to OpenFeature
LoadQueryTypes: features.IsEnabledGlobally(featuremgmt.FlagDatasourceQueryTypes),
UseDualWriter: false,
UseDualWriter: features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs),
},
)
if err != nil {
@@ -224,6 +225,12 @@ func (b *DataSourceAPIBuilder) AllowedV0Alpha1Resources() []string {
}
func (b *DataSourceAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
opts.StorageOptsRegister(b.datasourceResourceInfo.GroupResource(), apistore.StorageOptions{
EnableFolderSupport: false,
Scheme: opts.Scheme, // allows for generic Type applied to multiple groups
})
storage := map[string]rest.Storage{}
// Register the raw datasource connection

View File

@@ -33,7 +33,7 @@
},
"secure": {
"password": {
"name": "ds-d5c1b093af"
"name": "ds-0d27eff323"
}
}
}

View File

@@ -22,10 +22,10 @@
},
"secure": {
"extra": {
"name": "ds-bb8b5d8b32"
"name": "ds-6ed1b76e5d"
},
"password": {
"name": "ds-973a1eb29d"
"name": "ds-edc8fde0ac"
}
}
}

View File

@@ -60,7 +60,7 @@ func (s *LocalInlineSecureValueService) CanReference(ctx context.Context, owner
}
if owner.APIGroup == "" || owner.APIVersion == "" || owner.Kind == "" || owner.Name == "" {
return fmt.Errorf("owner reference must have a valid API group, API version, kind and name")
return fmt.Errorf("owner reference must have a valid API group, API version, kind and name [CanReference]")
}
if len(names) == 0 {
@@ -167,7 +167,7 @@ func (s *LocalInlineSecureValueService) verifyOwnerAndAuth(ctx context.Context,
}
if owner.Namespace == "" || owner.APIGroup == "" || owner.APIVersion == "" || owner.Kind == "" || owner.Name == "" {
return nil, fmt.Errorf("owner reference must have a valid API group, API version, kind, namespace and name")
return nil, fmt.Errorf("owner reference must have a valid API group, API version, kind, namespace and name [verifyOwnerAndAuth:%+v]", owner)
}
return authInfo, nil

12
pkg/server/wire_gen.go generated
View File

@@ -376,8 +376,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
keyretrieverService := keyretriever.ProvideService(keyRetriever)
signatureSignature := signature.ProvideService(pluginManagementCfg, keyretrieverService)
localProvider := pluginassets.NewLocalProvider()
pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg)
bootstrap := pipeline.ProvideBootstrapStage(pluginManagementCfg, signatureSignature, localProvider, pluginscdnService)
bootstrap := pipeline.ProvideBootstrapStage(pluginManagementCfg, signatureSignature, localProvider)
unsignedPluginAuthorizer := signature.ProvideOSSAuthorizer(pluginManagementCfg)
validation := signature.ProvideValidatorService(unsignedPluginAuthorizer)
angularpatternsstoreService := angularpatternsstore.ProvideService(kvStore)
@@ -715,7 +714,8 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
if err != nil {
return nil, err
}
pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, pluginstoreService)
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)
@@ -1042,8 +1042,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
keyretrieverService := keyretriever.ProvideService(keyRetriever)
signatureSignature := signature.ProvideService(pluginManagementCfg, keyretrieverService)
localProvider := pluginassets.NewLocalProvider()
pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg)
bootstrap := pipeline.ProvideBootstrapStage(pluginManagementCfg, signatureSignature, localProvider, pluginscdnService)
bootstrap := pipeline.ProvideBootstrapStage(pluginManagementCfg, signatureSignature, localProvider)
unsignedPluginAuthorizer := signature.ProvideOSSAuthorizer(pluginManagementCfg)
validation := signature.ProvideValidatorService(unsignedPluginAuthorizer)
angularpatternsstoreService := angularpatternsstore.ProvideService(kvStore)
@@ -1383,7 +1382,8 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
if err != nil {
return nil, err
}
pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, pluginstoreService)
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)

View File

@@ -49,7 +49,7 @@ var (
Name: "lokiExperimentalStreaming",
Description: "Support new streaming approach for loki (prototype, needs special loki build)",
Stage: FeatureStageExperimental,
Owner: grafanaObservabilityLogsSquad,
Owner: grafanaOSSBigTent,
},
{
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: grafanaObservabilityLogsSquad,
Owner: grafanaOSSBigTent,
},
{
Name: "disableSSEDataplane",
@@ -340,7 +340,7 @@ var (
Description: "Enables running Loki queries in parallel",
Stage: FeatureStagePrivatePreview,
FrontendOnly: false,
Owner: grafanaObservabilityLogsSquad,
Owner: grafanaOSSBigTent,
},
{
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: grafanaObservabilityLogsSquad,
Owner: grafanaOSSBigTent,
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: grafanaObservabilityLogsSquad,
Owner: grafanaOSSBigTent,
Expression: "true",
},
{

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/observability-logs,false,false,false
lokiExperimentalStreaming,experimental,@grafana/oss-big-tent,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/observability-logs,false,false,false
lokiLogsDataplane,experimental,@grafana/oss-big-tent,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/observability-logs,false,false,false
lokiRunQueriesInParallel,privatePreview,@grafana/oss-big-tent,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/observability-logs,false,false,false
logQLScope,privatePreview,@grafana/oss-big-tent,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/observability-logs,false,false,false
lokiLabelNamesQueryApi,GA,@grafana/oss-big-tent,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
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/observability-logs @grafana/oss-big-tent 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/observability-logs @grafana/oss-big-tent 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/observability-logs @grafana/oss-big-tent 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/observability-logs @grafana/oss-big-tent 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/observability-logs @grafana/oss-big-tent 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

View File

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

View File

@@ -26,7 +26,6 @@ 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"
@@ -214,27 +213,10 @@ 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")),
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",
},
Class: plugins.ClassExternal,
Module: "public/plugins/test-app/module.js",
BaseURL: "public/plugins/test-app",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "includes-symlinks")),
Signature: "valid",
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
@@ -665,24 +647,10 @@ 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")),
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/"},
},
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")),
Signature: "valid",
SignatureType: plugins.SignatureTypePrivate,
SignatureOrg: "Will Browne",
@@ -799,22 +767,8 @@ func TestLoader_Load_RBACReady(t *testing.T) {
},
Backend: false,
},
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/"},
},
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app-with-roles")),
Class: plugins.ClassExternal,
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypePrivate,
SignatureOrg: "gabrielmabille",
@@ -883,22 +837,8 @@ 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,
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"},
},
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature-root-url-uri/plugin")),
Class: plugins.ClassExternal,
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypePrivate,
SignatureOrg: "Will Browne",
@@ -985,24 +925,8 @@ func TestLoader_Load_DuplicatePlugins(t *testing.T) {
},
Backend: false,
},
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",
},
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "test-app")),
Class: plugins.ClassExternal,
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
@@ -1093,24 +1017,8 @@ func TestLoader_Load_SkipUninitializedPlugins(t *testing.T) {
},
Backend: false,
},
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",
},
FS: mustNewStaticFSForTests(t, pluginDir1),
Class: plugins.ClassExternal,
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
@@ -1272,23 +1180,9 @@ 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")),
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",
},
Module: "public/plugins/test-datasource/module.js",
BaseURL: "public/plugins/test-datasource",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent")),
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
@@ -1331,23 +1225,9 @@ 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")),
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",
},
Module: "public/plugins/test-panel/module.js",
BaseURL: "public/plugins/test-panel",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent/nested")),
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
@@ -1495,25 +1375,10 @@ 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",
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{},
},
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",
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "Grafana Labs",
@@ -1566,21 +1431,6 @@ 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",
@@ -1634,7 +1484,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, pluginscdn.ProvideService(cfg)),
pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), pluginAssetsProvider),
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)
@@ -1664,7 +1514,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(), pluginscdn.ProvideService(cfg)),
pipeline.ProvideBootstrapStage(cfg, signature.DefaultCalculator(cfg), pluginassets.NewLocalProvider()),
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,7 +18,6 @@ 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"
@@ -43,7 +42,7 @@ func ProvideDiscoveryStage(cfg *config.PluginManagementCfg, pr registry.Service)
})
}
func ProvideBootstrapStage(cfg *config.PluginManagementCfg, sc plugins.SignatureCalculator, ap pluginassets.Provider, cdn *pluginscdn.Service) *bootstrap.Bootstrap {
func ProvideBootstrapStage(cfg *config.PluginManagementCfg, sc plugins.SignatureCalculator, ap pluginassets.Provider) *bootstrap.Bootstrap {
disableAlertingForTempoDecorateFunc := func(ctx context.Context, p *plugins.Plugin) (*plugins.Plugin, error) {
if p.ID == coreplugin.Tempo && !cfg.Features.TempoAlertingEnabled {
p.Alerting = false
@@ -53,7 +52,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, cdn), disableAlertingForTempoDecorateFunc),
DecorateFuncs: append(bootstrap.DefaultDecorateFuncs(cfg), disableAlertingForTempoDecorateFunc),
})
}

View File

@@ -2,12 +2,19 @@ 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"
)
@@ -21,20 +28,24 @@ var (
scriptLoadingMinSupportedVersion = semver.MustParse(CreatePluginVersionScriptSupportEnabled)
)
func ProvideService(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service, store pluginstore.Store) *Service {
func ProvideService(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service, sig *signature.Signature, store pluginstore.Store) *Service {
return &Service{
cfg: cfg,
cdn: cdn,
store: store,
log: log.New("pluginassets"),
cfg: cfg,
cdn: cdn,
signature: sig,
store: store,
log: log.New("pluginassets"),
}
}
type Service struct {
cfg *config.PluginManagementCfg
cdn *pluginscdn.Service
store pluginstore.Store
log log.Logger
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.
@@ -71,6 +82,95 @@ func (s *Service) LoadingStrategy(_ context.Context, p pluginstore.Plugin) plugi
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)
@@ -88,3 +188,17 @@ func (s *Service) compatibleCreatePluginVersion(ps map[string]string) bool {
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

@@ -2,14 +2,19 @@ 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"
)
@@ -174,6 +179,349 @@ func TestService_Calculate(t *testing.T) {
}
}
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{
@@ -186,6 +534,13 @@ func newPlugin(pluginID string, cbs ...func(p pluginstore.Plugin) pluginstore.Pl
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
@@ -193,6 +548,13 @@ func withFS(fs plugins.FS) func(p pluginstore.Plugin) pluginstore.Plugin {
}
}
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}
@@ -200,6 +562,13 @@ func withAngular(angular bool) func(p pluginstore.Plugin) pluginstore.Plugin {
}
}
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
@@ -218,3 +587,9 @@ func newPluginSettings(pluginID string, kv map[string]string) config.PluginSetti
pluginID: kv,
}
}
func newSRIHash(t *testing.T, s string) string {
r, err := convertHashForSRI(s)
require.NoError(t, err)
return r
}

View File

@@ -30,9 +30,8 @@ type Plugin struct {
Error *plugins.Error
// SystemJS fields
Module string
BaseURL string
ModuleHash string
Module string
BaseURL string
Angular plugins.AngularMeta
@@ -81,7 +80,6 @@ func ToGrafanaDTO(p *plugins.Plugin) Plugin {
ExternalService: p.ExternalService,
Angular: p.Angular,
Translations: p.Translations,
ModuleHash: p.ModuleHash,
}
if p.Parent != nil {

View File

@@ -24,7 +24,6 @@ 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"
@@ -50,7 +49,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(), pluginscdn.ProvideService(pCfg))
boot := pipeline.ProvideBootstrapStage(pCfg, signature.ProvideService(pCfg, statickey.New()), pluginassets.NewLocalProvider())
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)
@@ -88,7 +87,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(), pluginscdn.ProvideService(cfg))
opts.Bootstrapper = pipeline.ProvideBootstrapStage(cfg, signature.ProvideService(cfg, statickey.New()), pluginassets.NewLocalProvider())
}
if opts.Validator == nil {

View File

@@ -0,0 +1,35 @@
package dualwrite
import (
"context"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
)
type ctxKey struct{}
type dualWriteContext struct {
updatedSecureValues common.InlineSecureValues
}
func addToContext(ctx context.Context) context.Context {
return context.WithValue(ctx, ctxKey{}, &dualWriteContext{})
}
// Get the Requester from context
func SetUpdatedSecureValues(ctx context.Context, sv common.InlineSecureValues) {
u, ok := ctx.Value(ctxKey{}).(*dualWriteContext)
if !ok || u == nil {
return // OK, this can happen when things are in mode 0 (legacy only)
}
u.updatedSecureValues = sv
}
// Get the Requester from context
func getUpdatedSecureValues(ctx context.Context) common.InlineSecureValues {
u, ok := ctx.Value(ctxKey{}).(*dualWriteContext)
if !ok || u == nil {
return nil
}
return u.updatedSecureValues
}

View File

@@ -16,14 +16,15 @@ import (
"k8s.io/apiserver/pkg/registry/rest"
"github.com/grafana/grafana-app-sdk/logging"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
)
var (
_ grafanarest.Storage = (*dualWriter)(nil)
tracer = otel.Tracer("github.com/grafana/grafana/pkg/storage/legacysql/dualwrite")
_ grafanarest.Storage = (*dualWriter)(nil)
tracer = otel.Tracer("github.com/grafana/grafana/pkg/storage/legacysql/dualwrite")
)
const (
@@ -203,7 +204,7 @@ func (d *dualWriter) Create(ctx context.Context, in runtime.Object, createValida
log := logging.FromContext(ctx).With("method", "Create")
accIn, err := meta.Accessor(in)
accIn, err := utils.MetaAccessor(in)
if err != nil {
return nil, err
}
@@ -216,19 +217,19 @@ func (d *dualWriter) Create(ctx context.Context, in runtime.Object, createValida
return nil, fmt.Errorf("name or generatename have to be set")
}
secure, err := accIn.GetSecureValues()
if err != nil {
return nil, fmt.Errorf("unable to read secure values %w", err)
}
readFromUnifiedWriteToBothStorages := d.readUnified && d.legacy != nil && d.unified != nil
permissions := ""
if readFromUnifiedWriteToBothStorages {
objIn, err := utils.MetaAccessor(in)
if err != nil {
return nil, err
}
// keep permissions, we will set it back after the object is created
permissions = objIn.GetAnnotation(utils.AnnoKeyGrantPermissions)
permissions = accIn.GetAnnotation(utils.AnnoKeyGrantPermissions)
if permissions != "" {
objIn.SetAnnotation(utils.AnnoKeyGrantPermissions, "") // remove the annotation for now
accIn.SetAnnotation(utils.AnnoKeyGrantPermissions, "") // remove the annotation for now
}
}
@@ -241,35 +242,36 @@ func (d *dualWriter) Create(ctx context.Context, in runtime.Object, createValida
}
createdCopy := createdFromLegacy.DeepCopyObject()
accCreated, err := meta.Accessor(createdCopy)
accCreated, err := utils.MetaAccessor(createdCopy)
if err != nil {
return nil, err
}
accCreated.SetResourceVersion("")
accCreated.SetUID("")
if secure != nil {
if err = accCreated.SetSecureValues(secure); err != nil {
return nil, fmt.Errorf("unable to set secure values on duplicate object %w", err)
}
}
if readFromUnifiedWriteToBothStorages {
objCopy, err := utils.MetaAccessor(createdCopy)
if err != nil {
return nil, err
}
// restore the permissions annotation, as we removed it before creating in legacy
if permissions != "" {
objCopy.SetAnnotation(utils.AnnoKeyGrantPermissions, permissions)
accCreated.SetAnnotation(utils.AnnoKeyGrantPermissions, permissions)
}
// Propagate annotations and labels to the object saved in
// unified storage, making sure the `deprecatedID` is saved
// as well as provisioning metadata, when present.
for name, val := range accIn.GetAnnotations() {
objCopy.SetAnnotation(name, val)
accCreated.SetAnnotation(name, val)
}
legacyAcc, err := meta.Accessor(createdFromLegacy)
if err != nil {
return nil, err
}
objCopy.SetLabels(legacyAcc.GetLabels())
accCreated.SetLabels(legacyAcc.GetLabels())
}
// If unified storage is the primary storage, let's just create it in the foreground and return it.
@@ -384,6 +386,7 @@ func (d *dualWriter) Update(ctx context.Context, name string, objInfo rest.Updat
// but legacy failed, the user would get a failure, but see the update did apply to the source
// of truth, and be less likely to retry to save (and get the stores in sync again)
ctx = addToContext(ctx)
legacyInfo := objInfo
legacyForceCreate := forceAllowCreate
unifiedInfo := objInfo
@@ -417,6 +420,14 @@ func (d *dualWriter) Update(ctx context.Context, name string, objInfo rest.Updat
}
}
// Propagate secure values from the update request to the unified storage update.
if secure := getUpdatedSecureValues(ctx); secure != nil {
wrapped, ok := unifiedInfo.(*wrappedUpdateInfo)
if ok {
wrapped.updatedSecureValues = secure
}
}
if d.readUnified {
return d.unified.Update(ctx, name, unifiedInfo, createValidation, updateValidation, unifiedForceCreate, options)
} else if d.errorIsOK {
@@ -515,9 +526,10 @@ func (d *dualWriter) ConvertToTable(ctx context.Context, object runtime.Object,
}
type wrappedUpdateInfo struct {
objInfo rest.UpdatedObjectInfo
legacyLabels map[string]string
legacyAnnotations map[string]string
objInfo rest.UpdatedObjectInfo
legacyLabels map[string]string
legacyAnnotations map[string]string
updatedSecureValues common.InlineSecureValues
}
// Preconditions implements rest.UpdatedObjectInfo.
@@ -560,6 +572,13 @@ func (w *wrappedUpdateInfo) UpdatedObject(ctx context.Context, oldObj runtime.Ob
meta.SetResourceVersion("")
meta.SetUID("")
if w.updatedSecureValues != nil {
if err = meta.SetSecureValues(w.updatedSecureValues); err != nil {
return nil, fmt.Errorf("unable to set secure values on duplicate object %w", err)
}
}
return obj, err
}

View File

@@ -3,8 +3,10 @@ package apistore
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"time"
"github.com/dustin/go-humanize"
@@ -83,6 +85,9 @@ func (s *Storage) prepareObjectForStorage(ctx context.Context, newObject runtime
if !ok {
return v, errors.New("missing auth info")
}
if err := s.checkGVK(newObject); err != nil {
return v, err
}
obj, err := utils.MetaAccessor(newObject)
if err != nil {
@@ -138,8 +143,7 @@ func (s *Storage) prepareObjectForStorage(ctx context.Context, newObject runtime
return v, err
}
err = s.codec.Encode(newObject, &v.raw)
if err == nil {
if err = s.encode(newObject, &v.raw); err == nil {
err = s.handleLargeResources(ctx, obj, &v.raw)
}
return v, err
@@ -152,6 +156,9 @@ func (s *Storage) prepareObjectForUpdate(ctx context.Context, updateObject runti
if !ok {
return v, errors.New("missing auth info")
}
if err := s.checkGVK(updateObject); err != nil {
return v, err
}
obj, err := utils.MetaAccessor(updateObject)
if err != nil {
@@ -233,8 +240,7 @@ func (s *Storage) prepareObjectForUpdate(ctx context.Context, updateObject runti
obj.SetAnnotation(utils.AnnoKeyUpdatedTimestamp, previous.GetAnnotation(utils.AnnoKeyUpdatedTimestamp))
}
err = s.codec.Encode(updateObject, &v.raw)
if err == nil {
if err = s.encode(updateObject, &v.raw); err == nil {
err = s.handleLargeResources(ctx, obj, &v.raw)
}
return v, err
@@ -268,7 +274,51 @@ func (s *Storage) handleLargeResources(ctx context.Context, obj utils.GrafanaMet
}
// Now encode the smaller version
return s.codec.Encode(orig, buf)
return s.encode(orig, buf)
}
return nil
}
func (s *Storage) checkGVK(obj runtime.Object) error {
if s.opts.Scheme == nil {
return nil // we can not do anything
}
// Ensure group+version+kind are configured
info := obj.GetObjectKind()
gvk := info.GroupVersionKind()
if gvk.Group == "" || gvk.Kind == "" || gvk.Version == "" {
gvks, _, err := s.opts.Scheme.ObjectKinds(obj)
if err != nil {
return fmt.Errorf("unknown object kind %w", err)
}
for _, v := range gvks {
if v.Group != s.gr.Group {
continue // skip values not in this group
}
gvk.Group = v.Group
gvk.Kind = v.Kind
if gvk.Version == "" {
gvk.Version = v.Version
}
info.SetGroupVersionKind(gvk)
return nil
}
}
return nil
}
func (s *Storage) encode(obj runtime.Object, w io.Writer) error {
// The standard encoder is fine when only one type maps to a group
if s.opts.Scheme == nil {
return s.codec.Encode(obj, w)
}
if err := s.checkGVK(obj); err != nil {
return err
}
// This will always write the saved GVK, unlike:
// https://github.com/kubernetes/kubernetes/blob/v1.34.3/staging/src/k8s.io/apimachinery/pkg/runtime/serializer/versioning/versioning.go#L267
// that picks an arbitrary GVK that may not match the same group!
return json.NewEncoder(w).Encode(obj)
}

Some files were not shown because too many files have changed in this diff Show More