;
+ publish(address: LiveChannelAddress, data: unknown, options?: LivePublishOptions): Promise
;
}
let singletonInstance: GrafanaLiveSrv;
diff --git a/packages/grafana-schema/package.json b/packages/grafana-schema/package.json
index 3a97767a218..d6b8759e8b1 100644
--- a/packages/grafana-schema/package.json
+++ b/packages/grafana-schema/package.json
@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/schema",
- "version": "11.6.0-pre",
+ "version": "12.0.0-pre",
"description": "Grafana Schema Library",
"keywords": [
"typescript"
diff --git a/packages/grafana-schema/src/raw/composable/annotationslist/panelcfg/x/AnnotationsListPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/annotationslist/panelcfg/x/AnnotationsListPanelCfg_types.gen.ts
index fb399d8d9af..b8c475c5cb0 100644
--- a/packages/grafana-schema/src/raw/composable/annotationslist/panelcfg/x/AnnotationsListPanelCfg_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/annotationslist/panelcfg/x/AnnotationsListPanelCfg_types.gen.ts
@@ -8,7 +8,7 @@
//
// Run 'make gen-cue' from repository root to regenerate.
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
export interface Options {
limit: number;
diff --git a/packages/grafana-schema/src/raw/composable/barchart/panelcfg/x/BarChartPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/barchart/panelcfg/x/BarChartPanelCfg_types.gen.ts
index ef9b15eb259..a613949f576 100644
--- a/packages/grafana-schema/src/raw/composable/barchart/panelcfg/x/BarChartPanelCfg_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/barchart/panelcfg/x/BarChartPanelCfg_types.gen.ts
@@ -10,7 +10,7 @@
import * as common from '@grafana/schema';
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
export interface Options extends common.OptionsWithLegend, common.OptionsWithTooltip, common.OptionsWithTextFormatting {
/**
diff --git a/packages/grafana-schema/src/raw/composable/bargauge/panelcfg/x/BarGaugePanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/bargauge/panelcfg/x/BarGaugePanelCfg_types.gen.ts
index 6eeabf6c4da..8ea837b198b 100644
--- a/packages/grafana-schema/src/raw/composable/bargauge/panelcfg/x/BarGaugePanelCfg_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/bargauge/panelcfg/x/BarGaugePanelCfg_types.gen.ts
@@ -10,7 +10,7 @@
import * as common from '@grafana/schema';
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
export interface Options extends common.OptionsWithLegend, common.SingleStatBaseOptions {
displayMode: common.BarGaugeDisplayMode;
diff --git a/packages/grafana-schema/src/raw/composable/candlestick/panelcfg/x/CandlestickPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/candlestick/panelcfg/x/CandlestickPanelCfg_types.gen.ts
index 51ffd29995a..1ab1eeb4005 100644
--- a/packages/grafana-schema/src/raw/composable/candlestick/panelcfg/x/CandlestickPanelCfg_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/candlestick/panelcfg/x/CandlestickPanelCfg_types.gen.ts
@@ -10,7 +10,7 @@
import * as common from '@grafana/schema';
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
export enum VizDisplayMode {
Candles = 'candles',
diff --git a/packages/grafana-schema/src/raw/composable/canvas/panelcfg/x/CanvasPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/canvas/panelcfg/x/CanvasPanelCfg_types.gen.ts
index 7172fd336ec..2e70c51ccbb 100644
--- a/packages/grafana-schema/src/raw/composable/canvas/panelcfg/x/CanvasPanelCfg_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/canvas/panelcfg/x/CanvasPanelCfg_types.gen.ts
@@ -10,7 +10,7 @@
import * as ui from '@grafana/schema';
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
export enum HorizontalConstraint {
Center = 'center',
diff --git a/packages/grafana-schema/src/raw/composable/cloudwatch/dataquery/x/CloudWatchDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/cloudwatch/dataquery/x/CloudWatchDataQuery_types.gen.ts
index e6b8fc13cd0..30ac8aa2af5 100644
--- a/packages/grafana-schema/src/raw/composable/cloudwatch/dataquery/x/CloudWatchDataQuery_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/cloudwatch/dataquery/x/CloudWatchDataQuery_types.gen.ts
@@ -10,7 +10,7 @@
import * as common from '@grafana/schema';
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
export interface MetricStat {
/**
diff --git a/packages/grafana-schema/src/raw/composable/dashboardlist/panelcfg/x/DashboardListPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/dashboardlist/panelcfg/x/DashboardListPanelCfg_types.gen.ts
index 376cd0293d6..c345f24064b 100644
--- a/packages/grafana-schema/src/raw/composable/dashboardlist/panelcfg/x/DashboardListPanelCfg_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/dashboardlist/panelcfg/x/DashboardListPanelCfg_types.gen.ts
@@ -8,7 +8,7 @@
//
// Run 'make gen-cue' from repository root to regenerate.
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
export interface Options {
/**
diff --git a/packages/grafana-schema/src/raw/composable/datagrid/panelcfg/x/DatagridPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/datagrid/panelcfg/x/DatagridPanelCfg_types.gen.ts
index ffc4365a8ae..14c4faac257 100644
--- a/packages/grafana-schema/src/raw/composable/datagrid/panelcfg/x/DatagridPanelCfg_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/datagrid/panelcfg/x/DatagridPanelCfg_types.gen.ts
@@ -8,7 +8,7 @@
//
// Run 'make gen-cue' from repository root to regenerate.
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
export interface Options {
selectedSeries: number;
diff --git a/packages/grafana-schema/src/raw/composable/debug/panelcfg/x/DebugPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/debug/panelcfg/x/DebugPanelCfg_types.gen.ts
index d2119d55213..7ea1d48bd5d 100644
--- a/packages/grafana-schema/src/raw/composable/debug/panelcfg/x/DebugPanelCfg_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/debug/panelcfg/x/DebugPanelCfg_types.gen.ts
@@ -8,7 +8,7 @@
//
// Run 'make gen-cue' from repository root to regenerate.
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
export type UpdateConfig = {
render: boolean,
diff --git a/packages/grafana-schema/src/raw/composable/elasticsearch/dataquery/x/ElasticsearchDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/elasticsearch/dataquery/x/ElasticsearchDataQuery_types.gen.ts
index d11da7e9b28..57fa63d3a07 100644
--- a/packages/grafana-schema/src/raw/composable/elasticsearch/dataquery/x/ElasticsearchDataQuery_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/elasticsearch/dataquery/x/ElasticsearchDataQuery_types.gen.ts
@@ -10,7 +10,7 @@
import * as common from '@grafana/schema';
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
export type BucketAggregation = (DateHistogram | Histogram | Terms | Filters | GeoHashGrid | Nested);
diff --git a/packages/grafana-schema/src/raw/composable/gauge/panelcfg/x/GaugePanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/gauge/panelcfg/x/GaugePanelCfg_types.gen.ts
index f1a444fd18b..0678c2947de 100644
--- a/packages/grafana-schema/src/raw/composable/gauge/panelcfg/x/GaugePanelCfg_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/gauge/panelcfg/x/GaugePanelCfg_types.gen.ts
@@ -10,7 +10,7 @@
import * as common from '@grafana/schema';
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
export interface Options extends common.SingleStatBaseOptions {
minVizHeight: number;
diff --git a/packages/grafana-schema/src/raw/composable/geomap/panelcfg/x/GeomapPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/geomap/panelcfg/x/GeomapPanelCfg_types.gen.ts
index f1960dd27a4..8258f6d0501 100644
--- a/packages/grafana-schema/src/raw/composable/geomap/panelcfg/x/GeomapPanelCfg_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/geomap/panelcfg/x/GeomapPanelCfg_types.gen.ts
@@ -10,7 +10,7 @@
import * as ui from '@grafana/schema';
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
export interface Options {
basemap: ui.MapLayerOptions;
diff --git a/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts
index 96ef75f8618..b440943dacd 100644
--- a/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/heatmap/panelcfg/x/HeatmapPanelCfg_types.gen.ts
@@ -10,7 +10,7 @@
import * as ui from '@grafana/schema';
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
/**
* Controls the color mode of the heatmap
diff --git a/packages/grafana-schema/src/raw/composable/histogram/panelcfg/x/HistogramPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/histogram/panelcfg/x/HistogramPanelCfg_types.gen.ts
index 91e402b587f..4871a0e625b 100644
--- a/packages/grafana-schema/src/raw/composable/histogram/panelcfg/x/HistogramPanelCfg_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/histogram/panelcfg/x/HistogramPanelCfg_types.gen.ts
@@ -10,7 +10,7 @@
import * as common from '@grafana/schema';
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
export interface Options extends common.OptionsWithLegend, common.OptionsWithTooltip {
/**
diff --git a/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts
index a2c30353a8e..39ef7f65f58 100644
--- a/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts
@@ -10,7 +10,7 @@
import * as common from '@grafana/schema';
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
export interface Options {
dedupStrategy: common.LogsDedupStrategy;
diff --git a/packages/grafana-schema/src/raw/composable/logsnew/panelcfg/x/LogsNewPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/logsnew/panelcfg/x/LogsNewPanelCfg_types.gen.ts
index b758575dba2..5ff339342be 100644
--- a/packages/grafana-schema/src/raw/composable/logsnew/panelcfg/x/LogsNewPanelCfg_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/logsnew/panelcfg/x/LogsNewPanelCfg_types.gen.ts
@@ -10,7 +10,7 @@
import * as common from '@grafana/schema';
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
export interface Options {
dedupStrategy: common.LogsDedupStrategy;
diff --git a/packages/grafana-schema/src/raw/composable/loki/dataquery/x/LokiDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/loki/dataquery/x/LokiDataQuery_types.gen.ts
index e5e064c929a..cc56fb954fa 100644
--- a/packages/grafana-schema/src/raw/composable/loki/dataquery/x/LokiDataQuery_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/loki/dataquery/x/LokiDataQuery_types.gen.ts
@@ -10,7 +10,7 @@
import * as common from '@grafana/schema';
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
export enum QueryEditorMode {
Builder = 'builder',
diff --git a/packages/grafana-schema/src/raw/composable/news/panelcfg/x/NewsPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/news/panelcfg/x/NewsPanelCfg_types.gen.ts
index 36210e7648d..cbd6c366085 100644
--- a/packages/grafana-schema/src/raw/composable/news/panelcfg/x/NewsPanelCfg_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/news/panelcfg/x/NewsPanelCfg_types.gen.ts
@@ -8,7 +8,7 @@
//
// Run 'make gen-cue' from repository root to regenerate.
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
export interface Options {
/**
diff --git a/packages/grafana-schema/src/raw/composable/nodegraph/panelcfg/x/NodeGraphPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/nodegraph/panelcfg/x/NodeGraphPanelCfg_types.gen.ts
index 34084472b19..b6c0e360636 100644
--- a/packages/grafana-schema/src/raw/composable/nodegraph/panelcfg/x/NodeGraphPanelCfg_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/nodegraph/panelcfg/x/NodeGraphPanelCfg_types.gen.ts
@@ -8,7 +8,7 @@
//
// Run 'make gen-cue' from repository root to regenerate.
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
export interface ArcOption {
/**
diff --git a/packages/grafana-schema/src/raw/composable/piechart/panelcfg/x/PieChartPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/piechart/panelcfg/x/PieChartPanelCfg_types.gen.ts
index 0f8681b7b78..e9dbbd45d5c 100644
--- a/packages/grafana-schema/src/raw/composable/piechart/panelcfg/x/PieChartPanelCfg_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/piechart/panelcfg/x/PieChartPanelCfg_types.gen.ts
@@ -10,7 +10,7 @@
import * as common from '@grafana/schema';
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
/**
* Select the pie chart display style.
diff --git a/packages/grafana-schema/src/raw/composable/stat/panelcfg/x/StatPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/stat/panelcfg/x/StatPanelCfg_types.gen.ts
index e731d7fa1fe..cac66616404 100644
--- a/packages/grafana-schema/src/raw/composable/stat/panelcfg/x/StatPanelCfg_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/stat/panelcfg/x/StatPanelCfg_types.gen.ts
@@ -10,7 +10,7 @@
import * as common from '@grafana/schema';
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
export interface Options extends common.SingleStatBaseOptions {
colorMode: common.BigValueColorMode;
diff --git a/packages/grafana-schema/src/raw/composable/statetimeline/panelcfg/x/StateTimelinePanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/statetimeline/panelcfg/x/StateTimelinePanelCfg_types.gen.ts
index e589cdde3d8..f3e32f729ee 100644
--- a/packages/grafana-schema/src/raw/composable/statetimeline/panelcfg/x/StateTimelinePanelCfg_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/statetimeline/panelcfg/x/StateTimelinePanelCfg_types.gen.ts
@@ -10,7 +10,7 @@
import * as ui from '@grafana/schema';
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui.OptionsWithTimezones {
/**
diff --git a/packages/grafana-schema/src/raw/composable/statushistory/panelcfg/x/StatusHistoryPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/statushistory/panelcfg/x/StatusHistoryPanelCfg_types.gen.ts
index beb6db453da..554a2e83491 100644
--- a/packages/grafana-schema/src/raw/composable/statushistory/panelcfg/x/StatusHistoryPanelCfg_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/statushistory/panelcfg/x/StatusHistoryPanelCfg_types.gen.ts
@@ -10,7 +10,7 @@
import * as ui from '@grafana/schema';
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui.OptionsWithTimezones {
/**
diff --git a/packages/grafana-schema/src/raw/composable/table/panelcfg/x/TablePanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/table/panelcfg/x/TablePanelCfg_types.gen.ts
index bc9b29ca205..fb3d375fd29 100644
--- a/packages/grafana-schema/src/raw/composable/table/panelcfg/x/TablePanelCfg_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/table/panelcfg/x/TablePanelCfg_types.gen.ts
@@ -10,7 +10,7 @@
import * as ui from '@grafana/schema';
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
export interface Options {
/**
diff --git a/packages/grafana-schema/src/raw/composable/tempo/dataquery/x/TempoDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/tempo/dataquery/x/TempoDataQuery_types.gen.ts
index 9a279f1653b..342f399cd17 100644
--- a/packages/grafana-schema/src/raw/composable/tempo/dataquery/x/TempoDataQuery_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/tempo/dataquery/x/TempoDataQuery_types.gen.ts
@@ -19,7 +19,7 @@ export interface TempoQuery extends common.DataQuery {
exemplars?: number;
filters: Array;
/**
- * Filters that are used to query the metrics summary
+ * deprecated Filters that are used to query the metrics summary
*/
groupBy?: Array;
/**
diff --git a/packages/grafana-schema/src/raw/composable/text/panelcfg/x/TextPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/text/panelcfg/x/TextPanelCfg_types.gen.ts
index a3cbd4096a9..cf1dc84ee31 100644
--- a/packages/grafana-schema/src/raw/composable/text/panelcfg/x/TextPanelCfg_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/text/panelcfg/x/TextPanelCfg_types.gen.ts
@@ -8,7 +8,7 @@
//
// Run 'make gen-cue' from repository root to regenerate.
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
export enum TextMode {
Code = 'code',
diff --git a/packages/grafana-schema/src/raw/composable/timeseries/panelcfg/x/TimeSeriesPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/timeseries/panelcfg/x/TimeSeriesPanelCfg_types.gen.ts
index be8096fcf67..7cb398ca8de 100644
--- a/packages/grafana-schema/src/raw/composable/timeseries/panelcfg/x/TimeSeriesPanelCfg_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/timeseries/panelcfg/x/TimeSeriesPanelCfg_types.gen.ts
@@ -10,7 +10,7 @@
import * as common from '@grafana/schema';
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
export interface Options extends common.OptionsWithTimezones {
legend: common.VizLegendOptions;
diff --git a/packages/grafana-schema/src/raw/composable/trend/panelcfg/x/TrendPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/trend/panelcfg/x/TrendPanelCfg_types.gen.ts
index e9e79e21c63..a0cb5ed211c 100644
--- a/packages/grafana-schema/src/raw/composable/trend/panelcfg/x/TrendPanelCfg_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/trend/panelcfg/x/TrendPanelCfg_types.gen.ts
@@ -10,7 +10,7 @@
import * as common from '@grafana/schema';
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
/**
* Identical to timeseries... except it does not have timezone settings
diff --git a/packages/grafana-schema/src/raw/composable/xychart/panelcfg/x/XYChartPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/xychart/panelcfg/x/XYChartPanelCfg_types.gen.ts
index 208d73a91c2..9e86a2ac7de 100644
--- a/packages/grafana-schema/src/raw/composable/xychart/panelcfg/x/XYChartPanelCfg_types.gen.ts
+++ b/packages/grafana-schema/src/raw/composable/xychart/panelcfg/x/XYChartPanelCfg_types.gen.ts
@@ -10,7 +10,7 @@
import * as common from '@grafana/schema';
-export const pluginVersion = "11.6.0-pre";
+export const pluginVersion = "12.0.0-pre";
export enum PointShape {
Circle = 'circle',
diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue
index 846d0cabd8f..e0fdc845ec3 100644
--- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue
+++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue
@@ -554,7 +554,7 @@ RowsLayoutRowSpec: {
collapsed: bool
repeat?: RowRepeatOptions
conditionalRendering?: ConditionalRenderingGroupKind
- layout: GridLayoutKind | ResponsiveGridLayoutKind | TabsLayoutKind
+ layout: GridLayoutKind | ResponsiveGridLayoutKind | TabsLayoutKind | RowsLayoutKind
}
ResponsiveGridLayoutKind: {
@@ -595,7 +595,7 @@ TabsLayoutTabKind: {
TabsLayoutTabSpec: {
title?: string
- layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind
+ layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind | TabsLayoutKind
}
PanelSpec: {
diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts
index 895014f6b6c..022ee9f3bc8 100644
--- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts
+++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts
@@ -51,49 +51,54 @@ export const defaultDashboardV2Spec = (): DashboardV2Spec => ({
variables: [],
});
-// Supported dashboard elements
-// |* more element types in the future
-export type Element = PanelKind | LibraryPanelKind;
-
-export const defaultElement = (): Element => (defaultPanelKind());
-
-export interface LibraryPanelKind {
- kind: "LibraryPanel";
- spec: LibraryPanelSpec;
+export interface AnnotationQueryKind {
+ kind: "AnnotationQuery";
+ spec: AnnotationQuerySpec;
}
-export const defaultLibraryPanelKind = (): LibraryPanelKind => ({
- kind: "LibraryPanel",
- spec: defaultLibraryPanelSpec(),
+export const defaultAnnotationQueryKind = (): AnnotationQueryKind => ({
+ kind: "AnnotationQuery",
+ spec: defaultAnnotationQuerySpec(),
});
-export interface LibraryPanelSpec {
- // Panel ID for the library panel in the dashboard
- id: number;
- // Title for the library panel in the dashboard
- title: string;
- libraryPanel: LibraryPanelRef;
-}
-
-export const defaultLibraryPanelSpec = (): LibraryPanelSpec => ({
- id: 0,
- title: "",
- libraryPanel: defaultLibraryPanelRef(),
-});
-
-// A library panel is a reusable panel that you can use in any dashboard.
-// When you make a change to a library panel, that change propagates to all instances of where the panel is used.
-// Library panels streamline reuse of panels across multiple dashboards.
-export interface LibraryPanelRef {
- // Library panel name
+export interface AnnotationQuerySpec {
+ datasource?: DataSourceRef;
+ query?: DataQueryKind;
+ enable: boolean;
+ hide: boolean;
+ iconColor: string;
name: string;
- // Library panel uid
- uid: string;
+ builtIn?: boolean;
+ filter?: AnnotationPanelFilter;
}
-export const defaultLibraryPanelRef = (): LibraryPanelRef => ({
+export const defaultAnnotationQuerySpec = (): AnnotationQuerySpec => ({
+ enable: false,
+ hide: false,
+ iconColor: "",
name: "",
- uid: "",
+ builtIn: false,
+});
+
+export interface DataSourceRef {
+ // The plugin type-id
+ type?: string;
+ // Specific datasource instance
+ uid?: string;
+}
+
+export const defaultDataSourceRef = (): DataSourceRef => ({
+});
+
+export interface DataQueryKind {
+ // The kind of a DataQueryKind is the datasource type
+ kind: string;
+ spec: Record;
+}
+
+export const defaultDataQueryKind = (): DataQueryKind => ({
+ kind: "",
+ spec: {},
});
export interface AnnotationPanelFilter {
@@ -115,51 +120,106 @@ export type DashboardCursorSync = "Off" | "Crosshair" | "Tooltip";
export const defaultDashboardCursorSync = (): DashboardCursorSync => ("Off");
-// Links with references to other dashboards or external resources
-export interface DashboardLink {
- // Title to display with the link
- title: string;
- // Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)
- // FIXME: The type is generated as `type: DashboardLinkType | dashboardLinkType.Link;` but it should be `type: DashboardLinkType`
- type: DashboardLinkType;
- // Icon name to be displayed with the link
- icon: string;
- // Tooltip to display when the user hovers their mouse over it
- tooltip: string;
- // Link URL. Only required/valid if the type is link
- url?: string;
- // List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards
- tags: string[];
- // If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards
- asDropdown: boolean;
- // If true, the link will be opened in a new tab
- targetBlank: boolean;
- // If true, includes current template variables values in the link as query params
- includeVars: boolean;
- // If true, includes current time range in the link as query params
- keepTime: boolean;
+// Supported dashboard elements
+// |* more element types in the future
+export type Element = PanelKind | LibraryPanelKind;
+
+export const defaultElement = (): Element => (defaultPanelKind());
+
+export interface PanelKind {
+ kind: "Panel";
+ spec: PanelSpec;
}
-export const defaultDashboardLink = (): DashboardLink => ({
- title: "",
- type: "link",
- icon: "",
- tooltip: "",
- tags: [],
- asDropdown: false,
- targetBlank: false,
- includeVars: false,
- keepTime: false,
+export const defaultPanelKind = (): PanelKind => ({
+ kind: "Panel",
+ spec: defaultPanelSpec(),
});
-export interface DataSourceRef {
- // The plugin type-id
- type?: string;
- // Specific datasource instance
- uid?: string;
+export interface PanelSpec {
+ id: number;
+ title: string;
+ description: string;
+ links: DataLink[];
+ data: QueryGroupKind;
+ vizConfig: VizConfigKind;
+ transparent?: boolean;
}
-export const defaultDataSourceRef = (): DataSourceRef => ({
+export const defaultPanelSpec = (): PanelSpec => ({
+ id: 0,
+ title: "",
+ description: "",
+ links: [],
+ data: defaultQueryGroupKind(),
+ vizConfig: defaultVizConfigKind(),
+});
+
+export interface DataLink {
+ title: string;
+ url: string;
+ targetBlank?: boolean;
+}
+
+export const defaultDataLink = (): DataLink => ({
+ title: "",
+ url: "",
+});
+
+export interface QueryGroupKind {
+ kind: "QueryGroup";
+ spec: QueryGroupSpec;
+}
+
+export const defaultQueryGroupKind = (): QueryGroupKind => ({
+ kind: "QueryGroup",
+ spec: defaultQueryGroupSpec(),
+});
+
+export interface QueryGroupSpec {
+ queries: PanelQueryKind[];
+ transformations: TransformationKind[];
+ queryOptions: QueryOptionsSpec;
+}
+
+export const defaultQueryGroupSpec = (): QueryGroupSpec => ({
+ queries: [],
+ transformations: [],
+ queryOptions: defaultQueryOptionsSpec(),
+});
+
+export interface PanelQueryKind {
+ kind: "PanelQuery";
+ spec: PanelQuerySpec;
+}
+
+export const defaultPanelQueryKind = (): PanelQueryKind => ({
+ kind: "PanelQuery",
+ spec: defaultPanelQuerySpec(),
+});
+
+export interface PanelQuerySpec {
+ query: DataQueryKind;
+ datasource?: DataSourceRef;
+ refId: string;
+ hidden: boolean;
+}
+
+export const defaultPanelQuerySpec = (): PanelQuerySpec => ({
+ query: defaultDataQueryKind(),
+ refId: "",
+ hidden: false,
+});
+
+export interface TransformationKind {
+ // The kind of a TransformationKind is the transformation ID
+ kind: string;
+ spec: DataTransformerConfig;
+}
+
+export const defaultTransformationKind = (): TransformationKind => ({
+ kind: "",
+ spec: defaultDataTransformerConfig(),
});
// Transformations allow to manipulate data returned by a query before the system applies a visualization.
@@ -184,15 +244,54 @@ export const defaultDataTransformerConfig = (): DataTransformerConfig => ({
options: {},
});
-export interface DataLink {
- title: string;
- url: string;
- targetBlank?: boolean;
+// Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation.
+// It comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type.
+export interface MatcherConfig {
+ // The matcher id. This is used to find the matcher implementation from registry.
+ id: string;
+ // The matcher options. This is specific to the matcher implementation.
+ options?: any;
}
-export const defaultDataLink = (): DataLink => ({
- title: "",
- url: "",
+export const defaultMatcherConfig = (): MatcherConfig => ({
+ id: "",
+});
+
+export interface QueryOptionsSpec {
+ timeFrom?: string;
+ maxDataPoints?: number;
+ timeShift?: string;
+ queryCachingTTL?: number;
+ interval?: string;
+ cacheTimeout?: string;
+ hideTimeOverride?: boolean;
+}
+
+export const defaultQueryOptionsSpec = (): QueryOptionsSpec => ({
+});
+
+export interface VizConfigKind {
+ // The kind of a VizConfigKind is the plugin ID
+ kind: string;
+ spec: VizConfigSpec;
+}
+
+export const defaultVizConfigKind = (): VizConfigKind => ({
+ kind: "",
+ spec: defaultVizConfigSpec(),
+});
+
+// --- Kinds ---
+export interface VizConfigSpec {
+ pluginVersion: string;
+ options: Record;
+ fieldConfig: FieldConfigSource;
+}
+
+export const defaultVizConfigSpec = (): VizConfigSpec => ({
+ pluginVersion: "",
+ options: {},
+ fieldConfig: defaultFieldConfigSource(),
});
// The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.
@@ -272,65 +371,10 @@ export interface FieldConfig {
export const defaultFieldConfig = (): FieldConfig => ({
});
-export interface DynamicConfigValue {
- id: string;
- value?: any;
-}
-
-export const defaultDynamicConfigValue = (): DynamicConfigValue => ({
- id: "",
-});
-
-// Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation.
-// It comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type.
-export interface MatcherConfig {
- // The matcher id. This is used to find the matcher implementation from registry.
- id: string;
- // The matcher options. This is specific to the matcher implementation.
- options?: any;
-}
-
-export const defaultMatcherConfig = (): MatcherConfig => ({
- id: "",
-});
-
-export interface Threshold {
- value: number;
- color: string;
-}
-
-export const defaultThreshold = (): Threshold => ({
- value: 0,
- color: "",
-});
-
-export type ThresholdsMode = "absolute" | "percentage";
-
-export const defaultThresholdsMode = (): ThresholdsMode => ("absolute");
-
-export interface ThresholdsConfig {
- mode: ThresholdsMode;
- steps: Threshold[];
-}
-
-export const defaultThresholdsConfig = (): ThresholdsConfig => ({
- mode: "absolute",
- steps: [],
-});
-
export type ValueMapping = ValueMap | RangeMap | RegexMap | SpecialValueMap;
export const defaultValueMapping = (): ValueMapping => (defaultValueMap());
-// Supported value mapping types
-// `value`: Maps text values to a color or different display text and color. For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.
-// `range`: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.
-// `regex`: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.
-// `special`: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A.
-export type MappingType = "value" | "range" | "regex" | "special";
-
-export const defaultMappingType = (): MappingType => ("value");
-
// Maps text values to a color or different display text and color.
// For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.
export interface ValueMap {
@@ -344,6 +388,21 @@ export const defaultValueMap = (): ValueMap => ({
options: {},
});
+// Result used as replacement with text and color when the value matches
+export interface ValueMappingResult {
+ // Text to display when the value matches
+ text?: string;
+ // Text to use when the value matches
+ color?: string;
+ // Icon to display when the value matches. Only specific visualizations.
+ icon?: string;
+ // Position in the mapping array. Only used internally.
+ index?: number;
+}
+
+export const defaultValueMappingResult = (): ValueMappingResult => ({
+});
+
// Maps numerical ranges to a display text and color.
// For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.
export interface RangeMap {
@@ -415,19 +474,42 @@ export type SpecialValueMatch = "true" | "false" | "null" | "nan" | "null+nan" |
export const defaultSpecialValueMatch = (): SpecialValueMatch => ("true");
-// Result used as replacement with text and color when the value matches
-export interface ValueMappingResult {
- // Text to display when the value matches
- text?: string;
- // Text to use when the value matches
- color?: string;
- // Icon to display when the value matches. Only specific visualizations.
- icon?: string;
- // Position in the mapping array. Only used internally.
- index?: number;
+export interface ThresholdsConfig {
+ mode: ThresholdsMode;
+ steps: Threshold[];
}
-export const defaultValueMappingResult = (): ValueMappingResult => ({
+export const defaultThresholdsConfig = (): ThresholdsConfig => ({
+ mode: "absolute",
+ steps: [],
+});
+
+export type ThresholdsMode = "absolute" | "percentage";
+
+export const defaultThresholdsMode = (): ThresholdsMode => ("absolute");
+
+export interface Threshold {
+ value: number;
+ color: string;
+}
+
+export const defaultThreshold = (): Threshold => ({
+ value: 0,
+ color: "",
+});
+
+// Map a field to a color.
+export interface FieldColor {
+ // The main color scheme mode.
+ mode: FieldColorModeId;
+ // The fixed color value for fixed or shades color modes.
+ fixedColor?: string;
+ // Some visualizations need to know how to assign a series color from by value color schemes.
+ seriesBy?: FieldColorSeriesByMode;
+}
+
+export const defaultFieldColor = (): FieldColor => ({
+ mode: "thresholds",
});
// Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value.
@@ -457,268 +539,80 @@ export type FieldColorSeriesByMode = "min" | "max" | "last";
export const defaultFieldColorSeriesByMode = (): FieldColorSeriesByMode => ("min");
-// Map a field to a color.
-export interface FieldColor {
- // The main color scheme mode.
- mode: FieldColorModeId;
- // The fixed color value for fixed or shades color modes.
- fixedColor?: string;
- // Some visualizations need to know how to assign a series color from by value color schemes.
- seriesBy?: FieldColorSeriesByMode;
+export interface DynamicConfigValue {
+ id: string;
+ value?: any;
}
-export const defaultFieldColor = (): FieldColor => ({
- mode: "thresholds",
+export const defaultDynamicConfigValue = (): DynamicConfigValue => ({
+ id: "",
});
-// Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)
-export type DashboardLinkType = "link" | "dashboards";
-
-export const defaultDashboardLinkType = (): DashboardLinkType => ("link");
-
-// --- Common types ---
-export interface Kind {
- kind: string;
- spec: any;
- metadata?: any;
+export interface LibraryPanelKind {
+ kind: "LibraryPanel";
+ spec: LibraryPanelSpec;
}
-export const defaultKind = (): Kind => ({
- kind: "",
- spec: {},
+export const defaultLibraryPanelKind = (): LibraryPanelKind => ({
+ kind: "LibraryPanel",
+ spec: defaultLibraryPanelSpec(),
});
-// --- Kinds ---
-export interface VizConfigSpec {
- pluginVersion: string;
- options: Record;
- fieldConfig: FieldConfigSource;
+export interface LibraryPanelSpec {
+ // Panel ID for the library panel in the dashboard
+ id: number;
+ // Title for the library panel in the dashboard
+ title: string;
+ libraryPanel: LibraryPanelRef;
}
-export const defaultVizConfigSpec = (): VizConfigSpec => ({
- pluginVersion: "",
- options: {},
- fieldConfig: defaultFieldConfigSource(),
+export const defaultLibraryPanelSpec = (): LibraryPanelSpec => ({
+ id: 0,
+ title: "",
+ libraryPanel: defaultLibraryPanelRef(),
});
-export interface VizConfigKind {
- // The kind of a VizConfigKind is the plugin ID
- kind: string;
- spec: VizConfigSpec;
-}
-
-export const defaultVizConfigKind = (): VizConfigKind => ({
- kind: "",
- spec: defaultVizConfigSpec(),
-});
-
-export interface AnnotationQuerySpec {
- datasource?: DataSourceRef;
- query?: DataQueryKind;
- enable: boolean;
- hide: boolean;
- iconColor: string;
+// A library panel is a reusable panel that you can use in any dashboard.
+// When you make a change to a library panel, that change propagates to all instances of where the panel is used.
+// Library panels streamline reuse of panels across multiple dashboards.
+export interface LibraryPanelRef {
+ // Library panel name
name: string;
- builtIn?: boolean;
- filter?: AnnotationPanelFilter;
+ // Library panel uid
+ uid: string;
}
-export const defaultAnnotationQuerySpec = (): AnnotationQuerySpec => ({
- enable: false,
- hide: false,
- iconColor: "",
+export const defaultLibraryPanelRef = (): LibraryPanelRef => ({
name: "",
- builtIn: false,
+ uid: "",
});
-export interface AnnotationQueryKind {
- kind: "AnnotationQuery";
- spec: AnnotationQuerySpec;
+export interface GridLayoutKind {
+ kind: "GridLayout";
+ spec: GridLayoutSpec;
}
-export const defaultAnnotationQueryKind = (): AnnotationQueryKind => ({
- kind: "AnnotationQuery",
- spec: defaultAnnotationQuerySpec(),
+export const defaultGridLayoutKind = (): GridLayoutKind => ({
+ kind: "GridLayout",
+ spec: defaultGridLayoutSpec(),
});
-export interface QueryOptionsSpec {
- timeFrom?: string;
- maxDataPoints?: number;
- timeShift?: string;
- queryCachingTTL?: number;
- interval?: string;
- cacheTimeout?: string;
- hideTimeOverride?: boolean;
+export interface GridLayoutSpec {
+ items: (GridLayoutItemKind | GridLayoutRowKind)[];
}
-export const defaultQueryOptionsSpec = (): QueryOptionsSpec => ({
+export const defaultGridLayoutSpec = (): GridLayoutSpec => ({
+ items: [],
});
-export interface DataQueryKind {
- // The kind of a DataQueryKind is the datasource type
- kind: string;
- spec: Record;
+export interface GridLayoutItemKind {
+ kind: "GridLayoutItem";
+ spec: GridLayoutItemSpec;
}
-export const defaultDataQueryKind = (): DataQueryKind => ({
- kind: "",
- spec: {},
-});
-
-export interface PanelQuerySpec {
- query: DataQueryKind;
- datasource?: DataSourceRef;
- refId: string;
- hidden: boolean;
-}
-
-export const defaultPanelQuerySpec = (): PanelQuerySpec => ({
- query: defaultDataQueryKind(),
- refId: "",
- hidden: false,
-});
-
-export interface PanelQueryKind {
- kind: "PanelQuery";
- spec: PanelQuerySpec;
-}
-
-export const defaultPanelQueryKind = (): PanelQueryKind => ({
- kind: "PanelQuery",
- spec: defaultPanelQuerySpec(),
-});
-
-export interface TransformationKind {
- // The kind of a TransformationKind is the transformation ID
- kind: string;
- spec: DataTransformerConfig;
-}
-
-export const defaultTransformationKind = (): TransformationKind => ({
- kind: "",
- spec: defaultDataTransformerConfig(),
-});
-
-export interface QueryGroupSpec {
- queries: PanelQueryKind[];
- transformations: TransformationKind[];
- queryOptions: QueryOptionsSpec;
-}
-
-export const defaultQueryGroupSpec = (): QueryGroupSpec => ({
- queries: [],
- transformations: [],
- queryOptions: defaultQueryOptionsSpec(),
-});
-
-export interface QueryGroupKind {
- kind: "QueryGroup";
- spec: QueryGroupSpec;
-}
-
-export const defaultQueryGroupKind = (): QueryGroupKind => ({
- kind: "QueryGroup",
- spec: defaultQueryGroupSpec(),
-});
-
-export interface TimeRangeOption {
- display: string;
- from: string;
- to: string;
-}
-
-export const defaultTimeRangeOption = (): TimeRangeOption => ({
- display: "Last 6 hours",
- from: "now-6h",
- to: "now",
-});
-
-// Time configuration
-// It defines the default time config for the time picker, the refresh picker for the specific dashboard.
-export interface TimeSettingsSpec {
- // Timezone of dashboard. Accepted values are IANA TZDB zone ID or "browser" or "utc".
- timezone?: string;
- // Start time range for dashboard.
- // Accepted values are relative time strings like "now-6h" or absolute time strings like "2020-07-10T08:00:00.000Z".
- from: string;
- // End time range for dashboard.
- // Accepted values are relative time strings like "now-6h" or absolute time strings like "2020-07-10T08:00:00.000Z".
- to: string;
- // Refresh rate of dashboard. Represented via interval string, e.g. "5s", "1m", "1h", "1d".
- // v1: refresh
- autoRefresh: string;
- // Interval options available in the refresh picker dropdown.
- // v1: timepicker.refresh_intervals
- autoRefreshIntervals: string[];
- // Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard.
- // v1: timepicker.quick_ranges , not exposed in the UI
- quickRanges?: TimeRangeOption[];
- // Whether timepicker is visible or not.
- // v1: timepicker.hidden
- hideTimepicker: boolean;
- // Day when the week starts. Expressed by the name of the day in lowercase, e.g. "monday".
- weekStart?: "saturday" | "monday" | "sunday";
- // The month that the fiscal year starts on. 0 = January, 11 = December
- fiscalYearStartMonth: number;
- // Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values.
- // v1: timepicker.nowDelay
- nowDelay?: string;
-}
-
-export const defaultTimeSettingsSpec = (): TimeSettingsSpec => ({
- timezone: "browser",
- from: "now-6h",
- to: "now",
- autoRefresh: "",
- autoRefreshIntervals: [
-"5s",
-"10s",
-"30s",
-"1m",
-"5m",
-"15m",
-"30m",
-"1h",
-"2h",
-"1d",
-],
- hideTimepicker: false,
- fiscalYearStartMonth: 0,
-});
-
-// other repeat modes will be added in the future: label, frame
-export const RepeatMode = "variable";
-
-export interface RepeatOptions {
- mode: "variable";
- value: string;
- direction?: "h" | "v";
- maxPerRow?: number;
-}
-
-export const defaultRepeatOptions = (): RepeatOptions => ({
- mode: RepeatMode,
- value: "",
-});
-
-export interface RowRepeatOptions {
- mode: "variable";
- value: string;
-}
-
-export const defaultRowRepeatOptions = (): RowRepeatOptions => ({
- mode: RepeatMode,
- value: "",
-});
-
-export interface ResponsiveGridRepeatOptions {
- mode: "variable";
- value: string;
-}
-
-export const defaultResponsiveGridRepeatOptions = (): ResponsiveGridRepeatOptions => ({
- mode: RepeatMode,
- value: "",
+export const defaultGridLayoutItemKind = (): GridLayoutItemKind => ({
+ kind: "GridLayoutItem",
+ spec: defaultGridLayoutItemSpec(),
});
export interface GridLayoutItemSpec {
@@ -739,16 +633,31 @@ export const defaultGridLayoutItemSpec = (): GridLayoutItemSpec => ({
element: defaultElementReference(),
});
-export interface GridLayoutItemKind {
- kind: "GridLayoutItem";
- spec: GridLayoutItemSpec;
+export interface ElementReference {
+ kind: "ElementReference";
+ name: string;
}
-export const defaultGridLayoutItemKind = (): GridLayoutItemKind => ({
- kind: "GridLayoutItem",
- spec: defaultGridLayoutItemSpec(),
+export const defaultElementReference = (): ElementReference => ({
+ kind: "ElementReference",
+ name: "",
});
+export interface RepeatOptions {
+ mode: "variable";
+ value: string;
+ direction?: "h" | "v";
+ maxPerRow?: number;
+}
+
+export const defaultRepeatOptions = (): RepeatOptions => ({
+ mode: RepeatMode,
+ value: "",
+});
+
+// other repeat modes will be added in the future: label, frame
+export const RepeatMode = "variable";
+
export interface GridLayoutRowKind {
kind: "GridLayoutRow";
spec: GridLayoutRowSpec;
@@ -775,22 +684,14 @@ export const defaultGridLayoutRowSpec = (): GridLayoutRowSpec => ({
elements: [],
});
-export interface GridLayoutSpec {
- items: (GridLayoutItemKind | GridLayoutRowKind)[];
+export interface RowRepeatOptions {
+ mode: "variable";
+ value: string;
}
-export const defaultGridLayoutSpec = (): GridLayoutSpec => ({
- items: [],
-});
-
-export interface GridLayoutKind {
- kind: "GridLayout";
- spec: GridLayoutSpec;
-}
-
-export const defaultGridLayoutKind = (): GridLayoutKind => ({
- kind: "GridLayout",
- spec: defaultGridLayoutSpec(),
+export const defaultRowRepeatOptions = (): RowRepeatOptions => ({
+ mode: RepeatMode,
+ value: "",
});
export interface RowsLayoutKind {
@@ -826,7 +727,7 @@ export interface RowsLayoutRowSpec {
collapsed: boolean;
repeat?: RowRepeatOptions;
conditionalRendering?: ConditionalRenderingGroupKind;
- layout: GridLayoutKind | ResponsiveGridLayoutKind | TabsLayoutKind;
+ layout: GridLayoutKind | ResponsiveGridLayoutKind | TabsLayoutKind | RowsLayoutKind;
}
export const defaultRowsLayoutRowSpec = (): RowsLayoutRowSpec => ({
@@ -834,575 +735,6 @@ export const defaultRowsLayoutRowSpec = (): RowsLayoutRowSpec => ({
layout: defaultGridLayoutKind(),
});
-export interface ResponsiveGridLayoutKind {
- kind: "ResponsiveGridLayout";
- spec: ResponsiveGridLayoutSpec;
-}
-
-export const defaultResponsiveGridLayoutKind = (): ResponsiveGridLayoutKind => ({
- kind: "ResponsiveGridLayout",
- spec: defaultResponsiveGridLayoutSpec(),
-});
-
-export interface ResponsiveGridLayoutSpec {
- row: string;
- col: string;
- items: ResponsiveGridLayoutItemKind[];
-}
-
-export const defaultResponsiveGridLayoutSpec = (): ResponsiveGridLayoutSpec => ({
- row: "",
- col: "",
- items: [],
-});
-
-export interface ResponsiveGridLayoutItemKind {
- kind: "ResponsiveGridLayoutItem";
- spec: ResponsiveGridLayoutItemSpec;
-}
-
-export const defaultResponsiveGridLayoutItemKind = (): ResponsiveGridLayoutItemKind => ({
- kind: "ResponsiveGridLayoutItem",
- spec: defaultResponsiveGridLayoutItemSpec(),
-});
-
-export interface ResponsiveGridLayoutItemSpec {
- element: ElementReference;
- repeat?: ResponsiveGridRepeatOptions;
- conditionalRendering?: ConditionalRenderingGroupKind;
-}
-
-export const defaultResponsiveGridLayoutItemSpec = (): ResponsiveGridLayoutItemSpec => ({
- element: defaultElementReference(),
-});
-
-export interface TabsLayoutKind {
- kind: "TabsLayout";
- spec: TabsLayoutSpec;
-}
-
-export const defaultTabsLayoutKind = (): TabsLayoutKind => ({
- kind: "TabsLayout",
- spec: defaultTabsLayoutSpec(),
-});
-
-export interface TabsLayoutSpec {
- tabs: TabsLayoutTabKind[];
-}
-
-export const defaultTabsLayoutSpec = (): TabsLayoutSpec => ({
- tabs: [],
-});
-
-export interface TabsLayoutTabKind {
- kind: "TabsLayoutTab";
- spec: TabsLayoutTabSpec;
-}
-
-export const defaultTabsLayoutTabKind = (): TabsLayoutTabKind => ({
- kind: "TabsLayoutTab",
- spec: defaultTabsLayoutTabSpec(),
-});
-
-export interface TabsLayoutTabSpec {
- title?: string;
- layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind;
-}
-
-export const defaultTabsLayoutTabSpec = (): TabsLayoutTabSpec => ({
- layout: defaultGridLayoutKind(),
-});
-
-export interface PanelSpec {
- id: number;
- title: string;
- description: string;
- links: DataLink[];
- data: QueryGroupKind;
- vizConfig: VizConfigKind;
- transparent?: boolean;
-}
-
-export const defaultPanelSpec = (): PanelSpec => ({
- id: 0,
- title: "",
- description: "",
- links: [],
- data: defaultQueryGroupKind(),
- vizConfig: defaultVizConfigKind(),
-});
-
-export interface PanelKind {
- kind: "Panel";
- spec: PanelSpec;
-}
-
-export const defaultPanelKind = (): PanelKind => ({
- kind: "Panel",
- spec: defaultPanelSpec(),
-});
-
-export interface ElementReference {
- kind: "ElementReference";
- name: string;
-}
-
-export const defaultElementReference = (): ElementReference => ({
- kind: "ElementReference",
- name: "",
-});
-
-// Variable types
-export type VariableValue = VariableValueSingle | VariableValueSingle[];
-
-export const defaultVariableValue = (): VariableValue => (defaultVariableValueSingle());
-
-export type VariableValueSingle = string | boolean | number | CustomVariableValue;
-
-export const defaultVariableValueSingle = (): VariableValueSingle => ("");
-
-// Custom formatter variable
-export interface CustomFormatterVariable {
- name: string;
- type: VariableType;
- multi: boolean;
- includeAll: boolean;
-}
-
-export const defaultCustomFormatterVariable = (): CustomFormatterVariable => ({
- name: "",
- type: "query",
- multi: false,
- includeAll: false,
-});
-
-// Custom variable value
-export interface CustomVariableValue {
- // The format name or function used in the expression
- formatter: string | VariableCustomFormatterFn;
-}
-
-export const defaultCustomVariableValue = (): CustomVariableValue => ({
- formatter: "",
-});
-
-// Custom formatter function
-export interface VariableCustomFormatterFn {
- value: any;
- legacyVariableModel: {
- name: string;
- type: VariableType;
- multi: boolean;
- includeAll: boolean;
- };
- legacyDefaultFormatter?: VariableCustomFormatterFn;
-}
-
-export const defaultVariableCustomFormatterFn = (): VariableCustomFormatterFn => ({
- value: {},
- legacyVariableModel: {
- name: "",
- type: "query",
- multi: false,
- includeAll: false,
-},
-});
-
-// Dashboard variable type
-// `query`: Query-generated list of values such as metric names, server names, sensor IDs, data centers, and so on.
-// `adhoc`: Key/value filters that are automatically added to all metric queries for a data source (Prometheus, Loki, InfluxDB, and Elasticsearch only).
-// `constant`: Define a hidden constant.
-// `datasource`: Quickly change the data source for an entire dashboard.
-// `interval`: Interval variables represent time spans.
-// `textbox`: Display a free text input field with an optional default value.
-// `custom`: Define the variable options manually using a comma-separated list.
-// `system`: Variables defined by Grafana. See: https://grafana.com/docs/grafana/latest/dashboards/variables/add-template-variables/#global-variables
-export type VariableType = "query" | "adhoc" | "groupby" | "constant" | "datasource" | "interval" | "textbox" | "custom" | "system" | "snapshot";
-
-export const defaultVariableType = (): VariableType => ("query");
-
-export type VariableKind = QueryVariableKind | TextVariableKind | ConstantVariableKind | DatasourceVariableKind | IntervalVariableKind | CustomVariableKind | GroupByVariableKind | AdhocVariableKind;
-
-export const defaultVariableKind = (): VariableKind => (defaultQueryVariableKind());
-
-// Sort variable options
-// Accepted values are:
-// `disabled`: No sorting
-// `alphabeticalAsc`: Alphabetical ASC
-// `alphabeticalDesc`: Alphabetical DESC
-// `numericalAsc`: Numerical ASC
-// `numericalDesc`: Numerical DESC
-// `alphabeticalCaseInsensitiveAsc`: Alphabetical Case Insensitive ASC
-// `alphabeticalCaseInsensitiveDesc`: Alphabetical Case Insensitive DESC
-// `naturalAsc`: Natural ASC
-// `naturalDesc`: Natural DESC
-// VariableSort enum with default value
-export type VariableSort = "disabled" | "alphabeticalAsc" | "alphabeticalDesc" | "numericalAsc" | "numericalDesc" | "alphabeticalCaseInsensitiveAsc" | "alphabeticalCaseInsensitiveDesc" | "naturalAsc" | "naturalDesc";
-
-export const defaultVariableSort = (): VariableSort => ("disabled");
-
-// Options to config when to refresh a variable
-// `never`: Never refresh the variable
-// `onDashboardLoad`: Queries the data source every time the dashboard loads.
-// `onTimeRangeChanged`: Queries the data source when the dashboard time range changes.
-export type VariableRefresh = "never" | "onDashboardLoad" | "onTimeRangeChanged";
-
-export const defaultVariableRefresh = (): VariableRefresh => ("never");
-
-// Determine if the variable shows on dashboard
-// Accepted values are `dontHide` (show label and value), `hideLabel` (show value only), `hideVariable` (show nothing).
-export type VariableHide = "dontHide" | "hideLabel" | "hideVariable";
-
-export const defaultVariableHide = (): VariableHide => ("dontHide");
-
-// FIXME: should we introduce this? --- Variable value option
-export interface VariableValueOption {
- label: string;
- value: VariableValueSingle;
- group?: string;
-}
-
-export const defaultVariableValueOption = (): VariableValueOption => ({
- label: "",
- value: defaultVariableValueSingle(),
-});
-
-// Variable option specification
-export interface VariableOption {
- // Whether the option is selected or not
- selected?: boolean;
- // Text to be displayed for the option
- text: string | string[];
- // Value of the option
- value: string | string[];
-}
-
-export const defaultVariableOption = (): VariableOption => ({
- text: "",
- value: "",
-});
-
-// Query variable specification
-export interface QueryVariableSpec {
- name: string;
- current: VariableOption;
- label?: string;
- hide: VariableHide;
- refresh: VariableRefresh;
- skipUrlSync: boolean;
- description?: string;
- datasource?: DataSourceRef;
- query: DataQueryKind;
- regex: string;
- sort: VariableSort;
- definition?: string;
- options: VariableOption[];
- multi: boolean;
- includeAll: boolean;
- allValue?: string;
- placeholder?: string;
-}
-
-export const defaultQueryVariableSpec = (): QueryVariableSpec => ({
- name: "",
- current: { text: "", value: "", },
- hide: "dontHide",
- refresh: "never",
- skipUrlSync: false,
- query: defaultDataQueryKind(),
- regex: "",
- sort: "disabled",
- options: [],
- multi: false,
- includeAll: false,
-});
-
-// Query variable kind
-export interface QueryVariableKind {
- kind: "QueryVariable";
- spec: QueryVariableSpec;
-}
-
-export const defaultQueryVariableKind = (): QueryVariableKind => ({
- kind: "QueryVariable",
- spec: defaultQueryVariableSpec(),
-});
-
-// Text variable specification
-export interface TextVariableSpec {
- name: string;
- current: VariableOption;
- query: string;
- label?: string;
- hide: VariableHide;
- skipUrlSync: boolean;
- description?: string;
-}
-
-export const defaultTextVariableSpec = (): TextVariableSpec => ({
- name: "",
- current: { text: "", value: "", },
- query: "",
- hide: "dontHide",
- skipUrlSync: false,
-});
-
-// Text variable kind
-export interface TextVariableKind {
- kind: "TextVariable";
- spec: TextVariableSpec;
-}
-
-export const defaultTextVariableKind = (): TextVariableKind => ({
- kind: "TextVariable",
- spec: defaultTextVariableSpec(),
-});
-
-// Constant variable specification
-export interface ConstantVariableSpec {
- name: string;
- query: string;
- current: VariableOption;
- label?: string;
- hide: VariableHide;
- skipUrlSync: boolean;
- description?: string;
-}
-
-export const defaultConstantVariableSpec = (): ConstantVariableSpec => ({
- name: "",
- query: "",
- current: { text: "", value: "", },
- hide: "dontHide",
- skipUrlSync: false,
-});
-
-// Constant variable kind
-export interface ConstantVariableKind {
- kind: "ConstantVariable";
- spec: ConstantVariableSpec;
-}
-
-export const defaultConstantVariableKind = (): ConstantVariableKind => ({
- kind: "ConstantVariable",
- spec: defaultConstantVariableSpec(),
-});
-
-// Datasource variable specification
-export interface DatasourceVariableSpec {
- name: string;
- pluginId: string;
- refresh: VariableRefresh;
- regex: string;
- current: VariableOption;
- options: VariableOption[];
- multi: boolean;
- includeAll: boolean;
- allValue?: string;
- label?: string;
- hide: VariableHide;
- skipUrlSync: boolean;
- description?: string;
-}
-
-export const defaultDatasourceVariableSpec = (): DatasourceVariableSpec => ({
- name: "",
- pluginId: "",
- refresh: "never",
- regex: "",
- current: { text: "", value: "", },
- options: [],
- multi: false,
- includeAll: false,
- hide: "dontHide",
- skipUrlSync: false,
-});
-
-// Datasource variable kind
-export interface DatasourceVariableKind {
- kind: "DatasourceVariable";
- spec: DatasourceVariableSpec;
-}
-
-export const defaultDatasourceVariableKind = (): DatasourceVariableKind => ({
- kind: "DatasourceVariable",
- spec: defaultDatasourceVariableSpec(),
-});
-
-// Interval variable specification
-export interface IntervalVariableSpec {
- name: string;
- query: string;
- current: VariableOption;
- options: VariableOption[];
- auto: boolean;
- auto_min: string;
- auto_count: number;
- refresh: VariableRefresh;
- label?: string;
- hide: VariableHide;
- skipUrlSync: boolean;
- description?: string;
-}
-
-export const defaultIntervalVariableSpec = (): IntervalVariableSpec => ({
- name: "",
- query: "",
- current: { text: "", value: "", },
- options: [],
- auto: false,
- auto_min: "",
- auto_count: 0,
- refresh: "never",
- hide: "dontHide",
- skipUrlSync: false,
-});
-
-// Interval variable kind
-export interface IntervalVariableKind {
- kind: "IntervalVariable";
- spec: IntervalVariableSpec;
-}
-
-export const defaultIntervalVariableKind = (): IntervalVariableKind => ({
- kind: "IntervalVariable",
- spec: defaultIntervalVariableSpec(),
-});
-
-// Custom variable specification
-export interface CustomVariableSpec {
- name: string;
- query: string;
- current: VariableOption;
- options: VariableOption[];
- multi: boolean;
- includeAll: boolean;
- allValue?: string;
- label?: string;
- hide: VariableHide;
- skipUrlSync: boolean;
- description?: string;
-}
-
-export const defaultCustomVariableSpec = (): CustomVariableSpec => ({
- name: "",
- query: "",
- current: defaultVariableOption(),
- options: [],
- multi: false,
- includeAll: false,
- hide: "dontHide",
- skipUrlSync: false,
-});
-
-// Custom variable kind
-export interface CustomVariableKind {
- kind: "CustomVariable";
- spec: CustomVariableSpec;
-}
-
-export const defaultCustomVariableKind = (): CustomVariableKind => ({
- kind: "CustomVariable",
- spec: defaultCustomVariableSpec(),
-});
-
-// GroupBy variable specification
-export interface GroupByVariableSpec {
- name: string;
- datasource?: DataSourceRef;
- current: VariableOption;
- options: VariableOption[];
- multi: boolean;
- label?: string;
- hide: VariableHide;
- skipUrlSync: boolean;
- description?: string;
-}
-
-export const defaultGroupByVariableSpec = (): GroupByVariableSpec => ({
- name: "",
- current: { text: "", value: "", },
- options: [],
- multi: false,
- hide: "dontHide",
- skipUrlSync: false,
-});
-
-// Group variable kind
-export interface GroupByVariableKind {
- kind: "GroupByVariable";
- spec: GroupByVariableSpec;
-}
-
-export const defaultGroupByVariableKind = (): GroupByVariableKind => ({
- kind: "GroupByVariable",
- spec: defaultGroupByVariableSpec(),
-});
-
-// Adhoc variable specification
-export interface AdhocVariableSpec {
- name: string;
- datasource?: DataSourceRef;
- baseFilters: AdHocFilterWithLabels[];
- filters: AdHocFilterWithLabels[];
- defaultKeys: MetricFindValue[];
- label?: string;
- hide: VariableHide;
- skipUrlSync: boolean;
- description?: string;
-}
-
-export const defaultAdhocVariableSpec = (): AdhocVariableSpec => ({
- name: "",
- baseFilters: [],
- filters: [],
- defaultKeys: [],
- hide: "dontHide",
- skipUrlSync: false,
-});
-
-// Define the MetricFindValue type
-export interface MetricFindValue {
- text: string;
- value?: string | number;
- group?: string;
- expandable?: boolean;
-}
-
-export const defaultMetricFindValue = (): MetricFindValue => ({
- text: "",
-});
-
-// Define the AdHocFilterWithLabels type
-export interface AdHocFilterWithLabels {
- key: string;
- operator: string;
- value: string;
- values?: string[];
- keyLabel?: string;
- valueLabels?: string[];
- forceEdit?: boolean;
- // @deprecated
- condition?: string;
-}
-
-export const defaultAdHocFilterWithLabels = (): AdHocFilterWithLabels => ({
- key: "",
- operator: "",
- value: "",
-});
-
-// Adhoc variable kind
-export interface AdhocVariableKind {
- kind: "AdhocVariable";
- spec: AdhocVariableSpec;
-}
-
-export const defaultAdhocVariableKind = (): AdhocVariableKind => ({
- kind: "AdhocVariable",
- spec: defaultAdhocVariableSpec(),
-});
-
export interface ConditionalRenderingGroupKind {
kind: "ConditionalRenderingGroup";
spec: ConditionalRenderingGroupSpec;
@@ -1481,3 +813,671 @@ export const defaultConditionalRenderingTimeIntervalSpec = (): ConditionalRender
value: "",
});
+export interface ResponsiveGridLayoutKind {
+ kind: "ResponsiveGridLayout";
+ spec: ResponsiveGridLayoutSpec;
+}
+
+export const defaultResponsiveGridLayoutKind = (): ResponsiveGridLayoutKind => ({
+ kind: "ResponsiveGridLayout",
+ spec: defaultResponsiveGridLayoutSpec(),
+});
+
+export interface ResponsiveGridLayoutSpec {
+ row: string;
+ col: string;
+ items: ResponsiveGridLayoutItemKind[];
+}
+
+export const defaultResponsiveGridLayoutSpec = (): ResponsiveGridLayoutSpec => ({
+ row: "",
+ col: "",
+ items: [],
+});
+
+export interface ResponsiveGridLayoutItemKind {
+ kind: "ResponsiveGridLayoutItem";
+ spec: ResponsiveGridLayoutItemSpec;
+}
+
+export const defaultResponsiveGridLayoutItemKind = (): ResponsiveGridLayoutItemKind => ({
+ kind: "ResponsiveGridLayoutItem",
+ spec: defaultResponsiveGridLayoutItemSpec(),
+});
+
+export interface ResponsiveGridLayoutItemSpec {
+ element: ElementReference;
+ repeat?: ResponsiveGridRepeatOptions;
+ conditionalRendering?: ConditionalRenderingGroupKind;
+}
+
+export const defaultResponsiveGridLayoutItemSpec = (): ResponsiveGridLayoutItemSpec => ({
+ element: defaultElementReference(),
+});
+
+export interface ResponsiveGridRepeatOptions {
+ mode: "variable";
+ value: string;
+}
+
+export const defaultResponsiveGridRepeatOptions = (): ResponsiveGridRepeatOptions => ({
+ mode: RepeatMode,
+ value: "",
+});
+
+export interface TabsLayoutKind {
+ kind: "TabsLayout";
+ spec: TabsLayoutSpec;
+}
+
+export const defaultTabsLayoutKind = (): TabsLayoutKind => ({
+ kind: "TabsLayout",
+ spec: defaultTabsLayoutSpec(),
+});
+
+export interface TabsLayoutSpec {
+ tabs: TabsLayoutTabKind[];
+}
+
+export const defaultTabsLayoutSpec = (): TabsLayoutSpec => ({
+ tabs: [],
+});
+
+export interface TabsLayoutTabKind {
+ kind: "TabsLayoutTab";
+ spec: TabsLayoutTabSpec;
+}
+
+export const defaultTabsLayoutTabKind = (): TabsLayoutTabKind => ({
+ kind: "TabsLayoutTab",
+ spec: defaultTabsLayoutTabSpec(),
+});
+
+export interface TabsLayoutTabSpec {
+ title?: string;
+ layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind | TabsLayoutKind;
+}
+
+export const defaultTabsLayoutTabSpec = (): TabsLayoutTabSpec => ({
+ layout: defaultGridLayoutKind(),
+});
+
+// Links with references to other dashboards or external resources
+export interface DashboardLink {
+ // Title to display with the link
+ title: string;
+ // Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)
+ // FIXME: The type is generated as `type: DashboardLinkType | dashboardLinkType.Link;` but it should be `type: DashboardLinkType`
+ type: DashboardLinkType;
+ // Icon name to be displayed with the link
+ icon: string;
+ // Tooltip to display when the user hovers their mouse over it
+ tooltip: string;
+ // Link URL. Only required/valid if the type is link
+ url?: string;
+ // List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards
+ tags: string[];
+ // If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards
+ asDropdown: boolean;
+ // If true, the link will be opened in a new tab
+ targetBlank: boolean;
+ // If true, includes current template variables values in the link as query params
+ includeVars: boolean;
+ // If true, includes current time range in the link as query params
+ keepTime: boolean;
+}
+
+export const defaultDashboardLink = (): DashboardLink => ({
+ title: "",
+ type: "link",
+ icon: "",
+ tooltip: "",
+ tags: [],
+ asDropdown: false,
+ targetBlank: false,
+ includeVars: false,
+ keepTime: false,
+});
+
+// Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)
+export type DashboardLinkType = "link" | "dashboards";
+
+export const defaultDashboardLinkType = (): DashboardLinkType => ("link");
+
+// Time configuration
+// It defines the default time config for the time picker, the refresh picker for the specific dashboard.
+export interface TimeSettingsSpec {
+ // Timezone of dashboard. Accepted values are IANA TZDB zone ID or "browser" or "utc".
+ timezone?: string;
+ // Start time range for dashboard.
+ // Accepted values are relative time strings like "now-6h" or absolute time strings like "2020-07-10T08:00:00.000Z".
+ from: string;
+ // End time range for dashboard.
+ // Accepted values are relative time strings like "now-6h" or absolute time strings like "2020-07-10T08:00:00.000Z".
+ to: string;
+ // Refresh rate of dashboard. Represented via interval string, e.g. "5s", "1m", "1h", "1d".
+ // v1: refresh
+ autoRefresh: string;
+ // Interval options available in the refresh picker dropdown.
+ // v1: timepicker.refresh_intervals
+ autoRefreshIntervals: string[];
+ // Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard.
+ // v1: timepicker.quick_ranges , not exposed in the UI
+ quickRanges?: TimeRangeOption[];
+ // Whether timepicker is visible or not.
+ // v1: timepicker.hidden
+ hideTimepicker: boolean;
+ // Day when the week starts. Expressed by the name of the day in lowercase, e.g. "monday".
+ weekStart?: "saturday" | "monday" | "sunday";
+ // The month that the fiscal year starts on. 0 = January, 11 = December
+ fiscalYearStartMonth: number;
+ // Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values.
+ // v1: timepicker.nowDelay
+ nowDelay?: string;
+}
+
+export const defaultTimeSettingsSpec = (): TimeSettingsSpec => ({
+ timezone: "browser",
+ from: "now-6h",
+ to: "now",
+ autoRefresh: "",
+ autoRefreshIntervals: [
+"5s",
+"10s",
+"30s",
+"1m",
+"5m",
+"15m",
+"30m",
+"1h",
+"2h",
+"1d",
+],
+ hideTimepicker: false,
+ fiscalYearStartMonth: 0,
+});
+
+export interface TimeRangeOption {
+ display: string;
+ from: string;
+ to: string;
+}
+
+export const defaultTimeRangeOption = (): TimeRangeOption => ({
+ display: "Last 6 hours",
+ from: "now-6h",
+ to: "now",
+});
+
+export type VariableKind = QueryVariableKind | TextVariableKind | ConstantVariableKind | DatasourceVariableKind | IntervalVariableKind | CustomVariableKind | GroupByVariableKind | AdhocVariableKind;
+
+export const defaultVariableKind = (): VariableKind => (defaultQueryVariableKind());
+
+// Query variable kind
+export interface QueryVariableKind {
+ kind: "QueryVariable";
+ spec: QueryVariableSpec;
+}
+
+export const defaultQueryVariableKind = (): QueryVariableKind => ({
+ kind: "QueryVariable",
+ spec: defaultQueryVariableSpec(),
+});
+
+// Query variable specification
+export interface QueryVariableSpec {
+ name: string;
+ current: VariableOption;
+ label?: string;
+ hide: VariableHide;
+ refresh: VariableRefresh;
+ skipUrlSync: boolean;
+ description?: string;
+ datasource?: DataSourceRef;
+ query: DataQueryKind;
+ regex: string;
+ sort: VariableSort;
+ definition?: string;
+ options: VariableOption[];
+ multi: boolean;
+ includeAll: boolean;
+ allValue?: string;
+ placeholder?: string;
+}
+
+export const defaultQueryVariableSpec = (): QueryVariableSpec => ({
+ name: "",
+ current: { text: "", value: "", },
+ hide: "dontHide",
+ refresh: "never",
+ skipUrlSync: false,
+ query: defaultDataQueryKind(),
+ regex: "",
+ sort: "disabled",
+ options: [],
+ multi: false,
+ includeAll: false,
+});
+
+// Variable option specification
+export interface VariableOption {
+ // Whether the option is selected or not
+ selected?: boolean;
+ // Text to be displayed for the option
+ text: string | string[];
+ // Value of the option
+ value: string | string[];
+}
+
+export const defaultVariableOption = (): VariableOption => ({
+ text: "",
+ value: "",
+});
+
+// Determine if the variable shows on dashboard
+// Accepted values are `dontHide` (show label and value), `hideLabel` (show value only), `hideVariable` (show nothing).
+export type VariableHide = "dontHide" | "hideLabel" | "hideVariable";
+
+export const defaultVariableHide = (): VariableHide => ("dontHide");
+
+// Options to config when to refresh a variable
+// `never`: Never refresh the variable
+// `onDashboardLoad`: Queries the data source every time the dashboard loads.
+// `onTimeRangeChanged`: Queries the data source when the dashboard time range changes.
+export type VariableRefresh = "never" | "onDashboardLoad" | "onTimeRangeChanged";
+
+export const defaultVariableRefresh = (): VariableRefresh => ("never");
+
+// Sort variable options
+// Accepted values are:
+// `disabled`: No sorting
+// `alphabeticalAsc`: Alphabetical ASC
+// `alphabeticalDesc`: Alphabetical DESC
+// `numericalAsc`: Numerical ASC
+// `numericalDesc`: Numerical DESC
+// `alphabeticalCaseInsensitiveAsc`: Alphabetical Case Insensitive ASC
+// `alphabeticalCaseInsensitiveDesc`: Alphabetical Case Insensitive DESC
+// `naturalAsc`: Natural ASC
+// `naturalDesc`: Natural DESC
+// VariableSort enum with default value
+export type VariableSort = "disabled" | "alphabeticalAsc" | "alphabeticalDesc" | "numericalAsc" | "numericalDesc" | "alphabeticalCaseInsensitiveAsc" | "alphabeticalCaseInsensitiveDesc" | "naturalAsc" | "naturalDesc";
+
+export const defaultVariableSort = (): VariableSort => ("disabled");
+
+// Text variable kind
+export interface TextVariableKind {
+ kind: "TextVariable";
+ spec: TextVariableSpec;
+}
+
+export const defaultTextVariableKind = (): TextVariableKind => ({
+ kind: "TextVariable",
+ spec: defaultTextVariableSpec(),
+});
+
+// Text variable specification
+export interface TextVariableSpec {
+ name: string;
+ current: VariableOption;
+ query: string;
+ label?: string;
+ hide: VariableHide;
+ skipUrlSync: boolean;
+ description?: string;
+}
+
+export const defaultTextVariableSpec = (): TextVariableSpec => ({
+ name: "",
+ current: { text: "", value: "", },
+ query: "",
+ hide: "dontHide",
+ skipUrlSync: false,
+});
+
+// Constant variable kind
+export interface ConstantVariableKind {
+ kind: "ConstantVariable";
+ spec: ConstantVariableSpec;
+}
+
+export const defaultConstantVariableKind = (): ConstantVariableKind => ({
+ kind: "ConstantVariable",
+ spec: defaultConstantVariableSpec(),
+});
+
+// Constant variable specification
+export interface ConstantVariableSpec {
+ name: string;
+ query: string;
+ current: VariableOption;
+ label?: string;
+ hide: VariableHide;
+ skipUrlSync: boolean;
+ description?: string;
+}
+
+export const defaultConstantVariableSpec = (): ConstantVariableSpec => ({
+ name: "",
+ query: "",
+ current: { text: "", value: "", },
+ hide: "dontHide",
+ skipUrlSync: false,
+});
+
+// Datasource variable kind
+export interface DatasourceVariableKind {
+ kind: "DatasourceVariable";
+ spec: DatasourceVariableSpec;
+}
+
+export const defaultDatasourceVariableKind = (): DatasourceVariableKind => ({
+ kind: "DatasourceVariable",
+ spec: defaultDatasourceVariableSpec(),
+});
+
+// Datasource variable specification
+export interface DatasourceVariableSpec {
+ name: string;
+ pluginId: string;
+ refresh: VariableRefresh;
+ regex: string;
+ current: VariableOption;
+ options: VariableOption[];
+ multi: boolean;
+ includeAll: boolean;
+ allValue?: string;
+ label?: string;
+ hide: VariableHide;
+ skipUrlSync: boolean;
+ description?: string;
+}
+
+export const defaultDatasourceVariableSpec = (): DatasourceVariableSpec => ({
+ name: "",
+ pluginId: "",
+ refresh: "never",
+ regex: "",
+ current: { text: "", value: "", },
+ options: [],
+ multi: false,
+ includeAll: false,
+ hide: "dontHide",
+ skipUrlSync: false,
+});
+
+// Interval variable kind
+export interface IntervalVariableKind {
+ kind: "IntervalVariable";
+ spec: IntervalVariableSpec;
+}
+
+export const defaultIntervalVariableKind = (): IntervalVariableKind => ({
+ kind: "IntervalVariable",
+ spec: defaultIntervalVariableSpec(),
+});
+
+// Interval variable specification
+export interface IntervalVariableSpec {
+ name: string;
+ query: string;
+ current: VariableOption;
+ options: VariableOption[];
+ auto: boolean;
+ auto_min: string;
+ auto_count: number;
+ refresh: VariableRefresh;
+ label?: string;
+ hide: VariableHide;
+ skipUrlSync: boolean;
+ description?: string;
+}
+
+export const defaultIntervalVariableSpec = (): IntervalVariableSpec => ({
+ name: "",
+ query: "",
+ current: { text: "", value: "", },
+ options: [],
+ auto: false,
+ auto_min: "",
+ auto_count: 0,
+ refresh: "never",
+ hide: "dontHide",
+ skipUrlSync: false,
+});
+
+// Custom variable kind
+export interface CustomVariableKind {
+ kind: "CustomVariable";
+ spec: CustomVariableSpec;
+}
+
+export const defaultCustomVariableKind = (): CustomVariableKind => ({
+ kind: "CustomVariable",
+ spec: defaultCustomVariableSpec(),
+});
+
+// Custom variable specification
+export interface CustomVariableSpec {
+ name: string;
+ query: string;
+ current: VariableOption;
+ options: VariableOption[];
+ multi: boolean;
+ includeAll: boolean;
+ allValue?: string;
+ label?: string;
+ hide: VariableHide;
+ skipUrlSync: boolean;
+ description?: string;
+}
+
+export const defaultCustomVariableSpec = (): CustomVariableSpec => ({
+ name: "",
+ query: "",
+ current: defaultVariableOption(),
+ options: [],
+ multi: false,
+ includeAll: false,
+ hide: "dontHide",
+ skipUrlSync: false,
+});
+
+// Group variable kind
+export interface GroupByVariableKind {
+ kind: "GroupByVariable";
+ spec: GroupByVariableSpec;
+}
+
+export const defaultGroupByVariableKind = (): GroupByVariableKind => ({
+ kind: "GroupByVariable",
+ spec: defaultGroupByVariableSpec(),
+});
+
+// GroupBy variable specification
+export interface GroupByVariableSpec {
+ name: string;
+ datasource?: DataSourceRef;
+ current: VariableOption;
+ options: VariableOption[];
+ multi: boolean;
+ label?: string;
+ hide: VariableHide;
+ skipUrlSync: boolean;
+ description?: string;
+}
+
+export const defaultGroupByVariableSpec = (): GroupByVariableSpec => ({
+ name: "",
+ current: { text: "", value: "", },
+ options: [],
+ multi: false,
+ hide: "dontHide",
+ skipUrlSync: false,
+});
+
+// Adhoc variable kind
+export interface AdhocVariableKind {
+ kind: "AdhocVariable";
+ spec: AdhocVariableSpec;
+}
+
+export const defaultAdhocVariableKind = (): AdhocVariableKind => ({
+ kind: "AdhocVariable",
+ spec: defaultAdhocVariableSpec(),
+});
+
+// Adhoc variable specification
+export interface AdhocVariableSpec {
+ name: string;
+ datasource?: DataSourceRef;
+ baseFilters: AdHocFilterWithLabels[];
+ filters: AdHocFilterWithLabels[];
+ defaultKeys: MetricFindValue[];
+ label?: string;
+ hide: VariableHide;
+ skipUrlSync: boolean;
+ description?: string;
+}
+
+export const defaultAdhocVariableSpec = (): AdhocVariableSpec => ({
+ name: "",
+ baseFilters: [],
+ filters: [],
+ defaultKeys: [],
+ hide: "dontHide",
+ skipUrlSync: false,
+});
+
+// Define the AdHocFilterWithLabels type
+export interface AdHocFilterWithLabels {
+ key: string;
+ operator: string;
+ value: string;
+ values?: string[];
+ keyLabel?: string;
+ valueLabels?: string[];
+ forceEdit?: boolean;
+ // @deprecated
+ condition?: string;
+}
+
+export const defaultAdHocFilterWithLabels = (): AdHocFilterWithLabels => ({
+ key: "",
+ operator: "",
+ value: "",
+});
+
+// Define the MetricFindValue type
+export interface MetricFindValue {
+ text: string;
+ value?: string | number;
+ group?: string;
+ expandable?: boolean;
+}
+
+export const defaultMetricFindValue = (): MetricFindValue => ({
+ text: "",
+});
+
+// Supported value mapping types
+// `value`: Maps text values to a color or different display text and color. For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.
+// `range`: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.
+// `regex`: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.
+// `special`: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A.
+export type MappingType = "value" | "range" | "regex" | "special";
+
+export const defaultMappingType = (): MappingType => ("value");
+
+// --- Common types ---
+export interface Kind {
+ kind: string;
+ spec: any;
+ metadata?: any;
+}
+
+export const defaultKind = (): Kind => ({
+ kind: "",
+ spec: {},
+});
+
+// Variable types
+export type VariableValue = VariableValueSingle | VariableValueSingle[];
+
+export const defaultVariableValue = (): VariableValue => (defaultVariableValueSingle());
+
+export type VariableValueSingle = string | boolean | number | CustomVariableValue;
+
+export const defaultVariableValueSingle = (): VariableValueSingle => ("");
+
+// Custom variable value
+export interface CustomVariableValue {
+ // The format name or function used in the expression
+ formatter: string | VariableCustomFormatterFn;
+}
+
+export const defaultCustomVariableValue = (): CustomVariableValue => ({
+ formatter: "",
+});
+
+// Custom formatter function
+export interface VariableCustomFormatterFn {
+ value: any;
+ legacyVariableModel: {
+ name: string;
+ type: VariableType;
+ multi: boolean;
+ includeAll: boolean;
+ };
+ legacyDefaultFormatter?: VariableCustomFormatterFn;
+}
+
+export const defaultVariableCustomFormatterFn = (): VariableCustomFormatterFn => ({
+ value: {},
+ legacyVariableModel: {
+ name: "",
+ type: "query",
+ multi: false,
+ includeAll: false,
+},
+});
+
+// Dashboard variable type
+// `query`: Query-generated list of values such as metric names, server names, sensor IDs, data centers, and so on.
+// `adhoc`: Key/value filters that are automatically added to all metric queries for a data source (Prometheus, Loki, InfluxDB, and Elasticsearch only).
+// `constant`: Define a hidden constant.
+// `datasource`: Quickly change the data source for an entire dashboard.
+// `interval`: Interval variables represent time spans.
+// `textbox`: Display a free text input field with an optional default value.
+// `custom`: Define the variable options manually using a comma-separated list.
+// `system`: Variables defined by Grafana. See: https://grafana.com/docs/grafana/latest/dashboards/variables/add-template-variables/#global-variables
+export type VariableType = "query" | "adhoc" | "groupby" | "constant" | "datasource" | "interval" | "textbox" | "custom" | "system" | "snapshot";
+
+export const defaultVariableType = (): VariableType => ("query");
+
+// Custom formatter variable
+export interface CustomFormatterVariable {
+ name: string;
+ type: VariableType;
+ multi: boolean;
+ includeAll: boolean;
+}
+
+export const defaultCustomFormatterVariable = (): CustomFormatterVariable => ({
+ name: "",
+ type: "query",
+ multi: false,
+ includeAll: false,
+});
+
+// FIXME: should we introduce this? --- Variable value option
+export interface VariableValueOption {
+ label: string;
+ value: VariableValueSingle;
+ group?: string;
+}
+
+export const defaultVariableValueOption = (): VariableValueOption => ({
+ label: "",
+ value: defaultVariableValueSingle(),
+});
+
diff --git a/packages/grafana-sql/package.json b/packages/grafana-sql/package.json
index ef556ab0d49..3c217f75312 100644
--- a/packages/grafana-sql/package.json
+++ b/packages/grafana-sql/package.json
@@ -3,7 +3,7 @@
"license": "AGPL-3.0-only",
"private": true,
"name": "@grafana/sql",
- "version": "11.6.0-pre",
+ "version": "12.0.0-pre",
"repository": {
"type": "git",
"url": "http://github.com/grafana/grafana.git",
@@ -15,11 +15,11 @@
},
"dependencies": {
"@emotion/css": "11.13.5",
- "@grafana/data": "11.6.0-pre",
- "@grafana/e2e-selectors": "11.6.0-pre",
+ "@grafana/data": "12.0.0-pre",
+ "@grafana/e2e-selectors": "12.0.0-pre",
"@grafana/plugin-ui": "0.10.1",
- "@grafana/runtime": "11.6.0-pre",
- "@grafana/ui": "11.6.0-pre",
+ "@grafana/runtime": "12.0.0-pre",
+ "@grafana/ui": "12.0.0-pre",
"@react-awesome-query-builder/ui": "6.6.4",
"immutable": "5.0.3",
"lodash": "4.17.21",
diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json
index e72cfe337bd..55785d7de92 100644
--- a/packages/grafana-ui/package.json
+++ b/packages/grafana-ui/package.json
@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/ui",
- "version": "11.6.0-pre",
+ "version": "12.0.0-pre",
"description": "Grafana Components Library",
"keywords": [
"grafana",
@@ -66,10 +66,10 @@
"@emotion/react": "11.14.0",
"@emotion/serialize": "1.3.3",
"@floating-ui/react": "0.27.5",
- "@grafana/data": "11.6.0-pre",
- "@grafana/e2e-selectors": "11.6.0-pre",
+ "@grafana/data": "12.0.0-pre",
+ "@grafana/e2e-selectors": "12.0.0-pre",
"@grafana/faro-web-sdk": "^1.13.2",
- "@grafana/schema": "11.6.0-pre",
+ "@grafana/schema": "12.0.0-pre",
"@hello-pangea/dnd": "17.0.0",
"@leeoniya/ufuzzy": "1.0.18",
"@monaco-editor/react": "4.6.0",
diff --git a/packages/grafana-ui/src/components/Combobox/useComboboxFloat.ts b/packages/grafana-ui/src/components/Combobox/useComboboxFloat.ts
index f5803afdf2e..f2d5ca7de20 100644
--- a/packages/grafana-ui/src/components/Combobox/useComboboxFloat.ts
+++ b/packages/grafana-ui/src/components/Combobox/useComboboxFloat.ts
@@ -1,4 +1,4 @@
-import { autoUpdate, flip, size, useFloating } from '@floating-ui/react';
+import { autoUpdate, autoPlacement, size, useFloating } from '@floating-ui/react';
import { useMemo, useRef, useState } from 'react';
import { measureText } from '../../utils';
@@ -31,10 +31,11 @@ export const useComboboxFloat = (items: Array>,
// the order of middleware is important!
const middleware = [
- flip({
- // see https://floating-ui.com/docs/flip#combining-with-shift
- crossAxis: true,
+ autoPlacement({
+ // see https://floating-ui.com/docs/autoplacement
+ allowedPlacements: ['bottom-start', 'bottom-end', 'top-start', 'top-end'],
boundary: document.body,
+ crossAxis: true,
}),
size({
apply({ availableWidth, availableHeight }) {
diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangeInput.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangeInput.tsx
index d07788bf97e..7ef005c42c6 100644
--- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangeInput.tsx
+++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangeInput.tsx
@@ -1,8 +1,7 @@
import { css, cx } from '@emotion/css';
-import { useDialog } from '@react-aria/dialog';
+import { useDismiss, useFloating, useInteractions } from '@floating-ui/react';
import { FocusScope } from '@react-aria/focus';
-import { useOverlay } from '@react-aria/overlays';
-import { createRef, FormEvent, MouseEvent, useState } from 'react';
+import { FormEvent, MouseEvent, useState } from 'react';
import { dateTime, getDefaultTimeRange, GrafanaTheme2, TimeRange, TimeZone } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
@@ -79,22 +78,21 @@ export const TimeRangeInput = ({
onChange({ from, to, raw: { from, to } });
};
- const overlayRef = createRef();
- const buttonRef = createRef();
+ const { refs, floatingStyles, context } = useFloating({
+ open: isOpen,
+ onOpenChange: setIsOpen,
+ placement: 'bottom-start',
+ strategy: 'fixed',
+ });
- const { dialogProps } = useDialog({}, overlayRef);
-
- const { overlayProps } = useOverlay(
- {
- onClose,
- isDismissable: true,
- isOpen,
- shouldCloseOnInteractOutside: (element) => {
- return !buttonRef.current?.contains(element);
- },
+ const dismiss = useDismiss(context, {
+ bubbles: {
+ outsidePress: false,
},
- overlayRef
- );
+ });
+
+ const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]);
+
return (
{showIcon && }
@@ -119,7 +118,7 @@ export const TimeRangeInput = ({
{isOpen && (
-
+
{
marginLeft: 0,
position: 'absolute',
top: '116%',
- zIndex: theme.zIndex.dropdown,
+ zIndex: theme.zIndex.modal,
}),
pickerInput: cx(
inputStyles.input,
diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx
index 3346c110c51..7b534d88b09 100644
--- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx
+++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx
@@ -151,7 +151,7 @@ export function PanelChrome({
const panelContentId = useId();
const panelTitleId = useId().replace(/:/g, '_');
const { isSelected, onSelect, isSelectable } = useElementSelection(selectionId);
- const pointerDownEvt = useRef();
+ const pointerDownPos = useRef<{ screenX: number; screenY: number }>({ screenX: 0, screenY: 0 });
const hasHeader = !hoverHeader;
@@ -203,11 +203,11 @@ export function PanelChrome({
evt.stopPropagation();
const distance = Math.hypot(
- pointerDownEvt.current?.screenX ?? 0 - evt.screenX,
- pointerDownEvt.current?.screenY ?? 0 - evt.screenY
+ pointerDownPos.current.screenX - evt.screenX,
+ pointerDownPos.current.screenY - evt.screenY
);
- pointerDownEvt.current = undefined;
+ pointerDownPos.current = { screenX: 0, screenY: 0 };
// If we are dragging some distance or clicking on elements that should cancel dragging (panel menu, etc)
if (
@@ -222,7 +222,10 @@ export function PanelChrome({
const onPointerDown = (evt: React.PointerEvent) => {
evt.stopPropagation();
- pointerDownEvt.current = evt;
+
+ pointerDownPos.current = { screenX: evt.screenX, screenY: evt.screenY };
+
+ onDragStart?.(evt);
};
const headerContent = (
@@ -350,11 +353,6 @@ export function PanelChrome({
className={cx(styles.headerContainer, dragClass)}
style={headerStyles}
data-testid="header-container"
- onPointerMove={() => {
- if (pointerDownEvt.current) {
- onDragStart?.(pointerDownEvt.current);
- }
- }}
onPointerDown={onPointerDown}
onMouseEnter={isSelectable ? onHeaderEnter : undefined}
onMouseLeave={isSelectable ? onHeaderLeave : undefined}
diff --git a/pkg/aggregator/go.mod b/pkg/aggregator/go.mod
index e6ca98240ed..eb227a8af37 100644
--- a/pkg/aggregator/go.mod
+++ b/pkg/aggregator/go.mod
@@ -22,7 +22,7 @@ require (
require (
cel.dev/expr v0.19.1 // indirect
- github.com/BurntSushi/toml v1.4.0 // indirect
+ github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
github.com/NYTimes/gziphandler v1.1.1 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/apache/arrow-go/v18 v18.0.1-0.20241212180703-82be143d7c30 // indirect
@@ -39,7 +39,7 @@ require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/elazarl/goproxy v1.7.2 // indirect
github.com/evanphx/json-patch v5.6.0+incompatible // indirect
- github.com/fatih/color v1.17.0 // indirect
+ github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
diff --git a/pkg/aggregator/go.sum b/pkg/aggregator/go.sum
index 0198e0efd67..e947b008d44 100644
--- a/pkg/aggregator/go.sum
+++ b/pkg/aggregator/go.sum
@@ -2,8 +2,9 @@ cel.dev/expr v0.19.1 h1:NciYrtDRIR0lNCnH1LFJegdjspNx9fI59O7TWcua/W4=
cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
+github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I=
github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
@@ -60,8 +61,8 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7
github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U=
github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
-github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
-github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
+github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
+github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
diff --git a/pkg/api/annotations.go b/pkg/api/annotations.go
index 4720e25e91f..5873504d7c9 100644
--- a/pkg/api/annotations.go
+++ b/pkg/api/annotations.go
@@ -16,7 +16,6 @@ import (
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
- "github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
@@ -278,7 +277,7 @@ func (hs *HTTPServer) UpdateAnnotation(c *contextmodel.ReqContext) response.Resp
}
if !hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagAnnotationPermissionUpdate) {
- if canSave, err := hs.canSaveAnnotation(c, annotation); err != nil || !canSave {
+ if canSave, err := hs.canSaveAnnotation(c, hs.AccessControl, annotation); err != nil || !canSave {
return dashboardGuardianResponse(err)
}
}
@@ -336,7 +335,7 @@ func (hs *HTTPServer) PatchAnnotation(c *contextmodel.ReqContext) response.Respo
}
if !hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagAnnotationPermissionUpdate) {
- if canSave, err := hs.canSaveAnnotation(c, annotation); err != nil || !canSave {
+ if canSave, err := hs.canSaveAnnotation(c, hs.AccessControl, annotation); err != nil || !canSave {
return dashboardGuardianResponse(err)
}
}
@@ -502,7 +501,7 @@ func (hs *HTTPServer) DeleteAnnotationByID(c *contextmodel.ReqContext) response.
return resp
}
- if canSave, err := hs.canSaveAnnotation(c, annotation); err != nil || !canSave {
+ if canSave, err := hs.canSaveAnnotation(c, hs.AccessControl, annotation); err != nil || !canSave {
return dashboardGuardianResponse(err)
}
}
@@ -518,25 +517,17 @@ func (hs *HTTPServer) DeleteAnnotationByID(c *contextmodel.ReqContext) response.
return response.Success("Annotation deleted")
}
-func (hs *HTTPServer) canSaveAnnotation(c *contextmodel.ReqContext, annotation *annotations.ItemDTO) (bool, error) {
+func (hs *HTTPServer) canSaveAnnotation(c *contextmodel.ReqContext, ac accesscontrol.AccessControl, annotation *annotations.ItemDTO) (bool, error) {
if annotation.GetType() == annotations.Dashboard {
- return canEditDashboard(c, annotation.DashboardID)
+ return canEditDashboard(c, ac, annotation.DashboardID)
} else {
return true, nil
}
}
-func canEditDashboard(c *contextmodel.ReqContext, dashboardID int64) (bool, error) {
- guard, err := guardian.New(c.Req.Context(), dashboardID, c.SignedInUser.GetOrgID(), c.SignedInUser)
- if err != nil {
- return false, err
- }
-
- if canEdit, err := guard.CanEdit(); err != nil || !canEdit {
- return false, err
- }
-
- return true, nil
+func canEditDashboard(c *contextmodel.ReqContext, ac accesscontrol.AccessControl, dashboardID int64) (bool, error) {
+ evaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsWrite, dashboards.ScopeDashboardsProvider.GetResourceScope(strconv.FormatInt(dashboardID, 10)))
+ return ac.Evaluate(c.Req.Context(), c.SignedInUser, evaluator)
}
func findAnnotationByID(ctx context.Context, repo annotations.Repository, annotationID int64, user *user.SignedInUser) (*annotations.ItemDTO, response.Response) {
@@ -680,7 +671,7 @@ func (hs *HTTPServer) canCreateAnnotation(c *contextmodel.ReqContext, dashboardI
return canSave, err
}
- return canEditDashboard(c, dashboardId)
+ return canEditDashboard(c, hs.AccessControl, dashboardId)
} else { // organization annotations
evaluator := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsCreate, accesscontrol.ScopeAnnotationsTypeOrganization)
return hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluator)
@@ -708,7 +699,7 @@ func (hs *HTTPServer) canMassDeleteAnnotations(c *contextmodel.ReqContext, dashb
return false, err
}
- canSave, err = canEditDashboard(c, dashboardID)
+ canSave, err = canEditDashboard(c, hs.AccessControl, dashboardID)
if err != nil || !canSave {
return false, err
}
diff --git a/pkg/api/annotations_test.go b/pkg/api/annotations_test.go
index 83e11eb91cf..fa4f119f13b 100644
--- a/pkg/api/annotations_test.go
+++ b/pkg/api/annotations_test.go
@@ -19,7 +19,6 @@ import (
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/folder/foldertest"
- "github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web/webtest"
)
@@ -110,7 +109,10 @@ func TestAPI_Annotations(t *testing.T) {
path: "/api/annotations/2",
method: http.MethodPut,
expectedCode: http.StatusOK,
- permissions: []accesscontrol.Permission{{Action: accesscontrol.ActionAnnotationsWrite, Scope: accesscontrol.ScopeAnnotationsTypeDashboard}},
+ permissions: []accesscontrol.Permission{
+ {Action: accesscontrol.ActionAnnotationsWrite, Scope: accesscontrol.ScopeAnnotationsTypeDashboard},
+ {Action: dashboards.ActionDashboardsWrite, Scope: dashboards.ScopeDashboardsAll},
+ },
},
{
desc: "should not be able to update dashboard annotation without correct permission",
@@ -162,7 +164,10 @@ func TestAPI_Annotations(t *testing.T) {
path: "/api/annotations/2",
method: http.MethodPatch,
expectedCode: http.StatusOK,
- permissions: []accesscontrol.Permission{{Action: accesscontrol.ActionAnnotationsWrite, Scope: accesscontrol.ScopeAnnotationsTypeDashboard}},
+ permissions: []accesscontrol.Permission{
+ {Action: accesscontrol.ActionAnnotationsWrite, Scope: accesscontrol.ScopeAnnotationsTypeDashboard},
+ {Action: dashboards.ActionDashboardsWrite, Scope: dashboards.ScopeDashboardsAll},
+ },
},
{
desc: "should not be able to patch dashboard annotation without correct permission",
@@ -215,7 +220,10 @@ func TestAPI_Annotations(t *testing.T) {
method: http.MethodPost,
body: "{\"dashboardId\": 2,\"text\": \"test\"}",
expectedCode: http.StatusOK,
- permissions: []accesscontrol.Permission{{Action: accesscontrol.ActionAnnotationsCreate, Scope: accesscontrol.ScopeAnnotationsTypeDashboard}},
+ permissions: []accesscontrol.Permission{
+ {Action: accesscontrol.ActionAnnotationsCreate, Scope: accesscontrol.ScopeAnnotationsTypeDashboard},
+ {Action: dashboards.ActionDashboardsWrite, Scope: dashboards.ScopeDashboardsAll},
+ },
},
{
desc: "should not be able to create dashboard annotation without correct permission",
@@ -273,7 +281,10 @@ func TestAPI_Annotations(t *testing.T) {
path: "/api/annotations/2",
method: http.MethodDelete,
expectedCode: http.StatusOK,
- permissions: []accesscontrol.Permission{{Action: accesscontrol.ActionAnnotationsDelete, Scope: accesscontrol.ScopeAnnotationsTypeDashboard}},
+ permissions: []accesscontrol.Permission{
+ {Action: accesscontrol.ActionAnnotationsDelete, Scope: accesscontrol.ScopeAnnotationsTypeDashboard},
+ {Action: dashboards.ActionDashboardsWrite, Scope: dashboards.ScopeDashboardsAll},
+ },
},
{
desc: "should not be able to delete dashboard annotation without correct permission",
@@ -341,7 +352,10 @@ func TestAPI_Annotations(t *testing.T) {
body: "{\"dashboardId\": 2, \"panelId\": 1}",
method: http.MethodPost,
expectedCode: http.StatusOK,
- permissions: []accesscontrol.Permission{{Action: accesscontrol.ActionAnnotationsDelete, Scope: accesscontrol.ScopeAnnotationsTypeDashboard}},
+ permissions: []accesscontrol.Permission{
+ {Action: accesscontrol.ActionAnnotationsDelete, Scope: accesscontrol.ScopeAnnotationsTypeDashboard},
+ {Action: dashboards.ActionDashboardsWrite, Scope: dashboards.ScopeDashboardsAll},
+ },
},
{
desc: "should not be able to mass delete dashboard annotations without correct permission",
@@ -382,10 +396,6 @@ func TestAPI_Annotations(t *testing.T) {
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
- // Don't need access to dashboards if annotationPermissionUpdate is enabled
- if len(tt.featureFlags) == 0 {
- setUpRBACGuardian(t)
- }
server := SetupAPITestServer(t, func(hs *HTTPServer) {
hs.Cfg = setting.NewCfg()
repo := annotationstest.NewFakeAnnotationsRepo()
@@ -518,12 +528,3 @@ func TestService_AnnotationTypeScopeResolver(t *testing.T) {
})
}
}
-
-func setUpRBACGuardian(t *testing.T) {
- origNewGuardian := guardian.New
- t.Cleanup(func() {
- guardian.New = origNewGuardian
- })
-
- guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanEditValue: true, CanViewValue: true})
-}
diff --git a/pkg/api/api.go b/pkg/api/api.go
index 77efde07038..0026529cbd8 100644
--- a/pkg/api/api.go
+++ b/pkg/api/api.go
@@ -117,6 +117,7 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/admin/orgs/edit/:id", authorizeInOrg(ac.UseGlobalOrg, ac.OrgsAccessEvaluator), hs.Index)
r.Get("/admin/stats", authorize(ac.EvalPermission(ac.ActionServerStatsRead)), hs.Index)
r.Get("/admin/provisioning", reqOrgAdmin, hs.Index)
+ r.Get("/admin/provisioning/*", reqOrgAdmin, hs.Index)
if hs.Features.IsEnabledGlobally(featuremgmt.FlagOnPremToCloudMigrations) {
r.Get("/admin/migrate-to-cloud", authorize(cloudmigration.MigrationAssistantAccess), hs.Index)
@@ -462,22 +463,24 @@ func (hs *HTTPServer) registerRoutes() {
// Dashboard
apiRoute.Group("/dashboards", func(dashboardRoute routing.RouteRegister) {
- dashboardRoute.Get("/uid/:uid", authorize(ac.EvalPermission(dashboards.ActionDashboardsRead)), routing.Wrap(hs.GetDashboard))
+ dashUIDScope := dashboards.ScopeDashboardsProvider.GetResourceScopeUID(ac.Parameter(":uid"))
+
+ dashboardRoute.Get("/uid/:uid", authorize(ac.EvalPermission(dashboards.ActionDashboardsRead, dashUIDScope)), routing.Wrap(hs.GetDashboard))
if hs.Features.IsEnabledGlobally(featuremgmt.FlagDashboardRestore) {
- dashboardRoute.Delete("/uid/:uid", authorize(ac.EvalPermission(dashboards.ActionDashboardsDelete)), routing.Wrap(hs.SoftDeleteDashboard))
+ dashboardRoute.Delete("/uid/:uid", authorize(ac.EvalPermission(dashboards.ActionDashboardsDelete, dashUIDScope)), routing.Wrap(hs.SoftDeleteDashboard))
} else {
- dashboardRoute.Delete("/uid/:uid", authorize(ac.EvalPermission(dashboards.ActionDashboardsDelete)), routing.Wrap(hs.DeleteDashboardByUID))
+ dashboardRoute.Delete("/uid/:uid", authorize(ac.EvalPermission(dashboards.ActionDashboardsDelete, dashUIDScope)), routing.Wrap(hs.DeleteDashboardByUID))
}
dashboardRoute.Group("/uid/:uid", func(dashUidRoute routing.RouteRegister) {
- dashUidRoute.Get("/versions", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.GetDashboardVersions))
- dashUidRoute.Post("/restore", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.RestoreDashboardVersion))
- dashUidRoute.Get("/versions/:id", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.GetDashboardVersion))
+ dashUidRoute.Get("/versions", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite, dashUIDScope)), routing.Wrap(hs.GetDashboardVersions))
+ dashUidRoute.Post("/restore", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite, dashUIDScope)), routing.Wrap(hs.RestoreDashboardVersion))
+ dashUidRoute.Get("/versions/:id", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite, dashUIDScope)), routing.Wrap(hs.GetDashboardVersion))
if hs.Features.IsEnabledGlobally(featuremgmt.FlagDashboardRestore) {
- dashUidRoute.Patch("/trash", reqOrgAdmin, routing.Wrap(hs.RestoreDeletedDashboard))
- dashUidRoute.Delete("/trash", reqOrgAdmin, routing.Wrap(hs.HardDeleteDashboardByUID))
+ dashUidRoute.Patch("/trash", reqOrgAdmin, authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite, dashUIDScope)), routing.Wrap(hs.RestoreDeletedDashboard))
+ dashUidRoute.Delete("/trash", reqOrgAdmin, authorize(ac.EvalPermission(dashboards.ActionDashboardsDelete, dashUIDScope)), routing.Wrap(hs.HardDeleteDashboardByUID))
}
dashUidRoute.Group("/permissions", func(dashboardPermissionRoute routing.RouteRegister) {
@@ -497,9 +500,10 @@ func (hs *HTTPServer) registerRoutes() {
// Deprecated: use /uid/:uid API instead.
dashboardRoute.Group("/id/:dashboardId", func(dashIdRoute routing.RouteRegister) {
- dashIdRoute.Get("/versions", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.GetDashboardVersions))
- dashIdRoute.Get("/versions/:id", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.GetDashboardVersion))
- dashIdRoute.Post("/restore", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.RestoreDashboardVersion))
+ dashIDScope := dashboards.ScopeDashboardsProvider.GetResourceScope(ac.Parameter(":dashboardId"))
+ dashIdRoute.Get("/versions", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite, dashIDScope)), routing.Wrap(hs.GetDashboardVersions))
+ dashIdRoute.Get("/versions/:id", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite, dashIDScope)), routing.Wrap(hs.GetDashboardVersion))
+ dashIdRoute.Post("/restore", authorize(ac.EvalPermission(dashboards.ActionDashboardsWrite, dashIDScope)), routing.Wrap(hs.RestoreDashboardVersion))
dashIdRoute.Group("/permissions", func(dashboardPermissionRoute routing.RouteRegister) {
dashboardPermissionRoute.Get("/", authorize(ac.EvalPermission(dashboards.ActionDashboardsPermissionsRead)), routing.Wrap(hs.GetDashboardPermissionList))
diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go
index c1c61753676..17d37830645 100644
--- a/pkg/api/dashboard.go
+++ b/pkg/api/dashboard.go
@@ -27,7 +27,6 @@ import (
"github.com/grafana/grafana/pkg/services/dashboardversion/dashverimpl"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
- "github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/org"
pref "github.com/grafana/grafana/pkg/services/preference"
publicdashboardModels "github.com/grafana/grafana/pkg/services/publicdashboards/models"
@@ -141,18 +140,19 @@ func (hs *HTTPServer) GetDashboard(c *contextmodel.ReqContext) response.Response
dash.Data.Set("id", dash.ID)
}
}
- guardian, err := guardian.NewByDashboard(ctx, dash, c.SignedInUser.GetOrgID(), c.SignedInUser)
- if err != nil {
- return response.Err(err)
- }
- if canView, err := guardian.CanView(); err != nil || !canView {
- return dashboardGuardianResponse(err)
+ dashScope := dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dash.UID)
+ writeEvaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsWrite, dashScope)
+ canSave, _ := hs.AccessControl.Evaluate(ctx, c.SignedInUser, writeEvaluator)
+ canEdit := canSave
+ //nolint:staticcheck // ViewersCanEdit is deprecated but still used for backward compatibility
+ if hs.Cfg.ViewersCanEdit {
+ canEdit = true
}
- canEdit, _ := guardian.CanEdit()
- canSave, _ := guardian.CanSave()
- canAdmin, _ := guardian.CanAdmin()
- canDelete, _ := guardian.CanDelete()
+ deleteEvaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsDelete, dashScope)
+ canDelete, _ := hs.AccessControl.Evaluate(ctx, c.SignedInUser, deleteEvaluator)
+ adminEvaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsPermissionsWrite, dashScope)
+ canAdmin, _ := hs.AccessControl.Evaluate(ctx, c.SignedInUser, adminEvaluator)
isStarred, err := hs.isDashboardStarredByUser(c, dash.ID)
if err != nil {
@@ -369,15 +369,6 @@ func (hs *HTTPServer) RestoreDeletedDashboard(c *contextmodel.ReqContext) respon
return response.Error(http.StatusNotFound, "Dashboard not found", err)
}
- guardian, err := guardian.NewByDashboard(c.Req.Context(), dash, c.SignedInUser.GetOrgID(), c.SignedInUser)
- if err != nil {
- return response.Err(err)
- }
-
- if canRestore, err := guardian.CanSave(); err != nil || !canRestore {
- return dashboardGuardianResponse(err)
- }
-
err = hs.DashboardService.RestoreDashboard(c.Req.Context(), dash, c.SignedInUser, cmd.FolderUID)
if err != nil {
var dashboardErr dashboards.DashboardErr
@@ -417,16 +408,7 @@ func (hs *HTTPServer) SoftDeleteDashboard(c *contextmodel.ReqContext) response.R
return rsp
}
- guardian, err := guardian.NewByDashboard(c.Req.Context(), dash, c.SignedInUser.GetOrgID(), c.SignedInUser)
- if err != nil {
- return response.Err(err)
- }
-
- if canDelete, err := guardian.CanDelete(); err != nil || !canDelete {
- return dashboardGuardianResponse(err)
- }
-
- err = hs.DashboardService.SoftDeleteDashboard(c.Req.Context(), c.SignedInUser.GetOrgID(), uid)
+ err := hs.DashboardService.SoftDeleteDashboard(c.Req.Context(), c.SignedInUser.GetOrgID(), uid)
if err != nil {
var dashboardErr dashboards.DashboardErr
if ok := errors.As(err, &dashboardErr); ok {
@@ -498,21 +480,12 @@ func (hs *HTTPServer) deleteDashboard(c *contextmodel.ReqContext) response.Respo
}
}
- guardian, err := guardian.NewByDashboard(c.Req.Context(), dash, c.SignedInUser.GetOrgID(), c.SignedInUser)
- if err != nil {
- return response.Err(err)
- }
-
- if canDelete, err := guardian.CanDelete(); err != nil || !canDelete {
- return dashboardGuardianResponse(err)
- }
-
if dash.IsFolder {
return response.Error(http.StatusBadRequest, "Use folders endpoint for deleting folders.", nil)
}
// disconnect all library elements for this dashboard
- err = hs.LibraryElementService.DisconnectElementsFromDashboard(c.Req.Context(), dash.ID)
+ err := hs.LibraryElementService.DisconnectElementsFromDashboard(c.Req.Context(), dash.ID)
if err != nil {
hs.log.Error(
"Failed to disconnect library elements",
@@ -840,14 +813,6 @@ func (hs *HTTPServer) GetDashboardVersions(c *contextmodel.ReqContext) response.
return rsp
}
- guardian, err := guardian.NewByDashboard(c.Req.Context(), dash, c.SignedInUser.GetOrgID(), c.SignedInUser)
- if err != nil {
- return response.Err(err)
- }
- if canSave, err := guardian.CanSave(); err != nil || !canSave {
- return dashboardGuardianResponse(err)
- }
-
query := dashver.ListDashboardVersionsQuery{
OrgID: c.SignedInUser.GetOrgID(),
DashboardID: dash.ID,
@@ -959,15 +924,6 @@ func (hs *HTTPServer) GetDashboardVersion(c *contextmodel.ReqContext) response.R
return rsp
}
- guardian, err := guardian.NewByDashboard(c.Req.Context(), dash, c.SignedInUser.GetOrgID(), c.SignedInUser)
- if err != nil {
- return response.Err(err)
- }
-
- if canSave, err := guardian.CanSave(); err != nil || !canSave {
- return dashboardGuardianResponse(err)
- }
-
version, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64)
if err != nil {
return response.Err(err)
@@ -1027,22 +983,15 @@ func (hs *HTTPServer) CalculateDashboardDiff(c *contextmodel.ReqContext) respons
if err := web.Bind(c.Req, &apiOptions); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
}
- guardianBase, err := guardian.New(c.Req.Context(), apiOptions.Base.DashboardId, c.SignedInUser.GetOrgID(), c.SignedInUser)
- if err != nil {
- return response.Err(err)
- }
- if canSave, err := guardianBase.CanSave(); err != nil || !canSave {
+ evaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsWrite, dashboards.ScopeDashboardsProvider.GetResourceScope(strconv.FormatInt(apiOptions.Base.DashboardId, 10)))
+ if canWrite, err := hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluator); err != nil || !canWrite {
return dashboardGuardianResponse(err)
}
if apiOptions.Base.DashboardId != apiOptions.New.DashboardId {
- guardianNew, err := guardian.New(c.Req.Context(), apiOptions.New.DashboardId, c.SignedInUser.GetOrgID(), c.SignedInUser)
- if err != nil {
- return response.Err(err)
- }
-
- if canSave, err := guardianNew.CanSave(); err != nil || !canSave {
+ evaluator = accesscontrol.EvalPermission(dashboards.ActionDashboardsWrite, dashboards.ScopeDashboardsProvider.GetResourceScope(strconv.FormatInt(apiOptions.New.DashboardId, 10)))
+ if canWrite, err := hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluator); err != nil || !canWrite {
return dashboardGuardianResponse(err)
}
}
@@ -1159,15 +1108,6 @@ func (hs *HTTPServer) RestoreDashboardVersion(c *contextmodel.ReqContext) respon
return rsp
}
- guardian, err := guardian.NewByDashboard(c.Req.Context(), dash, c.SignedInUser.GetOrgID(), c.SignedInUser)
- if err != nil {
- return response.Err(err)
- }
-
- if canSave, err := guardian.CanSave(); err != nil || !canSave {
- return dashboardGuardianResponse(err)
- }
-
versionQuery := dashver.GetDashboardVersionQuery{DashboardID: dashID, DashboardUID: dash.UID, Version: apiCmd.Version, OrgID: c.SignedInUser.GetOrgID()}
version, err := hs.dashboardVersionService.Get(c.Req.Context(), &versionQuery)
if err != nil {
diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go
index b583c550da7..4ebe1e8708f 100644
--- a/pkg/api/dashboard_snapshot.go
+++ b/pkg/api/dashboard_snapshot.go
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"net/http"
+ "strconv"
"time"
"github.com/grafana/grafana/pkg/api/dtos"
@@ -17,7 +18,6 @@ import (
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
"github.com/grafana/grafana/pkg/services/featuremgmt"
- "github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/util/errhttp"
"github.com/grafana/grafana/pkg/web"
@@ -226,21 +226,15 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *contextmodel.ReqContext) respon
dashboardID := queryResult.Dashboard.Get("id").MustInt64()
if dashboardID != 0 {
- g, err := guardian.New(c.Req.Context(), dashboardID, c.SignedInUser.GetOrgID(), c.SignedInUser)
- if err != nil {
- if !errors.Is(err, dashboards.ErrDashboardNotFound) {
- return response.Err(err)
- }
- } else {
- canEdit, err := g.CanEdit()
- // check for permissions only if the dashboard is found
- if err != nil && !errors.Is(err, dashboards.ErrDashboardNotFound) {
- return response.Error(http.StatusInternalServerError, "Error while checking permissions for snapshot", err)
- }
+ evaluator := ac.EvalPermission(dashboards.ActionDashboardsWrite, dashboards.ScopeDashboardsProvider.GetResourceScope(strconv.FormatInt(dashboardID, 10)))
+ canEdit, err := hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluator)
+ // check for permissions only if the dashboard is found
+ if err != nil && !errors.Is(err, dashboards.ErrDashboardNotFound) {
+ return response.Error(http.StatusInternalServerError, "Error while checking permissions for snapshot", err)
+ }
- if !canEdit && queryResult.UserID != c.SignedInUser.UserID && !errors.Is(err, dashboards.ErrDashboardNotFound) {
- return response.Error(http.StatusForbidden, "Access denied to this snapshot", nil)
- }
+ if !canEdit && queryResult.UserID != c.SignedInUser.UserID && !errors.Is(err, dashboards.ErrDashboardNotFound) {
+ return response.Error(http.StatusForbidden, "Access denied to this snapshot", nil)
}
}
diff --git a/pkg/api/dashboard_snapshot_test.go b/pkg/api/dashboard_snapshot_test.go
index 24dcd3ef4ec..01d9e6ce4d0 100644
--- a/pkg/api/dashboard_snapshot_test.go
+++ b/pkg/api/dashboard_snapshot_test.go
@@ -15,13 +15,12 @@ import (
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/db/dbtest"
- "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
+ "github.com/grafana/grafana/pkg/services/accesscontrol/actest"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
"github.com/grafana/grafana/pkg/services/featuremgmt"
- "github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
@@ -41,7 +40,7 @@ func TestHTTPServer_DeleteDashboardSnapshot(t *testing.T) {
hs.DashboardService = svc
hs.AccessControl = acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
- guardian.InitAccessControlGuardian(hs.Cfg, hs.AccessControl, hs.DashboardService, hs.folderService, log.NewNopLogger())
+ hs.AccessControl.RegisterScopeAttributeResolver(dashboards.NewDashboardIDScopeResolver(svc, nil))
})
}
@@ -378,6 +377,7 @@ func buildHttpServer(d dashboardsnapshots.Service, snapshotEnabled bool) *HTTPSe
Cfg: &setting.Cfg{
SnapshotEnabled: snapshotEnabled,
},
+ AccessControl: actest.FakeAccessControl{ExpectedEvaluate: true},
}
return hs
}
diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go
index 3ed18a073e8..0dfdcd43934 100644
--- a/pkg/api/dashboard_test.go
+++ b/pkg/api/dashboard_test.go
@@ -1,6 +1,7 @@
package api
import (
+ "bytes"
"context"
"encoding/json"
"fmt"
@@ -22,8 +23,10 @@ import (
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/db/dbtest"
+ "github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/infra/usagestats"
"github.com/grafana/grafana/pkg/services/accesscontrol"
@@ -134,7 +137,10 @@ func newTestLive(t *testing.T, store db.DB) *live.GrafanaLive {
nil,
&usagestats.UsageStatsMock{T: t},
nil,
- features, acimpl.ProvideAccessControl(features), &dashboards.FakeDashboardService{}, annotationstest.NewFakeAnnotationsRepo(), nil)
+ features, acimpl.ProvideAccessControl(features),
+ &dashboards.FakeDashboardService{},
+ annotationstest.NewFakeAnnotationsRepo(),
+ nil, nil)
require.NoError(t, err)
return gLive
}
@@ -327,12 +333,16 @@ func TestHTTPServer_GetDashboardVersions_AccessControl(t *testing.T) {
hs.AccessControl = acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
hs.starService = startest.NewStarServiceFake()
- hs.dashboardVersionService = &dashvertest.FakeDashboardVersionService{
- ExpectedListDashboarVersions: []*dashver.DashboardVersionDTO{},
- ExpectedDashboardVersion: &dashver.DashboardVersionDTO{},
+ expectedDashVersions := []*dashver.DashboardVersionDTO{
+ {Data: simplejson.NewFromAny(map[string]any{"title": "Dash"})},
+ {Data: simplejson.NewFromAny(map[string]any{"title": "Dash updated"})},
}
- guardian.InitAccessControlGuardian(hs.Cfg, hs.AccessControl, hs.DashboardService, hs.folderService, log.NewNopLogger())
+ hs.dashboardVersionService = &dashvertest.FakeDashboardVersionService{
+ ExpectedListDashboarVersions: []*dashver.DashboardVersionDTO{},
+ ExpectedDashboardVersions: expectedDashVersions,
+ ExpectedDashboardVersion: &dashver.DashboardVersionDTO{},
+ }
})
}
@@ -344,6 +354,17 @@ func TestHTTPServer_GetDashboardVersions_AccessControl(t *testing.T) {
return server.Send(webtest.RequestWithSignedInUser(server.NewGetRequest("/api/dashboards/uid/1/versions"), userWithPermissions(1, permissions)))
}
+ calculateDiff := func(server *webtest.Server, permissions []accesscontrol.Permission) (*http.Response, error) {
+ cmd := &dtos.CalculateDiffOptions{
+ Base: dtos.CalculateDiffTarget{DashboardId: 1, Version: 1},
+ New: dtos.CalculateDiffTarget{DashboardId: 1, Version: 2},
+ DiffType: "json",
+ }
+ jsonBytes, err := json.Marshal(cmd)
+ require.NoError(t, err)
+ return server.SendJSON(webtest.RequestWithSignedInUser(server.NewPostRequest("/api/dashboards/calculate-diff", bytes.NewReader(jsonBytes)), userWithPermissions(1, permissions)))
+ }
+
t.Run("Should not be able to list dashboard versions without correct permission", func(t *testing.T) {
server := setup()
@@ -363,7 +384,6 @@ func TestHTTPServer_GetDashboardVersions_AccessControl(t *testing.T) {
server := setup()
permissions := []accesscontrol.Permission{
- {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:1"},
{Action: dashboards.ActionDashboardsWrite, Scope: "dashboards:uid:1"},
}
@@ -378,6 +398,28 @@ func TestHTTPServer_GetDashboardVersions_AccessControl(t *testing.T) {
require.NoError(t, res.Body.Close())
})
+
+ t.Run("Should be able to diff dashboards with correct permissions", func(t *testing.T) {
+ server := setup()
+
+ permissions := []accesscontrol.Permission{
+ {Action: dashboards.ActionDashboardsWrite, Scope: dashboards.ScopeDashboardsAll},
+ }
+
+ res, err := calculateDiff(server, permissions)
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusOK, res.StatusCode)
+ require.NoError(t, res.Body.Close())
+ })
+
+ t.Run("Should not be able to diff dashboards without permissions", func(t *testing.T) {
+ server := setup()
+
+ res, err := calculateDiff(server, []accesscontrol.Permission{})
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusForbidden, res.StatusCode)
+ require.NoError(t, res.Body.Close())
+ })
}
func TestDashboardAPIEndpoint(t *testing.T) {
@@ -527,39 +569,6 @@ func TestDashboardAPIEndpoint(t *testing.T) {
}),
},
}
- sqlmock := dbtest.NewFakeDB()
- cmd := dtos.CalculateDiffOptions{
- Base: dtos.CalculateDiffTarget{
- DashboardId: 1,
- Version: 1,
- },
- New: dtos.CalculateDiffTarget{
- DashboardId: 2,
- Version: 2,
- },
- DiffType: "basic",
- }
-
- t.Run("when user does not have permission", func(t *testing.T) {
- role := org.RoleViewer
- postDiffScenario(t, "When calling POST on", "/api/dashboards/calculate-diff", "/api/dashboards/calculate-diff", cmd, role, func(sc *scenarioContext) {
- guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: false})
-
- callPostDashboard(sc)
- assert.Equal(t, http.StatusForbidden, sc.resp.Code)
- }, sqlmock, fakeDashboardVersionService)
- })
-
- t.Run("when user does have permission", func(t *testing.T) {
- role := org.RoleAdmin
- postDiffScenario(t, "When calling POST on", "/api/dashboards/calculate-diff", "/api/dashboards/calculate-diff", cmd, role, func(sc *scenarioContext) {
- guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true})
- // This test shouldn't hit GetDashboardACLInfoList, so no setup needed
- sc.dashboardVersionService = fakeDashboardVersionService
- callPostDashboard(sc)
- assert.Equal(t, http.StatusOK, sc.resp.Code)
- }, sqlmock, fakeDashboardVersionService)
- })
})
t.Run("Given dashboard in folder being restored should restore to folder", func(t *testing.T) {
@@ -588,11 +597,6 @@ func TestDashboardAPIEndpoint(t *testing.T) {
},
}
mockSQLStore := dbtest.NewFakeDB()
- origNewGuardian := guardian.New
- guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true})
- t.Cleanup(func() {
- guardian.New = origNewGuardian
- })
restoreDashboardVersionScenario(t, "When calling POST on", "/api/dashboards/id/1/restore",
"/api/dashboards/id/:dashboardId/restore", dashboardService, fakeDashboardVersionService, cmd, func(sc *scenarioContext) {
@@ -648,7 +652,6 @@ func TestDashboardAPIEndpoint(t *testing.T) {
require.NoError(t, err)
qResult := &dashboards.Dashboard{ID: 1, Data: dataValue}
dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(qResult, nil)
- guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanViewValue: true})
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/dash", "/api/dashboards/uid/:uid", org.RoleEditor, func(sc *scenarioContext) {
fakeProvisioningService := provisioning.NewProvisioningServiceMock(context.Background())
@@ -678,7 +681,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
LibraryElementService: &libraryelementsfake.LibraryElementService{},
dashboardProvisioningService: mockDashboardProvisioningService{},
SQLStore: mockSQLStore,
- AccessControl: accesscontrolmock.New(),
+ AccessControl: actest.FakeAccessControl{ExpectedEvaluate: true},
DashboardService: dashboardService,
Features: featuremgmt.WithFeatures(),
starService: startest.NewStarServiceFake(),
@@ -710,7 +713,6 @@ func TestDashboardAPIEndpoint(t *testing.T) {
Data: dataValue,
}
dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(qResult, nil)
- guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanViewValue: true})
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/dash", "/api/dashboards/uid/:uid", org.RoleEditor, func(sc *scenarioContext) {
hs := &HTTPServer{
@@ -718,7 +720,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
LibraryPanelService: &mockLibraryPanelService{},
LibraryElementService: &libraryelementsfake.LibraryElementService{},
SQLStore: mockSQLStore,
- AccessControl: accesscontrolmock.New(),
+ AccessControl: actest.FakeAccessControl{ExpectedEvaluate: true},
DashboardService: dashboardService,
Features: featuremgmt.WithFeatures(),
starService: startest.NewStarServiceFake(),
@@ -753,7 +755,7 @@ func TestDashboardVersionsAPIEndpoint(t *testing.T) {
Cfg: cfg,
pluginStore: &pluginstore.FakePluginStore{},
SQLStore: mockSQLStore,
- AccessControl: accesscontrolmock.New(),
+ AccessControl: actest.FakeAccessControl{ExpectedEvaluate: true},
Features: featuremgmt.WithFeatures(),
DashboardService: dashboardService,
dashboardVersionService: fakeDashboardVersionService,
@@ -765,13 +767,8 @@ func TestDashboardVersionsAPIEndpoint(t *testing.T) {
}
}
- setUp := func() {
- guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true})
- }
-
loggedInUserScenarioWithRole(t, "When user exists and calling GET on", "GET", "/api/dashboards/id/2/versions",
"/api/dashboards/id/:dashboardId/versions", org.RoleEditor, func(sc *scenarioContext) {
- setUp()
fakeDashboardVersionService.ExpectedListDashboarVersions = []*dashver.DashboardVersionDTO{
{
Version: 1,
@@ -797,7 +794,6 @@ func TestDashboardVersionsAPIEndpoint(t *testing.T) {
loggedInUserScenarioWithRole(t, "When user does not exist and calling GET on", "GET", "/api/dashboards/id/2/versions",
"/api/dashboards/id/:dashboardId/versions", org.RoleEditor, func(sc *scenarioContext) {
- setUp()
fakeDashboardVersionService.ExpectedListDashboarVersions = []*dashver.DashboardVersionDTO{
{
Version: 1,
@@ -823,7 +819,6 @@ func TestDashboardVersionsAPIEndpoint(t *testing.T) {
loggedInUserScenarioWithRole(t, "When failing to get user and calling GET on", "GET", "/api/dashboards/id/2/versions",
"/api/dashboards/id/:dashboardId/versions", org.RoleEditor, func(sc *scenarioContext) {
- setUp()
fakeDashboardVersionService.ExpectedListDashboarVersions = []*dashver.DashboardVersionDTO{
{
Version: 1,
@@ -882,6 +877,7 @@ func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, pr
cfg, dashboardStore, folderStore, features, folderPermissions,
ac, folderSvc, fStore, nil, client.MockTestRestConfig{}, nil, quotaService, nil, nil, nil,
dualwrite.ProvideTestService(), sort.ProvideService(),
+ serverlock.ProvideService(db, tracing.InitializeTracerForTest()), kvstore.NewFakeKVStore(),
)
require.NoError(t, err)
dashboardService.(dashboards.PermissionsRegistrationService).RegisterDashboardPermissions(dashboardPermissions)
@@ -891,6 +887,7 @@ func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, pr
cfg, dashboardStore, folderStore, features, folderPermissions,
ac, folderSvc, fStore, nil, client.MockTestRestConfig{}, nil, quotaService, nil, nil, nil,
dualwrite.ProvideTestService(), sort.ProvideService(),
+ serverlock.ProvideService(db, tracing.InitializeTracerForTest()), kvstore.NewFakeKVStore(),
)
require.NoError(t, err)
@@ -978,47 +975,6 @@ func postDashboardScenario(t *testing.T, desc string, url string, routePattern s
})
}
-func postDiffScenario(t *testing.T, desc string, url string, routePattern string, cmd dtos.CalculateDiffOptions,
- role org.RoleType, fn scenarioFunc, sqlmock db.DB, fakeDashboardVersionService *dashvertest.FakeDashboardVersionService,
-) {
- t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
- cfg := setting.NewCfg()
-
- dashSvc := dashboards.NewFakeDashboardService(t)
- hs := HTTPServer{
- Cfg: cfg,
- ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()),
- Live: newTestLive(t, db.InitTestDB(t)),
- QuotaService: quotatest.New(false, nil),
- LibraryPanelService: &mockLibraryPanelService{},
- LibraryElementService: &libraryelementsfake.LibraryElementService{},
- SQLStore: sqlmock,
- dashboardVersionService: fakeDashboardVersionService,
- Features: featuremgmt.WithFeatures(),
- DashboardService: dashSvc,
- tracer: tracing.InitializeTracerForTest(),
- }
-
- sc := setupScenarioContext(t, url)
- sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response {
- c.Req.Body = mockRequestBody(cmd)
- c.Req.Header.Add("Content-Type", "application/json")
- sc.context = c
- sc.context.SignedInUser = &user.SignedInUser{
- OrgID: testOrgID,
- UserID: testUserID,
- }
- sc.context.OrgRole = role
-
- return hs.CalculateDashboardDiff(c)
- })
-
- sc.m.Post(routePattern, sc.defaultHandler)
-
- fn(sc)
- })
-}
-
func restoreDashboardVersionScenario(t *testing.T, desc string, url string, routePattern string,
mock *dashboards.FakeDashboardService, fakeDashboardVersionService *dashvertest.FakeDashboardVersionService,
cmd dtos.RestoreDashboardVersionCommand, fn scenarioFunc, sqlStore db.DB,
diff --git a/pkg/api/dtos/frontend_settings.go b/pkg/api/dtos/frontend_settings.go
index 60fe760ce06..9193f3d6354 100644
--- a/pkg/api/dtos/frontend_settings.go
+++ b/pkg/api/dtos/frontend_settings.go
@@ -154,20 +154,21 @@ type FrontendSettingsSqlConnectionLimitsDTO struct {
}
type FrontendSettingsDTO struct {
- DefaultDatasource string `json:"defaultDatasource"`
- Datasources map[string]plugins.DataSourceDTO `json:"datasources"`
- MinRefreshInterval string `json:"minRefreshInterval"`
- Panels map[string]plugins.PanelDTO `json:"panels"`
- Apps map[string]*plugins.AppDTO `json:"apps"`
- AppUrl string `json:"appUrl"`
- AppSubUrl string `json:"appSubUrl"`
- AllowOrgCreate bool `json:"allowOrgCreate"`
- AuthProxyEnabled bool `json:"authProxyEnabled"`
- LdapEnabled bool `json:"ldapEnabled"`
- JwtHeaderName string `json:"jwtHeaderName"`
- JwtUrlLogin bool `json:"jwtUrlLogin"`
- LiveEnabled bool `json:"liveEnabled"`
- AutoAssignOrg bool `json:"autoAssignOrg"`
+ DefaultDatasource string `json:"defaultDatasource"`
+ Datasources map[string]plugins.DataSourceDTO `json:"datasources"`
+ MinRefreshInterval string `json:"minRefreshInterval"`
+ Panels map[string]plugins.PanelDTO `json:"panels"`
+ Apps map[string]*plugins.AppDTO `json:"apps"`
+ AppUrl string `json:"appUrl"`
+ AppSubUrl string `json:"appSubUrl"`
+ AllowOrgCreate bool `json:"allowOrgCreate"`
+ AuthProxyEnabled bool `json:"authProxyEnabled"`
+ LdapEnabled bool `json:"ldapEnabled"`
+ JwtHeaderName string `json:"jwtHeaderName"`
+ JwtUrlLogin bool `json:"jwtUrlLogin"`
+ LiveEnabled bool `json:"liveEnabled"`
+ LiveMessageSizeLimit int `json:"liveMessageSizeLimit"`
+ AutoAssignOrg bool `json:"autoAssignOrg"`
VerifyEmailEnabled bool `json:"verifyEmailEnabled"`
SigV4AuthEnabled bool `json:"sigV4AuthEnabled"`
diff --git a/pkg/api/folder_bench_test.go b/pkg/api/folder_bench_test.go
index 5847529c941..03a9ab1cf63 100644
--- a/pkg/api/folder_bench_test.go
+++ b/pkg/api/folder_bench_test.go
@@ -18,8 +18,10 @@ import (
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/db"
+ "github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
@@ -476,6 +478,8 @@ func setupServer(b testing.TB, sc benchScenario, features featuremgmt.FeatureTog
sc.cfg, dashStore, folderStore,
features, folderPermissions, ac,
folderServiceWithFlagOn, fStore, nil, client.MockTestRestConfig{}, nil, quotaSrv, nil, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService(),
+ serverlock.ProvideService(sc.db, tracing.InitializeTracerForTest()),
+ kvstore.NewFakeKVStore(),
)
require.NoError(b, err)
diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go
index 840ae125594..96e8b75a0ec 100644
--- a/pkg/api/folder_test.go
+++ b/pkg/api/folder_test.go
@@ -768,3 +768,12 @@ func TestSetDefaultPermissionsWhenCreatingFolder(t *testing.T) {
})
}
}
+
+func setUpRBACGuardian(t *testing.T) {
+ origNewGuardian := guardian.New
+ t.Cleanup(func() {
+ guardian.New = origNewGuardian
+ })
+
+ guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanEditValue: true, CanViewValue: true})
+}
diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go
index 6a9ee667d25..9028bd4faea 100644
--- a/pkg/api/frontendsettings.go
+++ b/pkg/api/frontendsettings.go
@@ -194,6 +194,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
JwtHeaderName: hs.Cfg.JWTAuth.HeaderName,
JwtUrlLogin: hs.Cfg.JWTAuth.URLLogin,
LiveEnabled: hs.Cfg.LiveMaxConnections != 0,
+ LiveMessageSizeLimit: hs.Cfg.LiveMessageSizeLimit,
AutoAssignOrg: hs.Cfg.AutoAssignOrg,
VerifyEmailEnabled: hs.Cfg.VerifyEmailEnabled,
SigV4AuthEnabled: hs.Cfg.SigV4AuthEnabled,
diff --git a/pkg/apimachinery/utils/meta.go b/pkg/apimachinery/utils/meta.go
index c285c202bc7..67076f2e96a 100644
--- a/pkg/apimachinery/utils/meta.go
+++ b/pkg/apimachinery/utils/meta.go
@@ -86,6 +86,7 @@ type GrafanaMetaAccessor interface {
GetMessage() string
SetMessage(msg string)
SetAnnotation(key string, val string)
+ GetAnnotation(key string) string
SetBlob(v *BlobInfo)
GetBlob() *BlobInfo
@@ -143,9 +144,13 @@ type grafanaMetaAccessor struct {
// required fields are missing. Fields that are not required return the default
// value and are a no-op if set.
func MetaAccessor(raw interface{}) (GrafanaMetaAccessor, error) {
+ if raw == nil {
+ return nil, fmt.Errorf("unable to read metadata from nil object")
+ }
+
obj, err := meta.Accessor(raw)
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("unable to read metadata from: %T, %s", raw, err)
}
// reflection to find title and other non object properties
@@ -188,6 +193,14 @@ func (m *grafanaMetaAccessor) SetAnnotation(key string, val string) {
m.obj.SetAnnotations(anno)
}
+func (m *grafanaMetaAccessor) GetAnnotation(key string) string {
+ anno := m.obj.GetAnnotations()
+ if anno != nil {
+ return anno[key]
+ }
+ return ""
+}
+
func (m *grafanaMetaAccessor) get(key string) string {
return m.obj.GetAnnotations()[key]
}
diff --git a/pkg/apis/provisioning/v0alpha1/jobs.go b/pkg/apis/provisioning/v0alpha1/jobs.go
index d621bf3411f..1e075fd1c26 100644
--- a/pkg/apis/provisioning/v0alpha1/jobs.go
+++ b/pkg/apis/provisioning/v0alpha1/jobs.go
@@ -104,16 +104,13 @@ type ExportJobOptions struct {
Branch string `json:"branch,omitempty"`
// Prefix in target file system
- Prefix string `json:"prefix,omitempty"`
+ Path string `json:"path,omitempty"`
// Include the identifier in the exported metadata
Identifier bool `json:"identifier"`
}
type MigrateJobOptions struct {
- // Target file prefix
- Prefix string `json:"prefix,omitempty"`
-
// Preserve history (if possible)
History bool `json:"history,omitempty"`
diff --git a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go
index 571f26b2b27..6ddc883ebda 100644
--- a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go
+++ b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go
@@ -108,7 +108,7 @@ func schema_pkg_apis_provisioning_v0alpha1_ExportJobOptions(ref common.Reference
Format: "",
},
},
- "prefix": {
+ "path": {
SchemaProps: spec.SchemaProps{
Description: "Prefix in target file system",
Type: []string{"string"},
@@ -818,13 +818,6 @@ func schema_pkg_apis_provisioning_v0alpha1_MigrateJobOptions(ref common.Referenc
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
- "prefix": {
- SchemaProps: spec.SchemaProps{
- Description: "Target file prefix",
- Type: []string{"string"},
- Format: "",
- },
- },
"history": {
SchemaProps: spec.SchemaProps{
Description: "Preserve history (if possible)",
diff --git a/pkg/apis/secret/v0alpha1/decrypt/decrypt.pb.go b/pkg/apis/secret/v0alpha1/decrypt/decrypt.pb.go
index c1b285caede..cffa2065ef0 100644
--- a/pkg/apis/secret/v0alpha1/decrypt/decrypt.pb.go
+++ b/pkg/apis/secret/v0alpha1/decrypt/decrypt.pb.go
@@ -78,9 +78,9 @@ func (x *SecureValueDecryptRequest) GetNames() []string {
type SecureValueDecryptResponseCollection struct {
state protoimpl.MessageState `protogen:"open.v1"`
// A map of secure value names and their decrypted values.
- // The value may be empty if the requestor does not have permissions to read it, or if the value does not exist.
+ // The value will be an error message if the requestor does not have permissions to read it, or if the value does not exist.
// It will never return a 404 Not Found to avoid scanning of valid secure values.
- DecryptedValues map[string]string `protobuf:"bytes,1,rep,name=decrypted_values,json=decryptedValues,proto3" json:"decrypted_values,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
+ DecryptedValues map[string]*Result `protobuf:"bytes,1,rep,name=decrypted_values,json=decryptedValues,proto3" json:"decrypted_values,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -115,13 +115,95 @@ func (*SecureValueDecryptResponseCollection) Descriptor() ([]byte, []int) {
return file_decrypt_proto_rawDescGZIP(), []int{1}
}
-func (x *SecureValueDecryptResponseCollection) GetDecryptedValues() map[string]string {
+func (x *SecureValueDecryptResponseCollection) GetDecryptedValues() map[string]*Result {
if x != nil {
return x.DecryptedValues
}
return nil
}
+type Result struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // Types that are valid to be assigned to Result:
+ //
+ // *Result_Value
+ // *Result_ErrorMessage
+ Result isResult_Result `protobuf_oneof:"result"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *Result) Reset() {
+ *x = Result{}
+ mi := &file_decrypt_proto_msgTypes[2]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *Result) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Result) ProtoMessage() {}
+
+func (x *Result) ProtoReflect() protoreflect.Message {
+ mi := &file_decrypt_proto_msgTypes[2]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use Result.ProtoReflect.Descriptor instead.
+func (*Result) Descriptor() ([]byte, []int) {
+ return file_decrypt_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *Result) GetResult() isResult_Result {
+ if x != nil {
+ return x.Result
+ }
+ return nil
+}
+
+func (x *Result) GetValue() string {
+ if x != nil {
+ if x, ok := x.Result.(*Result_Value); ok {
+ return x.Value
+ }
+ }
+ return ""
+}
+
+func (x *Result) GetErrorMessage() string {
+ if x != nil {
+ if x, ok := x.Result.(*Result_ErrorMessage); ok {
+ return x.ErrorMessage
+ }
+ }
+ return ""
+}
+
+type isResult_Result interface {
+ isResult_Result()
+}
+
+type Result_Value struct {
+ Value string `protobuf:"bytes,1,opt,name=value,proto3,oneof"`
+}
+
+type Result_ErrorMessage struct {
+ ErrorMessage string `protobuf:"bytes,2,opt,name=error_message,json=errorMessage,proto3,oneof"`
+}
+
+func (*Result_Value) isResult_Result() {}
+
+func (*Result_ErrorMessage) isResult_Result() {}
+
var File_decrypt_proto protoreflect.FileDescriptor
var file_decrypt_proto_rawDesc = string([]byte{
@@ -131,7 +213,7 @@ var file_decrypt_proto_rawDesc = string([]byte{
0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61,
0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70,
0x61, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03,
- 0x28, 0x09, 0x52, 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0xd9, 0x01, 0x0a, 0x24, 0x53, 0x65,
+ 0x28, 0x09, 0x52, 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0xea, 0x01, 0x0a, 0x24, 0x53, 0x65,
0x63, 0x75, 0x72, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69,
0x6f, 0x6e, 0x12, 0x6d, 0x0a, 0x10, 0x64, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x5f,
@@ -141,23 +223,30 @@ var file_decrypt_proto_rawDesc = string([]byte{
0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x44, 0x65, 0x63, 0x72,
0x79, 0x70, 0x74, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79,
0x52, 0x0f, 0x64, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65,
- 0x73, 0x1a, 0x42, 0x0a, 0x14, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x56, 0x61,
+ 0x73, 0x1a, 0x53, 0x0a, 0x14, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x56, 0x61,
0x6c, 0x75, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79,
- 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76,
- 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75,
- 0x65, 0x3a, 0x02, 0x38, 0x01, 0x32, 0x80, 0x01, 0x0a, 0x14, 0x53, 0x65, 0x63, 0x75, 0x72, 0x65,
- 0x56, 0x61, 0x6c, 0x75, 0x65, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x72, 0x12, 0x68,
- 0x0a, 0x13, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x53, 0x65, 0x63, 0x75, 0x72, 0x65, 0x56,
- 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x22, 0x2e, 0x64, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x2e,
- 0x53, 0x65, 0x63, 0x75, 0x72, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x44, 0x65, 0x63, 0x72, 0x79,
- 0x70, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2d, 0x2e, 0x64, 0x65, 0x63, 0x72,
+ 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x25, 0x0a, 0x05, 0x76,
+ 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x64, 0x65, 0x63,
+ 0x72, 0x79, 0x70, 0x74, 0x2e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c,
+ 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x51, 0x0a, 0x06, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74,
+ 0x12, 0x16, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48,
+ 0x00, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x25, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f,
+ 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48,
+ 0x00, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x42,
+ 0x08, 0x0a, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x32, 0x80, 0x01, 0x0a, 0x14, 0x53, 0x65,
+ 0x63, 0x75, 0x72, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74,
+ 0x65, 0x72, 0x12, 0x68, 0x0a, 0x13, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x53, 0x65, 0x63,
+ 0x75, 0x72, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x22, 0x2e, 0x64, 0x65, 0x63, 0x72,
0x79, 0x70, 0x74, 0x2e, 0x53, 0x65, 0x63, 0x75, 0x72, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x44,
- 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x43, 0x6f,
- 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x3d, 0x5a, 0x3b, 0x67, 0x69, 0x74, 0x68,
- 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x67,
- 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x61, 0x70, 0x69, 0x73, 0x2f,
- 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x2f, 0x76, 0x30, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2f,
- 0x64, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+ 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2d, 0x2e,
+ 0x64, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x2e, 0x53, 0x65, 0x63, 0x75, 0x72, 0x65, 0x56, 0x61,
+ 0x6c, 0x75, 0x65, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
+ 0x73, 0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x3d, 0x5a, 0x3b,
+ 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61,
+ 0x6e, 0x61, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x61,
+ 0x70, 0x69, 0x73, 0x2f, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x2f, 0x76, 0x30, 0x61, 0x6c, 0x70,
+ 0x68, 0x61, 0x31, 0x2f, 0x64, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f,
+ 0x74, 0x6f, 0x33,
})
var (
@@ -172,21 +261,23 @@ func file_decrypt_proto_rawDescGZIP() []byte {
return file_decrypt_proto_rawDescData
}
-var file_decrypt_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
+var file_decrypt_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
var file_decrypt_proto_goTypes = []any{
(*SecureValueDecryptRequest)(nil), // 0: decrypt.SecureValueDecryptRequest
(*SecureValueDecryptResponseCollection)(nil), // 1: decrypt.SecureValueDecryptResponseCollection
- nil, // 2: decrypt.SecureValueDecryptResponseCollection.DecryptedValuesEntry
+ (*Result)(nil), // 2: decrypt.Result
+ nil, // 3: decrypt.SecureValueDecryptResponseCollection.DecryptedValuesEntry
}
var file_decrypt_proto_depIdxs = []int32{
- 2, // 0: decrypt.SecureValueDecryptResponseCollection.decrypted_values:type_name -> decrypt.SecureValueDecryptResponseCollection.DecryptedValuesEntry
- 0, // 1: decrypt.SecureValueDecrypter.DecryptSecureValues:input_type -> decrypt.SecureValueDecryptRequest
- 1, // 2: decrypt.SecureValueDecrypter.DecryptSecureValues:output_type -> decrypt.SecureValueDecryptResponseCollection
- 2, // [2:3] is the sub-list for method output_type
- 1, // [1:2] is the sub-list for method input_type
- 1, // [1:1] is the sub-list for extension type_name
- 1, // [1:1] is the sub-list for extension extendee
- 0, // [0:1] is the sub-list for field type_name
+ 3, // 0: decrypt.SecureValueDecryptResponseCollection.decrypted_values:type_name -> decrypt.SecureValueDecryptResponseCollection.DecryptedValuesEntry
+ 2, // 1: decrypt.SecureValueDecryptResponseCollection.DecryptedValuesEntry.value:type_name -> decrypt.Result
+ 0, // 2: decrypt.SecureValueDecrypter.DecryptSecureValues:input_type -> decrypt.SecureValueDecryptRequest
+ 1, // 3: decrypt.SecureValueDecrypter.DecryptSecureValues:output_type -> decrypt.SecureValueDecryptResponseCollection
+ 3, // [3:4] is the sub-list for method output_type
+ 2, // [2:3] is the sub-list for method input_type
+ 2, // [2:2] is the sub-list for extension type_name
+ 2, // [2:2] is the sub-list for extension extendee
+ 0, // [0:2] is the sub-list for field type_name
}
func init() { file_decrypt_proto_init() }
@@ -194,13 +285,17 @@ func file_decrypt_proto_init() {
if File_decrypt_proto != nil {
return
}
+ file_decrypt_proto_msgTypes[2].OneofWrappers = []any{
+ (*Result_Value)(nil),
+ (*Result_ErrorMessage)(nil),
+ }
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_decrypt_proto_rawDesc), len(file_decrypt_proto_rawDesc)),
NumEnums: 0,
- NumMessages: 3,
+ NumMessages: 4,
NumExtensions: 0,
NumServices: 1,
},
diff --git a/pkg/apis/secret/v0alpha1/decrypt/decrypt.proto b/pkg/apis/secret/v0alpha1/decrypt/decrypt.proto
index 402ef684efd..bf328ff5217 100644
--- a/pkg/apis/secret/v0alpha1/decrypt/decrypt.proto
+++ b/pkg/apis/secret/v0alpha1/decrypt/decrypt.proto
@@ -14,9 +14,16 @@ message SecureValueDecryptRequest {
message SecureValueDecryptResponseCollection {
// A map of secure value names and their decrypted values.
- // The value may be empty if the requestor does not have permissions to read it, or if the value does not exist.
+ // The value will be an error message if the requestor does not have permissions to read it, or if the value does not exist.
// It will never return a 404 Not Found to avoid scanning of valid secure values.
- map decrypted_values = 1;
+ map decrypted_values = 1;
+}
+
+message Result {
+ oneof result {
+ string value = 1;
+ string error_message = 2;
+ }
}
service SecureValueDecrypter {
diff --git a/pkg/apiserver/registry/generic/strategy.go b/pkg/apiserver/registry/generic/strategy.go
index 6cb5dd918fd..5293335f1f3 100644
--- a/pkg/apiserver/registry/generic/strategy.go
+++ b/pkg/apiserver/registry/generic/strategy.go
@@ -3,8 +3,6 @@ package generic
import (
"context"
- "github.com/grafana/grafana/pkg/apimachinery/utils"
- apiequality "k8s.io/apimachinery/pkg/api/equality"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/labels"
@@ -14,6 +12,8 @@ import (
"k8s.io/apiserver/pkg/storage"
"k8s.io/apiserver/pkg/storage/names"
"sigs.k8s.io/structured-merge-diff/v4/fieldpath"
+
+ "github.com/grafana/grafana/pkg/apimachinery/utils"
)
type genericStrategy struct {
@@ -81,20 +81,6 @@ func (g *genericStrategy) PrepareForUpdate(ctx context.Context, obj, old runtime
} else {
_ = newMeta.SetStatus(status)
}
-
- spec, err := newMeta.GetSpec()
- if err != nil {
- return
- }
-
- oldSpec, err := oldMeta.GetSpec()
- if err != nil {
- return
- }
-
- if !apiequality.Semantic.DeepEqual(spec, oldSpec) {
- newMeta.SetGeneration(oldMeta.GetGeneration() + 1)
- }
}
func (g *genericStrategy) Validate(ctx context.Context, obj runtime.Object) field.ErrorList {
diff --git a/pkg/apiserver/registry/generic/strategy_test.go b/pkg/apiserver/registry/generic/strategy_test.go
index 969332305a2..906713d18fe 100644
--- a/pkg/apiserver/registry/generic/strategy_test.go
+++ b/pkg/apiserver/registry/generic/strategy_test.go
@@ -4,12 +4,13 @@ import (
"context"
"testing"
- "github.com/grafana/grafana/pkg/apiserver/registry/generic"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/apis/example"
+
+ "github.com/grafana/grafana/pkg/apiserver/registry/generic"
)
func TestPrepareForUpdate(t *testing.T) {
@@ -69,37 +70,6 @@ func TestPrepareForUpdate(t *testing.T) {
},
},
},
- {
- name: "increment generation if spec changes",
- newObj: &example.Pod{
- ObjectMeta: metav1.ObjectMeta{
- Name: "test",
- Namespace: "default",
- Generation: 1,
- },
- Spec: example.PodSpec{
- NodeSelector: map[string]string{"foo": "baz"},
- },
- Status: example.PodStatus{
- Phase: example.PodPhase("Running"),
- },
- },
- oldObj: oldObj.DeepCopy(),
- expectedGen: 2,
- expectedObj: &example.Pod{
- ObjectMeta: metav1.ObjectMeta{
- Name: "test",
- Namespace: "default",
- Generation: 2,
- },
- Spec: example.PodSpec{
- NodeSelector: map[string]string{"foo": "baz"},
- },
- Status: example.PodStatus{
- Phase: example.PodPhase("Running"),
- },
- },
- },
}
for _, tc := range testCases {
diff --git a/pkg/expr/sql/parser.go b/pkg/expr/sql/parser.go
index 12269b4a68f..c65e4b89131 100644
--- a/pkg/expr/sql/parser.go
+++ b/pkg/expr/sql/parser.go
@@ -14,7 +14,7 @@ var logger = log.New("sql_expr")
func TablesList(rawSQL string) ([]string, error) {
stmt, err := sqlparser.Parse(rawSQL)
if err != nil {
- logger.Error("error parsing sql: %s", err.Error(), "sql", rawSQL)
+ logger.Error("error parsing sql", "error", err.Error(), "sql", rawSQL)
return nil, fmt.Errorf("error parsing sql: %s", err.Error())
}
diff --git a/pkg/expr/sql_command.go b/pkg/expr/sql_command.go
index 0b4d7ab698e..8bcde6abb9b 100644
--- a/pkg/expr/sql_command.go
+++ b/pkg/expr/sql_command.go
@@ -14,6 +14,16 @@ import (
"github.com/grafana/grafana/pkg/infra/tracing"
)
+var (
+ ErrMissingSQLQuery = errutil.BadRequest("sql-missing-query").Errorf("missing SQL query")
+ ErrInvalidSQLQuery = errutil.BadRequest("sql-invalid-sql").MustTemplate(
+ "invalid SQL query: {{ .Private.query }} err: {{ .Error }}",
+ errutil.WithPublic(
+ "Invalid SQL query: {{ .Public.error }}",
+ ),
+ )
+)
+
// SQLCommand is an expression to run SQL over results
type SQLCommand struct {
query string
@@ -25,15 +35,20 @@ type SQLCommand struct {
// NewSQLCommand creates a new SQLCommand.
func NewSQLCommand(refID, rawSQL string, limit int64) (*SQLCommand, error) {
if rawSQL == "" {
- return nil, errutil.BadRequest("sql-missing-query",
- errutil.WithPublicMessage("missing SQL query"))
+ return nil, ErrMissingSQLQuery
}
tables, err := sql.TablesList(rawSQL)
if err != nil {
logger.Warn("invalid sql query", "sql", rawSQL, "error", err)
- return nil, errutil.BadRequest("sql-invalid-sql",
- errutil.WithPublicMessage(fmt.Sprintf("invalid SQL query: %s", err)),
- )
+ return nil, ErrInvalidSQLQuery.Build(errutil.TemplateData{
+ Error: err,
+ Public: map[string]any{
+ "error": err.Error(),
+ },
+ Private: map[string]any{
+ "query": rawSQL,
+ },
+ })
}
if len(tables) == 0 {
logger.Warn("no tables found in SQL query", "sql", rawSQL)
diff --git a/pkg/plugins/backendplugin/grpcplugin/client_v2.go b/pkg/plugins/backendplugin/grpcplugin/client_v2.go
index 7babf11c2df..78d7b0d6dcc 100644
--- a/pkg/plugins/backendplugin/grpcplugin/client_v2.go
+++ b/pkg/plugins/backendplugin/grpcplugin/client_v2.go
@@ -172,6 +172,14 @@ func (c *ClientV2) QueryData(ctx context.Context, req *backend.QueryDataRequest)
return nil, plugins.ErrMethodNotImplemented
}
+ if status.Code(err) == codes.Unavailable {
+ return nil, plugins.ErrPluginGrpcConnectionUnavailableBase.Errorf("%v", err)
+ }
+
+ if status.Code(err) == codes.ResourceExhausted {
+ return nil, plugins.ErrPluginGrpcResourceExhaustedBase.Errorf("%v", err)
+ }
+
if errorSource, ok := backend.ErrorSourceFromGrpcStatusError(ctx, err); ok {
return nil, handleGrpcStatusError(ctx, errorSource, err)
}
diff --git a/pkg/plugins/errors.go b/pkg/plugins/errors.go
index 1ab785ff7a3..77e35735ee0 100644
--- a/pkg/plugins/errors.go
+++ b/pkg/plugins/errors.go
@@ -35,4 +35,16 @@ var (
// Exposed as a base error to wrap it with plugin cancelled errors.
ErrPluginRequestCanceledErrorBase = errutil.ClientClosedRequest("plugin.requestCanceled",
errutil.WithPublicMessage("Plugin request canceled"))
+
+ // ErrPluginGrpcResourceExhaustedBase error returned when a plugin response is larger than the grpc limit.
+ // Exposed as a base error to wrap it with plugin resource exhausted errors.
+ ErrPluginGrpcResourceExhaustedBase = errutil.Internal("plugin.resourceExhausted",
+ errutil.WithPublicMessage("The response is too large. Please try to reduce the time range or narrow down your query to return fewer data points."),
+ errutil.WithDownstream())
+
+ // ErrPluginGrpcConnectionUnavailableBase error returned when a plugin connection issue occurs.
+ // Exposed as a base error to wrap it with plugin connection issue errors.
+ ErrPluginGrpcConnectionUnavailableBase = errutil.Internal("plugin.connectionUnavailable",
+ errutil.WithPublicMessage("Data source became unavailable during request. Please try again."),
+ errutil.WithDownstream())
)
diff --git a/pkg/plugins/manager/client/client.go b/pkg/plugins/manager/client/client.go
index 7ab5d6a60ff..4f4d19aa6b0 100644
--- a/pkg/plugins/manager/client/client.go
+++ b/pkg/plugins/manager/client/client.go
@@ -30,6 +30,14 @@ var (
errNilSender = errors.New("sender cannot be nil")
)
+// passthroughErrors contains a list of errors that should be returned directly to the caller without wrapping
+var passthroughErrors = []error{
+ plugins.ErrPluginUnavailable,
+ plugins.ErrMethodNotImplemented,
+ plugins.ErrPluginGrpcResourceExhaustedBase,
+ plugins.ErrPluginGrpcConnectionUnavailableBase,
+}
+
type Service struct {
pluginRegistry registry.Service
}
@@ -52,12 +60,10 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
resp, err := p.QueryData(ctx, req)
if err != nil {
- if errors.Is(err, plugins.ErrMethodNotImplemented) {
- return nil, err
- }
-
- if errors.Is(err, plugins.ErrPluginUnavailable) {
- return nil, err
+ for _, e := range passthroughErrors {
+ if errors.Is(err, e) {
+ return nil, err
+ }
}
if errors.Is(err, context.Canceled) {
diff --git a/pkg/plugins/manager/client/client_test.go b/pkg/plugins/manager/client/client_test.go
index f45761c3649..4964a3b1631 100644
--- a/pkg/plugins/manager/client/client_test.go
+++ b/pkg/plugins/manager/client/client_test.go
@@ -25,24 +25,39 @@ func TestQueryData(t *testing.T) {
t.Run("Non-empty registry", func(t *testing.T) {
tcs := []struct {
- err error
- expectedError error
+ err error
+ expectedError error
+ shouldPassThrough bool
}{
{
- err: plugins.ErrPluginUnavailable,
- expectedError: plugins.ErrPluginUnavailable,
+ err: plugins.ErrPluginUnavailable,
+ expectedError: plugins.ErrPluginUnavailable,
+ shouldPassThrough: true,
},
{
- err: plugins.ErrMethodNotImplemented,
- expectedError: plugins.ErrMethodNotImplemented,
+ err: plugins.ErrMethodNotImplemented,
+ expectedError: plugins.ErrMethodNotImplemented,
+ shouldPassThrough: true,
},
{
- err: errors.New("surprise surprise"),
- expectedError: plugins.ErrPluginRequestFailureErrorBase,
+ err: errors.New("surprise surprise"),
+ expectedError: plugins.ErrPluginRequestFailureErrorBase,
+ shouldPassThrough: false,
},
{
- err: context.Canceled,
- expectedError: plugins.ErrPluginRequestCanceledErrorBase,
+ err: context.Canceled,
+ expectedError: plugins.ErrPluginRequestCanceledErrorBase,
+ shouldPassThrough: false,
+ },
+ {
+ err: plugins.ErrPluginGrpcConnectionUnavailableBase.Errorf("unavailable"),
+ expectedError: plugins.ErrPluginGrpcConnectionUnavailableBase.Errorf("unavailable"),
+ shouldPassThrough: true,
+ },
+ {
+ err: plugins.ErrPluginGrpcResourceExhaustedBase.Errorf("exhausted"),
+ expectedError: plugins.ErrPluginGrpcResourceExhaustedBase.Errorf("exhausted"),
+ shouldPassThrough: true,
},
}
@@ -69,7 +84,11 @@ func TestQueryData(t *testing.T) {
},
})
require.Error(t, err)
- require.ErrorIs(t, err, tc.expectedError)
+ if tc.shouldPassThrough {
+ require.Equal(t, tc.err, err)
+ } else {
+ require.ErrorIs(t, err, tc.expectedError)
+ }
})
}
})
diff --git a/pkg/promlib/go.mod b/pkg/promlib/go.mod
index 4b4393c9c70..020113b5982 100644
--- a/pkg/promlib/go.mod
+++ b/pkg/promlib/go.mod
@@ -18,7 +18,7 @@ require (
require (
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.1 // indirect
- github.com/BurntSushi/toml v1.4.0 // indirect
+ github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
github.com/apache/arrow-go/v18 v18.0.1-0.20241212180703-82be143d7c30 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
@@ -33,7 +33,7 @@ require (
github.com/dennwc/varint v1.0.0 // indirect
github.com/elazarl/goproxy v1.7.2 // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
- github.com/fatih/color v1.17.0 // indirect
+ github.com/fatih/color v1.18.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/getkin/kin-openapi v0.129.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
diff --git a/pkg/promlib/go.sum b/pkg/promlib/go.sum
index 899f8550654..cbc5e25726a 100644
--- a/pkg/promlib/go.sum
+++ b/pkg/promlib/go.sum
@@ -13,8 +13,9 @@ github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkY
github.com/AzureAD/microsoft-authentication-library-for-go v1.3.2 h1:kYRSnvJju5gYVyhkij+RTJ/VR6QIUaCfWeaFm2ycsjQ=
github.com/AzureAD/microsoft-authentication-library-for-go v1.3.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
+github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0=
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
@@ -60,8 +61,8 @@ github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVo
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
-github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
-github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
+github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
+github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
diff --git a/pkg/registry/apis/dashboard/authorizer.go b/pkg/registry/apis/dashboard/authorizer.go
deleted file mode 100644
index 0b51cd32c68..00000000000
--- a/pkg/registry/apis/dashboard/authorizer.go
+++ /dev/null
@@ -1,93 +0,0 @@
-package dashboard
-
-import (
- "context"
-
- "k8s.io/apiserver/pkg/authorization/authorizer"
-
- claims "github.com/grafana/authlib/types"
- "github.com/grafana/grafana/pkg/apimachinery/identity"
- "github.com/grafana/grafana/pkg/infra/log"
- "github.com/grafana/grafana/pkg/services/dashboards"
- "github.com/grafana/grafana/pkg/services/guardian"
-)
-
-func GetAuthorizer(dashboardService dashboards.DashboardService, l log.Logger) authorizer.Authorizer {
- return authorizer.AuthorizerFunc(
- func(ctx context.Context, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) {
- // Use the standard authorizer
- if !attr.IsResourceRequest() {
- return authorizer.DecisionNoOpinion, "", nil
- }
-
- user, err := identity.GetRequester(ctx)
- if err != nil {
- return authorizer.DecisionDeny, "", err
- }
-
- // Allow search and list requests
- if attr.GetResource() == "search" || attr.GetName() == "" {
- return authorizer.DecisionNoOpinion, "", nil
- }
-
- ns := attr.GetNamespace()
- if ns == "" {
- return authorizer.DecisionDeny, "expected namespace", nil
- }
-
- info, err := claims.ParseNamespace(attr.GetNamespace())
- if err != nil {
- return authorizer.DecisionDeny, "error reading org from namespace", err
- }
-
- // expensive path to lookup permissions for a single dashboard
- // must include deleted to allow for restores
- dto, err := dashboardService.GetDashboard(ctx, &dashboards.GetDashboardQuery{
- UID: attr.GetName(),
- OrgID: info.OrgID,
- IncludeDeleted: true,
- })
- if err != nil {
- return authorizer.DecisionDeny, "error loading dashboard", err
- }
-
- ok := false
- guardian, err := guardian.NewByDashboard(ctx, dto, info.OrgID, user)
- if err != nil {
- return authorizer.DecisionDeny, "", err
- }
-
- switch attr.GetVerb() {
- case "get":
- ok, err = guardian.CanView()
- if !ok || err != nil {
- return authorizer.DecisionDeny, "can not view dashboard", err
- }
- case "create":
- fallthrough
- case "post":
- ok, err = guardian.CanSave() // vs Edit?
- if !ok || err != nil {
- return authorizer.DecisionDeny, "can not save dashboard", err
- }
- case "update":
- fallthrough
- case "patch":
- fallthrough
- case "put":
- ok, err = guardian.CanEdit() // vs Save
- if !ok || err != nil {
- return authorizer.DecisionDeny, "can not edit dashboard", err
- }
- case "delete":
- ok, err = guardian.CanDelete()
- if !ok || err != nil {
- return authorizer.DecisionDeny, "can not delete dashboard", err
- }
- default:
- l.Info("unknown verb", "verb", attr.GetVerb())
- return authorizer.DecisionNoOpinion, "unsupported verb", nil // Unknown verb
- }
- return authorizer.DecisionAllow, "", nil
- })
-}
diff --git a/pkg/registry/apis/dashboard/latest.go b/pkg/registry/apis/dashboard/latest.go
deleted file mode 100644
index 0944bcdf537..00000000000
--- a/pkg/registry/apis/dashboard/latest.go
+++ /dev/null
@@ -1,103 +0,0 @@
-package dashboard
-
-import (
- "context"
- "fmt"
- "net/http"
- "strconv"
-
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
- "k8s.io/apimachinery/pkg/runtime"
- "k8s.io/apimachinery/pkg/runtime/schema"
- "k8s.io/apiserver/pkg/registry/rest"
- "k8s.io/apiserver/pkg/storage"
-
- "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
- "github.com/grafana/grafana/pkg/storage/unified/resource"
-)
-
-// LatestConnector will return the latest version of the resource - even if it is deleted
-type LatestConnector interface {
- rest.Storage
- rest.Connecter
- rest.StorageMetadata
-}
-
-func NewLatestConnector(unified resource.ResourceClient, gr schema.GroupResource) LatestConnector {
- return &latestREST{
- unified: unified,
- gr: gr,
- }
-}
-
-type latestREST struct {
- unified resource.ResourceClient
- gr schema.GroupResource
-}
-
-func (l *latestREST) New() runtime.Object {
- return &metav1.PartialObjectMetadataList{}
-}
-
-func (l *latestREST) Destroy() {
-}
-
-func (l *latestREST) ConnectMethods() []string {
- return []string{"GET"}
-}
-
-func (l *latestREST) ProducesMIMETypes(verb string) []string {
- return nil
-}
-
-func (l *latestREST) ProducesObject(verb string) interface{} {
- return &metav1.PartialObjectMetadataList{}
-}
-
-func (l *latestREST) NewConnectOptions() (runtime.Object, bool, string) {
- return nil, false, ""
-}
-
-func (l *latestREST) Connect(ctx context.Context, uid string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
- info, err := request.NamespaceInfoFrom(ctx, true)
- if err != nil {
- return nil, err
- }
-
- key := &resource.ResourceKey{
- Namespace: info.Value,
- Group: l.gr.Group,
- Resource: l.gr.Resource,
- Name: uid,
- }
-
- return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
- rsp, err := l.unified.Read(ctx, &resource.ReadRequest{
- Key: key,
- ResourceVersion: 0, // 0 will return the latest version that was not a delete event
- IncludeDeleted: true,
- })
- if err != nil {
- responder.Error(err)
- return
- } else if rsp == nil || (rsp.Error != nil && rsp.Error.Code == http.StatusNotFound) {
- responder.Error(storage.NewKeyNotFoundError(uid, 0))
- return
- } else if rsp.Error != nil {
- responder.Error(fmt.Errorf("could not retrieve object: %s", rsp.Error.Message))
- return
- }
-
- uncastObj, err := runtime.Decode(unstructured.UnstructuredJSONScheme, rsp.Value)
- if err != nil {
- responder.Error(fmt.Errorf("could not convert object: %s", err.Error()))
- return
- }
-
- finalObj := uncastObj.(*unstructured.Unstructured)
- finalObj.SetResourceVersion(strconv.FormatInt(rsp.ResourceVersion, 10))
-
- responder.Object(http.StatusOK, finalObj)
- }), nil
-}
diff --git a/pkg/registry/apis/dashboard/latest_test.go b/pkg/registry/apis/dashboard/latest_test.go
deleted file mode 100644
index cdf54985a70..00000000000
--- a/pkg/registry/apis/dashboard/latest_test.go
+++ /dev/null
@@ -1,99 +0,0 @@
-package dashboard
-
-import (
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "reflect"
- "strconv"
- "testing"
-
- "github.com/grafana/grafana/pkg/storage/unified/resource"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/mock"
- "github.com/stretchr/testify/require"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
- "k8s.io/apimachinery/pkg/runtime/schema"
- "k8s.io/apiserver/pkg/endpoints/request"
-)
-
-func TestLatest(t *testing.T) {
- gr := schema.GroupResource{
- Group: "group",
- Resource: "resource",
- }
- ctx := context.Background()
- mockResponder := &mockResponder{}
- mockClient := &mockResourceClient{}
- r := &latestREST{
- unified: mockClient,
- gr: gr,
- }
-
- t.Run("no namespace in context", func(t *testing.T) {
- _, err := r.Connect(ctx, "test-uid", nil, mockResponder)
- require.Error(t, err)
- })
-
- ctx = request.WithNamespace(context.Background(), "default")
-
- t.Run("happy path", func(t *testing.T) {
- req := httptest.NewRequest("GET", "/latest", nil)
- w := httptest.NewRecorder()
-
- readReq := &resource.ReadRequest{
- Key: &resource.ResourceKey{
- Namespace: "default",
- Group: "group",
- Resource: "resource",
- Name: "uid",
- },
- ResourceVersion: 0,
- IncludeDeleted: true,
- }
-
- expectedObject := &metav1.PartialObjectMetadata{
- TypeMeta: metav1.TypeMeta{
- Kind: "resource",
- APIVersion: "v0alpha1",
- },
- ObjectMeta: metav1.ObjectMeta{
- Name: "uid",
- Namespace: "default",
- ResourceVersion: strconv.FormatInt(123, 10),
- },
- }
-
- val, err := json.Marshal(expectedObject)
- require.NoError(t, err)
- mockClient.On("Read", ctx, readReq).Return(&resource.ReadResponse{
- ResourceVersion: 123,
- Value: val,
- }, nil).Once()
-
- mockResponder.On("Object", http.StatusOK, mock.MatchedBy(func(obj interface{}) bool {
- unstructuredObj, ok := obj.(*unstructured.Unstructured)
- expectedMap := map[string]interface{}{
- "apiVersion": expectedObject.APIVersion,
- "kind": expectedObject.Kind,
- "metadata": map[string]interface{}{
- "name": expectedObject.Name,
- "namespace": expectedObject.Namespace,
- "resourceVersion": expectedObject.ResourceVersion,
- "creationTimestamp": nil,
- },
- }
- return ok && reflect.DeepEqual(unstructuredObj.Object, expectedMap)
- }))
-
- handler, err := r.Connect(ctx, "uid", nil, mockResponder)
- require.NoError(t, err)
- handler.ServeHTTP(w, req)
- assert.Equal(t, http.StatusOK, w.Code)
-
- mockClient.AssertExpectations(t)
- mockResponder.AssertExpectations(t)
- })
-}
diff --git a/pkg/registry/apis/dashboard/legacy/client.go b/pkg/registry/apis/dashboard/legacy/client.go
index afcd1e0fddf..d85c66047c9 100644
--- a/pkg/registry/apis/dashboard/legacy/client.go
+++ b/pkg/registry/apis/dashboard/legacy/client.go
@@ -70,11 +70,6 @@ func (d *directResourceClient) Read(ctx context.Context, in *resource.ReadReques
return d.server.Read(ctx, in)
}
-// Restore implements ResourceClient.
-func (d *directResourceClient) Restore(ctx context.Context, in *resource.RestoreRequest, opts ...grpc.CallOption) (*resource.RestoreResponse, error) {
- return d.server.Restore(ctx, in)
-}
-
// Search implements ResourceClient.
func (d *directResourceClient) Search(ctx context.Context, in *resource.ResourceSearchRequest, opts ...grpc.CallOption) (*resource.ResourceSearchResponse, error) {
return d.server.Search(ctx, in)
diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go
index 8f07f37658b..bc9b5a2c4e0 100644
--- a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go
+++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go
@@ -404,6 +404,7 @@ func (a *dashboardSqlAccess) buildSaveDashboardCommand(ctx context.Context, orgI
})
if old != nil {
dash.Spec.Set("id", old.ID)
+ dash.Spec.Set("version", float64(old.Version))
} else {
dash.Spec.Remove("id") // existing of "id" makes it an update
created = true
diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go
index 2e8d5485e9e..efa4ffde96f 100644
--- a/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go
+++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go
@@ -169,6 +169,7 @@ func TestBuildSaveDashboardCommand(t *testing.T) {
mockStore.On("GetDashboard", mock.Anything, mock.Anything).Return(
&dashboards.Dashboard{
ID: 1234,
+ Version: 2,
APIVersion: "dashboard.grafana.app/v0alpha1",
}, nil).Once()
cmd, created, err = access.buildSaveDashboardCommand(ctx, 1, dash)
@@ -176,8 +177,9 @@ func TestBuildSaveDashboardCommand(t *testing.T) {
require.Equal(t, false, created)
require.NotNil(t, cmd)
require.Equal(t, "test-dash", cmd.Dashboard.Get("uid").MustString())
- require.Equal(t, cmd.Dashboard.Get("id").MustInt64(), int64(1234)) // should set to existing ID
- require.Equal(t, cmd.APIVersion, "v0alpha1") // should trim prefix
+ require.Equal(t, cmd.Dashboard.Get("id").MustInt64(), int64(1234)) // should set to existing ID
+ require.Equal(t, cmd.Dashboard.Get("version").MustFloat64(), float64(2)) // version must be set - otherwise seen as a new dashboard in NewDashboardFromJson
+ require.Equal(t, cmd.APIVersion, "v0alpha1") // should trim prefix
require.Equal(t, cmd.OrgID, int64(1))
require.True(t, cmd.Overwrite)
}
diff --git a/pkg/registry/apis/dashboard/legacy_storage.go b/pkg/registry/apis/dashboard/legacy_storage.go
index 20445e5aa6c..2037376723f 100644
--- a/pkg/registry/apis/dashboard/legacy_storage.go
+++ b/pkg/registry/apis/dashboard/legacy_storage.go
@@ -39,6 +39,10 @@ func (s *DashboardStorage) NewStore(dash utils.ResourceInfo, scheme *runtime.Sch
optsGetter := apistore.NewRESTOptionsGetterForClient(client,
defaultOpts.StorageConfig.Config,
)
+ optsGetter.RegisterOptions(dash.GroupResource(), apistore.StorageOptions{
+ EnableFolderSupport: true,
+ RequireDeprecatedInternalID: true,
+ })
store, err := grafanaregistry.NewRegistryStore(scheme, dash, optsGetter)
return &storeWrapper{
diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go
index d09af5df91c..c8a7b6f9f55 100644
--- a/pkg/registry/apis/dashboard/register.go
+++ b/pkg/registry/apis/dashboard/register.go
@@ -184,6 +184,7 @@ func (b *DashboardsAPIBuilder) Validate(ctx context.Context, a admission.Attribu
func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
storageOpts := apistore.StorageOptions{
+ EnableFolderSupport: true,
RequireDeprecatedInternalID: true,
}
@@ -280,11 +281,6 @@ func (b *DashboardsAPIBuilder) storageForVersion(
return err
}
- if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesRestore) {
- storage[dashboards.StoragePath("restore")] = NewRestoreConnector(b.unified, gr)
- storage[dashboards.StoragePath("latest")] = NewLatestConnector(b.unified, gr)
- }
-
// Register the DTO endpoint that will consolidate all dashboard bits
storage[dashboards.StoragePath("dto")], err = NewDTOConnector(
storage[dashboards.StoragePath()].(rest.Getter),
diff --git a/pkg/registry/apis/dashboard/restore.go b/pkg/registry/apis/dashboard/restore.go
deleted file mode 100644
index 839516cbb31..00000000000
--- a/pkg/registry/apis/dashboard/restore.go
+++ /dev/null
@@ -1,122 +0,0 @@
-package dashboard
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "strconv"
-
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- "k8s.io/apimachinery/pkg/runtime"
- "k8s.io/apimachinery/pkg/runtime/schema"
- "k8s.io/apiserver/pkg/registry/rest"
- "k8s.io/apiserver/pkg/storage"
-
- "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
- "github.com/grafana/grafana/pkg/storage/unified/resource"
-)
-
-type RestoreConnector interface {
- rest.Storage
- rest.Connecter
- rest.StorageMetadata
-}
-
-func NewRestoreConnector(unified resource.ResourceClient, gr schema.GroupResource) RestoreConnector {
- return &restoreREST{
- unified: unified,
- gr: gr,
- }
-}
-
-type restoreREST struct {
- unified resource.ResourceClient
- gr schema.GroupResource
-}
-
-func (r *restoreREST) New() runtime.Object {
- return &metav1.PartialObjectMetadataList{}
-}
-
-func (r *restoreREST) Destroy() {
-}
-
-func (r *restoreREST) ConnectMethods() []string {
- return []string{"POST"}
-}
-
-func (r *restoreREST) ProducesMIMETypes(verb string) []string {
- return nil
-}
-
-func (r *restoreREST) ProducesObject(verb string) interface{} {
- return &metav1.PartialObjectMetadataList{}
-}
-
-func (r *restoreREST) NewConnectOptions() (runtime.Object, bool, string) {
- return nil, false, ""
-}
-
-type RestoreOptions struct {
- ResourceVersion int64 `json:"resourceVersion"`
-}
-
-func (r *restoreREST) Connect(ctx context.Context, uid string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
- info, err := request.NamespaceInfoFrom(ctx, true)
- if err != nil {
- return nil, err
- }
-
- key := &resource.ResourceKey{
- Namespace: info.Value,
- Group: r.gr.Group,
- Resource: r.gr.Resource,
- Name: uid,
- }
-
- return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
- body, err := io.ReadAll(req.Body)
- if err != nil {
- responder.Error(fmt.Errorf("unable to read request body: %s", err.Error()))
- return
- }
- reqBody := &RestoreOptions{}
- err = json.Unmarshal(body, &reqBody)
- if err != nil {
- responder.Error(fmt.Errorf("unable to unmarshal request body: %s", err.Error()))
- return
- }
-
- if reqBody.ResourceVersion == 0 {
- responder.Error(fmt.Errorf("resource version required"))
- return
- }
-
- rsp, err := r.unified.Restore(ctx, &resource.RestoreRequest{
- ResourceVersion: reqBody.ResourceVersion,
- Key: key,
- })
- if err != nil {
- responder.Error(err)
- return
- } else if rsp == nil || (rsp.Error != nil && rsp.Error.Code == http.StatusNotFound) {
- responder.Error(storage.NewKeyNotFoundError(uid, reqBody.ResourceVersion))
- return
- } else if rsp.Error != nil {
- responder.Error(fmt.Errorf("could not re-create object: %s", rsp.Error.Message))
- return
- }
-
- obj := metav1.PartialObjectMetadata{
- ObjectMeta: metav1.ObjectMeta{
- Name: key.Name,
- Namespace: key.Namespace,
- ResourceVersion: strconv.FormatInt(rsp.ResourceVersion, 10),
- },
- }
-
- responder.Object(http.StatusOK, &obj)
- }), nil
-}
diff --git a/pkg/registry/apis/dashboard/restore_test.go b/pkg/registry/apis/dashboard/restore_test.go
deleted file mode 100644
index 26edde534ef..00000000000
--- a/pkg/registry/apis/dashboard/restore_test.go
+++ /dev/null
@@ -1,126 +0,0 @@
-package dashboard
-
-import (
- "bytes"
- "context"
- "fmt"
- "net/http"
- "net/http/httptest"
- "strconv"
- "testing"
-
- "github.com/grafana/grafana/pkg/storage/unified/resource"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/mock"
- "google.golang.org/grpc"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- "k8s.io/apimachinery/pkg/runtime"
- "k8s.io/apimachinery/pkg/runtime/schema"
- "k8s.io/apiserver/pkg/endpoints/request"
-)
-
-type mockResourceClient struct {
- mock.Mock
- resource.ResourceClient
-}
-
-func (m *mockResourceClient) Restore(ctx context.Context, req *resource.RestoreRequest, opts ...grpc.CallOption) (*resource.RestoreResponse, error) {
- args := m.Called(ctx, req)
- return args.Get(0).(*resource.RestoreResponse), args.Error(1)
-}
-
-func (m *mockResourceClient) Read(ctx context.Context, req *resource.ReadRequest, opts ...grpc.CallOption) (*resource.ReadResponse, error) {
- args := m.Called(ctx, req)
- return args.Get(0).(*resource.ReadResponse), args.Error(1)
-}
-
-type mockResponder struct {
- mock.Mock
-}
-
-func (m *mockResponder) Object(statusCode int, obj runtime.Object) {
- m.Called(statusCode, obj)
-}
-
-func (m *mockResponder) Error(err error) {
- m.Called(err)
-}
-
-func TestRestore(t *testing.T) {
- gr := schema.GroupResource{
- Group: "group",
- Resource: "resource",
- }
- ctx := context.Background()
- mockResponder := &mockResponder{}
- mockClient := &mockResourceClient{}
- r := &restoreREST{
- unified: mockClient,
- gr: gr,
- }
-
- t.Run("no namespace in context", func(t *testing.T) {
- _, err := r.Connect(ctx, "test-uid", nil, mockResponder)
- assert.Error(t, err)
- })
-
- ctx = request.WithNamespace(context.Background(), "default")
-
- t.Run("invalid resourceVersion", func(t *testing.T) {
- req := httptest.NewRequest("POST", "/restore", bytes.NewReader([]byte(`{"resourceVersion":0}`)))
- w := httptest.NewRecorder()
-
- expectedError := fmt.Errorf("resource version required")
- mockResponder.On("Error", mock.MatchedBy(func(err error) bool {
- return err.Error() == expectedError.Error()
- }))
-
- handler, err := r.Connect(ctx, "test-uid", nil, mockResponder)
- assert.NoError(t, err)
-
- handler.ServeHTTP(w, req)
- mockResponder.AssertExpectations(t)
- })
-
- t.Run("happy path", func(t *testing.T) {
- req := httptest.NewRequest("POST", "/restore", bytes.NewReader([]byte(`{"resourceVersion":123}`)))
- w := httptest.NewRecorder()
- restoreReq := &resource.RestoreRequest{
- ResourceVersion: 123,
- Key: &resource.ResourceKey{
- Namespace: "default",
- Group: "group",
- Resource: "resource",
- Name: "uid",
- },
- }
-
- expectedObject := &metav1.PartialObjectMetadata{
- ObjectMeta: metav1.ObjectMeta{
- Name: "uid",
- Namespace: "default",
- ResourceVersion: strconv.FormatInt(123, 10),
- },
- }
-
- mockClient.On("Restore", ctx, restoreReq).Return(&resource.RestoreResponse{
- ResourceVersion: 123,
- }, nil).Once()
-
- mockResponder.On("Object", http.StatusOK, mock.MatchedBy(func(obj interface{}) bool {
- metadata, ok := obj.(*metav1.PartialObjectMetadata)
- return ok &&
- metadata.ObjectMeta.Name == "uid" &&
- metadata.ObjectMeta.Namespace == "default" &&
- metadata.ObjectMeta.ResourceVersion == "123"
- })).Return(expectedObject)
-
- handler, err := r.Connect(ctx, "uid", nil, mockResponder)
- assert.NoError(t, err)
- handler.ServeHTTP(w, req)
- assert.Equal(t, http.StatusOK, w.Code)
-
- mockClient.AssertExpectations(t)
- mockResponder.AssertExpectations(t)
- })
-}
diff --git a/pkg/registry/apis/dashboard/search.go b/pkg/registry/apis/dashboard/search.go
index 33162ccabb6..86967a9f5d3 100644
--- a/pkg/registry/apis/dashboard/search.go
+++ b/pkg/registry/apis/dashboard/search.go
@@ -407,6 +407,7 @@ func asResourceKey(ns string, k string) (*resource.ResourceKey, error) {
func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, user identity.Requester) ([]string, error) {
if !s.features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageSearchPermissionFiltering) {
+ s.log.Warn("Tried to search for 'sharedwithme' dashboards with ", featuremgmt.FlagUnifiedStorageSearchPermissionFiltering, " disabled")
return []string{}, nil
}
@@ -459,7 +460,7 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
}
if folderUidIdx == -1 {
- return sharedDashboards, fmt.Errorf("Error retrieving folder information")
+ return sharedDashboards, fmt.Errorf("error retrieving folder information")
}
// populate list of unique folder UIDs in the list of dashboards user has read permissions
diff --git a/pkg/registry/apis/dashboard/search_test.go b/pkg/registry/apis/dashboard/search_test.go
index e393aabea62..90411b3cbbf 100644
--- a/pkg/registry/apis/dashboard/search_test.go
+++ b/pkg/registry/apis/dashboard/search_test.go
@@ -692,9 +692,6 @@ func (m *MockClient) Update(ctx context.Context, in *resource.UpdateRequest, opt
func (m *MockClient) Read(ctx context.Context, in *resource.ReadRequest, opts ...grpc.CallOption) (*resource.ReadResponse, error) {
return nil, nil
}
-func (m *MockClient) Restore(ctx context.Context, in *resource.RestoreRequest, opts ...grpc.CallOption) (*resource.RestoreResponse, error) {
- return nil, nil
-}
func (m *MockClient) GetBlob(ctx context.Context, in *resource.GetBlobRequest, opts ...grpc.CallOption) (*resource.GetBlobResponse, error) {
return nil, nil
}
diff --git a/pkg/registry/apis/dashboard/sub_dto.go b/pkg/registry/apis/dashboard/sub_dto.go
index 5768ce906d5..32f0b787ee8 100644
--- a/pkg/registry/apis/dashboard/sub_dto.go
+++ b/pkg/registry/apis/dashboard/sub_dto.go
@@ -19,7 +19,6 @@ import (
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/dashboards"
- "github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/storage/unified/apistore"
"github.com/grafana/grafana/pkg/storage/unified/resource"
)
@@ -87,7 +86,7 @@ func (r *DTOConnector) ProducesObject(verb string) interface{} {
}
func (r *DTOConnector) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
- info, err := request.NamespaceInfoFrom(ctx, true)
+ _, err := request.NamespaceInfoFrom(ctx, true)
if err != nil {
return nil, err
}
@@ -128,33 +127,22 @@ func (r *DTOConnector) Connect(ctx context.Context, name string, opts runtime.Ob
return
}
- // Calculate access information -- needed to help smooth transition from /api/dashboard format
- dto := &dashboards.Dashboard{
- UID: name,
- OrgID: info.OrgID,
- ID: obj.GetDeprecatedInternalID(), // nolint:staticcheck
- }
- manager, ok := obj.GetManagerProperties()
- if ok && manager.Kind == utils.ManagerKindPlugin {
- dto.PluginID = manager.Identity
- }
-
- guardian, err := guardian.NewByDashboard(ctx, dto, info.OrgID, user)
- if err != nil {
- responder.Error(err)
- return
- }
- canView, err := guardian.CanView()
+ dashScope := dashboards.ScopeDashboardsProvider.GetResourceScopeUID(name)
+ evaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsRead, dashScope)
+ canView, err := r.accessControl.Evaluate(ctx, user, evaluator)
if err != nil || !canView {
responder.Error(fmt.Errorf("not allowed to view"))
return
}
access := &dashboard.DashboardAccess{}
- access.CanEdit, _ = guardian.CanEdit()
- access.CanSave, _ = guardian.CanSave()
- access.CanAdmin, _ = guardian.CanAdmin()
- access.CanDelete, _ = guardian.CanDelete()
+ writeEvaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsWrite, dashScope)
+ access.CanSave, _ = r.accessControl.Evaluate(ctx, user, writeEvaluator)
+ access.CanEdit = access.CanSave
+ adminEvaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsPermissionsWrite, dashScope)
+ access.CanAdmin, _ = r.accessControl.Evaluate(ctx, user, adminEvaluator)
+ deleteEvaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsDelete, dashScope)
+ access.CanDelete, _ = r.accessControl.Evaluate(ctx, user, deleteEvaluator)
access.CanStar = user.IsIdentityType(claims.TypeUser)
access.AnnotationsPermissions = &dashboard.AnnotationPermission{}
diff --git a/pkg/registry/apis/folders/register.go b/pkg/registry/apis/folders/register.go
index ca3d492815d..df8b2431f2f 100644
--- a/pkg/registry/apis/folders/register.go
+++ b/pkg/registry/apis/folders/register.go
@@ -6,7 +6,6 @@ import (
"fmt"
"strings"
- authtypes "github.com/grafana/authlib/types"
"github.com/prometheus/client_golang/prometheus"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
@@ -18,6 +17,7 @@ import (
common "k8s.io/kube-openapi/pkg/common"
"k8s.io/kube-openapi/pkg/spec3"
+ authtypes "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
@@ -156,6 +156,7 @@ func (b *FolderAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.API
}
opts.StorageOptions(resourceInfo.GroupResource(), apistore.StorageOptions{
+ EnableFolderSupport: true,
RequireDeprecatedInternalID: true})
folderStore := &folderStorage{
diff --git a/pkg/registry/apis/secret/contracts/encryption.go b/pkg/registry/apis/secret/contracts/encryption.go
new file mode 100644
index 00000000000..164143ce423
--- /dev/null
+++ b/pkg/registry/apis/secret/contracts/encryption.go
@@ -0,0 +1,49 @@
+package contracts
+
+import "context"
+
+// EncryptionManager is an envelope encryption service in charge of encrypting/decrypting secrets.
+type EncryptionManager interface {
+ // Encrypt MUST NOT be used within database transactions, it may cause database locks.
+ // For those specific use cases where the encryption operation cannot be moved outside
+ // the database transaction, look at database-specific methods present at the specific
+ // implementation present at manager.EncryptionService.
+ Encrypt(ctx context.Context, namespace string, payload []byte, opt EncryptionOptions) ([]byte, error)
+ Decrypt(ctx context.Context, namespace string, payload []byte) ([]byte, error)
+
+ RotateDataKeys(ctx context.Context, namespace string) error
+ ReEncryptDataKeys(ctx context.Context, namespace string) error
+}
+
+type EncryptionOptions func() string
+
+// EncryptWithoutScope uses a root level data key for encryption (DEK),
+// in other words this DEK is not bound to any specific scope (not attached to any user, org, etc.).
+func EncryptWithoutScope() EncryptionOptions {
+ return func() string {
+ return "root"
+ }
+}
+
+// EncryptWithScope uses a data key for encryption bound to some specific scope (i.e., user, org, etc.).
+// Scope should look like "user:10", "org:1".
+func EncryptWithScope(scope string) EncryptionOptions {
+ return func() string {
+ return scope
+ }
+}
+
+type EncryptedValue struct {
+ UID string
+ Namespace string
+ EncryptedData []byte
+ Created int64
+ Updated int64
+}
+
+type EncryptedValueStorage interface {
+ Create(ctx context.Context, namespace string, encryptedData []byte) (*EncryptedValue, error)
+ Update(ctx context.Context, namespace string, uid string, encryptedData []byte) error
+ Get(ctx context.Context, namespace string, uid string) (*EncryptedValue, error)
+ Delete(ctx context.Context, namespace string, uid string) error
+}
diff --git a/pkg/registry/apis/secret/contracts/keeper.go b/pkg/registry/apis/secret/contracts/keeper.go
index edd996ed4e9..2ba2cfb05dd 100644
--- a/pkg/registry/apis/secret/contracts/keeper.go
+++ b/pkg/registry/apis/secret/contracts/keeper.go
@@ -21,3 +21,35 @@ type KeeperMetadataStorage interface {
Delete(ctx context.Context, namespace xkube.Namespace, name string) error
List(ctx context.Context, namespace xkube.Namespace, options *internalversion.ListOptions) (*secretv0alpha1.KeeperList, error)
}
+
+// KeeperType represents the type of a Keeper.
+type KeeperType string
+
+const (
+ SQLKeeperType KeeperType = "sql"
+ AWSKeeperType KeeperType = "aws"
+ AzureKeeperType KeeperType = "azure"
+ GCPKeeperType KeeperType = "gcp"
+ HashiCorpKeeperType KeeperType = "hashicorp"
+)
+
+// ExternalID represents either the secure value's GUID or ref (in case of external secret references).
+// This is saved in the secure_value metadata storage as `external_id`.
+// TODO: this does not belong in the k8s spec, but it is used by us internally. Place it somewhere appropriate.
+type ExternalID string
+
+func (s ExternalID) String() string {
+ return string(s)
+}
+
+// Keeper is the interface for secret keepers.
+type Keeper interface {
+ Store(ctx context.Context, cfg secretv0alpha1.KeeperConfig, namespace string, exposedValueOrRef string) (ExternalID, error)
+ Update(ctx context.Context, cfg secretv0alpha1.KeeperConfig, namespace string, externalID ExternalID, exposedValueOrRef string) error
+ Expose(ctx context.Context, cfg secretv0alpha1.KeeperConfig, namespace string, externalID ExternalID) (secretv0alpha1.ExposedSecureValue, error)
+ Delete(ctx context.Context, cfg secretv0alpha1.KeeperConfig, namespace string, externalID ExternalID) error
+}
+
+const (
+ DefaultSQLKeeper = "kp-default-sql"
+)
diff --git a/pkg/registry/apis/secret/secretkeeper/secretkeeper.go b/pkg/registry/apis/secret/secretkeeper/secretkeeper.go
new file mode 100644
index 00000000000..daf150d1543
--- /dev/null
+++ b/pkg/registry/apis/secret/secretkeeper/secretkeeper.go
@@ -0,0 +1,45 @@
+package secretkeeper
+
+import (
+ "fmt"
+
+ "github.com/grafana/grafana/pkg/infra/tracing"
+ "github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
+ "github.com/grafana/grafana/pkg/registry/apis/secret/secretkeeper/sqlkeeper"
+)
+
+// Service is the interface for secret keeper services.
+// This exists because OSS and Enterprise have different amounts of keepers available.
+type Service interface {
+ GetKeepers() (map[contracts.KeeperType]contracts.Keeper, error)
+}
+
+// OSSKeeperService is the OSS implementation of the Service interface.
+type OSSKeeperService struct {
+ tracer tracing.Tracer
+ encryptionManager contracts.EncryptionManager
+ store contracts.EncryptedValueStorage
+}
+
+func ProvideService(
+ tracer tracing.Tracer,
+ store contracts.EncryptedValueStorage,
+ encryptionManager contracts.EncryptionManager,
+) (OSSKeeperService, error) {
+ return OSSKeeperService{
+ tracer: tracer,
+ encryptionManager: encryptionManager,
+ store: store,
+ }, nil
+}
+
+func (ks OSSKeeperService) GetKeepers() (map[contracts.KeeperType]contracts.Keeper, error) {
+ sqlKeeper, err := sqlkeeper.NewSQLKeeper(ks.tracer, ks.encryptionManager, ks.store)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create sql keeper: %w", err)
+ }
+
+ return map[contracts.KeeperType]contracts.Keeper{
+ contracts.SQLKeeperType: sqlKeeper,
+ }, nil
+}
diff --git a/pkg/registry/apis/secret/secretkeeper/secretkeeper_test.go b/pkg/registry/apis/secret/secretkeeper/secretkeeper_test.go
new file mode 100644
index 00000000000..8a89eefc226
--- /dev/null
+++ b/pkg/registry/apis/secret/secretkeeper/secretkeeper_test.go
@@ -0,0 +1,35 @@
+package secretkeeper
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/grafana/grafana/pkg/infra/tracing"
+ "github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
+ "github.com/grafana/grafana/pkg/registry/apis/secret/secretkeeper/sqlkeeper"
+ "github.com/grafana/grafana/pkg/setting"
+)
+
+func Test_OSSKeeperService_GetKeepers(t *testing.T) {
+ cfg := setting.NewCfg()
+ keeperService, err := setupTestService(t, cfg)
+ require.NoError(t, err)
+
+ t.Run("GetKeepers should return a map with a sql keeper", func(t *testing.T) {
+ keeperMap, err := keeperService.GetKeepers()
+ require.NoError(t, err)
+
+ assert.NotNil(t, keeperMap)
+ assert.Equal(t, 1, len(keeperMap))
+ assert.IsType(t, &sqlkeeper.SQLKeeper{}, keeperMap[contracts.SQLKeeperType])
+ })
+}
+
+func setupTestService(t *testing.T, cfg *setting.Cfg) (OSSKeeperService, error) {
+ // Initialize the keeper service
+ keeperService, err := ProvideService(tracing.InitializeTracerForTest(), nil, nil)
+
+ return keeperService, err
+}
diff --git a/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper.go b/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper.go
new file mode 100644
index 00000000000..7e35a2f6aa0
--- /dev/null
+++ b/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper.go
@@ -0,0 +1,92 @@
+package sqlkeeper
+
+import (
+ "context"
+ "fmt"
+
+ secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1"
+ "github.com/grafana/grafana/pkg/infra/tracing"
+ "github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
+)
+
+type SQLKeeper struct {
+ tracer tracing.Tracer
+ encryptionManager contracts.EncryptionManager
+ store contracts.EncryptedValueStorage
+}
+
+var _ contracts.Keeper = (*SQLKeeper)(nil)
+
+func NewSQLKeeper(
+ tracer tracing.Tracer,
+ encryptionManager contracts.EncryptionManager,
+ store contracts.EncryptedValueStorage,
+) (*SQLKeeper, error) {
+ return &SQLKeeper{
+ tracer: tracer,
+ encryptionManager: encryptionManager,
+ store: store,
+ }, nil
+}
+
+func (s *SQLKeeper) Store(ctx context.Context, cfg secretv0alpha1.KeeperConfig, namespace string, exposedValueOrRef string) (contracts.ExternalID, error) {
+ ctx, span := s.tracer.Start(ctx, "sqlKeeper.Store")
+ defer span.End()
+
+ encryptedData, err := s.encryptionManager.Encrypt(ctx, namespace, []byte(exposedValueOrRef), contracts.EncryptWithoutScope())
+ if err != nil {
+ return "", fmt.Errorf("unable to encrypt value: %w", err)
+ }
+
+ encryptedVal, err := s.store.Create(ctx, namespace, encryptedData)
+ if err != nil {
+ return "", fmt.Errorf("unable to store encrypted value: %w", err)
+ }
+
+ return contracts.ExternalID(encryptedVal.UID), nil
+}
+
+func (s *SQLKeeper) Expose(ctx context.Context, cfg secretv0alpha1.KeeperConfig, namespace string, externalID contracts.ExternalID) (secretv0alpha1.ExposedSecureValue, error) {
+ ctx, span := s.tracer.Start(ctx, "sqlKeeper.Expose")
+ defer span.End()
+
+ encryptedValue, err := s.store.Get(ctx, namespace, externalID.String())
+ if err != nil {
+ return "", fmt.Errorf("unable to get encrypted value: %w", err)
+ }
+
+ exposedBytes, err := s.encryptionManager.Decrypt(ctx, namespace, encryptedValue.EncryptedData)
+ if err != nil {
+ return "", fmt.Errorf("unable to decrypt value: %w", err)
+ }
+
+ exposedValue := secretv0alpha1.NewExposedSecureValue(string(exposedBytes))
+ return exposedValue, nil
+}
+
+func (s *SQLKeeper) Delete(ctx context.Context, cfg secretv0alpha1.KeeperConfig, namespace string, externalID contracts.ExternalID) error {
+ ctx, span := s.tracer.Start(ctx, "sqlKeeper.Delete")
+ defer span.End()
+
+ err := s.store.Delete(ctx, namespace, externalID.String())
+ if err != nil {
+ return fmt.Errorf("failed to delete encrypted value: %w", err)
+ }
+ return nil
+}
+
+func (s *SQLKeeper) Update(ctx context.Context, cfg secretv0alpha1.KeeperConfig, namespace string, externalID contracts.ExternalID, exposedValueOrRef string) error {
+ ctx, span := s.tracer.Start(ctx, "sqlKeeper.Update")
+ defer span.End()
+
+ encryptedData, err := s.encryptionManager.Encrypt(ctx, namespace, []byte(exposedValueOrRef), contracts.EncryptWithoutScope())
+ if err != nil {
+ return fmt.Errorf("unable to encrypt value: %w", err)
+ }
+
+ err = s.store.Update(ctx, namespace, externalID.String(), encryptedData)
+ if err != nil {
+ return fmt.Errorf("failed to update encrypted value: %w", err)
+ }
+ return nil
+}
diff --git a/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper_test.go b/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper_test.go
new file mode 100644
index 00000000000..a304c6cdc4c
--- /dev/null
+++ b/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper_test.go
@@ -0,0 +1,237 @@
+package sqlkeeper
+
+import (
+ "context"
+ "encoding/base64"
+ "fmt"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/grafana/grafana/pkg/infra/tracing"
+ "github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
+ "github.com/grafana/grafana/pkg/setting"
+)
+
+// Make this a `TestIntegration` once we have the real storage implementation
+func Test_SQLKeeperSetup(t *testing.T) {
+ ctx := context.Background()
+ namespace1 := "namespace1"
+ namespace2 := "namespace2"
+ plaintext1 := "very secret string in namespace 1"
+ plaintext2 := "very secret string in namespace 2"
+ nonExistentID := contracts.ExternalID("non existent")
+
+ cfg := setting.NewCfg()
+
+ sqlKeeper, err := setupTestService(t, cfg)
+ require.NoError(t, err)
+ require.NotNil(t, sqlKeeper)
+
+ t.Run("storing an encrypted value returns no error", func(t *testing.T) {
+ externalId1, err := sqlKeeper.Store(ctx, nil, namespace1, plaintext1)
+ require.NoError(t, err)
+ require.NotEmpty(t, externalId1)
+
+ externalId2, err := sqlKeeper.Store(ctx, nil, namespace2, plaintext2)
+ require.NoError(t, err)
+ require.NotEmpty(t, externalId2)
+
+ t.Run("expose the encrypted value from existing namespace", func(t *testing.T) {
+ exposedVal1, err := sqlKeeper.Expose(ctx, nil, namespace1, externalId1)
+ require.NoError(t, err)
+ require.NotNil(t, exposedVal1)
+ assert.Equal(t, plaintext1, exposedVal1.DangerouslyExposeAndConsumeValue())
+
+ exposedVal2, err := sqlKeeper.Expose(ctx, nil, namespace2, externalId2)
+ require.NoError(t, err)
+ require.NotNil(t, exposedVal2)
+ assert.Equal(t, plaintext2, exposedVal2.DangerouslyExposeAndConsumeValue())
+ })
+
+ t.Run("expose encrypted value from different namespace returns error", func(t *testing.T) {
+ exposedVal, err := sqlKeeper.Expose(ctx, nil, namespace2, externalId1)
+ require.Error(t, err)
+ assert.Empty(t, exposedVal)
+
+ exposedVal, err = sqlKeeper.Expose(ctx, nil, namespace1, externalId2)
+ require.Error(t, err)
+ assert.Empty(t, exposedVal)
+ })
+ })
+
+ t.Run("storing same value in same namespace returns no error", func(t *testing.T) {
+ externalId1, err := sqlKeeper.Store(ctx, nil, namespace1, plaintext1)
+ require.NoError(t, err)
+ require.NotEmpty(t, externalId1)
+
+ externalId2, err := sqlKeeper.Store(ctx, nil, namespace1, plaintext1)
+ require.NoError(t, err)
+ require.NotEmpty(t, externalId2)
+
+ assert.NotEqual(t, externalId1, externalId2)
+ })
+
+ t.Run("storing same value in different namespace returns no error", func(t *testing.T) {
+ externalId1, err := sqlKeeper.Store(ctx, nil, namespace1, plaintext1)
+ require.NoError(t, err)
+ require.NotEmpty(t, externalId1)
+
+ externalId2, err := sqlKeeper.Store(ctx, nil, namespace2, plaintext1)
+ require.NoError(t, err)
+ require.NotEmpty(t, externalId2)
+
+ assert.NotEqual(t, externalId1, externalId2)
+ })
+
+ t.Run("exposing non existing values returns error", func(t *testing.T) {
+ exposedVal, err := sqlKeeper.Expose(ctx, nil, namespace1, nonExistentID)
+ require.Error(t, err)
+ assert.Empty(t, exposedVal)
+ })
+
+ t.Run("deleting an existing encrypted value does not return error", func(t *testing.T) {
+ externalID, err := sqlKeeper.Store(ctx, nil, namespace1, plaintext1)
+ require.NoError(t, err)
+ require.NotEmpty(t, externalID)
+
+ exposedVal, err := sqlKeeper.Expose(ctx, nil, namespace1, externalID)
+ require.NoError(t, err)
+ assert.NotNil(t, exposedVal)
+ assert.Equal(t, plaintext1, exposedVal.DangerouslyExposeAndConsumeValue())
+
+ err = sqlKeeper.Delete(ctx, nil, namespace1, externalID)
+ require.NoError(t, err)
+ })
+
+ t.Run("deleting an non existing encrypted value does not return error", func(t *testing.T) {
+ err = sqlKeeper.Delete(ctx, nil, namespace1, nonExistentID)
+ require.NoError(t, err)
+ })
+
+ t.Run("updating an existent encrypted value returns no error", func(t *testing.T) {
+ externalId1, err := sqlKeeper.Store(ctx, nil, namespace1, plaintext1)
+ require.NoError(t, err)
+ require.NotEmpty(t, externalId1)
+
+ err = sqlKeeper.Update(ctx, nil, namespace1, externalId1, plaintext2)
+ require.NoError(t, err)
+
+ exposedVal, err := sqlKeeper.Expose(ctx, nil, namespace1, externalId1)
+ require.NoError(t, err)
+ assert.NotNil(t, exposedVal)
+ assert.Equal(t, plaintext2, exposedVal.DangerouslyExposeAndConsumeValue())
+ })
+
+ t.Run("updating a non existent encrypted value returns error", func(t *testing.T) {
+ externalId1, err := sqlKeeper.Store(ctx, nil, namespace1, plaintext1)
+ require.NoError(t, err)
+ require.NotEmpty(t, externalId1)
+
+ err = sqlKeeper.Update(ctx, nil, namespace1, nonExistentID, plaintext2)
+ require.Error(t, err)
+ })
+}
+
+func setupTestService(t *testing.T, cfg *setting.Cfg) (*SQLKeeper, error) {
+ // Initialize the encryption manager with in-memory implementation
+ encMgr := &inMemoryEncryptionManager{}
+
+ // Initialize encrypted value storage with in-memory implementation
+ encValueStore := newInMemoryEncryptedValueStorage()
+
+ // Initialize the SQLKeeper
+ sqlKeeper, err := NewSQLKeeper(tracing.InitializeTracerForTest(), encMgr, encValueStore)
+
+ return sqlKeeper, err
+}
+
+// While we don't have the real implementation, use an in-memory one
+type inMemoryEncryptionManager struct{}
+
+func (m *inMemoryEncryptionManager) Encrypt(_ context.Context, _ string, value []byte, _ contracts.EncryptionOptions) ([]byte, error) {
+ return []byte(base64.StdEncoding.EncodeToString(value)), nil
+}
+
+func (m *inMemoryEncryptionManager) Decrypt(_ context.Context, _ string, value []byte) ([]byte, error) {
+ return base64.StdEncoding.DecodeString(string(value))
+}
+
+func (m *inMemoryEncryptionManager) ReEncryptDataKeys(_ context.Context, _ string) error {
+ return nil
+}
+
+func (m *inMemoryEncryptionManager) RotateDataKeys(_ context.Context, _ string) error {
+ return nil
+}
+
+// While we don't have the real implementation, use an in-memory one
+type inMemoryEncryptedValueStorage struct {
+ mu sync.RWMutex
+ store map[string]*contracts.EncryptedValue
+}
+
+func newInMemoryEncryptedValueStorage() *inMemoryEncryptedValueStorage {
+ return &inMemoryEncryptedValueStorage{
+ store: make(map[string]*contracts.EncryptedValue),
+ }
+}
+
+func (m *inMemoryEncryptedValueStorage) Create(_ context.Context, namespace string, encryptedData []byte) (*contracts.EncryptedValue, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ uid := fmt.Sprintf("%d", len(m.store)+1) // Generate simple incremental IDs
+ encValue := &contracts.EncryptedValue{
+ UID: uid,
+ Namespace: namespace,
+ EncryptedData: encryptedData,
+ Created: 1, // Dummy timestamp
+ Updated: 1, // Dummy timestamp
+ }
+
+ compositeKey := namespace + ":" + uid
+ m.store[compositeKey] = encValue
+
+ return encValue, nil
+}
+
+func (m *inMemoryEncryptedValueStorage) Get(_ context.Context, namespace string, uid string) (*contracts.EncryptedValue, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+
+ compositeKey := namespace + ":" + uid
+ encValue, exists := m.store[compositeKey]
+ if !exists {
+ return nil, fmt.Errorf("value not found for namespace %s and uid %s", namespace, uid)
+ }
+
+ return encValue, nil
+}
+
+func (m *inMemoryEncryptedValueStorage) Delete(_ context.Context, namespace string, uid string) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ compositeKey := namespace + ":" + uid
+ delete(m.store, compositeKey)
+
+ return nil
+}
+
+func (m *inMemoryEncryptedValueStorage) Update(_ context.Context, namespace string, uid string, encryptedData []byte) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ compositeKey := namespace + ":" + uid
+ encValue, exists := m.store[compositeKey]
+ if !exists {
+ return fmt.Errorf("value not found for namespace %s and uid %s", namespace, uid)
+ }
+
+ encValue.EncryptedData = encryptedData
+ encValue.Updated = 2 // Update timestamp
+ return nil
+}
diff --git a/pkg/registry/backgroundsvcs/background_services.go b/pkg/registry/backgroundsvcs/background_services.go
index b0f339ed29d..621c2eee751 100644
--- a/pkg/registry/backgroundsvcs/background_services.go
+++ b/pkg/registry/backgroundsvcs/background_services.go
@@ -17,6 +17,7 @@ import (
"github.com/grafana/grafana/pkg/services/authn/authnimpl"
"github.com/grafana/grafana/pkg/services/cleanup"
"github.com/grafana/grafana/pkg/services/cloudmigration"
+ "github.com/grafana/grafana/pkg/services/dashboards/service"
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
"github.com/grafana/grafana/pkg/services/grpcserver"
"github.com/grafana/grafana/pkg/services/guardian"
@@ -69,6 +70,7 @@ func ProvideBackgroundServiceRegistry(
zanzanaReconciler *dualwrite.ZanzanaReconciler,
appRegistry *appregistry.Service,
pluginDashboardUpdater *plugindashboardsservice.DashboardUpdater,
+ dashboardServiceImpl *service.DashboardServiceImpl,
// Need to make sure these are initialized, is there a better place to put them?
_ dashboardsnapshots.Service,
_ serviceaccounts.Service, _ *guardian.Provider,
@@ -115,6 +117,7 @@ func ProvideBackgroundServiceRegistry(
zanzanaReconciler,
appRegistry,
pluginDashboardUpdater,
+ dashboardServiceImpl,
)
}
diff --git a/pkg/services/annotations/accesscontrol/accesscontrol_test.go b/pkg/services/annotations/accesscontrol/accesscontrol_test.go
index 3dc79fe876f..c92e1b9546b 100644
--- a/pkg/services/annotations/accesscontrol/accesscontrol_test.go
+++ b/pkg/services/annotations/accesscontrol/accesscontrol_test.go
@@ -10,9 +10,11 @@ import (
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/db"
+ "github.com/grafana/grafana/pkg/infra/kvstore"
+ "github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/accesscontrol"
- "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
+ "github.com/grafana/grafana/pkg/services/accesscontrol/actest"
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/annotations"
"github.com/grafana/grafana/pkg/services/annotations/testutil"
@@ -22,7 +24,6 @@ import (
dashboardsservice "github.com/grafana/grafana/pkg/services/dashboards/service"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder/folderimpl"
- "github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/quota/quotatest"
"github.com/grafana/grafana/pkg/services/search/sort"
"github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest"
@@ -42,19 +43,18 @@ func TestIntegrationAuthorize(t *testing.T) {
}
sql, cfg := db.InitTestDBWithCfg(t)
- origNewDashboardGuardian := guardian.New
- defer func() { guardian.New = origNewDashboardGuardian }()
- guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true})
folderStore := folderimpl.ProvideDashboardFolderStore(sql)
fStore := folderimpl.ProvideStore(sql)
dashStore, err := database.ProvideDashboardStore(sql, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sql))
require.NoError(t, err)
- ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
+ ac := actest.FakeAccessControl{ExpectedEvaluate: true}
folderSvc := folderimpl.ProvideService(
- fStore, accesscontrolmock.New(), bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderStore,
+ fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderStore,
nil, sql, featuremgmt.WithFeatures(), supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest(), nil, dualwrite.ProvideTestService(), sort.ProvideService())
dashSvc, err := dashboardsservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuremgmt.WithFeatures(), accesscontrolmock.NewMockedPermissionsService(),
- ac, folderSvc, fStore, nil, client.MockTestRestConfig{}, nil, quotatest.New(false, nil), nil, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService())
+ ac, folderSvc, fStore, nil, client.MockTestRestConfig{}, nil, quotatest.New(false, nil), nil, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService(),
+ serverlock.ProvideService(sql, tracing.InitializeTracerForTest()),
+ kvstore.NewFakeKVStore())
require.NoError(t, err)
dashSvc.RegisterDashboardPermissions(accesscontrolmock.NewMockedPermissionsService())
diff --git a/pkg/services/annotations/annotationsimpl/annotations_test.go b/pkg/services/annotations/annotationsimpl/annotations_test.go
index 0615023b430..8e169e88401 100644
--- a/pkg/services/annotations/annotationsimpl/annotations_test.go
+++ b/pkg/services/annotations/annotationsimpl/annotations_test.go
@@ -12,10 +12,12 @@ import (
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/db"
+ "github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/accesscontrol"
- "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
+ "github.com/grafana/grafana/pkg/services/accesscontrol/actest"
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/annotations"
"github.com/grafana/grafana/pkg/services/annotations/testutil"
@@ -54,19 +56,18 @@ func TestIntegrationAnnotationListingWithRBAC(t *testing.T) {
features := featuremgmt.WithFeatures()
tagService := tagimpl.ProvideService(sql)
ruleStore := alertingStore.SetupStoreForTesting(t, sql)
- origNewDashboardGuardian := guardian.New
- defer func() { guardian.New = origNewDashboardGuardian }()
- guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{})
folderStore := folderimpl.ProvideDashboardFolderStore(sql)
fStore := folderimpl.ProvideStore(sql)
dashStore, err := database.ProvideDashboardStore(sql, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sql))
require.NoError(t, err)
- ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
+ ac := actest.FakeAccessControl{ExpectedEvaluate: true}
folderSvc := folderimpl.ProvideService(
- fStore, accesscontrolmock.New(), bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderStore,
+ fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderStore,
nil, sql, featuremgmt.WithFeatures(), supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest(), nil, dualwrite.ProvideTestService(), sort.ProvideService())
dashSvc, err := dashboardsservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuremgmt.WithFeatures(), accesscontrolmock.NewMockedPermissionsService(),
- ac, folderSvc, fStore, nil, client.MockTestRestConfig{}, nil, quotatest.New(false, nil), nil, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService())
+ ac, folderSvc, fStore, nil, client.MockTestRestConfig{}, nil, quotatest.New(false, nil), nil, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService(),
+ serverlock.ProvideService(sql, tracing.InitializeTracerForTest()),
+ kvstore.NewFakeKVStore())
require.NoError(t, err)
dashSvc.RegisterDashboardPermissions(accesscontrolmock.NewMockedPermissionsService())
repo := ProvideService(sql, cfg, features, tagService, tracing.InitializeTracerForTest(), ruleStore, dashSvc)
@@ -242,14 +243,17 @@ func TestIntegrationAnnotationListingWithInheritedRBAC(t *testing.T) {
guardian.New = origNewGuardian
})
- ac := acimpl.ProvideAccessControl(features)
+ ac := actest.FakeAccessControl{ExpectedEvaluate: true}
fStore := folderimpl.ProvideStore(sql)
folderStore := folderimpl.ProvideDashboardFolderStore(sql)
folderSvc := folderimpl.ProvideService(
fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderStore,
nil, sql, features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest(), nil, dualwrite.ProvideTestService(), sort.ProvideService())
dashSvc, err := dashboardsservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, features, accesscontrolmock.NewMockedPermissionsService(),
- ac, folderSvc, fStore, nil, client.MockTestRestConfig{}, nil, quotatest.New(false, nil), nil, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService())
+ ac, folderSvc, fStore, nil, client.MockTestRestConfig{}, nil, quotatest.New(false, nil), nil, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService(),
+ serverlock.ProvideService(sql, tracing.InitializeTracerForTest()),
+ kvstore.NewFakeKVStore(),
+ )
require.NoError(t, err)
dashSvc.RegisterDashboardPermissions(accesscontrolmock.NewMockedPermissionsService())
cfg.AnnotationMaximumTagsLength = 60
diff --git a/pkg/services/annotations/annotationsimpl/cleanup_test.go b/pkg/services/annotations/annotationsimpl/cleanup_test.go
index 278bb48e365..324699736b1 100644
--- a/pkg/services/annotations/annotationsimpl/cleanup_test.go
+++ b/pkg/services/annotations/annotationsimpl/cleanup_test.go
@@ -119,8 +119,8 @@ func TestIntegrationAnnotationCleanUp(t *testing.T) {
t.Cleanup(func() {
err := fakeSQL.WithDbSession(context.Background(), func(session *db.Session) error {
- _, deleteAnnotationErr := session.Exec("DELETE FROM annotation")
- _, deleteAnnotationTagErr := session.Exec("DELETE FROM annotation_tag")
+ _, deleteAnnotationErr := session.Exec("DELETE FROM annotation WHERE true")
+ _, deleteAnnotationTagErr := session.Exec("DELETE FROM annotation_tag WHERE true")
return errors.Join(deleteAnnotationErr, deleteAnnotationTagErr)
})
assert.NoError(t, err)
@@ -157,7 +157,7 @@ func TestIntegrationOldAnnotationsAreDeletedFirst(t *testing.T) {
t.Cleanup(func() {
err := fakeSQL.WithDbSession(context.Background(), func(session *db.Session) error {
- _, err := session.Exec("DELETE FROM annotation")
+ _, err := session.Exec("DELETE FROM annotation WHERE true")
return err
})
assert.NoError(t, err)
diff --git a/pkg/services/annotations/annotationsimpl/xorm_store.go b/pkg/services/annotations/annotationsimpl/xorm_store.go
index 7b957189312..8bfa69e80cd 100644
--- a/pkg/services/annotations/annotationsimpl/xorm_store.go
+++ b/pkg/services/annotations/annotationsimpl/xorm_store.go
@@ -333,10 +333,11 @@ func (r *xormRepositoryImpl) Get(ctx context.Context, query annotations.ItemQuer
}
if len(tags) > 0 {
+ // "at" is a keyword in Spanner and needs to be quoted.
tagsSubQuery := fmt.Sprintf(`
- SELECT SUM(1) FROM annotation_tag at
- INNER JOIN tag on tag.id = at.tag_id
- WHERE at.annotation_id = a.id
+ SELECT SUM(1) FROM annotation_tag `+r.db.Quote("at")+`
+ INNER JOIN tag on tag.id = `+r.db.Quote("at")+`.tag_id
+ WHERE `+r.db.Quote("at")+`.annotation_id = a.id
AND (
%s
)
diff --git a/pkg/services/authn/authnimpl/registration.go b/pkg/services/authn/authnimpl/registration.go
index bad45e88ef5..444ae241c61 100644
--- a/pkg/services/authn/authnimpl/registration.go
+++ b/pkg/services/authn/authnimpl/registration.go
@@ -7,6 +7,7 @@ import (
"github.com/grafana/grafana/pkg/infra/remotecache"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/login/social"
+ "github.com/grafana/grafana/pkg/login/social/connectors"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/permreg"
"github.com/grafana/grafana/pkg/services/apikey"
@@ -112,7 +113,8 @@ func ProvideRegistration(
}
if cfg.JWTAuth.Enabled {
- authnSvc.RegisterClient(clients.ProvideJWT(jwtService, cfg))
+ orgRoleMapper := connectors.ProvideOrgRoleMapper(cfg, orgService)
+ authnSvc.RegisterClient(clients.ProvideJWT(jwtService, orgRoleMapper, cfg))
}
if cfg.ExtJWTAuth.Enabled {
diff --git a/pkg/services/authn/authnimpl/sync/user_sync.go b/pkg/services/authn/authnimpl/sync/user_sync.go
index f1ccefaae48..2f55c3a9cac 100644
--- a/pkg/services/authn/authnimpl/sync/user_sync.go
+++ b/pkg/services/authn/authnimpl/sync/user_sync.go
@@ -7,6 +7,7 @@ import (
"strconv"
claims "github.com/grafana/authlib/types"
+ "go.opentelemetry.io/otel/attribute"
"golang.org/x/sync/singleflight"
"github.com/grafana/grafana/pkg/apimachinery/errutil"
@@ -45,6 +46,14 @@ var (
"user.sync.fetch-not-found",
errutil.WithPublicMessage("User not found"),
)
+ errMismatchedExternalUID = errutil.Unauthorized(
+ "user.sync.mismatched-externalUID",
+ errutil.WithPublicMessage("Mismatched externalUID"),
+ )
+ errEmptyExternalUID = errutil.Unauthorized(
+ "user.sync.empty-externalUID",
+ errutil.WithPublicMessage("Empty externalUID"),
+ )
)
var (
@@ -231,7 +240,7 @@ func (s *UserSync) upsertAuthConnection(ctx context.Context, userID int64, ident
return nil
}
- // If a user does not a connection to a specific auth module, create it.
+ // If a user does not have a connection to a specific auth module, create it.
// This can happen when: using multiple auth client where the same user exists in several or
// changing to new auth client
if createConnection {
@@ -265,6 +274,8 @@ func (s *UserSync) updateUserAttributes(ctx context.Context, usr *user.User, id
ctx, span := s.tracer.Start(ctx, "user.sync.updateUserAttributes")
defer span.End()
+ needsConnectionCreation := userAuth == nil
+
if errProtection := s.userProtectionService.AllowUserMapping(usr, id.AuthenticatedBy); errProtection != nil {
return errUserProtection.Errorf("user mapping not allowed: %w", errProtection)
}
@@ -305,14 +316,38 @@ func (s *UserSync) updateUserAttributes(ctx context.Context, usr *user.User, id
needsUpdate = true
}
- if needsUpdate {
+ span.SetAttributes(
+ attribute.String("identity.ID", id.ID),
+ attribute.String("identity.ExternalUID", id.ExternalUID),
+ )
+ if usr.IsProvisioned {
+ s.log.Debug("User is provisioned", "id,UID", id.UID)
+ needsConnectionCreation = false
+ authInfo, err := s.authInfoService.GetAuthInfo(ctx, &login.GetAuthInfoQuery{UserId: usr.ID, AuthModule: id.AuthenticatedBy})
+ if err != nil {
+ s.log.Error("Error getting auth info", "error", err)
+ return err
+ }
+
+ if id.ExternalUID == "" {
+ s.log.Error("externalUID is empty", "id", id.UID)
+ return errEmptyExternalUID.Errorf("externalUID is empty")
+ }
+
+ if id.ExternalUID != authInfo.ExternalUID {
+ s.log.Error("mismatched externalUID", "provisioned_externalUID", authInfo.ExternalUID, "identity_externalUID", id.ExternalUID)
+ return errMismatchedExternalUID.Errorf("externalUID mistmatch")
+ }
+ }
+
+ if needsUpdate && !usr.IsProvisioned {
s.log.FromContext(ctx).Debug("Syncing user info", "id", id.ID, "update", fmt.Sprintf("%v", updateCmd))
if err := s.userService.Update(ctx, updateCmd); err != nil {
return err
}
}
- return s.upsertAuthConnection(ctx, usr.ID, id, userAuth == nil)
+ return s.upsertAuthConnection(ctx, usr.ID, id, needsConnectionCreation)
}
func (s *UserSync) createUser(ctx context.Context, id *authn.Identity) (*user.User, error) {
diff --git a/pkg/services/authn/clients/jwt.go b/pkg/services/authn/clients/jwt.go
index edc1f43dbc9..82323e21bfe 100644
--- a/pkg/services/authn/clients/jwt.go
+++ b/pkg/services/authn/clients/jwt.go
@@ -7,6 +7,7 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/errutil"
"github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/login/social/connectors"
"github.com/grafana/grafana/pkg/services/auth"
authJWT "github.com/grafana/grafana/pkg/services/auth/jwt"
"github.com/grafana/grafana/pkg/services/authn"
@@ -29,18 +30,22 @@ var (
"jwt.invalid_role", errutil.WithPublicMessage("Invalid Role in claim"))
)
-func ProvideJWT(jwtService auth.JWTVerifierService, cfg *setting.Cfg) *JWT {
+func ProvideJWT(jwtService auth.JWTVerifierService, orgRoleMapper *connectors.OrgRoleMapper, cfg *setting.Cfg) *JWT {
return &JWT{
- cfg: cfg,
- log: log.New(authn.ClientJWT),
- jwtService: jwtService,
+ cfg: cfg,
+ log: log.New(authn.ClientJWT),
+ jwtService: jwtService,
+ orgRoleMapper: orgRoleMapper,
+ orgMappingCfg: orgRoleMapper.ParseOrgMappingSettings(context.Background(), cfg.JWTAuth.OrgMapping, cfg.JWTAuth.RoleAttributeStrict),
}
}
type JWT struct {
- cfg *setting.Cfg
- log log.Logger
- jwtService auth.JWTVerifierService
+ cfg *setting.Cfg
+ orgRoleMapper *connectors.OrgRoleMapper
+ orgMappingCfg connectors.MappingConfiguration
+ log log.Logger
+ jwtService auth.JWTVerifierService
}
func (s *JWT) Name() string {
@@ -102,32 +107,31 @@ func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identi
id.Name = name
}
- orgRoles, isGrafanaAdmin, err := getRoles(s.cfg, func() (org.RoleType, *bool, error) {
- if s.cfg.JWTAuth.SkipOrgRoleSync {
- return "", nil, nil
- }
-
- role, grafanaAdmin := s.extractRoleAndAdmin(claims)
- if s.cfg.JWTAuth.RoleAttributeStrict && !role.IsValid() {
- return "", nil, errJWTInvalidRole.Errorf("invalid role claim in JWT: %s", role)
- }
-
- if !s.cfg.JWTAuth.AllowAssignGrafanaAdmin {
- return role, nil, nil
- }
-
- return role, &grafanaAdmin, nil
- })
+ id.Groups, err = s.extractGroups(claims)
if err != nil {
return nil, err
}
- id.OrgRoles = orgRoles
- id.IsGrafanaAdmin = isGrafanaAdmin
+ if !s.cfg.JWTAuth.SkipOrgRoleSync {
+ role, grafanaAdmin := s.extractRoleAndAdmin(claims)
+ if err != nil {
+ s.log.Warn("Failed to extract role", "err", err)
+ }
- id.Groups, err = s.extractGroups(claims)
- if err != nil {
- return nil, err
+ if s.cfg.JWTAuth.AllowAssignGrafanaAdmin {
+ id.IsGrafanaAdmin = &grafanaAdmin
+ }
+
+ externalOrgs, err := s.extractOrgs(claims)
+ if err != nil {
+ s.log.Warn("Failed to extract orgs", "err", err)
+ return nil, err
+ }
+
+ id.OrgRoles = s.orgRoleMapper.MapOrgRoles(s.orgMappingCfg, externalOrgs, role)
+ if s.cfg.JWTAuth.RoleAttributeStrict && len(id.OrgRoles) == 0 {
+ return nil, errJWTInvalidRole.Errorf("could not evaluate any valid roles using IdP provided data")
+ }
}
if id.Login == "" && id.Email == "" {
@@ -213,3 +217,12 @@ func (s *JWT) extractGroups(claims map[string]any) ([]string, error) {
return util.SearchJSONForStringSliceAttr(s.cfg.JWTAuth.GroupsAttributePath, claims)
}
+
+// This code was copied from the social_base.go file and was adapted to match with the JWT structure
+func (s *JWT) extractOrgs(claims map[string]any) ([]string, error) {
+ if s.cfg.JWTAuth.OrgAttributePath == "" {
+ return []string{}, nil
+ }
+
+ return util.SearchJSONForStringSliceAttr(s.cfg.JWTAuth.OrgAttributePath, claims)
+}
diff --git a/pkg/services/authn/clients/jwt_test.go b/pkg/services/authn/clients/jwt_test.go
index c4381f3945e..7fccffc6b17 100644
--- a/pkg/services/authn/clients/jwt_test.go
+++ b/pkg/services/authn/clients/jwt_test.go
@@ -11,9 +11,12 @@ import (
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/apimachinery/identity"
+ "github.com/grafana/grafana/pkg/login/social/connectors"
"github.com/grafana/grafana/pkg/services/auth/jwt"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/login"
+ "github.com/grafana/grafana/pkg/services/org"
+ "github.com/grafana/grafana/pkg/services/org/orgtest"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
@@ -136,6 +139,116 @@ func TestAuthenticateJWT(t *testing.T) {
},
},
},
+ {
+ name: "Valid Use case with org_mapping",
+ wantID: &authn.Identity{
+ OrgID: 0,
+ OrgName: "",
+ OrgRoles: map[int64]identity.RoleType{4: identity.RoleEditor, 5: identity.RoleViewer},
+ Login: "eai-doe",
+ Groups: []string{"foo", "bar"},
+ Name: "Eai Doe",
+ Email: "eai.doe@cor.po",
+ IsGrafanaAdmin: boolPtr(false),
+ AuthenticatedBy: login.JWTModule,
+ AuthID: "1234567890",
+ IsDisabled: false,
+ HelpFlags1: 0,
+ ClientParams: authn.ClientParams{
+ SyncUser: true,
+ AllowSignUp: true,
+ FetchSyncedUser: true,
+ SyncOrgRoles: true,
+ SyncPermissions: true,
+ SyncTeams: true,
+ LookUpParams: login.UserLookupParams{
+ Email: stringPtr("eai.doe@cor.po"),
+ Login: stringPtr("eai-doe"),
+ },
+ },
+ },
+ verifyProvider: func(context.Context, string) (map[string]any, error) {
+ return map[string]any{
+ "sub": "1234567890",
+ "email": "eai.doe@cor.po",
+ "preferred_username": "eai-doe",
+ "name": "Eai Doe",
+ "roles": "None",
+ "groups": []string{"foo", "bar"},
+ "orgs": []string{"org1", "org2"},
+ }, nil
+ },
+ cfg: &setting.Cfg{
+ JWTAuth: setting.AuthJWTSettings{
+ Enabled: true,
+ HeaderName: jwtHeaderName,
+ EmailClaim: "email",
+ UsernameClaim: "preferred_username",
+ AutoSignUp: true,
+ AllowAssignGrafanaAdmin: true,
+ RoleAttributeStrict: true,
+ RoleAttributePath: "roles",
+ GroupsAttributePath: "groups[]",
+ OrgAttributePath: "orgs[]",
+ OrgMapping: []string{"org1:Org4:Editor", "org2:Org5:Viewer"},
+ },
+ },
+ },
+ {
+ name: "Invalid Use case with org_mapping and invalid roles",
+ wantID: &authn.Identity{
+ OrgID: 0,
+ OrgName: "",
+ OrgRoles: map[int64]identity.RoleType{4: identity.RoleEditor, 5: identity.RoleViewer},
+ Login: "eai-doe",
+ Groups: []string{"foo", "bar"},
+ Name: "Eai Doe",
+ Email: "eai.doe@cor.po",
+ IsGrafanaAdmin: boolPtr(false),
+ AuthenticatedBy: login.JWTModule,
+ AuthID: "1234567890",
+ IsDisabled: false,
+ HelpFlags1: 0,
+ ClientParams: authn.ClientParams{
+ SyncUser: true,
+ AllowSignUp: true,
+ FetchSyncedUser: true,
+ SyncOrgRoles: true,
+ SyncPermissions: true,
+ SyncTeams: true,
+ LookUpParams: login.UserLookupParams{
+ Email: stringPtr("eai.doe@cor.po"),
+ Login: stringPtr("eai-doe"),
+ },
+ },
+ },
+ verifyProvider: func(context.Context, string) (map[string]any, error) {
+ return map[string]any{
+ "sub": "1234567890",
+ "email": "eai.doe@cor.po",
+ "preferred_username": "eai-doe",
+ "name": "Eai Doe",
+ "roles": []string{"Invalid"},
+ "groups": []string{"foo", "bar"},
+ "orgs": []string{"org1", "org2"},
+ }, nil
+ },
+ cfg: &setting.Cfg{
+ JWTAuth: setting.AuthJWTSettings{
+ Enabled: true,
+ HeaderName: jwtHeaderName,
+ EmailClaim: "email",
+ UsernameClaim: "preferred_username",
+ AutoSignUp: true,
+ AllowAssignGrafanaAdmin: true,
+ RoleAttributeStrict: true,
+ RoleAttributePath: "roles",
+ GroupsAttributePath: "groups[]",
+ OrgAttributePath: "orgs[]",
+ OrgMapping: []string{"org1:Org4:Editor", "org2:Org5:Viewer"},
+ },
+ },
+ },
}
for _, tc := range testCases {
@@ -146,7 +259,10 @@ func TestAuthenticateJWT(t *testing.T) {
VerifyProvider: tc.verifyProvider,
}
- jwtClient := ProvideJWT(jwtService, tc.cfg)
+ jwtClient := ProvideJWT(jwtService,
+ connectors.ProvideOrgRoleMapper(tc.cfg,
+ &orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}),
+ tc.cfg)
validHTTPReq := &http.Request{
Header: map[string][]string{
jwtHeaderName: {"sample-token"}},
@@ -262,7 +378,9 @@ func TestJWTClaimConfig(t *testing.T) {
Header: map[string][]string{
jwtHeaderName: {token}},
}
- jwtClient := ProvideJWT(jwtService, cfg)
+ jwtClient := ProvideJWT(jwtService, connectors.ProvideOrgRoleMapper(cfg,
+ &orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}),
+ cfg)
_, err := jwtClient.Authenticate(context.Background(), &authn.Request{
OrgID: 1,
HTTPRequest: httpReq,
@@ -372,7 +490,10 @@ func TestJWTTest(t *testing.T) {
RoleAttributeStrict: true,
},
}
- jwtClient := ProvideJWT(jwtService, cfg)
+ jwtClient := ProvideJWT(jwtService,
+ connectors.ProvideOrgRoleMapper(cfg,
+ &orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}),
+ cfg)
httpReq := &http.Request{
URL: &url.URL{RawQuery: "auth_token=" + tc.token},
Header: map[string][]string{
@@ -425,7 +546,10 @@ func TestJWTStripParam(t *testing.T) {
httpReq := &http.Request{
URL: &url.URL{RawQuery: "auth_token=" + token + "&other_param=other_value"},
}
- jwtClient := ProvideJWT(jwtService, cfg)
+ jwtClient := ProvideJWT(jwtService,
+ connectors.ProvideOrgRoleMapper(cfg,
+ &orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}),
+ cfg)
_, err := jwtClient.Authenticate(context.Background(), &authn.Request{
OrgID: 1,
HTTPRequest: httpReq,
@@ -481,7 +605,10 @@ func TestJWTSubClaimsConfig(t *testing.T) {
},
}
- jwtClient := ProvideJWT(jwtService, cfg)
+ jwtClient := ProvideJWT(jwtService,
+ connectors.ProvideOrgRoleMapper(cfg,
+ &orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}),
+ cfg)
identity, err := jwtClient.Authenticate(context.Background(), &authn.Request{
OrgID: 1,
HTTPRequest: httpReq,
diff --git a/pkg/services/authn/identity.go b/pkg/services/authn/identity.go
index fd971e7070f..2b6e7bfe533 100644
--- a/pkg/services/authn/identity.go
+++ b/pkg/services/authn/identity.go
@@ -76,6 +76,8 @@ type Identity struct {
Permissions map[int64]map[string][]string
// IDToken is a signed token representing the identity that can be forwarded to plugins and external services.
IDToken string
+ // ExternalUID is the unique identifier for the entity in the external system.
+ ExternalUID string
IDTokenClaims *authn.Claims[authn.IDTokenClaims]
AccessTokenClaims *authn.Claims[authn.AccessTokenClaims]
diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go
index 246e74c6da7..b8f30c1cab4 100644
--- a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go
+++ b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go
@@ -628,62 +628,92 @@ func TestGetFolderNamesForFolderUIDs(t *testing.T) {
func TestGetParentNames(t *testing.T) {
t.Parallel()
- s := setUpServiceTest(t, false).(*Service)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
+ s := setUpServiceTest(t, false).(*Service)
+
user := &user.SignedInUser{OrgID: 1}
- libraryElementFolderUID := "folderUID-A"
+
testcases := []struct {
+ name string
fakeFolders []*folder.Folder
- folders []folder.CreateFolderCommand
- dashboards []dashboards.Dashboard
- libraryElements []libraryElement
- alertRules []alertRule
- expectedParentNames map[cloudmigration.MigrateDataType][]string
+ folderHierarchy map[cloudmigration.MigrateDataType]map[string]string
+ expectedParentNames map[cloudmigration.MigrateDataType]map[string]string
}{
{
+ name: "multiple data types",
fakeFolders: []*folder.Folder{
- {UID: "folderUID-A", Title: "Folder A", OrgID: 1, ParentUID: ""},
- {UID: "folderUID-B", Title: "Folder B", OrgID: 1, ParentUID: "folderUID-A"},
- {UID: "folderUID-X", Title: "Folder X", OrgID: 1, ParentUID: ""},
+ {UID: "folderUID-A", Title: "Folder A", OrgID: 1},
+ {UID: "folderUID-B", Title: "Folder B", OrgID: 1},
+ {UID: "folderUID-C", Title: "Folder C", OrgID: 1},
},
- folders: []folder.CreateFolderCommand{
- {UID: "folderUID-C", Title: "Folder A", OrgID: 1, ParentUID: "folderUID-A"},
+ folderHierarchy: map[cloudmigration.MigrateDataType]map[string]string{
+ cloudmigration.DashboardDataType: {"dashboard-1": "folderUID-A", "dashboard-2": "folderUID-B", "dashboard-3": ""},
+ cloudmigration.LibraryElementDataType: {"libElement-1": "folderUID-A", "libElement-2": "folderUID-C"},
+ cloudmigration.AlertRuleType: {"alertRule-1": "folderUID-B"},
},
- dashboards: []dashboards.Dashboard{
- {UID: "dashboardUID-0", OrgID: 1, FolderUID: ""},
- {UID: "dashboardUID-1", OrgID: 1, FolderUID: "folderUID-A"},
- {UID: "dashboardUID-2", OrgID: 1, FolderUID: "folderUID-B"},
+ expectedParentNames: map[cloudmigration.MigrateDataType]map[string]string{
+ cloudmigration.DashboardDataType: {"dashboard-1": "Folder A", "dashboard-2": "Folder B", "dashboard-3": ""},
+ cloudmigration.LibraryElementDataType: {"libElement-1": "Folder A", "libElement-2": "Folder C"},
+ cloudmigration.AlertRuleType: {"alertRule-1": "Folder B"},
},
- libraryElements: []libraryElement{
- {UID: "libraryElementUID-0", FolderUID: &libraryElementFolderUID},
- {UID: "libraryElementUID-1"},
+ },
+ {
+ name: "empty folder hierarchy",
+ fakeFolders: []*folder.Folder{
+ {UID: "folderUID-A", Title: "Folder A", OrgID: 1},
},
- alertRules: []alertRule{
- {UID: "alertRuleUID-0", FolderUID: ""},
- {UID: "alertRuleUID-1", FolderUID: "folderUID-B"},
+ folderHierarchy: map[cloudmigration.MigrateDataType]map[string]string{},
+ expectedParentNames: map[cloudmigration.MigrateDataType]map[string]string{},
+ },
+ {
+ name: "all root folders (no parents)",
+ fakeFolders: []*folder.Folder{
+ {UID: "folderUID-A", Title: "Folder A", OrgID: 1},
},
- expectedParentNames: map[cloudmigration.MigrateDataType][]string{
- cloudmigration.DashboardDataType: {"", "Folder A", "Folder B"},
- cloudmigration.FolderDataType: {"Folder A"},
- cloudmigration.LibraryElementDataType: {"Folder A"},
- cloudmigration.AlertRuleType: {"Folder B"},
+ folderHierarchy: map[cloudmigration.MigrateDataType]map[string]string{
+ cloudmigration.DashboardDataType: {"dashboard-1": "", "dashboard-2": ""},
+ cloudmigration.LibraryElementDataType: {"libElement-1": ""},
+ },
+ expectedParentNames: map[cloudmigration.MigrateDataType]map[string]string{
+ cloudmigration.DashboardDataType: {"dashboard-1": "", "dashboard-2": ""},
+ cloudmigration.LibraryElementDataType: {"libElement-1": ""},
+ },
+ },
+ {
+ name: "non-existent folder UIDs",
+ fakeFolders: []*folder.Folder{
+ {UID: "folderUID-A", Title: "Folder A", OrgID: 1},
+ },
+ folderHierarchy: map[cloudmigration.MigrateDataType]map[string]string{
+ cloudmigration.DashboardDataType: {"dashboard-1": "folderUID-A", "dashboard-2": "non-existent-uid"},
+ },
+ expectedParentNames: map[cloudmigration.MigrateDataType]map[string]string{
+ cloudmigration.DashboardDataType: {"dashboard-1": "Folder A", "dashboard-2": ""},
},
},
}
for _, tc := range testcases {
- s.folderService = &foldertest.FakeService{ExpectedFolders: tc.fakeFolders}
+ t.Run(tc.name, func(t *testing.T) {
+ s.folderService = &foldertest.FakeService{ExpectedFolders: tc.fakeFolders}
- dataUIDsToParentNamesByType, err := s.getParentNames(ctx, user, tc.dashboards, tc.folders, tc.libraryElements, tc.alertRules)
- require.NoError(t, err)
+ dataUIDsToParentNamesByType, err := s.getParentNames(ctx, user, tc.folderHierarchy)
+ require.NoError(t, err)
- for dataType, expectedParentNames := range tc.expectedParentNames {
- actualParentNames := slices.Collect(maps.Values(dataUIDsToParentNamesByType[dataType]))
- require.Len(t, actualParentNames, len(expectedParentNames))
- require.ElementsMatch(t, expectedParentNames, actualParentNames)
- }
+ for dataType, expectedParentNames := range tc.expectedParentNames {
+ actualParentNames := dataUIDsToParentNamesByType[dataType]
+
+ require.Equal(t, len(expectedParentNames), len(actualParentNames))
+
+ for uid, expectedName := range expectedParentNames {
+ actualName, exists := actualParentNames[uid]
+ require.True(t, exists)
+ require.Equal(t, expectedName, actualName)
+ }
+ }
+ })
}
}
diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt.go b/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt.go
index 034c601f510..e74c9217fbc 100644
--- a/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt.go
+++ b/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt.go
@@ -12,6 +12,9 @@ import (
"sort"
"time"
+ "go.opentelemetry.io/otel/codes"
+ "golang.org/x/crypto/nacl/box"
+
snapshot "github.com/grafana/grafana-cloud-migration-snapshot/src"
"github.com/grafana/grafana-cloud-migration-snapshot/src/contracts"
"github.com/grafana/grafana-cloud-migration-snapshot/src/infra/crypto"
@@ -29,9 +32,6 @@ import (
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/util/retryer"
- "golang.org/x/crypto/nacl/box"
-
- "go.opentelemetry.io/otel/codes"
)
var currentMigrationTypes = []cloudmigration.MigrateDataType{
@@ -52,6 +52,10 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.getMigrationDataJSON")
defer span.End()
+ migrationDataSlice := make([]cloudmigration.MigrateDataRequestItem, 0)
+
+ folderHierarchy := make(map[cloudmigration.MigrateDataType]map[string]string, 0)
+
// Plugins
plugins, err := s.getPlugins(ctx, signedInUser)
if err != nil {
@@ -59,74 +63,6 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
return nil, err
}
- // Data sources
- dataSources, err := s.getDataSourceCommands(ctx, signedInUser)
- if err != nil {
- s.log.Error("Failed to get datasources", "err", err)
- return nil, err
- }
-
- // Dashboards and folders are linked via the schema, so we need to get both
- dashs, folders, err := s.getDashboardAndFolderCommands(ctx, signedInUser)
- if err != nil {
- s.log.Error("Failed to get dashboards and folders", "err", err)
- return nil, err
- }
-
- libraryElements, err := s.getLibraryElementsCommands(ctx, signedInUser)
- if err != nil {
- s.log.Error("Failed to get library elements", "err", err)
- return nil, err
- }
-
- // Alerts: Mute Timings
- muteTimings, err := s.getAlertMuteTimings(ctx, signedInUser)
- if err != nil {
- s.log.Error("Failed to get alert mute timings", "err", err)
- return nil, err
- }
-
- // Alerts: Notification Templates
- notificationTemplates, err := s.getNotificationTemplates(ctx, signedInUser)
- if err != nil {
- s.log.Error("Failed to get alert notification templates", "err", err)
- return nil, err
- }
-
- // Alerts: Contact Points
- contactPoints, err := s.getContactPoints(ctx, signedInUser)
- if err != nil {
- s.log.Error("Failed to get alert contact points", "err", err)
- return nil, err
- }
-
- // Alerts: Notification Policies
- notificationPolicies, err := s.getNotificationPolicies(ctx, signedInUser)
- if err != nil {
- s.log.Error("Failed to get alert notification policies", "err", err)
- return nil, err
- }
-
- // Alerts: Alert Rule Groups
- alertRuleGroups, err := s.getAlertRuleGroups(ctx, signedInUser)
- if err != nil {
- s.log.Error("Failed to get alert rule groups", "err", err)
- return nil, err
- }
-
- // Alerts: Alert Rules
- alertRules, err := s.getAlertRules(ctx, signedInUser)
- if err != nil {
- s.log.Error("Failed to get alert rules", "err", err)
- return nil, err
- }
-
- migrationDataSlice := make(
- []cloudmigration.MigrateDataRequestItem, 0,
- len(plugins)+len(dataSources)+len(dashs)+len(folders)+len(libraryElements)+
- len(muteTimings)+len(notificationTemplates)+len(contactPoints)+len(alertRules),
- )
-
for _, plugin := range plugins {
migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{
Type: cloudmigration.PluginDataType,
@@ -136,6 +72,13 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
})
}
+ // Data sources
+ dataSources, err := s.getDataSourceCommands(ctx, signedInUser)
+ if err != nil {
+ s.log.Error("Failed to get datasources", "err", err)
+ return nil, err
+ }
+
for _, ds := range dataSources {
migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{
Type: cloudmigration.DatasourceDataType,
@@ -145,6 +88,15 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
})
}
+ // Dashboards & Folders: linked via the schema, so we need to get both
+ dashs, folders, err := s.getDashboardAndFolderCommands(ctx, signedInUser)
+ if err != nil {
+ s.log.Error("Failed to get dashboards and folders", "err", err)
+ return nil, err
+ }
+
+ folderHierarchy[cloudmigration.DashboardDataType] = make(map[string]string, 0)
+
for _, dashboard := range dashs {
dashboard.Data.Del("id")
migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{
@@ -159,8 +111,12 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
FolderUID: dashboard.FolderUID,
},
})
+
+ folderHierarchy[cloudmigration.DashboardDataType][dashboard.UID] = dashboard.FolderUID
}
+ folderHierarchy[cloudmigration.FolderDataType] = make(map[string]string, 0)
+
folders = sortFolders(folders)
for _, f := range folders {
migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{
@@ -169,8 +125,19 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
Name: f.Title,
Data: f,
})
+
+ folderHierarchy[cloudmigration.FolderDataType][f.UID] = f.ParentUID
}
+ // Library Elements
+ libraryElements, err := s.getLibraryElementsCommands(ctx, signedInUser)
+ if err != nil {
+ s.log.Error("Failed to get library elements", "err", err)
+ return nil, err
+ }
+
+ folderHierarchy[cloudmigration.LibraryElementDataType] = make(map[string]string, 0)
+
for _, libraryElement := range libraryElements {
migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{
Type: cloudmigration.LibraryElementDataType,
@@ -178,6 +145,17 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
Name: libraryElement.Name,
Data: libraryElement,
})
+
+ if libraryElement.FolderUID != nil {
+ folderHierarchy[cloudmigration.LibraryElementDataType][libraryElement.UID] = *libraryElement.FolderUID
+ }
+ }
+
+ // Alerts: Mute Timings
+ muteTimings, err := s.getAlertMuteTimings(ctx, signedInUser)
+ if err != nil {
+ s.log.Error("Failed to get alert mute timings", "err", err)
+ return nil, err
}
for _, muteTiming := range muteTimings {
@@ -189,6 +167,13 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
})
}
+ // Alerts: Notification Templates
+ notificationTemplates, err := s.getNotificationTemplates(ctx, signedInUser)
+ if err != nil {
+ s.log.Error("Failed to get alert notification templates", "err", err)
+ return nil, err
+ }
+
for _, notificationTemplate := range notificationTemplates {
migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{
Type: cloudmigration.NotificationTemplateType,
@@ -198,6 +183,13 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
})
}
+ // Alerts: Contact Points
+ contactPoints, err := s.getContactPoints(ctx, signedInUser)
+ if err != nil {
+ s.log.Error("Failed to get alert contact points", "err", err)
+ return nil, err
+ }
+
for _, contactPoint := range contactPoints {
migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{
Type: cloudmigration.ContactPointType,
@@ -207,6 +199,13 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
})
}
+ // Alerts: Notification Policies
+ notificationPolicies, err := s.getNotificationPolicies(ctx, signedInUser)
+ if err != nil {
+ s.log.Error("Failed to get alert notification policies", "err", err)
+ return nil, err
+ }
+
if len(notificationPolicies.Name) > 0 {
// Notification Policy can only be managed by updating its entire tree, so we send the whole thing as one item.
migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{
@@ -217,6 +216,13 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
})
}
+ // Alerts: Alert Rule Groups
+ alertRuleGroups, err := s.getAlertRuleGroups(ctx, signedInUser)
+ if err != nil {
+ s.log.Error("Failed to get alert rule groups", "err", err)
+ return nil, err
+ }
+
for _, alertRuleGroup := range alertRuleGroups {
migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{
Type: cloudmigration.AlertRuleGroupType,
@@ -226,6 +232,15 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
})
}
+ // Alerts: Alert Rules
+ alertRules, err := s.getAlertRules(ctx, signedInUser)
+ if err != nil {
+ s.log.Error("Failed to get alert rules", "err", err)
+ return nil, err
+ }
+
+ folderHierarchy[cloudmigration.AlertRuleType] = make(map[string]string, 0)
+
for _, alertRule := range alertRules {
migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{
Type: cloudmigration.AlertRuleType,
@@ -233,10 +248,12 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
Name: alertRule.Title,
Data: alertRule,
})
+
+ folderHierarchy[cloudmigration.AlertRuleType][alertRule.UID] = alertRule.FolderUID
}
- // Obtain the names of parent elements for Dashboard and Folders data types
- parentNamesByType, err := s.getParentNames(ctx, signedInUser, dashs, folders, libraryElements, alertRules)
+ // Obtain the names of parent elements for data types that have folders.
+ parentNamesByType, err := s.getParentNames(ctx, signedInUser, folderHierarchy)
if err != nil {
s.log.Error("Failed to get parent folder names", "err", err)
}
@@ -805,10 +822,7 @@ func (s *Service) getFolderNamesForFolderUIDs(ctx context.Context, signedInUser
func (s *Service) getParentNames(
ctx context.Context,
signedInUser *user.SignedInUser,
- dashboards []dashboards.Dashboard,
- folders []folder.CreateFolderCommand,
- libraryElements []libraryElement,
- alertRules []alertRule,
+ folderHierarchy map[cloudmigration.MigrateDataType]map[string]string,
) (map[cloudmigration.MigrateDataType]map[string](string), error) {
parentNamesByType := make(map[cloudmigration.MigrateDataType]map[string]string)
for _, dataType := range currentMigrationTypes {
@@ -817,25 +831,18 @@ func (s *Service) getParentNames(
// Obtain list of unique folderUIDs
parentFolderUIDsSet := make(map[string]struct{})
- for _, dashboard := range dashboards {
- // we dont need the root folder
- if dashboard.FolderUID != "" {
- parentFolderUIDsSet[dashboard.FolderUID] = struct{}{}
- }
- }
- for _, f := range folders {
- parentFolderUIDsSet[f.ParentUID] = struct{}{}
- }
- for _, libraryElement := range libraryElements {
- if libraryElement.FolderUID != nil {
- parentFolderUIDsSet[*libraryElement.FolderUID] = struct{}{}
- }
- }
- for _, alertRule := range alertRules {
- if alertRule.FolderUID != "" {
- parentFolderUIDsSet[alertRule.FolderUID] = struct{}{}
+
+ for _, folderUIDs := range folderHierarchy {
+ for _, folderUID := range folderUIDs {
+ // Skip the root folder
+ if folderUID == "" {
+ continue
+ }
+
+ parentFolderUIDsSet[folderUID] = struct{}{}
}
}
+
parentFolderUIDsSlice := make([]string, 0, len(parentFolderUIDsSet))
for parentFolderUID := range parentFolderUIDsSet {
parentFolderUIDsSlice = append(parentFolderUIDsSlice, parentFolderUID)
@@ -849,20 +856,9 @@ func (s *Service) getParentNames(
}
// Prepare map of {data type: {data UID : parentName}}
- for _, dashboard := range dashboards {
- parentNamesByType[cloudmigration.DashboardDataType][dashboard.UID] = foldersUIDsToFolderName[dashboard.FolderUID]
- }
- for _, f := range folders {
- parentNamesByType[cloudmigration.FolderDataType][f.UID] = foldersUIDsToFolderName[f.ParentUID]
- }
- for _, libraryElement := range libraryElements {
- if libraryElement.FolderUID != nil {
- parentNamesByType[cloudmigration.LibraryElementDataType][libraryElement.UID] = foldersUIDsToFolderName[*libraryElement.FolderUID]
- }
- }
- for _, alertRule := range alertRules {
- if alertRule.FolderUID != "" {
- parentNamesByType[cloudmigration.AlertRuleType][alertRule.UID] = foldersUIDsToFolderName[alertRule.FolderUID]
+ for dataType, uidFolderMap := range folderHierarchy {
+ for uid, folderUID := range uidFolderMap {
+ parentNamesByType[dataType][uid] = foldersUIDsToFolderName[folderUID]
}
}
diff --git a/pkg/services/dashboards/dashboard.go b/pkg/services/dashboards/dashboard.go
index 92232c95bd8..b904e3c2e07 100644
--- a/pkg/services/dashboards/dashboard.go
+++ b/pkg/services/dashboards/dashboard.go
@@ -35,6 +35,7 @@ type DashboardService interface {
GetAllDashboardsByOrgId(ctx context.Context, orgID int64) ([]*Dashboard, error)
SoftDeleteDashboard(ctx context.Context, orgID int64, dashboardUid string) error
RestoreDashboard(ctx context.Context, dashboard *Dashboard, user identity.Requester, optionalFolderUID string) error
+ CleanUpDashboard(ctx context.Context, dashboardUID string, orgId int64) error
CleanUpDeletedDashboards(ctx context.Context) (int64, error)
GetSoftDeletedDashboard(ctx context.Context, orgID int64, uid string) (*Dashboard, error)
CountDashboardsInOrg(ctx context.Context, orgID int64) (int64, error)
diff --git a/pkg/services/dashboards/dashboard_service_mock.go b/pkg/services/dashboards/dashboard_service_mock.go
index 2e37ea6f3b8..ad526998488 100644
--- a/pkg/services/dashboards/dashboard_service_mock.go
+++ b/pkg/services/dashboards/dashboard_service_mock.go
@@ -562,6 +562,24 @@ func (_m *FakeDashboardService) SoftDeleteDashboard(ctx context.Context, orgID i
return r0
}
+// CleanUpDashboard provides a mock function with given fields: ctx, dashboardUID, orgId
+func (_m *FakeDashboardService) CleanUpDashboard(ctx context.Context, dashboardUID string, orgId int64) error {
+ ret := _m.Called(ctx, dashboardUID, orgId)
+
+ if len(ret) == 0 {
+ panic("no return value specified for CleanUpDashboard")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(context.Context, string, int64) error); ok {
+ r0 = rf(ctx, dashboardUID, orgId)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
// NewFakeDashboardService creates a new instance of FakeDashboardService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewFakeDashboardService(t interface {
diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go
index d5972f16bc3..032d574b07f 100644
--- a/pkg/services/dashboards/database/database.go
+++ b/pkg/services/dashboards/database/database.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
+ "strconv"
"strings"
"time"
@@ -673,7 +674,7 @@ func (d *dashboardStore) deleteDashboard(cmd *dashboards.DeleteDashboardCommand,
{SQL: "DELETE FROM dashboard_tag WHERE dashboard_uid = ? AND org_id = ?", args: []any{dashboard.UID, dashboard.OrgID}},
{SQL: "DELETE FROM star WHERE dashboard_id = ? ", args: []any{dashboard.ID}},
{SQL: "DELETE FROM dashboard WHERE id = ?", args: []any{dashboard.ID}},
- {SQL: "DELETE FROM playlist_item WHERE type = 'dashboard_by_id' AND value = ?", args: []any{dashboard.ID}},
+ {SQL: "DELETE FROM playlist_item WHERE type = 'dashboard_by_id' AND value = ?", args: []any{strconv.FormatInt(dashboard.ID, 10)}}, // Column has TEXT type.
{SQL: "DELETE FROM dashboard_version WHERE dashboard_id = ?", args: []any{dashboard.ID}},
{SQL: "DELETE FROM dashboard_provisioning WHERE dashboard_id = ?", args: []any{dashboard.ID}},
{SQL: "DELETE FROM dashboard_acl WHERE dashboard_id = ?", args: []any{dashboard.ID}},
@@ -737,7 +738,7 @@ func (d *dashboardStore) CleanupAfterDelete(ctx context.Context, cmd *dashboards
sqlStatements := []statement{
{SQL: "DELETE FROM dashboard_tag WHERE dashboard_uid = ? AND org_id = ?", args: []any{cmd.UID, cmd.OrgID}},
{SQL: "DELETE FROM star WHERE dashboard_uid = ? AND org_id = ?", args: []any{cmd.UID, cmd.OrgID}},
- {SQL: "DELETE FROM playlist_item WHERE type = 'dashboard_by_id' AND value = ?", args: []any{cmd.ID}},
+ {SQL: "DELETE FROM playlist_item WHERE type = 'dashboard_by_id' AND value = ?", args: []any{strconv.FormatInt(cmd.ID, 10)}}, // Column has TEXT type.
{SQL: "DELETE FROM dashboard_version WHERE dashboard_id = ?", args: []any{cmd.ID}},
{SQL: "DELETE FROM dashboard_provisioning WHERE dashboard_id = ?", args: []any{cmd.ID}},
{SQL: "DELETE FROM dashboard_acl WHERE dashboard_id = ?", args: []any{cmd.ID}},
diff --git a/pkg/services/dashboards/models.go b/pkg/services/dashboards/models.go
index 0128d7d2e17..fd074aba343 100644
--- a/pkg/services/dashboards/models.go
+++ b/pkg/services/dashboards/models.go
@@ -265,8 +265,6 @@ type GetDashboardQuery struct {
FolderID *int64
FolderUID *string
OrgID int64
-
- IncludeDeleted bool // only supported when using unified storage
}
type DashboardTagCloudItem struct {
diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go
index 53164cda375..d85dd62405b 100644
--- a/pkg/services/dashboards/service/dashboard_service.go
+++ b/pkg/services/dashboards/service/dashboard_service.go
@@ -11,8 +11,6 @@ import (
"time"
"github.com/google/uuid"
- "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacysearcher"
- "github.com/grafana/grafana/pkg/util/retryer"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel"
"golang.org/x/exp/maps"
@@ -31,9 +29,13 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/utils"
folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
"github.com/grafana/grafana/pkg/components/simplejson"
+ "github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/metrics"
+ "github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/infra/slugify"
+ "github.com/grafana/grafana/pkg/registry"
+ "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacysearcher"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/apiserver/client"
@@ -43,7 +45,6 @@ import (
dashboardsearch "github.com/grafana/grafana/pkg/services/dashboards/service/search"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
- "github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/publicdashboards"
"github.com/grafana/grafana/pkg/services/quota"
@@ -57,6 +58,8 @@ import (
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/search"
"github.com/grafana/grafana/pkg/util"
+ "github.com/grafana/grafana/pkg/util/retryer"
+ "go.opentelemetry.io/otel/attribute"
)
var (
@@ -69,6 +72,11 @@ var (
tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/dashboards/service")
)
+const (
+ k8sDashboardKvNamespace = "dashboard-cleanup"
+ k8sDashboardKvLastResourceVersionKey = "last-resource-version"
+)
+
type DashboardServiceImpl struct {
cfg *setting.Cfg
log log.Logger
@@ -83,11 +91,271 @@ type DashboardServiceImpl struct {
k8sclient client.K8sHandler
metrics *dashboardsMetrics
publicDashboardService publicdashboards.ServiceWrapper
+ serverLockService *serverlock.ServerLockService
+ kvstore kvstore.KVStore
dashboardPermissionsReady chan struct{}
}
+func (dr *DashboardServiceImpl) startK8sDeletedDashboardsCleanupJob(ctx context.Context) chan struct{} {
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+
+ ticker := time.NewTicker(dr.cfg.K8sDashboardCleanup.Interval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ if err := dr.executeCleanupWithLock(ctx); err != nil {
+ dr.log.Error("Failed to execute k8s dashboard cleanup", "error", err)
+ }
+ }
+ }
+ }()
+ return done
+}
+
+func (dr *DashboardServiceImpl) executeCleanupWithLock(ctx context.Context) error {
+ // We're taking a leader-like locking approach here. By locking and executing, but never releasing the lock,
+ // we ensure that other instances of this service can't run in parallel and hence the cleanup will only happen once
+ // per cleanup interval by setting the maxInterval and having the time between executions be the cleanup interval as well.
+ return dr.serverLockService.LockAndExecute(
+ ctx,
+ k8sDashboardKvNamespace,
+ dr.cfg.K8sDashboardCleanup.Interval,
+ func(ctx context.Context) {
+ if err := dr.cleanupK8sDashboardResources(ctx, dr.cfg.K8sDashboardCleanup.BatchSize, dr.cfg.K8sDashboardCleanup.Timeout); err != nil {
+ dr.log.Error("Failed to cleanup k8s dashboard resources", "error", err)
+ }
+ },
+ )
+}
+
+// cleanupK8sDashboardResources cleans up resources marked for deletion in the k8s API.
+// It processes all organizations, finds dashboards with the trash label, and cleans them up.
+// batchSize specifies how many dashboards to process in a single batch.
+// timeout specifies the timeout duration for the cleanup operation.
+func (dr *DashboardServiceImpl) cleanupK8sDashboardResources(ctx context.Context, batchSize int64, timeout time.Duration) error {
+ ctx, span := tracer.Start(ctx, "dashboards.service.cleanupK8sDashboardResources")
+ defer span.End()
+
+ if !dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) {
+ return nil
+ }
+
+ // Create a timeout context to ensure we complete before the lock expires
+ ctx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+
+ orgs, err := dr.orgService.Search(ctx, &org.SearchOrgsQuery{})
+ if err != nil {
+ return err
+ }
+ dr.log.Debug("Running k8s dashboard resource cleanup for all orgs", "numOrgs", len(orgs))
+
+ var errs []error
+ for _, org := range orgs {
+ // Check if we're approaching the timeout
+ if ctx.Err() != nil {
+ dr.log.Info("Timeout reached during cleanup, stopping processing", "timeout", timeout)
+ break
+ }
+
+ orgErr := dr.cleanupOrganizationK8sDashboards(ctx, org.ID, batchSize)
+ if orgErr != nil {
+ errs = append(errs, fmt.Errorf("org %d: %w", org.ID, orgErr))
+ }
+ }
+
+ if len(errs) > 0 {
+ return errors.Join(errs...)
+ }
+
+ return nil
+}
+
+// cleanupOrganizationK8sDashboards handles cleanup for a single organization's Kubernetes dashboards
+func (dr *DashboardServiceImpl) cleanupOrganizationK8sDashboards(ctx context.Context, orgID int64, batchSize int64) error {
+ dr.log.Debug("Running k8s dashboard resource cleanup for org", "orgID", orgID)
+
+ ctx, span := tracer.Start(ctx, "dashboards.service.cleanupK8sDashboardResources.org")
+ defer span.End()
+ span.SetAttributes(attribute.Int64("org_id", orgID))
+
+ ctx, _ = identity.WithServiceIdentity(ctx, orgID)
+
+ // Get the last processed resource version
+ lastResourceVersion, err := dr.getLastResourceVersion(ctx, orgID)
+ if err != nil {
+ return err
+ }
+
+ var errs []error
+ continueToken := ""
+ itemsProcessed := 0
+
+ for {
+ // Check if we're approaching the timeout
+ if ctx.Err() != nil {
+ dr.log.Info("Timeout reached during org cleanup, stopping processing", "orgID", orgID)
+ break
+ }
+
+ // List resources to be cleaned up
+ data, listErr, shouldContinue := dr.listResourcesToCleanup(ctx, orgID, lastResourceVersion, continueToken, batchSize)
+ if listErr != nil {
+ errs = append(errs, fmt.Errorf("failed to list resources: %w", listErr))
+ break
+ }
+ if shouldContinue {
+ // Reset and try again with updated resource version
+ lastResourceVersion = "0"
+ continueToken = ""
+ continue
+ }
+
+ // Skip the first item if it matches our last resource version (due to NotOlderThan behavior)
+ if len(data.Items) > 0 && data.Items[0].GetResourceVersion() == lastResourceVersion {
+ data.Items = data.Items[1:]
+ }
+
+ if len(data.Items) == 0 {
+ dr.log.Debug("No items to clean up in this batch", "orgID", orgID)
+ break
+ }
+
+ dr.log.Info("Processing dashboard cleanup batch", "orgID", orgID, "count", len(data.Items))
+
+ // Process the batch
+ processedItems, processingErrs := dr.processDashboardBatch(ctx, orgID, data.Items)
+ if len(processingErrs) > 0 {
+ errs = append(errs, processingErrs...)
+ }
+ itemsProcessed += processedItems
+
+ // Update resource version after the batch
+ if len(data.Items) > 0 {
+ maxBatchResourceVersion := data.Items[len(data.Items)-1].GetResourceVersion()
+ if lastResourceVersion != maxBatchResourceVersion {
+ dr.log.Info("Updating resource version after batch", "orgID", orgID,
+ "newResourceVersion", maxBatchResourceVersion, "oldResourceVersion", lastResourceVersion)
+
+ if updateErr := dr.kvstore.Set(ctx, orgID, k8sDashboardKvNamespace,
+ k8sDashboardKvLastResourceVersionKey, maxBatchResourceVersion); updateErr != nil {
+ errs = append(errs, fmt.Errorf("failed to update resource version: %w", updateErr))
+ }
+ }
+ }
+
+ meta, _ := data.Object["metadata"].(map[string]interface{})
+ continueToken, _ = meta["continue"].(string)
+ if continueToken == "" {
+ break
+ }
+ }
+
+ if itemsProcessed > 0 {
+ dr.log.Info("Finished k8s dashboard resources cleanup", "orgID", orgID, "itemsProcessed", itemsProcessed)
+ }
+
+ if len(errs) > 0 {
+ return errors.Join(errs...)
+ }
+ return nil
+}
+
+// getLastResourceVersion retrieves the last processed resource version from kvstore
+func (dr *DashboardServiceImpl) getLastResourceVersion(ctx context.Context, orgID int64) (string, error) {
+ lastResourceVersion, ok, err := dr.kvstore.Get(ctx, orgID, k8sDashboardKvNamespace, k8sDashboardKvLastResourceVersionKey)
+ if err != nil {
+ return "", fmt.Errorf("failed to get last resource version: %w", err)
+ }
+
+ if !ok {
+ dr.log.Info("No last resource version found, starting from scratch", "orgID", orgID)
+ return "0", nil
+ }
+
+ return lastResourceVersion, nil
+}
+
+// listResourcesToCleanup lists resources that need to be cleaned up
+func (dr *DashboardServiceImpl) listResourcesToCleanup(ctx context.Context, orgID int64, resourceVersion, continueToken string, batchSize int64) (*unstructured.UnstructuredList, error, bool) {
+ var listOptions v1.ListOptions
+ if continueToken != "" {
+ listOptions = v1.ListOptions{
+ LabelSelector: utils.LabelKeyGetTrash + "=true",
+ Continue: continueToken,
+ Limit: batchSize,
+ }
+ } else {
+ listOptions = v1.ListOptions{
+ LabelSelector: utils.LabelKeyGetTrash + "=true",
+ ResourceVersionMatch: v1.ResourceVersionMatchNotOlderThan,
+ ResourceVersion: resourceVersion,
+ Limit: batchSize,
+ }
+ }
+
+ data, err := dr.k8sclient.List(ctx, orgID, listOptions)
+ if err != nil {
+ if strings.Contains(err.Error(), "too old resource version") {
+ // If the resource version is too old, start from the current version
+ dr.log.Info("Resource version too old, starting from current version", "orgID", orgID)
+ return nil, nil, true // Signal to continue with reset version
+ }
+ return nil, err, false
+ }
+
+ return data, nil, false
+}
+
+// processDashboardBatch processes a batch of dashboards for cleanup
+func (dr *DashboardServiceImpl) processDashboardBatch(ctx context.Context, orgID int64, items []unstructured.Unstructured) (int, []error) {
+ var errs []error
+ itemsProcessed := 0
+
+ for _, item := range items {
+ dash, err := dr.UnstructuredToLegacyDashboard(ctx, &item, orgID)
+ if err != nil {
+ errs = append(errs, fmt.Errorf("failed to convert dashboard: %w", err))
+ continue
+ }
+
+ meta, _ := item.Object["metadata"].(map[string]interface{})
+ deletionTimestamp, _ := meta["deletionTimestamp"].(string)
+ resourceVersion, _ := meta["resourceVersion"].(string)
+
+ dr.log.Info("K8s dashboard resource previously got deleted, cleaning up",
+ "UID", dash.UID,
+ "orgID", orgID,
+ "deletionTimestamp", deletionTimestamp,
+ "resourceVersion", resourceVersion)
+
+ if err = dr.CleanUpDashboard(ctx, dash.UID, orgID); err != nil {
+ errs = append(errs, fmt.Errorf("failed to clean up dashboard %s: %w", dash.UID, err))
+ }
+ itemsProcessed++
+ }
+
+ return itemsProcessed, errs
+}
+
+// This gets auto-invoked when grafana starts, part of the BackgroundService interface
+func (dr *DashboardServiceImpl) Run(ctx context.Context) error {
+ cleanupBackgroundJobStopped := dr.startK8sDeletedDashboardsCleanupJob(ctx)
+ <-ctx.Done()
+ // Wait for cleanup job to finish
+ <-cleanupBackgroundJobStopped
+ return ctx.Err()
+}
+
var _ dashboards.PermissionsRegistrationService = (*DashboardServiceImpl)(nil)
+var _ registry.BackgroundService = (*DashboardServiceImpl)(nil)
// This is the uber service that implements a three smaller services
func ProvideDashboardServiceImpl(
@@ -97,6 +365,8 @@ func ProvideDashboardServiceImpl(
restConfigProvider apiserver.RestConfigProvider, userService user.Service,
quotaService quota.Service, orgService org.Service, publicDashboardService publicdashboards.ServiceWrapper,
resourceClient resource.ResourceClient, dual dualwrite.Service, sorter sort.Service,
+ serverLockService *serverlock.ServerLockService,
+ kvstore kvstore.KVStore,
) (*DashboardServiceImpl, error) {
k8sHandler := client.NewK8sHandler(dual, request.GetNamespaceMapper(cfg), dashboardv0alpha1.DashboardResourceInfo.GroupVersionResource(), restConfigProvider.GetRestConfig, dashboardStore, userService, resourceClient, sorter)
@@ -114,6 +384,8 @@ func ProvideDashboardServiceImpl(
metrics: newDashboardsMetrics(r),
dashboardPermissionsReady: make(chan struct{}),
publicDashboardService: publicDashboardService,
+ serverLockService: serverLockService,
+ kvstore: kvstore,
}
defaultLimits, err := readQuotaConfig(cfg)
@@ -413,14 +685,24 @@ func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, d
}
if isParentFolderChanged {
- // Check that the user is allowed to add a dashboard to the folder
- guardian, err := guardian.NewByDashboard(ctx, dash, dto.OrgID, dto.User)
- if err != nil {
- return nil, err
+ if canCreate, err := dr.canCreateDashboard(ctx, dto.User, dash); err != nil || !canCreate {
+ if err != nil {
+ return nil, err
+ }
+ return nil, dashboards.ErrDashboardUpdateAccessDenied
}
+ }
+
+ if dash.ID == 0 {
metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc()
- // nolint:staticcheck
- if canSave, err := guardian.CanCreate(dash.FolderID, dash.IsFolder); err != nil || !canSave {
+ if canCreate, err := dr.canCreateDashboard(ctx, dto.User, dash); err != nil || !canCreate {
+ if err != nil {
+ return nil, err
+ }
+ return nil, dashboards.ErrDashboardUpdateAccessDenied
+ }
+ } else {
+ if canSave, err := dr.canSaveDashboard(ctx, dto.User, dash); err != nil || !canSave {
if err != nil {
return nil, err
}
@@ -439,29 +721,6 @@ func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, d
}
}
- guard, err := getGuardianForSavePermissionCheck(ctx, dash, dto.User)
- if err != nil {
- return nil, err
- }
-
- if dash.ID == 0 {
- metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc()
- // nolint:staticcheck
- if canCreate, err := guard.CanCreate(dash.FolderID, dash.IsFolder); err != nil || !canCreate {
- if err != nil {
- return nil, err
- }
- return nil, dashboards.ErrDashboardUpdateAccessDenied
- }
- } else {
- if canSave, err := guard.CanSave(); err != nil || !canSave {
- if err != nil {
- return nil, err
- }
- return nil, dashboards.ErrDashboardUpdateAccessDenied
- }
- }
-
var userID int64
if id, err := identity.UserIdentifier(dto.User.GetID()); err == nil {
userID = id
@@ -561,6 +820,30 @@ func (dr *DashboardServiceImpl) ValidateDashboardBeforeSave(ctx context.Context,
return isParentFolderChanged, nil
}
+func (dr *DashboardServiceImpl) canSaveDashboard(ctx context.Context, user identity.Requester, dash *dashboards.Dashboard) (bool, error) {
+ action := dashboards.ActionDashboardsWrite
+ if dash.IsFolder {
+ action = dashboards.ActionFoldersWrite
+ }
+ scope := dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dash.UID)
+ if dash.IsFolder {
+ scope = dashboards.ScopeFoldersProvider.GetResourceScopeUID(dash.UID)
+ }
+ return dr.ac.Evaluate(ctx, user, accesscontrol.EvalPermission(action, scope))
+}
+
+func (dr *DashboardServiceImpl) canCreateDashboard(ctx context.Context, user identity.Requester, dash *dashboards.Dashboard) (bool, error) {
+ action := dashboards.ActionDashboardsCreate
+ if dash.IsFolder {
+ action = dashboards.ActionFoldersCreate
+ }
+ scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(dash.FolderUID)
+ if dash.FolderUID == "" {
+ scope = dashboards.ScopeFoldersProvider.GetResourceScopeUID(accesscontrol.GeneralFolderUID)
+ }
+ return dr.ac.Evaluate(ctx, user, accesscontrol.EvalPermission(action, scope))
+}
+
// waitForSearchQuery waits for the search query to return the expected number of hits.
// Since US doesn't offer search-after-write guarantees, we can use this to wait after writes until the indexer is up to date.
func (dr *DashboardServiceImpl) waitForSearchQuery(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery, maxRetries int, expectedHits int64) error {
@@ -620,46 +903,6 @@ func (dr *DashboardServiceImpl) DeleteOrphanedProvisionedDashboards(ctx context.
return dr.dashboardStore.DeleteOrphanedProvisionedDashboards(ctx, cmd)
}
-// getGuardianForSavePermissionCheck returns the guardian to be used for checking permission of dashboard
-// It replaces deleted Dashboard.GetDashboardIdForSavePermissionCheck()
-func getGuardianForSavePermissionCheck(ctx context.Context, d *dashboards.Dashboard, user identity.Requester) (guardian.DashboardGuardian, error) {
- ctx, span := tracer.Start(ctx, "dashboards.service.getGuardianForSavePermissionCheck")
- defer span.End()
-
- newDashboard := d.ID == 0
-
- if newDashboard {
- // if it's a new dashboard/folder check the parent folder permissions
- metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc()
- guard, err := guardian.NewByFolder(ctx, &folder.Folder{
- ID: d.FolderID, // nolint:staticcheck
- OrgID: d.OrgID,
- }, d.OrgID, user)
- if err != nil {
- return nil, err
- }
- return guard, nil
- }
-
- if d.IsFolder {
- guard, err := guardian.NewByFolder(ctx, &folder.Folder{
- ID: d.ID, // nolint:staticcheck
- UID: d.UID,
- OrgID: d.OrgID,
- }, d.OrgID, user)
- if err != nil {
- return nil, err
- }
- return guard, nil
- }
-
- guard, err := guardian.NewByDashboard(ctx, d, d.OrgID, user)
- if err != nil {
- return nil, err
- }
- return guard, nil
-}
-
func validateDashboardRefreshInterval(minRefreshInterval string, dash *dashboards.Dashboard) error {
if minRefreshInterval == "" {
return nil
@@ -787,7 +1030,7 @@ func (dr *DashboardServiceImpl) saveDashboard(ctx context.Context, cmd *dashboar
func (dr *DashboardServiceImpl) GetSoftDeletedDashboard(ctx context.Context, orgID int64, uid string) (*dashboards.Dashboard, error) {
if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) {
- return dr.getDashboardThroughK8s(ctx, &dashboards.GetDashboardQuery{OrgID: orgID, UID: uid, IncludeDeleted: true})
+ return dr.getDashboardThroughK8s(ctx, &dashboards.GetDashboardQuery{OrgID: orgID, UID: uid})
}
return dr.dashboardStore.GetSoftDeletedDashboard(ctx, orgID, uid)
@@ -890,18 +1133,7 @@ func (dr *DashboardServiceImpl) deleteDashboard(ctx context.Context, dashboardId
cmd := &dashboards.DeleteDashboardCommand{OrgID: orgId, ID: dashboardId, UID: dashboardUID}
if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) {
- err := dr.deleteDashboardThroughK8s(ctx, cmd, validateProvisionedDashboard)
- if err != nil {
- return err
- }
-
- // cleanup things related to dashboards that are not stored in unistore yet
- err = dr.publicDashboardService.DeleteByDashboardUIDs(ctx, orgId, []string{dashboardUID})
- if err != nil {
- return err
- }
-
- return dr.dashboardStore.CleanupAfterDelete(ctx, cmd)
+ return dr.deleteDashboardThroughK8s(ctx, cmd, validateProvisionedDashboard)
}
if validateProvisionedDashboard {
@@ -1524,6 +1756,19 @@ func (dr *DashboardServiceImpl) DeleteInFolders(ctx context.Context, orgID int64
func (dr *DashboardServiceImpl) Kind() string { return entity.StandardKindDashboard }
+func (dr *DashboardServiceImpl) CleanUpDashboard(ctx context.Context, dashboardUID string, orgId int64) error {
+ ctx, span := tracer.Start(ctx, "dashboards.service.CleanUpDashboard")
+ defer span.End()
+
+ // cleanup things related to dashboards that are not stored in unistore yet
+ var err = dr.publicDashboardService.DeleteByDashboardUIDs(ctx, orgId, []string{dashboardUID})
+ if err != nil {
+ return err
+ }
+
+ return dr.dashboardStore.CleanupAfterDelete(ctx, &dashboards.DeleteDashboardCommand{OrgID: orgId, UID: dashboardUID})
+}
+
func (dr *DashboardServiceImpl) CleanUpDeletedDashboards(ctx context.Context) (int64, error) {
ctx, span := tracer.Start(ctx, "dashboards.service.CleanUpDeletedDashboards")
defer span.End()
@@ -1550,12 +1795,6 @@ func (dr *DashboardServiceImpl) CleanUpDeletedDashboards(ctx context.Context) (i
// -----------------------------------------------------------------------------------------
func (dr *DashboardServiceImpl) getDashboardThroughK8s(ctx context.Context, query *dashboards.GetDashboardQuery) (*dashboards.Dashboard, error) {
- // if including deleted dashboards for restore, use the /latest subresource
- subresource := ""
- if query.IncludeDeleted && dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesRestore) {
- subresource = "latest"
- }
-
// get uid if not passed in
if query.UID == "" {
result, err := dr.GetDashboardUIDByID(ctx, &dashboards.GetDashboardRefByIDQuery{
@@ -1568,7 +1807,7 @@ func (dr *DashboardServiceImpl) getDashboardThroughK8s(ctx context.Context, quer
query.UID = result.UID
}
- out, err := dr.k8sclient.Get(ctx, query.UID, query.OrgID, v1.GetOptions{}, subresource)
+ out, err := dr.k8sclient.Get(ctx, query.UID, query.OrgID, v1.GetOptions{}, "")
if err != nil && !apierrors.IsNotFound(err) {
return nil, err
} else if err != nil || out == nil {
@@ -1606,12 +1845,13 @@ func (dr *DashboardServiceImpl) saveProvisionedDashboardThroughK8s(ctx context.C
meta.SetManagerProperties(m)
meta.SetSourceProperties(s)
- out, err := dr.createOrUpdateDash(ctx, obj, cmd.OrgID)
+ // Update will create if not exists (upsert!)
+ out, err := dr.k8sclient.Update(ctx, obj, cmd.OrgID)
if err != nil {
return nil, err
}
- return out, nil
+ return dr.UnstructuredToLegacyDashboard(ctx, out, cmd.OrgID)
}
func (dr *DashboardServiceImpl) saveDashboardThroughK8s(ctx context.Context, cmd *dashboards.SaveDashboardCommand, orgID int64) (*dashboards.Dashboard, error) {
@@ -1622,35 +1862,13 @@ func (dr *DashboardServiceImpl) saveDashboardThroughK8s(ctx context.Context, cmd
dashboard.SetPluginIDMeta(obj, cmd.PluginID)
- out, err := dr.createOrUpdateDash(ctx, obj, orgID)
+ // Update will create if not exists (upsert!)
+ out, err := dr.k8sclient.Update(ctx, obj, orgID)
if err != nil {
return nil, err
}
- return out, nil
-}
-
-func (dr *DashboardServiceImpl) createOrUpdateDash(ctx context.Context, obj *unstructured.Unstructured, orgID int64) (*dashboards.Dashboard, error) {
- var out *unstructured.Unstructured
- current, err := dr.k8sclient.Get(ctx, obj.GetName(), orgID, v1.GetOptions{})
- if current == nil || err != nil {
- out, err = dr.k8sclient.Create(ctx, obj, orgID)
- if err != nil {
- return nil, err
- }
- } else {
- out, err = dr.k8sclient.Update(ctx, obj, orgID)
- if err != nil {
- return nil, err
- }
- }
-
- finalDash, err := dr.UnstructuredToLegacyDashboard(ctx, out, orgID)
- if err != nil {
- return nil, err
- }
-
- return finalDash, nil
+ return dr.UnstructuredToLegacyDashboard(ctx, out, orgID)
}
func (dr *DashboardServiceImpl) deleteAllDashboardThroughK8s(ctx context.Context, orgID int64) error {
diff --git a/pkg/services/dashboards/service/dashboard_service_integration_test.go b/pkg/services/dashboards/service/dashboard_service_integration_test.go
index 221ade24f72..7887e628110 100644
--- a/pkg/services/dashboards/service/dashboard_service_integration_test.go
+++ b/pkg/services/dashboards/service/dashboard_service_integration_test.go
@@ -8,13 +8,14 @@ import (
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
- "github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/db"
- "github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/infra/kvstore"
+ "github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/accesscontrol"
+ "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/apiserver/client"
@@ -23,7 +24,6 @@ import (
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/folder/folderimpl"
- "github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/publicdashboards"
"github.com/grafana/grafana/pkg/services/quota/quotatest"
@@ -49,809 +49,710 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) {
t.Run("Given saved folders and dashboards in organization A", func(t *testing.T) {
// Basic validation tests
- permissionScenario(t, "When saving a dashboard with non-existing id", true,
- func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: testOrgID,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "id": float64(123412321),
- "title": "Expect error",
- }),
- }
+ permissionScenario(t, "When saving a dashboard with non-existing id", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: testOrgID,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "id": float64(123412321),
+ "title": "Expect error",
+ }),
+ }
- err := callSaveWithError(t, cmd, sc.sqlStore)
- assert.Equal(t, dashboards.ErrDashboardNotFound, err)
- })
+ _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ assert.Equal(t, dashboards.ErrDashboardNotFound, err)
+ })
// Given other organization
t.Run("Given organization B", func(t *testing.T) {
const otherOrgId int64 = 2
- permissionScenario(t, "When creating a dashboard with same id as dashboard in organization A",
- true, func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: otherOrgId,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "id": sc.savedDashInFolder.ID,
- "title": "Expect error",
- }),
- Overwrite: false,
- }
+ permissionScenario(t, "When creating a dashboard with same id as dashboard in organization A", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: otherOrgId,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "id": sc.savedDashInFolder.ID,
+ "title": "Expect error",
+ }),
+ Overwrite: false,
+ }
- err := callSaveWithError(t, cmd, sc.sqlStore)
- assert.Equal(t, dashboards.ErrDashboardNotFound, err)
- })
-
- permissionScenario(t, "When creating a dashboard with same uid as dashboard in organization A, it should create a new dashboard in org B",
- true, func(t *testing.T, sc *permissionScenarioContext) {
- const otherOrgId int64 = 2
- cmd := dashboards.SaveDashboardCommand{
- OrgID: otherOrgId,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "uid": sc.savedDashInFolder.UID,
- "title": "Dash with existing uid in other org",
- }),
- Overwrite: false,
- }
-
- res := callSaveWithResult(t, cmd, sc.sqlStore)
- require.NotNil(t, res)
-
- _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{
- OrgID: otherOrgId,
- UID: sc.savedDashInFolder.UID,
- })
- require.NoError(t, err)
- })
- })
-
- t.Run("Given user has no permission to save", func(t *testing.T) {
- const canSave = false
-
- permissionScenario(t, "When creating a new dashboard in the General folder", canSave,
- func(t *testing.T, sc *permissionScenarioContext) {
- sqlStore := db.InitTestDB(t)
- cmd := dashboards.SaveDashboardCommand{
- OrgID: testOrgID,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "title": "Dash",
- }),
- UserID: 10000,
- Overwrite: true,
- }
-
- err := callSaveWithError(t, cmd, sqlStore)
- assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err)
-
- userID, err := identity.IntIdentifier(sc.dashboardGuardianMock.User.GetID())
- require.NoError(t, err)
-
- assert.Equal(t, "", sc.dashboardGuardianMock.DashUID)
- assert.Equal(t, cmd.OrgID, sc.dashboardGuardianMock.OrgID)
- assert.Equal(t, cmd.UserID, userID)
- })
-
- permissionScenario(t, "When creating a new dashboard in other folder, it should create dashboard guardian for other folder with correct arguments and rsult in access denied error",
- canSave, func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: testOrgID,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "title": "Dash",
- }),
- FolderUID: sc.otherSavedFolder.UID,
- UserID: 10000,
- Overwrite: true,
- }
-
- err := callSaveWithError(t, cmd, sc.sqlStore)
- require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err)
-
- userID, err := identity.IntIdentifier(sc.dashboardGuardianMock.User.GetID())
- require.NoError(t, err)
-
- assert.Equal(t, sc.otherSavedFolder.ID, sc.dashboardGuardianMock.DashID)
- assert.Equal(t, cmd.OrgID, sc.dashboardGuardianMock.OrgID)
- assert.Equal(t, cmd.UserID, userID)
- })
-
- permissionScenario(t, "When creating a new dashboard by existing title in folder, it should create dashboard guardian for dashboard with correct arguments and result in access denied error",
- canSave, func(t *testing.T, sc *permissionScenarioContext) {
- t.Skip()
-
- cmd := dashboards.SaveDashboardCommand{
- OrgID: testOrgID,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "title": sc.savedDashInFolder.Title,
- }),
- FolderUID: sc.savedFolder.UID,
- UserID: 10000,
- Overwrite: true,
- }
-
- err := callSaveWithError(t, cmd, sc.sqlStore)
- require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err)
-
- userID, err := identity.IntIdentifier(sc.dashboardGuardianMock.User.GetID())
- require.NoError(t, err)
-
- assert.Equal(t, sc.savedDashInFolder.UID, sc.dashboardGuardianMock.DashUID)
- assert.Equal(t, cmd.OrgID, sc.dashboardGuardianMock.OrgID)
- assert.Equal(t, cmd.UserID, userID)
- })
-
- permissionScenario(t, "When creating a new dashboard by existing UID in folder, it should create dashboard guardian for dashboard with correct arguments and result in access denied error",
- canSave, func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: testOrgID,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "uid": sc.savedDashInFolder.UID,
- "title": "New dash",
- }),
- FolderUID: sc.savedFolder.UID,
- UserID: 10000,
- Overwrite: true,
- }
-
- err := callSaveWithError(t, cmd, sc.sqlStore)
- require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err)
-
- userID, err := identity.IntIdentifier(sc.dashboardGuardianMock.User.GetID())
- require.NoError(t, err)
-
- assert.Equal(t, sc.savedDashInFolder.UID, sc.dashboardGuardianMock.DashUID)
- assert.Equal(t, cmd.OrgID, sc.dashboardGuardianMock.OrgID)
- assert.Equal(t, cmd.UserID, userID)
- })
-
- permissionScenario(t, "When updating a dashboard by existing id in the General folder, it should create dashboard guardian for dashboard with correct arguments and result in access denied error",
- canSave, func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: testOrgID,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "id": sc.savedDashInGeneralFolder.ID,
- "title": "Dash",
- }),
- FolderUID: sc.savedDashInGeneralFolder.FolderUID,
- UserID: 10000,
- Overwrite: true,
- }
-
- err := callSaveWithError(t, cmd, sc.sqlStore)
- assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err)
-
- userID, err := identity.IntIdentifier(sc.dashboardGuardianMock.User.GetID())
- require.NoError(t, err)
-
- assert.Equal(t, sc.savedDashInGeneralFolder.UID, sc.dashboardGuardianMock.DashUID)
- assert.Equal(t, cmd.OrgID, sc.dashboardGuardianMock.OrgID)
- assert.Equal(t, cmd.UserID, userID)
- })
-
- permissionScenario(t, "When updating a dashboard by existing id in other folder, it should create dashboard guardian for dashboard with correct arguments and result in access denied error",
- canSave, func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: testOrgID,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "id": sc.savedDashInFolder.ID,
- "title": "Dash",
- }),
- FolderUID: sc.savedDashInFolder.FolderUID,
- UserID: 10000,
- Overwrite: true,
- }
-
- err := callSaveWithError(t, cmd, sc.sqlStore)
- require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err)
-
- userID, err := identity.IntIdentifier(sc.dashboardGuardianMock.User.GetID())
- require.NoError(t, err)
-
- assert.Equal(t, sc.savedDashInFolder.UID, sc.dashboardGuardianMock.DashUID)
- assert.Equal(t, cmd.OrgID, sc.dashboardGuardianMock.OrgID)
- assert.Equal(t, cmd.UserID, userID)
- })
-
- permissionScenario(t, "When moving a dashboard by existing ID to other folder from General folder, it should create dashboard guardian for dashboard with correct arguments and result in access denied error",
- canSave, func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: testOrgID,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "id": sc.savedDashInGeneralFolder.ID,
- "title": "Dash",
- }),
- FolderUID: sc.otherSavedFolder.UID,
- UserID: 10000,
- Overwrite: true,
- }
-
- err := callSaveWithError(t, cmd, sc.sqlStore)
- require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err)
-
- userID, err := identity.IntIdentifier(sc.dashboardGuardianMock.User.GetID())
- require.NoError(t, err)
-
- assert.Equal(t, sc.savedDashInGeneralFolder.UID, sc.dashboardGuardianMock.DashUID)
- assert.Equal(t, cmd.OrgID, sc.dashboardGuardianMock.OrgID)
- assert.Equal(t, cmd.UserID, userID)
- })
-
- permissionScenario(t, "When moving a dashboard by existing id to the General folder from other folder, it should create dashboard guardian for dashboard with correct arguments and result in access denied error",
- canSave, func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: testOrgID,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "id": sc.savedDashInFolder.ID,
- "title": "Dash",
- }),
- FolderUID: "",
- UserID: 10000,
- Overwrite: true,
- }
-
- err := callSaveWithError(t, cmd, sc.sqlStore)
- assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err)
-
- userID, err := identity.IntIdentifier(sc.dashboardGuardianMock.User.GetID())
- require.NoError(t, err)
-
- assert.Equal(t, sc.savedDashInFolder.UID, sc.dashboardGuardianMock.DashUID)
- assert.Equal(t, cmd.OrgID, sc.dashboardGuardianMock.OrgID)
- assert.Equal(t, cmd.UserID, userID)
- })
-
- permissionScenario(t, "When moving a dashboard by existing uid to other folder from General folder, it should create dashboard guardian for dashboard with correct arguments and result in access denied error",
- canSave, func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: testOrgID,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "uid": sc.savedDashInGeneralFolder.UID,
- "title": "Dash",
- }),
- FolderUID: sc.otherSavedFolder.UID,
- UserID: 10000,
- Overwrite: true,
- }
-
- err := callSaveWithError(t, cmd, sc.sqlStore)
- require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err)
-
- userID, err := identity.IntIdentifier(sc.dashboardGuardianMock.User.GetID())
- require.NoError(t, err)
-
- assert.Equal(t, sc.savedDashInGeneralFolder.UID, sc.dashboardGuardianMock.DashUID)
- assert.Equal(t, cmd.OrgID, sc.dashboardGuardianMock.OrgID)
- assert.Equal(t, cmd.UserID, userID)
- })
-
- permissionScenario(t, "When moving a dashboard by existing UID to the General folder from other folder, it should create dashboard guardian for dashboard with correct arguments and result in access denied error",
- canSave, func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: testOrgID,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "uid": sc.savedDashInFolder.UID,
- "title": "Dash",
- }),
- FolderUID: "",
- UserID: 10000,
- Overwrite: true,
- }
-
- err := callSaveWithError(t, cmd, sc.sqlStore)
- require.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err)
-
- userID, err := identity.IntIdentifier(sc.dashboardGuardianMock.User.GetID())
- require.NoError(t, err)
-
- assert.Equal(t, sc.savedDashInFolder.UID, sc.dashboardGuardianMock.DashUID)
- assert.Equal(t, cmd.OrgID, sc.dashboardGuardianMock.OrgID)
- assert.Equal(t, cmd.UserID, userID)
+ _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ assert.Equal(t, dashboards.ErrDashboardNotFound, err)
+ })
+
+ permissionScenario(t, "When creating a dashboard with same uid as dashboard in organization A, it should create a new dashboard in org B", func(t *testing.T, sc *permissionScenarioContext) {
+ const otherOrgId int64 = 2
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: otherOrgId,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "uid": sc.savedDashInFolder.UID,
+ "title": "Dash with existing uid in other org",
+ }),
+ Overwrite: false,
+ }
+
+ res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ require.NotNil(t, res)
+
+ _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{
+ OrgID: otherOrgId,
+ UID: sc.savedDashInFolder.UID,
})
+ require.NoError(t, err)
+ })
})
t.Run("Given user has permission to save", func(t *testing.T) {
- const canSave = true
-
t.Run("and overwrite flag is set to false", func(t *testing.T) {
const shouldOverwrite = false
- permissionScenario(t, "When creating a dashboard in General folder with same name as dashboard in other folder",
- canSave, func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: testOrgID,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "id": nil,
- "title": sc.savedDashInFolder.Title,
- }),
- FolderUID: "",
- Overwrite: shouldOverwrite,
- }
+ permissionScenario(t, "When creating a dashboard in General folder with same name as dashboard in other folder", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: testOrgID,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "id": nil,
+ "title": sc.savedDashInFolder.Title,
+ }),
+ FolderUID: "",
+ Overwrite: shouldOverwrite,
+ }
- res := callSaveWithResult(t, cmd, sc.sqlStore)
- require.NotNil(t, res)
+ res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ require.NotNil(t, res)
- _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{
- ID: res.ID,
- OrgID: cmd.OrgID,
- })
-
- require.NoError(t, err)
+ _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{
+ ID: res.ID,
+ OrgID: cmd.OrgID,
})
- permissionScenario(t, "When creating a dashboard in other folder with same name as dashboard in General folder",
- canSave, func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: testOrgID,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "id": nil,
- "title": sc.savedDashInGeneralFolder.Title,
- }),
- FolderUID: sc.savedFolder.UID,
- Overwrite: shouldOverwrite,
- }
+ require.NoError(t, err)
+ })
- res := callSaveWithResult(t, cmd, sc.sqlStore)
- require.NotNil(t, res)
+ permissionScenario(t, "When creating a dashboard in other folder with same name as dashboard in General folder", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: testOrgID,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "id": nil,
+ "title": sc.savedDashInGeneralFolder.Title,
+ }),
+ FolderUID: sc.savedFolder.UID,
+ Overwrite: shouldOverwrite,
+ }
- assert.NotEqual(t, sc.savedDashInGeneralFolder.ID, res.ID)
+ res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ require.NotNil(t, res)
- _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{
- ID: res.ID,
- OrgID: cmd.OrgID,
- })
- require.NoError(t, err)
+ assert.NotEqual(t, sc.savedDashInGeneralFolder.ID, res.ID)
+
+ _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{
+ ID: res.ID,
+ OrgID: cmd.OrgID,
+ })
+ require.NoError(t, err)
+ })
+
+ permissionScenario(t, "When creating a folder with same name as dashboard in other folder", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: testOrgID,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "id": nil,
+ "title": sc.savedDashInFolder.Title,
+ }),
+ IsFolder: true,
+ Overwrite: shouldOverwrite,
+ }
+
+ res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ require.NotNil(t, res)
+
+ assert.NotEqual(t, sc.savedDashInGeneralFolder.ID, res.ID)
+ assert.True(t, res.IsFolder)
+
+ _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{
+ ID: res.ID,
+ OrgID: cmd.OrgID,
+ })
+ require.NoError(t, err)
+ })
+
+ permissionScenario(t, "When saving a dashboard without id and uid and unique title in folder", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: testOrgID,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "title": "Dash without id and uid",
+ }),
+ Overwrite: shouldOverwrite,
+ }
+
+ res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ require.NotNil(t, res)
+
+ assert.Greater(t, res.ID, int64(0))
+ assert.NotEmpty(t, res.UID)
+ _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{
+ ID: res.ID,
+ OrgID: cmd.OrgID,
+ })
+ require.NoError(t, err)
+ })
+
+ permissionScenario(t, "When saving a dashboard when dashboard id is zero ", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: testOrgID,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "id": 0,
+ "title": "Dash with zero id",
+ }),
+ Overwrite: shouldOverwrite,
+ }
+
+ res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ require.NotNil(t, res)
+
+ _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{
+ ID: res.ID,
+ OrgID: cmd.OrgID,
+ })
+ require.NoError(t, err)
+ })
+
+ permissionScenario(t, "When saving a dashboard in non-existing folder", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: testOrgID,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "title": "Expect error",
+ }),
+ FolderUID: "123412321",
+ Overwrite: shouldOverwrite,
+ }
+
+ _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ assert.Equal(t, dashboards.ErrFolderNotFound, err)
+ })
+
+ permissionScenario(t, "When updating an existing dashboard by id without current version", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: 1,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "id": sc.savedDashInGeneralFolder.ID,
+ "title": "test dash 23",
+ }),
+ FolderUID: sc.savedFolder.UID,
+ Overwrite: shouldOverwrite,
+ }
+
+ _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ assert.Equal(t, dashboards.ErrDashboardVersionMismatch, err)
+ })
+
+ permissionScenario(t, "When updating an existing dashboard by id with current version", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: 1,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "id": sc.savedDashInGeneralFolder.ID,
+ "title": "Updated title",
+ "version": sc.savedDashInGeneralFolder.Version,
+ }),
+ FolderUID: sc.savedFolder.UID,
+ Overwrite: shouldOverwrite,
+ }
+
+ res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ require.NotNil(t, res)
+
+ _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{
+ ID: sc.savedDashInGeneralFolder.ID,
+ OrgID: cmd.OrgID,
})
- permissionScenario(t, "When creating a folder with same name as dashboard in other folder",
- canSave, func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: testOrgID,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "id": nil,
- "title": sc.savedDashInFolder.Title,
- }),
- IsFolder: true,
- Overwrite: shouldOverwrite,
- }
+ require.NoError(t, err)
+ })
- res := callSaveWithResult(t, cmd, sc.sqlStore)
- require.NotNil(t, res)
+ permissionScenario(t, "When updating an existing dashboard by uid without current version", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: 1,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "uid": sc.savedDashInFolder.UID,
+ "title": "test dash 23",
+ }),
+ FolderUID: "",
+ Overwrite: shouldOverwrite,
+ }
- assert.NotEqual(t, sc.savedDashInGeneralFolder.ID, res.ID)
- assert.True(t, res.IsFolder)
+ _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ assert.Equal(t, dashboards.ErrDashboardVersionMismatch, err)
+ })
- _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{
- ID: res.ID,
- OrgID: cmd.OrgID,
- })
- require.NoError(t, err)
+ permissionScenario(t, "When updating an existing dashboard by uid with current version", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: 1,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "uid": sc.savedDashInFolder.UID,
+ "title": "Updated title",
+ "version": sc.savedDashInFolder.Version,
+ }),
+ FolderUID: "",
+ Overwrite: shouldOverwrite,
+ }
+
+ res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ require.NotNil(t, res)
+
+ _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{
+ ID: sc.savedDashInFolder.ID,
+ OrgID: cmd.OrgID,
})
+ require.NoError(t, err)
+ })
- permissionScenario(t, "When saving a dashboard without id and uid and unique title in folder",
- canSave, func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: testOrgID,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "title": "Dash without id and uid",
- }),
- Overwrite: shouldOverwrite,
- }
+ permissionScenario(t, "When creating a dashboard with same name as dashboard in other folder", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: testOrgID,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "id": nil,
+ "title": sc.savedDashInFolder.Title,
+ }),
+ FolderUID: sc.savedDashInFolder.FolderUID,
+ Overwrite: shouldOverwrite,
+ }
- res := callSaveWithResult(t, cmd, sc.sqlStore)
- require.NotNil(t, res)
+ _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ require.NoError(t, err)
+ })
- assert.Greater(t, res.ID, int64(0))
- assert.NotEmpty(t, res.UID)
- _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{
- ID: res.ID,
- OrgID: cmd.OrgID,
- })
- require.NoError(t, err)
- })
+ permissionScenario(t, "When creating a dashboard with same name as dashboard in General folder", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: testOrgID,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "id": nil,
+ "title": sc.savedDashInGeneralFolder.Title,
+ }),
+ FolderUID: sc.savedDashInGeneralFolder.FolderUID,
+ Overwrite: shouldOverwrite,
+ }
- permissionScenario(t, "When saving a dashboard when dashboard id is zero ", canSave,
- func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: testOrgID,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "id": 0,
- "title": "Dash with zero id",
- }),
- Overwrite: shouldOverwrite,
- }
+ _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ require.NoError(t, err)
+ })
- res := callSaveWithResult(t, cmd, sc.sqlStore)
- require.NotNil(t, res)
+ permissionScenario(t, "When creating a folder with same name as existing folder", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: testOrgID,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "id": nil,
+ "title": sc.savedFolder.Title,
+ }),
+ IsFolder: true,
+ Overwrite: shouldOverwrite,
+ }
- _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{
- ID: res.ID,
- OrgID: cmd.OrgID,
- })
- require.NoError(t, err)
- })
-
- permissionScenario(t, "When saving a dashboard in non-existing folder", canSave,
- func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: testOrgID,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "title": "Expect error",
- }),
- FolderUID: "123412321",
- Overwrite: shouldOverwrite,
- }
-
- err := callSaveWithError(t, cmd, sc.sqlStore)
- assert.Equal(t, dashboards.ErrFolderNotFound, err)
- })
-
- permissionScenario(t, "When updating an existing dashboard by id without current version", canSave,
- func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: 1,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "id": sc.savedDashInGeneralFolder.ID,
- "title": "test dash 23",
- }),
- FolderUID: sc.savedFolder.UID,
- Overwrite: shouldOverwrite,
- }
-
- err := callSaveWithError(t, cmd, sc.sqlStore)
- assert.Equal(t, dashboards.ErrDashboardVersionMismatch, err)
- })
-
- permissionScenario(t, "When updating an existing dashboard by id with current version", canSave,
- func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: 1,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "id": sc.savedDashInGeneralFolder.ID,
- "title": "Updated title",
- "version": sc.savedDashInGeneralFolder.Version,
- }),
- FolderUID: sc.savedFolder.UID,
- Overwrite: shouldOverwrite,
- }
-
- res := callSaveWithResult(t, cmd, sc.sqlStore)
- require.NotNil(t, res)
-
- _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{
- ID: sc.savedDashInGeneralFolder.ID,
- OrgID: cmd.OrgID,
- })
-
- require.NoError(t, err)
- })
-
- permissionScenario(t, "When updating an existing dashboard by uid without current version", canSave,
- func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: 1,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "uid": sc.savedDashInFolder.UID,
- "title": "test dash 23",
- }),
- FolderUID: "",
- Overwrite: shouldOverwrite,
- }
-
- err := callSaveWithError(t, cmd, sc.sqlStore)
- assert.Equal(t, dashboards.ErrDashboardVersionMismatch, err)
- })
-
- permissionScenario(t, "When updating an existing dashboard by uid with current version", canSave,
- func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: 1,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "uid": sc.savedDashInFolder.UID,
- "title": "Updated title",
- "version": sc.savedDashInFolder.Version,
- }),
- FolderUID: "",
- Overwrite: shouldOverwrite,
- }
-
- res := callSaveWithResult(t, cmd, sc.sqlStore)
- require.NotNil(t, res)
-
- _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{
- ID: sc.savedDashInFolder.ID,
- OrgID: cmd.OrgID,
- })
- require.NoError(t, err)
- })
-
- permissionScenario(t, "When creating a dashboard with same name as dashboard in other folder",
- canSave, func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: testOrgID,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "id": nil,
- "title": sc.savedDashInFolder.Title,
- }),
- FolderUID: sc.savedDashInFolder.FolderUID,
- Overwrite: shouldOverwrite,
- }
-
- err := callSaveWithError(t, cmd, sc.sqlStore)
- require.NoError(t, err)
- })
-
- permissionScenario(t, "When creating a dashboard with same name as dashboard in General folder",
- canSave, func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: testOrgID,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "id": nil,
- "title": sc.savedDashInGeneralFolder.Title,
- }),
- FolderUID: sc.savedDashInGeneralFolder.FolderUID,
- Overwrite: shouldOverwrite,
- }
-
- err := callSaveWithError(t, cmd, sc.sqlStore)
- require.NoError(t, err)
- })
-
- permissionScenario(t, "When creating a folder with same name as existing folder", canSave,
- func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: testOrgID,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "id": nil,
- "title": sc.savedFolder.Title,
- }),
- IsFolder: true,
- Overwrite: shouldOverwrite,
- }
-
- err := callSaveWithError(t, cmd, sc.sqlStore)
- require.NoError(t, err)
- })
+ _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ require.NoError(t, err)
+ })
})
t.Run("and overwrite flag is set to true", func(t *testing.T) {
const shouldOverwrite = true
- permissionScenario(t, "When updating an existing dashboard by id without current version", canSave,
- func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: 1,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "id": sc.savedDashInGeneralFolder.ID,
- "title": "Updated title",
- }),
- FolderUID: sc.savedFolder.UID,
- Overwrite: shouldOverwrite,
- }
+ permissionScenario(t, "When updating an existing dashboard by id without current version", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: 1,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "id": sc.savedDashInGeneralFolder.ID,
+ "title": "Updated title",
+ }),
+ FolderUID: sc.savedFolder.UID,
+ Overwrite: shouldOverwrite,
+ }
- res := callSaveWithResult(t, cmd, sc.sqlStore)
- require.NotNil(t, res)
+ res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ require.NotNil(t, res)
- _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{
- ID: sc.savedDashInGeneralFolder.ID,
- OrgID: cmd.OrgID,
- })
- require.NoError(t, err)
+ _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{
+ ID: sc.savedDashInGeneralFolder.ID,
+ OrgID: cmd.OrgID,
})
+ require.NoError(t, err)
+ })
- permissionScenario(t, "When updating an existing dashboard by uid without current version", canSave,
- func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: 1,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "uid": sc.savedDashInFolder.UID,
- "title": "Updated title",
- }),
- FolderUID: "",
- Overwrite: shouldOverwrite,
- }
+ permissionScenario(t, "When updating an existing dashboard by uid without current version", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: 1,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "uid": sc.savedDashInFolder.UID,
+ "title": "Updated title",
+ }),
+ FolderUID: "",
+ Overwrite: shouldOverwrite,
+ }
- res := callSaveWithResult(t, cmd, sc.sqlStore)
- require.NotNil(t, res)
+ res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ require.NotNil(t, res)
- _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{
- ID: sc.savedDashInFolder.ID,
- OrgID: cmd.OrgID,
- })
- require.NoError(t, err)
+ _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{
+ ID: sc.savedDashInFolder.ID,
+ OrgID: cmd.OrgID,
})
+ require.NoError(t, err)
+ })
- permissionScenario(t, "When updating uid for existing dashboard using id", canSave,
- func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: 1,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "id": sc.savedDashInFolder.ID,
- "uid": "new-uid",
- "title": sc.savedDashInFolder.Title,
- }),
- Overwrite: shouldOverwrite,
- }
+ permissionScenario(t, "When updating uid for existing dashboard using id", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: 1,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "id": sc.savedDashInFolder.ID,
+ "uid": "new-uid",
+ "title": sc.savedDashInFolder.Title,
+ }),
+ Overwrite: shouldOverwrite,
+ }
- res := callSaveWithResult(t, cmd, sc.sqlStore)
- require.NotNil(t, res)
- assert.Equal(t, sc.savedDashInFolder.ID, res.ID)
- assert.Equal(t, "new-uid", res.UID)
+ res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ require.NotNil(t, res)
+ assert.Equal(t, sc.savedDashInFolder.ID, res.ID)
+ assert.Equal(t, "new-uid", res.UID)
- _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{
- ID: sc.savedDashInFolder.ID,
- OrgID: cmd.OrgID,
- })
- require.NoError(t, err)
+ _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{
+ ID: sc.savedDashInFolder.ID,
+ OrgID: cmd.OrgID,
})
+ require.NoError(t, err)
+ })
- permissionScenario(t, "When updating uid to an existing uid for existing dashboard using id", canSave,
- func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: 1,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "id": sc.savedDashInFolder.ID,
- "uid": sc.savedDashInGeneralFolder.UID,
- "title": sc.savedDashInFolder.Title,
- }),
- Overwrite: shouldOverwrite,
- }
+ permissionScenario(t, "When updating uid to an existing uid for existing dashboard using id", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: 1,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "id": sc.savedDashInFolder.ID,
+ "uid": sc.savedDashInGeneralFolder.UID,
+ "title": sc.savedDashInFolder.Title,
+ }),
+ Overwrite: shouldOverwrite,
+ }
- err := callSaveWithError(t, cmd, sc.sqlStore)
- assert.Equal(t, dashboards.ErrDashboardWithSameUIDExists, err)
+ _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ assert.Equal(t, dashboards.ErrDashboardWithSameUIDExists, err)
+ })
+
+ permissionScenario(t, "When creating a dashboard with same name as dashboard in other folder", func(t *testing.T, sc *permissionScenarioContext) {
+ t.Skip()
+
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: testOrgID,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "id": nil,
+ "title": sc.savedDashInFolder.Title,
+ }),
+ FolderUID: sc.savedDashInFolder.FolderUID,
+ Overwrite: shouldOverwrite,
+ }
+
+ res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ require.NotNil(t, res)
+ assert.Equal(t, sc.savedDashInFolder.ID, res.ID)
+ assert.Equal(t, sc.savedDashInFolder.UID, res.UID)
+
+ _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{
+ ID: res.ID,
+ OrgID: cmd.OrgID,
})
+ require.NoError(t, err)
+ })
- permissionScenario(t, "When creating a dashboard with same name as dashboard in other folder", canSave,
- func(t *testing.T, sc *permissionScenarioContext) {
- t.Skip()
+ permissionScenario(t, "When creating a dashboard with same name as dashboard in General folder", func(t *testing.T, sc *permissionScenarioContext) {
+ t.Skip()
- cmd := dashboards.SaveDashboardCommand{
- OrgID: testOrgID,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "id": nil,
- "title": sc.savedDashInFolder.Title,
- }),
- FolderUID: sc.savedDashInFolder.FolderUID,
- Overwrite: shouldOverwrite,
- }
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: testOrgID,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "id": nil,
+ "title": sc.savedDashInGeneralFolder.Title,
+ }),
+ FolderUID: sc.savedDashInGeneralFolder.FolderUID,
+ Overwrite: shouldOverwrite,
+ }
- res := callSaveWithResult(t, cmd, sc.sqlStore)
- require.NotNil(t, res)
- assert.Equal(t, sc.savedDashInFolder.ID, res.ID)
- assert.Equal(t, sc.savedDashInFolder.UID, res.UID)
+ res, _ := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ require.NotNil(t, res)
+ assert.Equal(t, sc.savedDashInGeneralFolder.ID, res.ID)
+ assert.Equal(t, sc.savedDashInGeneralFolder.UID, res.UID)
- _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{
- ID: res.ID,
- OrgID: cmd.OrgID,
- })
- require.NoError(t, err)
+ _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{
+ ID: res.ID,
+ OrgID: cmd.OrgID,
})
+ require.NoError(t, err)
+ })
- permissionScenario(t, "When creating a dashboard with same name as dashboard in General folder", canSave,
- func(t *testing.T, sc *permissionScenarioContext) {
- t.Skip()
+ permissionScenario(t, "When updating existing folder to a dashboard using id", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: 1,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "id": sc.savedFolder.ID,
+ "title": "new title",
+ }),
+ IsFolder: false,
+ Overwrite: shouldOverwrite,
+ }
- cmd := dashboards.SaveDashboardCommand{
- OrgID: testOrgID,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "id": nil,
- "title": sc.savedDashInGeneralFolder.Title,
- }),
- FolderUID: sc.savedDashInGeneralFolder.FolderUID,
- Overwrite: shouldOverwrite,
- }
+ _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err)
+ })
- res := callSaveWithResult(t, cmd, sc.sqlStore)
- require.NotNil(t, res)
- assert.Equal(t, sc.savedDashInGeneralFolder.ID, res.ID)
- assert.Equal(t, sc.savedDashInGeneralFolder.UID, res.UID)
+ permissionScenario(t, "When updating existing dashboard to a folder using id", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: 1,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "id": sc.savedDashInFolder.ID,
+ "title": "new folder title",
+ }),
+ IsFolder: true,
+ Overwrite: shouldOverwrite,
+ }
- _, err := sc.dashboardStore.GetDashboard(context.Background(), &dashboards.GetDashboardQuery{
- ID: res.ID,
- OrgID: cmd.OrgID,
- })
- require.NoError(t, err)
- })
+ _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err)
+ })
- permissionScenario(t, "When updating existing folder to a dashboard using id", canSave,
- func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: 1,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "id": sc.savedFolder.ID,
- "title": "new title",
- }),
- IsFolder: false,
- Overwrite: shouldOverwrite,
- }
+ permissionScenario(t, "When updating existing folder to a dashboard using uid", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: 1,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "uid": sc.savedFolder.UID,
+ "title": "new title",
+ }),
+ IsFolder: false,
+ Overwrite: shouldOverwrite,
+ }
- err := callSaveWithError(t, cmd, sc.sqlStore)
- assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err)
- })
+ _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err)
+ })
- permissionScenario(t, "When updating existing dashboard to a folder using id", canSave,
- func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: 1,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "id": sc.savedDashInFolder.ID,
- "title": "new folder title",
- }),
- IsFolder: true,
- Overwrite: shouldOverwrite,
- }
+ permissionScenario(t, "When updating existing dashboard to a folder using uid", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: 1,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "uid": sc.savedDashInFolder.UID,
+ "title": "new folder title",
+ }),
+ IsFolder: true,
+ Overwrite: shouldOverwrite,
+ }
- err := callSaveWithError(t, cmd, sc.sqlStore)
- assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err)
- })
+ _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err)
+ })
- permissionScenario(t, "When updating existing folder to a dashboard using uid", canSave,
- func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: 1,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "uid": sc.savedFolder.UID,
- "title": "new title",
- }),
- IsFolder: false,
- Overwrite: shouldOverwrite,
- }
+ permissionScenario(t, "When updating existing folder to a dashboard using title", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: 1,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "title": sc.savedFolder.Title,
+ }),
+ IsFolder: false,
+ Overwrite: shouldOverwrite,
+ }
- err := callSaveWithError(t, cmd, sc.sqlStore)
- assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err)
- })
+ _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ require.NoError(t, err)
+ })
- permissionScenario(t, "When updating existing dashboard to a folder using uid", canSave,
- func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: 1,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "uid": sc.savedDashInFolder.UID,
- "title": "new folder title",
- }),
- IsFolder: true,
- Overwrite: shouldOverwrite,
- }
+ permissionScenario(t, "When updating existing dashboard to a folder using title", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: 1,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "title": sc.savedDashInGeneralFolder.Title,
+ }),
+ IsFolder: true,
+ Overwrite: shouldOverwrite,
+ }
- err := callSaveWithError(t, cmd, sc.sqlStore)
- assert.Equal(t, dashboards.ErrDashboardTypeMismatch, err)
- })
-
- permissionScenario(t, "When updating existing folder to a dashboard using title", canSave,
- func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: 1,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "title": sc.savedFolder.Title,
- }),
- IsFolder: false,
- Overwrite: shouldOverwrite,
- }
-
- err := callSaveWithError(t, cmd, sc.sqlStore)
- require.NoError(t, err)
- })
-
- permissionScenario(t, "When updating existing dashboard to a folder using title", canSave,
- func(t *testing.T, sc *permissionScenarioContext) {
- cmd := dashboards.SaveDashboardCommand{
- OrgID: 1,
- Dashboard: simplejson.NewFromAny(map[string]any{
- "title": sc.savedDashInGeneralFolder.Title,
- }),
- IsFolder: true,
- Overwrite: shouldOverwrite,
- }
-
- err := callSaveWithError(t, cmd, sc.sqlStore)
- require.NoError(t, err)
- })
+ _, err := callSaveWithResult(t, cmd, sc.sqlStore, nil)
+ require.NoError(t, err)
+ })
})
})
})
}
+func TestIntegrationDashboardServicePermissions(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping integration test")
+ }
+ t.Run("Given saved folders and dashboards in organization A", func(t *testing.T) {
+ permissionScenario(t, "When creating a new dashboard in the General folder, requires create permissions scoped to the general folder",
+ func(t *testing.T, sc *permissionScenarioContext) {
+ sqlStore := db.InitTestDB(t)
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: testOrgID,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "title": "Dash",
+ }),
+ UserID: 10000,
+ Overwrite: true,
+ }
+
+ permissions := map[int64]map[string][]string{
+ testOrgID: {
+ dashboards.ActionDashboardsWrite: {dashboards.ScopeDashboardsAll},
+ },
+ }
+ _, err := callSaveWithResult(t, cmd, sqlStore, permissions)
+ assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err)
+
+ permissions = map[int64]map[string][]string{
+ testOrgID: {
+ dashboards.ActionDashboardsCreate: {dashboards.ScopeFoldersProvider.GetResourceScopeUID(accesscontrol.GeneralFolderUID)},
+ },
+ }
+ _, err = callSaveWithResult(t, cmd, sqlStore, permissions)
+ assert.Nil(t, err)
+ })
+
+ permissionScenario(t, "When creating a new dashboard in other folder, requires create permissions scoped to the other folder", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: testOrgID,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "title": "Dash",
+ }),
+ FolderUID: sc.otherSavedFolder.UID,
+ UserID: 10000,
+ Overwrite: true,
+ }
+
+ permissions := map[int64]map[string][]string{
+ testOrgID: {
+ dashboards.ActionDashboardsCreate: {dashboards.ScopeFoldersProvider.GetResourceScopeUID("different_folder_uid")},
+ },
+ }
+ _, err := callSaveWithResult(t, cmd, sc.sqlStore, permissions)
+ assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err)
+
+ permissions = map[int64]map[string][]string{
+ testOrgID: {
+ dashboards.ActionDashboardsCreate: {dashboards.ScopeFoldersProvider.GetResourceScopeUID(sc.otherSavedFolder.UID)},
+ },
+ }
+ _, err = callSaveWithResult(t, cmd, sc.sqlStore, permissions)
+ assert.Nil(t, err)
+ })
+
+ permissionScenario(t, "When creating a new dashboard by existing UID in folder, requires write permissions on the existing dashboard", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: testOrgID,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "uid": sc.savedDashInFolder.UID,
+ "title": "New dash",
+ }),
+ FolderUID: sc.savedFolder.UID,
+ UserID: 10000,
+ Overwrite: true,
+ }
+
+ permissions := map[int64]map[string][]string{
+ testOrgID: {
+ dashboards.ActionDashboardsWrite: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID("different_dash_uid")},
+ },
+ }
+ _, err := callSaveWithResult(t, cmd, sc.sqlStore, permissions)
+ assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err)
+
+ permissions = map[int64]map[string][]string{
+ testOrgID: {
+ dashboards.ActionDashboardsWrite: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID(sc.savedDashInFolder.UID)},
+ },
+ }
+ _, err = callSaveWithResult(t, cmd, sc.sqlStore, permissions)
+ assert.Nil(t, err)
+ })
+
+ permissionScenario(t, "When moving a dashboard by existing uid to other folder from General folder, requires dashboard creation permissions on the destination folder and write access to the dashboard", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: testOrgID,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "uid": sc.savedDashInGeneralFolder.UID,
+ "title": "Dash",
+ }),
+ FolderUID: sc.otherSavedFolder.UID,
+ UserID: 10000,
+ Overwrite: true,
+ }
+
+ // Perms to write dashboard but not create dashboards in the destination folder
+ permissions := map[int64]map[string][]string{
+ testOrgID: {
+ dashboards.ActionDashboardsWrite: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID(sc.savedDashInGeneralFolder.UID)},
+ },
+ }
+ _, err := callSaveWithResult(t, cmd, sc.sqlStore, permissions)
+ assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err)
+
+ // Perms to create dashboards in the destination folder but not write the dashboard
+ permissions = map[int64]map[string][]string{
+ testOrgID: {
+ dashboards.ActionDashboardsCreate: {dashboards.ScopeFoldersProvider.GetResourceScopeUID(sc.otherSavedFolder.UID)},
+ },
+ }
+ _, err = callSaveWithResult(t, cmd, sc.sqlStore, permissions)
+ assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err)
+
+ // Perms to write dashboard and create dashboards in the destination folder
+ permissions = map[int64]map[string][]string{
+ testOrgID: {
+ dashboards.ActionDashboardsWrite: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID(sc.savedDashInGeneralFolder.UID)},
+ dashboards.ActionDashboardsCreate: {dashboards.ScopeFoldersProvider.GetResourceScopeUID(sc.otherSavedFolder.UID)},
+ },
+ }
+ _, err = callSaveWithResult(t, cmd, sc.sqlStore, permissions)
+ assert.Nil(t, err)
+ })
+
+ permissionScenario(t, "When moving a dashboard by existing uid to the General folder from other folder, requires dashboard creation permissions on the general folder and write access to the dashboard", func(t *testing.T, sc *permissionScenarioContext) {
+ cmd := dashboards.SaveDashboardCommand{
+ OrgID: testOrgID,
+ Dashboard: simplejson.NewFromAny(map[string]any{
+ "uid": sc.savedDashInFolder.UID,
+ "title": "Dash",
+ }),
+ FolderUID: "",
+ UserID: 10000,
+ Overwrite: true,
+ }
+
+ // Perms to write dashboard but not create dashboards in the destination folder
+ permissions := map[int64]map[string][]string{
+ testOrgID: {
+ dashboards.ActionDashboardsWrite: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID(sc.savedDashInFolder.UID)},
+ },
+ }
+ _, err := callSaveWithResult(t, cmd, sc.sqlStore, permissions)
+ assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err)
+
+ // Perms to create dashboards in the destination folder but not write the dashboard
+ permissions = map[int64]map[string][]string{
+ testOrgID: {
+ dashboards.ActionDashboardsCreate: {dashboards.ScopeFoldersProvider.GetResourceScopeUID(accesscontrol.GeneralFolderUID)},
+ },
+ }
+ _, err = callSaveWithResult(t, cmd, sc.sqlStore, permissions)
+ assert.Equal(t, dashboards.ErrDashboardUpdateAccessDenied, err)
+
+ // Perms to write dashboard and create dashboards in the destination folder
+ permissions = map[int64]map[string][]string{
+ testOrgID: {
+ dashboards.ActionDashboardsWrite: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID(sc.savedDashInFolder.UID)},
+ dashboards.ActionDashboardsCreate: {dashboards.ScopeFoldersProvider.GetResourceScopeUID(accesscontrol.GeneralFolderUID)},
+ },
+ }
+ _, err = callSaveWithResult(t, cmd, sc.sqlStore, permissions)
+ assert.NoError(t, err)
+ })
+ })
+}
+
type permissionScenarioContext struct {
- dashboardGuardianMock *guardian.FakeDashboardGuardian
sqlStore db.DB
dashboardStore dashboards.Store
savedFolder *dashboards.Dashboard
@@ -862,14 +763,9 @@ type permissionScenarioContext struct {
type permissionScenarioFunc func(t *testing.T, sc *permissionScenarioContext)
-func permissionScenario(t *testing.T, desc string, canSave bool, fn permissionScenarioFunc) {
+func permissionScenario(t *testing.T, desc string, fn permissionScenarioFunc) {
t.Helper()
- guardianMock := &guardian.FakeDashboardGuardian{
- CanSaveValue: canSave,
- CanViewValue: true,
- }
-
t.Run(desc, func(t *testing.T) {
features := featuremgmt.WithFeatures()
cfg := setting.NewCfg()
@@ -919,10 +815,11 @@ func permissionScenario(t *testing.T, desc string, canSave bool, fn permissionSc
nil,
dualwrite.ProvideTestService(),
sort.ProvideService(),
+ serverlock.ProvideService(sqlStore, tracing.InitializeTracerForTest()),
+ kvstore.NewFakeKVStore(),
)
dashboardService.RegisterDashboardPermissions(dashboardPermissions)
require.NoError(t, err)
- guardian.InitAccessControlGuardian(cfg, ac, dashboardService, folderService, log.NewNopLogger())
savedFolder := saveTestFolder(t, "Saved folder", testOrgID, sqlStore)
savedDashInFolder := saveTestDashboard(t, "Saved dash in folder", testOrgID, savedFolder.UID, sqlStore)
@@ -942,14 +839,7 @@ func permissionScenario(t *testing.T, desc string, canSave bool, fn permissionSc
require.False(t, savedDashInFolder.IsFolder)
require.NotEmpty(t, savedDashInFolder.UID)
- origNewDashboardGuardian := guardian.New
- t.Cleanup(func() {
- guardian.New = origNewDashboardGuardian
- })
- guardian.MockDashboardGuardian(guardianMock)
-
sc := &permissionScenarioContext{
- dashboardGuardianMock: guardianMock,
sqlStore: sqlStore,
savedDashInFolder: savedDashInFolder,
otherSavedFolder: otherSavedFolder,
@@ -962,11 +852,17 @@ func permissionScenario(t *testing.T, desc string, canSave bool, fn permissionSc
})
}
-func callSaveWithResult(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlStore db.DB) *dashboards.Dashboard {
+func callSaveWithResult(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlStore db.DB, permissions map[int64]map[string][]string) (*dashboards.Dashboard, error) {
t.Helper()
features := featuremgmt.WithFeatures()
dto := toSaveDashboardDto(cmd)
+ var ac accesscontrol.AccessControl
+ ac = actest.FakeAccessControl{ExpectedEvaluate: true}
+ if permissions != nil {
+ dto.User = &user.SignedInUser{UserID: cmd.UserID, OrgID: testOrgID, Permissions: permissions}
+ ac = acimpl.ProvideAccessControl(features)
+ }
cfg := setting.NewCfg()
quotaService := quotatest.New(false, nil)
dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore))
@@ -1002,7 +898,7 @@ func callSaveWithResult(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlSt
cfg, dashboardStore, folderStore,
featuremgmt.WithFeatures(),
folderPermissions,
- actest.FakeAccessControl{},
+ ac,
folderService,
folder.NewFakeStore(),
nil,
@@ -1014,64 +910,12 @@ func callSaveWithResult(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlSt
nil,
dualwrite.ProvideTestService(),
sort.ProvideService(),
+ serverlock.ProvideService(sqlStore, tracing.InitializeTracerForTest()),
+ kvstore.NewFakeKVStore(),
)
require.NoError(t, err)
service.RegisterDashboardPermissions(dashboardPermissions)
- res, err := service.SaveDashboard(context.Background(), &dto, false)
- require.NoError(t, err)
-
- return res
-}
-
-func callSaveWithError(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlStore db.DB) error {
- features := featuremgmt.WithFeatures()
- dto := toSaveDashboardDto(cmd)
- cfg := setting.NewCfg()
- quotaService := quotatest.New(false, nil)
- dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore))
- require.NoError(t, err)
- folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore)
- tracer := tracing.InitializeTracerForTest()
- publicDashboardFakeService := publicdashboards.NewFakePublicDashboardServiceWrapper(t)
- folderStore2 := folderimpl.ProvideStore(sqlStore)
- folderService := folderimpl.ProvideService(folderStore2,
- actest.FakeAccessControl{ExpectedEvaluate: true},
- bus.ProvideBus(tracer),
- dashboardStore,
- folderStore,
- nil,
- sqlStore,
- features,
- supportbundlestest.NewFakeBundleService(),
- publicDashboardFakeService,
- cfg,
- nil,
- tracer,
- nil,
- dualwrite.ProvideTestService(),
- sort.ProvideService(),
- )
- service, err := ProvideDashboardServiceImpl(
- cfg, dashboardStore, folderStore,
- featuremgmt.WithFeatures(),
- accesscontrolmock.NewMockedPermissionsService(),
- actest.FakeAccessControl{},
- folderService,
- folder.NewFakeStore(),
- nil,
- client.MockTestRestConfig{},
- nil,
- quotaService,
- nil,
- nil,
- nil,
- dualwrite.ProvideTestService(),
- sort.ProvideService(),
- )
- require.NoError(t, err)
- service.RegisterDashboardPermissions(accesscontrolmock.NewMockedPermissionsService())
- _, err = service.SaveDashboard(context.Background(), &dto, false)
- return err
+ return service.SaveDashboard(context.Background(), &dto, false)
}
func saveTestDashboard(t *testing.T, title string, orgID int64, folderUID string, sqlStore db.DB) *dashboards.Dashboard {
@@ -1127,7 +971,7 @@ func saveTestDashboard(t *testing.T, title string, orgID int64, folderUID string
cfg, dashboardStore, folderStore,
features,
accesscontrolmock.NewMockedPermissionsService(),
- actest.FakeAccessControl{},
+ actest.FakeAccessControl{ExpectedEvaluate: true},
folderService,
folder.NewFakeStore(),
nil,
@@ -1139,6 +983,8 @@ func saveTestDashboard(t *testing.T, title string, orgID int64, folderUID string
nil,
dualwrite.ProvideTestService(),
sort.ProvideService(),
+ serverlock.ProvideService(sqlStore, tracing.InitializeTracerForTest()),
+ kvstore.NewFakeKVStore(),
)
require.NoError(t, err)
service.RegisterDashboardPermissions(dashboardPermissions)
@@ -1206,7 +1052,7 @@ func saveTestFolder(t *testing.T, title string, orgID int64, sqlStore db.DB) *da
cfg, dashboardStore, folderStore,
featuremgmt.WithFeatures(),
folderPermissions,
- actest.FakeAccessControl{},
+ actest.FakeAccessControl{ExpectedEvaluate: true},
folderService,
folder.NewFakeStore(),
nil,
@@ -1218,6 +1064,8 @@ func saveTestFolder(t *testing.T, title string, orgID int64, sqlStore db.DB) *da
nil,
dualwrite.ProvideTestService(),
sort.ProvideService(),
+ serverlock.ProvideService(sqlStore, tracing.InitializeTracerForTest()),
+ kvstore.NewFakeKVStore(),
)
require.NoError(t, err)
service.RegisterDashboardPermissions(accesscontrolmock.NewMockedPermissionsService())
diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go
index 2223eabb9d7..82600d9f2cd 100644
--- a/pkg/services/dashboards/service/dashboard_service_test.go
+++ b/pkg/services/dashboards/service/dashboard_service_test.go
@@ -18,21 +18,25 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/components/simplejson"
+ "github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/infra/serverlock"
+ "github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/accesscontrol"
+ "github.com/grafana/grafana/pkg/services/accesscontrol/actest"
acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/apiserver/client"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/folder/foldertest"
- "github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/org/orgtest"
"github.com/grafana/grafana/pkg/services/publicdashboards"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/search/model"
"github.com/grafana/grafana/pkg/services/search/sort"
+ "github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource"
@@ -50,6 +54,7 @@ func TestDashboardService(t *testing.T) {
log: log.New("test.logger"),
dashboardStore: &fakeStore,
folderService: folderSvc,
+ ac: actest.FakeAccessControl{ExpectedEvaluate: true},
features: featuremgmt.WithFeatures(),
publicDashboardService: fakePublicDashboardService,
}
@@ -57,10 +62,6 @@ func TestDashboardService(t *testing.T) {
folderStore.On("GetFolderByUID", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")).Return(nil, dashboards.ErrFolderNotFound).Once()
service.folderStore = &folderStore
- origNewDashboardGuardian := guardian.New
- defer func() { guardian.New = origNewDashboardGuardian }()
- guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true})
-
t.Run("Save dashboard validation", func(t *testing.T) {
dto := &dashboards.SaveDashboardDTO{}
@@ -836,9 +837,6 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
_, k8sCliMock := setupK8sDashboardTests(service)
k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default")
k8sCliMock.On("Delete", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
- fakeStore.On("CleanupAfterDelete", mock.Anything, &dashboards.DeleteDashboardCommand{UID: "uid", OrgID: 1}).Return(nil).Once()
- fakeStore.On("CleanupAfterDelete", mock.Anything, &dashboards.DeleteDashboardCommand{UID: "uid3", OrgID: 2}).Return(nil).Once()
- fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil)
k8sCliMock.On("Get", mock.Anything, "uid", mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{
"metadata": map[string]any{
"name": "uid",
@@ -1036,8 +1034,6 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
// Mock deleteDashboard()
k8sCliMock.On("Delete", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
- fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil)
- fakeStore.On("CleanupAfterDelete", mock.Anything, mock.Anything).Return(nil).Once()
// Mock WaitForSearchQuery()
// First call returns 1 hit
@@ -1292,13 +1288,10 @@ func TestSetDefaultPermissionsWhenSavingFolderForProvisionedDashboards(t *testin
UID: "general",
},
},
+ ac: actest.FakeAccessControl{ExpectedEvaluate: true},
log: log.NewNopLogger(),
}
- origNewDashboardGuardian := guardian.New
- defer func() { guardian.New = origNewDashboardGuardian }()
- guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true})
-
cmd := &folder.CreateFolderCommand{
Title: "foo",
OrgID: 1,
@@ -1326,13 +1319,10 @@ func TestSaveProvisionedDashboard(t *testing.T) {
UID: "general",
},
},
+ ac: actest.FakeAccessControl{ExpectedEvaluate: true},
log: log.NewNopLogger(),
}
- origNewDashboardGuardian := guardian.New
- defer func() { guardian.New = origNewDashboardGuardian }()
- guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true})
-
query := &dashboards.SaveDashboardDTO{
OrgID: 1,
User: &user.SignedInUser{UserID: 1},
@@ -1369,9 +1359,8 @@ func TestSaveProvisionedDashboard(t *testing.T) {
t.Run("Should use Kubernetes create if feature flags are enabled", func(t *testing.T) {
ctx, k8sCliMock := setupK8sDashboardTests(service)
fakeStore.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil)
- k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)
k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil)
- k8sCliMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil)
+ k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil)
k8sCliMock.On("GetNamespace", mock.Anything).Return("default")
dashboard, err := service.SaveProvisionedDashboard(ctx, query, &dashboards.DashboardProvisioning{})
@@ -1392,12 +1381,9 @@ func TestSaveDashboard(t *testing.T) {
folderService: &foldertest.FakeService{
ExpectedFolder: &folder.Folder{},
},
+ ac: actest.FakeAccessControl{ExpectedEvaluate: true},
}
- origNewDashboardGuardian := guardian.New
- defer func() { guardian.New = origNewDashboardGuardian }()
- guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true})
-
query := &dashboards.SaveDashboardDTO{
OrgID: 1,
User: &user.SignedInUser{UserID: 1},
@@ -1434,10 +1420,9 @@ func TestSaveDashboard(t *testing.T) {
t.Run("Should use Kubernetes create if feature flags are enabled and dashboard doesn't exist", func(t *testing.T) {
ctx, k8sCliMock := setupK8sDashboardTests(service)
- k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)
k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil)
k8sCliMock.On("GetNamespace", mock.Anything).Return("default")
- k8sCliMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil)
+ k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil)
dashboard, err := service.SaveDashboard(ctx, query, false)
require.NoError(t, err)
@@ -1446,7 +1431,6 @@ func TestSaveDashboard(t *testing.T) {
t.Run("Should use Kubernetes update if feature flags are enabled and dashboard exists", func(t *testing.T) {
ctx, k8sCliMock := setupK8sDashboardTests(service)
- k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil)
k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil)
k8sCliMock.On("GetNamespace", mock.Anything).Return("default")
k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil)
@@ -1458,9 +1442,8 @@ func TestSaveDashboard(t *testing.T) {
t.Run("Should return an error if uid is invalid", func(t *testing.T) {
ctx, k8sCliMock := setupK8sDashboardTests(service)
- k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)
k8sCliMock.On("GetNamespace", mock.Anything).Return("default")
- k8sCliMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil)
+ k8sCliMock.On("Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructured, nil)
query.Dashboard.UID = "invalid/uid"
_, err := service.SaveDashboard(ctx, query, false)
@@ -1492,8 +1475,6 @@ func TestDeleteDashboard(t *testing.T) {
t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) {
ctx, k8sCliMock := setupK8sDashboardTests(service)
k8sCliMock.On("Delete", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
- fakeStore.On("CleanupAfterDelete", mock.Anything, mock.Anything).Return(nil).Once()
- fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
err := service.DeleteDashboard(ctx, 1, "uid", 1)
require.NoError(t, err)
@@ -1504,8 +1485,6 @@ func TestDeleteDashboard(t *testing.T) {
ctx, k8sCliMock := setupK8sDashboardTests(service)
k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default")
k8sCliMock.On("Delete", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
- fakeStore.On("CleanupAfterDelete", mock.Anything, mock.Anything).Return(nil).Once()
- fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resource.ResourceSearchResponse{
Results: &resource.ResourceTable{
Columns: []*resource.ResourceTableColumnDefinition{
@@ -2330,3 +2309,335 @@ func TestLegacySaveCommandToUnstructured(t *testing.T) {
assert.Equal(t, result.GetAnnotations(), map[string]string(nil))
})
}
+
+func TestCleanUpDashboard(t *testing.T) {
+ tests := []struct {
+ name string
+ deleteError error
+ cleanupError error
+ expectCleanup bool
+ expectedError error
+ }{
+ {
+ name: "Should delete public dashboards and clean up after delete",
+ expectCleanup: true,
+ },
+ {
+ name: "Should return error if DeleteByDashboardUIDs fails",
+ deleteError: fmt.Errorf("deletion error"),
+ expectCleanup: false,
+ expectedError: fmt.Errorf("deletion error"),
+ },
+ {
+ name: "Should return error if CleanupAfterDelete fails",
+ cleanupError: fmt.Errorf("cleanup error"),
+ expectCleanup: true,
+ expectedError: fmt.Errorf("cleanup error"),
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ fakeStore := dashboards.FakeDashboardStore{}
+ fakePublicDashboardService := publicdashboards.NewFakePublicDashboardServiceWrapper(t)
+ service := &DashboardServiceImpl{
+ cfg: setting.NewCfg(),
+ dashboardStore: &fakeStore,
+ publicDashboardService: fakePublicDashboardService,
+ }
+
+ ctx := context.Background()
+ dashboardUID := "dash-uid"
+ orgID := int64(1)
+
+ // Setup mocks
+ fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, orgID, []string{dashboardUID}).Return(tc.deleteError).Maybe()
+
+ if tc.expectCleanup {
+ fakeStore.On("CleanupAfterDelete", mock.Anything, &dashboards.DeleteDashboardCommand{
+ OrgID: orgID,
+ UID: dashboardUID,
+ }).Return(tc.cleanupError).Maybe()
+ }
+
+ // Execute
+ err := service.CleanUpDashboard(ctx, dashboardUID, orgID)
+
+ // Assert
+ if tc.expectedError != nil {
+ require.Error(t, err)
+ require.Equal(t, tc.expectedError.Error(), err.Error())
+ } else {
+ require.NoError(t, err)
+ }
+
+ fakePublicDashboardService.AssertExpectations(t)
+ fakeStore.AssertExpectations(t)
+ })
+ }
+}
+
+func TestK8sDashboardCleanupJob(t *testing.T) {
+ tests := []struct {
+ name string
+ featureEnabled bool
+ batchSize int
+ setupFunc func(*DashboardServiceImpl, context.Context, *client.MockK8sHandler)
+ verifyFunc func(*testing.T, *DashboardServiceImpl, context.Context, *client.MockK8sHandler, *kvstore.FakeKVStore)
+ }{
+ {
+ name: "Should not run cleanup when feature flag is disabled",
+ featureEnabled: false,
+ batchSize: 10,
+ },
+ {
+ name: "Should process dashboard cleanup for all orgs",
+ featureEnabled: true,
+ batchSize: 10,
+ setupFunc: func(service *DashboardServiceImpl, ctx context.Context, k8sCliMock *client.MockK8sHandler) {
+ // Test organizations
+ fakeOrgService := service.orgService.(*orgtest.FakeOrgService)
+ fakeOrgService.ExpectedOrgs = []*org.OrgDTO{
+ {ID: 1, Name: "org1"},
+ {ID: 2, Name: "org2"},
+ }
+
+ kv := service.kvstore.(*kvstore.FakeKVStore)
+ fakeStore := service.dashboardStore.(*dashboards.FakeDashboardStore)
+ fakePublicDashboardService := service.publicDashboardService.(*publicdashboards.FakePublicDashboardServiceWrapper)
+
+ // Create dashboard unstructured items for response
+ dashboard1 := createTestUnstructuredDashboard("dash1", "org1-dashboard", "101")
+ dashboard2 := createTestUnstructuredDashboard("dash2", "org2-dashboard", "201")
+
+ // Setup test data in KV store. Only populate org 1.
+ _ = kv.Set(ctx, int64(1), k8sDashboardKvNamespace, k8sDashboardKvLastResourceVersionKey, "100")
+
+ // Mock K8s responses for org 1
+ k8sCliMock.On("List", mock.AnythingOfType("*context.valueCtx"), int64(1), mock.MatchedBy(func(opts metav1.ListOptions) bool {
+ return opts.LabelSelector == utils.LabelKeyGetTrash+"=true" &&
+ opts.Continue == ""
+ })).Return(&unstructured.UnstructuredList{
+ Object: map[string]interface{}{
+ "metadata": map[string]interface{}{
+ "resourceVersion": "101",
+ },
+ },
+ Items: []unstructured.Unstructured{dashboard1},
+ }, nil).Once()
+
+ // Mock K8s responses for org 2
+ k8sCliMock.On("List", mock.AnythingOfType("*context.valueCtx"), int64(2), mock.MatchedBy(func(opts metav1.ListOptions) bool {
+ return opts.LabelSelector == utils.LabelKeyGetTrash+"=true" &&
+ opts.Continue == ""
+ })).Return(&unstructured.UnstructuredList{
+ Object: map[string]interface{}{
+ "metadata": map[string]interface{}{
+ "resourceVersion": "201",
+ },
+ },
+ Items: []unstructured.Unstructured{dashboard2},
+ }, nil).Once()
+
+ // Mock GetUserFromMeta calls
+ k8sCliMock.On("GetUserFromMeta", mock.AnythingOfType("*context.valueCtx"), mock.Anything).Return(&user.User{}, nil).Times(4)
+
+ // Mock cleanup
+ fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, int64(1), []string{"dash1"}).Return(nil).Once()
+ fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, int64(2), []string{"dash2"}).Return(nil).Once()
+ fakeStore.On("CleanupAfterDelete", mock.Anything, mock.Anything).Return(nil).Times(2)
+ },
+ verifyFunc: func(t *testing.T, service *DashboardServiceImpl, ctx context.Context, k8sCliMock *client.MockK8sHandler, kv *kvstore.FakeKVStore) {
+ k8sCliMock.AssertExpectations(t)
+
+ // Verify KV store was updated with new resource versions
+ val1, found1, _ := kv.Get(ctx, int64(1), k8sDashboardKvNamespace, k8sDashboardKvLastResourceVersionKey)
+ require.True(t, found1)
+ require.Equal(t, "101", val1)
+
+ val2, found2, _ := kv.Get(ctx, int64(2), k8sDashboardKvNamespace, k8sDashboardKvLastResourceVersionKey)
+ require.True(t, found2)
+ require.Equal(t, "201", val2)
+ },
+ },
+ {
+ name: "Should handle pagination and batching when processing large sets of dashboards",
+ featureEnabled: true,
+ batchSize: 3,
+ setupFunc: func(service *DashboardServiceImpl, ctx context.Context, k8sCliMock *client.MockK8sHandler) {
+ // Test organization
+ fakeOrgService := service.orgService.(*orgtest.FakeOrgService)
+ fakeOrgService.ExpectedOrgs = []*org.OrgDTO{
+ {ID: 1, Name: "org1"},
+ }
+
+ kv := service.kvstore.(*kvstore.FakeKVStore)
+ fakeStore := service.dashboardStore.(*dashboards.FakeDashboardStore)
+ fakePublicDashboardService := service.publicDashboardService.(*publicdashboards.FakePublicDashboardServiceWrapper)
+
+ // Setup initial resource version
+ initialVersion := "100"
+ _ = kv.Set(ctx, int64(1), k8sDashboardKvNamespace, k8sDashboardKvLastResourceVersionKey, initialVersion)
+
+ // Create dashboard batches (5 dashboards total, to be processed in 2 batches)
+ firstBatch := []unstructured.Unstructured{
+ createTestUnstructuredDashboard("dash1", "dashboard1", "101"),
+ createTestUnstructuredDashboard("dash2", "dashboard2", "102"),
+ createTestUnstructuredDashboard("dash3", "dashboard3", "150"),
+ }
+ secondBatch := []unstructured.Unstructured{
+ createTestUnstructuredDashboard("dash4", "dashboard4", "180"),
+ createTestUnstructuredDashboard("dash5", "dashboard5", "200"),
+ }
+
+ // First batch response with continue token
+ k8sCliMock.On("List", mock.AnythingOfType("*context.valueCtx"), int64(1), mock.MatchedBy(func(opts metav1.ListOptions) bool {
+ return opts.LabelSelector == utils.LabelKeyGetTrash+"=true" &&
+ opts.Continue == ""
+ })).Return(&unstructured.UnstructuredList{
+ Object: map[string]interface{}{
+ "metadata": map[string]interface{}{
+ "resourceVersion": "200",
+ "continue": "next-token",
+ },
+ },
+ Items: firstBatch,
+ }, nil).Once()
+
+ // Second batch response with updated resource version
+ k8sCliMock.On("List", mock.AnythingOfType("*context.valueCtx"), int64(1), mock.MatchedBy(func(opts metav1.ListOptions) bool {
+ return opts.LabelSelector == utils.LabelKeyGetTrash+"=true" &&
+ opts.Continue == "next-token"
+ })).Return(&unstructured.UnstructuredList{
+ Object: map[string]interface{}{
+ "metadata": map[string]interface{}{
+ "resourceVersion": "200",
+ },
+ },
+ Items: secondBatch,
+ }, nil).Once()
+
+ // Mock GetUserFromMeta calls for each dashboard
+ k8sCliMock.On("GetUserFromMeta", mock.AnythingOfType("*context.valueCtx"), mock.Anything).Return(&user.User{}, nil).Times(10)
+
+ // Mock public dashboard deletion for each dashboard
+ fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, int64(1), []string{"dash1"}).Return(nil).Once()
+ fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, int64(1), []string{"dash2"}).Return(nil).Once()
+ fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, int64(1), []string{"dash3"}).Return(nil).Once()
+ fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, int64(1), []string{"dash4"}).Return(nil).Once()
+ fakePublicDashboardService.On("DeleteByDashboardUIDs", mock.Anything, int64(1), []string{"dash5"}).Return(nil).Once()
+
+ // Mock cleanup after delete for each dashboard
+ fakeStore.On("CleanupAfterDelete", mock.Anything, mock.Anything).Return(nil).Times(5)
+ },
+ verifyFunc: func(t *testing.T, service *DashboardServiceImpl, ctx context.Context, k8sCliMock *client.MockK8sHandler, kv *kvstore.FakeKVStore) {
+ k8sCliMock.AssertExpectations(t)
+
+ // Verify KV store was updated with latest resource version
+ val, found, _ := kv.Get(ctx, int64(1), k8sDashboardKvNamespace, k8sDashboardKvLastResourceVersionKey)
+ require.True(t, found)
+ require.Equal(t, "200", val)
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup test database and utilities
+ sqlStore, _ := sqlstore.InitTestDB(t)
+ lockService := serverlock.ProvideService(sqlStore, tracing.InitializeTracerForTest())
+ kv := kvstore.NewFakeKVStore()
+
+ fakeStore := dashboards.FakeDashboardStore{}
+ fakePublicDashboardService := publicdashboards.NewFakePublicDashboardServiceWrapper(t)
+ fakeOrgService := orgtest.NewOrgServiceFake()
+
+ features := featuremgmt.WithFeatures()
+ if tc.featureEnabled {
+ features = featuremgmt.WithFeatures(featuremgmt.FlagKubernetesClientDashboardsFolders)
+ }
+
+ service := &DashboardServiceImpl{
+ cfg: setting.NewCfg(),
+ log: log.New("test.logger"),
+ dashboardStore: &fakeStore,
+ publicDashboardService: fakePublicDashboardService,
+ orgService: fakeOrgService,
+ serverLockService: lockService,
+ kvstore: kv,
+ features: features,
+ }
+
+ ctx, k8sCliMock := setupK8sDashboardTests(service)
+
+ if tc.setupFunc != nil {
+ tc.setupFunc(service, ctx, k8sCliMock)
+ }
+
+ // Execute
+ err := service.cleanupK8sDashboardResources(ctx, int64(tc.batchSize), 20*time.Second)
+ require.NoError(t, err)
+
+ if tc.verifyFunc != nil {
+ tc.verifyFunc(t, service, ctx, k8sCliMock, kv)
+ }
+ })
+ }
+
+ t.Run("Should start and stop background job correctly", func(t *testing.T) {
+ // Setup test database and utilities
+ sqlStore, _ := sqlstore.InitTestDB(t)
+ lockService := serverlock.ProvideService(sqlStore, tracing.InitializeTracerForTest())
+
+ cfg := setting.NewCfg()
+ cfg.K8sDashboardCleanup = setting.K8sDashboardCleanupSettings{
+ Interval: 30 * time.Second,
+ Timeout: 25 * time.Second,
+ BatchSize: 10,
+ }
+
+ service := &DashboardServiceImpl{
+ cfg: cfg,
+ log: log.New("test.logger"),
+ features: featuremgmt.WithFeatures(featuremgmt.FlagKubernetesClientDashboardsFolders),
+ serverLockService: lockService,
+ }
+
+ // Create a test context that can be canceled
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ // Start job with the context
+ done := service.startK8sDeletedDashboardsCleanupJob(ctx)
+
+ // Cancel context to verify graceful shutdown
+ cancel()
+
+ // Wait for goroutine to exit instead of using sleep
+ select {
+ case <-done:
+ // Job exited successfully
+ case <-time.After(time.Second):
+ t.Fatal("Cleanup job didn't exit within timeout")
+ }
+ })
+}
+
+// Helper functions for testing
+
+func createTestUnstructuredDashboard(uid, title string, resourceVersion string) unstructured.Unstructured {
+ return unstructured.Unstructured{
+ Object: map[string]interface{}{
+ "apiVersion": dashboardv0alpha1.DashboardResourceInfo.GroupVersion().String(),
+ "kind": dashboardv0alpha1.DashboardResourceInfo.GroupVersionKind().Kind,
+ "metadata": map[string]interface{}{
+ "name": uid,
+ "deletionTimestamp": "2023-01-01T00:00:00Z",
+ "resourceVersion": resourceVersion,
+ },
+ "spec": map[string]interface{}{
+ "title": title,
+ },
+ },
+ }
+}
diff --git a/pkg/services/dashboardsnapshots/service/service_test.go b/pkg/services/dashboardsnapshots/service/service_test.go
index 9c4fedb47e4..c647528785b 100644
--- a/pkg/services/dashboardsnapshots/service/service_test.go
+++ b/pkg/services/dashboardsnapshots/service/service_test.go
@@ -11,6 +11,9 @@ import (
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
"github.com/grafana/grafana/pkg/infra/db"
+ "github.com/grafana/grafana/pkg/infra/kvstore"
+ "github.com/grafana/grafana/pkg/infra/serverlock"
+ "github.com/grafana/grafana/pkg/infra/tracing"
acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/apiserver/client"
"github.com/grafana/grafana/pkg/services/dashboards"
@@ -121,6 +124,8 @@ func TestValidateDashboardExists(t *testing.T) {
nil,
dualwrite.ProvideTestService(),
sort.ProvideService(),
+ serverlock.ProvideService(sqlStore, tracing.InitializeTracerForTest()),
+ kvstore.NewFakeKVStore(),
)
require.NoError(t, err)
s := ProvideService(dsStore, secretsService, dashSvc)
diff --git a/pkg/services/dashboardversion/dashverimpl/dashver.go b/pkg/services/dashboardversion/dashverimpl/dashver.go
index 31103e1821a..c054154a72d 100644
--- a/pkg/services/dashboardversion/dashverimpl/dashver.go
+++ b/pkg/services/dashboardversion/dashverimpl/dashver.go
@@ -221,25 +221,42 @@ func (s *Service) getDashIDMaybeEmpty(ctx context.Context, uid string, orgID int
return result.ID, nil
}
-func (s *Service) getHistoryThroughK8s(ctx context.Context, orgID int64, dashboardUID string, rv int64) (*dashver.DashboardVersionDTO, error) {
- out, err := s.k8sclient.Get(ctx, dashboardUID, orgID, v1.GetOptions{ResourceVersion: strconv.FormatInt(rv, 10)})
- if err != nil {
- if apierrors.IsNotFound(err) {
+func (s *Service) getHistoryThroughK8s(ctx context.Context, orgID int64, dashboardUID string, version int64) (*dashver.DashboardVersionDTO, error) {
+ // this is an unideal implementation - we have to list all versions and filter here, since there currently is no way to query for the
+ // generation id in unified storage, so we cannot query for the dashboard version directly, and we cannot use search as history is not indexed.
+ // use batches to make sure we don't load too much data at once.
+ const batchSize = 50
+ labelSelector := utils.LabelKeyGetHistory + "=" + dashboardUID
+ var continueToken string
+ for {
+ out, err := s.k8sclient.List(ctx, orgID, v1.ListOptions{
+ LabelSelector: labelSelector,
+ Limit: int64(batchSize),
+ Continue: continueToken,
+ })
+ if err != nil {
+ if apierrors.IsNotFound(err) {
+ return nil, dashboards.ErrDashboardNotFound
+ }
+ return nil, err
+ }
+ if out == nil {
return nil, dashboards.ErrDashboardNotFound
}
- return nil, err
- }
- if out == nil {
- return nil, dashboards.ErrDashboardNotFound
+ for _, item := range out.Items {
+ if item.GetGeneration() == version {
+ return s.UnstructuredToLegacyDashboardVersion(ctx, &item, orgID)
+ }
+ }
+
+ continueToken = out.GetContinue()
+ if continueToken == "" || len(out.Items) == 0 {
+ break
+ }
}
- dash, err := s.UnstructuredToLegacyDashboardVersion(ctx, out, orgID)
- if err != nil {
- return nil, err
- }
-
- return dash, nil
+ return nil, dashboards.ErrDashboardNotFound
}
func (s *Service) listHistoryThroughK8s(ctx context.Context, orgID int64, dashboardUID string, limit int64, continueToken string) (*dashver.DashboardVersionResponse, error) {
@@ -308,10 +325,9 @@ func (s *Service) UnstructuredToLegacyDashboardVersion(ctx context.Context, item
createdBy = updatedBy
}
}
-
- id, err := obj.GetResourceVersionInt64()
- if err != nil {
- return nil, err
+ created := obj.GetCreationTimestamp().Time
+ if updated, err := obj.GetUpdatedTimestamp(); err == nil && updated != nil {
+ created = *updated
}
restoreVer, err := getRestoreVersion(obj.GetMessage())
@@ -320,10 +336,10 @@ func (s *Service) UnstructuredToLegacyDashboardVersion(ctx context.Context, item
}
out := dashver.DashboardVersionDTO{
- ID: id,
+ ID: dashVersion,
DashboardID: obj.GetDeprecatedInternalID(), // nolint:staticcheck
DashboardUID: uid,
- Created: obj.GetCreationTimestamp().Time,
+ Created: created,
CreatedBy: createdBy.ID,
Message: obj.GetMessage(),
RestoredFrom: restoreVer,
diff --git a/pkg/services/dashboardversion/dashverimpl/dashver_test.go b/pkg/services/dashboardversion/dashverimpl/dashver_test.go
index bd022a00640..4f210ea906f 100644
--- a/pkg/services/dashboardversion/dashverimpl/dashver_test.go
+++ b/pkg/services/dashboardversion/dashverimpl/dashver_test.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"testing"
+ "time"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
@@ -49,8 +50,9 @@ func TestDashboardVersionService(t *testing.T) {
dashboardVersionService.features = featuremgmt.WithFeatures(featuremgmt.FlagKubernetesClientDashboardsFolders)
dashboardService.On("GetDashboardUIDByID", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardRefByIDQuery")).Return(&dashboards.DashboardRef{UID: "uid"}, nil)
- mockCli.On("GetUserFromMeta", mock.Anything, "user:1").Return(&user.User{ID: 1}, nil)
- mockCli.On("Get", mock.Anything, "uid", int64(1), v1.GetOptions{ResourceVersion: "10"}, mock.Anything).Return(&unstructured.Unstructured{
+ creationTimestamp := time.Now().Add(time.Hour * -24).UTC()
+ updatedTimestamp := time.Now().UTC().Truncate(time.Second)
+ dash := &unstructured.Unstructured{
Object: map[string]any{
"metadata": map[string]any{
"name": "uid",
@@ -66,7 +68,14 @@ func TestDashboardVersionService(t *testing.T) {
"spec": map[string]any{
"hello": "world",
},
- }}, nil).Once()
+ }}
+ dash.SetCreationTimestamp(v1.NewTime(creationTimestamp))
+ obj, err := utils.MetaAccessor(dash)
+ require.NoError(t, err)
+ obj.SetUpdatedTimestamp(&updatedTimestamp)
+ mockCli.On("GetUserFromMeta", mock.Anything, "user:1").Return(&user.User{ID: 1}, nil)
+ mockCli.On("List", mock.Anything, int64(1), mock.Anything).Return(&unstructured.UnstructuredList{
+ Items: []unstructured.Unstructured{*dash}}, nil).Once()
res, err := dashboardVersionService.Get(context.Background(), &dashver.GetDashboardVersionQuery{
DashboardID: 42,
OrgID: 1,
@@ -74,32 +83,34 @@ func TestDashboardVersionService(t *testing.T) {
})
require.Nil(t, err)
require.Equal(t, res, &dashver.DashboardVersionDTO{
- ID: 12, // RV should be used
+ ID: 10,
Version: 10,
ParentVersion: 9,
DashboardID: 42,
DashboardUID: "uid",
CreatedBy: 1,
+ Created: updatedTimestamp,
Data: simplejson.NewFromAny(map[string]any{"uid": "uid", "version": int64(10), "hello": "world"}),
})
mockCli.On("GetUserFromMeta", mock.Anything, "user:2").Return(&user.User{ID: 2}, nil)
- mockCli.On("Get", mock.Anything, "uid", int64(1), v1.GetOptions{ResourceVersion: "11"}, mock.Anything).Return(&unstructured.Unstructured{
- Object: map[string]any{
- "metadata": map[string]any{
- "name": "uid",
- "resourceVersion": "11",
- "generation": int64(11),
- "labels": map[string]any{
- utils.LabelKeyDeprecatedInternalID: "42", // nolint:staticcheck
+ mockCli.On("List", mock.Anything, int64(1), mock.Anything).Return(&unstructured.UnstructuredList{
+ Items: []unstructured.Unstructured{{
+ Object: map[string]any{
+ "metadata": map[string]any{
+ "name": "uid",
+ "resourceVersion": "11",
+ "generation": int64(11),
+ "labels": map[string]any{
+ utils.LabelKeyDeprecatedInternalID: "42", // nolint:staticcheck
+ },
+ "annotations": map[string]any{
+ utils.AnnoKeyCreatedBy: "user:1",
+ utils.AnnoKeyUpdatedBy: "user:2", // if updated by is set, that is the version creator
+ },
},
- "annotations": map[string]any{
- utils.AnnoKeyCreatedBy: "user:1",
- utils.AnnoKeyUpdatedBy: "user:2", // if updated by is set, that is the version creator
- },
- },
- "spec": map[string]any{},
- }}, nil).Once()
+ "spec": map[string]any{},
+ }}}}, nil).Once()
res, err = dashboardVersionService.Get(context.Background(), &dashver.GetDashboardVersionQuery{
DashboardID: 42,
OrgID: 1,
@@ -107,7 +118,7 @@ func TestDashboardVersionService(t *testing.T) {
})
require.Nil(t, err)
require.Equal(t, res, &dashver.DashboardVersionDTO{
- ID: 11, // RV should be used
+ ID: 11,
Version: 11,
ParentVersion: 10,
DashboardID: 42,
@@ -124,7 +135,7 @@ func TestDashboardVersionService(t *testing.T) {
dashboardVersionService.k8sclient = mockCli
dashboardVersionService.features = featuremgmt.WithFeatures(featuremgmt.FlagKubernetesClientDashboardsFolders)
dashboardService.On("GetDashboardUIDByID", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardRefByIDQuery")).Return(&dashboards.DashboardRef{UID: "uid"}, nil)
- mockCli.On("Get", mock.Anything, "uid", int64(1), v1.GetOptions{ResourceVersion: "10"}, mock.Anything).Return(nil, apierrors.NewNotFound(schema.GroupResource{Group: "dashboards.dashboard.grafana.app", Resource: "dashboard"}, "uid"))
+ mockCli.On("List", mock.Anything, int64(1), mock.Anything).Return(nil, apierrors.NewNotFound(schema.GroupResource{Group: "dashboards.dashboard.grafana.app", Resource: "dashboard"}, "uid"))
_, err := dashboardVersionService.Get(context.Background(), &dashver.GetDashboardVersionQuery{
DashboardID: 42,
@@ -276,7 +287,7 @@ func TestListDashboardVersions(t *testing.T) {
require.Equal(t, 1, len(res.Versions))
require.EqualValues(t, &dashver.DashboardVersionResponse{
Versions: []*dashver.DashboardVersionDTO{{
- ID: 12, // should take rv
+ ID: 5,
DashboardID: 42,
ParentVersion: 4,
Version: 5, // should take from spec
diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go
index da680c84add..89f2d6f8220 100644
--- a/pkg/services/featuremgmt/registry.go
+++ b/pkg/services/featuremgmt/registry.go
@@ -441,13 +441,6 @@ var (
Owner: grafanaObservabilityTracesAndProfilingSquad,
Expression: "false",
},
- {
- Name: "metricsSummary",
- Description: "Enables metrics summary queries in the Tempo data source",
- Stage: FeatureStageExperimental,
- FrontendOnly: true,
- Owner: grafanaObservabilityTracesAndProfilingSquad,
- },
{
Name: "datasourceAPIServers",
Description: "Expose some datasources as apiservers.",
@@ -656,12 +649,6 @@ var (
Owner: grafanaAppPlatformSquad,
FrontendOnly: true,
},
- {
- Name: "kubernetesRestore",
- Description: "Allow restoring objects in k8s",
- Stage: FeatureStageExperimental,
- Owner: grafanaAppPlatformSquad,
- },
{
Name: "kubernetesClientDashboardsFolders",
Description: "Route the folder and dashboard service requests to k8s",
@@ -1405,8 +1392,9 @@ var (
{
Name: "unifiedStorageSearchPermissionFiltering",
Description: "Enable permission filtering on unified storage search",
- Stage: FeatureStageExperimental,
+ Stage: FeatureStageGeneralAvailability,
Owner: grafanaSearchAndStorageSquad,
+ Expression: "true",
HideFromDocs: true,
HideFromAdminPage: true,
},
@@ -1836,6 +1824,14 @@ var (
HideFromAdminPage: true,
HideFromDocs: true,
},
+ {
+ Name: "unifiedStorageGrpcConnectionPool",
+ Description: "Enables the unified storage grpc connection pool",
+ Stage: FeatureStageExperimental,
+ Owner: grafanaSearchAndStorageSquad,
+ HideFromAdminPage: true,
+ HideFromDocs: true,
+ },
}
)
diff --git a/pkg/services/featuremgmt/toggles-gitlog.csv b/pkg/services/featuremgmt/toggles-gitlog.csv
index 0cdaace5e3e..cb90956b908 100644
--- a/pkg/services/featuremgmt/toggles-gitlog.csv
+++ b/pkg/services/featuremgmt/toggles-gitlog.csv
@@ -413,7 +413,6 @@ unifiedStorageSearchUI,2024-12-19T18:21:48Z,,a8f347144ddc16f2033fdeb4f3474e49239
playlistsReconciler,2024-12-20T03:09:31Z,,24bf337c562dc9b9d8684cc9acb7ea171ea83414,Charandas
k8SFolderCounts,2024-12-27T17:10:44Z,,df36e77cd31d2ad77e3d708748d040367a0c8c9c,Leonor Oliveira
k8SFolderMove,2024-12-27T17:10:44Z,,df36e77cd31d2ad77e3d708748d040367a0c8c9c,Leonor Oliveira
-kubernetesRestore,2025-01-03T14:48:47Z,,5429512779bd5f25b88ff728ea91efdef7dfafa0,Stephanie Hingtgen
improvedExternalSessionHandlingSAML,2025-01-09T17:02:49Z,,c52ec21c75ab72c2f7d28259bac0364edae560d0,Misi
teamHttpHeadersMimir,2025-01-13T10:42:47Z,,04acbcdef23f673bd6bbfdbbece29c9769ce155a,Eric Leijonmarck
ABTestFeatureToggleA,2025-01-13T21:13:13Z,,009d7f42b3d09b3a6be1f00f07314e2b25af7ebc,Nathan Marrs
diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv
index 9d6dc6fd264..62bd4844236 100644
--- a/pkg/services/featuremgmt/toggles_gen.csv
+++ b/pkg/services/featuremgmt/toggles_gen.csv
@@ -58,7 +58,6 @@ awsDatasourcesTempCredentials,experimental,@grafana/aws-datasources,false,false,
transformationsRedesign,GA,@grafana/observability-metrics,false,false,true
mlExpressions,experimental,@grafana/alerting-squad,false,false,false
traceQLStreaming,GA,@grafana/observability-traces-and-profiling,false,false,true
-metricsSummary,experimental,@grafana/observability-traces-and-profiling,false,false,true
datasourceAPIServers,experimental,@grafana/grafana-app-platform-squad,false,true,false
grafanaAPIServerWithExperimentalAPIs,experimental,@grafana/grafana-app-platform-squad,true,true,false
provisioning,experimental,@grafana/grafana-app-platform-squad,false,true,false
@@ -86,7 +85,6 @@ formatString,GA,@grafana/dataviz-squad,false,false,true
kubernetesPlaylists,GA,@grafana/grafana-app-platform-squad,false,true,false
kubernetesSnapshots,experimental,@grafana/grafana-app-platform-squad,false,true,false
kubernetesDashboards,experimental,@grafana/grafana-app-platform-squad,false,false,true
-kubernetesRestore,experimental,@grafana/grafana-app-platform-squad,false,false,false
kubernetesClientDashboardsFolders,experimental,@grafana/grafana-app-platform-squad,false,false,false
datasourceQueryTypes,experimental,@grafana/grafana-app-platform-squad,false,true,false
queryService,experimental,@grafana/grafana-app-platform-squad,false,true,false
@@ -184,7 +182,7 @@ useSessionStorageForRedirection,GA,@grafana/identity-access-team,false,false,fal
rolePickerDrawer,experimental,@grafana/identity-access-team,false,false,false
unifiedStorageSearch,experimental,@grafana/search-and-storage,false,false,false
unifiedStorageSearchSprinkles,experimental,@grafana/search-and-storage,false,false,false
-unifiedStorageSearchPermissionFiltering,experimental,@grafana/search-and-storage,false,false,false
+unifiedStorageSearchPermissionFiltering,GA,@grafana/search-and-storage,false,false,false
managedDualWriter,experimental,@grafana/search-and-storage,false,false,false
pluginsSriChecks,GA,@grafana/plugins-platform-backend,false,false,false
unifiedStorageBigObjectsSupport,experimental,@grafana/search-and-storage,false,false,false
@@ -242,3 +240,4 @@ extraLanguages,experimental,@grafana/grafana-frontend-platform,false,false,true
noBackdropBlur,experimental,@grafana/grafana-frontend-platform,false,false,true
alertingMigrationUI,experimental,@grafana/alerting-squad,false,false,true
unifiedStorageHistoryPruner,experimental,@grafana/search-and-storage,false,false,false
+unifiedStorageGrpcConnectionPool,experimental,@grafana/search-and-storage,false,false,false
diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go
index 943895b2c9e..b1d5e756e5b 100644
--- a/pkg/services/featuremgmt/toggles_gen.go
+++ b/pkg/services/featuremgmt/toggles_gen.go
@@ -243,10 +243,6 @@ const (
// Enables response streaming of TraceQL queries of the Tempo data source
FlagTraceQLStreaming = "traceQLStreaming"
- // FlagMetricsSummary
- // Enables metrics summary queries in the Tempo data source
- FlagMetricsSummary = "metricsSummary"
-
// FlagDatasourceAPIServers
// Expose some datasources as apiservers.
FlagDatasourceAPIServers = "datasourceAPIServers"
@@ -355,10 +351,6 @@ const (
// Use the kubernetes API in the frontend for dashboards
FlagKubernetesDashboards = "kubernetesDashboards"
- // FlagKubernetesRestore
- // Allow restoring objects in k8s
- FlagKubernetesRestore = "kubernetesRestore"
-
// FlagKubernetesClientDashboardsFolders
// Route the folder and dashboard service requests to k8s
FlagKubernetesClientDashboardsFolders = "kubernetesClientDashboardsFolders"
@@ -978,4 +970,8 @@ const (
// FlagUnifiedStorageHistoryPruner
// Enables the unified storage history pruner
FlagUnifiedStorageHistoryPruner = "unifiedStorageHistoryPruner"
+
+ // FlagUnifiedStorageGrpcConnectionPool
+ // Enables the unified storage grpc connection pool
+ FlagUnifiedStorageGrpcConnectionPool = "unifiedStorageGrpcConnectionPool"
)
diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json
index 0d0fe4b0a57..1d0e357c378 100644
--- a/pkg/services/featuremgmt/toggles_gen.json
+++ b/pkg/services/featuremgmt/toggles_gen.json
@@ -2404,18 +2404,6 @@
"expression": "true"
}
},
- {
- "metadata": {
- "name": "kubernetesRestore",
- "resourceVersion": "1735880498698",
- "creationTimestamp": "2025-01-03T14:48:47Z"
- },
- "spec": {
- "description": "Allow restoring objects in k8s",
- "stage": "experimental",
- "codeowner": "@grafana/grafana-app-platform-squad"
- }
- },
{
"metadata": {
"name": "kubernetesSnapshots",
@@ -2780,7 +2768,8 @@
"metadata": {
"name": "metricsSummary",
"resourceVersion": "1718727528075",
- "creationTimestamp": "2023-08-28T14:02:12Z"
+ "creationTimestamp": "2023-08-28T14:02:12Z",
+ "deletionTimestamp": "2024-11-25T10:47:18Z"
},
"spec": {
"description": "Enables metrics summary queries in the Tempo data source",
@@ -4215,6 +4204,20 @@
"codeowner": "@grafana/search-and-storage"
}
},
+ {
+ "metadata": {
+ "name": "unifiedStorageGrpcConnectionPool",
+ "resourceVersion": "1742549790491",
+ "creationTimestamp": "2025-03-21T09:36:30Z"
+ },
+ "spec": {
+ "description": "Enables the unified storage grpc connection pool",
+ "stage": "experimental",
+ "codeowner": "@grafana/search-and-storage",
+ "hideFromAdminPage": true,
+ "hideFromDocs": true
+ }
+ },
{
"metadata": {
"name": "unifiedStorageHistoryPruner",
@@ -4261,15 +4264,19 @@
{
"metadata": {
"name": "unifiedStorageSearchPermissionFiltering",
- "resourceVersion": "1737489629408",
- "creationTimestamp": "2025-01-22T11:38:37Z"
+ "resourceVersion": "1742564039800",
+ "creationTimestamp": "2025-01-22T11:38:37Z",
+ "annotations": {
+ "grafana.app/updatedTimestamp": "2025-03-21 13:33:59.800619 +0000 UTC"
+ }
},
"spec": {
"description": "Enable permission filtering on unified storage search",
- "stage": "experimental",
+ "stage": "GA",
"codeowner": "@grafana/search-and-storage",
"hideFromAdminPage": true,
- "hideFromDocs": true
+ "hideFromDocs": true,
+ "expression": "true"
}
},
{
diff --git a/pkg/services/folder/folderimpl/folder_test.go b/pkg/services/folder/folderimpl/folder_test.go
index b6361936a5d..fcb88a922cf 100644
--- a/pkg/services/folder/folderimpl/folder_test.go
+++ b/pkg/services/folder/folderimpl/folder_test.go
@@ -20,8 +20,10 @@ import (
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/db/dbtest"
+ "github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/log/logtest"
+ "github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
@@ -498,7 +500,10 @@ func TestIntegrationNestedFolderService(t *testing.T) {
publicDashboardFakeService.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil)
dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuresFlagOn, folderPermissions, ac, serviceWithFlagOn, nestedFolderStore, nil,
- client.MockTestRestConfig{}, nil, quotaService, nil, publicDashboardFakeService, nil, dualwrite.ProvideTestService(), sort.ProvideService())
+ client.MockTestRestConfig{}, nil, quotaService, nil, publicDashboardFakeService, nil, dualwrite.ProvideTestService(), sort.ProvideService(),
+ serverlock.ProvideService(db, tracing.InitializeTracerForTest()),
+ kvstore.NewFakeKVStore(),
+ )
require.NoError(t, err)
dashSrv.RegisterDashboardPermissions(dashboardPermissions)
@@ -584,7 +589,10 @@ func TestIntegrationNestedFolderService(t *testing.T) {
publicDashboardFakeService.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil)
dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuresFlagOff,
- folderPermissions, ac, serviceWithFlagOff, nestedFolderStore, nil, client.MockTestRestConfig{}, nil, quotaService, nil, publicDashboardFakeService, nil, dualwrite.ProvideTestService(), sort.ProvideService())
+ folderPermissions, ac, serviceWithFlagOff, nestedFolderStore, nil, client.MockTestRestConfig{}, nil, quotaService, nil, publicDashboardFakeService, nil, dualwrite.ProvideTestService(), sort.ProvideService(),
+ serverlock.ProvideService(db, tracing.InitializeTracerForTest()),
+ kvstore.NewFakeKVStore(),
+ )
require.NoError(t, err)
dashSrv.RegisterDashboardPermissions(dashboardPermissions)
alertStore, err := ngstore.ProvideDBStore(cfg, featuresFlagOff, db, serviceWithFlagOff, dashSrv, ac, b)
@@ -729,7 +737,10 @@ func TestIntegrationNestedFolderService(t *testing.T) {
dashSrv, err := dashboardservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, tc.featuresFlag, folderPermissions, ac, tc.service,
tc.service.store, nil, client.MockTestRestConfig{}, nil, quotaService, nil, publicDashboardFakeService, nil,
- dualwrite.ProvideTestService(), sort.ProvideService())
+ dualwrite.ProvideTestService(), sort.ProvideService(),
+ serverlock.ProvideService(db, tracing.InitializeTracerForTest()),
+ kvstore.NewFakeKVStore(),
+ )
require.NoError(t, err)
dashSrv.RegisterDashboardPermissions(dashboardPermissions)
@@ -1524,6 +1535,8 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) {
nil,
dualwrite.ProvideTestService(),
sort.ProvideService(),
+ serverlock.ProvideService(db, tracing.InitializeTracerForTest()),
+ kvstore.NewFakeKVStore(),
)
require.NoError(t, err)
dashboardService.RegisterDashboardPermissions(dashboardPermissions)
diff --git a/pkg/services/folder/folderimpl/sqlstore.go b/pkg/services/folder/folderimpl/sqlstore.go
index 14601344a82..20c17c324b5 100644
--- a/pkg/services/folder/folderimpl/sqlstore.go
+++ b/pkg/services/folder/folderimpl/sqlstore.go
@@ -607,9 +607,9 @@ func (ss *FolderStoreImpl) GetDescendants(ctx context.Context, orgID int64, ance
}
func getFullpathSQL(dialect migrator.Dialect) string {
- escaped := "\\/"
- if dialect.DriverName() == migrator.MySQL {
- escaped = "\\\\/"
+ escaped := `\/`
+ if dialect.DriverName() == migrator.MySQL || dialect.DriverName() == migrator.Spanner {
+ escaped = `\\/`
}
concatCols := make([]string, 0, folder.MaxNestedFolderDepth)
concatCols = append(concatCols, fmt.Sprintf("COALESCE(REPLACE(f0.title, '/', '%s'), '')", escaped))
diff --git a/pkg/services/guardian/guardian.go b/pkg/services/guardian/guardian.go
index 7100b7ffacc..58ebe2aa878 100644
--- a/pkg/services/guardian/guardian.go
+++ b/pkg/services/guardian/guardian.go
@@ -7,7 +7,6 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/errutil"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/metrics"
- "github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/folder"
)
@@ -34,12 +33,6 @@ var New = func(ctx context.Context, dashId int64, orgId int64, user identity.Req
panic("no guardian factory implementation provided")
}
-// NewByDashboard factory for creating a new dashboard guardian instance
-// When using access control this function is replaced on startup and the AccessControlDashboardGuardian is returned
-var NewByDashboard = func(ctx context.Context, dash *dashboards.Dashboard, orgId int64, user identity.Requester) (DashboardGuardian, error) {
- panic("no guardian factory implementation provided")
-}
-
// NewByFolderUID factory for creating a new folder guardian instance
// When using access control this function is replaced on startup and the AccessControlDashboardGuardian is returned
var NewByFolderUID = func(ctx context.Context, folderUID string, orgId int64, user identity.Requester) (DashboardGuardian, error) {
@@ -108,13 +101,6 @@ func MockDashboardGuardian(mock *FakeDashboardGuardian) {
mock.User = user
return mock, nil
}
- NewByDashboard = func(_ context.Context, dash *dashboards.Dashboard, orgId int64, user identity.Requester) (DashboardGuardian, error) {
- mock.OrgID = orgId
- mock.DashUID = dash.UID
- mock.DashID = dash.ID
- mock.User = user
- return mock, nil
- }
NewByFolderUID = func(_ context.Context, folderUID string, orgId int64, user identity.Requester) (DashboardGuardian, error) {
mock.OrgID = orgId
diff --git a/pkg/services/guardian/provider.go b/pkg/services/guardian/provider.go
index 7e3902f1e5a..7be96481b51 100644
--- a/pkg/services/guardian/provider.go
+++ b/pkg/services/guardian/provider.go
@@ -31,10 +31,6 @@ func InitAccessControlGuardian(
return NewAccessControlDashboardGuardian(ctx, cfg, dashId, user, ac, dashboardService, folderService, logger)
}
- NewByDashboard = func(ctx context.Context, dash *dashboards.Dashboard, orgId int64, user identity.Requester) (DashboardGuardian, error) {
- return NewAccessControlDashboardGuardianByDashboard(ctx, cfg, dash, user, ac, dashboardService, folderService, logger)
- }
-
NewByFolderUID = func(ctx context.Context, folderUID string, orgId int64, user identity.Requester) (DashboardGuardian, error) {
return NewAccessControlFolderGuardianByUID(ctx, cfg, folderUID, user, ac, dashboardService, folderService)
}
diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go
index ee1d1fe7ec6..8eec5685b14 100644
--- a/pkg/services/libraryelements/libraryelements_test.go
+++ b/pkg/services/libraryelements/libraryelements_test.go
@@ -18,7 +18,9 @@ import (
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/db"
+ "github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/kinds/librarypanel"
"github.com/grafana/grafana/pkg/services/accesscontrol"
@@ -366,6 +368,8 @@ func createDashboard(t *testing.T, sqlStore db.DB, user user.SignedInUser, dash
nil,
dualwrite.ProvideTestService(),
sort.ProvideService(),
+ serverlock.ProvideService(sqlStore, tracing.InitializeTracerForTest()),
+ kvstore.NewFakeKVStore(),
)
require.NoError(t, err)
service.RegisterDashboardPermissions(dashboardPermissions)
@@ -459,6 +463,8 @@ func scenarioWithPanel(t *testing.T, desc string, fn func(t *testing.T, sc scena
features, folderPermissions, ac,
folderSvc, fStore,
nil, client.MockTestRestConfig{}, nil, quotaService, nil, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService(),
+ serverlock.ProvideService(sqlStore, tracing.InitializeTracerForTest()),
+ kvstore.NewFakeKVStore(),
)
require.NoError(t, svcErr)
dashboardService.RegisterDashboardPermissions(dashboardPermissions)
@@ -532,6 +538,8 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo
features, folderPermissions, ac,
folderSvc, fStore,
nil, client.MockTestRestConfig{}, nil, quotaService, nil, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService(),
+ serverlock.ProvideService(sqlStore, tracing.InitializeTracerForTest()),
+ kvstore.NewFakeKVStore(),
)
require.NoError(t, dashSvcErr)
dashService.RegisterDashboardPermissions(dashboardPermissions)
diff --git a/pkg/services/libraryelements/model/model.go b/pkg/services/libraryelements/model/model.go
index 0828e9578f6..0189d785fd5 100644
--- a/pkg/services/libraryelements/model/model.go
+++ b/pkg/services/libraryelements/model/model.go
@@ -26,7 +26,7 @@ type LibraryElement struct {
Kind int64
Type string
Description string
- Model json.RawMessage
+ Model json.RawMessage `xorm:"TEXT"` // Column is defined as TEXT in `library_element`.
Version int64
Created time.Time
diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go
index 531f3714eca..930ec705e64 100644
--- a/pkg/services/librarypanels/librarypanels_test.go
+++ b/pkg/services/librarypanels/librarypanels_test.go
@@ -15,7 +15,9 @@ import (
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/db"
+ "github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/infra/slugify"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/kinds/librarypanel"
@@ -739,7 +741,8 @@ func createDashboard(t *testing.T, sqlStore db.DB, user *user.SignedInUser, dash
features, acmock.NewMockedPermissionsService(), ac,
foldertest.NewFakeService(), folder.NewFakeStore(),
nil, client.MockTestRestConfig{}, nil, quotaService, nil, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService(),
- )
+ serverlock.ProvideService(sqlStore, tracing.InitializeTracerForTest()),
+ kvstore.NewFakeKVStore())
require.NoError(t, err)
service.RegisterDashboardPermissions(dashPermissionService)
dashboard, err := service.SaveDashboard(context.Background(), dashItem, true)
@@ -837,7 +840,8 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo
features, acmock.NewMockedPermissionsService(), ac,
folderSvc, folder.NewFakeStore(),
nil, client.MockTestRestConfig{}, nil, quotaService, nil, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService(),
- )
+ serverlock.ProvideService(sqlStore, tracing.InitializeTracerForTest()),
+ kvstore.NewFakeKVStore())
require.NoError(t, err)
dashService.RegisterDashboardPermissions(dashPermissionService)
guardian.InitAccessControlGuardian(cfg, ac, dashService, folderSvc, log.NewNopLogger())
diff --git a/pkg/services/live/features/dashboard.go b/pkg/services/live/features/dashboard.go
index 2390f409fb4..286dad7c49c 100644
--- a/pkg/services/live/features/dashboard.go
+++ b/pkg/services/live/features/dashboard.go
@@ -10,8 +10,8 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/db"
+ "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/dashboards"
- "github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/live/model"
)
@@ -63,6 +63,7 @@ type DashboardHandler struct {
ClientCount model.ChannelClientCount
Store db.DB
DashboardService dashboards.DashboardService
+ AccessControl accesscontrol.AccessControl
}
// GetHandlerForPath called on init
@@ -77,20 +78,17 @@ func (h *DashboardHandler) OnSubscribe(ctx context.Context, user identity.Reques
// make sure can view this dashboard
if len(parts) == 2 && parts[0] == "uid" {
query := dashboards.GetDashboardQuery{UID: parts[1], OrgID: user.GetOrgID()}
- queryResult, err := h.DashboardService.GetDashboard(ctx, &query)
+ _, err := h.DashboardService.GetDashboard(ctx, &query)
if err != nil {
logger.Error("Error getting dashboard", "query", query, "error", err)
return model.SubscribeReply{}, backend.SubscribeStreamStatusNotFound, nil
}
- dash := queryResult
- guard, err := guardian.NewByDashboard(ctx, dash, user.GetOrgID(), user)
- if err != nil {
+ evaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsRead, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(parts[1]))
+ canView, err := h.AccessControl.Evaluate(ctx, user, evaluator)
+ if err != nil || !canView {
return model.SubscribeReply{}, backend.SubscribeStreamStatusPermissionDenied, err
}
- if canView, err := guard.CanView(); err != nil || !canView {
- return model.SubscribeReply{}, backend.SubscribeStreamStatusPermissionDenied, nil
- }
return model.SubscribeReply{
Presence: true,
@@ -119,19 +117,14 @@ func (h *DashboardHandler) OnPublish(ctx context.Context, requester identity.Req
return model.PublishReply{}, backend.PublishStreamStatusNotFound, fmt.Errorf("ignore???")
}
query := dashboards.GetDashboardQuery{UID: parts[1], OrgID: requester.GetOrgID()}
- queryResult, err := h.DashboardService.GetDashboard(ctx, &query)
+ _, err = h.DashboardService.GetDashboard(ctx, &query)
if err != nil {
logger.Error("Unknown dashboard", "query", query)
return model.PublishReply{}, backend.PublishStreamStatusNotFound, nil
}
- guard, err := guardian.NewByDashboard(ctx, queryResult, requester.GetOrgID(), requester)
- if err != nil {
- logger.Error("Failed to create guardian", "err", err)
- return model.PublishReply{}, backend.PublishStreamStatusNotFound, fmt.Errorf("internal error")
- }
-
- canEdit, err := guard.CanEdit()
+ evaluator := accesscontrol.EvalPermission(dashboards.ActionDashboardsWrite, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(parts[1]))
+ canEdit, err := h.AccessControl.Evaluate(ctx, requester, evaluator)
if err != nil {
return model.PublishReply{}, backend.PublishStreamStatusNotFound, fmt.Errorf("internal error")
}
diff --git a/pkg/services/live/features/watch.go b/pkg/services/live/features/watch.go
new file mode 100644
index 00000000000..05a7f875518
--- /dev/null
+++ b/pkg/services/live/features/watch.go
@@ -0,0 +1,214 @@
+package features
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "sync"
+
+ v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/apimachinery/pkg/watch"
+ "k8s.io/client-go/dynamic"
+
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+
+ "github.com/grafana/authlib/types"
+ "github.com/grafana/grafana-app-sdk/logging"
+ "github.com/grafana/grafana-plugin-sdk-go/backend"
+ "github.com/grafana/grafana-plugin-sdk-go/data/utils/jsoniter"
+ "github.com/grafana/grafana-plugin-sdk-go/live"
+
+ "github.com/grafana/grafana/pkg/apimachinery/identity"
+ "github.com/grafana/grafana/pkg/services/apiserver"
+ "github.com/grafana/grafana/pkg/services/live/model"
+)
+
+// WatchRunner will start a watch task and broadcast results
+type WatchRunner struct {
+ publisher model.ChannelPublisher
+ configProvider apiserver.RestConfigProvider
+
+ watchingMu sync.Mutex
+ watching map[string]*watcher
+}
+
+func NewWatchRunner(publisher model.ChannelPublisher, configProvider apiserver.RestConfigProvider) *WatchRunner {
+ return &WatchRunner{
+ publisher: publisher,
+ configProvider: configProvider,
+ watching: make(map[string]*watcher),
+ }
+}
+
+func (b *WatchRunner) GetHandlerForPath(_ string) (model.ChannelHandler, error) {
+ return b, nil // all dashboards share the same handler
+}
+
+// Valid paths look like: {version}/{resource}[={name}]/{user.uid}
+// * v0alpha1/dashboards/u12345
+// * v0alpha1/dashboards=ABCD/u12345
+func (b *WatchRunner) OnSubscribe(ctx context.Context, u identity.Requester, e model.SubscribeEvent) (model.SubscribeReply, backend.SubscribeStreamStatus, error) {
+ // To make sure we do not share resources across users, in clude the UID in the path
+ userID := u.GetIdentifier()
+ if userID == "" {
+ return model.SubscribeReply{}, backend.SubscribeStreamStatusPermissionDenied, fmt.Errorf("missing user identity")
+ }
+ if !strings.HasSuffix(e.Path, userID) {
+ return model.SubscribeReply{}, backend.SubscribeStreamStatusPermissionDenied, fmt.Errorf("path must end with user uid (%s)", userID)
+ }
+
+ // While testing with provisioning repositories, we will limit this to admin only
+ if !u.HasRole(identity.RoleAdmin) {
+ return model.SubscribeReply{}, backend.SubscribeStreamStatusPermissionDenied, fmt.Errorf("only admin users for now")
+ }
+
+ b.watchingMu.Lock()
+ defer b.watchingMu.Unlock()
+
+ current, ok := b.watching[e.Channel]
+ if ok && !current.done {
+ return model.SubscribeReply{
+ JoinLeave: false,
+ Presence: false,
+ Recover: false,
+ }, backend.SubscribeStreamStatusOK, nil
+ }
+
+ // Try to start a watcher for this request
+ gvr, name, err := parseWatchRequest(e.Channel, userID)
+ if err != nil {
+ return model.SubscribeReply{}, backend.SubscribeStreamStatusNotFound, err
+ }
+
+ // Test this with only provisiong support -- then we can evaluate a broader rollout
+ if gvr.Group != provisioning.GROUP {
+ return model.SubscribeReply{}, backend.SubscribeStreamStatusPermissionDenied,
+ fmt.Errorf("watching provisioned resources is OK allowed (for now)")
+ }
+
+ requester := types.WithAuthInfo(context.Background(), u)
+ cfg, err := b.configProvider.GetRestConfig(requester)
+ if err != nil {
+ return model.SubscribeReply{}, backend.SubscribeStreamStatusNotFound, err
+ }
+ uclient, err := dynamic.NewForConfig(cfg)
+ if err != nil {
+ return model.SubscribeReply{}, backend.SubscribeStreamStatusNotFound, err
+ }
+ client := uclient.Resource(gvr).Namespace(u.GetNamespace())
+
+ opts := v1.ListOptions{}
+ if len(name) > 1 {
+ opts.FieldSelector = "metadata.name=" + name
+ }
+ watch, err := client.Watch(requester, opts)
+ if err != nil {
+ return model.SubscribeReply{}, backend.SubscribeStreamStatusNotFound, err
+ }
+
+ current = &watcher{
+ orgId: u.GetOrgID(),
+ channel: e.Channel,
+ publisher: b.publisher,
+ watch: watch,
+ }
+
+ b.watching[e.Channel] = current
+ go current.run(ctx)
+
+ return model.SubscribeReply{
+ JoinLeave: false, // need unsubscribe envents
+ Presence: false,
+ Recover: false,
+ }, backend.SubscribeStreamStatusOK, nil
+}
+
+func parseWatchRequest(channel string, user string) (gvr schema.GroupVersionResource, name string, err error) {
+ addr, err := live.ParseChannel(channel)
+ if err != nil {
+ return gvr, "", err
+ }
+
+ parts := strings.Split(addr.Path, "/")
+ if len(parts) != 3 {
+ return gvr, "", fmt.Errorf("expecting path: {version}/{resource}={name}/{user}")
+ }
+ if parts[2] != user {
+ return gvr, "", fmt.Errorf("expecting user suffix: %s", user)
+ }
+
+ resource := strings.Split(parts[1], "=")
+ gvr = schema.GroupVersionResource{
+ Group: addr.Namespace,
+ Version: parts[0],
+ Resource: resource[0],
+ }
+ if len(resource) > 1 {
+ name = resource[1]
+ }
+ return gvr, name, nil
+}
+
+// OnPublish is called when a client wants to broadcast on the websocket
+func (b *WatchRunner) OnPublish(_ context.Context, u identity.Requester, e model.PublishEvent) (model.PublishReply, backend.PublishStreamStatus, error) {
+ return model.PublishReply{}, backend.PublishStreamStatusNotFound, fmt.Errorf("watch does not support publish")
+}
+
+type watcher struct {
+ orgId int64
+ channel string
+ publisher model.ChannelPublisher
+ done bool
+ watch watch.Interface
+}
+
+func (b *watcher) run(ctx context.Context) {
+ logger := logging.FromContext(ctx).With("channel", b.channel)
+
+ ch := b.watch.ResultChan()
+ for {
+ select {
+ // This is sent when there are no longer any subscriptions
+ case <-ctx.Done():
+ logger.Info("context done", "channel", b.channel)
+ b.watch.Stop()
+ b.done = true
+ return
+
+ // Each watch event
+ case event, ok := <-ch:
+ if !ok {
+ logger.Info("watch stream broken", "channel", b.channel)
+ b.watch.Stop()
+ b.done = true // will force reconnect from the frontend
+ return
+ }
+
+ cfg := jsoniter.ConfigCompatibleWithStandardLibrary
+ stream := cfg.BorrowStream(nil)
+ defer cfg.ReturnStream(stream)
+
+ // regular json.Marshal() uses upper case
+ stream.WriteObjectStart()
+ stream.WriteObjectField("type")
+ stream.WriteString(string(event.Type))
+ stream.WriteMore()
+ stream.WriteObjectField("object")
+ stream.WriteVal(event.Object)
+ stream.WriteObjectEnd()
+
+ buf := stream.Buffer()
+ data := make([]byte, len(buf))
+ copy(data, buf)
+
+ err := b.publisher(b.orgId, b.channel, data)
+ if err != nil {
+ logger.Error("publish error", "channel", b.channel, "err", err)
+ b.watch.Stop()
+ b.done = true // will force reconnect from the frontend
+ continue
+ }
+ }
+ }
+}
diff --git a/pkg/services/live/live.go b/pkg/services/live/live.go
index 35c2be26d69..eb854f99d9c 100644
--- a/pkg/services/live/live.go
+++ b/pkg/services/live/live.go
@@ -36,6 +36,7 @@ import (
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/annotations"
+ "github.com/grafana/grafana/pkg/services/apiserver"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/datasources"
@@ -79,7 +80,7 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r
dataSourceCache datasources.CacheService, sqlStore db.DB, secretsService secrets.Service,
usageStatsService usagestats.Service, queryDataService query.Service, toggles featuremgmt.FeatureToggles,
accessControl accesscontrol.AccessControl, dashboardService dashboards.DashboardService, annotationsRepo annotations.Repository,
- orgService org.Service) (*GrafanaLive, error) {
+ orgService org.Service, configProvider apiserver.RestConfigProvider) (*GrafanaLive, error) {
g := &GrafanaLive{
Cfg: cfg,
Features: toggles,
@@ -184,12 +185,18 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r
ClientCount: g.ClientCount,
Store: sqlStore,
DashboardService: dashboardService,
+ AccessControl: accessControl,
}
g.storage = database.NewStorage(g.SQLStore, g.CacheService)
g.GrafanaScope.Dashboards = dash
g.GrafanaScope.Features["dashboard"] = dash
g.GrafanaScope.Features["broadcast"] = features.NewBroadcastRunner(g.storage)
+ // Testing watch with just the provisioning support -- this will be removed when it is well validated
+ if toggles.IsEnabledGlobally(featuremgmt.FlagProvisioning) {
+ g.GrafanaScope.Features["watch"] = features.NewWatchRunner(g.Publish, configProvider)
+ }
+
g.surveyCaller = survey.NewCaller(managedStreamRunner, node)
err = g.surveyCaller.SetupHandlers()
if err != nil {
@@ -888,6 +895,8 @@ func (g *GrafanaLive) GetChannelHandlerFactory(ctx context.Context, user identit
switch scope {
case live.ScopeGrafana:
return g.handleGrafanaScope(user, namespace)
+ case "watch": // TODO: live.ScopeWatch: update 275 https://github.com/grafana/grafana-plugin-sdk-go/releases
+ return g.handleWatchScope()
case live.ScopePlugin:
return g.handlePluginScope(ctx, user, namespace)
case live.ScopeDatasource:
@@ -906,6 +915,13 @@ func (g *GrafanaLive) handleGrafanaScope(_ identity.Requester, namespace string)
return nil, fmt.Errorf("unknown feature: %q", namespace)
}
+func (g *GrafanaLive) handleWatchScope() (model.ChannelHandlerFactory, error) {
+ if p, ok := g.GrafanaScope.Features["watch"]; ok {
+ return p, nil
+ }
+ return nil, fmt.Errorf("watch not registered")
+}
+
func (g *GrafanaLive) handlePluginScope(ctx context.Context, _ identity.Requester, namespace string) (model.ChannelHandlerFactory, error) {
streamHandler, err := g.getStreamPlugin(ctx, namespace)
if err != nil {
diff --git a/pkg/services/live/live_test.go b/pkg/services/live/live_test.go
index 447910e1b84..c002ec7c705 100644
--- a/pkg/services/live/live_test.go
+++ b/pkg/services/live/live_test.go
@@ -36,7 +36,11 @@ func Test_provideLiveService_RedisUnavailable(t *testing.T) {
nil,
&usagestats.UsageStatsMock{T: t},
nil,
- featuremgmt.WithFeatures(), acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), &dashboards.FakeDashboardService{}, annotationstest.NewFakeAnnotationsRepo(), nil)
+ featuremgmt.WithFeatures(),
+ acimpl.ProvideAccessControl(featuremgmt.WithFeatures()),
+ &dashboards.FakeDashboardService{},
+ annotationstest.NewFakeAnnotationsRepo(),
+ nil, nil)
// Proceeds without live HA if redis is unavaialble
require.NoError(t, err)
diff --git a/pkg/services/login/authinfoimpl/store.go b/pkg/services/login/authinfoimpl/store.go
index b5a90fc2a8a..84c405bf276 100644
--- a/pkg/services/login/authinfoimpl/store.go
+++ b/pkg/services/login/authinfoimpl/store.go
@@ -107,10 +107,11 @@ func (s *Store) GetUserLabels(ctx context.Context, query login.GetUserLabelsQuer
func (s *Store) SetAuthInfo(ctx context.Context, cmd *login.SetAuthInfoCommand) error {
authUser := &login.UserAuth{
- UserId: cmd.UserId,
- AuthModule: cmd.AuthModule,
- AuthId: cmd.AuthId,
- Created: GetTime(),
+ UserId: cmd.UserId,
+ AuthModule: cmd.AuthModule,
+ AuthId: cmd.AuthId,
+ ExternalUID: cmd.ExternalUID,
+ Created: GetTime(),
}
if cmd.OAuthToken != nil {
diff --git a/pkg/services/login/model.go b/pkg/services/login/model.go
index 3d755a80301..979a89d6fb2 100644
--- a/pkg/services/login/model.go
+++ b/pkg/services/login/model.go
@@ -23,6 +23,7 @@ type UserAuth struct {
OAuthIdToken string
OAuthTokenType string
OAuthExpiry time.Time
+ ExternalUID string `xorm:"external_uid"`
}
type ExternalUserInfo struct {
@@ -72,10 +73,11 @@ type RequestURIKey struct{}
// COMMANDS
type SetAuthInfoCommand struct {
- AuthModule string
- AuthId string
- UserId int64
- OAuthToken *oauth2.Token
+ AuthModule string
+ AuthId string
+ UserId int64
+ OAuthToken *oauth2.Token
+ ExternalUID string
}
type UpdateAuthInfoCommand struct {
diff --git a/pkg/services/ngalert/api/api_convert_prometheus.go b/pkg/services/ngalert/api/api_convert_prometheus.go
index d9f28509622..5cbe398b459 100644
--- a/pkg/services/ngalert/api/api_convert_prometheus.go
+++ b/pkg/services/ngalert/api/api_convert_prometheus.go
@@ -463,6 +463,7 @@ func (srv *ConvertPrometheusSrv) convertToGrafanaRuleGroup(
IsPaused: pauseAlertRules,
},
KeepOriginalRuleDefinition: util.Pointer(keepOriginalRuleDefinition),
+ EvaluationOffset: &srv.cfg.PrometheusConversion.RuleQueryOffset,
},
)
if err != nil {
diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json
index 49ab21484b0..12bee7c6447 100644
--- a/pkg/services/ngalert/api/tooling/api.json
+++ b/pkg/services/ngalert/api/tooling/api.json
@@ -208,6 +208,9 @@
"isPaused": {
"type": "boolean"
},
+ "keepFiringFor": {
+ "$ref": "#/definitions/Duration"
+ },
"labels": {
"additionalProperties": {
"type": "string"
@@ -468,6 +471,10 @@
"health": {
"type": "string"
},
+ "keepFiringFor": {
+ "format": "double",
+ "type": "number"
+ },
"labels": {
"$ref": "#/definitions/Labels"
},
@@ -3012,9 +3019,22 @@
"Interval": {
"$ref": "#/definitions/Duration"
},
+ "Labels": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "type": "object"
+ },
+ "Limit": {
+ "format": "int64",
+ "type": "integer"
+ },
"Name": {
"type": "string"
},
+ "QueryOffset": {
+ "type": "string"
+ },
"Rules": {
"items": {
"$ref": "#/definitions/PrometheusRule"
@@ -3117,6 +3137,10 @@
"example": false,
"type": "boolean"
},
+ "keep_firing_for": {
+ "format": "duration",
+ "type": "string"
+ },
"labels": {
"additionalProperties": {
"type": "string"
@@ -4952,7 +4976,6 @@
"type": "object"
},
"gettableAlerts": {
- "description": "GettableAlerts gettable alerts",
"items": {
"$ref": "#/definitions/gettableAlert",
"type": "object"
diff --git a/pkg/services/ngalert/api/tooling/definitions/convert_prometheus_api.go b/pkg/services/ngalert/api/tooling/definitions/convert_prometheus_api.go
index f0202486e46..6e9f0f2bd4c 100644
--- a/pkg/services/ngalert/api/tooling/definitions/convert_prometheus_api.go
+++ b/pkg/services/ngalert/api/tooling/definitions/convert_prometheus_api.go
@@ -192,9 +192,12 @@ type PrometheusNamespace struct {
// swagger:model
type PrometheusRuleGroup struct {
- Name string `yaml:"name"`
- Interval model.Duration `yaml:"interval"`
- Rules []PrometheusRule `yaml:"rules"`
+ Name string `yaml:"name"`
+ Interval model.Duration `yaml:"interval"`
+ QueryOffset *model.Duration `yaml:"query_offset,omitempty"`
+ Limit int `yaml:"limit,omitempty"`
+ Rules []PrometheusRule `yaml:"rules"`
+ Labels map[string]string `yaml:"labels,omitempty"`
}
// swagger:model
diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json
index 3c9cc03dbec..5968f49fdbc 100644
--- a/pkg/services/ngalert/api/tooling/post.json
+++ b/pkg/services/ngalert/api/tooling/post.json
@@ -208,6 +208,9 @@
"isPaused": {
"type": "boolean"
},
+ "keepFiringFor": {
+ "$ref": "#/definitions/Duration"
+ },
"labels": {
"additionalProperties": {
"type": "string"
@@ -468,6 +471,10 @@
"health": {
"type": "string"
},
+ "keepFiringFor": {
+ "format": "double",
+ "type": "number"
+ },
"labels": {
"$ref": "#/definitions/Labels"
},
@@ -3012,9 +3019,22 @@
"Interval": {
"$ref": "#/definitions/Duration"
},
+ "Labels": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "type": "object"
+ },
+ "Limit": {
+ "format": "int64",
+ "type": "integer"
+ },
"Name": {
"type": "string"
},
+ "QueryOffset": {
+ "type": "string"
+ },
"Rules": {
"items": {
"$ref": "#/definitions/PrometheusRule"
@@ -3117,6 +3137,10 @@
"example": false,
"type": "boolean"
},
+ "keep_firing_for": {
+ "format": "duration",
+ "type": "string"
+ },
"labels": {
"additionalProperties": {
"type": "string"
diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json
index 15d8a288303..706a40d67fd 100644
--- a/pkg/services/ngalert/api/tooling/spec.json
+++ b/pkg/services/ngalert/api/tooling/spec.json
@@ -4433,6 +4433,9 @@
"isPaused": {
"type": "boolean"
},
+ "keepFiringFor": {
+ "$ref": "#/definitions/Duration"
+ },
"labels": {
"type": "object",
"additionalProperties": {
@@ -4703,6 +4706,10 @@
"health": {
"type": "string"
},
+ "keepFiringFor": {
+ "type": "number",
+ "format": "double"
+ },
"labels": {
"$ref": "#/definitions/Labels"
},
@@ -7238,9 +7245,22 @@
"Interval": {
"$ref": "#/definitions/Duration"
},
+ "Labels": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "Limit": {
+ "type": "integer",
+ "format": "int64"
+ },
"Name": {
"type": "string"
},
+ "QueryOffset": {
+ "type": "string"
+ },
"Rules": {
"type": "array",
"items": {
@@ -7354,6 +7374,10 @@
"type": "boolean",
"example": false
},
+ "keep_firing_for": {
+ "type": "string",
+ "format": "duration"
+ },
"labels": {
"type": "object",
"additionalProperties": {
diff --git a/pkg/services/ngalert/prom/convert.go b/pkg/services/ngalert/prom/convert.go
index c86126bfb90..b433a2ec76b 100644
--- a/pkg/services/ngalert/prom/convert.go
+++ b/pkg/services/ngalert/prom/convert.go
@@ -199,7 +199,7 @@ func (p *Converter) convertRule(orgID int64, namespaceUID string, promGroup Prom
var err error
isRecordingRule := rule.Record != ""
- query, err = p.createQuery(rule.Expr, isRecordingRule)
+ query, err = p.createQuery(rule.Expr, isRecordingRule, promGroup)
if err != nil {
return models.AlertRule{}, err
}
@@ -241,6 +241,12 @@ func (p *Converter) convertRule(orgID int64, namespaceUID string, promGroup Prom
RuleGroup: promGroup.Name,
IsPaused: isPaused,
Record: record,
+
+ // MissingSeriesEvalsToResolve is set to 1 to match the Prometheus behaviour.
+ // Prometheus resolves alerts as soon as the series disappears.
+ // By setting this value to 1 we ensure that the alert is resolved on the first evaluation
+ // that doesn't have the series.
+ MissingSeriesEvalsToResolve: util.Pointer(1),
}
if p.cfg.KeepOriginalRuleDefinition != nil && *p.cfg.KeepOriginalRuleDefinition {
@@ -265,8 +271,16 @@ func (p *Converter) convertRule(orgID int64, namespaceUID string, promGroup Prom
//
// This is needed to ensure that we keep the Prometheus behaviour, where any returned result
// is considered alerting, and only when the query returns no data is the alert treated as normal.
-func (p *Converter) createQuery(expr string, isRecordingRule bool) ([]models.AlertQuery, error) {
- queryNode, err := createQueryNode(p.cfg.DatasourceUID, p.cfg.DatasourceType, expr, *p.cfg.FromTimeRange, *p.cfg.EvaluationOffset)
+func (p *Converter) createQuery(expr string, isRecordingRule bool, promGroup PrometheusRuleGroup) ([]models.AlertQuery, error) {
+ // If evaluation offset is set on the group level, use that, otherwise use the global evaluation offset.
+ var evaluationOffset time.Duration
+ if promGroup.QueryOffset != nil {
+ evaluationOffset = time.Duration(*promGroup.QueryOffset)
+ } else {
+ evaluationOffset = *p.cfg.EvaluationOffset
+ }
+
+ queryNode, err := createQueryNode(p.cfg.DatasourceUID, p.cfg.DatasourceType, expr, *p.cfg.FromTimeRange, evaluationOffset)
if err != nil {
return nil, err
}
diff --git a/pkg/services/ngalert/prom/convert_test.go b/pkg/services/ngalert/prom/convert_test.go
index dc4017f477e..479417bc6b8 100644
--- a/pkg/services/ngalert/prom/convert_test.go
+++ b/pkg/services/ngalert/prom/convert_test.go
@@ -36,8 +36,9 @@ func TestPrometheusRulesToGrafana(t *testing.T) {
orgID: 1,
namespace: "some-namespace-uid",
promGroup: PrometheusRuleGroup{
- Name: "test-group-1",
- Interval: prommodel.Duration(10 * time.Second),
+ Name: "test-group-1",
+ Interval: prommodel.Duration(10 * time.Second),
+ QueryOffset: util.Pointer(prommodel.Duration(1 * time.Minute)),
Rules: []PrometheusRule{
{
Alert: "alert-1",
@@ -124,16 +125,13 @@ func TestPrometheusRulesToGrafana(t *testing.T) {
expectError: false,
},
{
- name: "rule group with query_offset is not supported",
+ name: "query_offset must be >= 0",
orgID: 1,
namespace: "namespaceUID",
promGroup: PrometheusRuleGroup{
- Name: "test-group-1",
- Interval: prommodel.Duration(10 * time.Second),
- QueryOffset: func() *prommodel.Duration {
- d := prommodel.Duration(30 * time.Second)
- return &d
- }(),
+ Name: "test-group-1",
+ Interval: prommodel.Duration(10 * time.Second),
+ QueryOffset: util.Pointer(prommodel.Duration(-1)),
Rules: []PrometheusRule{
{
Alert: "alert-1",
@@ -142,7 +140,7 @@ func TestPrometheusRulesToGrafana(t *testing.T) {
},
},
expectError: true,
- errorMsg: "query_offset is not supported",
+ errorMsg: "query_offset must be >= 0",
},
{
name: "rule group with limit is not supported",
@@ -179,6 +177,32 @@ func TestPrometheusRulesToGrafana(t *testing.T) {
},
expectError: false,
},
+ {
+ name: "when global query offset is set, it should be used",
+ orgID: 1,
+ namespace: "some-namespace-uid",
+ promGroup: PrometheusRuleGroup{
+ Name: "test-group-1",
+ Interval: prommodel.Duration(10 * time.Second),
+ Rules: []PrometheusRule{
+ {
+ Alert: "alert-1",
+ Expr: "cpu_usage > 80",
+ For: util.Pointer(prommodel.Duration(5 * time.Minute)),
+ Labels: map[string]string{
+ "severity": "critical",
+ },
+ Annotations: map[string]string{
+ "summary": "CPU usage is critical",
+ },
+ },
+ },
+ },
+ config: Config{
+ EvaluationOffset: util.Pointer(5 * time.Minute),
+ },
+ expectError: false,
+ },
}
for _, tc := range testCases {
@@ -244,8 +268,19 @@ func TestPrometheusRulesToGrafana(t *testing.T) {
require.Equal(t, expectedLabels, grafanaRule.Labels, tc.name)
require.Equal(t, promRule.Annotations, grafanaRule.Annotations, tc.name)
- require.Equal(t, models.Duration(0*time.Minute), grafanaRule.Data[0].RelativeTimeRange.To)
- require.Equal(t, models.Duration(10*time.Minute), grafanaRule.Data[0].RelativeTimeRange.From)
+
+ evalOffset := time.Duration(0)
+ if tc.config.EvaluationOffset != nil {
+ evalOffset = *tc.config.EvaluationOffset
+ }
+ if tc.promGroup.QueryOffset != nil {
+ // group-level offset takes precedence
+ evalOffset = time.Duration(*tc.promGroup.QueryOffset)
+ }
+
+ require.Equal(t, models.Duration(evalOffset), grafanaRule.Data[0].RelativeTimeRange.To)
+ require.Equal(t, models.Duration(10*time.Minute+evalOffset), grafanaRule.Data[0].RelativeTimeRange.From)
+ require.Equal(t, util.Pointer(1), grafanaRule.MissingSeriesEvalsToResolve)
originalRuleDefinition, err := yaml.Marshal(promRule)
require.NoError(t, err)
diff --git a/pkg/services/ngalert/prom/models.go b/pkg/services/ngalert/prom/models.go
index 21ea45061dc..0ef0a301885 100644
--- a/pkg/services/ngalert/prom/models.go
+++ b/pkg/services/ngalert/prom/models.go
@@ -25,14 +25,14 @@ type PrometheusRuleGroup struct {
}
func (g *PrometheusRuleGroup) Validate() error {
- if g.QueryOffset != nil {
- return ErrPrometheusRuleGroupValidationFailed.Errorf("query_offset is not supported")
- }
-
if g.Limit != 0 {
return ErrPrometheusRuleGroupValidationFailed.Errorf("limit is not supported")
}
+ if g.QueryOffset != nil && *g.QueryOffset < prommodel.Duration(0) {
+ return ErrPrometheusRuleGroupValidationFailed.Errorf("query_offset must be >= 0")
+ }
+
for _, rule := range g.Rules {
if err := rule.Validate(); err != nil {
return err
diff --git a/pkg/services/ngalert/prom/models_test.go b/pkg/services/ngalert/prom/models_test.go
index b2bdb734b44..90be51c140c 100644
--- a/pkg/services/ngalert/prom/models_test.go
+++ b/pkg/services/ngalert/prom/models_test.go
@@ -26,6 +26,7 @@ func TestPrometheusRuleGroup_Validate(t *testing.T) {
Labels: map[string]string{
"label-1": "value-1",
},
+ QueryOffset: util.Pointer(prommodel.Duration(time.Duration(1) * time.Second)),
Rules: []PrometheusRule{
{
Alert: "test_alert",
@@ -36,14 +37,14 @@ func TestPrometheusRuleGroup_Validate(t *testing.T) {
expectError: false,
},
{
- name: "invalid group with query_offset",
+ name: "invalid group with negative query_offset",
group: PrometheusRuleGroup{
Name: "test_group",
Interval: prommodel.Duration(60),
- QueryOffset: util.Pointer(prommodel.Duration(10)),
+ QueryOffset: util.Pointer(prommodel.Duration(-1)),
},
expectError: true,
- errorMsg: "query_offset is not supported",
+ errorMsg: "query_offset must be >= 0",
},
{
name: "invalid group with limit",
diff --git a/pkg/services/ngalert/prom/query.go b/pkg/services/ngalert/prom/query.go
index 74aed34f83f..8e28b0ac3e7 100644
--- a/pkg/services/ngalert/prom/query.go
+++ b/pkg/services/ngalert/prom/query.go
@@ -43,7 +43,7 @@ func createQueryNode(datasourceUID, datasourceType, expr string, fromTimeRange,
RefID: queryRefID,
RelativeTimeRange: models.RelativeTimeRange{
From: models.Duration(fromTimeRange + evaluationOffset),
- To: models.Duration(0 + evaluationOffset),
+ To: models.Duration(evaluationOffset),
},
}, nil
}
diff --git a/pkg/services/ngalert/testutil/testutil.go b/pkg/services/ngalert/testutil/testutil.go
index 0e13a46834d..daa61da93d3 100644
--- a/pkg/services/ngalert/testutil/testutil.go
+++ b/pkg/services/ngalert/testutil/testutil.go
@@ -8,6 +8,8 @@ import (
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/db"
+ "github.com/grafana/grafana/pkg/infra/kvstore"
+ "github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/accesscontrol"
acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
@@ -67,6 +69,8 @@ func SetupDashboardService(tb testing.TB, sqlStore db.DB, fs *folderimpl.Dashboa
foldertest.NewFakeService(), folder.NewFakeStore(),
nil, client.MockTestRestConfig{}, nil, quotaService, nil, nil, nil,
dualwrite.ProvideTestService(), sort.ProvideService(),
+ serverlock.ProvideService(sqlStore, tracing.InitializeTracerForTest()),
+ kvstore.NewFakeKVStore(),
)
require.NoError(tb, err)
dashboardService.RegisterDashboardPermissions(dashboardPermissions)
diff --git a/pkg/services/publicdashboards/api/query_test.go b/pkg/services/publicdashboards/api/query_test.go
index 8c27d18b0ea..ddbf2925d4b 100644
--- a/pkg/services/publicdashboards/api/query_test.go
+++ b/pkg/services/publicdashboards/api/query_test.go
@@ -21,8 +21,11 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/errutil"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/db"
+ "github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/infra/serverlock"
+ "github.com/grafana/grafana/pkg/infra/tracing"
acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/annotations/annotationstest"
"github.com/grafana/grafana/pkg/services/apiserver/client"
@@ -330,6 +333,8 @@ func TestIntegrationUnauthenticatedUserCanGetPubdashPanelQueryData(t *testing.T)
featuremgmt.WithFeatures(), acmock.NewMockedPermissionsService(), ac,
foldertest.NewFakeService(), folder.NewFakeStore(), nil, client.MockTestRestConfig{}, nil, quotatest.New(false, nil), nil, nil,
nil, dualwrite.ProvideTestService(), sort.ProvideService(),
+ serverlock.ProvideService(db, tracing.InitializeTracerForTest()),
+ kvstore.NewFakeKVStore(),
)
require.NoError(t, err)
dashService.RegisterDashboardPermissions(dashPermissionService)
diff --git a/pkg/services/publicdashboards/service/service_test.go b/pkg/services/publicdashboards/service/service_test.go
index 1ae4797a45f..e8ea78749a6 100644
--- a/pkg/services/publicdashboards/service/service_test.go
+++ b/pkg/services/publicdashboards/service/service_test.go
@@ -19,6 +19,8 @@ import (
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/db"
+ "github.com/grafana/grafana/pkg/infra/kvstore"
+ "github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
@@ -29,7 +31,6 @@ import (
dashsvc "github.com/grafana/grafana/pkg/services/dashboards/service"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder/folderimpl"
- "github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/org"
. "github.com/grafana/grafana/pkg/services/publicdashboards"
. "github.com/grafana/grafana/pkg/services/publicdashboards/models"
@@ -1392,7 +1393,7 @@ func TestPublicDashboardServiceImpl_ListPublicDashboards(t *testing.T) {
testDB, cfg := db.InitTestDBWithCfg(t)
dashStore, err := dashboardsDB.ProvideDashboardStore(testDB, cfg, features, tagimpl.ProvideService(testDB))
require.NoError(t, err)
- ac := acmock.New()
+ ac := actest.FakeAccessControl{ExpectedEvaluate: true}
fStore := folderimpl.ProvideStore(testDB)
folderPermissions := acmock.NewMockedPermissionsService()
@@ -1401,15 +1402,11 @@ func TestPublicDashboardServiceImpl_ListPublicDashboards(t *testing.T) {
fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderStore,
nil, testDB, features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest(), nil, dualwrite.ProvideTestService(), sort.ProvideService())
- dashboardService, err := dashsvc.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuremgmt.WithFeatures(), folderPermissions, ac, folderSvc, fStore, nil, client.MockTestRestConfig{}, nil, quotatest.New(false, nil), nil, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService())
+ dashboardService, err := dashsvc.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuremgmt.WithFeatures(), folderPermissions, ac, folderSvc, fStore, nil, client.MockTestRestConfig{}, nil, quotatest.New(false, nil), nil, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService(),
+ serverlock.ProvideService(testDB, tracing.InitializeTracerForTest()),
+ kvstore.NewFakeKVStore())
require.NoError(t, err)
dashboardService.RegisterDashboardPermissions(&actest.FakePermissionsService{})
- fakeGuardian := &guardian.FakeDashboardGuardian{
- CanSaveValue: true,
- CanEditUIDs: []string{},
- CanViewUIDs: []string{},
- }
- guardian.MockDashboardGuardian(fakeGuardian)
// insert in test data so we can check that permissions are working properly through the dashboard service
// this will create 4 dashboards and 3 users
diff --git a/pkg/services/quota/quotaimpl/quota_test.go b/pkg/services/quota/quotaimpl/quota_test.go
index 6100f1f0e6a..c8cc6d5dfde 100644
--- a/pkg/services/quota/quotaimpl/quota_test.go
+++ b/pkg/services/quota/quotaimpl/quota_test.go
@@ -12,7 +12,9 @@ import (
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/httpclient"
+ "github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/infra/tracing"
pluginfakes "github.com/grafana/grafana/pkg/plugins/manager/fakes"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
@@ -499,7 +501,9 @@ func setupEnv(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, b bus.Bus, quotaSe
fStore, acmock.New(), bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderStore,
nil, sqlStore, featuremgmt.WithFeatures(), supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest(), nil, dualwrite.ProvideTestService(), sort.ProvideService())
dashService, err := dashService.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuremgmt.WithFeatures(), acmock.NewMockedPermissionsService(),
- ac, folderSvc, fStore, nil, client.MockTestRestConfig{}, nil, quotaService, nil, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService())
+ ac, folderSvc, fStore, nil, client.MockTestRestConfig{}, nil, quotaService, nil, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService(),
+ serverlock.ProvideService(sqlStore, tracing.InitializeTracerForTest()),
+ kvstore.NewFakeKVStore())
require.NoError(t, err)
dashService.RegisterDashboardPermissions(acmock.NewMockedPermissionsService())
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
diff --git a/pkg/services/sqlstore/migrations/dashboard_mig.go b/pkg/services/sqlstore/migrations/dashboard_mig.go
index 002b1e2e8a7..c6943ffb07d 100644
--- a/pkg/services/sqlstore/migrations/dashboard_mig.go
+++ b/pkg/services/sqlstore/migrations/dashboard_mig.go
@@ -261,6 +261,11 @@ func addDashboardMigration(mg *Migrator) {
mg.AddMigration("Add apiVersion for dashboard", NewAddColumnMigration(dashboardV2, &Column{
Name: "api_version", Type: DB_Varchar, Length: 16, Nullable: true,
}))
+
+ mg.AddMigration("Add index for dashboard_uid on dashboard_tag table", NewAddIndexMigration(dashboardTagV1, &Index{
+ Cols: []string{"dashboard_uid"},
+ Type: IndexType,
+ }))
}
type FillDashbordUIDAndOrgIDMigration struct {
@@ -278,23 +283,23 @@ func (m *FillDashbordUIDAndOrgIDMigration) Exec(sess *xorm.Session, mg *Migrator
func RunDashboardTagMigrations(sess *xorm.Session, driverName string) error {
// sqlite
sql := `UPDATE dashboard_tag
- SET
+ SET
dashboard_uid = (SELECT uid FROM dashboard WHERE dashboard.id = dashboard_tag.dashboard_id),
org_id = (SELECT org_id FROM dashboard WHERE dashboard.id = dashboard_tag.dashboard_id)
- WHERE
+ WHERE
(dashboard_uid IS NULL OR org_id IS NULL)
AND EXISTS (SELECT 1 FROM dashboard WHERE dashboard.id = dashboard_tag.dashboard_id);`
if driverName == Postgres {
- sql = `UPDATE dashboard_tag
- SET dashboard_uid = dashboard.uid,
+ sql = `UPDATE dashboard_tag
+ SET dashboard_uid = dashboard.uid,
org_id = dashboard.org_id
- FROM dashboard
+ FROM dashboard
WHERE dashboard_tag.dashboard_id = dashboard.id
AND (dashboard_tag.dashboard_uid IS NULL OR dashboard_tag.org_id IS NULL);`
} else if driverName == MySQL {
- sql = `UPDATE dashboard_tag
- LEFT JOIN dashboard ON dashboard_tag.dashboard_id = dashboard.id
- SET dashboard_tag.dashboard_uid = dashboard.uid,
+ sql = `UPDATE dashboard_tag
+ LEFT JOIN dashboard ON dashboard_tag.dashboard_id = dashboard.id
+ SET dashboard_tag.dashboard_uid = dashboard.uid,
dashboard_tag.org_id = dashboard.org_id
WHERE dashboard_tag.dashboard_uid IS NULL OR dashboard_tag.org_id IS NULL;`
}
diff --git a/pkg/services/sqlstore/migrations/ualert/alert_rule_version_guid_mig.go b/pkg/services/sqlstore/migrations/ualert/alert_rule_version_guid_mig.go
index 3713e4eaa0a..ae1c1945e36 100644
--- a/pkg/services/sqlstore/migrations/ualert/alert_rule_version_guid_mig.go
+++ b/pkg/services/sqlstore/migrations/ualert/alert_rule_version_guid_mig.go
@@ -2,6 +2,8 @@ package ualert
import (
"fmt"
+ "os"
+ "strconv"
"strings"
"github.com/google/uuid"
@@ -29,10 +31,13 @@ func AddAlertRuleGuidMigration(mg *migrator.Migrator) {
Nullable: false,
Default: "''",
}))
- mg.AddMigration("drop index in alert_rule_version table on rule_org_id, rule_uid and version columns", migrator.NewDropIndexMigration(alertRuleVersion, alertRuleVersionUDX_OrgIdRuleUIDVersion))
+
+ mg.AddMigration("cleanup alert_rule_version table", &cleanUpRuleVersionsMigration{})
mg.AddMigration("populate rule guid in alert rule table", &setRuleGuidMigration{})
+ mg.AddMigration("drop index in alert_rule_version table on rule_org_id, rule_uid and version columns", migrator.NewDropIndexMigration(alertRuleVersion, alertRuleVersionUDX_OrgIdRuleUIDVersion))
+
mg.AddMigration("add index in alert_rule_version table on rule_org_id, rule_uid, rule_guid and version columns",
migrator.NewAddIndexMigration(alertRuleVersion,
&migrator.Index{Cols: []string{"rule_org_id", "rule_uid", "rule_guid", "version"}, Type: migrator.UniqueIndex},
@@ -118,3 +123,80 @@ func (c setRuleGuidMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) er
}
return nil
}
+
+type cleanUpRuleVersionsMigration struct {
+ migrator.MigrationBase
+}
+
+var _ migrator.CodeMigration = (*cleanUpRuleVersionsMigration)(nil)
+
+func (c cleanUpRuleVersionsMigration) SQL(migrator.Dialect) string {
+ return codeMigration
+}
+
+func getBatchSize() int {
+ const defaultBatchSize = 50
+ envvar := os.Getenv("ALERT_RULE_VERSION_CLEANUP_MIGRATION_BATCH_SIZE")
+ if envvar == "" {
+ return defaultBatchSize
+ }
+ batchSize, err := strconv.Atoi(envvar)
+ if err != nil {
+ return defaultBatchSize
+ }
+ return batchSize
+}
+
+func (c cleanUpRuleVersionsMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) error {
+ var batchSize = getBatchSize()
+
+ const maxRetention = 100
+ toKeep := mg.Cfg.UnifiedAlerting.RuleVersionRecordLimit
+ if toKeep <= 0 {
+ mg.Logger.Info("Rule version record limit is not set, fallback to 100", "limit", toKeep)
+ toKeep = maxRetention
+ }
+
+ var rules []alertRule
+ err := sess.Table(alertRule{}).Select("uid, version").Where("version > ?", toKeep).Find(&rules)
+ if err != nil {
+ return err
+ }
+ mg.Logger.Debug("Got alert rule UIDs with versions greater than retention", "count", len(rules))
+ batches := len(rules) / batchSize
+ if len(rules)%batchSize != 0 {
+ batches++
+ }
+
+ mg.Logger.Info("Cleaning up table `alert_rule_version`", "batchSize", batchSize, "batches", batches, "keepVersions", toKeep)
+
+ for i := 0; i < batches; i++ {
+ end := i*batchSize + batchSize
+ if end > len(rules) {
+ end = len(rules)
+ }
+ bd := strings.Builder{}
+ for idx, r := range rules[i*batchSize : end] {
+ if idx == 0 {
+ bd.WriteString(fmt.Sprintf("SELECT '%s' as uid, %d as version", r.UID, r.Version))
+ continue
+ }
+ bd.WriteString(fmt.Sprintf(" UNION ALL SELECT '%s', %d ", r.UID, r.Version))
+ }
+ _, err = sess.Exec(fmt.Sprintf(`
+ DELETE FROM alert_rule_version
+ WHERE EXISTS (
+ SELECT 1
+ FROM (%s) AR
+ WHERE AR.uid = alert_rule_version.rule_uid
+ AND alert_rule_version.version < AR.version - %d
+ )`, bd.String(), toKeep),
+ )
+ if err != nil {
+ return err
+ }
+
+ mg.Logger.Debug(fmt.Sprintf("Batch %d of %d processed", i+1, batches))
+ }
+ return nil
+}
diff --git a/pkg/services/sqlstore/migrations/user_auth_mig.go b/pkg/services/sqlstore/migrations/user_auth_mig.go
index bf3b98955b7..9546d84a766 100644
--- a/pkg/services/sqlstore/migrations/user_auth_mig.go
+++ b/pkg/services/sqlstore/migrations/user_auth_mig.go
@@ -46,4 +46,8 @@ func addUserAuthMigrations(mg *Migrator) {
mg.AddMigration("Add OAuth ID token to user_auth", NewAddColumnMigration(userAuthV1, &Column{
Name: "o_auth_id_token", Type: DB_Text, Nullable: true,
}))
+
+ mg.AddMigration("Add user_unique_id to user_auth", NewAddColumnMigration(userAuthV1, &Column{
+ Name: "external_uid", Type: DB_Text, Nullable: true,
+ }))
}
diff --git a/pkg/services/sqlstore/migrator/spanner_dialect.go b/pkg/services/sqlstore/migrator/spanner_dialect.go
index c66032367ff..950aabf2f2a 100644
--- a/pkg/services/sqlstore/migrator/spanner_dialect.go
+++ b/pkg/services/sqlstore/migrator/spanner_dialect.go
@@ -7,7 +7,6 @@ import (
"encoding/json"
"errors"
"fmt"
- "strconv"
"time"
"cloud.google.com/go/spanner"
@@ -15,12 +14,10 @@ import (
"github.com/googleapis/gax-go/v2"
spannerdriver "github.com/googleapis/go-sql-spanner"
"github.com/grafana/dskit/concurrency"
- "google.golang.org/api/option"
- "google.golang.org/grpc"
"google.golang.org/grpc/codes"
- "google.golang.org/grpc/credentials/insecure"
"xorm.io/core"
+ spannerext "github.com/grafana/grafana/pkg/extensions/spanner"
"xorm.io/xorm"
_ "embed"
@@ -294,7 +291,7 @@ func (s *SpannerDialect) executeDDLStatements(ctx context.Context, engine *xorm.
return err
}
- opts := SpannerConnectorConfigToClientOptions(cfg)
+ opts := spannerext.SpannerConnectorConfigToClientOptions(cfg)
databaseAdminClient, err := database.NewDatabaseAdminClient(ctx, opts...)
if err != nil {
@@ -319,28 +316,6 @@ func (s *SpannerDialect) executeDDLStatements(ctx context.Context, engine *xorm.
return nil
}
-// SpannerConnectorConfigToClientOptions is adapted from https://github.com/googleapis/go-sql-spanner/blob/main/driver.go#L341-L477, from version 1.11.1.
-func SpannerConnectorConfigToClientOptions(connectorConfig spannerdriver.ConnectorConfig) []option.ClientOption {
- var opts []option.ClientOption
- if connectorConfig.Host != "" {
- opts = append(opts, option.WithEndpoint(connectorConfig.Host))
- }
- if strval, ok := connectorConfig.Params["credentials"]; ok {
- opts = append(opts, option.WithCredentialsFile(strval))
- }
- if strval, ok := connectorConfig.Params["credentialsjson"]; ok {
- opts = append(opts, option.WithCredentialsJSON([]byte(strval)))
- }
- if strval, ok := connectorConfig.Params["useplaintext"]; ok {
- if val, err := strconv.ParseBool(strval); err == nil && val {
- opts = append(opts,
- option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())),
- option.WithoutAuthentication())
- }
- }
- return opts
-}
-
func (s *SpannerDialect) UnionDistinct() string {
return "UNION DISTINCT"
}
diff --git a/pkg/services/sqlstore/permissions/dashboard_test.go b/pkg/services/sqlstore/permissions/dashboard_test.go
index 2f625298937..f26e923228f 100644
--- a/pkg/services/sqlstore/permissions/dashboard_test.go
+++ b/pkg/services/sqlstore/permissions/dashboard_test.go
@@ -760,9 +760,21 @@ func setupTest(t *testing.T, numFolders, numDashboards int, permissions []access
})
}
- _, err := sess.InsertMulti(&dashes)
- if err != nil {
- return err
+ // Insert dashboards in batches
+ batchSize := 500
+ if db.IsTestDBSpanner() {
+ batchSize = 30 // spanner has a limit of 950 parameters per query
+ }
+ for i := 0; i < len(dashes); i += batchSize {
+ end := i + batchSize
+ if end > len(dashes) {
+ end = len(dashes)
+ }
+
+ _, err := sess.InsertMulti(dashes[i:end])
+ if err != nil {
+ return err
+ }
}
role := &accesscontrol.Role{
@@ -772,7 +784,7 @@ func setupTest(t *testing.T, numFolders, numDashboards int, permissions []access
Updated: time.Now(),
Created: time.Now(),
}
- _, err = sess.Insert(role)
+ _, err := sess.Insert(role)
if err != nil {
return err
}
@@ -794,10 +806,18 @@ func setupTest(t *testing.T, numFolders, numDashboards int, permissions []access
permissions[i].Updated = time.Now()
permissions[i].Kind, permissions[i].Attribute, permissions[i].Identifier = permissions[i].SplitScope()
}
+
if len(permissions) > 0 {
- _, err = sess.InsertMulti(&permissions)
- if err != nil {
- return err
+ for i := 0; i < len(permissions); i += batchSize {
+ end := i + batchSize
+ if end > len(permissions) {
+ end = len(permissions)
+ }
+
+ _, err = sess.InsertMulti(permissions[i:end])
+ if err != nil {
+ return err
+ }
}
}
diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go
index a3029fffe77..10dcf1d0c14 100644
--- a/pkg/services/sqlstore/sqlstore.go
+++ b/pkg/services/sqlstore/sqlstore.go
@@ -628,6 +628,8 @@ func TestMain(m *testing.M) {
if err := testSQLStore.dialect.TruncateDBTables(testSQLStore.GetEngine()); err != nil {
return nil, err
}
+ testSQLStore.engine.ResetSequenceGenerator()
+
if err := testSQLStore.Reset(); err != nil {
return nil, err
}
diff --git a/pkg/services/sqlstore/sqlstore_testinfra.go b/pkg/services/sqlstore/sqlstore_testinfra.go
index fa7e1c47b3d..0a5c4b5db4f 100644
--- a/pkg/services/sqlstore/sqlstore_testinfra.go
+++ b/pkg/services/sqlstore/sqlstore_testinfra.go
@@ -183,6 +183,7 @@ func NewTestStore(tb TestingTB, opts ...TestOption) *SQLStore {
tb.Fatalf("failed to truncate DB tables after migrations: %v", err)
panic("unreachable")
}
+ testSQLStore.engine.ResetSequenceGenerator()
}
return store
diff --git a/pkg/services/ssosettings/strategies/saml_strategy.go b/pkg/services/ssosettings/strategies/saml_strategy.go
index 026fd5cef7f..a863b9f491e 100644
--- a/pkg/services/ssosettings/strategies/saml_strategy.go
+++ b/pkg/services/ssosettings/strategies/saml_strategy.go
@@ -32,43 +32,44 @@ func (s *SAMLStrategy) GetProviderConfig(_ context.Context, provider string) (ma
func (s *SAMLStrategy) loadSAMLSettings() map[string]any {
section := s.settingsProvider.Section("auth.saml")
result := map[string]any{
- "enabled": section.KeyValue("enabled").MustBool(false),
- "entity_id": section.KeyValue("entity_id").MustString(""),
- "name": section.KeyValue("name").MustString("SAML"),
- "single_logout": section.KeyValue("single_logout").MustBool(false),
- "allow_sign_up": section.KeyValue("allow_sign_up").MustBool(false),
- "auto_login": section.KeyValue("auto_login").MustBool(false),
- "certificate": section.KeyValue("certificate").MustString(""),
- "certificate_path": section.KeyValue("certificate_path").MustString(""),
- "private_key": section.KeyValue("private_key").MustString(""),
- "private_key_path": section.KeyValue("private_key_path").MustString(""),
- "signature_algorithm": section.KeyValue("signature_algorithm").MustString(""),
- "idp_metadata": section.KeyValue("idp_metadata").MustString(""),
- "idp_metadata_path": section.KeyValue("idp_metadata_path").MustString(""),
- "idp_metadata_url": section.KeyValue("idp_metadata_url").MustString(""),
- "max_issue_delay": section.KeyValue("max_issue_delay").MustDuration(90 * time.Second),
- "metadata_valid_duration": section.KeyValue("metadata_valid_duration").MustDuration(48 * time.Hour),
- "allow_idp_initiated": section.KeyValue("allow_idp_initiated").MustBool(false),
- "relay_state": section.KeyValue("relay_state").MustString(""),
- "assertion_attribute_name": section.KeyValue("assertion_attribute_name").MustString(""),
- "assertion_attribute_login": section.KeyValue("assertion_attribute_login").MustString(""),
- "assertion_attribute_email": section.KeyValue("assertion_attribute_email").MustString(""),
- "assertion_attribute_groups": section.KeyValue("assertion_attribute_groups").MustString(""),
- "assertion_attribute_role": section.KeyValue("assertion_attribute_role").MustString(""),
- "assertion_attribute_org": section.KeyValue("assertion_attribute_org").MustString(""),
- "allowed_organizations": section.KeyValue("allowed_organizations").MustString(""),
- "org_mapping": section.KeyValue("org_mapping").MustString(""),
- "role_values_none": section.KeyValue("role_values_none").MustString(""),
- "role_values_viewer": section.KeyValue("role_values_viewer").MustString(""),
- "role_values_editor": section.KeyValue("role_values_editor").MustString(""),
- "role_values_admin": section.KeyValue("role_values_admin").MustString(""),
- "role_values_grafana_admin": section.KeyValue("role_values_grafana_admin").MustString(""),
- "name_id_format": section.KeyValue("name_id_format").MustString(""),
- "skip_org_role_sync": section.KeyValue("skip_org_role_sync").MustBool(false),
- "client_id": section.KeyValue("client_id").MustString(""),
- "client_secret": section.KeyValue("client_secret").MustString(""),
- "token_url": section.KeyValue("token_url").MustString(""),
- "force_use_graph_api": section.KeyValue("force_use_graph_api").MustBool(false),
+ "allow_idp_initiated": section.KeyValue("allow_idp_initiated").MustBool(false),
+ "allow_sign_up": section.KeyValue("allow_sign_up").MustBool(false),
+ "allowed_organizations": section.KeyValue("allowed_organizations").MustString(""),
+ "assertion_attribute_email": section.KeyValue("assertion_attribute_email").MustString(""),
+ "assertion_attribute_groups": section.KeyValue("assertion_attribute_groups").MustString(""),
+ "assertion_attribute_login": section.KeyValue("assertion_attribute_login").MustString(""),
+ "assertion_attribute_name": section.KeyValue("assertion_attribute_name").MustString(""),
+ "assertion_attribute_org": section.KeyValue("assertion_attribute_org").MustString(""),
+ "assertion_attribute_role": section.KeyValue("assertion_attribute_role").MustString(""),
+ "auto_login": section.KeyValue("auto_login").MustBool(false),
+ "certificate": section.KeyValue("certificate").MustString(""),
+ "certificate_path": section.KeyValue("certificate_path").MustString(""),
+ "client_id": section.KeyValue("client_id").MustString(""),
+ "client_secret": section.KeyValue("client_secret").MustString(""),
+ "enabled": section.KeyValue("enabled").MustBool(false),
+ "entity_id": section.KeyValue("entity_id").MustString(""),
+ "external_uid_assertion_name": section.KeyValue("external_uid_assertion_name").MustString(""),
+ "force_use_graph_api": section.KeyValue("force_use_graph_api").MustBool(false),
+ "idp_metadata": section.KeyValue("idp_metadata").MustString(""),
+ "idp_metadata_path": section.KeyValue("idp_metadata_path").MustString(""),
+ "idp_metadata_url": section.KeyValue("idp_metadata_url").MustString(""),
+ "max_issue_delay": section.KeyValue("max_issue_delay").MustDuration(90 * time.Second),
+ "metadata_valid_duration": section.KeyValue("metadata_valid_duration").MustDuration(48 * time.Hour),
+ "name": section.KeyValue("name").MustString("SAML"),
+ "name_id_format": section.KeyValue("name_id_format").MustString(""),
+ "org_mapping": section.KeyValue("org_mapping").MustString(""),
+ "private_key": section.KeyValue("private_key").MustString(""),
+ "private_key_path": section.KeyValue("private_key_path").MustString(""),
+ "relay_state": section.KeyValue("relay_state").MustString(""),
+ "role_values_admin": section.KeyValue("role_values_admin").MustString(""),
+ "role_values_editor": section.KeyValue("role_values_editor").MustString(""),
+ "role_values_grafana_admin": section.KeyValue("role_values_grafana_admin").MustString(""),
+ "role_values_none": section.KeyValue("role_values_none").MustString(""),
+ "role_values_viewer": section.KeyValue("role_values_viewer").MustString(""),
+ "signature_algorithm": section.KeyValue("signature_algorithm").MustString(""),
+ "single_logout": section.KeyValue("single_logout").MustBool(false),
+ "skip_org_role_sync": section.KeyValue("skip_org_role_sync").MustBool(false),
+ "token_url": section.KeyValue("token_url").MustString(""),
}
return result
}
diff --git a/pkg/services/ssosettings/strategies/saml_strategy_test.go b/pkg/services/ssosettings/strategies/saml_strategy_test.go
index c04ef280318..507005a45b2 100644
--- a/pkg/services/ssosettings/strategies/saml_strategy_test.go
+++ b/pkg/services/ssosettings/strategies/saml_strategy_test.go
@@ -54,43 +54,44 @@ var (
`
expectedSAMLInfo = map[string]any{
- "enabled": true,
- "entity_id": "custom-entity-id",
- "single_logout": true,
- "allow_sign_up": true,
- "auto_login": true,
- "name": "SAML Test",
- "certificate": "devenv/docker/blocks/auth/saml-enterprise/cert.crt",
- "certificate_path": "/path/to/cert",
- "private_key": "dGhpcyBpcyBteSBwcml2YXRlIGtleSB0aGF0IEkgd2FudCB0byBnZXQgZW5jb2RlZCBpbiBiYXNlIDY0",
- "private_key_path": "devenv/docker/blocks/auth/saml-enterprise/key.pem",
- "signature_algorithm": "rsa-sha256",
- "idp_metadata": "dGhpcyBpcyBteSBwcml2YXRlIGtleSB0aGF0IEkgd2FudCB0byBnZXQgZW5jb2RlZCBpbiBiYXNlIDY0",
- "idp_metadata_path": "/path/to/metadata",
- "idp_metadata_url": "http://localhost:8086/realms/grafana/protocol/saml/descriptor",
- "max_issue_delay": 90 * time.Second,
- "metadata_valid_duration": 48 * time.Hour,
- "allow_idp_initiated": false,
- "relay_state": "relay_state",
- "assertion_attribute_name": "name",
- "assertion_attribute_login": "login",
- "assertion_attribute_email": "email",
- "assertion_attribute_groups": "groups",
- "assertion_attribute_role": "roles",
- "assertion_attribute_org": "orgs",
- "allowed_organizations": "org1 org2",
- "org_mapping": "org1:1:editor, *:2:viewer",
- "role_values_viewer": "viewer",
- "role_values_editor": "editor",
- "role_values_admin": "admin",
- "role_values_grafana_admin": "serveradmin",
- "name_id_format": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
- "skip_org_role_sync": false,
- "role_values_none": "guest disabled",
- "token_url": "http://localhost:8086/auth/realms/grafana/protocol/openid-connect/token",
- "client_id": "grafana",
- "client_secret": "grafana",
- "force_use_graph_api": false,
+ "enabled": true,
+ "entity_id": "custom-entity-id",
+ "external_uid_assertion_name": "",
+ "single_logout": true,
+ "allow_sign_up": true,
+ "auto_login": true,
+ "name": "SAML Test",
+ "certificate": "devenv/docker/blocks/auth/saml-enterprise/cert.crt",
+ "certificate_path": "/path/to/cert",
+ "private_key": "dGhpcyBpcyBteSBwcml2YXRlIGtleSB0aGF0IEkgd2FudCB0byBnZXQgZW5jb2RlZCBpbiBiYXNlIDY0",
+ "private_key_path": "devenv/docker/blocks/auth/saml-enterprise/key.pem",
+ "signature_algorithm": "rsa-sha256",
+ "idp_metadata": "dGhpcyBpcyBteSBwcml2YXRlIGtleSB0aGF0IEkgd2FudCB0byBnZXQgZW5jb2RlZCBpbiBiYXNlIDY0",
+ "idp_metadata_path": "/path/to/metadata",
+ "idp_metadata_url": "http://localhost:8086/realms/grafana/protocol/saml/descriptor",
+ "max_issue_delay": 90 * time.Second,
+ "metadata_valid_duration": 48 * time.Hour,
+ "allow_idp_initiated": false,
+ "relay_state": "relay_state",
+ "assertion_attribute_name": "name",
+ "assertion_attribute_login": "login",
+ "assertion_attribute_email": "email",
+ "assertion_attribute_groups": "groups",
+ "assertion_attribute_role": "roles",
+ "assertion_attribute_org": "orgs",
+ "allowed_organizations": "org1 org2",
+ "org_mapping": "org1:1:editor, *:2:viewer",
+ "role_values_viewer": "viewer",
+ "role_values_editor": "editor",
+ "role_values_admin": "admin",
+ "role_values_grafana_admin": "serveradmin",
+ "name_id_format": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
+ "skip_org_role_sync": false,
+ "role_values_none": "guest disabled",
+ "token_url": "http://localhost:8086/auth/realms/grafana/protocol/openid-connect/token",
+ "client_id": "grafana",
+ "client_secret": "grafana",
+ "force_use_graph_api": false,
}
)
diff --git a/pkg/services/user/usertest/fake.go b/pkg/services/user/usertest/fake.go
index 09a456d81d5..d0552d16d0f 100644
--- a/pkg/services/user/usertest/fake.go
+++ b/pkg/services/user/usertest/fake.go
@@ -20,6 +20,7 @@ type FakeUserService struct {
UpdateFn func(ctx context.Context, cmd *user.UpdateUserCommand) error
GetSignedInUserFn func(ctx context.Context, query *user.GetSignedInUserQuery) (*user.SignedInUser, error)
CreateFn func(ctx context.Context, cmd *user.CreateUserCommand) (*user.User, error)
+ GetByLoginFn func(ctx context.Context, query *user.GetUserByLoginQuery) (*user.User, error)
BatchDisableUsersFn func(ctx context.Context, cmd *user.BatchDisableUsersCommand) error
GetByEmailFn func(ctx context.Context, query *user.GetUserByEmailQuery) (*user.User, error)
@@ -59,6 +60,9 @@ func (f *FakeUserService) GetByUID(ctx context.Context, query *user.GetUserByUID
}
func (f *FakeUserService) GetByLogin(ctx context.Context, query *user.GetUserByLoginQuery) (*user.User, error) {
+ if f.GetByLoginFn != nil {
+ return f.GetByLoginFn(ctx, query)
+ }
return f.ExpectedUser, f.ExpectedError
}
diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go
index 815553d3813..59fe66d9175 100644
--- a/pkg/setting/setting.go
+++ b/pkg/setting/setting.go
@@ -181,6 +181,9 @@ type Cfg struct {
DataProxyWhiteList map[string]bool
ActionsAllowPostURL string
+ // K8s Dashboard Cleanup
+ K8sDashboardCleanup K8sDashboardCleanupSettings
+
TempDataLifetime time.Duration
// Plugins
@@ -1291,6 +1294,7 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error {
cfg.readDataSourcesSettings()
cfg.readDataSourceSecuritySettings()
+ cfg.readK8sDashboardCleanupSettings()
cfg.readSqlDataSourceSettings()
cfg.Storage = readStorageSettings(iniFile)
diff --git a/pkg/setting/setting_jwt.go b/pkg/setting/setting_jwt.go
index 2a559a145b7..18c7866cccf 100644
--- a/pkg/setting/setting_jwt.go
+++ b/pkg/setting/setting_jwt.go
@@ -1,6 +1,10 @@
package setting
-import "time"
+import (
+ "time"
+
+ "github.com/grafana/grafana/pkg/util"
+)
const (
extJWTAccessTokenExpectAudience = "grafana"
@@ -22,6 +26,8 @@ type AuthJWTSettings struct {
AutoSignUp bool
RoleAttributePath string
RoleAttributeStrict bool
+ OrgMapping []string
+ OrgAttributePath string
AllowAssignGrafanaAdmin bool
SkipOrgRoleSync bool
GroupsAttributePath string
@@ -71,6 +77,8 @@ func (cfg *Cfg) readAuthJWTSettings() {
jwtSettings.EmailAttributePath = valueAsString(authJWT, "email_attribute_path", "")
jwtSettings.UsernameAttributePath = valueAsString(authJWT, "username_attribute_path", "")
jwtSettings.TlsSkipVerify = authJWT.Key("tls_skip_verify_insecure").MustBool(false)
+ jwtSettings.OrgAttributePath = valueAsString(authJWT, "org_attribute_path", "")
+ jwtSettings.OrgMapping = util.SplitString(valueAsString(authJWT, "org_mapping", ""))
cfg.JWTAuth = jwtSettings
}
diff --git a/pkg/setting/setting_k8s_dashboard_cleanup.go b/pkg/setting/setting_k8s_dashboard_cleanup.go
new file mode 100644
index 00000000000..660de43c388
--- /dev/null
+++ b/pkg/setting/setting_k8s_dashboard_cleanup.go
@@ -0,0 +1,53 @@
+package setting
+
+import (
+ "time"
+)
+
+type K8sDashboardCleanupSettings struct {
+ Interval time.Duration
+ Timeout time.Duration
+ BatchSize int64
+}
+
+const (
+ defaultK8sDashboardCleanupInterval = 30 * time.Second
+ defaultK8sDashboardCleanupBatchSize = int64(10)
+ minK8sDashboardCleanupInterval = 10 * time.Second
+ minK8sDashboardCleanupTimeout = 5 * time.Second
+ minK8sDashboardCleanupBatchSize = int64(5)
+ maxK8sDashboardCleanupBatchSize = int64(200)
+)
+
+func (cfg *Cfg) readK8sDashboardCleanupSettings() {
+ section := cfg.Raw.Section("dashboard_cleanup")
+
+ // Read interval setting with validation
+ cleanupInterval := section.Key("interval").MustDuration(defaultK8sDashboardCleanupInterval)
+ if cleanupInterval < minK8sDashboardCleanupInterval {
+ cfg.Logger.Warn("[dashboard_cleanup.interval] is too low; the minimum allowed (10s) is enforced")
+ cleanupInterval = minK8sDashboardCleanupInterval
+ }
+
+ // Calculate timeout as 5 seconds less than interval, with minimum validation
+ cleanupTimeout := cleanupInterval - (5 * time.Second)
+ if cleanupTimeout < minK8sDashboardCleanupTimeout {
+ cleanupTimeout = minK8sDashboardCleanupTimeout
+ }
+
+ // Read batch size with validation
+ batchSize := section.Key("batch_size").MustInt64(defaultK8sDashboardCleanupBatchSize)
+ if batchSize < minK8sDashboardCleanupBatchSize {
+ cfg.Logger.Warn("[dashboard_cleanup.batch_size] is too low; the minimum allowed (5) is enforced")
+ batchSize = minK8sDashboardCleanupBatchSize
+ } else if batchSize > maxK8sDashboardCleanupBatchSize {
+ cfg.Logger.Warn("[dashboard_cleanup.batch_size] is too high; the maximum allowed (1000) is enforced")
+ batchSize = maxK8sDashboardCleanupBatchSize
+ }
+
+ cfg.K8sDashboardCleanup = K8sDashboardCleanupSettings{
+ Interval: cleanupInterval,
+ Timeout: cleanupTimeout,
+ BatchSize: batchSize,
+ }
+}
diff --git a/pkg/setting/setting_unified_alerting.go b/pkg/setting/setting_unified_alerting.go
index b14d22b97bc..84461d3c82f 100644
--- a/pkg/setting/setting_unified_alerting.go
+++ b/pkg/setting/setting_unified_alerting.go
@@ -112,6 +112,7 @@ type UnifiedAlertingSettings struct {
StateHistory UnifiedAlertingStateHistorySettings
RemoteAlertmanager RemoteAlertmanagerSettings
RecordingRules RecordingRuleSettings
+ PrometheusConversion UnifiedAlertingPrometheusConversionSettings
// MaxStateSaveConcurrency controls the number of goroutines (per rule) that can save alert state in parallel.
MaxStateSaveConcurrency int
@@ -165,6 +166,12 @@ type UnifiedAlertingReservedLabelSettings struct {
DisabledLabels map[string]struct{}
}
+// UnifiedAlertingPrometheusConversionSettings contains configuration for converting Prometheus rules to Grafana format
+type UnifiedAlertingPrometheusConversionSettings struct {
+ // RuleQueryOffset defines a time offset to apply to rule queries during conversion from Prometheus to Grafana format
+ RuleQueryOffset time.Duration
+}
+
type UnifiedAlertingStateHistorySettings struct {
Enabled bool
Backend string
@@ -437,6 +444,11 @@ func (cfg *Cfg) ReadUnifiedAlertingSettings(iniFile *ini.File) error {
}
uaCfg.StateHistory = uaCfgStateHistory
+ prometheusConversion := iniFile.Section("unified_alerting.prometheus_conversion")
+ uaCfg.PrometheusConversion = UnifiedAlertingPrometheusConversionSettings{
+ RuleQueryOffset: prometheusConversion.Key("rule_query_offset").MustDuration(time.Minute),
+ }
+
rr := iniFile.Section("recording_rules")
uaCfgRecordingRules := RecordingRuleSettings{
Enabled: rr.Key("enabled").MustBool(false),
diff --git a/pkg/storage/legacysql/dualwrite/dualwriter.go b/pkg/storage/legacysql/dualwrite/dualwriter.go
index c34609cc9f5..7e3c955fa26 100644
--- a/pkg/storage/legacysql/dualwrite/dualwriter.go
+++ b/pkg/storage/legacysql/dualwrite/dualwriter.go
@@ -45,13 +45,12 @@ func (d *dualWriter) Get(ctx context.Context, name string, options *metav1.GetOp
// Once we have successfully read from legacy, we can check if we want to fail on a unified read.
// If we allow the unified read to fail, we can do it in the background.
if d.errorIsOK {
- go func() {
- ctxBg, cancel := context.WithTimeout(context.Background(), backgroundReqTimeout)
+ go func(ctxBg context.Context, cancel context.CancelFunc) {
defer cancel()
if _, err := d.unified.Get(ctxBg, name, options); err != nil {
d.log.Error("failed background GET to unified", "err", err)
}
- }()
+ }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout))
return legacyGet, nil
}
// If it's not okay to fail, we have to check it in the foreground.
@@ -75,13 +74,12 @@ func (d *dualWriter) List(ctx context.Context, options *metainternalversion.List
// Once we have successfully listed from legacy, we can check if we want to fail on a unified list.
// If we allow the unified list to fail, we can do it in the background and return.
if d.errorIsOK {
- go func() {
- ctxBg, cancel := context.WithTimeout(context.Background(), backgroundReqTimeout)
+ go func(ctxBg context.Context, cancel context.CancelFunc) {
defer cancel()
if _, err := d.unified.List(ctxBg, options); err != nil {
d.log.Error("failed background LIST to unified", "err", err)
}
- }()
+ }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout))
return legacyList, nil
}
// If it's not okay to fail, we have to check it in the foreground.
@@ -139,13 +137,12 @@ func (d *dualWriter) Create(ctx context.Context, in runtime.Object, createValida
return storageObj, nil
} else if d.errorIsOK {
// If we don't use unified as the primary store and errors are okay, let's create it in the background.
- go func() {
- ctxBg, cancel := context.WithTimeout(context.Background(), backgroundReqTimeout)
+ go func(ctxBg context.Context, cancel context.CancelFunc) {
defer cancel()
if _, err := d.unified.Create(ctxBg, createdCopy, createValidation, options); err != nil {
log.Error("unable to create object in unified storage", "err", err)
}
- }()
+ }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout))
} else {
// Otherwise let's create it in the foreground and return any error.
if _, err := d.unified.Create(ctx, createdCopy, createValidation, options); err != nil {
@@ -185,14 +182,13 @@ func (d *dualWriter) Delete(ctx context.Context, name string, deleteValidation r
return objFromStorage, asyncStorage, nil
} else if d.errorIsOK {
// If errors are okay and unified is not primary, we can just run it as background operation.
- go func() {
- ctxBg, cancel := context.WithTimeout(context.Background(), backgroundReqTimeout)
+ go func(ctxBg context.Context, cancel context.CancelFunc) {
defer cancel()
_, _, err := d.unified.Delete(ctxBg, name, deleteValidation, options)
if err != nil && !apierrors.IsNotFound(err) && !d.errorIsOK {
d.log.Error("failed background DELETE in unified storage", "err", err)
}
- }()
+ }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout))
}
// Otherwise we just run it in the foreground and return an error if any might happen.
_, _, err = d.unified.Delete(ctx, name, deleteValidation, options)
@@ -226,13 +222,12 @@ func (d *dualWriter) Update(ctx context.Context, name string, objInfo rest.Updat
return d.unified.Update(ctx, name, objInfo, createValidation, updateValidation, forceAllowCreate, options)
} else if d.errorIsOK {
// If unified is not primary, but errors are okay, we can just run in the background.
- go func() {
- ctxBg, cancel := context.WithTimeout(context.Background(), backgroundReqTimeout)
+ go func(ctxBg context.Context, cancel context.CancelFunc) {
defer cancel()
if _, _, err := d.unified.Update(ctxBg, name, objInfo, createValidation, updateValidation, forceAllowCreate, options); err != nil {
log.Error("failed background UPDATE to unified storage", "err", err)
}
- }()
+ }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout))
return objFromLegacy, createdLegacy, nil
}
// If we want to check unified errors just run it in foreground.
@@ -263,13 +258,12 @@ func (d *dualWriter) DeleteCollection(ctx context.Context, deleteValidation rest
return d.unified.DeleteCollection(ctx, deleteValidation, options, listOptions)
} else if d.errorIsOK {
// If unified storage is not the primary store and errors are okay, we can just run it in the background.
- go func() {
- ctxBg, cancel := context.WithTimeout(context.Background(), backgroundReqTimeout)
+ go func(ctxBg context.Context, cancel context.CancelFunc) {
defer cancel()
if _, err := d.unified.DeleteCollection(ctxBg, deleteValidation, options, listOptions); err != nil {
log.Error("failed background DELETE collection to unified storage", "err", err)
}
- }()
+ }(context.WithTimeout(context.WithoutCancel(ctx), backgroundReqTimeout))
return deletedLegacy, nil
}
// Otherwise we have to check the error and run it in the foreground.
diff --git a/pkg/storage/unified/README.md b/pkg/storage/unified/README.md
index f08da73b33a..8efde1c2c91 100644
--- a/pkg/storage/unified/README.md
+++ b/pkg/storage/unified/README.md
@@ -253,23 +253,24 @@ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
- make changes in `.proto` file
- to compile all protobuf files in the repository run `make protobuf` at its top level
-## Setting up search (EXPERIMENTAL)
-Unified storage now exposes an **experimental** search API. It can be used to search for specific resources, or to filter/query resources.
+## Setting up search
To enable it, add the following to your `custom.ini` under the `[feature_toggles]` section:
```ini
[feature_toggles]
+; Used by the Grafana instance
+unifiedStorageSearchUI = true
+kubernetesClientDashboardsFolders = true
+
+; Used by unified storage
unifiedStorageSearch = true
+; (optional) Allows you to sort dashboards by usage insights fields when using enterprise
+; unifiedStorageSearchSprinkles = true
+; (optional) Will skip search results filter based on user permissions
+; unifiedStorageSearchPermissionFiltering = false
```
-To access the api through Grafana, go to Explore -> Query Type -> Search.
+The dashboard search page has been set up to search unified storage. Additionally, all legacy search calls (e.g. `/api/search`) will go to
+unified storage when the dual writer mode is set to 3 or greater. When <= 2, the legacy search api calls will go to legacy storage.
-The query needs to be a valid [Bleve query string](https://blevesearch.com/docs/Query-String-Query/).
-
-Some example queries are:
-- `*` - returns all objects
-- `Kind:Playlist` - returns all playlists
-- `Spec.inveral:5m` - returns all objects with the spec.inverval field set to 5m
-- `+Kind:Playlist +Spec.title:p4` - returns all playlists with the title matching "p4"
-- `*foo*` - returns all objects containing "foo" in any field
-- `CreatedAt:>="2024-10-17"` - returns all objects created after 2024-10-17
-- `+CreatedAt:>="2024-10-17" +Kind:Playlist` - returns all playlists created after 2024-10-17
+## Running load tests
+Load tests and instructions can be found [here](https://github.com/grafana/grafana-api-tests/tree/main/simulation/src/unified_storage).
diff --git a/pkg/storage/unified/apistore/go.mod b/pkg/storage/unified/apistore/go.mod
index 14dc81193b1..c6b1c75bfda 100644
--- a/pkg/storage/unified/apistore/go.mod
+++ b/pkg/storage/unified/apistore/go.mod
@@ -51,7 +51,7 @@ require (
github.com/Azure/go-autorest/autorest/to v0.4.0 // indirect
github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.3.2 // indirect
- github.com/BurntSushi/toml v1.4.0 // indirect
+ github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.2 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.49.0 // indirect
@@ -149,7 +149,7 @@ require (
github.com/emirpasic/gods v1.18.1 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.32.3 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
- github.com/fatih/color v1.17.0 // indirect
+ github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/fullstorydev/grpchan v1.1.1 // indirect
diff --git a/pkg/storage/unified/apistore/go.sum b/pkg/storage/unified/apistore/go.sum
index a74e2346a1f..d2ab1f07f14 100644
--- a/pkg/storage/unified/apistore/go.sum
+++ b/pkg/storage/unified/apistore/go.sum
@@ -649,8 +649,9 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mo
github.com/AzureAD/microsoft-authentication-library-for-go v1.3.2 h1:kYRSnvJju5gYVyhkij+RTJ/VR6QIUaCfWeaFm2ycsjQ=
github.com/AzureAD/microsoft-authentication-library-for-go v1.3.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
+github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
@@ -975,8 +976,8 @@ github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2T
github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U=
github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
-github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
-github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
+github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
+github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
diff --git a/pkg/storage/unified/apistore/prepare.go b/pkg/storage/unified/apistore/prepare.go
index c9677fd0763..e6d9458ebfb 100644
--- a/pkg/storage/unified/apistore/prepare.go
+++ b/pkg/storage/unified/apistore/prepare.go
@@ -9,13 +9,14 @@ import (
"time"
"github.com/google/uuid"
+ apiequality "k8s.io/apimachinery/pkg/api/equality"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apiserver/pkg/storage"
"k8s.io/klog/v2"
authtypes "github.com/grafana/authlib/types"
-
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/storage/unified/resource"
)
@@ -57,6 +58,9 @@ func (s *Storage) prepareObjectForStorage(ctx context.Context, newObject runtime
if obj.GetUID() == "" {
obj.SetUID(types.UID(uuid.NewString()))
}
+ if obj.GetFolder() != "" && !s.opts.EnableFolderSupport {
+ return nil, apierrors.NewBadRequest(fmt.Sprintf("folders are not supported for: %s", s.gr.String()))
+ }
if s.opts.RequireDeprecatedInternalID {
// nolint:staticcheck
@@ -77,6 +81,7 @@ func (s *Storage) prepareObjectForStorage(ctx context.Context, newObject runtime
obj.SetUpdatedBy("")
obj.SetUpdatedTimestamp(nil)
obj.SetCreatedBy(info.GetUID())
+ obj.SetGeneration(1) // the first time we write
var buf bytes.Buffer
if err = s.codec.Encode(newObject, &buf); err != nil {
@@ -131,8 +136,35 @@ func (s *Storage) prepareObjectForUpdate(ctx context.Context, updateObject runti
obj.SetDeprecatedInternalID(previousInternalID) // nolint:staticcheck
}
- obj.SetUpdatedBy(info.GetUID())
- obj.SetUpdatedTimestampMillis(time.Now().UnixMilli())
+ // Check if we should bump the generation
+ changed := obj.GetFolder() != previous.GetFolder()
+ if changed {
+ if !s.opts.EnableFolderSupport {
+ return nil, apierrors.NewBadRequest(fmt.Sprintf("folders are not supported for: %s", s.gr.String()))
+ }
+ // TODO: check that we can move the folder?
+ } else if obj.GetDeletionTimestamp() != nil && previous.GetDeletionTimestamp() == nil {
+ changed = true // bump generation when deleted
+ } else {
+ spec, e1 := obj.GetSpec()
+ oldSpec, e2 := previous.GetSpec()
+ if e1 == nil && e2 == nil {
+ if !apiequality.Semantic.DeepEqual(spec, oldSpec) {
+ changed = true
+ }
+ }
+ }
+
+ // Mark the resource as changed
+ if changed {
+ obj.SetGeneration(previous.GetGeneration() + 1)
+ obj.SetUpdatedBy(info.GetUID())
+ obj.SetUpdatedTimestampMillis(time.Now().UnixMilli())
+ } else {
+ obj.SetGeneration(previous.GetGeneration())
+ obj.SetAnnotation(utils.AnnoKeyUpdatedBy, previous.GetAnnotation(utils.AnnoKeyUpdatedBy))
+ obj.SetAnnotation(utils.AnnoKeyUpdatedTimestamp, previous.GetAnnotation(utils.AnnoKeyUpdatedTimestamp))
+ }
var buf bytes.Buffer
if err = s.codec.Encode(updateObject, &buf); err != nil {
diff --git a/pkg/storage/unified/apistore/prepare_test.go b/pkg/storage/unified/apistore/prepare_test.go
index 1bb9c06cd04..1c17a9806c2 100644
--- a/pkg/storage/unified/apistore/prepare_test.go
+++ b/pkg/storage/unified/apistore/prepare_test.go
@@ -9,6 +9,8 @@ import (
"github.com/stretchr/testify/require"
"golang.org/x/exp/rand"
"k8s.io/apimachinery/pkg/api/apitesting"
+ v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apiserver/pkg/storage"
@@ -30,11 +32,14 @@ func TestPrepareObjectForStorage(t *testing.T) {
codec: apitesting.TestCodec(codecs, v0alpha1.DashboardResourceInfo.GroupVersion()),
snowflake: node,
opts: StorageOptions{
- LargeObjectSupport: nil,
+ EnableFolderSupport: true,
+ LargeObjectSupport: nil,
},
}
- ctx := authtypes.WithAuthInfo(context.Background(), &identity.StaticRequester{UserID: 1, UserUID: "user-uid", Type: authtypes.TypeUser})
+ ctx := authtypes.WithAuthInfo(context.Background(),
+ &identity.StaticRequester{UserID: 1, UserUID: "user-uid", Type: authtypes.TypeUser},
+ )
t.Run("Error getting auth info from context", func(t *testing.T) {
_, err := s.prepareObjectForStorage(context.Background(), nil)
@@ -81,7 +86,7 @@ func TestPrepareObjectForStorage(t *testing.T) {
require.Empty(t, updatedTS)
})
- t.Run("Should keep repo info", func(t *testing.T) {
+ t.Run("Should keep manager info", func(t *testing.T) {
dashboard := v0alpha1.Dashboard{}
dashboard.Name = "test-name"
obj := dashboard.DeepCopyObject()
@@ -117,6 +122,65 @@ func TestPrepareObjectForStorage(t *testing.T) {
require.Equal(t, s.TimestampMillis, now.UnixMilli())
})
+ t.Run("Update should manage incrementing generation and metadata", func(t *testing.T) {
+ dashboard := v0alpha1.Dashboard{}
+ dashboard.Name = "test-name"
+ obj := dashboard.DeepCopyObject()
+ meta, err := utils.MetaAccessor(obj)
+ meta.SetFolder("aaa")
+ require.NoError(t, err)
+
+ encodedData, err := s.prepareObjectForStorage(ctx, obj)
+ require.NoError(t, err)
+
+ insertedObject, _, err := s.codec.Decode(encodedData, nil, &v0alpha1.Dashboard{})
+ require.NoError(t, err)
+ meta, err = utils.MetaAccessor(insertedObject)
+ require.NoError(t, err)
+ require.Equal(t, int64(1), meta.GetGeneration())
+ require.Equal(t, "user:user-uid", meta.GetCreatedBy())
+ require.Equal(t, "", meta.GetUpdatedBy()) // empty
+ ts, err := meta.GetUpdatedTimestamp()
+ require.NoError(t, err)
+ require.Nil(t, ts)
+
+ // Change the user... and only update metadata
+ ctx = authtypes.WithAuthInfo(context.Background(),
+ &identity.StaticRequester{UserID: 1, UserUID: "user2", Type: authtypes.TypeUser},
+ )
+
+ // Change the status... but generation is the same
+ updatedObject := insertedObject.DeepCopyObject()
+ meta, err = utils.MetaAccessor(updatedObject)
+ require.NoError(t, err)
+ err = meta.SetStatus(v0alpha1.DashboardStatus{
+ Conversion: &v0alpha1.DashboardConversionStatus{
+ Failed: true,
+ Error: "test",
+ },
+ })
+ require.NoError(t, err)
+ meta.SetGeneration(123) // will be removed
+
+ // Update status without changing generation or update metadata
+ _, err = s.prepareObjectForUpdate(ctx, updatedObject, insertedObject)
+ require.NoError(t, err)
+ require.Equal(t, "", meta.GetUpdatedBy())
+ require.Equal(t, int64(1), meta.GetGeneration())
+
+ // Change the folder -- the generation should increase and the updatedBy metadata
+ dashboard2 := &v0alpha1.Dashboard{ObjectMeta: v1.ObjectMeta{
+ Name: dashboard.Name,
+ }} // TODO... deep copy, See: https://github.com/grafana/grafana/pull/102258
+ meta2, err := utils.MetaAccessor(dashboard2)
+ require.NoError(t, err)
+ meta2.SetFolder("xyz") // will bump generation
+ _, err = s.prepareObjectForUpdate(ctx, dashboard2, updatedObject)
+ require.NoError(t, err)
+ require.Equal(t, "user:user2", meta2.GetUpdatedBy())
+ require.Equal(t, int64(2), meta2.GetGeneration())
+ })
+
s.opts.RequireDeprecatedInternalID = true
t.Run("Should generate internal id", func(t *testing.T) {
dashboard := v0alpha1.Dashboard{}
@@ -149,4 +213,87 @@ func TestPrepareObjectForStorage(t *testing.T) {
require.NoError(t, err)
require.Equal(t, meta.GetDeprecatedInternalID(), int64(1)) // nolint:staticcheck
})
+
+ t.Run("calculate generation", func(t *testing.T) {
+ dash := &v0alpha1.Dashboard{
+ ObjectMeta: v1.ObjectMeta{
+ Name: "test",
+ },
+ Spec: v0alpha1.DashboardSpec{
+ Object: map[string]interface{}{
+ "hello": "world",
+ },
+ },
+ }
+ out := getPreparedObject(t, ctx, s, dash, nil)
+ require.Equal(t, int64(1), out.GetGeneration())
+ require.NotEmpty(t, out.GetAnnotation(utils.AnnoKeyCreatedBy))
+ require.Equal(t, "", out.GetAnnotation(utils.AnnoKeyUpdatedBy))
+ require.Equal(t, "", out.GetAnnotation(utils.AnnoKeyUpdatedTimestamp))
+
+ t.Run("increment when the spec changes", func(t *testing.T) {
+ b := dash.DeepCopy()
+ b.Spec.Object["x"] = "y"
+ out = getPreparedObject(t, ctx, s, b, dash)
+ require.Equal(t, int64(2), out.GetGeneration())
+ require.NotEmpty(t, out.GetAnnotation(utils.AnnoKeyUpdatedBy))
+ require.NotEmpty(t, out.GetAnnotation(utils.AnnoKeyUpdatedTimestamp))
+ })
+
+ t.Run("increment when the folder changes", func(t *testing.T) {
+ b := dash.DeepCopy()
+ b.Annotations = map[string]string{
+ utils.AnnoKeyFolder: "abc",
+ }
+ out = getPreparedObject(t, ctx, s, b, dash)
+ require.Equal(t, int64(2), out.GetGeneration())
+ })
+
+ t.Run("increment when deleted", func(t *testing.T) {
+ now := v1.Now()
+ b := dash.DeepCopy()
+ b.DeletionTimestamp = &now
+ out = getPreparedObject(t, ctx, s, b, dash)
+ require.Equal(t, int64(2), out.GetGeneration())
+ })
+
+ t.Run("keep when status, labels, or annotations change", func(t *testing.T) {
+ b := dash.DeepCopy()
+ b.Annotations = map[string]string{
+ "x": "hello",
+ }
+ b.Labels = map[string]string{
+ "a": "b",
+ }
+ b.Status = v0alpha1.DashboardStatus{
+ Conversion: &v0alpha1.DashboardConversionStatus{
+ Failed: true,
+ },
+ }
+ out = getPreparedObject(t, ctx, s, b, dash)
+ require.Equal(t, int64(1), out.GetGeneration()) // still 1
+ })
+ })
+}
+
+func getPreparedObject(t *testing.T, ctx context.Context, s *Storage, obj runtime.Object, old runtime.Object) utils.GrafanaMetaAccessor {
+ t.Helper()
+
+ var raw []byte
+ var err error
+
+ if old == nil {
+ raw, err = s.prepareObjectForStorage(ctx, obj)
+ } else {
+ raw, err = s.prepareObjectForUpdate(ctx, obj, old)
+ }
+ require.NoError(t, err)
+
+ out := &unstructured.Unstructured{}
+ err = out.UnmarshalJSON(raw)
+ require.NoError(t, err)
+
+ meta, err := utils.MetaAccessor(out)
+ require.NoError(t, err)
+ return meta
}
diff --git a/pkg/storage/unified/apistore/store.go b/pkg/storage/unified/apistore/store.go
index 69fc83f803c..4557c0a5d39 100644
--- a/pkg/storage/unified/apistore/store.go
+++ b/pkg/storage/unified/apistore/store.go
@@ -47,8 +47,14 @@ var _ storage.Interface = (*Storage)(nil)
// Optional settings that apply to a single resource
type StorageOptions struct {
+ // ????: should we constrain this to only dashboards for now?
+ // Not yet clear if this is a good general solution, or just a stop-gap
LargeObjectSupport LargeObjectSupport
+ // Allow writing objects with metadata.annotations[grafana.app/folder]
+ EnableFolderSupport bool
+
+ // Add internalID label when missing
RequireDeprecatedInternalID bool
}
@@ -445,11 +451,11 @@ func (s *Storage) GuaranteedUpdate(
cachedExistingObject runtime.Object,
) error {
var (
- res storage.ResponseMeta
- updatedObj runtime.Object
- existingObj runtime.Object
- created bool
- err error
+ res storage.ResponseMeta
+ updatedObj runtime.Object
+ existingObj runtime.Object
+ existingBytes []byte
+ err error
)
req := &resource.UpdateRequest{}
req.Key, err = s.getKey(key)
@@ -465,61 +471,71 @@ func (s *Storage) GuaranteedUpdate(
for attempt := 1; attempt <= MaxUpdateAttempts; attempt = attempt + 1 {
// Read the latest value
- rsp, err := s.store.Read(ctx, &resource.ReadRequest{Key: req.Key})
+ readResponse, err := s.store.Read(ctx, &resource.ReadRequest{Key: req.Key})
if err != nil {
return resource.GetError(resource.AsErrorResult(err))
}
- if rsp.Error != nil {
- if rsp.Error.Code == http.StatusNotFound {
+ if readResponse.Error != nil {
+ if readResponse.Error.Code == http.StatusNotFound {
if !ignoreNotFound {
return apierrors.NewNotFound(s.gr, req.Key.Name)
}
} else {
- return resource.GetError(rsp.Error)
+ return resource.GetError(readResponse.Error)
}
}
- created = true
- existingObj = s.newFunc()
- if len(rsp.Value) > 0 {
- created = false
- _, err = s.convertToObject(rsp.Value, existingObj)
+ // Upsert? (create because it does not already exist)
+ if len(readResponse.Value) == 0 {
+ if !ignoreNotFound {
+ return apierrors.NewNotFound(s.gr, req.Key.Name)
+ }
+
+ updatedObj, _, err = tryUpdate(s.newFunc(), res)
if err != nil {
- return err
- }
-
- mmm, err := utils.MetaAccessor(existingObj)
- if err != nil {
- return err
- }
- mmm.SetResourceVersionInt64(rsp.ResourceVersion)
- res.ResourceVersion = uint64(rsp.ResourceVersion)
-
- if rest.IsDualWriteUpdate(ctx) {
- // Ignore the RV when updating legacy values
- mmm.SetResourceVersion("")
- } else {
- if err := preconditions.Check(key, existingObj); err != nil {
- if attempt >= MaxUpdateAttempts {
- return fmt.Errorf("precondition failed: %w", err)
- }
- continue
- }
- }
-
- // restore the full original object before tryUpdate
- if s.opts.LargeObjectSupport != nil && mmm.GetBlob() != nil {
- err = s.opts.LargeObjectSupport.Reconstruct(ctx, req.Key, s.store, mmm)
- if err != nil {
+ if attempt >= MaxUpdateAttempts {
return err
}
+ continue
}
- } else if !ignoreNotFound {
- return apierrors.NewNotFound(s.gr, req.Key.Name)
+ return s.Create(ctx, key, updatedObj, destination, 0)
}
- updatedObj, _, err = tryUpdate(existingObj.DeepCopyObject(), res)
+ existingBytes = readResponse.Value
+ existingObj, err = s.convertToObject(readResponse.Value, s.newFunc())
+ if err != nil {
+ return err
+ }
+
+ existing, err := utils.MetaAccessor(existingObj)
+ if err != nil {
+ return err
+ }
+ existing.SetResourceVersionInt64(readResponse.ResourceVersion)
+ res.ResourceVersion = uint64(readResponse.ResourceVersion)
+
+ if rest.IsDualWriteUpdate(ctx) {
+ // Ignore the RV when updating legacy values
+ existing.SetResourceVersion("")
+ } else {
+ if err := preconditions.Check(key, existingObj); err != nil {
+ if attempt >= MaxUpdateAttempts {
+ return fmt.Errorf("precondition failed: %w", err)
+ }
+ continue
+ }
+ }
+
+ // restore the full original object before tryUpdate
+ if s.opts.LargeObjectSupport != nil && existing.GetBlob() != nil {
+ err = s.opts.LargeObjectSupport.Reconstruct(ctx, req.Key, s.store, existing)
+ if err != nil {
+ return err
+ }
+ }
+
+ updatedObj, _, err = tryUpdate(existingObj, res)
if err != nil {
if attempt >= MaxUpdateAttempts {
return err
@@ -529,64 +545,32 @@ func (s *Storage) GuaranteedUpdate(
break
}
- unchanged, err := isUnchanged(s.codec, existingObj, updatedObj)
+ req.Value, err = s.prepareObjectForUpdate(ctx, updatedObj, existingObj)
if err != nil {
return err
}
- if unchanged {
- var buf bytes.Buffer
- if err = s.codec.Encode(updatedObj, &buf); err != nil {
- return err
- }
- if _, err := s.convertToObject(buf.Bytes(), destination); err != nil {
- return err
- }
- return nil
- }
-
- var (
- value []byte
- rv int64
- )
- if created {
- value, err = s.prepareObjectForStorage(ctx, updatedObj)
- if err != nil {
- return err
- }
- rsp2, err := s.store.Create(ctx, &resource.CreateRequest{
- Key: req.Key,
- Value: value,
- })
+ var rv uint64
+ // Only update (for real) if the bytes have changed
+ if !bytes.Equal(req.Value, existingBytes) {
+ updateResponse, err := s.store.Update(ctx, req)
if err != nil {
return resource.GetError(resource.AsErrorResult(err))
}
- if rsp2.Error != nil {
- return resource.GetError(rsp2.Error)
+ if updateResponse.Error != nil {
+ return resource.GetError(updateResponse.Error)
}
- rv = rsp2.ResourceVersion
- } else {
- value, err = s.prepareObjectForUpdate(ctx, updatedObj, existingObj)
- if err != nil {
+ rv = uint64(updateResponse.ResourceVersion)
+ }
+
+ if _, err := s.convertToObject(req.Value, destination); err != nil {
+ return err
+ }
+
+ if rv > 0 {
+ if err := s.versioner.UpdateObject(destination, rv); err != nil {
return err
}
- req.Value = value
- rsp2, err := s.store.Update(ctx, req)
- if err != nil {
- return resource.GetError(resource.AsErrorResult(err))
- }
- if rsp2.Error != nil {
- return resource.GetError(rsp2.Error)
- }
- rv = rsp2.ResourceVersion
- }
-
- if _, err := s.convertToObject(value, destination); err != nil {
- return err
- }
-
- if err := s.versioner.UpdateObject(destination, uint64(rv)); err != nil {
- return err
}
return nil
diff --git a/pkg/storage/unified/apistore/util.go b/pkg/storage/unified/apistore/util.go
index a716bd77024..44ed68d0b68 100644
--- a/pkg/storage/unified/apistore/util.go
+++ b/pkg/storage/unified/apistore/util.go
@@ -6,13 +6,11 @@
package apistore
import (
- "bytes"
"fmt"
"strconv"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/selection"
"k8s.io/apiserver/pkg/storage"
@@ -40,10 +38,12 @@ func toListRequest(k *resource.ResourceKey, opts storage.ListOptions) (*resource
}
switch opts.ResourceVersionMatch {
- case "", metav1.ResourceVersionMatchNotOlderThan:
- req.VersionMatch = resource.ResourceVersionMatch_NotOlderThan
+ case "":
+ req.VersionMatchV2 = resource.ResourceVersionMatchV2_Unset
+ case metav1.ResourceVersionMatchNotOlderThan:
+ req.VersionMatchV2 = resource.ResourceVersionMatchV2_NotOlderThan
case metav1.ResourceVersionMatchExact:
- req.VersionMatch = resource.ResourceVersionMatch_Exact
+ req.VersionMatchV2 = resource.ResourceVersionMatchV2_Exact
default:
return nil, predicate, apierrors.NewBadRequest(
fmt.Sprintf("unsupported version match: %v", opts.ResourceVersionMatch),
@@ -112,17 +112,3 @@ func toListRequest(k *resource.ResourceKey, opts storage.ListOptions) (*resource
return req, predicate, nil
}
-
-func isUnchanged(codec runtime.Codec, obj runtime.Object, newObj runtime.Object) (bool, error) {
- buf := new(bytes.Buffer)
- if err := codec.Encode(obj, buf); err != nil {
- return false, err
- }
-
- newBuf := new(bytes.Buffer)
- if err := codec.Encode(newObj, newBuf); err != nil {
- return false, err
- }
-
- return bytes.Equal(buf.Bytes(), newBuf.Bytes()), nil
-}
diff --git a/pkg/storage/unified/client.go b/pkg/storage/unified/client.go
index c16fbc92ad3..9239b260deb 100644
--- a/pkg/storage/unified/client.go
+++ b/pkg/storage/unified/client.go
@@ -117,10 +117,31 @@ func newClient(opts options.StorageOptions,
return nil, fmt.Errorf("expecting address for storage_type: %s", opts.StorageType)
}
- // Create a connection to the gRPC server.
- conn, err := GrpcConn(opts.Address, reg)
- if err != nil {
- return nil, err
+ var (
+ conn grpc.ClientConnInterface
+ err error
+ metrics = newClientMetrics(reg)
+ )
+ // Create either a connection pool or a single connection.
+ // The connection pool __can__ be useful when connection to
+ // server side load balancers like kube-proxy.
+ if features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageGrpcConnectionPool) {
+ conn, err = newPooledConn(&poolOpts{
+ initialCapacity: 3,
+ maxCapacity: 6,
+ idleTimeout: time.Minute,
+ factory: func() (*grpc.ClientConn, error) {
+ return grpcConn(opts.Address, metrics)
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ conn, err = grpcConn(opts.Address, metrics)
+ if err != nil {
+ return nil, err
+ }
}
// Create a client instance
@@ -144,7 +165,7 @@ func newClient(opts options.StorageOptions,
}
}
-func newResourceClient(conn *grpc.ClientConn, cfg *setting.Cfg, features featuremgmt.FeatureToggles, tracer tracing.Tracer) (resource.ResourceClient, error) {
+func newResourceClient(conn grpc.ClientConnInterface, cfg *setting.Cfg, features featuremgmt.FeatureToggles, tracer tracing.Tracer) (resource.ResourceClient, error) {
if !features.IsEnabledGlobally(featuremgmt.FlagAppPlatformGrpcClientAuth) {
return resource.NewLegacyResourceClient(conn), nil
}
@@ -160,22 +181,8 @@ func newResourceClient(conn *grpc.ClientConn, cfg *setting.Cfg, features feature
})
}
-// GrpcConn creates a new gRPC connection to the provided address.
-func GrpcConn(address string, reg prometheus.Registerer) (*grpc.ClientConn, error) {
- // This works for now as the Provide function is only called once during startup.
- // We might eventually want to tight this factory to a struct for more runtime control.
- metrics := clientMetrics{
- requestDuration: promauto.With(reg).NewHistogramVec(prometheus.HistogramOpts{
- Name: "resource_server_client_request_duration_seconds",
- Help: "Time spent executing requests to the resource server.",
- Buckets: prometheus.ExponentialBuckets(0.008, 4, 7),
- }, []string{"operation", "status_code"}),
- requestRetries: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{
- Name: "resource_server_client_request_retries_total",
- Help: "Total number of retries for requests to the resource server.",
- }, []string{"operation"}),
- }
-
+// grpcConn creates a new gRPC connection to the provided address.
+func grpcConn(address string, metrics *clientMetrics) (*grpc.ClientConn, error) {
// Report gRPC status code errors as labels.
unary, stream := instrument(metrics.requestDuration, middleware.ReportGRPCStatusOption)
@@ -212,6 +219,12 @@ func GrpcConn(address string, reg prometheus.Registerer) (*grpc.ClientConn, erro
return grpc.NewClient(address, opts...)
}
+// GrpcConn is the public constructor that can be used for testing.
+func GrpcConn(address string, reg prometheus.Registerer) (*grpc.ClientConn, error) {
+ metrics := newClientMetrics(reg)
+ return grpcConn(address, metrics)
+}
+
// instrument is the same as grpcclient.Instrument but without the middleware.ClientUserHeaderInterceptor
// and middleware.StreamClientUserHeaderInterceptor as we don't need them.
func instrument(requestDuration *prometheus.HistogramVec, instrumentationLabelOptions ...middleware.InstrumentationOption) ([]grpc.UnaryClientInterceptor, []grpc.StreamClientInterceptor) {
@@ -223,3 +236,19 @@ func instrument(requestDuration *prometheus.HistogramVec, instrumentationLabelOp
middleware.StreamClientInstrumentInterceptor(requestDuration, instrumentationLabelOptions...),
}
}
+
+func newClientMetrics(reg prometheus.Registerer) *clientMetrics {
+ // This works for now as the Provide function is only called once during startup.
+ // We might eventually want to tight this factory to a struct for more runtime control.
+ return &clientMetrics{
+ requestDuration: promauto.With(reg).NewHistogramVec(prometheus.HistogramOpts{
+ Name: "resource_server_client_request_duration_seconds",
+ Help: "Time spent executing requests to the resource server.",
+ Buckets: prometheus.ExponentialBuckets(0.008, 4, 7),
+ }, []string{"operation", "status_code"}),
+ requestRetries: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{
+ Name: "resource_server_client_request_retries_total",
+ Help: "Total number of retries for requests to the resource server.",
+ }, []string{"operation"}),
+ }
+}
diff --git a/pkg/storage/unified/grpc_pool.go b/pkg/storage/unified/grpc_pool.go
new file mode 100644
index 00000000000..cebc8abce86
--- /dev/null
+++ b/pkg/storage/unified/grpc_pool.go
@@ -0,0 +1,82 @@
+package unified
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ grpcpool "github.com/1NCE-GmbH/grpc-go-pool"
+ "google.golang.org/grpc"
+)
+
+// pooledClientConn implements grpc.ClientConnInterface using a connection from a pool.
+type pooledClientConn struct {
+ pool *grpcpool.Pool
+ // For streaming we want to keep a single connection, as otherwise we saturate the pool.
+ // Streaming should only be used for watching.
+ streamConn grpc.ClientConnInterface
+}
+
+// Invoke implements the grpc.ClientConnInterface.Invoke method.
+func (pc *pooledClientConn) Invoke(ctx context.Context, method string, args interface{}, reply interface{}, opts ...grpc.CallOption) error {
+ conn, err := pc.pool.Get(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to create grpc conn in pooled client: %w", err)
+ }
+ // Return connection to pool when done.
+ defer func() {
+ _ = conn.Close()
+ }()
+ return conn.ClientConn.Invoke(ctx, method, args, reply, opts...)
+}
+
+// NewStream implements the grpc.ClientConnInterface.NewStream method.
+func (pc *pooledClientConn) NewStream(ctx context.Context, desc *grpc.StreamDesc, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) {
+ stream, err := pc.streamConn.NewStream(ctx, desc, method, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create grpc stream in pooled client: %w", err)
+ }
+ return stream, nil
+}
+
+type poolOpts struct {
+ initialCapacity int
+ maxCapacity int
+ idleTimeout time.Duration
+ factory func() (*grpc.ClientConn, error)
+}
+
+func (opts *poolOpts) validate() error {
+ if opts.initialCapacity <= 0 {
+ return errors.New("initial capacity is required")
+ }
+ if opts.maxCapacity < opts.initialCapacity {
+ return errors.New("max capacity is less than initial capacity")
+ }
+ if opts.idleTimeout <= 0 {
+ return errors.New("idle timeout is required")
+ }
+ if opts.factory == nil {
+ return errors.New("factory is required")
+ }
+ return nil
+}
+
+func newPooledConn(opts *poolOpts) (grpc.ClientConnInterface, error) {
+ if err := opts.validate(); err != nil {
+ return nil, fmt.Errorf("failed to validate grpc connection pool options: %w", err)
+ }
+ pool, err := grpcpool.New(opts.factory, opts.initialCapacity, opts.maxCapacity, opts.idleTimeout)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create grpc connection pool: %w", err)
+ }
+ streamConn, err := opts.factory()
+ if err != nil {
+ return nil, fmt.Errorf("failed to create groc streaming connection: %w", err)
+ }
+ return &pooledClientConn{
+ pool: pool,
+ streamConn: streamConn,
+ }, nil
+}
diff --git a/pkg/storage/unified/resource/client.go b/pkg/storage/unified/resource/client.go
index 649a0b7c9c0..c0eb700835d 100644
--- a/pkg/storage/unified/resource/client.go
+++ b/pkg/storage/unified/resource/client.go
@@ -14,6 +14,7 @@ import (
authnlib "github.com/grafana/authlib/authn"
"github.com/grafana/authlib/types"
+
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/tracing"
@@ -40,7 +41,7 @@ type resourceClient struct {
DiagnosticsClient
}
-func NewLegacyResourceClient(channel *grpc.ClientConn) ResourceClient {
+func NewLegacyResourceClient(channel grpc.ClientConnInterface) ResourceClient {
cc := grpchan.InterceptClientConn(channel, grpcUtils.UnaryClientInterceptor, grpcUtils.StreamClientInterceptor)
return &resourceClient{
ResourceStoreClient: NewResourceStoreClient(cc),
@@ -99,7 +100,7 @@ type RemoteResourceClientConfig struct {
AllowInsecure bool
}
-func NewRemoteResourceClient(tracer tracing.Tracer, conn *grpc.ClientConn, cfg RemoteResourceClientConfig) (ResourceClient, error) {
+func NewRemoteResourceClient(tracer tracing.Tracer, conn grpc.ClientConnInterface, cfg RemoteResourceClientConfig) (ResourceClient, error) {
exchangeOpts := []authnlib.ExchangeClientOpts{}
if cfg.AllowInsecure {
diff --git a/pkg/storage/unified/resource/continue.go b/pkg/storage/unified/resource/continue.go
index 70ca18a89ba..c8825ccde88 100644
--- a/pkg/storage/unified/resource/continue.go
+++ b/pkg/storage/unified/resource/continue.go
@@ -9,6 +9,7 @@ import (
type ContinueToken struct {
StartOffset int64 `json:"o"`
ResourceVersion int64 `json:"v"`
+ SortAscending bool `json:"s"`
}
func (c ContinueToken) String() string {
diff --git a/pkg/storage/unified/resource/continue_test.go b/pkg/storage/unified/resource/continue_test.go
index d82b74a4d85..3f67740efaf 100644
--- a/pkg/storage/unified/resource/continue_test.go
+++ b/pkg/storage/unified/resource/continue_test.go
@@ -10,6 +10,7 @@ func TestContinueToken(t *testing.T) {
token := &ContinueToken{
ResourceVersion: 100,
StartOffset: 50,
+ SortAscending: false,
}
- assert.Equal(t, "eyJvIjo1MCwidiI6MTAwfQ==", token.String())
+ assert.Equal(t, "eyJvIjo1MCwidiI6MTAwLCJzIjpmYWxzZX0=", token.String())
}
diff --git a/pkg/storage/unified/resource/go.mod b/pkg/storage/unified/resource/go.mod
index bd4cea71221..e3aab925836 100644
--- a/pkg/storage/unified/resource/go.mod
+++ b/pkg/storage/unified/resource/go.mod
@@ -52,7 +52,7 @@ require (
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
github.com/Azure/go-autorest/autorest/to v0.4.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.3.2 // indirect
- github.com/BurntSushi/toml v1.4.0 // indirect
+ github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.2 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.49.0 // indirect
@@ -98,7 +98,7 @@ require (
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/envoyproxy/go-control-plane v0.13.1 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
- github.com/fatih/color v1.17.0 // indirect
+ github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/getkin/kin-openapi v0.129.0 // indirect
diff --git a/pkg/storage/unified/resource/go.sum b/pkg/storage/unified/resource/go.sum
index 11f1f5bcb10..2ac29e1c27f 100644
--- a/pkg/storage/unified/resource/go.sum
+++ b/pkg/storage/unified/resource/go.sum
@@ -649,8 +649,9 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mo
github.com/AzureAD/microsoft-authentication-library-for-go v1.3.2 h1:kYRSnvJju5gYVyhkij+RTJ/VR6QIUaCfWeaFm2ycsjQ=
github.com/AzureAD/microsoft-authentication-library-for-go v1.3.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
+github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.2 h1:DBjmt6/otSdULyJdVg2BlG0qGZO5tKL4VzOs0jpvw5Q=
@@ -890,8 +891,8 @@ github.com/envoyproxy/protoc-gen-validate v0.10.1/go.mod h1:DRjgyB0I43LtJapqN6Ni
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
-github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
-github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
+github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
+github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
diff --git a/pkg/storage/unified/resource/resource.pb.go b/pkg/storage/unified/resource/resource.pb.go
index 8f8d7925e13..69f2de4f1a9 100644
--- a/pkg/storage/unified/resource/resource.pb.go
+++ b/pkg/storage/unified/resource/resource.pb.go
@@ -24,19 +24,20 @@ const (
type ResourceVersionMatch int32
const (
- ResourceVersionMatch_NotOlderThan ResourceVersionMatch = 0
- ResourceVersionMatch_Exact ResourceVersionMatch = 1
+ // Deprecated, use ResourceVersionMatch V2
+ ResourceVersionMatch_DEPRECATED_NotOlderThan ResourceVersionMatch = 0
+ ResourceVersionMatch_DEPRECATED_Exact ResourceVersionMatch = 1
)
// Enum value maps for ResourceVersionMatch.
var (
ResourceVersionMatch_name = map[int32]string{
- 0: "NotOlderThan",
- 1: "Exact",
+ 0: "DEPRECATED_NotOlderThan",
+ 1: "DEPRECATED_Exact",
}
ResourceVersionMatch_value = map[string]int32{
- "NotOlderThan": 0,
- "Exact": 1,
+ "DEPRECATED_NotOlderThan": 0,
+ "DEPRECATED_Exact": 1,
}
)
@@ -67,6 +68,58 @@ func (ResourceVersionMatch) EnumDescriptor() ([]byte, []int) {
return file_resource_proto_rawDescGZIP(), []int{0}
}
+type ResourceVersionMatchV2 int32
+
+const (
+ ResourceVersionMatchV2_UNKNOWN ResourceVersionMatchV2 = 0
+ ResourceVersionMatchV2_Unset ResourceVersionMatchV2 = 1
+ ResourceVersionMatchV2_Exact ResourceVersionMatchV2 = 2
+ ResourceVersionMatchV2_NotOlderThan ResourceVersionMatchV2 = 3
+)
+
+// Enum value maps for ResourceVersionMatchV2.
+var (
+ ResourceVersionMatchV2_name = map[int32]string{
+ 0: "UNKNOWN",
+ 1: "Unset",
+ 2: "Exact",
+ 3: "NotOlderThan",
+ }
+ ResourceVersionMatchV2_value = map[string]int32{
+ "UNKNOWN": 0,
+ "Unset": 1,
+ "Exact": 2,
+ "NotOlderThan": 3,
+ }
+)
+
+func (x ResourceVersionMatchV2) Enum() *ResourceVersionMatchV2 {
+ p := new(ResourceVersionMatchV2)
+ *p = x
+ return p
+}
+
+func (x ResourceVersionMatchV2) String() string {
+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (ResourceVersionMatchV2) Descriptor() protoreflect.EnumDescriptor {
+ return file_resource_proto_enumTypes[1].Descriptor()
+}
+
+func (ResourceVersionMatchV2) Type() protoreflect.EnumType {
+ return &file_resource_proto_enumTypes[1]
+}
+
+func (x ResourceVersionMatchV2) Number() protoreflect.EnumNumber {
+ return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use ResourceVersionMatchV2.Descriptor instead.
+func (ResourceVersionMatchV2) EnumDescriptor() ([]byte, []int) {
+ return file_resource_proto_rawDescGZIP(), []int{1}
+}
+
type ListRequest_Source int32
const (
@@ -100,11 +153,11 @@ func (x ListRequest_Source) String() string {
}
func (ListRequest_Source) Descriptor() protoreflect.EnumDescriptor {
- return file_resource_proto_enumTypes[1].Descriptor()
+ return file_resource_proto_enumTypes[2].Descriptor()
}
func (ListRequest_Source) Type() protoreflect.EnumType {
- return &file_resource_proto_enumTypes[1]
+ return &file_resource_proto_enumTypes[2]
}
func (x ListRequest_Source) Number() protoreflect.EnumNumber {
@@ -158,11 +211,11 @@ func (x WatchEvent_Type) String() string {
}
func (WatchEvent_Type) Descriptor() protoreflect.EnumDescriptor {
- return file_resource_proto_enumTypes[2].Descriptor()
+ return file_resource_proto_enumTypes[3].Descriptor()
}
func (WatchEvent_Type) Type() protoreflect.EnumType {
- return &file_resource_proto_enumTypes[2]
+ return &file_resource_proto_enumTypes[3]
}
func (x WatchEvent_Type) Number() protoreflect.EnumNumber {
@@ -212,11 +265,11 @@ func (x BulkRequest_Action) String() string {
}
func (BulkRequest_Action) Descriptor() protoreflect.EnumDescriptor {
- return file_resource_proto_enumTypes[3].Descriptor()
+ return file_resource_proto_enumTypes[4].Descriptor()
}
func (BulkRequest_Action) Type() protoreflect.EnumType {
- return &file_resource_proto_enumTypes[3]
+ return &file_resource_proto_enumTypes[4]
}
func (x BulkRequest_Action) Number() protoreflect.EnumNumber {
@@ -264,11 +317,11 @@ func (x HealthCheckResponse_ServingStatus) String() string {
}
func (HealthCheckResponse_ServingStatus) Descriptor() protoreflect.EnumDescriptor {
- return file_resource_proto_enumTypes[4].Descriptor()
+ return file_resource_proto_enumTypes[5].Descriptor()
}
func (HealthCheckResponse_ServingStatus) Type() protoreflect.EnumType {
- return &file_resource_proto_enumTypes[4]
+ return &file_resource_proto_enumTypes[5]
}
func (x HealthCheckResponse_ServingStatus) Number() protoreflect.EnumNumber {
@@ -339,11 +392,11 @@ func (x ResourceTableColumnDefinition_ColumnType) String() string {
}
func (ResourceTableColumnDefinition_ColumnType) Descriptor() protoreflect.EnumDescriptor {
- return file_resource_proto_enumTypes[5].Descriptor()
+ return file_resource_proto_enumTypes[6].Descriptor()
}
func (ResourceTableColumnDefinition_ColumnType) Type() protoreflect.EnumType {
- return &file_resource_proto_enumTypes[5]
+ return &file_resource_proto_enumTypes[6]
}
func (x ResourceTableColumnDefinition_ColumnType) Number() protoreflect.EnumNumber {
@@ -387,11 +440,11 @@ func (x PutBlobRequest_Method) String() string {
}
func (PutBlobRequest_Method) Descriptor() protoreflect.EnumDescriptor {
- return file_resource_proto_enumTypes[6].Descriptor()
+ return file_resource_proto_enumTypes[7].Descriptor()
}
func (PutBlobRequest_Method) Type() protoreflect.EnumType {
- return &file_resource_proto_enumTypes[6]
+ return &file_resource_proto_enumTypes[7]
}
func (x PutBlobRequest_Method) Number() protoreflect.EnumNumber {
@@ -400,7 +453,7 @@ func (x PutBlobRequest_Method) Number() protoreflect.EnumNumber {
// Deprecated: Use PutBlobRequest_Method.Descriptor instead.
func (PutBlobRequest_Method) EnumDescriptor() ([]byte, []int) {
- return file_resource_proto_rawDescGZIP(), []int{36, 0}
+ return file_resource_proto_rawDescGZIP(), []int{34, 0}
}
type ResourceKey struct {
@@ -1152,10 +1205,8 @@ type ReadRequest struct {
Key *ResourceKey `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"`
// Optionally pick an explicit resource version
ResourceVersion int64 `protobuf:"varint,2,opt,name=resource_version,json=resourceVersion,proto3" json:"resource_version,omitempty"`
- // Optionally decide to return the latest RV if deleted
- IncludeDeleted bool `protobuf:"varint,3,opt,name=include_deleted,json=includeDeleted,proto3" json:"include_deleted,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *ReadRequest) Reset() {
@@ -1202,13 +1253,6 @@ func (x *ReadRequest) GetResourceVersion() int64 {
return 0
}
-func (x *ReadRequest) GetIncludeDeleted() bool {
- if x != nil {
- return x.IncludeDeleted
- }
- return false
-}
-
type ReadResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Error details
@@ -1408,16 +1452,18 @@ type ListRequest struct {
// The resource version
ResourceVersion int64 `protobuf:"varint,2,opt,name=resource_version,json=resourceVersion,proto3" json:"resource_version,omitempty"`
// List options
- VersionMatch ResourceVersionMatch `protobuf:"varint,3,opt,name=version_match,json=versionMatch,proto3,enum=resource.ResourceVersionMatch" json:"version_match,omitempty"`
+ // DEPRECATED - use version_match_v2
+ VersionMatch *ResourceVersionMatch `protobuf:"varint,3,opt,name=version_match,json=versionMatch,proto3,enum=resource.ResourceVersionMatch,oneof" json:"version_match,omitempty"`
// Maximum number of items to return
// NOTE responses will also be limited by the response payload size
Limit int64 `protobuf:"varint,4,opt,name=limit,proto3" json:"limit,omitempty"`
// Filtering
Options *ListOptions `protobuf:"bytes,5,opt,name=options,proto3" json:"options,omitempty"`
// Select values from history or trash
- Source ListRequest_Source `protobuf:"varint,6,opt,name=source,proto3,enum=resource.ListRequest_Source" json:"source,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ Source ListRequest_Source `protobuf:"varint,6,opt,name=source,proto3,enum=resource.ListRequest_Source" json:"source,omitempty"`
+ VersionMatchV2 ResourceVersionMatchV2 `protobuf:"varint,7,opt,name=version_match_v2,json=versionMatchV2,proto3,enum=resource.ResourceVersionMatchV2" json:"version_match_v2,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *ListRequest) Reset() {
@@ -1465,10 +1511,10 @@ func (x *ListRequest) GetResourceVersion() int64 {
}
func (x *ListRequest) GetVersionMatch() ResourceVersionMatch {
- if x != nil {
- return x.VersionMatch
+ if x != nil && x.VersionMatch != nil {
+ return *x.VersionMatch
}
- return ResourceVersionMatch_NotOlderThan
+ return ResourceVersionMatch_DEPRECATED_NotOlderThan
}
func (x *ListRequest) GetLimit() int64 {
@@ -1492,6 +1538,13 @@ func (x *ListRequest) GetSource() ListRequest_Source {
return ListRequest_STORE
}
+func (x *ListRequest) GetVersionMatchV2() ResourceVersionMatchV2 {
+ if x != nil {
+ return x.VersionMatchV2
+ }
+ return ResourceVersionMatchV2_UNKNOWN
+}
+
type ListResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Items []*ResourceWrapper `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
@@ -2844,114 +2897,6 @@ func (x *ResourceTableRow) GetObject() []byte {
return nil
}
-type RestoreRequest struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- // Full key must be set
- Key *ResourceKey `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"`
- // The resource version to restore
- ResourceVersion int64 `protobuf:"varint,2,opt,name=resource_version,json=resourceVersion,proto3" json:"resource_version,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
-}
-
-func (x *RestoreRequest) Reset() {
- *x = RestoreRequest{}
- mi := &file_resource_proto_msgTypes[34]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *RestoreRequest) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*RestoreRequest) ProtoMessage() {}
-
-func (x *RestoreRequest) ProtoReflect() protoreflect.Message {
- mi := &file_resource_proto_msgTypes[34]
- if x != nil {
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- if ms.LoadMessageInfo() == nil {
- ms.StoreMessageInfo(mi)
- }
- return ms
- }
- return mi.MessageOf(x)
-}
-
-// Deprecated: Use RestoreRequest.ProtoReflect.Descriptor instead.
-func (*RestoreRequest) Descriptor() ([]byte, []int) {
- return file_resource_proto_rawDescGZIP(), []int{34}
-}
-
-func (x *RestoreRequest) GetKey() *ResourceKey {
- if x != nil {
- return x.Key
- }
- return nil
-}
-
-func (x *RestoreRequest) GetResourceVersion() int64 {
- if x != nil {
- return x.ResourceVersion
- }
- return 0
-}
-
-type RestoreResponse struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- // Error details
- Error *ErrorResult `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"`
- // The updated resource version
- ResourceVersion int64 `protobuf:"varint,2,opt,name=resource_version,json=resourceVersion,proto3" json:"resource_version,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
-}
-
-func (x *RestoreResponse) Reset() {
- *x = RestoreResponse{}
- mi := &file_resource_proto_msgTypes[35]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *RestoreResponse) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*RestoreResponse) ProtoMessage() {}
-
-func (x *RestoreResponse) ProtoReflect() protoreflect.Message {
- mi := &file_resource_proto_msgTypes[35]
- if x != nil {
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- if ms.LoadMessageInfo() == nil {
- ms.StoreMessageInfo(mi)
- }
- return ms
- }
- return mi.MessageOf(x)
-}
-
-// Deprecated: Use RestoreResponse.ProtoReflect.Descriptor instead.
-func (*RestoreResponse) Descriptor() ([]byte, []int) {
- return file_resource_proto_rawDescGZIP(), []int{35}
-}
-
-func (x *RestoreResponse) GetError() *ErrorResult {
- if x != nil {
- return x.Error
- }
- return nil
-}
-
-func (x *RestoreResponse) GetResourceVersion() int64 {
- if x != nil {
- return x.ResourceVersion
- }
- return 0
-}
-
type PutBlobRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// The resource that will use this blob
@@ -2970,7 +2915,7 @@ type PutBlobRequest struct {
func (x *PutBlobRequest) Reset() {
*x = PutBlobRequest{}
- mi := &file_resource_proto_msgTypes[36]
+ mi := &file_resource_proto_msgTypes[34]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2982,7 +2927,7 @@ func (x *PutBlobRequest) String() string {
func (*PutBlobRequest) ProtoMessage() {}
func (x *PutBlobRequest) ProtoReflect() protoreflect.Message {
- mi := &file_resource_proto_msgTypes[36]
+ mi := &file_resource_proto_msgTypes[34]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2995,7 +2940,7 @@ func (x *PutBlobRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use PutBlobRequest.ProtoReflect.Descriptor instead.
func (*PutBlobRequest) Descriptor() ([]byte, []int) {
- return file_resource_proto_rawDescGZIP(), []int{36}
+ return file_resource_proto_rawDescGZIP(), []int{34}
}
func (x *PutBlobRequest) GetResource() *ResourceKey {
@@ -3048,7 +2993,7 @@ type PutBlobResponse struct {
func (x *PutBlobResponse) Reset() {
*x = PutBlobResponse{}
- mi := &file_resource_proto_msgTypes[37]
+ mi := &file_resource_proto_msgTypes[35]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3060,7 +3005,7 @@ func (x *PutBlobResponse) String() string {
func (*PutBlobResponse) ProtoMessage() {}
func (x *PutBlobResponse) ProtoReflect() protoreflect.Message {
- mi := &file_resource_proto_msgTypes[37]
+ mi := &file_resource_proto_msgTypes[35]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3073,7 +3018,7 @@ func (x *PutBlobResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use PutBlobResponse.ProtoReflect.Descriptor instead.
func (*PutBlobResponse) Descriptor() ([]byte, []int) {
- return file_resource_proto_rawDescGZIP(), []int{37}
+ return file_resource_proto_rawDescGZIP(), []int{35}
}
func (x *PutBlobResponse) GetError() *ErrorResult {
@@ -3140,7 +3085,7 @@ type GetBlobRequest struct {
func (x *GetBlobRequest) Reset() {
*x = GetBlobRequest{}
- mi := &file_resource_proto_msgTypes[38]
+ mi := &file_resource_proto_msgTypes[36]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3152,7 +3097,7 @@ func (x *GetBlobRequest) String() string {
func (*GetBlobRequest) ProtoMessage() {}
func (x *GetBlobRequest) ProtoReflect() protoreflect.Message {
- mi := &file_resource_proto_msgTypes[38]
+ mi := &file_resource_proto_msgTypes[36]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3165,7 +3110,7 @@ func (x *GetBlobRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetBlobRequest.ProtoReflect.Descriptor instead.
func (*GetBlobRequest) Descriptor() ([]byte, []int) {
- return file_resource_proto_rawDescGZIP(), []int{38}
+ return file_resource_proto_rawDescGZIP(), []int{36}
}
func (x *GetBlobRequest) GetResource() *ResourceKey {
@@ -3214,7 +3159,7 @@ type GetBlobResponse struct {
func (x *GetBlobResponse) Reset() {
*x = GetBlobResponse{}
- mi := &file_resource_proto_msgTypes[39]
+ mi := &file_resource_proto_msgTypes[37]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3226,7 +3171,7 @@ func (x *GetBlobResponse) String() string {
func (*GetBlobResponse) ProtoMessage() {}
func (x *GetBlobResponse) ProtoReflect() protoreflect.Message {
- mi := &file_resource_proto_msgTypes[39]
+ mi := &file_resource_proto_msgTypes[37]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3239,7 +3184,7 @@ func (x *GetBlobResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetBlobResponse.ProtoReflect.Descriptor instead.
func (*GetBlobResponse) Descriptor() ([]byte, []int) {
- return file_resource_proto_rawDescGZIP(), []int{39}
+ return file_resource_proto_rawDescGZIP(), []int{37}
}
func (x *GetBlobResponse) GetError() *ErrorResult {
@@ -3280,7 +3225,7 @@ type WatchEvent_Resource struct {
func (x *WatchEvent_Resource) Reset() {
*x = WatchEvent_Resource{}
- mi := &file_resource_proto_msgTypes[40]
+ mi := &file_resource_proto_msgTypes[38]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3292,7 +3237,7 @@ func (x *WatchEvent_Resource) String() string {
func (*WatchEvent_Resource) ProtoMessage() {}
func (x *WatchEvent_Resource) ProtoReflect() protoreflect.Message {
- mi := &file_resource_proto_msgTypes[40]
+ mi := &file_resource_proto_msgTypes[38]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3339,7 +3284,7 @@ type BulkResponse_Summary struct {
func (x *BulkResponse_Summary) Reset() {
*x = BulkResponse_Summary{}
- mi := &file_resource_proto_msgTypes[41]
+ mi := &file_resource_proto_msgTypes[39]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3351,7 +3296,7 @@ func (x *BulkResponse_Summary) String() string {
func (*BulkResponse_Summary) ProtoMessage() {}
func (x *BulkResponse_Summary) ProtoReflect() protoreflect.Message {
- mi := &file_resource_proto_msgTypes[41]
+ mi := &file_resource_proto_msgTypes[39]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3435,7 +3380,7 @@ type BulkResponse_Rejected struct {
func (x *BulkResponse_Rejected) Reset() {
*x = BulkResponse_Rejected{}
- mi := &file_resource_proto_msgTypes[42]
+ mi := &file_resource_proto_msgTypes[40]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3447,7 +3392,7 @@ func (x *BulkResponse_Rejected) String() string {
func (*BulkResponse_Rejected) ProtoMessage() {}
func (x *BulkResponse_Rejected) ProtoReflect() protoreflect.Message {
- mi := &file_resource_proto_msgTypes[42]
+ mi := &file_resource_proto_msgTypes[40]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3498,7 +3443,7 @@ type ResourceStatsResponse_Stats struct {
func (x *ResourceStatsResponse_Stats) Reset() {
*x = ResourceStatsResponse_Stats{}
- mi := &file_resource_proto_msgTypes[43]
+ mi := &file_resource_proto_msgTypes[41]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3510,7 +3455,7 @@ func (x *ResourceStatsResponse_Stats) String() string {
func (*ResourceStatsResponse_Stats) ProtoMessage() {}
func (x *ResourceStatsResponse_Stats) ProtoReflect() protoreflect.Message {
- mi := &file_resource_proto_msgTypes[43]
+ mi := &file_resource_proto_msgTypes[41]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3557,7 +3502,7 @@ type ResourceSearchRequest_Sort struct {
func (x *ResourceSearchRequest_Sort) Reset() {
*x = ResourceSearchRequest_Sort{}
- mi := &file_resource_proto_msgTypes[44]
+ mi := &file_resource_proto_msgTypes[42]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3569,7 +3514,7 @@ func (x *ResourceSearchRequest_Sort) String() string {
func (*ResourceSearchRequest_Sort) ProtoMessage() {}
func (x *ResourceSearchRequest_Sort) ProtoReflect() protoreflect.Message {
- mi := &file_resource_proto_msgTypes[44]
+ mi := &file_resource_proto_msgTypes[42]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3609,7 +3554,7 @@ type ResourceSearchRequest_Facet struct {
func (x *ResourceSearchRequest_Facet) Reset() {
*x = ResourceSearchRequest_Facet{}
- mi := &file_resource_proto_msgTypes[45]
+ mi := &file_resource_proto_msgTypes[43]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3621,7 +3566,7 @@ func (x *ResourceSearchRequest_Facet) String() string {
func (*ResourceSearchRequest_Facet) ProtoMessage() {}
func (x *ResourceSearchRequest_Facet) ProtoReflect() protoreflect.Message {
- mi := &file_resource_proto_msgTypes[45]
+ mi := &file_resource_proto_msgTypes[43]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3666,7 +3611,7 @@ type ResourceSearchResponse_Facet struct {
func (x *ResourceSearchResponse_Facet) Reset() {
*x = ResourceSearchResponse_Facet{}
- mi := &file_resource_proto_msgTypes[47]
+ mi := &file_resource_proto_msgTypes[45]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3678,7 +3623,7 @@ func (x *ResourceSearchResponse_Facet) String() string {
func (*ResourceSearchResponse_Facet) ProtoMessage() {}
func (x *ResourceSearchResponse_Facet) ProtoReflect() protoreflect.Message {
- mi := &file_resource_proto_msgTypes[47]
+ mi := &file_resource_proto_msgTypes[45]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3732,7 +3677,7 @@ type ResourceSearchResponse_TermFacet struct {
func (x *ResourceSearchResponse_TermFacet) Reset() {
*x = ResourceSearchResponse_TermFacet{}
- mi := &file_resource_proto_msgTypes[48]
+ mi := &file_resource_proto_msgTypes[46]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3744,7 +3689,7 @@ func (x *ResourceSearchResponse_TermFacet) String() string {
func (*ResourceSearchResponse_TermFacet) ProtoMessage() {}
func (x *ResourceSearchResponse_TermFacet) ProtoReflect() protoreflect.Message {
- mi := &file_resource_proto_msgTypes[48]
+ mi := &file_resource_proto_msgTypes[46]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3794,7 +3739,7 @@ type ListManagedObjectsResponse_Item struct {
func (x *ListManagedObjectsResponse_Item) Reset() {
*x = ListManagedObjectsResponse_Item{}
- mi := &file_resource_proto_msgTypes[50]
+ mi := &file_resource_proto_msgTypes[48]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3806,7 +3751,7 @@ func (x *ListManagedObjectsResponse_Item) String() string {
func (*ListManagedObjectsResponse_Item) ProtoMessage() {}
func (x *ListManagedObjectsResponse_Item) ProtoReflect() protoreflect.Message {
- mi := &file_resource_proto_msgTypes[50]
+ mi := &file_resource_proto_msgTypes[48]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3877,7 +3822,7 @@ type CountManagedObjectsResponse_ResourceCount struct {
func (x *CountManagedObjectsResponse_ResourceCount) Reset() {
*x = CountManagedObjectsResponse_ResourceCount{}
- mi := &file_resource_proto_msgTypes[51]
+ mi := &file_resource_proto_msgTypes[49]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3889,7 +3834,7 @@ func (x *CountManagedObjectsResponse_ResourceCount) String() string {
func (*CountManagedObjectsResponse_ResourceCount) ProtoMessage() {}
func (x *CountManagedObjectsResponse_ResourceCount) ProtoReflect() protoreflect.Message {
- mi := &file_resource_proto_msgTypes[51]
+ mi := &file_resource_proto_msgTypes[49]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3962,7 +3907,7 @@ type ResourceTableColumnDefinition_Properties struct {
func (x *ResourceTableColumnDefinition_Properties) Reset() {
*x = ResourceTableColumnDefinition_Properties{}
- mi := &file_resource_proto_msgTypes[52]
+ mi := &file_resource_proto_msgTypes[50]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3974,7 +3919,7 @@ func (x *ResourceTableColumnDefinition_Properties) String() string {
func (*ResourceTableColumnDefinition_Properties) ProtoMessage() {}
func (x *ResourceTableColumnDefinition_Properties) ProtoReflect() protoreflect.Message {
- mi := &file_resource_proto_msgTypes[52]
+ mi := &file_resource_proto_msgTypes[50]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4107,529 +4052,522 @@ var file_resource_proto_rawDesc = string([]byte{
0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f,
0x72, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65,
0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73,
- 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x8a, 0x01, 0x0a,
- 0x0b, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x03,
- 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f,
- 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79,
- 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
- 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52,
- 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e,
- 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x5f, 0x64, 0x65, 0x6c, 0x65,
- 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x69, 0x6e, 0x63, 0x6c, 0x75,
- 0x64, 0x65, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x22, 0x7c, 0x0a, 0x0c, 0x52, 0x65, 0x61,
- 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72,
- 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75,
- 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52,
- 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72,
- 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03,
- 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f,
- 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c,
- 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x53, 0x0a, 0x0b, 0x52, 0x65, 0x71, 0x75, 0x69,
- 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20,
- 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x6f, 0x70, 0x65, 0x72,
- 0x61, 0x74, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6f, 0x70, 0x65, 0x72,
- 0x61, 0x74, 0x6f, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x03,
- 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x22, 0x94, 0x01, 0x0a,
- 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x27, 0x0a, 0x03,
- 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f,
- 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79,
- 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2d, 0x0a, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18,
- 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
- 0x2e, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x6c, 0x61,
- 0x62, 0x65, 0x6c, 0x73, 0x12, 0x2d, 0x0a, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x03,
- 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e,
- 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x66, 0x69, 0x65,
- 0x6c, 0x64, 0x73, 0x22, 0xcf, 0x02, 0x0a, 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75,
- 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65,
- 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65,
- 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x72,
- 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18,
- 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56,
- 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x43, 0x0a, 0x0d, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f,
- 0x6e, 0x5f, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1e, 0x2e,
+ 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x61, 0x0a, 0x0b,
+ 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x03, 0x6b,
+ 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75,
+ 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52,
+ 0x03, 0x6b, 0x65, 0x79, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
+ 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f,
+ 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22,
+ 0x7c, 0x0a, 0x0c, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
+ 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15,
+ 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52,
+ 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x29, 0x0a, 0x10,
+ 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e,
+ 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
+ 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65,
+ 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x53, 0x0a,
+ 0x0b, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x10, 0x0a, 0x03,
+ 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x1a,
+ 0x0a, 0x08, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x08, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x76, 0x61,
+ 0x6c, 0x75, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x76, 0x61, 0x6c, 0x75,
+ 0x65, 0x73, 0x22, 0x94, 0x01, 0x0a, 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f,
+ 0x6e, 0x73, 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
+ 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75,
+ 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2d, 0x0a, 0x06, 0x6c,
+ 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65,
+ 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65,
+ 0x6e, 0x74, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x2d, 0x0a, 0x06, 0x66, 0x69,
+ 0x65, 0x6c, 0x64, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73,
+ 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, 0x6e,
+ 0x74, 0x52, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x22, 0xb2, 0x03, 0x0a, 0x0b, 0x4c, 0x69,
+ 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78,
+ 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01,
+ 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65,
+ 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65,
+ 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73,
+ 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x48, 0x0a, 0x0d,
+ 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x18, 0x03, 0x20,
+ 0x01, 0x28, 0x0e, 0x32, 0x1e, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52,
+ 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x4d, 0x61,
+ 0x74, 0x63, 0x68, 0x48, 0x00, 0x52, 0x0c, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x4d, 0x61,
+ 0x74, 0x63, 0x68, 0x88, 0x01, 0x01, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18,
+ 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x2f, 0x0a, 0x07,
+ 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e,
+ 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4f, 0x70, 0x74,
+ 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x34, 0x0a,
+ 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1c, 0x2e,
+ 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71,
+ 0x75, 0x65, 0x73, 0x74, 0x2e, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x06, 0x73, 0x6f, 0x75,
+ 0x72, 0x63, 0x65, 0x12, 0x4a, 0x0a, 0x10, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x6d,
+ 0x61, 0x74, 0x63, 0x68, 0x5f, 0x76, 0x32, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x20, 0x2e,
0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
- 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x52, 0x0c, 0x76,
- 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x12, 0x14, 0x0a, 0x05, 0x6c,
- 0x69, 0x6d, 0x69, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69,
- 0x74, 0x12, 0x2f, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x05, 0x20, 0x01,
- 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69,
- 0x73, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f,
- 0x6e, 0x73, 0x12, 0x34, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x06, 0x20, 0x01,
- 0x28, 0x0e, 0x32, 0x1c, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69,
- 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65,
- 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x22, 0x2b, 0x0a, 0x06, 0x53, 0x6f, 0x75, 0x72,
- 0x63, 0x65, 0x12, 0x09, 0x0a, 0x05, 0x53, 0x54, 0x4f, 0x52, 0x45, 0x10, 0x00, 0x12, 0x0b, 0x0a,
- 0x07, 0x48, 0x49, 0x53, 0x54, 0x4f, 0x52, 0x59, 0x10, 0x01, 0x12, 0x09, 0x0a, 0x05, 0x54, 0x52,
- 0x41, 0x53, 0x48, 0x10, 0x02, 0x22, 0xf1, 0x01, 0x0a, 0x0c, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65,
- 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2f, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18,
- 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
- 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x57, 0x72, 0x61, 0x70, 0x70, 0x65, 0x72,
- 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f,
- 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
- 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12,
- 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73,
- 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75,
- 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x30, 0x0a, 0x14, 0x72, 0x65,
- 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x63, 0x6f, 0x75,
- 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x12, 0x72, 0x65, 0x6d, 0x61, 0x69, 0x6e,
- 0x69, 0x6e, 0x67, 0x49, 0x74, 0x65, 0x6d, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x2b, 0x0a, 0x05,
- 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65,
- 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75,
- 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0xb9, 0x01, 0x0a, 0x0c, 0x57, 0x61,
- 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x69,
- 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x73, 0x69, 0x6e, 0x63, 0x65,
- 0x12, 0x2f, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28,
- 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73,
- 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e,
- 0x73, 0x12, 0x2e, 0x0a, 0x13, 0x73, 0x65, 0x6e, 0x64, 0x5f, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61,
- 0x6c, 0x5f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11,
- 0x73, 0x65, 0x6e, 0x64, 0x49, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x45, 0x76, 0x65, 0x6e, 0x74,
- 0x73, 0x12, 0x32, 0x0a, 0x15, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x5f, 0x77, 0x61, 0x74, 0x63, 0x68,
- 0x5f, 0x62, 0x6f, 0x6f, 0x6b, 0x6d, 0x61, 0x72, 0x6b, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08,
- 0x52, 0x13, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x57, 0x61, 0x74, 0x63, 0x68, 0x42, 0x6f, 0x6f, 0x6b,
- 0x6d, 0x61, 0x72, 0x6b, 0x73, 0x22, 0xdf, 0x02, 0x0a, 0x0a, 0x57, 0x61, 0x74, 0x63, 0x68, 0x45,
- 0x76, 0x65, 0x6e, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
- 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61,
- 0x6d, 0x70, 0x12, 0x2d, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e,
- 0x32, 0x19, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, 0x74, 0x63,
- 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70,
- 0x65, 0x12, 0x39, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x03, 0x20,
- 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57,
- 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72,
- 0x63, 0x65, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x39, 0x0a, 0x08,
- 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d,
- 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x45,
- 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x08, 0x70,
- 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x1a, 0x3a, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x6f, 0x75,
- 0x72, 0x63, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01,
- 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a,
- 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61,
- 0x6c, 0x75, 0x65, 0x22, 0x52, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x55,
- 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x41, 0x44, 0x44, 0x45,
- 0x44, 0x10, 0x01, 0x12, 0x0c, 0x0a, 0x08, 0x4d, 0x4f, 0x44, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10,
- 0x02, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x03, 0x12, 0x0c,
- 0x0a, 0x08, 0x42, 0x4f, 0x4f, 0x4b, 0x4d, 0x41, 0x52, 0x4b, 0x10, 0x04, 0x12, 0x09, 0x0a, 0x05,
- 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x05, 0x22, 0xd7, 0x01, 0x0a, 0x0b, 0x42, 0x75, 0x6c, 0x6b,
- 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01,
- 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e,
- 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79,
- 0x12, 0x34, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e,
- 0x32, 0x1c, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x42, 0x75, 0x6c, 0x6b,
- 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06,
- 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18,
- 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x16, 0x0a, 0x06,
- 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f,
- 0x6c, 0x64, 0x65, 0x72, 0x22, 0x3b, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0b,
- 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x41,
- 0x44, 0x44, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0c, 0x0a, 0x08, 0x4d, 0x4f, 0x44, 0x49, 0x46, 0x49,
- 0x45, 0x44, 0x10, 0x02, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10,
- 0x03, 0x22, 0xda, 0x04, 0x0a, 0x0c, 0x42, 0x75, 0x6c, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
- 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28,
- 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72,
- 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12,
- 0x1c, 0x0a, 0x09, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01,
- 0x28, 0x03, 0x52, 0x09, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x65, 0x64, 0x12, 0x38, 0x0a,
- 0x07, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e,
- 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x42, 0x75, 0x6c, 0x6b, 0x52, 0x65,
- 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x52, 0x07,
- 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x12, 0x3b, 0x0a, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63,
- 0x74, 0x65, 0x64, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x72, 0x65, 0x73, 0x6f,
- 0x75, 0x72, 0x63, 0x65, 0x2e, 0x42, 0x75, 0x6c, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
- 0x65, 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x52, 0x08, 0x72, 0x65, 0x6a, 0x65,
- 0x63, 0x74, 0x65, 0x64, 0x1a, 0x86, 0x02, 0x0a, 0x07, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79,
- 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x01, 0x20,
- 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x14,
- 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x67,
- 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
- 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
- 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52,
- 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x68, 0x69, 0x73, 0x74, 0x6f, 0x72,
- 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x68, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79,
- 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72,
- 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f,
- 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x70,
- 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x07, 0x20,
- 0x01, 0x28, 0x03, 0x52, 0x0d, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x43, 0x6f, 0x75,
- 0x6e, 0x74, 0x12, 0x29, 0x0a, 0x10, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x5f, 0x68,
- 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x70, 0x72,
- 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x1a, 0x7f, 0x0a,
- 0x08, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79,
- 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
- 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b,
- 0x65, 0x79, 0x12, 0x34, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01,
- 0x28, 0x0e, 0x32, 0x1c, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x42, 0x75,
- 0x6c, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e,
- 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f,
- 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x62,
- 0x0a, 0x14, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52,
- 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70,
- 0x61, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73,
- 0x70, 0x61, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x6b, 0x69, 0x6e, 0x64, 0x73, 0x18, 0x02, 0x20,
- 0x03, 0x28, 0x09, 0x52, 0x05, 0x6b, 0x69, 0x6e, 0x64, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f,
- 0x6c, 0x64, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64,
- 0x65, 0x72, 0x22, 0xd2, 0x01, 0x0a, 0x15, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53,
- 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x05,
- 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65,
- 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75,
- 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x3b, 0x0a, 0x05, 0x73, 0x74, 0x61,
- 0x74, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75,
- 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74,
- 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52,
- 0x05, 0x73, 0x74, 0x61, 0x74, 0x73, 0x1a, 0x4f, 0x0a, 0x05, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12,
- 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05,
- 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
- 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
- 0x65, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03,
- 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x8e, 0x05, 0x0a, 0x15, 0x52, 0x65, 0x73, 0x6f,
- 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
- 0x74, 0x12, 0x2f, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x01,
- 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69,
- 0x73, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f,
- 0x6e, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x66, 0x65, 0x64, 0x65, 0x72, 0x61, 0x74, 0x65, 0x64, 0x18,
- 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
- 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x09, 0x66, 0x65,
- 0x64, 0x65, 0x72, 0x61, 0x74, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79,
- 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x12, 0x14, 0x0a,
- 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69,
- 0x6d, 0x69, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x66, 0x66, 0x73, 0x65, 0x74, 0x18, 0x05, 0x20,
- 0x01, 0x28, 0x03, 0x52, 0x06, 0x6f, 0x66, 0x66, 0x73, 0x65, 0x74, 0x12, 0x3c, 0x0a, 0x06, 0x73,
- 0x6f, 0x72, 0x74, 0x42, 0x79, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x72, 0x65,
- 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53,
- 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x53, 0x6f, 0x72,
- 0x74, 0x52, 0x06, 0x73, 0x6f, 0x72, 0x74, 0x42, 0x79, 0x12, 0x40, 0x0a, 0x05, 0x66, 0x61, 0x63,
- 0x65, 0x74, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75,
- 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72,
- 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x46, 0x61, 0x63, 0x65, 0x74, 0x45,
- 0x6e, 0x74, 0x72, 0x79, 0x52, 0x05, 0x66, 0x61, 0x63, 0x65, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x66,
- 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x66, 0x69, 0x65,
- 0x6c, 0x64, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x78, 0x70, 0x6c, 0x61, 0x69, 0x6e, 0x18, 0x09,
- 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x78, 0x70, 0x6c, 0x61, 0x69, 0x6e, 0x12, 0x1d, 0x0a,
- 0x0a, 0x69, 0x73, 0x5f, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28,
- 0x08, 0x52, 0x09, 0x69, 0x73, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x12, 0x12, 0x0a, 0x04,
- 0x70, 0x61, 0x67, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x70, 0x61, 0x67, 0x65,
- 0x12, 0x1e, 0x0a, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0c,
- 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e,
- 0x1a, 0x30, 0x0a, 0x04, 0x53, 0x6f, 0x72, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c,
- 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x12,
- 0x0a, 0x04, 0x64, 0x65, 0x73, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x64, 0x65,
- 0x73, 0x63, 0x1a, 0x33, 0x0a, 0x05, 0x46, 0x61, 0x63, 0x65, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x66,
- 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c,
- 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03,
- 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x1a, 0x5f, 0x0a, 0x0a, 0x46, 0x61, 0x63, 0x65, 0x74,
- 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01,
- 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x3b, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65,
- 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
- 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68,
- 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x46, 0x61, 0x63, 0x65, 0x74, 0x52, 0x05, 0x76,
- 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xea, 0x04, 0x0a, 0x16, 0x52, 0x65, 0x73,
- 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f,
- 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01,
- 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72,
- 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72,
- 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e,
- 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
- 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x31, 0x0a, 0x07, 0x72, 0x65, 0x73,
- 0x75, 0x6c, 0x74, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x72, 0x65, 0x73,
- 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61,
- 0x62, 0x6c, 0x65, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a,
- 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x68, 0x69, 0x74, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03,
- 0x52, 0x09, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x48, 0x69, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x71,
- 0x75, 0x65, 0x72, 0x79, 0x5f, 0x63, 0x6f, 0x73, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x01, 0x52,
- 0x09, 0x71, 0x75, 0x65, 0x72, 0x79, 0x43, 0x6f, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x61,
- 0x78, 0x5f, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x01, 0x52, 0x08, 0x6d,
- 0x61, 0x78, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x41, 0x0a, 0x05, 0x66, 0x61, 0x63, 0x65, 0x74,
- 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
- 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68,
- 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x46, 0x61, 0x63, 0x65, 0x74, 0x45, 0x6e,
- 0x74, 0x72, 0x79, 0x52, 0x05, 0x66, 0x61, 0x63, 0x65, 0x74, 0x1a, 0x8f, 0x01, 0x0a, 0x05, 0x46,
- 0x61, 0x63, 0x65, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20,
- 0x01, 0x28, 0x09, 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f,
- 0x74, 0x61, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x74, 0x6f, 0x74, 0x61, 0x6c,
- 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28,
- 0x03, 0x52, 0x07, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6e, 0x67, 0x12, 0x40, 0x0a, 0x05, 0x74, 0x65,
- 0x72, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x72, 0x65, 0x73, 0x6f,
- 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61,
- 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x54, 0x65, 0x72, 0x6d,
- 0x46, 0x61, 0x63, 0x65, 0x74, 0x52, 0x05, 0x74, 0x65, 0x72, 0x6d, 0x73, 0x1a, 0x35, 0x0a, 0x09,
- 0x54, 0x65, 0x72, 0x6d, 0x46, 0x61, 0x63, 0x65, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x65, 0x72,
- 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x65, 0x72, 0x6d, 0x12, 0x14, 0x0a,
- 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f,
- 0x75, 0x6e, 0x74, 0x1a, 0x60, 0x0a, 0x0a, 0x46, 0x61, 0x63, 0x65, 0x74, 0x45, 0x6e, 0x74, 0x72,
- 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
- 0x6b, 0x65, 0x79, 0x12, 0x3c, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01,
- 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65,
- 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70,
- 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x46, 0x61, 0x63, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75,
- 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x85, 0x01, 0x0a, 0x19, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61,
- 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75,
- 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65,
- 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65,
- 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x6e,
- 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09,
- 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e,
- 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x0e, 0x0a,
- 0x02, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0xd4, 0x02,
- 0x0a, 0x1a, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a,
- 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a, 0x05,
- 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x72, 0x65,
- 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67,
- 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
- 0x65, 0x2e, 0x49, 0x74, 0x65, 0x6d, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x12, 0x26, 0x0a,
- 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e,
- 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65,
- 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x03,
+ 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x56, 0x32, 0x52,
+ 0x0e, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x56, 0x32, 0x22,
+ 0x2b, 0x0a, 0x06, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x09, 0x0a, 0x05, 0x53, 0x54, 0x4f,
+ 0x52, 0x45, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x48, 0x49, 0x53, 0x54, 0x4f, 0x52, 0x59, 0x10,
+ 0x01, 0x12, 0x09, 0x0a, 0x05, 0x54, 0x52, 0x41, 0x53, 0x48, 0x10, 0x02, 0x42, 0x10, 0x0a, 0x0e,
+ 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x22, 0xf1,
+ 0x01, 0x0a, 0x0c, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
+ 0x2f, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19,
+ 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72,
+ 0x63, 0x65, 0x57, 0x72, 0x61, 0x70, 0x70, 0x65, 0x72, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73,
+ 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f,
+ 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50,
+ 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f,
+ 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01,
+ 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73,
+ 0x69, 0x6f, 0x6e, 0x12, 0x30, 0x0a, 0x14, 0x72, 0x65, 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x67,
+ 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28,
+ 0x03, 0x52, 0x12, 0x72, 0x65, 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x67, 0x49, 0x74, 0x65, 0x6d,
+ 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x05,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e,
0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72,
- 0x6f, 0x72, 0x1a, 0x9f, 0x01, 0x0a, 0x04, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x2d, 0x0a, 0x06, 0x6f,
- 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65,
- 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b,
- 0x65, 0x79, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61,
- 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x12,
- 0x0a, 0x04, 0x68, 0x61, 0x73, 0x68, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x61,
- 0x73, 0x68, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03,
- 0x52, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x18,
- 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x12, 0x16, 0x0a, 0x06,
- 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f,
- 0x6c, 0x64, 0x65, 0x72, 0x22, 0x5e, 0x0a, 0x1a, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x4d, 0x61, 0x6e,
- 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65,
- 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18,
- 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65,
- 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
- 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09,
- 0x52, 0x02, 0x69, 0x64, 0x22, 0x92, 0x02, 0x0a, 0x1b, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x4d, 0x61,
- 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70,
- 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x49, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20,
- 0x03, 0x28, 0x0b, 0x32, 0x33, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x43,
- 0x6f, 0x75, 0x6e, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63,
- 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75,
- 0x72, 0x63, 0x65, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x12,
- 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15,
- 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52,
- 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x1a, 0x7b, 0x0a, 0x0d,
- 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x12, 0x0a,
- 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e,
- 0x64, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69,
- 0x64, 0x12, 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09,
- 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75,
- 0x72, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75,
- 0x72, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01,
- 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x2e, 0x0a, 0x12, 0x48, 0x65, 0x61,
- 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
- 0x18, 0x0a, 0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
- 0x52, 0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x22, 0xab, 0x01, 0x0a, 0x13, 0x48, 0x65,
- 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
- 0x65, 0x12, 0x43, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28,
- 0x0e, 0x32, 0x2b, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x65, 0x61,
- 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
- 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06,
- 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x4f, 0x0a, 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e,
- 0x67, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f,
- 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x53, 0x45, 0x52, 0x56, 0x49, 0x4e, 0x47, 0x10,
- 0x01, 0x12, 0x0f, 0x0a, 0x0b, 0x4e, 0x4f, 0x54, 0x5f, 0x53, 0x45, 0x52, 0x56, 0x49, 0x4e, 0x47,
- 0x10, 0x02, 0x12, 0x13, 0x0a, 0x0f, 0x53, 0x45, 0x52, 0x56, 0x49, 0x43, 0x45, 0x5f, 0x55, 0x4e,
- 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x03, 0x22, 0x87, 0x02, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f,
- 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x41, 0x0a, 0x07, 0x63, 0x6f, 0x6c,
- 0x75, 0x6d, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x72, 0x65, 0x73,
- 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61,
- 0x62, 0x6c, 0x65, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74,
- 0x69, 0x6f, 0x6e, 0x52, 0x07, 0x63, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x73, 0x12, 0x2e, 0x0a, 0x04,
- 0x72, 0x6f, 0x77, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x72, 0x65, 0x73,
- 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61,
- 0x62, 0x6c, 0x65, 0x52, 0x6f, 0x77, 0x52, 0x04, 0x72, 0x6f, 0x77, 0x73, 0x12, 0x26, 0x0a, 0x0f,
- 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18,
- 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54,
- 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
- 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f,
- 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12,
- 0x30, 0x0a, 0x14, 0x72, 0x65, 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x69, 0x74, 0x65,
- 0x6d, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x12, 0x72,
- 0x65, 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x67, 0x49, 0x74, 0x65, 0x6d, 0x43, 0x6f, 0x75, 0x6e,
- 0x74, 0x22, 0xf1, 0x04, 0x0a, 0x1d, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61,
- 0x62, 0x6c, 0x65, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74,
- 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,
- 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x46, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18,
- 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x32, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
- 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6f,
- 0x6c, 0x75, 0x6d, 0x6e, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x43,
- 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12,
- 0x19, 0x0a, 0x08, 0x69, 0x73, 0x5f, 0x61, 0x72, 0x72, 0x61, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28,
- 0x08, 0x52, 0x07, 0x69, 0x73, 0x41, 0x72, 0x72, 0x61, 0x79, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65,
- 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52,
- 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x52, 0x0a, 0x0a,
- 0x70, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b,
- 0x32, 0x32, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f,
- 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x44,
- 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x50, 0x72, 0x6f, 0x70, 0x65, 0x72,
- 0x74, 0x69, 0x65, 0x73, 0x52, 0x0a, 0x70, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73,
- 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x69, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x18, 0x06, 0x20, 0x01,
- 0x28, 0x05, 0x52, 0x08, 0x70, 0x72, 0x69, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x1a, 0xae, 0x01, 0x0a,
- 0x0a, 0x50, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x75,
- 0x6e, 0x69, 0x71, 0x75, 0x65, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x01, 0x20, 0x01,
- 0x28, 0x08, 0x52, 0x0c, 0x75, 0x6e, 0x69, 0x71, 0x75, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73,
- 0x12, 0x1b, 0x0a, 0x09, 0x66, 0x72, 0x65, 0x65, 0x5f, 0x74, 0x65, 0x78, 0x74, 0x18, 0x02, 0x20,
- 0x01, 0x28, 0x08, 0x52, 0x08, 0x66, 0x72, 0x65, 0x65, 0x54, 0x65, 0x78, 0x74, 0x12, 0x1e, 0x0a,
- 0x0a, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28,
- 0x08, 0x52, 0x0a, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x19, 0x0a,
- 0x08, 0x6e, 0x6f, 0x74, 0x5f, 0x6e, 0x75, 0x6c, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52,
- 0x07, 0x6e, 0x6f, 0x74, 0x4e, 0x75, 0x6c, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, 0x65, 0x66, 0x61,
- 0x75, 0x6c, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52,
- 0x0c, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x95, 0x01,
- 0x0a, 0x0a, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x10, 0x0a, 0x0c,
- 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x10, 0x00, 0x12, 0x0a,
- 0x0a, 0x06, 0x53, 0x54, 0x52, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x42, 0x4f,
- 0x4f, 0x4c, 0x45, 0x41, 0x4e, 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x49, 0x4e, 0x54, 0x33, 0x32,
- 0x10, 0x03, 0x12, 0x09, 0x0a, 0x05, 0x49, 0x4e, 0x54, 0x36, 0x34, 0x10, 0x04, 0x12, 0x09, 0x0a,
- 0x05, 0x46, 0x4c, 0x4f, 0x41, 0x54, 0x10, 0x05, 0x12, 0x0a, 0x0a, 0x06, 0x44, 0x4f, 0x55, 0x42,
- 0x4c, 0x45, 0x10, 0x06, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x41, 0x54, 0x45, 0x10, 0x07, 0x12, 0x0d,
- 0x0a, 0x09, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x49, 0x4d, 0x45, 0x10, 0x08, 0x12, 0x0a, 0x0a,
- 0x06, 0x42, 0x49, 0x4e, 0x41, 0x52, 0x59, 0x10, 0x09, 0x12, 0x0a, 0x0a, 0x06, 0x4f, 0x42, 0x4a,
- 0x45, 0x43, 0x54, 0x10, 0x0a, 0x22, 0x94, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72,
- 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x6f, 0x77, 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65,
- 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72,
- 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03,
- 0x6b, 0x65, 0x79, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f,
- 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72,
- 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x14,
- 0x0a, 0x05, 0x63, 0x65, 0x6c, 0x6c, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x05, 0x63,
- 0x65, 0x6c, 0x6c, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x04,
- 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x64, 0x0a, 0x0e,
- 0x52, 0x65, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27,
- 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65,
- 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b,
- 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75,
- 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28,
- 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69,
- 0x6f, 0x6e, 0x22, 0x69, 0x0a, 0x0f, 0x52, 0x65, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x52, 0x65, 0x73,
+ 0x6f, 0x72, 0x22, 0xb9, 0x01, 0x0a, 0x0c, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75,
+ 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x69, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01,
+ 0x28, 0x03, 0x52, 0x05, 0x73, 0x69, 0x6e, 0x63, 0x65, 0x12, 0x2f, 0x0a, 0x07, 0x6f, 0x70, 0x74,
+ 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73,
+ 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e,
+ 0x73, 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x2e, 0x0a, 0x13, 0x73, 0x65,
+ 0x6e, 0x64, 0x5f, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x5f, 0x65, 0x76, 0x65, 0x6e, 0x74,
+ 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x73, 0x65, 0x6e, 0x64, 0x49, 0x6e, 0x69,
+ 0x74, 0x69, 0x61, 0x6c, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x32, 0x0a, 0x15, 0x61, 0x6c,
+ 0x6c, 0x6f, 0x77, 0x5f, 0x77, 0x61, 0x74, 0x63, 0x68, 0x5f, 0x62, 0x6f, 0x6f, 0x6b, 0x6d, 0x61,
+ 0x72, 0x6b, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x61, 0x6c, 0x6c, 0x6f, 0x77,
+ 0x57, 0x61, 0x74, 0x63, 0x68, 0x42, 0x6f, 0x6f, 0x6b, 0x6d, 0x61, 0x72, 0x6b, 0x73, 0x22, 0xdf,
+ 0x02, 0x0a, 0x0a, 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x1c, 0x0a,
+ 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03,
+ 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x2d, 0x0a, 0x04, 0x74,
+ 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x72, 0x65, 0x73, 0x6f,
+ 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e,
+ 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x39, 0x0a, 0x08, 0x72, 0x65,
+ 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x72,
+ 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65,
+ 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x08, 0x72, 0x65, 0x73,
+ 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x39, 0x0a, 0x08, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75,
+ 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72,
+ 0x63, 0x65, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65,
+ 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x08, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73,
+ 0x1a, 0x3a, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x18, 0x0a, 0x07,
+ 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x76,
+ 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18,
+ 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x52, 0x0a, 0x04,
+ 0x54, 0x79, 0x70, 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10,
+ 0x00, 0x12, 0x09, 0x0a, 0x05, 0x41, 0x44, 0x44, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0c, 0x0a, 0x08,
+ 0x4d, 0x4f, 0x44, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x02, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45,
+ 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x03, 0x12, 0x0c, 0x0a, 0x08, 0x42, 0x4f, 0x4f, 0x4b, 0x4d,
+ 0x41, 0x52, 0x4b, 0x10, 0x04, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x05,
+ 0x22, 0xd7, 0x01, 0x0a, 0x0b, 0x42, 0x75, 0x6c, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
+ 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e,
+ 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
+ 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x34, 0x0a, 0x06, 0x61, 0x63, 0x74,
+ 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1c, 0x2e, 0x72, 0x65, 0x73, 0x6f,
+ 0x75, 0x72, 0x63, 0x65, 0x2e, 0x42, 0x75, 0x6c, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
+ 0x2e, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12,
+ 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05,
+ 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18,
+ 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x22, 0x3b, 0x0a,
+ 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f,
+ 0x57, 0x4e, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x41, 0x44, 0x44, 0x45, 0x44, 0x10, 0x01, 0x12,
+ 0x0c, 0x0a, 0x08, 0x4d, 0x4f, 0x44, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x02, 0x12, 0x0b, 0x0a,
+ 0x07, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x03, 0x22, 0xda, 0x04, 0x0a, 0x0c, 0x42,
+ 0x75, 0x6c, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x05, 0x65,
+ 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73,
+ 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c,
+ 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x1c, 0x0a, 0x09, 0x70, 0x72, 0x6f, 0x63,
+ 0x65, 0x73, 0x73, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x70, 0x72, 0x6f,
+ 0x63, 0x65, 0x73, 0x73, 0x65, 0x64, 0x12, 0x38, 0x0a, 0x07, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72,
+ 0x79, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72,
+ 0x63, 0x65, 0x2e, 0x42, 0x75, 0x6c, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e,
+ 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x52, 0x07, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79,
+ 0x12, 0x3b, 0x0a, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x04, 0x20, 0x03,
+ 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x42, 0x75,
+ 0x6c, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63,
+ 0x74, 0x65, 0x64, 0x52, 0x08, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x1a, 0x86, 0x02,
+ 0x0a, 0x07, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d,
+ 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61,
+ 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70,
+ 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1a, 0x0a,
+ 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
+ 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75,
+ 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12,
+ 0x18, 0x0a, 0x07, 0x68, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03,
+ 0x52, 0x07, 0x68, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73,
+ 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x06, 0x20,
+ 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72,
+ 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73,
+ 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x70, 0x72,
+ 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x29, 0x0a, 0x10, 0x70,
+ 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x5f, 0x68, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x18,
+ 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x48,
+ 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x1a, 0x7f, 0x0a, 0x08, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74,
+ 0x65, 0x64, 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
+ 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75,
+ 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x34, 0x0a, 0x06, 0x61,
+ 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1c, 0x2e, 0x72, 0x65,
+ 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x42, 0x75, 0x6c, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65,
+ 0x73, 0x74, 0x2e, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f,
+ 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x62, 0x0a, 0x14, 0x52, 0x65, 0x73, 0x6f, 0x75,
+ 0x72, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
+ 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01,
+ 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x14, 0x0a,
+ 0x05, 0x6b, 0x69, 0x6e, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x6b, 0x69,
+ 0x6e, 0x64, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x03, 0x20,
+ 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x22, 0xd2, 0x01, 0x0a, 0x15,
+ 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e,
0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72,
- 0x6f, 0x72, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76,
- 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65,
- 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0xd3, 0x01,
- 0x0a, 0x0e, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
- 0x12, 0x31, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01,
- 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65,
- 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75,
- 0x72, 0x63, 0x65, 0x12, 0x37, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x18, 0x02, 0x20,
- 0x01, 0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x50,
- 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65,
- 0x74, 0x68, 0x6f, 0x64, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, 0x21, 0x0a, 0x0c,
- 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01,
- 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12,
- 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05,
- 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x1c, 0x0a, 0x06, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12,
- 0x08, 0x0a, 0x04, 0x47, 0x52, 0x50, 0x43, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x48, 0x54, 0x54,
- 0x50, 0x10, 0x01, 0x22, 0xc1, 0x01, 0x0a, 0x0f, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52,
- 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72,
- 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
- 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65,
- 0x72, 0x72, 0x6f, 0x72, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28,
- 0x09, 0x52, 0x03, 0x75, 0x69, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20,
- 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65,
- 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x12, 0x0a, 0x04,
- 0x68, 0x61, 0x73, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x61, 0x73, 0x68,
- 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x69, 0x6d, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x06, 0x20,
- 0x01, 0x28, 0x09, 0x52, 0x08, 0x6d, 0x69, 0x6d, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a,
- 0x07, 0x63, 0x68, 0x61, 0x72, 0x73, 0x65, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
- 0x63, 0x68, 0x61, 0x72, 0x73, 0x65, 0x74, 0x22, 0xaa, 0x01, 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x42,
- 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x31, 0x0a, 0x08, 0x72, 0x65,
- 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72,
- 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
- 0x4b, 0x65, 0x79, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x29, 0x0a,
- 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f,
- 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
- 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x28, 0x0a, 0x10, 0x6d, 0x75, 0x73, 0x74,
- 0x5f, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x03, 0x20, 0x01,
- 0x28, 0x08, 0x52, 0x0e, 0x6d, 0x75, 0x73, 0x74, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x42, 0x79, 0x74,
- 0x65, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52,
- 0x03, 0x75, 0x69, 0x64, 0x22, 0x89, 0x01, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62,
- 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f,
- 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72,
- 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05,
- 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01,
- 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x74, 0x65,
- 0x6e, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63,
- 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61,
- 0x6c, 0x75, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65,
- 0x2a, 0x33, 0x0a, 0x14, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73,
- 0x69, 0x6f, 0x6e, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x12, 0x10, 0x0a, 0x0c, 0x4e, 0x6f, 0x74, 0x4f,
- 0x6c, 0x64, 0x65, 0x72, 0x54, 0x68, 0x61, 0x6e, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x78,
- 0x61, 0x63, 0x74, 0x10, 0x01, 0x32, 0xad, 0x03, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72,
- 0x63, 0x65, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x35, 0x0a, 0x04, 0x52, 0x65, 0x61, 0x64, 0x12,
- 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52,
- 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
- 0x65, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b,
- 0x0a, 0x06, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75,
- 0x72, 0x63, 0x65, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
- 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x43, 0x72, 0x65,
- 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x55,
- 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
- 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18,
- 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65,
- 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65,
- 0x74, 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x44, 0x65,
- 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65,
- 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x73,
- 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, 0x07, 0x52, 0x65, 0x73, 0x74, 0x6f, 0x72, 0x65,
- 0x12, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x74,
- 0x6f, 0x72, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x72, 0x65, 0x73,
- 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x52, 0x65, 0x73,
- 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x35, 0x0a, 0x04, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x15, 0x2e,
- 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71,
- 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e,
- 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x37, 0x0a, 0x05,
- 0x57, 0x61, 0x74, 0x63, 0x68, 0x12, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
- 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x14, 0x2e,
- 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76,
- 0x65, 0x6e, 0x74, 0x30, 0x01, 0x32, 0x4b, 0x0a, 0x09, 0x42, 0x75, 0x6c, 0x6b, 0x53, 0x74, 0x6f,
- 0x72, 0x65, 0x12, 0x3e, 0x0a, 0x0b, 0x42, 0x75, 0x6c, 0x6b, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73,
- 0x73, 0x12, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x42, 0x75, 0x6c,
- 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75,
- 0x72, 0x63, 0x65, 0x2e, 0x42, 0x75, 0x6c, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
- 0x28, 0x01, 0x32, 0xa9, 0x01, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49,
- 0x6e, 0x64, 0x65, 0x78, 0x12, 0x4b, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x1f,
+ 0x6f, 0x72, 0x12, 0x3b, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28,
+ 0x0b, 0x32, 0x25, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73,
+ 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
+ 0x73, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x73, 0x1a,
+ 0x4f, 0x0a, 0x05, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75,
+ 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1a,
+ 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f,
+ 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74,
+ 0x22, 0x8e, 0x05, 0x0a, 0x15, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61,
+ 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2f, 0x0a, 0x07, 0x6f, 0x70,
+ 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65,
+ 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f,
+ 0x6e, 0x73, 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x66,
+ 0x65, 0x64, 0x65, 0x72, 0x61, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15,
0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72,
- 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
- 0x20, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75,
+ 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x09, 0x66, 0x65, 0x64, 0x65, 0x72, 0x61, 0x74, 0x65, 0x64,
+ 0x12, 0x14, 0x0a, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
+ 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18,
+ 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x16, 0x0a, 0x06,
+ 0x6f, 0x66, 0x66, 0x73, 0x65, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x6f, 0x66,
+ 0x66, 0x73, 0x65, 0x74, 0x12, 0x3c, 0x0a, 0x06, 0x73, 0x6f, 0x72, 0x74, 0x42, 0x79, 0x18, 0x06,
+ 0x20, 0x03, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e,
+ 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65,
+ 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x53, 0x6f, 0x72, 0x74, 0x52, 0x06, 0x73, 0x6f, 0x72, 0x74,
+ 0x42, 0x79, 0x12, 0x40, 0x0a, 0x05, 0x66, 0x61, 0x63, 0x65, 0x74, 0x18, 0x07, 0x20, 0x03, 0x28,
+ 0x0b, 0x32, 0x2a, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73,
+ 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65,
+ 0x73, 0x74, 0x2e, 0x46, 0x61, 0x63, 0x65, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x05, 0x66,
+ 0x61, 0x63, 0x65, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x08,
+ 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12, 0x18, 0x0a, 0x07,
+ 0x65, 0x78, 0x70, 0x6c, 0x61, 0x69, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65,
+ 0x78, 0x70, 0x6c, 0x61, 0x69, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x69, 0x73, 0x5f, 0x64, 0x65, 0x6c,
+ 0x65, 0x74, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x69, 0x73, 0x44, 0x65,
+ 0x6c, 0x65, 0x74, 0x65, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x67, 0x65, 0x18, 0x0b, 0x20,
+ 0x01, 0x28, 0x03, 0x52, 0x04, 0x70, 0x61, 0x67, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x70, 0x65, 0x72,
+ 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x70,
+ 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x1a, 0x30, 0x0a, 0x04, 0x53, 0x6f, 0x72,
+ 0x74, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x65, 0x73, 0x63, 0x18,
+ 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x64, 0x65, 0x73, 0x63, 0x1a, 0x33, 0x0a, 0x05, 0x46,
+ 0x61, 0x63, 0x65, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20,
+ 0x01, 0x28, 0x09, 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69,
+ 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74,
+ 0x1a, 0x5f, 0x0a, 0x0a, 0x46, 0x61, 0x63, 0x65, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10,
+ 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79,
+ 0x12, 0x3b, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,
+ 0x25, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75,
+ 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
+ 0x2e, 0x46, 0x61, 0x63, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38,
+ 0x01, 0x22, 0xea, 0x04, 0x0a, 0x16, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65,
+ 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2b, 0x0a, 0x05,
+ 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65,
+ 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75,
+ 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79,
+ 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
+ 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b,
+ 0x65, 0x79, 0x12, 0x31, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x18, 0x03, 0x20,
+ 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52,
+ 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x07, 0x72, 0x65,
+ 0x73, 0x75, 0x6c, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x68,
+ 0x69, 0x74, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, 0x6f, 0x74, 0x61, 0x6c,
+ 0x48, 0x69, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x71, 0x75, 0x65, 0x72, 0x79, 0x5f, 0x63, 0x6f,
+ 0x73, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x01, 0x52, 0x09, 0x71, 0x75, 0x65, 0x72, 0x79, 0x43,
+ 0x6f, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x61, 0x78, 0x5f, 0x73, 0x63, 0x6f, 0x72, 0x65,
+ 0x18, 0x06, 0x20, 0x01, 0x28, 0x01, 0x52, 0x08, 0x6d, 0x61, 0x78, 0x53, 0x63, 0x6f, 0x72, 0x65,
+ 0x12, 0x41, 0x0a, 0x05, 0x66, 0x61, 0x63, 0x65, 0x74, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32,
+ 0x2b, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75,
0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
- 0x65, 0x12, 0x4b, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x1e, 0x2e,
- 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
- 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e,
- 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
- 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xd9,
- 0x01, 0x0a, 0x12, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74,
- 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x62, 0x0a, 0x13, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x4d, 0x61,
- 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x12, 0x24, 0x2e, 0x72,
- 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x4d, 0x61, 0x6e,
- 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65,
- 0x73, 0x74, 0x1a, 0x25, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x43, 0x6f,
- 0x75, 0x6e, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74,
- 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5f, 0x0a, 0x12, 0x4c, 0x69, 0x73,
- 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x12,
- 0x23, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4d,
- 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x71,
- 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e,
+ 0x65, 0x2e, 0x46, 0x61, 0x63, 0x65, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x05, 0x66, 0x61,
+ 0x63, 0x65, 0x74, 0x1a, 0x8f, 0x01, 0x0a, 0x05, 0x46, 0x61, 0x63, 0x65, 0x74, 0x12, 0x14, 0x0a,
+ 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x66, 0x69,
+ 0x65, 0x6c, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x18, 0x02, 0x20, 0x01,
+ 0x28, 0x03, 0x52, 0x05, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x69, 0x73,
+ 0x73, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x6d, 0x69, 0x73, 0x73,
+ 0x69, 0x6e, 0x67, 0x12, 0x40, 0x0a, 0x05, 0x74, 0x65, 0x72, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x03,
+ 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65,
+ 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70,
+ 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x54, 0x65, 0x72, 0x6d, 0x46, 0x61, 0x63, 0x65, 0x74, 0x52, 0x05,
+ 0x74, 0x65, 0x72, 0x6d, 0x73, 0x1a, 0x35, 0x0a, 0x09, 0x54, 0x65, 0x72, 0x6d, 0x46, 0x61, 0x63,
+ 0x65, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x65, 0x72, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x04, 0x74, 0x65, 0x72, 0x6d, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18,
+ 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x1a, 0x60, 0x0a, 0x0a,
+ 0x46, 0x61, 0x63, 0x65, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65,
+ 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x3c, 0x0a, 0x05,
+ 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x72, 0x65,
+ 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53,
+ 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x46, 0x61,
+ 0x63, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x85,
+ 0x01, 0x0a, 0x19, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62,
+ 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f,
+ 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18,
+ 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54,
+ 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63,
+ 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61,
+ 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01,
+ 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0xd4, 0x02, 0x0a, 0x1a, 0x4c, 0x69, 0x73, 0x74, 0x4d,
+ 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73,
+ 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01,
+ 0x20, 0x03, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e,
0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63,
- 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x8b, 0x01, 0x0a, 0x09, 0x42,
- 0x6c, 0x6f, 0x62, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x3e, 0x0a, 0x07, 0x50, 0x75, 0x74, 0x42,
- 0x6c, 0x6f, 0x62, 0x12, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x50,
- 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e,
- 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62,
- 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, 0x07, 0x47, 0x65, 0x74, 0x42,
- 0x6c, 0x6f, 0x62, 0x12, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x47,
- 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e,
- 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62,
- 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x57, 0x0a, 0x0b, 0x44, 0x69, 0x61, 0x67,
- 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x73, 0x12, 0x48, 0x0a, 0x09, 0x49, 0x73, 0x48, 0x65, 0x61,
- 0x6c, 0x74, 0x68, 0x79, 0x12, 0x1c, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e,
- 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65,
- 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x65,
- 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
- 0x65, 0x42, 0x39, 0x5a, 0x37, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f,
- 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f,
- 0x70, 0x6b, 0x67, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x2f, 0x75, 0x6e, 0x69, 0x66,
- 0x69, 0x65, 0x64, 0x2f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x62, 0x06, 0x70, 0x72,
- 0x6f, 0x74, 0x6f, 0x33,
+ 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x49, 0x74, 0x65, 0x6d, 0x52,
+ 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70,
+ 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
+ 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x2b,
+ 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e,
+ 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65,
+ 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x1a, 0x9f, 0x01, 0x0a, 0x04,
+ 0x49, 0x74, 0x65, 0x6d, 0x12, 0x2d, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01,
+ 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e,
+ 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x06, 0x6f, 0x62, 0x6a,
+ 0x65, 0x63, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28,
+ 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x61, 0x73, 0x68, 0x18,
+ 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x61, 0x73, 0x68, 0x12, 0x12, 0x0a, 0x04, 0x74,
+ 0x69, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x74, 0x69, 0x6d, 0x65, 0x12,
+ 0x14, 0x0a, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05,
+ 0x74, 0x69, 0x74, 0x6c, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x18,
+ 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x6f, 0x6c, 0x64, 0x65, 0x72, 0x22, 0x5e, 0x0a,
+ 0x1a, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a,
+ 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x6e,
+ 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09,
+ 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e,
+ 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x0e, 0x0a,
+ 0x02, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x92, 0x02,
+ 0x0a, 0x1b, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62,
+ 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x49, 0x0a,
+ 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x33, 0x2e, 0x72,
+ 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x4d, 0x61, 0x6e,
+ 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f,
+ 0x6e, 0x73, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x43, 0x6f, 0x75, 0x6e,
+ 0x74, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f,
+ 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72,
+ 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05,
+ 0x65, 0x72, 0x72, 0x6f, 0x72, 0x1a, 0x7b, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
+ 0x65, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x01,
+ 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64,
+ 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x67, 0x72,
+ 0x6f, 0x75, 0x70, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70,
+ 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01,
+ 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x14, 0x0a, 0x05,
+ 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75,
+ 0x6e, 0x74, 0x22, 0x2e, 0x0a, 0x12, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63,
+ 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x65, 0x72, 0x76,
+ 0x69, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x65, 0x72, 0x76, 0x69,
+ 0x63, 0x65, 0x22, 0xab, 0x01, 0x0a, 0x13, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65,
+ 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x06, 0x73, 0x74,
+ 0x61, 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2b, 0x2e, 0x72, 0x65, 0x73,
+ 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63,
+ 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e,
+ 0x67, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22,
+ 0x4f, 0x0a, 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73,
+ 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0b, 0x0a,
+ 0x07, 0x53, 0x45, 0x52, 0x56, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x0f, 0x0a, 0x0b, 0x4e, 0x4f,
+ 0x54, 0x5f, 0x53, 0x45, 0x52, 0x56, 0x49, 0x4e, 0x47, 0x10, 0x02, 0x12, 0x13, 0x0a, 0x0f, 0x53,
+ 0x45, 0x52, 0x56, 0x49, 0x43, 0x45, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x03,
+ 0x22, 0x87, 0x02, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62,
+ 0x6c, 0x65, 0x12, 0x41, 0x0a, 0x07, 0x63, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x73, 0x18, 0x01, 0x20,
+ 0x03, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52,
+ 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6f, 0x6c, 0x75,
+ 0x6d, 0x6e, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x07, 0x63, 0x6f,
+ 0x6c, 0x75, 0x6d, 0x6e, 0x73, 0x12, 0x2e, 0x0a, 0x04, 0x72, 0x6f, 0x77, 0x73, 0x18, 0x02, 0x20,
+ 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52,
+ 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x6f, 0x77, 0x52,
+ 0x04, 0x72, 0x6f, 0x77, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61,
+ 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d,
+ 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x29, 0x0a,
+ 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f,
+ 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
+ 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x30, 0x0a, 0x14, 0x72, 0x65, 0x6d, 0x61,
+ 0x69, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74,
+ 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x12, 0x72, 0x65, 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e,
+ 0x67, 0x49, 0x74, 0x65, 0x6d, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0xf1, 0x04, 0x0a, 0x1d, 0x52,
+ 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6f, 0x6c, 0x75,
+ 0x6d, 0x6e, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04,
+ 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65,
+ 0x12, 0x46, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x32,
+ 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72,
+ 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x44, 0x65, 0x66,
+ 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x54, 0x79,
+ 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x69, 0x73, 0x5f, 0x61,
+ 0x72, 0x72, 0x61, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x69, 0x73, 0x41, 0x72,
+ 0x72, 0x61, 0x79, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69,
+ 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69,
+ 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x52, 0x0a, 0x0a, 0x70, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74,
+ 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x72, 0x65, 0x73, 0x6f,
+ 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62,
+ 0x6c, 0x65, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69,
+ 0x6f, 0x6e, 0x2e, 0x50, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x52, 0x0a, 0x70,
+ 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x69,
+ 0x6f, 0x72, 0x69, 0x74, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x70, 0x72, 0x69,
+ 0x6f, 0x72, 0x69, 0x74, 0x79, 0x1a, 0xae, 0x01, 0x0a, 0x0a, 0x50, 0x72, 0x6f, 0x70, 0x65, 0x72,
+ 0x74, 0x69, 0x65, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x75, 0x6e, 0x69, 0x71, 0x75, 0x65, 0x5f, 0x76,
+ 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x75, 0x6e, 0x69,
+ 0x71, 0x75, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x66, 0x72, 0x65,
+ 0x65, 0x5f, 0x74, 0x65, 0x78, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x66, 0x72,
+ 0x65, 0x65, 0x54, 0x65, 0x78, 0x74, 0x12, 0x1e, 0x0a, 0x0a, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72,
+ 0x61, 0x62, 0x6c, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x66, 0x69, 0x6c, 0x74,
+ 0x65, 0x72, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x6e, 0x6f, 0x74, 0x5f, 0x6e, 0x75,
+ 0x6c, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x6e, 0x6f, 0x74, 0x4e, 0x75, 0x6c,
+ 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x5f, 0x76, 0x61, 0x6c,
+ 0x75, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0c, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c,
+ 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x95, 0x01, 0x0a, 0x0a, 0x43, 0x6f, 0x6c, 0x75, 0x6d,
+ 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x10, 0x0a, 0x0c, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e,
+ 0x5f, 0x54, 0x59, 0x50, 0x45, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x53, 0x54, 0x52, 0x49, 0x4e,
+ 0x47, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x42, 0x4f, 0x4f, 0x4c, 0x45, 0x41, 0x4e, 0x10, 0x02,
+ 0x12, 0x09, 0x0a, 0x05, 0x49, 0x4e, 0x54, 0x33, 0x32, 0x10, 0x03, 0x12, 0x09, 0x0a, 0x05, 0x49,
+ 0x4e, 0x54, 0x36, 0x34, 0x10, 0x04, 0x12, 0x09, 0x0a, 0x05, 0x46, 0x4c, 0x4f, 0x41, 0x54, 0x10,
+ 0x05, 0x12, 0x0a, 0x0a, 0x06, 0x44, 0x4f, 0x55, 0x42, 0x4c, 0x45, 0x10, 0x06, 0x12, 0x08, 0x0a,
+ 0x04, 0x44, 0x41, 0x54, 0x45, 0x10, 0x07, 0x12, 0x0d, 0x0a, 0x09, 0x44, 0x41, 0x54, 0x45, 0x5f,
+ 0x54, 0x49, 0x4d, 0x45, 0x10, 0x08, 0x12, 0x0a, 0x0a, 0x06, 0x42, 0x49, 0x4e, 0x41, 0x52, 0x59,
+ 0x10, 0x09, 0x12, 0x0a, 0x0a, 0x06, 0x4f, 0x42, 0x4a, 0x45, 0x43, 0x54, 0x10, 0x0a, 0x22, 0x94,
+ 0x01, 0x0a, 0x10, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x61, 0x62, 0x6c, 0x65,
+ 0x52, 0x6f, 0x77, 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b,
+ 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f,
+ 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x29, 0x0a, 0x10,
+ 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e,
+ 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
+ 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x65, 0x6c, 0x6c, 0x73,
+ 0x18, 0x03, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x05, 0x63, 0x65, 0x6c, 0x6c, 0x73, 0x12, 0x16, 0x0a,
+ 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x6f,
+ 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0xd3, 0x01, 0x0a, 0x0e, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f,
+ 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x31, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f,
+ 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73,
+ 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65,
+ 0x79, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x37, 0x0a, 0x06, 0x6d,
+ 0x65, 0x74, 0x68, 0x6f, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x72, 0x65,
+ 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65,
+ 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x52, 0x06, 0x6d, 0x65,
+ 0x74, 0x68, 0x6f, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x5f,
+ 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x74,
+ 0x65, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65,
+ 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x1c, 0x0a,
+ 0x06, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, 0x08, 0x0a, 0x04, 0x47, 0x52, 0x50, 0x43, 0x10,
+ 0x00, 0x12, 0x08, 0x0a, 0x04, 0x48, 0x54, 0x54, 0x50, 0x10, 0x01, 0x22, 0xc1, 0x01, 0x0a, 0x0f,
+ 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
+ 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15,
+ 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52,
+ 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x10, 0x0a, 0x03,
+ 0x75, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x69, 0x64, 0x12, 0x10,
+ 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c,
+ 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04,
+ 0x73, 0x69, 0x7a, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x61, 0x73, 0x68, 0x18, 0x05, 0x20, 0x01,
+ 0x28, 0x09, 0x52, 0x04, 0x68, 0x61, 0x73, 0x68, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x69, 0x6d, 0x65,
+ 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6d, 0x69, 0x6d,
+ 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x68, 0x61, 0x72, 0x73, 0x65, 0x74,
+ 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x68, 0x61, 0x72, 0x73, 0x65, 0x74, 0x22,
+ 0xaa, 0x01, 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65,
+ 0x73, 0x74, 0x12, 0x31, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01,
+ 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e,
+ 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x08, 0x72, 0x65, 0x73,
+ 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
+ 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52,
+ 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e,
+ 0x12, 0x28, 0x0a, 0x10, 0x6d, 0x75, 0x73, 0x74, 0x5f, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x62,
+ 0x79, 0x74, 0x65, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x6d, 0x75, 0x73, 0x74,
+ 0x50, 0x72, 0x6f, 0x78, 0x79, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x69,
+ 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x69, 0x64, 0x22, 0x89, 0x01, 0x0a,
+ 0x0f, 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
+ 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
+ 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72,
+ 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x10, 0x0a,
+ 0x03, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12,
+ 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18,
+ 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x54, 0x79,
+ 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28,
+ 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x2a, 0x49, 0x0a, 0x14, 0x52, 0x65, 0x73, 0x6f,
+ 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x4d, 0x61, 0x74, 0x63, 0x68,
+ 0x12, 0x1b, 0x0a, 0x17, 0x44, 0x45, 0x50, 0x52, 0x45, 0x43, 0x41, 0x54, 0x45, 0x44, 0x5f, 0x4e,
+ 0x6f, 0x74, 0x4f, 0x6c, 0x64, 0x65, 0x72, 0x54, 0x68, 0x61, 0x6e, 0x10, 0x00, 0x12, 0x14, 0x0a,
+ 0x10, 0x44, 0x45, 0x50, 0x52, 0x45, 0x43, 0x41, 0x54, 0x45, 0x44, 0x5f, 0x45, 0x78, 0x61, 0x63,
+ 0x74, 0x10, 0x01, 0x2a, 0x4d, 0x0a, 0x16, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56,
+ 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x56, 0x32, 0x12, 0x0b, 0x0a,
+ 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x55, 0x6e,
+ 0x73, 0x65, 0x74, 0x10, 0x01, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x78, 0x61, 0x63, 0x74, 0x10, 0x02,
+ 0x12, 0x10, 0x0a, 0x0c, 0x4e, 0x6f, 0x74, 0x4f, 0x6c, 0x64, 0x65, 0x72, 0x54, 0x68, 0x61, 0x6e,
+ 0x10, 0x03, 0x32, 0xed, 0x02, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53,
+ 0x74, 0x6f, 0x72, 0x65, 0x12, 0x35, 0x0a, 0x04, 0x52, 0x65, 0x61, 0x64, 0x12, 0x15, 0x2e, 0x72,
+ 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75,
+ 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52,
+ 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x43,
+ 0x72, 0x65, 0x61, 0x74, 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
+ 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18,
+ 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65,
+ 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x55, 0x70, 0x64, 0x61,
+ 0x74, 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x55, 0x70,
+ 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65,
+ 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73,
+ 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12,
+ 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74,
+ 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75,
+ 0x72, 0x63, 0x65, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
+ 0x73, 0x65, 0x12, 0x35, 0x0a, 0x04, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x15, 0x2e, 0x72, 0x65, 0x73,
+ 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
+ 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73,
+ 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x37, 0x0a, 0x05, 0x57, 0x61, 0x74,
+ 0x63, 0x68, 0x12, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61,
+ 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x14, 0x2e, 0x72, 0x65, 0x73,
+ 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74,
+ 0x30, 0x01, 0x32, 0x4b, 0x0a, 0x09, 0x42, 0x75, 0x6c, 0x6b, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12,
+ 0x3e, 0x0a, 0x0b, 0x42, 0x75, 0x6c, 0x6b, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x12, 0x15,
+ 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x42, 0x75, 0x6c, 0x6b, 0x52, 0x65,
+ 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
+ 0x2e, 0x42, 0x75, 0x6c, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x32,
+ 0xa9, 0x01, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x6e, 0x64, 0x65,
+ 0x78, 0x12, 0x4b, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x1f, 0x2e, 0x72, 0x65,
+ 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53,
+ 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x72,
+ 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
+ 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b,
+ 0x0a, 0x08, 0x47, 0x65, 0x74, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x1e, 0x2e, 0x72, 0x65, 0x73,
+ 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74,
+ 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x72, 0x65, 0x73,
+ 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74,
+ 0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xd9, 0x01, 0x0a, 0x12,
+ 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x49, 0x6e, 0x64,
+ 0x65, 0x78, 0x12, 0x62, 0x0a, 0x13, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67,
+ 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x12, 0x24, 0x2e, 0x72, 0x65, 0x73, 0x6f,
+ 0x75, 0x72, 0x63, 0x65, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+ 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
+ 0x25, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74,
+ 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65,
+ 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5f, 0x0a, 0x12, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61,
+ 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x12, 0x23, 0x2e, 0x72,
+ 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61, 0x6e, 0x61,
+ 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
+ 0x74, 0x1a, 0x24, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73,
+ 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52,
+ 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x8b, 0x01, 0x0a, 0x09, 0x42, 0x6c, 0x6f, 0x62,
+ 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x3e, 0x0a, 0x07, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62,
+ 0x12, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x50, 0x75, 0x74, 0x42,
+ 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x72, 0x65, 0x73,
+ 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x73,
+ 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, 0x07, 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62,
+ 0x12, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x42,
+ 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x72, 0x65, 0x73,
+ 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x73,
+ 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x57, 0x0a, 0x0b, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73,
+ 0x74, 0x69, 0x63, 0x73, 0x12, 0x48, 0x0a, 0x09, 0x49, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68,
+ 0x79, 0x12, 0x1c, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x65, 0x61,
+ 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
+ 0x1d, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74,
+ 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x39,
+ 0x5a, 0x37, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61,
+ 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x70, 0x6b, 0x67,
+ 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x2f, 0x75, 0x6e, 0x69, 0x66, 0x69, 0x65, 0x64,
+ 0x2f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+ 0x33,
})
var (
@@ -4644,166 +4582,162 @@ func file_resource_proto_rawDescGZIP() []byte {
return file_resource_proto_rawDescData
}
-var file_resource_proto_enumTypes = make([]protoimpl.EnumInfo, 7)
-var file_resource_proto_msgTypes = make([]protoimpl.MessageInfo, 53)
+var file_resource_proto_enumTypes = make([]protoimpl.EnumInfo, 8)
+var file_resource_proto_msgTypes = make([]protoimpl.MessageInfo, 51)
var file_resource_proto_goTypes = []any{
(ResourceVersionMatch)(0), // 0: resource.ResourceVersionMatch
- (ListRequest_Source)(0), // 1: resource.ListRequest.Source
- (WatchEvent_Type)(0), // 2: resource.WatchEvent.Type
- (BulkRequest_Action)(0), // 3: resource.BulkRequest.Action
- (HealthCheckResponse_ServingStatus)(0), // 4: resource.HealthCheckResponse.ServingStatus
- (ResourceTableColumnDefinition_ColumnType)(0), // 5: resource.ResourceTableColumnDefinition.ColumnType
- (PutBlobRequest_Method)(0), // 6: resource.PutBlobRequest.Method
- (*ResourceKey)(nil), // 7: resource.ResourceKey
- (*ResourceWrapper)(nil), // 8: resource.ResourceWrapper
- (*ErrorResult)(nil), // 9: resource.ErrorResult
- (*ErrorDetails)(nil), // 10: resource.ErrorDetails
- (*ErrorCause)(nil), // 11: resource.ErrorCause
- (*CreateRequest)(nil), // 12: resource.CreateRequest
- (*CreateResponse)(nil), // 13: resource.CreateResponse
- (*UpdateRequest)(nil), // 14: resource.UpdateRequest
- (*UpdateResponse)(nil), // 15: resource.UpdateResponse
- (*DeleteRequest)(nil), // 16: resource.DeleteRequest
- (*DeleteResponse)(nil), // 17: resource.DeleteResponse
- (*ReadRequest)(nil), // 18: resource.ReadRequest
- (*ReadResponse)(nil), // 19: resource.ReadResponse
- (*Requirement)(nil), // 20: resource.Requirement
- (*ListOptions)(nil), // 21: resource.ListOptions
- (*ListRequest)(nil), // 22: resource.ListRequest
- (*ListResponse)(nil), // 23: resource.ListResponse
- (*WatchRequest)(nil), // 24: resource.WatchRequest
- (*WatchEvent)(nil), // 25: resource.WatchEvent
- (*BulkRequest)(nil), // 26: resource.BulkRequest
- (*BulkResponse)(nil), // 27: resource.BulkResponse
- (*ResourceStatsRequest)(nil), // 28: resource.ResourceStatsRequest
- (*ResourceStatsResponse)(nil), // 29: resource.ResourceStatsResponse
- (*ResourceSearchRequest)(nil), // 30: resource.ResourceSearchRequest
- (*ResourceSearchResponse)(nil), // 31: resource.ResourceSearchResponse
- (*ListManagedObjectsRequest)(nil), // 32: resource.ListManagedObjectsRequest
- (*ListManagedObjectsResponse)(nil), // 33: resource.ListManagedObjectsResponse
- (*CountManagedObjectsRequest)(nil), // 34: resource.CountManagedObjectsRequest
- (*CountManagedObjectsResponse)(nil), // 35: resource.CountManagedObjectsResponse
- (*HealthCheckRequest)(nil), // 36: resource.HealthCheckRequest
- (*HealthCheckResponse)(nil), // 37: resource.HealthCheckResponse
- (*ResourceTable)(nil), // 38: resource.ResourceTable
- (*ResourceTableColumnDefinition)(nil), // 39: resource.ResourceTableColumnDefinition
- (*ResourceTableRow)(nil), // 40: resource.ResourceTableRow
- (*RestoreRequest)(nil), // 41: resource.RestoreRequest
- (*RestoreResponse)(nil), // 42: resource.RestoreResponse
- (*PutBlobRequest)(nil), // 43: resource.PutBlobRequest
- (*PutBlobResponse)(nil), // 44: resource.PutBlobResponse
- (*GetBlobRequest)(nil), // 45: resource.GetBlobRequest
- (*GetBlobResponse)(nil), // 46: resource.GetBlobResponse
- (*WatchEvent_Resource)(nil), // 47: resource.WatchEvent.Resource
- (*BulkResponse_Summary)(nil), // 48: resource.BulkResponse.Summary
- (*BulkResponse_Rejected)(nil), // 49: resource.BulkResponse.Rejected
- (*ResourceStatsResponse_Stats)(nil), // 50: resource.ResourceStatsResponse.Stats
- (*ResourceSearchRequest_Sort)(nil), // 51: resource.ResourceSearchRequest.Sort
- (*ResourceSearchRequest_Facet)(nil), // 52: resource.ResourceSearchRequest.Facet
- nil, // 53: resource.ResourceSearchRequest.FacetEntry
- (*ResourceSearchResponse_Facet)(nil), // 54: resource.ResourceSearchResponse.Facet
- (*ResourceSearchResponse_TermFacet)(nil), // 55: resource.ResourceSearchResponse.TermFacet
- nil, // 56: resource.ResourceSearchResponse.FacetEntry
- (*ListManagedObjectsResponse_Item)(nil), // 57: resource.ListManagedObjectsResponse.Item
- (*CountManagedObjectsResponse_ResourceCount)(nil), // 58: resource.CountManagedObjectsResponse.ResourceCount
- (*ResourceTableColumnDefinition_Properties)(nil), // 59: resource.ResourceTableColumnDefinition.Properties
+ (ResourceVersionMatchV2)(0), // 1: resource.ResourceVersionMatchV2
+ (ListRequest_Source)(0), // 2: resource.ListRequest.Source
+ (WatchEvent_Type)(0), // 3: resource.WatchEvent.Type
+ (BulkRequest_Action)(0), // 4: resource.BulkRequest.Action
+ (HealthCheckResponse_ServingStatus)(0), // 5: resource.HealthCheckResponse.ServingStatus
+ (ResourceTableColumnDefinition_ColumnType)(0), // 6: resource.ResourceTableColumnDefinition.ColumnType
+ (PutBlobRequest_Method)(0), // 7: resource.PutBlobRequest.Method
+ (*ResourceKey)(nil), // 8: resource.ResourceKey
+ (*ResourceWrapper)(nil), // 9: resource.ResourceWrapper
+ (*ErrorResult)(nil), // 10: resource.ErrorResult
+ (*ErrorDetails)(nil), // 11: resource.ErrorDetails
+ (*ErrorCause)(nil), // 12: resource.ErrorCause
+ (*CreateRequest)(nil), // 13: resource.CreateRequest
+ (*CreateResponse)(nil), // 14: resource.CreateResponse
+ (*UpdateRequest)(nil), // 15: resource.UpdateRequest
+ (*UpdateResponse)(nil), // 16: resource.UpdateResponse
+ (*DeleteRequest)(nil), // 17: resource.DeleteRequest
+ (*DeleteResponse)(nil), // 18: resource.DeleteResponse
+ (*ReadRequest)(nil), // 19: resource.ReadRequest
+ (*ReadResponse)(nil), // 20: resource.ReadResponse
+ (*Requirement)(nil), // 21: resource.Requirement
+ (*ListOptions)(nil), // 22: resource.ListOptions
+ (*ListRequest)(nil), // 23: resource.ListRequest
+ (*ListResponse)(nil), // 24: resource.ListResponse
+ (*WatchRequest)(nil), // 25: resource.WatchRequest
+ (*WatchEvent)(nil), // 26: resource.WatchEvent
+ (*BulkRequest)(nil), // 27: resource.BulkRequest
+ (*BulkResponse)(nil), // 28: resource.BulkResponse
+ (*ResourceStatsRequest)(nil), // 29: resource.ResourceStatsRequest
+ (*ResourceStatsResponse)(nil), // 30: resource.ResourceStatsResponse
+ (*ResourceSearchRequest)(nil), // 31: resource.ResourceSearchRequest
+ (*ResourceSearchResponse)(nil), // 32: resource.ResourceSearchResponse
+ (*ListManagedObjectsRequest)(nil), // 33: resource.ListManagedObjectsRequest
+ (*ListManagedObjectsResponse)(nil), // 34: resource.ListManagedObjectsResponse
+ (*CountManagedObjectsRequest)(nil), // 35: resource.CountManagedObjectsRequest
+ (*CountManagedObjectsResponse)(nil), // 36: resource.CountManagedObjectsResponse
+ (*HealthCheckRequest)(nil), // 37: resource.HealthCheckRequest
+ (*HealthCheckResponse)(nil), // 38: resource.HealthCheckResponse
+ (*ResourceTable)(nil), // 39: resource.ResourceTable
+ (*ResourceTableColumnDefinition)(nil), // 40: resource.ResourceTableColumnDefinition
+ (*ResourceTableRow)(nil), // 41: resource.ResourceTableRow
+ (*PutBlobRequest)(nil), // 42: resource.PutBlobRequest
+ (*PutBlobResponse)(nil), // 43: resource.PutBlobResponse
+ (*GetBlobRequest)(nil), // 44: resource.GetBlobRequest
+ (*GetBlobResponse)(nil), // 45: resource.GetBlobResponse
+ (*WatchEvent_Resource)(nil), // 46: resource.WatchEvent.Resource
+ (*BulkResponse_Summary)(nil), // 47: resource.BulkResponse.Summary
+ (*BulkResponse_Rejected)(nil), // 48: resource.BulkResponse.Rejected
+ (*ResourceStatsResponse_Stats)(nil), // 49: resource.ResourceStatsResponse.Stats
+ (*ResourceSearchRequest_Sort)(nil), // 50: resource.ResourceSearchRequest.Sort
+ (*ResourceSearchRequest_Facet)(nil), // 51: resource.ResourceSearchRequest.Facet
+ nil, // 52: resource.ResourceSearchRequest.FacetEntry
+ (*ResourceSearchResponse_Facet)(nil), // 53: resource.ResourceSearchResponse.Facet
+ (*ResourceSearchResponse_TermFacet)(nil), // 54: resource.ResourceSearchResponse.TermFacet
+ nil, // 55: resource.ResourceSearchResponse.FacetEntry
+ (*ListManagedObjectsResponse_Item)(nil), // 56: resource.ListManagedObjectsResponse.Item
+ (*CountManagedObjectsResponse_ResourceCount)(nil), // 57: resource.CountManagedObjectsResponse.ResourceCount
+ (*ResourceTableColumnDefinition_Properties)(nil), // 58: resource.ResourceTableColumnDefinition.Properties
}
var file_resource_proto_depIdxs = []int32{
- 10, // 0: resource.ErrorResult.details:type_name -> resource.ErrorDetails
- 11, // 1: resource.ErrorDetails.causes:type_name -> resource.ErrorCause
- 7, // 2: resource.CreateRequest.key:type_name -> resource.ResourceKey
- 9, // 3: resource.CreateResponse.error:type_name -> resource.ErrorResult
- 7, // 4: resource.UpdateRequest.key:type_name -> resource.ResourceKey
- 9, // 5: resource.UpdateResponse.error:type_name -> resource.ErrorResult
- 7, // 6: resource.DeleteRequest.key:type_name -> resource.ResourceKey
- 9, // 7: resource.DeleteResponse.error:type_name -> resource.ErrorResult
- 7, // 8: resource.ReadRequest.key:type_name -> resource.ResourceKey
- 9, // 9: resource.ReadResponse.error:type_name -> resource.ErrorResult
- 7, // 10: resource.ListOptions.key:type_name -> resource.ResourceKey
- 20, // 11: resource.ListOptions.labels:type_name -> resource.Requirement
- 20, // 12: resource.ListOptions.fields:type_name -> resource.Requirement
+ 11, // 0: resource.ErrorResult.details:type_name -> resource.ErrorDetails
+ 12, // 1: resource.ErrorDetails.causes:type_name -> resource.ErrorCause
+ 8, // 2: resource.CreateRequest.key:type_name -> resource.ResourceKey
+ 10, // 3: resource.CreateResponse.error:type_name -> resource.ErrorResult
+ 8, // 4: resource.UpdateRequest.key:type_name -> resource.ResourceKey
+ 10, // 5: resource.UpdateResponse.error:type_name -> resource.ErrorResult
+ 8, // 6: resource.DeleteRequest.key:type_name -> resource.ResourceKey
+ 10, // 7: resource.DeleteResponse.error:type_name -> resource.ErrorResult
+ 8, // 8: resource.ReadRequest.key:type_name -> resource.ResourceKey
+ 10, // 9: resource.ReadResponse.error:type_name -> resource.ErrorResult
+ 8, // 10: resource.ListOptions.key:type_name -> resource.ResourceKey
+ 21, // 11: resource.ListOptions.labels:type_name -> resource.Requirement
+ 21, // 12: resource.ListOptions.fields:type_name -> resource.Requirement
0, // 13: resource.ListRequest.version_match:type_name -> resource.ResourceVersionMatch
- 21, // 14: resource.ListRequest.options:type_name -> resource.ListOptions
- 1, // 15: resource.ListRequest.source:type_name -> resource.ListRequest.Source
- 8, // 16: resource.ListResponse.items:type_name -> resource.ResourceWrapper
- 9, // 17: resource.ListResponse.error:type_name -> resource.ErrorResult
- 21, // 18: resource.WatchRequest.options:type_name -> resource.ListOptions
- 2, // 19: resource.WatchEvent.type:type_name -> resource.WatchEvent.Type
- 47, // 20: resource.WatchEvent.resource:type_name -> resource.WatchEvent.Resource
- 47, // 21: resource.WatchEvent.previous:type_name -> resource.WatchEvent.Resource
- 7, // 22: resource.BulkRequest.key:type_name -> resource.ResourceKey
- 3, // 23: resource.BulkRequest.action:type_name -> resource.BulkRequest.Action
- 9, // 24: resource.BulkResponse.error:type_name -> resource.ErrorResult
- 48, // 25: resource.BulkResponse.summary:type_name -> resource.BulkResponse.Summary
- 49, // 26: resource.BulkResponse.rejected:type_name -> resource.BulkResponse.Rejected
- 9, // 27: resource.ResourceStatsResponse.error:type_name -> resource.ErrorResult
- 50, // 28: resource.ResourceStatsResponse.stats:type_name -> resource.ResourceStatsResponse.Stats
- 21, // 29: resource.ResourceSearchRequest.options:type_name -> resource.ListOptions
- 7, // 30: resource.ResourceSearchRequest.federated:type_name -> resource.ResourceKey
- 51, // 31: resource.ResourceSearchRequest.sortBy:type_name -> resource.ResourceSearchRequest.Sort
- 53, // 32: resource.ResourceSearchRequest.facet:type_name -> resource.ResourceSearchRequest.FacetEntry
- 9, // 33: resource.ResourceSearchResponse.error:type_name -> resource.ErrorResult
- 7, // 34: resource.ResourceSearchResponse.key:type_name -> resource.ResourceKey
- 38, // 35: resource.ResourceSearchResponse.results:type_name -> resource.ResourceTable
- 56, // 36: resource.ResourceSearchResponse.facet:type_name -> resource.ResourceSearchResponse.FacetEntry
- 57, // 37: resource.ListManagedObjectsResponse.items:type_name -> resource.ListManagedObjectsResponse.Item
- 9, // 38: resource.ListManagedObjectsResponse.error:type_name -> resource.ErrorResult
- 58, // 39: resource.CountManagedObjectsResponse.items:type_name -> resource.CountManagedObjectsResponse.ResourceCount
- 9, // 40: resource.CountManagedObjectsResponse.error:type_name -> resource.ErrorResult
- 4, // 41: resource.HealthCheckResponse.status:type_name -> resource.HealthCheckResponse.ServingStatus
- 39, // 42: resource.ResourceTable.columns:type_name -> resource.ResourceTableColumnDefinition
- 40, // 43: resource.ResourceTable.rows:type_name -> resource.ResourceTableRow
- 5, // 44: resource.ResourceTableColumnDefinition.type:type_name -> resource.ResourceTableColumnDefinition.ColumnType
- 59, // 45: resource.ResourceTableColumnDefinition.properties:type_name -> resource.ResourceTableColumnDefinition.Properties
- 7, // 46: resource.ResourceTableRow.key:type_name -> resource.ResourceKey
- 7, // 47: resource.RestoreRequest.key:type_name -> resource.ResourceKey
- 9, // 48: resource.RestoreResponse.error:type_name -> resource.ErrorResult
- 7, // 49: resource.PutBlobRequest.resource:type_name -> resource.ResourceKey
- 6, // 50: resource.PutBlobRequest.method:type_name -> resource.PutBlobRequest.Method
- 9, // 51: resource.PutBlobResponse.error:type_name -> resource.ErrorResult
- 7, // 52: resource.GetBlobRequest.resource:type_name -> resource.ResourceKey
- 9, // 53: resource.GetBlobResponse.error:type_name -> resource.ErrorResult
- 7, // 54: resource.BulkResponse.Rejected.key:type_name -> resource.ResourceKey
- 3, // 55: resource.BulkResponse.Rejected.action:type_name -> resource.BulkRequest.Action
- 52, // 56: resource.ResourceSearchRequest.FacetEntry.value:type_name -> resource.ResourceSearchRequest.Facet
- 55, // 57: resource.ResourceSearchResponse.Facet.terms:type_name -> resource.ResourceSearchResponse.TermFacet
- 54, // 58: resource.ResourceSearchResponse.FacetEntry.value:type_name -> resource.ResourceSearchResponse.Facet
- 7, // 59: resource.ListManagedObjectsResponse.Item.object:type_name -> resource.ResourceKey
- 18, // 60: resource.ResourceStore.Read:input_type -> resource.ReadRequest
- 12, // 61: resource.ResourceStore.Create:input_type -> resource.CreateRequest
- 14, // 62: resource.ResourceStore.Update:input_type -> resource.UpdateRequest
- 16, // 63: resource.ResourceStore.Delete:input_type -> resource.DeleteRequest
- 41, // 64: resource.ResourceStore.Restore:input_type -> resource.RestoreRequest
- 22, // 65: resource.ResourceStore.List:input_type -> resource.ListRequest
- 24, // 66: resource.ResourceStore.Watch:input_type -> resource.WatchRequest
- 26, // 67: resource.BulkStore.BulkProcess:input_type -> resource.BulkRequest
- 30, // 68: resource.ResourceIndex.Search:input_type -> resource.ResourceSearchRequest
- 28, // 69: resource.ResourceIndex.GetStats:input_type -> resource.ResourceStatsRequest
- 34, // 70: resource.ManagedObjectIndex.CountManagedObjects:input_type -> resource.CountManagedObjectsRequest
- 32, // 71: resource.ManagedObjectIndex.ListManagedObjects:input_type -> resource.ListManagedObjectsRequest
- 43, // 72: resource.BlobStore.PutBlob:input_type -> resource.PutBlobRequest
- 45, // 73: resource.BlobStore.GetBlob:input_type -> resource.GetBlobRequest
- 36, // 74: resource.Diagnostics.IsHealthy:input_type -> resource.HealthCheckRequest
- 19, // 75: resource.ResourceStore.Read:output_type -> resource.ReadResponse
- 13, // 76: resource.ResourceStore.Create:output_type -> resource.CreateResponse
- 15, // 77: resource.ResourceStore.Update:output_type -> resource.UpdateResponse
- 17, // 78: resource.ResourceStore.Delete:output_type -> resource.DeleteResponse
- 42, // 79: resource.ResourceStore.Restore:output_type -> resource.RestoreResponse
- 23, // 80: resource.ResourceStore.List:output_type -> resource.ListResponse
- 25, // 81: resource.ResourceStore.Watch:output_type -> resource.WatchEvent
- 27, // 82: resource.BulkStore.BulkProcess:output_type -> resource.BulkResponse
- 31, // 83: resource.ResourceIndex.Search:output_type -> resource.ResourceSearchResponse
- 29, // 84: resource.ResourceIndex.GetStats:output_type -> resource.ResourceStatsResponse
- 35, // 85: resource.ManagedObjectIndex.CountManagedObjects:output_type -> resource.CountManagedObjectsResponse
- 33, // 86: resource.ManagedObjectIndex.ListManagedObjects:output_type -> resource.ListManagedObjectsResponse
- 44, // 87: resource.BlobStore.PutBlob:output_type -> resource.PutBlobResponse
- 46, // 88: resource.BlobStore.GetBlob:output_type -> resource.GetBlobResponse
- 37, // 89: resource.Diagnostics.IsHealthy:output_type -> resource.HealthCheckResponse
- 75, // [75:90] is the sub-list for method output_type
- 60, // [60:75] is the sub-list for method input_type
- 60, // [60:60] is the sub-list for extension type_name
- 60, // [60:60] is the sub-list for extension extendee
- 0, // [0:60] is the sub-list for field type_name
+ 22, // 14: resource.ListRequest.options:type_name -> resource.ListOptions
+ 2, // 15: resource.ListRequest.source:type_name -> resource.ListRequest.Source
+ 1, // 16: resource.ListRequest.version_match_v2:type_name -> resource.ResourceVersionMatchV2
+ 9, // 17: resource.ListResponse.items:type_name -> resource.ResourceWrapper
+ 10, // 18: resource.ListResponse.error:type_name -> resource.ErrorResult
+ 22, // 19: resource.WatchRequest.options:type_name -> resource.ListOptions
+ 3, // 20: resource.WatchEvent.type:type_name -> resource.WatchEvent.Type
+ 46, // 21: resource.WatchEvent.resource:type_name -> resource.WatchEvent.Resource
+ 46, // 22: resource.WatchEvent.previous:type_name -> resource.WatchEvent.Resource
+ 8, // 23: resource.BulkRequest.key:type_name -> resource.ResourceKey
+ 4, // 24: resource.BulkRequest.action:type_name -> resource.BulkRequest.Action
+ 10, // 25: resource.BulkResponse.error:type_name -> resource.ErrorResult
+ 47, // 26: resource.BulkResponse.summary:type_name -> resource.BulkResponse.Summary
+ 48, // 27: resource.BulkResponse.rejected:type_name -> resource.BulkResponse.Rejected
+ 10, // 28: resource.ResourceStatsResponse.error:type_name -> resource.ErrorResult
+ 49, // 29: resource.ResourceStatsResponse.stats:type_name -> resource.ResourceStatsResponse.Stats
+ 22, // 30: resource.ResourceSearchRequest.options:type_name -> resource.ListOptions
+ 8, // 31: resource.ResourceSearchRequest.federated:type_name -> resource.ResourceKey
+ 50, // 32: resource.ResourceSearchRequest.sortBy:type_name -> resource.ResourceSearchRequest.Sort
+ 52, // 33: resource.ResourceSearchRequest.facet:type_name -> resource.ResourceSearchRequest.FacetEntry
+ 10, // 34: resource.ResourceSearchResponse.error:type_name -> resource.ErrorResult
+ 8, // 35: resource.ResourceSearchResponse.key:type_name -> resource.ResourceKey
+ 39, // 36: resource.ResourceSearchResponse.results:type_name -> resource.ResourceTable
+ 55, // 37: resource.ResourceSearchResponse.facet:type_name -> resource.ResourceSearchResponse.FacetEntry
+ 56, // 38: resource.ListManagedObjectsResponse.items:type_name -> resource.ListManagedObjectsResponse.Item
+ 10, // 39: resource.ListManagedObjectsResponse.error:type_name -> resource.ErrorResult
+ 57, // 40: resource.CountManagedObjectsResponse.items:type_name -> resource.CountManagedObjectsResponse.ResourceCount
+ 10, // 41: resource.CountManagedObjectsResponse.error:type_name -> resource.ErrorResult
+ 5, // 42: resource.HealthCheckResponse.status:type_name -> resource.HealthCheckResponse.ServingStatus
+ 40, // 43: resource.ResourceTable.columns:type_name -> resource.ResourceTableColumnDefinition
+ 41, // 44: resource.ResourceTable.rows:type_name -> resource.ResourceTableRow
+ 6, // 45: resource.ResourceTableColumnDefinition.type:type_name -> resource.ResourceTableColumnDefinition.ColumnType
+ 58, // 46: resource.ResourceTableColumnDefinition.properties:type_name -> resource.ResourceTableColumnDefinition.Properties
+ 8, // 47: resource.ResourceTableRow.key:type_name -> resource.ResourceKey
+ 8, // 48: resource.PutBlobRequest.resource:type_name -> resource.ResourceKey
+ 7, // 49: resource.PutBlobRequest.method:type_name -> resource.PutBlobRequest.Method
+ 10, // 50: resource.PutBlobResponse.error:type_name -> resource.ErrorResult
+ 8, // 51: resource.GetBlobRequest.resource:type_name -> resource.ResourceKey
+ 10, // 52: resource.GetBlobResponse.error:type_name -> resource.ErrorResult
+ 8, // 53: resource.BulkResponse.Rejected.key:type_name -> resource.ResourceKey
+ 4, // 54: resource.BulkResponse.Rejected.action:type_name -> resource.BulkRequest.Action
+ 51, // 55: resource.ResourceSearchRequest.FacetEntry.value:type_name -> resource.ResourceSearchRequest.Facet
+ 54, // 56: resource.ResourceSearchResponse.Facet.terms:type_name -> resource.ResourceSearchResponse.TermFacet
+ 53, // 57: resource.ResourceSearchResponse.FacetEntry.value:type_name -> resource.ResourceSearchResponse.Facet
+ 8, // 58: resource.ListManagedObjectsResponse.Item.object:type_name -> resource.ResourceKey
+ 19, // 59: resource.ResourceStore.Read:input_type -> resource.ReadRequest
+ 13, // 60: resource.ResourceStore.Create:input_type -> resource.CreateRequest
+ 15, // 61: resource.ResourceStore.Update:input_type -> resource.UpdateRequest
+ 17, // 62: resource.ResourceStore.Delete:input_type -> resource.DeleteRequest
+ 23, // 63: resource.ResourceStore.List:input_type -> resource.ListRequest
+ 25, // 64: resource.ResourceStore.Watch:input_type -> resource.WatchRequest
+ 27, // 65: resource.BulkStore.BulkProcess:input_type -> resource.BulkRequest
+ 31, // 66: resource.ResourceIndex.Search:input_type -> resource.ResourceSearchRequest
+ 29, // 67: resource.ResourceIndex.GetStats:input_type -> resource.ResourceStatsRequest
+ 35, // 68: resource.ManagedObjectIndex.CountManagedObjects:input_type -> resource.CountManagedObjectsRequest
+ 33, // 69: resource.ManagedObjectIndex.ListManagedObjects:input_type -> resource.ListManagedObjectsRequest
+ 42, // 70: resource.BlobStore.PutBlob:input_type -> resource.PutBlobRequest
+ 44, // 71: resource.BlobStore.GetBlob:input_type -> resource.GetBlobRequest
+ 37, // 72: resource.Diagnostics.IsHealthy:input_type -> resource.HealthCheckRequest
+ 20, // 73: resource.ResourceStore.Read:output_type -> resource.ReadResponse
+ 14, // 74: resource.ResourceStore.Create:output_type -> resource.CreateResponse
+ 16, // 75: resource.ResourceStore.Update:output_type -> resource.UpdateResponse
+ 18, // 76: resource.ResourceStore.Delete:output_type -> resource.DeleteResponse
+ 24, // 77: resource.ResourceStore.List:output_type -> resource.ListResponse
+ 26, // 78: resource.ResourceStore.Watch:output_type -> resource.WatchEvent
+ 28, // 79: resource.BulkStore.BulkProcess:output_type -> resource.BulkResponse
+ 32, // 80: resource.ResourceIndex.Search:output_type -> resource.ResourceSearchResponse
+ 30, // 81: resource.ResourceIndex.GetStats:output_type -> resource.ResourceStatsResponse
+ 36, // 82: resource.ManagedObjectIndex.CountManagedObjects:output_type -> resource.CountManagedObjectsResponse
+ 34, // 83: resource.ManagedObjectIndex.ListManagedObjects:output_type -> resource.ListManagedObjectsResponse
+ 43, // 84: resource.BlobStore.PutBlob:output_type -> resource.PutBlobResponse
+ 45, // 85: resource.BlobStore.GetBlob:output_type -> resource.GetBlobResponse
+ 38, // 86: resource.Diagnostics.IsHealthy:output_type -> resource.HealthCheckResponse
+ 73, // [73:87] is the sub-list for method output_type
+ 59, // [59:73] is the sub-list for method input_type
+ 59, // [59:59] is the sub-list for extension type_name
+ 59, // [59:59] is the sub-list for extension extendee
+ 0, // [0:59] is the sub-list for field type_name
}
func init() { file_resource_proto_init() }
@@ -4811,13 +4745,14 @@ func file_resource_proto_init() {
if File_resource_proto != nil {
return
}
+ file_resource_proto_msgTypes[15].OneofWrappers = []any{}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_resource_proto_rawDesc), len(file_resource_proto_rawDesc)),
- NumEnums: 7,
- NumMessages: 53,
+ NumEnums: 8,
+ NumMessages: 51,
NumExtensions: 0,
NumServices: 6,
},
diff --git a/pkg/storage/unified/resource/resource.proto b/pkg/storage/unified/resource/resource.proto
index 830c2a4d890..f5111d90657 100644
--- a/pkg/storage/unified/resource/resource.proto
+++ b/pkg/storage/unified/resource/resource.proto
@@ -177,8 +177,6 @@ message ReadRequest {
// Optionally pick an explicit resource version
int64 resource_version = 2;
- // Optionally decide to return the latest RV if deleted
- bool include_deleted = 3;
}
message ReadResponse {
@@ -220,10 +218,19 @@ message ListOptions {
}
enum ResourceVersionMatch {
- NotOlderThan = 0;
- Exact = 1;
+ // Deprecated, use ResourceVersionMatch V2
+ DEPRECATED_NotOlderThan = 0;
+ DEPRECATED_Exact = 1;
}
+enum ResourceVersionMatchV2 {
+ UNKNOWN = 0;
+ Unset = 1;
+ Exact = 2;
+ NotOlderThan = 3;
+}
+
+
message ListRequest {
enum Source {
STORE = 0; // the standard place
@@ -238,7 +245,8 @@ message ListRequest {
int64 resource_version = 2;
// List options
- ResourceVersionMatch version_match = 3;
+ // DEPRECATED - use version_match_v2
+ optional ResourceVersionMatch version_match = 3;
// Maximum number of items to return
// NOTE responses will also be limited by the response payload size
@@ -249,6 +257,8 @@ message ListRequest {
// Select values from history or trash
Source source = 6;
+
+ ResourceVersionMatchV2 version_match_v2 = 7;
}
message ListResponse {
@@ -702,26 +712,6 @@ message ResourceTableRow {
bytes object = 4;
}
-//----------------------------
-// Restore Support
-//----------------------------
-
-message RestoreRequest {
- // Full key must be set
- ResourceKey key = 1;
-
- // The resource version to restore
- int64 resource_version = 2;
-}
-
-message RestoreResponse {
- // Error details
- ErrorResult error = 1;
-
- // The updated resource version
- int64 resource_version = 2;
-}
-
//----------------------------
// Blob Support
//----------------------------
@@ -811,7 +801,6 @@ service ResourceStore {
rpc Create(CreateRequest) returns (CreateResponse);
rpc Update(UpdateRequest) returns (UpdateResponse);
rpc Delete(DeleteRequest) returns (DeleteResponse);
- rpc Restore(RestoreRequest) returns (RestoreResponse);
// The results *may* include values that should not be returned to the user
// This will perform best-effort filtering to increase performace.
diff --git a/pkg/storage/unified/resource/resource_grpc.pb.go b/pkg/storage/unified/resource/resource_grpc.pb.go
index 25c4a937f9c..4186147ef7c 100644
--- a/pkg/storage/unified/resource/resource_grpc.pb.go
+++ b/pkg/storage/unified/resource/resource_grpc.pb.go
@@ -19,13 +19,12 @@ import (
const _ = grpc.SupportPackageIsVersion8
const (
- ResourceStore_Read_FullMethodName = "/resource.ResourceStore/Read"
- ResourceStore_Create_FullMethodName = "/resource.ResourceStore/Create"
- ResourceStore_Update_FullMethodName = "/resource.ResourceStore/Update"
- ResourceStore_Delete_FullMethodName = "/resource.ResourceStore/Delete"
- ResourceStore_Restore_FullMethodName = "/resource.ResourceStore/Restore"
- ResourceStore_List_FullMethodName = "/resource.ResourceStore/List"
- ResourceStore_Watch_FullMethodName = "/resource.ResourceStore/Watch"
+ ResourceStore_Read_FullMethodName = "/resource.ResourceStore/Read"
+ ResourceStore_Create_FullMethodName = "/resource.ResourceStore/Create"
+ ResourceStore_Update_FullMethodName = "/resource.ResourceStore/Update"
+ ResourceStore_Delete_FullMethodName = "/resource.ResourceStore/Delete"
+ ResourceStore_List_FullMethodName = "/resource.ResourceStore/List"
+ ResourceStore_Watch_FullMethodName = "/resource.ResourceStore/Watch"
)
// ResourceStoreClient is the client API for ResourceStore service.
@@ -41,7 +40,6 @@ type ResourceStoreClient interface {
Create(ctx context.Context, in *CreateRequest, opts ...grpc.CallOption) (*CreateResponse, error)
Update(ctx context.Context, in *UpdateRequest, opts ...grpc.CallOption) (*UpdateResponse, error)
Delete(ctx context.Context, in *DeleteRequest, opts ...grpc.CallOption) (*DeleteResponse, error)
- Restore(ctx context.Context, in *RestoreRequest, opts ...grpc.CallOption) (*RestoreResponse, error)
// The results *may* include values that should not be returned to the user
// This will perform best-effort filtering to increase performace.
// NOTE: storage.Interface is ultimatly responsible for the final filtering
@@ -100,16 +98,6 @@ func (c *resourceStoreClient) Delete(ctx context.Context, in *DeleteRequest, opt
return out, nil
}
-func (c *resourceStoreClient) Restore(ctx context.Context, in *RestoreRequest, opts ...grpc.CallOption) (*RestoreResponse, error) {
- cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
- out := new(RestoreResponse)
- err := c.cc.Invoke(ctx, ResourceStore_Restore_FullMethodName, in, out, cOpts...)
- if err != nil {
- return nil, err
- }
- return out, nil
-}
-
func (c *resourceStoreClient) List(ctx context.Context, in *ListRequest, opts ...grpc.CallOption) (*ListResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListResponse)
@@ -166,7 +154,6 @@ type ResourceStoreServer interface {
Create(context.Context, *CreateRequest) (*CreateResponse, error)
Update(context.Context, *UpdateRequest) (*UpdateResponse, error)
Delete(context.Context, *DeleteRequest) (*DeleteResponse, error)
- Restore(context.Context, *RestoreRequest) (*RestoreResponse, error)
// The results *may* include values that should not be returned to the user
// This will perform best-effort filtering to increase performace.
// NOTE: storage.Interface is ultimatly responsible for the final filtering
@@ -193,9 +180,6 @@ func (UnimplementedResourceStoreServer) Update(context.Context, *UpdateRequest)
func (UnimplementedResourceStoreServer) Delete(context.Context, *DeleteRequest) (*DeleteResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Delete not implemented")
}
-func (UnimplementedResourceStoreServer) Restore(context.Context, *RestoreRequest) (*RestoreResponse, error) {
- return nil, status.Errorf(codes.Unimplemented, "method Restore not implemented")
-}
func (UnimplementedResourceStoreServer) List(context.Context, *ListRequest) (*ListResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method List not implemented")
}
@@ -286,24 +270,6 @@ func _ResourceStore_Delete_Handler(srv interface{}, ctx context.Context, dec fun
return interceptor(ctx, in, info, handler)
}
-func _ResourceStore_Restore_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
- in := new(RestoreRequest)
- if err := dec(in); err != nil {
- return nil, err
- }
- if interceptor == nil {
- return srv.(ResourceStoreServer).Restore(ctx, in)
- }
- info := &grpc.UnaryServerInfo{
- Server: srv,
- FullMethod: ResourceStore_Restore_FullMethodName,
- }
- handler := func(ctx context.Context, req interface{}) (interface{}, error) {
- return srv.(ResourceStoreServer).Restore(ctx, req.(*RestoreRequest))
- }
- return interceptor(ctx, in, info, handler)
-}
-
func _ResourceStore_List_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListRequest)
if err := dec(in); err != nil {
@@ -366,10 +332,6 @@ var ResourceStore_ServiceDesc = grpc.ServiceDesc{
MethodName: "Delete",
Handler: _ResourceStore_Delete_Handler,
},
- {
- MethodName: "Restore",
- Handler: _ResourceStore_Restore_Handler,
- },
{
MethodName: "List",
Handler: _ResourceStore_List_Handler,
diff --git a/pkg/storage/unified/resource/server.go b/pkg/storage/unified/resource/server.go
index 0a983de75aa..0509c938382 100644
--- a/pkg/storage/unified/resource/server.go
+++ b/pkg/storage/unified/resource/server.go
@@ -10,14 +10,12 @@ import (
"sync/atomic"
"time"
- "github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel/trace"
"go.opentelemetry.io/otel/trace/noop"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
- "k8s.io/apimachinery/pkg/types"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/utils"
@@ -393,6 +391,7 @@ func (s *server) newEvent(ctx context.Context, user claims.AuthInfo, key *Resour
if oldValue == nil {
event.Type = WatchEvent_ADDED
} else {
+ event.Type = WatchEvent_MODIFIED
check.Verb = utils.VerbUpdate
temp := &unstructured.Unstructured{}
@@ -404,13 +403,6 @@ func (s *server) newEvent(ctx context.Context, user claims.AuthInfo, key *Resour
if err != nil {
return nil, AsErrorResult(err)
}
-
- // restores will restore with a different k8s uid
- if event.ObjectOld.GetUID() != obj.GetUID() {
- event.Type = WatchEvent_ADDED
- } else {
- event.Type = WatchEvent_MODIFIED
- }
}
if key.Namespace != obj.GetNamespace() {
@@ -761,10 +753,11 @@ func (s *server) List(ctx context.Context, req *ListRequest) (*ListResponse, err
rsp.Items = append(rsp.Items, item)
if len(rsp.Items) >= int(req.Limit) || pageBytes >= maxPageBytes {
t := iter.ContinueToken()
- if req.Source == ListRequest_HISTORY {
- // history lists in desc order, so the continue token takes the
- // final RV in the list, and then will start from there in the next page,
- // rather than the lists first RV
+ if req.Source == ListRequest_HISTORY || req.Source == ListRequest_TRASH {
+ // For history lists, we need to use the current RV in the continue token
+ // to ensure consistent pagination. The order depends on VersionMatch:
+ // - NotOlderThan: ascending order (oldest to newest)
+ // - Unset: descending order (newest to oldest)
t = iter.ContinueTokenWithCurrentRV()
}
if iter.Next() {
@@ -792,126 +785,6 @@ func (s *server) List(ctx context.Context, req *ListRequest) (*ListResponse, err
return rsp, err
}
-func (s *server) Restore(ctx context.Context, req *RestoreRequest) (*RestoreResponse, error) {
- ctx, span := s.tracer.Start(ctx, "storage_server.List")
- defer span.End()
-
- // check that the user has access
- user, ok := claims.AuthInfoFrom(ctx)
- if !ok || user == nil {
- return &RestoreResponse{
- Error: &ErrorResult{
- Message: "no user found in context",
- Code: http.StatusUnauthorized,
- }}, nil
- }
-
- if err := s.Init(ctx); err != nil {
- return nil, err
- }
-
- checker, err := s.access.Compile(ctx, user, claims.ListRequest{
- Group: req.Key.Group,
- Resource: req.Key.Resource,
- Namespace: req.Key.Namespace,
- Verb: utils.VerbGet,
- })
- if err != nil {
- return &RestoreResponse{Error: AsErrorResult(err)}, nil
- }
- if checker == nil {
- return &RestoreResponse{Error: &ErrorResult{
- Code: http.StatusForbidden,
- }}, nil
- }
-
- // get the asked for resource version to restore
- readRsp, err := s.Read(ctx, &ReadRequest{
- Key: req.Key,
- ResourceVersion: req.ResourceVersion,
- IncludeDeleted: true,
- })
- if err != nil || readRsp == nil || readRsp.Error != nil {
- return &RestoreResponse{
- Error: &ErrorResult{
- Code: http.StatusNotFound,
- Message: fmt.Sprintf("could not find old resource: %s", readRsp.Error.Message),
- },
- }, nil
- }
-
- // generate a new k8s UID when restoring. The name will remain the same
- // (for dashboards, this will be the dashboard uid), but since controllers
- // will see this as a create event, we do not want the same k8s UID, or
- // there may be unintended behavior
- newUid := types.UID(uuid.NewString())
- tmp := &unstructured.Unstructured{}
- err = tmp.UnmarshalJSON(readRsp.Value)
- if err != nil {
- return &RestoreResponse{
- Error: &ErrorResult{
- Code: http.StatusNotFound,
- Message: fmt.Sprintf("could not unmarhsal: %s", err.Error()),
- },
- }, nil
- }
- obj, err := utils.MetaAccessor(tmp)
- if err != nil {
- return &RestoreResponse{
- Error: &ErrorResult{
- Code: http.StatusNotFound,
- Message: fmt.Sprintf("could not get object: %s", err.Error()),
- },
- }, nil
- }
- obj.SetUID(newUid)
-
- rtObj, ok := obj.GetRuntimeObject()
- if !ok {
- return &RestoreResponse{
- Error: &ErrorResult{
- Code: http.StatusNotFound,
- Message: "could not get runtime object",
- },
- }, nil
- }
-
- newObj, err := json.Marshal(rtObj)
- if err != nil {
- return &RestoreResponse{
- Error: &ErrorResult{
- Code: http.StatusNotFound,
- Message: fmt.Sprintf("could not marshal object: %s", err.Error()),
- },
- }, nil
- }
-
- // finally, send to the backend to create & update the history of the restored object
- event, errRes := s.newEvent(ctx, user, req.Key, newObj, readRsp.Value)
- if errRes != nil {
- return &RestoreResponse{
- Error: &ErrorResult{
- Code: http.StatusInternalServerError,
- Message: fmt.Sprintf("could not create restore resource event: %s", errRes.Message),
- },
- }, nil
- }
- rv, err := s.backend.WriteEvent(ctx, *event)
- if err != nil {
- return &RestoreResponse{
- Error: &ErrorResult{
- Code: http.StatusInternalServerError,
- Message: fmt.Sprintf("could not restore resource: %s", err.Error()),
- },
- }, nil
- }
-
- return &RestoreResponse{
- Error: nil,
- ResourceVersion: rv,
- }, nil
-}
-
func (s *server) initWatcher() error {
var err error
s.broadcaster, err = NewBroadcaster(s.ctx, func(out chan<- *WrittenEvent) error {
diff --git a/pkg/storage/unified/resource/server_test.go b/pkg/storage/unified/resource/server_test.go
index 3e204717040..bb42d73b12b 100644
--- a/pkg/storage/unified/resource/server_test.go
+++ b/pkg/storage/unified/resource/server_test.go
@@ -233,79 +233,4 @@ func TestSimpleServer(t *testing.T) {
ResourceVersion: created.ResourceVersion})
require.ErrorIs(t, err, ErrOptimisticLockingFailed)
})
-
- t.Run("playlist restore", func(t *testing.T) {
- uid := "zzz"
- raw := []byte(`{
- "apiVersion": "playlist.grafana.app/v0alpha1",
- "kind": "Playlist",
- "metadata": {
- "name": "fdgsv37qslr0ga",
- "namespace": "default",
- "uid": "` + uid + `",
- "annotations": {
- "grafana.app/repoName": "elsewhere",
- "grafana.app/repoPath": "path/to/item",
- "grafana.app/repoTimestamp": "2024-02-02T00:00:00Z"
- }
- },
- "spec": {
- "title": "hello",
- "interval": "5m",
- "items": [
- {
- "type": "dashboard_by_uid",
- "value": "vmie2cmWz"
- }
- ]
- }
- }`)
-
- key := &ResourceKey{
- Group: "playlist.grafana.app",
- Resource: "rrrr",
- Namespace: "default",
- Name: "fdgsv37qslr0ga",
- }
-
- // create
- created, err := server.Create(ctx, &CreateRequest{
- Value: raw,
- Key: key,
- })
- require.NoError(t, err)
-
- // make sure it exists
- found, err := server.Read(ctx, &ReadRequest{Key: key})
- require.NoError(t, err)
- require.Nil(t, found.Error)
- fmt.Println(found.ResourceVersion)
-
- // delete it
- deleted, err := server.Delete(ctx, &DeleteRequest{Key: key, ResourceVersion: created.ResourceVersion})
- require.NoError(t, err)
- require.True(t, deleted.ResourceVersion > created.ResourceVersion)
-
- // restore it
- restored, err := server.Restore(ctx, &RestoreRequest{
- Key: key,
- ResourceVersion: found.ResourceVersion,
- })
- require.NoError(t, err)
- require.Nil(t, restored.Error)
- require.True(t, restored.ResourceVersion > deleted.ResourceVersion)
-
- // ensure it exists now
- found, err = server.Read(ctx, &ReadRequest{Key: key})
- require.NoError(t, err)
- require.Nil(t, found.Error)
- require.Equal(t, restored.ResourceVersion, found.ResourceVersion)
- foundUnstructured := &unstructured.Unstructured{}
- err = foundUnstructured.UnmarshalJSON(found.Value)
- require.NoError(t, err)
- foundObj, err := utils.MetaAccessor(foundUnstructured)
- require.NoError(t, err)
- // the UID should be different now
- require.NotEqual(t, uid, string(foundObj.GetUID()))
- })
}
diff --git a/pkg/storage/unified/search/bleve.go b/pkg/storage/unified/search/bleve.go
index 4976449f548..e99df0870e3 100644
--- a/pkg/storage/unified/search/bleve.go
+++ b/pkg/storage/unified/search/bleve.go
@@ -159,7 +159,7 @@ func (b *bleveBackend) BuildIndex(ctx context.Context,
var index bleve.Index
build := true
- mapper, err := getBleveMappings(fields)
+ mapper, err := GetBleveMappings(fields)
if err != nil {
return nil, err
}
@@ -660,8 +660,14 @@ func (b *bleveIndex) toBleveSearchRequest(ctx context.Context, req *resource.Res
}
}
- // Add a text query
- if req.Query != "" && req.Query != "*" {
+ if len(req.Query) > 1 && strings.Contains(req.Query, "*") {
+ // wildcard query is expensive - should be used with caution
+ wildcard := bleve.NewWildcardQuery(req.Query)
+ queries = append(queries, wildcard)
+ }
+
+ if req.Query != "" && !strings.Contains(req.Query, "*") {
+ // Add a text query
searchrequest.Fields = append(searchrequest.Fields, resource.SEARCH_FIELD_SCORE)
// There are multiple ways to match the query string to documents. The following queries are ordered by priority:
@@ -789,6 +795,11 @@ var textSortFields = map[string]string{
const lowerCase = "phrase"
+// termField fields to use termQuery for filtering
+var termFields = []string{
+ resource.SEARCH_FIELD_TITLE,
+}
+
// Convert a "requirement" into a bleve query
func requirementQuery(req *resource.Requirement, prefix string) (query.Query, *resource.ErrorResult) {
switch selection.Operator(req.Operator) {
@@ -797,16 +808,14 @@ func requirementQuery(req *resource.Requirement, prefix string) (query.Query, *r
return query.NewMatchAllQuery(), nil
}
- if len(req.Values[0]) == 1 {
- q := query.NewMatchQuery(filterValue(req.Key, req.Values[0]))
- q.FieldVal = prefix + req.Key
- return q, nil
+ if len(req.Values) == 1 {
+ filter := filterValue(req.Key, req.Values[0])
+ return newQuery(req.Key, filter, prefix), nil
}
conjuncts := []query.Query{}
for _, v := range req.Values {
- q := query.NewMatchQuery(filterValue(req.Key, v))
- q.FieldVal = prefix + req.Key
+ q := newQuery(req.Key, filterValue(req.Key, v), prefix)
conjuncts = append(conjuncts, q)
}
@@ -822,15 +831,13 @@ func requirementQuery(req *resource.Requirement, prefix string) (query.Query, *r
return query.NewMatchAllQuery(), nil
}
if len(req.Values) == 1 {
- q := query.NewMatchQuery(filterValue(req.Key, req.Values[0]))
- q.FieldVal = prefix + req.Key
+ q := newQuery(req.Key, filterValue(req.Key, req.Values[0]), prefix)
return q, nil
}
disjuncts := []query.Query{}
for _, v := range req.Values {
- q := query.NewMatchQuery(filterValue(req.Key, v))
- q.FieldVal = prefix + req.Key
+ q := newQuery(req.Key, filterValue(req.Key, v), prefix)
disjuncts = append(disjuncts, q)
}
@@ -841,7 +848,8 @@ func requirementQuery(req *resource.Requirement, prefix string) (query.Query, *r
var mustNotQueries []query.Query
for _, value := range req.Values {
- mustNotQueries = append(mustNotQueries, bleve.NewMatchQuery(filterValue(req.Key, value)))
+ q := newQuery(req.Key, filterValue(req.Key, value), prefix)
+ mustNotQueries = append(mustNotQueries, q)
}
boolQuery.AddMustNot(mustNotQueries...)
@@ -856,6 +864,55 @@ func requirementQuery(req *resource.Requirement, prefix string) (query.Query, *r
)
}
+// newQuery will create a query that will match the value or the tokens of the value
+func newQuery(key string, value string, prefix string) query.Query {
+ if value == "*" {
+ return bleve.NewMatchAllQuery()
+ }
+ if strings.Contains(value, "*") {
+ // wildcard query is expensive - should be used with caution
+ return bleve.NewWildcardQuery(value)
+ }
+ delimiter, ok := hasTerms(value)
+ if slices.Contains(termFields, key) && ok {
+ return newTermsQuery(key, value, delimiter, prefix)
+ }
+ q := bleve.NewMatchQuery(value)
+ q.SetField(prefix + key)
+ return q
+}
+
+// newTermsQuery will create a query that will match on term or tokens
+func newTermsQuery(key string, value string, delimiter string, prefix string) query.Query {
+ tokens := strings.Split(value, delimiter)
+ // won't match with ending space
+ value = strings.TrimSuffix(value, " ")
+
+ q := bleve.NewTermQuery(value)
+ q.SetField(prefix + key)
+
+ cq := newMatchAllTokensQuery(tokens, key, prefix)
+ return bleve.NewDisjunctionQuery(q, cq)
+}
+
+// newMatchAllTokensQuery will create a query that will match on all tokens
+func newMatchAllTokensQuery(tokens []string, key string, prefix string) query.Query {
+ cq := bleve.NewConjunctionQuery()
+ for _, token := range tokens {
+ _, ok := hasTerms(token)
+ if ok {
+ tq := bleve.NewTermQuery(token)
+ tq.SetField(prefix + key)
+ cq.AddQuery(tq)
+ continue
+ }
+ mq := bleve.NewMatchQuery(token)
+ mq.SetField(prefix + key)
+ cq.AddQuery(mq)
+ }
+ return cq
+}
+
// filterValue will convert the value to lower case if the field is a phrase field
func filterValue(field string, v string) string {
if strings.HasSuffix(field, lowerCase) {
@@ -1068,3 +1125,20 @@ func (q *permissionScopedQuery) Searcher(ctx context.Context, i index.IndexReade
return filteringSearcher, nil
}
+
+// hasTerms - any value that will be split into multiple tokens
+var hasTerms = func(v string) (string, bool) {
+ for _, c := range TermCharacters {
+ if strings.Contains(v, c) {
+ return c, true
+ }
+ }
+ return "", false
+}
+
+// TermCharacters characters that will be used to determine if a value is split into tokens
+var TermCharacters = []string{
+ " ", "-", "_", ".", ",", ":", ";", "?", "!", "@", "#", "$", "%", "^", "&", "*", "(", ")", "+",
+ "=", "{", "}", "[", "]", "|", "\\", "/", "<", ">", "~", "`",
+ "'", "\"",
+}
diff --git a/pkg/storage/unified/search/bleve_mappings.go b/pkg/storage/unified/search/bleve_mappings.go
index c9afce91a09..3a8c4e75420 100644
--- a/pkg/storage/unified/search/bleve_mappings.go
+++ b/pkg/storage/unified/search/bleve_mappings.go
@@ -9,7 +9,7 @@ import (
"github.com/grafana/grafana/pkg/storage/unified/resource"
)
-func getBleveMappings(fields resource.SearchableDocumentFields) (mapping.IndexMapping, error) {
+func GetBleveMappings(fields resource.SearchableDocumentFields) (mapping.IndexMapping, error) {
mapper := bleve.NewIndexMapping()
err := RegisterCustomAnalyzers(mapper)
@@ -31,22 +31,22 @@ func getBleveDocMappings(_ resource.SearchableDocumentFields) *mapping.DocumentM
}
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_NAME, nameMapping)
+ // for sorting by title full phrase
+ titlePhraseMapping := bleve.NewKeywordFieldMapping()
+ titlePhraseMapping.Store = false // already stored in title
+ mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TITLE_PHRASE, titlePhraseMapping)
+
// for searching by title - uses an edge ngram token filter
titleSearchMapping := bleve.NewTextFieldMapping()
titleSearchMapping.Analyzer = TITLE_ANALYZER
titleSearchMapping.Store = false // already stored in title
- mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TITLE_NGRAM, titleSearchMapping)
// mapping for title to search on words/tokens larger than the ngram size
titleWordMapping := bleve.NewTextFieldMapping()
titleWordMapping.Analyzer = standard.Name
titleWordMapping.Store = true
- mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TITLE, titleWordMapping)
-
- // for filtering/sorting by title full phrase
- titlePhraseMapping := bleve.NewKeywordFieldMapping()
- titleSearchMapping.Store = false // already stored in title
- mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TITLE_PHRASE, titlePhraseMapping)
+ // NOTE: this causes 3 title fields in the response
+ mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TITLE, titleWordMapping, titleSearchMapping, titlePhraseMapping)
descriptionMapping := &mapping.FieldMapping{
Name: resource.SEARCH_FIELD_DESCRIPTION,
diff --git a/pkg/storage/unified/search/bleve_mappings_test.go b/pkg/storage/unified/search/bleve_mappings_test.go
index 9c1c6c7f8ad..3b8027ee06e 100644
--- a/pkg/storage/unified/search/bleve_mappings_test.go
+++ b/pkg/storage/unified/search/bleve_mappings_test.go
@@ -1,4 +1,4 @@
-package search
+package search_test
import (
"fmt"
@@ -9,10 +9,11 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/storage/unified/resource"
+ "github.com/grafana/grafana/pkg/storage/unified/search"
)
func TestDocumentMapping(t *testing.T) {
- mappings, err := getBleveMappings(nil)
+ mappings, err := search.GetBleveMappings(nil)
require.NoError(t, err)
data := resource.IndexableDocument{
Title: "title",
@@ -48,5 +49,5 @@ func TestDocumentMapping(t *testing.T) {
fmt.Printf("DOC: fields %d\n", len(doc.Fields))
fmt.Printf("DOC: size %d\n", doc.Size())
- require.Equal(t, 16, len(doc.Fields))
+ require.Equal(t, 17, len(doc.Fields))
}
diff --git a/pkg/storage/unified/search/bleve_performance_test.go b/pkg/storage/unified/search/bleve_performance_test.go
new file mode 100644
index 00000000000..2d645235259
--- /dev/null
+++ b/pkg/storage/unified/search/bleve_performance_test.go
@@ -0,0 +1,131 @@
+package search_test
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "runtime"
+ "testing"
+ "time"
+
+ "github.com/grafana/grafana/pkg/storage/unified/resource"
+ "github.com/stretchr/testify/require"
+)
+
+func setupIndex() (resource.ResourceIndex, string) {
+ // size := 1000000 // TODO: 200k documents standard size?
+ size := 200000
+ // batchSize := 1000 slower 8s (for 200k documents) - 34s (for 1M documents)
+ // batchSize := 10000 // faster 5s (for 200k documents) - 27s (for 1M documents)
+ batchSize := 100000 // fasterer 3.5s (for 200k documents) - 27s (for 1M documents)
+ writer := newTestWriter(size, batchSize)
+ return newTestDashboardsIndex(nil, 1, int64(size), int64(batchSize), writer)
+}
+
+const maxAllowedTime = 20 * time.Millisecond // Reasonable (can vary per env) performance threshold per query (e.g., 20ms)
+const maxAllowedAllocMB = 1 // 1MB memory per operation
+const maxAllowedAlloc = maxAllowedAllocMB * 1024 * 1024
+const verbose = false
+
+// BenchmarkBleveQuery measures the time, mem, cpu to execute a search query
+// changes the the indexer settings can cause unforeseen performance issues ( for example: using wildcard queries )
+// this will fail if the stats exceed the "normal" thresholds
+func BenchmarkBleveQuery(b *testing.B) {
+ var memStatsStart runtime.MemStats
+ var memStatsAfterIndex runtime.MemStats
+ runtime.ReadMemStats(&memStatsStart)
+
+ testIndex, testIndexDir := setupIndex()
+ defer func() {
+ err := os.RemoveAll(testIndexDir)
+ if err != nil {
+ fmt.Printf("Error removing index directory: %v\n", err)
+ }
+ }()
+
+ runtime.ReadMemStats(&memStatsAfterIndex)
+
+ allocDiff := memStatsAfterIndex.Alloc - memStatsStart.Alloc
+
+ logVerbose(fmt.Sprintf("Memory allocated for index: %d bytes", allocDiff))
+
+ searchRequest := newQueryByTitle("name99999")
+
+ b.ResetTimer() // Reset timer before benchmarking
+ b.ReportAllocs() // Track memory allocations
+
+ for i := 0; i < b.N; i++ {
+ start := time.Now() // Start timer
+ var memStatsBefore, memStatsAfter runtime.MemStats
+ runtime.ReadMemStats(&memStatsBefore)
+
+ _, err := testIndex.Search(context.Background(), nil, searchRequest, nil)
+
+ elapsed := time.Since(start) // Calculate elapsed time
+ runtime.ReadMemStats(&memStatsAfter)
+ allocDiff := (memStatsAfter.Alloc - memStatsBefore.Alloc)
+ if memStatsAfter.Alloc < memStatsBefore.Alloc {
+ // This can happen due to memory being freed after the search operation
+ allocDiff = 0 // don't care if it goes down
+ }
+
+ logVerbose(fmt.Sprintf("Memory allocated for query: %d bytes", allocDiff))
+
+ require.NoError(b, err)
+
+ // Fail if query takes longer than maxAllowedTime
+ if elapsed > maxAllowedTime {
+ b.Fatalf("Query too slow: %v (limit: %v)", elapsed, maxAllowedTime)
+ }
+ // Check memory allocation limit
+ if allocDiff > maxAllowedAlloc {
+ b.Fatalf("Excessive memory usage: %d mb (limit: %d mb)", allocDiff, maxAllowedAllocMB)
+ }
+ }
+}
+
+func newTestWriter(size int, batchSize int) IndexWriter {
+ key := &resource.ResourceKey{
+ Namespace: "default",
+ Group: "dashboard.grafana.app",
+ Resource: "dashboards",
+ }
+
+ return func(index resource.ResourceIndex) (int64, error) {
+ total := time.Now()
+ start := time.Now()
+ for i := range size {
+ name := fmt.Sprintf("name%d", i)
+ err := index.Write(&resource.IndexableDocument{
+ RV: int64(i),
+ Name: name,
+ Key: &resource.ResourceKey{
+ Name: name,
+ Namespace: key.Namespace,
+ Group: key.Group,
+ Resource: key.Resource,
+ },
+ Title: name + "-title",
+ })
+ if err != nil {
+ return 0, err
+ }
+ // show progress for every batch
+ if i%batchSize == 0 && verbose {
+ fmt.Printf("Indexed %d documents\n", i)
+ end := time.Now()
+ fmt.Printf("Time taken for indexing batch: %s\n", end.Sub(start))
+ start = time.Now()
+ }
+ }
+ end := time.Now()
+ logVerbose(fmt.Sprintf("Indexed %d documents in %s", size, end.Sub(total)))
+ return 0, nil
+ }
+}
+
+func logVerbose(msg string) {
+ if verbose {
+ fmt.Println(msg)
+ }
+}
diff --git a/pkg/storage/unified/search/bleve_search_test.go b/pkg/storage/unified/search/bleve_search_test.go
index 29287de97e4..ae1782c61a2 100644
--- a/pkg/storage/unified/search/bleve_search_test.go
+++ b/pkg/storage/unified/search/bleve_search_test.go
@@ -1,4 +1,4 @@
-package search
+package search_test
import (
"context"
@@ -16,8 +16,11 @@ import (
"github.com/grafana/grafana/pkg/services/store/kind/dashboard"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/storage/unified/resource"
+ "github.com/grafana/grafana/pkg/storage/unified/search"
)
+const threshold = 9999
+
func TestCanSearchByTitle(t *testing.T) {
key := &resource.ResourceKey{
Namespace: "default",
@@ -26,7 +29,7 @@ func TestCanSearchByTitle(t *testing.T) {
}
t.Run("when query is empty, sort documents by title instead of search score", func(t *testing.T) {
- index := newTestDashboardsIndex(t)
+ index, _ := newTestDashboardsIndex(t, threshold, 2, 2, noop)
err := index.Write(&resource.IndexableDocument{
RV: 1,
Name: "name1",
@@ -53,7 +56,7 @@ func TestCanSearchByTitle(t *testing.T) {
require.NoError(t, err)
// search for phrase
- query := newQuery("")
+ query := newTestQuery("")
res, err := index.Search(context.Background(), nil, query, nil)
require.NoError(t, err)
require.Equal(t, int64(2), res.TotalHits)
@@ -61,7 +64,7 @@ func TestCanSearchByTitle(t *testing.T) {
})
t.Run("will boost phrase match query over match query results", func(t *testing.T) {
- index := newTestDashboardsIndex(t)
+ index, _ := newTestDashboardsIndex(t, threshold, 2, 2, noop)
err := index.Write(&resource.IndexableDocument{
RV: 1,
Name: "name1",
@@ -88,7 +91,7 @@ func TestCanSearchByTitle(t *testing.T) {
require.NoError(t, err)
// search for phrase
- query := newQuery("want hello")
+ query := newTestQuery("want hello")
res, err := index.Search(context.Background(), nil, query, nil)
require.NoError(t, err)
require.Equal(t, int64(2), res.TotalHits)
@@ -96,7 +99,7 @@ func TestCanSearchByTitle(t *testing.T) {
})
t.Run("will prioritize matches", func(t *testing.T) {
- index := newTestDashboardsIndex(t)
+ index, _ := newTestDashboardsIndex(t, threshold, 2, 2, noop)
err := index.Write(&resource.IndexableDocument{
RV: 1,
Name: "name1",
@@ -122,7 +125,7 @@ func TestCanSearchByTitle(t *testing.T) {
})
require.NoError(t, err)
- query := newQuery("New dash")
+ query := newTestQuery("New dash")
res, err := index.Search(context.Background(), nil, query, nil)
require.NoError(t, err)
require.Equal(t, int64(2), res.TotalHits)
@@ -130,7 +133,7 @@ func TestCanSearchByTitle(t *testing.T) {
})
t.Run("will boost exact match query over match phrase query results", func(t *testing.T) {
- index := newTestDashboardsIndex(t)
+ index, _ := newTestDashboardsIndex(t, threshold, 2, 2, noop)
err := index.Write(&resource.IndexableDocument{
RV: 1,
Name: "name1",
@@ -157,7 +160,7 @@ func TestCanSearchByTitle(t *testing.T) {
require.NoError(t, err)
// search for exact match
- query := newQuery("we want hello")
+ query := newTestQuery("we want hello")
res, err := index.Search(context.Background(), nil, query, nil)
require.NoError(t, err)
require.Equal(t, int64(2), res.TotalHits)
@@ -165,7 +168,7 @@ func TestCanSearchByTitle(t *testing.T) {
})
t.Run("title with numbers will match document", func(t *testing.T) {
- index := newTestDashboardsIndex(t)
+ index, _ := newTestDashboardsIndex(t, threshold, 2, 2, noop)
err := index.Write(&resource.IndexableDocument{
RV: 1,
Name: "name1",
@@ -180,20 +183,65 @@ func TestCanSearchByTitle(t *testing.T) {
require.NoError(t, err)
// search for prefix of title with mix of chars and numbers
- query := newQuery("A12")
+ query := newQueryByTitle("A12")
res, err := index.Search(context.Background(), nil, query, nil)
require.NoError(t, err)
require.Equal(t, int64(1), res.TotalHits)
// search for whole title
- query = newQuery("A123456")
+ query = newQueryByTitle("A123456")
+ res, err = index.Search(context.Background(), nil, query, nil)
+ require.NoError(t, err)
+ require.Equal(t, int64(1), res.TotalHits)
+
+ // case insensive search for partial title
+ query = newQueryByTitle("a1234")
res, err = index.Search(context.Background(), nil, query, nil)
require.NoError(t, err)
require.Equal(t, int64(1), res.TotalHits)
})
+ t.Run("title will match escaped characters", func(t *testing.T) {
+ index, _ := newTestDashboardsIndex(t, threshold, 2, 2, noop)
+ err := index.Write(&resource.IndexableDocument{
+ RV: 1,
+ Name: "name1",
+ Key: &resource.ResourceKey{
+ Name: "aaa",
+ Namespace: key.Namespace,
+ Group: key.Group,
+ Resource: key.Resource,
+ },
+ Title: "what\"s up",
+ })
+ require.NoError(t, err)
+
+ err = index.Write(&resource.IndexableDocument{
+ RV: 2,
+ Name: "name2",
+ Key: &resource.ResourceKey{
+ Name: "name2",
+ Namespace: key.Namespace,
+ Group: key.Group,
+ Resource: key.Resource,
+ },
+ Title: "what\"s that",
+ })
+ require.NoError(t, err)
+
+ query := newQueryByTitle("what\"s up")
+ res, err := index.Search(context.Background(), nil, query, nil)
+ require.NoError(t, err)
+ require.Equal(t, int64(1), res.TotalHits)
+
+ query = newQueryByTitle("what\"s")
+ res, err = index.Search(context.Background(), nil, query, nil)
+ require.NoError(t, err)
+ require.Equal(t, int64(2), res.TotalHits)
+ })
+
t.Run("title search will match document", func(t *testing.T) {
- index := newTestDashboardsIndex(t)
+ index, _ := newTestDashboardsIndex(t, threshold, 2, 2, noop)
err := index.Write(&resource.IndexableDocument{
RV: 1,
Name: "name1",
@@ -208,50 +256,50 @@ func TestCanSearchByTitle(t *testing.T) {
require.NoError(t, err)
// search by entire phrase
- query := newQuery("I want to say a wonderfully Hello to the WORLD! Hello-world")
+ query := newTestQuery("I want to say a wonderfully Hello to the WORLD! Hello-world")
res, err := index.Search(context.Background(), nil, query, nil)
require.NoError(t, err)
require.Equal(t, int64(1), res.TotalHits)
// search for word at start
- query = newQuery("hello")
+ query = newTestQuery("hello")
res, err = index.Search(context.Background(), nil, query, nil)
require.NoError(t, err)
require.Equal(t, int64(1), res.TotalHits)
// search for word larger than ngram max size
- query = newQuery("wonderfully")
+ query = newQueryByTitle("wonderfully")
res, err = index.Search(context.Background(), nil, query, nil)
require.NoError(t, err)
require.Equal(t, int64(1), res.TotalHits)
// search for word at end
- query = newQuery("world")
+ query = newQueryByTitle("world")
res, err = index.Search(context.Background(), nil, query, nil)
require.NoError(t, err)
require.Equal(t, int64(1), res.TotalHits)
// can search for word substring anchored at start of word (edge ngram)
- query = newQuery("worl")
+ query = newQueryByTitle("worl")
res, err = index.Search(context.Background(), nil, query, nil)
require.NoError(t, err)
require.Equal(t, int64(1), res.TotalHits)
// can search for multiple, non-consecutive words in title
- query = newQuery("hello world")
+ query = newQueryByTitle("hello world")
res, err = index.Search(context.Background(), nil, query, nil)
require.NoError(t, err)
require.Equal(t, int64(1), res.TotalHits)
// can search for a term with a hyphen
- query = newQuery("hello-world")
+ query = newQueryByTitle("hello-world")
res, err = index.Search(context.Background(), nil, query, nil)
require.NoError(t, err)
require.Equal(t, int64(1), res.TotalHits)
})
t.Run("title search will NOT match documents", func(t *testing.T) {
- index := newTestDashboardsIndex(t)
+ index, _ := newTestDashboardsIndex(t, threshold, 2, 2, noop)
err := index.Write(&resource.IndexableDocument{
RV: 1,
Name: "name1",
@@ -290,26 +338,79 @@ func TestCanSearchByTitle(t *testing.T) {
require.NoError(t, err)
// word that doesn't exist
- query := newQuery("cats")
+ query := newQueryByTitle("cats")
res, err := index.Search(context.Background(), nil, query, nil)
require.NoError(t, err)
require.Equal(t, int64(0), res.TotalHits)
// string shorter than 3 chars (ngam min)
- query = newQuery("ma")
+ query = newQueryByTitle("ma")
res, err = index.Search(context.Background(), nil, query, nil)
require.NoError(t, err)
require.Equal(t, int64(0), res.TotalHits)
// substring that doesn't exist
- query = newQuery("A01")
+ query = newQueryByTitle("A01")
res, err = index.Search(context.Background(), nil, query, nil)
require.NoError(t, err)
require.Equal(t, int64(0), res.TotalHits)
})
+
+ t.Run("title search with character will match one document", func(t *testing.T) {
+ index, _ := newTestDashboardsIndex(t, threshold, 2, 2, noop)
+ err := index.Write(&resource.IndexableDocument{
+ RV: 1,
+ Name: "name1",
+ Key: &resource.ResourceKey{
+ Name: "aaa",
+ Namespace: key.Namespace,
+ Group: key.Group,
+ Resource: key.Resource,
+ },
+ Title: "foo",
+ })
+ require.NoError(t, err)
+
+ for i, v := range search.TermCharacters {
+ err = index.Write(&resource.IndexableDocument{
+ RV: int64(i),
+ Name: fmt.Sprintf("name%d", i),
+ Key: &resource.ResourceKey{
+ Name: fmt.Sprintf("name%d", i),
+ Namespace: key.Namespace,
+ Group: key.Group,
+ Resource: key.Resource,
+ },
+ Title: fmt.Sprintf(`test foo%d%sbar`, i, v),
+ })
+ require.NoError(t, err)
+ }
+
+ for i, v := range search.TermCharacters {
+ title := fmt.Sprintf(`test foo%d%sbar`, i, v)
+ query := newQueryByTitle(title)
+ res, err := index.Search(context.Background(), nil, query, nil)
+ require.NoError(t, err)
+ if res.TotalHits != 1 {
+ fmt.Printf("i: %d, v: %s, title: %s", i, v, title)
+ }
+ require.Equal(t, int64(1), res.TotalHits)
+
+ // can search for a title with a term character suffix
+ title = fmt.Sprintf(`foo%d%s`, i, v)
+ query = newQueryByTitle(title)
+ res, err = index.Search(context.Background(), nil, query, nil)
+ require.NoError(t, err)
+ if res.TotalHits != 1 {
+ fmt.Printf("i: %d, v: %s, title: %s", i, v, title)
+ }
+
+ require.Equal(t, int64(1), res.TotalHits)
+ }
+ })
}
-func newQuery(query string) *resource.ResourceSearchRequest {
+func newTestQuery(query string) *resource.ResourceSearchRequest {
return &resource.ResourceSearchRequest{
Options: &resource.ListOptions{
Key: &resource.ResourceKey{
@@ -323,7 +424,21 @@ func newQuery(query string) *resource.ResourceSearchRequest {
}
}
-func newTestDashboardsIndex(t *testing.T) resource.ResourceIndex {
+func newQueryByTitle(query string) *resource.ResourceSearchRequest {
+ return &resource.ResourceSearchRequest{
+ Options: &resource.ListOptions{
+ Key: &resource.ResourceKey{
+ Namespace: "default",
+ Group: "dashboard.grafana.app",
+ Resource: "dashboards",
+ },
+ Fields: []*resource.Requirement{{Key: "title", Operator: "=", Values: []string{query}}},
+ },
+ Limit: 100000,
+ }
+}
+
+func newTestDashboardsIndex(t TB, threshold int64, size int64, batchSize int64, writer IndexWriter) (resource.ResourceIndex, string) {
key := &resource.ResourceKey{
Namespace: "default",
Group: "dashboard.grafana.app",
@@ -332,17 +447,18 @@ func newTestDashboardsIndex(t *testing.T) resource.ResourceIndex {
tmpdir, err := os.MkdirTemp("", "grafana-bleve-test")
require.NoError(t, err)
- backend, err := NewBleveBackend(BleveOptions{
+ backend, err := search.NewBleveBackend(search.BleveOptions{
Root: tmpdir,
- FileThreshold: 9999, // use in-memory for tests
+ FileThreshold: threshold, // use in-memory for tests
+ BatchSize: int(batchSize),
}, tracing.NewNoopTracerService(), featuremgmt.WithFeatures(featuremgmt.FlagUnifiedStorageSearchPermissionFiltering), nil)
require.NoError(t, err)
rv := int64(10)
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{Namespace: "ns"})
- info, err := DashboardBuilder(func(ctx context.Context, namespace string, blob resource.BlobSupport) (resource.DocumentBuilder, error) {
- return &DashboardDocumentBuilder{
+ info, err := search.DashboardBuilder(func(ctx context.Context, namespace string, blob resource.BlobSupport) (resource.DocumentBuilder, error) {
+ return &search.DashboardDocumentBuilder{
Namespace: namespace,
Blob: blob,
Stats: make(map[string]map[string]int64), // empty stats
@@ -355,10 +471,16 @@ func newTestDashboardsIndex(t *testing.T) resource.ResourceIndex {
Namespace: key.Namespace,
Group: key.Group,
Resource: key.Resource,
- }, 2, rv, info.Fields, func(index resource.ResourceIndex) (int64, error) { return 0, nil })
+ }, size, rv, info.Fields, writer)
require.NoError(t, err)
- return index
+ return index, tmpdir
+}
+
+type IndexWriter func(index resource.ResourceIndex) (int64, error)
+
+var noop IndexWriter = func(index resource.ResourceIndex) (int64, error) {
+ return 0, nil
}
// helper to check which tokens are generated by an analyzer
@@ -399,3 +521,15 @@ func debugIndexedTerms(index bleve.Index, field string) {
}
}
}
+
+// TB is an interface that works for both *testing.T and *testing.B
+type TB interface {
+ Log(args ...interface{})
+ Logf(format string, args ...interface{})
+ Error(args ...interface{})
+ Errorf(format string, args ...interface{})
+ Fatal(args ...interface{})
+ Fatalf(format string, args ...interface{})
+ Helper()
+ FailNow()
+}
diff --git a/pkg/storage/unified/search/document_test.go b/pkg/storage/unified/search/document_test.go
index c11ada997a6..49c7e0c9e89 100644
--- a/pkg/storage/unified/search/document_test.go
+++ b/pkg/storage/unified/search/document_test.go
@@ -1,4 +1,4 @@
-package search
+package search_test
import (
"context"
@@ -13,6 +13,7 @@ import (
"github.com/grafana/grafana/pkg/services/store/kind/dashboard"
"github.com/grafana/grafana/pkg/storage/unified/resource"
+ "github.com/grafana/grafana/pkg/storage/unified/search"
)
func doSnapshotTests(t *testing.T, builder resource.DocumentBuilder, kind string, key *resource.ResourceKey, names []string) {
@@ -52,14 +53,14 @@ func TestDashboardDocumentBuilder(t *testing.T) {
Resource: "dashboards",
}
- info, err := DashboardBuilder(func(ctx context.Context, namespace string, blob resource.BlobSupport) (resource.DocumentBuilder, error) {
- return &DashboardDocumentBuilder{
+ info, err := search.DashboardBuilder(func(ctx context.Context, namespace string, blob resource.BlobSupport) (resource.DocumentBuilder, error) {
+ return &search.DashboardDocumentBuilder{
Namespace: namespace,
Blob: blob,
Stats: map[string]map[string]int64{
"aaa": {
- DASHBOARD_ERRORS_LAST_1_DAYS: 1,
- DASHBOARD_ERRORS_LAST_7_DAYS: 1,
+ search.DASHBOARD_ERRORS_LAST_1_DAYS: 1,
+ search.DASHBOARD_ERRORS_LAST_7_DAYS: 1,
},
},
DatasourceLookup: dashboard.CreateDatasourceLookup([]*dashboard.DatasourceQueryResult{{
diff --git a/pkg/storage/unified/sql/backend.go b/pkg/storage/unified/sql/backend.go
index c4f2a82e725..d675998f97a 100644
--- a/pkg/storage/unified/sql/backend.go
+++ b/pkg/storage/unified/sql/backend.go
@@ -308,9 +308,6 @@ func (b *backend) WriteEvent(ctx context.Context, event resource.WriteEvent) (in
// TODO: validate key ?
switch event.Type {
case resource.WatchEvent_ADDED:
- if event.ObjectOld != nil {
- return b.restore(ctx, event)
- }
return b.create(ctx, event)
case resource.WatchEvent_MODIFIED:
return b.update(ctx, event)
@@ -488,73 +485,6 @@ func (b *backend) delete(ctx context.Context, event resource.WriteEvent) (int64,
return rv, nil
}
-func (b *backend) restore(ctx context.Context, event resource.WriteEvent) (int64, error) {
- ctx, span := b.tracer.Start(ctx, tracePrefix+"Restore")
- defer span.End()
- guid := uuid.New().String()
- folder := ""
- if event.Object != nil {
- folder = event.Object.GetFolder()
- }
- rv, err := b.rvManager.ExecWithRV(ctx, event.Key, func(tx db.Tx) (string, error) {
- // 1. Re-create resource
- // Note: we may want to replace the write event with a create event, tbd.
- if _, err := dbutil.Exec(ctx, tx, sqlResourceInsert, sqlResourceRequest{
- SQLTemplate: sqltemplate.New(b.dialect),
- WriteEvent: event,
- Folder: folder,
- GUID: guid,
- }); err != nil {
- return guid, fmt.Errorf("insert into resource: %w", err)
- }
-
- // 2. Insert into resource history
- if _, err := dbutil.Exec(ctx, tx, sqlResourceHistoryInsert, sqlResourceRequest{
- SQLTemplate: sqltemplate.New(b.dialect),
- WriteEvent: event,
- Folder: folder,
- GUID: guid,
- }); err != nil {
- return guid, fmt.Errorf("insert into resource history: %w", err)
- }
- _ = b.historyPruner.Add(pruningKey{
- namespace: event.Key.Namespace,
- group: event.Key.Group,
- resource: event.Key.Resource,
- name: event.Key.Name,
- })
-
- // 3. Update all resource history entries with the new UID
- // Note: we do not update any history entries that have a deletion timestamp included. This will become
- // important once we start using finalizers, as the initial delete will show up as an update with a deletion timestamp included.
- if _, err := dbutil.Exec(ctx, tx, sqlResoureceHistoryUpdateUid, sqlResourceHistoryUpdateRequest{
- SQLTemplate: sqltemplate.New(b.dialect),
- WriteEvent: event,
- OldUID: string(event.ObjectOld.GetUID()),
- NewUID: string(event.Object.GetUID()),
- }); err != nil {
- return guid, fmt.Errorf("update history uid: %w", err)
- }
-
- return guid, nil
- })
-
- if err != nil {
- return 0, err
- }
-
- b.notifier.send(ctx, &resource.WrittenEvent{
- Type: event.Type,
- Key: event.Key,
- PreviousRV: event.PreviousRV,
- Value: event.Value,
- ResourceVersion: rv,
- Folder: folder,
- })
-
- return rv, nil
-}
-
func (b *backend) ReadResource(ctx context.Context, req *resource.ReadRequest) *resource.BackendReadResponse {
_, span := b.tracer.Start(ctx, tracePrefix+".Read")
defer span.End()
@@ -577,17 +507,6 @@ func (b *backend) ReadResource(ctx context.Context, req *resource.ReadRequest) *
err := b.db.WithTx(ctx, ReadCommittedRO, func(ctx context.Context, tx db.Tx) error {
var err error
res, err = dbutil.QueryRow(ctx, tx, sr, readReq)
- // if not found, look for latest deleted version (if requested)
- if errors.Is(err, sql.ErrNoRows) && req.IncludeDeleted {
- sr = sqlResourceHistoryRead
- readReq2 := &sqlResourceReadRequest{
- SQLTemplate: sqltemplate.New(b.dialect),
- Request: req,
- Response: NewReadResponse(),
- }
- res, err = dbutil.QueryRow(ctx, tx, sr, readReq2)
- return err
- }
return err
})
@@ -625,9 +544,10 @@ func (b *backend) ListIterator(ctx context.Context, req *resource.ListRequest, c
}
type listIter struct {
- rows db.Rows
- offset int64
- listRV int64
+ rows db.Rows
+ offset int64
+ listRV int64
+ sortAsc bool
// any error
err error
@@ -642,11 +562,11 @@ type listIter struct {
// ContinueToken implements resource.ListIterator.
func (l *listIter) ContinueToken() string {
- return resource.ContinueToken{ResourceVersion: l.listRV, StartOffset: l.offset}.String()
+ return resource.ContinueToken{ResourceVersion: l.listRV, StartOffset: l.offset, SortAscending: l.sortAsc}.String()
}
func (l *listIter) ContinueTokenWithCurrentRV() string {
- return resource.ContinueToken{ResourceVersion: l.rv, StartOffset: l.offset}.String()
+ return resource.ContinueToken{ResourceVersion: l.rv, StartOffset: l.offset, SortAscending: l.sortAsc}.String()
}
func (l *listIter) Error() error {
@@ -696,7 +616,7 @@ func (b *backend) listLatest(ctx context.Context, req *resource.ListRequest, cb
return 0, fmt.Errorf("only works for the 'latest' resource version")
}
- iter := &listIter{}
+ iter := &listIter{sortAsc: false}
err := b.db.WithTx(ctx, ReadCommittedRO, func(ctx context.Context, tx db.Tx) error {
var err error
iter.listRV, err = fetchLatestRV(ctx, tx, b.dialect, req.Options.Key.Group, req.Options.Key.Resource)
@@ -730,8 +650,11 @@ func (b *backend) listLatest(ctx context.Context, req *resource.ListRequest, cb
// listAtRevision fetches the resources from the resource_history table at a specific revision.
func (b *backend) listAtRevision(ctx context.Context, req *resource.ListRequest, cb func(resource.ListIterator) error) (int64, error) {
+ ctx, span := b.tracer.Start(ctx, tracePrefix+"listAtRevision")
+ defer span.End()
+
// Get the RV
- iter := &listIter{listRV: req.ResourceVersion}
+ iter := &listIter{listRV: req.ResourceVersion, sortAsc: false}
if req.NextPageToken != "" {
continueToken, err := resource.GetContinueToken(req.NextPageToken)
if err != nil {
@@ -748,6 +671,10 @@ func (b *backend) listAtRevision(ctx context.Context, req *resource.ListRequest,
return 0, apierrors.NewBadRequest("expecting an explicit resource version query")
}
+ // The query below has the potential to be EXTREMELY slow if the resource_history table is big. May be helpful to know
+ // which stack is calling this.
+ b.log.Debug("listAtRevision", "ns", req.Options.Key.Namespace, "group", req.Options.Key.Group, "resource", req.Options.Key.Resource, "rv", iter.listRV)
+
err := b.db.WithTx(ctx, ReadCommittedRO, func(ctx context.Context, tx db.Tx) error {
limit := int64(0) // ignore limit
if iter.offset > 0 {
@@ -781,14 +708,37 @@ func (b *backend) listAtRevision(ctx context.Context, req *resource.ListRequest,
return iter.listRV, err
}
-// listLatest fetches the resources from the resource table.
+// getHistory fetches the resources from the resource table.
func (b *backend) getHistory(ctx context.Context, req *resource.ListRequest, cb func(resource.ListIterator) error) (int64, error) {
+ // Backwards compatibility for ResourceVersionMatch
+ if req.VersionMatch != nil && req.GetVersionMatchV2() == resource.ResourceVersionMatchV2_UNKNOWN {
+ switch req.GetVersionMatch() {
+ case resource.ResourceVersionMatch_DEPRECATED_NotOlderThan:
+ // This is not a typo. The old implementation actually did behave like Unset.
+ req.VersionMatchV2 = resource.ResourceVersionMatchV2_Unset
+ case resource.ResourceVersionMatch_DEPRECATED_Exact:
+ req.VersionMatchV2 = resource.ResourceVersionMatchV2_Exact
+ default:
+ return 0, fmt.Errorf("unknown version match: %v", req.GetVersionMatch())
+ }
+
+ // Log the migration for debugging purposes
+ b.log.Debug("Old client request received, migrating from version_match to version_match_v2",
+ "oldValue", req.GetVersionMatch(),
+ "newValue", req.GetVersionMatchV2())
+ }
+
listReq := sqlGetHistoryRequest{
SQLTemplate: sqltemplate.New(b.dialect),
Key: req.Options.Key,
Trash: req.Source == resource.ListRequest_TRASH,
}
+ // We are assuming that users want history in ascending order
+ // when they are using NotOlderThan matching, and descending order
+ // for Unset (default) and Exact matching.
+ listReq.SortAscending = req.GetVersionMatchV2() == resource.ResourceVersionMatchV2_NotOlderThan
+
iter := &listIter{}
if req.NextPageToken != "" {
continueToken, err := resource.GetContinueToken(req.NextPageToken)
@@ -796,10 +746,12 @@ func (b *backend) getHistory(ctx context.Context, req *resource.ListRequest, cb
return 0, fmt.Errorf("get continue token: %w", err)
}
listReq.StartRV = continueToken.ResourceVersion
+ listReq.SortAscending = continueToken.SortAscending
}
+ iter.sortAsc = listReq.SortAscending
// Set ExactRV when using Exact matching
- if req.VersionMatch == resource.ResourceVersionMatch_Exact {
+ if req.VersionMatchV2 == resource.ResourceVersionMatchV2_Exact {
if req.ResourceVersion <= 0 {
return 0, fmt.Errorf("expecting an explicit resource version query when using Exact matching")
}
@@ -807,7 +759,7 @@ func (b *backend) getHistory(ctx context.Context, req *resource.ListRequest, cb
}
// Set MinRV when using NotOlderThan matching to filter at the database level
- if req.ResourceVersion > 0 && req.VersionMatch == resource.ResourceVersionMatch_NotOlderThan {
+ if req.ResourceVersion > 0 && req.VersionMatchV2 == resource.ResourceVersionMatchV2_NotOlderThan {
listReq.MinRV = req.ResourceVersion
}
diff --git a/pkg/storage/unified/sql/backend_test.go b/pkg/storage/unified/sql/backend_test.go
index 24b9317c568..f275b2b78e5 100644
--- a/pkg/storage/unified/sql/backend_test.go
+++ b/pkg/storage/unified/sql/backend_test.go
@@ -376,88 +376,6 @@ func TestBackend_delete(t *testing.T) {
})
}
-func TestBackend_restore(t *testing.T) {
- t.Parallel()
- meta, err := utils.MetaAccessor(&unstructured.Unstructured{
- Object: map[string]any{},
- })
- require.NoError(t, err)
- meta.SetUID("new-uid")
- oldMeta, err := utils.MetaAccessor(&unstructured.Unstructured{
- Object: map[string]any{},
- })
- require.NoError(t, err)
- oldMeta.SetUID("old-uid")
- event := resource.WriteEvent{
- Type: resource.WatchEvent_ADDED,
- Key: resKey,
- Object: meta,
- ObjectOld: oldMeta,
- }
-
- t.Run("happy path", func(t *testing.T) {
- t.Parallel()
- b, ctx := setupBackendTest(t)
-
- b.SQLMock.ExpectBegin()
- expectSuccessfulResourceVersionExec(t, b.TestDBProvider,
- func() { b.ExecWithResult("insert resource", 0, 1) },
- func() { b.ExecWithResult("insert resource_history", 0, 1) },
- func() { b.ExecWithResult("update resource_history", 0, 1) },
- )
- b.SQLMock.ExpectCommit()
-
- v, err := b.restore(ctx, event)
- require.NoError(t, err)
- require.Equal(t, int64(200), v)
- })
-
- t.Run("error restoring resource", func(t *testing.T) {
- t.Parallel()
- b, ctx := setupBackendTest(t)
-
- b.SQLMock.ExpectBegin()
- b.ExecWithErr("insert resource", errTest)
- b.SQLMock.ExpectRollback()
-
- v, err := b.restore(ctx, event)
- require.Zero(t, v)
- require.Error(t, err)
- require.ErrorContains(t, err, "insert into resource")
- })
-
- t.Run("error inserting into resource history", func(t *testing.T) {
- t.Parallel()
- b, ctx := setupBackendTest(t)
-
- b.SQLMock.ExpectBegin()
- b.ExecWithResult("insert resource", 0, 1)
- b.ExecWithErr("insert resource_history", errTest)
- b.SQLMock.ExpectRollback()
-
- v, err := b.restore(ctx, event)
- require.Zero(t, v)
- require.Error(t, err)
- require.ErrorContains(t, err, "insert into resource history")
- })
-
- t.Run("error updating resource history uid", func(t *testing.T) {
- t.Parallel()
- b, ctx := setupBackendTest(t)
-
- b.SQLMock.ExpectBegin()
- b.ExecWithResult("insert resource", 0, 1)
- b.ExecWithResult("insert resource_history", 0, 1)
- b.ExecWithErr("update resource_history", errTest)
- b.SQLMock.ExpectRollback()
-
- v, err := b.restore(ctx, event)
- require.Zero(t, v)
- require.Error(t, err)
- require.ErrorContains(t, err, "update history uid")
- })
-}
-
func TestBackend_getHistory(t *testing.T) {
t.Parallel()
@@ -473,7 +391,7 @@ func TestBackend_getHistory(t *testing.T) {
tests := []struct {
name string
- versionMatch resource.ResourceVersionMatch
+ versionMatch resource.ResourceVersionMatchV2
resourceVersion int64
expectedVersions []int64
expectedListRv int64
@@ -482,30 +400,37 @@ func TestBackend_getHistory(t *testing.T) {
}{
{
name: "with ResourceVersionMatch_NotOlderThan",
- versionMatch: resource.ResourceVersionMatch_NotOlderThan,
+ versionMatch: resource.ResourceVersionMatchV2_NotOlderThan,
resourceVersion: rv2,
- expectedVersions: []int64{rv3, rv2},
+ expectedVersions: []int64{rv2, rv3}, // Should be in ASC order due to NotOlderThan
expectedListRv: rv3,
expectedRowsCount: 2,
},
+ {
+ name: "with ResourceVersionMatch_NotOlderThan and ResourceVersion=0",
+ versionMatch: resource.ResourceVersionMatchV2_NotOlderThan,
+ resourceVersion: 0,
+ expectedVersions: []int64{rv1, rv2, rv3}, // Should be in ASC order due to NotOlderThan
+ expectedListRv: rv3,
+ expectedRowsCount: 3,
+ },
{
name: "with ResourceVersionMatch_Exact",
- versionMatch: resource.ResourceVersionMatch_Exact,
+ versionMatch: resource.ResourceVersionMatchV2_Exact,
resourceVersion: rv2,
expectedVersions: []int64{rv2},
expectedListRv: rv3,
expectedRowsCount: 1,
},
{
- name: "without version matcher",
- versionMatch: resource.ResourceVersionMatch_NotOlderThan,
- expectedVersions: []int64{rv3, rv2, rv1},
+ name: "with ResourceVersionMatch_Unset (default)",
+ expectedVersions: []int64{rv3, rv2, rv1}, // Should be in DESC order by default
expectedListRv: rv3,
expectedRowsCount: 3,
},
{
name: "error with ResourceVersionMatch_Exact and ResourceVersion <= 0",
- versionMatch: resource.ResourceVersionMatch_Exact,
+ versionMatch: resource.ResourceVersionMatchV2_Exact,
resourceVersion: 0,
expectedErr: "expecting an explicit resource version query when using Exact matching",
},
@@ -521,7 +446,7 @@ func TestBackend_getHistory(t *testing.T) {
req := &resource.ListRequest{
Options: &resource.ListOptions{Key: key},
ResourceVersion: tc.resourceVersion,
- VersionMatch: tc.versionMatch,
+ VersionMatchV2: tc.versionMatch,
Source: resource.ListRequest_HISTORY,
}
@@ -536,14 +461,18 @@ func TestBackend_getHistory(t *testing.T) {
// Callback that tracks returned items
callback := func(iter resource.ListIterator) error {
count := 0
+ var seenVersions []int64
for iter.Next() {
count++
currentRV := iter.ResourceVersion()
+ seenVersions = append(seenVersions, currentRV)
expectedValue, ok := expectedValues[currentRV]
require.True(t, ok, "Got unexpected RV: %d", currentRV)
require.Equal(t, expectedValue, string(iter.Value()))
}
require.Equal(t, tc.expectedRowsCount, count)
+ // Verify the order matches what we expect
+ require.Equal(t, tc.expectedVersions, seenVersions, "Resource versions returned in incorrect order")
return nil
}
@@ -585,3 +514,153 @@ func TestBackend_getHistory(t *testing.T) {
})
}
}
+
+// TestBackend_getHistoryPagination tests the ordering behavior for ResourceVersionMatch_NotOlderThan
+// when using pagination, ensuring entries are returned in oldest-to-newest order.
+func TestBackend_getHistoryPagination(t *testing.T) {
+ t.Parallel()
+
+ // Common setup
+ key := &resource.ResourceKey{
+ Namespace: "ns",
+ Group: "gr",
+ Resource: "rs",
+ Name: "nm",
+ }
+
+ // Create resource versions that will be returned in our test
+ versions := make([]int64, 10)
+ for i := range versions {
+ versions[i] = int64(51 + i)
+ }
+ rv51, rv52, rv53, rv54, rv55, rv56, rv57, rv58, rv59, rv60 := versions[0], versions[1], versions[2], versions[3], versions[4], versions[5], versions[6], versions[7], versions[8], versions[9]
+
+ t.Run("pagination with NotOlderThan should return entries from oldest to newest", func(t *testing.T) {
+ b, ctx := setupBackendTest(t)
+
+ // Define all pages we want to test
+ pages := []struct {
+ versions []int64
+ token *resource.ContinueToken
+ }{
+ {
+ versions: []int64{rv51, rv52, rv53, rv54},
+ token: nil,
+ },
+ {
+ versions: []int64{rv55, rv56, rv57, rv58},
+ token: &resource.ContinueToken{
+ ResourceVersion: rv54,
+ StartOffset: 4,
+ SortAscending: true,
+ },
+ },
+ {
+ versions: []int64{rv59, rv60},
+ token: &resource.ContinueToken{
+ ResourceVersion: rv58,
+ StartOffset: 8,
+ SortAscending: true,
+ },
+ },
+ }
+
+ var allItems []int64
+ initialRV := rv51
+
+ // Test each page
+ for _, page := range pages {
+ req := &resource.ListRequest{
+ Options: &resource.ListOptions{Key: key},
+ ResourceVersion: initialRV,
+ VersionMatchV2: resource.ResourceVersionMatchV2_NotOlderThan,
+ Source: resource.ListRequest_HISTORY,
+ Limit: 4,
+ }
+ if page.token != nil {
+ req.NextPageToken = page.token.String()
+ }
+
+ items := make([]int64, 0)
+ callback := func(iter resource.ListIterator) error {
+ for iter.Next() {
+ items = append(items, iter.ResourceVersion())
+ }
+ return nil
+ }
+
+ b.SQLMock.ExpectBegin()
+ historyRows := setupHistoryTest(b, page.versions, rv60)
+ b.SQLMock.ExpectQuery("SELECT .* FROM resource_history").WillReturnRows(historyRows)
+ b.SQLMock.ExpectCommit()
+
+ listRv, err := b.getHistory(ctx, req, callback)
+ require.NoError(t, err)
+ require.Equal(t, rv60, listRv, "Head version should be the latest resource version (rv60)")
+ require.Equal(t, page.versions, items, "Items should be in ASC order")
+
+ allItems = append(allItems, items...)
+ }
+
+ // Verify complete sequence
+ expectedAllItems := []int64{rv51, rv52, rv53, rv54, rv55, rv56, rv57, rv58, rv59, rv60}
+ require.Equal(t, expectedAllItems, allItems)
+ })
+
+ t.Run("pagination with ResourceVersion=0 and NotOlderThan should return entries in ASC order", func(t *testing.T) {
+ b, ctx := setupBackendTest(t)
+
+ req := &resource.ListRequest{
+ Options: &resource.ListOptions{Key: key},
+ ResourceVersion: 0,
+ VersionMatchV2: resource.ResourceVersionMatchV2_NotOlderThan,
+ Source: resource.ListRequest_HISTORY,
+ Limit: 4,
+ }
+
+ // First batch of items we expect, in ASC order (because of NotOlderThan flag)
+ // Even with ResourceVersion=0, the order is ASC because we use SortAscending=true
+ expectedVersions := []int64{rv51, rv52, rv53, rv54}
+ items := make([]int64, 0)
+
+ callback := func(iter resource.ListIterator) error {
+ for iter.Next() {
+ items = append(items, iter.ResourceVersion())
+ }
+ return nil
+ }
+
+ b.SQLMock.ExpectBegin()
+ historyRows := setupHistoryTest(b, expectedVersions, rv60)
+ b.SQLMock.ExpectQuery("SELECT .* FROM resource_history").WillReturnRows(historyRows)
+ b.SQLMock.ExpectCommit()
+
+ listRv, err := b.getHistory(ctx, req, callback)
+ require.NoError(t, err)
+ require.Equal(t, rv60, listRv, "Head version should be the latest resource version (rv60)")
+ require.Equal(t, expectedVersions, items, "Items should be in ASC order even with ResourceVersion=0")
+ })
+}
+
+// setupHistoryTest creates the necessary mock expectations for a history test
+func setupHistoryTest(b testBackend, resourceVersions []int64, latestRV int64) *sqlmock.Rows {
+ // Expect fetch latest RV call - set to the highest resource version
+ latestRVRows := sqlmock.NewRows([]string{"resource_version", "unix_timestamp"}).
+ AddRow(latestRV, 0)
+ b.SQLMock.ExpectQuery("SELECT .* FROM resource_version").WillReturnRows(latestRVRows)
+
+ // Create the mock rows for the history items
+ cols := []string{"resource_version", "namespace", "name", "folder", "value"}
+ historyRows := sqlmock.NewRows(cols)
+ for _, rv := range resourceVersions {
+ historyRows.AddRow(
+ rv, // resource_version
+ "ns", // namespace
+ "nm", // name
+ "folder", // folder
+ []byte(fmt.Sprintf("rv-%d", rv)), // value
+ )
+ }
+
+ return historyRows
+}
diff --git a/pkg/storage/unified/sql/data/resource_history_get.sql b/pkg/storage/unified/sql/data/resource_history_get.sql
index 066f0498810..379cc67967c 100644
--- a/pkg/storage/unified/sql/data/resource_history_get.sql
+++ b/pkg/storage/unified/sql/data/resource_history_get.sql
@@ -16,12 +16,20 @@ WHERE 1 = 1
AND {{ .Ident "action" }} = 3
{{ end }}
{{ if (gt .StartRV 0) }}
+ {{ if .SortAscending }}
+ AND {{ .Ident "resource_version" }} > {{ .Arg .StartRV }}
+ {{ else }}
AND {{ .Ident "resource_version" }} < {{ .Arg .StartRV }}
{{ end }}
+ {{ end }}
{{ if (gt .MinRV 0) }}
AND {{ .Ident "resource_version" }} >= {{ .Arg .MinRV }}
{{ end }}
{{ if (gt .ExactRV 0) }}
AND {{ .Ident "resource_version" }} = {{ .Arg .ExactRV }}
{{ end }}
+{{ if .SortAscending }}
+ORDER BY resource_version ASC
+{{ else }}
ORDER BY resource_version DESC
+{{ end }}
diff --git a/pkg/storage/unified/sql/data/resource_history_read.sql b/pkg/storage/unified/sql/data/resource_history_read.sql
index d78209817b2..3eb350e275d 100644
--- a/pkg/storage/unified/sql/data/resource_history_read.sql
+++ b/pkg/storage/unified/sql/data/resource_history_read.sql
@@ -14,12 +14,8 @@ SELECT
AND {{ .Ident "group" }} = {{ .Arg .Request.Key.Group }}
AND {{ .Ident "resource" }} = {{ .Arg .Request.Key.Resource }}
AND {{ .Ident "name" }} = {{ .Arg .Request.Key.Name }}
- {{ if .Request.IncludeDeleted }}
- AND {{ .Ident "action" }} != 3
- AND {{ .Ident "value" }} NOT LIKE '%deletionTimestamp%'
- {{ end }}
{{ if gt .Request.ResourceVersion 0 }}
- AND {{ .Ident "resource_version" }} {{ if .Request.IncludeDeleted }}={{ else }}<={{ end }} {{ .Arg .Request.ResourceVersion }}
+ AND {{ .Ident "resource_version" }} <= {{ .Arg .Request.ResourceVersion }}
{{ end }}
ORDER BY {{ .Ident "resource_version" }} DESC
LIMIT 1
diff --git a/pkg/storage/unified/sql/data/resource_history_update_uid.sql b/pkg/storage/unified/sql/data/resource_history_update_uid.sql
deleted file mode 100644
index 7624b5e9c53..00000000000
--- a/pkg/storage/unified/sql/data/resource_history_update_uid.sql
+++ /dev/null
@@ -1,8 +0,0 @@
-UPDATE {{ .Ident "resource_history" }}
- SET {{ .Ident "value" }} = REPLACE({{ .Ident "value" }}, CONCAT('"uid":"', {{ .Arg .OldUID }}, '"'), CONCAT('"uid":"', {{ .Arg .NewUID }}, '"'))
- WHERE {{ .Ident "name" }} = {{ .Arg .WriteEvent.Key.Name }}
- AND {{ .Ident "namespace" }} = {{ .Arg .WriteEvent.Key.Namespace }}
- AND {{ .Ident "group" }} = {{ .Arg .WriteEvent.Key.Group }}
- AND {{ .Ident "resource" }} = {{ .Arg .WriteEvent.Key.Resource }}
- AND {{ .Ident "action" }} != 3
- AND {{ .Ident "value" }} NOT LIKE '%deletionTimestamp%';
diff --git a/pkg/storage/unified/sql/queries.go b/pkg/storage/unified/sql/queries.go
index 022c33f4775..0da9cd9187e 100644
--- a/pkg/storage/unified/sql/queries.go
+++ b/pkg/storage/unified/sql/queries.go
@@ -39,7 +39,6 @@ var (
sqlResourceUpdateRV = mustTemplate("resource_update_rv.sql")
sqlResourceHistoryRead = mustTemplate("resource_history_read.sql")
sqlResourceHistoryUpdateRV = mustTemplate("resource_history_update_rv.sql")
- sqlResoureceHistoryUpdateUid = mustTemplate("resource_history_update_uid.sql")
sqlResourceHistoryInsert = mustTemplate("resource_history_insert.sql")
sqlResourceHistoryPoll = mustTemplate("resource_history_poll.sql")
sqlResourceHistoryGet = mustTemplate("resource_history_get.sql")
@@ -244,11 +243,12 @@ func (r *sqlResourceHistoryDeleteRequest) Validate() error {
type sqlGetHistoryRequest struct {
sqltemplate.SQLTemplate
- Key *resource.ResourceKey
- Trash bool // only deleted items
- StartRV int64 // from NextPageToken
- MinRV int64 // minimum resource version for NotOlderThan
- ExactRV int64 // exact resource version for Exact
+ Key *resource.ResourceKey
+ Trash bool // only deleted items
+ StartRV int64 // from NextPageToken
+ MinRV int64 // minimum resource version for NotOlderThan
+ ExactRV int64 // exact resource version for Exact
+ SortAscending bool // if true, sort by resource_version ASC, otherwise DESC
}
func (r sqlGetHistoryRequest) Validate() error {
@@ -281,19 +281,6 @@ func (r *sqlPruneHistoryRequest) Validate() error {
return nil
}
-// update resource history
-
-type sqlResourceHistoryUpdateRequest struct {
- sqltemplate.SQLTemplate
- WriteEvent resource.WriteEvent
- OldUID string
- NewUID string
-}
-
-func (r sqlResourceHistoryUpdateRequest) Validate() error {
- return nil // TODO
-}
-
type sqlResourceBlobInsertRequest struct {
sqltemplate.SQLTemplate
Now time.Time
diff --git a/pkg/storage/unified/sql/queries_test.go b/pkg/storage/unified/sql/queries_test.go
index 9858394bccc..6321604fbe4 100644
--- a/pkg/storage/unified/sql/queries_test.go
+++ b/pkg/storage/unified/sql/queries_test.go
@@ -176,26 +176,6 @@ func TestUnifiedStorageQueries(t *testing.T) {
},
},
- sqlResoureceHistoryUpdateUid: {
- {
- Name: "modify uids in history",
- Data: &sqlResourceHistoryUpdateRequest{
- SQLTemplate: mocks.NewTestingSQLTemplate(),
- WriteEvent: resource.WriteEvent{
- Key: &resource.ResourceKey{
- Namespace: "nn",
- Group: "gg",
- Resource: "rr",
- Name: "name",
- },
- PreviousRV: 1234,
- },
- OldUID: "old-uid",
- NewUID: "new-uid",
- },
- },
- },
-
sqlResourceHistoryInsert: {
{
Name: "insert into resource_history",
diff --git a/pkg/storage/unified/sql/testdata/mysql--resource_history_update_uid-modify uids in history.sql b/pkg/storage/unified/sql/testdata/mysql--resource_history_update_uid-modify uids in history.sql
deleted file mode 100755
index 05431eea218..00000000000
--- a/pkg/storage/unified/sql/testdata/mysql--resource_history_update_uid-modify uids in history.sql
+++ /dev/null
@@ -1,8 +0,0 @@
-UPDATE `resource_history`
- SET `value` = REPLACE(`value`, CONCAT('"uid":"', 'old-uid', '"'), CONCAT('"uid":"', 'new-uid', '"'))
- WHERE `name` = 'name'
- AND `namespace` = 'nn'
- AND `group` = 'gg'
- AND `resource` = 'rr'
- AND `action` != 3
- AND `value` NOT LIKE '%deletionTimestamp%';
diff --git a/pkg/storage/unified/sql/testdata/postgres--resource_history_update_uid-modify uids in history.sql b/pkg/storage/unified/sql/testdata/postgres--resource_history_update_uid-modify uids in history.sql
deleted file mode 100755
index 79192fa5a6e..00000000000
--- a/pkg/storage/unified/sql/testdata/postgres--resource_history_update_uid-modify uids in history.sql
+++ /dev/null
@@ -1,8 +0,0 @@
-UPDATE "resource_history"
- SET "value" = REPLACE("value", CONCAT('"uid":"', 'old-uid', '"'), CONCAT('"uid":"', 'new-uid', '"'))
- WHERE "name" = 'name'
- AND "namespace" = 'nn'
- AND "group" = 'gg'
- AND "resource" = 'rr'
- AND "action" != 3
- AND "value" NOT LIKE '%deletionTimestamp%';
diff --git a/pkg/storage/unified/sql/testdata/sqlite--resource_history_update_uid-modify uids in history.sql b/pkg/storage/unified/sql/testdata/sqlite--resource_history_update_uid-modify uids in history.sql
deleted file mode 100755
index 79192fa5a6e..00000000000
--- a/pkg/storage/unified/sql/testdata/sqlite--resource_history_update_uid-modify uids in history.sql
+++ /dev/null
@@ -1,8 +0,0 @@
-UPDATE "resource_history"
- SET "value" = REPLACE("value", CONCAT('"uid":"', 'old-uid', '"'), CONCAT('"uid":"', 'new-uid', '"'))
- WHERE "name" = 'name'
- AND "namespace" = 'nn'
- AND "group" = 'gg'
- AND "resource" = 'rr'
- AND "action" != 3
- AND "value" NOT LIKE '%deletionTimestamp%';
diff --git a/pkg/storage/unified/testing/storage_backend.go b/pkg/storage/unified/testing/storage_backend.go
index 2b55ea386c2..c45d13ce6ba 100644
--- a/pkg/storage/unified/testing/storage_backend.go
+++ b/pkg/storage/unified/testing/storage_backend.go
@@ -443,6 +443,7 @@ func runTestIntegrationBackendList(t *testing.T, backend resource.StorageBackend
continueToken := &resource.ContinueToken{
ResourceVersion: rv8,
StartOffset: 2,
+ SortAscending: false,
}
res, err := server.List(ctx, &resource.ListRequest{
NextPageToken: continueToken.String(),
@@ -492,42 +493,142 @@ func runTestIntegrationBackendListHistory(t *testing.T, backend resource.Storage
require.NoError(t, err)
require.Greater(t, rvHistory5, rvHistory4)
- t.Run("fetch first history page at revision with limit", func(t *testing.T) {
- res, err := server.List(ctx, &resource.ListRequest{
- Limit: 3,
- Source: resource.ListRequest_HISTORY,
- Options: &resource.ListOptions{
- Key: &resource.ResourceKey{
- Namespace: ns,
- Group: "group",
- Resource: "resource",
- Name: "item1",
- },
- },
- })
- require.NoError(t, err)
- require.NoError(t, err)
- require.Nil(t, res.Error)
- require.Len(t, res.Items, 3)
- t.Log(res.Items)
- // should be in desc order, so the newest RVs are returned first
- require.Equal(t, "item1 MODIFIED", string(res.Items[0].Value))
- require.Equal(t, rvHistory5, res.Items[0].ResourceVersion)
- require.Equal(t, "item1 MODIFIED", string(res.Items[1].Value))
- require.Equal(t, rvHistory4, res.Items[1].ResourceVersion)
- require.Equal(t, "item1 MODIFIED", string(res.Items[2].Value))
- require.Equal(t, rvHistory3, res.Items[2].ResourceVersion)
+ t.Run("fetch history with different version matching", func(t *testing.T) {
+ baseKey := &resource.ResourceKey{
+ Namespace: ns,
+ Group: "group",
+ Resource: "resource",
+ Name: "item1",
+ }
- continueToken, err := resource.GetContinueToken(res.NextPageToken)
- require.NoError(t, err)
- // should return the furthest back RV as the next page token
- require.Equal(t, rvHistory3, continueToken.ResourceVersion)
+ tests := []struct {
+ name string
+ request *resource.ListRequest
+ expectedVersions []int64
+ expectedValues []string
+ minExpectedHeadRV int64
+ expectedContinueRV int64
+ expectedSortAsc bool
+ }{
+ {
+ name: "NotOlderThan with rv1 (ASC order)",
+ request: &resource.ListRequest{
+ Limit: 3,
+ Source: resource.ListRequest_HISTORY,
+ ResourceVersion: rv1,
+ VersionMatchV2: resource.ResourceVersionMatchV2_NotOlderThan,
+ Options: &resource.ListOptions{
+ Key: baseKey,
+ },
+ },
+ expectedVersions: []int64{rv1, rvHistory1, rvHistory2},
+ expectedValues: []string{"item1 ADDED", "item1 MODIFIED", "item1 MODIFIED"},
+ minExpectedHeadRV: rvHistory2,
+ expectedContinueRV: rvHistory2,
+ expectedSortAsc: true,
+ },
+ {
+ name: "NotOlderThan with rv=0 (ASC order)",
+ request: &resource.ListRequest{
+ Limit: 3,
+ Source: resource.ListRequest_HISTORY,
+ ResourceVersion: 0,
+ VersionMatchV2: resource.ResourceVersionMatchV2_NotOlderThan,
+ Options: &resource.ListOptions{
+ Key: baseKey,
+ },
+ },
+ expectedVersions: []int64{rv1, rvHistory1, rvHistory2},
+ expectedValues: []string{"item1 ADDED", "item1 MODIFIED", "item1 MODIFIED"},
+ minExpectedHeadRV: rvHistory2,
+ expectedContinueRV: rvHistory2,
+ expectedSortAsc: true,
+ },
+ {
+ name: "ResourceVersionMatch_Unset (DESC order)",
+ request: &resource.ListRequest{
+ Limit: 3,
+ Source: resource.ListRequest_HISTORY,
+ Options: &resource.ListOptions{
+ Key: baseKey,
+ },
+ },
+ expectedVersions: []int64{rvHistory5, rvHistory4, rvHistory3},
+ expectedValues: []string{"item1 MODIFIED", "item1 MODIFIED", "item1 MODIFIED"},
+ minExpectedHeadRV: rvHistory5,
+ expectedContinueRV: rvHistory3,
+ expectedSortAsc: false,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ res, err := server.List(ctx, tc.request)
+ require.NoError(t, err)
+ require.Nil(t, res.Error)
+ require.Len(t, res.Items, 3)
+
+ // Check versions and values match expectations
+ for i := 0; i < 3; i++ {
+ require.Equal(t, tc.expectedVersions[i], res.Items[i].ResourceVersion)
+ require.Equal(t, tc.expectedValues[i], string(res.Items[i].Value))
+ }
+
+ // Check resource version in response
+ require.GreaterOrEqual(t, res.ResourceVersion, tc.minExpectedHeadRV)
+
+ // Check continue token
+ continueToken, err := resource.GetContinueToken(res.NextPageToken)
+ require.NoError(t, err)
+ require.Equal(t, tc.expectedContinueRV, continueToken.ResourceVersion)
+ require.Equal(t, tc.expectedSortAsc, continueToken.SortAscending)
+ })
+ }
+
+ // Test pagination for NotOlderThan (second page)
+ t.Run("second page with NotOlderThan", func(t *testing.T) {
+ // Get first page
+ firstRequest := &resource.ListRequest{
+ Limit: 3,
+ Source: resource.ListRequest_HISTORY,
+ ResourceVersion: rv1,
+ VersionMatchV2: resource.ResourceVersionMatchV2_NotOlderThan,
+ Options: &resource.ListOptions{Key: baseKey},
+ }
+ firstPageRes, err := server.List(ctx, firstRequest)
+ require.NoError(t, err)
+
+ // Get continue token for second page
+ continueToken, err := resource.GetContinueToken(firstPageRes.NextPageToken)
+ require.NoError(t, err)
+
+ // Get second page
+ secondPageRes, err := server.List(ctx, &resource.ListRequest{
+ Limit: 3,
+ Source: resource.ListRequest_HISTORY,
+ ResourceVersion: rv1,
+ VersionMatchV2: resource.ResourceVersionMatchV2_NotOlderThan,
+ NextPageToken: continueToken.String(),
+ Options: &resource.ListOptions{Key: baseKey},
+ })
+ require.NoError(t, err)
+ require.Nil(t, secondPageRes.Error)
+ require.Len(t, secondPageRes.Items, 3)
+
+ // Second page should continue in ascending order
+ expectedRVs := []int64{rvHistory3, rvHistory4, rvHistory5}
+ for i, expectedRV := range expectedRVs {
+ require.Equal(t, expectedRV, secondPageRes.Items[i].ResourceVersion)
+ require.Equal(t, "item1 MODIFIED", string(secondPageRes.Items[i].Value))
+ }
+ })
})
t.Run("fetch second page of history at revision", func(t *testing.T) {
continueToken := &resource.ContinueToken{
ResourceVersion: rvHistory3,
StartOffset: 2,
+ SortAscending: false,
}
res, err := server.List(ctx, &resource.ListRequest{
NextPageToken: continueToken.String(),
@@ -551,6 +652,123 @@ func runTestIntegrationBackendListHistory(t *testing.T, backend resource.Storage
require.Equal(t, "item1 MODIFIED", string(res.Items[1].Value))
require.Equal(t, rvHistory1, res.Items[1].ResourceVersion)
})
+
+ t.Run("paginated history with NotOlderThan returns items in ascending order", func(t *testing.T) {
+ // Create 10 versions of a resource to test pagination
+ ns2 := nsPrefix + "-ns2"
+ resourceKey := &resource.ResourceKey{
+ Namespace: ns2,
+ Group: "group",
+ Resource: "resource",
+ Name: "paged-item",
+ }
+
+ var resourceVersions []int64
+
+ // First create the initial resource
+ initialRV, err := writeEvent(ctx, backend, "paged-item", resource.WatchEvent_ADDED, WithNamespace(ns2))
+ require.NoError(t, err)
+ resourceVersions = append(resourceVersions, initialRV)
+
+ // Create 9 more versions with modifications
+ for i := 0; i < 9; i++ {
+ rv, err := writeEvent(ctx, backend, "paged-item", resource.WatchEvent_MODIFIED, WithNamespace(ns2))
+ require.NoError(t, err)
+ resourceVersions = append(resourceVersions, rv)
+ }
+
+ // Now we should have 10 versions total (1 ADDED + 9 MODIFIED)
+ require.Len(t, resourceVersions, 10)
+
+ // Test pagination with limit of 3 and NotOlderThan, starting from the beginning
+ pages := []struct {
+ pageNumber int
+ pageSize int
+ startToken string
+ }{
+ {pageNumber: 1, pageSize: 3, startToken: ""},
+ {pageNumber: 2, pageSize: 3, startToken: ""}, // Will be set in the test
+ {pageNumber: 3, pageSize: 3, startToken: ""}, // Will be set in the test
+ {pageNumber: 4, pageSize: 1, startToken: ""}, // Will be set in the test - last page with remaining item
+ }
+
+ var allItems []*resource.ResourceWrapper
+
+ // Request first page with NotOlderThan and ResourceVersion=0 (should start from oldest)
+ for i, page := range pages {
+ req := &resource.ListRequest{
+ Limit: int64(page.pageSize),
+ Source: resource.ListRequest_HISTORY,
+ ResourceVersion: 0,
+ VersionMatchV2: resource.ResourceVersionMatchV2_NotOlderThan,
+ Options: &resource.ListOptions{
+ Key: resourceKey,
+ },
+ }
+
+ if i > 0 {
+ // For subsequent pages, use the continue token from the previous page
+ req.NextPageToken = pages[i].startToken
+ }
+
+ res, err := server.List(ctx, req)
+ require.NoError(t, err)
+ require.Nil(t, res.Error)
+
+ // First 3 pages should have exactly pageSize items
+ if i < 3 {
+ require.Len(t, res.Items, page.pageSize, "Page %d should have %d items", i+1, page.pageSize)
+ } else {
+ // Last page should have 1 item (10 items total with 3+3+3+1 distribution)
+ require.Len(t, res.Items, 1, "Last page should have 1 item")
+ }
+
+ // Save continue token for next page if not the last page
+ if i < len(pages)-1 {
+ pages[i+1].startToken = res.NextPageToken
+ require.NotEmpty(t, res.NextPageToken, "Should have continue token for page %d", i+1)
+ } else {
+ // Last page should not have a continue token
+ require.Empty(t, res.NextPageToken, "Last page should not have continue token")
+ }
+
+ // Add items to our collection
+ allItems = append(allItems, res.Items...)
+
+ // Verify all items in current page are in ascending order
+ for j := 1; j < len(res.Items); j++ {
+ require.Less(t, res.Items[j-1].ResourceVersion, res.Items[j].ResourceVersion,
+ "Items within page %d should be in ascending order", i+1)
+ }
+
+ // For pages after the first, verify first item of current page is greater than last item of previous page
+ if i > 0 && len(allItems) > page.pageSize {
+ prevPageLastIdx := len(allItems) - len(res.Items) - 1
+ currentPageFirstIdx := len(allItems) - len(res.Items)
+ require.Greater(t, allItems[currentPageFirstIdx].ResourceVersion, allItems[prevPageLastIdx].ResourceVersion,
+ "First item of page %d should have higher RV than last item of page %d", i+1, i)
+ }
+ }
+
+ // Verify we got all 10 items
+ require.Len(t, allItems, 10, "Should have retrieved all 10 items")
+
+ // Verify all items are in ascending order of resource version
+ for i := 1; i < len(allItems); i++ {
+ require.Less(t, allItems[i-1].ResourceVersion, allItems[i].ResourceVersion,
+ "All items should be in ascending order of resource version")
+ }
+
+ // Verify the first item is the initial ADDED event
+ require.Equal(t, initialRV, allItems[0].ResourceVersion, "First item should be the initial ADDED event")
+ require.Equal(t, "paged-item ADDED", string(allItems[0].Value))
+
+ // Verify all other items are MODIFIED events and correspond to our recorded resource versions
+ for i := 1; i < len(allItems); i++ {
+ require.Equal(t, "paged-item MODIFIED", string(allItems[i].Value))
+ require.Equal(t, resourceVersions[i], allItems[i].ResourceVersion)
+ }
+ })
}
func runTestIntegrationBlobSupport(t *testing.T, backend resource.StorageBackend, nsPrefix string) {
diff --git a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json
index d74ca23d0be..5121b7861eb 100644
--- a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json
+++ b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json
@@ -1121,7 +1121,7 @@
"type": "boolean",
"default": false
},
- "prefix": {
+ "path": {
"description": "Prefix in target file system",
"type": "string"
}
@@ -1130,7 +1130,7 @@
"example": {
"folder": "grafan-folder-ref",
"branch": "target-branch",
- "prefix": "prefix/in/repo/tree",
+ "path": "path/in/tree",
"identifier": false
}
}
@@ -1780,15 +1780,10 @@
"description": "Include the identifier in the exported metadata",
"type": "boolean",
"default": false
- },
- "prefix": {
- "description": "Target file prefix",
- "type": "string"
}
}
},
"example": {
- "prefix": "prefix/in/repo/tree",
"history": true,
"identifier": false
}
@@ -2567,7 +2562,7 @@
"type": "boolean",
"default": false
},
- "prefix": {
+ "path": {
"description": "Prefix in target file system",
"type": "string"
}
@@ -3035,10 +3030,6 @@
"description": "Include the identifier in the exported metadata",
"type": "boolean",
"default": false
- },
- "prefix": {
- "description": "Target file prefix",
- "type": "string"
}
}
},
diff --git a/pkg/tests/apis/playlist/playlist_test.go b/pkg/tests/apis/playlist/playlist_test.go
index a7b61abec50..a30aeba2fc2 100644
--- a/pkg/tests/apis/playlist/playlist_test.go
+++ b/pkg/tests/apis/playlist/playlist_test.go
@@ -10,11 +10,13 @@ import (
"testing"
"github.com/stretchr/testify/require"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
+ "github.com/grafana/grafana/pkg/apimachinery/utils"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/apiserver/options"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -156,7 +158,7 @@ func TestIntegrationPlaylist(t *testing.T) {
})
t.Run("with dual write (file, mode 5)", func(t *testing.T) {
- doPlaylistTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
+ helper := doPlaylistTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "file", // write the files to disk
@@ -169,6 +171,33 @@ func TestIntegrationPlaylist(t *testing.T) {
featuremgmt.FlagKubernetesPlaylists, // Required so that legacy calls are also written
},
}))
+
+ client := helper.GetResourceClient(apis.ResourceClientArgs{
+ User: helper.Org1.Editor,
+ GVR: gvr,
+ })
+
+ // Folder support needs to be enabled explicitly for this resource
+ t.Run("ensure writing folders is an error", func(t *testing.T) {
+ // Create works without folder
+ obj := helper.LoadYAMLOrJSONFile("testdata/playlist-generate.yaml")
+ out, err := client.Resource.Create(context.Background(), obj, metav1.CreateOptions{})
+ require.NoError(t, err)
+
+ meta, err := utils.MetaAccessor(out)
+ require.NoError(t, err)
+ require.Equal(t, int64(1), meta.GetGeneration())
+ require.Equal(t, helper.Org1.Editor.Identity.GetUID(), meta.GetCreatedBy())
+ require.Equal(t, "", meta.GetUpdatedBy())
+
+ meta, err = utils.MetaAccessor(obj)
+ require.NoError(t, err)
+ meta.SetFolder("FolderUID")
+
+ _, err = client.Resource.Create(context.Background(), obj, metav1.CreateOptions{})
+ require.Error(t, err)
+ require.True(t, apierrors.IsBadRequest(err))
+ })
})
t.Run("with dual write (unified storage, mode 0)", func(t *testing.T) {
diff --git a/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go b/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go
index 874c4161741..457ed0c9baa 100644
--- a/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go
+++ b/pkg/tsdb/tempo/kinds/dataquery/types_dataquery_gen.go
@@ -48,7 +48,7 @@ type TempoQuery struct {
// Defines the maximum number of spans per spanset that are returned from Tempo
Spss *int64 `json:"spss,omitempty"`
Filters []TraceqlFilter `json:"filters"`
- // Filters that are used to query the metrics summary
+ // deprecated Filters that are used to query the metrics summary
GroupBy []TraceqlFilter `json:"groupBy,omitempty"`
// The type of the table that is used to display the search results
TableType *SearchTableType `json:"tableType,omitempty"`
diff --git a/pkg/util/xorm/dialect_spanner.go b/pkg/util/xorm/dialect_spanner.go
index 4269107cbb6..df4b0e3965e 100644
--- a/pkg/util/xorm/dialect_spanner.go
+++ b/pkg/util/xorm/dialect_spanner.go
@@ -9,7 +9,10 @@ import (
"strings"
_ "github.com/googleapis/go-sql-spanner"
+ spannerdriver "github.com/googleapis/go-sql-spanner"
"xorm.io/core"
+
+ spannerext "github.com/grafana/grafana/pkg/extensions/spanner"
)
func init() {
@@ -370,3 +373,23 @@ func (s *spanner) GetIndexes(tableName string) (map[string]*core.Index, error) {
}
return indexes, res.Err()
}
+
+func (s *spanner) CreateSequenceGenerator(db *sql.DB) (SequenceGenerator, error) {
+ dsn := s.DataSourceName()
+ connectorConfig, err := spannerdriver.ExtractConnectorConfig(dsn)
+ if err != nil {
+ return nil, err
+ }
+
+ if spannerext.UsePlainText(connectorConfig) {
+ // Plain-text means we're either using spannertest or Spanner emulator.
+ // Switch to fake in-memory sequence number generator in that case.
+ //
+ // Using database-based sequence generator doesn't work with emulator, as emulator
+ // only supports single transaction. If there is already another transaction started
+ // generating new ID via database-based sequence generator would always fail.
+ return newInMemSequenceGenerator(), nil
+ }
+
+ return newSequenceGenerator(db), nil
+}
diff --git a/pkg/util/xorm/engine.go b/pkg/util/xorm/engine.go
index 8db50041ae2..4d29facbd77 100644
--- a/pkg/util/xorm/engine.go
+++ b/pkg/util/xorm/engine.go
@@ -44,7 +44,7 @@ type Engine struct {
tagHandlers map[string]tagHandler
defaultContext context.Context
- sequenceGenerator *sequenceGenerator // If not nil, this generator is used to generate auto-increment values for inserts.
+ sequenceGenerator SequenceGenerator // If not nil, this generator is used to generate auto-increment values for inserts.
}
// CondDeleted returns the conditions whether a record is soft deleted.
@@ -239,9 +239,6 @@ func (engine *Engine) NewSession() *Session {
// Close the engine
func (engine *Engine) Close() error {
- if engine.sequenceGenerator != nil {
- engine.sequenceGenerator.close()
- }
return engine.db.Close()
}
diff --git a/pkg/util/xorm/sequence.go b/pkg/util/xorm/sequence.go
index bb74e2064b6..19192db96f3 100644
--- a/pkg/util/xorm/sequence.go
+++ b/pkg/util/xorm/sequence.go
@@ -3,65 +3,153 @@ package xorm
import (
"context"
"database/sql"
- "errors"
"fmt"
+ "sync"
)
+// batchState represents the state of a sequence batch
+type batchState struct {
+ mu sync.Mutex
+ nextValue int64
+ lastValueInBatch int64
+}
+
type sequenceGenerator struct {
db *sql.DB
sequencesTable string
+ batchSize int64
+
+ mu sync.Mutex
+ batchStates map[string]*batchState // Track sequence batches per key (table:column)
}
func newSequenceGenerator(db *sql.DB) *sequenceGenerator {
return &sequenceGenerator{
db: db,
sequencesTable: "autoincrement_sequences",
+ batchSize: 100, // Default batch size
+ batchStates: make(map[string]*batchState),
}
}
+func (sg *sequenceGenerator) Reset() {
+ sg.mu.Lock()
+ defer sg.mu.Unlock()
+ sg.batchStates = make(map[string]*batchState)
+}
+
func (sg *sequenceGenerator) Next(ctx context.Context, table, column string) (int64, error) {
- // Current implementation fetches new value for each Next call.
key := fmt.Sprintf("%s:%s", table, column)
+ // First get or create the state with a global lock (only for map access)
+ sg.mu.Lock()
+ state, exists := sg.batchStates[key]
+ if !exists {
+ state = &batchState{
+ nextValue: 0,
+ lastValueInBatch: -1,
+ }
+ sg.batchStates[key] = state
+ }
+ sg.mu.Unlock() // Release global lock as soon as possible
+
+ // Now lock only the specific sequence state
+ state.mu.Lock()
+ defer state.mu.Unlock()
+
+ // If we've used all values in the current batch, get a new batch
+ if state.nextValue > state.lastValueInBatch {
+ start, end, err := sg.allocateNewBatch(ctx, key)
+ if err != nil {
+ return 0, err
+ }
+ state.nextValue = start
+ state.lastValueInBatch = end
+ }
+
+ // Return the next value from the batch
+ val := state.nextValue
+ state.nextValue++
+ return val, nil
+}
+
+// allocateNewBatch retrieves a new batch of sequence values from the database.
+// It returns the start and end values of the new batch on success.
+func (sg *sequenceGenerator) allocateNewBatch(ctx context.Context, key string) (start, end int64, retErr error) {
tx, err := sg.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
if err != nil {
- return 0, err
+ return 0, 0, err
}
- // TODO: "FOR UPDATE". (Somehow this doesn't seem to be supported in Spanner emulator?)
- r, err := tx.QueryContext(ctx, "SELECT next_value FROM "+sg.sequencesTable+" WHERE name = ?", key)
- if err != nil {
- err2 := tx.Rollback()
- return 0, errors.Join(err, err2)
- }
- defer r.Close()
-
- // Sequence doesn't exist yet. Return 1, and put 2 into the table.
- if !r.Next() {
- if err := r.Err(); err != nil {
- return 0, errors.Join(err, tx.Rollback())
+ defer func() {
+ if retErr != nil {
+ tx.Rollback()
}
- val := int64(1)
+ }()
- _, err := tx.ExecContext(ctx, "INSERT INTO "+sg.sequencesTable+" (name, next_value) VALUES(?, ?)", key, val+1)
+ // Query the current sequence value
+ rows, err := tx.QueryContext(ctx, "SELECT next_value FROM "+sg.sequencesTable+" WHERE name = ?", key)
+ if err != nil {
+ return 0, 0, err
+ }
+ defer rows.Close()
+
+ // Handle case where sequence doesn't exist yet
+ if !rows.Next() {
+ if err = rows.Err(); err != nil {
+ return 0, 0, err
+ }
+
+ // This is a new sequence - start from 1 and allocate a batch
+ batchEnd := sg.batchSize
+ nextBatchStart := batchEnd + 1
+
+ // Insert the next batch start value
+ _, err = tx.ExecContext(ctx,
+ "INSERT INTO "+sg.sequencesTable+" (name, next_value) VALUES(?, ?)",
+ key, nextBatchStart)
if err != nil {
- return 0, errors.Join(err, tx.Rollback())
+ return 0, 0, err
}
- return val, tx.Commit()
+ // Commit the transaction
+ if err = tx.Commit(); err != nil {
+ return 0, 0, err
+ }
+
+ return 1, batchEnd, nil
}
- var val int64
- if err := r.Scan(&val); err != nil {
- return 0, errors.Join(err, tx.Rollback())
+ // Sequence exists - read current value and allocate next batch
+ var batchStart int64
+ if err = rows.Scan(&batchStart); err != nil {
+ return 0, 0, err
}
- _, err = tx.ExecContext(ctx, "UPDATE "+sg.sequencesTable+" SET next_value = ? WHERE name = ?", val+1, key)
+ batchEnd := batchStart + sg.batchSize - 1
+ nextBatchStart := batchEnd + 1
+
+ // Update the next batch start value
+ _, err = tx.ExecContext(ctx,
+ "UPDATE "+sg.sequencesTable+" SET next_value = ? WHERE name = ?",
+ nextBatchStart, key)
if err != nil {
- return 0, errors.Join(err, tx.Rollback())
+ return 0, 0, err
}
- return val, tx.Commit()
+ // Commit the transaction
+ if err = tx.Commit(); err != nil {
+ return 0, 0, err
+ }
+
+ return batchStart, batchEnd, nil
+}
+
+// SetBatchSize allows changing the batch size
+func (sg *sequenceGenerator) SetBatchSize(size int64) {
+ sg.mu.Lock()
+ defer sg.mu.Unlock()
+ sg.batchSize = size
}
func (sg *sequenceGenerator) close() {
diff --git a/pkg/util/xorm/sequence_inmem.go b/pkg/util/xorm/sequence_inmem.go
new file mode 100644
index 00000000000..1d5cfacea76
--- /dev/null
+++ b/pkg/util/xorm/sequence_inmem.go
@@ -0,0 +1,46 @@
+package xorm
+
+import (
+ "context"
+ "fmt"
+ "math/rand/v2"
+ "sync"
+)
+
+type inMemSequenceGenerator struct {
+ sequencesMu sync.Mutex
+ nextValues map[string]int
+}
+
+func newInMemSequenceGenerator() *inMemSequenceGenerator {
+ return &inMemSequenceGenerator{
+ nextValues: make(map[string]int),
+ }
+}
+
+func (g *inMemSequenceGenerator) Reset() {
+ g.sequencesMu.Lock()
+ defer g.sequencesMu.Unlock()
+
+ g.nextValues = make(map[string]int)
+}
+
+func (g *inMemSequenceGenerator) Next(_ context.Context, table, column string) (int64, error) {
+ if table == "migration_log" {
+ // Don't use sequential IDs for migration log entries, as we don't clean up migration_log table between tests,
+ // so restarting the sequence can lead to conflicting IDs.
+ return rand.Int64(), nil
+ }
+
+ key := fmt.Sprintf("%s:%s", table, column)
+
+ g.sequencesMu.Lock()
+ defer g.sequencesMu.Unlock()
+
+ seq, ok := g.nextValues[key]
+ if !ok {
+ seq = 1
+ }
+ g.nextValues[key] = seq + 1
+ return int64(seq), nil
+}
diff --git a/pkg/util/xorm/sequence_test.go b/pkg/util/xorm/sequence_test.go
index 289e65e4034..b49bb568c8b 100644
--- a/pkg/util/xorm/sequence_test.go
+++ b/pkg/util/xorm/sequence_test.go
@@ -2,6 +2,7 @@ package xorm
import (
"context"
+ "sync"
"testing"
"github.com/stretchr/testify/require"
@@ -29,3 +30,97 @@ func TestSequenceGenerator(t *testing.T) {
require.NoError(t, err)
require.Equal(t, int64(2), val)
}
+
+func TestBatchSequenceAllocation(t *testing.T) {
+ eng, err := NewEngine("sqlite3", ":memory:")
+ require.NoError(t, err)
+
+ _, err = eng.Exec("CREATE TABLE `autoincrement_sequences` (`name` STRING(128) NOT NULL PRIMARY KEY, `next_value` INT64 NOT NULL)")
+ require.NoError(t, err)
+
+ // Create sequence generator with small batch size for testing
+ sg := newSequenceGenerator(eng.db.DB)
+ sg.SetBatchSize(10)
+
+ // First batch (1-10)
+ for i := 1; i <= 10; i++ {
+ val, err := sg.Next(context.Background(), "test", "batch")
+ require.NoError(t, err)
+ require.Equal(t, int64(i), val)
+ }
+
+ // Next value should trigger a new batch (11-20)
+ val, err := sg.Next(context.Background(), "test", "batch")
+ require.NoError(t, err)
+ require.Equal(t, int64(11), val)
+
+ // Check database value is now set to next batch
+ var nextVal int64
+ err = eng.db.QueryRow("SELECT next_value FROM autoincrement_sequences WHERE name = 'test:batch'").Scan(&nextVal)
+ require.NoError(t, err)
+ require.Equal(t, int64(21), nextVal, "Database should store the start of the next batch")
+
+ // Continue getting values from the second batch
+ for i := 12; i <= 20; i++ {
+ val, err := sg.Next(context.Background(), "test", "batch")
+ require.NoError(t, err)
+ require.Equal(t, int64(i), val)
+ }
+}
+
+func TestConcurrentSequenceAccess(t *testing.T) {
+ eng, err := NewEngine("sqlite3", ":memory:")
+ require.NoError(t, err)
+
+ _, err = eng.Exec("CREATE TABLE `autoincrement_sequences` (`name` STRING(128) NOT NULL PRIMARY KEY, `next_value` INT64 NOT NULL)")
+ require.NoError(t, err)
+
+ sg := newSequenceGenerator(eng.db.DB)
+ sg.SetBatchSize(100)
+
+ // Launch multiple goroutines to get sequence values
+ const numRoutines = 50
+ const valuesPerRoutine = 20
+ results := make([]int64, numRoutines*valuesPerRoutine)
+
+ var wg sync.WaitGroup
+ var mu sync.Mutex // To protect results slice
+
+ ctx := context.Background()
+
+ for i := 0; i < numRoutines; i++ {
+ wg.Add(1)
+ go func(routineID int) {
+ defer wg.Done()
+
+ for j := 0; j < valuesPerRoutine; j++ {
+ val, err := sg.Next(ctx, "test", "concurrent")
+ require.NoError(t, err)
+
+ // Store the result in our results array
+ mu.Lock()
+ results[routineID*valuesPerRoutine+j] = val
+ mu.Unlock()
+ }
+ }(i)
+ }
+
+ wg.Wait()
+
+ // Check that we have the expected number of values
+ require.Equal(t, numRoutines*valuesPerRoutine, len(results))
+
+ // Create a map to check for duplicates
+ seen := make(map[int64]bool)
+ for _, val := range results {
+ // Verify we haven't seen this value before
+ require.False(t, seen[val], "Found duplicate sequence value: %d", val)
+ seen[val] = true
+ }
+
+ // Verify the range of values is correct (all values should be between 1 and numRoutines*valuesPerRoutine)
+ require.Equal(t, numRoutines*valuesPerRoutine, len(seen), "Should have exactly the right number of unique values")
+ for i := int64(1); i <= int64(numRoutines*valuesPerRoutine); i++ {
+ require.True(t, seen[i], "Missing sequence value: %d", i)
+ }
+}
diff --git a/pkg/util/xorm/xorm.go b/pkg/util/xorm/xorm.go
index 25ce8bd952f..d1464af1da4 100644
--- a/pkg/util/xorm/xorm.go
+++ b/pkg/util/xorm/xorm.go
@@ -9,6 +9,7 @@ package xorm
import (
"context"
+ "database/sql"
"fmt"
"os"
"reflect"
@@ -22,6 +23,8 @@ import (
const (
// Version show the xorm's version
Version string = "0.8.0.1015"
+
+ Spanner = "spanner"
)
func regDrvsNDialects() bool {
@@ -97,7 +100,7 @@ func NewEngine(driverName string, dataSourceName string) (*Engine, error) {
switch uri.DbType {
case core.SQLITE:
engine.DatabaseTZ = time.UTC
- case "spanner":
+ case Spanner:
engine.DatabaseTZ = time.UTC
// We need to specify "Z" to indicate that timestamp is in UTC.
// Otherwise Spanner uses default America/Los_Angeles timezone.
@@ -114,9 +117,30 @@ func NewEngine(driverName string, dataSourceName string) (*Engine, error) {
runtime.SetFinalizer(engine, close)
- if dialect.DBType() == "spanner" {
- engine.sequenceGenerator = newSequenceGenerator(db.DB)
+ if ext, ok := dialect.(DialectExt); ok {
+ engine.sequenceGenerator, err = ext.CreateSequenceGenerator(db.DB)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create sequence generator: %w", err)
+ }
}
return engine, nil
}
+
+func (engine *Engine) ResetSequenceGenerator() {
+ if engine.sequenceGenerator != nil {
+ engine.sequenceGenerator.Reset()
+ }
+}
+
+type SequenceGenerator interface {
+ Next(ctx context.Context, table, column string) (int64, error)
+ Reset()
+}
+
+type DialectExt interface {
+ core.Dialect
+
+ // CreateSequenceGenerator returns optional generator used to create AUTOINCREMENT ids for inserts.
+ CreateSequenceGenerator(db *sql.DB) (SequenceGenerator, error)
+}
diff --git a/public/api-merged.json b/public/api-merged.json
index 6beecc94824..3ca4e15b213 100644
--- a/public/api-merged.json
+++ b/public/api-merged.json
@@ -12654,6 +12654,9 @@
"isPaused": {
"type": "boolean"
},
+ "keepFiringFor": {
+ "$ref": "#/definitions/Duration"
+ },
"labels": {
"type": "object",
"additionalProperties": {
@@ -12924,6 +12927,10 @@
"health": {
"type": "string"
},
+ "keepFiringFor": {
+ "type": "number",
+ "format": "double"
+ },
"labels": {
"$ref": "#/definitions/Labels"
},
@@ -18688,9 +18695,22 @@
"Interval": {
"$ref": "#/definitions/Duration"
},
+ "Labels": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "Limit": {
+ "type": "integer",
+ "format": "int64"
+ },
"Name": {
"type": "string"
},
+ "QueryOffset": {
+ "type": "string"
+ },
"Rules": {
"type": "array",
"items": {
@@ -18804,6 +18824,10 @@
"type": "boolean",
"example": false
},
+ "keep_firing_for": {
+ "type": "string",
+ "format": "duration"
+ },
"labels": {
"type": "object",
"additionalProperties": {
@@ -22869,7 +22893,6 @@
}
},
"gettableAlerts": {
- "description": "GettableAlerts gettable alerts",
"type": "array",
"items": {
"type": "object",
diff --git a/public/app/api/clients/provisioning/endpoints.gen.ts b/public/app/api/clients/provisioning/endpoints.gen.ts
index 8f22200d13f..a7baef15950 100644
--- a/public/app/api/clients/provisioning/endpoints.gen.ts
+++ b/public/app/api/clients/provisioning/endpoints.gen.ts
@@ -497,7 +497,7 @@ export type CreateRepositoryExportApiArg = {
/** Include the identifier in the exported metadata */
identifier: boolean;
/** Prefix in target file system */
- prefix?: string;
+ path?: string;
};
};
export type GetRepositoryFilesApiResponse = /** status 200 OK */ {
@@ -587,8 +587,6 @@ export type CreateRepositoryMigrateApiArg = {
history?: boolean;
/** Include the identifier in the exported metadata */
identifier: boolean;
- /** Target file prefix */
- prefix?: string;
};
};
export type GetRepositoryRenderWithPathApiResponse = unknown;
@@ -748,8 +746,6 @@ export type MigrateJobOptions = {
history?: boolean;
/** Include the identifier in the exported metadata */
identifier: boolean;
- /** Target file prefix */
- prefix?: string;
};
export type PullRequestJobOptions = {
hash?: string;
@@ -772,7 +768,7 @@ export type ExportJobOptions = {
/** Include the identifier in the exported metadata */
identifier: boolean;
/** Prefix in target file system */
- prefix?: string;
+ path?: string;
};
export type JobSpec = {
/** Possible enum values:
diff --git a/public/app/api/clients/provisioning/utils/createOnCacheEntryAdded.ts b/public/app/api/clients/provisioning/utils/createOnCacheEntryAdded.ts
index 724eb79193d..c3c4f38b8e1 100644
--- a/public/app/api/clients/provisioning/utils/createOnCacheEntryAdded.ts
+++ b/public/app/api/clients/provisioning/utils/createOnCacheEntryAdded.ts
@@ -48,17 +48,17 @@ export function createOnCacheEntryAdded<
}
const existingIndex = draft.items.findIndex((item) => item.metadata?.name === event.object.metadata.name);
- if (event.type === 'ADDED') {
- // Add the new item
+ if (event.type === 'ADDED' && existingIndex === -1) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
draft.items.push(event.object as unknown as T);
- } else if (event.type === 'MODIFIED' && existingIndex !== -1) {
- // Update the existing item if it exists
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
- draft.items[existingIndex] = event.object as unknown as T;
} else if (event.type === 'DELETED' && existingIndex !== -1) {
// Remove the item if it exists
draft.items.splice(existingIndex, 1);
+ } else if (existingIndex !== -1) {
+ // Could be ADDED or MODIFIED
+ // Update the existing item if it exists
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ draft.items[existingIndex] = event.object as unknown as T;
}
});
});
diff --git a/public/app/features/admin/ldap/LdapUserGroups.tsx b/public/app/features/admin/ldap/LdapUserGroups.tsx
index 890ec4aee56..565048f1059 100644
--- a/public/app/features/admin/ldap/LdapUserGroups.tsx
+++ b/public/app/features/admin/ldap/LdapUserGroups.tsx
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
-import { Tooltip, Icon, InteractiveTable, type CellProps, Column } from '@grafana/ui';
+import { Tooltip, Icon, InteractiveTable, type CellProps, Column, Stack } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { LdapRole } from 'app/types';
@@ -28,14 +28,12 @@ export const LdapUserGroups = ({ groups }: Props) => {
header: 'Role',
cell: (props: CellProps) =>
props.value || (
- <>
-
- No match{' '}
-
-
-
-
- >
+
+ No match
+
+
+
+
),
},
],
diff --git a/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx
index 7d1d429b312..e36e91febb5 100644
--- a/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/NotificationsStep.tsx
@@ -270,22 +270,16 @@ function NeedHelpInfoForContactpoint() {
<>
Select a contact point to notify all recipients in it.
+ Muting, grouping, and timings options allow you to customize how notifications are sent.
- Notifications for firing alert instances are grouped based on folder and alert rule name.
- The wait time before sending the first notification for a new group of alerts is 30 seconds.
-
- The waiting time before sending a notification about changes in the alert group after the first notification
- has been sent is 5 minutes.
-
- The wait time before resending a notification that has already been sent successfully is 4 hours.
-
- Grouping and wait time values are defined in your default notification policy.
+ Alternatively, toggle the Advanced options button to route notifications using notification policies
+ for greater flexibility.
>
}
externalLink="https://grafana.com/docs/grafana/latest/alerting/fundamentals/notifications/"
linkText="Read more about notifications"
- title="Notify contact points"
+ title="Notify by selecting a contact point"
/>
);
}
diff --git a/public/app/features/alerting/unified/components/rule-editor/QueryRows.tsx b/public/app/features/alerting/unified/components/rule-editor/QueryRows.tsx
index 08c67ebf3d2..4e951c5110a 100644
--- a/public/app/features/alerting/unified/components/rule-editor/QueryRows.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/QueryRows.tsx
@@ -14,6 +14,7 @@ import { getDataSourceSrv } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import { Button, Card, Icon, Stack } from '@grafana/ui';
import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow';
+import { isExpressionQuery } from 'app/features/expressions/guards';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto';
@@ -236,8 +237,10 @@ function copyModel(item: AlertQuery, settings: DataSourceInstanceSettings): Omit
}
function newModel(item: AlertQuery, settings: DataSourceInstanceSettings): Omit {
- const isInstant = getInstantFromDataQuery(item.model, settings.type);
- return {
+ const isExpression = isExpressionQuery(item);
+ const isInstant = isExpression ? false : getInstantFromDataQuery(item);
+
+ const newQuery: Omit = {
refId: item.refId,
relativeTimeRange: item.relativeTimeRange,
queryType: '',
@@ -246,9 +249,14 @@ function newModel(item: AlertQuery, settings: DataSourceInstanceSettings): Omit<
refId: item.refId,
hide: false,
datasource: getDataSourceRef(settings),
- instant: isInstant,
},
};
+
+ if (isInstant && !isExpressionQuery(item)) {
+ (newQuery as AlertQuery).model.instant = isInstant;
+ }
+
+ return newQuery;
}
interface DatasourceNotFoundProps {
diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx
index b99fcac732a..f68296b6fbb 100644
--- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx
@@ -320,7 +320,7 @@ export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) =>
{/* Step 1 */}
{/* Step 2 */}
-
+
{/* Step 3-4-5 */}
{showDataSourceDependantStep && (
<>
diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx
index 8b867293622..32e4fd72f13 100644
--- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx
@@ -100,7 +100,7 @@ export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFor
{/* Step 1 */}
{/* Step 2 */}
-
+
{/* Step 3-4-5 */}
diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx
index de3cb8bfcc8..3d86b3e33cb 100644
--- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx
@@ -3,12 +3,14 @@ import { useEffect, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
-import { CollapsableSection, Stack, useStyles2 } from '@grafana/ui';
+import { CollapsableSection, Stack, Text, useStyles2 } from '@grafana/ui';
+import { Trans, t } from 'app/core/internationalization';
import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form';
import { AlertManagerDataSource } from 'app/features/alerting/unified/utils/datasource';
import { useContactPointsWithStatus } from '../../../contact-points/useContactPoints';
import { ContactPointWithMetadata } from '../../../contact-points/utils';
+import { NeedHelpInfo } from '../../NeedHelpInfo';
import { ContactPointDetails } from './contactPoint/ContactPointDetails';
import { ContactPointSelector } from './contactPoint/ContactPointSelector';
@@ -57,7 +59,7 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo
- Alertmanager:
+
Alertmanager:
{alertManagerName}
@@ -74,8 +76,37 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo
label="Muting, grouping and timings (optional)"
isOpen={hasRouteSettings}
className={styles.collapsableSection}
+ contentClassName={styles.collapsableSectionContent}
>
+
+
+
+ Configure how notifications for this alert rule are sent.
+
+
+
+
+ {t(
+ 'alerting.rule-form.simple-routing.optional-settings.help-info1',
+ 'Mute timings allows you to temporarily pause notifications for a specific recurring period, such as a regular maintenance window or weekends.'
+ )}
+
+ {t(
+ 'alerting.rule-form.simple-routing.optional-settings.help-info2',
+ 'Grouping and timing options combine multiple alerts within a specific period into a single notification, allowing you to customize default options.'
+ )}
+ >
+ }
+ />
+
@@ -110,6 +141,9 @@ const getStyles = (theme: GrafanaTheme2) => ({
width: 'fit-content',
fontSize: theme.typography.body.fontSize,
}),
+ collapsableSectionContent: css({
+ padding: '0',
+ }),
routingSection: css({
display: 'flex',
flexDirection: 'column',
diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/MuteTimingFields.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/MuteTimingFields.tsx
index 86608e72112..0a7810a37a4 100644
--- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/MuteTimingFields.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/MuteTimingFields.tsx
@@ -1,6 +1,8 @@
+import { css } from '@emotion/css';
import { Controller, useFormContext } from 'react-hook-form';
-import { Field } from '@grafana/ui';
+import { GrafanaTheme2 } from '@grafana/data';
+import { Field, useStyles2 } from '@grafana/ui';
import MuteTimingsSelector from 'app/features/alerting/unified/components/alertmanager-entities/MuteTimingsSelector';
import { BaseAlertmanagerArgs } from 'app/features/alerting/unified/types/hooks';
import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form';
@@ -8,6 +10,7 @@ import { mapMultiSelectValueToStrings } from 'app/features/alerting/unified/util
/** Provides a form field for use in simplified routing, for selecting appropriate mute timings */
export function MuteTimingFields({ alertmanager }: BaseAlertmanagerArgs) {
+ const styles = useStyles2(getStyles);
const {
control,
formState: { errors },
@@ -18,6 +21,7 @@ export function MuteTimingFields({ alertmanager }: BaseAlertmanagerArgs) {
label="Mute timings"
data-testid="am-mute-timing-select"
description="Select a mute timing to define when not to send notifications for this alert rule"
+ className={styles.muteTimingField}
invalid={!!errors.contactPoints?.[alertmanager]?.muteTimeIntervals}
>
);
}
+const getStyles = (theme: GrafanaTheme2) => ({
+ muteTimingField: css({
+ marginTop: theme.spacing(1),
+ }),
+});
diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx
index 20d3359a03e..463401721a0 100644
--- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx
@@ -23,7 +23,12 @@ import {
} from '@grafana/ui';
import { Trans, t } from 'app/core/internationalization';
import { isExpressionQuery } from 'app/features/expressions/guards';
-import { ExpressionDatasourceUID, ExpressionQueryType, expressionTypes } from 'app/features/expressions/types';
+import {
+ ExpressionDatasourceUID,
+ ExpressionQuery,
+ ExpressionQueryType,
+ expressionTypes,
+} from 'app/features/expressions/types';
import { AlertQuery } from 'app/types/unified-alerting-dto';
import { useRulesSourcesWithRuler } from '../../../hooks/useRuleSourcesWithRuler';
@@ -50,7 +55,7 @@ import { RuleEditorSection } from '../RuleEditorSection';
import { errorFromCurrentCondition, errorFromPreviewData, findRenamedDataQueryReferences, refIdExists } from '../util';
import { CloudDataSourceSelector } from './CloudDataSourceSelector';
-import { SimpleConditionEditor, SimpleConditionIdentifier, getSimpleConditionFromExpressions } from './SimpleCondition';
+import { SimpleConditionEditor, getSimpleConditionFromExpressions } from './SimpleCondition';
import { SmartAlertTypeDetector } from './SmartAlertTypeDetector';
import { DESCRIPTIONS } from './descriptions';
import {
@@ -77,9 +82,15 @@ import { useAlertQueryRunner } from './useAlertQueryRunner';
interface Props {
editingExistingRule: boolean;
onDataChange: (error: string) => void;
+ /**
+ * The mode of the rule editor.
+ * - 'edit' standard rule editor mode
+ * - 'draft' non-saveable form mode used for exporting to provisioning formats
+ */
+ mode: 'edit' | 'draft';
}
-export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: Props) => {
+export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange, mode }: Props) => {
const {
setValue,
getValues,
@@ -140,7 +151,7 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
);
const simplifiedQueryStep =
- isSwitchModeEnabled && isGrafanaAlertingType ? getValues('editorSettings.simplifiedQueryEditor') : false;
+ isSwitchModeEnabled && isGrafanaAlertingType ? editorSettings?.simplifiedQueryEditor : false;
// If we switch to simple mode we need to update the simple condition with the data in the queries reducer
useEffect(() => {
@@ -158,15 +169,22 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
// Grafana Managed rules and recording rules do
return;
}
- // we need to be sure the condition is set once we switch to simple mode
+
if (simplifiedQueryStep) {
- setValue('condition', SimpleConditionIdentifier.thresholdId);
- runQueries(getValues('queries'), SimpleConditionIdentifier.thresholdId);
+ const lastExpression = expressionQueries.at(-1);
+ if (!lastExpression) {
+ return;
+ }
+
+ const condition = lastExpression.refId;
+ // we need to be sure the condition is set once we switch to simple mode
+ setValue('condition', condition);
+ runQueries(getValues('queries'), condition);
} else {
runQueries(getValues('queries'), condition || (getValues('condition') ?? ''));
}
},
- [isCloudAlertRuleType, runQueries, getValues, simplifiedQueryStep, setValue]
+ [isCloudAlertRuleType, expressionQueries, simplifiedQueryStep, setValue, runQueries, getValues]
);
// whenever we update the queries we have to update the form too
@@ -241,7 +259,9 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
// As a workaround we update form values as soon as possible to avoid stale state
// This way we can access up to date queries in runQueriesPreview without waiting for re-render
const previousQueries = getValues('queries');
- const expressionQueries = previousQueries.filter((query) => isExpressionQuery(query.model));
+
+ const expressionQueries = previousQueries.filter>(isExpressionQueryInAlert);
+
setValue('queries', [...updatedQueries, ...expressionQueries], { shouldValidate: false });
updateExpressionAndDatasource(updatedQueries);
@@ -515,12 +535,14 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
}}
/>
-
+ {mode === 'edit' && (
+
+ )}
)}
@@ -555,7 +577,7 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P
)}
{/* We only show Switch for Grafana managed alerts */}
- {isGrafanaAlertingType && !simplifiedQueryStep && (
+ {isGrafanaAlertingType && !simplifiedQueryStep && mode === 'edit' && (
>,
dispatch: Dispatch
) {
- const reduceExpression = expressionQueriesList.find(
- (query) =>
- query.model.type === ExpressionQueryType.reduce && query.model.refId === SimpleConditionIdentifier.reducerId
- );
+ // 1. make sure have have a reduce expression and that it is pointing to the data query
+ const reduceExpression = expressionQueriesList.find((query) => query.model.type === ExpressionQueryType.reduce);
const newReduceExpression = reduceExpression
? produce(reduceExpression?.model, (draft) => {
@@ -179,10 +172,7 @@ function updateThresholdFunction(
expressionQueriesList: Array>,
dispatch: Dispatch
) {
- const thresholdExpression = expressionQueriesList.find(
- (query) =>
- query.model.type === ExpressionQueryType.threshold && query.model.refId === SimpleConditionIdentifier.thresholdId
- );
+ const thresholdExpression = expressionQueriesList.find((query) => query.model.type === ExpressionQueryType.threshold);
const newThresholdExpression = produce(thresholdExpression, (draft) => {
if (draft && draft.model.conditions) {
@@ -198,10 +188,7 @@ function updateThresholdValue(
expressionQueriesList: Array>,
dispatch: Dispatch
) {
- const thresholdExpression = expressionQueriesList.find(
- (query) =>
- query.model.type === ExpressionQueryType.threshold && query.model.refId === SimpleConditionIdentifier.thresholdId
- );
+ const thresholdExpression = expressionQueriesList.find((query) => query.model.type === ExpressionQueryType.threshold);
const newThresholdExpression = produce(thresholdExpression, (draft) => {
if (draft && draft.model.conditions) {
@@ -212,13 +199,8 @@ function updateThresholdValue(
}
export function getSimpleConditionFromExpressions(expressions: Array>): SimpleCondition {
- const reduceExpression = expressions.find(
- (query) => query.model.type === ExpressionQueryType.reduce && query.refId === SimpleConditionIdentifier.reducerId
- );
- const thresholdExpression = expressions.find(
- (query) =>
- query.model.type === ExpressionQueryType.threshold && query.refId === SimpleConditionIdentifier.thresholdId
- );
+ const reduceExpression = expressions.find((query) => query.model.type === ExpressionQueryType.reduce);
+ const thresholdExpression = expressions.find((query) => query.model.type === ExpressionQueryType.threshold);
const conditionsFromThreshold = thresholdExpression?.model.conditions ?? [];
const whenField = reduceExpression?.model.reducer;
const params = conditionsFromThreshold[0]?.evaluator?.params
diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/areQueriesTransformableToSimpleCondition.test.ts b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/areQueriesTransformableToSimpleCondition.test.ts
index 2a9294ada7d..96be5b02dc5 100644
--- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/areQueriesTransformableToSimpleCondition.test.ts
+++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/areQueriesTransformableToSimpleCondition.test.ts
@@ -1,35 +1,50 @@
import { produce } from 'immer';
import { EvalFunction } from 'app/features/alerting/state/alertDef';
-import { dataQuery, reduceExpression, thresholdExpression } from 'app/features/alerting/unified/mocks';
+import {
+ mockDataQuery,
+ mockDataSource,
+ mockReduceExpression,
+ mockThresholdExpression,
+} from 'app/features/alerting/unified/mocks';
import { areQueriesTransformableToSimpleCondition } from 'app/features/alerting/unified/rule-editor/formProcessing';
+import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources';
+import { DataSourceType } from 'app/features/alerting/unified/utils/datasource';
import { ExpressionQuery, ReducerMode } from 'app/features/expressions/types';
import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto';
-const expressionQueries: Array> = [reduceExpression, thresholdExpression];
+const reduceExpression = mockReduceExpression({ expression: 'A', settings: { mode: ReducerMode.Strict } });
+const thresholdExpression = mockThresholdExpression({ expression: 'B' });
+const expressionQueries: Array> = [reduceExpression, thresholdExpression];
+const ds = mockDataSource({ type: DataSourceType.Prometheus, name: 'Mimir-cloud', uid: 'abc123' });
describe('areQueriesTransformableToSimpleCondition', () => {
+ beforeEach(() => {
+ setupDataSources(ds);
+ });
it('should return false if dataQueries length is not 1', () => {
// zero dataQueries
expect(areQueriesTransformableToSimpleCondition([], expressionQueries)).toBe(false);
// more than one dataQueries
- expect(areQueriesTransformableToSimpleCondition([dataQuery, dataQuery], expressionQueries)).toBe(false);
+ expect(areQueriesTransformableToSimpleCondition([mockDataQuery(), mockDataQuery()], expressionQueries)).toBe(false);
});
+
it('should return false if expressionQueries length is not 2', () => {
- const dataQueries: Array> = [dataQuery];
+ const dataQueries: Array> = [mockDataQuery()];
const result = areQueriesTransformableToSimpleCondition(dataQueries, []);
expect(result).toBe(false);
});
- it('should return false if the dataQuery refId does not match SimpleConditionIdentifier.queryId', () => {
- const dataQueries: Array> = [
- { refId: 'notSimpleCondition', datasourceUid: 'abc123', queryType: '', model: { refId: 'notSimpleCondition' } },
- ];
+ // notSimpleCondition
+ // reducer:
+ it('should return false if the mockDataQuery() refId does not match SimpleConditionIdentifier.queryId', () => {
+ const dataQueries: Array> = [mockDataQuery({ refId: 'foo' })];
const result = areQueriesTransformableToSimpleCondition(dataQueries, expressionQueries);
expect(result).toBe(false);
});
+
it('should return false if no reduce expression is found with correct type and refId', () => {
- const dataQueries: Array> = [dataQuery];
+ const dataQueries: Array> = [mockDataQuery()];
const result = areQueriesTransformableToSimpleCondition(dataQueries, [
{ ...reduceExpression, refId: 'hello' },
thresholdExpression,
@@ -37,17 +52,25 @@ describe('areQueriesTransformableToSimpleCondition', () => {
expect(result).toBe(false);
});
- it('should return false if no threshold expression is found with correct type and refId', () => {
- const dataQueries: Array> = [dataQuery];
+ it('should return false if no threshold expression is found that points to reducer', () => {
+ const dataQueries: Array> = [mockDataQuery()];
const result = areQueriesTransformableToSimpleCondition(dataQueries, [
reduceExpression,
- { ...thresholdExpression, refId: 'hello' },
+ mockThresholdExpression({ expression: 'hello' }),
+ ]);
+ expect(result).toBe(false);
+ });
+
+ it('should return false if no threshold expression is found that points to instant data query', () => {
+ const dataQueries: Array> = [mockDataQuery({ instant: true })];
+ const result = areQueriesTransformableToSimpleCondition(dataQueries, [
+ mockThresholdExpression({ expression: 'hello' }),
]);
expect(result).toBe(false);
});
it('should return false if reduceExpression settings mode is not ReducerMode.Strict', () => {
- const dataQueries: Array> = [dataQuery];
+ const dataQueries: Array> = [mockDataQuery()];
const transformedReduceExpression = produce(reduceExpression, (draft) => {
draft.model.settings = { mode: ReducerMode.DropNonNumbers };
});
@@ -60,7 +83,7 @@ describe('areQueriesTransformableToSimpleCondition', () => {
});
it('should return false if thresholdExpression unloadEvaluator has a value', () => {
- const dataQueries: Array> = [dataQuery];
+ const dataQueries: Array> = [mockDataQuery()];
const transformedThresholdExpression = produce(thresholdExpression, (draft) => {
draft.model.conditions = [
@@ -79,9 +102,17 @@ describe('areQueriesTransformableToSimpleCondition', () => {
]);
expect(result).toBe(false);
});
- it('should return true when all conditions are met', () => {
- const dataQueries: Array> = [dataQuery];
- const result = areQueriesTransformableToSimpleCondition(dataQueries, expressionQueries);
+
+ it('should return true when data query is connected to valid reducer and threshold', () => {
+ const result = areQueriesTransformableToSimpleCondition([mockDataQuery({ refId: 'A' })], expressionQueries);
+ expect(result).toBe(true);
+ });
+
+ it('should return true when all conditions are met for instant data query with threshold', () => {
+ const result = areQueriesTransformableToSimpleCondition(
+ [mockDataQuery({ instant: true })],
+ [mockThresholdExpression({ expression: 'A' })]
+ );
expect(result).toBe(true);
});
});
diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/reducer.test.tsx.snap b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/reducer.test.tsx.snap
index a316611bf55..292a6904f80 100644
--- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/reducer.test.tsx.snap
+++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/reducer.test.tsx.snap
@@ -130,16 +130,16 @@ exports[`Query and expressions reducer should add reduce expression if there is
},
"expression": "A",
"reducer": "last",
- "refId": "B",
+ "refId": "reducer",
"type": "reduce",
},
"queryType": "expression",
- "refId": "B",
+ "refId": "reducer",
},
{
"datasourceUid": "__expr__",
"model": {
- "expression": "B",
+ "expression": "reducer",
"refId": "C",
"type": "threshold",
},
@@ -218,6 +218,31 @@ exports[`Query and expressions reducer should remove first reducer 1`] = `
}
`;
+exports[`Query and expressions reducer should remove reducer even if reducer is not the first expression 1`] = `
+{
+ "queries": [
+ {
+ "datasourceUid": "abc123",
+ "model": {
+ "refId": "A",
+ },
+ "queryType": "query",
+ "refId": "A",
+ },
+ {
+ "datasourceUid": "__expr__",
+ "model": {
+ "expression": "A",
+ "refId": "C",
+ "type": "threshold",
+ },
+ "queryType": "expression",
+ "refId": "C",
+ },
+ ],
+}
+`;
+
exports[`Query and expressions reducer should rewire expressions 1`] = `
{
"queries": [
diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.test.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.test.tsx
index 2a112e089c3..73372e12a57 100644
--- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.test.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.test.tsx
@@ -10,7 +10,6 @@ import {
import { defaultCondition } from 'app/features/expressions/utils/expressionTypes';
import { AlertQuery } from 'app/types/unified-alerting-dto';
-import { SimpleConditionIdentifier } from './SimpleCondition';
import {
QueriesAndExpressionsState,
addNewDataQuery,
@@ -28,22 +27,23 @@ import {
} from './reducer';
const reduceExpression: AlertQuery = {
- refId: SimpleConditionIdentifier.reducerId,
+ refId: 'B',
queryType: 'expression',
datasourceUid: '__expr__',
model: {
type: ExpressionQueryType.reduce,
- refId: SimpleConditionIdentifier.reducerId,
+ refId: 'B',
settings: { mode: ReducerMode.Strict },
+ expression: 'A',
},
};
const thresholdExpression: AlertQuery = {
- refId: SimpleConditionIdentifier.thresholdId,
+ refId: 'C',
queryType: 'expression',
datasourceUid: '__expr__',
model: {
type: ExpressionQueryType.threshold,
- refId: SimpleConditionIdentifier.thresholdId,
+ refId: 'C',
},
};
@@ -400,7 +400,7 @@ describe('Query and expressions reducer', () => {
expect(newState).toMatchSnapshot();
});
- it('should not remove first reducer if reducer is not the first expression', () => {
+ it('should remove reducer even if reducer is not the first expression', () => {
const initialState: QueriesAndExpressionsState = {
queries: [alertQuery, thresholdExpression, reduceExpression],
};
@@ -412,7 +412,7 @@ describe('Query and expressions reducer', () => {
expressionQueries: [thresholdExpression, reduceExpression],
})
);
- expect(newState).toEqual(initialState);
+ expect(newState).toMatchSnapshot();
});
it('should not remove first reducer if reducer is not the second query', () => {
diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts
index aab9e184a56..b3499b8c520 100644
--- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts
+++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts
@@ -8,21 +8,25 @@ import {
getNextRefId,
rangeUtil,
} from '@grafana/data';
-import { getDataSourceSrv } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import { dataSource as expressionDatasource } from 'app/features/expressions/ExpressionDatasource';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { ExpressionDatasourceUID, ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types';
-import { defaultCondition } from 'app/features/expressions/utils/expressionTypes';
+import {
+ defaultCondition,
+ isReducerExpression,
+ isThresholdExpression,
+} from 'app/features/expressions/utils/expressionTypes';
import { AlertQuery } from 'app/types/unified-alerting-dto';
import { logError } from '../../../Analytics';
-import { DataSourceType, getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource';
+import { getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource';
import { getDefaultQueries, getInstantFromDataQuery } from '../../../utils/rule-form';
import { createDagFromQueries, getOriginOfRefId } from '../dag';
import { queriesWithUpdatedReferences, refIdExists } from '../util';
-import { SimpleConditionIdentifier } from './SimpleCondition';
+// this one will be used as the refID when we create a new reducer for the threshold expression
+export const NEW_REDUCER_REF = 'reducer';
export interface QueriesAndExpressionsState {
queries: AlertQuery[];
@@ -64,9 +68,10 @@ export const updateMaxDataPoints = createAction<{ refId: string; maxDataPoints:
export const updateMinInterval = createAction<{ refId: string; minInterval: string }>('updateMinInterval');
export const resetToSimpleCondition = createAction('resetToSimpleCondition');
-export const optimizeReduceExpression = createAction<{ updatedQueries: AlertQuery[]; expressionQueries: AlertQuery[] }>(
- 'optimizeReduceExpression'
-);
+export const optimizeReduceExpression = createAction<{
+ updatedQueries: AlertQuery[];
+ expressionQueries: Array>;
+}>('optimizeReduceExpression');
export const setRecordingRulesQueries = createAction<{ recordingRuleQueries: AlertQuery[]; expression: string }>(
'setRecordingRulesQueries'
);
@@ -231,6 +236,7 @@ export const queriesAndExpressionsReducer = createReducer(initialState, (builder
.addCase(rewireExpressions, (state, { payload }) => {
state.queries = queriesWithUpdatedReferences(state.queries, payload.oldRefId, payload.newRefId);
})
+ // removes the reduce expression when we have a instant data query
.addCase(optimizeReduceExpression, (state, { payload }) => {
const { updatedQueries, expressionQueries } = payload;
@@ -239,48 +245,29 @@ export const queriesAndExpressionsReducer = createReducer(initialState, (builder
return;
}
- //sometimes we dont have data source in the model yet
- const getDataSourceSettingsForFirstQuery = getDataSourceSrv().getInstanceSettings(
- updatedQueries[0].datasourceUid
- );
-
- if (!getDataSourceSettingsForFirstQuery) {
- return;
- }
- const type = getDataSourceSettingsForFirstQuery?.type;
-
- const firstQueryIsPromOrLoki = type === DataSourceType.Prometheus || type === DataSourceType.Loki;
-
- const isInstant = getInstantFromDataQuery(updatedQueries[0].model, type);
-
- const shouldRemoveReducer =
- firstQueryIsPromOrLoki && updatedQueries.length === 1 && isInstant && expressionQueries.length === 2;
-
- const onlyOneExpressionNotReducer =
- expressionQueries.length === 1 &&
- 'type' in expressionQueries[0].model &&
- expressionQueries[0].model.type !== ExpressionQueryType.reduce;
-
- // we only add the reduce expression if we have one data query and one expression query. For other cases we don't do anything,
- // and let the user add the reducer manually.
- const shouldAddReduceExpression =
- firstQueryIsPromOrLoki && updatedQueries.length === 1 && !isInstant && onlyOneExpressionNotReducer;
+ const dataQuery = updatedQueries.at(0);
+ const isInstantDataQuery = dataQuery ? getInstantFromDataQuery(dataQuery) : false;
+ const shouldRemoveReducer = isInstantDataQuery && expressionQueries.length === 2;
if (shouldRemoveReducer) {
const reduceExpressionIndex = state.queries.findIndex(
- (query) => isExpressionQuery(query.model) && query.model.type === ExpressionQueryType.reduce
+ (query) =>
+ isExpressionQuery(query.model) &&
+ isReducerExpression(query.model) &&
+ query.model.expression === dataQuery?.refId
);
- if (reduceExpressionIndex === 1) {
- // means the reduce expression is the second query
- state.queries.splice(reduceExpressionIndex, 1);
- state.queries[1].model.expression = SimpleConditionIdentifier.queryId;
- }
+ state.queries.splice(reduceExpressionIndex, 1);
+ state.queries[1].model.expression = dataQuery?.refId;
}
+
+ const shouldAddReduceExpression =
+ !isInstantDataQuery && expressionQueries.length === 1 && isThresholdExpression(expressionQueries[0].model);
if (shouldAddReduceExpression) {
// add reducer to the second position
// we only update the refid and the model to point to the reducer expression
- state.queries[1].model.expression = SimpleConditionIdentifier.reducerId;
+ state.queries[1].model.expression = NEW_REDUCER_REF;
+
// insert in second position the reducer expression
state.queries.splice(1, 0, {
datasourceUid: ExpressionDatasourceUID,
@@ -288,10 +275,10 @@ export const queriesAndExpressionsReducer = createReducer(initialState, (builder
type: ExpressionQueryType.reduce,
reducer: ReducerID.last,
conditions: [{ ...defaultCondition, query: { params: [] } }],
- expression: SimpleConditionIdentifier.queryId,
- refId: SimpleConditionIdentifier.reducerId,
+ expression: dataQuery?.refId,
+ refId: NEW_REDUCER_REF,
}),
- refId: SimpleConditionIdentifier.reducerId,
+ refId: NEW_REDUCER_REF,
queryType: 'expression',
});
}
diff --git a/public/app/features/alerting/unified/components/rule-editor/util.test.ts b/public/app/features/alerting/unified/components/rule-editor/util.test.ts
index 680367f38ec..13a4a6480b5 100644
--- a/public/app/features/alerting/unified/components/rule-editor/util.test.ts
+++ b/public/app/features/alerting/unified/components/rule-editor/util.test.ts
@@ -2,6 +2,7 @@ import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWi
import { ClassicCondition, ExpressionQuery } from 'app/features/expressions/types';
import { AlertQuery } from 'app/types/unified-alerting-dto';
+import { NEW_REDUCER_REF } from './query-and-alert-condition/reducer';
import {
containsPathSeparator,
findRenamedDataQueryReferences,
@@ -163,10 +164,10 @@ describe('rule-editor', () => {
it('should rewire threshold expressions', () => {
const queries: AlertQuery[] = [dataSource, reduceExpression, thresholdExpression];
- const rewiredQueries = queriesWithUpdatedReferences(queries, 'B', 'REDUCER');
+ const rewiredQueries = queriesWithUpdatedReferences(queries, 'B', NEW_REDUCER_REF);
const queryModel = rewiredQueries[2].model as ExpressionQuery;
- expect(queryModel.expression).toBe('REDUCER');
+ expect(queryModel.expression).toBe(NEW_REDUCER_REF);
});
it('should rewire multiple expressions', () => {
diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts
index 8e2701e0e04..4e0cd678109 100644
--- a/public/app/features/alerting/unified/mocks.ts
+++ b/public/app/features/alerting/unified/mocks.ts
@@ -56,7 +56,6 @@ import {
import { DashboardSearchItem, DashboardSearchItemType } from '../../search/types';
-import { SimpleConditionIdentifier } from './components/rule-editor/query-and-alert-condition/SimpleCondition';
import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
import { parsePromQLStyleMatcherLooseSafe } from './utils/matchers';
@@ -772,30 +771,33 @@ export function mockDashboardDto(
};
}
-export const dataQuery: AlertQuery = {
- refId: SimpleConditionIdentifier.queryId,
+export const mockDataQuery = (partial: Partial = {}): AlertQuery => ({
+ refId: partial?.refId ?? 'A',
datasourceUid: 'abc123',
queryType: '',
- model: { refId: SimpleConditionIdentifier.queryId },
-};
+ model: { refId: 'A', ...partial },
+});
-export const reduceExpression: AlertQuery = {
- refId: SimpleConditionIdentifier.reducerId,
+export const mockReduceExpression = (partial: Partial = {}): AlertQuery => ({
+ refId: 'B',
queryType: 'expression',
datasourceUid: '__expr__',
model: {
type: ExpressionQueryType.reduce,
- refId: SimpleConditionIdentifier.reducerId,
+ refId: 'B',
settings: { mode: ReducerMode.Strict },
reducer: ReducerID.last,
+ ...partial,
},
-};
-export const thresholdExpression: AlertQuery = {
- refId: SimpleConditionIdentifier.thresholdId,
+});
+
+export const mockThresholdExpression = (partial: Partial = {}): AlertQuery => ({
+ refId: 'C',
queryType: 'expression',
datasourceUid: '__expr__',
model: {
type: ExpressionQueryType.threshold,
- refId: SimpleConditionIdentifier.thresholdId,
+ refId: 'C',
+ ...partial,
},
-};
+});
diff --git a/public/app/features/alerting/unified/rule-editor/formDefaults.test.ts b/public/app/features/alerting/unified/rule-editor/formDefaults.test.ts
index 800b95a6193..9dd60a76b8d 100644
--- a/public/app/features/alerting/unified/rule-editor/formDefaults.test.ts
+++ b/public/app/features/alerting/unified/rule-editor/formDefaults.test.ts
@@ -1,6 +1,6 @@
import { config } from '@grafana/runtime';
-import { mockAlertQuery, mockDataSource, reduceExpression, thresholdExpression } from '../mocks';
+import { mockAlertQuery, mockDataSource, mockReduceExpression, mockThresholdExpression } from '../mocks';
import { testWithFeatureToggles } from '../test/test-utils';
import { RuleFormType } from '../types/rule-form';
import { Annotation } from '../utils/constants';
@@ -73,7 +73,11 @@ describe('formValuesFromQueryParams', () => {
it('should enable simplified query editor if queries are transformable to simple condition', () => {
const result = formValuesFromQueryParams(
JSON.stringify({
- queries: [mockAlertQuery(), reduceExpression, thresholdExpression],
+ queries: [
+ mockAlertQuery(),
+ mockReduceExpression({ expression: 'A' }),
+ mockThresholdExpression({ expression: 'B' }),
+ ],
}),
RuleFormType.grafana
);
@@ -85,7 +89,7 @@ describe('formValuesFromQueryParams', () => {
it('should disable simplified query editor if queries are not transformable to simple condition', () => {
const result = formValuesFromQueryParams(
JSON.stringify({
- queries: [mockAlertQuery(), mockAlertQuery(), thresholdExpression],
+ queries: [mockAlertQuery(), mockAlertQuery(), mockThresholdExpression({ expression: 'B' })],
}),
RuleFormType.grafana
);
diff --git a/public/app/features/alerting/unified/rule-editor/formProcessing.ts b/public/app/features/alerting/unified/rule-editor/formProcessing.ts
index c9fa1951417..d9f79892ce5 100644
--- a/public/app/features/alerting/unified/rule-editor/formProcessing.ts
+++ b/public/app/features/alerting/unified/rule-editor/formProcessing.ts
@@ -1,14 +1,15 @@
-import { omit } from 'lodash';
+import { isEmpty, omit } from 'lodash';
import { config } from '@grafana/runtime';
import { isExpressionQuery } from 'app/features/expressions/guards';
-import { ExpressionQuery, ExpressionQueryType, ReducerMode } from 'app/features/expressions/types';
+import { ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types';
+import { isStrictReducer } from 'app/features/expressions/utils/expressionTypes';
import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto';
-import { SimpleConditionIdentifier } from '../components/rule-editor/query-and-alert-condition/SimpleCondition';
import { KVObject, RuleFormValues } from '../types/rule-form';
import { defaultAnnotations } from '../utils/constants';
import { DataSourceType } from '../utils/datasource';
+import { getInstantFromDataQuery } from '../utils/rule-form';
export function setQueryEditorSettings(values: RuleFormValues): RuleFormValues {
const isQuerySwitchModeEnabled = config.featureToggles.alertingQueryAndExpressionsStepMode ?? false;
@@ -64,47 +65,63 @@ export function setInstantOrRange(values: RuleFormValues): RuleFormValues {
};
}
+/**
+ * A alert rule is "transformable" to a simple condition editor if
+ * 1. we have a single data query
+ * 2. we have _either_
+ * 2.1 a reduce expression (pointing to the data query) _and_ a threshold expression pointing to the reducer
+ * 2.2 a threshold expression pointing to a (instant) data query
+ * ⚠️ do not assert on refIds or indexes of the queries
+ */
export function areQueriesTransformableToSimpleCondition(
- dataQueries: Array>,
+ dataQueries: Array>,
expressionQueries: Array>
) {
+ // 1. check if we only have a _single_ data query
if (dataQueries.length !== 1) {
return false;
}
- const singleReduceExpressionInInstantQuery =
- 'instant' in dataQueries[0].model && dataQueries[0].model.instant && expressionQueries.length === 1;
- if (expressionQueries.length !== 2 && !singleReduceExpressionInInstantQuery) {
+ // short-circuit when we have more than 2 expressions, we don't know what to do with that
+ if (expressionQueries.length > 2) {
return false;
}
- const query = dataQueries[0];
+ const dataQuery = dataQueries.at(0);
- if (query.refId !== SimpleConditionIdentifier.queryId) {
- return false;
+ // find the reduce or threshold expressions
+ const reduceExpression = expressionQueries.find((query) => query.model.type === ExpressionQueryType.reduce);
+ const thresholdExpression = expressionQueries.find((query) => query.model.type === ExpressionQueryType.threshold);
+
+ // reducer should be set to "strict" mode
+ const reducerIsStrict = reduceExpression ? isStrictReducer(reduceExpression.model) : false;
+ // threshold expression shouldn't have an unload evaluator (custom recovery threshold)
+ const thresholdExpressionIsClean =
+ thresholdExpression?.model.conditions?.every((condition) => {
+ return isEmpty(condition.unloadEvaluator);
+ }) ?? true;
+
+ const validReducerExpression = reduceExpression && reducerIsStrict;
+ const validThresholdExpression = thresholdExpression && thresholdExpressionIsClean;
+
+ const thresholdPointingToReducer = thresholdExpression?.model.expression === reduceExpression?.refId;
+ const reducerPointingToDataQuery = reduceExpression?.model.expression === dataQuery?.refId;
+
+ // 2.1 check for a reduce + threshold expression and their targets
+ if (validReducerExpression && reducerPointingToDataQuery && validThresholdExpression && thresholdPointingToReducer) {
+ return true;
}
- const reduceExpressionIndex = expressionQueries.findIndex(
- (query) => query.model.type === ExpressionQueryType.reduce && query.refId === SimpleConditionIdentifier.reducerId
- );
- const reduceExpression = expressionQueries.at(reduceExpressionIndex);
- const reduceOk =
- reduceExpression &&
- reduceExpressionIndex === 0 &&
- (reduceExpression.model.settings?.mode === ReducerMode.Strict ||
- reduceExpression.model.settings?.mode === undefined);
+ // 2.2 check for a single threshold expression pointing to an "instant" data query
+ const isInstantDataQuery = dataQuery ? getInstantFromDataQuery(dataQuery) : false;
+ const hasSingleThresholdExpression = expressionQueries.length === 1 && thresholdExpression;
+ const thresholdPointingToDataQuery = thresholdExpression?.model.expression === dataQuery?.refId;
- const thresholdExpressionIndex = expressionQueries.findIndex(
- (query) =>
- query.model.type === ExpressionQueryType.threshold && query.refId === SimpleConditionIdentifier.thresholdId
- );
- const thresholdExpression = expressionQueries.at(thresholdExpressionIndex);
- const conditions = thresholdExpression?.model.conditions ?? [];
- const thresholdIndexOk = singleReduceExpressionInInstantQuery
- ? thresholdExpressionIndex === 0
- : thresholdExpressionIndex === 1;
- const thresholdOk = thresholdExpression && thresholdIndexOk && conditions[0]?.unloadEvaluator === undefined;
- return (Boolean(reduceOk) || Boolean(singleReduceExpressionInInstantQuery)) && Boolean(thresholdOk);
+ if (isInstantDataQuery && hasSingleThresholdExpression && validThresholdExpression && thresholdPointingToDataQuery) {
+ return true;
+ }
+
+ return false;
}
export function isExpressionQueryInAlert(
diff --git a/public/app/features/alerting/unified/utils/rule-form.test.ts b/public/app/features/alerting/unified/utils/rule-form.test.ts
index e8939c0c336..5c13f97c954 100644
--- a/public/app/features/alerting/unified/utils/rule-form.test.ts
+++ b/public/app/features/alerting/unified/utils/rule-form.test.ts
@@ -1,10 +1,18 @@
import { PromQuery } from '@grafana/prometheus';
-import { GrafanaAlertStateDecision, GrafanaRuleDefinition, RulerAlertingRuleDTO } from 'app/types/unified-alerting-dto';
+import {
+ AlertDataQuery,
+ AlertQuery,
+ GrafanaAlertStateDecision,
+ GrafanaRuleDefinition,
+ RulerAlertingRuleDTO,
+} from 'app/types/unified-alerting-dto';
+import { mockDataSource } from '../mocks';
import { getDefaultFormValues } from '../rule-editor/formDefaults';
+import { setupDataSources } from '../testSetup/datasources';
import { AlertManagerManualRouting, RuleFormType, RuleFormValues } from '../types/rule-form';
-import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
+import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './datasource';
import {
alertingRulerRuleToRuleForm,
cleanAnnotations,
@@ -12,6 +20,7 @@ import {
formValuesToRulerGrafanaRuleDTO,
formValuesToRulerRuleDTO,
getContactPointsFromDTO,
+ getInstantFromDataQuery,
getNotificationSettingsForDTO,
} from './rule-form';
@@ -254,3 +263,71 @@ describe('cleanLabels', () => {
expect(output).toStrictEqual([{ key: 'key', value: '' }]);
});
});
+
+describe('getInstantFromDataQuery', () => {
+ const query: AlertQuery = {
+ refId: 'Q',
+ datasourceUid: 'abc123',
+ queryType: '',
+ relativeTimeRange: {
+ from: 600,
+ to: 0,
+ },
+ model: {
+ refId: 'Q',
+ },
+ };
+
+ it('should return undefined if datasource UID is undefined', () => {
+ setupDataSources(mockDataSource({ type: DataSourceType.Prometheus, name: 'Mimir-cloud', uid: 'mimir-1' }));
+ const result = getInstantFromDataQuery({ ...query });
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined if datasource type is not Prometheus or Loki', () => {
+ setupDataSources(mockDataSource({ type: DataSourceType.Alertmanager, name: 'aa', uid: 'aa-1' }));
+ const result = getInstantFromDataQuery({ ...query, datasourceUid: 'aa' });
+ expect(result).toBeUndefined();
+ });
+
+ it('should return true if datasource is Prometheus and instant is not defined', () => {
+ setupDataSources(mockDataSource({ type: DataSourceType.Prometheus, name: 'aa', uid: 'aa-1' }));
+ const result = getInstantFromDataQuery({ ...query, datasourceUid: 'aa' });
+
+ expect(result).toBe(true);
+ });
+
+ it('should return the value of instant if datasource is Prometheus and instant is defined', () => {
+ setupDataSources(mockDataSource({ type: DataSourceType.Prometheus, name: 'aa', uid: 'aa-1' }));
+ const result = getInstantFromDataQuery({ ...query, datasourceUid: 'aa', model: { refId: 'f', instant: false } });
+ expect(result).toBe(false);
+ });
+
+ it('should return true if datasource is Loki and queryType is not defined', () => {
+ setupDataSources(mockDataSource({ type: DataSourceType.Loki, name: 'aa', uid: 'aa-1' }));
+ const result = getInstantFromDataQuery({ ...query, datasourceUid: 'aa' });
+ expect(result).toBe(true);
+ });
+
+ it('should return true if datasource is Loki and queryType is instant', () => {
+ setupDataSources(mockDataSource({ type: DataSourceType.Loki, name: 'aa', uid: 'aa-1' }));
+ const result = getInstantFromDataQuery({
+ ...query,
+ datasourceUid: 'aa',
+ model: { refId: 'f', queryType: 'instant' },
+ });
+
+ expect(result).toBe(true);
+ });
+
+ it('should return false if datasource is Loki and queryType is not instant', () => {
+ setupDataSources(mockDataSource({ type: DataSourceType.Loki, name: 'aa', uid: 'aa-1' }));
+ const result = getInstantFromDataQuery({
+ ...query,
+ datasourceUid: 'aa',
+ model: { refId: 'f', queryType: 'range' },
+ });
+
+ expect(result).toBe(false);
+ });
+});
diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts
index 194b7bec552..7845c5e228f 100644
--- a/public/app/features/alerting/unified/utils/rule-form.ts
+++ b/public/app/features/alerting/unified/utils/rule-form.ts
@@ -803,19 +803,22 @@ export function isPromOrLokiQuery(model: AlertDataQuery): model is PromOrLokiQue
return 'expr' in model;
}
-export function getInstantFromDataQuery(model: AlertDataQuery, type: string): boolean | undefined {
- // if the datasource is not prometheus or loki, instant is defined in the model or defaults to undefined
- if (type !== DataSourceType.Prometheus && type !== DataSourceType.Loki) {
- if ('instant' in model) {
- return model.instant;
- } else {
- if ('queryType' in model) {
- return model.queryType === 'instant';
- } else {
- return undefined;
- }
- }
+export function getInstantFromDataQuery(query: AlertQuery): boolean | undefined {
+ const dataSourceUID = query.datasourceUid ?? query.model.datasource?.uid;
+ if (!dataSourceUID) {
+ return undefined;
}
+
+ // find the datasource type from the UID
+ const type = getDataSourceSrv().getInstanceSettings(dataSourceUID)?.type;
+
+ // if the datasource is not prometheus or loki, return "undefined"
+ if (type !== DataSourceType.Prometheus && type !== DataSourceType.Loki) {
+ return undefined;
+ }
+
+ const { model } = query;
+
// if the datasource is prometheus or loki, instant is defined in the model, or defaults to true
const isInstantForPrometheus = 'instant' in model && model.instant !== undefined ? model.instant : true;
const isInstantForLoki = 'queryType' in model && model.queryType !== undefined ? model.queryType === 'instant' : true;
diff --git a/public/app/features/apiserver/client.ts b/public/app/features/apiserver/client.ts
index cb53c57aa65..2af5503674f 100644
--- a/public/app/features/apiserver/client.ts
+++ b/public/app/features/apiserver/client.ts
@@ -1,6 +1,7 @@
import { Observable, from, retry, catchError, filter, map, mergeMap } from 'rxjs';
-import { BackendSrvRequest, config, getBackendSrv } from '@grafana/runtime';
+import { isLiveChannelMessageEvent, LiveChannelScope } from '@grafana/data';
+import { config, getBackendSrv, getGrafanaLiveSrv } from '@grafana/runtime';
import { contextSrv } from 'app/core/core';
import { getAPINamespace } from '../../api/utils';
@@ -19,20 +20,16 @@ import {
K8sAPIGroupList,
AnnoKeySavedFromUI,
ResourceEvent,
+ GroupVersionResource,
} from './types';
-export interface GroupVersionResource {
- group: string;
- version: string;
- resource: string;
-}
-
export class ScopedResourceClient implements ResourceClient {
readonly url: string;
+ readonly gvr: GroupVersionResource;
constructor(gvr: GroupVersionResource, namespaced = true) {
const ns = namespaced ? `namespaces/${getAPINamespace()}/` : '';
-
+ this.gvr = gvr;
this.url = `/apis/${gvr.group}/${gvr.version}/${ns}${gvr.resource}`;
}
@@ -40,26 +37,40 @@ export class ScopedResourceClient implements
return getBackendSrv().get>(`${this.url}/${name}`);
}
- public watch(
- params?: WatchOptions,
- config?: Pick
- ): Observable> {
- const decoder = new TextDecoder();
- const { name, ...rest } = params ?? {}; // name needs to be added to fieldSelector
+ public watch(params?: WatchOptions): Observable> {
const requestParams = {
- ...rest,
watch: true,
labelSelector: this.parseListOptionsSelector(params?.labelSelector),
fieldSelector: this.parseListOptionsSelector(params?.fieldSelector),
};
- if (name) {
+ if (params?.name) {
requestParams.fieldSelector = `metadata.name=${name}`;
}
+
+ // For now, watch over live only supports provisioning
+ if (this.gvr.group === 'provisioning.grafana.app') {
+ let query = '';
+ if (requestParams.fieldSelector?.startsWith('metadata.name=')) {
+ query = requestParams.fieldSelector.substring('metadata.name'.length);
+ }
+ return getGrafanaLiveSrv()
+ .getStream>({
+ scope: LiveChannelScope.Watch,
+ namespace: this.gvr.group,
+ path: `${this.gvr.version}/${this.gvr.resource}${query}/${config.bootData.user.uid}`,
+ })
+ .pipe(
+ filter((event) => isLiveChannelMessageEvent(event)),
+ map((event) => event.message)
+ );
+ }
+
+ const decoder = new TextDecoder();
return getBackendSrv()
.chunked({
url: this.url,
params: requestParams,
- ...config,
+ method: 'GET',
})
.pipe(
filter((response) => response.ok && response.data instanceof Uint8Array),
@@ -73,7 +84,7 @@ export class ScopedResourceClient implements
try {
return JSON.parse(line);
} catch (e) {
- console.warn('Invalid JSON in watch stream:', e);
+ console.warn('Invalid JSON in watch stream:', e, line);
return null;
}
}),
diff --git a/public/app/features/apiserver/discovery.test.ts b/public/app/features/apiserver/discovery.test.ts
new file mode 100644
index 00000000000..5b784197eea
--- /dev/null
+++ b/public/app/features/apiserver/discovery.test.ts
@@ -0,0 +1,12 @@
+import { discoveryResources } from './discovery';
+
+const discoverySnapshot = require('./snapshots/discovery-snapshot.json');
+
+describe('simple typescript tests', () => {
+ it('simple', async () => {
+ const watchable = discoveryResources(discoverySnapshot)
+ .filter((v) => v.verbs.includes('watch'))
+ .map((v) => v.resource);
+ expect(watchable).toEqual(['user-storage', 'dashboards', 'dashboards', 'dashboards']);
+ });
+});
diff --git a/public/app/features/apiserver/discovery.ts b/public/app/features/apiserver/discovery.ts
new file mode 100644
index 00000000000..ece21681d48
--- /dev/null
+++ b/public/app/features/apiserver/discovery.ts
@@ -0,0 +1,84 @@
+import { lastValueFrom, map } from 'rxjs';
+
+import { FetchResponse, getBackendSrv } from '@grafana/runtime';
+
+import { GroupVersionKind, ListMeta } from './types';
+
+export type GroupDiscoveryResource = {
+ resource: string;
+ responseKind: GroupVersionKind;
+ scope: 'Namespaced' | 'Cluster';
+ singularResource: string;
+ verbs: string[];
+ subresources?: GroupDiscoverySubresource[];
+};
+
+export type GroupDiscoverySubresource = {
+ subresource: string;
+ responseKind: GroupVersionKind;
+ verbs: string[];
+};
+
+export type GroupDiscoveryVersion = {
+ version: string;
+ freshness: 'Current' | string;
+ resources: GroupDiscoveryResource[];
+};
+
+export type GroupDiscoveryItem = {
+ metadata: {
+ name: string;
+ };
+ versions: GroupDiscoveryVersion[];
+};
+
+export type APIGroupDiscoveryList = {
+ metadata: ListMeta;
+ items: GroupDiscoveryItem[];
+};
+
+export async function getAPIGroupDiscoveryList(): Promise {
+ return await lastValueFrom(
+ getBackendSrv()
+ .fetch({
+ method: 'GET',
+ url: '/apis',
+ headers: {
+ Accept:
+ 'application/json;g=apidiscovery.k8s.io;v=v2;as=APIGroupDiscoveryList,application/json;g=apidiscovery.k8s.io;v=v2beta1;as=APIGroupDiscoveryList,application/json',
+ },
+ })
+ .pipe(
+ map((response: FetchResponse) => {
+ // Fill in the group+version before returning
+ for (let api of response.data.items) {
+ for (let version of api.versions) {
+ for (let resource of version.resources) {
+ resource.responseKind.group = api.metadata.name;
+ resource.responseKind.version = version.version;
+ if (resource.subresources) {
+ for (let sub of resource.subresources) {
+ sub.responseKind.group = api.metadata.name;
+ sub.responseKind.version = version.version;
+ }
+ }
+ }
+ }
+ }
+ return response.data;
+ })
+ )
+ );
+}
+
+export function discoveryResources(apis: APIGroupDiscoveryList): GroupDiscoveryResource[] {
+ const resources: GroupDiscoveryResource[] = [];
+ for (let api of apis.items) {
+ for (let version of api.versions) {
+ for (let resource of version.resources) {
+ resources.push(resource);
+ }
+ }
+ }
+ return resources;
+}
diff --git a/public/app/features/apiserver/snapshots/discovery-snapshot.json b/public/app/features/apiserver/snapshots/discovery-snapshot.json
new file mode 100644
index 00000000000..36fc7f05aa3
--- /dev/null
+++ b/public/app/features/apiserver/snapshots/discovery-snapshot.json
@@ -0,0 +1,295 @@
+{
+ "kind": "APIGroupDiscoveryList",
+ "apiVersion": "apidiscovery.k8s.io/v2",
+ "metadata": {},
+ "items": [
+ {
+ "metadata": { "name": "userstorage.grafana.app", "creationTimestamp": null },
+ "versions": [
+ {
+ "version": "v0alpha1",
+ "resources": [
+ {
+ "resource": "user-storage",
+ "responseKind": { "group": "", "version": "", "kind": "UserStorage" },
+ "scope": "Namespaced",
+ "singularResource": "user-storage",
+ "verbs": ["create", "delete", "deletecollection", "get", "list", "patch", "update", "watch"]
+ }
+ ],
+ "freshness": "Current"
+ }
+ ]
+ },
+ {
+ "metadata": { "name": "notifications.alerting.grafana.app", "creationTimestamp": null },
+ "versions": [
+ {
+ "version": "v0alpha1",
+ "resources": [
+ {
+ "resource": "receivers",
+ "responseKind": { "group": "", "version": "", "kind": "Receiver" },
+ "scope": "Namespaced",
+ "singularResource": "receiver",
+ "verbs": ["create", "delete", "deletecollection", "get", "list", "patch", "update"]
+ },
+ {
+ "resource": "routingtrees",
+ "responseKind": { "group": "", "version": "", "kind": "RoutingTree" },
+ "scope": "Namespaced",
+ "singularResource": "routingtree",
+ "verbs": ["create", "delete", "deletecollection", "get", "list", "patch", "update"]
+ },
+ {
+ "resource": "templategroups",
+ "responseKind": { "group": "", "version": "", "kind": "TemplateGroup" },
+ "scope": "Namespaced",
+ "singularResource": "templategroup",
+ "verbs": ["create", "delete", "deletecollection", "get", "list", "patch", "update"]
+ },
+ {
+ "resource": "timeintervals",
+ "responseKind": { "group": "", "version": "", "kind": "TimeInterval" },
+ "scope": "Namespaced",
+ "singularResource": "timeinterval",
+ "verbs": ["create", "delete", "deletecollection", "get", "list", "patch", "update"]
+ }
+ ],
+ "freshness": "Current"
+ }
+ ]
+ },
+ {
+ "metadata": { "name": "iam.grafana.app", "creationTimestamp": null },
+ "versions": [
+ {
+ "version": "v0alpha1",
+ "resources": [
+ {
+ "resource": "serviceaccounts",
+ "responseKind": { "group": "", "version": "", "kind": "ServiceAccount" },
+ "scope": "Namespaced",
+ "singularResource": "serviceaccount",
+ "verbs": ["get", "list"],
+ "subresources": [
+ {
+ "subresource": "tokens",
+ "responseKind": { "group": "", "version": "", "kind": "UserTeamList" },
+ "verbs": ["get"]
+ }
+ ]
+ },
+ {
+ "resource": "ssosettings",
+ "responseKind": { "group": "", "version": "", "kind": "SSOSetting" },
+ "scope": "Namespaced",
+ "singularResource": "ssosetting",
+ "verbs": ["delete", "get", "list", "patch", "update"]
+ },
+ {
+ "resource": "teambindings",
+ "responseKind": { "group": "", "version": "", "kind": "TeamBinding" },
+ "scope": "Namespaced",
+ "singularResource": "teambinding",
+ "verbs": ["get", "list"]
+ },
+ {
+ "resource": "teams",
+ "responseKind": { "group": "", "version": "", "kind": "Team" },
+ "scope": "Namespaced",
+ "singularResource": "team",
+ "verbs": ["get", "list"],
+ "subresources": [
+ {
+ "subresource": "members",
+ "responseKind": { "group": "", "version": "", "kind": "TeamMemberList" },
+ "verbs": ["get"]
+ }
+ ]
+ },
+ {
+ "resource": "users",
+ "responseKind": { "group": "", "version": "", "kind": "User" },
+ "scope": "Namespaced",
+ "singularResource": "user",
+ "verbs": ["get", "list"],
+ "subresources": [
+ {
+ "subresource": "teams",
+ "responseKind": { "group": "", "version": "", "kind": "UserTeamList" },
+ "verbs": ["get"]
+ }
+ ]
+ }
+ ],
+ "freshness": "Current"
+ }
+ ]
+ },
+ {
+ "metadata": { "name": "folder.grafana.app", "creationTimestamp": null },
+ "versions": [
+ {
+ "version": "v0alpha1",
+ "resources": [
+ {
+ "resource": "folders",
+ "responseKind": { "group": "", "version": "", "kind": "Folder" },
+ "scope": "Namespaced",
+ "singularResource": "folder",
+ "verbs": ["create", "delete", "deletecollection", "get", "list", "patch", "update"],
+ "subresources": [
+ {
+ "subresource": "access",
+ "responseKind": { "group": "", "version": "", "kind": "FolderAccessInfo" },
+ "verbs": ["get"]
+ },
+ {
+ "subresource": "counts",
+ "responseKind": { "group": "", "version": "", "kind": "DescendantCounts" },
+ "verbs": ["get"]
+ },
+ {
+ "subresource": "parents",
+ "responseKind": { "group": "", "version": "", "kind": "FolderInfoList" },
+ "verbs": ["get"]
+ }
+ ]
+ }
+ ],
+ "freshness": "Current"
+ }
+ ]
+ },
+ {
+ "metadata": { "name": "featuretoggle.grafana.app", "creationTimestamp": null },
+ "versions": [
+ {
+ "version": "v0alpha1",
+ "resources": [
+ {
+ "resource": "features",
+ "responseKind": { "group": "", "version": "", "kind": "Feature" },
+ "scope": "Cluster",
+ "singularResource": "feature",
+ "verbs": ["get", "list"]
+ },
+ {
+ "resource": "featuretoggles",
+ "responseKind": { "group": "", "version": "", "kind": "FeatureToggles" },
+ "scope": "Namespaced",
+ "singularResource": "featuretoggle",
+ "verbs": ["get", "list"]
+ }
+ ],
+ "freshness": "Current"
+ }
+ ]
+ },
+ {
+ "metadata": { "name": "dashboard.grafana.app", "creationTimestamp": null },
+ "versions": [
+ {
+ "version": "v0alpha1",
+ "resources": [
+ {
+ "resource": "dashboards",
+ "responseKind": { "group": "", "version": "", "kind": "Dashboard" },
+ "scope": "Namespaced",
+ "singularResource": "dashboard",
+ "verbs": ["create", "delete", "deletecollection", "get", "list", "patch", "update", "watch"],
+ "subresources": [
+ {
+ "subresource": "dto",
+ "responseKind": { "group": "", "version": "", "kind": "DashboardWithAccessInfo" },
+ "verbs": ["get"]
+ }
+ ]
+ },
+ {
+ "resource": "librarypanels",
+ "responseKind": { "group": "", "version": "", "kind": "LibraryPanel" },
+ "scope": "Namespaced",
+ "singularResource": "librarypanel",
+ "verbs": ["get", "list"]
+ }
+ ],
+ "freshness": "Current"
+ },
+ {
+ "version": "v1alpha1",
+ "resources": [
+ {
+ "resource": "dashboards",
+ "responseKind": { "group": "", "version": "", "kind": "Dashboard" },
+ "scope": "Namespaced",
+ "singularResource": "dashboard",
+ "verbs": ["create", "delete", "deletecollection", "get", "list", "patch", "update", "watch"],
+ "subresources": [
+ {
+ "subresource": "dto",
+ "responseKind": { "group": "", "version": "", "kind": "DashboardWithAccessInfo" },
+ "verbs": ["get"]
+ }
+ ]
+ },
+ {
+ "resource": "librarypanels",
+ "responseKind": { "group": "", "version": "", "kind": "LibraryPanel" },
+ "scope": "Namespaced",
+ "singularResource": "librarypanel",
+ "verbs": ["get", "list"]
+ }
+ ],
+ "freshness": "Current"
+ },
+ {
+ "version": "v2alpha1",
+ "resources": [
+ {
+ "resource": "dashboards",
+ "responseKind": { "group": "", "version": "", "kind": "Dashboard" },
+ "scope": "Namespaced",
+ "singularResource": "dashboard",
+ "verbs": ["create", "delete", "deletecollection", "get", "list", "patch", "update", "watch"],
+ "subresources": [
+ {
+ "subresource": "dto",
+ "responseKind": { "group": "", "version": "", "kind": "DashboardWithAccessInfo" },
+ "verbs": ["get"]
+ }
+ ]
+ },
+ {
+ "resource": "librarypanels",
+ "responseKind": { "group": "", "version": "", "kind": "LibraryPanel" },
+ "scope": "Namespaced",
+ "singularResource": "librarypanel",
+ "verbs": ["get", "list"]
+ }
+ ],
+ "freshness": "Current"
+ }
+ ]
+ },
+ {
+ "metadata": { "name": "playlist.grafana.app", "creationTimestamp": null },
+ "versions": [
+ {
+ "version": "v0alpha1",
+ "resources": [
+ {
+ "resource": "playlists",
+ "responseKind": { "group": "", "version": "", "kind": "Playlist" },
+ "scope": "Namespaced",
+ "singularResource": "playlist",
+ "verbs": ["create", "delete", "deletecollection", "get", "list", "patch", "update"]
+ }
+ ],
+ "freshness": "Current"
+ }
+ ]
+ }
+ ]
+}
diff --git a/public/app/features/apiserver/types.ts b/public/app/features/apiserver/types.ts
index 06199f6d677..631f56f505c 100644
--- a/public/app/features/apiserver/types.ts
+++ b/public/app/features/apiserver/types.ts
@@ -111,6 +111,18 @@ type GrafanaLabels = {
[DeprecatedInternalId]?: string;
};
+export interface GroupVersionResource {
+ group: string;
+ version: string;
+ resource: string;
+}
+
+export interface GroupVersionKind {
+ group: string;
+ version: string;
+ kind: string;
+}
+
export interface Resource extends TypeMeta {
metadata: ObjectMeta;
spec: T;
diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx
index 387fcf41dca..fbb8f414e4d 100644
--- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx
+++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx
@@ -99,6 +99,10 @@ export class DashboardEditPane extends SceneObjectBase {
}
}
+ public getSelection(): SceneObject | SceneObject[] | undefined {
+ return this.state.selection?.getSelection();
+ }
+
public selectObject(obj: SceneObject, id: string, multi?: boolean) {
const prevItem = this.state.selection?.getFirstObject();
if (prevItem === obj && !multi) {
diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx
index e7241022596..b2de2c14ca0 100644
--- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx
+++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx
@@ -75,7 +75,7 @@ import { LayoutOrchestrator } from './layout-manager/LayoutOrchestrator';
import { LayoutRestorer } from './layouts-shared/LayoutRestorer';
import { addNewRowTo, addNewTabTo } from './layouts-shared/addNew';
import { DashboardLayoutManager } from './types/DashboardLayoutManager';
-import { LayoutParent } from './types/LayoutParent';
+import { isLayoutParent, LayoutParent } from './types/LayoutParent';
export const PERSISTED_PROPS = ['title', 'description', 'tags', 'editable', 'graphTooltip', 'links', 'meta', 'preload'];
export const PANEL_SEARCH_VAR = 'systemPanelFilterVar';
@@ -500,6 +500,13 @@ export class DashboardScene extends SceneObjectBase impleme
this.onEnterEditMode();
}
+ const selectedObject = this.state.editPane.getSelection();
+ if (selectedObject && !Array.isArray(selectedObject) && isLayoutParent(selectedObject)) {
+ const layout = selectedObject.getLayout();
+ layout.addPanel(vizPanel);
+ return;
+ }
+
// Add panel to layout
this.state.body.addPanel(vizPanel);
}
@@ -609,10 +616,22 @@ export class DashboardScene extends SceneObjectBase impleme
}
public onCreateNewRow() {
+ const selectedObject = this.state.editPane.getSelection();
+ if (selectedObject && !Array.isArray(selectedObject) && isLayoutParent(selectedObject)) {
+ const layout = selectedObject.getLayout();
+ return addNewRowTo(layout);
+ }
+
return addNewRowTo(this.state.body);
}
public onCreateNewTab() {
+ const selectedObject = this.state.editPane.getSelection();
+ if (selectedObject && !Array.isArray(selectedObject) && isLayoutParent(selectedObject)) {
+ const layout = selectedObject.getLayout();
+ return addNewTabTo(layout);
+ }
+
return addNewTabTo(this.state.body);
}
@@ -686,7 +705,8 @@ export class DashboardScene extends SceneObjectBase impleme
saveModel?: Dashboard | DashboardV2Spec,
meta?: DashboardMeta | DashboardWithAccessInfo['metadata']
): void {
- this.serializer.initializeMapping(saveModel);
+ this.serializer.initializeElementMapping(saveModel);
+ this.serializer.initializeDSReferencesMapping(saveModel);
const sortedModel = sortedDeepCloneWithoutNulls(saveModel);
this.serializer.initialSaveModel = sortedModel;
this.serializer.metadata = meta;
diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
index d8671716331..6b6c0b54f50 100644
--- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
+++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
@@ -1,5 +1,5 @@
import { css } from '@emotion/css';
-import { memo, ReactNode, useEffect, useId, useState } from 'react';
+import { memo, ReactNode, useEffect, useState } from 'react';
import { GrafanaTheme2, store } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
@@ -10,10 +10,8 @@ import {
ButtonGroup,
Dropdown,
Icon,
- InlineLabel,
Menu,
Stack,
- Switch,
ToolbarButton,
ToolbarButtonRow,
useStyles2,
@@ -39,15 +37,20 @@ import { isLibraryPanel } from '../utils/utils';
import { DashboardScene } from './DashboardScene';
import { GoToSnapshotOriginButton } from './GoToSnapshotOriginButton';
import ManagedDashboardNavBarBadge from './ManagedDashboardNavBarBadge';
+import { ToolbarActionsNew } from './new-toolbar/ToolbarActionsNew';
interface Props {
dashboard: DashboardScene;
}
export const NavToolbarActions = memo(({ dashboard }) => {
- const id = useId();
+ const hasNewToolbar = config.featureToggles.dashboardNewLayouts && config.featureToggles.newDashboardSharingComponent;
- const actions = ;
+ const actions = hasNewToolbar ? (
+
+ ) : (
+
+ );
return ;
});
@@ -57,8 +60,7 @@ NavToolbarActions.displayName = 'NavToolbarActions';
* This part is split into a separate component to help test this
*/
export function ToolbarActions({ dashboard }: Props) {
- const { isEditing, showHiddenElements, viewPanelScene, isDirty, uid, meta, editview, editPanel, editable } =
- dashboard.useState();
+ const { isEditing, viewPanelScene, isDirty, uid, meta, editview, editPanel, editable } = dashboard.useState();
const { isPlaying } = playlistSrv.useState();
const [isAddPanelMenuOpen, setIsAddPanelMenuOpen] = useState(false);
@@ -77,7 +79,6 @@ export function ToolbarActions({ dashboard }: Props) {
// Means we are not in settings view, fullscreen panel or edit panel
const isShowingDashboard = !editview && !isViewingPanel && !isEditingPanel;
const isEditingAndShowingDashboard = isEditing && isShowingDashboard;
- const dashboardNewLayouts = config.featureToggles.dashboardNewLayouts;
const folderRepo = useSelector((state) => selectFolderRepository(state, meta.folderUid));
const isManaged = Boolean(dashboard.isManagedRepository() || folderRepo);
@@ -168,97 +169,75 @@ export function ToolbarActions({ dashboard }: Props) {
addDynamicActions(toolbarActions, dynamicDashNavActions.right, 'icon-actions');
}
- if (dashboardNewLayouts) {
- leftActions.push({
- group: 'hidden-elements',
- condition: isEditingAndShowingDashboard,
- render: () => (
-
-