;
exploreDefaultTimeOffset = '1h';
diff --git a/packages/grafana-runtime/src/services/index.ts b/packages/grafana-runtime/src/services/index.ts
index 2a40243f2ad..964ac05316e 100644
--- a/packages/grafana-runtime/src/services/index.ts
+++ b/packages/grafana-runtime/src/services/index.ts
@@ -35,7 +35,12 @@ export {
type UsePluginFunctionsResult,
} from './pluginExtensions/usePluginFunctions';
-export { isPluginExtensionLink, isPluginExtensionComponent } from './pluginExtensions/utils';
+export {
+ isPluginExtensionLink,
+ isPluginExtensionComponent,
+ getLimitedComponentsToRender,
+ renderLimitedComponents,
+} from './pluginExtensions/utils';
export { setCurrentUser } from './user';
export { RuntimeDataSource } from './RuntimeDataSource';
export { ScopesContext, type ScopesContextValueState, type ScopesContextValue, useScopes } from './ScopesContext';
diff --git a/packages/grafana-runtime/src/services/live.ts b/packages/grafana-runtime/src/services/live.ts
index e3787383995..41c05ac76bd 100644
--- a/packages/grafana-runtime/src/services/live.ts
+++ b/packages/grafana-runtime/src/services/live.ts
@@ -39,6 +39,20 @@ export interface LiveQueryDataOptions {
body: unknown; // processed queries, same as sent to `/api/query/ds`
}
+/**
+ * @alpha -- experimental
+ */
+export interface LivePublishOptions {
+ /**
+ * Publish the data over the websocket instead of the HTTP API.
+ *
+ * This is not recommended for most use cases.
+ *
+ * @experimental
+ */
+ useSocket?: boolean;
+}
+
/**
* @alpha -- experimental
*/
@@ -79,7 +93,7 @@ export interface GrafanaLiveSrv {
*
* @alpha -- experimental
*/
- publish(address: LiveChannelAddress, data: unknown): Promise
;
+ publish(address: LiveChannelAddress, data: unknown, options?: LivePublishOptions): Promise;
}
let singletonInstance: GrafanaLiveSrv;
diff --git a/packages/grafana-runtime/src/services/pluginExtensions/utils.test.ts b/packages/grafana-runtime/src/services/pluginExtensions/utils.test.ts
deleted file mode 100644
index c4c6e1f10bd..00000000000
--- a/packages/grafana-runtime/src/services/pluginExtensions/utils.test.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { PluginExtension, PluginExtensionTypes } from '@grafana/data';
-
-import { isPluginExtensionLink } from './utils';
-
-describe('Plugin Extensions / Utils', () => {
- describe('isPluginExtensionLink()', () => {
- test('should return TRUE if the object is a link extension', () => {
- expect(
- isPluginExtensionLink({
- id: 'id',
- pluginId: 'plugin-id',
- type: PluginExtensionTypes.link,
- title: 'Title',
- description: 'Description',
- path: '...',
- } as PluginExtension)
- ).toBe(true);
-
- expect(
- isPluginExtensionLink({
- id: 'id',
- pluginId: 'plugin-id',
- type: PluginExtensionTypes.link,
- title: 'Title',
- description: 'Description',
- onClick: () => {},
- } as PluginExtension)
- ).toBe(true);
- });
- test('should return FALSE if the object is NOT a link extension', () => {
- expect(
- isPluginExtensionLink({
- type: PluginExtensionTypes.link,
- title: 'Title',
- description: 'Description',
- } as PluginExtension)
- ).toBe(false);
-
- expect(
- // @ts-ignore (Right now we only have a single type of extension)
- isPluginExtensionLink({
- type: 'unknown',
- title: 'Title',
- description: 'Description',
- path: '...',
- } as PluginExtension)
- ).toBe(false);
- });
- });
-});
diff --git a/packages/grafana-runtime/src/services/pluginExtensions/utils.test.tsx b/packages/grafana-runtime/src/services/pluginExtensions/utils.test.tsx
new file mode 100644
index 00000000000..e235aace668
--- /dev/null
+++ b/packages/grafana-runtime/src/services/pluginExtensions/utils.test.tsx
@@ -0,0 +1,269 @@
+import { render } from '@testing-library/react';
+import React from 'react';
+
+import {
+ ComponentTypeWithExtensionMeta,
+ PluginExtension,
+ PluginExtensionComponentMeta,
+ PluginExtensionTypes,
+} from '@grafana/data';
+
+import { getLimitedComponentsToRender, isPluginExtensionLink, renderLimitedComponents } from './utils';
+
+describe('Plugin Extensions / Utils', () => {
+ describe('isPluginExtensionLink()', () => {
+ test('should return TRUE if the object is a link extension', () => {
+ expect(
+ isPluginExtensionLink({
+ id: 'id',
+ pluginId: 'plugin-id',
+ type: PluginExtensionTypes.link,
+ title: 'Title',
+ description: 'Description',
+ path: '...',
+ } as PluginExtension)
+ ).toBe(true);
+
+ expect(
+ isPluginExtensionLink({
+ id: 'id',
+ pluginId: 'plugin-id',
+ type: PluginExtensionTypes.link,
+ title: 'Title',
+ description: 'Description',
+ onClick: () => {},
+ } as PluginExtension)
+ ).toBe(true);
+ });
+ test('should return FALSE if the object is NOT a link extension', () => {
+ expect(
+ isPluginExtensionLink({
+ type: PluginExtensionTypes.link,
+ title: 'Title',
+ description: 'Description',
+ } as PluginExtension)
+ ).toBe(false);
+
+ expect(
+ // @ts-ignore (Right now we only have a single type of extension)
+ isPluginExtensionLink({
+ type: 'unknown',
+ title: 'Title',
+ description: 'Description',
+ path: '...',
+ } as PluginExtension)
+ ).toBe(false);
+ });
+ });
+
+ describe('getLimitedComponentsToRender()', () => {
+ test('should return `null` if it receives an empty array of components', () => {
+ const props = {};
+ const components: Array> = [];
+ const limitedComponents = getLimitedComponentsToRender({ props, components });
+ expect(limitedComponents).toEqual(null);
+ });
+
+ test('should return all components if no limit is provided', () => {
+ const props = {};
+ const components: Array> = [
+ createComponent(() => Test 1
, undefined, 'id-1'),
+ createComponent(() => Test 2
, undefined, 'id-2'),
+ createComponent(() => Test 3
, undefined, 'id-3'),
+ ];
+
+ expect(getLimitedComponentsToRender({ props, components })?.length).toEqual(3);
+ });
+
+ test('should limit the number of components', () => {
+ const props = {};
+ const components: Array> = [
+ createComponent(() => Test 1
, undefined, 'id-1'),
+ createComponent(() => Test 2
, undefined, 'id-2'),
+ createComponent(() => Test 3
, undefined, 'id-3'),
+ createComponent(() => Test 4
, undefined, 'id-4'),
+ createComponent(() => Test 5
, undefined, 'id-5'),
+ ];
+
+ // Check if the limit is respected
+ expect(getLimitedComponentsToRender({ props, components, limit: 1 })?.length).toEqual(1);
+ expect(getLimitedComponentsToRender({ props, components, limit: 3 })?.length).toEqual(3);
+
+ // Check if the right components are selected
+ const limitedComponents = getLimitedComponentsToRender({ props, components, limit: 3 });
+ const rendered = render(
+ <>{limitedComponents?.map((Component, index) => )}>
+ );
+
+ expect(rendered.getByText('Test 1')).toBeInTheDocument();
+ expect(rendered.getByText('Test 2')).toBeInTheDocument();
+ expect(rendered.getByText('Test 3')).toBeInTheDocument();
+ expect(rendered.queryByText('Test 4')).not.toBeInTheDocument();
+ expect(rendered.queryByText('Test 5')).not.toBeInTheDocument();
+ });
+
+ test('should work when using class components', () => {
+ const props = {};
+ const Component1 = class extends React.Component<{}> {
+ render() {
+ return Test 1
;
+ }
+ };
+ const Component2 = class extends React.Component<{}> {
+ render() {
+ return Test 2
;
+ }
+ };
+
+ const components: Array> = [
+ createComponent(Component1, undefined, 'id-1'),
+ createComponent(Component2, undefined, 'id-2'),
+ ];
+
+ // Check if the limit is respected
+ expect(getLimitedComponentsToRender({ props, components, limit: 1 })?.length).toEqual(1);
+ expect(getLimitedComponentsToRender({ props, components, limit: 2 })?.length).toEqual(2);
+
+ // Check if the right components are selected
+ const limitedComponents = getLimitedComponentsToRender({ props, components, limit: 1 });
+ const rendered = render(
+ <>{limitedComponents?.map((Component, index) => )}>
+ );
+
+ expect(rendered.getByText('Test 1')).toBeInTheDocument();
+ expect(rendered.queryByText('Test 2')).not.toBeInTheDocument();
+ });
+
+ test('should filter components by plugin id', () => {
+ const props = {};
+ const components: Array> = [
+ createComponent(() => Test 1
, 'plugin-id-1', 'id-1'),
+ createComponent(() => Test 2
, 'plugin-id-2', 'id-2'),
+ createComponent(() => Test 3
, 'plugin-id-3', 'id-3'),
+ createComponent(() => Test 4
, 'plugin-id-4', 'id-4'),
+ createComponent(() => Test 5
, 'plugin-id-5', 'id-5'),
+ ];
+
+ // Check if the filtering works
+ expect(getLimitedComponentsToRender({ props, components, pluginId: 'plugin-id-1' })?.length).toEqual(1);
+ expect(
+ getLimitedComponentsToRender({ props, components, pluginId: ['plugin-id-1', 'plugin-id-2'] })?.length
+ ).toEqual(2);
+ expect(getLimitedComponentsToRender({ props, components, pluginId: /plugin-id.*/ })?.length).toEqual(5);
+
+ // Check if the right components are selected
+ const limitedComponents = getLimitedComponentsToRender({
+ props,
+ components,
+ pluginId: ['plugin-id-2', 'plugin-id-3'],
+ });
+ const rendered = render(
+ <>{limitedComponents?.map((Component, index) => )}>
+ );
+
+ expect(rendered.getByText('Test 2')).toBeInTheDocument();
+ expect(rendered.getByText('Test 3')).toBeInTheDocument();
+ expect(rendered.queryByText('Test 1')).not.toBeInTheDocument();
+ expect(rendered.queryByText('Test 4')).not.toBeInTheDocument();
+ expect(rendered.queryByText('Test 5')).not.toBeInTheDocument();
+ });
+
+ test('should filter components based on both limit and plugin id', () => {
+ const props = {};
+ const components: Array> = [
+ createComponent(() => Test 1
, 'plugin-id-1', 'id-1'),
+ createComponent(() => Test 2
, 'plugin-id-2', 'id-2'),
+ createComponent(() => Test 3
, 'plugin-id-3', 'id-3'),
+ createComponent(() => Test 4
, 'plugin-id-4', 'id-4'),
+ createComponent(() => Test 5
, 'plugin-id-5', 'id-5'),
+ ];
+
+ // Check if the filtering works
+ expect(getLimitedComponentsToRender({ props, components, limit: 1, pluginId: /plugin-id.*/ })?.length).toEqual(1);
+ expect(getLimitedComponentsToRender({ props, components, limit: 2, pluginId: 'plugin-id-3' })?.length).toEqual(1);
+ expect(
+ getLimitedComponentsToRender({
+ props,
+ components,
+ limit: 1,
+ pluginId: ['plugin-id-1', 'plugin-id-2', 'plugin-id-3'],
+ })?.length
+ ).toEqual(1);
+ });
+ });
+
+ describe('renderLimitedComponents()', () => {
+ test('should render all components if no limit is provided', () => {
+ const props = {};
+ const components: Array> = [
+ createComponent(() => Test 1
, 'plugin-id-1', 'id-1'),
+ createComponent(() => Test 2
, 'plugin-id-1', 'id-2'),
+ createComponent(() => Test 3
, 'plugin-id-2', 'id-3'),
+ createComponent(() => Test 4
, 'plugin-id-3', 'id-4'),
+ ];
+
+ const rendered = render(<>{renderLimitedComponents({ props, components })}>);
+
+ expect(rendered.getByText('Test 1')).toBeInTheDocument();
+ expect(rendered.getByText('Test 2')).toBeInTheDocument();
+ expect(rendered.getByText('Test 3')).toBeInTheDocument();
+ expect(rendered.getByText('Test 4')).toBeInTheDocument();
+ });
+
+ test('should limit the number of components', () => {
+ const props = {};
+ const components: Array> = [
+ createComponent(() => Test 1
, 'plugin-id-1', 'id-1'),
+ createComponent(() => Test 2
, 'plugin-id-2', 'id-2'),
+ createComponent(() => Test 3
, 'plugin-id-3', 'id-3'),
+ ];
+
+ const rendered = render(<>{renderLimitedComponents({ props, components, limit: 1 })}>);
+
+ expect(rendered.getByText('Test 1')).toBeInTheDocument();
+ expect(rendered.queryByText('Test 2')).not.toBeInTheDocument();
+ expect(rendered.queryByText('Test 3')).not.toBeInTheDocument();
+ });
+
+ test('should filter components by plugin id', () => {
+ const props = {};
+ const components: Array> = [
+ createComponent(() => Test 1
, 'plugin-id-1', 'id-1'),
+ createComponent(() => Test 2
, 'plugin-id-2', 'id-2'),
+ createComponent(() => Test 3
, 'plugin-id-3', 'id-3'),
+ ];
+
+ const rendered = render(<>{renderLimitedComponents({ props, components, pluginId: ['plugin-id-2'] })}>);
+ expect(rendered.getByText('Test 2')).toBeInTheDocument();
+ expect(rendered.queryByText('Test 1')).not.toBeInTheDocument();
+ expect(rendered.queryByText('Test 3')).not.toBeInTheDocument();
+ });
+ });
+});
+
+function createComponent(
+ Implementation?: React.ComponentType,
+ pluginId?: string,
+ id?: string
+): ComponentTypeWithExtensionMeta {
+ function ComponentWithMeta(props: Props) {
+ if (Implementation) {
+ return ;
+ }
+
+ return Test
;
+ }
+
+ ComponentWithMeta.displayName = '';
+ ComponentWithMeta.propTypes = {};
+ ComponentWithMeta.contextTypes = {};
+ ComponentWithMeta.meta = {
+ id: id ?? '',
+ pluginId: pluginId ?? '',
+ title: '',
+ description: '',
+ type: PluginExtensionTypes.component,
+ } satisfies PluginExtensionComponentMeta;
+
+ return ComponentWithMeta;
+}
diff --git a/packages/grafana-runtime/src/services/pluginExtensions/utils.ts b/packages/grafana-runtime/src/services/pluginExtensions/utils.ts
deleted file mode 100644
index afd08da9e49..00000000000
--- a/packages/grafana-runtime/src/services/pluginExtensions/utils.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import {
- type PluginExtension,
- type PluginExtensionComponent,
- type PluginExtensionLink,
- PluginExtensionTypes,
-} from '@grafana/data';
-
-export function isPluginExtensionLink(extension: PluginExtension | undefined): extension is PluginExtensionLink {
- if (!extension) {
- return false;
- }
- return extension.type === PluginExtensionTypes.link && ('path' in extension || 'onClick' in extension);
-}
-
-export function isPluginExtensionComponent(
- extension: PluginExtension | undefined
-): extension is PluginExtensionComponent {
- if (!extension) {
- return false;
- }
- return extension.type === PluginExtensionTypes.component && 'component' in extension;
-}
diff --git a/packages/grafana-runtime/src/services/pluginExtensions/utils.tsx b/packages/grafana-runtime/src/services/pluginExtensions/utils.tsx
new file mode 100644
index 00000000000..3bb27160ef5
--- /dev/null
+++ b/packages/grafana-runtime/src/services/pluginExtensions/utils.tsx
@@ -0,0 +1,103 @@
+import React from 'react';
+
+import {
+ ComponentTypeWithExtensionMeta,
+ type PluginExtension,
+ type PluginExtensionComponent,
+ type PluginExtensionLink,
+ PluginExtensionTypes,
+} from '@grafana/data';
+
+export function isPluginExtensionLink(extension: PluginExtension | undefined): extension is PluginExtensionLink {
+ if (!extension) {
+ return false;
+ }
+ return extension.type === PluginExtensionTypes.link && ('path' in extension || 'onClick' in extension);
+}
+
+export function isPluginExtensionComponent(
+ extension: PluginExtension | undefined
+): extension is PluginExtensionComponent {
+ if (!extension) {
+ return false;
+ }
+ return extension.type === PluginExtensionTypes.component && 'component' in extension;
+}
+
+export function getLimitedComponentsToRender({
+ props,
+ components,
+ limit,
+ pluginId,
+}: {
+ props: Props;
+ components: Array>;
+ limit?: number;
+ pluginId?: string | string[] | RegExp;
+}) {
+ if (!components.length) {
+ return null;
+ }
+
+ const renderedComponents: Array> = [];
+
+ for (const Component of components) {
+ const { meta } = Component;
+
+ if (pluginId && typeof pluginId === 'string' && pluginId !== meta.pluginId) {
+ continue;
+ }
+
+ if (pluginId && Array.isArray(pluginId) && !pluginId.includes(meta.pluginId)) {
+ continue;
+ }
+
+ if (pluginId instanceof RegExp && !pluginId.test(meta.pluginId)) {
+ continue;
+ }
+
+ // If no limit is provided, return all components
+ if (limit === undefined) {
+ renderedComponents.push(Component);
+ continue;
+ }
+
+ // If a component does not render anything, do not count it in the limit
+ if (React.createElement(Component, props) !== null) {
+ renderedComponents.push(Component);
+ }
+
+ // Stop if we've reached the limit
+ if (renderedComponents.length >= limit) {
+ break;
+ }
+ }
+
+ return renderedComponents;
+}
+
+export function renderLimitedComponents({
+ props,
+ components,
+ limit,
+ pluginId,
+}: {
+ props: Props;
+ components: Array>;
+ limit?: number;
+ pluginId?: string | string[] | RegExp;
+}) {
+ const limitedComponents = getLimitedComponentsToRender({ props, components, limit, pluginId });
+
+ if (!limitedComponents?.length) {
+ return null;
+ }
+
+ return (
+ <>
+ {limitedComponents.map((Component) => (
+
+ ))}
+ >
+ );
+}
diff --git a/packages/grafana-runtime/src/types/i18n.ts b/packages/grafana-runtime/src/types/i18n.ts
new file mode 100644
index 00000000000..31879879a5f
--- /dev/null
+++ b/packages/grafana-runtime/src/types/i18n.ts
@@ -0,0 +1,63 @@
+/**
+ * Hook type for translation function that takes an ID, default message, and optional values
+ * @returns A function that returns the translated string
+ */
+type UseTranslateHook = () => (id: string, defaultMessage: string, values?: Record) => string;
+
+/**
+ * Type for children elements in Trans component
+ * Can be either React nodes or an object of values
+ */
+type TransChild = React.ReactNode | Record;
+
+/**
+ * Props interface for the Trans component used for internationalization
+ */
+interface TransProps {
+ /**
+ * The translation key to look up
+ */
+ i18nKey: string;
+ /**
+ * Child elements or values to interpolate
+ */
+ children?: TransChild | readonly TransChild[];
+ /**
+ * React elements to use for interpolation
+ */
+ components?: readonly React.ReactElement[] | { readonly [tagName: string]: React.ReactElement };
+ /**
+ * Count value for pluralization
+ */
+ count?: number;
+ /**
+ * Default text if translation is not found
+ */
+ defaults?: string;
+ /**
+ * Namespace for the translation key
+ */
+ ns?: string;
+ /**
+ * Whether to unescape HTML entities
+ */
+ shouldUnescape?: boolean;
+ /**
+ * Values to interpolate into the translation
+ */
+ values?: Record;
+}
+
+/**
+ * Function declaration for the Trans component
+ * @param props - The TransProps object containing translation configuration
+ * @returns A React element with translated content
+ */
+declare function Trans(props: TransProps): React.ReactElement;
+
+/**
+ * Type alias for the Trans component
+ */
+type TransType = typeof Trans;
+
+export type { UseTranslateHook, TransProps, TransType };
diff --git a/packages/grafana-runtime/src/unstable.ts b/packages/grafana-runtime/src/unstable.ts
index 84075dbb08a..07bb817ac5b 100644
--- a/packages/grafana-runtime/src/unstable.ts
+++ b/packages/grafana-runtime/src/unstable.ts
@@ -9,4 +9,5 @@
* and be subject to the standard policies
*/
-export { useTranslate, setUseTranslateHook } from './utils/i18n';
+export { useTranslate, setUseTranslateHook, setTransComponent, Trans } from './utils/i18n';
+export type { TransProps } from './types/i18n';
diff --git a/packages/grafana-runtime/src/utils/i18n.ts b/packages/grafana-runtime/src/utils/i18n.ts
deleted file mode 100644
index f67f63316e5..00000000000
--- a/packages/grafana-runtime/src/utils/i18n.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-type UseTranslateHook = () => (id: string, defaultMessage: string, values?: Record) => string;
-
-/**
- * Provides a i18next-compatible translation function.
- */
-export let useTranslate: UseTranslateHook = () => {
- // Fallback implementation that should be overridden by setUseT
- const errorMessage = 'useTranslate is not set. useTranslate must not be called before Grafana is initialized.';
- if (process.env.NODE_ENV === 'development') {
- throw new Error(errorMessage);
- }
-
- console.error(errorMessage);
- return (id: string, defaultMessage: string) => {
- return defaultMessage;
- };
-};
-
-export function setUseTranslateHook(hook: UseTranslateHook) {
- useTranslate = hook;
-}
diff --git a/packages/grafana-runtime/src/utils/i18n.tsx b/packages/grafana-runtime/src/utils/i18n.tsx
new file mode 100644
index 00000000000..30c06263dfc
--- /dev/null
+++ b/packages/grafana-runtime/src/utils/i18n.tsx
@@ -0,0 +1,57 @@
+import { type TransProps, type TransType, type UseTranslateHook } from '../types/i18n';
+
+/**
+ * Provides a i18next-compatible translation function.
+ */
+export let useTranslate: UseTranslateHook = useTranslateDefault;
+
+function useTranslateDefault() {
+ // Fallback implementation that should be overridden by setUseT
+ const errorMessage = 'useTranslate is not set. useTranslate must not be called before Grafana is initialized.';
+ if (process.env.NODE_ENV === 'development') {
+ throw new Error(errorMessage);
+ }
+
+ console.error(errorMessage);
+ return (id: string, defaultMessage: string) => {
+ return defaultMessage;
+ };
+}
+
+export function setUseTranslateHook(hook: UseTranslateHook) {
+ useTranslate = hook;
+}
+
+let TransComponent: TransType | undefined;
+
+/**
+ * Sets the Trans component that will be used for translations throughout the application.
+ * This function should only be called once during application initialization.
+ *
+ * @param transComponent - The Trans component function to use for translations
+ * @throws {Error} If called multiple times outside of test environment
+ */
+export function setTransComponent(transComponent: TransType) {
+ // We allow overriding the trans component in tests
+ if (TransComponent && process.env.NODE_ENV !== 'test') {
+ throw new Error('setTransComponent() function should only be called once, when Grafana is starting.');
+ }
+
+ TransComponent = transComponent;
+}
+
+/**
+ * A React component for handling translations with support for interpolation and pluralization.
+ * This component must be initialized using setTransComponent before use.
+ *
+ * @param props - The translation props including the i18nKey and any interpolation values
+ * @returns A React element containing the translated content
+ * @throws {Error} If the Trans component hasn't been initialized
+ */
+export function Trans(props: TransProps): React.ReactElement {
+ if (!TransComponent) {
+ throw new Error('Trans component not set. Use setTransComponent to set the Trans component.');
+ }
+
+ return ;
+}
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/Badge/Badge.tsx b/packages/grafana-ui/src/components/Badge/Badge.tsx
index 59cd9074a2d..fbb2fecb085 100644
--- a/packages/grafana-ui/src/components/Badge/Badge.tsx
+++ b/packages/grafana-ui/src/components/Badge/Badge.tsx
@@ -10,6 +10,7 @@ import { useStyles2 } from '../../themes/ThemeContext';
import { IconName } from '../../types';
import { SkeletonComponent, attachSkeleton } from '../../utils/skeleton';
import { Icon } from '../Icon/Icon';
+import { PopoverContent } from '../Tooltip';
import { Tooltip } from '../Tooltip/Tooltip';
export type BadgeColor = 'blue' | 'red' | 'green' | 'orange' | 'purple' | 'darkgrey';
@@ -18,7 +19,7 @@ export interface BadgeProps extends HTMLAttributes {
text: React.ReactNode;
color: BadgeColor;
icon?: IconName;
- tooltip?: string;
+ tooltip?: PopoverContent;
}
const BadgeComponent = React.memo(({ icon, color, text, tooltip, className, ...otherProps }) => {
diff --git a/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx b/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx
index 34e0f95db20..caab6779ff1 100644
--- a/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx
+++ b/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx
@@ -126,5 +126,7 @@ const collapsableSectionStyles = (theme: GrafanaTheme2) => ({
}),
label: css({
display: 'flex',
+ fontWeight: theme.typography.fontWeightMedium,
+ color: theme.colors.text.maxContrast,
}),
});
diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPickerInput.test.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerInput.test.tsx
index 8b81be33bf0..9f13e67918b 100644
--- a/packages/grafana-ui/src/components/ColorPicker/ColorPickerInput.test.tsx
+++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerInput.test.tsx
@@ -12,6 +12,24 @@ describe('ColorPickerInput', () => {
expect(screen.getByTestId('color-popover')).toBeInTheDocument();
});
+ it('should hide color popover on blur', async () => {
+ render( );
+ expect(screen.queryByTestId('color-popover')).not.toBeInTheDocument();
+ await userEvent.click(screen.getByRole('textbox'));
+ expect(screen.getByTestId('color-popover')).toBeInTheDocument();
+ await userEvent.click(document.body);
+ expect(screen.queryByTestId('color-popover')).not.toBeInTheDocument();
+ });
+
+ it('should not hide color popover on blur if clicked inside the color picker', async () => {
+ render( );
+ expect(screen.queryByTestId('color-popover')).not.toBeInTheDocument();
+ await userEvent.click(screen.getByRole('textbox'));
+ expect(screen.getByTestId('color-popover')).toBeInTheDocument();
+ await userEvent.click(screen.getAllByRole('slider')[0]);
+ expect(screen.queryByTestId('color-popover')).toBeInTheDocument();
+ });
+
it('should pass correct color to onChange callback', async () => {
const mockOnChange = jest.fn();
render( );
diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorPickerInput.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorPickerInput.tsx
index add799af11c..381ad0403b2 100644
--- a/packages/grafana-ui/src/components/ColorPicker/ColorPickerInput.tsx
+++ b/packages/grafana-ui/src/components/ColorPicker/ColorPickerInput.tsx
@@ -1,5 +1,5 @@
import { css, cx } from '@emotion/css';
-import { useState, forwardRef } from 'react';
+import { useState, forwardRef, FocusEvent } from 'react';
import { RgbaStringColorPicker } from 'react-colorful';
import { useThrottleFn } from 'react-use';
@@ -48,6 +48,14 @@ export const ColorPickerInput = forwardRef) => {
+ // Unless the user clicked inside the color picker, close it on blur
+ const isClickInPopover = document.querySelector('[data-testid="color-popover"]')?.contains(evt.relatedTarget);
+ if (!isClickInPopover) {
+ setIsOpen(false);
+ }
+ };
+
return (
setIsOpen(false)}>
@@ -66,7 +74,7 @@ export const ColorPickerInput = forwardRef
setIsOpen(true)}
- onBlur={() => setIsOpen(false)}
+ onBlur={(e) => handleBlur(e)}
ref={ref}
isClearable
/>
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/Combobox/useOptions.test.ts b/packages/grafana-ui/src/components/Combobox/useOptions.test.ts
new file mode 100644
index 00000000000..a80d6f01168
--- /dev/null
+++ b/packages/grafana-ui/src/components/Combobox/useOptions.test.ts
@@ -0,0 +1,156 @@
+import { renderHook, act, waitFor } from '@testing-library/react';
+
+import { sortByGroup, useOptions } from './useOptions';
+
+describe('useOptions', () => {
+ it('should handle a large number of synchronous options without throwing an error', () => {
+ const largeOptions = Array.from({ length: 1_000_000 }, (_, i) => ({
+ label: `Option ${i + 1}`,
+ value: `${i + 1}`,
+ }));
+ const { result } = renderHook(() => useOptions(largeOptions, false));
+
+ act(() => {
+ result.current.updateOptions('Option 999999');
+ });
+
+ expect(result.current.options).toEqual([{ label: 'Option 999999', value: '999999' }]);
+ });
+
+ it('should return filtered options for synchronous options', () => {
+ const options = [
+ { label: 'Option 1', value: '1' },
+ { label: 'Option 2', value: '2' },
+ ];
+ const { result } = renderHook(() => useOptions(options, false));
+
+ act(() => {
+ result.current.updateOptions('Option 1');
+ });
+
+ expect(result.current.options).toEqual([{ label: 'Option 1', value: '1' }]);
+ });
+
+ it('should handle asynchronous options', async () => {
+ const asyncOptions = jest.fn().mockResolvedValue([
+ { label: 'Async Option 1', value: '1' },
+ { label: 'Async Option 2', value: '2' },
+ ]);
+ const { result } = renderHook(() => useOptions(asyncOptions, false));
+
+ act(() => {
+ result.current.updateOptions('Async');
+ });
+
+ expect(result.current.asyncLoading).toBe(true);
+
+ await waitFor(() => expect(result.current.asyncLoading).toBe(false));
+
+ expect(result.current.options).toEqual([
+ { label: 'Async Option 1', value: '1' },
+ { label: 'Async Option 2', value: '2' },
+ ]);
+ });
+
+ it('should add a custom value if enabled', () => {
+ const options = [
+ { label: 'Apple', value: 'apple' },
+ { label: 'Carrot', value: 'carrot' },
+ ];
+ const { result } = renderHook(() => useOptions(options, true));
+
+ act(() => {
+ result.current.updateOptions('car');
+ });
+
+ expect(result.current.options).toEqual([
+ { label: 'car', value: 'car', description: 'Use custom value' },
+ { label: 'Carrot', value: 'carrot' },
+ ]);
+ });
+
+ it('should not add a custom value if it already exists', () => {
+ const options = [
+ { label: 'Apple', value: 'apple' },
+ { label: 'Carrot', value: 'carrot' },
+ ];
+ const { result } = renderHook(() => useOptions(options, true));
+
+ act(() => {
+ result.current.updateOptions('carrot');
+ });
+
+ expect(result.current.options).toEqual([{ label: 'Carrot', value: 'carrot' }]);
+ });
+
+ it('should handle errors in asynchronous options', async () => {
+ jest.spyOn(console, 'error').mockImplementation();
+
+ const asyncOptions = jest.fn().mockRejectedValue(new Error('Async error'));
+ const { result } = renderHook(() => useOptions(asyncOptions, false));
+
+ act(() => {
+ result.current.updateOptions('Async');
+ });
+
+ expect(result.current.asyncLoading).toBe(true);
+
+ await waitFor(() => expect(result.current.asyncLoading).toBe(false));
+
+ expect(result.current.asyncLoading).toBe(false);
+ expect(result.current.asyncError).toBe(true);
+ });
+});
+
+describe('sortByGroup', () => {
+ it('should return original array when no groups exist', () => {
+ const options = [
+ { label: 'Apple', value: 'apple' },
+ { label: 'Banana', value: 'banana' },
+ { label: 'Carrot', value: 'carrot' },
+ ];
+
+ const { options: sortedOptions, groupStartIndices } = sortByGroup(options);
+
+ expect(sortedOptions).toBe(options); // Check reference equality
+ expect(groupStartIndices.size).toBe(0);
+ });
+
+ it('should return original array when only one group exists', () => {
+ const options = [
+ { label: 'Apple', value: 'apple', group: 'fruits' },
+ { label: 'Banana', value: 'banana', group: 'fruits' },
+ { label: 'Tomato', value: 'tomato', group: 'fruits' },
+ ];
+
+ const { options: sortedOptions, groupStartIndices } = sortByGroup(options);
+
+ expect(sortedOptions).toEqual(options);
+ expect(groupStartIndices.size).toBe(1);
+ expect(groupStartIndices.get('fruits')).toBe(0);
+ });
+
+ it('should group options and track group start indices', () => {
+ const options = [
+ { label: 'Apple', value: 'apple', group: 'fruits' },
+ { label: 'Carrot', value: 'carrot', group: 'vegetables' },
+ { label: 'Banana', value: 'banana', group: 'fruits' },
+ { label: 'Celery', value: 'celery', group: 'vegetables' },
+ { label: 'Other', value: 'other' }, // Ungrouped
+ ];
+
+ const { options: sortedOptions, groupStartIndices } = sortByGroup(options);
+
+ expect(sortedOptions).toEqual([
+ { label: 'Apple', value: 'apple', group: 'fruits' },
+ { label: 'Banana', value: 'banana', group: 'fruits' },
+ { label: 'Carrot', value: 'carrot', group: 'vegetables' },
+ { label: 'Celery', value: 'celery', group: 'vegetables' },
+ { label: 'Other', value: 'other' },
+ ]);
+
+ expect(groupStartIndices.size).toBe(2);
+ expect(groupStartIndices.get('fruits')).toBe(0);
+ expect(groupStartIndices.get('vegetables')).toBe(2);
+ });
+});
diff --git a/packages/grafana-ui/src/components/Combobox/useOptions.ts b/packages/grafana-ui/src/components/Combobox/useOptions.ts
index 7801fe5b007..bedd6dc1b8b 100644
--- a/packages/grafana-ui/src/components/Combobox/useOptions.ts
+++ b/packages/grafana-ui/src/components/Combobox/useOptions.ts
@@ -1,3 +1,6 @@
+/* Spreading unbound arrays can be very slow or even crash the browser if used for arguments */
+/* eslint no-restricted-syntax: ["error", "SpreadElement"] */
+
import { debounce } from 'lodash';
import { useState, useCallback, useMemo } from 'react';
@@ -14,7 +17,7 @@ type AsyncOptions =
const asyncNoop = () => Promise.resolve([]);
/**
- * Abstracts away sync/async options for MultiCombobox (and later Combobox).
+ * Abstracts away sync/async options for combobox components.
* It also filters options based on the user's input.
*
* Returns:
@@ -66,14 +69,11 @@ export function useOptions(rawOptions: AsyncOptions opt.value === userTypedSearch);
if (!customValueExists) {
- currentOptions = [
- {
- label: userTypedSearch,
- value: userTypedSearch as T,
- description: t('combobox.custom-value.description', 'Use custom value'),
- },
- ...currentOptions,
- ];
+ currentOptions.unshift({
+ label: userTypedSearch,
+ value: userTypedSearch as T,
+ description: t('combobox.custom-value.description', 'Use custom value'),
+ });
}
}
return currentOptions;
@@ -115,41 +115,61 @@ export function useOptions(rawOptions: AsyncOptions(options: Array>) {
+/**
+ * Sorts options by group and returns the sorted options and the starting index of each group
+ */
+export function sortByGroup(options: Array>) {
+ // Group options by their group
const groupedOptions = new Map>>();
+ const groupStartIndices = new Map();
+
for (const option of options) {
- const groupExists = groupedOptions.has(option.group);
- if (groupExists) {
- groupedOptions.get(option.group)?.push(option);
+ const group = option.group;
+ const existing = groupedOptions.get(group);
+ if (existing) {
+ existing.push(option);
} else {
- groupedOptions.set(option.group, [option]);
+ groupedOptions.set(group, [option]);
}
}
- // Create a map to track the starting index of each group
- const groupStartIndices = new Map();
+ // If we only have one group (either the undefined group, or a single group), return the original array
+ if (groupedOptions.size <= 1) {
+ if (options[0]?.group) {
+ groupStartIndices.set(options[0]?.group, 0);
+ }
+
+ return {
+ options,
+ groupStartIndices,
+ };
+ }
+
+ // 'Preallocate' result array with same size as input - very minor optimization
+ const result: Array> = new Array(options.length);
+
let currentIndex = 0;
- // Reorganize options to have groups first, then undefined group
- const reorganizeOptions = [];
+ // Fill result array with grouped options
for (const [group, groupOptions] of groupedOptions) {
- if (!group) {
- continue;
+ if (group) {
+ groupStartIndices.set(group, currentIndex);
+ for (const option of groupOptions) {
+ result[currentIndex++] = option;
+ }
}
-
- groupStartIndices.set(group, currentIndex);
- reorganizeOptions.push(...groupOptions);
- currentIndex += groupOptions.length;
}
- const undefinedGroupOptions = groupedOptions.get(undefined);
- if (undefinedGroupOptions) {
- groupStartIndices.set('undefined', currentIndex);
- reorganizeOptions.push(...undefinedGroupOptions);
+ // Add ungrouped options at the end
+ const ungrouped = groupedOptions.get(undefined);
+ if (ungrouped) {
+ for (const option of ungrouped) {
+ result[currentIndex++] = option;
+ }
}
return {
- options: reorganizeOptions,
+ options: result,
groupStartIndices,
};
}
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 310ae7fe7b4..7b534d88b09 100644
--- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx
+++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx
@@ -143,6 +143,7 @@ export function PanelChrome({
onFocus,
onMouseMove,
onMouseEnter,
+ onDragStart,
showMenuAlways = false,
}: PanelChromeProps) {
const theme = useTheme2();
@@ -201,11 +202,13 @@ export function PanelChrome({
const onPointerUp = (evt: React.PointerEvent) => {
evt.stopPropagation();
- const distance = Math.sqrt(
- Math.pow(pointerDownPos.current.screenX - evt.screenX, 2) +
- Math.pow(pointerDownPos.current.screenY - evt.screenY, 2)
+ const distance = Math.hypot(
+ pointerDownPos.current.screenX - evt.screenX,
+ pointerDownPos.current.screenY - evt.screenY
);
+ pointerDownPos.current = { screenX: 0, screenY: 0 };
+
// If we are dragging some distance or clicking on elements that should cancel dragging (panel menu, etc)
if (
distance > 10 ||
@@ -219,7 +222,10 @@ export function PanelChrome({
const onPointerDown = (evt: React.PointerEvent) => {
evt.stopPropagation();
+
pointerDownPos.current = { screenX: evt.screenX, screenY: evt.screenY };
+
+ onDragStart?.(evt);
};
const headerContent = (
diff --git a/packages/grafana-ui/src/components/ScrollContainer/ScrollIndicators.tsx b/packages/grafana-ui/src/components/ScrollContainer/ScrollIndicators.tsx
index 8d7819e7efe..2263b607db1 100644
--- a/packages/grafana-ui/src/components/ScrollContainer/ScrollIndicators.tsx
+++ b/packages/grafana-ui/src/components/ScrollContainer/ScrollIndicators.tsx
@@ -54,6 +54,9 @@ export const ScrollIndicators = ({ children }: React.PropsWithChildren<{}>) => {
};
const getStyles = (theme: GrafanaTheme2) => {
+ // we specifically don't want a theme color here
+ // this gradient is more like a shadow
+ const scrollGradientColor = `rgba(0, 0, 0, ${theme.isDark ? 0.25 : 0.08})`;
return {
scrollContent: css({
display: 'flex',
@@ -62,7 +65,7 @@ const getStyles = (theme: GrafanaTheme2) => {
position: 'relative',
}),
scrollIndicator: css({
- height: theme.spacing(6),
+ height: `max(5%, ${theme.spacing(3)})`,
left: 0,
opacity: 0,
pointerEvents: 'none',
@@ -74,11 +77,11 @@ const getStyles = (theme: GrafanaTheme2) => {
zIndex: 1,
}),
scrollTopIndicator: css({
- background: `linear-gradient(0deg, transparent, ${theme.colors.background.canvas})`,
+ background: `linear-gradient(0deg, transparent, ${scrollGradientColor})`,
top: 0,
}),
scrollBottomIndicator: css({
- background: `linear-gradient(180deg, transparent, ${theme.colors.background.canvas})`,
+ background: `linear-gradient(180deg, transparent, ${scrollGradientColor})`,
bottom: 0,
}),
scrollIndicatorVisible: css({
diff --git a/packages/grafana-ui/src/themes/GlobalStyles/dashboardGrid.ts b/packages/grafana-ui/src/themes/GlobalStyles/dashboardGrid.ts
index 144dbfa3b79..aa243c17c68 100644
--- a/packages/grafana-ui/src/themes/GlobalStyles/dashboardGrid.ts
+++ b/packages/grafana-ui/src/themes/GlobalStyles/dashboardGrid.ts
@@ -19,6 +19,13 @@ export function getDashboardGridStyles(theme: GrafanaTheme2) {
},
},
+ '.dragging-active': {
+ '*': {
+ cursor: 'move',
+ userSelect: 'none',
+ },
+ },
+
[theme.breakpoints.down('md')]: {
'.react-grid-layout': {
height: 'unset !important',
@@ -83,6 +90,14 @@ export function getDashboardGridStyles(theme: GrafanaTheme2) {
},
},
+ '.dashboard-canvas-add-button': {
+ opacity: 0,
+
+ '&:hover': {
+ opacity: 1,
+ },
+ },
+
'.dashboard-visible-hidden-element': {
opacity: 0.6,
diff --git a/pkg/aggregator/go.mod b/pkg/aggregator/go.mod
index e6ca98240ed..f893303d595 100644
--- a/pkg/aggregator/go.mod
+++ b/pkg/aggregator/go.mod
@@ -4,7 +4,7 @@ go 1.23.7
require (
github.com/emicklei/go-restful/v3 v3.11.0
- github.com/grafana/grafana-plugin-sdk-go v0.272.0
+ github.com/grafana/grafana-plugin-sdk-go v0.274.1-0.20250318081012-21a7f15619b0
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20240808213237-f4d2e064f435
github.com/grafana/grafana/pkg/semconv v0.0.0-20240808213237-f4d2e064f435
github.com/mattbaird/jsonpatch v0.0.0-20240118010651-0ba75a80ca38
@@ -22,10 +22,10 @@ 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
+ github.com/apache/arrow-go/v18 v18.2.0 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/blang/semver/v4 v4.0.0 // 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
@@ -49,13 +49,13 @@ require (
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
- github.com/goccy/go-json v0.10.4 // indirect
+ github.com/goccy/go-json v0.10.5 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
- github.com/golang-jwt/jwt/v4 v4.5.1 // indirect
+ github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/cel-go v0.23.2 // indirect
- github.com/google/flatbuffers v24.3.25+incompatible // indirect
+ github.com/google/flatbuffers v25.2.10+incompatible // indirect
github.com/google/gnostic-models v0.6.8 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
@@ -77,8 +77,8 @@ require (
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6 // indirect
- github.com/klauspost/compress v1.17.11 // indirect
- github.com/klauspost/cpuid/v2 v2.2.9 // indirect
+ github.com/klauspost/compress v1.18.0 // indirect
+ github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/magefile/mage v1.15.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
@@ -97,7 +97,7 @@ require (
github.com/onsi/ginkgo/v2 v2.22.0 // indirect
github.com/onsi/gomega v1.36.1 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
- github.com/pierrec/lz4/v4 v4.1.21 // indirect
+ github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.21.0 // indirect
@@ -125,7 +125,7 @@ require (
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/contrib/propagators/jaeger v1.34.0 // indirect
- go.opentelemetry.io/contrib/samplers/jaegerremote v0.28.0 // indirect
+ go.opentelemetry.io/contrib/samplers/jaegerremote v0.29.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
@@ -140,16 +140,16 @@ require (
golang.org/x/net v0.36.0 // indirect
golang.org/x/oauth2 v0.27.0 // indirect
golang.org/x/sync v0.11.0 // indirect
- golang.org/x/sys v0.30.0 // indirect
+ golang.org/x/sys v0.31.0 // indirect
golang.org/x/term v0.29.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/time v0.9.0 // indirect
golang.org/x/tools v0.30.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/genproto v0.0.0-20250122153221-138b5a5a4fd4 // indirect
- google.golang.org/genproto/googleapis/api v0.0.0-20250204164813-702378808489 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20250207221924-e9438ea467c6 // indirect
- google.golang.org/grpc v1.70.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b // indirect
+ google.golang.org/grpc v1.71.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect
diff --git a/pkg/aggregator/go.sum b/pkg/aggregator/go.sum
index 0198e0efd67..49b2f24fbea 100644
--- a/pkg/aggregator/go.sum
+++ b/pkg/aggregator/go.sum
@@ -2,16 +2,17 @@ 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=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
-github.com/apache/arrow-go/v18 v18.0.1-0.20241212180703-82be143d7c30 h1:hXVi7QKuCQ0E8Yujfu9b0f0RnzZ72efpWvPnZgnJPrE=
-github.com/apache/arrow-go/v18 v18.0.1-0.20241212180703-82be143d7c30/go.mod h1:RNuWDIiGjq5nndL2PyQrndUy9nMLwheA3uWaAV7fe4U=
+github.com/apache/arrow-go/v18 v18.2.0 h1:QhWqpgZMKfWOniGPhbUxrHohWnooGURqL2R2Gg4SO1Q=
+github.com/apache/arrow-go/v18 v18.2.0/go.mod h1:Ic/01WSwGJWRrdAZcxjBZ5hbApNJ28K96jGYaxzzGUc=
github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE=
github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
@@ -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=
@@ -92,13 +93,13 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
-github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
-github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
+github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
-github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
-github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
+github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
+github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -112,8 +113,8 @@ github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/cel-go v0.23.2 h1:UdEe3CvQh3Nv+E/j9r1Y//WO0K0cSyD7/y0bzyLIMI4=
github.com/google/cel-go v0.23.2/go.mod h1:52Pb6QsDbC5kvgxvZhiL9QX1oZEkcUF/ZqaPx1J5Wwo=
-github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI=
-github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
+github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
+github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -135,8 +136,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-github.com/grafana/grafana-plugin-sdk-go v0.272.0 h1:TmPIG+6e3lYGzkyfUfCHuaMaaiwDbkCacTZ7V/JaSeg=
-github.com/grafana/grafana-plugin-sdk-go v0.272.0/go.mod h1:i/9KH9y/6m5hkRnG3H6aR2nOMPbJUmvo4XNrHjI15cU=
+github.com/grafana/grafana-plugin-sdk-go v0.274.1-0.20250318081012-21a7f15619b0 h1:qVdhLR+XkVdTQ2Sr7+VnRfGM8RMp8oPe25nghsSpQms=
+github.com/grafana/grafana-plugin-sdk-go v0.274.1-0.20250318081012-21a7f15619b0/go.mod h1:jV+CTjXqXYuaz8FgSG7ALOib3sgiDo/00dfsQFVTSpM=
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20240808213237-f4d2e064f435 h1:lmw60EW7JWlAEvgggktOyVkH4hF1m/+LSF/Ap0NCyi8=
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20240808213237-f4d2e064f435/go.mod h1:ORVFiW/KNRY52lNjkGwnFWCxNVfE97bJG2jr2fetq0I=
github.com/grafana/grafana/pkg/semconv v0.0.0-20240808213237-f4d2e064f435 h1:SNEeqY22DrGr5E9kGF1mKSqlOom14W9+b1u4XEGJowA=
@@ -182,10 +183,10 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
-github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
-github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
-github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
-github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
+github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -247,8 +248,8 @@ github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7y
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
-github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
-github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
+github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
+github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -362,8 +363,8 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRND
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/contrib/propagators/jaeger v1.34.0 h1:D3htJISCUU/wOVlKwisVKancWm+2U4h9xDEaiMkiyRE=
go.opentelemetry.io/contrib/propagators/jaeger v1.34.0/go.mod h1:DAX1bsj+uDm2ZuOQH/RgZRx7RQZWyzV5W2WR/0UX8JA=
-go.opentelemetry.io/contrib/samplers/jaegerremote v0.28.0 h1:Xx1N6cDr8iWy1Cz6OcY7oS0ACdt/6HDYTdu4KskuC7s=
-go.opentelemetry.io/contrib/samplers/jaegerremote v0.28.0/go.mod h1:iWS+NvC948FyfnJbVfPN9h/8+vr8CR2FPn6XsLRkvH8=
+go.opentelemetry.io/contrib/samplers/jaegerremote v0.29.0 h1:VpYbyLrB5BS3blBCJMqHRIrbU4RlPnyFovR3La+1j4Q=
+go.opentelemetry.io/contrib/samplers/jaegerremote v0.29.0/go.mod h1:XAJmM2MWhiIoTO4LCLBVeE8w009TmsYk6hq1UNdXs5A=
go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
@@ -446,8 +447,8 @@ golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
-golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
+golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -484,18 +485,18 @@ google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98
google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20250122153221-138b5a5a4fd4 h1:Pw6WnI9W/LIdRxqK7T6XGugGbHIRl5Q7q3BssH6xk4s=
google.golang.org/genproto v0.0.0-20250122153221-138b5a5a4fd4/go.mod h1:qbZzneIOXSq+KFAFut9krLfRLZiFLzZL5u2t8SV83EE=
-google.golang.org/genproto/googleapis/api v0.0.0-20250204164813-702378808489 h1:fCuMM4fowGzigT89NCIsW57Pk9k2D12MMi2ODn+Nk+o=
-google.golang.org/genproto/googleapis/api v0.0.0-20250204164813-702378808489/go.mod h1:iYONQfRdizDB8JJBybql13nArx91jcUk7zCXEsOofM4=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250207221924-e9438ea467c6 h1:2duwAxN2+k0xLNpjnHTXoMUgnv6VPSp5fiqTuwSxjmI=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250207221924-e9438ea467c6/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk=
+google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
+google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b h1:FQtJ1MxbXoIIrZHZ33M+w5+dAP9o86rgpjoKr/ZmT7k=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk=
google.golang.org/grpc v1.18.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
-google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=
-google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
+google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
+google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/pkg/api/admin_encryption.go b/pkg/api/admin_encryption.go
index e7bb65070f2..74a04357042 100644
--- a/pkg/api/admin_encryption.go
+++ b/pkg/api/admin_encryption.go
@@ -6,7 +6,6 @@ import (
"github.com/grafana/grafana/pkg/api/response"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
- skv "github.com/grafana/grafana/pkg/services/secrets/kvstore"
)
func (hs *HTTPServer) AdminRotateDataEncryptionKeys(c *contextmodel.ReqContext) response.Response {
@@ -50,51 +49,3 @@ func (hs *HTTPServer) AdminRollbackSecrets(c *contextmodel.ReqContext) response.
return response.Respond(http.StatusOK, "Secrets rolled back successfully")
}
-
-// To migrate to the plugin, it must be installed and configured
-// so as not to lose access to migrated secrets
-func (hs *HTTPServer) AdminMigrateSecretsToPlugin(c *contextmodel.ReqContext) response.Response {
- if skv.EvaluateRemoteSecretsPlugin(c.Req.Context(), hs.secretsPluginManager, hs.Cfg) != nil {
- hs.log.Warn("Received secrets plugin migration request while plugin is not available")
- return response.Respond(http.StatusBadRequest, "Secrets plugin is not available")
- }
- err := hs.secretsPluginMigrator.TriggerPluginMigration(c.Req.Context(), true)
- if err != nil {
- hs.log.Error("Failed to trigger secret migration to plugin", "error", err.Error())
- return response.Respond(http.StatusInternalServerError, "Secret migration to plugin failed")
- }
- return response.Respond(http.StatusOK, "Secret migration to plugin triggered successfully")
-}
-
-// To migrate from the plugin, it must be installed only
-// as it is possible the user disabled it and then wants to migrate
-func (hs *HTTPServer) AdminMigrateSecretsFromPlugin(c *contextmodel.ReqContext) response.Response {
- if hs.secretsPluginManager.SecretsManager(c.Req.Context()) == nil {
- hs.log.Warn("Received secrets plugin migration request while plugin is not installed")
- return response.Respond(http.StatusBadRequest, "Secrets plugin is not installed")
- }
- err := hs.secretsPluginMigrator.TriggerPluginMigration(c.Req.Context(), false)
- if err != nil {
- hs.log.Error("Failed to trigger secret migration from plugin", "error", err.Error())
- return response.Respond(http.StatusInternalServerError, "Secret migration from plugin failed")
- }
- return response.Respond(http.StatusOK, "Secret migration from plugin triggered successfully")
-}
-
-func (hs *HTTPServer) AdminDeleteAllSecretsManagerPluginSecrets(c *contextmodel.ReqContext) response.Response {
- if hs.secretsPluginManager.SecretsManager(c.Req.Context()) == nil {
- hs.log.Warn("Received secrets plugin deletion request while plugin is not installed")
- return response.Respond(http.StatusBadRequest, "Secrets plugin is not installed")
- }
- items, err := hs.secretsStore.GetAll(c.Req.Context())
- if err != nil {
- return response.Respond(http.StatusInternalServerError, "an error occurred while retrieving secrets")
- }
- for _, item := range items {
- err := hs.secretsStore.Del(c.Req.Context(), *item.OrgId, *item.Namespace, *item.Type)
- if err != nil {
- return response.Respond(http.StatusInternalServerError, fmt.Sprintf("error deleting key with org=%v namespace=%v type=%v. error=%v", *item.OrgId, *item.Namespace, *item.Type, err.Error()))
- }
- }
- return response.Respond(http.StatusOK, fmt.Sprintf("All %d Secrets Manager plugin secrets deleted", len(items)))
-}
diff --git a/pkg/api/annotations.go b/pkg/api/annotations.go
index 4720e25e91f..d0e56d1c052 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) {
@@ -642,23 +633,25 @@ func AnnotationTypeScopeResolver(annotationsRepo annotations.Repository, feature
if annotation.DashboardID == 0 {
return []string{accesscontrol.ScopeAnnotationsTypeOrganization}, nil
} else {
- dashboard, err := dashSvc.GetDashboard(ctx, &dashboards.GetDashboardQuery{ID: annotation.DashboardID, OrgID: orgID})
- if err != nil {
- return nil, err
- }
- scopes := []string{dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dashboard.UID)}
- // Append dashboard parent scopes if dashboard is in a folder or the general scope if dashboard is not in a folder
- if dashboard.FolderUID != "" {
- scopes = append(scopes, dashboards.ScopeFoldersProvider.GetResourceScopeUID(dashboard.FolderUID))
- inheritedScopes, err := dashboards.GetInheritedScopes(ctx, orgID, dashboard.FolderUID, folderSvc)
+ return identity.WithServiceIdentityFn(ctx, orgID, func(ctx context.Context) ([]string, error) {
+ dashboard, err := dashSvc.GetDashboard(ctx, &dashboards.GetDashboardQuery{ID: annotation.DashboardID, OrgID: orgID})
if err != nil {
return nil, err
}
- scopes = append(scopes, inheritedScopes...)
- } else {
- scopes = append(scopes, dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder.GeneralFolderUID))
- }
- return scopes, nil
+ scopes := []string{dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dashboard.UID)}
+ // Append dashboard parent scopes if dashboard is in a folder or the general scope if dashboard is not in a folder
+ if dashboard.FolderUID != "" {
+ scopes = append(scopes, dashboards.ScopeFoldersProvider.GetResourceScopeUID(dashboard.FolderUID))
+ inheritedScopes, err := dashboards.GetInheritedScopes(ctx, orgID, dashboard.FolderUID, folderSvc)
+ if err != nil {
+ return nil, err
+ }
+ scopes = append(scopes, inheritedScopes...)
+ } else {
+ scopes = append(scopes, dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder.GeneralFolderUID))
+ }
+ return scopes, nil
+ })
}
})
}
@@ -680,7 +673,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 +701,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..6eaf4317be6 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()
@@ -426,8 +436,8 @@ func TestService_AnnotationTypeScopeResolver(t *testing.T) {
dashSvc := &dashboards.FakeDashboardService{}
rootDash := &dashboards.Dashboard{ID: 1, OrgID: 1, UID: rootDashUID}
folderDash := &dashboards.Dashboard{ID: 2, OrgID: 1, UID: folderDashUID, FolderUID: folderUID}
- dashSvc.On("GetDashboard", context.Background(), &dashboards.GetDashboardQuery{ID: rootDash.ID, OrgID: 1}).Return(rootDash, nil)
- dashSvc.On("GetDashboard", context.Background(), &dashboards.GetDashboardQuery{ID: folderDash.ID, OrgID: 1}).Return(folderDash, nil)
+ dashSvc.On("GetDashboard", mock.Anything, &dashboards.GetDashboardQuery{ID: rootDash.ID, OrgID: 1}).Return(rootDash, nil)
+ dashSvc.On("GetDashboard", mock.Anything, &dashboards.GetDashboardQuery{ID: folderDash.ID, OrgID: 1}).Return(folderDash, nil)
rootDashboardAnnotation := annotations.Item{ID: 1, DashboardID: rootDash.ID}
folderDashboardAnnotation := annotations.Item{ID: 3, DashboardID: folderDash.ID}
@@ -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 bb4aa87dc5d..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))
@@ -573,9 +577,6 @@ func (hs *HTTPServer) registerRoutes() {
adminRoute.Post("/encryption/reencrypt-data-keys", reqGrafanaAdmin, routing.Wrap(hs.AdminReEncryptEncryptionKeys))
adminRoute.Post("/encryption/reencrypt-secrets", reqGrafanaAdmin, routing.Wrap(hs.AdminReEncryptSecrets))
adminRoute.Post("/encryption/rollback-secrets", reqGrafanaAdmin, routing.Wrap(hs.AdminRollbackSecrets))
- adminRoute.Post("/encryption/migrate-secrets/to-plugin", reqGrafanaAdmin, routing.Wrap(hs.AdminMigrateSecretsToPlugin))
- adminRoute.Post("/encryption/migrate-secrets/from-plugin", reqGrafanaAdmin, routing.Wrap(hs.AdminMigrateSecretsFromPlugin))
- adminRoute.Post("/encryption/delete-secretsmanagerplugin-secrets", reqGrafanaAdmin, routing.Wrap(hs.AdminDeleteAllSecretsManagerPluginSecrets))
adminRoute.Post("/provisioning/dashboards/reload", authorize(ac.EvalPermission(ActionProvisioningReload, ScopeProvisionersDashboards)), routing.Wrap(hs.AdminProvisioningReloadDashboards))
adminRoute.Post("/provisioning/plugins/reload", authorize(ac.EvalPermission(ActionProvisioningReload, ScopeProvisionersPlugins)), routing.Wrap(hs.AdminProvisioningReloadPlugins))
diff --git a/pkg/api/apierrors/folder.go b/pkg/api/apierrors/folder.go
index b29d5000fad..19cf5323e56 100644
--- a/pkg/api/apierrors/folder.go
+++ b/pkg/api/apierrors/folder.go
@@ -44,9 +44,13 @@ func ToFolderErrorResponse(err error) response.Response {
return response.JSON(http.StatusPreconditionFailed, util.DynMap{"status": "version-mismatch", "message": dashboards.ErrFolderVersionMismatch.Error()})
}
- // folder errors are wrapped in an error util, so this is the only way of comparing errors
- if err.Error() == folder.ErrMaximumDepthReached.Error() {
- return response.JSON(http.StatusBadRequest, util.DynMap{"messageId": "folder.maximum-depth-reached", "message": "Maximum nested folder depth reached"})
+ if errors.Is(err, folder.ErrMaximumDepthReached) {
+ return response.JSON(http.StatusBadRequest, util.DynMap{"messageId": "folder.maximum-depth-reached", "message": folder.ErrMaximumDepthReached.Error()})
+ }
+
+ var statusErr *k8sErrors.StatusError
+ if errors.As(err, &statusErr) {
+ return response.Error(int(statusErr.ErrStatus.Code), statusErr.ErrStatus.Message, err)
}
return response.ErrOrFallback(http.StatusInternalServerError, "Folder API error", err)
diff --git a/pkg/api/apierrors/folder_test.go b/pkg/api/apierrors/folder_test.go
index e3d70bded31..09cd5aba16b 100644
--- a/pkg/api/apierrors/folder_test.go
+++ b/pkg/api/apierrors/folder_test.go
@@ -10,6 +10,8 @@ import (
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/util"
"github.com/stretchr/testify/require"
+ k8sErrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestToFolderErrorResponse(t *testing.T) {
@@ -66,13 +68,28 @@ func TestToFolderErrorResponse(t *testing.T) {
{
name: "folder max depth reached",
input: folder.ErrMaximumDepthReached,
- want: response.JSON(http.StatusBadRequest, util.DynMap{"messageId": "folder.maximum-depth-reached", "message": "Maximum nested folder depth reached"}),
+ want: response.JSON(http.StatusBadRequest, util.DynMap{"messageId": "folder.maximum-depth-reached", "message": folder.ErrMaximumDepthReached.Error()}),
},
{
name: "fallback error",
input: errors.New("some error"),
want: response.ErrOrFallback(http.StatusInternalServerError, "Folder API error", errors.New("some error")),
},
+ {
+ name: "kubernetes status error",
+ input: &k8sErrors.StatusError{
+ ErrStatus: metav1.Status{
+ Code: 412,
+ Message: "the folder has been changed by someone else",
+ },
+ },
+ want: response.Error(412, "the folder has been changed by someone else", &k8sErrors.StatusError{
+ ErrStatus: metav1.Status{
+ Code: 412,
+ Message: "the folder has been changed by someone else",
+ },
+ }),
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go
index c1c61753676..4406b5d8891 100644
--- a/pkg/api/dashboard.go
+++ b/pkg/api/dashboard.go
@@ -8,6 +8,7 @@ import (
"net/http"
"os"
"path/filepath"
+ "reflect"
"strconv"
"strings"
@@ -27,7 +28,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 +141,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 +370,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 +409,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 +481,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 +814,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 +925,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 +984,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,21 +1109,19 @@ 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 {
return response.Error(http.StatusNotFound, "Dashboard version not found", nil)
}
+ // do not allow restores if the json data is identical
+ // this is needed for the k8s flow, as the generation id will be used on the
+ // version table, and the generation id only increments when the actual spec is changed
+ if compareDashboardData(version.Data.MustMap(), dash.Data.MustMap()) {
+ return response.Error(http.StatusBadRequest, "Current dashboard is identical to the specified version", nil)
+ }
+
var userID int64
if id, err := identity.UserIdentifier(c.SignedInUser.GetID()); err == nil {
userID = id
@@ -1195,6 +1143,18 @@ func (hs *HTTPServer) RestoreDashboardVersion(c *contextmodel.ReqContext) respon
return hs.postDashboard(c, saveCmd)
}
+func compareDashboardData(versionData, dashData map[string]any) bool {
+ // these can be different but the actual data is the same
+ delete(versionData, "version")
+ delete(dashData, "version")
+ delete(versionData, "id")
+ delete(dashData, "id")
+ delete(versionData, "uid")
+ delete(dashData, "uid")
+
+ return reflect.DeepEqual(versionData, dashData)
+}
+
// swagger:route GET /dashboards/tags dashboards getDashboardTags
//
// Get all dashboards tags of an organisation.
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..c32c3d1835c 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) {
@@ -584,15 +593,12 @@ func TestDashboardAPIEndpoint(t *testing.T) {
{
DashboardID: 2,
Version: 1,
- Data: fakeDash.Data,
+ Data: simplejson.NewFromAny(map[string]any{
+ "title": "Dash1",
+ }),
},
}
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) {
@@ -603,6 +609,72 @@ func TestDashboardAPIEndpoint(t *testing.T) {
}, mockSQLStore)
})
+ t.Run("Should not be able to restore to the same data", func(t *testing.T) {
+ fakeDash := dashboards.NewDashboard("Child dash")
+ fakeDash.ID = 2
+ fakeDash.HasACL = false
+
+ dashboardService := dashboards.NewFakeDashboardService(t)
+ dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(fakeDash, nil)
+
+ cmd := dtos.RestoreDashboardVersionCommand{
+ Version: 1,
+ }
+ fakeDashboardVersionService := dashvertest.NewDashboardVersionServiceFake()
+ fakeDashboardVersionService.ExpectedDashboardVersions = []*dashver.DashboardVersionDTO{
+ {
+ DashboardID: 2,
+ Version: 1,
+ Data: fakeDash.Data,
+ },
+ }
+ mockSQLStore := dbtest.NewFakeDB()
+
+ restoreDashboardVersionScenario(t, "When calling POST on", "/api/dashboards/id/1/restore",
+ "/api/dashboards/id/:dashboardId/restore", dashboardService, fakeDashboardVersionService, cmd, func(sc *scenarioContext) {
+ sc.dashboardVersionService = fakeDashboardVersionService
+
+ callRestoreDashboardVersion(sc)
+ assert.Equal(t, http.StatusBadRequest, sc.resp.Code)
+ }, mockSQLStore)
+ })
+
+ t.Run("Given dashboard in general folder being restored should restore to general folder", func(t *testing.T) {
+ fakeDash := dashboards.NewDashboard("Child dash")
+ fakeDash.ID = 2
+ fakeDash.HasACL = false
+
+ dashboardService := dashboards.NewFakeDashboardService(t)
+ dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(fakeDash, nil)
+ dashboardService.On("SaveDashboard", mock.Anything, mock.AnythingOfType("*dashboards.SaveDashboardDTO"), mock.AnythingOfType("bool")).Run(func(args mock.Arguments) {
+ cmd := args.Get(1).(*dashboards.SaveDashboardDTO)
+ cmd.Dashboard = &dashboards.Dashboard{
+ ID: 2, UID: "uid", Title: "Dash", Slug: "dash", Version: 1,
+ }
+ }).Return(nil, nil)
+
+ fakeDashboardVersionService := dashvertest.NewDashboardVersionServiceFake()
+ fakeDashboardVersionService.ExpectedDashboardVersions = []*dashver.DashboardVersionDTO{
+ {
+ DashboardID: 2,
+ Version: 1,
+ Data: simplejson.NewFromAny(map[string]any{
+ "title": "Dash1",
+ }),
+ },
+ }
+
+ cmd := dtos.RestoreDashboardVersionCommand{
+ Version: 1,
+ }
+ mockSQLStore := dbtest.NewFakeDB()
+ restoreDashboardVersionScenario(t, "When calling POST on", "/api/dashboards/id/1/restore",
+ "/api/dashboards/id/:dashboardId/restore", dashboardService, fakeDashboardVersionService, cmd, func(sc *scenarioContext) {
+ callRestoreDashboardVersion(sc)
+ assert.Equal(t, http.StatusOK, sc.resp.Code)
+ }, mockSQLStore)
+ })
+
t.Run("Given dashboard in general folder being restored should restore to general folder", func(t *testing.T) {
fakeDash := dashboards.NewDashboard("Child dash")
fakeDash.ID = 2
@@ -622,7 +694,9 @@ func TestDashboardAPIEndpoint(t *testing.T) {
{
DashboardID: 2,
Version: 1,
- Data: fakeDash.Data,
+ Data: simplejson.NewFromAny(map[string]any{
+ "title": "Dash1",
+ }),
},
}
@@ -648,7 +722,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 +751,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 +783,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 +790,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 +825,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 +837,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 +864,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 +889,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 +947,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 +957,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 +1045,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/datasources.go b/pkg/api/datasources.go
index 0eef6a7ce16..8906ab381a4 100644
--- a/pkg/api/datasources.go
+++ b/pkg/api/datasources.go
@@ -26,7 +26,6 @@ import (
)
var datasourcesLogger = log.New("datasources")
-var secretsPluginError datasources.ErrDatasourceSecretsPluginUserFriendly
// swagger:route GET /datasources datasources getDataSources
//
@@ -178,9 +177,6 @@ func (hs *HTTPServer) DeleteDataSourceById(c *contextmodel.ReqContext) response.
err = hs.DataSourcesService.DeleteDataSource(c.Req.Context(), cmd)
if err != nil {
- if errors.As(err, &secretsPluginError) {
- return response.Error(http.StatusInternalServerError, "Failed to delete datasource: "+err.Error(), err)
- }
return response.Error(http.StatusInternalServerError, "Failed to delete datasource", err)
}
@@ -257,9 +253,6 @@ func (hs *HTTPServer) DeleteDataSourceByUID(c *contextmodel.ReqContext) response
err = hs.DataSourcesService.DeleteDataSource(c.Req.Context(), cmd)
if err != nil {
- if errors.As(err, &secretsPluginError) {
- return response.Error(http.StatusInternalServerError, "Failed to delete datasource: "+err.Error(), err)
- }
return response.Error(http.StatusInternalServerError, "Failed to delete datasource", err)
}
@@ -307,9 +300,6 @@ func (hs *HTTPServer) DeleteDataSourceByName(c *contextmodel.ReqContext) respons
cmd := &datasources.DeleteDataSourceCommand{Name: name, OrgID: c.SignedInUser.GetOrgID()}
err = hs.DataSourcesService.DeleteDataSource(c.Req.Context(), cmd)
if err != nil {
- if errors.As(err, &secretsPluginError) {
- return response.Error(http.StatusInternalServerError, "Failed to delete datasource: "+err.Error(), err)
- }
return response.Error(http.StatusInternalServerError, "Failed to delete datasource", err)
}
@@ -405,10 +395,6 @@ func (hs *HTTPServer) AddDataSource(c *contextmodel.ReqContext) response.Respons
return response.Error(http.StatusConflict, err.Error(), err)
}
- if errors.As(err, &secretsPluginError) {
- return response.Error(http.StatusInternalServerError, "Failed to add datasource: "+err.Error(), err)
- }
-
return response.ErrOrFallback(http.StatusInternalServerError, "Failed to add datasource", err)
}
@@ -532,10 +518,6 @@ func (hs *HTTPServer) updateDataSourceByID(c *contextmodel.ReqContext, ds *datas
return response.Error(http.StatusConflict, "Datasource has already been updated by someone else. Please reload and try again", err)
}
- if errors.As(err, &secretsPluginError) {
- return response.Error(http.StatusInternalServerError, "Failed to update datasource: "+err.Error(), err)
- }
-
return response.ErrOrFallback(http.StatusInternalServerError, "Failed to update datasource", err)
}
diff --git a/pkg/api/ds_query.go b/pkg/api/ds_query.go
index 2ff08a727b7..eee746165b2 100644
--- a/pkg/api/ds_query.go
+++ b/pkg/api/ds_query.go
@@ -29,11 +29,6 @@ func (hs *HTTPServer) handleQueryMetricsError(err error) *response.NormalRespons
return response.Error(http.StatusNotFound, "Data source not found", err)
}
- var secretsPlugin datasources.ErrDatasourceSecretsPluginUserFriendly
- if errors.As(err, &secretsPlugin) {
- return response.Error(http.StatusInternalServerError, fmt.Sprint("Secrets Plugin error: ", err.Error()), err)
- }
-
return response.ErrOrFallback(http.StatusInternalServerError, "Query data error", err)
}
diff --git a/pkg/api/ds_query_test.go b/pkg/api/ds_query_test.go
index 60d6d3a2a47..f0f20e7a7ad 100644
--- a/pkg/api/ds_query_test.go
+++ b/pkg/api/ds_query_test.go
@@ -1,7 +1,6 @@
package api
import (
- "bytes"
"context"
"encoding/json"
"errors"
@@ -25,7 +24,6 @@ import (
fakeDatasources "github.com/grafana/grafana/pkg/services/datasources/fakes"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig"
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext"
- "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings"
pluginSettings "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings/service"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
"github.com/grafana/grafana/pkg/services/query"
@@ -40,11 +38,6 @@ type fakeDataSourceRequestValidator struct {
err error
}
-type secretsErrorResponseBody struct {
- Error string `json:"error"`
- Message string `json:"message"`
-}
-
func (rv *fakeDataSourceRequestValidator) Validate(ds *datasources.DataSource, req *http.Request) error {
return rv.err
}
@@ -93,64 +86,6 @@ func TestAPIEndpoint_Metrics_QueryMetricsV2(t *testing.T) {
})
}
-func TestAPIEndpoint_Metrics_PluginDecryptionFailure(t *testing.T) {
- cfg := setting.NewCfg()
- ds := &fakeDatasources.FakeDataSourceService{SimulatePluginFailure: true}
- db := &dbtest.FakeDB{ExpectedError: pluginsettings.ErrPluginSettingNotFound}
- pcp := plugincontext.ProvideService(cfg, localcache.ProvideService(),
- &pluginstore.FakePluginStore{
- PluginList: []pluginstore.Plugin{
- {
- JSONData: plugins.JSONData{
- ID: "grafana",
- },
- },
- },
- },
- &fakeDatasources.FakeCacheService{},
- ds, pluginSettings.ProvideService(db, secretstest.NewFakeSecretsService()), pluginconfig.NewFakePluginRequestConfigProvider(),
- )
- qds := query.ProvideService(
- cfg,
- nil,
- nil,
- &fakeDataSourceRequestValidator{},
- &fakePluginClient{
- QueryDataHandlerFunc: func(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
- resp := backend.Responses{
- "A": backend.DataResponse{
- Error: errors.New("query failed"),
- },
- }
- return &backend.QueryDataResponse{Responses: resp}, nil
- },
- },
- pcp,
- )
- httpServer := SetupAPITestServer(t, func(hs *HTTPServer) {
- hs.queryDataService = qds
- hs.QuotaService = quotatest.New(false, nil)
- hs.pluginContextProvider = pcp
- })
-
- t.Run("Status code is 500 and a secrets plugin error is returned if there is a problem getting secrets from the remote plugin", func(t *testing.T) {
- req := httpServer.NewPostRequest("/api/ds/query", strings.NewReader(reqValid))
- webtest.RequestWithSignedInUser(req, &user.SignedInUser{UserID: 1, OrgID: 1, Permissions: map[int64]map[string][]string{1: {datasources.ActionQuery: []string{datasources.ScopeAll}}}})
- resp, err := httpServer.SendJSON(req)
- require.NoError(t, err)
- require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
- buf := new(bytes.Buffer)
- _, err = buf.ReadFrom(resp.Body)
- require.NoError(t, err)
- require.NoError(t, resp.Body.Close())
- var resObj secretsErrorResponseBody
- err = json.Unmarshal(buf.Bytes(), &resObj)
- require.NoError(t, err)
- require.Equal(t, "", resObj.Error)
- require.Contains(t, resObj.Message, "Secrets Plugin error:")
- })
-}
-
var reqValid = `{
"from": "",
"to": "",
diff --git a/pkg/api/dtos/frontend_settings.go b/pkg/api/dtos/frontend_settings.go
index 997c52eb8b0..19306e3d4aa 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"`
@@ -228,7 +229,6 @@ type FrontendSettingsDTO struct {
RendererDefaultImageWidth int `json:"rendererDefaultImageWidth"`
RendererDefaultImageHeight int `json:"rendererDefaultImageHeight"`
RendererDefaultImageScale float64 `json:"rendererDefaultImageScale"`
- SecretsManagerPluginEnabled bool `json:"secretsManagerPluginEnabled"`
Http2Enabled bool `json:"http2Enabled"`
GrafanaJavascriptAgent setting.GrafanaJavascriptAgent `json:"grafanaJavascriptAgent"`
PluginCatalogURL string `json:"pluginCatalogURL"`
@@ -269,9 +269,8 @@ type FrontendSettingsDTO struct {
PublicDashboardAccessToken string `json:"publicDashboardAccessToken"`
PublicDashboardsEnabled bool `json:"publicDashboardsEnabled"`
- CloudMigrationIsTarget bool `json:"cloudMigrationIsTarget"`
- CloudMigrationFeedbackURL string `json:"cloudMigrationFeedbackURL"`
- CloudMigrationPollIntervalMs int `json:"cloudMigrationPollIntervalMs"`
+ CloudMigrationIsTarget bool `json:"cloudMigrationIsTarget"`
+ CloudMigrationPollIntervalMs int `json:"cloudMigrationPollIntervalMs"`
DateFormats setting.DateFormats `json:"dateFormats,omitempty"`
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 83a1eeb2838..070e977af90 100644
--- a/pkg/api/frontendsettings.go
+++ b/pkg/api/frontendsettings.go
@@ -23,7 +23,6 @@ import (
"github.com/grafana/grafana/pkg/services/licensing"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
- "github.com/grafana/grafana/pkg/services/secrets/kvstore"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tsdb/grafanads"
"github.com/grafana/grafana/pkg/util"
@@ -174,7 +173,6 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
}
hasAccess := accesscontrol.HasAccess(hs.AccessControl, c)
- secretsManagerPluginEnabled := kvstore.EvaluateRemoteSecretsPlugin(c.Req.Context(), hs.secretsPluginManager, hs.Cfg) == nil
trustedTypesDefaultPolicyEnabled := (hs.Cfg.CSPEnabled && strings.Contains(hs.Cfg.CSPTemplate, "require-trusted-types-for")) || (hs.Cfg.CSPReportOnlyEnabled && strings.Contains(hs.Cfg.CSPReportOnlyTemplate, "require-trusted-types-for"))
isCloudMigrationTarget := hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagOnPremToCloudMigrations) && hs.Cfg.CloudMigration.IsTarget
featureToggles := hs.Features.GetEnabled(c.Req.Context())
@@ -196,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,
@@ -240,7 +239,6 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
PublicDashboardAccessToken: c.PublicDashboardAccessToken,
PublicDashboardsEnabled: hs.Cfg.PublicDashboardsEnabled,
CloudMigrationIsTarget: isCloudMigrationTarget,
- CloudMigrationFeedbackURL: hs.Cfg.CloudMigration.FeedbackURL,
CloudMigrationPollIntervalMs: int(hs.Cfg.CloudMigration.FrontendPollInterval.Milliseconds()),
SharedWithMeFolderUID: folder.SharedWithMeFolderUID,
RootFolderUID: accesscontrol.GeneralFolderUID,
@@ -280,7 +278,6 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
RendererDefaultImageWidth: hs.Cfg.RendererDefaultImageWidth,
RendererDefaultImageHeight: hs.Cfg.RendererDefaultImageHeight,
RendererDefaultImageScale: hs.Cfg.RendererDefaultImageScale,
- SecretsManagerPluginEnabled: secretsManagerPluginEnabled,
Http2Enabled: hs.Cfg.Protocol == setting.HTTP2Scheme,
GrafanaJavascriptAgent: hs.Cfg.GrafanaJavascriptAgent,
PluginCatalogURL: hs.Cfg.PluginCatalogURL,
diff --git a/pkg/api/health.go b/pkg/api/health.go
index db80f9cdaec..b564dd6ada2 100644
--- a/pkg/api/health.go
+++ b/pkg/api/health.go
@@ -15,7 +15,7 @@ func (hs *HTTPServer) databaseHealthy(ctx context.Context) bool {
}
err := hs.SQLStore.WithDbSession(ctx, func(session *db.Session) error {
- _, err := session.Exec("SELECT 1")
+ _, err := session.Query("SELECT 1")
return err
})
healthy := err == nil
diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go
index 21f6f2dcd56..de1c4dc810c 100644
--- a/pkg/api/http_server.go
+++ b/pkg/api/http_server.go
@@ -169,10 +169,9 @@ type HTTPServer struct {
Listener net.Listener
EncryptionService encryption.Internal
SecretsService secrets.Service
- secretsPluginManager plugins.SecretsPluginManager
secretsStore secretsKV.SecretsKVStore
secretsMigrator secrets.Migrator
- secretsPluginMigrator spm.SecretMigrationProvider
+ secretMigrationProvider spm.SecretMigrationProvider
DataSourcesService datasources.DataSourceService
cleanUpService *cleanup.CleanUpService
tracer tracing.Tracer
@@ -264,8 +263,8 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
dashboardPermissionsService accesscontrol.DashboardPermissionsService, dashboardVersionService dashver.Service,
starService star.Service, csrfService csrf.Service, managedPlugins managedplugins.Manager,
playlistService playlist.Service, apiKeyService apikey.Service, kvStore kvstore.KVStore,
- secretsMigrator secrets.Migrator, secretsPluginManager plugins.SecretsPluginManager, secretsService secrets.Service,
- secretsPluginMigrator spm.SecretMigrationProvider, secretsStore secretsKV.SecretsKVStore,
+ secretsMigrator secrets.Migrator, secretsService secrets.Service,
+ secretMigrationProvider spm.SecretMigrationProvider, secretsStore secretsKV.SecretsKVStore,
publicDashboardsApi *publicdashboardsApi.Api, userService user.Service, tempUserService tempUser.Service,
loginAttemptService loginAttempt.Service, orgService org.Service, orgDeletionService org.DeletionService, teamService team.Service,
accesscontrolService accesscontrol.Service, navTreeService navtree.Service,
@@ -329,9 +328,8 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
SocialService: socialService,
EncryptionService: encryptionService,
SecretsService: secretsService,
- secretsPluginManager: secretsPluginManager,
secretsMigrator: secretsMigrator,
- secretsPluginMigrator: secretsPluginMigrator,
+ secretMigrationProvider: secretMigrationProvider,
secretsStore: secretsStore,
DataSourcesService: dataSourcesService,
searchUsersService: searchUsersService,
diff --git a/pkg/apimachinery/errutil/errors.go b/pkg/apimachinery/errutil/errors.go
index e8bc0e4b0f2..2bb0fb79c73 100644
--- a/pkg/apimachinery/errutil/errors.go
+++ b/pkg/apimachinery/errutil/errors.go
@@ -71,6 +71,17 @@ func UnprocessableEntity(msgID string, opts ...BaseOpt) Base {
return NewBase(StatusUnprocessableEntity, msgID, opts...)
}
+// UnsupportedMediaType initializes a new [Base] error with reason StatusUnsupportedMediaType
+// that is used to construct [Error]. The msgID is passed to the caller
+// to serve as the base for user facing error messages.
+//
+// msgID should be structured as component.errorBrief, for example
+//
+// plugin.unsupportedMediaType
+func UnsupportedMediaType(msgID string, opts ...BaseOpt) Base {
+ return NewBase(StatusUnsupportedMediaType, msgID, opts...)
+}
+
// Conflict initializes a new [Base] error with reason StatusConflict
// that is used to construct [Error]. The msgID is passed to the caller
// to serve as the base for user facing error messages.
diff --git a/pkg/apimachinery/errutil/status.go b/pkg/apimachinery/errutil/status.go
index 6379dcdf447..edfede773ba 100644
--- a/pkg/apimachinery/errutil/status.go
+++ b/pkg/apimachinery/errutil/status.go
@@ -29,6 +29,10 @@ const (
// contained instructions.
// HTTP status code 422.
StatusUnprocessableEntity CoreStatus = "Unprocessable Entity"
+ // StatusUnsupportedMediaType means that the server does not support
+ // the request payload's media type.
+ // HTTP status code 415.
+ StatusUnsupportedMediaType CoreStatus = CoreStatus(metav1.StatusReasonUnsupportedMediaType)
// StatusConflict means that the server cannot fulfill the request
// there is a conflict in the current state of a resource
// HTTP status code 409.
@@ -107,6 +111,8 @@ func (s CoreStatus) HTTPStatus() int {
return http.StatusGatewayTimeout
case StatusUnprocessableEntity:
return http.StatusUnprocessableEntity
+ case StatusUnsupportedMediaType:
+ return http.StatusUnsupportedMediaType
case StatusConflict:
return http.StatusConflict
case StatusTooManyRequests:
@@ -137,6 +143,8 @@ func (s CoreStatus) LogLevel() LogLevel {
return LevelInfo
case StatusTimeout:
return LevelInfo
+ case StatusUnsupportedMediaType:
+ return LevelInfo
case StatusUnprocessableEntity:
return LevelInfo
case StatusConflict:
diff --git a/pkg/apimachinery/go.mod b/pkg/apimachinery/go.mod
index e3222591c87..72e9166d58e 100644
--- a/pkg/apimachinery/go.mod
+++ b/pkg/apimachinery/go.mod
@@ -40,10 +40,10 @@ require (
golang.org/x/crypto v0.35.0 // indirect
golang.org/x/net v0.36.0 // indirect
golang.org/x/sync v0.11.0 // indirect
- golang.org/x/sys v0.30.0 // indirect
+ golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.22.0 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20250207221924-e9438ea467c6 // indirect
- google.golang.org/grpc v1.70.0 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b // indirect
+ google.golang.org/grpc v1.71.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
diff --git a/pkg/apimachinery/go.sum b/pkg/apimachinery/go.sum
index 5b69199f357..482565aefa8 100644
--- a/pkg/apimachinery/go.sum
+++ b/pkg/apimachinery/go.sum
@@ -122,8 +122,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
-golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
+golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -147,10 +147,10 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250207221924-e9438ea467c6 h1:2duwAxN2+k0xLNpjnHTXoMUgnv6VPSp5fiqTuwSxjmI=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250207221924-e9438ea467c6/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk=
-google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=
-google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b h1:FQtJ1MxbXoIIrZHZ33M+w5+dAP9o86rgpjoKr/ZmT7k=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk=
+google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
+google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/pkg/apimachinery/identity/context.go b/pkg/apimachinery/identity/context.go
index 8699b3a3ad9..68b63762ce6 100644
--- a/pkg/apimachinery/identity/context.go
+++ b/pkg/apimachinery/identity/context.go
@@ -62,7 +62,7 @@ func WithServiceIdentity(ctx context.Context, orgID int64) (context.Context, Req
return WithRequester(ctx, r), r
}
-func WithProvisioningIdentitiy(ctx context.Context, namespace string) (context.Context, Requester, error) {
+func WithProvisioningIdentity(ctx context.Context, namespace string) (context.Context, Requester, error) {
ns, err := types.ParseNamespace(namespace)
if err != nil {
return nil, nil, err
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..2e5571c9f25 100644
--- a/pkg/apis/provisioning/v0alpha1/jobs.go
+++ b/pkg/apis/provisioning/v0alpha1/jobs.go
@@ -22,6 +22,26 @@ type JobList struct {
Items []Job `json:"items,omitempty"`
}
+// HistoricJob is a history entry of Job. It is used to store Jobs that have been processed.
+//
+// The repository name and type are stored as labels.
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+type HistoricJob struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ Spec JobSpec `json:"spec,omitempty"`
+ Status JobStatus `json:"status,omitempty"`
+}
+
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+type HistoricJobList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitempty"`
+
+ Items []HistoricJob `json:"items,omitempty"`
+}
+
// +enum
type JobAction string
@@ -104,16 +124,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/register.go b/pkg/apis/provisioning/v0alpha1/register.go
index 9e5eec0b6cc..788c2bd176c 100644
--- a/pkg/apis/provisioning/v0alpha1/register.go
+++ b/pkg/apis/provisioning/v0alpha1/register.go
@@ -68,7 +68,35 @@ var JobResourceInfo = utils.NewResourceInfo(GROUP, VERSION,
Reader: func(obj any) ([]interface{}, error) {
m, ok := obj.(*Job)
if !ok {
- return nil, errors.New("expected Repository")
+ return nil, errors.New("expected Job")
+ }
+
+ return []interface{}{
+ m.Name, // may our may not be nice to read
+ m.CreationTimestamp.UTC().Format(time.RFC3339),
+ m.Spec.Action,
+ m.Status.State,
+ m.Status.Message,
+ }, nil
+ },
+ })
+
+var HistoricJobResourceInfo = utils.NewResourceInfo(GROUP, VERSION,
+ "historicjobs", "historicjob", "HistoricJob",
+ func() runtime.Object { return &HistoricJob{} }, // newObj
+ func() runtime.Object { return &HistoricJobList{} }, // newList
+ utils.TableColumns{ // Returned by `kubectl get`. Doesn't affect disk storage.
+ Definition: []metav1.TableColumnDefinition{
+ {Name: "Name", Type: "string", Format: "name"},
+ {Name: "Created At", Type: "date"},
+ {Name: "Action", Type: "string"},
+ {Name: "State", Type: "string"},
+ {Name: "Message", Type: "string"},
+ },
+ Reader: func(obj any) ([]interface{}, error) {
+ m, ok := obj.(*HistoricJob)
+ if !ok {
+ return nil, errors.New("expected HistoricJob")
}
return []interface{}{
@@ -117,6 +145,8 @@ func AddKnownTypes(gv schema.GroupVersion, scheme *runtime.Scheme) error {
&ResourceStats{},
&Job{},
&JobList{},
+ &HistoricJob{},
+ &HistoricJobList{},
)
return nil
}
diff --git a/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go
index 6ab9dfc8a11..2c22ee95346 100644
--- a/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go
+++ b/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go
@@ -132,6 +132,67 @@ func (in *HealthStatus) DeepCopy() *HealthStatus {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *HistoricJob) DeepCopyInto(out *HistoricJob) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ in.Spec.DeepCopyInto(&out.Spec)
+ in.Status.DeepCopyInto(&out.Status)
+ return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HistoricJob.
+func (in *HistoricJob) DeepCopy() *HistoricJob {
+ if in == nil {
+ return nil
+ }
+ out := new(HistoricJob)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *HistoricJob) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *HistoricJobList) DeepCopyInto(out *HistoricJobList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]HistoricJob, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+ return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HistoricJobList.
+func (in *HistoricJobList) DeepCopy() *HistoricJobList {
+ if in == nil {
+ return nil
+ }
+ out := new(HistoricJobList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *HistoricJobList) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *HistoryItem) DeepCopyInto(out *HistoryItem) {
*out = *in
diff --git a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go
index 571f26b2b27..0ae93afb00d 100644
--- a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go
+++ b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go
@@ -20,6 +20,8 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.FileList": schema_pkg_apis_provisioning_v0alpha1_FileList(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.GitHubRepositoryConfig": schema_pkg_apis_provisioning_v0alpha1_GitHubRepositoryConfig(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.HealthStatus": schema_pkg_apis_provisioning_v0alpha1_HealthStatus(ref),
+ "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.HistoricJob": schema_pkg_apis_provisioning_v0alpha1_HistoricJob(ref),
+ "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.HistoricJobList": schema_pkg_apis_provisioning_v0alpha1_HistoricJobList(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.HistoryItem": schema_pkg_apis_provisioning_v0alpha1_HistoryItem(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.HistoryList": schema_pkg_apis_provisioning_v0alpha1_HistoryList(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.Job": schema_pkg_apis_provisioning_v0alpha1_Job(ref),
@@ -108,7 +110,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"},
@@ -336,6 +338,100 @@ func schema_pkg_apis_provisioning_v0alpha1_HealthStatus(ref common.ReferenceCall
}
}
+func schema_pkg_apis_provisioning_v0alpha1_HistoricJob(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Description: "HistoricJob is a history entry of Job. It is used to store Jobs that have been processed.\n\nThe repository name and type are stored as labels.",
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "kind": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "apiVersion": {
+ SchemaProps: spec.SchemaProps{
+ Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "metadata": {
+ SchemaProps: spec.SchemaProps{
+ Default: map[string]interface{}{},
+ Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"),
+ },
+ },
+ "spec": {
+ SchemaProps: spec.SchemaProps{
+ Default: map[string]interface{}{},
+ Ref: ref("github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.JobSpec"),
+ },
+ },
+ "status": {
+ SchemaProps: spec.SchemaProps{
+ Default: map[string]interface{}{},
+ Ref: ref("github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.JobStatus"),
+ },
+ },
+ },
+ },
+ },
+ Dependencies: []string{
+ "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.JobSpec", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.JobStatus", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
+ }
+}
+
+func schema_pkg_apis_provisioning_v0alpha1_HistoricJobList(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "kind": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "apiVersion": {
+ SchemaProps: spec.SchemaProps{
+ Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "metadata": {
+ SchemaProps: spec.SchemaProps{
+ Default: map[string]interface{}{},
+ Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"),
+ },
+ },
+ "items": {
+ SchemaProps: spec.SchemaProps{
+ Type: []string{"array"},
+ Items: &spec.SchemaOrArray{
+ Schema: &spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Default: map[string]interface{}{},
+ Ref: ref("github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.HistoricJob"),
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ Dependencies: []string{
+ "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.HistoricJob", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
+ }
+}
+
func schema_pkg_apis_provisioning_v0alpha1_HistoryItem(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
@@ -818,13 +914,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/go.mod b/pkg/apis/secret/go.mod
index 4c61171ac6c..86b6e29cf97 100644
--- a/pkg/apis/secret/go.mod
+++ b/pkg/apis/secret/go.mod
@@ -5,7 +5,7 @@ go 1.23.7
require (
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250314071911-14e2784e6979
github.com/stretchr/testify v1.10.0
- google.golang.org/grpc v1.70.0
+ google.golang.org/grpc v1.71.0
google.golang.org/protobuf v1.36.5
gopkg.in/yaml.v3 v3.0.1
k8s.io/apimachinery v0.32.1
@@ -30,7 +30,7 @@ require (
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
- github.com/golang-jwt/jwt/v4 v4.5.1 // indirect
+ github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/gnostic-models v0.6.8 // indirect
@@ -45,7 +45,7 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
- github.com/klauspost/compress v1.17.11 // indirect
+ github.com/klauspost/compress v1.18.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
@@ -79,14 +79,14 @@ require (
go.uber.org/zap v1.27.0 // indirect
golang.org/x/net v0.36.0 // indirect
golang.org/x/oauth2 v0.27.0 // indirect
- golang.org/x/sys v0.30.0 // indirect
+ golang.org/x/sys v0.31.0 // indirect
golang.org/x/term v0.29.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/time v0.9.0 // indirect
golang.org/x/tools v0.30.0 // indirect
google.golang.org/genproto v0.0.0-20250122153221-138b5a5a4fd4 // indirect
- google.golang.org/genproto/googleapis/api v0.0.0-20250204164813-702378808489 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20250207221924-e9438ea467c6 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
k8s.io/api v0.32.1 // indirect
diff --git a/pkg/apis/secret/go.sum b/pkg/apis/secret/go.sum
index 0f270b903fb..540c90bd580 100644
--- a/pkg/apis/secret/go.sum
+++ b/pkg/apis/secret/go.sum
@@ -53,8 +53,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZ
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
-github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
-github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
+github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
+github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -99,8 +99,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
-github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -259,8 +259,8 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
-golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
+golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -292,18 +292,18 @@ google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98
google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20250122153221-138b5a5a4fd4 h1:Pw6WnI9W/LIdRxqK7T6XGugGbHIRl5Q7q3BssH6xk4s=
google.golang.org/genproto v0.0.0-20250122153221-138b5a5a4fd4/go.mod h1:qbZzneIOXSq+KFAFut9krLfRLZiFLzZL5u2t8SV83EE=
-google.golang.org/genproto/googleapis/api v0.0.0-20250204164813-702378808489 h1:fCuMM4fowGzigT89NCIsW57Pk9k2D12MMi2ODn+Nk+o=
-google.golang.org/genproto/googleapis/api v0.0.0-20250204164813-702378808489/go.mod h1:iYONQfRdizDB8JJBybql13nArx91jcUk7zCXEsOofM4=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250207221924-e9438ea467c6 h1:2duwAxN2+k0xLNpjnHTXoMUgnv6VPSp5fiqTuwSxjmI=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250207221924-e9438ea467c6/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk=
+google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
+google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b h1:FQtJ1MxbXoIIrZHZ33M+w5+dAP9o86rgpjoKr/ZmT7k=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk=
google.golang.org/grpc v1.18.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
-google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=
-google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
+google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
+google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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/go.mod b/pkg/apiserver/go.mod
index 39a9e18756d..32fc5be8d16 100644
--- a/pkg/apiserver/go.mod
+++ b/pkg/apiserver/go.mod
@@ -1,6 +1,6 @@
module github.com/grafana/grafana/pkg/apiserver
-go 1.23.7
+go 1.24.1
require (
github.com/google/go-cmp v0.7.0
@@ -37,7 +37,7 @@ require (
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
- github.com/golang-jwt/jwt/v4 v4.5.1 // indirect
+ github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/gnostic-models v0.6.8 // indirect
@@ -51,7 +51,7 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
- github.com/klauspost/compress v1.17.11 // indirect
+ github.com/klauspost/compress v1.18.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
@@ -83,15 +83,15 @@ require (
go.uber.org/zap v1.27.0 // indirect
golang.org/x/net v0.36.0 // indirect
golang.org/x/oauth2 v0.27.0 // indirect
- golang.org/x/sys v0.30.0 // indirect
+ golang.org/x/sys v0.31.0 // indirect
golang.org/x/term v0.29.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/time v0.9.0 // indirect
golang.org/x/tools v0.30.0 // indirect
google.golang.org/genproto v0.0.0-20250122153221-138b5a5a4fd4 // indirect
- google.golang.org/genproto/googleapis/api v0.0.0-20250204164813-702378808489 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20250207221924-e9438ea467c6 // indirect
- google.golang.org/grpc v1.70.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b // indirect
+ google.golang.org/grpc v1.71.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
diff --git a/pkg/apiserver/go.sum b/pkg/apiserver/go.sum
index 1d54379d869..075fb395455 100644
--- a/pkg/apiserver/go.sum
+++ b/pkg/apiserver/go.sum
@@ -53,8 +53,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZ
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
-github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
-github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
+github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
+github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -103,8 +103,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
-github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -267,8 +267,8 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
-golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
+golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -300,18 +300,18 @@ google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98
google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20250122153221-138b5a5a4fd4 h1:Pw6WnI9W/LIdRxqK7T6XGugGbHIRl5Q7q3BssH6xk4s=
google.golang.org/genproto v0.0.0-20250122153221-138b5a5a4fd4/go.mod h1:qbZzneIOXSq+KFAFut9krLfRLZiFLzZL5u2t8SV83EE=
-google.golang.org/genproto/googleapis/api v0.0.0-20250204164813-702378808489 h1:fCuMM4fowGzigT89NCIsW57Pk9k2D12MMi2ODn+Nk+o=
-google.golang.org/genproto/googleapis/api v0.0.0-20250204164813-702378808489/go.mod h1:iYONQfRdizDB8JJBybql13nArx91jcUk7zCXEsOofM4=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250207221924-e9438ea467c6 h1:2duwAxN2+k0xLNpjnHTXoMUgnv6VPSp5fiqTuwSxjmI=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250207221924-e9438ea467c6/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk=
+google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
+google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b h1:FQtJ1MxbXoIIrZHZ33M+w5+dAP9o86rgpjoKr/ZmT7k=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk=
google.golang.org/grpc v1.18.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
-google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=
-google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
+google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
+google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/pkg/apiserver/readonly/store.go b/pkg/apiserver/readonly/store.go
new file mode 100644
index 00000000000..140b0f7c3c4
--- /dev/null
+++ b/pkg/apiserver/readonly/store.go
@@ -0,0 +1,160 @@
+package readonly
+
+import (
+ "context"
+ "errors"
+ "net/http"
+
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/watch"
+ "k8s.io/apiserver/pkg/registry/rest"
+)
+
+var (
+ // ErrOperationUnsupported is returned when a read operation is not supported by the underlying storage.
+ // If you wish to check for this, call IsOperationUnsupported instead.
+ //
+ // Some methods may even include the name of the object that caused the error.
+ ErrOperationUnsupported = &apierrors.StatusError{
+ ErrStatus: metav1.Status{
+ Message: "Operation not supported",
+ Code: http.StatusMethodNotAllowed,
+ Status: metav1.StatusFailure,
+ Reason: metav1.StatusReasonMethodNotAllowed,
+ Details: &metav1.StatusDetails{
+ Causes: []metav1.StatusCause{
+ {
+ Type: "OperationNotImplementedByInner",
+ Message: "Operation not supported by the wrapped storage",
+ },
+ },
+ },
+ },
+ }
+
+ _ rest.Storage = readOnly{}
+ _ rest.Scoper = readOnly{}
+ _ rest.SingularNameProvider = readOnly{}
+ _ rest.StorageWithReadiness = readOnly{}
+ _ rest.Getter = readOnly{}
+ _ rest.Lister = readOnlyLister{}
+ _ rest.TableConvertor = readOnlyLister{} // due to rest.Lister
+ _ rest.Watcher = readOnly{}
+)
+
+// ReadOnly wraps a storage interface and makes all methods read-only.
+//
+// One write operation is passed through: Destroy. This allows you to not have to have some complex clean up setup.
+type readOnly struct {
+ inner rest.Storage
+}
+
+// ReadOnlyLister is like ReadOnly, but also implements rest.Lister.
+type readOnlyLister struct {
+ readOnly
+ inner rest.Lister
+}
+
+func Wrap(store rest.Storage) rest.Storage {
+ ro := readOnly{inner: store}
+ if l, ok := store.(rest.Lister); ok {
+ return readOnlyLister{readOnly: ro, inner: l}
+ }
+ return ro
+}
+
+func (ro readOnly) New() runtime.Object {
+ return ro.inner.New()
+}
+
+func (ro readOnlyLister) NewList() runtime.Object {
+ return ro.inner.NewList()
+}
+
+func (ro readOnly) Destroy() {
+ ro.inner.Destroy()
+}
+
+func (ro readOnly) NamespaceScoped() bool {
+ if s, ok := ro.inner.(rest.Scoper); ok {
+ return s.NamespaceScoped()
+ }
+ return false
+}
+
+func (ro readOnly) GetSingularName() string {
+ if s, ok := ro.inner.(rest.SingularNameProvider); ok {
+ return s.GetSingularName()
+ }
+ return ""
+}
+
+// ReadinessCheck does not check for readiness if the inner storage does not implement it.
+// In that case, the inner storage is always considered ready.
+func (ro readOnly) ReadinessCheck() error {
+ if r, ok := ro.inner.(rest.StorageWithReadiness); ok {
+ return r.ReadinessCheck()
+ }
+ return nil
+}
+
+func (ro readOnly) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
+ if r, ok := ro.inner.(rest.Getter); ok {
+ return r.Get(ctx, name, options)
+ }
+
+ return nil, ro.unsupported(name)
+}
+
+func (ro readOnlyLister) List(ctx context.Context, options *metainternalversion.ListOptions) (runtime.Object, error) {
+ return ro.inner.List(ctx, options)
+}
+
+func (ro readOnlyLister) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
+ return ro.inner.ConvertToTable(ctx, object, tableOptions)
+}
+
+func (ro readOnly) Watch(ctx context.Context, options *metainternalversion.ListOptions) (watch.Interface, error) {
+ if r, ok := ro.inner.(rest.Watcher); ok {
+ return r.Watch(ctx, options)
+ }
+
+ return nil, ro.unsupported("")
+}
+
+func (ro readOnly) unsupported(name string) error {
+ obj := ro.New()
+ gvk := obj.GetObjectKind().GroupVersionKind()
+ err := *ErrOperationUnsupported
+ err.ErrStatus.Details.Kind = gvk.Kind
+ err.ErrStatus.Details.Group = gvk.Group
+ err.ErrStatus.Details.Name = name
+
+ return &err
+}
+
+// IsOperationUnsupported checks the error for its reasoning. If it originated from the read-only wrapper, it will return true.
+// When this returns true, it indicates the underlying storage does not support the operation despite us "announcing" it (by implementing an interface).
+func IsOperationUnsupported(err error) bool {
+ var statusErr *apierrors.StatusError
+ if !errors.As(err, &statusErr) {
+ return false
+ }
+
+ if statusErr.Status().Reason != metav1.StatusReasonMethodNotAllowed ||
+ statusErr.Status().Details == nil ||
+ len(statusErr.Status().Details.Causes) == 0 {
+ return false
+ }
+
+ for _, detail := range statusErr.Status().Details.Causes {
+ if detail.Type == "OperationNotImplementedByInner" {
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/pkg/apiserver/registry/generic/storage.go b/pkg/apiserver/registry/generic/storage.go
index 67b0efecb8d..184ad344db0 100644
--- a/pkg/apiserver/registry/generic/storage.go
+++ b/pkg/apiserver/registry/generic/storage.go
@@ -9,7 +9,9 @@ import (
)
func NewRegistryStore(scheme *runtime.Scheme, resourceInfo utils.ResourceInfo, optsGetter generic.RESTOptionsGetter) (*registry.Store, error) {
- strategy := NewStrategy(scheme, resourceInfo.GroupVersion())
+ gv := resourceInfo.GroupVersion()
+ gv.Version = runtime.APIVersionInternal
+ strategy := NewStrategy(scheme, gv)
store := ®istry.Store{
NewFunc: resourceInfo.NewFunc,
NewListFunc: resourceInfo.NewListFunc,
@@ -30,6 +32,18 @@ func NewRegistryStore(scheme *runtime.Scheme, resourceInfo utils.ResourceInfo, o
return store, nil
}
+func NewCompleteRegistryStore(scheme *runtime.Scheme, resourceInfo utils.ResourceInfo, optsGetter generic.RESTOptionsGetter) (*registry.Store, error) {
+ registryStore, err := NewRegistryStore(scheme, resourceInfo, optsGetter)
+ if err != nil {
+ return nil, err
+ }
+ strategy := NewCompleteStrategy(scheme, resourceInfo.GroupVersion())
+ registryStore.CreateStrategy = strategy
+ registryStore.UpdateStrategy = strategy
+ registryStore.DeleteStrategy = strategy
+ return registryStore, nil
+}
+
func NewRegistryStatusStore(scheme *runtime.Scheme, specStore *registry.Store) *StatusREST {
gv := specStore.New().GetObjectKind().GroupVersionKind().GroupVersion()
strategy := NewStatusStrategy(scheme, gv)
diff --git a/pkg/apiserver/registry/generic/strategy.go b/pkg/apiserver/registry/generic/strategy.go
index 6cb5dd918fd..e899b8aa90f 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,8 +12,12 @@ 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"
)
+// genericStrategy allows for writing objects with spec fields.
+// It ignores status fields, and does not allow for status updates.
type genericStrategy struct {
runtime.ObjectTyper
names.NameGenerator
@@ -81,20 +83,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 {
@@ -125,6 +113,8 @@ func (g *genericStrategy) WarningsOnUpdate(ctx context.Context, obj, old runtime
return nil
}
+// genericStatusStrategy allows for writing objects with status fields, however may not create them.
+// It ignores spec and metadata fields, and does not allow for updates outside of the status field.
type genericStatusStrategy struct {
runtime.ObjectTyper
names.NameGenerator
@@ -190,6 +180,71 @@ func (g *genericStatusStrategy) WarningsOnUpdate(ctx context.Context, obj, old r
return nil
}
+// genericCompleteStrategy allows for writing objects with spec and status fields.
+// It does not ignore any fields, and allows for updates to both spec and status fields.
+// This is the same as having separate stores for spec and status fields.
+//
+// This can be applied to both the root object and status subresource in a Kubernetes REST API.
+type genericCompleteStrategy struct {
+ runtime.ObjectTyper
+ names.NameGenerator
+
+ gv schema.GroupVersion
+}
+
+// NewCompleteStrategy creates a new genericCompleteStrategy.
+func NewCompleteStrategy(typer runtime.ObjectTyper, gv schema.GroupVersion) *genericCompleteStrategy {
+ return &genericCompleteStrategy{typer, names.SimpleNameGenerator, gv}
+}
+
+func (g *genericCompleteStrategy) NamespaceScoped() bool {
+ return true
+}
+
+func (g *genericCompleteStrategy) GetResetFields() map[fieldpath.APIVersion]*fieldpath.Set {
+ fields := map[fieldpath.APIVersion]*fieldpath.Set{
+ fieldpath.APIVersion(g.gv.String()): fieldpath.NewSet(),
+ }
+
+ return fields
+}
+
+func (g *genericCompleteStrategy) PrepareForCreate(ctx context.Context, obj runtime.Object) {
+ meta, err := utils.MetaAccessor(obj)
+ if err != nil {
+ return
+ }
+ meta.SetGeneration(1)
+}
+
+func (g *genericCompleteStrategy) PrepareForUpdate(ctx context.Context, obj, old runtime.Object) {}
+
+func (g *genericCompleteStrategy) AllowCreateOnUpdate() bool {
+ return true
+}
+
+func (g *genericCompleteStrategy) AllowUnconditionalUpdate() bool {
+ return true
+}
+
+func (g *genericCompleteStrategy) Canonicalize(obj runtime.Object) {}
+
+func (g *genericCompleteStrategy) Validate(ctx context.Context, obj runtime.Object) field.ErrorList {
+ return nil
+}
+
+func (g *genericCompleteStrategy) ValidateUpdate(ctx context.Context, obj, old runtime.Object) field.ErrorList {
+ return nil
+}
+
+func (g *genericCompleteStrategy) WarningsOnCreate(ctx context.Context, obj runtime.Object) []string {
+ return nil
+}
+
+func (g *genericCompleteStrategy) WarningsOnUpdate(ctx context.Context, obj, old runtime.Object) []string {
+ return nil
+}
+
// GetAttrs returns labels and fields of an object.
func GetAttrs(obj runtime.Object) (labels.Set, fields.Set, error) {
accessor, err := meta.Accessor(obj)
diff --git a/pkg/apiserver/registry/generic/strategy_test.go b/pkg/apiserver/registry/generic/strategy_test.go
index 969332305a2..e7423e8540c 100644
--- a/pkg/apiserver/registry/generic/strategy_test.go
+++ b/pkg/apiserver/registry/generic/strategy_test.go
@@ -1,134 +1,465 @@
package generic_test
import (
- "context"
"testing"
"github.com/grafana/grafana/pkg/apiserver/registry/generic"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/fields"
+ "k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/apis/example"
)
-func TestPrepareForUpdate(t *testing.T) {
- ctx := context.TODO()
+func TestGenericStrategy(t *testing.T) {
+ t.Parallel()
gv := schema.GroupVersion{Group: "test", Version: "v1"}
- strategy := generic.NewStrategy(runtime.NewScheme(), gv)
- oldObj := &example.Pod{
- ObjectMeta: metav1.ObjectMeta{
- Name: "test",
- Namespace: "default",
- Generation: 1,
- },
- Spec: example.PodSpec{
- NodeSelector: map[string]string{"foo": "bar"},
- },
- Status: example.PodStatus{
- Phase: example.PodPhase("Running"),
- },
- }
+ t.Run("PrepareForUpdate", func(t *testing.T) {
+ t.Parallel()
- testCases := []struct {
- name string
- newObj *example.Pod
- oldObj *example.Pod
- expectedGen int64
- expectedObj *example.Pod
- }{
- {
- name: "ignore status updates",
- newObj: &example.Pod{
- ObjectMeta: metav1.ObjectMeta{
- Name: "test",
- Namespace: "default",
- Generation: 1,
- },
- Spec: example.PodSpec{
- NodeSelector: map[string]string{"foo": "bar"},
- },
- Status: example.PodStatus{
- Phase: example.PodPhase("Stopped"),
- },
+ obj := &example.Pod{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test",
+ Namespace: "default",
+ Generation: 1,
},
- oldObj: oldObj.DeepCopy(),
- expectedGen: 2,
- expectedObj: &example.Pod{
- ObjectMeta: metav1.ObjectMeta{
- Name: "test",
- Namespace: "default",
- Generation: 1,
- },
- Spec: example.PodSpec{
- NodeSelector: map[string]string{"foo": "bar"},
- },
- Status: example.PodStatus{
- Phase: example.PodPhase("Running"),
- },
+ Spec: example.PodSpec{
+ NodeSelector: map[string]string{"foo": "bar"},
},
- },
- {
- 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"),
- },
+ 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 {
- t.Run(tc.name, func(t *testing.T) {
- strategy.PrepareForUpdate(ctx, tc.newObj, tc.oldObj)
- require.Equal(t, tc.expectedObj, tc.newObj)
+ t.Run("ignores status updates", func(t *testing.T) {
+ t.Parallel()
+ oldObj := obj.DeepCopy()
+ newObj := obj.DeepCopy()
+ newObj.Status.Phase = example.PodPhase("Stopped")
+ expectedObj := obj.DeepCopy()
+
+ strategy := generic.NewStrategy(runtime.NewScheme(), gv)
+ strategy.PrepareForUpdate(t.Context(), newObj, oldObj)
+ require.Equal(t, expectedObj, newObj)
})
- }
+
+ t.Run("does not increment generation if annotations, labels, finalizers, or owner references change", func(t *testing.T) {
+ t.Parallel()
+ oldObj := obj.DeepCopy()
+ newObj := obj.DeepCopy()
+ newObj.ObjectMeta.Annotations = map[string]string{"foo": "baz"}
+ newObj.ObjectMeta.Labels = map[string]string{"foo": "baz"}
+ newObj.ObjectMeta.Finalizers = []string{"foo"}
+ newObj.ObjectMeta.OwnerReferences = []metav1.OwnerReference{{Name: "foo"}}
+
+ strategy := generic.NewStrategy(runtime.NewScheme(), gv)
+ strategy.PrepareForUpdate(t.Context(), newObj, oldObj)
+ assert.Equal(t, map[string]string{"foo": "baz"}, newObj.ObjectMeta.Annotations)
+ assert.Equal(t, map[string]string{"foo": "baz"}, newObj.ObjectMeta.Labels)
+ assert.Equal(t, []string{"foo"}, newObj.ObjectMeta.Finalizers)
+ assert.Equal(t, []metav1.OwnerReference{{Name: "foo"}}, newObj.ObjectMeta.OwnerReferences)
+ assert.Equal(t, int64(1), newObj.Generation)
+ })
+
+ t.Run("does not increment generation if spec changes", func(t *testing.T) {
+ t.Parallel()
+ oldObj := obj.DeepCopy()
+ newObj := obj.DeepCopy()
+ newObj.Spec.NodeSelector = map[string]string{"foo": "baz"}
+ expectedObj := newObj.DeepCopy()
+
+ strategy := generic.NewStrategy(runtime.NewScheme(), gv)
+ strategy.PrepareForUpdate(t.Context(), newObj, oldObj)
+ require.Equal(t, expectedObj, newObj)
+ })
+ })
+
+ t.Run("PrepareForCreate", func(t *testing.T) {
+ t.Parallel()
+
+ obj := &example.Pod{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test",
+ Namespace: "default",
+ },
+ Spec: example.PodSpec{
+ NodeSelector: map[string]string{"foo": "bar"},
+ },
+ Status: example.PodStatus{
+ Phase: example.PodPhase("Running"),
+ },
+ }
+
+ t.Run("assigns generation=1", func(t *testing.T) {
+ t.Parallel()
+ strategy := generic.NewStrategy(runtime.NewScheme(), gv)
+ obj := obj.DeepCopy()
+
+ strategy.PrepareForCreate(t.Context(), obj)
+ require.Equal(t, int64(1), obj.Generation)
+ })
+
+ t.Run("clears status", func(t *testing.T) {
+ t.Parallel()
+ strategy := generic.NewStrategy(runtime.NewScheme(), gv)
+ obj := obj.DeepCopy()
+
+ strategy.PrepareForCreate(t.Context(), obj)
+ require.Equal(t, example.PodStatus{}, obj.Status)
+ })
+
+ t.Run("leaves spec untouched", func(t *testing.T) {
+ t.Parallel()
+ strategy := generic.NewStrategy(runtime.NewScheme(), gv)
+ obj := obj.DeepCopy()
+ originalSpec := *obj.Spec.DeepCopy()
+
+ strategy.PrepareForCreate(t.Context(), obj)
+ require.Equal(t, originalSpec, obj.Spec)
+ })
+ })
}
-func TestPrepareForCreate(t *testing.T) {
- ctx := context.TODO()
+func TestStatusStrategy(t *testing.T) {
+ t.Parallel()
gv := schema.GroupVersion{Group: "test", Version: "v1"}
- strategy := generic.NewStrategy(runtime.NewScheme(), gv)
- obj := &example.Pod{
- ObjectMeta: metav1.ObjectMeta{
- Name: "test",
- Namespace: "default",
- },
- Spec: example.PodSpec{
- NodeSelector: map[string]string{"foo": "bar"},
- },
- Status: example.PodStatus{
- Phase: example.PodPhase("Running"),
- },
- }
+ t.Run("PrepareForUpdate", func(t *testing.T) {
+ t.Parallel()
- strategy.PrepareForCreate(ctx, obj)
- require.Equal(t, int64(1), obj.Generation)
- require.Equal(t, example.PodStatus{}, obj.Status)
+ obj := &example.Pod{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test",
+ Namespace: "default",
+ Generation: 1,
+ },
+ Spec: example.PodSpec{
+ NodeSelector: map[string]string{"foo": "bar"},
+ },
+ Status: example.PodStatus{
+ Phase: example.PodPhase("Running"),
+ },
+ }
+
+ t.Run("ignores spec updates", func(t *testing.T) {
+ // The assumption here is that the status strategy should not allow for spec updates.
+ // This is drawn due to the GetResetFields function returning `metadata` and `spec`, and due to it copying old `metadata` fields to the new object (but not spec?).
+ t.Skip("assumption does not hold -- verify with app platform if this is intended")
+
+ t.Parallel()
+ oldObj := obj.DeepCopy()
+ newObj := obj.DeepCopy()
+ newObj.Spec.NodeSelector = map[string]string{"foo": "baz"}
+ expectedObj := obj.DeepCopy()
+
+ strategy := generic.NewStatusStrategy(runtime.NewScheme(), gv)
+ strategy.PrepareForUpdate(t.Context(), newObj, oldObj)
+ require.Equal(t, expectedObj, newObj)
+ })
+
+ t.Run("ignores label updates", func(t *testing.T) {
+ t.Parallel()
+ oldObj := obj.DeepCopy()
+ newObj := obj.DeepCopy()
+ newObj.ObjectMeta.Labels = map[string]string{"foo": "baz"}
+ expectedObj := obj.DeepCopy()
+
+ strategy := generic.NewStatusStrategy(runtime.NewScheme(), gv)
+ strategy.PrepareForUpdate(t.Context(), newObj, oldObj)
+ require.Equal(t, expectedObj, newObj)
+ })
+
+ t.Run("ignores annotation updates", func(t *testing.T) {
+ t.Parallel()
+ oldObj := obj.DeepCopy()
+ newObj := obj.DeepCopy()
+ newObj.ObjectMeta.Annotations = map[string]string{"foo": "baz"}
+ expectedObj := obj.DeepCopy()
+
+ strategy := generic.NewStatusStrategy(runtime.NewScheme(), gv)
+ strategy.PrepareForUpdate(t.Context(), newObj, oldObj)
+ require.Equal(t, expectedObj, newObj)
+ })
+
+ t.Run("ignores finalizer updates", func(t *testing.T) {
+ t.Parallel()
+ oldObj := obj.DeepCopy()
+ newObj := obj.DeepCopy()
+ newObj.ObjectMeta.Finalizers = []string{"foo"}
+ expectedObj := obj.DeepCopy()
+
+ strategy := generic.NewStatusStrategy(runtime.NewScheme(), gv)
+ strategy.PrepareForUpdate(t.Context(), newObj, oldObj)
+ require.Equal(t, expectedObj, newObj)
+ })
+
+ t.Run("ignores owner references updates", func(t *testing.T) {
+ t.Parallel()
+ oldObj := obj.DeepCopy()
+ newObj := obj.DeepCopy()
+ newObj.ObjectMeta.OwnerReferences = []metav1.OwnerReference{{Name: "foo"}}
+ expectedObj := obj.DeepCopy()
+
+ strategy := generic.NewStatusStrategy(runtime.NewScheme(), gv)
+ strategy.PrepareForUpdate(t.Context(), newObj, oldObj)
+ require.Equal(t, expectedObj, newObj)
+ })
+
+ t.Run("does not increment generation on status changes", func(t *testing.T) {
+ t.Parallel()
+ oldObj := obj.DeepCopy()
+ newObj := obj.DeepCopy()
+ newObj.Status.Phase = example.PodPhase("Stopped")
+
+ strategy := generic.NewStatusStrategy(runtime.NewScheme(), gv)
+ strategy.PrepareForUpdate(t.Context(), newObj, oldObj)
+ require.Equal(t, int64(1), newObj.Generation)
+ })
+ })
+}
+
+func TestCompleteStrategy(t *testing.T) {
+ t.Parallel()
+ gv := schema.GroupVersion{Group: "test", Version: "v1"}
+
+ t.Run("PrepareForUpdate", func(t *testing.T) {
+ t.Parallel()
+
+ obj := &example.Pod{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test",
+ Namespace: "default",
+ Generation: 1,
+ },
+ Spec: example.PodSpec{
+ NodeSelector: map[string]string{"foo": "bar"},
+ },
+ Status: example.PodStatus{
+ Phase: example.PodPhase("Running"),
+ },
+ }
+
+ t.Run("on status updates", func(t *testing.T) {
+ t.Parallel()
+
+ t.Run("keeps the change", func(t *testing.T) {
+ t.Parallel()
+ oldObj := obj.DeepCopy()
+ newObj := obj.DeepCopy()
+ newObj.Status.Phase = example.PodPhase("Stopped")
+
+ strategy := generic.NewCompleteStrategy(runtime.NewScheme(), gv)
+ strategy.PrepareForUpdate(t.Context(), newObj, oldObj)
+ require.Equal(t, example.PodPhase("Stopped"), newObj.Status.Phase)
+ })
+
+ t.Run("does not change generation", func(t *testing.T) {
+ t.Parallel()
+ oldObj := obj.DeepCopy()
+ newObj := obj.DeepCopy()
+ newObj.Status.Phase = example.PodPhase("Stopped")
+
+ strategy := generic.NewCompleteStrategy(runtime.NewScheme(), gv)
+ strategy.PrepareForUpdate(t.Context(), newObj, oldObj)
+ require.Equal(t, int64(1), newObj.Generation)
+ })
+ })
+
+ t.Run("on spec updates", func(t *testing.T) {
+ t.Parallel()
+
+ t.Run("keeps the change", func(t *testing.T) {
+ t.Parallel()
+ oldObj := obj.DeepCopy()
+ newObj := obj.DeepCopy()
+ newObj.Spec.NodeSelector = map[string]string{"foo": "baz"}
+
+ strategy := generic.NewCompleteStrategy(runtime.NewScheme(), gv)
+ strategy.PrepareForUpdate(t.Context(), newObj, oldObj)
+ require.Equal(t, map[string]string{"foo": "baz"}, newObj.Spec.NodeSelector)
+ })
+
+ t.Run("does not increment generation", func(t *testing.T) {
+ t.Parallel()
+ oldObj := obj.DeepCopy()
+ newObj := obj.DeepCopy()
+ newObj.Spec.NodeSelector = map[string]string{"foo": "baz"}
+
+ strategy := generic.NewCompleteStrategy(runtime.NewScheme(), gv)
+ strategy.PrepareForUpdate(t.Context(), newObj, oldObj)
+ require.Equal(t, int64(1), newObj.Generation)
+ })
+ })
+
+ t.Run("on metadata updates", func(t *testing.T) {
+ t.Parallel()
+
+ t.Run("keeps the change", func(t *testing.T) {
+ t.Parallel()
+ oldObj := obj.DeepCopy()
+ newObj := obj.DeepCopy()
+ newObj.ObjectMeta.Annotations = map[string]string{"foo": "baz"}
+ newObj.ObjectMeta.Labels = map[string]string{"foo": "baz"}
+ newObj.ObjectMeta.Finalizers = []string{"foo"}
+ newObj.ObjectMeta.OwnerReferences = []metav1.OwnerReference{{Name: "foo"}}
+
+ strategy := generic.NewCompleteStrategy(runtime.NewScheme(), gv)
+ strategy.PrepareForUpdate(t.Context(), newObj, oldObj)
+ assert.Equal(t, map[string]string{"foo": "baz"}, newObj.ObjectMeta.Annotations)
+ assert.Equal(t, map[string]string{"foo": "baz"}, newObj.ObjectMeta.Labels)
+ assert.Equal(t, []string{"foo"}, newObj.ObjectMeta.Finalizers)
+ assert.Equal(t, []metav1.OwnerReference{{Name: "foo"}}, newObj.ObjectMeta.OwnerReferences)
+ })
+
+ t.Run("does not increment generation", func(t *testing.T) {
+ t.Parallel()
+ oldObj := obj.DeepCopy()
+ newObj := obj.DeepCopy()
+ newObj.ObjectMeta.Annotations = map[string]string{"foo": "baz"}
+ newObj.ObjectMeta.Labels = map[string]string{"foo": "baz"}
+ newObj.ObjectMeta.Finalizers = []string{"foo"}
+ newObj.ObjectMeta.OwnerReferences = []metav1.OwnerReference{{Name: "foo"}}
+
+ strategy := generic.NewCompleteStrategy(runtime.NewScheme(), gv)
+ strategy.PrepareForUpdate(t.Context(), newObj, oldObj)
+ assert.Equal(t, int64(1), newObj.Generation)
+ })
+ })
+ })
+
+ t.Run("PrepareForCreate", func(t *testing.T) {
+ t.Parallel()
+
+ obj := &example.Pod{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test",
+ Namespace: "default",
+ },
+ Spec: example.PodSpec{
+ NodeSelector: map[string]string{"foo": "bar"},
+ },
+ Status: example.PodStatus{
+ Phase: example.PodPhase("Running"),
+ },
+ }
+
+ t.Run("assigns generation=1", func(t *testing.T) {
+ t.Parallel()
+
+ t.Run("when generation is not set", func(t *testing.T) {
+ t.Parallel()
+ strategy := generic.NewCompleteStrategy(runtime.NewScheme(), gv)
+ obj := obj.DeepCopy()
+
+ strategy.PrepareForCreate(t.Context(), obj)
+ require.Equal(t, int64(1), obj.Generation)
+ })
+
+ t.Run("when generation is set to a higher value", func(t *testing.T) {
+ t.Parallel()
+ strategy := generic.NewCompleteStrategy(runtime.NewScheme(), gv)
+ obj := obj.DeepCopy()
+ obj.Generation = 2
+
+ strategy.PrepareForCreate(t.Context(), obj)
+ require.Equal(t, int64(1), obj.Generation)
+ })
+ })
+ })
+}
+
+func TestGetAttrs(t *testing.T) {
+ t.Parallel()
+
+ t.Run("returns all labels", func(t *testing.T) {
+ t.Parallel()
+
+ t.Run("when labels is nil", func(t *testing.T) {
+ t.Parallel()
+
+ obj := &example.Pod{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test",
+ Namespace: "default",
+ Labels: nil,
+ },
+ }
+
+ labels, _, err := generic.GetAttrs(obj)
+ require.NoError(t, err)
+
+ require.Empty(t, labels, "expected no labels")
+ })
+
+ t.Run("when there are no labels", func(t *testing.T) {
+ t.Parallel()
+
+ obj := &example.Pod{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test",
+ Namespace: "default",
+ Labels: make(map[string]string),
+ },
+ }
+
+ labels, _, err := generic.GetAttrs(obj)
+ require.NoError(t, err)
+
+ require.Empty(t, labels, "expected no labels")
+ })
+
+ t.Run("when there is only 1 label", func(t *testing.T) {
+ t.Parallel()
+
+ obj := &example.Pod{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test",
+ Namespace: "default",
+ Labels: map[string]string{"foo": "bar"},
+ },
+ }
+
+ l, _, err := generic.GetAttrs(obj)
+ require.NoError(t, err)
+
+ require.Equal(t, labels.Set{"foo": "bar"}, l, "expected labels to match")
+ })
+
+ t.Run("when there are many labels", func(t *testing.T) {
+ t.Parallel()
+
+ obj := &example.Pod{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test",
+ Namespace: "default",
+ Labels: map[string]string{"foo": "bar", "baz": "qux", "grafana": "is-cool"},
+ },
+ }
+
+ l, _, err := generic.GetAttrs(obj)
+ require.NoError(t, err)
+
+ require.Equal(t, labels.Set{"foo": "bar", "baz": "qux", "grafana": "is-cool"}, l, "expected labels to match")
+ })
+ })
+
+ t.Run("includes only name in fields", func(t *testing.T) {
+ t.Parallel()
+
+ obj := &example.Pod{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test",
+ Namespace: "default",
+ },
+ }
+
+ _, f, err := generic.GetAttrs(obj)
+ require.NoError(t, err)
+
+ require.Equal(t, fields.Set{"metadata.name": "test"}, f, "expected fields to match")
+ })
}
diff --git a/pkg/build/go.mod b/pkg/build/go.mod
index 14352adba3e..380ae46e2c0 100644
--- a/pkg/build/go.mod
+++ b/pkg/build/go.mod
@@ -10,7 +10,7 @@ replace github.com/docker/docker => github.com/moby/moby v27.5.1+incompatible
require (
cloud.google.com/go/storage v1.50.0 // @grafana/grafana-backend-group
github.com/Masterminds/semver/v3 v3.3.0 // @grafana/grafana-developer-enablement-squad
- github.com/aws/aws-sdk-go v1.55.5 // @grafana/aws-datasources
+ github.com/aws/aws-sdk-go v1.55.6 // @grafana/aws-datasources
github.com/docker/docker v27.5.1+incompatible // @grafana/grafana-developer-enablement-squad
github.com/drone/drone-cli v1.8.0 // @grafana/grafana-developer-enablement-squad
github.com/gogo/protobuf v1.3.2 // indirect; @grafana/alerting-backend
@@ -34,7 +34,7 @@ require (
golang.org/x/text v0.22.0 // indirect; @grafana/grafana-backend-group
golang.org/x/time v0.9.0 // indirect; @grafana/grafana-backend-group
google.golang.org/api v0.220.0 // @grafana/grafana-backend-group
- google.golang.org/grpc v1.70.0 // indirect; @grafana/plugins-platform-backend
+ google.golang.org/grpc v1.71.0 // indirect; @grafana/plugins-platform-backend
google.golang.org/protobuf v1.36.5 // indirect; @grafana/plugins-platform-backend
gopkg.in/yaml.v3 v3.0.1 // @grafana/alerting-backend
)
@@ -72,10 +72,10 @@ require (
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.starlark.net v0.0.0-20230525235612-a134d8f9ddca // indirect
- golang.org/x/sys v0.30.0 // indirect
+ golang.org/x/sys v0.31.0 // indirect
google.golang.org/genproto v0.0.0-20250122153221-138b5a5a4fd4 // indirect; @grafana/grafana-backend-group
- google.golang.org/genproto/googleapis/api v0.0.0-20250204164813-702378808489 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20250207221924-e9438ea467c6 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
@@ -93,10 +93,10 @@ require (
github.com/adrg/xdg v0.4.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
- github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 // indirect
+ github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
- github.com/envoyproxy/go-control-plane/envoy v1.32.3 // indirect
+ github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
@@ -106,7 +106,7 @@ require (
github.com/sosodev/duration v1.2.0 // indirect
github.com/vektah/gqlparser/v2 v2.5.20 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
- go.opentelemetry.io/contrib/detectors/gcp v1.33.0 // indirect
+ go.opentelemetry.io/contrib/detectors/gcp v1.34.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.0.0-20240518090000-14441aefdf88 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.4.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect
diff --git a/pkg/build/go.sum b/pkg/build/go.sum
index 1d55ac2a88b..8428aa1138b 100644
--- a/pkg/build/go.sum
+++ b/pkg/build/go.sum
@@ -53,8 +53,8 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
-github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
-github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
+github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk=
+github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0=
github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE=
@@ -70,8 +70,8 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
-github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 h1:QVw89YDxXxEe+l8gU8ETbOasdwEV+avkR75ZzsVV9WI=
-github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
+github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3 h1:boJj011Hh+874zpIySeApCX4GeOjPl9qhRF3QuIZq+Q=
+github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
github.com/containerd/containerd v1.3.4/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
@@ -110,8 +110,8 @@ github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.m
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA=
-github.com/envoyproxy/go-control-plane/envoy v1.32.3 h1:hVEaommgvzTjTd4xCaFd+kEQ2iYBtGxP6luyLrx6uOk=
-github.com/envoyproxy/go-control-plane/envoy v1.32.3/go.mod h1:F6hWupPfh75TBXGKA++MCT/CZHFq5r9/uwt/kQYkZfE=
+github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
+github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
@@ -251,8 +251,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
-go.opentelemetry.io/contrib/detectors/gcp v1.33.0 h1:FVPoXEoILwgbZUu4X7YSgsESsAmGRgoYcnXkzgQPhP4=
-go.opentelemetry.io/contrib/detectors/gcp v1.33.0/go.mod h1:ZHrLmr4ikK2AwRj9QL+c9s2SOlgoSRyMpNVzUj2fZqI=
+go.opentelemetry.io/contrib/detectors/gcp v1.34.0 h1:JRxssobiPg23otYU5SbWtQC//snGVIM3Tx6QRzlQBao=
+go.opentelemetry.io/contrib/detectors/gcp v1.34.0/go.mod h1:cV4BMFcscUR/ckqLkbfQmF0PRsq8w/lMGzdbCSveBHo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
@@ -335,8 +335,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
-golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
+golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -366,17 +366,17 @@ google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20250122153221-138b5a5a4fd4 h1:Pw6WnI9W/LIdRxqK7T6XGugGbHIRl5Q7q3BssH6xk4s=
google.golang.org/genproto v0.0.0-20250122153221-138b5a5a4fd4/go.mod h1:qbZzneIOXSq+KFAFut9krLfRLZiFLzZL5u2t8SV83EE=
-google.golang.org/genproto/googleapis/api v0.0.0-20250204164813-702378808489 h1:fCuMM4fowGzigT89NCIsW57Pk9k2D12MMi2ODn+Nk+o=
-google.golang.org/genproto/googleapis/api v0.0.0-20250204164813-702378808489/go.mod h1:iYONQfRdizDB8JJBybql13nArx91jcUk7zCXEsOofM4=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250207221924-e9438ea467c6 h1:2duwAxN2+k0xLNpjnHTXoMUgnv6VPSp5fiqTuwSxjmI=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250207221924-e9438ea467c6/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk=
+google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
+google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b h1:FQtJ1MxbXoIIrZHZ33M+w5+dAP9o86rgpjoKr/ZmT7k=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
-google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=
-google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
+google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
+google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
diff --git a/pkg/expr/convert_from_full_long.go b/pkg/expr/convert_from_full_long.go
new file mode 100644
index 00000000000..c4de09cbee3
--- /dev/null
+++ b/pkg/expr/convert_from_full_long.go
@@ -0,0 +1,128 @@
+package expr
+
+import (
+ "fmt"
+
+ "github.com/grafana/grafana-plugin-sdk-go/data"
+)
+
+func ConvertFromFullLongToNumericMulti(frames data.Frames) (data.Frames, error) {
+ if len(frames) != 1 {
+ return nil, fmt.Errorf("expected exactly one frame, got %d", len(frames))
+ }
+ frame := frames[0]
+ if frame.Meta == nil || frame.Meta.Type != numericFullLongType {
+ return nil, fmt.Errorf("expected frame of type %q", numericFullLongType)
+ }
+
+ var (
+ metricField *data.Field
+ valueField *data.Field
+ displayField *data.Field
+ labelFields []*data.Field
+ )
+
+ // Identify key fields
+ for _, f := range frame.Fields {
+ switch f.Name {
+ case SQLMetricFieldName:
+ metricField = f
+ case SQLValueFieldName:
+ valueField = f
+ case SQLDisplayFieldName:
+ displayField = f
+ default:
+ if f.Type() == data.FieldTypeNullableString {
+ labelFields = append(labelFields, f)
+ }
+ }
+ }
+
+ if metricField == nil || valueField == nil {
+ return nil, fmt.Errorf("missing required fields: %q or %q", SQLMetricFieldName, SQLValueFieldName)
+ }
+
+ type seriesKey struct {
+ metric string
+ labelFP data.Fingerprint
+ displayName string
+ }
+
+ type seriesEntry struct {
+ indices []int
+ labels data.Labels
+ displayName *string
+ }
+
+ grouped := make(map[seriesKey]*seriesEntry)
+
+ for i := 0; i < frame.Rows(); i++ {
+ if valueField.NilAt(i) {
+ continue // skip null values
+ }
+
+ metric := metricField.At(i).(string)
+
+ // collect labels
+ labels := data.Labels{}
+ for _, f := range labelFields {
+ if f.NilAt(i) {
+ continue
+ }
+ val := f.At(i).(*string)
+ if val != nil {
+ labels[f.Name] = *val
+ }
+ }
+ fp := labels.Fingerprint()
+
+ // handle optional display name
+ var displayPtr *string
+ displayKey := ""
+ if displayField != nil && !displayField.NilAt(i) {
+ if raw := displayField.At(i).(*string); raw != nil {
+ displayPtr = raw
+ displayKey = *raw
+ }
+ }
+
+ key := seriesKey{
+ metric: metric,
+ labelFP: fp,
+ displayName: displayKey,
+ }
+
+ entry, ok := grouped[key]
+ if !ok {
+ entry = &seriesEntry{
+ labels: labels,
+ displayName: displayPtr,
+ }
+ grouped[key] = entry
+ }
+ entry.indices = append(entry.indices, i)
+ }
+
+ var result data.Frames
+ for key, entry := range grouped {
+ values := make([]*float64, 0, len(entry.indices))
+ for _, i := range entry.indices {
+ v, err := valueField.FloatAt(i)
+ if err != nil {
+ return nil, fmt.Errorf("failed to convert value at index %d to float: %w", i, err)
+ }
+ values = append(values, &v)
+ }
+
+ field := data.NewField(key.metric, entry.labels, values)
+ if entry.displayName != nil {
+ field.Config = &data.FieldConfig{DisplayNameFromDS: *entry.displayName}
+ }
+
+ frame := data.NewFrame("", field)
+ frame.Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
+ result = append(result, frame)
+ }
+
+ return result, nil
+}
diff --git a/pkg/expr/convert_from_full_long_test.go b/pkg/expr/convert_from_full_long_test.go
new file mode 100644
index 00000000000..2487fac661d
--- /dev/null
+++ b/pkg/expr/convert_from_full_long_test.go
@@ -0,0 +1,192 @@
+package expr
+
+import (
+ "sort"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/grafana/grafana-plugin-sdk-go/data"
+ "github.com/stretchr/testify/require"
+)
+
+func TestConvertFromFullLongToNumericMulti(t *testing.T) {
+ t.Run("SingleRowNoLabels", func(t *testing.T) {
+ input := data.NewFrame("",
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu"}),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(3.14)}),
+ )
+ input.Meta = &data.FrameMeta{Type: numericFullLongType}
+
+ out, err := ConvertFromFullLongToNumericMulti(data.Frames{input})
+ require.NoError(t, err)
+ require.Len(t, out, 1)
+
+ expected := data.NewFrame("",
+ data.NewField("cpu", nil, []*float64{fp(3.14)}),
+ )
+ expected.Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
+
+ if diff := cmp.Diff(expected, out[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Mismatch (-want +got):\n%s", diff)
+ }
+ })
+
+ t.Run("TwoRowsWithLabelsAndDisplay", func(t *testing.T) {
+ input := data.NewFrame("",
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu", "cpu"}),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(2.0)}),
+ data.NewField(SQLDisplayFieldName, nil, []*string{sp("CPU A"), sp("CPU A")}),
+ data.NewField("host", nil, []*string{sp("a"), sp("a")}),
+ )
+ input.Meta = &data.FrameMeta{Type: numericFullLongType}
+
+ out, err := ConvertFromFullLongToNumericMulti(data.Frames{input})
+ require.NoError(t, err)
+ require.Len(t, out, 1)
+
+ expected := data.NewFrame("",
+ func() *data.Field {
+ f := data.NewField("cpu", data.Labels{"host": "a"}, []*float64{fp(1.0), fp(2.0)})
+ f.Config = &data.FieldConfig{DisplayNameFromDS: "CPU A"}
+ return f
+ }(),
+ )
+ expected.Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
+
+ if diff := cmp.Diff(expected, out[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Mismatch (-want +got):\n%s", diff)
+ }
+ })
+
+ t.Run("SkipsNullValues", func(t *testing.T) {
+ input := data.NewFrame("",
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu", "cpu"}),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), nil}),
+ )
+ input.Meta = &data.FrameMeta{Type: numericFullLongType}
+
+ out, err := ConvertFromFullLongToNumericMulti(data.Frames{input})
+ require.NoError(t, err)
+ require.Len(t, out, 1)
+
+ expected := data.NewFrame("",
+ data.NewField("cpu", nil, []*float64{fp(1.0)}),
+ )
+ expected.Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
+
+ if diff := cmp.Diff(expected, out[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Mismatch (-want +got):\n%s", diff)
+ }
+ })
+}
+
+func TestConvertNumericMultiRoundTripToFullLongAndBack(t *testing.T) {
+ t.Run("TwoFieldsWithSparseLabels", func(t *testing.T) {
+ input := data.Frames{
+ data.NewFrame("",
+ data.NewField("cpu", data.Labels{"host": "a"}, []*float64{fp(1.0)}),
+ ),
+ data.NewFrame("",
+ data.NewField("cpu", data.Labels{"host": "b", "env": "prod"}, []*float64{fp(2.0)}),
+ ),
+ }
+ for _, f := range input {
+ f.Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
+ }
+
+ fullLong, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, fullLong, 1)
+
+ roundTrip, err := ConvertFromFullLongToNumericMulti(fullLong)
+ require.NoError(t, err)
+
+ expected := data.Frames{
+ data.NewFrame("",
+ data.NewField("cpu", data.Labels{"host": "a"}, []*float64{fp(1.0)}),
+ ),
+ data.NewFrame("",
+ data.NewField("cpu", data.Labels{"host": "b", "env": "prod"}, []*float64{fp(2.0)}),
+ ),
+ }
+ for _, f := range expected {
+ f.Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
+ }
+
+ sortFramesByMetricDisplayAndLabels(expected)
+ sortFramesByMetricDisplayAndLabels(roundTrip)
+
+ require.Len(t, roundTrip, len(expected))
+ for i := range expected {
+ if diff := cmp.Diff(expected[i], roundTrip[i], data.FrameTestCompareOptions()...); diff != "" {
+ t.Errorf("Mismatch on frame %d (-want +got):\n%s", i, diff)
+ }
+ }
+ })
+
+ t.Run("PreservesDisplayName", func(t *testing.T) {
+ input := data.Frames{
+ data.NewFrame("",
+ func() *data.Field {
+ f := data.NewField("cpu", data.Labels{"host": "a"}, []*float64{fp(1.0)})
+ f.Config = &data.FieldConfig{DisplayNameFromDS: "CPU A"}
+ return f
+ }(),
+ ),
+ }
+ input[0].Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
+
+ fullLong, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, fullLong, 1)
+
+ roundTrip, err := ConvertFromFullLongToNumericMulti(fullLong)
+ require.NoError(t, err)
+
+ expected := data.Frames{
+ data.NewFrame("",
+ func() *data.Field {
+ f := data.NewField("cpu", data.Labels{"host": "a"}, []*float64{fp(1.0)})
+ f.Config = &data.FieldConfig{DisplayNameFromDS: "CPU A"}
+ return f
+ }(),
+ ),
+ }
+ expected[0].Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
+
+ sortFramesByMetricDisplayAndLabels(expected)
+ sortFramesByMetricDisplayAndLabels(roundTrip)
+
+ require.Len(t, roundTrip, 1)
+ if diff := cmp.Diff(expected[0], roundTrip[0], data.FrameTestCompareOptions()...); diff != "" {
+ t.Errorf("Mismatch (-want +got):\n%s", diff)
+ }
+ })
+}
+
+func sortFramesByMetricDisplayAndLabels(frames data.Frames) {
+ sort.Slice(frames, func(i, j int) bool {
+ fi := frames[i].Fields[0]
+ fj := frames[j].Fields[0]
+
+ // 1. Metric name
+ if fi.Name != fj.Name {
+ return fi.Name < fj.Name
+ }
+
+ // 2. Display name (if set)
+ var di, dj string
+ if fi.Config != nil {
+ di = fi.Config.DisplayNameFromDS
+ }
+ if fj.Config != nil {
+ dj = fj.Config.DisplayNameFromDS
+ }
+ if di != dj {
+ return di < dj
+ }
+
+ // 3. Labels fingerprint
+ return fi.Labels.Fingerprint() < fj.Labels.Fingerprint()
+ })
+}
diff --git a/pkg/expr/convert_to_full_long.go b/pkg/expr/convert_to_full_long.go
new file mode 100644
index 00000000000..94dc2648bd6
--- /dev/null
+++ b/pkg/expr/convert_to_full_long.go
@@ -0,0 +1,400 @@
+package expr
+
+import (
+ "fmt"
+ "sort"
+ "time"
+
+ "github.com/grafana/grafana-plugin-sdk-go/data"
+)
+
+const (
+ SQLMetricFieldName = "__metric_name__"
+ SQLValueFieldName = "__value__"
+ SQLDisplayFieldName = "__display_name__"
+
+ // These are not types in the SDK or dataplane contract yet.
+ numericFullLongType = "numeric_full_long"
+ timeseriesFullLongType = "time_series_full_long"
+)
+
+func ConvertToFullLong(frames data.Frames) (data.Frames, error) {
+ if len(frames) == 0 {
+ return frames, nil
+ }
+
+ var inputType data.FrameType
+ if frames[0].Meta != nil && frames[0].Meta.Type != "" {
+ inputType = frames[0].Meta.Type
+ } else {
+ return nil, fmt.Errorf("input frame missing FrameMeta.Type")
+ }
+
+ if !supportedToLongConversion(inputType) {
+ return nil, fmt.Errorf("unsupported input dataframe type %s for full long conversion", inputType)
+ }
+
+ switch inputType {
+ case data.FrameTypeNumericMulti:
+ return convertNumericMultiToFullLong(frames)
+ case data.FrameTypeNumericWide:
+ return convertNumericWideToFullLong(frames)
+ case data.FrameTypeTimeSeriesMulti:
+ return convertTimeSeriesMultiToFullLong(frames)
+ case data.FrameTypeTimeSeriesWide:
+ return convertTimeSeriesWideToFullLong(frames)
+ default:
+ return nil, fmt.Errorf("unsupported input type %s for full long conversion", inputType)
+ }
+}
+
+func convertNumericMultiToFullLong(frames data.Frames) (data.Frames, error) {
+ wide := convertNumericMultiToNumericWide(frames)
+ return convertNumericWideToFullLong(wide)
+}
+
+func convertNumericWideToFullLong(frames data.Frames) (data.Frames, error) {
+ if len(frames) != 1 {
+ return nil, fmt.Errorf("expected exactly one frame for wide format, but got %d", len(frames))
+ }
+ inputFrame := frames[0]
+ if inputFrame.Rows() > 1 {
+ return nil, fmt.Errorf("expected no more than one row in the frame, but got %d", inputFrame.Rows())
+ }
+
+ var (
+ metricCol = make([]string, 0, len(inputFrame.Fields))
+ valueCol = make([]*float64, 0, len(inputFrame.Fields))
+ displayCol = make([]*string, 0, len(inputFrame.Fields))
+ hasDisplayCol bool
+ )
+
+ labelKeySet := map[string]struct{}{}
+ for _, field := range inputFrame.Fields {
+ if !field.Type().Numeric() {
+ continue
+ }
+ val, err := field.FloatAt(0)
+ if err != nil {
+ continue
+ }
+ v := val
+ valueCol = append(valueCol, &v)
+ metricCol = append(metricCol, field.Name)
+
+ // Display name
+ var d *string
+ if field.Config != nil && field.Config.DisplayNameFromDS != "" {
+ s := field.Config.DisplayNameFromDS
+ d = &s
+ hasDisplayCol = true
+ }
+ displayCol = append(displayCol, d)
+
+ for k := range field.Labels {
+ labelKeySet[k] = struct{}{}
+ }
+ }
+
+ labelKeys := make([]string, 0, len(labelKeySet))
+
+ labelValues := make(map[string][]*string)
+ for k := range labelKeySet {
+ labelKeys = append(labelKeys, k)
+ labelValues[k] = make([]*string, 0, len(valueCol))
+ }
+ sort.Strings(labelKeys)
+
+ for _, field := range inputFrame.Fields {
+ if !field.Type().Numeric() {
+ continue
+ }
+ for _, k := range labelKeys {
+ var val *string
+ if field.Labels != nil {
+ if v, ok := field.Labels[k]; ok {
+ val = &v
+ }
+ }
+ labelValues[k] = append(labelValues[k], val)
+ }
+ }
+
+ fields := []*data.Field{
+ data.NewField(SQLMetricFieldName, nil, metricCol),
+ data.NewField(SQLValueFieldName, nil, valueCol),
+ }
+ if hasDisplayCol {
+ fields = append(fields, data.NewField(SQLDisplayFieldName, nil, displayCol))
+ }
+ for _, k := range labelKeys {
+ fields = append(fields, data.NewField(k, nil, labelValues[k]))
+ }
+
+ out := data.NewFrame("", fields...)
+ out.Meta = &data.FrameMeta{Type: numericFullLongType}
+ return data.Frames{out}, nil
+}
+
+func convertTimeSeriesMultiToFullLong(frames data.Frames) (data.Frames, error) {
+ type row struct {
+ t time.Time
+ value *float64
+ metric string
+ display *string
+ labels data.Labels
+ }
+
+ var rows []row
+ labelKeysSet := map[string]struct{}{}
+ hasDisplayCol := false
+
+ for _, frame := range frames {
+ var timeField *data.Field
+ for _, f := range frame.Fields {
+ if f.Type() == data.FieldTypeTime {
+ timeField = f
+ break
+ }
+ }
+ if timeField == nil {
+ return nil, fmt.Errorf("missing time field")
+ }
+ for _, f := range frame.Fields {
+ if !f.Type().Numeric() {
+ continue
+ }
+ var display *string
+ if f.Config != nil && f.Config.DisplayNameFromDS != "" {
+ s := f.Config.DisplayNameFromDS
+ display = &s
+ hasDisplayCol = true
+ }
+ for i := 0; i < f.Len(); i++ {
+ t := timeField.At(i).(time.Time)
+ v, err := f.FloatAt(i)
+ if err != nil {
+ continue
+ }
+ val := v
+ rows = append(rows, row{
+ t: t,
+ value: &val,
+ metric: f.Name,
+ display: display,
+ labels: f.Labels,
+ })
+ for k := range f.Labels {
+ labelKeysSet[k] = struct{}{}
+ }
+ }
+ }
+ }
+
+ labelKeys := make([]string, 0, len(labelKeysSet))
+ for k := range labelKeysSet {
+ labelKeys = append(labelKeys, k)
+ }
+ sort.Strings(labelKeys)
+ sort.SliceStable(rows, func(i, j int) bool {
+ if rows[i].t.Equal(rows[j].t) {
+ return rows[i].metric < rows[j].metric
+ }
+ return rows[i].t.Before(rows[j].t)
+ })
+
+ times := make([]time.Time, len(rows))
+ values := make([]*float64, len(rows))
+ metrics := make([]string, len(rows))
+ var displays []*string
+ if hasDisplayCol {
+ displays = make([]*string, len(rows))
+ }
+ labels := make(map[string][]*string)
+ for _, k := range labelKeys {
+ labels[k] = make([]*string, len(rows))
+ }
+
+ for i, r := range rows {
+ times[i] = r.t
+ values[i] = r.value
+ metrics[i] = r.metric
+ if hasDisplayCol {
+ displays[i] = r.display
+ }
+ for _, k := range labelKeys {
+ if v, ok := r.labels[k]; ok {
+ labels[k][i] = &v
+ }
+ }
+ }
+
+ fields := []*data.Field{
+ data.NewField("time", nil, times),
+ data.NewField(SQLValueFieldName, nil, values),
+ data.NewField(SQLMetricFieldName, nil, metrics),
+ }
+ if hasDisplayCol {
+ fields = append(fields, data.NewField(SQLDisplayFieldName, nil, displays))
+ }
+ for _, k := range labelKeys {
+ fields = append(fields, data.NewField(k, nil, labels[k]))
+ }
+
+ out := data.NewFrame("", fields...)
+ out.Meta = &data.FrameMeta{Type: timeseriesFullLongType}
+ return data.Frames{out}, nil
+}
+
+func convertTimeSeriesWideToFullLong(frames data.Frames) (data.Frames, error) {
+ if len(frames) != 1 {
+ return nil, fmt.Errorf("expected exactly one frame for wide format, but got %d", len(frames))
+ }
+ frame := frames[0]
+
+ var timeField *data.Field
+ for _, f := range frame.Fields {
+ if f.Type() == data.FieldTypeTime {
+ timeField = f
+ break
+ }
+ }
+ if timeField == nil {
+ return nil, fmt.Errorf("time field not found in TimeSeriesWide frame")
+ }
+
+ type row struct {
+ t time.Time
+ value *float64
+ metric string
+ display *string
+ labels data.Labels
+ }
+
+ var (
+ rows []row
+ labelKeysSet = map[string]struct{}{}
+ hasDisplayCol bool
+ )
+
+ // Collect all label keys
+ for _, f := range frame.Fields {
+ if !f.Type().Numeric() {
+ continue
+ }
+ for k := range f.Labels {
+ labelKeysSet[k] = struct{}{}
+ }
+ }
+
+ labelKeys := make([]string, 0, len(labelKeysSet))
+ for k := range labelKeysSet {
+ labelKeys = append(labelKeys, k)
+ }
+ sort.Strings(labelKeys)
+
+ timeLen := timeField.Len()
+ for _, f := range frame.Fields {
+ if !f.Type().Numeric() {
+ continue
+ }
+ var display *string
+ if f.Config != nil && f.Config.DisplayNameFromDS != "" {
+ s := f.Config.DisplayNameFromDS
+ display = &s
+ hasDisplayCol = true
+ }
+ for i := 0; i < timeLen; i++ {
+ t := timeField.At(i).(time.Time)
+ v, err := f.FloatAt(i)
+ if err != nil {
+ continue
+ }
+ val := v
+ rows = append(rows, row{
+ t: t,
+ value: &val,
+ metric: f.Name,
+ display: display,
+ labels: f.Labels,
+ })
+ }
+ }
+
+ sort.SliceStable(rows, func(i, j int) bool {
+ if rows[i].t.Equal(rows[j].t) {
+ return rows[i].metric < rows[j].metric
+ }
+ return rows[i].t.Before(rows[j].t)
+ })
+
+ times := make([]time.Time, len(rows))
+ values := make([]*float64, len(rows))
+ metrics := make([]string, len(rows))
+ var displays []*string
+ if hasDisplayCol {
+ displays = make([]*string, len(rows))
+ }
+ labels := make(map[string][]*string)
+ for _, k := range labelKeys {
+ labels[k] = make([]*string, len(rows))
+ }
+
+ for i, r := range rows {
+ times[i] = r.t
+ values[i] = r.value
+ metrics[i] = r.metric
+ if hasDisplayCol {
+ displays[i] = r.display
+ }
+ for _, k := range labelKeys {
+ if v, ok := r.labels[k]; ok {
+ labels[k][i] = &v
+ }
+ }
+ }
+
+ fields := []*data.Field{
+ data.NewField("time", nil, times),
+ data.NewField(SQLValueFieldName, nil, values),
+ data.NewField(SQLMetricFieldName, nil, metrics),
+ }
+ if hasDisplayCol {
+ fields = append(fields, data.NewField(SQLDisplayFieldName, nil, displays))
+ }
+ for _, k := range labelKeys {
+ fields = append(fields, data.NewField(k, nil, labels[k]))
+ }
+
+ out := data.NewFrame("", fields...)
+ out.Meta = &data.FrameMeta{Type: timeseriesFullLongType}
+ return data.Frames{out}, nil
+}
+
+func supportedToLongConversion(inputType data.FrameType) bool {
+ switch inputType {
+ case data.FrameTypeNumericMulti, data.FrameTypeNumericWide:
+ return true
+ case data.FrameTypeTimeSeriesMulti, data.FrameTypeTimeSeriesWide:
+ return true
+ default:
+ return false
+ }
+}
+
+func convertNumericMultiToNumericWide(frames data.Frames) data.Frames {
+ if len(frames) == 0 {
+ return nil
+ }
+
+ out := data.NewFrame("")
+ for _, frame := range frames {
+ for _, field := range frame.Fields {
+ if field.Type().Numeric() {
+ out.Fields = append(out.Fields, field)
+ }
+ }
+ }
+ out.Meta = &data.FrameMeta{Type: data.FrameTypeNumericWide}
+ return data.Frames{out}
+}
diff --git a/pkg/expr/convert_to_full_long_num_test.go b/pkg/expr/convert_to_full_long_num_test.go
new file mode 100644
index 00000000000..88a157f89aa
--- /dev/null
+++ b/pkg/expr/convert_to_full_long_num_test.go
@@ -0,0 +1,507 @@
+package expr
+
+import (
+ "testing"
+ "time"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/grafana/grafana-plugin-sdk-go/data"
+ "github.com/stretchr/testify/require"
+)
+
+func TestConvertNumericWideToFullLong(t *testing.T) {
+ t.Run("SingleItemNoLabels", func(t *testing.T) {
+ input := data.Frames{
+ data.NewFrame("numeric",
+ data.NewField("cpu", nil, []float64{3.14}),
+ ),
+ }
+ input[0].Meta = &data.FrameMeta{Type: data.FrameTypeNumericWide}
+
+ expected := data.NewFrame("",
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu"}),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(3.14)}),
+ )
+ expected.Meta = &data.FrameMeta{Type: numericFullLongType}
+
+ output, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, output, 1)
+ if diff := cmp.Diff(expected, output[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Result mismatch (-want +got):%s", diff)
+ }
+ })
+
+ t.Run("MultiRowShouldError", func(t *testing.T) {
+ input := data.Frames{
+ data.NewFrame("numeric",
+ data.NewField("cpu", nil, []float64{1.0, 2.0}),
+ ),
+ }
+ input[0].Meta = &data.FrameMeta{Type: data.FrameTypeNumericWide}
+
+ _, err := ConvertToFullLong(input)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "no more than one row")
+ })
+
+ t.Run("TwoItemsWithSingleLabel", func(t *testing.T) {
+ input := data.Frames{
+ data.NewFrame("numeric",
+ data.NewField("cpu", data.Labels{"host": "a"}, []float64{1.0}),
+ data.NewField("cpu", data.Labels{"host": "b"}, []float64{2.0}),
+ ),
+ }
+ input[0].Meta = &data.FrameMeta{Type: data.FrameTypeNumericWide}
+
+ expected := data.NewFrame("",
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu", "cpu"}),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(2.0)}),
+ data.NewField("host", nil, []*string{sp("a"), sp("b")}),
+ )
+ expected.Meta = &data.FrameMeta{Type: numericFullLongType}
+
+ output, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, output, 1)
+ if diff := cmp.Diff(expected, output[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Result mismatch (-want +got):%s", diff)
+ }
+ })
+
+ t.Run("TwoItemsWithSparseLabels", func(t *testing.T) {
+ input := data.Frames{
+ data.NewFrame("numeric",
+ data.NewField("cpu", data.Labels{"host": "a"}, []float64{1.0}),
+ data.NewField("cpu", data.Labels{"host": "b", "env": "prod"}, []float64{2.0}),
+ ),
+ }
+ input[0].Meta = &data.FrameMeta{Type: data.FrameTypeNumericWide}
+
+ expected := data.NewFrame("",
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu", "cpu"}),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(2.0)}),
+ data.NewField("env", nil, []*string{nil, sp("prod")}),
+ data.NewField("host", nil, []*string{sp("a"), sp("b")}),
+ )
+ expected.Meta = &data.FrameMeta{Type: numericFullLongType}
+
+ output, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, output, 1)
+ if diff := cmp.Diff(expected, output[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Result mismatch (-want +got):%s", diff)
+ }
+ })
+
+ t.Run("TwoDifferentMetricsWithSharedLabels", func(t *testing.T) {
+ input := data.Frames{
+ data.NewFrame("numeric",
+ data.NewField("cpu", data.Labels{"host": "a"}, []float64{1.0}),
+ data.NewField("mem", data.Labels{"host": "a"}, []float64{4.0}),
+ ),
+ }
+ input[0].Meta = &data.FrameMeta{Type: data.FrameTypeNumericWide}
+
+ expected := data.NewFrame("",
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu", "mem"}),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(4.0)}),
+ data.NewField("host", nil, []*string{sp("a"), sp("a")}),
+ )
+ expected.Meta = &data.FrameMeta{Type: numericFullLongType}
+
+ output, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, output, 1)
+ if diff := cmp.Diff(expected, output[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Result mismatch (-want +got):%s", diff)
+ }
+ })
+
+ t.Run("TwoSparseMetricsAndLabels", func(t *testing.T) {
+ input := data.Frames{
+ data.NewFrame("numeric",
+ data.NewField("cpu", data.Labels{"host": "a"}, []float64{1.0}),
+ data.NewField("mem", data.Labels{"env": "prod"}, []float64{4.0}),
+ ),
+ }
+ input[0].Meta = &data.FrameMeta{Type: data.FrameTypeNumericWide}
+
+ expected := data.NewFrame("",
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu", "mem"}),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(4.0)}),
+ data.NewField("env", nil, []*string{nil, sp("prod")}),
+ data.NewField("host", nil, []*string{sp("a"), nil}),
+ )
+ expected.Meta = &data.FrameMeta{Type: numericFullLongType}
+
+ output, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, output, 1)
+ if diff := cmp.Diff(expected, output[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Result mismatch (-want +got):%s", diff)
+ }
+ })
+
+ t.Run("ExtraTimeFieldIsDropped", func(t *testing.T) {
+ // Note we may consider changing this behavior and looking into keeping
+ // remainder fields in the future.
+ input := data.Frames{
+ data.NewFrame("numeric",
+ data.NewField("timestamp", nil, []time.Time{time.Now()}), // extra time field
+ data.NewField("cpu", nil, []float64{1.23}),
+ ),
+ }
+ input[0].Meta = &data.FrameMeta{Type: data.FrameTypeNumericWide}
+
+ expected := data.NewFrame("",
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu"}),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(1.23)}),
+ )
+ expected.Meta = &data.FrameMeta{Type: numericFullLongType}
+
+ output, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, output, 1)
+ if diff := cmp.Diff(expected, output[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Result mismatch (-want +got):%s", diff)
+ }
+ })
+}
+
+func TestConvertNumericWideToFullLongWithDisplayName(t *testing.T) {
+ t.Run("SingleFieldWithDisplayName", func(t *testing.T) {
+ input := data.Frames{
+ data.NewFrame("numeric",
+ func() *data.Field {
+ f := data.NewField("cpu", nil, []float64{3.14})
+ f.Config = &data.FieldConfig{DisplayNameFromDS: "CPU Display"}
+ return f
+ }(),
+ ),
+ }
+ input[0].Meta = &data.FrameMeta{Type: data.FrameTypeNumericWide}
+
+ expected := data.NewFrame("",
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu"}),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(3.14)}),
+ data.NewField(SQLDisplayFieldName, nil, []*string{sp("CPU Display")}),
+ )
+ expected.Meta = &data.FrameMeta{Type: numericFullLongType}
+
+ output, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, output, 1)
+ if diff := cmp.Diff(expected, output[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Result mismatch (-want +got):%s", diff)
+ }
+ })
+
+ t.Run("MixedDisplayNames", func(t *testing.T) {
+ input := data.Frames{
+ data.NewFrame("numeric",
+ func() *data.Field {
+ f := data.NewField("cpu", data.Labels{"host": "a"}, []float64{1.0})
+ f.Config = &data.FieldConfig{DisplayNameFromDS: "CPU A"}
+ return f
+ }(),
+ data.NewField("cpu", data.Labels{"host": "b"}, []float64{2.0}),
+ ),
+ }
+ input[0].Meta = &data.FrameMeta{Type: data.FrameTypeNumericWide}
+
+ expected := data.NewFrame("",
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu", "cpu"}),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(2.0)}),
+ data.NewField(SQLDisplayFieldName, nil, []*string{sp("CPU A"), nil}),
+ data.NewField("host", nil, []*string{sp("a"), sp("b")}),
+ )
+ expected.Meta = &data.FrameMeta{Type: numericFullLongType}
+
+ output, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, output, 1)
+ if diff := cmp.Diff(expected, output[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Result mismatch (-want +got):%s", diff)
+ }
+ })
+}
+
+func TestConvertNumericMultiToFullLong(t *testing.T) {
+ t.Run("SingleItemNoLabels", func(t *testing.T) {
+ input := data.Frames{
+ data.NewFrame("",
+ data.NewField("cpu", nil, []float64{3.14}),
+ ),
+ }
+ input[0].Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
+
+ expected := data.NewFrame("",
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu"}),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(3.14)}),
+ )
+ expected.Meta = &data.FrameMeta{Type: numericFullLongType}
+
+ output, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, output, 1)
+ if diff := cmp.Diff(expected, output[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Result mismatch (-want +got):%s", diff)
+ }
+ })
+
+ t.Run("TwoItemsWithSingleLabel", func(t *testing.T) {
+ input := data.Frames{
+ data.NewFrame("",
+ data.NewField("cpu", data.Labels{"host": "a"}, []float64{1.0}),
+ ),
+ data.NewFrame("",
+ data.NewField("cpu", data.Labels{"host": "b"}, []float64{2.0}),
+ ),
+ }
+ for _, f := range input {
+ f.Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
+ }
+
+ expected := data.NewFrame("",
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu", "cpu"}),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(2.0)}),
+ data.NewField("host", nil, []*string{sp("a"), sp("b")}),
+ )
+ expected.Meta = &data.FrameMeta{Type: numericFullLongType}
+
+ output, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, output, 1)
+ if diff := cmp.Diff(expected, output[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Result mismatch (-want +got):%s", diff)
+ }
+ })
+
+ t.Run("TwoItemsWithSparseLabels", func(t *testing.T) {
+ input := data.Frames{
+ data.NewFrame("",
+ data.NewField("cpu", data.Labels{"host": "a"}, []float64{1.0}),
+ ),
+ data.NewFrame("",
+ data.NewField("cpu", data.Labels{"host": "b", "env": "prod"}, []float64{2.0}),
+ ),
+ }
+ for _, f := range input {
+ f.Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
+ }
+
+ expected := data.NewFrame("",
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu", "cpu"}),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(2.0)}),
+ data.NewField("env", nil, []*string{nil, sp("prod")}),
+ data.NewField("host", nil, []*string{sp("a"), sp("b")}),
+ )
+ expected.Meta = &data.FrameMeta{Type: numericFullLongType}
+
+ output, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, output, 1)
+ if diff := cmp.Diff(expected, output[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Result mismatch (-want +got):%s", diff)
+ }
+ })
+
+ t.Run("TwoDifferentMetricsWithSharedLabels", func(t *testing.T) {
+ input := data.Frames{
+ data.NewFrame("",
+ data.NewField("cpu", data.Labels{"host": "a"}, []float64{1.0}),
+ ),
+ data.NewFrame("",
+ data.NewField("mem", data.Labels{"host": "a"}, []float64{4.0}),
+ ),
+ }
+ for _, f := range input {
+ f.Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
+ }
+
+ expected := data.NewFrame("",
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu", "mem"}),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(4.0)}),
+ data.NewField("host", nil, []*string{sp("a"), sp("a")}),
+ )
+ expected.Meta = &data.FrameMeta{Type: numericFullLongType}
+
+ output, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, output, 1)
+ if diff := cmp.Diff(expected, output[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Result mismatch (-want +got):%s", diff)
+ }
+ })
+
+ t.Run("TwoSparseMetricsAndLabels", func(t *testing.T) {
+ input := data.Frames{
+ data.NewFrame("",
+ data.NewField("cpu", data.Labels{"host": "a"}, []float64{1.0}),
+ ),
+ data.NewFrame("",
+ data.NewField("mem", data.Labels{"env": "prod"}, []float64{4.0}),
+ ),
+ }
+ for _, f := range input {
+ f.Meta = &data.FrameMeta{Type: data.FrameTypeNumericMulti}
+ }
+
+ expected := data.NewFrame("",
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu", "mem"}),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(4.0)}),
+ data.NewField("env", nil, []*string{nil, sp("prod")}),
+ data.NewField("host", nil, []*string{sp("a"), nil}),
+ )
+ expected.Meta = &data.FrameMeta{Type: numericFullLongType}
+
+ output, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, output, 1)
+ if diff := cmp.Diff(expected, output[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Result mismatch (-want +got):%s", diff)
+ }
+ })
+}
+
+func TestConvertTimeSeriesWideToFullLong(t *testing.T) {
+ times := []time.Time{
+ time.Unix(0, 0),
+ time.Unix(10, 0),
+ }
+
+ t.Run("SingleSeriesNoLabels", func(t *testing.T) {
+ input := data.Frames{
+ data.NewFrame("",
+ data.NewField("time", nil, times),
+ data.NewField("cpu", nil, []float64{1.0, 2.0}),
+ ),
+ }
+ input[0].Meta = &data.FrameMeta{Type: data.FrameTypeTimeSeriesWide}
+
+ expected := data.NewFrame("",
+ data.NewField("time", nil, times),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(2.0)}),
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu", "cpu"}),
+ )
+ expected.Meta = &data.FrameMeta{Type: timeseriesFullLongType}
+
+ output, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, output, 1)
+ if diff := cmp.Diff(expected, output[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Mismatch (-want +got):\n%s", diff)
+ }
+ })
+
+ t.Run("TwoSeriesOneLabel", func(t *testing.T) {
+ input := data.Frames{
+ data.NewFrame("",
+ data.NewField("time", nil, times),
+ data.NewField("cpu", data.Labels{"host": "a"}, []float64{1.0, 2.0}),
+ data.NewField("cpu", data.Labels{"host": "b"}, []float64{3.0, 4.0}),
+ ),
+ }
+ input[0].Meta = &data.FrameMeta{Type: data.FrameTypeTimeSeriesWide}
+
+ expected := data.NewFrame("",
+ data.NewField("time", nil, []time.Time{times[0], times[0], times[1], times[1]}),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(3.0), fp(2.0), fp(4.0)}),
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu", "cpu", "cpu", "cpu"}),
+ data.NewField("host", nil, []*string{sp("a"), sp("b"), sp("a"), sp("b")}),
+ )
+ expected.Meta = &data.FrameMeta{Type: timeseriesFullLongType}
+
+ output, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, output, 1)
+ if diff := cmp.Diff(expected, output[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Mismatch (-want +got):\n%s", diff)
+ }
+ })
+
+ t.Run("TwoMetricsWithSharedLabels", func(t *testing.T) {
+ input := data.Frames{
+ data.NewFrame("",
+ data.NewField("time", nil, times),
+ data.NewField("cpu", data.Labels{"host": "a"}, []float64{1.0, 2.0}),
+ data.NewField("mem", data.Labels{"host": "a"}, []float64{3.0, 4.0}),
+ ),
+ }
+ input[0].Meta = &data.FrameMeta{Type: data.FrameTypeTimeSeriesWide}
+
+ expected := data.NewFrame("",
+ data.NewField("time", nil, []time.Time{times[0], times[0], times[1], times[1]}),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(3.0), fp(2.0), fp(4.0)}),
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu", "mem", "cpu", "mem"}),
+ data.NewField("host", nil, []*string{sp("a"), sp("a"), sp("a"), sp("a")}),
+ )
+ expected.Meta = &data.FrameMeta{Type: timeseriesFullLongType}
+
+ output, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, output, 1)
+ if diff := cmp.Diff(expected, output[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Mismatch (-want +got):\n%s", diff)
+ }
+ })
+
+ t.Run("TwoSeriesSparseLabels", func(t *testing.T) {
+ input := data.Frames{
+ data.NewFrame("",
+ data.NewField("time", nil, times),
+ data.NewField("cpu", data.Labels{"host": "a"}, []float64{1.0, 2.0}),
+ data.NewField("cpu", data.Labels{"host": "b", "env": "prod"}, []float64{3.0, 4.0}),
+ ),
+ }
+ input[0].Meta = &data.FrameMeta{Type: data.FrameTypeTimeSeriesWide}
+
+ expected := data.NewFrame("",
+ data.NewField("time", nil, []time.Time{times[0], times[0], times[1], times[1]}),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(3.0), fp(2.0), fp(4.0)}),
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu", "cpu", "cpu", "cpu"}),
+ data.NewField("env", nil, []*string{nil, sp("prod"), nil, sp("prod")}),
+ data.NewField("host", nil, []*string{sp("a"), sp("b"), sp("a"), sp("b")}),
+ )
+ expected.Meta = &data.FrameMeta{Type: timeseriesFullLongType}
+
+ output, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, output, 1)
+ if diff := cmp.Diff(expected, output[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Mismatch (-want +got):\n%s", diff)
+ }
+ })
+
+ t.Run("TwoSeriesSparseMetricsAndLabels", func(t *testing.T) {
+ input := data.Frames{
+ data.NewFrame("",
+ data.NewField("time", nil, times),
+ data.NewField("cpu", data.Labels{"host": "a"}, []float64{1.0, 2.0}),
+ data.NewField("mem", data.Labels{"host": "b", "env": "prod"}, []float64{3.0, 4.0}),
+ ),
+ }
+ input[0].Meta = &data.FrameMeta{Type: data.FrameTypeTimeSeriesWide}
+
+ expected := data.NewFrame("",
+ data.NewField("time", nil, []time.Time{times[0], times[0], times[1], times[1]}),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(3.0), fp(2.0), fp(4.0)}),
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu", "mem", "cpu", "mem"}),
+ data.NewField("env", nil, []*string{nil, sp("prod"), nil, sp("prod")}),
+ data.NewField("host", nil, []*string{sp("a"), sp("b"), sp("a"), sp("b")}),
+ )
+ expected.Meta = &data.FrameMeta{Type: timeseriesFullLongType}
+
+ output, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, output, 1)
+ if diff := cmp.Diff(expected, output[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Mismatch (-want +got):\n%s", diff)
+ }
+ })
+}
+
+func sp(s string) *string {
+ return &s
+}
diff --git a/pkg/expr/convert_to_full_long_ts_test.go b/pkg/expr/convert_to_full_long_ts_test.go
new file mode 100644
index 00000000000..eb43d9324fe
--- /dev/null
+++ b/pkg/expr/convert_to_full_long_ts_test.go
@@ -0,0 +1,373 @@
+package expr
+
+import (
+ "testing"
+ "time"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/grafana/grafana-plugin-sdk-go/data"
+ "github.com/stretchr/testify/require"
+)
+
+func TestConvertTimeSeriesMultiToFullLong(t *testing.T) {
+ t.Run("SingleSeriesNoLabels", func(t *testing.T) {
+ times := []time.Time{
+ time.Unix(0, 0),
+ time.Unix(10, 0),
+ time.Unix(20, 0),
+ }
+ values := []float64{1.0, 2.0, 3.0}
+
+ input := data.Frames{
+ data.NewFrame("cpu",
+ data.NewField("time", nil, times),
+ data.NewField("cpu", nil, values),
+ ),
+ }
+ input[0].Meta = &data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti}
+
+ expected := data.NewFrame("",
+ data.NewField("time", nil, times),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(2.0), fp(3.0)}),
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu", "cpu", "cpu"}),
+ )
+ expected.Meta = &data.FrameMeta{Type: timeseriesFullLongType}
+
+ output, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, output, 1)
+ if diff := cmp.Diff(expected, output[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Result mismatch (-want +got):%s", diff)
+ }
+ })
+
+ t.Run("TwoSeriesOneLabel", func(t *testing.T) {
+ times := []time.Time{
+ time.Unix(0, 0),
+ time.Unix(10, 0),
+ }
+
+ input := data.Frames{
+ data.NewFrame("cpu",
+ data.NewField("time", nil, times),
+ data.NewField("cpu", data.Labels{"host": "a"}, []float64{1.0, 2.0}),
+ ),
+ data.NewFrame("cpu",
+ data.NewField("time", nil, times),
+ data.NewField("cpu", data.Labels{"host": "b"}, []float64{3.0, 4.0}),
+ ),
+ }
+ input[0].Meta = &data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti}
+ input[1].Meta = &data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti}
+
+ expected := data.NewFrame("",
+ data.NewField("time", nil, []time.Time{
+ time.Unix(0, 0), time.Unix(0, 0), time.Unix(10, 0), time.Unix(10, 0),
+ }),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(3.0), fp(2.0), fp(4.0)}),
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu", "cpu", "cpu", "cpu"}),
+ data.NewField("host", nil, []*string{sp("a"), sp("b"), sp("a"), sp("b")}),
+ )
+ expected.Meta = &data.FrameMeta{Type: timeseriesFullLongType}
+
+ output, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, output, 1)
+ if diff := cmp.Diff(expected, output[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Result mismatch (-want +got):%s", diff)
+ }
+ })
+
+ t.Run("TwoMetricsWithSharedLabels", func(t *testing.T) {
+ times := []time.Time{
+ time.Unix(0, 0),
+ time.Unix(10, 0),
+ }
+
+ input := data.Frames{
+ data.NewFrame("cpu",
+ data.NewField("time", nil, times),
+ data.NewField("cpu", data.Labels{"host": "a"}, []float64{1.0, 2.0}),
+ ),
+ data.NewFrame("mem",
+ data.NewField("time", nil, times),
+ data.NewField("mem", data.Labels{"host": "a"}, []float64{3.0, 4.0}),
+ ),
+ }
+ input[0].Meta = &data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti}
+ input[1].Meta = &data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti}
+
+ expected := data.NewFrame("",
+ data.NewField("time", nil, []time.Time{
+ time.Unix(0, 0), time.Unix(0, 0), time.Unix(10, 0), time.Unix(10, 0),
+ }),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(3.0), fp(2.0), fp(4.0)}),
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu", "mem", "cpu", "mem"}),
+ data.NewField("host", nil, []*string{sp("a"), sp("a"), sp("a"), sp("a")}),
+ )
+ expected.Meta = &data.FrameMeta{Type: timeseriesFullLongType}
+
+ output, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, output, 1)
+ if diff := cmp.Diff(expected, output[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Result mismatch (-want +got):%s", diff)
+ }
+ })
+
+ t.Run("TwoSeriesSparseLabels", func(t *testing.T) {
+ times := []time.Time{
+ time.Unix(0, 0),
+ time.Unix(10, 0),
+ }
+
+ input := data.Frames{
+ data.NewFrame("cpu",
+ data.NewField("time", nil, times),
+ data.NewField("cpu", data.Labels{"host": "a"}, []float64{1.0, 2.0}),
+ ),
+ data.NewFrame("cpu",
+ data.NewField("time", nil, times),
+ data.NewField("cpu", data.Labels{"host": "b", "env": "prod"}, []float64{3.0, 4.0}),
+ ),
+ }
+ input[0].Meta = &data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti}
+ input[1].Meta = &data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti}
+
+ expected := data.NewFrame("",
+ data.NewField("time", nil, []time.Time{
+ time.Unix(0, 0), time.Unix(0, 0), time.Unix(10, 0), time.Unix(10, 0),
+ }),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(3.0), fp(2.0), fp(4.0)}),
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu", "cpu", "cpu", "cpu"}),
+ data.NewField("env", nil, []*string{nil, sp("prod"), nil, sp("prod")}),
+ data.NewField("host", nil, []*string{sp("a"), sp("b"), sp("a"), sp("b")}),
+ )
+ expected.Meta = &data.FrameMeta{Type: timeseriesFullLongType}
+
+ output, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, output, 1)
+ if diff := cmp.Diff(expected, output[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Result mismatch (-want +got):%s", diff)
+ }
+ })
+
+ t.Run("TwoSeriesSparseMetrics", func(t *testing.T) {
+ times := []time.Time{
+ time.Unix(0, 0),
+ time.Unix(10, 0),
+ }
+
+ input := data.Frames{
+ data.NewFrame("cpu",
+ data.NewField("time", nil, times),
+ data.NewField("cpu", data.Labels{"host": "a"}, []float64{1.0, 2.0}),
+ ),
+ data.NewFrame("mem",
+ data.NewField("time", nil, times),
+ data.NewField("mem", data.Labels{"host": "b"}, []float64{3.0, 4.0}),
+ ),
+ }
+ input[0].Meta = &data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti}
+ input[1].Meta = &data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti}
+
+ expected := data.NewFrame("",
+ data.NewField("time", nil, []time.Time{
+ time.Unix(0, 0), time.Unix(0, 0), time.Unix(10, 0), time.Unix(10, 0),
+ }),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(3.0), fp(2.0), fp(4.0)}),
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu", "mem", "cpu", "mem"}),
+ data.NewField("host", nil, []*string{sp("a"), sp("b"), sp("a"), sp("b")}),
+ )
+ expected.Meta = &data.FrameMeta{Type: timeseriesFullLongType}
+
+ output, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, output, 1)
+ if diff := cmp.Diff(expected, output[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Result mismatch (-want +got):%s", diff)
+ }
+ })
+
+ t.Run("TwoSeriesSparseMetricsAndLabels", func(t *testing.T) {
+ times := []time.Time{
+ time.Unix(0, 0),
+ time.Unix(10, 0),
+ }
+
+ input := data.Frames{
+ data.NewFrame("cpu",
+ data.NewField("time", nil, times),
+ data.NewField("cpu", data.Labels{"host": "a"}, []float64{1.0, 2.0}),
+ ),
+ data.NewFrame("mem",
+ data.NewField("time", nil, times),
+ data.NewField("mem", data.Labels{"host": "b", "env": "prod"}, []float64{3.0, 4.0}),
+ ),
+ }
+ input[0].Meta = &data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti}
+ input[1].Meta = &data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti}
+
+ expected := data.NewFrame("",
+ data.NewField("time", nil, []time.Time{
+ time.Unix(0, 0), time.Unix(0, 0), time.Unix(10, 0), time.Unix(10, 0),
+ }),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(3.0), fp(2.0), fp(4.0)}),
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu", "mem", "cpu", "mem"}),
+ data.NewField("env", nil, []*string{nil, sp("prod"), nil, sp("prod")}),
+ data.NewField("host", nil, []*string{sp("a"), sp("b"), sp("a"), sp("b")}),
+ )
+ expected.Meta = &data.FrameMeta{Type: timeseriesFullLongType}
+
+ output, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, output, 1)
+ if diff := cmp.Diff(expected, output[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Result mismatch (-want +got):%s", diff)
+ }
+ })
+
+ t.Run("ThreeSeriesSparseTimeLabelsMetrics", func(t *testing.T) {
+ timesA := []time.Time{
+ time.Unix(0, 0),
+ time.Unix(10, 0),
+ }
+ timesB := []time.Time{
+ time.Unix(5, 0),
+ time.Unix(15, 0),
+ }
+ timesMem := []time.Time{
+ time.Unix(10, 0),
+ time.Unix(30, 0),
+ }
+
+ input := data.Frames{
+ data.NewFrame("cpu",
+ data.NewField("time", nil, timesA),
+ data.NewField("cpu", data.Labels{"host": "a"}, []float64{1.0, 2.0}),
+ ),
+ data.NewFrame("cpu",
+ data.NewField("time", nil, timesB),
+ data.NewField("cpu", nil, []float64{9.0, 10.0}), // no labels
+ ),
+ data.NewFrame("mem",
+ data.NewField("time", nil, timesMem),
+ data.NewField("mem", data.Labels{"host": "b", "env": "prod"}, []float64{3.0, 4.0}),
+ ),
+ }
+ for _, f := range input {
+ f.Meta = &data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti}
+ }
+
+ expected := data.NewFrame("",
+ data.NewField("time", nil, []time.Time{
+ time.Unix(0, 0), // cpu a
+ time.Unix(5, 0), // cpu no label
+ time.Unix(10, 0), // cpu a
+ time.Unix(10, 0), // mem
+ time.Unix(15, 0), // cpu no label
+ time.Unix(30, 0), // mem
+ }),
+ data.NewField(SQLValueFieldName, nil, []*float64{
+ fp(1.0), fp(9.0), fp(2.0), fp(3.0), fp(10.0), fp(4.0),
+ }),
+ data.NewField(SQLMetricFieldName, nil, []string{
+ "cpu", "cpu", "cpu", "mem", "cpu", "mem",
+ }),
+ data.NewField("env", nil, []*string{
+ nil, nil, nil, sp("prod"), nil, sp("prod"),
+ }),
+ data.NewField("host", nil, []*string{
+ sp("a"), nil, sp("a"), sp("b"), nil, sp("b"),
+ }),
+ )
+ expected.Meta = &data.FrameMeta{Type: timeseriesFullLongType}
+
+ output, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, output, 1)
+ if diff := cmp.Diff(expected, output[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Result mismatch (-want +got):%s", diff)
+ }
+ })
+}
+
+func TestConvertTimeSeriesMultiToFullLongWithDisplayName(t *testing.T) {
+ t.Run("SingleSeriesWithDisplayName", func(t *testing.T) {
+ times := []time.Time{time.Unix(0, 0), time.Unix(10, 0)}
+
+ input := data.Frames{
+ data.NewFrame("cpu",
+ data.NewField("time", nil, times),
+ func() *data.Field {
+ f := data.NewField("cpu", nil, []float64{1.0, 2.0})
+ f.Config = &data.FieldConfig{DisplayNameFromDS: "CPU Display"}
+ return f
+ }(),
+ ),
+ }
+ input[0].Meta = &data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti}
+
+ expected := data.NewFrame("",
+ data.NewField("time", nil, times),
+ data.NewField(SQLValueFieldName, nil, []*float64{fp(1.0), fp(2.0)}),
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu", "cpu"}),
+ data.NewField(SQLDisplayFieldName, nil, []*string{sp("CPU Display"), sp("CPU Display")}),
+ )
+ expected.Meta = &data.FrameMeta{Type: timeseriesFullLongType}
+
+ output, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, output, 1)
+ if diff := cmp.Diff(expected, output[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Mismatch (-want +got):\n%s", diff)
+ }
+ })
+
+ t.Run("TwoSeriesMixedDisplayNames", func(t *testing.T) {
+ times := []time.Time{time.Unix(0, 0), time.Unix(10, 0)}
+
+ input := data.Frames{
+ data.NewFrame("cpu",
+ data.NewField("time", nil, times),
+ func() *data.Field {
+ f := data.NewField("cpu", data.Labels{"host": "a"}, []float64{1.0, 2.0})
+ f.Config = &data.FieldConfig{DisplayNameFromDS: "CPU A"}
+ return f
+ }(),
+ ),
+ data.NewFrame("cpu",
+ data.NewField("time", nil, times),
+ data.NewField("cpu", data.Labels{"host": "b"}, []float64{3.0, 4.0}),
+ ),
+ }
+ for _, f := range input {
+ f.Meta = &data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti}
+ }
+
+ expected := data.NewFrame("",
+ data.NewField("time", nil, []time.Time{
+ times[0], times[0], times[1], times[1],
+ }),
+ data.NewField(SQLValueFieldName, nil, []*float64{
+ fp(1.0), fp(3.0), fp(2.0), fp(4.0),
+ }),
+ data.NewField(SQLMetricFieldName, nil, []string{"cpu", "cpu", "cpu", "cpu"}),
+ data.NewField(SQLDisplayFieldName, nil, []*string{
+ sp("CPU A"), nil, sp("CPU A"), nil,
+ }),
+ data.NewField("host", nil, []*string{
+ sp("a"), sp("b"), sp("a"), sp("b"),
+ }),
+ )
+ expected.Meta = &data.FrameMeta{Type: timeseriesFullLongType}
+
+ output, err := ConvertToFullLong(input)
+ require.NoError(t, err)
+ require.Len(t, output, 1)
+ if diff := cmp.Diff(expected, output[0], data.FrameTestCompareOptions()...); diff != "" {
+ require.FailNowf(t, "Mismatch (-want +got):\n%s", diff)
+ }
+ })
+}
diff --git a/pkg/expr/convert_to_long.go b/pkg/expr/convert_to_long.go
deleted file mode 100644
index 3ab3339b8b0..00000000000
--- a/pkg/expr/convert_to_long.go
+++ /dev/null
@@ -1,311 +0,0 @@
-package expr
-
-import (
- "fmt"
- "sort"
- "time"
-
- "github.com/grafana/grafana-plugin-sdk-go/data"
-)
-
-func ConvertToLong(frames data.Frames) (data.Frames, error) {
- if len(frames) == 0 {
- // general empty case for now
- return frames, nil
- }
- // Four Conversion Possible Cases
- // 1. NumericMulti -> NumericLong
- // 2. NumericWide -> NumericLong
- // 3. TimeSeriesMulti -> TimeSeriesLong
- // 4. TimeSeriesWide -> TimeSeriesLong
-
- // Detect if input type is declared
- // First Check Frame Meta Type
-
- var inputType data.FrameType
- if frames[0].Meta != nil && frames[0].Meta.Type != "" {
- inputType = frames[0].Meta.Type
- }
-
- // TODO: Add some guessing of Type if not declared
- if inputType == "" {
- return frames, fmt.Errorf("no input dataframe type set")
- }
-
- if !supportedToLongConversion(inputType) {
- return frames, fmt.Errorf("unsupported input dataframe type %s for SQL expression", inputType)
- }
-
- toLong := getToLongConversionFunc(inputType)
- if toLong == nil {
- return frames, fmt.Errorf("could not get conversion function for input type %s", inputType)
- }
-
- return toLong(frames)
-}
-
-func convertNumericMultiToNumericLong(frames data.Frames) (data.Frames, error) {
- // Apart from metadata, NumericMulti is basically NumericWide, except one frame per thing
- // so we collapse into wide and call the wide conversion
- wide := convertNumericMultiToNumericWide(frames)
- return convertNumericWideToNumericLong(wide)
-}
-
-func convertNumericMultiToNumericWide(frames data.Frames) data.Frames {
- newFrame := data.NewFrame("")
- for _, frame := range frames {
- for _, field := range frame.Fields {
- if !field.Type().Numeric() {
- continue
- }
- newField := data.NewFieldFromFieldType(field.Type(), field.Len())
- newField.Name = field.Name
- newField.Labels = field.Labels.Copy()
- if field.Len() == 1 {
- newField.Set(0, field.CopyAt(0))
- }
- newFrame.Fields = append(newFrame.Fields, newField)
- }
- }
- return data.Frames{newFrame}
-}
-
-func convertNumericWideToNumericLong(frames data.Frames) (data.Frames, error) {
- // Wide should only be one frame
- if len(frames) != 1 {
- return nil, fmt.Errorf("expected exactly one frame for wide format, but got %d", len(frames))
- }
- inputFrame := frames[0]
-
- // The Frame should have no more than one row
- if inputFrame.Rows() > 1 {
- return nil, fmt.Errorf("expected no more than one row in the frame, but got %d", inputFrame.Rows())
- }
-
- // Gather:
- // - unique numeric Field Names, and
- // - unique Label Keys (from Numeric Fields only)
- // each one maps to a field in the output long Frame.
- uniqueNames := make([]string, 0)
- uniqueKeys := make([]string, 0)
-
- uniqueNamesMap := make(map[string]data.FieldType)
- uniqueKeysMap := make(map[string]struct{})
-
- prints := make(map[string]int)
-
- registerPrint := func(labels data.Labels) {
- fp := labels.Fingerprint().String()
- if _, ok := prints[fp]; !ok {
- prints[fp] = len(prints)
- }
- }
-
- for _, field := range inputFrame.Fields {
- if field.Type().Numeric() {
- if _, ok := uniqueNamesMap[field.Name]; !ok {
- uniqueNames = append(uniqueNames, field.Name)
- uniqueNamesMap[field.Name] = field.Type()
- }
-
- if field.Labels != nil {
- registerPrint(field.Labels)
- for key := range field.Labels {
- if _, ok := uniqueKeysMap[key]; !ok {
- uniqueKeys = append(uniqueKeys, key)
- }
- uniqueKeysMap[key] = struct{}{}
- }
- }
- }
- }
-
- // Create new fields for output Long frame
- fields := make([]*data.Field, 0, len(uniqueNames)+len(uniqueKeys))
-
- // Create the Numeric Fields, tracking the index of each field by name
- // Note: May want to use FloatAt and and prepopulate with NaN so missing
- // combinations of value can be NA instead of the zero value of 0.
- var nameIndexMap = make(map[string]int, len(uniqueNames))
- for i, name := range uniqueNames {
- field := data.NewFieldFromFieldType(uniqueNamesMap[name], len(prints))
- field.Name = name
- fields = append(fields, field)
- nameIndexMap[name] = i
- }
-
- // Create the String fields, tracking the index of each field by key
- var keyIndexMap = make(map[string]int, len(uniqueKeys))
- for i, k := range uniqueKeys {
- fields = append(fields, data.NewField(k, nil, make([]string, len(prints))))
- keyIndexMap[k] = len(nameIndexMap) + i
- }
-
- longFrame := data.NewFrame("", fields...)
-
- if inputFrame.Rows() == 0 {
- return data.Frames{longFrame}, nil
- }
-
- // Add Rows to the fields
- for _, field := range inputFrame.Fields {
- if !field.Type().Numeric() {
- continue
- }
- fieldIdx := prints[field.Labels.Fingerprint().String()]
- longFrame.Fields[nameIndexMap[field.Name]].Set(fieldIdx, field.CopyAt(0))
- for key, value := range field.Labels {
- longFrame.Fields[keyIndexMap[key]].Set(fieldIdx, value)
- }
- }
-
- return data.Frames{longFrame}, nil
-}
-
-func convertTimeSeriesMultiToTimeSeriesLong(frames data.Frames) (data.Frames, error) {
- // Collect all time values and ensure no duplicates
- timeSet := make(map[time.Time]struct{})
- labelKeys := make(map[string]struct{}) // Collect all unique label keys
- numericFields := make(map[string]struct{}) // Collect unique numeric field names
-
- for _, frame := range frames {
- for _, field := range frame.Fields {
- if field.Type() == data.FieldTypeTime {
- for i := 0; i < field.Len(); i++ {
- t := field.At(i).(time.Time)
- timeSet[t] = struct{}{}
- }
- } else if field.Type().Numeric() {
- numericFields[field.Name] = struct{}{}
- if field.Labels != nil {
- for key := range field.Labels {
- labelKeys[key] = struct{}{}
- }
- }
- }
- }
- }
-
- // Create a sorted slice of unique time values
- times := make([]time.Time, 0, len(timeSet))
- for t := range timeSet {
- times = append(times, t)
- }
- sort.Slice(times, func(i, j int) bool { return times[i].Before(times[j]) })
-
- // Create output fields: Time, one numeric field per unique numeric name, and label fields
- timeField := data.NewField("Time", nil, times)
- outputNumericFields := make(map[string]*data.Field)
- for name := range numericFields {
- outputNumericFields[name] = data.NewField(name, nil, make([]float64, len(times)))
- }
- outputLabelFields := make(map[string]*data.Field)
- for key := range labelKeys {
- outputLabelFields[key] = data.NewField(key, nil, make([]string, len(times)))
- }
-
- // Map time to index for quick lookup
- timeIndexMap := make(map[time.Time]int, len(times))
- for i, t := range times {
- timeIndexMap[t] = i
- }
-
- // Populate output fields
- for _, frame := range frames {
- var timeField *data.Field
- for _, field := range frame.Fields {
- if field.Type() == data.FieldTypeTime {
- timeField = field
- break
- }
- }
-
- if timeField == nil {
- return nil, fmt.Errorf("no time field found in frame")
- }
-
- for _, field := range frame.Fields {
- if field.Type().Numeric() {
- for i := 0; i < field.Len(); i++ {
- t := timeField.At(i).(time.Time)
- val, err := field.FloatAt(i)
- if err != nil {
- val = 0 // Default value for missing data
- }
- idx := timeIndexMap[t]
- if outputField, exists := outputNumericFields[field.Name]; exists {
- outputField.Set(idx, val)
- }
-
- // Add labels for the numeric field
- for key, value := range field.Labels {
- if outputField, exists := outputLabelFields[key]; exists {
- outputField.Set(idx, value)
- }
- }
- }
- }
- }
- }
-
- // Build the output frame
- outputFields := []*data.Field{timeField}
- for _, field := range outputNumericFields {
- outputFields = append(outputFields, field)
- }
- for _, field := range outputLabelFields {
- outputFields = append(outputFields, field)
- }
- outputFrame := data.NewFrame("time_series_long", outputFields...)
-
- // Set metadata
- if outputFrame.Meta == nil {
- outputFrame.Meta = &data.FrameMeta{}
- }
- outputFrame.Meta.Type = data.FrameTypeTimeSeriesLong
-
- return data.Frames{outputFrame}, nil
-}
-
-func convertTimeSeriesWideToTimeSeriesLong(frames data.Frames) (data.Frames, error) {
- // Wide should only be one frame
- if len(frames) != 1 {
- return nil, fmt.Errorf("expected exactly one frame for wide format, but got %d", len(frames))
- }
- inputFrame := frames[0]
- longFrame, err := data.WideToLong(inputFrame)
- if err != nil {
- return nil, fmt.Errorf("failed to convert wide time series to long timeseries for sql expression: %w", err)
- }
- return data.Frames{longFrame}, nil
-}
-
-func getToLongConversionFunc(inputType data.FrameType) func(data.Frames) (data.Frames, error) {
- switch inputType {
- case data.FrameTypeNumericMulti:
- return convertNumericMultiToNumericLong
- case data.FrameTypeNumericWide:
- return convertNumericWideToNumericLong
- case data.FrameTypeTimeSeriesMulti:
- return convertTimeSeriesMultiToTimeSeriesLong
- case data.FrameTypeTimeSeriesWide:
- return convertTimeSeriesWideToTimeSeriesLong
- default:
- return convertErr
- }
-}
-
-func convertErr(_ data.Frames) (data.Frames, error) {
- return nil, fmt.Errorf("unsupported input type for SQL expression")
-}
-
-func supportedToLongConversion(inputType data.FrameType) bool {
- switch inputType {
- case data.FrameTypeNumericMulti, data.FrameTypeNumericWide:
- return true
- case data.FrameTypeTimeSeriesMulti, data.FrameTypeTimeSeriesWide:
- return true
- default:
- return false
- }
-}
diff --git a/pkg/expr/convert_to_long_test.go b/pkg/expr/convert_to_long_test.go
deleted file mode 100644
index 291fdb62f17..00000000000
--- a/pkg/expr/convert_to_long_test.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package expr
-
-import (
- "testing"
-
- "github.com/google/go-cmp/cmp"
- "github.com/grafana/grafana-plugin-sdk-go/data"
- "github.com/stretchr/testify/require"
-)
-
-func TestConvertNumericMultiToLong(t *testing.T) {
- input := data.Frames{
- data.NewFrame("test",
- data.NewField("Value", data.Labels{"city": "MIA"}, []int64{5})),
- data.NewFrame("test",
- data.NewField("Value", data.Labels{"city": "LGA"}, []int64{7}),
- ),
- }
- expectedFrame := data.NewFrame("",
- data.NewField("Value", nil, []int64{5, 7}),
- data.NewField("city", nil, []string{"MIA", "LGA"}),
- )
- output, err := convertNumericMultiToNumericLong(input)
- require.NoError(t, err)
-
- if diff := cmp.Diff(expectedFrame, output[0], data.FrameTestCompareOptions()...); diff != "" {
- require.FailNowf(t, "Result mismatch (-want +got):%s\n", diff)
- }
-}
-
-func TestConvertNumericWideToLong(t *testing.T) {
- input := data.Frames{
- data.NewFrame("test",
- data.NewField("Value", data.Labels{"city": "MIA"}, []int64{5}),
- data.NewField("Value", data.Labels{"city": "LGA"}, []int64{7}),
- ),
- }
- expectedFrame := data.NewFrame("",
- data.NewField("Value", nil, []int64{5, 7}),
- data.NewField("city", nil, []string{"MIA", "LGA"}),
- )
- output, err := convertNumericWideToNumericLong(input)
- require.NoError(t, err)
-
- if diff := cmp.Diff(expectedFrame, output[0], data.FrameTestCompareOptions()...); diff != "" {
- require.FailNowf(t, "Result mismatch (-want +got):%s\n", diff)
- }
-}
diff --git a/pkg/expr/mathexp/parse/lex.go b/pkg/expr/mathexp/parse/lex.go
index 7d74bd876ab..20dcb170e9c 100644
--- a/pkg/expr/mathexp/parse/lex.go
+++ b/pkg/expr/mathexp/parse/lex.go
@@ -67,13 +67,14 @@ type stateFn func(*lexer) stateFn
// lexer holds the state of the scanner.
type lexer struct {
- input string // the string being scanned
- state stateFn // the next lexing function to enter
- pos Pos // current position in the input
- start Pos // start position of this item
- width Pos // width of last rune read from input
- lastPos Pos // position of most recent item returned by nextItem
- items chan item // channel of scanned items
+ input string // the string being scanned
+ state stateFn // the next lexing function to enter
+ pos Pos // current position in the input
+ start Pos // start position of this item
+ width Pos // width of last rune read from input
+ lastPos Pos // position of most recent item returned by nextItem
+ items chan item // channel of scanned items
+ done chan struct{} // channel to signal lexer shutdown
}
// next returns the next rune in the input.
@@ -103,7 +104,11 @@ func (l *lexer) backup() {
// emit passes an item back to the client.
func (l *lexer) emit(t itemType) {
- l.items <- item{t, l.start, l.input[l.start:l.pos]}
+ select {
+ case l.items <- item{t, l.start, l.input[l.start:l.pos]}:
+ case <-l.done:
+ return
+ }
l.start = l.pos
}
@@ -139,7 +144,11 @@ func (l *lexer) lineNumber() int {
// errorf returns an error token and terminates the scan by passing
// back a nil pointer that will be the next state, terminating l.nextItem.
func (l *lexer) errorf(format string, args ...any) stateFn {
- l.items <- item{itemError, l.start, fmt.Sprintf(format, args...)}
+ select {
+ case l.items <- item{itemError, l.start, fmt.Sprintf(format, args...)}:
+ case <-l.done:
+ return nil
+ }
return nil
}
@@ -155,15 +164,32 @@ func lex(input string) *lexer {
l := &lexer{
input: input,
items: make(chan item),
+ done: make(chan struct{}),
}
go l.run()
return l
}
+// Close terminates the lexer goroutine.
+func (l *lexer) Close() {
+ select {
+ case <-l.done:
+ // already closed
+ default:
+ close(l.done)
+ }
+}
+
// run runs the state machine for the lexer.
func (l *lexer) run() {
+ defer close(l.items)
for l.state = lexItem; l.state != nil; {
- l.state = l.state(l)
+ select {
+ case <-l.done:
+ return
+ default:
+ l.state = l.state(l)
+ }
}
}
diff --git a/pkg/expr/mathexp/parse/lex_test.go b/pkg/expr/mathexp/parse/lex_test.go
index e5b5ec59fc8..8356d2f9b3f 100644
--- a/pkg/expr/mathexp/parse/lex_test.go
+++ b/pkg/expr/mathexp/parse/lex_test.go
@@ -6,7 +6,9 @@ package parse
import (
"fmt"
+ "runtime"
"testing"
+ "time"
)
// Make the types prettyprint.
@@ -167,3 +169,70 @@ func TestLex(t *testing.T) {
}
}
}
+
+// TestLexerClose verifies that a lexer can be explicitly closed
+func TestLexerClose(t *testing.T) {
+ // Create a lexer with some input
+ lexer := lex("1 + 2")
+
+ // Read one item to verify it's working
+ item := lexer.nextItem()
+ if item.typ != itemNumber || item.val != "1" {
+ t.Errorf("unexpected first item: %v", item)
+ }
+
+ // Close the lexer explicitly
+ lexer.Close()
+
+ // Verify the lexer's channel closes
+ select {
+ case _, ok := <-lexer.items:
+ if ok {
+ t.Fatal("lexer.items channel should be closed after lexer.Close()")
+ }
+ case <-time.After(100 * time.Millisecond):
+ t.Fatal("timed out waiting for lexer.items channel to close")
+ }
+}
+
+// TestParseErrorNoLeak verifies that lexer goroutines are properly terminated when Parse encounters errors
+func TestParseErrorNoLeak(t *testing.T) {
+ // Count initial goroutines
+ initialGoroutines := runtime.NumGoroutine()
+
+ // Create several trees with parsing errors to check for leaks
+ for i := 0; i < 10; i++ {
+ tree := New()
+ input := "invalid expression with $"
+ err := tree.Parse(input)
+
+ // Verify that Parse returned an error
+ if err == nil {
+ t.Fatal("expected error but got nil")
+ }
+
+ // Verify that tree.lex is nil after an error
+ if tree.lex != nil {
+ t.Fatal("tree.lex was not set to nil after error")
+ }
+ }
+
+ // Poll for goroutine count to stabilize
+ deadline := time.Now().Add(500 * time.Millisecond)
+ var finalGoroutines int
+
+ for time.Now().Before(deadline) {
+ finalGoroutines = runtime.NumGoroutine()
+ // If we're close to the initial count, we can exit early
+ if finalGoroutines <= initialGoroutines+2 {
+ break
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+
+ // Check if we've leaked goroutines (with a small buffer for normal variations)
+ if finalGoroutines > initialGoroutines+5 {
+ t.Fatalf("Goroutine leak detected: started with %d goroutines, ended with %d (difference of %d)",
+ initialGoroutines, finalGoroutines, finalGoroutines-initialGoroutines)
+ }
+}
diff --git a/pkg/expr/mathexp/parse/parse.go b/pkg/expr/mathexp/parse/parse.go
index 4652390f29c..ed1af7e111a 100644
--- a/pkg/expr/mathexp/parse/parse.go
+++ b/pkg/expr/mathexp/parse/parse.go
@@ -140,7 +140,10 @@ func (t *Tree) startParse(funcs []map[string]Func, lex *lexer) {
// stopParse terminates parsing.
func (t *Tree) stopParse() {
- t.lex = nil
+ if t.lex != nil {
+ t.lex.Close()
+ t.lex = nil
+ }
}
// Parse parses the expression definition string to construct a representation
diff --git a/pkg/expr/nodes.go b/pkg/expr/nodes.go
index dea3b10e659..141ce36e3af 100644
--- a/pkg/expr/nodes.go
+++ b/pkg/expr/nodes.go
@@ -429,7 +429,7 @@ func (dn *DSNode) Execute(ctx context.Context, now time.Time, _ mathexp.Vars, s
}
if needsConversion {
- convertedFrames, err := ConvertToLong(dataFrames)
+ convertedFrames, err := ConvertToFullLong(dataFrames)
if err != nil {
return result, fmt.Errorf("failed to convert data frames to long format for sql: %w", err)
}
diff --git a/pkg/expr/sql/db.go b/pkg/expr/sql/db.go
index f1af425ad6d..a1badd44974 100644
--- a/pkg/expr/sql/db.go
+++ b/pkg/expr/sql/db.go
@@ -4,6 +4,7 @@ package sql
import (
"context"
+ "fmt"
sqle "github.com/dolthub/go-mysql-server"
mysql "github.com/dolthub/go-mysql-server/sql"
@@ -15,6 +16,42 @@ import (
// DB is a database that can execute SQL queries against a set of Frames.
type DB struct{}
+// GoMySQLServerError represents an error from the underlying Go MySQL Server
+type GoMySQLServerError struct {
+ Err error
+}
+
+// Error implements the error interface
+func (e *GoMySQLServerError) Error() string {
+ return fmt.Sprintf("error in go-mysql-server: %v", e.Err)
+}
+
+// Unwrap provides the original error for errors.Is/As
+func (e *GoMySQLServerError) Unwrap() error {
+ return e.Err
+}
+
+// WrapGoMySQLServerError wraps errors from Go MySQL Server with additional context
+func WrapGoMySQLServerError(err error) error {
+ // Don't wrap nil errors
+ if err == nil {
+ return nil
+ }
+
+ // Check if it's a function not found error or other specific GMS errors
+ if isFunctionNotFoundError(err) {
+ return &GoMySQLServerError{Err: err}
+ }
+
+ // Return original error if it's not one we want to wrap
+ return err
+}
+
+// isFunctionNotFoundError checks if the error is related to a function not being found
+func isFunctionNotFoundError(err error) bool {
+ return mysql.ErrFunctionNotFound.Is(err)
+}
+
// QueryFrames runs the sql query query against a database created from frames, and returns the frame.
// The RefID of each frame becomes a table in the database.
// It is expected that there is only one frame per RefID.
@@ -47,7 +84,7 @@ func (db *DB) QueryFrames(ctx context.Context, name string, query string, frames
schema, iter, _, err := engine.Query(mCtx, query)
if err != nil {
- return nil, err
+ return nil, WrapGoMySQLServerError(err)
}
f, err := convertToDataFrame(mCtx, iter, schema)
diff --git a/pkg/expr/sql/db_test.go b/pkg/expr/sql/db_test.go
index 57171fbca94..77263d028de 100644
--- a/pkg/expr/sql/db_test.go
+++ b/pkg/expr/sql/db_test.go
@@ -193,6 +193,18 @@ func TestQueryFramesDateTimeSelect(t *testing.T) {
}
}
+func TestErrorsFromGoMySQLServerAreFlagged(t *testing.T) {
+ const GmsNotImplemented = "STDDEV" // not implemented in go-mysql-server as of 2025-03-18
+
+ db := DB{}
+
+ query := `SELECT ` + GmsNotImplemented + `(1);`
+
+ _, err := db.QueryFrames(context.Background(), "sqlExpressionRefId", query, nil)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "error in go-mysql-server")
+}
+
// p is a utility for pointers from constants
func p[T any](v T) *T {
return &v
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/parser_allow_test.go b/pkg/expr/sql/parser_allow_test.go
index fc4b33369df..abb997d73c1 100644
--- a/pkg/expr/sql/parser_allow_test.go
+++ b/pkg/expr/sql/parser_allow_test.go
@@ -129,7 +129,17 @@ var example_case_statement = `SELECT
END AS category
FROM metrics`
-var example_all_allowed_functions = `SELECT
+var example_all_allowed_functions = `WITH sample_data AS (
+ SELECT
+ 100 AS value,
+ 'example' AS name,
+ NOW() AS created_at
+ UNION ALL SELECT
+ 50 AS value,
+ 'test' AS name,
+ DATE_SUB(NOW(), INTERVAL 1 DAY) AS created_at
+)
+SELECT
-- Conditional functions
IF(value > 100, 'High', 'Low') AS conditional_if,
COALESCE(value, 0) AS conditional_coalesce,
@@ -191,6 +201,6 @@ var example_all_allowed_functions = `SELECT
-- Type conversion
CAST(value AS CHAR) AS type_cast,
CONVERT(value, CHAR) AS type_convert
-FROM metrics
+FROM sample_data
GROUP BY name, value, created_at
LIMIT 10`
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/infra/db/db.go b/pkg/infra/db/db.go
index 5f61d5d3847..ad2d58f3242 100644
--- a/pkg/infra/db/db.go
+++ b/pkg/infra/db/db.go
@@ -94,3 +94,11 @@ func IsTestDBMSSQL() bool {
return false
}
+
+func IsTestDBSpanner() bool {
+ if db, present := os.LookupEnv("GRAFANA_TEST_DB"); present {
+ return db == migrator.Spanner
+ }
+
+ return false
+}
diff --git a/pkg/infra/usagestats/statscollector/service.go b/pkg/infra/usagestats/statscollector/service.go
index cb30bd7f030..13ed55b238f 100644
--- a/pkg/infra/usagestats/statscollector/service.go
+++ b/pkg/infra/usagestats/statscollector/service.go
@@ -150,7 +150,7 @@ func (s *Service) collectSystemStats(ctx context.Context) (map[string]any, error
m["stats.plugins.apps.count"] = s.appCount(ctx)
m["stats.plugins.panels.count"] = s.panelCount(ctx)
m["stats.plugins.datasources.count"] = s.dataSourceCount(ctx)
- m["stats.plugins.sandboxed_plugins.count"] = s.sandboxCount()
+ m["stats.plugins.sandboxed_plugins.count"] = s.sandboxCount(ctx)
m["stats.alerts.count"] = statsResult.Alerts
m["stats.active_users.count"] = statsResult.ActiveUsers
m["stats.active_admins.count"] = statsResult.ActiveAdmins
@@ -367,8 +367,8 @@ func (s *Service) dataSourceCount(ctx context.Context) int {
return len(s.plugins.Plugins(ctx, plugins.TypeDataSource))
}
-func (s *Service) sandboxCount() int {
- ps, err := s.sandbox.Plugins()
+func (s *Service) sandboxCount(ctx context.Context) int {
+ ps, err := s.sandbox.Plugins(ctx)
if err != nil {
s.log.Error("Failed to get sandboxed plugin count", "error", err)
return 0
diff --git a/pkg/plugins/backendplugin/grpcplugin/client.go b/pkg/plugins/backendplugin/grpcplugin/client.go
index 35920fef2cf..1c4a2b186bb 100644
--- a/pkg/plugins/backendplugin/grpcplugin/client.go
+++ b/pkg/plugins/backendplugin/grpcplugin/client.go
@@ -12,7 +12,6 @@ import (
"github.com/grafana/grafana/pkg/plugins/backendplugin"
"github.com/grafana/grafana/pkg/plugins/backendplugin/pluginextensionv2"
- "github.com/grafana/grafana/pkg/plugins/backendplugin/secretsmanagerplugin"
"github.com/grafana/grafana/pkg/plugins/log"
)
@@ -31,14 +30,13 @@ var handshake = goplugin.HandshakeConfig{
// pluginSet is list of plugins supported on v2.
var pluginSet = map[int]goplugin.PluginSet{
grpcplugin.ProtocolVersion: {
- "diagnostics": &grpcplugin.DiagnosticsGRPCPlugin{},
- "resource": &grpcplugin.ResourceGRPCPlugin{},
- "data": &grpcplugin.DataGRPCPlugin{},
- "stream": &grpcplugin.StreamGRPCPlugin{},
- "admission": &grpcplugin.AdmissionGRPCPlugin{},
- "conversion": &grpcplugin.ConversionGRPCPlugin{},
- "renderer": &pluginextensionv2.RendererGRPCPlugin{},
- "secretsmanager": &secretsmanagerplugin.SecretsManagerGRPCPlugin{},
+ "diagnostics": &grpcplugin.DiagnosticsGRPCPlugin{},
+ "resource": &grpcplugin.ResourceGRPCPlugin{},
+ "data": &grpcplugin.DataGRPCPlugin{},
+ "stream": &grpcplugin.StreamGRPCPlugin{},
+ "admission": &grpcplugin.AdmissionGRPCPlugin{},
+ "conversion": &grpcplugin.ConversionGRPCPlugin{},
+ "renderer": &pluginextensionv2.RendererGRPCPlugin{},
},
}
@@ -84,19 +82,15 @@ func newClientConfig(executablePath string, args []string, env []string, skipHos
// StartRendererFunc callback function called when a renderer plugin is started.
type StartRendererFunc func(pluginID string, renderer pluginextensionv2.RendererPlugin, logger log.Logger) error
-// StartSecretsManagerFunc callback function called when a secrets manager plugin is started.
-type StartSecretsManagerFunc func(pluginID string, secretsmanager secretsmanagerplugin.SecretsManagerPlugin, logger log.Logger) error
-
// PluginDescriptor is a descriptor used for registering backend plugins.
type PluginDescriptor struct {
- pluginID string
- executablePath string
- executableArgs []string
- skipHostEnvVars bool
- managed bool
- versionedPlugins map[int]goplugin.PluginSet
- startRendererFn StartRendererFunc
- startSecretsManagerFn StartSecretsManagerFunc
+ pluginID string
+ executablePath string
+ executableArgs []string
+ skipHostEnvVars bool
+ managed bool
+ versionedPlugins map[int]goplugin.PluginSet
+ startRendererFn StartRendererFunc
}
// NewBackendPlugin creates a new backend plugin factory used for registering a backend plugin.
@@ -131,14 +125,3 @@ func NewRendererPlugin(pluginID, executablePath string, startFn StartRendererFun
startRendererFn: startFn,
})
}
-
-// NewSecretsManagerPlugin creates a new secrets manager plugin factory used for registering a backend secrets manager plugin.
-func NewSecretsManagerPlugin(pluginID, executablePath string, startFn StartSecretsManagerFunc) backendplugin.PluginFactoryFunc {
- return newPlugin(PluginDescriptor{
- pluginID: pluginID,
- executablePath: executablePath,
- managed: false,
- versionedPlugins: pluginSet,
- startSecretsManagerFn: startFn,
- })
-}
diff --git a/pkg/plugins/backendplugin/grpcplugin/client_v2.go b/pkg/plugins/backendplugin/grpcplugin/client_v2.go
index ade867a2ace..78d7b0d6dcc 100644
--- a/pkg/plugins/backendplugin/grpcplugin/client_v2.go
+++ b/pkg/plugins/backendplugin/grpcplugin/client_v2.go
@@ -16,7 +16,6 @@ import (
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/backendplugin/pluginextensionv2"
- "github.com/grafana/grafana/pkg/plugins/backendplugin/secretsmanagerplugin"
"github.com/grafana/grafana/pkg/plugins/log"
)
@@ -32,7 +31,6 @@ type ClientV2 struct {
grpcplugin.AdmissionClient
grpcplugin.ConversionClient
pluginextensionv2.RendererPlugin
- secretsmanagerplugin.SecretsManagerPlugin
}
func newClientV2(descriptor PluginDescriptor, logger log.Logger, rpcClient plugin.ClientProtocol) (*ClientV2, error) {
@@ -71,11 +69,6 @@ func newClientV2(descriptor PluginDescriptor, logger log.Logger, rpcClient plugi
return nil, err
}
- rawSecretsManager, err := rpcClient.Dispense("secretsmanager")
- if err != nil {
- return nil, err
- }
-
c := &ClientV2{}
if rawDiagnostics != nil {
if diagnosticsClient, ok := rawDiagnostics.(grpcplugin.DiagnosticsClient); ok {
@@ -119,24 +112,12 @@ func newClientV2(descriptor PluginDescriptor, logger log.Logger, rpcClient plugi
}
}
- if rawSecretsManager != nil {
- if secretsManagerPlugin, ok := rawSecretsManager.(secretsmanagerplugin.SecretsManagerPlugin); ok {
- c.SecretsManagerPlugin = secretsManagerPlugin
- }
- }
-
if descriptor.startRendererFn != nil {
if err := descriptor.startRendererFn(descriptor.pluginID, c.RendererPlugin, logger); err != nil {
return nil, err
}
}
- if descriptor.startSecretsManagerFn != nil {
- if err := descriptor.startSecretsManagerFn(descriptor.pluginID, c.SecretsManagerPlugin, logger); err != nil {
- return nil, err
- }
- }
-
return c, nil
}
@@ -191,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/backendplugin/provider/provider.go b/pkg/plugins/backendplugin/provider/provider.go
index 685c06a2df4..db9e3309a34 100644
--- a/pkg/plugins/backendplugin/provider/provider.go
+++ b/pkg/plugins/backendplugin/provider/provider.go
@@ -8,7 +8,6 @@ import (
"github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin"
"github.com/grafana/grafana/pkg/plugins/backendplugin/grpcplugin"
"github.com/grafana/grafana/pkg/plugins/backendplugin/pluginextensionv2"
- "github.com/grafana/grafana/pkg/plugins/backendplugin/secretsmanagerplugin"
"github.com/grafana/grafana/pkg/plugins/log"
)
@@ -21,7 +20,7 @@ type Service struct {
func New(providers ...PluginBackendProvider) *Service {
if len(providers) == 0 {
- return New(SecretsManagerProvider, DefaultProvider)
+ return New(DefaultProvider)
}
return &Service{
providerChain: providers,
@@ -29,7 +28,7 @@ func New(providers ...PluginBackendProvider) *Service {
}
func ProvideService(coreRegistry *coreplugin.Registry) *Service {
- return New(coreRegistry.BackendFactoryProvider(), SecretsManagerProvider, DefaultProvider)
+ return New(coreRegistry.BackendFactoryProvider(), DefaultProvider)
}
func (s *Service) BackendFactory(ctx context.Context, p *plugins.Plugin) backendplugin.PluginFactoryFunc {
@@ -53,18 +52,6 @@ var RendererProvider PluginBackendProvider = func(_ context.Context, p *plugins.
)
}
-var SecretsManagerProvider PluginBackendProvider = func(_ context.Context, p *plugins.Plugin) backendplugin.PluginFactoryFunc {
- if !p.IsSecretsManager() {
- return nil
- }
- return grpcplugin.NewSecretsManagerPlugin(p.ID, p.ExecutablePath(),
- func(pluginID string, secretsmanager secretsmanagerplugin.SecretsManagerPlugin, logger log.Logger) error {
- p.SecretsManager = secretsmanager
- return nil
- },
- )
-}
-
var DefaultProvider = PluginBackendProvider(func(_ context.Context, p *plugins.Plugin) backendplugin.PluginFactoryFunc {
return grpcplugin.NewBackendPlugin(p.ID, p.ExecutablePath(), p.SkipHostEnvVars)
})
diff --git a/pkg/plugins/backendplugin/secretsmanagerplugin/buf.gen.yaml b/pkg/plugins/backendplugin/secretsmanagerplugin/buf.gen.yaml
deleted file mode 100644
index 99beef65289..00000000000
--- a/pkg/plugins/backendplugin/secretsmanagerplugin/buf.gen.yaml
+++ /dev/null
@@ -1,9 +0,0 @@
-version: v1
-plugins:
- - plugin: go
- out: pkg/plugins/backendplugin/secretsmanagerplugin
- opt: paths=source_relative
- - plugin: go-grpc
- out: pkg/plugins/backendplugin/secretsmanagerplugin
- opt:
- - paths=source_relative
diff --git a/pkg/plugins/backendplugin/secretsmanagerplugin/buf.yaml b/pkg/plugins/backendplugin/secretsmanagerplugin/buf.yaml
deleted file mode 100644
index 1a5194568a9..00000000000
--- a/pkg/plugins/backendplugin/secretsmanagerplugin/buf.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-version: v1
-breaking:
- use:
- - FILE
-lint:
- use:
- - DEFAULT
diff --git a/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager.pb.go b/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager.pb.go
deleted file mode 100644
index bdecccee0d8..00000000000
--- a/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager.pb.go
+++ /dev/null
@@ -1,921 +0,0 @@
-// Code generated by protoc-gen-go. DO NOT EDIT.
-// versions:
-// protoc-gen-go v1.36.5
-// protoc (unknown)
-// source: secretsmanager.proto
-
-package secretsmanagerplugin
-
-import (
- protoreflect "google.golang.org/protobuf/reflect/protoreflect"
- protoimpl "google.golang.org/protobuf/runtime/protoimpl"
- reflect "reflect"
- sync "sync"
- unsafe "unsafe"
-)
-
-const (
- // Verify that this generated code is sufficiently up-to-date.
- _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
- // Verify that runtime/protoimpl is sufficiently up-to-date.
- _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
-)
-
-type Key struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- OrgId int64 `protobuf:"varint,1,opt,name=orgId,proto3" json:"orgId,omitempty"`
- Namespace string `protobuf:"bytes,2,opt,name=namespace,proto3" json:"namespace,omitempty"`
- Type string `protobuf:"bytes,3,opt,name=type,proto3" json:"type,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
-}
-
-func (x *Key) Reset() {
- *x = Key{}
- mi := &file_secretsmanager_proto_msgTypes[0]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *Key) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*Key) ProtoMessage() {}
-
-func (x *Key) ProtoReflect() protoreflect.Message {
- mi := &file_secretsmanager_proto_msgTypes[0]
- 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 Key.ProtoReflect.Descriptor instead.
-func (*Key) Descriptor() ([]byte, []int) {
- return file_secretsmanager_proto_rawDescGZIP(), []int{0}
-}
-
-func (x *Key) GetOrgId() int64 {
- if x != nil {
- return x.OrgId
- }
- return 0
-}
-
-func (x *Key) GetNamespace() string {
- if x != nil {
- return x.Namespace
- }
- return ""
-}
-
-func (x *Key) GetType() string {
- if x != nil {
- return x.Type
- }
- return ""
-}
-
-type Item struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- Key *Key `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"`
- Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
-}
-
-func (x *Item) Reset() {
- *x = Item{}
- mi := &file_secretsmanager_proto_msgTypes[1]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *Item) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*Item) ProtoMessage() {}
-
-func (x *Item) ProtoReflect() protoreflect.Message {
- mi := &file_secretsmanager_proto_msgTypes[1]
- 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 Item.ProtoReflect.Descriptor instead.
-func (*Item) Descriptor() ([]byte, []int) {
- return file_secretsmanager_proto_rawDescGZIP(), []int{1}
-}
-
-func (x *Item) GetKey() *Key {
- if x != nil {
- return x.Key
- }
- return nil
-}
-
-func (x *Item) GetValue() string {
- if x != nil {
- return x.Value
- }
- return ""
-}
-
-type GetSecretRequest struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- KeyDescriptor *Key `protobuf:"bytes,1,opt,name=keyDescriptor,proto3" json:"keyDescriptor,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
-}
-
-func (x *GetSecretRequest) Reset() {
- *x = GetSecretRequest{}
- mi := &file_secretsmanager_proto_msgTypes[2]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *GetSecretRequest) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*GetSecretRequest) ProtoMessage() {}
-
-func (x *GetSecretRequest) ProtoReflect() protoreflect.Message {
- mi := &file_secretsmanager_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 GetSecretRequest.ProtoReflect.Descriptor instead.
-func (*GetSecretRequest) Descriptor() ([]byte, []int) {
- return file_secretsmanager_proto_rawDescGZIP(), []int{2}
-}
-
-func (x *GetSecretRequest) GetKeyDescriptor() *Key {
- if x != nil {
- return x.KeyDescriptor
- }
- return nil
-}
-
-type GetSecretResponse struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- UserFriendlyError string `protobuf:"bytes,1,opt,name=userFriendlyError,proto3" json:"userFriendlyError,omitempty"`
- DecryptedValue string `protobuf:"bytes,2,opt,name=decryptedValue,proto3" json:"decryptedValue,omitempty"`
- Exists bool `protobuf:"varint,3,opt,name=exists,proto3" json:"exists,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
-}
-
-func (x *GetSecretResponse) Reset() {
- *x = GetSecretResponse{}
- mi := &file_secretsmanager_proto_msgTypes[3]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *GetSecretResponse) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*GetSecretResponse) ProtoMessage() {}
-
-func (x *GetSecretResponse) ProtoReflect() protoreflect.Message {
- mi := &file_secretsmanager_proto_msgTypes[3]
- 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 GetSecretResponse.ProtoReflect.Descriptor instead.
-func (*GetSecretResponse) Descriptor() ([]byte, []int) {
- return file_secretsmanager_proto_rawDescGZIP(), []int{3}
-}
-
-func (x *GetSecretResponse) GetUserFriendlyError() string {
- if x != nil {
- return x.UserFriendlyError
- }
- return ""
-}
-
-func (x *GetSecretResponse) GetDecryptedValue() string {
- if x != nil {
- return x.DecryptedValue
- }
- return ""
-}
-
-func (x *GetSecretResponse) GetExists() bool {
- if x != nil {
- return x.Exists
- }
- return false
-}
-
-type SetSecretRequest struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- KeyDescriptor *Key `protobuf:"bytes,1,opt,name=keyDescriptor,proto3" json:"keyDescriptor,omitempty"`
- Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
-}
-
-func (x *SetSecretRequest) Reset() {
- *x = SetSecretRequest{}
- mi := &file_secretsmanager_proto_msgTypes[4]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *SetSecretRequest) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*SetSecretRequest) ProtoMessage() {}
-
-func (x *SetSecretRequest) ProtoReflect() protoreflect.Message {
- mi := &file_secretsmanager_proto_msgTypes[4]
- 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 SetSecretRequest.ProtoReflect.Descriptor instead.
-func (*SetSecretRequest) Descriptor() ([]byte, []int) {
- return file_secretsmanager_proto_rawDescGZIP(), []int{4}
-}
-
-func (x *SetSecretRequest) GetKeyDescriptor() *Key {
- if x != nil {
- return x.KeyDescriptor
- }
- return nil
-}
-
-func (x *SetSecretRequest) GetValue() string {
- if x != nil {
- return x.Value
- }
- return ""
-}
-
-type SetSecretResponse struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- UserFriendlyError string `protobuf:"bytes,1,opt,name=userFriendlyError,proto3" json:"userFriendlyError,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
-}
-
-func (x *SetSecretResponse) Reset() {
- *x = SetSecretResponse{}
- mi := &file_secretsmanager_proto_msgTypes[5]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *SetSecretResponse) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*SetSecretResponse) ProtoMessage() {}
-
-func (x *SetSecretResponse) ProtoReflect() protoreflect.Message {
- mi := &file_secretsmanager_proto_msgTypes[5]
- 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 SetSecretResponse.ProtoReflect.Descriptor instead.
-func (*SetSecretResponse) Descriptor() ([]byte, []int) {
- return file_secretsmanager_proto_rawDescGZIP(), []int{5}
-}
-
-func (x *SetSecretResponse) GetUserFriendlyError() string {
- if x != nil {
- return x.UserFriendlyError
- }
- return ""
-}
-
-type DeleteSecretRequest struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- KeyDescriptor *Key `protobuf:"bytes,1,opt,name=keyDescriptor,proto3" json:"keyDescriptor,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
-}
-
-func (x *DeleteSecretRequest) Reset() {
- *x = DeleteSecretRequest{}
- mi := &file_secretsmanager_proto_msgTypes[6]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *DeleteSecretRequest) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*DeleteSecretRequest) ProtoMessage() {}
-
-func (x *DeleteSecretRequest) ProtoReflect() protoreflect.Message {
- mi := &file_secretsmanager_proto_msgTypes[6]
- 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 DeleteSecretRequest.ProtoReflect.Descriptor instead.
-func (*DeleteSecretRequest) Descriptor() ([]byte, []int) {
- return file_secretsmanager_proto_rawDescGZIP(), []int{6}
-}
-
-func (x *DeleteSecretRequest) GetKeyDescriptor() *Key {
- if x != nil {
- return x.KeyDescriptor
- }
- return nil
-}
-
-type DeleteSecretResponse struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- UserFriendlyError string `protobuf:"bytes,1,opt,name=userFriendlyError,proto3" json:"userFriendlyError,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
-}
-
-func (x *DeleteSecretResponse) Reset() {
- *x = DeleteSecretResponse{}
- mi := &file_secretsmanager_proto_msgTypes[7]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *DeleteSecretResponse) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*DeleteSecretResponse) ProtoMessage() {}
-
-func (x *DeleteSecretResponse) ProtoReflect() protoreflect.Message {
- mi := &file_secretsmanager_proto_msgTypes[7]
- 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 DeleteSecretResponse.ProtoReflect.Descriptor instead.
-func (*DeleteSecretResponse) Descriptor() ([]byte, []int) {
- return file_secretsmanager_proto_rawDescGZIP(), []int{7}
-}
-
-func (x *DeleteSecretResponse) GetUserFriendlyError() string {
- if x != nil {
- return x.UserFriendlyError
- }
- return ""
-}
-
-type ListSecretsRequest struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- KeyDescriptor *Key `protobuf:"bytes,1,opt,name=keyDescriptor,proto3" json:"keyDescriptor,omitempty"`
- AllOrganizations bool `protobuf:"varint,2,opt,name=allOrganizations,proto3" json:"allOrganizations,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
-}
-
-func (x *ListSecretsRequest) Reset() {
- *x = ListSecretsRequest{}
- mi := &file_secretsmanager_proto_msgTypes[8]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *ListSecretsRequest) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*ListSecretsRequest) ProtoMessage() {}
-
-func (x *ListSecretsRequest) ProtoReflect() protoreflect.Message {
- mi := &file_secretsmanager_proto_msgTypes[8]
- 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 ListSecretsRequest.ProtoReflect.Descriptor instead.
-func (*ListSecretsRequest) Descriptor() ([]byte, []int) {
- return file_secretsmanager_proto_rawDescGZIP(), []int{8}
-}
-
-func (x *ListSecretsRequest) GetKeyDescriptor() *Key {
- if x != nil {
- return x.KeyDescriptor
- }
- return nil
-}
-
-func (x *ListSecretsRequest) GetAllOrganizations() bool {
- if x != nil {
- return x.AllOrganizations
- }
- return false
-}
-
-type ListSecretsResponse struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- UserFriendlyError string `protobuf:"bytes,1,opt,name=userFriendlyError,proto3" json:"userFriendlyError,omitempty"`
- Keys []*Key `protobuf:"bytes,2,rep,name=keys,proto3" json:"keys,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
-}
-
-func (x *ListSecretsResponse) Reset() {
- *x = ListSecretsResponse{}
- mi := &file_secretsmanager_proto_msgTypes[9]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *ListSecretsResponse) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*ListSecretsResponse) ProtoMessage() {}
-
-func (x *ListSecretsResponse) ProtoReflect() protoreflect.Message {
- mi := &file_secretsmanager_proto_msgTypes[9]
- 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 ListSecretsResponse.ProtoReflect.Descriptor instead.
-func (*ListSecretsResponse) Descriptor() ([]byte, []int) {
- return file_secretsmanager_proto_rawDescGZIP(), []int{9}
-}
-
-func (x *ListSecretsResponse) GetUserFriendlyError() string {
- if x != nil {
- return x.UserFriendlyError
- }
- return ""
-}
-
-func (x *ListSecretsResponse) GetKeys() []*Key {
- if x != nil {
- return x.Keys
- }
- return nil
-}
-
-type GetAllSecretsRequest struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
-}
-
-func (x *GetAllSecretsRequest) Reset() {
- *x = GetAllSecretsRequest{}
- mi := &file_secretsmanager_proto_msgTypes[10]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *GetAllSecretsRequest) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*GetAllSecretsRequest) ProtoMessage() {}
-
-func (x *GetAllSecretsRequest) ProtoReflect() protoreflect.Message {
- mi := &file_secretsmanager_proto_msgTypes[10]
- 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 GetAllSecretsRequest.ProtoReflect.Descriptor instead.
-func (*GetAllSecretsRequest) Descriptor() ([]byte, []int) {
- return file_secretsmanager_proto_rawDescGZIP(), []int{10}
-}
-
-type GetAllSecretsResponse struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- UserFriendlyError string `protobuf:"bytes,1,opt,name=userFriendlyError,proto3" json:"userFriendlyError,omitempty"`
- Items []*Item `protobuf:"bytes,2,rep,name=items,proto3" json:"items,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
-}
-
-func (x *GetAllSecretsResponse) Reset() {
- *x = GetAllSecretsResponse{}
- mi := &file_secretsmanager_proto_msgTypes[11]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *GetAllSecretsResponse) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*GetAllSecretsResponse) ProtoMessage() {}
-
-func (x *GetAllSecretsResponse) ProtoReflect() protoreflect.Message {
- mi := &file_secretsmanager_proto_msgTypes[11]
- 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 GetAllSecretsResponse.ProtoReflect.Descriptor instead.
-func (*GetAllSecretsResponse) Descriptor() ([]byte, []int) {
- return file_secretsmanager_proto_rawDescGZIP(), []int{11}
-}
-
-func (x *GetAllSecretsResponse) GetUserFriendlyError() string {
- if x != nil {
- return x.UserFriendlyError
- }
- return ""
-}
-
-func (x *GetAllSecretsResponse) GetItems() []*Item {
- if x != nil {
- return x.Items
- }
- return nil
-}
-
-type RenameSecretRequest struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- KeyDescriptor *Key `protobuf:"bytes,1,opt,name=keyDescriptor,proto3" json:"keyDescriptor,omitempty"`
- NewNamespace string `protobuf:"bytes,2,opt,name=newNamespace,proto3" json:"newNamespace,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
-}
-
-func (x *RenameSecretRequest) Reset() {
- *x = RenameSecretRequest{}
- mi := &file_secretsmanager_proto_msgTypes[12]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *RenameSecretRequest) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*RenameSecretRequest) ProtoMessage() {}
-
-func (x *RenameSecretRequest) ProtoReflect() protoreflect.Message {
- mi := &file_secretsmanager_proto_msgTypes[12]
- 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 RenameSecretRequest.ProtoReflect.Descriptor instead.
-func (*RenameSecretRequest) Descriptor() ([]byte, []int) {
- return file_secretsmanager_proto_rawDescGZIP(), []int{12}
-}
-
-func (x *RenameSecretRequest) GetKeyDescriptor() *Key {
- if x != nil {
- return x.KeyDescriptor
- }
- return nil
-}
-
-func (x *RenameSecretRequest) GetNewNamespace() string {
- if x != nil {
- return x.NewNamespace
- }
- return ""
-}
-
-type RenameSecretResponse struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- UserFriendlyError string `protobuf:"bytes,1,opt,name=userFriendlyError,proto3" json:"userFriendlyError,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
-}
-
-func (x *RenameSecretResponse) Reset() {
- *x = RenameSecretResponse{}
- mi := &file_secretsmanager_proto_msgTypes[13]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *RenameSecretResponse) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*RenameSecretResponse) ProtoMessage() {}
-
-func (x *RenameSecretResponse) ProtoReflect() protoreflect.Message {
- mi := &file_secretsmanager_proto_msgTypes[13]
- 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 RenameSecretResponse.ProtoReflect.Descriptor instead.
-func (*RenameSecretResponse) Descriptor() ([]byte, []int) {
- return file_secretsmanager_proto_rawDescGZIP(), []int{13}
-}
-
-func (x *RenameSecretResponse) GetUserFriendlyError() string {
- if x != nil {
- return x.UserFriendlyError
- }
- return ""
-}
-
-var File_secretsmanager_proto protoreflect.FileDescriptor
-
-var file_secretsmanager_proto_rawDesc = string([]byte{
- 0x0a, 0x14, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72,
- 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x14, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d,
- 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x22, 0x4d, 0x0a, 0x03,
- 0x4b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x6f, 0x72, 0x67, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01,
- 0x28, 0x03, 0x52, 0x05, 0x6f, 0x72, 0x67, 0x49, 0x64, 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, 0x74, 0x79, 0x70, 0x65, 0x18,
- 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0x49, 0x0a, 0x04, 0x49,
- 0x74, 0x65, 0x6d, 0x12, 0x2b, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b,
- 0x32, 0x19, 0x2e, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
- 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x4b, 0x65, 0x79, 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, 0x22, 0x53, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x53, 0x65, 0x63,
- 0x72, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x3f, 0x0a, 0x0d, 0x6b, 0x65,
- 0x79, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28,
- 0x0b, 0x32, 0x19, 0x2e, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67,
- 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x4b, 0x65, 0x79, 0x52, 0x0d, 0x6b, 0x65,
- 0x79, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x22, 0x81, 0x01, 0x0a, 0x11,
- 0x47, 0x65, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
- 0x65, 0x12, 0x2c, 0x0a, 0x11, 0x75, 0x73, 0x65, 0x72, 0x46, 0x72, 0x69, 0x65, 0x6e, 0x64, 0x6c,
- 0x79, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x75, 0x73,
- 0x65, 0x72, 0x46, 0x72, 0x69, 0x65, 0x6e, 0x64, 0x6c, 0x79, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x12,
- 0x26, 0x0a, 0x0e, 0x64, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75,
- 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x64, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74,
- 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x65, 0x78, 0x69, 0x73, 0x74,
- 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x65, 0x78, 0x69, 0x73, 0x74, 0x73, 0x22,
- 0x69, 0x0a, 0x10, 0x53, 0x65, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75,
- 0x65, 0x73, 0x74, 0x12, 0x3f, 0x0a, 0x0d, 0x6b, 0x65, 0x79, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69,
- 0x70, 0x74, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x73, 0x65, 0x63,
- 0x72, 0x65, 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69,
- 0x6e, 0x2e, 0x4b, 0x65, 0x79, 0x52, 0x0d, 0x6b, 0x65, 0x79, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69,
- 0x70, 0x74, 0x6f, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20,
- 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x41, 0x0a, 0x11, 0x53, 0x65,
- 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
- 0x2c, 0x0a, 0x11, 0x75, 0x73, 0x65, 0x72, 0x46, 0x72, 0x69, 0x65, 0x6e, 0x64, 0x6c, 0x79, 0x45,
- 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x75, 0x73, 0x65, 0x72,
- 0x46, 0x72, 0x69, 0x65, 0x6e, 0x64, 0x6c, 0x79, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x56, 0x0a,
- 0x13, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x52, 0x65, 0x71,
- 0x75, 0x65, 0x73, 0x74, 0x12, 0x3f, 0x0a, 0x0d, 0x6b, 0x65, 0x79, 0x44, 0x65, 0x73, 0x63, 0x72,
- 0x69, 0x70, 0x74, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x73, 0x65,
- 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67,
- 0x69, 0x6e, 0x2e, 0x4b, 0x65, 0x79, 0x52, 0x0d, 0x6b, 0x65, 0x79, 0x44, 0x65, 0x73, 0x63, 0x72,
- 0x69, 0x70, 0x74, 0x6f, 0x72, 0x22, 0x44, 0x0a, 0x14, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53,
- 0x65, 0x63, 0x72, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2c, 0x0a,
- 0x11, 0x75, 0x73, 0x65, 0x72, 0x46, 0x72, 0x69, 0x65, 0x6e, 0x64, 0x6c, 0x79, 0x45, 0x72, 0x72,
- 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x75, 0x73, 0x65, 0x72, 0x46, 0x72,
- 0x69, 0x65, 0x6e, 0x64, 0x6c, 0x79, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x81, 0x01, 0x0a, 0x12,
- 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65,
- 0x73, 0x74, 0x12, 0x3f, 0x0a, 0x0d, 0x6b, 0x65, 0x79, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70,
- 0x74, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x73, 0x65, 0x63, 0x72,
- 0x65, 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e,
- 0x2e, 0x4b, 0x65, 0x79, 0x52, 0x0d, 0x6b, 0x65, 0x79, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70,
- 0x74, 0x6f, 0x72, 0x12, 0x2a, 0x0a, 0x10, 0x61, 0x6c, 0x6c, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69,
- 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x61,
- 0x6c, 0x6c, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22,
- 0x72, 0x0a, 0x13, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x52, 0x65,
- 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2c, 0x0a, 0x11, 0x75, 0x73, 0x65, 0x72, 0x46, 0x72,
- 0x69, 0x65, 0x6e, 0x64, 0x6c, 0x79, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28,
- 0x09, 0x52, 0x11, 0x75, 0x73, 0x65, 0x72, 0x46, 0x72, 0x69, 0x65, 0x6e, 0x64, 0x6c, 0x79, 0x45,
- 0x72, 0x72, 0x6f, 0x72, 0x12, 0x2d, 0x0a, 0x04, 0x6b, 0x65, 0x79, 0x73, 0x18, 0x02, 0x20, 0x03,
- 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61,
- 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x4b, 0x65, 0x79, 0x52, 0x04, 0x6b,
- 0x65, 0x79, 0x73, 0x22, 0x16, 0x0a, 0x14, 0x47, 0x65, 0x74, 0x41, 0x6c, 0x6c, 0x53, 0x65, 0x63,
- 0x72, 0x65, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x77, 0x0a, 0x15, 0x47,
- 0x65, 0x74, 0x41, 0x6c, 0x6c, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70,
- 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2c, 0x0a, 0x11, 0x75, 0x73, 0x65, 0x72, 0x46, 0x72, 0x69, 0x65,
- 0x6e, 0x64, 0x6c, 0x79, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
- 0x11, 0x75, 0x73, 0x65, 0x72, 0x46, 0x72, 0x69, 0x65, 0x6e, 0x64, 0x6c, 0x79, 0x45, 0x72, 0x72,
- 0x6f, 0x72, 0x12, 0x30, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28,
- 0x0b, 0x32, 0x1a, 0x2e, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67,
- 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x49, 0x74, 0x65, 0x6d, 0x52, 0x05, 0x69,
- 0x74, 0x65, 0x6d, 0x73, 0x22, 0x7a, 0x0a, 0x13, 0x52, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x53, 0x65,
- 0x63, 0x72, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x3f, 0x0a, 0x0d, 0x6b,
- 0x65, 0x79, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01,
- 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61,
- 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x4b, 0x65, 0x79, 0x52, 0x0d, 0x6b,
- 0x65, 0x79, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x12, 0x22, 0x0a, 0x0c,
- 0x6e, 0x65, 0x77, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01,
- 0x28, 0x09, 0x52, 0x0c, 0x6e, 0x65, 0x77, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65,
- 0x22, 0x44, 0x0a, 0x14, 0x52, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74,
- 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2c, 0x0a, 0x11, 0x75, 0x73, 0x65, 0x72,
- 0x46, 0x72, 0x69, 0x65, 0x6e, 0x64, 0x6c, 0x79, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20,
- 0x01, 0x28, 0x09, 0x52, 0x11, 0x75, 0x73, 0x65, 0x72, 0x46, 0x72, 0x69, 0x65, 0x6e, 0x64, 0x6c,
- 0x79, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x32, 0xe8, 0x04, 0x0a, 0x0e, 0x53, 0x65, 0x63, 0x72, 0x65,
- 0x74, 0x73, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x12, 0x5c, 0x0a, 0x09, 0x47, 0x65, 0x74,
- 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x26, 0x2e, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73,
- 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x47, 0x65,
- 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27,
- 0x2e, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70,
- 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x52,
- 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5c, 0x0a, 0x09, 0x53, 0x65, 0x74, 0x53, 0x65,
- 0x63, 0x72, 0x65, 0x74, 0x12, 0x26, 0x2e, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d, 0x61,
- 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x53, 0x65, 0x74, 0x53,
- 0x65, 0x63, 0x72, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x73,
- 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75,
- 0x67, 0x69, 0x6e, 0x2e, 0x53, 0x65, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x52, 0x65, 0x73,
- 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x65, 0x0a, 0x0c, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53,
- 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x29, 0x2e, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d,
- 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x44, 0x65, 0x6c,
- 0x65, 0x74, 0x65, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
- 0x1a, 0x2a, 0x2e, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
- 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x65,
- 0x63, 0x72, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x62, 0x0a, 0x0b,
- 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x12, 0x28, 0x2e, 0x73, 0x65,
- 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67,
- 0x69, 0x6e, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x52, 0x65,
- 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x29, 0x2e, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d,
- 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x4c, 0x69, 0x73,
- 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
- 0x12, 0x65, 0x0a, 0x0c, 0x52, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74,
- 0x12, 0x29, 0x2e, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
- 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x52, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x53, 0x65,
- 0x63, 0x72, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x73, 0x65,
- 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67,
- 0x69, 0x6e, 0x2e, 0x52, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x52,
- 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, 0x0d, 0x47, 0x65, 0x74, 0x41, 0x6c,
- 0x6c, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x12, 0x2a, 0x2e, 0x73, 0x65, 0x63, 0x72, 0x65,
- 0x74, 0x73, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e,
- 0x47, 0x65, 0x74, 0x41, 0x6c, 0x6c, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x52, 0x65, 0x71,
- 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d, 0x61,
- 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x47, 0x65, 0x74, 0x41,
- 0x6c, 0x6c, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
- 0x65, 0x42, 0x19, 0x5a, 0x17, 0x2e, 0x2f, 0x3b, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x6d,
- 0x61, 0x6e, 0x61, 0x67, 0x65, 0x72, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x62, 0x06, 0x70, 0x72,
- 0x6f, 0x74, 0x6f, 0x33,
-})
-
-var (
- file_secretsmanager_proto_rawDescOnce sync.Once
- file_secretsmanager_proto_rawDescData []byte
-)
-
-func file_secretsmanager_proto_rawDescGZIP() []byte {
- file_secretsmanager_proto_rawDescOnce.Do(func() {
- file_secretsmanager_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_secretsmanager_proto_rawDesc), len(file_secretsmanager_proto_rawDesc)))
- })
- return file_secretsmanager_proto_rawDescData
-}
-
-var file_secretsmanager_proto_msgTypes = make([]protoimpl.MessageInfo, 14)
-var file_secretsmanager_proto_goTypes = []any{
- (*Key)(nil), // 0: secretsmanagerplugin.Key
- (*Item)(nil), // 1: secretsmanagerplugin.Item
- (*GetSecretRequest)(nil), // 2: secretsmanagerplugin.GetSecretRequest
- (*GetSecretResponse)(nil), // 3: secretsmanagerplugin.GetSecretResponse
- (*SetSecretRequest)(nil), // 4: secretsmanagerplugin.SetSecretRequest
- (*SetSecretResponse)(nil), // 5: secretsmanagerplugin.SetSecretResponse
- (*DeleteSecretRequest)(nil), // 6: secretsmanagerplugin.DeleteSecretRequest
- (*DeleteSecretResponse)(nil), // 7: secretsmanagerplugin.DeleteSecretResponse
- (*ListSecretsRequest)(nil), // 8: secretsmanagerplugin.ListSecretsRequest
- (*ListSecretsResponse)(nil), // 9: secretsmanagerplugin.ListSecretsResponse
- (*GetAllSecretsRequest)(nil), // 10: secretsmanagerplugin.GetAllSecretsRequest
- (*GetAllSecretsResponse)(nil), // 11: secretsmanagerplugin.GetAllSecretsResponse
- (*RenameSecretRequest)(nil), // 12: secretsmanagerplugin.RenameSecretRequest
- (*RenameSecretResponse)(nil), // 13: secretsmanagerplugin.RenameSecretResponse
-}
-var file_secretsmanager_proto_depIdxs = []int32{
- 0, // 0: secretsmanagerplugin.Item.key:type_name -> secretsmanagerplugin.Key
- 0, // 1: secretsmanagerplugin.GetSecretRequest.keyDescriptor:type_name -> secretsmanagerplugin.Key
- 0, // 2: secretsmanagerplugin.SetSecretRequest.keyDescriptor:type_name -> secretsmanagerplugin.Key
- 0, // 3: secretsmanagerplugin.DeleteSecretRequest.keyDescriptor:type_name -> secretsmanagerplugin.Key
- 0, // 4: secretsmanagerplugin.ListSecretsRequest.keyDescriptor:type_name -> secretsmanagerplugin.Key
- 0, // 5: secretsmanagerplugin.ListSecretsResponse.keys:type_name -> secretsmanagerplugin.Key
- 1, // 6: secretsmanagerplugin.GetAllSecretsResponse.items:type_name -> secretsmanagerplugin.Item
- 0, // 7: secretsmanagerplugin.RenameSecretRequest.keyDescriptor:type_name -> secretsmanagerplugin.Key
- 2, // 8: secretsmanagerplugin.SecretsManager.GetSecret:input_type -> secretsmanagerplugin.GetSecretRequest
- 4, // 9: secretsmanagerplugin.SecretsManager.SetSecret:input_type -> secretsmanagerplugin.SetSecretRequest
- 6, // 10: secretsmanagerplugin.SecretsManager.DeleteSecret:input_type -> secretsmanagerplugin.DeleteSecretRequest
- 8, // 11: secretsmanagerplugin.SecretsManager.ListSecrets:input_type -> secretsmanagerplugin.ListSecretsRequest
- 12, // 12: secretsmanagerplugin.SecretsManager.RenameSecret:input_type -> secretsmanagerplugin.RenameSecretRequest
- 10, // 13: secretsmanagerplugin.SecretsManager.GetAllSecrets:input_type -> secretsmanagerplugin.GetAllSecretsRequest
- 3, // 14: secretsmanagerplugin.SecretsManager.GetSecret:output_type -> secretsmanagerplugin.GetSecretResponse
- 5, // 15: secretsmanagerplugin.SecretsManager.SetSecret:output_type -> secretsmanagerplugin.SetSecretResponse
- 7, // 16: secretsmanagerplugin.SecretsManager.DeleteSecret:output_type -> secretsmanagerplugin.DeleteSecretResponse
- 9, // 17: secretsmanagerplugin.SecretsManager.ListSecrets:output_type -> secretsmanagerplugin.ListSecretsResponse
- 13, // 18: secretsmanagerplugin.SecretsManager.RenameSecret:output_type -> secretsmanagerplugin.RenameSecretResponse
- 11, // 19: secretsmanagerplugin.SecretsManager.GetAllSecrets:output_type -> secretsmanagerplugin.GetAllSecretsResponse
- 14, // [14:20] is the sub-list for method output_type
- 8, // [8:14] is the sub-list for method input_type
- 8, // [8:8] is the sub-list for extension type_name
- 8, // [8:8] is the sub-list for extension extendee
- 0, // [0:8] is the sub-list for field type_name
-}
-
-func init() { file_secretsmanager_proto_init() }
-func file_secretsmanager_proto_init() {
- if File_secretsmanager_proto != nil {
- return
- }
- type x struct{}
- out := protoimpl.TypeBuilder{
- File: protoimpl.DescBuilder{
- GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
- RawDescriptor: unsafe.Slice(unsafe.StringData(file_secretsmanager_proto_rawDesc), len(file_secretsmanager_proto_rawDesc)),
- NumEnums: 0,
- NumMessages: 14,
- NumExtensions: 0,
- NumServices: 1,
- },
- GoTypes: file_secretsmanager_proto_goTypes,
- DependencyIndexes: file_secretsmanager_proto_depIdxs,
- MessageInfos: file_secretsmanager_proto_msgTypes,
- }.Build()
- File_secretsmanager_proto = out.File
- file_secretsmanager_proto_goTypes = nil
- file_secretsmanager_proto_depIdxs = nil
-}
diff --git a/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager.proto b/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager.proto
deleted file mode 100644
index d967a05fa7b..00000000000
--- a/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager.proto
+++ /dev/null
@@ -1,78 +0,0 @@
-syntax = "proto3";
-package secretsmanagerplugin;
-
-option go_package = "./;secretsmanagerplugin";
-
-message Key {
- int64 orgId = 1;
- string namespace = 2;
- string type = 3;
-}
-
-message Item {
- Key key = 1;
- string value = 2;
-}
-
-message GetSecretRequest {
- Key keyDescriptor = 1;
-}
-
-message GetSecretResponse {
- string userFriendlyError = 1;
- string decryptedValue = 2;
- bool exists = 3;
-}
-
-message SetSecretRequest {
- Key keyDescriptor = 1;
- string value = 2;
-}
-
-message SetSecretResponse {
- string userFriendlyError = 1;
-}
-
-message DeleteSecretRequest {
- Key keyDescriptor = 1;
-}
-
-message DeleteSecretResponse {
- string userFriendlyError = 1;
-}
-
-message ListSecretsRequest {
- Key keyDescriptor = 1;
- bool allOrganizations = 2;
-}
-
-message ListSecretsResponse {
- string userFriendlyError = 1;
- repeated Key keys = 2;
-}
-
-message GetAllSecretsRequest {
-}
-
-message GetAllSecretsResponse {
- string userFriendlyError = 1;
- repeated Item items = 2;
-}
-
-message RenameSecretRequest {
- Key keyDescriptor = 1;
- string newNamespace = 2;
-}
-
-message RenameSecretResponse {
- string userFriendlyError = 1;
-}
-
-service SecretsManager {
- rpc GetSecret(GetSecretRequest) returns (GetSecretResponse);
- rpc SetSecret(SetSecretRequest) returns (SetSecretResponse);
- rpc DeleteSecret(DeleteSecretRequest) returns (DeleteSecretResponse);
- rpc ListSecrets(ListSecretsRequest) returns (ListSecretsResponse);
- rpc RenameSecret(RenameSecretRequest) returns (RenameSecretResponse);
- rpc GetAllSecrets(GetAllSecretsRequest) returns (GetAllSecretsResponse);
-}
diff --git a/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager_grcp_plugin.go b/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager_grcp_plugin.go
deleted file mode 100644
index ac65764e928..00000000000
--- a/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager_grcp_plugin.go
+++ /dev/null
@@ -1,61 +0,0 @@
-package secretsmanagerplugin
-
-import (
- "context"
-
- "github.com/hashicorp/go-plugin"
- "google.golang.org/grpc"
-)
-
-type SecretsManagerPlugin interface {
- SecretsManagerClient
-}
-
-type SecretsManagerGRPCPlugin struct {
- plugin.NetRPCUnsupportedPlugin
-}
-
-func (p *SecretsManagerGRPCPlugin) GRPCServer(broker *plugin.GRPCBroker, s *grpc.Server) error {
- return nil
-}
-
-func (p *SecretsManagerGRPCPlugin) GRPCClient(ctx context.Context, broker *plugin.GRPCBroker, c *grpc.ClientConn) (any, error) {
- return &SecretsManagerGRPCClient{NewSecretsManagerClient(c)}, nil
-}
-
-type SecretsManagerGRPCClient struct {
- SecretsManagerClient
-}
-
-// Get an item from the store
-func (sm *SecretsManagerGRPCClient) GetSecret(ctx context.Context, req *GetSecretRequest, opts ...grpc.CallOption) (*GetSecretResponse, error) {
- return sm.SecretsManagerClient.GetSecret(ctx, req)
-}
-
-// Set an item in the store
-func (sm *SecretsManagerGRPCClient) SetSecret(ctx context.Context, req *SetSecretRequest, opts ...grpc.CallOption) (*SetSecretResponse, error) {
- return sm.SecretsManagerClient.SetSecret(ctx, req)
-}
-
-// Del deletes an item from the store.
-func (sm *SecretsManagerGRPCClient) DeleteSecret(ctx context.Context, req *DeleteSecretRequest, opts ...grpc.CallOption) (*DeleteSecretResponse, error) {
- return sm.SecretsManagerClient.DeleteSecret(ctx, req)
-}
-
-// Keys get all keys for a given namespace.
-func (sm *SecretsManagerGRPCClient) ListSecrets(ctx context.Context, req *ListSecretsRequest, opts ...grpc.CallOption) (*ListSecretsResponse, error) {
- return sm.SecretsManagerClient.ListSecrets(ctx, req)
-}
-
-// Rename an item in the store
-func (sm *SecretsManagerGRPCClient) RenameSecret(ctx context.Context, req *RenameSecretRequest, opts ...grpc.CallOption) (*RenameSecretResponse, error) {
- return sm.SecretsManagerClient.RenameSecret(ctx, req)
-}
-
-// Get all items from the store
-func (sm *SecretsManagerGRPCClient) GetAllSecrets(ctx context.Context, req *GetAllSecretsRequest, opts ...grpc.CallOption) (*GetAllSecretsResponse, error) {
- return sm.SecretsManagerClient.GetAllSecrets(ctx, req)
-}
-
-var _ SecretsManagerClient = &SecretsManagerGRPCClient{}
-var _ plugin.GRPCPlugin = &SecretsManagerGRPCPlugin{}
diff --git a/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager_grpc.pb.go b/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager_grpc.pb.go
deleted file mode 100644
index 3f23d1715fe..00000000000
--- a/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager_grpc.pb.go
+++ /dev/null
@@ -1,300 +0,0 @@
-// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
-// versions:
-// - protoc-gen-go-grpc v1.4.0
-// - protoc (unknown)
-// source: secretsmanager.proto
-
-package secretsmanagerplugin
-
-import (
- context "context"
- grpc "google.golang.org/grpc"
- codes "google.golang.org/grpc/codes"
- status "google.golang.org/grpc/status"
-)
-
-// This is a compile-time assertion to ensure that this generated file
-// is compatible with the grpc package it is being compiled against.
-// Requires gRPC-Go v1.62.0 or later.
-const _ = grpc.SupportPackageIsVersion8
-
-const (
- SecretsManager_GetSecret_FullMethodName = "/secretsmanagerplugin.SecretsManager/GetSecret"
- SecretsManager_SetSecret_FullMethodName = "/secretsmanagerplugin.SecretsManager/SetSecret"
- SecretsManager_DeleteSecret_FullMethodName = "/secretsmanagerplugin.SecretsManager/DeleteSecret"
- SecretsManager_ListSecrets_FullMethodName = "/secretsmanagerplugin.SecretsManager/ListSecrets"
- SecretsManager_RenameSecret_FullMethodName = "/secretsmanagerplugin.SecretsManager/RenameSecret"
- SecretsManager_GetAllSecrets_FullMethodName = "/secretsmanagerplugin.SecretsManager/GetAllSecrets"
-)
-
-// SecretsManagerClient is the client API for SecretsManager service.
-//
-// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
-type SecretsManagerClient interface {
- GetSecret(ctx context.Context, in *GetSecretRequest, opts ...grpc.CallOption) (*GetSecretResponse, error)
- SetSecret(ctx context.Context, in *SetSecretRequest, opts ...grpc.CallOption) (*SetSecretResponse, error)
- DeleteSecret(ctx context.Context, in *DeleteSecretRequest, opts ...grpc.CallOption) (*DeleteSecretResponse, error)
- ListSecrets(ctx context.Context, in *ListSecretsRequest, opts ...grpc.CallOption) (*ListSecretsResponse, error)
- RenameSecret(ctx context.Context, in *RenameSecretRequest, opts ...grpc.CallOption) (*RenameSecretResponse, error)
- GetAllSecrets(ctx context.Context, in *GetAllSecretsRequest, opts ...grpc.CallOption) (*GetAllSecretsResponse, error)
-}
-
-type secretsManagerClient struct {
- cc grpc.ClientConnInterface
-}
-
-func NewSecretsManagerClient(cc grpc.ClientConnInterface) SecretsManagerClient {
- return &secretsManagerClient{cc}
-}
-
-func (c *secretsManagerClient) GetSecret(ctx context.Context, in *GetSecretRequest, opts ...grpc.CallOption) (*GetSecretResponse, error) {
- cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
- out := new(GetSecretResponse)
- err := c.cc.Invoke(ctx, SecretsManager_GetSecret_FullMethodName, in, out, cOpts...)
- if err != nil {
- return nil, err
- }
- return out, nil
-}
-
-func (c *secretsManagerClient) SetSecret(ctx context.Context, in *SetSecretRequest, opts ...grpc.CallOption) (*SetSecretResponse, error) {
- cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
- out := new(SetSecretResponse)
- err := c.cc.Invoke(ctx, SecretsManager_SetSecret_FullMethodName, in, out, cOpts...)
- if err != nil {
- return nil, err
- }
- return out, nil
-}
-
-func (c *secretsManagerClient) DeleteSecret(ctx context.Context, in *DeleteSecretRequest, opts ...grpc.CallOption) (*DeleteSecretResponse, error) {
- cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
- out := new(DeleteSecretResponse)
- err := c.cc.Invoke(ctx, SecretsManager_DeleteSecret_FullMethodName, in, out, cOpts...)
- if err != nil {
- return nil, err
- }
- return out, nil
-}
-
-func (c *secretsManagerClient) ListSecrets(ctx context.Context, in *ListSecretsRequest, opts ...grpc.CallOption) (*ListSecretsResponse, error) {
- cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
- out := new(ListSecretsResponse)
- err := c.cc.Invoke(ctx, SecretsManager_ListSecrets_FullMethodName, in, out, cOpts...)
- if err != nil {
- return nil, err
- }
- return out, nil
-}
-
-func (c *secretsManagerClient) RenameSecret(ctx context.Context, in *RenameSecretRequest, opts ...grpc.CallOption) (*RenameSecretResponse, error) {
- cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
- out := new(RenameSecretResponse)
- err := c.cc.Invoke(ctx, SecretsManager_RenameSecret_FullMethodName, in, out, cOpts...)
- if err != nil {
- return nil, err
- }
- return out, nil
-}
-
-func (c *secretsManagerClient) GetAllSecrets(ctx context.Context, in *GetAllSecretsRequest, opts ...grpc.CallOption) (*GetAllSecretsResponse, error) {
- cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
- out := new(GetAllSecretsResponse)
- err := c.cc.Invoke(ctx, SecretsManager_GetAllSecrets_FullMethodName, in, out, cOpts...)
- if err != nil {
- return nil, err
- }
- return out, nil
-}
-
-// SecretsManagerServer is the server API for SecretsManager service.
-// All implementations must embed UnimplementedSecretsManagerServer
-// for forward compatibility
-type SecretsManagerServer interface {
- GetSecret(context.Context, *GetSecretRequest) (*GetSecretResponse, error)
- SetSecret(context.Context, *SetSecretRequest) (*SetSecretResponse, error)
- DeleteSecret(context.Context, *DeleteSecretRequest) (*DeleteSecretResponse, error)
- ListSecrets(context.Context, *ListSecretsRequest) (*ListSecretsResponse, error)
- RenameSecret(context.Context, *RenameSecretRequest) (*RenameSecretResponse, error)
- GetAllSecrets(context.Context, *GetAllSecretsRequest) (*GetAllSecretsResponse, error)
- mustEmbedUnimplementedSecretsManagerServer()
-}
-
-// UnimplementedSecretsManagerServer must be embedded to have forward compatible implementations.
-type UnimplementedSecretsManagerServer struct {
-}
-
-func (UnimplementedSecretsManagerServer) GetSecret(context.Context, *GetSecretRequest) (*GetSecretResponse, error) {
- return nil, status.Errorf(codes.Unimplemented, "method GetSecret not implemented")
-}
-func (UnimplementedSecretsManagerServer) SetSecret(context.Context, *SetSecretRequest) (*SetSecretResponse, error) {
- return nil, status.Errorf(codes.Unimplemented, "method SetSecret not implemented")
-}
-func (UnimplementedSecretsManagerServer) DeleteSecret(context.Context, *DeleteSecretRequest) (*DeleteSecretResponse, error) {
- return nil, status.Errorf(codes.Unimplemented, "method DeleteSecret not implemented")
-}
-func (UnimplementedSecretsManagerServer) ListSecrets(context.Context, *ListSecretsRequest) (*ListSecretsResponse, error) {
- return nil, status.Errorf(codes.Unimplemented, "method ListSecrets not implemented")
-}
-func (UnimplementedSecretsManagerServer) RenameSecret(context.Context, *RenameSecretRequest) (*RenameSecretResponse, error) {
- return nil, status.Errorf(codes.Unimplemented, "method RenameSecret not implemented")
-}
-func (UnimplementedSecretsManagerServer) GetAllSecrets(context.Context, *GetAllSecretsRequest) (*GetAllSecretsResponse, error) {
- return nil, status.Errorf(codes.Unimplemented, "method GetAllSecrets not implemented")
-}
-func (UnimplementedSecretsManagerServer) mustEmbedUnimplementedSecretsManagerServer() {}
-
-// UnsafeSecretsManagerServer may be embedded to opt out of forward compatibility for this service.
-// Use of this interface is not recommended, as added methods to SecretsManagerServer will
-// result in compilation errors.
-type UnsafeSecretsManagerServer interface {
- mustEmbedUnimplementedSecretsManagerServer()
-}
-
-func RegisterSecretsManagerServer(s grpc.ServiceRegistrar, srv SecretsManagerServer) {
- s.RegisterService(&SecretsManager_ServiceDesc, srv)
-}
-
-func _SecretsManager_GetSecret_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
- in := new(GetSecretRequest)
- if err := dec(in); err != nil {
- return nil, err
- }
- if interceptor == nil {
- return srv.(SecretsManagerServer).GetSecret(ctx, in)
- }
- info := &grpc.UnaryServerInfo{
- Server: srv,
- FullMethod: SecretsManager_GetSecret_FullMethodName,
- }
- handler := func(ctx context.Context, req interface{}) (interface{}, error) {
- return srv.(SecretsManagerServer).GetSecret(ctx, req.(*GetSecretRequest))
- }
- return interceptor(ctx, in, info, handler)
-}
-
-func _SecretsManager_SetSecret_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
- in := new(SetSecretRequest)
- if err := dec(in); err != nil {
- return nil, err
- }
- if interceptor == nil {
- return srv.(SecretsManagerServer).SetSecret(ctx, in)
- }
- info := &grpc.UnaryServerInfo{
- Server: srv,
- FullMethod: SecretsManager_SetSecret_FullMethodName,
- }
- handler := func(ctx context.Context, req interface{}) (interface{}, error) {
- return srv.(SecretsManagerServer).SetSecret(ctx, req.(*SetSecretRequest))
- }
- return interceptor(ctx, in, info, handler)
-}
-
-func _SecretsManager_DeleteSecret_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
- in := new(DeleteSecretRequest)
- if err := dec(in); err != nil {
- return nil, err
- }
- if interceptor == nil {
- return srv.(SecretsManagerServer).DeleteSecret(ctx, in)
- }
- info := &grpc.UnaryServerInfo{
- Server: srv,
- FullMethod: SecretsManager_DeleteSecret_FullMethodName,
- }
- handler := func(ctx context.Context, req interface{}) (interface{}, error) {
- return srv.(SecretsManagerServer).DeleteSecret(ctx, req.(*DeleteSecretRequest))
- }
- return interceptor(ctx, in, info, handler)
-}
-
-func _SecretsManager_ListSecrets_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
- in := new(ListSecretsRequest)
- if err := dec(in); err != nil {
- return nil, err
- }
- if interceptor == nil {
- return srv.(SecretsManagerServer).ListSecrets(ctx, in)
- }
- info := &grpc.UnaryServerInfo{
- Server: srv,
- FullMethod: SecretsManager_ListSecrets_FullMethodName,
- }
- handler := func(ctx context.Context, req interface{}) (interface{}, error) {
- return srv.(SecretsManagerServer).ListSecrets(ctx, req.(*ListSecretsRequest))
- }
- return interceptor(ctx, in, info, handler)
-}
-
-func _SecretsManager_RenameSecret_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
- in := new(RenameSecretRequest)
- if err := dec(in); err != nil {
- return nil, err
- }
- if interceptor == nil {
- return srv.(SecretsManagerServer).RenameSecret(ctx, in)
- }
- info := &grpc.UnaryServerInfo{
- Server: srv,
- FullMethod: SecretsManager_RenameSecret_FullMethodName,
- }
- handler := func(ctx context.Context, req interface{}) (interface{}, error) {
- return srv.(SecretsManagerServer).RenameSecret(ctx, req.(*RenameSecretRequest))
- }
- return interceptor(ctx, in, info, handler)
-}
-
-func _SecretsManager_GetAllSecrets_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
- in := new(GetAllSecretsRequest)
- if err := dec(in); err != nil {
- return nil, err
- }
- if interceptor == nil {
- return srv.(SecretsManagerServer).GetAllSecrets(ctx, in)
- }
- info := &grpc.UnaryServerInfo{
- Server: srv,
- FullMethod: SecretsManager_GetAllSecrets_FullMethodName,
- }
- handler := func(ctx context.Context, req interface{}) (interface{}, error) {
- return srv.(SecretsManagerServer).GetAllSecrets(ctx, req.(*GetAllSecretsRequest))
- }
- return interceptor(ctx, in, info, handler)
-}
-
-// SecretsManager_ServiceDesc is the grpc.ServiceDesc for SecretsManager service.
-// It's only intended for direct use with grpc.RegisterService,
-// and not to be introspected or modified (even as a copy)
-var SecretsManager_ServiceDesc = grpc.ServiceDesc{
- ServiceName: "secretsmanagerplugin.SecretsManager",
- HandlerType: (*SecretsManagerServer)(nil),
- Methods: []grpc.MethodDesc{
- {
- MethodName: "GetSecret",
- Handler: _SecretsManager_GetSecret_Handler,
- },
- {
- MethodName: "SetSecret",
- Handler: _SecretsManager_SetSecret_Handler,
- },
- {
- MethodName: "DeleteSecret",
- Handler: _SecretsManager_DeleteSecret_Handler,
- },
- {
- MethodName: "ListSecrets",
- Handler: _SecretsManager_ListSecrets_Handler,
- },
- {
- MethodName: "RenameSecret",
- Handler: _SecretsManager_RenameSecret_Handler,
- },
- {
- MethodName: "GetAllSecrets",
- Handler: _SecretsManager_GetAllSecrets_Handler,
- },
- },
- Streams: []grpc.StreamDesc{},
- Metadata: "secretsmanager.proto",
-}
diff --git a/pkg/plugins/codegen/go.mod b/pkg/plugins/codegen/go.mod
index 63d87776c96..d020ab0c981 100644
--- a/pkg/plugins/codegen/go.mod
+++ b/pkg/plugins/codegen/go.mod
@@ -46,6 +46,7 @@ require (
golang.org/x/net v0.36.0 // indirect
golang.org/x/oauth2 v0.24.0 // indirect
golang.org/x/sync v0.11.0 // indirect
+ golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/tools v0.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
diff --git a/pkg/plugins/codegen/go.sum b/pkg/plugins/codegen/go.sum
index 48caaa1a1c5..b6b4d943414 100644
--- a/pkg/plugins/codegen/go.sum
+++ b/pkg/plugins/codegen/go.sum
@@ -100,8 +100,8 @@ golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
-golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
+golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
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/ifaces.go b/pkg/plugins/ifaces.go
index 368537db337..fd227184de5 100644
--- a/pkg/plugins/ifaces.go
+++ b/pkg/plugins/ifaces.go
@@ -98,11 +98,6 @@ type BackendFactoryProvider interface {
BackendFactory(ctx context.Context, p *Plugin) backendplugin.PluginFactoryFunc
}
-type SecretsPluginManager interface {
- // SecretsManager returns a secretsmanager plugin
- SecretsManager(ctx context.Context) *Plugin
-}
-
type StaticRouteResolver interface {
Routes(ctx context.Context) []*StaticRoute
}
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/plugins/manager/loader/loader_test.go b/pkg/plugins/manager/loader/loader_test.go
index 80526b94dd5..23cd3be818e 100644
--- a/pkg/plugins/manager/loader/loader_test.go
+++ b/pkg/plugins/manager/loader/loader_test.go
@@ -90,6 +90,7 @@ func TestLoader_Load(t *testing.T) {
},
Links: []plugins.InfoLink{
{Name: "Raise issue", URL: "https://github.com/grafana/grafana/issues/new"},
+ {Name: "Documentation", URL: "https://grafana.com/docs/grafana/latest/datasources/aws-cloudwatch/"},
},
},
Includes: []*plugins.Includes{
diff --git a/pkg/plugins/manager/pipeline/initialization/steps_test.go b/pkg/plugins/manager/pipeline/initialization/steps_test.go
index da00444e93e..4892784b0ee 100644
--- a/pkg/plugins/manager/pipeline/initialization/steps_test.go
+++ b/pkg/plugins/manager/pipeline/initialization/steps_test.go
@@ -64,30 +64,6 @@ func TestInitializer_Initialize(t *testing.T) {
require.NotNil(t, c)
})
- t.Run("secretsmanager", func(t *testing.T) {
- p := &plugins.Plugin{
- JSONData: plugins.JSONData{
- ID: "test",
- Type: plugins.TypeSecretsManager,
- Dependencies: plugins.Dependencies{
- GrafanaVersion: ">=8.x",
- },
- Backend: true,
- },
- Class: plugins.ClassExternal,
- }
-
- stepFunc := BackendClientInitStep(&fakeEnvVarsProvider{}, &fakeBackendProvider{plugin: p}, fakes.InitializeNoopTracerForTest())
-
- var err error
- p, err = stepFunc(context.Background(), p)
- require.NoError(t, err)
-
- c, exists := p.Client()
- require.True(t, exists)
- require.NotNil(t, c)
- })
-
t.Run("non backend plugin app", func(t *testing.T) {
p := &plugins.Plugin{
JSONData: plugins.JSONData{
diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go
index f815d07c6b1..cdbde14df77 100644
--- a/pkg/plugins/plugins.go
+++ b/pkg/plugins/plugins.go
@@ -17,7 +17,6 @@ import (
"github.com/grafana/grafana/pkg/plugins/auth"
"github.com/grafana/grafana/pkg/plugins/backendplugin"
"github.com/grafana/grafana/pkg/plugins/backendplugin/pluginextensionv2"
- "github.com/grafana/grafana/pkg/plugins/backendplugin/secretsmanagerplugin"
"github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/util"
@@ -58,10 +57,9 @@ type Plugin struct {
ExternalService *auth.ExternalService
- Renderer pluginextensionv2.RendererPlugin
- SecretsManager secretsmanagerplugin.SecretsManagerPlugin
- client backendplugin.Plugin
- log log.Logger
+ Renderer pluginextensionv2.RendererPlugin
+ client backendplugin.Plugin
+ log log.Logger
SkipHostEnvVars bool
@@ -126,7 +124,7 @@ type JSONData struct {
SDK bool `json:"sdk,omitempty"`
MultiValueFilterOperators bool `json:"multiValueFilterOperators,omitempty"`
- // Backend (Datasource + Renderer + SecretsManager)
+ // Backend (Datasource + Renderer)
Executable string `json:"executable,omitempty"`
// App Service Auth Registration
@@ -442,10 +440,6 @@ func (p *Plugin) ExecutablePath() string {
return p.executablePath("plugin_start")
}
- if p.IsSecretsManager() {
- return p.executablePath("secrets_plugin_start")
- }
-
return p.executablePath(p.Executable)
}
@@ -486,10 +480,6 @@ func (p *Plugin) IsRenderer() bool {
return p.Type == TypeRenderer
}
-func (p *Plugin) IsSecretsManager() bool {
- return p.Type == TypeSecretsManager
-}
-
func (p *Plugin) IsApp() bool {
return p.Type == TypeApp
}
@@ -519,22 +509,20 @@ var PluginTypes = []Type{
TypePanel,
TypeApp,
TypeRenderer,
- TypeSecretsManager,
}
type Type string
const (
- TypeDataSource Type = "datasource"
- TypePanel Type = "panel"
- TypeApp Type = "app"
- TypeRenderer Type = "renderer"
- TypeSecretsManager Type = "secretsmanager"
+ TypeDataSource Type = "datasource"
+ TypePanel Type = "panel"
+ TypeApp Type = "app"
+ TypeRenderer Type = "renderer"
)
func (pt Type) IsValid() bool {
switch pt {
- case TypeDataSource, TypePanel, TypeApp, TypeRenderer, TypeSecretsManager:
+ case TypeDataSource, TypePanel, TypeApp, TypeRenderer:
return true
}
return false
diff --git a/pkg/promlib/go.mod b/pkg/promlib/go.mod
index 4b4393c9c70..859cbe020a1 100644
--- a/pkg/promlib/go.mod
+++ b/pkg/promlib/go.mod
@@ -4,7 +4,7 @@ go 1.23.7
require (
github.com/grafana/dskit v0.0.0-20241105154643-a6b453a88040
- github.com/grafana/grafana-plugin-sdk-go v0.272.0
+ github.com/grafana/grafana-plugin-sdk-go v0.274.1-0.20250318081012-21a7f15619b0
github.com/json-iterator/go v1.1.12
github.com/prometheus/client_golang v1.21.0
github.com/prometheus/common v0.62.0
@@ -18,9 +18,10 @@ 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/apache/arrow-go/v18 v18.0.1-0.20241212180703-82be143d7c30 // indirect
+ github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
+ github.com/apache/arrow-go/v18 v18.2.0 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
+ github.com/aws/aws-sdk-go v1.55.6 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
@@ -33,7 +34,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
@@ -41,10 +42,11 @@ require (
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
- github.com/goccy/go-json v0.10.4 // indirect
+ github.com/goccy/go-json v0.10.5 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
+ github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
- github.com/google/flatbuffers v24.3.25+incompatible // indirect
+ github.com/google/flatbuffers v25.2.10+incompatible // indirect
github.com/google/gnostic-models v0.6.8 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
@@ -62,8 +64,8 @@ require (
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6 // indirect
- github.com/klauspost/compress v1.17.11 // indirect
- github.com/klauspost/cpuid/v2 v2.2.9 // indirect
+ github.com/klauspost/compress v1.18.0 // indirect
+ github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/magefile/mage v1.15.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattetti/filebuffer v1.0.1 // indirect
@@ -79,7 +81,7 @@ require (
github.com/oklog/run v1.1.0 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
- github.com/pierrec/lz4/v4 v4.1.21 // indirect
+ github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
@@ -99,7 +101,7 @@ require (
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0 // indirect
go.opentelemetry.io/contrib/propagators/jaeger v1.34.0 // indirect
- go.opentelemetry.io/contrib/samplers/jaegerremote v0.28.0 // indirect
+ go.opentelemetry.io/contrib/samplers/jaegerremote v0.29.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
@@ -111,14 +113,14 @@ require (
golang.org/x/mod v0.23.0 // indirect
golang.org/x/net v0.36.0 // indirect
golang.org/x/sync v0.11.0 // indirect
- golang.org/x/sys v0.30.0 // indirect
+ golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/tools v0.30.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.220.0 // indirect
- google.golang.org/genproto/googleapis/api v0.0.0-20250204164813-702378808489 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20250207221924-e9438ea467c6 // indirect
- google.golang.org/grpc v1.70.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b // indirect
+ google.golang.org/grpc v1.71.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
diff --git a/pkg/promlib/go.sum b/pkg/promlib/go.sum
index 899f8550654..473cb58301f 100644
--- a/pkg/promlib/go.sum
+++ b/pkg/promlib/go.sum
@@ -1,3 +1,4 @@
+cloud.google.com/go v0.118.0 h1:tvZe1mgqRxpiVa3XlIGMiPcEUbP1gNXELgD4y/IXmeQ=
cloud.google.com/go/auth v0.14.1 h1:AwoJbzUdxA/whv1qj3TLKwh3XX5sikny2fc40wUl+h0=
cloud.google.com/go/auth v0.14.1/go.mod h1:4JHUxlGXisL0AW8kXPtUF6ztuOksyfUQNFjfsOCXkPM=
cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M=
@@ -13,20 +14,21 @@ 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=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
-github.com/apache/arrow-go/v18 v18.0.1-0.20241212180703-82be143d7c30 h1:hXVi7QKuCQ0E8Yujfu9b0f0RnzZ72efpWvPnZgnJPrE=
-github.com/apache/arrow-go/v18 v18.0.1-0.20241212180703-82be143d7c30/go.mod h1:RNuWDIiGjq5nndL2PyQrndUy9nMLwheA3uWaAV7fe4U=
+github.com/apache/arrow-go/v18 v18.2.0 h1:QhWqpgZMKfWOniGPhbUxrHohWnooGURqL2R2Gg4SO1Q=
+github.com/apache/arrow-go/v18 v18.2.0/go.mod h1:Ic/01WSwGJWRrdAZcxjBZ5hbApNJ28K96jGYaxzzGUc=
github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE=
github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
-github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
-github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
+github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk=
+github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3 h1:6df1vn4bBlDDo4tARvBm7l6KA9iVMnE3NWizDeWSrps=
@@ -60,8 +62,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=
@@ -85,18 +87,18 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
-github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
-github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
+github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
-github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
-github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
+github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
-github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI=
-github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
+github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
+github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@@ -121,8 +123,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/grafana/dskit v0.0.0-20241105154643-a6b453a88040 h1:IR+UNYHqaU31t8/TArJk8K/GlDwOyxMpGNkWCXeZ28g=
github.com/grafana/dskit v0.0.0-20241105154643-a6b453a88040/go.mod h1:SPLNCARd4xdjCkue0O6hvuoveuS1dGJjDnfxYe405YQ=
-github.com/grafana/grafana-plugin-sdk-go v0.272.0 h1:TmPIG+6e3lYGzkyfUfCHuaMaaiwDbkCacTZ7V/JaSeg=
-github.com/grafana/grafana-plugin-sdk-go v0.272.0/go.mod h1:i/9KH9y/6m5hkRnG3H6aR2nOMPbJUmvo4XNrHjI15cU=
+github.com/grafana/grafana-plugin-sdk-go v0.274.1-0.20250318081012-21a7f15619b0 h1:qVdhLR+XkVdTQ2Sr7+VnRfGM8RMp8oPe25nghsSpQms=
+github.com/grafana/grafana-plugin-sdk-go v0.274.1-0.20250318081012-21a7f15619b0/go.mod h1:jV+CTjXqXYuaz8FgSG7ALOib3sgiDo/00dfsQFVTSpM=
github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8=
github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls=
github.com/grafana/pyroscope-go/godeltaprof v0.1.8 h1:iwOtYXeeVSAeYefJNaxDytgjKtUuKQbJqgAIjlnicKg=
@@ -162,10 +164,10 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
-github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
-github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
-github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
-github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
+github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -219,8 +221,8 @@ github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
-github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
-github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
+github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
+github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -301,8 +303,8 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRND
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/contrib/propagators/jaeger v1.34.0 h1:D3htJISCUU/wOVlKwisVKancWm+2U4h9xDEaiMkiyRE=
go.opentelemetry.io/contrib/propagators/jaeger v1.34.0/go.mod h1:DAX1bsj+uDm2ZuOQH/RgZRx7RQZWyzV5W2WR/0UX8JA=
-go.opentelemetry.io/contrib/samplers/jaegerremote v0.28.0 h1:Xx1N6cDr8iWy1Cz6OcY7oS0ACdt/6HDYTdu4KskuC7s=
-go.opentelemetry.io/contrib/samplers/jaegerremote v0.28.0/go.mod h1:iWS+NvC948FyfnJbVfPN9h/8+vr8CR2FPn6XsLRkvH8=
+go.opentelemetry.io/contrib/samplers/jaegerremote v0.29.0 h1:VpYbyLrB5BS3blBCJMqHRIrbU4RlPnyFovR3La+1j4Q=
+go.opentelemetry.io/contrib/samplers/jaegerremote v0.29.0/go.mod h1:XAJmM2MWhiIoTO4LCLBVeE8w009TmsYk6hq1UNdXs5A=
go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
@@ -364,8 +366,8 @@ golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
-golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
+golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
@@ -389,12 +391,12 @@ gonum.org/v1/gonum v0.15.1 h1:FNy7N6OUZVUaWG9pTiD+jlhdQ3lMP+/LcTpJ6+a8sQ0=
gonum.org/v1/gonum v0.15.1/go.mod h1:eZTZuRFrzu5pcyjN5wJhcIhnUdNijYxX1T2IcrOGY0o=
google.golang.org/api v0.220.0 h1:3oMI4gdBgB72WFVwE1nerDD8W3HUOS4kypK6rRLbGns=
google.golang.org/api v0.220.0/go.mod h1:26ZAlY6aN/8WgpCzjPNy18QpYaz7Zgg1h0qe1GkZEmY=
-google.golang.org/genproto/googleapis/api v0.0.0-20250204164813-702378808489 h1:fCuMM4fowGzigT89NCIsW57Pk9k2D12MMi2ODn+Nk+o=
-google.golang.org/genproto/googleapis/api v0.0.0-20250204164813-702378808489/go.mod h1:iYONQfRdizDB8JJBybql13nArx91jcUk7zCXEsOofM4=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250207221924-e9438ea467c6 h1:2duwAxN2+k0xLNpjnHTXoMUgnv6VPSp5fiqTuwSxjmI=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250207221924-e9438ea467c6/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk=
-google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=
-google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
+google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
+google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b h1:FQtJ1MxbXoIIrZHZ33M+w5+dAP9o86rgpjoKr/ZmT7k=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk=
+google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
+google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/pkg/registry/apis/apis.go b/pkg/registry/apis/apis.go
index aebcfcbbdd0..0ba63af8484 100644
--- a/pkg/registry/apis/apis.go
+++ b/pkg/registry/apis/apis.go
@@ -10,6 +10,7 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/iam"
"github.com/grafana/grafana/pkg/registry/apis/provisioning"
"github.com/grafana/grafana/pkg/registry/apis/query"
+ "github.com/grafana/grafana/pkg/registry/apis/secret"
"github.com/grafana/grafana/pkg/registry/apis/userstorage"
)
@@ -27,6 +28,7 @@ func ProvideRegistryServiceSink(
_ *query.QueryAPIBuilder,
_ *notifications.NotificationsAPIBuilder,
_ *userstorage.UserStorageAPIBuilder,
+ _ *secret.SecretAPIBuilder,
_ *provisioning.APIBuilder,
) *Service {
return &Service{}
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/legacysearcher/search_client.go b/pkg/registry/apis/dashboard/legacysearcher/search_client.go
index fd5e2283b27..be67dcddb9c 100644
--- a/pkg/registry/apis/dashboard/legacysearcher/search_client.go
+++ b/pkg/registry/apis/dashboard/legacysearcher/search_client.go
@@ -34,11 +34,33 @@ func NewDashboardSearchClient(dashboardStore dashboards.Store, sorter sort.Servi
}
var sortByMapping = map[string]string{
- unisearch.DASHBOARD_VIEWS_LAST_30_DAYS: "viewed-recently-",
- unisearch.DASHBOARD_VIEWS_TOTAL: "viewed-",
- unisearch.DASHBOARD_ERRORS_LAST_30_DAYS: "errors-recently-",
- unisearch.DASHBOARD_ERRORS_TOTAL: "errors-",
- "title": "alpha-",
+ unisearch.DASHBOARD_VIEWS_LAST_30_DAYS: "viewed-recently",
+ unisearch.DASHBOARD_VIEWS_TOTAL: "viewed",
+ unisearch.DASHBOARD_ERRORS_LAST_30_DAYS: "errors-recently",
+ unisearch.DASHBOARD_ERRORS_TOTAL: "errors",
+ "title": "alpha",
+}
+
+func ParseSortName(sortName string) (string, bool, error) {
+ if sortName == "" {
+ return "", false, nil
+ }
+
+ isDesc := strings.HasSuffix(sortName, "-desc")
+ isAsc := strings.HasSuffix(sortName, "-asc")
+ // default to desc if no suffix is provided
+ if !isDesc && !isAsc {
+ isDesc = true
+ }
+
+ prefix := strings.TrimSuffix(strings.TrimSuffix(sortName, "-desc"), "-asc")
+ for key, mappedPrefix := range sortByMapping {
+ if prefix == mappedPrefix {
+ return key, isDesc, nil
+ }
+ }
+
+ return "", false, fmt.Errorf("no matching sort field found for: %s", sortName)
}
// nolint:gocyclo
@@ -99,9 +121,9 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
sorterName := sortByMapping[sortByField]
if sort.Desc {
- sorterName += "desc"
+ sorterName += "-desc"
} else {
- sorterName += "asc"
+ sorterName += "-asc"
}
if sorter, ok := c.sorter.GetSortOption(sorterName); ok {
@@ -207,22 +229,27 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
}
}
searchFields := resource.StandardSearchFields()
+ columns := []*resource.ResourceTableColumnDefinition{
+ searchFields.Field(resource.SEARCH_FIELD_TITLE),
+ searchFields.Field(resource.SEARCH_FIELD_FOLDER),
+ searchFields.Field(resource.SEARCH_FIELD_TAGS),
+ {
+ Name: unisearch.DASHBOARD_LEGACY_ID,
+ Type: resource.ResourceTableColumnDefinition_INT64,
+ Description: "Deprecated legacy id of the dashboard",
+ },
+ }
+
+ if sortByField != "" {
+ columns = append(columns, &resource.ResourceTableColumnDefinition{
+ Name: sortByField,
+ Type: resource.ResourceTableColumnDefinition_INT64,
+ })
+ }
+
list := &resource.ResourceSearchResponse{
Results: &resource.ResourceTable{
- Columns: []*resource.ResourceTableColumnDefinition{
- searchFields.Field(resource.SEARCH_FIELD_TITLE),
- searchFields.Field(resource.SEARCH_FIELD_FOLDER),
- searchFields.Field(resource.SEARCH_FIELD_TAGS),
- {
- Name: unisearch.DASHBOARD_LEGACY_ID,
- Type: resource.ResourceTableColumnDefinition_INT64,
- Description: "Deprecated legacy id of the dashboard",
- },
- {
- Name: sortByField,
- Type: resource.ResourceTableColumnDefinition_INT64,
- },
- },
+ Columns: columns,
},
}
@@ -249,11 +276,22 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
}
for _, dashboard := range dashes {
+ cells := [][]byte{
+ []byte(dashboard.Title),
+ []byte(dashboard.FolderUID),
+ []byte("[]"), // no tags retrieved for provisioned dashboards
+ []byte(strconv.FormatInt(dashboard.ID, 10)),
+ }
+
+ if sortByField != "" {
+ cells = append(cells, []byte("0"))
+ }
+
list.Results.Rows = append(list.Results.Rows, &resource.ResourceTableRow{
Key: getResourceKey(&dashboards.DashboardSearchProjection{
UID: dashboard.UID,
}, req.Options.Key.Namespace),
- Cells: [][]byte{[]byte(dashboard.Title), []byte(dashboard.FolderUID), []byte(strconv.FormatInt(dashboard.ID, 10)), {}, {}},
+ Cells: cells,
})
}
@@ -275,9 +313,20 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
return nil, err
}
+ cells := [][]byte{
+ []byte(dashboard.Title),
+ []byte(dashboard.FolderUID),
+ tags,
+ []byte(strconv.FormatInt(dashboard.ID, 10)),
+ }
+
+ if sortByField != "" {
+ cells = append(cells, []byte(strconv.FormatInt(dashboard.SortMeta, 10)))
+ }
+
list.Results.Rows = append(list.Results.Rows, &resource.ResourceTableRow{
Key: getResourceKey(dashboard, req.Options.Key.Namespace),
- Cells: [][]byte{[]byte(dashboard.Title), []byte(dashboard.FolderUID), tags, []byte(strconv.FormatInt(dashboard.ID, 10)), []byte(strconv.FormatInt(dashboard.SortMeta, 10))},
+ Cells: cells,
})
}
diff --git a/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go b/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go
index a4eecae9058..700fad7a2ce 100644
--- a/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go
+++ b/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go
@@ -77,10 +77,6 @@ func TestDashboardSearchClient_Search(t *testing.T) {
Type: resource.ResourceTableColumnDefinition_INT64,
Description: "Deprecated legacy id of the dashboard",
},
- {
- Name: "", // sort by should be empty if title is what we sorted by
- Type: resource.ResourceTableColumnDefinition_INT64,
- },
},
Rows: []*resource.ResourceTableRow{
{
@@ -94,7 +90,6 @@ func TestDashboardSearchClient_Search(t *testing.T) {
[]byte("folder1"),
tags,
[]byte("1"),
- []byte(strconv.FormatInt(0, 10)),
},
},
{
@@ -108,7 +103,6 @@ func TestDashboardSearchClient_Search(t *testing.T) {
[]byte("folder2"),
emptyTags,
[]byte("2"),
- []byte(strconv.FormatInt(0, 10)),
},
},
},
@@ -501,4 +495,131 @@ func TestDashboardSearchClient_Search(t *testing.T) {
}
require.Equal(t, resp.TotalHits, int64(1))
})
+
+ t.Run("Should set empty sort field when sorting by title", func(t *testing.T) {
+ mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{
+ SignedInUser: user,
+ Sort: sort.SortAlphaAsc,
+ Type: "dash-db",
+ }).Return([]dashboards.DashboardSearchProjection{
+ {ID: 1, UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"},
+ }, nil).Once()
+
+ req := &resource.ResourceSearchRequest{
+ Options: &resource.ListOptions{
+ Key: dashboardKey,
+ },
+ SortBy: []*resource.ResourceSearchRequest_Sort{
+ {
+ Field: resource.SEARCH_FIELD_TITLE,
+ },
+ },
+ }
+ resp, err := client.Search(ctx, req)
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ require.Len(t, resp.Results.Columns, 4)
+ mockStore.AssertExpectations(t)
+ })
+
+ t.Run("Should set correct sort field when sorting by views", func(t *testing.T) {
+ mockStore.On("FindDashboards", mock.Anything, mock.Anything).Return([]dashboards.DashboardSearchProjection{
+ {ID: 1, UID: "uid", Title: "Test Dashboard", FolderUID: "folder1", SortMeta: 100},
+ }, nil).Once()
+
+ req := &resource.ResourceSearchRequest{
+ Options: &resource.ListOptions{
+ Key: dashboardKey,
+ },
+ SortBy: []*resource.ResourceSearchRequest_Sort{
+ {
+ Field: resource.SEARCH_FIELD_PREFIX + unisearch.DASHBOARD_VIEWS_TOTAL,
+ },
+ },
+ }
+ resp, err := client.Search(ctx, req)
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+
+ require.Len(t, resp.Results.Columns, 5)
+ i := len(resp.Results.Columns) - 1
+ require.Equal(t, "views_total", resp.Results.Columns[i].Name)
+ require.Equal(t, []byte(strconv.FormatInt(100, 10)), resp.Results.Rows[0].Cells[i]) // views should be set to 100
+ mockStore.AssertExpectations(t)
+ })
+}
+
+func TestParseSortName(t *testing.T) {
+ tests := []struct {
+ name string
+ sortName string
+ wantField string
+ wantDesc bool
+ wantErr bool
+ }{
+ {
+ name: "empty sort name",
+ sortName: "",
+ wantField: "",
+ wantDesc: false,
+ wantErr: false,
+ },
+ {
+ name: "viewed-recently with desc suffix",
+ sortName: "viewed-recently-desc",
+ wantField: unisearch.DASHBOARD_VIEWS_LAST_30_DAYS,
+ wantDesc: true,
+ wantErr: false,
+ },
+ {
+ name: "defaults to desc",
+ sortName: "viewed",
+ wantField: unisearch.DASHBOARD_VIEWS_TOTAL,
+ wantDesc: true,
+ wantErr: false,
+ },
+ {
+ name: "errors-recentlyy with asc suffix",
+ sortName: "errors-recently-asc",
+ wantField: unisearch.DASHBOARD_ERRORS_LAST_30_DAYS,
+ wantDesc: false,
+ wantErr: false,
+ },
+ {
+ name: "errors - defaults to desc too",
+ sortName: "errors",
+ wantField: unisearch.DASHBOARD_ERRORS_TOTAL,
+ wantDesc: true,
+ wantErr: false,
+ },
+ {
+ name: "alpha sort with asc suffix",
+ sortName: "alpha-asc",
+ wantField: "title",
+ wantDesc: false,
+ wantErr: false,
+ },
+ {
+ name: "invalid sort name",
+ sortName: "invalid-sort-desc",
+ wantField: "",
+ wantDesc: false,
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ field, isDesc, err := ParseSortName(tt.sortName)
+
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+
+ require.NoError(t, err)
+ require.Equal(t, tt.wantField, field)
+ require.Equal(t, tt.wantDesc, isDesc)
+ })
+ }
}
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/provisioning/apifmt/error.go b/pkg/registry/apis/provisioning/apifmt/error.go
new file mode 100644
index 00000000000..c5d4b4669d6
--- /dev/null
+++ b/pkg/registry/apis/provisioning/apifmt/error.go
@@ -0,0 +1,109 @@
+// apifmt aims to provide a Kubernetes-compatible way to format text.
+package apifmt
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+var (
+ _ error = (*fmtError)(nil)
+ _ apierrors.APIStatus = (*fmtError)(nil)
+)
+
+type fmtError struct {
+ inner error
+ str string
+
+ innerStatusErr apierrors.APIStatus
+ initInnerStatusErr bool
+}
+
+func (e *fmtError) Error() string {
+ return e.str
+}
+
+// Status returns the status that is closest in the tree, in a depth-first search.
+func (e *fmtError) Status() metav1.Status {
+ if !e.initInnerStatusErr {
+ if status, ok := e.inner.(apierrors.APIStatus); ok || errors.As(e.inner, &status) {
+ e.innerStatusErr = status
+ }
+ e.initInnerStatusErr = true
+ }
+
+ status := metav1.Status{
+ Message: e.str,
+ Code: http.StatusInternalServerError,
+ Reason: metav1.StatusReasonInternalError,
+ Status: metav1.StatusFailure,
+ }
+
+ if e.innerStatusErr != nil {
+ s := e.innerStatusErr.Status()
+ status.Code, status.Reason, status.Status, status.Details = s.Code, s.Reason, s.Status, s.Details
+ }
+ return status
+}
+
+func (e *fmtError) Unwrap() error {
+ return e.inner
+}
+
+func (e *fmtError) Is(target error) bool {
+ if e.initInnerStatusErr && e.innerStatusErr != nil {
+ // If we already know the inner status, we can speed up the Is check for apierrors Is functions. These are the most common case.
+ if err, ok := e.innerStatusErr.(error); ok {
+ return errors.Is(err, target)
+ }
+ }
+ return errors.Is(e.inner, target)
+}
+
+// Errorf acts like `fmt.Errorf`. Use `%w` to wrap a specific error.
+// The returned error will propagate the inner `metav1.Status`, if one exists. Otherwise, an HTTP 500 Internal Server Error will be returned.
+// If multiple errors are passed, they will be joined with `errors.Join`, just like `fmt.Errorf`.
+func Errorf(format string, args ...any) *fmtError {
+ // We go via Errorf to only give the %w errors as inner errors.
+ wrapped := fmt.Errorf(format, args...)
+ str := wrapped.Error()
+ err := unwrap(wrapped)
+
+ return &fmtError{
+ inner: err,
+ str: str,
+ }
+}
+
+// unwrap returns the inner error of an error, if it exists.
+// If multiple errors are present, it will errors.Join them.
+func unwrap(err error) error {
+ type singleUnwrapper interface {
+ Unwrap() error
+ }
+ type multiUnwrapper interface {
+ Unwrap() []error
+ }
+
+ if err == nil {
+ return nil
+ }
+ if e, ok := err.(singleUnwrapper); ok {
+ return e.Unwrap()
+ }
+ if e, ok := err.(multiUnwrapper); ok {
+ errs := e.Unwrap()
+ if len(errs) == 0 {
+ return err
+ }
+ if len(errs) == 1 && errs[0] != nil {
+ return errs[0]
+ }
+ return errors.Join(errs...)
+ }
+ return err
+}
diff --git a/pkg/registry/apis/provisioning/apifmt/error_test.go b/pkg/registry/apis/provisioning/apifmt/error_test.go
new file mode 100644
index 00000000000..b685bcad673
--- /dev/null
+++ b/pkg/registry/apis/provisioning/apifmt/error_test.go
@@ -0,0 +1,97 @@
+package apifmt_test
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/apifmt"
+ "github.com/stretchr/testify/assert"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func TestErrorf(t *testing.T) {
+ t.Parallel()
+
+ for _, fmt := range []string{"1 %v 2 %v", "1 %w 2 %v", "1 %v 2 %w", "1 %w 2 %w"} {
+ t.Run("error string is formatted appropriately with fmt="+fmt, func(t *testing.T) {
+ t.Parallel()
+
+ err1 := errors.New("error1")
+ err2 := errors.New("error2")
+
+ err := apifmt.Errorf(fmt, err1, err2)
+ assert.Equal(t, "1 error1 2 error2", err.Error())
+ })
+ }
+
+ t.Run("no inner error defaults to internal server error", func(t *testing.T) {
+ t.Parallel()
+
+ err := apifmt.Errorf("nothing inside")
+
+ assert.True(t, apierrors.IsInternalError(err), "err is not internal error per apierrors")
+ assert.Equal(t, int32(http.StatusInternalServerError), err.Status().Code, ".Code")
+ assert.Equal(t, metav1.StatusReasonInternalError, err.Status().Reason, ".Reason")
+ assert.Equal(t, metav1.StatusFailure, err.Status().Status, ".Status")
+ })
+
+ t.Run("non-apistatus inner error defaults to internal server error", func(t *testing.T) {
+ t.Parallel()
+
+ inner := errors.New("an inner error")
+ err := apifmt.Errorf("%w", inner)
+
+ assert.True(t, apierrors.IsInternalError(err), "err is not internal error per apierrors")
+ assert.Equal(t, int32(http.StatusInternalServerError), err.Status().Code, ".Code")
+ assert.Equal(t, metav1.StatusReasonInternalError, err.Status().Reason, ".Reason")
+ assert.Equal(t, metav1.StatusFailure, err.Status().Status, ".Status")
+ })
+
+ t.Run("apistatus inner error is used for status", func(t *testing.T) {
+ t.Parallel()
+
+ inner := apierrors.NewBadRequest("bad request")
+ err := apifmt.Errorf("%w", inner)
+
+ assert.Equal(t, inner.Status(), err.Status(), "err.Status()")
+ })
+
+ t.Run("message is used with inner apistatus error", func(t *testing.T) {
+ t.Parallel()
+
+ inner := apierrors.NewBadRequest("bad request")
+ err := apifmt.Errorf("context here: %w", inner)
+
+ status := inner.Status()
+ status.Message = "context here: bad request"
+ assert.Equal(t, status, err.Status(), "err.Status()")
+ assert.Equal(t, "context here: bad request", err.Error(), "err.Error()")
+ })
+
+ t.Run("deep apierror is used", func(t *testing.T) {
+ t.Parallel()
+
+ inner := apierrors.NewBadRequest("bad request")
+ wrapped := fmt.Errorf("%w", inner)
+ wrapped = fmt.Errorf("%w", wrapped)
+ err := apifmt.Errorf("%w", wrapped)
+
+ assert.Equal(t, inner.Status(), err.Status(), "err.Status()")
+ })
+
+ t.Run("deep error in multi-unwrap wrapper's apierror is used", func(t *testing.T) {
+ t.Parallel()
+
+ inner := apierrors.NewBadRequest("bad request")
+ anotherError := errors.New("not an apierror")
+ wrapped := errors.Join(fmt.Errorf("this is cool: %w", anotherError), fmt.Errorf("another one: %w", errors.Join(anotherError, inner, anotherError)))
+ err := apifmt.Errorf("%w", wrapped)
+
+ status := inner.Status()
+ status.Message = "this is cool: not an apierror\nanother one: not an apierror\nbad request\nnot an apierror"
+ assert.Equal(t, status, err.Status(), "err.Status()")
+ })
+}
diff --git a/pkg/registry/apis/provisioning/controller/finalizers.go b/pkg/registry/apis/provisioning/controller/finalizers.go
new file mode 100644
index 00000000000..1c0437d3fca
--- /dev/null
+++ b/pkg/registry/apis/provisioning/controller/finalizers.go
@@ -0,0 +1,142 @@
+package controller
+
+import (
+ "sort"
+ "strings"
+
+ "golang.org/x/net/context"
+ v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/client-go/dynamic"
+
+ "github.com/grafana/grafana-app-sdk/logging"
+ "github.com/grafana/grafana/pkg/apimachinery/utils"
+ folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
+)
+
+// RemoveOrphanResourcesFinalizer removes everything this repo created
+const RemoveOrphanResourcesFinalizer = "remove-orphan-resources"
+
+// ReleaseOrphanResourcesFinalizer removes the metadata for anything this repo created
+const ReleaseOrphanResourcesFinalizer = "release-orphan-resources"
+
+// CleanFinalizer calls the "OnDelete" function for resource
+const CleanFinalizer = "cleanup"
+
+type finalizer struct {
+ lister resources.ResourceLister
+ clientFactory *resources.ClientFactory
+}
+
+func (f *finalizer) process(ctx context.Context,
+ repo repository.Repository,
+ finalizers []string,
+) error {
+ logger := logging.FromContext(ctx)
+
+ for _, finalizer := range finalizers {
+ switch finalizer {
+ case CleanFinalizer:
+ // NOTE: the controller loop will never get run unless a finalizer is set
+ hooks, ok := repo.(repository.Hooks)
+ if ok {
+ if err := hooks.OnDelete(ctx); err != nil {
+ logger.Warn("Error running deletion hooks", "err", err)
+ }
+ }
+
+ case ReleaseOrphanResourcesFinalizer:
+ err := f.processExistingItems(ctx, repo.Config(),
+ func(client dynamic.ResourceInterface, item *provisioning.ResourceListItem) error {
+ _, err := client.Patch(ctx, item.Name, types.JSONPatchType, []byte(`[
+ {"op": "remove", "path": "/metadata/annotations/`+utils.AnnoKeyManagerKind+`" },
+ {"op": "remove", "path": "/metadata/annotations/`+utils.AnnoKeyManagerIdentity+`" },
+ {"op": "remove", "path": "/metadata/annotations/`+utils.AnnoKeySourcePath+`" },
+ {"op": "remove", "path": "/metadata/annotations/`+utils.AnnoKeySourceChecksum+`" }
+ ]`), v1.PatchOptions{})
+ return err
+ })
+ if err != nil {
+ return err
+ }
+
+ case RemoveOrphanResourcesFinalizer:
+ err := f.processExistingItems(ctx, repo.Config(),
+ func(client dynamic.ResourceInterface, item *provisioning.ResourceListItem) error {
+ return client.Delete(ctx, item.Name, v1.DeleteOptions{})
+ })
+ if err != nil {
+ return err
+ }
+
+ default:
+ logger.Warn("skipping unknown finalizer", "finalizer", finalizer)
+ }
+ }
+ return nil
+}
+
+// internal iterator to walk the existing items
+func (f *finalizer) processExistingItems(
+ ctx context.Context,
+ repo *provisioning.Repository,
+ cb func(client dynamic.ResourceInterface, item *provisioning.ResourceListItem) error,
+) error {
+ logger := logging.FromContext(ctx)
+ clients, err := f.clientFactory.Clients(ctx, repo.Namespace)
+ if err != nil {
+ return err
+ }
+
+ items, err := f.lister.List(ctx, repo.Namespace, repo.Name)
+ if err != nil {
+ logger.Warn("error listing resources", "error", err)
+ return err
+ }
+
+ // Safe deletion order
+ sortResourceListForDeletion(items)
+ count := 0
+ errors := 0
+
+ for _, item := range items.Items {
+ res, _, err := clients.ForResource(schema.GroupVersionResource{
+ Group: item.Group,
+ Resource: item.Resource,
+ })
+ if err != nil {
+ return err
+ }
+
+ err = cb(res, &item)
+ if err != nil {
+ logger.Warn("error processing item", "name", item.Name, "error", err)
+ errors++
+ } else {
+ count++
+ }
+ }
+ logger.Info("processed orphan items", "items", count, "errors", errors)
+ return nil
+}
+
+func sortResourceListForDeletion(list *provisioning.ResourceList) {
+ // FIXME: this code should be simplified once unified storage folders support recursive deletion
+ // Sort by the following logic:
+ // - Put folders at the end so that we empty them first.
+ // - Sort folders by depth so that we remove the deepest first
+ sort.Slice(list.Items, func(i, j int) bool {
+ switch {
+ case list.Items[i].Group != folders.RESOURCE:
+ return true
+ case list.Items[j].Group != folders.RESOURCE:
+ return false
+ default:
+ return len(strings.Split(list.Items[i].Path, "/")) > len(strings.Split(list.Items[j].Path, "/"))
+ }
+ })
+}
diff --git a/pkg/registry/apis/provisioning/controller/repository.go b/pkg/registry/apis/provisioning/controller/repository.go
new file mode 100644
index 00000000000..5a2a18e375b
--- /dev/null
+++ b/pkg/registry/apis/provisioning/controller/repository.go
@@ -0,0 +1,532 @@
+package controller
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+ utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+ "k8s.io/apimachinery/pkg/util/wait"
+ "k8s.io/apiserver/pkg/endpoints/request"
+ "k8s.io/client-go/tools/cache"
+ "k8s.io/client-go/util/workqueue"
+
+ "github.com/grafana/grafana-app-sdk/logging"
+ "github.com/grafana/grafana/pkg/apimachinery/identity"
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ client "github.com/grafana/grafana/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1"
+ informer "github.com/grafana/grafana/pkg/generated/informers/externalversions/provisioning/v0alpha1"
+ listers "github.com/grafana/grafana/pkg/generated/listers/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
+ "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
+)
+
+type RepoGetter interface {
+ // Given a repository configuration, return it as a repository instance
+ // This will only error for un-recoverable system errors
+ // the repository instance may or may not be valid/healthy
+ AsRepository(ctx context.Context, cfg *provisioning.Repository) (repository.Repository, error)
+}
+
+type RepositoryTester interface {
+ TestRepository(ctx context.Context, repo repository.Repository) (*provisioning.TestResults, error)
+}
+
+const loggerName = "provisioning-repository-controller"
+
+const (
+ maxAttempts = 3
+)
+
+type queueItem struct {
+ key string
+ obj interface{}
+ attempts int
+}
+
+// RepositoryController controls how and when CRD is established.
+type RepositoryController struct {
+ client client.ProvisioningV0alpha1Interface
+ resourceLister resources.ResourceLister
+ repoLister listers.RepositoryLister
+ repoSynced cache.InformerSynced
+ parsers *resources.ParserFactory
+ logger logging.Logger
+ secrets secrets.Service
+ dualwrite dualwrite.Service
+
+ jobs jobs.Queue
+ finalizer *finalizer
+
+ // Converts config to instance
+ repoGetter RepoGetter
+ tester RepositoryTester
+ // To allow injection for testing.
+ processFn func(item *queueItem) error
+ enqueueRepository func(obj any)
+ keyFunc func(obj any) (string, error)
+
+ queue workqueue.TypedRateLimitingInterface[*queueItem]
+}
+
+// NewRepositoryController creates new RepositoryController.
+func NewRepositoryController(
+ provisioningClient client.ProvisioningV0alpha1Interface,
+ repoInformer informer.RepositoryInformer,
+ repoGetter RepoGetter,
+ resourceLister resources.ResourceLister,
+ parsers *resources.ParserFactory,
+ tester RepositoryTester,
+ jobs jobs.Queue,
+ secrets secrets.Service,
+ dualwrite dualwrite.Service,
+) (*RepositoryController, error) {
+ rc := &RepositoryController{
+ client: provisioningClient,
+ resourceLister: resourceLister,
+ repoLister: repoInformer.Lister(),
+ repoSynced: repoInformer.Informer().HasSynced,
+ queue: workqueue.NewTypedRateLimitingQueueWithConfig(
+ workqueue.DefaultTypedControllerRateLimiter[*queueItem](),
+ workqueue.TypedRateLimitingQueueConfig[*queueItem]{
+ Name: "provisioningRepositoryController",
+ },
+ ),
+ repoGetter: repoGetter,
+ parsers: parsers,
+ finalizer: &finalizer{
+ lister: resourceLister,
+ clientFactory: parsers.ClientFactory,
+ },
+ tester: tester,
+ jobs: jobs,
+ logger: logging.DefaultLogger.With("logger", loggerName),
+ secrets: secrets,
+ dualwrite: dualwrite,
+ }
+
+ _, err := repoInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
+ AddFunc: rc.enqueue,
+ UpdateFunc: func(oldObj, newObj interface{}) {
+ rc.enqueue(newObj)
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ rc.processFn = rc.process
+ rc.enqueueRepository = rc.enqueue
+ rc.keyFunc = repoKeyFunc
+
+ return rc, nil
+}
+
+func repoKeyFunc(obj any) (string, error) {
+ repo, ok := obj.(*provisioning.Repository)
+ if !ok {
+ return "", fmt.Errorf("expected a Repository but got %T", obj)
+ }
+ return cache.DeletionHandlingMetaNamespaceKeyFunc(repo)
+}
+
+// Run starts the RepositoryController.
+func (rc *RepositoryController) Run(ctx context.Context, workerCount int) {
+ defer utilruntime.HandleCrash()
+ defer rc.queue.ShutDown()
+
+ logger := rc.logger
+ ctx = logging.Context(ctx, logger)
+ logger.Info("Starting RepositoryController")
+ defer logger.Info("Shutting down RepositoryController")
+
+ if !cache.WaitForCacheSync(ctx.Done(), rc.repoSynced) {
+ return
+ }
+
+ logger.Info("Starting workers", "count", workerCount)
+ for i := 0; i < workerCount; i++ {
+ go wait.UntilWithContext(ctx, rc.runWorker, time.Second)
+ }
+
+ logger.Info("Started workers")
+ <-ctx.Done()
+ logger.Info("Shutting down workers")
+}
+
+func (rc *RepositoryController) runWorker(ctx context.Context) {
+ for rc.processNextWorkItem(ctx) {
+ }
+}
+
+func (rc *RepositoryController) enqueue(obj interface{}) {
+ key, err := rc.keyFunc(obj)
+ if err != nil {
+ utilruntime.HandleError(fmt.Errorf("couldn't get key for object: %v", err))
+ return
+ }
+
+ item := queueItem{key: key, obj: obj}
+ rc.queue.Add(&item)
+}
+
+// processNextWorkItem deals with one key off the queue.
+// It returns false when it's time to quit.
+func (rc *RepositoryController) processNextWorkItem(ctx context.Context) bool {
+ item, quit := rc.queue.Get()
+ if quit {
+ return false
+ }
+ defer rc.queue.Done(item)
+
+ // TODO: should we move tracking work to trace ids instead?
+ logger := logging.FromContext(ctx).With("work_key", item.key)
+ logger.Info("RepositoryController processing key")
+
+ err := rc.processFn(item)
+ if err == nil {
+ rc.queue.Forget(item)
+ return true
+ }
+
+ item.attempts++
+ logger = logger.With("error", err, "attempts", item.attempts)
+ logger.Error("RepositoryController failed to process key")
+
+ if item.attempts >= maxAttempts {
+ logger.Error("RepositoryController failed too many times")
+ rc.queue.Forget(item)
+ return true
+ }
+
+ if !apierrors.IsServiceUnavailable(err) {
+ logger.Info("RepositoryController will not retry")
+ rc.queue.Forget(item)
+ return true
+ } else {
+ logger.Info("RepositoryController will retry as service is unavailable")
+ }
+
+ utilruntime.HandleError(fmt.Errorf("%v failed with: %v", item, err))
+ rc.queue.AddRateLimited(item)
+
+ return true
+}
+
+func (rc *RepositoryController) handleDelete(ctx context.Context, obj *provisioning.Repository) error {
+ logger := logging.FromContext(ctx)
+ logger.Info("handle repository delete")
+
+ // Process any finalizers
+ if len(obj.Finalizers) > 0 {
+ repo, err := rc.repoGetter.AsRepository(ctx, obj)
+ if err != nil {
+ logger.Warn("unable to get repository for cleanup")
+ } else {
+ err := rc.finalizer.process(ctx, repo, obj.Finalizers)
+ if err != nil {
+ logger.Warn("error running finalizer", "err")
+ }
+ }
+
+ // remove the finalizers
+ _, err = rc.client.Repositories(obj.GetNamespace()).
+ Patch(ctx, obj.Name, types.JSONPatchType, []byte(`[
+ { "op": "remove", "path": "/metadata/finalizers" }
+ ]`), v1.PatchOptions{
+ FieldManager: "repository-controller",
+ })
+ return err // delete will be called again
+ }
+
+ return nil
+}
+
+func (rc *RepositoryController) shouldCheckHealth(obj *provisioning.Repository) bool {
+ if obj.Status.Health.Checked == 0 || obj.Generation != obj.Status.ObservedGeneration {
+ return true
+ }
+
+ healthAge := time.Since(time.UnixMilli(obj.Status.Health.Checked))
+ if obj.Status.Health.Healthy {
+ return healthAge > time.Minute*5 // when healthy, check every 5 mins
+ }
+
+ return healthAge > time.Minute // otherwise within a minute
+}
+
+func (rc *RepositoryController) runHealthCheck(ctx context.Context, repo repository.Repository) provisioning.HealthStatus {
+ logger := logging.FromContext(ctx)
+ logger.Info("running health check")
+ res, err := rc.tester.TestRepository(ctx, repo)
+ if err != nil {
+ res = &provisioning.TestResults{
+ Success: false,
+ Errors: []string{
+ "error running test repository",
+ err.Error(),
+ },
+ }
+ }
+
+ healthStatus := provisioning.HealthStatus{
+ Healthy: res.Success,
+ Checked: time.Now().UnixMilli(),
+ Message: res.Errors,
+ }
+ logger.Info("health check completed", "status", healthStatus)
+
+ return healthStatus
+}
+
+func (rc *RepositoryController) shouldResync(obj *provisioning.Repository) bool {
+ // don't trigger resync if a sync was never started
+ if obj.Status.Sync.Finished == 0 && obj.Status.Sync.State == "" {
+ return false
+ }
+
+ syncAge := time.Since(time.UnixMilli(obj.Status.Sync.Finished))
+ syncInterval := time.Duration(obj.Spec.Sync.IntervalSeconds) * time.Second
+ tolerance := time.Second
+
+ // HACK: how would this work in a multi-tenant world or under heavy load?
+ // It will start queueing up jobs and we will have to deal with that
+ pendingForTooLong := syncAge >= syncInterval/2 && obj.Status.Sync.State == provisioning.JobStatePending
+ isRunning := obj.Status.Sync.State == provisioning.JobStateWorking
+
+ return obj.Spec.Sync.Enabled && syncAge >= (syncInterval-tolerance) && !pendingForTooLong && !isRunning
+}
+
+func (rc *RepositoryController) runHooks(ctx context.Context, repo repository.Repository, obj *provisioning.Repository) (*provisioning.WebhookStatus, error) {
+ logger := logging.FromContext(ctx)
+ hooks, _ := repo.(repository.Hooks)
+ if hooks == nil || obj.Generation == obj.Status.ObservedGeneration {
+ return nil, nil
+ }
+
+ if obj.Status.ObservedGeneration < 1 {
+ logger.Info("handle repository create")
+ webhookStatus, err := hooks.OnCreate(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("error running OnCreate: %w", err)
+ }
+ return webhookStatus, nil
+ }
+
+ logger.Info("handle repository spec update", "Generation", obj.Generation, "ObservedGeneration", obj.Status.ObservedGeneration)
+ webhookStatus, err := hooks.OnUpdate(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("error running OnUpdate: %w", err)
+ }
+
+ return webhookStatus, nil
+}
+
+func (rc *RepositoryController) determineSyncStrategy(ctx context.Context, obj *provisioning.Repository, shouldResync bool, healthStatus provisioning.HealthStatus) *provisioning.SyncJobOptions {
+ logger := logging.FromContext(ctx)
+
+ switch {
+ case !obj.Spec.Sync.Enabled:
+ logger.Info("skip sync as it's disabled")
+ return nil
+ case !healthStatus.Healthy:
+ logger.Info("skip sync for unhealthy repository")
+ return nil
+ case dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, rc.dualwrite):
+ logger.Info("skip sync as we are reading from legacy storage")
+ return nil
+ case healthStatus.Healthy != obj.Status.Health.Healthy:
+ logger.Info("repository became healthy, full resync")
+ return &provisioning.SyncJobOptions{}
+ case obj.Status.ObservedGeneration < 1:
+ logger.Info("full sync for new repository")
+ return &provisioning.SyncJobOptions{}
+ case obj.Generation != obj.Status.ObservedGeneration:
+ logger.Info("full sync for spec change")
+ return &provisioning.SyncJobOptions{}
+ case shouldResync:
+ logger.Info("incremental sync for sync interval")
+ return &provisioning.SyncJobOptions{Incremental: true}
+ default:
+ return nil
+ }
+}
+
+func (rc *RepositoryController) addSyncJob(ctx context.Context, obj *provisioning.Repository, syncOptions *provisioning.SyncJobOptions) error {
+ job, err := rc.jobs.Insert(ctx, &provisioning.Job{
+ ObjectMeta: v1.ObjectMeta{
+ Namespace: obj.Namespace,
+ },
+ Spec: provisioning.JobSpec{
+ Repository: obj.GetName(),
+ Action: provisioning.JobActionSync,
+ Pull: syncOptions,
+ },
+ })
+ if err != nil {
+ // FIXME: should we update the status of the repository if we fail to add the job?
+ return fmt.Errorf("error adding sync job: %w", err)
+ }
+
+ logging.FromContext(ctx).Info("sync job triggered", "job", job.Name)
+ return nil
+}
+
+func (rc *RepositoryController) patchStatus(ctx context.Context, obj *provisioning.Repository, patchOperations []map[string]interface{}) error {
+ if len(patchOperations) == 0 {
+ return nil
+ }
+
+ patch, err := json.Marshal(patchOperations)
+ if err != nil {
+ return fmt.Errorf("error encoding status patch: %w", err)
+ }
+
+ _, err = rc.client.Repositories(obj.GetNamespace()).
+ Patch(ctx, obj.Name, types.JSONPatchType, patch, v1.PatchOptions{}, "status")
+ if err != nil {
+ return fmt.Errorf("error applying status patch: %w", err)
+ }
+
+ return nil
+}
+
+func (rc *RepositoryController) determineSyncStatus(obj *provisioning.Repository, syncOptions *provisioning.SyncJobOptions) *provisioning.SyncStatus {
+ const unhealthyMessage = "Repository is unhealthy"
+
+ hasUnhealthyMessage := len(obj.Status.Sync.Message) > 0 && obj.Status.Sync.Message[0] == unhealthyMessage
+ switch {
+ case syncOptions != nil:
+ return &provisioning.SyncStatus{
+ State: provisioning.JobStatePending,
+ LastRef: obj.Status.Sync.LastRef,
+ Started: time.Now().UnixMilli(),
+ }
+ case obj.Status.Health.Healthy && hasUnhealthyMessage: // if the repository is healthy and the message is set, clear it
+ // FIXME: is this the clearest way to do this? Should we introduce another status or way of way of handling more
+ // specific errors?
+ return &provisioning.SyncStatus{
+ LastRef: obj.Status.Sync.LastRef,
+ }
+ case !obj.Status.Health.Healthy && !hasUnhealthyMessage: // if the repository is unhealthy and the message is not already set, set it
+ return &provisioning.SyncStatus{
+ State: provisioning.JobStateError,
+ Message: []string{unhealthyMessage},
+ LastRef: obj.Status.Sync.LastRef,
+ }
+ default:
+ return nil
+ }
+}
+
+//nolint:gocyclo
+func (rc *RepositoryController) process(item *queueItem) error {
+ logger := rc.logger.With("key", item.key)
+
+ namespace, name, err := cache.SplitMetaNamespaceKey(item.key)
+ if err != nil {
+ return err
+ }
+
+ obj, err := rc.repoLister.Repositories(namespace).Get(name)
+ switch {
+ case apierrors.IsNotFound(err):
+ return errors.New("repository not found in cache")
+ case err != nil:
+ return err
+ }
+
+ ctx, _, err := identity.WithProvisioningIdentity(context.Background(), namespace)
+ if err != nil {
+ return err
+ }
+ ctx = request.WithNamespace(ctx, namespace)
+ logger = logger.WithContext(ctx)
+
+ if obj.DeletionTimestamp != nil {
+ return rc.handleDelete(ctx, obj)
+ }
+
+ shouldResync := rc.shouldResync(obj)
+ shouldCheckHealth := rc.shouldCheckHealth(obj)
+ hasSpecChanged := obj.Generation != obj.Status.ObservedGeneration
+ patchOperations := []map[string]interface{}{}
+
+ // Determine the main triggering condition
+ switch {
+ case hasSpecChanged:
+ logger.Info("spec changed", "Generation", obj.Generation, "ObservedGeneration", obj.Status.ObservedGeneration)
+ patchOperations = append(patchOperations, map[string]interface{}{
+ "op": "replace",
+ "path": "/status/observedGeneration",
+ "value": obj.Generation,
+ })
+ case shouldResync:
+ logger.Info("sync interval triggered", "sync_interval", time.Duration(obj.Spec.Sync.IntervalSeconds)*time.Second, "sync_status", obj.Status.Sync)
+ case shouldCheckHealth:
+ logger.Info("health is stale", "health_status", obj.Status.Health.Healthy)
+ default:
+ logger.Info("skipping as conditions are not met", "status", obj.Status, "generation", obj.Generation, "sync_spec", obj.Spec.Sync)
+ return nil
+ }
+
+ repo, err := rc.repoGetter.AsRepository(ctx, obj)
+ if err != nil {
+ return fmt.Errorf("unable to create repository from configuration: %w", err)
+ }
+
+ healthStatus := obj.Status.Health
+ if shouldCheckHealth {
+ healthStatus = rc.runHealthCheck(ctx, repo)
+ patchOperations = append(patchOperations, map[string]interface{}{
+ "op": "replace",
+ "path": "/status/health",
+ "value": healthStatus,
+ })
+ }
+
+ // Run hooks
+ webhookStatus, err := rc.runHooks(ctx, repo, obj)
+ switch {
+ case err != nil:
+ return err
+ case webhookStatus != nil:
+ patchOperations = append(patchOperations, map[string]interface{}{
+ "op": "replace",
+ "path": "/status/webhook",
+ "value": webhookStatus,
+ })
+ }
+
+ // determine the sync strategy and sync status to apply
+ syncOptions := rc.determineSyncStrategy(ctx, obj, shouldResync, healthStatus)
+ if syncStatus := rc.determineSyncStatus(obj, syncOptions); syncStatus != nil {
+ patchOperations = append(patchOperations, map[string]interface{}{
+ "op": "replace",
+ "path": "/status/sync",
+ "value": syncStatus,
+ })
+ }
+
+ // Apply all patch operations
+ if err := rc.patchStatus(ctx, obj, patchOperations); err != nil {
+ return err
+ }
+
+ // Trigger sync job after we have applied all patch operations
+ if syncOptions != nil {
+ if err := rc.addSyncJob(ctx, obj, syncOptions); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/pkg/registry/apis/provisioning/export.go b/pkg/registry/apis/provisioning/export.go
new file mode 100644
index 00000000000..71e71e4678c
--- /dev/null
+++ b/pkg/registry/apis/provisioning/export.go
@@ -0,0 +1,83 @@
+package provisioning
+
+import (
+ "context"
+ "net/http"
+ "time"
+
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apiserver/pkg/registry/rest"
+
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+)
+
+type exportConnector struct {
+ repoGetter RepoGetter
+ jobs jobs.Queue
+}
+
+func (*exportConnector) New() runtime.Object {
+ return &provisioning.Job{}
+}
+
+func (*exportConnector) Destroy() {}
+
+func (*exportConnector) ProducesMIMETypes(verb string) []string {
+ return []string{"application/json"}
+}
+
+func (c *exportConnector) ProducesObject(verb string) any {
+ return c.New()
+}
+
+func (*exportConnector) ConnectMethods() []string {
+ return []string{http.MethodPost}
+}
+
+func (*exportConnector) NewConnectOptions() (runtime.Object, bool, string) {
+ return nil, false, ""
+}
+
+func (c *exportConnector) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
+ repo, err := c.repoGetter.GetRepository(ctx, name)
+ if err != nil {
+ return nil, err
+ }
+ cfg := repo.Config()
+ if err := repository.IsWriteAllowed(cfg, ""); err != nil {
+ return nil, err
+ }
+
+ return withTimeout(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ options := &provisioning.ExportJobOptions{}
+ if err := unmarshalJSON(r, defaultMaxBodySize, options); err != nil {
+ responder.Error(apierrors.NewBadRequest(err.Error()))
+ return
+ }
+ job, err := c.jobs.Insert(ctx, &provisioning.Job{
+ ObjectMeta: v1.ObjectMeta{
+ Namespace: cfg.Namespace,
+ },
+ Spec: provisioning.JobSpec{
+ Action: provisioning.JobActionExport,
+ Repository: cfg.Name,
+ Push: options,
+ },
+ })
+ if err != nil {
+ responder.Error(err)
+ } else {
+ responder.Object(http.StatusAccepted, job)
+ }
+ }), 30*time.Second), nil
+}
+
+var (
+ _ rest.Connecter = (*exportConnector)(nil)
+ _ rest.Storage = (*exportConnector)(nil)
+ _ rest.StorageMetadata = (*exportConnector)(nil)
+)
diff --git a/pkg/registry/apis/provisioning/filepath.go b/pkg/registry/apis/provisioning/filepath.go
new file mode 100644
index 00000000000..d8b9146e49e
--- /dev/null
+++ b/pkg/registry/apis/provisioning/filepath.go
@@ -0,0 +1,16 @@
+package provisioning
+
+import (
+ "strings"
+
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+)
+
+func pathAfterPrefix(urlPath, prefix string) (string, error) {
+ idx := strings.Index(urlPath, prefix)
+ if idx == -1 {
+ return "", apierrors.NewBadRequest("invalid request path")
+ }
+
+ return strings.TrimPrefix(urlPath[idx+len(prefix):], "/"), nil
+}
diff --git a/pkg/registry/apis/provisioning/filepath_test.go b/pkg/registry/apis/provisioning/filepath_test.go
new file mode 100644
index 00000000000..3758c9a48ba
--- /dev/null
+++ b/pkg/registry/apis/provisioning/filepath_test.go
@@ -0,0 +1,79 @@
+package provisioning
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestPathAfterPrefix(t *testing.T) {
+ tests := []struct {
+ name string
+ urlPath string
+ prefix string
+ want string
+ expectError bool
+ }{
+ {
+ name: "basic path with prefix",
+ urlPath: "/a/b/prefix/c",
+ prefix: "/prefix",
+ want: "c",
+ expectError: false,
+ },
+ {
+ name: "path with multiple prefix occurrences",
+ urlPath: "/a/prefix/b/prefix/c",
+ prefix: "/prefix",
+ want: "b/prefix/c",
+ expectError: false,
+ },
+ {
+ name: "path without prefix",
+ urlPath: "/a/b/c",
+ prefix: "/prefix",
+ want: "",
+ expectError: true,
+ },
+ {
+ name: "empty path",
+ urlPath: "",
+ prefix: "/prefix",
+ want: "",
+ expectError: true,
+ },
+ {
+ name: "empty prefix",
+ urlPath: "/a/b/c/d",
+ prefix: "",
+ want: "a/b/c/d",
+ expectError: false,
+ },
+ {
+ name: "prefix at start of path",
+ urlPath: "/prefix/rest/of/path",
+ prefix: "/prefix",
+ want: "rest/of/path",
+ expectError: false,
+ },
+ {
+ name: "prefix in middle with special chars",
+ urlPath: "/a/b-c/prefix/d_e/f",
+ prefix: "/prefix",
+ want: "d_e/f",
+ expectError: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := pathAfterPrefix(tt.urlPath, tt.prefix)
+ if tt.expectError {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ require.Equal(t, tt.want, got)
+ }
+ })
+ }
+}
diff --git a/pkg/registry/apis/provisioning/files.go b/pkg/registry/apis/provisioning/files.go
new file mode 100644
index 00000000000..ffd6d678e06
--- /dev/null
+++ b/pkg/registry/apis/provisioning/files.go
@@ -0,0 +1,386 @@
+package provisioning
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apiserver/pkg/registry/rest"
+
+ "github.com/grafana/grafana-app-sdk/logging"
+ "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
+)
+
+const (
+ // Files endpoint max size for dashboards etc (5MB)
+ filesMaxBodySize = 5 * 1024 * 1024
+)
+
+type filesConnector struct {
+ getter RepoGetter
+ parsers *resources.ParserFactory
+}
+
+func (*filesConnector) New() runtime.Object {
+ // This is added as the "ResponseType" regardless what ProducesObject() returns
+ return &provisioning.ResourceWrapper{}
+}
+
+func (*filesConnector) Destroy() {}
+
+func (*filesConnector) ProducesMIMETypes(verb string) []string {
+ return []string{"application/json"}
+}
+
+func (*filesConnector) ProducesObject(verb string) any {
+ return &provisioning.ResourceWrapper{}
+}
+
+func (*filesConnector) ConnectMethods() []string {
+ return []string{http.MethodGet, http.MethodPut, http.MethodPost, http.MethodDelete}
+}
+
+func (*filesConnector) NewConnectOptions() (runtime.Object, bool, string) {
+ return nil, true, "" // true adds the {path} component
+}
+
+// TODO: document the synchronous write and delete on the API Spec
+// TODO: Move dual write logic to `resources` package and keep this connector simple
+func (s *filesConnector) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
+ logger := logging.FromContext(ctx).With("logger", "files-connector", "repository_name", name)
+ ctx = logging.Context(ctx, logger)
+ repo, err := s.getter.GetRepository(ctx, name)
+ if err != nil {
+ logger.Debug("failed to find repository", "error", err)
+ return nil, err
+ }
+
+ reader, ok := repo.(repository.Reader)
+ if !ok {
+ return nil, apierrors.NewBadRequest("repository does not support read")
+ }
+
+ return withTimeout(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ query := r.URL.Query()
+ ref := query.Get("ref")
+ message := query.Get("message")
+ logger := logger.With("url", r.URL.Path, "ref", ref, "message", message)
+ ctx := logging.Context(r.Context(), logger)
+
+ filePath, err := pathAfterPrefix(r.URL.Path, fmt.Sprintf("/%s/files", name))
+ if err != nil {
+ responder.Error(apierrors.NewBadRequest(err.Error()))
+ return
+ }
+
+ if err := resources.IsPathSupported(filePath); err != nil {
+ responder.Error(apierrors.NewBadRequest(err.Error()))
+ return
+ }
+
+ isDir := safepath.IsDir(filePath)
+ if r.Method == http.MethodGet && isDir {
+ // TODO: Implement folder navigation
+ if len(filePath) > 0 {
+ responder.Error(apierrors.NewBadRequest("folder navigation not yet supported"))
+ return
+ }
+
+ // TODO: Add pagination
+ rsp, err := reader.ReadTree(ctx, ref)
+ if err != nil {
+ responder.Error(err)
+ return
+ }
+
+ files := &provisioning.FileList{}
+ for _, v := range rsp {
+ if !v.Blob {
+ continue // folder item
+ }
+ files.Items = append(files.Items, provisioning.FileItem{
+ Path: v.Path,
+ Size: v.Size,
+ Hash: v.Hash,
+ })
+ }
+ responder.Object(http.StatusOK, files)
+ return
+ }
+
+ if filePath == "" {
+ responder.Error(apierrors.NewBadRequest("path is required"))
+ return
+ }
+
+ // TODO: Implement folder delete
+ if r.Method == http.MethodDelete && isDir {
+ responder.Error(apierrors.NewBadRequest("folder navigation not yet supported"))
+ return
+ }
+
+ var obj *provisioning.ResourceWrapper
+ code := http.StatusOK
+ switch r.Method {
+ case http.MethodGet:
+ code, obj, err = s.doRead(ctx, reader, filePath, ref)
+ case http.MethodPost:
+ obj, err = s.doWrite(ctx, false, repo, filePath, ref, message, r)
+ case http.MethodPut:
+ // TODO: document in API specification
+ if isDir {
+ err = apierrors.NewMethodNotSupported(provisioning.RepositoryResourceInfo.GroupResource(), r.Method)
+ } else {
+ obj, err = s.doWrite(ctx, true, repo, filePath, ref, message, r)
+ }
+ case http.MethodDelete:
+ // TODO: limit file size
+ obj, err = s.doDelete(ctx, repo, filePath, ref, message)
+ default:
+ err = apierrors.NewMethodNotSupported(provisioning.RepositoryResourceInfo.GroupResource(), r.Method)
+ }
+
+ if err != nil {
+ logger.Debug("got an error after processing request", "error", err)
+ responder.Error(err)
+ return
+ }
+
+ // something failed
+ if len(obj.Errors) > 0 {
+ code = http.StatusInternalServerError
+ }
+
+ logger.Debug("request resulted in valid object", "object", obj)
+ responder.Object(code, obj)
+ }), 30*time.Second), nil
+}
+
+func (s *filesConnector) doRead(ctx context.Context, repo repository.Reader, path string, ref string) (int, *provisioning.ResourceWrapper, error) {
+ info, err := repo.Read(ctx, path, ref)
+ if err != nil {
+ return 0, nil, err
+ }
+
+ parser, err := s.parsers.GetParser(ctx, repo)
+ if err != nil {
+ return 0, nil, err
+ }
+
+ parsed, err := parser.Parse(ctx, info, true)
+ if err != nil {
+ return 0, nil, err
+ }
+
+ // GVR will exist for anything we can actually save
+ // TODO: Add known error in parser for unsupported resource
+ if parsed.GVR == nil {
+ if parsed.GVK != nil {
+ //nolint:govet
+ parsed.Errors = append(parsed.Errors, fmt.Errorf("unknown resource for Kind: %s", parsed.GVK.Kind))
+ } else {
+ parsed.Errors = append(parsed.Errors, fmt.Errorf("unknown resource"))
+ }
+ }
+
+ code := http.StatusOK
+ if len(parsed.Errors) > 0 {
+ code = http.StatusNotAcceptable
+ }
+ return code, parsed.AsResourceWrapper(), nil
+}
+
+func (s *filesConnector) doWrite(ctx context.Context, update bool, repo repository.Repository, path string, ref string, message string, req *http.Request) (*provisioning.ResourceWrapper, error) {
+ if err := repository.IsWriteAllowed(repo.Config(), ref); err != nil {
+ return nil, err
+ }
+
+ writer, ok := repo.(repository.ReaderWriter)
+ if !ok {
+ return nil, apierrors.NewBadRequest("repository does not support read-writing")
+ }
+
+ parser, err := s.parsers.GetParser(ctx, writer)
+ if err != nil {
+ return nil, err
+ }
+
+ defer func() { _ = req.Body.Close() }()
+ if safepath.IsDir(path) {
+ return s.doCreateFolder(ctx, writer, path, ref, message, parser)
+ }
+
+ data, err := readBody(req, filesMaxBodySize)
+ if err != nil {
+ return nil, err
+ }
+
+ info := &repository.FileInfo{
+ Data: data,
+ Path: path,
+ Ref: ref,
+ }
+
+ // TODO: improve parser to parse out of reader
+ parsed, err := parser.Parse(ctx, info, true)
+ if err != nil {
+ if errors.Is(err, resources.ErrUnableToReadResourceBytes) {
+ return nil, apierrors.NewBadRequest("unable to read the request as a resource")
+ }
+ return nil, err
+ }
+
+ // GVR will exist for anything we can actually save
+ // TODO: Add known error in parser for unsupported resource
+ if parsed.GVR == nil {
+ return nil, apierrors.NewBadRequest("The payload does not map to a known resource")
+ }
+
+ // Do not write if any errors exist
+ if len(parsed.Errors) > 0 {
+ return parsed.AsResourceWrapper(), err
+ }
+
+ data, err = parsed.ToSaveBytes()
+ if err != nil {
+ return nil, err
+ }
+
+ if update {
+ err = writer.Update(ctx, path, ref, data, message)
+ } else {
+ err = writer.Create(ctx, path, ref, data, message)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ // Directly update the grafana database
+ // Behaves the same running sync after writing
+ if ref == "" {
+ if parsed.Existing == nil {
+ parsed.Upsert, err = parsed.Client.Create(ctx, parsed.Obj, metav1.CreateOptions{})
+ if err != nil {
+ parsed.Errors = append(parsed.Errors, err)
+ }
+ } else {
+ parsed.Upsert, err = parsed.Client.Update(ctx, parsed.Obj, metav1.UpdateOptions{})
+ if err != nil {
+ parsed.Errors = append(parsed.Errors, err)
+ }
+ }
+ }
+
+ return parsed.AsResourceWrapper(), err
+}
+
+func (s *filesConnector) doCreateFolder(ctx context.Context, repo repository.Writer, path string, ref string, message string, parser *resources.Parser) (*provisioning.ResourceWrapper, error) {
+ client, err := parser.Clients().Folder()
+ if err != nil {
+ return nil, err
+ }
+ manager := resources.NewFolderManager(repo, client)
+
+ // Now actually create the folder
+ if err := repo.Create(ctx, path, ref, nil, message); err != nil {
+ return nil, fmt.Errorf("failed to create folder: %w", err)
+ }
+
+ cfg := repo.Config()
+ wrap := &provisioning.ResourceWrapper{
+ Path: path,
+ Ref: ref,
+ Repository: provisioning.ResourceRepositoryInfo{
+ Type: cfg.Spec.Type,
+ Namespace: cfg.Namespace,
+ Name: cfg.Name,
+ Title: cfg.Spec.Title,
+ },
+ Resource: provisioning.ResourceObjects{
+ Action: provisioning.ResourceActionCreate,
+ },
+ }
+
+ if ref == "" {
+ folderName, err := manager.EnsureFolderPathExist(ctx, path)
+ if err != nil {
+ return nil, err
+ }
+
+ current, err := manager.GetFolder(ctx, folderName)
+ if err != nil && !apierrors.IsNotFound(err) {
+ return nil, err // unable to check if the folder exists
+ }
+ wrap.Resource.Upsert = v0alpha1.Unstructured{
+ Object: current.Object,
+ }
+ }
+
+ return wrap, nil
+}
+
+// Deletes a file from the repository and the Grafana database.
+// If the path is a folder, it will return an error.
+// If the file is not parsable, it will return an error.
+func (s *filesConnector) doDelete(ctx context.Context, repo repository.Repository, path string, ref string, message string) (*provisioning.ResourceWrapper, error) {
+ if err := repository.IsWriteAllowed(repo.Config(), ref); err != nil {
+ return nil, err
+ }
+
+ // Read the existing value
+ access, ok := repo.(repository.ReaderWriter)
+ if !ok {
+ return nil, fmt.Errorf("repository is not read+writeable")
+ }
+
+ file, err := access.Read(ctx, path, ref)
+ if err != nil {
+ return nil, err // unable to read value
+ }
+
+ parser, err := s.parsers.GetParser(ctx, access)
+ if err != nil {
+ return nil, err // unable to read value
+ }
+
+ // TODO: document in API specification
+ // We can only delete parsable things
+ parsed, err := parser.Parse(ctx, file, false)
+ if err != nil {
+ return nil, err // unable to read value
+ }
+
+ parsed.Action = provisioning.ResourceActionDelete
+ wrap := parsed.AsResourceWrapper()
+
+ // Now delete the file
+ err = access.Delete(ctx, path, ref, message)
+ if err != nil {
+ return nil, err
+ }
+
+ // Delete the file in the grafana database
+ if ref == "" {
+ err = parsed.Client.Delete(ctx, parsed.Obj.GetName(), metav1.DeleteOptions{})
+ if apierrors.IsNotFound(err) {
+ err = nil // ignorable
+ }
+ }
+
+ return wrap, err
+}
+
+var (
+ _ rest.Storage = (*filesConnector)(nil)
+ _ rest.Connecter = (*filesConnector)(nil)
+ _ rest.StorageMetadata = (*filesConnector)(nil)
+)
diff --git a/pkg/registry/apis/provisioning/history.go b/pkg/registry/apis/provisioning/history.go
new file mode 100644
index 00000000000..cc45f63f32d
--- /dev/null
+++ b/pkg/registry/apis/provisioning/history.go
@@ -0,0 +1,97 @@
+package provisioning
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "time"
+
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apiserver/pkg/registry/rest"
+
+ "github.com/grafana/grafana-app-sdk/logging"
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
+)
+
+type historySubresource struct {
+ repoGetter RepoGetter
+}
+
+func (h *historySubresource) New() runtime.Object {
+ // This is added as the "ResponseType" regardless what ProducesObject() returns
+ return &provisioning.HistoryList{}
+}
+
+func (h *historySubresource) Destroy() {}
+
+func (h *historySubresource) NamespaceScoped() bool {
+ return true
+}
+
+func (h *historySubresource) GetSingularName() string {
+ return "History"
+}
+
+func (h *historySubresource) ProducesMIMETypes(verb string) []string {
+ return []string{"application/json"}
+}
+
+func (h *historySubresource) ProducesObject(verb string) runtime.Object {
+ return &provisioning.HistoryList{}
+}
+
+func (h *historySubresource) ConnectMethods() []string {
+ return []string{http.MethodGet}
+}
+
+func (h *historySubresource) NewConnectOptions() (runtime.Object, bool, string) {
+ return nil, true, "" // true adds the {path} component
+}
+
+func (h *historySubresource) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
+ logger := logging.FromContext(ctx).With("logger", "history-subresource")
+ ctx = logging.Context(ctx, logger)
+ repo, err := h.repoGetter.GetRepository(ctx, name)
+ if err != nil {
+ logger.Debug("failed to find repository", "error", err)
+ return nil, err
+ }
+
+ return withTimeout(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ versioned, ok := repo.(repository.Versioned)
+ if !ok {
+ responder.Error(apierrors.NewBadRequest("this repository does not support history"))
+ return
+ }
+
+ query := r.URL.Query()
+ ref := query.Get("ref")
+
+ filePath, err := pathAfterPrefix(r.URL.Path, fmt.Sprintf("/%s/history/", name))
+ if err != nil {
+ responder.Error(apierrors.NewBadRequest(err.Error()))
+ return
+ }
+
+ if err := resources.IsPathSupported(filePath); err != nil {
+ responder.Error(apierrors.NewBadRequest(err.Error()))
+ return
+ }
+
+ logger = logger.With("ref", ref, "path", filePath)
+ ctx = logging.Context(r.Context(), logger)
+
+ // TODO: Add history pagination
+ commits, err := versioned.History(ctx, filePath, ref)
+ if err != nil {
+ logger.Debug("failed to get history", "error", err)
+ responder.Error(err)
+ return
+ }
+
+ responder.Object(http.StatusOK, &provisioning.HistoryList{Items: commits})
+ }), 30*time.Second), nil
+}
diff --git a/pkg/registry/apis/provisioning/jobs/driver.go b/pkg/registry/apis/provisioning/jobs/driver.go
new file mode 100644
index 00000000000..f158dfd83e4
--- /dev/null
+++ b/pkg/registry/apis/provisioning/jobs/driver.go
@@ -0,0 +1,211 @@
+package jobs
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "github.com/grafana/grafana-app-sdk/logging"
+ "github.com/grafana/grafana/pkg/apimachinery/identity"
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/apifmt"
+ "k8s.io/apiserver/pkg/endpoints/request"
+)
+
+// Store is an abstraction for the storage API.
+// This exists to allow for unit testing.
+type Store interface {
+ // Claim takes a job from storage, marks it as ours, and returns it.
+ //
+ // Any job which has not been claimed by another worker is fair game.
+ //
+ // If err is not nil, the job and rollback values are always nil.
+ // The err may be ErrNoJobs if there are no jobs to claim.
+ Claim(ctx context.Context) (job *provisioning.Job, rollback func(), err error)
+
+ // Complete marks a job as completed and moves it to the historic job store.
+ // When in the historic store, there is no more claim on the job.
+ Complete(ctx context.Context, job *provisioning.Job) error
+
+ // Cleanup should be called periodically to clean up abandoned jobs.
+ // An abandoned job is one that has been claimed by a worker, but the worker has not updated the job in a while.
+ Cleanup(ctx context.Context) error
+
+ // InsertNotifications returns a channel that will have a value sent to it when a new job is inserted.
+ // This is used to wake up the job driver when a new job is inserted.
+ InsertNotifications() chan struct{}
+
+ // Update saves the job back to the store.
+ Update(ctx context.Context, job *provisioning.Job) (*provisioning.Job, error)
+}
+
+var _ Store = (*persistentStore)(nil)
+
+// jobDriver drives jobs to completion and manages the job queue.
+// There may be multiple jobDrivers running in parallel.
+// The jobDriver deals with cleaning up upon death and ensuring that jobs remain claimable.
+type jobDriver struct {
+ // Timeout for processing a job. This should be the same or less than a claim expiry.
+ timeout time.Duration
+ // CleanupInterval is the time between cleanup runs.
+ cleanupInterval time.Duration
+ // JobInterval is the time between job ticks. This should be relatively low.
+ jobInterval time.Duration
+
+ // Store is the job storage backend.
+ store Store
+ // RepoGetter lets us access repositories to pass to the worker.
+ repoGetter RepoGetter
+
+ // Workers process the job.
+ // Only the first worker who supports the job will process it; the rest are ignored.
+ workers []Worker
+}
+
+func NewJobDriver(
+ timeout, cleanupInterval, jobInterval time.Duration,
+ store Store,
+ repoGetter RepoGetter,
+ workers ...Worker,
+) *jobDriver {
+ return &jobDriver{
+ timeout: timeout,
+ cleanupInterval: cleanupInterval,
+ jobInterval: jobInterval,
+ store: store,
+ repoGetter: repoGetter,
+ workers: workers,
+ }
+}
+
+// Run drives jobs to completion. This is a blocking function.
+// It will run until the context is canceled.
+// This is a thread-safe function; it may be called from multiple goroutines.
+func (d *jobDriver) Run(ctx context.Context) {
+ cleanupTicker := time.NewTicker(d.cleanupInterval)
+ defer cleanupTicker.Stop()
+
+ jobTicker := time.NewTicker(d.jobInterval)
+ defer jobTicker.Stop()
+
+ logger := logging.FromContext(ctx).With("logger", "job-driver")
+ ctx = logging.Context(ctx, logger)
+ ctx, _, err := identity.WithProvisioningIdentity(ctx, "*") // "*" grants us access to all namespaces.
+ if err != nil {
+ logger.Error("failed to grant provisioning identity; this will panic!", "error", err)
+ panic("unreachable?: failed to grant provisioning identity: " + err.Error())
+ }
+
+ // Drive without waiting on startup.
+ d.startDriving(ctx)
+
+ for {
+ select {
+ case <-cleanupTicker.C:
+ if err := d.store.Cleanup(ctx); err != nil {
+ logger.Error("failed to cleanup jobs", "error", err)
+ }
+ case <-jobTicker.C:
+ d.startDriving(ctx)
+ case <-d.store.InsertNotifications():
+ d.startDriving(ctx)
+ }
+ }
+}
+
+func (d *jobDriver) startDriving(ctx context.Context) {
+ timeoutCtx, cancel := context.WithTimeout(ctx, d.timeout)
+ defer cancel()
+ for timeoutCtx.Err() == nil {
+ if err := d.drive(timeoutCtx); err != nil {
+ if !errors.Is(err, context.Canceled) && !errors.Is(err, ErrNoJobs) {
+ logging.FromContext(ctx).Error("failed to drive jobs", "error", err)
+ }
+ break
+ }
+ }
+}
+
+func (d *jobDriver) drive(ctx context.Context) error {
+ logger := logging.FromContext(ctx)
+
+ // Claim a job to work on.
+ job, rollback, err := d.store.Claim(ctx)
+ if err != nil {
+ return apifmt.Errorf("failed to claim job: %w", err)
+ }
+ // Ensure that the job is cleaned up if we fail to complete it.
+ // The rollback function does not care about cancellations.
+ defer rollback()
+
+ logger = logger.With("job", job.GetName(), "namespace", job.GetNamespace())
+ ctx = logging.Context(ctx, logger)
+ logger.Debug("claimed a job")
+
+ // Now that we have a job, we need to augment our namespace to grant ourselves permission to work on it.
+ // Incidentally, this also limits our permissions to only the namespace of the job.
+ ctx = request.WithNamespace(ctx, job.GetNamespace())
+ ctx, _, err = identity.WithProvisioningIdentity(ctx, job.GetNamespace())
+ if err != nil {
+ return apifmt.Errorf("failed to grant provisioning identity: %w", err)
+ }
+
+ // Process the job.
+ start := time.Now()
+ err = d.processJob(ctx, job) // NOTE: We pass in a pointer here such that the job status can be kept in Complete without re-fetching.
+ end := time.Now()
+ logger.Debug("job processed", "duration", end.Sub(start), "error", err)
+
+ if err != nil {
+ return apifmt.Errorf("failed to process job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
+ }
+
+ // Mark the job as completed.
+ if err := d.store.Complete(ctx, job); err != nil {
+ return apifmt.Errorf("failed to complete job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
+ }
+ logger.Debug("job completed")
+
+ return nil
+}
+
+func (d *jobDriver) processJob(ctx context.Context, job *provisioning.Job) error {
+ for _, worker := range d.workers {
+ if !worker.IsSupported(ctx, *job) {
+ continue
+ }
+
+ repo, err := d.repoGetter.GetRepository(ctx, job.Spec.Repository)
+ if err != nil {
+ return apifmt.Errorf("failed to get repository '%s': %w", job.Spec.Repository, err)
+ }
+
+ recorder := newJobProgressRecorder(d.onProgress(job))
+
+ err = worker.Process(ctx, repo, *job, recorder)
+ if err != nil {
+ return apifmt.Errorf("worker failed to process job: %w", err)
+ }
+
+ job.Status = recorder.Complete(ctx, err)
+
+ return nil
+ }
+
+ return apifmt.Errorf("no workers were registered to handle the job")
+}
+
+func (d *jobDriver) onProgress(job *provisioning.Job) ProgressFn {
+ return func(ctx context.Context, status provisioning.JobStatus) error {
+ logging.FromContext(ctx).Debug("job progress", "status", status)
+ job.Status = status
+
+ updated, err := d.store.Update(ctx, job)
+ if err != nil {
+ return apifmt.Errorf("failed to update job: %w", err)
+ }
+
+ *job = *updated
+ return nil
+ }
+}
diff --git a/pkg/registry/apis/provisioning/jobs/export/folders.go b/pkg/registry/apis/provisioning/jobs/export/folders.go
new file mode 100644
index 00000000000..d8fe746cff0
--- /dev/null
+++ b/pkg/registry/apis/provisioning/jobs/export/folders.go
@@ -0,0 +1,96 @@
+package export
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
+)
+
+// FIXME: revise logging in this method
+func (r *exportJob) loadFolders(ctx context.Context) error {
+ logger := r.logger
+ r.progress.SetMessage(ctx, "reading folder tree")
+
+ repoName := r.target.Config().Name
+
+ // TODO: should this be logging or message or both?
+ r.progress.SetMessage(ctx, "read folder tree from unified storage")
+ client, err := r.client.Folder()
+ if err != nil {
+ return err
+ }
+
+ rawList, err := client.List(ctx, metav1.ListOptions{Limit: 10000})
+ if err != nil {
+ return fmt.Errorf("failed to list folders: %w", err)
+ }
+ if rawList.GetContinue() != "" {
+ return fmt.Errorf("unable to list all folders in one request: %s", rawList.GetContinue())
+ }
+
+ for _, item := range rawList.Items {
+ err = r.folderTree.AddUnstructured(&item, repoName)
+ if err != nil {
+ r.progress.Record(ctx, jobs.JobResourceResult{
+ Name: item.GetName(),
+ Resource: folders.RESOURCE,
+ Group: folders.GROUP,
+ Error: err,
+ })
+ }
+ }
+
+ // create folders first is required so that empty folders exist when finished
+ r.progress.SetMessage(ctx, "write folders")
+
+ err = r.folderTree.Walk(ctx, func(ctx context.Context, folder resources.Folder) error {
+ p := folder.Path
+ if r.path != "" {
+ p = safepath.Join(r.path, p)
+ }
+ logger := logger.With("path", p)
+
+ result := jobs.JobResourceResult{
+ Name: folder.ID,
+ Resource: folders.RESOURCE,
+ Group: folders.GROUP,
+ Path: p,
+ }
+
+ _, err := r.target.Read(ctx, p, r.ref)
+ if err != nil && !(errors.Is(err, repository.ErrFileNotFound) || apierrors.IsNotFound(err)) {
+ result.Error = fmt.Errorf("failed to check if folder exists before writing: %w", err)
+ return result.Error
+ } else if err == nil {
+ logger.Info("folder already exists")
+ result.Action = repository.FileActionIgnored
+ r.progress.Record(ctx, result)
+ return nil
+ }
+
+ result.Action = repository.FileActionCreated
+ msg := fmt.Sprintf("export folder %s", p)
+ // Create with an empty body will make a folder (or .keep file if unsupported)
+ if err := r.target.Create(ctx, p, r.ref, nil, msg); err != nil {
+ result.Error = fmt.Errorf("failed to write folder in repo: %w", err)
+ r.progress.Record(ctx, result)
+ return result.Error
+ }
+
+ r.progress.Record(ctx, result)
+ return nil
+ })
+ if err != nil {
+ return fmt.Errorf("failed to write folders: %w", err)
+ }
+ return nil
+}
diff --git a/pkg/registry/apis/provisioning/jobs/export/job.go b/pkg/registry/apis/provisioning/jobs/export/job.go
new file mode 100644
index 00000000000..eb7f229d42d
--- /dev/null
+++ b/pkg/registry/apis/provisioning/jobs/export/job.go
@@ -0,0 +1,45 @@
+package export
+
+import (
+ "context"
+
+ "github.com/grafana/grafana-app-sdk/logging"
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
+)
+
+// ExportJob holds all context for a running job
+type exportJob struct {
+ logger logging.Logger
+ client *resources.ResourceClients // Read from
+ target repository.ReaderWriter // Write to
+ namespace string
+
+ progress jobs.JobProgressRecorder
+ folderTree *resources.FolderTree
+
+ path string // from options (now clean+safe)
+ ref string // from options (only git)
+ keepIdentifier bool
+}
+
+func newExportJob(ctx context.Context,
+ target repository.ReaderWriter,
+ options provisioning.ExportJobOptions,
+ clients *resources.ResourceClients,
+ progress jobs.JobProgressRecorder,
+) *exportJob {
+ return &exportJob{
+ namespace: target.Config().Namespace,
+ target: target,
+ client: clients,
+ logger: logging.FromContext(ctx),
+ progress: progress,
+ path: options.Path,
+ ref: options.Branch,
+ keepIdentifier: options.Identifier,
+ folderTree: resources.NewEmptyFolderTree(),
+ }
+}
diff --git a/pkg/registry/apis/provisioning/jobs/export/resources.go b/pkg/registry/apis/provisioning/jobs/export/resources.go
new file mode 100644
index 00000000000..2cfbf138e11
--- /dev/null
+++ b/pkg/registry/apis/provisioning/jobs/export/resources.go
@@ -0,0 +1,149 @@
+package export
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
+ "github.com/grafana/grafana/pkg/apimachinery/utils"
+ "github.com/grafana/grafana/pkg/infra/slugify"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
+)
+
+func (r *exportJob) loadResources(ctx context.Context) error {
+ kinds := []schema.GroupVersionResource{{
+ Group: dashboard.GROUP,
+ Resource: dashboard.DASHBOARD_RESOURCE,
+ Version: "v1alpha1",
+ }}
+
+ for _, kind := range kinds {
+ r.progress.SetMessage(ctx, fmt.Sprintf("reading %s resource", kind.Resource))
+ if err := r.loadResourcesFromAPIServer(ctx, kind); err != nil {
+ return fmt.Errorf("error loading %s %w", kind.Resource, err)
+ }
+ }
+ return nil
+}
+
+func (r *exportJob) loadResourcesFromAPIServer(ctx context.Context, kind schema.GroupVersionResource) error {
+ client, _, err := r.client.ForResource(kind)
+ if err != nil {
+ return err
+ }
+
+ var continueToken string
+ for {
+ list, err := client.List(ctx, metav1.ListOptions{Limit: 100, Continue: continueToken})
+ if err != nil {
+ return fmt.Errorf("error executing list: %w", err)
+ }
+
+ for _, item := range list.Items {
+ r.progress.Record(ctx, r.write(ctx, &item))
+ if err := r.progress.TooManyErrors(); err != nil {
+ return err
+ }
+ }
+
+ continueToken = list.GetContinue()
+ if continueToken == "" {
+ break
+ }
+ }
+
+ return nil
+}
+
+func (r *exportJob) write(ctx context.Context, obj *unstructured.Unstructured) jobs.JobResourceResult {
+ gvk := obj.GroupVersionKind()
+ result := jobs.JobResourceResult{
+ Name: obj.GetName(),
+ Resource: gvk.Kind,
+ Group: gvk.Group,
+ Action: repository.FileActionCreated,
+ }
+
+ if err := ctx.Err(); err != nil {
+ result.Error = fmt.Errorf("context error: %w", err)
+ return result
+ }
+
+ meta, err := utils.MetaAccessor(obj)
+ if err != nil {
+ result.Error = fmt.Errorf("extract meta accessor: %w", err)
+ return result
+ }
+
+ // Message from annotations
+ commitMessage := meta.GetMessage()
+ if commitMessage == "" {
+ g := meta.GetGeneration()
+ if g > 0 {
+ commitMessage = fmt.Sprintf("Generation: %d", g)
+ } else {
+ commitMessage = "exported from grafana"
+ }
+ }
+
+ name := meta.GetName()
+ manager, _ := meta.GetManagerProperties()
+ if manager.Identity == r.target.Config().GetName() {
+ result.Action = repository.FileActionIgnored
+ return result
+ }
+
+ title := meta.FindTitle("")
+ if title == "" {
+ title = name
+ }
+ folder := meta.GetFolder()
+
+ // Get the absolute path of the folder
+ fid, ok := r.folderTree.DirPath(folder, "")
+ if !ok {
+ // FIXME: Shouldn't this fail instead?
+ fid = resources.Folder{
+ Path: "__folder_not_found/" + slugify.Slugify(folder),
+ }
+ r.logger.Error("folder of item was not in tree of repository")
+ }
+
+ result.Path = fid.Path
+
+ // Clear the metadata
+ delete(obj.Object, "metadata")
+
+ if r.keepIdentifier {
+ meta.SetName(name) // keep the identifier in the metadata
+ }
+
+ body, err := json.MarshalIndent(obj.Object, "", " ")
+ if err != nil {
+ result.Error = fmt.Errorf("failed to marshal dashboard: %w", err)
+ return result
+ }
+
+ fileName := slugify.Slugify(title) + ".json"
+ if fid.Path != "" {
+ fileName = safepath.Join(fid.Path, fileName)
+ }
+ if r.path != "" {
+ fileName = safepath.Join(r.path, fileName)
+ }
+
+ err = r.target.Write(ctx, fileName, r.ref, body, commitMessage)
+ if err != nil {
+ result.Error = fmt.Errorf("failed to write file: %w", err)
+ }
+
+ return result
+}
diff --git a/pkg/registry/apis/provisioning/jobs/export/worker.go b/pkg/registry/apis/provisioning/jobs/export/worker.go
new file mode 100644
index 00000000000..7092fcc0b5b
--- /dev/null
+++ b/pkg/registry/apis/provisioning/jobs/export/worker.go
@@ -0,0 +1,124 @@
+package export
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/grafana/grafana-app-sdk/logging"
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+ gogit "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/go-git"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
+ "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
+)
+
+type ExportWorker struct {
+ // Tempdir for repo clones
+ clonedir string
+
+ // required to create clients
+ clientFactory *resources.ClientFactory
+
+ // Check where values are currently saved
+ storageStatus dualwrite.Service
+
+ // Decrypt secrets in config
+ secrets secrets.Service
+}
+
+func NewExportWorker(clientFactory *resources.ClientFactory,
+ storageStatus dualwrite.Service,
+ secrets secrets.Service,
+ clonedir string,
+) *ExportWorker {
+ return &ExportWorker{
+ clonedir,
+ clientFactory,
+ storageStatus,
+ secrets,
+ }
+}
+
+func (r *ExportWorker) IsSupported(ctx context.Context, job provisioning.Job) bool {
+ return job.Spec.Action == provisioning.JobActionExport
+}
+
+// Process will start a job
+func (r *ExportWorker) Process(ctx context.Context, repo repository.Repository, job provisioning.Job, progress jobs.JobProgressRecorder) error {
+ options := job.Spec.Push
+ if options == nil {
+ return errors.New("missing export settings")
+ }
+
+ // Can write to external branch
+ err := repository.IsWriteAllowed(repo.Config(), options.Branch)
+ if err != nil {
+ return err
+ }
+
+ // Use the existing clone if already checked out
+ buffered, ok := repo.(*gogit.GoGitRepo)
+ if !ok && repo.Config().Spec.GitHub != nil {
+ progress.SetMessage(ctx, "clone target")
+ buffered, err = gogit.Clone(ctx, repo.Config(), gogit.GoGitCloneOptions{
+ Root: r.clonedir,
+ SingleCommitBeforePush: true,
+ // TODO: make this configurable
+ Timeout: 10 * time.Minute,
+ }, r.secrets, os.Stdout)
+ if err != nil {
+ return fmt.Errorf("unable to clone target: %w", err)
+ }
+
+ repo = buffered // send all writes to the buffered repo
+ defer func() {
+ if err := buffered.Remove(ctx); err != nil {
+ logging.FromContext(ctx).Error("failed to remove cloned repository after export", "err", err)
+ }
+ }()
+
+ options.Branch = "" // :( the branch is now baked into the repo
+ }
+
+ rw, ok := repo.(repository.ReaderWriter)
+ if !ok {
+ return errors.New("export job submitted targeting repository that is not a ReaderWriter")
+ }
+
+ clients, err := r.clientFactory.Clients(ctx, repo.Config().Namespace)
+ if err != nil {
+ return err
+ }
+
+ worker := newExportJob(ctx, rw, *options, clients, progress)
+
+ // Load and write all folders
+ progress.SetMessage(ctx, "start folder export")
+ err = worker.loadFolders(ctx)
+ if err != nil {
+ return err
+ }
+
+ progress.SetMessage(ctx, "start resource export")
+ err = worker.loadResources(ctx)
+ if err != nil {
+ return err
+ }
+
+ if buffered != nil {
+ progress.SetMessage(ctx, "push changes")
+ if err := buffered.Push(ctx, gogit.GoGitPushOptions{
+ // TODO: make this configurable
+ Timeout: 10 * time.Minute,
+ }, os.Stdout); err != nil {
+ return fmt.Errorf("error pushing changes: %w", err)
+ }
+ }
+
+ return nil
+}
diff --git a/pkg/registry/apis/provisioning/jobs/migrate/folders.go b/pkg/registry/apis/provisioning/jobs/migrate/folders.go
new file mode 100644
index 00000000000..0f9090e4f8c
--- /dev/null
+++ b/pkg/registry/apis/provisioning/jobs/migrate/folders.go
@@ -0,0 +1,113 @@
+package migrate
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
+ "github.com/grafana/grafana/pkg/storage/unified/parquet"
+ "github.com/grafana/grafana/pkg/storage/unified/resource"
+)
+
+var _ resource.BulkResourceWriter = (*folderReader)(nil)
+
+type folderReader struct {
+ tree *resources.FolderTree
+ targetRepoName string
+}
+
+// Close implements resource.BulkResourceWrite.
+func (f *folderReader) Close() error {
+ return nil
+}
+
+// CloseWithResults implements resource.BulkResourceWrite.
+func (f *folderReader) CloseWithResults() (*resource.BulkResponse, error) {
+ return &resource.BulkResponse{}, nil
+}
+
+// Write implements resource.BulkResourceWrite.
+func (f *folderReader) Write(ctx context.Context, key *resource.ResourceKey, value []byte) error {
+ item := &unstructured.Unstructured{}
+ err := item.UnmarshalJSON(value)
+ if err != nil {
+ return fmt.Errorf("unmarshal unstructured to JSON: %w", err)
+ }
+
+ return f.tree.AddUnstructured(item, f.targetRepoName)
+}
+
+func (j *migrationJob) migrateLegacyFolders(ctx context.Context) error {
+ logger := j.logger
+ j.progress.SetMessage(ctx, "reading folder tree")
+
+ repoName := j.target.Config().Name
+
+ j.progress.SetMessage(ctx, "migrate folder tree from legacy")
+ reader := &folderReader{
+ tree: j.folderTree,
+ targetRepoName: repoName,
+ }
+ _, err := j.legacy.Migrate(ctx, legacy.MigrateOptions{
+ Namespace: j.namespace,
+ Resources: []schema.GroupResource{{
+ Group: folders.GROUP,
+ Resource: folders.RESOURCE,
+ }},
+ Store: parquet.NewBulkResourceWriterClient(reader),
+ })
+ if err != nil {
+ return fmt.Errorf("unable to read folders from legacy storage %w", err)
+ }
+
+ // create folders first is required so that empty folders exist when finished
+ j.progress.SetMessage(ctx, "write folders")
+
+ err = j.folderTree.Walk(ctx, func(ctx context.Context, folder resources.Folder) error {
+ p := folder.Path
+ logger = logger.With("path", p)
+
+ result := jobs.JobResourceResult{
+ Name: folder.ID,
+ Resource: folders.RESOURCE,
+ Group: folders.GROUP,
+ Path: p,
+ }
+
+ _, err := j.target.Read(ctx, p, "")
+ if err != nil && !(errors.Is(err, repository.ErrFileNotFound) || apierrors.IsNotFound(err)) {
+ result.Error = fmt.Errorf("failed to check if folder exists before writing: %w", err)
+ return result.Error
+ } else if err == nil {
+ logger.Info("folder already exists")
+ result.Action = repository.FileActionIgnored
+ j.progress.Record(ctx, result)
+ return nil
+ }
+
+ result.Action = repository.FileActionCreated
+ msg := fmt.Sprintf("export folder %s", p)
+ // Create with an empty body will make a folder (or .keep file if unsupported)
+ if err := j.target.Create(ctx, p, "", nil, msg); err != nil {
+ result.Error = fmt.Errorf("failed to write folder in repo: %w", err)
+ j.progress.Record(ctx, result)
+ return result.Error
+ }
+
+ return nil
+ })
+ if err != nil {
+ return fmt.Errorf("failed to write folders: %w", err)
+ }
+
+ return nil
+}
diff --git a/pkg/registry/apis/provisioning/jobs/migrate/import.go b/pkg/registry/apis/provisioning/jobs/migrate/import.go
new file mode 100644
index 00000000000..3a4fa42a180
--- /dev/null
+++ b/pkg/registry/apis/provisioning/jobs/migrate/import.go
@@ -0,0 +1,90 @@
+package migrate
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "google.golang.org/grpc/metadata"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ "github.com/grafana/grafana-app-sdk/logging"
+ dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
+ folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
+ "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
+ "github.com/grafana/grafana/pkg/storage/unified/resource"
+)
+
+// called when an error exists
+func stopReadingUnifiedStorage(ctx context.Context, dual dualwrite.Service) error {
+ kinds := []schema.GroupResource{{
+ Group: folders.GROUP,
+ Resource: folders.RESOURCE,
+ }, {
+ Group: dashboard.GROUP,
+ Resource: dashboard.DASHBOARD_RESOURCE,
+ }}
+
+ for _, gr := range kinds {
+ status, _ := dual.Status(ctx, gr)
+ status.ReadUnified = false
+ status.Migrated = 0
+ status.Migrating = 0
+ _, err := dual.Update(ctx, status)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (j *migrationJob) wipeUnifiedAndSetMigratedFlag(ctx context.Context, dual dualwrite.Service) error {
+ kinds := []schema.GroupResource{{
+ Group: folders.GROUP,
+ Resource: folders.RESOURCE,
+ }, {
+ Group: dashboard.GROUP,
+ Resource: dashboard.DASHBOARD_RESOURCE,
+ }}
+
+ for _, gr := range kinds {
+ status, _ := dual.Status(ctx, gr)
+ if status.ReadUnified {
+ return fmt.Errorf("unexpected state - already using unified storage for: %s", gr)
+ }
+ if status.Migrating > 0 {
+ if time.Since(time.UnixMilli(status.Migrating)) < time.Second*30 {
+ return fmt.Errorf("another migration job is running for: %s", gr)
+ }
+ }
+ settings := resource.BulkSettings{
+ RebuildCollection: true, // wipes everything in the collection
+ Collection: []*resource.ResourceKey{{
+ Namespace: j.namespace,
+ Group: gr.Group,
+ Resource: gr.Resource,
+ }},
+ }
+ ctx = metadata.NewOutgoingContext(ctx, settings.ToMD())
+ stream, err := j.batch.BulkProcess(ctx)
+ if err != nil {
+ return fmt.Errorf("error clearing unified %s / %w", gr, err)
+ }
+ stats, err := stream.CloseAndRecv()
+ if err != nil {
+ return fmt.Errorf("error clearing unified %s / %w", gr, err)
+ }
+ logger := logging.FromContext(ctx)
+ logger.Error("cleared unified stoage", "stats", stats)
+
+ status.Migrated = time.Now().UnixMilli() // but not really... since the sync is starting
+ status.ReadUnified = true
+ status.WriteLegacy = false // keep legacy "clean"
+ _, err = dual.Update(ctx, status)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/pkg/registry/apis/provisioning/jobs/migrate/resources.go b/pkg/registry/apis/provisioning/jobs/migrate/resources.go
new file mode 100644
index 00000000000..320fb7bc5cb
--- /dev/null
+++ b/pkg/registry/apis/provisioning/jobs/migrate/resources.go
@@ -0,0 +1,234 @@
+package migrate
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/client-go/dynamic"
+
+ dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
+ "github.com/grafana/grafana/pkg/apimachinery/utils"
+ "github.com/grafana/grafana/pkg/infra/slugify"
+ "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
+ "github.com/grafana/grafana/pkg/storage/unified/parquet"
+ "github.com/grafana/grafana/pkg/storage/unified/resource"
+)
+
+var _ resource.BulkResourceWriter = (*resourceReader)(nil)
+
+type resourceReader struct {
+ job *migrationJob
+}
+
+// Close implements resource.BulkResourceWriter.
+func (r *resourceReader) Close() error {
+ return nil
+}
+
+// CloseWithResults implements resource.BulkResourceWriter.
+func (r *resourceReader) CloseWithResults() (*resource.BulkResponse, error) {
+ return &resource.BulkResponse{}, nil
+}
+
+// Write implements resource.BulkResourceWriter.
+func (r *resourceReader) Write(ctx context.Context, key *resource.ResourceKey, value []byte) error {
+ // Reuse the same parse+cleanup logic
+ parsed, err := r.job.parser.Parse(ctx, &repository.FileInfo{
+ Path: "", // empty path to ignore file system
+ Data: value,
+ }, false)
+ if err != nil {
+ // TODO: should we fail the entire execution?
+ return fmt.Errorf("failed to unmarshal unstructured: %w", err)
+ }
+
+ // clear anything so it will get written
+ parsed.Meta.SetManagerProperties(utils.ManagerProperties{})
+ parsed.Meta.SetSourceProperties(utils.SourceProperties{})
+
+ if result := r.job.write(ctx, parsed.Obj); result.Error != nil {
+ r.job.progress.Record(ctx, result)
+ if err := r.job.progress.TooManyErrors(); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (j *migrationJob) migrateLegacyResources(ctx context.Context) error {
+ kinds := []schema.GroupVersionResource{{
+ Group: dashboard.GROUP,
+ Resource: dashboard.DASHBOARD_RESOURCE,
+ Version: "v1alpha1",
+ }}
+
+ for _, kind := range kinds {
+ j.progress.SetMessage(ctx, fmt.Sprintf("migrate %s resource", kind.Resource))
+ gr := kind.GroupResource()
+ opts := legacy.MigrateOptions{
+ Namespace: j.namespace,
+ WithHistory: j.options.History,
+ Resources: []schema.GroupResource{gr},
+ Store: parquet.NewBulkResourceWriterClient(&resourceReader{job: j}),
+ OnlyCount: true, // first get the count
+ }
+ stats, err := j.legacy.Migrate(ctx, opts)
+ if err != nil {
+ return fmt.Errorf("unable to count legacy items %w", err)
+ }
+
+ // FIXME: explain why we calculate it in this way
+ if len(stats.Summary) > 0 {
+ count := stats.Summary[0].Count //
+ history := stats.Summary[0].History
+ if history > count {
+ count = history // the number of items we will process
+ }
+ j.progress.SetTotal(ctx, int(count))
+ }
+
+ opts.OnlyCount = false // this time actually write
+ _, err = j.legacy.Migrate(ctx, opts)
+ if err != nil {
+ return fmt.Errorf("error running legacy migrate %s %w", kind.Resource, err)
+ }
+ }
+ return nil
+}
+
+func (j *migrationJob) write(ctx context.Context, obj *unstructured.Unstructured) jobs.JobResourceResult {
+ gvk := obj.GroupVersionKind()
+ result := jobs.JobResourceResult{
+ Name: obj.GetName(),
+ Resource: gvk.Kind,
+ Group: gvk.Group,
+ Action: repository.FileActionCreated,
+ }
+
+ if err := ctx.Err(); err != nil {
+ result.Error = fmt.Errorf("context error: %w", err)
+ return result
+ }
+
+ meta, err := utils.MetaAccessor(obj)
+ if err != nil {
+ result.Error = fmt.Errorf("extract meta accessor: %w", err)
+ return result
+ }
+
+ // Message from annotations
+ commitMessage := meta.GetMessage()
+ if commitMessage == "" {
+ g := meta.GetGeneration()
+ if g > 0 {
+ commitMessage = fmt.Sprintf("Generation: %d", g)
+ } else {
+ commitMessage = "exported from grafana"
+ }
+ }
+
+ name := meta.GetName()
+ manager, _ := meta.GetManagerProperties()
+ if manager.Identity == j.target.Config().GetName() {
+ result.Action = repository.FileActionIgnored
+ return result
+ }
+
+ title := meta.FindTitle("")
+ if title == "" {
+ title = name
+ }
+ folder := meta.GetFolder()
+
+ // Add the author in context (if available)
+ ctx = j.withAuthorSignature(ctx, meta)
+
+ // Get the absolute path of the folder
+ fid, ok := j.folderTree.DirPath(folder, "")
+ if !ok {
+ // FIXME: Shouldn't this fail instead?
+ fid = resources.Folder{
+ Path: "__folder_not_found/" + slugify.Slugify(folder),
+ }
+ j.logger.Error("folder of item was not in tree of repository")
+ }
+
+ result.Path = fid.Path
+
+ // Clear the metadata
+ delete(obj.Object, "metadata")
+
+ if j.options.Identifier {
+ meta.SetName(name) // keep the identifier in the metadata
+ }
+
+ body, err := json.MarshalIndent(obj.Object, "", " ")
+ if err != nil {
+ result.Error = fmt.Errorf("failed to marshal dashboard: %w", err)
+ return result
+ }
+
+ fileName := slugify.Slugify(title) + ".json"
+ if fid.Path != "" {
+ fileName = safepath.Join(fid.Path, fileName)
+ }
+
+ err = j.target.Write(ctx, fileName, "", body, commitMessage)
+ if err != nil {
+ result.Error = fmt.Errorf("failed to write file: %w", err)
+ }
+
+ return result
+}
+
+func removeUnprovisioned(ctx context.Context, client dynamic.ResourceInterface, progress jobs.JobProgressRecorder) error {
+ rawList, err := client.List(ctx, metav1.ListOptions{Limit: 10000})
+ if err != nil {
+ return fmt.Errorf("failed to list resources: %w", err)
+ }
+
+ if rawList.GetContinue() != "" {
+ return fmt.Errorf("unable to list all resources in one request: %s", rawList.GetContinue())
+ }
+
+ for _, item := range rawList.Items {
+ // Create a pointer to the item since MetaAccessor requires a pointer
+ itemPtr := &item
+ meta, err := utils.MetaAccessor(itemPtr)
+ if err != nil {
+ return fmt.Errorf("extract meta accessor: %w", err)
+ }
+
+ // Skip if managed
+ _, ok := meta.GetManagerProperties()
+ if ok {
+ continue
+ }
+
+ result := jobs.JobResourceResult{
+ Name: item.GetName(),
+ Resource: item.GetKind(),
+ Group: item.GroupVersionKind().Group,
+ Action: repository.FileActionDeleted,
+ }
+
+ if err = client.Delete(ctx, item.GetName(), metav1.DeleteOptions{}); err != nil {
+ result.Error = fmt.Errorf("failed to delete folder: %w", err)
+ progress.Record(ctx, result)
+ return result.Error
+ }
+
+ progress.Record(ctx, result)
+ }
+
+ return nil
+}
diff --git a/pkg/registry/apis/provisioning/jobs/migrate/users.go b/pkg/registry/apis/provisioning/jobs/migrate/users.go
new file mode 100644
index 00000000000..30d52faf770
--- /dev/null
+++ b/pkg/registry/apis/provisioning/jobs/migrate/users.go
@@ -0,0 +1,53 @@
+package migrate
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+)
+
+func (j *migrationJob) loadUsers(ctx context.Context) error {
+ client, err := j.parser.Clients().User()
+ if err != nil {
+ return err
+ }
+
+ rawList, err := client.List(ctx, metav1.ListOptions{Limit: 10000})
+ if err != nil {
+ return fmt.Errorf("failed to list users: %w", err)
+ }
+ if rawList.GetContinue() != "" {
+ return fmt.Errorf("unable to list all users in one request: %s", rawList.GetContinue())
+ }
+
+ var ok bool
+ j.userInfo = make(map[string]repository.CommitSignature)
+ for _, item := range rawList.Items {
+ sig := repository.CommitSignature{}
+ // FIXME: should we improve logging here?
+ sig.Name, ok, err = unstructured.NestedString(item.Object, "spec", "login")
+ if !ok || err != nil {
+ continue
+ }
+ sig.Email, ok, err = unstructured.NestedString(item.Object, "spec", "email")
+ if !ok || err != nil {
+ continue
+ }
+
+ if sig.Name == sig.Email {
+ if sig.Name == "" {
+ sig.Name = item.GetName()
+ } else if strings.Contains(sig.Email, "@") {
+ sig.Email = "" // don't use the same value for name+email
+ }
+ }
+
+ j.userInfo["user:"+item.GetName()] = sig
+ }
+ return nil
+}
diff --git a/pkg/registry/apis/provisioning/jobs/migrate/worker.go b/pkg/registry/apis/provisioning/jobs/migrate/worker.go
new file mode 100644
index 00000000000..1a66066b2db
--- /dev/null
+++ b/pkg/registry/apis/provisioning/jobs/migrate/worker.go
@@ -0,0 +1,333 @@
+package migrate
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "time"
+
+ "github.com/grafana/grafana-app-sdk/logging"
+ "github.com/grafana/grafana/pkg/apimachinery/utils"
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/export"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/sync"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+ gogit "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/go-git"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
+ "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
+ "github.com/grafana/grafana/pkg/storage/unified/resource"
+)
+
+type MigrationWorker struct {
+ // Tempdir for repo clones
+ clonedir string
+
+ // temporary... while we still do an import
+ parsers *resources.ParserFactory
+
+ // Check where values are currently saved
+ storageStatus dualwrite.Service
+
+ // Support reading from history
+ legacyMigrator legacy.LegacyMigrator
+
+ // Direct access to unified storage... use carefully!
+ bulk resource.BulkStoreClient
+
+ // Decrypt secret from config object
+ secrets secrets.Service
+
+ // Delegate the export to the export worker
+ exportWorker *export.ExportWorker
+
+ // Delegate the import to sync worker
+ syncWorker *sync.SyncWorker
+}
+
+func NewMigrationWorker(
+ legacyMigrator legacy.LegacyMigrator,
+ parsers *resources.ParserFactory, // should not be necessary!
+ storageStatus dualwrite.Service,
+ batch resource.BulkStoreClient,
+ secrets secrets.Service,
+ exportWorker *export.ExportWorker,
+ syncWorker *sync.SyncWorker,
+ clonedir string,
+) *MigrationWorker {
+ return &MigrationWorker{
+ clonedir,
+ parsers,
+ storageStatus,
+ legacyMigrator,
+ batch,
+ secrets,
+ exportWorker,
+ syncWorker,
+ }
+}
+
+func (w *MigrationWorker) IsSupported(ctx context.Context, job provisioning.Job) bool {
+ return job.Spec.Action == provisioning.JobActionMigrate
+}
+
+// Process will start a job
+func (w *MigrationWorker) Process(ctx context.Context, repo repository.Repository, job provisioning.Job, progress jobs.JobProgressRecorder) error {
+ options := job.Spec.Migrate
+ if options == nil {
+ return errors.New("missing migrate settings")
+ }
+
+ var (
+ err error
+ buffered *gogit.GoGitRepo
+ )
+
+ isFromLegacy := dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, w.storageStatus)
+ progress.SetTotal(ctx, 10) // will show a progress bar
+
+ // TODO: we should fail fast if migration is not possible and not always clone the repository.
+ if repo.Config().Spec.GitHub != nil {
+ progress.SetMessage(ctx, "clone "+repo.Config().Spec.GitHub.URL)
+ reader, writer := io.Pipe()
+ go func() {
+ scanner := bufio.NewScanner(reader)
+ for scanner.Scan() {
+ progress.SetMessage(ctx, scanner.Text())
+ }
+ }()
+
+ buffered, err = gogit.Clone(ctx, repo.Config(), gogit.GoGitCloneOptions{
+ Root: w.clonedir,
+ SingleCommitBeforePush: !(options.History && isFromLegacy),
+ // TODO: make this configurable
+ Timeout: 10 * time.Minute,
+ }, w.secrets, writer)
+ if err != nil {
+ return fmt.Errorf("unable to clone target: %w", err)
+ }
+
+ repo = buffered // send all writes to the buffered repo
+ defer func() {
+ if err := buffered.Remove(ctx); err != nil {
+ logging.FromContext(ctx).Error("failed to remove cloned repository after migrate", "err", err)
+ }
+ }()
+ }
+
+ rw, ok := repo.(repository.ReaderWriter)
+ if !ok {
+ return errors.New("migration job submitted targeting repository that is not a ReaderWriter")
+ }
+
+ if isFromLegacy {
+ return w.migrateFromLegacy(ctx, rw, buffered, *options, progress)
+ }
+
+ return w.migrateFromUnifiedStorage(ctx, rw, *options, progress)
+}
+
+// migrateFromLegacy will export the resources from legacy storage and import them into the target repository
+func (w *MigrationWorker) migrateFromLegacy(ctx context.Context, rw repository.ReaderWriter, buffered *gogit.GoGitRepo, options provisioning.MigrateJobOptions, progress jobs.JobProgressRecorder) error {
+ parser, err := w.parsers.GetParser(ctx, rw)
+ if err != nil {
+ return fmt.Errorf("error getting parser: %w", err)
+ }
+
+ worker, err := newMigrationJob(ctx, rw, options, parser, w.bulk, w.legacyMigrator, progress)
+ if err != nil {
+ return fmt.Errorf("error creating job: %w", err)
+ }
+
+ if options.History {
+ progress.SetMessage(ctx, "loading users")
+ err = worker.loadUsers(ctx)
+ if err != nil {
+ return fmt.Errorf("error loading users: %w", err)
+ }
+ }
+
+ progress.SetMessage(ctx, "exporting legacy folders")
+ err = worker.migrateLegacyFolders(ctx)
+ if err != nil {
+ return err
+ }
+
+ progress.SetMessage(ctx, "exporting legacy resources")
+ err = worker.migrateLegacyResources(ctx)
+ if err != nil {
+ return err
+ }
+
+ if buffered != nil {
+ progress.SetMessage(ctx, "pushing changes")
+ reader, writer := io.Pipe()
+ go func() {
+ scanner := bufio.NewScanner(reader)
+ for scanner.Scan() {
+ progress.SetMessage(ctx, scanner.Text())
+ }
+ }()
+
+ if err := buffered.Push(ctx, gogit.GoGitPushOptions{
+ // TODO: make this configurable
+ Timeout: 10 * time.Minute,
+ }, writer); err != nil {
+ return fmt.Errorf("error pushing changes: %w", err)
+ }
+ }
+
+ progress.SetMessage(ctx, "resetting unified storage")
+ if err = worker.wipeUnifiedAndSetMigratedFlag(ctx, w.storageStatus); err != nil {
+ return fmt.Errorf("unable to reset unified storage %w", err)
+ }
+
+ // Reset the results after the export as pull will operate on the same resources
+ progress.ResetResults()
+
+ // Delegate the import to a sync (from the already checked out go-git repository!)
+ progress.SetMessage(ctx, "pulling resources")
+ err = w.syncWorker.Process(ctx, rw, provisioning.Job{
+ Spec: provisioning.JobSpec{
+ Pull: &provisioning.SyncJobOptions{
+ Incremental: false,
+ },
+ },
+ }, progress)
+ if err != nil { // this will have an error when too many errors exist
+ progress.SetMessage(ctx, "error importing resources, reverting")
+ if e2 := stopReadingUnifiedStorage(ctx, w.storageStatus); e2 != nil {
+ logger := logging.FromContext(ctx)
+ logger.Warn("error trying to revert dual write settings after an error", "err", err)
+ }
+ }
+
+ return err
+}
+
+// migrateFromUnifiedStorage will export the resources from unified storage and import them into the target repository
+func (w *MigrationWorker) migrateFromUnifiedStorage(ctx context.Context, repo repository.ReaderWriter, options provisioning.MigrateJobOptions, progress jobs.JobProgressRecorder) error {
+ parser, err := w.parsers.GetParser(ctx, repo)
+ if err != nil {
+ return fmt.Errorf("error getting parser: %w", err)
+ }
+
+ progress.SetMessage(ctx, "exporting unified storage resources")
+ if err := w.exportWorker.Process(ctx, repo, provisioning.Job{
+ Spec: provisioning.JobSpec{
+ Push: &provisioning.ExportJobOptions{
+ Identifier: options.Identifier,
+ },
+ },
+ }, progress); err != nil {
+ return fmt.Errorf("export resources: %w", err)
+ }
+
+ // Reset the results after the export as pull will operate on the same resources
+ progress.ResetResults()
+
+ progress.SetMessage(ctx, "pulling resources")
+ err = w.syncWorker.Process(ctx, repo, provisioning.Job{
+ Spec: provisioning.JobSpec{
+ Pull: &provisioning.SyncJobOptions{
+ Incremental: false,
+ },
+ },
+ }, progress)
+ if err != nil {
+ return fmt.Errorf("pull resources: %w", err)
+ }
+
+ folderClient, err := parser.Clients().Folder()
+ if err != nil {
+ return fmt.Errorf("unable to get folder client: %w", err)
+ }
+
+ dashboardClient, err := parser.Clients().Dashboard()
+ if err != nil {
+ return fmt.Errorf("unable to get dashboard client: %w", err)
+ }
+
+ progress.SetMessage(ctx, "removing unprovisioned folders")
+ err = removeUnprovisioned(ctx, folderClient, progress)
+ if err != nil {
+ return fmt.Errorf("remove unprovisioned folders: %w", err)
+ }
+
+ progress.SetMessage(ctx, "removing unprovisioned dashboards")
+ err = removeUnprovisioned(ctx, dashboardClient, progress)
+ if err != nil {
+ return fmt.Errorf("remove unprovisioned dashboards: %w", err)
+ }
+
+ return nil
+}
+
+// MigrationJob holds all context for a running job
+type migrationJob struct {
+ logger logging.Logger
+ target repository.ReaderWriter
+ legacy legacy.LegacyMigrator
+ parser *resources.Parser
+ batch resource.BulkStoreClient
+
+ namespace string
+
+ progress jobs.JobProgressRecorder
+
+ userInfo map[string]repository.CommitSignature
+ folderTree *resources.FolderTree
+
+ options provisioning.MigrateJobOptions
+}
+
+func newMigrationJob(ctx context.Context,
+ target repository.ReaderWriter,
+ options provisioning.MigrateJobOptions,
+ parser *resources.Parser,
+ batch resource.BulkStoreClient,
+ legacyMigrator legacy.LegacyMigrator,
+ progress jobs.JobProgressRecorder,
+) (*migrationJob, error) {
+ return &migrationJob{
+ namespace: target.Config().Namespace,
+ target: target,
+ logger: logging.FromContext(ctx),
+ progress: progress,
+ options: options,
+ parser: parser,
+ batch: batch,
+ legacy: legacyMigrator,
+ folderTree: resources.NewEmptyFolderTree(),
+ }, nil
+}
+
+func (j *migrationJob) withAuthorSignature(ctx context.Context, item utils.GrafanaMetaAccessor) context.Context {
+ if j.userInfo == nil {
+ return ctx
+ }
+ id := item.GetUpdatedBy()
+ if id == "" {
+ id = item.GetCreatedBy()
+ }
+ if id == "" {
+ id = "grafana"
+ }
+
+ sig := j.userInfo[id] // lookup
+ if sig.Name == "" && sig.Email == "" {
+ sig.Name = id
+ }
+ t, err := item.GetUpdatedTimestamp()
+ if err == nil && t != nil {
+ sig.When = *t
+ } else {
+ sig.When = item.GetCreationTimestamp().Time
+ }
+
+ return repository.WithAuthorSignature(ctx, sig)
+}
diff --git a/pkg/registry/apis/provisioning/jobs/persistentstore.go b/pkg/registry/apis/provisioning/jobs/persistentstore.go
new file mode 100644
index 00000000000..c8e836efe41
--- /dev/null
+++ b/pkg/registry/apis/provisioning/jobs/persistentstore.go
@@ -0,0 +1,427 @@
+package jobs
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/grafana/grafana-app-sdk/logging"
+ "github.com/grafana/grafana/pkg/apimachinery/identity"
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/apifmt"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/apis/meta/internalversion"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/labels"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/selection"
+ "k8s.io/apiserver/pkg/endpoints/request"
+ "k8s.io/apiserver/pkg/registry/rest"
+)
+
+const (
+ // LabelJobClaim includes the timestamp when the job was claimed.
+ // The label must be formatted as milliseconds from Epoch. This grants a natural ordering, allowing for less-than operators in label selectors.
+ // The natural ordering would be broken if the number rolls over into 1 more digit. This won't happen before Nov, 2286.
+ LabelJobClaim = "provisioning.grafana.app/claim"
+ // LabelJobOriginalName contains the Job's name as a label. This allows for label selectors to find the archived version of a job.
+ LabelJobOriginalName = "provisioning.grafana.app/original-name"
+ // LabelRepository contains the repository name as a label. This allows for label selectors to find the archived version of a job.
+ LabelRepository = "provisioning.grafana.app/repository"
+)
+
+var (
+ ErrNoJobs = &apierrors.StatusError{
+ ErrStatus: metav1.Status{
+ Status: metav1.StatusFailure,
+ Reason: metav1.StatusReasonConflict,
+ Message: "no jobs are available to claim, try again later",
+ Code: http.StatusNoContent,
+ Details: &metav1.StatusDetails{
+ Group: provisioning.GROUP,
+ Kind: provisioning.JobResourceInfo.GetName(),
+ RetryAfterSeconds: 3,
+ },
+ },
+ }
+
+ errWouldCreate = errors.New("this call would have created a new resource; it is rejected")
+ failCreation rest.ValidateObjectFunc = func(_ context.Context, _ runtime.Object) error {
+ return errWouldCreate
+ }
+)
+
+type Queue interface {
+ // Insert adds a new job to the queue.
+ //
+ // This saves it if it is a new job, or fails with ErrJobAlreadyExists if one with the same name already exists.
+ Insert(ctx context.Context, job *provisioning.Job) (*provisioning.Job, error)
+}
+
+var _ Queue = (*persistentStore)(nil)
+
+type jobStorage interface {
+ rest.Creater
+ rest.Lister
+ rest.Patcher
+ rest.GracefulDeleter
+}
+
+// persistentStore is a job queue abstraction.
+// It calls out to a real storage implementation to store the jobs, and a separate storage for historic jobs that have been completed.
+// When persistentStore claims a job, it will update the status of it. This does a ResourceVersion check to ensure it is atomic; if the job has been claimed by another worker, the claim will fail.
+// When a job is completed, it is moved to the historic job store by first deleting it from the job store and then creating it in the historic job store. We are fine with the job being lost if the historic job store fails to create it.
+type persistentStore struct {
+ jobStore jobStorage
+ historicJobStore rest.Creater
+
+ // clock is a function that returns the current time.
+ clock func() time.Time
+ // expiry is the time after which a job is considered abandoned.
+ // If a job is abandoned, it will have its claim cleaned up periodically.
+ expiry time.Duration
+
+ // notifications has a signal sent to it when a new job is inserted. If a value already exists, nothing is sent.
+ //
+ // This is very similar to the concept of a Waker in Rust:
+ notifications chan struct{}
+}
+
+func NewStore(
+ jobStore jobStorage,
+ historicJobStore rest.Creater,
+ expiry time.Duration,
+) (*persistentStore, error) {
+ if expiry <= 0 {
+ expiry = time.Second * 30
+ }
+
+ return &persistentStore{
+ jobStore: jobStore,
+ historicJobStore: historicJobStore,
+
+ clock: time.Now,
+ expiry: expiry,
+
+ notifications: make(chan struct{}, 1),
+ }, nil
+}
+
+// Claim takes a job from storage, marks it as ours, and returns it.
+//
+// Any job which has not been claimed by another worker is fair game.
+//
+// If err is not nil, the job and rollback values are always nil.
+// The err may be ErrNoJobs if there are no jobs to claim.
+func (s *persistentStore) Claim(ctx context.Context) (job *provisioning.Job, rollback func(), err error) {
+ requirement, err := labels.NewRequirement(LabelJobClaim, selection.DoesNotExist, nil)
+ if err != nil {
+ return nil, nil, apifmt.Errorf("could not create requirement: %w", err)
+ }
+
+ jobsObj, err := s.jobStore.List(ctx, &internalversion.ListOptions{
+ LabelSelector: labels.NewSelector().Add(*requirement),
+ Limit: 16,
+ })
+ if err != nil {
+ return nil, nil, apifmt.Errorf("failed to list jobs: %w", err)
+ }
+ jobs, ok := jobsObj.(*provisioning.JobList)
+ if !ok {
+ return nil, nil, apifmt.Errorf("unexpected object type %T", jobsObj)
+ }
+
+ if len(jobs.Items) == 0 {
+ return nil, nil, ErrNoJobs
+ }
+
+ for _, job := range jobs.Items {
+ if job.Labels == nil {
+ job.Labels = make(map[string]string)
+ }
+ job.Labels[LabelJobClaim] = strconv.FormatInt(s.clock().UnixMilli(), 10)
+
+ // We list jobs from all namespaces. So when we want to update a specific job, we also need its namespace in the context.
+ ctx := request.WithNamespace(ctx, job.GetNamespace())
+ // Likewise, we should use the provisioning identity now that we have the namespace we are operating within.
+ ctx, _, err = identity.WithProvisioningIdentity(ctx, job.GetNamespace())
+ if err != nil {
+ // This should never happen, as it is already a valid namespace from the job existing... but better be safe.
+ return nil, nil, apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err)
+ }
+
+ // This relies on the resource version being updated for us.
+ // If the resource version we pass in via the current job is not the same as the one currently in the store, it will fail with Conflict.
+ // This is the desired behavior, as it ensures that claims are atomic.
+ updated, _, err := s.jobStore.Update(ctx,
+ job.GetName(), // name
+ rest.DefaultUpdatedObjectInfo(&job), // objInfo
+ failCreation, // createValidation
+ nil, // updateValidation
+ false, // forceAllowCreate
+ &metav1.UpdateOptions{}, // options
+ )
+ if apierrors.IsConflict(err) || errors.Is(err, errWouldCreate) {
+ // On conflict: another worker claimed the job before us.
+ // On would create: the job was completed and deleted before we could claim it.
+ // We'll just move on to the next job.
+ continue
+ }
+ if err != nil {
+ return nil, nil, apifmt.Errorf("failed to claim job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
+ }
+ updatedJob, ok := updated.(*provisioning.Job)
+ if !ok {
+ return nil, nil, apifmt.Errorf("unexpected object type %T", updated)
+ }
+
+ return updatedJob.DeepCopy(), func() {
+ // Rolling back does not need to care about the parent's cancellation state.
+ // This will also use the parent context (i.e. from the for loop!), ensuring we have permissions to do this.
+ ctx = context.WithoutCancel(ctx)
+
+ logger := logging.FromContext(ctx).With("namespace", updatedJob.GetNamespace(), "job", updatedJob.GetName())
+
+ timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ refetched, err := s.jobStore.Get(timeoutCtx, updatedJob.GetName(), &metav1.GetOptions{})
+ cancel() // we have no response body to read (the obj already contains all of it), so just cancel immediately
+ if apierrors.IsNotFound(err) {
+ // The job was probably completed already. Nothing to roll back!
+ return
+ } else if err != nil {
+ // We failed. Nothing much we can do but let the job be cleaned up by the periodic cleaner.
+ logger.Warn("failed to roll back job claim; letting periodic cleaner deal with it", "error", err)
+ return
+ }
+ refetchedJob, ok := refetched.(*provisioning.Job)
+ if !ok {
+ logger.Warn("failed to roll back job claim: the job we got is not a *provisioning.Job?", "got", refetched)
+ return
+ }
+
+ // Rollback the claim.
+ delete(refetchedJob.Labels, LabelJobClaim)
+ refetchedJob.Status.State = provisioning.JobStatePending
+
+ timeoutCtx, cancel = context.WithTimeout(ctx, 5*time.Second)
+ _, _, err = s.jobStore.Update(timeoutCtx,
+ refetchedJob.GetName(), // name
+ rest.DefaultUpdatedObjectInfo(refetchedJob), // objInfo
+ failCreation, // createValidation
+ nil, // updateValidation
+ false, // forceAllowCreate
+ &metav1.UpdateOptions{}, // options
+ )
+ cancel() // we have no response body to read (the obj already contains all of it), so just cancel immediately
+ if err != nil && !apierrors.IsConflict(err) && !errors.Is(err, errWouldCreate) {
+ logger.Warn("failed to roll back job claim; letting periodic cleaner deal with it", "error", err)
+ } else if err != nil {
+ logger.Debug("failed to roll back job claim; got an OK error", "error", err)
+ }
+ }, nil
+ }
+
+ // We failed to claim any jobs.
+ return nil, nil, ErrNoJobs
+}
+
+// Update saves the job back to the store.
+func (s *persistentStore) Update(ctx context.Context, job *provisioning.Job) (*provisioning.Job, error) {
+ obj, _, err := s.jobStore.Update(ctx,
+ job.GetName(), // name
+ rest.DefaultUpdatedObjectInfo(job), // objInfo
+ failCreation, // createValidation
+ nil, // updateValidation
+ false, // forceAllowCreate
+ &metav1.UpdateOptions{}, // options
+ )
+ if err != nil {
+ return nil, apifmt.Errorf("failed to update job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
+ }
+
+ updatedJob, ok := obj.(*provisioning.Job)
+ if !ok {
+ return nil, apifmt.Errorf("unexpected object type %T", obj)
+ }
+
+ return updatedJob, nil
+}
+
+// Complete marks a job as completed and moves it to the historic job store.
+// When in the historic store, there is no more claim on the job.
+func (s *persistentStore) Complete(ctx context.Context, job *provisioning.Job) error {
+ logger := logging.FromContext(ctx).With("namespace", job.GetNamespace(), "job", job.GetName())
+
+ // We need to delete the job from the job store and create it in the historic job store.
+ // We are fine with the job being lost if the historic job store fails to create it.
+ //
+ // We will assume that the caller is the claimant. If this is not true, an error is returned.
+ // This is a best-effort operation; if the job is not in the claimed state, we will still attempt to move it to the historic job store.
+ _, _, err := s.jobStore.Delete(ctx, job.GetName(), nil, &metav1.DeleteOptions{})
+ if err != nil {
+ return apifmt.Errorf("failed to delete job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
+ }
+ logger.Debug("deleted job from job store")
+
+ // We need to remove the claim label before moving the job to the historic job store.
+ if job.Labels == nil {
+ job.Labels = make(map[string]string)
+ }
+ delete(job.Labels, LabelJobClaim)
+ // We also need a new, unique name.
+ job.Labels[LabelJobOriginalName] = job.GetName()
+ job.Labels[LabelRepository] = job.Spec.Repository
+ job.GenerateName = job.Name + "-"
+ job.Name = ""
+ // We also reset the UID as this is not the same object.
+ job.ObjectMeta.UID = ""
+ // We aren't allowed to write with ResourceVersion set.
+ job.ResourceVersion = ""
+
+ historicJob := &provisioning.HistoricJob{
+ ObjectMeta: job.ObjectMeta,
+ Spec: job.Spec,
+ Status: job.Status,
+ }
+ _, err = s.historicJobStore.Create(ctx, historicJob, nil, &metav1.CreateOptions{})
+ if err != nil {
+ // We're not going to return this as it is not critical. Not ideal, but not critical.
+ logger.Warn("failed to create historic job", "historic_job", *historicJob, "error", err)
+ } else {
+ logger.Debug("created historic job", "historic_job", *historicJob)
+ }
+
+ logger.Debug("job completion done")
+ return nil
+}
+
+// Cleanup should be called periodically to clean up abandoned jobs.
+// An abandoned job is one that has been claimed by a worker, but the worker has not updated the job in a while.
+func (s *persistentStore) Cleanup(ctx context.Context) error {
+ if err := s.cleanupClaims(ctx); err != nil {
+ return apifmt.Errorf("failed to clean up claims: %w", err)
+ }
+
+ return nil
+}
+
+// cleanupClaims will clean up abandoned claims.
+// Any claim that is older than the expiry time will have their claims removed.
+//
+// This is only necessary because Kubernetes does not support logical OR in label selectors.
+func (s *persistentStore) cleanupClaims(ctx context.Context) error {
+ // We will list all jobs that have been claimed but not updated in a while.
+ // We will then remove the claim from them.
+ // We will not care about the result of the update, as the job may have been completed in the meantime.
+ expiry := s.clock().Add(-s.expiry).UnixMilli()
+ requirement, err := labels.NewRequirement(LabelJobClaim, selection.LessThan, []string{strconv.FormatInt(expiry, 10)})
+ if err != nil {
+ return apifmt.Errorf("could not create requirement: %w", err)
+ }
+
+ timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ jobsObj, err := s.jobStore.List(timeoutCtx, &internalversion.ListOptions{
+ LabelSelector: labels.NewSelector().Add(*requirement),
+ // We don't need to clean up everything all the time. Just do enough such that we have a fair amount of work.
+ Limit: 100,
+ })
+ cancel() // by the time we have the list, there is no response body to read, so just cancel immediately
+ if err != nil {
+ return apifmt.Errorf("failed to list jobs: %w", err)
+ }
+ jobs, ok := jobsObj.(*provisioning.JobList)
+ if !ok {
+ return apifmt.Errorf("unexpected object type %T", jobsObj)
+ }
+
+ for _, job := range jobs.Items {
+ if job.Labels == nil {
+ job.Labels = make(map[string]string)
+ }
+ delete(job.Labels, LabelJobClaim)
+ job.Status.State = provisioning.JobStatePending
+
+ // We list jobs from all namespaces. So when we want to update a specific job, we also need its namespace in the context.
+ ctx := request.WithNamespace(ctx, job.GetNamespace())
+ // Likewise, we should use the provisioning identity now that we have the namespace we are operating within.
+ ctx, _, err = identity.WithProvisioningIdentity(ctx, job.GetNamespace())
+ if err != nil {
+ // This should never happen, as it is already a valid namespace from the job existing... but better be safe.
+ return apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err)
+ }
+
+ timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ _, _, err := s.jobStore.Update(timeoutCtx,
+ job.GetName(), // name
+ rest.DefaultUpdatedObjectInfo(&job), // objInfo
+ failCreation, // createValidation
+ nil, // updateValidation
+ false, // forceAllowCreate
+ &metav1.UpdateOptions{}, // options
+ )
+ cancel() // we have no response body to read, so just cancel immediately
+ if apierrors.IsConflict(err) || errors.Is(err, errWouldCreate) {
+ continue
+ }
+ if err != nil {
+ return apifmt.Errorf("failed to unclaim job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
+ }
+ }
+
+ return nil
+}
+
+// Insert adds a new job to the queue.
+//
+// The job name is not honoured. It will be overwritten with a name that fits the job.
+//
+// This saves it if it is a new job, or fails with `apierrors.IsAlreadyExists(err) == true` if one already exists.
+func (s *persistentStore) Insert(ctx context.Context, job *provisioning.Job) (*provisioning.Job, error) {
+ s.generateJobName(job) // Side-effect: updates the job's name.
+
+ obj, err := s.jobStore.Create(ctx, job, nil, &metav1.CreateOptions{})
+ if apierrors.IsAlreadyExists(err) {
+ return nil, apifmt.Errorf("job '%s' in '%s' already exists: %w", job.GetName(), job.GetNamespace(), err)
+ }
+ if err != nil {
+ return nil, apifmt.Errorf("failed to create job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
+ }
+
+ created, ok := obj.(*provisioning.Job)
+ if !ok {
+ return nil, apifmt.Errorf("unexpected object type %T", obj)
+ }
+
+ select {
+ case s.notifications <- struct{}{}:
+ default:
+ // We don't want to block if there is already a notification waiting.
+ }
+
+ return created, nil
+}
+
+func (s *persistentStore) InsertNotifications() chan struct{} {
+ return s.notifications
+}
+
+// generateJobName creates and updates the job's name to one that fits it.
+func (s *persistentStore) generateJobName(job *provisioning.Job) {
+ switch job.Spec.Action {
+ case provisioning.JobActionMigrate, provisioning.JobActionSync:
+ // Sync and migrate jobs should never run at the same time. Hence, the name encapsulates them both (and the spec differentiates them).
+ job.Name = job.Spec.Repository + "-syncmigrate"
+ case provisioning.JobActionPullRequest:
+ var pr int
+ if job.Spec.PullRequest != nil {
+ pr = job.Spec.PullRequest.PR
+ }
+ // There may be multiple pull requests at the same time. They need different names.
+ job.Name = fmt.Sprintf("%s-pr-%d", job.Spec.Repository, pr)
+ default:
+ job.Name = fmt.Sprintf("%s-%s", job.Spec.Repository, job.Spec.Action)
+ }
+}
diff --git a/pkg/registry/apis/provisioning/jobs/progress.go b/pkg/registry/apis/provisioning/jobs/progress.go
index 3a38fc902ce..949512ddb0a 100644
--- a/pkg/registry/apis/provisioning/jobs/progress.go
+++ b/pkg/registry/apis/provisioning/jobs/progress.go
@@ -36,22 +36,26 @@ type JobResourceResult struct {
}
type jobProgressRecorder struct {
- started time.Time
- total int
- ref string
- message string
- resultCount int
- errorCount int
- errors []string
- progressFn ProgressFn
- summaries map[string]*provisioning.JobResourceSummary
+ started time.Time
+ total int
+ ref string
+ message string
+ finalMessage string
+ resultCount int
+ errorCount int
+ errors []string
+ notifyImmediatelyFn ProgressFn
+ maybeNotifyFn ProgressFn
+ summaries map[string]*provisioning.JobResourceSummary
}
func newJobProgressRecorder(ProgressFn ProgressFn) JobProgressRecorder {
return &jobProgressRecorder{
- started: time.Now(),
- progressFn: maybeNotifyProgress(5*time.Second, ProgressFn),
- summaries: make(map[string]*provisioning.JobResourceSummary),
+ started: time.Now(),
+ // Have a faster notifier for messages and total
+ notifyImmediatelyFn: maybeNotifyProgress(500*time.Millisecond, ProgressFn),
+ maybeNotifyFn: maybeNotifyProgress(5*time.Second, ProgressFn),
+ summaries: make(map[string]*provisioning.JobResourceSummary),
}
}
@@ -70,15 +74,26 @@ func (r *jobProgressRecorder) Record(ctx context.Context, result JobResourceResu
}
r.updateSummary(result)
- r.notify(ctx)
+ r.maybeNotify(ctx)
}
-func (r *jobProgressRecorder) SetMessage(msg string) {
+// ResetResults will reset the results of the job
+func (r *jobProgressRecorder) ResetResults() {
+ r.resultCount = 0
+ r.errorCount = 0
+ r.errors = nil
+ r.summaries = make(map[string]*provisioning.JobResourceSummary)
+}
+
+func (r *jobProgressRecorder) SetMessage(ctx context.Context, msg string) {
r.message = msg
+ logging.FromContext(ctx).Info("job progress message", "message", msg)
+ r.notifyImmediately(ctx)
}
-func (r *jobProgressRecorder) GetMessage() string {
- return r.message
+func (r *jobProgressRecorder) SetFinalMessage(ctx context.Context, msg string) {
+ r.finalMessage = msg
+ logging.FromContext(ctx).Info("job final message", "message", msg)
}
func (r *jobProgressRecorder) SetRef(ref string) {
@@ -89,8 +104,10 @@ func (r *jobProgressRecorder) GetRef() string {
return r.ref
}
-func (r *jobProgressRecorder) SetTotal(total int) {
+func (r *jobProgressRecorder) SetTotal(ctx context.Context, total int) {
r.total = total
+
+ r.notifyImmediately(ctx)
}
func (r *jobProgressRecorder) TooManyErrors() error {
@@ -154,7 +171,7 @@ func (r *jobProgressRecorder) progress() float64 {
return float64(r.resultCount) / float64(r.total) * 100
}
-func (r *jobProgressRecorder) notify(ctx context.Context) {
+func (r *jobProgressRecorder) notifyImmediately(ctx context.Context) {
jobStatus := provisioning.JobStatus{
State: provisioning.JobStateWorking,
Message: r.message,
@@ -164,7 +181,22 @@ func (r *jobProgressRecorder) notify(ctx context.Context) {
}
logger := logging.FromContext(ctx)
- if err := r.progressFn(ctx, jobStatus); err != nil {
+ if err := r.notifyImmediatelyFn(ctx, jobStatus); err != nil {
+ logger.Warn("error notifying immediate progress", "err", err)
+ }
+}
+
+func (r *jobProgressRecorder) maybeNotify(ctx context.Context) {
+ jobStatus := provisioning.JobStatus{
+ State: provisioning.JobStateWorking,
+ Message: r.message,
+ Errors: r.errors,
+ Progress: r.progress(),
+ Summary: r.summary(),
+ }
+
+ logger := logging.FromContext(ctx)
+ if err := r.maybeNotifyFn(ctx, jobStatus); err != nil {
logger.Warn("error notifying progress", "err", err)
}
}
@@ -195,8 +227,8 @@ func (r *jobProgressRecorder) Complete(ctx context.Context, err error) provision
}
// Override message if progress have a more explicit message
- if r.message != "" && jobStatus.State != provisioning.JobStateError {
- jobStatus.Message = r.message
+ if r.finalMessage != "" && jobStatus.State != provisioning.JobStateError {
+ jobStatus.Message = r.finalMessage
}
return jobStatus
diff --git a/pkg/registry/apis/provisioning/jobs/pullrequest/preview.go b/pkg/registry/apis/provisioning/jobs/pullrequest/preview.go
new file mode 100644
index 00000000000..0521d85d79f
--- /dev/null
+++ b/pkg/registry/apis/provisioning/jobs/pullrequest/preview.go
@@ -0,0 +1,158 @@
+package pullrequest
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "html/template"
+ "net/url"
+ "path"
+
+ "github.com/grafana/grafana-app-sdk/logging"
+ "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+)
+
+// resourcePreview represents a resource that has changed in a pull request.
+type resourcePreview struct {
+ Filename string
+ Path string
+ Action string
+ Kind string
+ OriginalURL string
+ PreviewURL string
+ PreviewScreenshotURL string
+}
+
+const previewsCommentTemplate = `Hey there! 🎉
+Grafana spotted some changes for your resources in this pull request:
+## Summary
+| File Name | Kind | Path | Action | Links |
+|-----------|------|------|--------|-------|
+{{- range .}}
+| {{.Filename}} | {{.Kind}} | {{.Path}} | {{.Action}} | {{- if .OriginalURL}}[Original]({{.OriginalURL}}){{- end}}{{- if and .OriginalURL .PreviewURL}}, {{end}}{{- if .PreviewURL}}[Preview]({{.PreviewURL}}){{- end}} |
+{{- end}}
+
+Click the preview links above to view how your changes will look and compare them with the original and current versions.
+
+{{- range .}}
+{{- if .PreviewScreenshotURL}}
+### Preview of {{.Filename}}
+
+{{- end}}{{- end}}`
+
+// PreviewRenderer is an interface for rendering a preview of a file
+type PreviewRenderer interface {
+ IsAvailable(ctx context.Context) bool
+ RenderDashboardPreview(ctx context.Context, namespace, repoName, path, ref string) (string, error)
+}
+
+type Previewer struct {
+ template *template.Template
+ urlProvider func(namespace string) string
+ renderer PreviewRenderer
+}
+
+func NewPreviewer(renderer PreviewRenderer, urlProvider func(namespace string) string) *Previewer {
+ return &Previewer{
+ template: template.Must(template.New("comment").Parse(previewsCommentTemplate)),
+ urlProvider: urlProvider,
+ renderer: renderer,
+ }
+}
+
+// GenerateComment creates a formatted comment for dashboard previews
+func (p *Previewer) GenerateComment(previews []resourcePreview) (string, error) {
+ var buf bytes.Buffer
+ if err := p.template.Execute(&buf, previews); err != nil {
+ return "", fmt.Errorf("execute previews comment template: %w", err)
+ }
+ return buf.String(), nil
+}
+
+// getOriginalURL returns the URL for the original version of the file based on the action
+func (p *Previewer) getOriginalURL(ctx context.Context, f repository.VersionedFileChange, baseURL *url.URL, repoName, base, pullRequestURL string) string {
+ switch f.Action {
+ case repository.FileActionCreated:
+ return "" // No original URL for new files
+ case repository.FileActionUpdated:
+ return p.previewURL(baseURL, repoName, base, f.Path, pullRequestURL)
+ case repository.FileActionRenamed:
+ return p.previewURL(baseURL, repoName, base, f.PreviousPath, pullRequestURL)
+ case repository.FileActionDeleted:
+ return p.previewURL(baseURL, repoName, base, f.Path, pullRequestURL)
+ default:
+ logging.FromContext(ctx).Error("unknown file action for original URL", "action", f.Action)
+ return ""
+ }
+}
+
+// getPreviewURL returns the URL for the preview version of the file based on the action
+func (p *Previewer) getPreviewURL(ctx context.Context, f repository.VersionedFileChange, baseURL *url.URL, repoName, ref, pullRequestURL string) string {
+ switch f.Action {
+ case repository.FileActionCreated, repository.FileActionUpdated, repository.FileActionRenamed:
+ return p.previewURL(baseURL, repoName, ref, f.Path, pullRequestURL)
+ case repository.FileActionDeleted:
+ return "" // No preview URL for deleted files
+ default:
+ logging.FromContext(ctx).Error("unknown file action for preview URL", "action", f.Action)
+ return ""
+ }
+}
+
+// previewURL returns the URL to preview the file in Grafana
+func (p *Previewer) previewURL(u *url.URL, repoName, ref, filePath, pullRequestURL string) string {
+ baseURL := *u
+ baseURL = *baseURL.JoinPath("/admin/provisioning", repoName, "dashboard/preview", filePath)
+
+ query := baseURL.Query()
+ if ref != "" {
+ query.Set("ref", ref)
+ }
+ if pullRequestURL != "" {
+ query.Set("pull_request_url", url.QueryEscape(pullRequestURL))
+ }
+ baseURL.RawQuery = query.Encode()
+
+ return baseURL.String()
+}
+
+// Preview creates a preview for a single file change
+func (p *Previewer) Preview(
+ ctx context.Context,
+ f repository.VersionedFileChange,
+ namespace string,
+ repoName string,
+ base string,
+ ref string,
+ pullRequestURL string,
+ generatePreview bool,
+) (*resourcePreview, error) {
+ baseURL, err := url.Parse(p.urlProvider(namespace))
+ if err != nil {
+ return nil, fmt.Errorf("error parsing base url: %w", err)
+ }
+
+ preview := resourcePreview{
+ Filename: path.Base(f.Path),
+ Path: f.Path,
+ Kind: "dashboard", // TODO: add more kinds
+ Action: string(f.Action),
+ OriginalURL: p.getOriginalURL(ctx, f, baseURL, repoName, base, pullRequestURL),
+ PreviewURL: p.getPreviewURL(ctx, f, baseURL, repoName, ref, pullRequestURL),
+ }
+
+ if !generatePreview && len(preview.PreviewURL) == 0 {
+ logger.Info("skipping dashboard preview generation", "path", f.Path)
+ return &preview, nil
+ }
+
+ screenshotURL, err := p.renderer.RenderDashboardPreview(ctx, namespace, repoName, f.Path, ref)
+ if err != nil {
+ return nil, fmt.Errorf("render dashboard preview: %w", err)
+ }
+ preview.PreviewScreenshotURL = screenshotURL
+ logger.Info("dashboard preview generated", "screenshotURL", screenshotURL)
+
+ return &preview, nil
+}
diff --git a/pkg/registry/apis/provisioning/jobs/pullrequest/render.go b/pkg/registry/apis/provisioning/jobs/pullrequest/render.go
new file mode 100644
index 00000000000..cf4a6b43275
--- /dev/null
+++ b/pkg/registry/apis/provisioning/jobs/pullrequest/render.go
@@ -0,0 +1,93 @@
+package pullrequest
+
+import (
+ "context"
+ "fmt"
+ "mime"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/grafana/grafana/pkg/apimachinery/identity"
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/rendering"
+ "github.com/grafana/grafana/pkg/storage/unified/resource"
+)
+
+type screenshotRenderer struct {
+ render rendering.Service
+ blobstore resource.BlobStoreClient
+ urlProvider func(namespace string) string
+ isPublic bool
+}
+
+func NewScreenshotRenderer(render rendering.Service, blobstore resource.BlobStoreClient, isPublic bool, urlProvider func(namespace string) string) *screenshotRenderer {
+ return &screenshotRenderer{
+ render: render,
+ blobstore: blobstore,
+ urlProvider: urlProvider,
+ isPublic: isPublic,
+ }
+}
+
+func (r *screenshotRenderer) IsAvailable(ctx context.Context) bool {
+ return r.render != nil && r.render.IsAvailable(ctx) && r.blobstore != nil && r.isPublic
+}
+
+func (r *screenshotRenderer) RenderDashboardPreview(ctx context.Context, namespace, repoName, path, ref string) (string, error) {
+ url := fmt.Sprintf("admin/provisioning/%s/dashboard/preview/%s?kiosk&ref=%s", repoName, path, ref)
+
+ // TODO: why were we using a different context?
+ // renderContext := identity.WithRequester(context.Background(), r.id)
+ result, err := r.render.Render(ctx, rendering.RenderPNG, rendering.Opts{
+ CommonOpts: rendering.CommonOpts{
+ Path: url,
+ AuthOpts: rendering.AuthOpts{
+ OrgID: 1, // TODO!!!, use the worker identity
+ UserID: 1,
+ OrgRole: identity.RoleAdmin,
+ },
+ TimeoutOpts: rendering.TimeoutOpts{
+ Timeout: time.Second * 30,
+ },
+ },
+ Theme: models.ThemeDark, // from config?
+ Width: 1024,
+ Height: -1, // full page height
+ }, nil)
+ if err != nil {
+ return "", err
+ }
+
+ ext := filepath.Ext(result.FilePath)
+ body, err := os.ReadFile(result.FilePath)
+ if err != nil {
+ return "", err
+ }
+
+ rsp, err := r.blobstore.PutBlob(ctx, &resource.PutBlobRequest{
+ Resource: &resource.ResourceKey{
+ Namespace: namespace,
+ Group: provisioning.GROUP,
+ Resource: provisioning.RepositoryResourceInfo.GroupResource().Resource,
+ Name: repoName,
+ },
+ Method: resource.PutBlobRequest_GRPC,
+ ContentType: mime.TypeByExtension(ext), // image/png
+ Value: body,
+ })
+ if err != nil {
+ return "", err
+ }
+ if rsp.Url != "" {
+ return rsp.Url, nil
+ }
+ base := r.urlProvider(namespace)
+ if !strings.HasSuffix(base, "/") {
+ base += "/"
+ }
+ return fmt.Sprintf("%sapis/%s/namespaces/%s/repositories/%s/render/%s",
+ base, provisioning.APIVERSION, namespace, repoName, rsp.Uid), nil
+}
diff --git a/pkg/registry/apis/provisioning/jobs/pullrequest/worker.go b/pkg/registry/apis/provisioning/jobs/pullrequest/worker.go
new file mode 100644
index 00000000000..9ae20efad2e
--- /dev/null
+++ b/pkg/registry/apis/provisioning/jobs/pullrequest/worker.go
@@ -0,0 +1,158 @@
+package pullrequest
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+
+ "github.com/grafana/grafana-app-sdk/logging"
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
+)
+
+type PullRequestRepo interface {
+ Config() *provisioning.Repository
+ Read(ctx context.Context, path, ref string) (*repository.FileInfo, error)
+ CompareFiles(ctx context.Context, base, ref string) ([]repository.VersionedFileChange, error)
+ ClearAllPullRequestFileComments(ctx context.Context, pr int) error
+ CommentPullRequestFile(ctx context.Context, pr int, path string, ref string, comment string) error
+ CommentPullRequest(ctx context.Context, pr int, comment string) error
+}
+
+type PullRequestWorker struct {
+ parsers *resources.ParserFactory
+ previewer *Previewer
+}
+
+func NewPullRequestWorker(
+ parsers *resources.ParserFactory,
+ previewer *Previewer,
+) (*PullRequestWorker, error) {
+ return &PullRequestWorker{
+ parsers: parsers,
+ previewer: previewer,
+ }, nil
+}
+
+func (c *PullRequestWorker) IsSupported(ctx context.Context, job provisioning.Job) bool {
+ return job.Spec.Action == provisioning.JobActionPullRequest
+}
+
+//nolint:gocyclo
+func (c *PullRequestWorker) Process(ctx context.Context,
+ repo repository.Repository,
+ job provisioning.Job,
+ progress jobs.JobProgressRecorder,
+) error {
+ cfg := repo.Config().Spec
+ options := job.Spec.PullRequest
+ if options == nil {
+ return apierrors.NewBadRequest("missing spec.pr")
+ }
+
+ prRepo, ok := repo.(PullRequestRepo)
+ if !ok {
+ return fmt.Errorf("repository is not a github repository")
+ }
+
+ reader, ok := repo.(repository.Reader)
+ if !ok {
+ return errors.New("pull request job submitted targeting repository that is not a Reader")
+ }
+
+ parser, err := c.parsers.GetParser(ctx, reader)
+ if err != nil {
+ return fmt.Errorf("failed to get parser for %s: %w", repo.Config().Name, err)
+ }
+
+ logger := logging.FromContext(ctx).With("pr", options.PR)
+ logger.Info("process pull request")
+ defer logger.Info("pull request processed")
+
+ progress.SetMessage(ctx, "listing pull request files")
+ base := cfg.GitHub.Branch
+ ref := options.Hash
+ files, err := prRepo.CompareFiles(ctx, base, ref)
+ if err != nil {
+ return fmt.Errorf("failed to list pull request files: %s", err.Error())
+ }
+
+ progress.SetMessage(ctx, "clearing pull request comments")
+ if err := prRepo.ClearAllPullRequestFileComments(ctx, options.PR); err != nil {
+ return fmt.Errorf("failed to clear pull request comments: %+v", err)
+ }
+
+ if len(files) == 0 {
+ progress.SetFinalMessage(ctx, "no files to process")
+ return nil
+ }
+
+ progress.SetMessage(ctx, "processing pull request files")
+ previews := make([]resourcePreview, 0, len(files))
+ for _, f := range files {
+ result := jobs.JobResourceResult{
+ Path: f.Path,
+ }
+
+ if err := resources.IsPathSupported(f.Path); err != nil {
+ result.Action = repository.FileActionIgnored
+ progress.Record(ctx, result)
+ continue
+ }
+ result.Action = f.Action
+
+ fileInfo, err := prRepo.Read(ctx, f.Path, ref)
+ if err != nil {
+ return fmt.Errorf("read file: %w", err)
+ }
+
+ parsed, err := parser.Parse(ctx, fileInfo, true)
+ if err != nil {
+ if errors.Is(err, resources.ErrUnableToReadResourceBytes) {
+ logger.Debug("file is not a resource", "path", f.Path)
+ result.Action = repository.FileActionIgnored
+ progress.Record(ctx, result)
+ } else {
+ result.Error = fmt.Errorf("failed to parse resource: %w", err)
+ progress.Record(ctx, result)
+ }
+ continue
+ }
+
+ result.Resource = parsed.GVR.Resource
+ result.Group = parsed.GVR.Group
+ result.Name = parsed.Obj.GetName()
+
+ preview, err := c.previewer.Preview(ctx, f, job.Namespace, repo.Config().Name, cfg.GitHub.Branch, ref, options.URL, cfg.GitHub.GenerateDashboardPreviews)
+ if err != nil {
+ result.Error = fmt.Errorf("create preview: %w", err)
+ progress.Record(ctx, result)
+ continue
+ }
+
+ previews = append(previews, *preview)
+ progress.Record(ctx, result)
+ }
+
+ if len(previews) == 0 {
+ progress.SetFinalMessage(ctx, "no previews to add")
+ return nil
+ }
+
+ progress.SetMessage(ctx, "generating previews comment")
+ comment, err := c.previewer.GenerateComment(previews)
+ if err != nil {
+ return fmt.Errorf("generate comment: %w", err)
+ }
+
+ if err := prRepo.CommentPullRequest(ctx, options.PR, comment); err != nil {
+ return fmt.Errorf("comment pull request: %w", err)
+ }
+ logger.Info("previews comment added", "number", len(previews))
+
+ return nil
+}
diff --git a/pkg/registry/apis/provisioning/jobs/queue.go b/pkg/registry/apis/provisioning/jobs/queue.go
index cb865e91578..4a0ab7e7e3e 100644
--- a/pkg/registry/apis/provisioning/jobs/queue.go
+++ b/pkg/registry/apis/provisioning/jobs/queue.go
@@ -11,35 +11,23 @@ type RepoGetter interface {
GetRepository(ctx context.Context, name string) (repository.Repository, error)
}
-// Basic job queue infrastructure
-type JobQueue interface {
- // Add a new Job to the Queue. The status must be empty
- Add(ctx context.Context, job *provisioning.Job) (*provisioning.Job, error)
-
- // Get the next job we should process
- Next(ctx context.Context) *provisioning.Job
-
- // Update the status on a given job
- // This is only valid if current job is not finished
- Update(ctx context.Context, namespace string, name string, status provisioning.JobStatus) error
-
- // Register a worker (inline for now)
- Register(worker Worker)
-}
-
type JobProgressRecorder interface {
Record(ctx context.Context, result JobResourceResult)
- SetMessage(msg string)
- GetMessage() string
+ ResetResults()
+ SetFinalMessage(ctx context.Context, msg string)
+ SetMessage(ctx context.Context, msg string)
SetRef(ref string)
GetRef() string
- SetTotal(total int)
+ SetTotal(ctx context.Context, total int)
TooManyErrors() error
Complete(ctx context.Context, err error) provisioning.JobStatus
}
type Worker interface {
IsSupported(ctx context.Context, job provisioning.Job) bool
+ // Process the job. The job status should be updated as the job progresses.
+ //
+ // The job spec and metadata MUST not be modified in the storage layer while this is running. All updates go via the progress type.
Process(ctx context.Context, repo repository.Repository, job provisioning.Job, progress JobProgressRecorder) error
}
diff --git a/pkg/registry/apis/provisioning/jobs/store.go b/pkg/registry/apis/provisioning/jobs/store.go
deleted file mode 100644
index 77e13c8c7d4..00000000000
--- a/pkg/registry/apis/provisioning/jobs/store.go
+++ /dev/null
@@ -1,367 +0,0 @@
-package jobs
-
-import (
- "context"
- "errors"
- "fmt"
- "net/http"
- "strconv"
- "sync"
- "time"
-
- apierrors "k8s.io/apimachinery/pkg/api/errors"
- "k8s.io/apimachinery/pkg/apis/meta/internalversion"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- "k8s.io/apimachinery/pkg/fields"
- "k8s.io/apimachinery/pkg/labels"
- "k8s.io/apimachinery/pkg/runtime"
- "k8s.io/apimachinery/pkg/watch"
- "k8s.io/apiserver/pkg/endpoints/request"
- "k8s.io/apiserver/pkg/registry/rest"
- "k8s.io/apiserver/pkg/storage"
-
- "github.com/grafana/grafana-app-sdk/logging"
- "github.com/grafana/grafana/pkg/apimachinery/identity"
- provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
- "github.com/grafana/grafana/pkg/util"
-)
-
-var (
- _ JobQueue = (*jobStore)(nil)
- _ rest.Scoper = (*jobStore)(nil)
- _ rest.SingularNameProvider = (*jobStore)(nil)
- _ rest.Getter = (*jobStore)(nil)
- _ rest.Lister = (*jobStore)(nil)
- _ rest.Storage = (*jobStore)(nil)
- _ rest.Watcher = (*jobStore)(nil)
-)
-
-func NewJobStore(capacity int, getter RepoGetter) *jobStore {
- return &jobStore{
- workers: make([]Worker, 0),
- getter: getter,
- rv: 1,
- capacity: capacity,
- jobs: []provisioning.Job{},
- watchSet: NewWatchSet(),
- versioner: &storage.APIObjectVersioner{},
- }
-}
-
-type jobStore struct {
- getter RepoGetter
- capacity int
- workers []Worker
-
- // All jobs
- jobs []provisioning.Job
- rv int64 // updates whenever changed
- watchSet *WatchSet
- versioner storage.Versioner
-
- mutex sync.RWMutex
-}
-
-// Implementing Kube interfaces
-
-func (s *jobStore) New() runtime.Object {
- return provisioning.JobResourceInfo.NewFunc()
-}
-
-func (s *jobStore) Destroy() {}
-
-func (s *jobStore) NamespaceScoped() bool {
- return true // namespace == org
-}
-
-func (s *jobStore) GetSingularName() string {
- return provisioning.JobResourceInfo.GetSingularName()
-}
-
-func (s *jobStore) NewList() runtime.Object {
- return provisioning.JobResourceInfo.NewListFunc()
-}
-
-func (s *jobStore) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
- return provisioning.JobResourceInfo.TableConverter().ConvertToTable(ctx, object, tableOptions)
-}
-
-func (s *jobStore) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
- ns, ok := request.NamespaceFrom(ctx)
- if !ok {
- return nil, fmt.Errorf("missing namespace")
- }
-
- queue := &provisioning.JobList{
- ListMeta: metav1.ListMeta{
- ResourceVersion: strconv.FormatInt(s.rv, 10),
- },
- }
-
- query := options.LabelSelector
-
- s.mutex.RLock()
- defer s.mutex.RUnlock()
-
- for _, job := range s.jobs {
- if job.Namespace != ns {
- continue
- }
-
- // maybe filter
- if query != nil && !query.Matches(labels.Set(job.Labels)) {
- continue
- }
-
- copy := job.DeepCopy()
- queue.Items = append(queue.Items, *copy)
- }
-
- return queue, nil
-}
-
-func (s *jobStore) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
- s.mutex.RLock()
- defer s.mutex.RUnlock()
-
- ns, ok := request.NamespaceFrom(ctx)
- if !ok {
- return nil, fmt.Errorf("missing namespace")
- }
-
- for _, job := range s.jobs {
- if job.Name == name && job.Namespace == ns {
- return job.DeepCopy(), nil
- }
- }
-
- return nil, apierrors.NewNotFound(provisioning.JobResourceInfo.GroupResource(), name)
-}
-
-func (s *jobStore) Watch(ctx context.Context, opts *internalversion.ListOptions) (watch.Interface, error) {
- ns, ok := request.NamespaceFrom(ctx)
- if !ok {
- return nil, fmt.Errorf("missing namespace")
- }
-
- p := storage.SelectionPredicate{
- Label: labels.Everything(), // TODO... limit
- Field: fields.Everything(),
- }
-
- // Can watch by label selection
- jw := s.watchSet.newWatch(ctx, 0, p, s.versioner, &ns)
- jw.Start()
- return jw, nil
-}
-
-// Implementing JobQueue
-
-// Register a worker (inline for now)
-func (s *jobStore) Register(worker Worker) {
- s.workers = append(s.workers, worker)
-}
-
-func (s *jobStore) Add(ctx context.Context, job *provisioning.Job) (*provisioning.Job, error) {
- if job.Namespace == "" {
- return nil, apierrors.NewBadRequest("missing metadata.namespace")
- }
- if job.Name != "" {
- return nil, apierrors.NewBadRequest("name will always be generated")
- }
- if job.Spec.Repository == "" {
- return nil, apierrors.NewBadRequest("missing spec.repository")
- }
- if job.Spec.Action == "" {
- return nil, apierrors.NewBadRequest("missing spec.action")
- }
- if job.Spec.Action == provisioning.JobActionExport && job.Spec.Push == nil {
- return nil, apierrors.NewBadRequest("missing spec.push")
- }
-
- if job.Spec.Action == provisioning.JobActionSync && job.Spec.Pull == nil {
- return nil, apierrors.NewBadRequest("missing spec.pull")
- }
-
- // Only for add
- if job.Status.State != "" {
- return nil, apierrors.NewBadRequest("must add jobs with empty status")
- }
-
- if job.Labels == nil {
- job.Labels = make(map[string]string)
- }
- job.Labels["repository"] = job.Spec.Repository // for now, make sure we can search Multi-tenant
- job.Name = fmt.Sprintf("%s:%s:%s", job.Spec.Repository, job.Spec.Action, util.GenerateShortUID())
-
- s.mutex.Lock()
- defer s.mutex.Unlock()
-
- s.rv++
- job.ResourceVersion = strconv.FormatInt(s.rv, 10)
- job.Status.State = provisioning.JobStatePending
- job.CreationTimestamp = metav1.NewTime(time.Now())
-
- jobs := make([]provisioning.Job, 0, len(s.jobs)+2)
- jobs = append(jobs, *job)
- for i, j := range s.jobs {
- if i >= s.capacity {
- // Remove the old jobs
- s.watchSet.notifyWatchers(watch.Event{
- Object: j.DeepCopyObject(),
- Type: watch.Deleted,
- }, nil)
- continue
- }
- jobs = append(jobs, j)
- }
-
- // Send add event
- s.watchSet.notifyWatchers(watch.Event{
- Object: job.DeepCopyObject(),
- Type: watch.Added,
- }, nil)
-
- // For now, start a thread processing each job
- go s.drainPending()
-
- s.jobs = jobs // replace existing list
- return job, nil
-}
-
-// Reads the queue until no jobs remain
-func (s *jobStore) drainPending() {
- logger := logging.DefaultLogger.With("logger", "job-store")
- ctx := logging.Context(context.Background(), logger)
-
- var err error
- for {
- time.Sleep(time.Microsecond * 200)
-
- job := s.Next(ctx)
- if job == nil {
- return // done
- }
- logger := logger.With("job", job.GetName(), "namespace", job.GetNamespace())
- ctx := logging.Context(ctx, logger)
-
- var foundWorker bool
- recorder := newJobProgressRecorder(func(ctx context.Context, j provisioning.JobStatus) error {
- return s.Update(ctx, job.Namespace, job.Name, j)
- })
-
- for _, worker := range s.workers {
- if !worker.IsSupported(ctx, *job) {
- continue
- }
-
- // Already found a worker, no need to continue
- foundWorker = true
- err = s.processByWorker(ctx, worker, *job, recorder)
- break
- }
-
- if !foundWorker {
- err = errors.New("no registered worker supports this job")
- }
-
- status := recorder.Complete(ctx, err)
- err = s.Update(ctx, job.Namespace, job.Name, status)
- if err != nil {
- logger.Error("error running job", "error", err)
- }
- logger.Debug("job has been fully completed")
- }
-}
-
-func (s *jobStore) processByWorker(ctx context.Context, worker Worker, job provisioning.Job, recorder JobProgressRecorder) error {
- ctx = request.WithNamespace(ctx, job.Namespace)
- ctx, _, err := identity.WithProvisioningIdentitiy(ctx, job.Namespace)
- if err != nil {
- return fmt.Errorf("get worker identity: %w", err)
- }
- repoName := job.Spec.Repository
-
- logger := logging.FromContext(ctx)
- logger = logger.With("repository", repoName)
- ctx = logging.Context(ctx, logger)
-
- repo, err := s.getter.GetRepository(ctx, repoName)
- if err != nil {
- return fmt.Errorf("get repository: %w", err)
- }
-
- // TODO: does this really happen?
- if repo == nil {
- return errors.New("unknown repository")
- }
-
- return worker.Process(ctx, repo, job, recorder)
-}
-
-// Checkout the next "pending" job
-func (s *jobStore) Next(ctx context.Context) *provisioning.Job {
- s.mutex.Lock()
- defer s.mutex.Unlock()
-
- // The oldest jobs should be checked out first
- for i := len(s.jobs) - 1; i >= 0; i-- {
- if s.jobs[i].Status.State == provisioning.JobStatePending {
- oldObj := s.jobs[i].DeepCopyObject()
-
- s.rv++
- s.jobs[i].ResourceVersion = strconv.FormatInt(s.rv, 10)
- s.jobs[i].Status.State = provisioning.JobStateWorking
- s.jobs[i].Status.Started = time.Now().UnixMilli()
- job := s.jobs[i]
-
- s.watchSet.notifyWatchers(watch.Event{
- Object: job.DeepCopyObject(),
- Type: watch.Modified,
- }, oldObj)
- return &job
- }
- }
- return nil
-}
-
-func (s *jobStore) Update(ctx context.Context, namespace string, name string, status provisioning.JobStatus) error {
- s.mutex.Lock()
- defer s.mutex.Unlock()
-
- s.rv++
-
- if status.State == "" {
- return apierrors.NewBadRequest("The state must be set")
- }
- if status.Progress > 100 || status.Progress < 0 {
- return apierrors.NewBadRequest("progress must be between 0 and 100")
- }
-
- for idx, job := range s.jobs {
- if job.Name == name && job.Namespace == namespace {
- if job.Status.State.Finished() {
- return &apierrors.StatusError{ErrStatus: metav1.Status{
- Code: http.StatusPreconditionFailed,
- Message: "The job is already finished and can not be updated",
- }}
- }
- if status.State.Finished() {
- status.Finished = time.Now().UnixMilli()
- }
-
- oldObj := job.DeepCopyObject()
- job.ResourceVersion = strconv.FormatInt(s.rv, 10)
- job.Status = status
- s.jobs[idx] = job
-
- s.watchSet.notifyWatchers(watch.Event{
- Object: job.DeepCopyObject(),
- Type: watch.Modified,
- }, oldObj)
- return nil
- }
- }
-
- return apierrors.NewNotFound(provisioning.JobResourceInfo.GroupResource(), name)
-}
diff --git a/pkg/registry/apis/provisioning/jobs/sync/changes.go b/pkg/registry/apis/provisioning/jobs/sync/changes.go
new file mode 100644
index 00000000000..312c023854b
--- /dev/null
+++ b/pkg/registry/apis/provisioning/jobs/sync/changes.go
@@ -0,0 +1,88 @@
+package sync
+
+import (
+ "fmt"
+ "sort"
+
+ folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
+)
+
+type ResourceFileChange struct {
+ // Path to the file in a repository with a change
+ Path string
+ Action repository.FileAction
+
+ // The current value in the database -- only required for delete
+ Existing *provisioning.ResourceListItem
+}
+
+func Changes(source []repository.FileTreeEntry, target *provisioning.ResourceList) ([]ResourceFileChange, error) {
+ lookup := make(map[string]*provisioning.ResourceListItem, len(target.Items))
+ for _, item := range target.Items {
+ if item.Path == "" {
+ if item.Group != folders.GROUP {
+ return nil, fmt.Errorf("empty path on a non folder")
+ }
+ continue
+ }
+ lookup[item.Path] = &item
+ }
+
+ keep := safepath.NewTrie()
+ changes := make([]ResourceFileChange, 0, len(source))
+ for _, file := range source {
+ if !file.Blob {
+ continue // skip folder references?
+ }
+
+ check, ok := lookup[file.Path]
+ if ok {
+ if check.Hash != file.Hash {
+ changes = append(changes, ResourceFileChange{
+ Action: repository.FileActionUpdated,
+ Path: check.Path,
+ Existing: check,
+ })
+ }
+
+ if err := keep.Add(file.Path); err != nil {
+ return nil, fmt.Errorf("failed to add path to keep trie: %w", err)
+ }
+
+ delete(lookup, file.Path)
+ continue
+ }
+
+ // TODO: does this work with empty folders?
+ if resources.IsPathSupported(file.Path) == nil {
+ changes = append(changes, ResourceFileChange{
+ Action: repository.FileActionCreated, // or previously ignored/failed
+ Path: file.Path,
+ })
+ }
+ }
+
+ // Paths found in grafana, without a matching path in the repository
+ for _, v := range lookup {
+ if v.Resource == folders.RESOURCE && keep.Exists(v.Path) {
+ continue
+ }
+
+ changes = append(changes, ResourceFileChange{
+ Action: repository.FileActionDeleted,
+ Path: v.Path,
+ Existing: v,
+ })
+ }
+
+ // Deepest first (stable sort order)
+ sort.Slice(changes, func(i, j int) bool {
+ return safepath.Depth(changes[i].Path) > safepath.Depth(changes[j].Path)
+ })
+
+ return changes, nil
+}
diff --git a/pkg/registry/apis/provisioning/jobs/sync/changes_test.go b/pkg/registry/apis/provisioning/jobs/sync/changes_test.go
new file mode 100644
index 00000000000..94c415f958d
--- /dev/null
+++ b/pkg/registry/apis/provisioning/jobs/sync/changes_test.go
@@ -0,0 +1,145 @@
+package sync
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+)
+
+func TestChanges(t *testing.T) {
+ t.Run("start the same", func(t *testing.T) {
+ source, target := getBase(t)
+ changes, err := Changes(source, target)
+ require.NoError(t, err)
+ require.Empty(t, changes)
+ })
+
+ t.Run("create a source file", func(t *testing.T) {
+ source, target := getBase(t)
+ source = append(source, repository.FileTreeEntry{
+ Path: "muta.json", Hash: "xyz", Blob: true,
+ })
+
+ changes, err := Changes(source, target)
+ require.NoError(t, err)
+ require.Len(t, changes, 1)
+ require.Equal(t, ResourceFileChange{
+ Action: repository.FileActionCreated,
+ Path: "muta.json",
+ }, changes[0])
+ })
+
+ t.Run("delete a source file", func(t *testing.T) {
+ source, target := getBase(t)
+ source = []repository.FileTreeEntry{source[0]}
+
+ changes, err := Changes(source, target)
+ require.NoError(t, err)
+ require.Len(t, changes, 1)
+ require.Equal(t, ResourceFileChange{
+ Action: repository.FileActionDeleted,
+ Path: "adsl62h.yaml",
+ Existing: &provisioning.ResourceListItem{
+ Path: "adsl62h.yaml",
+ Group: "dashboard.grafana.app",
+ Resource: "dashboards",
+ Name: "adsl62h-hrw-f-fvlt2dghp-gufrc4lisksgmq-c",
+ Hash: "ce5d497c4deadde6831162ce8509e2b2b1776237",
+ },
+ }, changes[0])
+ })
+
+ t.Run("folder deletion order", func(t *testing.T) {
+ source := []repository.FileTreeEntry{
+ {Path: "x/y/z/ignored.md"}, // ignored
+ {Path: "aaa/bbb.yaml", Hash: "xyz", Blob: true},
+ }
+ target := &provisioning.ResourceList{
+ Items: []provisioning.ResourceListItem{
+ {Path: "a.json"},
+ {Path: "x/y/file.json"},
+ {Path: "aaa/", Resource: "folders"}, // Folder... no action required
+ {Path: "aaa/bbb.yaml", Hash: "xyz"},
+ {Path: "zzz/longest/path/here.json"},
+ {Path: "short/file.yml"},
+ },
+ }
+ changes, err := Changes(source, target)
+ require.NoError(t, err)
+
+ order := make([]string, len(changes))
+ for i := range changes {
+ order[i] = changes[i].Path
+ }
+ require.Equal(t, []string{
+ "zzz/longest/path/here.json", // not sorted yet
+ "x/y/file.json",
+ "short/file.yml",
+ "a.json",
+ }, order)
+ })
+
+ t.Run("modify a file", func(t *testing.T) {
+ source, target := getBase(t)
+ source[1].Hash = "different"
+
+ changes, err := Changes(source, target)
+ require.NoError(t, err)
+ require.Len(t, changes, 1)
+ require.Equal(t, ResourceFileChange{
+ Action: repository.FileActionUpdated,
+ Path: "adsl62h.yaml",
+ Existing: &provisioning.ResourceListItem{
+ Path: "adsl62h.yaml",
+ Group: "dashboard.grafana.app",
+ Resource: "dashboards",
+ Name: "adsl62h-hrw-f-fvlt2dghp-gufrc4lisksgmq-c",
+ Hash: "ce5d497c4deadde6831162ce8509e2b2b1776237",
+ },
+ }, changes[0])
+ })
+}
+
+func getBase(t *testing.T) (source []repository.FileTreeEntry, target *provisioning.ResourceList) {
+ target = &provisioning.ResourceList{}
+ err := json.Unmarshal([]byte(`{
+ "kind": "ResourceList",
+ "apiVersion": "provisioning.grafana.app/v0alpha1",
+ "metadata": {},
+ "items": [
+ {
+ "path": "",
+ "group": "folder.grafana.app",
+ "resource": "folders",
+ "name": "simplelocal-3794ab9",
+ "hash": ""
+ },
+ {
+ "path": "ad4lwp2.yaml",
+ "group": "dashboard.grafana.app",
+ "resource": "dashboards",
+ "name": "ad4lwp2-xofjsuo-mr5blr1zwimlfi0ds0pyrrpd",
+ "hash": "ca83d64b9c4a23fed975aacdf47e7de8878b4ae0"
+ },
+ {
+ "path": "adsl62h.yaml",
+ "group": "dashboard.grafana.app",
+ "resource": "dashboards",
+ "name": "adsl62h-hrw-f-fvlt2dghp-gufrc4lisksgmq-c",
+ "hash": "ce5d497c4deadde6831162ce8509e2b2b1776237"
+ }
+ ]
+ }`), target)
+ require.NoError(t, err)
+
+ source = []repository.FileTreeEntry{
+ {Path: "ad4lwp2.yaml", Hash: "ca83d64b9c4a23fed975aacdf47e7de8878b4ae0", Blob: true},
+ {Path: "adsl62h.yaml", Hash: "ce5d497c4deadde6831162ce8509e2b2b1776237", Blob: true},
+ }
+
+ return // named values!
+}
diff --git a/pkg/registry/apis/provisioning/jobs/sync/worker.go b/pkg/registry/apis/provisioning/jobs/sync/worker.go
new file mode 100644
index 00000000000..aa5757aabba
--- /dev/null
+++ b/pkg/registry/apis/provisioning/jobs/sync/worker.go
@@ -0,0 +1,476 @@
+package sync
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "sort"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/client-go/dynamic"
+
+ "github.com/grafana/grafana-app-sdk/logging"
+ dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
+ folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ client "github.com/grafana/grafana/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
+ "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
+)
+
+// SyncWorker synchronizes the external repo with grafana database
+// this function updates the status for both the job and the referenced repository
+type SyncWorker struct {
+ // Used to update the repository status with sync info
+ client client.ProvisioningV0alpha1Interface
+
+ // Lists the values saved in grafana database
+ lister resources.ResourceLister
+
+ // Parses fields saved in remore repository
+ parsers *resources.ParserFactory
+
+ // Check if the system is using unified storage
+ storageStatus dualwrite.Service
+}
+
+func NewSyncWorker(
+ client client.ProvisioningV0alpha1Interface,
+ parsers *resources.ParserFactory,
+ lister resources.ResourceLister,
+ storageStatus dualwrite.Service,
+) *SyncWorker {
+ return &SyncWorker{
+ client: client,
+ parsers: parsers,
+ lister: lister,
+ storageStatus: storageStatus,
+ }
+}
+
+func (r *SyncWorker) IsSupported(ctx context.Context, job provisioning.Job) bool {
+ return job.Spec.Action == provisioning.JobActionSync
+}
+
+func (r *SyncWorker) Process(ctx context.Context, repo repository.Repository, job provisioning.Job, progress jobs.JobProgressRecorder) error {
+ cfg := repo.Config()
+ logger := logging.FromContext(ctx).With("job", job.GetName(), "namespace", job.GetNamespace())
+ // Check if we are onboarding from legacy storage
+ if dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, r.storageStatus) {
+ return fmt.Errorf("sync not supported until storage has migrated")
+ }
+
+ rw, ok := repo.(repository.Reader)
+ if !ok {
+ return fmt.Errorf("sync job submitted for repository that does not support read-write -- this is a bug")
+ }
+
+ syncStatus := job.Status.ToSyncStatus(job.Name)
+ // Preserve last ref as we use replace operation
+ syncStatus.LastRef = repo.Config().Status.Sync.LastRef
+
+ // Update sync status at start using JSON patch
+ patchOperations := []map[string]interface{}{
+ {
+ "op": "replace",
+ "path": "/status/sync",
+ "value": syncStatus,
+ },
+ }
+
+ progress.SetMessage(ctx, "update sync status at start")
+ if err := r.patchStatus(ctx, cfg, patchOperations); err != nil {
+ return fmt.Errorf("update repo with job status at start: %w", err)
+ }
+
+ progress.SetMessage(ctx, "execute sync job")
+ syncJob, err := r.createJob(ctx, rw, progress)
+ if err != nil {
+ return fmt.Errorf("failed to create sync job: %w", err)
+ }
+
+ syncError := syncJob.run(ctx, *job.Spec.Pull)
+ jobStatus := progress.Complete(ctx, syncError)
+ syncStatus = jobStatus.ToSyncStatus(job.Name)
+
+ // Create sync status and set hash if successful
+ if syncStatus.State == provisioning.JobStateSuccess {
+ syncStatus.LastRef = progress.GetRef()
+ }
+
+ // Update final status using JSON patch
+ progress.SetMessage(ctx, "update status and stats")
+ patchOperations = []map[string]interface{}{
+ {
+ "op": "replace",
+ "path": "/status/sync",
+ "value": syncStatus,
+ },
+ }
+
+ // Only add stats patch if stats are not nil
+ if stats, err := r.lister.Stats(ctx, cfg.Namespace, cfg.Name); err != nil {
+ logger.Error("unable to read stats", "error", err)
+ } else if stats != nil && len(stats.Managed) == 1 {
+ patchOperations = append(patchOperations, map[string]interface{}{
+ "op": "replace",
+ "path": "/status/stats",
+ "value": stats.Managed[0].Stats,
+ })
+ }
+
+ // Only patch the specific fields we want to update, not the entire status
+ if err := r.patchStatus(ctx, cfg, patchOperations); err != nil {
+ return fmt.Errorf("update repo with job final status: %w", err)
+ }
+
+ return syncError
+}
+
+// start a job and run it
+func (r *SyncWorker) createJob(ctx context.Context, repo repository.Reader, progress jobs.JobProgressRecorder) (*syncJob, error) {
+ cfg := repo.Config()
+ parser, err := r.parsers.GetParser(ctx, repo)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get parser for %s: %w", cfg.Name, err)
+ }
+
+ folderClient, err := parser.Clients().Folder()
+ if err != nil {
+ return nil, fmt.Errorf("unable to get folder client: %w", err)
+ }
+
+ dashboardClient, err := parser.Clients().Dashboard()
+ if err != nil {
+ return nil, fmt.Errorf("unable to get dashboard client: %w", err)
+ }
+
+ job := &syncJob{
+ repository: repo,
+ progress: progress,
+ parser: parser,
+ lister: r.lister,
+ folders: resources.NewFolderManager(repo, folderClient),
+ dashboards: dashboardClient,
+ resourcesLookup: map[resourceID]string{},
+ }
+
+ return job, nil
+}
+
+func (r *SyncWorker) patchStatus(ctx context.Context, repo *provisioning.Repository, patchOperations []map[string]interface{}) error {
+ patch, err := json.Marshal(patchOperations)
+ if err != nil {
+ return fmt.Errorf("unable to marshal patch data: %w", err)
+ }
+
+ _, err = r.client.Repositories(repo.Namespace).
+ Patch(ctx, repo.Name, types.JSONPatchType, patch, metav1.PatchOptions{}, "status")
+ if err != nil {
+ return fmt.Errorf("unable to update repo with job status: %w", err)
+ }
+
+ return nil
+}
+
+type resourceID struct {
+ Name string
+ Resource string
+ Group string
+}
+
+// created once for each sync execution
+type syncJob struct {
+ repository repository.Reader
+ progress jobs.JobProgressRecorder
+ parser *resources.Parser
+ lister resources.ResourceLister
+ folders *resources.FolderManager
+ dashboards dynamic.ResourceInterface
+ folderLookup *resources.FolderTree
+ resourcesLookup map[resourceID]string // the path with this k8s name
+}
+
+func (r *syncJob) run(ctx context.Context, options provisioning.SyncJobOptions) error {
+ // Ensure the configured folder exists and is managed by the repository
+ cfg := r.repository.Config()
+ rootFolder := resources.RootFolder(cfg)
+ if rootFolder != "" {
+ if err := r.folders.EnsureFolderExists(ctx, resources.Folder{
+ ID: rootFolder, // will not change if exists
+ Title: cfg.Spec.Title,
+ Path: "", // at the root of the repository
+ }, ""); err != nil {
+ return fmt.Errorf("unable to create root folder: %w", err)
+ }
+ }
+
+ var err error
+ var currentRef string
+
+ versionedRepo, _ := r.repository.(repository.Versioned)
+ if versionedRepo != nil {
+ currentRef, err = versionedRepo.LatestRef(ctx)
+ if err != nil {
+ return fmt.Errorf("getting latest ref: %w", err)
+ }
+ r.progress.SetRef(currentRef)
+
+ if cfg.Status.Sync.LastRef != "" && options.Incremental {
+ if currentRef == cfg.Status.Sync.LastRef {
+ r.progress.SetFinalMessage(ctx, "same commit as last sync")
+ return nil
+ }
+
+ return r.applyVersionedChanges(ctx, versionedRepo, cfg.Status.Sync.LastRef, currentRef)
+ }
+ }
+
+ // Read the complete change set
+ target, err := r.lister.List(ctx, cfg.Namespace, cfg.Name)
+ if err != nil {
+ return fmt.Errorf("error listing current: %w", err)
+ }
+ source, err := r.repository.ReadTree(ctx, currentRef)
+ if err != nil {
+ return fmt.Errorf("error reading tree: %w", err)
+ }
+ changes, err := Changes(source, target)
+ if err != nil {
+ return fmt.Errorf("error calculating changes: %w", err)
+ }
+
+ if len(changes) == 0 {
+ r.progress.SetFinalMessage(ctx, "no changes to sync")
+ return nil
+ }
+
+ // Load any existing folder information
+ r.folderLookup = resources.NewFolderTreeFromResourceList(target)
+
+ // Now apply the changes
+ return r.applyChanges(ctx, changes)
+}
+
+func (r *syncJob) applyChanges(ctx context.Context, changes []ResourceFileChange) error {
+ if len(r.resourcesLookup) > 0 {
+ return fmt.Errorf("this should be empty")
+ }
+
+ // Do the deepest paths first (important for delete)
+ sort.Slice(changes, func(i, j int) bool {
+ return safepath.Depth(changes[i].Path) > safepath.Depth(changes[j].Path)
+ })
+
+ r.progress.SetTotal(ctx, len(changes))
+ r.progress.SetMessage(ctx, "replicating changes")
+
+ // Create folder structure first
+ for _, change := range changes {
+ if err := r.progress.TooManyErrors(); err != nil {
+ return err
+ }
+
+ if change.Action == repository.FileActionDeleted {
+ result := jobs.JobResourceResult{
+ Name: change.Existing.Name,
+ Resource: change.Existing.Resource,
+ Group: change.Existing.Group,
+ Path: change.Path,
+ Action: change.Action,
+ }
+
+ if change.Existing == nil || change.Existing.Name == "" {
+ result.Error = errors.New("missing existing reference")
+ r.progress.Record(ctx, result)
+ continue
+ }
+
+ client, err := r.client(change.Existing.Resource)
+ if err != nil {
+ result.Error = fmt.Errorf("unable to get client for deleted object: %w", err)
+ r.progress.Record(ctx, result)
+ continue
+ }
+
+ result.Error = client.Delete(ctx, change.Existing.Name, metav1.DeleteOptions{})
+ r.progress.Record(ctx, result)
+ continue
+ }
+
+ // Write the resource file
+ r.progress.Record(ctx, r.writeResourceFromFile(ctx, change.Path, "", change.Action))
+ }
+
+ r.progress.SetMessage(ctx, "changes replicated")
+
+ return nil
+}
+
+// Convert git changes into resource file changes
+func (r *syncJob) applyVersionedChanges(ctx context.Context, repo repository.Versioned, previousRef, currentRef string) error {
+ diff, err := repo.CompareFiles(ctx, previousRef, currentRef)
+ if err != nil {
+ return fmt.Errorf("compare files error: %w", err)
+ }
+
+ if len(diff) < 1 {
+ r.progress.SetFinalMessage(ctx, "no changes detected between commits")
+ return nil
+ }
+
+ r.progress.SetTotal(ctx, len(diff))
+ r.progress.SetMessage(ctx, "replicating versioned changes")
+
+ for _, change := range diff {
+ if err := r.progress.TooManyErrors(); err != nil {
+ return err
+ }
+
+ if err := resources.IsPathSupported(change.Path); err != nil {
+ r.progress.Record(ctx, jobs.JobResourceResult{
+ Path: change.Path,
+ Action: repository.FileActionIgnored,
+ })
+ continue
+ }
+
+ switch change.Action {
+ case repository.FileActionCreated, repository.FileActionUpdated:
+ r.progress.Record(ctx, r.writeResourceFromFile(ctx, change.Path, change.Ref, change.Action))
+ case repository.FileActionDeleted:
+ r.progress.Record(ctx, r.deleteObject(ctx, change.Path, change.PreviousRef))
+ case repository.FileActionRenamed:
+ // 1. Delete
+ result := r.deleteObject(ctx, change.Path, change.PreviousRef)
+ if result.Error != nil {
+ r.progress.Record(ctx, result)
+ continue
+ }
+
+ // 2. Create
+ r.progress.Record(ctx, r.writeResourceFromFile(ctx, change.Path, change.Ref, repository.FileActionCreated))
+ case repository.FileActionIgnored:
+ r.progress.Record(ctx, jobs.JobResourceResult{
+ Path: change.Path,
+ Action: repository.FileActionIgnored,
+ })
+ }
+ }
+
+ r.progress.SetMessage(ctx, "versioned changes replicated")
+
+ return nil
+}
+
+func (r *syncJob) deleteObject(ctx context.Context, path string, ref string) jobs.JobResourceResult {
+ info, err := r.repository.Read(ctx, path, ref)
+ result := jobs.JobResourceResult{
+ Path: path,
+ Action: repository.FileActionDeleted,
+ }
+
+ if err != nil {
+ result.Error = fmt.Errorf("failed to read file: %w", err)
+ return result
+ }
+
+ obj, gvk, _ := resources.DecodeYAMLObject(bytes.NewBuffer(info.Data))
+ if obj == nil {
+ result.Error = errors.New("no object found")
+ return result
+ }
+
+ objName := obj.GetName()
+ if objName == "" {
+ // Find the referenced file
+ objName, _ = resources.NamesFromHashedRepoPath(r.repository.Config().Name, path)
+ }
+
+ result.Name = objName
+ result.Resource = gvk.Kind
+ result.Group = gvk.Group
+
+ client, err := r.client(gvk.Kind)
+ if err != nil {
+ result.Error = fmt.Errorf("unable to get client for deleted object: %w", err)
+ return result
+ }
+
+ err = client.Delete(ctx, objName, metav1.DeleteOptions{})
+ if err != nil {
+ result.Error = fmt.Errorf("failed to delete: %w", err)
+ return result
+ }
+
+ return result
+}
+
+func (r *syncJob) writeResourceFromFile(ctx context.Context, path string, ref string, action repository.FileAction) jobs.JobResourceResult {
+ result := jobs.JobResourceResult{
+ Path: path,
+ Action: action,
+ }
+
+ // Read the referenced file
+ fileInfo, err := r.repository.Read(ctx, path, ref)
+ if err != nil {
+ result.Error = fmt.Errorf("failed to read file: %w", err)
+ return result
+ }
+
+ parsed, err := r.parser.Parse(ctx, fileInfo, false) // no validation
+ if err != nil {
+ result.Error = fmt.Errorf("failed to parse file: %w", err)
+ return result
+ }
+
+ // Check if the resource already exists
+ id := resourceID{
+ Name: parsed.Obj.GetName(),
+ Resource: parsed.GVR.Resource,
+ Group: parsed.GVK.Group,
+ }
+ existing, found := r.resourcesLookup[id]
+ if found {
+ result.Error = fmt.Errorf("duplicate resource name: %s, %s and %s", parsed.Obj.GetName(), path, existing)
+ return result
+ }
+ r.resourcesLookup[id] = path
+
+ // Make sure the parent folders exist
+ folder, err := r.folders.EnsureFolderPathExist(ctx, path)
+ if err != nil {
+ result.Error = fmt.Errorf("failed to ensure folder path exists: %w", err)
+ return result
+ }
+
+ parsed.Meta.SetFolder(folder)
+ parsed.Meta.SetUID("") // clear identifiers
+ parsed.Meta.SetResourceVersion("") // clear identifiers
+
+ result.Name = parsed.Obj.GetName()
+ result.Resource = parsed.GVR.Resource
+ result.Group = parsed.GVK.Group
+
+ // Update will also create (for resources we care about)
+ _, err = parsed.Client.Update(ctx, parsed.Obj, metav1.UpdateOptions{})
+ result.Error = err
+ return result
+}
+
+func (r *syncJob) client(kind string) (dynamic.ResourceInterface, error) {
+ switch kind {
+ case dashboard.GROUP, dashboard.DASHBOARD_RESOURCE, "Dashboard":
+ return r.dashboards, nil
+ case folders.GROUP, folders.RESOURCE, "Folder":
+ return r.folders.Client(), nil
+ }
+ return nil, fmt.Errorf("unsupported resource: %s", kind)
+}
diff --git a/pkg/registry/apis/provisioning/jobs/watchset.go b/pkg/registry/apis/provisioning/jobs/watchset.go
deleted file mode 100644
index f9a2934482d..00000000000
--- a/pkg/registry/apis/provisioning/jobs/watchset.go
+++ /dev/null
@@ -1,379 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// Provenance-includes-location: https://github.com/tilt-dev/tilt-apiserver/blob/main/pkg/storage/filepath/watchset.go
-// Provenance-includes-license: Apache-2.0
-// Provenance-includes-copyright: The Kubernetes Authors.
-
-// See also
-// https://github.com/grafana/grafana/blob/v11.1.9/pkg/apiserver/storage/file/watchset.go
-
-package jobs
-
-import (
- "context"
- "fmt"
- "sync"
- "sync/atomic"
-
- "k8s.io/apimachinery/pkg/api/meta"
- "k8s.io/apimachinery/pkg/runtime"
- "k8s.io/apimachinery/pkg/watch"
- "k8s.io/apiserver/pkg/storage"
- "k8s.io/klog/v2"
-)
-
-const (
- UpdateChannelSize = 25
- InitialWatchNodesSize = 20
- InitialBufferedEventsSize = 25
-)
-
-type eventWrapper struct {
- ev watch.Event
- // optional: oldObject is only set for modifications for determining their type as necessary (when using predicate filtering)
- oldObject runtime.Object
-}
-
-type watchNode struct {
- ctx context.Context
- s *WatchSet
- id uint64
- updateCh chan eventWrapper
- outCh chan watch.Event
- requestedRV uint64
- // the watch may or may not be namespaced for a namespaced resource. This is always nil for cluster-scoped kinds
- watchNamespace *string
- predicate storage.SelectionPredicate
- versioner storage.Versioner
-}
-
-// Keeps track of which watches need to be notified
-type WatchSet struct {
- mu sync.RWMutex
- // mu protects both nodes and counter
- nodes map[uint64]*watchNode
- counter atomic.Uint64
- buffered []eventWrapper
- bufferedMutex sync.RWMutex
-}
-
-func NewWatchSet() *WatchSet {
- return &WatchSet{
- buffered: make([]eventWrapper, 0, InitialBufferedEventsSize),
- nodes: make(map[uint64]*watchNode, InitialWatchNodesSize),
- }
-}
-
-// Creates a new watch with a unique id, but
-// does not start sending events to it until start() is called.
-func (s *WatchSet) newWatch(ctx context.Context, requestedRV uint64, p storage.SelectionPredicate, versioner storage.Versioner, namespace *string) *watchNode {
- s.counter.Add(1)
-
- node := &watchNode{
- ctx: ctx,
- requestedRV: requestedRV,
- id: s.counter.Load(),
- s: s,
- // updateCh size needs to be > 1 to allow slower clients to not block passing new events
- updateCh: make(chan eventWrapper, UpdateChannelSize),
- // outCh size needs to be > 1 for single process use-cases such as tests where watch and event seeding from CUD
- // events is happening on the same thread
- outCh: make(chan watch.Event, UpdateChannelSize),
- predicate: p,
- watchNamespace: namespace,
- versioner: versioner,
- }
-
- return node
-}
-
-func (s *WatchSet) CleanupWatchers() {
- s.mu.Lock()
- defer s.mu.Unlock()
- for _, w := range s.nodes {
- w.stop()
- }
-}
-
-// oldObject is only passed in the event of a modification
-// in case a predicate filtered watch is impacted as a result of modification
-// NOTE: this function gives one the misperception that a newly added node will never
-// get a double event, one from buffered and one from the update channel
-// That perception is not true. Even though this function maintains the lock throughout the function body
-// it is not true of the Start function. So basically, the Start function running after this function
-// fully stands the chance of another future notifyWatchers double sending it the event through the two means mentioned
-func (s *WatchSet) notifyWatchers(ev watch.Event, oldObject runtime.Object) {
- s.mu.RLock()
- defer s.mu.RUnlock()
-
- updateEv := eventWrapper{
- ev: ev,
- }
- if oldObject != nil {
- updateEv.oldObject = oldObject
- }
-
- // Events are always buffered.
- // this is because of an inadvertent delay which is built into the watch process
- // Watch() from storage returns Watch.Interface with a async start func.
- // The only way to guarantee that we can interpret the passed RV correctly is to play it against missed events
- // (notice the loop below over s.nodes isn't exactly going to work on a new node
- // unless start is called on it)
- s.bufferedMutex.Lock()
- s.buffered = append(s.buffered, updateEv)
- s.bufferedMutex.Unlock()
-
- for _, w := range s.nodes {
- w.updateCh <- updateEv
- }
-}
-
-// isValid is not necessary to be called on oldObject in UpdateEvents - assuming the Watch pushes correctly setup eventWrapper our way
-// first bool is whether the event is valid for current watcher
-// second bool is whether checking the old value against the predicate may be valuable to the caller
-// second bool may be a helpful aid to establish context around MODIFIED events
-// (note that this second bool is only marked true if we pass other checks first, namely RV and namespace)
-func (w *watchNode) isValid(e eventWrapper) (bool, bool, error) {
- obj, err := meta.Accessor(e.ev.Object)
- if err != nil {
- klog.Error("Could not get accessor to object in event")
- return false, false, nil
- }
-
- eventRV, err := w.getResourceVersionAsInt(e.ev.Object)
- if err != nil {
- return false, false, err
- }
-
- if eventRV < w.requestedRV {
- return false, false, nil
- }
-
- if w.watchNamespace != nil && *w.watchNamespace != obj.GetNamespace() {
- return false, false, err
- }
-
- valid, err := w.predicate.Matches(e.ev.Object)
- if err != nil {
- return false, false, err
- }
-
- return valid, e.ev.Type == watch.Modified, nil
-}
-
-// Only call this method if current object matches the predicate
-func (w *watchNode) handleAddedForFilteredList(e eventWrapper) (*watch.Event, error) {
- if e.oldObject == nil {
- return nil, fmt.Errorf("oldObject should be set for modified events")
- }
-
- ok, err := w.predicate.Matches(e.oldObject)
- if err != nil {
- return nil, err
- }
-
- if !ok {
- e.ev.Type = watch.Added
- return &e.ev, nil
- }
-
- return nil, nil
-}
-
-func (w *watchNode) handleDeletedForFilteredList(e eventWrapper) (*watch.Event, error) {
- if e.oldObject == nil {
- return nil, fmt.Errorf("oldObject should be set for modified events")
- }
-
- ok, err := w.predicate.Matches(e.oldObject)
- if err != nil {
- return nil, err
- }
-
- if !ok {
- return nil, nil
- }
-
- // isn't a match but used to be
- e.ev.Type = watch.Deleted
-
- oldObjectAccessor, err := meta.Accessor(e.oldObject)
- if err != nil {
- klog.Errorf("Could not get accessor to correct the old RV of filtered out object")
- return nil, err
- }
-
- currentRV, err := getResourceVersion(e.ev.Object)
- if err != nil {
- klog.Errorf("Could not get accessor to object in event")
- return nil, err
- }
-
- oldObjectAccessor.SetResourceVersion(currentRV)
- e.ev.Object = e.oldObject
-
- return &e.ev, nil
-}
-
-func (w *watchNode) processEvent(e eventWrapper, isInitEvent bool) error {
- if isInitEvent {
- // Init events have already been vetted against the predicate and other RV behavior
- // Let them pass through
- w.outCh <- e.ev
- return nil
- }
-
- valid, runDeleteFromFilteredListHandler, err := w.isValid(e)
- if err != nil {
- klog.Errorf("Could not determine validity of the event: %v", err)
- return err
- }
- if valid {
- if e.ev.Type == watch.Modified {
- ev, err := w.handleAddedForFilteredList(e)
- if err != nil {
- return err
- }
- if ev != nil {
- w.outCh <- *ev
- } else {
- // forward the original event if add handling didn't signal any impact
- w.outCh <- e.ev
- }
- } else {
- w.outCh <- e.ev
- }
- return nil
- }
-
- if runDeleteFromFilteredListHandler {
- if e.ev.Type == watch.Modified {
- ev, err := w.handleDeletedForFilteredList(e)
- if err != nil {
- return err
- }
- if ev != nil {
- w.outCh <- *ev
- }
- } // explicitly doesn't have an event forward for the else case here
- return nil
- }
-
- return nil
-}
-
-// Start sending events to this watch.
-func (w *watchNode) Start(initEvents ...watch.Event) {
- w.s.mu.Lock()
- w.s.nodes[w.id] = w
- w.s.mu.Unlock()
-
- go func() {
- maxRV := uint64(0)
- for _, ev := range initEvents {
- currentRV, err := w.getResourceVersionAsInt(ev.Object)
- if err != nil {
- klog.Errorf("Could not determine init event RV for deduplication of buffered events: %v", err)
- continue
- }
-
- if maxRV < currentRV {
- maxRV = currentRV
- }
-
- if err := w.processEvent(eventWrapper{ev: ev}, true); err != nil {
- klog.Errorf("Could not process event: %v", err)
- }
- }
-
- // If we had no init events, simply rely on the passed RV
- if maxRV == 0 {
- maxRV = w.requestedRV
- }
-
- w.s.bufferedMutex.RLock()
- for _, e := range w.s.buffered {
- eventRV, err := w.getResourceVersionAsInt(e.ev.Object)
- if err != nil {
- klog.Errorf("Could not determine RV for deduplication of buffered events: %v", err)
- continue
- }
-
- if maxRV >= eventRV {
- continue
- } else {
- maxRV = eventRV
- }
-
- if err := w.processEvent(e, false); err != nil {
- klog.Errorf("Could not process event: %v", err)
- }
- }
- w.s.bufferedMutex.RUnlock()
-
- for {
- select {
- case e, ok := <-w.updateCh:
- if !ok {
- close(w.outCh)
- return
- }
-
- eventRV, err := w.getResourceVersionAsInt(e.ev.Object)
- if err != nil {
- klog.Errorf("Could not determine RV for deduplication of channel events: %v", err)
- continue
- }
-
- if maxRV >= eventRV {
- continue
- } else {
- maxRV = eventRV
- }
-
- if err := w.processEvent(e, false); err != nil {
- klog.Errorf("Could not process event: %v", err)
- }
- case <-w.ctx.Done():
- close(w.outCh)
- return
- }
- }
- }()
-}
-
-func (w *watchNode) Stop() {
- w.s.mu.Lock()
- defer w.s.mu.Unlock()
- w.stop()
-}
-
-// Unprotected func: ensure mutex on the parent watch set is locked before calling
-func (w *watchNode) stop() {
- if _, ok := w.s.nodes[w.id]; ok {
- delete(w.s.nodes, w.id)
- close(w.updateCh)
- }
-}
-
-func (w *watchNode) ResultChan() <-chan watch.Event {
- return w.outCh
-}
-
-func getResourceVersion(obj runtime.Object) (string, error) {
- accessor, err := meta.Accessor(obj)
- if err != nil {
- klog.Error("Could not get accessor to object in event")
- return "", err
- }
- return accessor.GetResourceVersion(), nil
-}
-
-func (w *watchNode) getResourceVersionAsInt(obj runtime.Object) (uint64, error) {
- accessor, err := meta.Accessor(obj)
- if err != nil {
- klog.Error("Could not get accessor to object in event")
- return 0, err
- }
-
- return w.versioner.ParseResourceVersion(accessor.GetResourceVersion())
-}
diff --git a/pkg/registry/apis/provisioning/list.go b/pkg/registry/apis/provisioning/list.go
new file mode 100644
index 00000000000..6fc0a13c72d
--- /dev/null
+++ b/pkg/registry/apis/provisioning/list.go
@@ -0,0 +1,66 @@
+package provisioning
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "time"
+
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apiserver/pkg/endpoints/request"
+ "k8s.io/apiserver/pkg/registry/rest"
+
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
+)
+
+// TODO: Rename to resources as it's not clear that we are returning here is repository resources
+type listConnector struct {
+ getter RepoGetter
+ lister resources.ResourceLister
+}
+
+func (*listConnector) New() runtime.Object {
+ return &provisioning.ResourceList{}
+}
+
+func (*listConnector) Destroy() {}
+
+func (*listConnector) ProducesMIMETypes(verb string) []string {
+ return []string{"application/json"}
+}
+
+func (*listConnector) ProducesObject(verb string) any {
+ return &provisioning.ResourceList{}
+}
+
+func (*listConnector) ConnectMethods() []string {
+ return []string{http.MethodGet}
+}
+
+func (*listConnector) NewConnectOptions() (runtime.Object, bool, string) {
+ return nil, false, ""
+}
+
+func (s *listConnector) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
+ ns, ok := request.NamespaceFrom(ctx)
+ if !ok {
+ return nil, fmt.Errorf("missing namespace")
+ }
+
+ return withTimeout(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // TODO: Add pagination to resource lister
+ rsp, err := s.lister.List(ctx, ns, name)
+ if err != nil {
+ responder.Error(err)
+ } else {
+ responder.Object(200, rsp)
+ }
+ }), 30*time.Second), nil
+}
+
+var (
+ _ rest.Storage = (*listConnector)(nil)
+ _ rest.Connecter = (*listConnector)(nil)
+ _ rest.StorageMetadata = (*listConnector)(nil)
+)
diff --git a/pkg/registry/apis/provisioning/migrate.go b/pkg/registry/apis/provisioning/migrate.go
new file mode 100644
index 00000000000..03bad2ee690
--- /dev/null
+++ b/pkg/registry/apis/provisioning/migrate.go
@@ -0,0 +1,89 @@
+package provisioning
+
+import (
+ "context"
+ "net/http"
+ "time"
+
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apiserver/pkg/registry/rest"
+
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
+ "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
+)
+
+// TODO: should we have merge migrate and sync connectors and have a single repository job connector?
+type migrateConnector struct {
+ dual dualwrite.Service
+ repoGetter RepoGetter
+ jobs jobs.Queue
+}
+
+func (*migrateConnector) New() runtime.Object {
+ return &provisioning.Job{}
+}
+
+func (*migrateConnector) Destroy() {}
+
+func (*migrateConnector) ProducesMIMETypes(verb string) []string {
+ return []string{"application/json"}
+}
+
+func (c *migrateConnector) ProducesObject(verb string) any {
+ return c.New()
+}
+
+func (*migrateConnector) ConnectMethods() []string {
+ return []string{http.MethodPost}
+}
+
+func (*migrateConnector) NewConnectOptions() (runtime.Object, bool, string) {
+ return nil, false, ""
+}
+
+func (c *migrateConnector) Connect(
+ ctx context.Context,
+ name string,
+ opts runtime.Object,
+ responder rest.Responder,
+) (http.Handler, error) {
+ repo, err := c.repoGetter.GetHealthyRepository(ctx, name)
+ if err != nil {
+ return nil, err
+ }
+
+ cfg := repo.Config()
+
+ return withTimeout(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var options provisioning.MigrateJobOptions
+ if err := unmarshalJSON(r, defaultMaxBodySize, &options); err != nil {
+ responder.Error(apierrors.NewBadRequest("error decoding MigrateJobOptions from request"))
+ return
+ }
+
+ job, err := c.jobs.Insert(ctx, &provisioning.Job{
+ ObjectMeta: v1.ObjectMeta{
+ Namespace: cfg.Namespace,
+ },
+ Spec: provisioning.JobSpec{
+ Action: provisioning.JobActionMigrate,
+ Repository: cfg.Name,
+ Migrate: &options,
+ },
+ })
+ if err != nil {
+ responder.Error(err)
+ return
+ }
+ responder.Object(http.StatusAccepted, job)
+ }), 30*time.Second), nil
+}
+
+var (
+ _ rest.Connecter = (*migrateConnector)(nil)
+ _ rest.Storage = (*migrateConnector)(nil)
+ _ rest.StorageMetadata = (*migrateConnector)(nil)
+)
diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go
index d5a12275733..1abb955522c 100644
--- a/pkg/registry/apis/provisioning/register.go
+++ b/pkg/registry/apis/provisioning/register.go
@@ -3,17 +3,16 @@ package provisioning
import (
"context"
"fmt"
+ "net/http"
+ "path/filepath"
+ "strings"
+ "time"
- provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
- grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
- "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
- "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
- "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
- "github.com/grafana/grafana/pkg/services/apiserver/builder"
- "github.com/grafana/grafana/pkg/services/featuremgmt"
- grafanasecrets "github.com/grafana/grafana/pkg/services/secrets"
+ "github.com/prometheus/client_golang/prometheus"
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/labels"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/validation/field"
@@ -23,38 +22,134 @@ import (
genericapiserver "k8s.io/apiserver/pkg/server"
"k8s.io/kube-openapi/pkg/common"
"k8s.io/kube-openapi/pkg/spec3"
+ "k8s.io/kube-openapi/pkg/validation/spec"
+
+ "github.com/grafana/grafana-app-sdk/logging"
+ dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
+ "github.com/grafana/grafana/pkg/apimachinery/identity"
+ apiutils "github.com/grafana/grafana/pkg/apimachinery/utils"
+ folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/apiserver/readonly"
+ grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
+ clientset "github.com/grafana/grafana/pkg/generated/clientset/versioned"
+ informers "github.com/grafana/grafana/pkg/generated/informers/externalversions"
+ listers "github.com/grafana/grafana/pkg/generated/listers/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/infra/usagestats"
+ "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/controller"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/export"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/migrate"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/pullrequest"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/sync"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
+ "github.com/grafana/grafana/pkg/services/apiserver"
+ "github.com/grafana/grafana/pkg/services/apiserver/builder"
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
+ "github.com/grafana/grafana/pkg/services/rendering"
+ grafanasecrets "github.com/grafana/grafana/pkg/services/secrets"
+ "github.com/grafana/grafana/pkg/setting"
+ "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
+ "github.com/grafana/grafana/pkg/storage/unified/resource"
)
+const repoControllerWorkers = 1
+
var (
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
_ builder.APIGroupMutation = (*APIBuilder)(nil)
_ builder.APIGroupValidation = (*APIBuilder)(nil)
+ _ builder.APIGroupRouteProvider = (*APIBuilder)(nil)
_ builder.APIGroupPostStartHookProvider = (*APIBuilder)(nil)
_ builder.OpenAPIPostProcessor = (*APIBuilder)(nil)
)
type APIBuilder struct {
- secrets secrets.Service
- jobs jobs.JobQueue
- getter rest.Getter
+ urlProvider func(namespace string) string
+ webhookSecretKey string
+ isPublic bool
+
+ features featuremgmt.FeatureToggles
+ getter rest.Getter
+ localFileResolver *repository.LocalFolderResolver
+ render rendering.Service
+ parsers *resources.ParserFactory
+ ghFactory *github.Factory
+ clonedir string // where repo clones are managed
+ jobs interface {
+ jobs.Queue
+ jobs.Store
+ }
+ tester *RepositoryTester
+ resourceLister resources.ResourceLister
+ repositoryLister listers.RepositoryLister
+ legacyMigrator legacy.LegacyMigrator
+ storageStatus dualwrite.Service
+ unified resource.ResourceClient
+ secrets secrets.Service
}
// NewAPIBuilder creates an API builder.
// It avoids anything that is core to Grafana, such that it can be used in a multi-tenant service down the line.
// This means there are no hidden dependencies, and no use of e.g. *settings.Cfg.
func NewAPIBuilder(
+ local *repository.LocalFolderResolver,
+ urlProvider func(namespace string) string,
+ webhookSecretKey string,
+ features featuremgmt.FeatureToggles,
+ render rendering.Service,
+ unified resource.ResourceClient,
+ clonedir string, // where repo clones are managed
+ configProvider apiserver.RestConfigProvider,
+ ghFactory *github.Factory,
+ legacyMigrator legacy.LegacyMigrator,
+ storageStatus dualwrite.Service,
secrets secrets.Service,
) *APIBuilder {
+ // HACK: Assume is only public if it is HTTPS
+ isPublic := strings.HasPrefix(urlProvider(""), "https://")
+
return &APIBuilder{
- secrets: secrets,
+ urlProvider: urlProvider,
+ localFileResolver: local,
+ webhookSecretKey: webhookSecretKey,
+ isPublic: isPublic,
+ features: features,
+ ghFactory: ghFactory,
+ parsers: &resources.ParserFactory{
+ ClientFactory: resources.NewClientFactory(configProvider),
+ },
+ render: render,
+ clonedir: clonedir,
+ resourceLister: resources.NewResourceLister(unified, unified, legacyMigrator, storageStatus),
+ legacyMigrator: legacyMigrator,
+ storageStatus: storageStatus,
+ unified: unified,
+ secrets: secrets,
}
}
// RegisterAPIService returns an API builder, from [NewAPIBuilder]. It is called by Wire.
// This function happily uses services core to Grafana, and does not need to be multi-tenancy-compatible.
func RegisterAPIService(
+ // It is OK to use setting.Cfg here -- this is only used when running single tenant with a full setup
+ cfg *setting.Cfg,
features featuremgmt.FeatureToggles,
apiregistration builder.APIRegistrar,
+ reg prometheus.Registerer,
+ render rendering.Service,
+ client resource.ResourceClient, // implements resource.RepositoryClient
+ configProvider apiserver.RestConfigProvider,
+ ghFactory *github.Factory,
+ legacyMigrator legacy.LegacyMigrator,
+ storageStatus dualwrite.Service,
+ usageStatsService usagestats.Service,
+ // FIXME: use multi-tenant service when one exists. In this state, we can't make this a multi-tenant service!
secretsSvc grafanasecrets.Service,
) (*APIBuilder, error) {
if !features.IsEnabledGlobally(featuremgmt.FlagProvisioning) &&
@@ -62,18 +157,142 @@ func RegisterAPIService(
return nil, nil // skip registration unless opting into experimental apis OR the feature specifically
}
- builder := NewAPIBuilder(secrets.NewSingleTenant(secretsSvc))
+ folderResolver := &repository.LocalFolderResolver{
+ PermittedPrefixes: cfg.PermittedProvisioningPaths,
+ HomePath: safepath.Clean(cfg.HomePath),
+ }
+ urlProvider := func(namespace string) string {
+ return cfg.AppURL
+ }
+
+ builder := NewAPIBuilder(folderResolver, urlProvider, cfg.SecretKey, features,
+ render, client,
+ filepath.Join(cfg.DataPath, "clone"), // where repositories are cloned (temporarialy for now)
+ configProvider, ghFactory,
+ legacyMigrator, storageStatus,
+ secrets.NewSingleTenant(secretsSvc),
+ )
apiregistration.RegisterAPI(builder)
+ usageStatsService.RegisterMetricsFunc(builder.collectProvisioningStats)
return builder, nil
}
+// TODO: Move specific endpoint authorization together with the rest of the logic.
+// so that things are not spread out all over the place.
func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer {
return authorizer.AuthorizerFunc(
- func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
- // TODO: Implement a webhook authoriser somehow.
+ func(ctx context.Context, a authorizer.Attributes) (decision authorizer.Decision, reason string, err error) {
+ if identity.IsServiceIdentity(ctx) {
+ // A Grafana sub-system should have full access. We trust them to make wise decisions.
+ return authorizer.DecisionAllow, "", nil
+ }
- // fallback to the standard authorizer
- return authorizer.DecisionNoOpinion, "", nil
+ // Different routes may need different permissions.
+ // * Reading and modifying a repository's configuration requires administrator privileges.
+ // * Reading a repository's limited configuration (/stats & /settings) requires viewer privileges.
+ // * Reading a repository's files requires viewer privileges.
+ // * Editing a repository's files requires editor privileges.
+ // * Syncing a repository requires editor privileges.
+ // * Exporting a repository requires administrator privileges.
+ // * Migrating a repository requires administrator privileges.
+ // * Testing a repository configuration requires administrator privileges.
+ // * Viewing a repository's history requires editor privileges.
+
+ id, err := identity.GetRequester(ctx)
+ if err != nil {
+ return authorizer.DecisionDeny, "failed to find requester", err
+ }
+
+ switch a.GetResource() {
+ case provisioning.RepositoryResourceInfo.GetName():
+ // TODO: Support more fine-grained permissions than the basic roles. Especially on Enterprise.
+ switch a.GetSubresource() {
+ case "":
+ // Doing something with the repository itself.
+ if id.GetOrgRole().Includes(identity.RoleAdmin) {
+ return authorizer.DecisionAllow, "", nil
+ }
+ return authorizer.DecisionDeny, "admin role is required", nil
+
+ case "test", "export", "migrate":
+ // Testing doesn't make sense for non-admins.
+ // Exporting and migrating are potentially dangerous.
+ if id.GetOrgRole().Includes(identity.RoleAdmin) {
+ return authorizer.DecisionAllow, "", nil
+ }
+ return authorizer.DecisionDeny, "admin role is required", nil
+
+ case "webhook":
+ // When the resource is a webhook, we'll deal with permissions manually by checking signatures or similar in the webhook handler.
+ // The user in this context is usually an anonymous user, but may also be an authenticated synthetic check by the Grafana instance's operator as well.
+ // For context on the anonymous user, check the authn/clients/provisioning.go file.
+ return authorizer.DecisionAllow, "", nil
+
+ case "files":
+ // Reading files is allowed for everyone, so as to allow code reviews and similar.
+ // Writing files is only allowed fo Editor and higher.
+ isViewer := id.GetOrgRole().Includes(identity.RoleViewer)
+ isEditor := id.GetOrgRole().Includes(identity.RoleEditor)
+ isReadOperation := a.GetVerb() == apiutils.VerbGet
+
+ if isEditor || (isViewer && isReadOperation) {
+ return authorizer.DecisionAllow, "", nil
+ } else if isReadOperation {
+ return authorizer.DecisionDeny, "viewer role is required for reads", nil
+ } else {
+ return authorizer.DecisionDeny, "editor role is required for edits", nil
+ }
+
+ case "render":
+ // This is used to read a blob from unified storage, for GitHub PR comments.
+ // GH uses a proxy for all images, so we need to accept it, always.
+ return authorizer.DecisionAllow, "", nil
+
+ case "resources", "sync", "history":
+ // These are strictly read operations.
+ // Sync can also be somewhat destructive, but it's expected to be fine to import changes.
+ if id.GetOrgRole().Includes(identity.RoleEditor) {
+ return authorizer.DecisionAllow, "", nil
+ } else {
+ return authorizer.DecisionDeny, "editor role is required", nil
+ }
+
+ default:
+ if id.GetIsGrafanaAdmin() {
+ return authorizer.DecisionAllow, "", nil
+ }
+ return authorizer.DecisionDeny, "unmapped subresource defaults to no access", nil
+ }
+
+ case "stats":
+ // This can leak information one shouldn't necessarily have access to.
+ if id.GetOrgRole().Includes(identity.RoleAdmin) {
+ return authorizer.DecisionAllow, "", nil
+ }
+ return authorizer.DecisionDeny, "admin role is required", nil
+
+ case "settings":
+ // This is strictly a read operation. It is handy on the frontend for viewers.
+ if id.GetOrgRole().Includes(identity.RoleViewer) {
+ return authorizer.DecisionAllow, "", nil
+ }
+ return authorizer.DecisionDeny, "viewer role is required", nil
+
+ case provisioning.JobResourceInfo.GetName(),
+ provisioning.HistoricJobResourceInfo.GetName():
+ // Jobs are shown on the configuration page.
+ if id.GetOrgRole().Includes(identity.RoleAdmin) {
+ return authorizer.DecisionAllow, "", nil
+ }
+ return authorizer.DecisionDeny, "admin role is required", nil
+
+ default:
+ // We haven't bothered with this kind yet.
+ if id.GetIsGrafanaAdmin() {
+ return authorizer.DecisionAllow, "", nil
+ }
+ return authorizer.DecisionDeny, "unmapped kind defaults to no access", nil
+ }
})
}
@@ -103,21 +322,74 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI
if err != nil {
return fmt.Errorf("failed to create repository storage: %w", err)
}
-
- // FIXME: Make job queue store the jobs somewhere persistent.
- jobStore := jobs.NewJobStore(50, b) // in memory, for now...
- b.jobs = jobStore
-
repositoryStatusStorage := grafanaregistry.NewRegistryStatusStore(opts.Scheme, repositoryStorage)
+ b.getter = repositoryStorage
+
+ realJobStore, err := grafanaregistry.NewCompleteRegistryStore(opts.Scheme, provisioning.JobResourceInfo, opts.OptsGetter)
+ if err != nil {
+ return fmt.Errorf("failed to create job storage: %w", err)
+ }
+
+ historicJobStore, err := grafanaregistry.NewCompleteRegistryStore(opts.Scheme, provisioning.HistoricJobResourceInfo, opts.OptsGetter)
+ if err != nil {
+ return fmt.Errorf("failed to create historic job storage: %w", err)
+ }
+
+ b.jobs, err = jobs.NewStore(realJobStore, historicJobStore, time.Second*30)
+ if err != nil {
+ return fmt.Errorf("failed to create job store: %w", err)
+ }
storage := map[string]rest.Storage{}
- storage[provisioning.JobResourceInfo.StoragePath()] = jobStore
+ // Although we never interact with these resources via the API, we want them to be readable from the API.
+ storage[provisioning.JobResourceInfo.StoragePath()] = readonly.Wrap(realJobStore)
+ storage[provisioning.HistoricJobResourceInfo.StoragePath()] = readonly.Wrap(historicJobStore)
+
storage[provisioning.RepositoryResourceInfo.StoragePath()] = repositoryStorage
storage[provisioning.RepositoryResourceInfo.StoragePath("status")] = repositoryStatusStorage
+
+ // TODO: Add some logic so that the connectors can registered themselves and we don't have logic all over the place
+ // TODO: Do not set private fields directly, use factory methods.
+ storage[provisioning.RepositoryResourceInfo.StoragePath("webhook")] = &webhookConnector{
+ getter: b,
+ jobs: b.jobs,
+ webhooksEnabled: b.isPublic,
+ }
+ storage[provisioning.RepositoryResourceInfo.StoragePath("test")] = &testConnector{
+ getter: b,
+ }
+ storage[provisioning.RepositoryResourceInfo.StoragePath("files")] = &filesConnector{
+ getter: b,
+ parsers: b.parsers,
+ }
+ storage[provisioning.RepositoryResourceInfo.StoragePath("resources")] = &listConnector{
+ getter: b,
+ lister: b.resourceLister,
+ }
+ storage[provisioning.RepositoryResourceInfo.StoragePath("history")] = &historySubresource{
+ repoGetter: b,
+ }
+ storage[provisioning.RepositoryResourceInfo.StoragePath("sync")] = &syncConnector{
+ repoGetter: b,
+ jobs: b.jobs,
+ }
+ storage[provisioning.RepositoryResourceInfo.StoragePath("export")] = &exportConnector{
+ repoGetter: b,
+ jobs: b.jobs,
+ }
+ storage[provisioning.RepositoryResourceInfo.StoragePath("migrate")] = &migrateConnector{
+ repoGetter: b,
+ jobs: b.jobs,
+ dual: b.storageStatus,
+ }
+ storage[provisioning.RepositoryResourceInfo.StoragePath("render")] = &renderConnector{
+ blob: b.unified,
+ }
apiGroupInfo.VersionedResourcesStorageMap[provisioning.VERSION] = storage
return nil
}
+// TODO: Move this to a more appropriate place. Probably controller/mutation.go
func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error {
obj := a.GetObject()
@@ -130,20 +402,102 @@ func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admis
return fmt.Errorf("expected repository configuration")
}
- // TODO: Do something based on the resource we got.
- _ = r
+ // This is called on every update, so be careful to only add the finalizer for create
+ if len(r.Finalizers) == 0 && a.GetOperation() == admission.Create {
+ r.Finalizers = []string{
+ controller.RemoveOrphanResourcesFinalizer,
+ controller.CleanFinalizer,
+ }
+ }
+
+ if r.Spec.Sync.IntervalSeconds == 0 {
+ r.Spec.Sync.IntervalSeconds = 60
+ }
+
+ // TODO: move this logic into github repository concrete implementation.
+ if r.Spec.Type == provisioning.GitHubRepositoryType {
+ if r.Spec.GitHub == nil {
+ return fmt.Errorf("github configuration is required")
+ }
+
+ // Trim trailing slash or .git
+ if len(r.Spec.GitHub.URL) > 5 {
+ r.Spec.GitHub.URL = strings.TrimRight(strings.TrimRight(r.Spec.GitHub.URL, "/"), ".git")
+ }
+ }
+
+ if r.Spec.Workflows == nil {
+ r.Spec.Workflows = []provisioning.Workflow{}
+ }
+
+ if err := b.encryptSecrets(ctx, r); err != nil {
+ return fmt.Errorf("failed to encrypt secrets: %w", err)
+ }
return nil
}
+// TODO: move logic to a more appropriate place. Probably controller/validation.go
func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
obj := a.GetObject()
- if obj == nil || a.GetOperation() == admission.Connect {
+ if obj == nil || a.GetOperation() == admission.Connect || a.GetOperation() == admission.Delete {
return nil // This is normal for sub-resource
}
- var list field.ErrorList
- // TODO: Fill the list with validation errors.
+ // Do not validate objects we are trying to delete
+ meta, _ := apiutils.MetaAccessor(obj)
+ if meta.GetDeletionTimestamp() != nil {
+ return nil
+ }
+
+ repo, err := b.asRepository(ctx, obj)
+ if err != nil {
+ return err
+ }
+
+ list := repository.ValidateRepository(repo)
+ cfg := repo.Config()
+
+ if a.GetOperation() == admission.Update {
+ oldRepo, err := b.asRepository(ctx, a.GetOldObject())
+ if err != nil {
+ return fmt.Errorf("get old repository for update: %w", err)
+ }
+ oldCfg := oldRepo.Config()
+
+ if cfg.Spec.Type != oldCfg.Spec.Type {
+ list = append(list, field.Forbidden(field.NewPath("spec", "type"),
+ "Changing repository type is not supported"))
+ }
+
+ // Do not allow changing the sync target once anything has synced successfully
+ if cfg.Spec.Sync.Target != oldCfg.Spec.Sync.Target && len(cfg.Status.Stats) > 0 {
+ list = append(list, field.Forbidden(field.NewPath("spec", "sync", "target"),
+ "Changing sync target after running sync is not supported"))
+ }
+ }
+
+ targetError := b.verifyAgaintsExistingRepositories(cfg)
+ if targetError != nil {
+ list = append(list, targetError)
+ }
+
+ // For *create* we do a synchronous test... this can be expensive!
+ // it is the same as a full healthcheck, so should not be run on every update
+ if len(list) == 0 && a.GetOperation() == admission.Create {
+ testResults, err := repository.TestRepository(ctx, repo)
+ if err != nil {
+ list = append(list, field.Invalid(field.NewPath("spec"),
+ "Repository test failed", "Unable to verify repository: "+err.Error()))
+ }
+
+ if !testResults.Success {
+ for _, err := range testResults.Errors {
+ list = append(list, field.Invalid(field.NewPath("spec"),
+ "Repository test failed", err))
+ }
+ }
+ }
if len(list) > 0 {
return apierrors.NewInvalid(
@@ -153,43 +507,619 @@ func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o adm
return nil
}
-func (b *APIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
- return provisioning.GetOpenAPIDefinitions
+// TODO: move this to a more appropriate place. Probably controller/validation.go
+func (b *APIBuilder) verifyAgaintsExistingRepositories(cfg *provisioning.Repository) *field.Error {
+ all, err := b.repositoryLister.Repositories(cfg.Namespace).List(labels.Everything())
+ if err != nil {
+ return field.Forbidden(field.NewPath("spec"),
+ "Unable to verify root target: "+err.Error())
+ }
+
+ if cfg.Spec.Sync.Target == provisioning.SyncTargetTypeInstance {
+ for _, v := range all {
+ if v.Name != cfg.Name && v.Spec.Sync.Target == provisioning.SyncTargetTypeInstance {
+ return field.Forbidden(field.NewPath("spec", "sync", "target"),
+ "Another repository is already targeting root: "+v.Name)
+ }
+ }
+ }
+
+ if len(all) >= 10 {
+ return field.Forbidden(field.NewPath("spec"),
+ "Maximum number of 10 repositories reached")
+ }
+
+ return nil
}
func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartHookFunc, error) {
postStartHooks := map[string]genericapiserver.PostStartHookFunc{
"grafana-provisioning": func(postStartHookCtx genericapiserver.PostStartHookContext) error {
- // TODO: Set up a shared informer for a controller and a watcher with workers.
+ c, err := clientset.NewForConfig(postStartHookCtx.LoopbackClientConfig)
+ if err != nil {
+ return err
+ }
+
+ // When starting with an empty instance -- swith to "mode 4+"
+ err = b.tryRunningOnlyUnifiedStorage()
+ if err != nil {
+ return err
+ }
+
+ // Informer with resync interval used for health check and reconciliation
+ sharedInformerFactory := informers.NewSharedInformerFactory(c, 60*time.Second)
+ repoInformer := sharedInformerFactory.Provisioning().V0alpha1().Repositories()
+ go repoInformer.Informer().Run(postStartHookCtx.Context.Done())
+
+ // We do not have a local client until *GetPostStartHooks*, so we can delay init for some
+ b.tester = &RepositoryTester{
+ client: c.ProvisioningV0alpha1(),
+ }
+ b.repositoryLister = repoInformer.Lister()
+
+ exportWorker := export.NewExportWorker(
+ b.parsers.ClientFactory,
+ b.storageStatus,
+ b.secrets,
+ b.clonedir,
+ )
+ syncWorker := sync.NewSyncWorker(
+ c.ProvisioningV0alpha1(),
+ b.parsers,
+ b.resourceLister,
+ b.storageStatus,
+ )
+ migrationWorker := migrate.NewMigrationWorker(
+ b.legacyMigrator,
+ b.parsers,
+ b.storageStatus,
+ b.unified,
+ b.secrets,
+ exportWorker,
+ syncWorker,
+ b.clonedir,
+ )
+
+ // Pull request worker
+ renderer := pullrequest.NewScreenshotRenderer(b.render, b.unified, b.isPublic, b.urlProvider)
+ previewer := pullrequest.NewPreviewer(renderer, b.urlProvider)
+ pullRequestWorker, err := pullrequest.NewPullRequestWorker(b.parsers, previewer)
+ if err != nil {
+ return fmt.Errorf("create pull request worker: %w", err)
+ }
+
+ driver := jobs.NewJobDriver(time.Second*28, time.Second*30, time.Second*30, b.jobs, b,
+ exportWorker, syncWorker, migrationWorker, pullRequestWorker)
+ go driver.Run(postStartHookCtx.Context)
+
+ repoController, err := controller.NewRepositoryController(
+ c.ProvisioningV0alpha1(),
+ repoInformer,
+ b, // repoGetter
+ b.resourceLister,
+ b.parsers,
+ &repository.Tester{},
+ b.jobs,
+ b.secrets,
+ b.storageStatus,
+ )
+ if err != nil {
+ return err
+ }
+
+ go repoController.Run(postStartHookCtx.Context, repoControllerWorkers)
return nil
},
}
return postStartHooks, nil
}
+func (b *APIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
+ return provisioning.GetOpenAPIDefinitions
+}
+
+// TODO: move endpoint specific logic to the connector so that we don't have things spread out all over the place.
func (b *APIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) {
oas.Info.Description = "Provisioning"
root := "/apis/" + b.GetGroupVersion().String() + "/"
+ repoprefix := root + "namespaces/{namespace}/repositories/{name}"
- // The root API discovery list
- sub := oas.Paths.Paths[root]
- if sub != nil && sub.Get != nil {
- sub.Get.Tags = []string{"API Discovery"} // sorts first in the list
+ defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} })
+ defsBase := "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1."
+
+ sub := oas.Paths.Paths[repoprefix+"/test"]
+ if sub != nil {
+ repoSchema := defs[defsBase+"Repository"].Schema
+ sub.Post.Description = "Check if the configuration is valid"
+ sub.Post.RequestBody = &spec3.RequestBody{
+ RequestBodyProps: spec3.RequestBodyProps{
+ Required: false,
+ Content: map[string]*spec3.MediaType{
+ "application/json": {
+ MediaTypeProps: spec3.MediaTypeProps{
+ Schema: &repoSchema,
+ },
+ },
+ },
+ },
+ }
}
+ sub = oas.Paths.Paths[repoprefix+"/webhook"]
+ if sub != nil && sub.Get != nil {
+ sub.Post.Description = "Currently only supports github webhooks"
+ }
+
+ ref := &spec3.Parameter{
+ ParameterProps: spec3.ParameterProps{
+ Name: "ref",
+ In: "query",
+ Example: "",
+ Examples: map[string]*spec3.Example{
+ "": {
+ ExampleProps: spec3.ExampleProps{
+ Summary: "The default",
+ },
+ },
+ "branch": {
+ ExampleProps: spec3.ExampleProps{
+ Value: "my-branch",
+ Summary: "Select branch",
+ },
+ },
+ "commit": {
+ ExampleProps: spec3.ExampleProps{
+ Value: "7f7cc2153",
+ Summary: "Commit hash (or prefix)",
+ },
+ },
+ },
+ Description: "branch or commit hash",
+ Schema: spec.StringProperty(),
+ Required: false,
+ },
+ }
+
+ sub = oas.Paths.Paths[repoprefix+"/history"]
+ if sub != nil {
+ sub.Get.Description = "Get the history of the repository"
+ sub.Get.Parameters = []*spec3.Parameter{ref}
+ }
+
+ sub = oas.Paths.Paths[repoprefix+"/history/{path}"]
+ if sub != nil {
+ sub.Get.Description = "Get the history of a path"
+ sub.Get.Parameters = []*spec3.Parameter{ref}
+ }
+
+ // Show a special list command
+ sub = oas.Paths.Paths[repoprefix+"/files"]
+ if sub != nil {
+ delete(oas.Paths.Paths, repoprefix+"/files")
+ oas.Paths.Paths[repoprefix+"/files/"] = sub // add the trailing final slash
+ sub.Get.Description = "Get the files and content hash"
+ sub.Get.Summary = "File listing"
+ sub.Get.Parameters = []*spec3.Parameter{ref}
+ sub.Post = nil
+ sub.Put = nil
+ sub.Delete = nil
+
+ // Replace the content type for this response
+ mt := sub.Get.Responses.StatusCodeResponses[200].Content
+ s := defs[defsBase+"FileList"].Schema
+ mt["*/*"].Schema = &s
+ }
+
+ // update the version with a path
+ sub = oas.Paths.Paths[repoprefix+"/files/{path}"]
+ if sub != nil {
+ sub.Get.Description = "Read value from upstream repository"
+ sub.Get.Parameters = []*spec3.Parameter{ref}
+
+ // Add message to the OpenAPI spec
+ comment := []*spec3.Parameter{
+ ref,
+ {
+ ParameterProps: spec3.ParameterProps{
+ Name: "message",
+ In: "query",
+ Description: "optional message sent with any changes",
+ Schema: spec.StringProperty(),
+ Required: false,
+ },
+ },
+ }
+ sub.Delete.Parameters = comment
+ sub.Post.Parameters = comment
+ sub.Put.Parameters = comment
+ sub.Post.RequestBody = &spec3.RequestBody{
+ RequestBodyProps: spec3.RequestBodyProps{
+ Content: map[string]*spec3.MediaType{
+ "application/json": {
+ MediaTypeProps: spec3.MediaTypeProps{
+ Schema: spec.MapProperty(nil),
+ Example: &unstructured.Unstructured{},
+ Examples: map[string]*spec3.Example{
+ "dashboard": {
+ ExampleProps: spec3.ExampleProps{
+ Value: &unstructured.Unstructured{
+ Object: map[string]interface{}{
+ "spec": map[string]interface{}{
+ "hello": "dashboard",
+ },
+ },
+ },
+ },
+ },
+ "playlist": {
+ ExampleProps: spec3.ExampleProps{
+ Value: &unstructured.Unstructured{
+ Object: map[string]interface{}{
+ "spec": map[string]interface{}{
+ "hello": "playlist",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ "application/x-yaml": {
+ MediaTypeProps: spec3.MediaTypeProps{
+ Schema: spec.MapProperty(nil),
+ Example: &unstructured.Unstructured{},
+ Examples: map[string]*spec3.Example{
+ "dashboard": {
+ ExampleProps: spec3.ExampleProps{
+ Value: `apiVersion: dashboards.grafana.app/v0alpha1
+kind: Dashboard
+spec:
+ title: Sample dashboard
+`,
+ },
+ },
+ "playlist": {
+ ExampleProps: spec3.ExampleProps{
+ Value: `apiVersion: playlist.grafana.app/v0alpha1
+kind: Playlist
+spec:
+ title: Playlist from provisioning
+ interval: 5m
+ items:
+ - type: dashboard_by_tag
+ value: panel-tests
+`,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+ // POST and put have the same request
+ sub.Put.RequestBody = sub.Post.RequestBody
+ }
+
+ sub = oas.Paths.Paths[repoprefix+"/sync"]
+ if sub != nil {
+ optionsSchema := defs[defsBase+"SyncJobOptions"].Schema
+ sub.Post.Description = "Sync from repository into Grafana"
+ sub.Post.RequestBody = &spec3.RequestBody{
+ RequestBodyProps: spec3.RequestBodyProps{
+ Content: map[string]*spec3.MediaType{
+ "application/json": {
+ MediaTypeProps: spec3.MediaTypeProps{
+ Schema: &optionsSchema,
+ Example: &provisioning.SyncJobOptions{
+ Incremental: false,
+ },
+ },
+ },
+ },
+ },
+ }
+ }
+
+ sub = oas.Paths.Paths[repoprefix+"/export"]
+ if sub != nil {
+ optionsSchema := defs[defsBase+"ExportJobOptions"].Schema
+ sub.Post.Description = "Export from grafana into the remote repository"
+ sub.Post.RequestBody = &spec3.RequestBody{
+ RequestBodyProps: spec3.RequestBodyProps{
+ Content: map[string]*spec3.MediaType{
+ "application/json": {
+ MediaTypeProps: spec3.MediaTypeProps{
+ Schema: &optionsSchema,
+ Example: &provisioning.ExportJobOptions{
+ Folder: "grafan-folder-ref",
+ Branch: "target-branch",
+ Path: "path/in/tree",
+ },
+ },
+ },
+ },
+ },
+ }
+ }
+
+ sub = oas.Paths.Paths[repoprefix+"/migrate"]
+ if sub != nil {
+ optionsSchema := defs[defsBase+"MigrateJobOptions"].Schema
+ sub.Post.Description = "Export from grafana into the remote repository"
+ sub.Post.RequestBody = &spec3.RequestBody{
+ RequestBodyProps: spec3.RequestBodyProps{
+ Content: map[string]*spec3.MediaType{
+ "application/json": {
+ MediaTypeProps: spec3.MediaTypeProps{
+ Schema: &optionsSchema,
+ Example: &provisioning.MigrateJobOptions{
+ History: true,
+ },
+ },
+ },
+ },
+ },
+ }
+ }
+
+ delete(oas.Paths.Paths, repoprefix+"/render")
+ sub = oas.Paths.Paths[repoprefix+"/render/{path}"]
+ if sub != nil {
+ sub.Get.Description = "get a rendered preview image"
+ sub.Get.Responses = &spec3.Responses{
+ ResponsesProps: spec3.ResponsesProps{
+ StatusCodeResponses: map[int]*spec3.Response{
+ 200: {
+ ResponseProps: spec3.ResponseProps{
+ Content: map[string]*spec3.MediaType{
+ "image/png": {},
+ },
+ Description: "OK",
+ },
+ },
+ },
+ },
+ }
+ }
+
+ // Add any missing definitions
+ //-----------------------------
+ for k, v := range defs {
+ clean := strings.Replace(k, defsBase, "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.", 1)
+ if oas.Components.Schemas[clean] == nil {
+ oas.Components.Schemas[clean] = &v.Schema
+ }
+ }
+ compBase := "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1."
+ schema := oas.Components.Schemas[compBase+"RepositoryViewList"].Properties["items"]
+ schema.Items = &spec.SchemaOrArray{
+ Schema: &spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ AllOf: []spec.Schema{
+ {
+ SchemaProps: spec.SchemaProps{
+ Ref: spec.MustCreateRef("#/components/schemas/" + compBase + "RepositoryView"),
+ },
+ },
+ },
+ },
+ },
+ }
+ oas.Components.Schemas[compBase+"RepositoryViewList"].Properties["items"] = schema
+
+ countSpec := &spec.SchemaOrArray{
+ Schema: &spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ AllOf: []spec.Schema{
+ {
+ SchemaProps: spec.SchemaProps{
+ Ref: spec.MustCreateRef("#/components/schemas/" + compBase + "ResourceCount"),
+ },
+ },
+ },
+ },
+ },
+ }
+ managerSpec := &spec.SchemaOrArray{
+ Schema: &spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ AllOf: []spec.Schema{
+ {
+ SchemaProps: spec.SchemaProps{
+ Ref: spec.MustCreateRef("#/components/schemas/" + compBase + "ManagerStats"),
+ },
+ },
+ },
+ },
+ },
+ }
+ schema = oas.Components.Schemas[compBase+"ResourceStats"].Properties["instance"]
+ schema.Items = countSpec
+ oas.Components.Schemas[compBase+"ResourceStats"].Properties["instance"] = schema
+
+ schema = oas.Components.Schemas[compBase+"ResourceStats"].Properties["managed"]
+ schema.Items = managerSpec
+ oas.Components.Schemas[compBase+"ResourceStats"].Properties["managed"] = schema
+
+ schema = oas.Components.Schemas[compBase+"ManagerStats"].Properties["stats"]
+ schema.Items = countSpec
+ oas.Components.Schemas[compBase+"ManagerStats"].Properties["stats"] = schema
+
return oas, nil
}
+// TODO: move this to a more appropriate place
+func (b *APIBuilder) encryptSecrets(ctx context.Context, repo *provisioning.Repository) error {
+ var err error
+ if repo.Spec.GitHub != nil &&
+ repo.Spec.GitHub.Token != "" {
+ repo.Spec.GitHub.EncryptedToken, err = b.secrets.Encrypt(ctx, []byte(repo.Spec.GitHub.Token))
+ if err != nil {
+ return err
+ }
+ repo.Spec.GitHub.Token = ""
+ }
+
+ if repo.Status.Webhook != nil &&
+ repo.Status.Webhook.Secret != "" {
+ repo.Status.Webhook.EncryptedSecret, err = b.secrets.Encrypt(ctx, []byte(repo.Status.Webhook.Secret))
+ if err != nil {
+ return err
+ }
+ repo.Status.Webhook.Secret = ""
+ }
+ return nil
+}
+
+// FIXME: This logic does not belong in provisioning! (but required for now)
+// When starting an empty instance, we shift so that we never reference legacy storage
+// This should run somewhere else at startup by default (dual writer? dashboards?)
+func (b *APIBuilder) tryRunningOnlyUnifiedStorage() error {
+ ctx := context.Background()
+
+ if !b.storageStatus.ShouldManage(dashboard.DashboardResourceInfo.GroupResource()) {
+ return nil // not enabled
+ }
+
+ if !dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, b.storageStatus) {
+ return nil
+ }
+
+ // Count how many things exist
+ rsp, err := b.legacyMigrator.Migrate(ctx, legacy.MigrateOptions{
+ Namespace: "default", // FIXME! this works for single org, but need to check multi-org
+ Resources: []schema.GroupResource{{
+ Group: dashboard.GROUP, Resource: dashboard.DASHBOARD_RESOURCE,
+ }, {
+ Group: folders.GROUP, Resource: folders.RESOURCE,
+ }},
+ OnlyCount: true,
+ })
+ if err != nil {
+ return fmt.Errorf("error getting legacy count %w", err)
+ }
+ for _, stats := range rsp.Summary {
+ if stats.Count > 0 {
+ return nil // something exists we can not just switch
+ }
+ }
+
+ logger := logging.DefaultLogger.With("logger", "provisioning startup")
+ mode5 := func(gr schema.GroupResource) error {
+ status, _ := b.storageStatus.Status(ctx, gr)
+ if !status.ReadUnified {
+ status.ReadUnified = true
+ status.WriteLegacy = false
+ status.WriteUnified = true
+ status.Runtime = false
+ status.Migrated = time.Now().UnixMilli()
+ _, err = b.storageStatus.Update(ctx, status)
+ logger.Info("set unified storage access", "group", gr.Group, "resource", gr.Resource)
+ return err
+ }
+ return nil // already reading unified
+ }
+
+ if err = mode5(dashboard.DashboardResourceInfo.GroupResource()); err != nil {
+ return err
+ }
+ if err = mode5(folders.FolderResourceInfo.GroupResource()); err != nil {
+ return err
+ }
+ return nil
+}
+
// Helpers for fetching valid Repository objects
+// TODO: where should the helpers live?
func (b *APIBuilder) GetRepository(ctx context.Context, name string) (repository.Repository, error) {
obj, err := b.getter.Get(ctx, name, &metav1.GetOptions{})
if err != nil {
return nil, err
}
+ return b.asRepository(ctx, obj)
+}
- _ = obj
- // FIXME: Return a valid Repository object with the correct underlying storage.
- panic("FIXME")
+func timeSince(when int64) time.Duration {
+ return time.Duration(time.Now().UnixMilli()-when) * time.Millisecond
+}
+
+func (b *APIBuilder) GetHealthyRepository(ctx context.Context, name string) (repository.Repository, error) {
+ repo, err := b.GetRepository(ctx, name)
+ if err != nil {
+ return nil, err
+ }
+ status := repo.Config().Status.Health
+ if !status.Healthy {
+ if timeSince(status.Checked) > time.Second*25 {
+ ctx, _, err = identity.WithProvisioningIdentity(ctx, repo.Config().Namespace)
+ if err != nil {
+ return nil, err // The status
+ }
+
+ // Check health again
+ s, err := repository.TestRepository(ctx, repo)
+ if err != nil {
+ return nil, err // The status
+ }
+
+ // Write and return the repo with current status
+ cfg, _ := b.tester.UpdateHealthStatus(ctx, repo.Config(), s)
+ if cfg != nil {
+ status = cfg.Status.Health
+ if cfg.Status.Health.Healthy {
+ status = cfg.Status.Health
+ repo, err = b.AsRepository(ctx, cfg)
+ if err != nil {
+ return nil, err
+ }
+ }
+ }
+ }
+ if !status.Healthy {
+ return nil, &apierrors.StatusError{ErrStatus: metav1.Status{
+ Code: http.StatusFailedDependency,
+ Message: "The repository configuration is not healthy",
+ }}
+ }
+ }
+ return repo, err
+}
+
+func (b *APIBuilder) asRepository(ctx context.Context, obj runtime.Object) (repository.Repository, error) {
+ if obj == nil {
+ return nil, fmt.Errorf("missing repository object")
+ }
+ r, ok := obj.(*provisioning.Repository)
+ if !ok {
+ return nil, fmt.Errorf("expected repository configuration")
+ }
+ return b.AsRepository(ctx, r)
+}
+
+func (b *APIBuilder) AsRepository(ctx context.Context, r *provisioning.Repository) (repository.Repository, error) {
+ switch r.Spec.Type {
+ case provisioning.LocalRepositoryType:
+ return repository.NewLocal(r, b.localFileResolver), nil
+ case provisioning.GitHubRepositoryType:
+ gvr := provisioning.RepositoryResourceInfo.GroupVersionResource()
+ var webhookURL string
+ if b.isPublic {
+ webhookURL = fmt.Sprintf(
+ "%sapis/%s/%s/namespaces/%s/%s/%s/webhook",
+ b.urlProvider(r.GetNamespace()),
+ gvr.Group,
+ gvr.Version,
+ r.GetNamespace(),
+ gvr.Resource,
+ r.GetName(),
+ )
+ }
+ return repository.NewGitHub(ctx, r, b.ghFactory, b.secrets, webhookURL)
+ default:
+ return nil, fmt.Errorf("unknown repository type (%s)", r.Spec.Type)
+ }
}
diff --git a/pkg/registry/apis/provisioning/render.go b/pkg/registry/apis/provisioning/render.go
new file mode 100644
index 00000000000..3bfc587364c
--- /dev/null
+++ b/pkg/registry/apis/provisioning/render.go
@@ -0,0 +1,131 @@
+package provisioning
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apiserver/pkg/endpoints/request"
+ "k8s.io/apiserver/pkg/registry/rest"
+
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
+ "github.com/grafana/grafana/pkg/storage/unified/resource"
+)
+
+type renderConnector struct {
+ blob resource.BlobStoreClient
+}
+
+func (*renderConnector) New() runtime.Object {
+ return &provisioning.Repository{}
+}
+
+func (*renderConnector) Destroy() {}
+
+func (*renderConnector) ProducesMIMETypes(verb string) []string {
+ return []string{"application/json"}
+}
+
+func (c *renderConnector) ProducesObject(verb string) any {
+ return c.New()
+}
+
+func (*renderConnector) ConnectMethods() []string {
+ return []string{http.MethodGet}
+}
+
+func (*renderConnector) NewConnectOptions() (runtime.Object, bool, string) {
+ return nil, true, ""
+}
+
+func (c *renderConnector) Connect(
+ ctx context.Context,
+ name string,
+ opts runtime.Object,
+ responder rest.Responder,
+) (http.Handler, error) {
+ namespace := request.NamespaceValue(ctx)
+ return withTimeout(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ prefix := fmt.Sprintf("/%s/render", name)
+ idx := strings.Index(r.URL.Path, prefix)
+ if idx == -1 {
+ logger.Debug("failed to find a file path in the URL")
+ responder.Error(apierrors.NewBadRequest("invalid request path"))
+ return
+ }
+ blobID := strings.TrimPrefix(r.URL.Path[idx+len(prefix):], "/")
+ if len(blobID) == 0 {
+ responder.Error(apierrors.NewNotFound(provisioning.RepositoryResourceInfo.GroupResource(), "render"))
+ return
+ }
+ if !validBlobID(blobID) {
+ responder.Error(apierrors.NewBadRequest(fmt.Sprintf("invalid blob id: %s", blobID)))
+ return
+ }
+
+ rsp, err := c.blob.GetBlob(ctx, &resource.GetBlobRequest{
+ Resource: &resource.ResourceKey{
+ Namespace: namespace,
+ Group: provisioning.GROUP,
+ Resource: provisioning.RepositoryResourceInfo.GroupResource().Resource,
+ Name: name,
+ },
+ MustProxyBytes: true,
+ Uid: blobID,
+ })
+ if err != nil {
+ responder.Error(err)
+ return
+ }
+ if rsp.Error != nil {
+ responder.Error(resource.GetError(rsp.Error))
+ return
+ }
+
+ if len(rsp.Value) > 0 {
+ if rsp.ContentType != "" {
+ w.Header().Add("Content-Type", rsp.ContentType)
+ }
+ _, err = w.Write(rsp.Value)
+ if err != nil {
+ responder.Error(err)
+ return
+ }
+ } else {
+ responder.Error(&apierrors.StatusError{
+ ErrStatus: v1.Status{
+ Code: http.StatusNoContent,
+ Message: "empty body",
+ },
+ })
+ }
+ }), 20*time.Second), nil
+}
+
+// validBlobID ensures the ID is valid for a blob.
+// The ID is always a UUID. As such, this checks for something that can resemble a UUID.
+// This does not check for the ID to be an actual UUID, as the blob store may change their ID format, which we do not wish to stand in the way of.
+func validBlobID(id string) bool {
+ for _, c := range id {
+ // [a-zA-Z0-9\-] are valid characters.
+ az := c >= 'a' && c <= 'z'
+ AZ := c >= 'A' && c <= 'Z'
+ digit := c >= '0' && c <= '9'
+ if !(az || AZ || digit || c == '-') {
+ return false
+ }
+ }
+ return true
+}
+
+var (
+ _ rest.Connecter = (*renderConnector)(nil)
+ _ rest.Storage = (*renderConnector)(nil)
+ _ rest.StorageMetadata = (*renderConnector)(nil)
+)
diff --git a/pkg/registry/apis/provisioning/repository/context.go b/pkg/registry/apis/provisioning/repository/context.go
new file mode 100644
index 00000000000..ac10239a130
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/context.go
@@ -0,0 +1,31 @@
+package repository
+
+import (
+ "context"
+ "time"
+)
+
+// When writing values from history, we want the metadata to match Legacy grafana SQL
+type CommitSignature struct {
+ // Name represents a person name. It is an arbitrary string.
+ Name string
+ // Email is an email, but it cannot be assumed to be well-formed.
+ Email string
+ // When is the timestamp of the signature.
+ When time.Time
+}
+
+type ctxAuthorKey struct{}
+
+func WithAuthorSignature(ctx context.Context, sig CommitSignature) context.Context {
+ return context.WithValue(ctx, ctxAuthorKey{}, sig)
+}
+
+func GetAuthorSignature(ctx context.Context) *CommitSignature {
+ u, ok := ctx.Value(ctxAuthorKey{}).(CommitSignature)
+ if ok {
+ copy := u
+ return ©
+ }
+ return nil
+}
diff --git a/pkg/registry/apis/provisioning/repository/context_test.go b/pkg/registry/apis/provisioning/repository/context_test.go
new file mode 100644
index 00000000000..2fd49c33b15
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/context_test.go
@@ -0,0 +1,52 @@
+package repository
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestAuthorSignature(t *testing.T) {
+ t.Run("should store and retrieve author signature", func(t *testing.T) {
+ expected := CommitSignature{
+ Name: "John Doe",
+ Email: "john@example.com",
+ When: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
+ }
+
+ ctx := context.Background()
+ ctx = WithAuthorSignature(ctx, expected)
+
+ result := GetAuthorSignature(ctx)
+ require.NotNil(t, result)
+ require.Equal(t, expected.Name, result.Name)
+ require.Equal(t, expected.Email, result.Email)
+ require.Equal(t, expected.When, result.When)
+ })
+
+ t.Run("should return nil when no signature is set", func(t *testing.T) {
+ ctx := context.Background()
+ result := GetAuthorSignature(ctx)
+ require.Nil(t, result)
+ })
+
+ t.Run("should return copy of signature", func(t *testing.T) {
+ original := CommitSignature{
+ Name: "John Doe",
+ Email: "john@example.com",
+ When: time.Now(),
+ }
+
+ ctx := context.Background()
+ ctx = WithAuthorSignature(ctx, original)
+
+ result1 := GetAuthorSignature(ctx)
+ result2 := GetAuthorSignature(ctx)
+
+ require.NotNil(t, result1)
+ require.NotNil(t, result2)
+ require.NotSame(t, result1, result2)
+ })
+}
diff --git a/pkg/registry/apis/provisioning/repository/github.go b/pkg/registry/apis/provisioning/repository/github.go
new file mode 100644
index 00000000000..21d8903ef35
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/github.go
@@ -0,0 +1,916 @@
+package repository
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "regexp"
+ "slices"
+ "strings"
+
+ "github.com/google/go-github/v69/github"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/util/validation/field"
+
+ "github.com/google/uuid"
+
+ "github.com/grafana/grafana-app-sdk/logging"
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ pgh "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
+)
+
+var subscribedEvents = []string{"push", "pull_request"}
+
+// Make sure all public functions of this struct call the (*githubRepository).logger function, to ensure the GH repo details are included.
+type githubRepository struct {
+ config *provisioning.Repository
+ gh pgh.Client // assumes github.com base URL
+ secrets secrets.Service
+ webhookURL string
+
+ owner string
+ repo string
+}
+
+var (
+ _ Repository = (*githubRepository)(nil)
+ _ Hooks = (*githubRepository)(nil)
+ _ Versioned = (*githubRepository)(nil)
+ _ Writer = (*githubRepository)(nil)
+ _ Reader = (*githubRepository)(nil)
+ _ RepositoryWithURLs = (*githubRepository)(nil)
+)
+
+func NewGitHub(
+ ctx context.Context,
+ config *provisioning.Repository,
+ factory *pgh.Factory,
+ secrets secrets.Service,
+ webhookURL string,
+) (*githubRepository, error) {
+ owner, repo, err := parseOwnerRepo(config.Spec.GitHub.URL)
+ if err != nil {
+ return nil, err
+ }
+ token := config.Spec.GitHub.Token
+ if token == "" {
+ decrypted, err := secrets.Decrypt(ctx, config.Spec.GitHub.EncryptedToken)
+ if err != nil {
+ return nil, err
+ }
+ token = string(decrypted)
+ }
+ return &githubRepository{
+ config: config,
+ gh: factory.New(ctx, token), // TODO, baseURL from config
+ secrets: secrets,
+ webhookURL: webhookURL,
+ owner: owner,
+ repo: repo,
+ }, nil
+}
+
+func (r *githubRepository) Config() *provisioning.Repository {
+ return r.config
+}
+
+// Validate implements provisioning.Repository.
+func (r *githubRepository) Validate() (list field.ErrorList) {
+ gh := r.config.Spec.GitHub
+ if gh == nil {
+ list = append(list, field.Required(field.NewPath("spec", "github"), "a github config is required"))
+ return list
+ }
+ if gh.URL == "" {
+ list = append(list, field.Required(field.NewPath("spec", "github", "url"), "a github url is required"))
+ } else {
+ _, _, err := parseOwnerRepo(gh.URL)
+ if err != nil {
+ list = append(list, field.Invalid(field.NewPath("spec", "github", "url"), gh.URL, err.Error()))
+ } else if !strings.HasPrefix(gh.URL, "https://github.com/") {
+ list = append(list, field.Invalid(field.NewPath("spec", "github", "url"), gh.URL, "URL must start with https://github.com/"))
+ }
+ }
+ if gh.Branch == "" {
+ list = append(list, field.Required(field.NewPath("spec", "github", "branch"), "a github branch is required"))
+ }
+ if !isValidGitBranchName(gh.Branch) {
+ list = append(list, field.Invalid(field.NewPath("spec", "github", "branch"), gh.Branch, "invalid branch name"))
+ }
+ // TODO: Use two fields for token
+ if gh.Token == "" && len(gh.EncryptedToken) == 0 {
+ list = append(list, field.Required(field.NewPath("spec", "github", "token"), "a github access token is required"))
+ }
+
+ if err := safepath.IsSafe(gh.Path); err != nil {
+ list = append(list, field.Invalid(field.NewPath("spec", "github", "prefix"), gh.Path, err.Error()))
+ }
+
+ if safepath.IsAbs(gh.Path) {
+ list = append(list, field.Invalid(field.NewPath("spec", "github", "prefix"), gh.Path, "path must be relative"))
+ }
+
+ return list
+}
+
+func parseOwnerRepo(giturl string) (owner string, repo string, err error) {
+ parsed, e := url.Parse(strings.TrimSuffix(giturl, ".git"))
+ if e != nil {
+ err = e
+ return
+ }
+ parts := strings.Split(parsed.Path, "/")
+ if len(parts) < 3 {
+ err = fmt.Errorf("unable to parse repo+owner from url")
+ return
+ }
+ return parts[1], parts[2], nil
+}
+
+func fromError(err error, code int) *provisioning.TestResults {
+ statusErr, ok := err.(apierrors.APIStatus)
+ if ok {
+ s := statusErr.Status()
+ return &provisioning.TestResults{
+ Code: int(s.Code),
+ Success: false,
+ Errors: []string{s.Message},
+ }
+ }
+ return &provisioning.TestResults{
+ Code: code,
+ Success: false,
+ Errors: []string{err.Error()},
+ }
+}
+
+// Test implements provisioning.Repository.
+func (r *githubRepository) Test(ctx context.Context) (*provisioning.TestResults, error) {
+ if err := r.gh.IsAuthenticated(ctx); err != nil {
+ return fromError(err, http.StatusUnauthorized), nil
+ }
+
+ owner, repo, err := parseOwnerRepo(r.config.Spec.GitHub.URL)
+ if err != nil {
+ return fromError(err, http.StatusBadRequest), nil
+ }
+
+ // FIXME: check token permissions
+ ok, err := r.gh.RepoExists(ctx, owner, repo)
+ if err != nil {
+ return fromError(err, http.StatusBadRequest), nil
+ }
+
+ if !ok {
+ return &provisioning.TestResults{
+ Code: http.StatusBadRequest,
+ Success: false,
+ Errors: []string{"repository does not exist"},
+ }, nil
+ }
+
+ ok, err = r.gh.BranchExists(ctx, r.owner, r.repo, r.config.Spec.GitHub.Branch)
+ if err != nil {
+ return fromError(err, http.StatusBadRequest), nil
+ }
+
+ if !ok {
+ return &provisioning.TestResults{
+ Code: http.StatusBadRequest,
+ Success: false,
+ Errors: []string{"branch does not exist"},
+ }, nil
+ }
+
+ return &provisioning.TestResults{
+ Code: http.StatusOK,
+ Success: true,
+ }, nil
+}
+
+// ReadResource implements provisioning.Repository.
+func (r *githubRepository) Read(ctx context.Context, filePath, ref string) (*FileInfo, error) {
+ if ref == "" {
+ ref = r.config.Spec.GitHub.Branch
+ }
+
+ finalPath := safepath.Join(r.config.Spec.GitHub.Path, filePath)
+ content, dirContent, err := r.gh.GetContents(ctx, r.owner, r.repo, finalPath, ref)
+ if err != nil {
+ if errors.Is(err, pgh.ErrResourceNotFound) {
+ return nil, &apierrors.StatusError{
+ ErrStatus: metav1.Status{
+ Message: fmt.Sprintf("file not found; path=%s ref=%s", finalPath, ref),
+ Code: http.StatusNotFound,
+ },
+ }
+ }
+
+ return nil, fmt.Errorf("get contents: %w", err)
+ }
+ if dirContent != nil {
+ return &FileInfo{
+ Path: filePath,
+ Ref: ref,
+ }, nil
+ }
+
+ data, err := content.GetFileContent()
+ if err != nil {
+ return nil, fmt.Errorf("get content: %w", err)
+ }
+ return &FileInfo{
+ Path: filePath,
+ Ref: ref,
+ Data: []byte(data),
+ Hash: content.GetSHA(),
+ }, nil
+}
+
+func (r *githubRepository) ReadTree(ctx context.Context, ref string) ([]FileTreeEntry, error) {
+ if ref == "" {
+ ref = r.config.Spec.GitHub.Branch
+ }
+
+ ctx, logger := r.logger(ctx, ref)
+
+ tree, truncated, err := r.gh.GetTree(ctx, r.owner, r.repo, r.config.Spec.GitHub.Path, ref, true)
+ if err != nil {
+ if errors.Is(err, pgh.ErrResourceNotFound) {
+ return nil, &apierrors.StatusError{
+ ErrStatus: metav1.Status{
+ Message: fmt.Sprintf("tree not found; ref=%s", ref),
+ Code: http.StatusNotFound,
+ },
+ }
+ }
+ }
+ if truncated {
+ logger.Warn("tree from github was truncated")
+ }
+
+ entries := make([]FileTreeEntry, 0, len(tree))
+ for _, entry := range tree {
+ converted := FileTreeEntry{
+ Path: entry.GetPath(),
+ Size: entry.GetSize(),
+ Hash: entry.GetSHA(),
+ Blob: !entry.IsDirectory(),
+ }
+ entries = append(entries, converted)
+ }
+ return entries, nil
+}
+
+func (r *githubRepository) Create(ctx context.Context, path, ref string, data []byte, comment string) error {
+ if ref == "" {
+ ref = r.config.Spec.GitHub.Branch
+ }
+ ctx, _ = r.logger(ctx, ref)
+
+ if err := r.ensureBranchExists(ctx, ref); err != nil {
+ return fmt.Errorf("create branch on create: %w", err)
+ }
+
+ finalPath := safepath.Join(r.config.Spec.GitHub.Path, path)
+
+ // Create .keep file if it is a directory
+ if safepath.IsDir(finalPath) {
+ if data != nil {
+ return apierrors.NewBadRequest("data cannot be provided for a directory")
+ }
+
+ finalPath = safepath.Join(finalPath, ".keep")
+ data = []byte{}
+ }
+
+ err := r.gh.CreateFile(ctx, r.owner, r.repo, finalPath, ref, comment, data)
+ if errors.Is(err, pgh.ErrResourceAlreadyExists) {
+ return &apierrors.StatusError{
+ ErrStatus: metav1.Status{
+ Message: "file already exists",
+ Code: http.StatusConflict,
+ },
+ }
+ }
+
+ return err
+}
+
+func (r *githubRepository) Update(ctx context.Context, path, ref string, data []byte, comment string) error {
+ if ref == "" {
+ ref = r.config.Spec.GitHub.Branch
+ }
+ ctx, _ = r.logger(ctx, ref)
+
+ if err := r.ensureBranchExists(ctx, ref); err != nil {
+ return fmt.Errorf("create branch on update: %w", err)
+ }
+
+ finalPath := safepath.Join(r.config.Spec.GitHub.Path, path)
+ file, _, err := r.gh.GetContents(ctx, r.owner, r.repo, finalPath, ref)
+ if err != nil {
+ if errors.Is(err, pgh.ErrResourceNotFound) {
+ return &apierrors.StatusError{
+ ErrStatus: metav1.Status{
+ Message: "file not found",
+ Code: http.StatusNotFound,
+ },
+ }
+ }
+
+ return fmt.Errorf("get content before file update: %w", err)
+ }
+ if file.IsDirectory() {
+ return apierrors.NewBadRequest("cannot update a directory")
+ }
+
+ if err := r.gh.UpdateFile(ctx, r.owner, r.repo, finalPath, ref, comment, file.GetSHA(), data); err != nil {
+ return fmt.Errorf("update file: %w", err)
+ }
+ return nil
+}
+
+func (r *githubRepository) Write(ctx context.Context, path string, ref string, data []byte, message string) error {
+ if ref == "" {
+ ref = r.config.Spec.GitHub.Branch
+ }
+ ctx, _ = r.logger(ctx, ref)
+ finalPath := safepath.Join(r.config.Spec.GitHub.Path, path)
+
+ return writeWithReadThenCreateOrUpdate(ctx, r, finalPath, ref, data, message)
+}
+
+func (r *githubRepository) Delete(ctx context.Context, path, ref, comment string) error {
+ if ref == "" {
+ ref = r.config.Spec.GitHub.Branch
+ }
+ ctx, _ = r.logger(ctx, ref)
+
+ if err := r.ensureBranchExists(ctx, ref); err != nil {
+ return fmt.Errorf("create branch on delete: %w", err)
+ }
+
+ finalPath := safepath.Join(r.config.Spec.GitHub.Path, path)
+
+ return r.deleteRecursively(ctx, finalPath, ref, comment)
+}
+
+func (r *githubRepository) deleteRecursively(ctx context.Context, path, ref, comment string) error {
+ finalPath := safepath.Join(r.config.Spec.GitHub.Path, path)
+ file, contents, err := r.gh.GetContents(ctx, r.owner, r.repo, finalPath, ref)
+ if err != nil {
+ if errors.Is(err, pgh.ErrResourceNotFound) {
+ return &apierrors.StatusError{
+ ErrStatus: metav1.Status{
+ Message: "file not found",
+ Code: http.StatusNotFound,
+ },
+ }
+ }
+ return fmt.Errorf("finding file to delete: %w", err)
+ }
+
+ if file != nil && !file.IsDirectory() {
+ return r.gh.DeleteFile(ctx, r.owner, r.repo, finalPath, ref, comment, file.GetSHA())
+ }
+
+ for _, c := range contents {
+ if c.IsDirectory() {
+ if err := r.deleteRecursively(ctx, c.GetPath(), ref, comment); err != nil {
+ return fmt.Errorf("delete file recursive: %w", err)
+ }
+ continue
+ }
+
+ if err := r.gh.DeleteFile(ctx, r.owner, r.repo, c.GetPath(), ref, comment, c.GetSHA()); err != nil {
+ return fmt.Errorf("delete file: %w", err)
+ }
+ }
+
+ return nil
+}
+
+func (r *githubRepository) History(ctx context.Context, path, ref string) ([]provisioning.HistoryItem, error) {
+ if ref == "" {
+ ref = r.config.Spec.GitHub.Branch
+ }
+ ctx, _ = r.logger(ctx, ref)
+
+ finalPath := safepath.Join(r.config.Spec.GitHub.Path, path)
+ commits, err := r.gh.Commits(ctx, r.owner, r.repo, finalPath, ref)
+ if err != nil {
+ if errors.Is(err, pgh.ErrResourceNotFound) {
+ return nil, &apierrors.StatusError{
+ ErrStatus: metav1.Status{
+ Message: "path not found",
+ Code: http.StatusNotFound,
+ },
+ }
+ }
+
+ return nil, fmt.Errorf("get commits: %w", err)
+ }
+
+ ret := make([]provisioning.HistoryItem, 0, len(commits))
+ for _, commit := range commits {
+ authors := make([]provisioning.Author, 0)
+ if commit.Author != nil {
+ authors = append(authors, provisioning.Author{
+ Name: commit.Author.Name,
+ Username: commit.Author.Username,
+ AvatarURL: commit.Author.AvatarURL,
+ })
+ }
+
+ if commit.Committer != nil && commit.Author != nil && commit.Author.Name != commit.Committer.Name {
+ authors = append(authors, provisioning.Author{
+ Name: commit.Committer.Name,
+ Username: commit.Committer.Username,
+ AvatarURL: commit.Committer.AvatarURL,
+ })
+ }
+
+ ret = append(ret, provisioning.HistoryItem{
+ Ref: commit.Ref,
+ Message: commit.Message,
+ Authors: authors,
+ CreatedAt: commit.CreatedAt.UnixMilli(),
+ })
+ }
+
+ return ret, nil
+}
+
+// basicGitBranchNameRegex is a regular expression to validate a git branch name
+// it does not cover all cases as positive lookaheads are not supported in Go's regexp
+var basicGitBranchNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\-\_\/\.]+$`)
+
+// isValidGitBranchName checks if a branch name is valid.
+// It uses the following regexp `^[a-zA-Z0-9\-\_\/\.]+$` to validate the branch name with some additional checks that must satisfy the following rules:
+// 1. The branch name must have at least one character and must not be empty.
+// 2. The branch name cannot start with `/` or end with `/`, `.`, or whitespace.
+// 3. The branch name cannot contain consecutive slashes (`//`).
+// 4. The branch name cannot contain consecutive dots (`..`).
+// 5. The branch name cannot contain `@{`.
+// 6. The branch name cannot include the following characters: `~`, `^`, `:`, `?`, `*`, `[`, `\`, or `]`.
+func isValidGitBranchName(branch string) bool {
+ if !basicGitBranchNameRegex.MatchString(branch) {
+ return false
+ }
+
+ // Additional checks for invalid patterns
+ if strings.HasPrefix(branch, "/") || strings.HasSuffix(branch, "/") ||
+ strings.HasSuffix(branch, ".") || strings.Contains(branch, "..") ||
+ strings.Contains(branch, "//") || strings.HasSuffix(branch, ".lock") {
+ return false
+ }
+
+ return true
+}
+
+func (r *githubRepository) ensureBranchExists(ctx context.Context, branchName string) error {
+ if !isValidGitBranchName(branchName) {
+ return &apierrors.StatusError{
+ ErrStatus: metav1.Status{
+ Code: http.StatusBadRequest,
+ Message: "invalid branch name",
+ },
+ }
+ }
+
+ ok, err := r.gh.BranchExists(ctx, r.owner, r.repo, branchName)
+ if err != nil {
+ return fmt.Errorf("check branch exists: %w", err)
+ }
+
+ if ok {
+ logging.FromContext(ctx).Info("branch already exists", "branch", branchName)
+
+ return nil
+ }
+
+ srcBranch := r.config.Spec.GitHub.Branch
+ if err := r.gh.CreateBranch(ctx, r.owner, r.repo, srcBranch, branchName); err != nil {
+ if errors.Is(err, pgh.ErrResourceAlreadyExists) {
+ return &apierrors.StatusError{
+ ErrStatus: metav1.Status{
+ Code: http.StatusConflict,
+ Message: "branch already exists",
+ },
+ }
+ }
+
+ return fmt.Errorf("create branch: %w", err)
+ }
+
+ return nil
+}
+
+// Webhook implements Repository.
+func (r *githubRepository) Webhook(ctx context.Context, req *http.Request) (*provisioning.WebhookResponse, error) {
+ if r.config.Status.Webhook == nil {
+ return nil, fmt.Errorf("unexpected webhook request")
+ }
+
+ secret, err := r.secrets.Decrypt(ctx, r.config.Status.Webhook.EncryptedSecret)
+ if err != nil {
+ return nil, fmt.Errorf("failed to decrypt secret: %w", err)
+ }
+
+ payload, err := github.ValidatePayload(req, secret)
+ if err != nil {
+ return nil, apierrors.NewUnauthorized("invalid signature")
+ }
+
+ return r.parseWebhook(github.WebHookType(req), payload)
+}
+
+// This method does not include context because it does delegate any more requests
+func (r *githubRepository) parseWebhook(messageType string, payload []byte) (*provisioning.WebhookResponse, error) {
+ event, err := github.ParseWebHook(messageType, payload)
+ if err != nil {
+ return nil, apierrors.NewBadRequest("invalid payload")
+ }
+
+ switch event := event.(type) {
+ case *github.PushEvent:
+ return r.parsePushEvent(event)
+ case *github.PullRequestEvent:
+ return r.parsePullRequestEvent(event)
+ case *github.PingEvent:
+ return &provisioning.WebhookResponse{
+ Code: http.StatusOK,
+ Message: "ping received",
+ }, nil
+ }
+
+ return &provisioning.WebhookResponse{
+ Code: http.StatusNotImplemented,
+ Message: fmt.Sprintf("unsupported messageType: %s", messageType),
+ }, nil
+}
+
+func (r *githubRepository) parsePushEvent(event *github.PushEvent) (*provisioning.WebhookResponse, error) {
+ if event.GetRepo() == nil {
+ return nil, fmt.Errorf("missing repository in push event")
+ }
+ if event.GetRepo().GetFullName() != fmt.Sprintf("%s/%s", r.owner, r.repo) {
+ return nil, fmt.Errorf("repository mismatch")
+ }
+
+ // No need to sync if not enabled
+ if !r.config.Spec.Sync.Enabled {
+ return &provisioning.WebhookResponse{Code: http.StatusOK}, nil
+ }
+
+ // Skip silently if the event is not for the main/master branch
+ // as we cannot configure the webhook to only publish events for the main branch
+ if event.GetRef() != fmt.Sprintf("refs/heads/%s", r.config.Spec.GitHub.Branch) {
+ return &provisioning.WebhookResponse{Code: http.StatusOK}, nil
+ }
+
+ return &provisioning.WebhookResponse{
+ Code: http.StatusAccepted,
+ Job: &provisioning.JobSpec{
+ Repository: r.Config().GetName(),
+ Action: provisioning.JobActionSync,
+ Pull: &provisioning.SyncJobOptions{
+ Incremental: true,
+ },
+ },
+ }, nil
+}
+
+func (r *githubRepository) parsePullRequestEvent(event *github.PullRequestEvent) (*provisioning.WebhookResponse, error) {
+ if event.GetRepo() == nil {
+ return nil, fmt.Errorf("missing repository in pull request event")
+ }
+ cfg := r.config.Spec.GitHub
+ if cfg == nil {
+ return nil, fmt.Errorf("missing github config")
+ }
+
+ if event.GetRepo().GetFullName() != fmt.Sprintf("%s/%s", r.owner, r.repo) {
+ return nil, fmt.Errorf("repository mismatch")
+ }
+ pr := event.GetPullRequest()
+ if pr == nil {
+ return nil, fmt.Errorf("expected PR in event")
+ }
+
+ if pr.GetBase().GetRef() != r.config.Spec.GitHub.Branch {
+ return &provisioning.WebhookResponse{
+ Code: http.StatusOK,
+ Message: fmt.Sprintf("ignoring pull request event as %s is not the configured branch", pr.GetBase().GetRef()),
+ }, nil
+ }
+
+ action := event.GetAction()
+ if action != "opened" && action != "reopened" && action != "synchronize" {
+ return &provisioning.WebhookResponse{
+ Code: http.StatusOK, // Nothing needed
+ Message: fmt.Sprintf("ignore pull request event: %s", action),
+ }, nil
+ }
+
+ // Queue an async job that will parse files
+ return &provisioning.WebhookResponse{
+ Code: http.StatusAccepted, // Nothing needed
+ Message: fmt.Sprintf("pull request: %s", action),
+ Job: &provisioning.JobSpec{
+ Repository: r.Config().GetName(),
+ Action: provisioning.JobActionPullRequest,
+ PullRequest: &provisioning.PullRequestJobOptions{
+ URL: pr.GetHTMLURL(),
+ PR: pr.GetNumber(),
+ Ref: pr.GetHead().GetRef(),
+ Hash: pr.GetHead().GetSHA(),
+ },
+ },
+ }, nil
+}
+
+func (r *githubRepository) LatestRef(ctx context.Context) (string, error) {
+ ctx, _ = r.logger(ctx, "")
+ branch, err := r.gh.GetBranch(ctx, r.owner, r.repo, r.Config().Spec.GitHub.Branch)
+ if err != nil {
+ return "", fmt.Errorf("get branch: %w", err)
+ }
+
+ return branch.Sha, nil
+}
+
+func (r *githubRepository) CompareFiles(ctx context.Context, base, ref string) ([]VersionedFileChange, error) {
+ if ref == "" {
+ var err error
+ ref, err = r.LatestRef(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("get latest ref: %w", err)
+ }
+ }
+ ctx, logger := r.logger(ctx, ref)
+
+ files, err := r.gh.CompareCommits(ctx, r.owner, r.repo, base, ref)
+ if err != nil {
+ return nil, fmt.Errorf("compare commits: %w", err)
+ }
+
+ changes := make([]VersionedFileChange, 0)
+ for _, f := range files {
+ // reference: https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#get-a-commit
+ switch f.GetStatus() {
+ case "added", "copied":
+ changes = append(changes, VersionedFileChange{
+ Path: f.GetFilename(),
+ Ref: ref,
+ Action: FileActionCreated,
+ })
+ case "modified", "changed":
+ changes = append(changes, VersionedFileChange{
+ Path: f.GetFilename(),
+ Ref: ref,
+ Action: FileActionUpdated,
+ })
+ case "renamed":
+ changes = append(changes, VersionedFileChange{
+ Path: f.GetFilename(),
+ PreviousPath: f.GetPreviousFilename(),
+ Ref: ref,
+ PreviousRef: base,
+ Action: FileActionRenamed,
+ })
+ case "removed":
+ changes = append(changes, VersionedFileChange{
+ Ref: base,
+ Path: f.GetFilename(),
+ Action: FileActionDeleted,
+ })
+ case "unchanged":
+ // do nothing
+ default:
+ logger.Error("ignore unhandled file", "file", f.GetFilename(), "status", f.GetStatus())
+ }
+ }
+
+ return changes, nil
+}
+
+// ClearAllPullRequestFileComments clears all comments on a pull request
+func (r *githubRepository) ClearAllPullRequestFileComments(ctx context.Context, prNumber int) error {
+ ctx, _ = r.logger(ctx, "")
+ return r.gh.ClearAllPullRequestFileComments(ctx, r.owner, r.repo, prNumber)
+}
+
+// CommentPullRequest adds a comment to a pull request.
+func (r *githubRepository) CommentPullRequest(ctx context.Context, prNumber int, comment string) error {
+ ctx, _ = r.logger(ctx, "")
+ return r.gh.CreatePullRequestComment(ctx, r.owner, r.repo, prNumber, comment)
+}
+
+// CommentPullRequestFile lints a file and comments the issues found.
+func (r *githubRepository) CommentPullRequestFile(ctx context.Context, prNumber int, path, ref, comment string) error {
+ ctx, _ = r.logger(ctx, ref)
+ fileComment := pgh.FileComment{
+ Content: comment,
+ Path: path,
+ Position: 1, // create a top-level comment
+ Ref: ref,
+ }
+
+ // FIXME: comment with Grafana Logo
+ // FIXME: comment author should be written by Grafana and not the user
+ return r.gh.CreatePullRequestFileComment(ctx, r.owner, r.repo, prNumber, fileComment)
+}
+
+// ResourceURLs implements RepositoryWithURLs.
+func (r *githubRepository) ResourceURLs(ctx context.Context, file *FileInfo) (*provisioning.ResourceURLs, error) {
+ cfg := r.config.Spec.GitHub
+ if file.Path == "" || cfg == nil {
+ return nil, nil
+ }
+
+ ref := file.Ref
+ if ref == "" {
+ ref = cfg.Branch
+ }
+
+ urls := &provisioning.ResourceURLs{
+ RepositoryURL: cfg.URL,
+ SourceURL: fmt.Sprintf("%s/blob/%s/%s", cfg.URL, ref, file.Path),
+ }
+
+ if ref != cfg.Branch {
+ urls.CompareURL = fmt.Sprintf("%s/compare/%s...%s", cfg.URL, cfg.Branch, ref)
+
+ // Create a new pull request
+ urls.NewPullRequestURL = fmt.Sprintf("%s?quick_pull=1&labels=grafana", urls.CompareURL)
+ }
+
+ return urls, nil
+}
+
+func (r *githubRepository) createWebhook(ctx context.Context) (pgh.WebhookConfig, error) {
+ secret, err := uuid.NewRandom()
+ if err != nil {
+ return pgh.WebhookConfig{}, fmt.Errorf("could not generate secret: %w", err)
+ }
+
+ cfg := pgh.WebhookConfig{
+ URL: r.webhookURL,
+ Secret: secret.String(),
+ ContentType: "json",
+ Events: subscribedEvents,
+ Active: true,
+ }
+
+ hook, err := r.gh.CreateWebhook(ctx, r.owner, r.repo, cfg)
+ if err != nil {
+ return pgh.WebhookConfig{}, err
+ }
+
+ // HACK: GitHub does not return the secret, so we need to update it manually
+ hook.Secret = cfg.Secret
+
+ logging.FromContext(ctx).Info("webhook created", "url", cfg.URL, "id", hook.ID)
+ return hook, nil
+}
+
+// updateWebhook checks if the webhook needs to be updated and updates it if necessary.
+// if the webhook does not exist, it will create it.
+func (r *githubRepository) updateWebhook(ctx context.Context) (pgh.WebhookConfig, bool, error) {
+ if r.config.Status.Webhook == nil || r.config.Status.Webhook.ID == 0 {
+ hook, err := r.createWebhook(ctx)
+ if err != nil {
+ return pgh.WebhookConfig{}, false, err
+ }
+ return hook, true, nil
+ }
+
+ hook, err := r.gh.GetWebhook(ctx, r.owner, r.repo, r.config.Status.Webhook.ID)
+ switch {
+ case errors.Is(err, pgh.ErrResourceNotFound):
+ hook, err := r.createWebhook(ctx)
+ if err != nil {
+ return pgh.WebhookConfig{}, false, err
+ }
+ return hook, true, nil
+ case err != nil:
+ return pgh.WebhookConfig{}, false, fmt.Errorf("get webhook: %w", err)
+ }
+
+ hook.Secret = r.config.Status.Webhook.Secret // we always random gen this, so don't use it for mustUpdate below.
+
+ var mustUpdate bool
+
+ if hook.URL != r.config.Status.Webhook.URL {
+ mustUpdate = true
+ hook.URL = r.webhookURL
+ }
+
+ if !slices.Equal(hook.Events, subscribedEvents) {
+ mustUpdate = true
+ hook.Events = subscribedEvents
+ }
+
+ if !mustUpdate {
+ return hook, false, nil
+ }
+
+ // Something has changed in the webhook. Let's rotate the secret as well, so as to ensure we end up with a 100% correct webhook.
+ secret, err := uuid.NewRandom()
+ if err != nil {
+ return pgh.WebhookConfig{}, false, fmt.Errorf("could not generate secret: %w", err)
+ }
+ hook.Secret = secret.String()
+
+ if err := r.gh.EditWebhook(ctx, r.owner, r.repo, hook); err != nil {
+ return pgh.WebhookConfig{}, false, fmt.Errorf("edit webhook: %w", err)
+ }
+
+ return hook, true, nil
+}
+
+func (r *githubRepository) deleteWebhook(ctx context.Context) error {
+ if r.config.Status.Webhook == nil {
+ return fmt.Errorf("webhook not found")
+ }
+
+ id := r.config.Status.Webhook.ID
+
+ if err := r.gh.DeleteWebhook(ctx, r.owner, r.repo, id); err != nil {
+ return fmt.Errorf("delete webhook: %w", err)
+ }
+
+ logging.FromContext(ctx).Info("webhook deleted", "url", r.config.Status.Webhook.URL, "id", id)
+ return nil
+}
+
+func (r *githubRepository) OnCreate(ctx context.Context) (*provisioning.WebhookStatus, error) {
+ if len(r.webhookURL) == 0 {
+ return nil, nil
+ }
+
+ ctx, _ = r.logger(ctx, "")
+ hook, err := r.createWebhook(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return &provisioning.WebhookStatus{
+ ID: hook.ID,
+ URL: hook.URL,
+ Secret: hook.Secret,
+ SubscribedEvents: hook.Events,
+ }, nil
+}
+
+func (r *githubRepository) OnUpdate(ctx context.Context) (*provisioning.WebhookStatus, error) {
+ if len(r.webhookURL) == 0 {
+ return nil, nil
+ }
+ ctx, _ = r.logger(ctx, "")
+ hook, _, err := r.updateWebhook(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ return &provisioning.WebhookStatus{
+ ID: hook.ID,
+ URL: hook.URL,
+ Secret: hook.Secret,
+ SubscribedEvents: hook.Events,
+ }, nil
+}
+
+func (r *githubRepository) OnDelete(ctx context.Context) error {
+ if len(r.webhookURL) == 0 {
+ return nil
+ }
+ ctx, _ = r.logger(ctx, "")
+ return r.deleteWebhook(ctx)
+}
+
+func (r *githubRepository) logger(ctx context.Context, ref string) (context.Context, logging.Logger) {
+ logger := logging.FromContext(ctx)
+
+ type containsGh int
+ var containsGhKey containsGh
+ if ctx.Value(containsGhKey) != nil {
+ return ctx, logging.FromContext(ctx)
+ }
+
+ if ref == "" {
+ ref = r.config.Spec.GitHub.Branch
+ }
+ logger = logger.With(slog.Group("github_repository", "owner", r.owner, "name", r.repo, "ref", ref))
+ ctx = logging.Context(ctx, logger)
+ // We want to ensure we don't add multiple github_repository keys. With doesn't deduplicate the keys...
+ ctx = context.WithValue(ctx, containsGhKey, true)
+ return ctx, logger
+}
diff --git a/pkg/registry/apis/provisioning/repository/github/client.go b/pkg/registry/apis/provisioning/repository/github/client.go
new file mode 100644
index 00000000000..17669d73b77
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/github/client.go
@@ -0,0 +1,182 @@
+// The github package exists to provide a client for the GH API, which can also be faked with a mock.
+// In most cases, we want the real client, but testing should mock it, lest we get blocked from their API, or have to configure auth for simple tests.
+package github
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "github.com/google/go-github/v69/github"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+)
+
+// API errors that we need to convey after parsing real GH errors (or faking them).
+var (
+ ErrResourceAlreadyExists = errors.New("the resource already exists")
+ ErrResourceNotFound = errors.New("the resource does not exist")
+ ErrMismatchedHash = errors.New("the update cannot be applied because the expected and actual hashes are unequal")
+ ErrNoSecret = errors.New("new webhooks must have a secret")
+ //lint:ignore ST1005 this is not punctuation
+ ErrPathTraversalDisallowed = errors.New("the path contained ..") //nolint:stylecheck
+ ErrServiceUnavailable = apierrors.NewServiceUnavailable("github is unavailable")
+ ErrFileTooLarge = errors.New("file exceeds maximum allowed size")
+ ErrTooManyItems = errors.New("maximum number of items exceeded")
+)
+
+// MaxFileSize maximum file size limit (10MB)
+const MaxFileSize = 10 * 1024 * 1024 // 10MB in bytes
+
+type ErrRateLimited = github.RateLimitError
+
+type Client interface {
+ // IsAuthenticated checks if the client is authenticated.
+ IsAuthenticated(ctx context.Context) error
+
+ // GetContents returns the metadata and content of a file or directory.
+ // When a file is checked, the first returned value will have a value. For a directory, the second will. The other value is always nil.
+ // If an error occurs, the returned values may or may not be nil.
+ //
+ // If ".." appears in the "path", this method will return an error.
+ GetContents(ctx context.Context, owner, repository, path, ref string) (fileContents RepositoryContent, dirContents []RepositoryContent, err error)
+
+ // GetTree returns the Git tree in the repository.
+ // When recursive is given, subtrees are mapped into the returned array.
+ // When basePath is given, only trees under it are given. The results do not include this path in their names.
+ //
+ // The truncated bool will be set to true if the tree is larger than 7 MB or 100 000 entries.
+ // When truncated is true, you may wish to read each subtree manually instead.
+ GetTree(ctx context.Context, owner, repository, basePath, ref string, recursive bool) (entries []RepositoryContent, truncated bool, err error)
+
+ // CreateFile creates a new file in the repository under the given path.
+ // The file is created on the branch given.
+ // The message given is the commit message. If none is given, an appropriate default is used.
+ // The content is what the file should contain. An empty slice is valid, though often not very useful.
+ //
+ // If ".." appears in the "path", this method will return an error.
+ CreateFile(ctx context.Context, owner, repository, path, branch, message string, content []byte) error
+
+ // UpdateFile updates a file in the repository under the given path.
+ // The file is updated on the branch given.
+ // The message given is the commit message. If none is given, an appropriate default is used.
+ // The content is what the file should contain. An empty slice is valid, though often not very useful.
+ // If the path does not exist, an error is returned.
+ // The hash given must be the SHA hash of the file contents. Calling GetContents in advance is an easy way of handling this.
+ //
+ // If ".." appears in the "path", this method will return an error.
+ UpdateFile(ctx context.Context, owner, repository, path, branch, message, hash string, content []byte) error
+
+ // DeleteFile deletes a file in the repository under the given path.
+ // The file is deleted from the branch given.
+ // The message given is the commit message. If none is given, an appropriate default is used.
+ // If the path does not exist, an error is returned.
+ // The hash given must be the SHA hash of the file contents. Calling GetContents in advance is an easy way of handling this.
+ //
+ // If ".." appears in the "path", this method will return an error.
+ DeleteFile(ctx context.Context, owner, repository, path, branch, message, hash string) error
+
+ // Commits returns the commits for the given path
+ Commits(ctx context.Context, owner, repository, path, branch string) ([]Commit, error)
+
+ // CompareCommits returns the changes between two commits.
+ CompareCommits(ctx context.Context, owner, repository, base, head string) ([]CommitFile, error)
+
+ // RepoExists checks if a repository exists.
+ RepoExists(ctx context.Context, owner, repository string) (bool, error)
+
+ // CreateBranch creates a new branch in the repository.
+ CreateBranch(ctx context.Context, owner, repository, sourceBranch, branchName string) error
+ // BranchExists checks if a branch exists in the repository.
+ BranchExists(ctx context.Context, owner, repository, branchName string) (bool, error)
+ // GetBranch returns the branch of the repository.
+ GetBranch(ctx context.Context, owner, repository, branchName string) (Branch, error)
+
+ ListWebhooks(ctx context.Context, owner, repository string) ([]WebhookConfig, error)
+ CreateWebhook(ctx context.Context, owner, repository string, cfg WebhookConfig) (WebhookConfig, error)
+ GetWebhook(ctx context.Context, owner, repository string, webhookID int64) (WebhookConfig, error)
+ DeleteWebhook(ctx context.Context, owner, repository string, webhookID int64) error
+ EditWebhook(ctx context.Context, owner, repository string, cfg WebhookConfig) error
+
+ ListPullRequestFiles(ctx context.Context, owner, repository string, number int) ([]CommitFile, error)
+ CreatePullRequestComment(ctx context.Context, owner, repository string, number int, body string) error
+ CreatePullRequestFileComment(ctx context.Context, owner, repository string, number int, comment FileComment) error
+ ClearAllPullRequestFileComments(ctx context.Context, owner, repository string, number int) error
+}
+
+type RepositoryContent interface {
+ // Returns true if this is a directory, false if it is a file.
+ IsDirectory() bool
+ // Returns the contents of the file. Decoding happens if necessary.
+ // Returns an error if the content represents a directory.
+ GetFileContent() (string, error)
+ // Returns true if this is a symlink.
+ // If true, GetPath returns the path where this symlink leads.
+ IsSymlink() bool
+ // Returns the full path from the root of the repository.
+ // This has no leading or trailing slashes.
+ // The path only uses '/' for directories. You can use the 'path' package to interact with these.
+ GetPath() string
+ // Get the SHA hash. This is usually a SHA-256, but may also be SHA-512.
+ // Directories have SHA hashes, too (TODO: how is this calculated?).
+ GetSHA() string
+ // The size of the file. Not necessarily non-zero, even if the file is supposed to be non-zero.
+ GetSize() int64
+}
+
+type Branch struct {
+ Name string
+ Sha string
+}
+
+type CommitAuthor struct {
+ Name string
+ Username string
+ AvatarURL string
+}
+
+type Commit struct {
+ Ref string
+ Message string
+ Author *CommitAuthor
+ Committer *CommitAuthor
+ CreatedAt time.Time
+}
+
+type CommitFile interface {
+ GetSHA() string
+ GetFilename() string
+ GetPreviousFilename() string
+ GetStatus() string
+}
+
+type FileComment struct {
+ Content string
+ Path string
+ Position int
+ Ref string
+}
+
+type CreateFileOptions struct {
+ // The message of the commit. May be empty, in which case a default value is entered.
+ Message string
+ // The content of the file to write, unencoded.
+ Content []byte
+}
+
+type WebhookConfig struct {
+ // The ID of the webhook.
+ // Can be 0 on creation.
+ ID int64
+ // The events which this webhook shall contact the URL for.
+ Events []string
+ // Is the webhook enabled?
+ Active bool
+ // The URL GitHub should contact on events.
+ URL string
+ // The content type GitHub should send to the URL.
+ // If not specified, this is "form".
+ ContentType string
+ // The secret to use when sending events to the URL.
+ // If fetched from GitHub, this is empty as it contains no useful information.
+ Secret string
+}
diff --git a/pkg/registry/apis/provisioning/repository/github/factory.go b/pkg/registry/apis/provisioning/repository/github/factory.go
new file mode 100644
index 00000000000..aea36ce8dcd
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/github/factory.go
@@ -0,0 +1,32 @@
+package github
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/google/go-github/v69/github"
+ "golang.org/x/oauth2"
+)
+
+// Factory creates new GitHub clients.
+// It exists only for the ability to test the code easily.
+type Factory struct {
+ // Client allows overriding the client to use in the GH client returned. It exists primarily for testing.
+ Client *http.Client
+}
+
+func ProvideFactory() *Factory {
+ return &Factory{}
+}
+
+func (r *Factory) New(ctx context.Context, ghToken string) Client {
+ if r.Client != nil {
+ return NewClient(github.NewClient(r.Client))
+ }
+
+ tokenSrc := oauth2.StaticTokenSource(
+ &oauth2.Token{AccessToken: ghToken},
+ )
+ tokenClient := oauth2.NewClient(ctx, tokenSrc)
+ return NewClient(github.NewClient(tokenClient))
+}
diff --git a/pkg/registry/apis/provisioning/repository/github/impl.go b/pkg/registry/apis/provisioning/repository/github/impl.go
new file mode 100644
index 00000000000..190080c3ccd
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/github/impl.go
@@ -0,0 +1,770 @@
+package github
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/google/go-github/v69/github"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+type githubClient struct {
+ gh *github.Client
+}
+
+var _ Client = (*githubClient)(nil)
+
+func NewClient(client *github.Client) *githubClient {
+ return &githubClient{client}
+}
+
+func (r *githubClient) IsAuthenticated(ctx context.Context) error {
+ if _, _, err := r.gh.Users.Get(ctx, ""); err != nil {
+ var ghErr *github.ErrorResponse
+ if errors.As(err, &ghErr) {
+ switch ghErr.Response.StatusCode {
+ case http.StatusUnauthorized:
+ return apierrors.NewUnauthorized("token is invalid or expired")
+ case http.StatusForbidden:
+ return &apierrors.StatusError{
+ ErrStatus: metav1.Status{
+ Status: metav1.StatusFailure,
+ Code: http.StatusUnauthorized,
+ Reason: metav1.StatusReasonUnauthorized,
+ Message: "token is revoked or has insufficient permissions",
+ },
+ }
+ case http.StatusServiceUnavailable:
+ return ErrServiceUnavailable
+ }
+ }
+
+ return err
+ }
+
+ return nil
+}
+
+func (r *githubClient) RepoExists(ctx context.Context, owner, repository string) (bool, error) {
+ _, resp, err := r.gh.Repositories.Get(ctx, owner, repository)
+ if err == nil {
+ return true, nil
+ }
+ if resp.StatusCode == http.StatusNotFound {
+ return false, nil
+ }
+
+ return false, err
+}
+
+const (
+ maxDirectoryItems = 1000 // Maximum number of items allowed in a directory
+ maxTreeItems = 10000 // Maximum number of items allowed in a tree
+ maxCommits = 1000 // Maximum number of commits to fetch
+ maxCompareFiles = 1000 // Maximum number of files to compare between commits
+ maxWebhooks = 100 // Maximum number of webhooks allowed per repository
+ maxPRFiles = 1000 // Maximum number of files allowed in a pull request
+ maxPullRequestsFileComments = 1000 // Maximum number of comments allowed in a pull request
+ maxFileSize = 10 * 1024 * 1024 // 10MB in bytes
+)
+
+func (r *githubClient) GetContents(ctx context.Context, owner, repository, path, ref string) (fileContents RepositoryContent, dirContents []RepositoryContent, err error) {
+ // First try to get repository contents
+ opts := &github.RepositoryContentGetOptions{
+ Ref: ref,
+ }
+
+ fc, dc, _, err := r.gh.Repositories.GetContents(ctx, owner, repository, path, opts)
+ if err != nil {
+ var ghErr *github.ErrorResponse
+ if !errors.As(err, &ghErr) {
+ return nil, nil, err
+ }
+ if ghErr.Response.StatusCode == http.StatusServiceUnavailable {
+ return nil, nil, ErrServiceUnavailable
+ }
+ if ghErr.Response.StatusCode == http.StatusNotFound {
+ return nil, nil, ErrResourceNotFound
+ }
+ return nil, nil, err
+ }
+
+ if fc != nil {
+ // Check file size before returning content
+ if fc.GetSize() > maxFileSize {
+ return nil, nil, ErrFileTooLarge
+ }
+ return realRepositoryContent{fc}, nil, nil
+ }
+
+ // For directories, check size limits
+ if len(dc) > maxDirectoryItems {
+ return nil, nil, fmt.Errorf("directory contains too many items (more than %d)", maxDirectoryItems)
+ }
+
+ // Convert directory contents
+ allContents := make([]RepositoryContent, 0, len(dc))
+ for _, original := range dc {
+ allContents = append(allContents, realRepositoryContent{original})
+ }
+
+ return nil, allContents, nil
+}
+
+func (r *githubClient) GetTree(ctx context.Context, owner, repository, basePath, ref string, recursive bool) ([]RepositoryContent, bool, error) {
+ var tree *github.Tree
+ var err error
+
+ subPaths := safepath.Split(basePath)
+ currentRef := ref
+
+ for {
+ // If subPaths is empty, we can read recursively, as we're reading the tree from the "base" of the repository. Otherwise, always read only the direct children.
+ recursive := recursive && len(subPaths) == 0
+
+ tree, _, err = r.gh.Git.GetTree(ctx, owner, repository, currentRef, recursive)
+ if err != nil {
+ var ghErr *github.ErrorResponse
+ if !errors.As(err, &ghErr) {
+ return nil, false, err
+ }
+ if ghErr.Response.StatusCode == http.StatusServiceUnavailable {
+ return nil, false, ErrServiceUnavailable
+ }
+ if ghErr.Response.StatusCode == http.StatusNotFound {
+ if currentRef != ref {
+ // We're operating with a subpath which doesn't exist yet.
+ // Pretend as if there is simply no files.
+ return nil, false, nil
+ }
+ // currentRef == ref
+ // This indicates the repository or commitish reference doesn't exist. This should always return an error.
+ return nil, false, ErrResourceNotFound
+ }
+ return nil, false, err
+ }
+
+ // Check if we've exceeded the maximum allowed items
+ if len(tree.Entries) > maxTreeItems {
+ return nil, false, fmt.Errorf("tree contains too many items (more than %d)", maxTreeItems)
+ }
+
+ // Prep for next iteration.
+ if len(subPaths) == 0 {
+ // We're done: we've discovered the tree we want.
+ break
+ }
+
+ // the ref must be equal the SHA of the entry corresponding to subPaths[0]
+ currentRef = ""
+ for _, e := range tree.Entries {
+ if e.GetPath() == subPaths[0] {
+ currentRef = e.GetSHA()
+ break
+ }
+ }
+ subPaths = subPaths[1:]
+ if currentRef == "" {
+ // We couldn't find the folder in the tree...
+ return nil, false, nil
+ }
+ }
+
+ // If the tree is truncated and we're in recursive mode, return an error
+ if tree.GetTruncated() && recursive {
+ return nil, true, fmt.Errorf("tree is too large to fetch recursively (more than %d items)", maxTreeItems)
+ }
+
+ entries := make([]RepositoryContent, 0, len(tree.Entries))
+ for _, te := range tree.Entries {
+ rrc := &realRepositoryContent{
+ real: &github.RepositoryContent{
+ Path: te.Path,
+ Size: te.Size,
+ SHA: te.SHA,
+ },
+ }
+ if te.GetType() == "tree" {
+ rrc.real.Type = github.Ptr("dir")
+ } else {
+ rrc.real.Type = te.Type
+ }
+ entries = append(entries, rrc)
+ }
+ return entries, tree.GetTruncated(), nil
+}
+
+func (r *githubClient) CreateFile(ctx context.Context, owner, repository, path, branch, message string, content []byte) error {
+ if message == "" {
+ message = fmt.Sprintf("Create %s", path)
+ }
+
+ _, _, err := r.gh.Repositories.CreateFile(ctx, owner, repository, path, &github.RepositoryContentFileOptions{
+ Branch: &branch,
+ Message: &message,
+ Content: content,
+ })
+ if err == nil {
+ return nil
+ }
+
+ var ghErr *github.ErrorResponse
+ if !errors.As(err, &ghErr) {
+ return err
+ }
+ if ghErr.Response.StatusCode == http.StatusUnprocessableEntity {
+ return ErrResourceAlreadyExists
+ }
+ return err
+}
+
+func (r *githubClient) UpdateFile(ctx context.Context, owner, repository, path, branch, message, hash string, content []byte) error {
+ if message == "" {
+ message = fmt.Sprintf("Update %s", path)
+ }
+
+ _, _, err := r.gh.Repositories.UpdateFile(ctx, owner, repository, path, &github.RepositoryContentFileOptions{
+ Branch: &branch,
+ Message: &message,
+ Content: content,
+ SHA: &hash,
+ })
+ if err == nil {
+ return nil
+ }
+
+ var ghErr *github.ErrorResponse
+ if !errors.As(err, &ghErr) {
+ return err
+ }
+ if ghErr.Response.StatusCode == http.StatusNotFound {
+ return ErrResourceNotFound
+ }
+ if ghErr.Response.StatusCode == http.StatusConflict {
+ return ErrMismatchedHash
+ }
+ if ghErr.Response.StatusCode == http.StatusServiceUnavailable {
+ return ErrServiceUnavailable
+ }
+ return err
+}
+
+func (r *githubClient) DeleteFile(ctx context.Context, owner, repository, path, branch, message, hash string) error {
+ if message == "" {
+ message = fmt.Sprintf("Delete %s", path)
+ }
+
+ _, _, err := r.gh.Repositories.DeleteFile(ctx, owner, repository, path, &github.RepositoryContentFileOptions{
+ Branch: &branch,
+ Message: &message,
+ SHA: &hash,
+ })
+ if err == nil {
+ return nil
+ }
+
+ var ghErr *github.ErrorResponse
+ if !errors.As(err, &ghErr) {
+ return err
+ }
+ if ghErr.Response.StatusCode == http.StatusNotFound {
+ return ErrResourceNotFound
+ }
+ if ghErr.Response.StatusCode == http.StatusConflict {
+ return ErrMismatchedHash
+ }
+ if ghErr.Response.StatusCode == http.StatusServiceUnavailable {
+ return ErrServiceUnavailable
+ }
+ return err
+}
+
+// Commits returns a list of commits for a given repository and branch.
+func (r *githubClient) Commits(ctx context.Context, owner, repository, path, branch string) ([]Commit, error) {
+ listFn := func(ctx context.Context, opts *github.ListOptions) ([]*github.RepositoryCommit, *github.Response, error) {
+ return r.gh.Repositories.ListCommits(ctx, owner, repository, &github.CommitsListOptions{
+ Path: path,
+ SHA: branch,
+ ListOptions: *opts,
+ })
+ }
+
+ commits, err := paginatedList(
+ ctx,
+ listFn,
+ defaultListOptions(maxCommits),
+ )
+ if errors.Is(err, ErrTooManyItems) {
+ return nil, fmt.Errorf("too many commits to fetch (more than %d)", maxCommits)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ ret := make([]Commit, 0, len(commits))
+ for _, c := range commits {
+ var createdAt time.Time
+ var author *CommitAuthor
+ if c.GetCommit().GetAuthor() != nil {
+ author = &CommitAuthor{
+ Name: c.GetCommit().GetAuthor().GetName(),
+ Username: c.GetAuthor().GetLogin(),
+ AvatarURL: c.GetAuthor().GetAvatarURL(),
+ }
+
+ createdAt = c.GetCommit().GetAuthor().GetDate().Time
+ }
+
+ var committer *CommitAuthor
+ if c.GetCommitter() != nil {
+ committer = &CommitAuthor{
+ Name: c.GetCommit().GetCommitter().GetName(),
+ Username: c.GetCommitter().GetLogin(),
+ AvatarURL: c.GetCommitter().GetAvatarURL(),
+ }
+ }
+
+ ret = append(ret, Commit{
+ Ref: c.GetSHA(),
+ Message: c.GetCommit().GetMessage(),
+ Author: author,
+ Committer: committer,
+ CreatedAt: createdAt,
+ })
+ }
+
+ return ret, nil
+}
+
+func (r *githubClient) CompareCommits(ctx context.Context, owner, repository, base, head string) ([]CommitFile, error) {
+ listFn := func(ctx context.Context, opts *github.ListOptions) ([]*github.CommitFile, *github.Response, error) {
+ compare, resp, err := r.gh.Repositories.CompareCommits(ctx, owner, repository, base, head, opts)
+ if err != nil {
+ return nil, resp, err
+ }
+ return compare.Files, resp, nil
+ }
+
+ files, err := paginatedList(
+ ctx,
+ listFn,
+ defaultListOptions(maxCompareFiles),
+ )
+ if errors.Is(err, ErrTooManyItems) {
+ return nil, fmt.Errorf("too many files changed between commits (more than %d)", maxCompareFiles)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ // Convert to the interface type
+ ret := make([]CommitFile, 0, len(files))
+ for _, f := range files {
+ ret = append(ret, f)
+ }
+
+ return ret, nil
+}
+
+func (r *githubClient) GetBranch(ctx context.Context, owner, repository, branchName string) (Branch, error) {
+ branch, _, err := r.gh.Repositories.GetBranch(ctx, owner, repository, branchName, 0)
+ if err != nil {
+ var ghErr *github.ErrorResponse
+ if !errors.As(err, &ghErr) {
+ return Branch{}, err
+ }
+ if ghErr.Response.StatusCode == http.StatusServiceUnavailable {
+ return Branch{}, ErrServiceUnavailable
+ }
+ if ghErr.Response.StatusCode == http.StatusNotFound {
+ return Branch{}, ErrResourceNotFound
+ }
+ return Branch{}, err
+ }
+
+ return Branch{
+ Name: branch.GetName(),
+ Sha: branch.GetCommit().GetSHA(),
+ }, nil
+}
+
+func (r *githubClient) CreateBranch(ctx context.Context, owner, repository, sourceBranch, branchName string) error {
+ // Fail if the branch already exists
+ if _, _, err := r.gh.Repositories.GetBranch(ctx, owner, repository, branchName, 0); err == nil {
+ return ErrResourceAlreadyExists
+ }
+
+ // Branch out based on the repository branch
+ baseRef, _, err := r.gh.Repositories.GetBranch(ctx, owner, repository, sourceBranch, 0)
+ if err != nil {
+ return fmt.Errorf("get base branch: %w", err)
+ }
+
+ if _, _, err := r.gh.Git.CreateRef(ctx, owner, repository, &github.Reference{
+ Ref: github.Ptr(fmt.Sprintf("refs/heads/%s", branchName)),
+ Object: &github.GitObject{
+ SHA: baseRef.Commit.SHA,
+ },
+ }); err != nil {
+ return fmt.Errorf("create branch ref: %w", err)
+ }
+
+ return nil
+}
+
+func (r *githubClient) BranchExists(ctx context.Context, owner, repository, branchName string) (bool, error) {
+ _, resp, err := r.gh.Repositories.GetBranch(ctx, owner, repository, branchName, 0)
+ if err == nil {
+ return true, nil
+ }
+
+ if resp.StatusCode == http.StatusNotFound {
+ return false, nil
+ }
+
+ return false, err
+}
+
+func (r *githubClient) ListWebhooks(ctx context.Context, owner, repository string) ([]WebhookConfig, error) {
+ listFn := func(ctx context.Context, opts *github.ListOptions) ([]*github.Hook, *github.Response, error) {
+ return r.gh.Repositories.ListHooks(ctx, owner, repository, opts)
+ }
+
+ hooks, err := paginatedList(
+ ctx,
+ listFn,
+ defaultListOptions(maxWebhooks),
+ )
+ if errors.Is(err, ErrTooManyItems) {
+ return nil, fmt.Errorf("too many webhooks configured (more than %d)", maxWebhooks)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ // Pre-allocate the result slice
+ ret := make([]WebhookConfig, 0, len(hooks))
+ for _, h := range hooks {
+ contentType := h.GetConfig().GetContentType()
+ if contentType == "" {
+ contentType = "form"
+ }
+
+ ret = append(ret, WebhookConfig{
+ ID: h.GetID(),
+ Events: h.Events,
+ Active: h.GetActive(),
+ URL: h.GetConfig().GetURL(),
+ ContentType: contentType,
+ // Intentionally not setting Secret.
+ })
+ }
+ return ret, nil
+}
+
+func (r *githubClient) CreateWebhook(ctx context.Context, owner, repository string, cfg WebhookConfig) (WebhookConfig, error) {
+ if cfg.ContentType == "" {
+ cfg.ContentType = "form"
+ }
+
+ hook := &github.Hook{
+ URL: &cfg.URL,
+ Events: cfg.Events,
+ Active: &cfg.Active,
+ Config: &github.HookConfig{
+ ContentType: &cfg.ContentType,
+ Secret: &cfg.Secret,
+ URL: &cfg.URL,
+ },
+ }
+
+ createdHook, _, err := r.gh.Repositories.CreateHook(ctx, owner, repository, hook)
+ var ghErr *github.ErrorResponse
+ if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable {
+ return WebhookConfig{}, ErrServiceUnavailable
+ }
+ if err != nil {
+ return WebhookConfig{}, err
+ }
+
+ return WebhookConfig{
+ ID: createdHook.GetID(),
+ // events is not returned by GitHub.
+ Events: cfg.Events,
+ Active: createdHook.GetActive(),
+ URL: createdHook.GetConfig().GetURL(),
+ ContentType: createdHook.GetConfig().GetContentType(),
+ // Secret is not returned by GitHub.
+ Secret: cfg.Secret,
+ }, nil
+}
+
+func (r *githubClient) GetWebhook(ctx context.Context, owner, repository string, webhookID int64) (WebhookConfig, error) {
+ hook, _, err := r.gh.Repositories.GetHook(ctx, owner, repository, webhookID)
+ if err != nil {
+ var ghErr *github.ErrorResponse
+ if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable {
+ return WebhookConfig{}, ErrServiceUnavailable
+ }
+ if ghErr.Response.StatusCode == http.StatusNotFound {
+ return WebhookConfig{}, ErrResourceNotFound
+ }
+ return WebhookConfig{}, err
+ }
+
+ contentType := hook.GetConfig().GetContentType()
+ if contentType == "" {
+ contentType = "json"
+ }
+
+ return WebhookConfig{
+ ID: hook.GetID(),
+ Events: hook.Events,
+ Active: hook.GetActive(),
+ URL: hook.GetConfig().GetURL(),
+ ContentType: contentType,
+ // Intentionally not setting Secret.
+ }, nil
+}
+
+func (r *githubClient) DeleteWebhook(ctx context.Context, owner, repository string, webhookID int64) error {
+ _, err := r.gh.Repositories.DeleteHook(ctx, owner, repository, webhookID)
+ var ghErr *github.ErrorResponse
+ if !errors.As(err, &ghErr) {
+ return err
+ }
+ if ghErr.Response.StatusCode == http.StatusServiceUnavailable {
+ return ErrServiceUnavailable
+ }
+ if ghErr.Response.StatusCode == http.StatusNotFound {
+ return ErrResourceNotFound
+ }
+ return err
+}
+
+func (r *githubClient) EditWebhook(ctx context.Context, owner, repository string, cfg WebhookConfig) error {
+ if cfg.ContentType == "" {
+ cfg.ContentType = "form"
+ }
+
+ hook := &github.Hook{
+ URL: &cfg.URL,
+ Events: cfg.Events,
+ Active: &cfg.Active,
+ Config: &github.HookConfig{
+ ContentType: &cfg.ContentType,
+ Secret: &cfg.Secret,
+ URL: &cfg.URL,
+ },
+ }
+ _, _, err := r.gh.Repositories.EditHook(ctx, owner, repository, cfg.ID, hook)
+ var ghErr *github.ErrorResponse
+ if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable {
+ return ErrServiceUnavailable
+ }
+ return err
+}
+
+func (r *githubClient) ListPullRequestFiles(ctx context.Context, owner, repository string, number int) ([]CommitFile, error) {
+ listFn := func(ctx context.Context, opts *github.ListOptions) ([]*github.CommitFile, *github.Response, error) {
+ return r.gh.PullRequests.ListFiles(ctx, owner, repository, number, opts)
+ }
+
+ files, err := paginatedList(
+ ctx,
+ listFn,
+ defaultListOptions(maxPRFiles),
+ )
+ if errors.Is(err, ErrTooManyItems) {
+ return nil, fmt.Errorf("pull request contains too many files (more than %d)", maxPRFiles)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ // Convert to the interface type
+ ret := make([]CommitFile, 0, len(files))
+ for _, f := range files {
+ ret = append(ret, f)
+ }
+
+ return ret, nil
+}
+
+func (r *githubClient) CreatePullRequestComment(ctx context.Context, owner, repository string, number int, body string) error {
+ comment := &github.IssueComment{
+ Body: &body,
+ }
+
+ if _, _, err := r.gh.Issues.CreateComment(ctx, owner, repository, number, comment); err != nil {
+ var ghErr *github.ErrorResponse
+ if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable {
+ return ErrServiceUnavailable
+ }
+ return err
+ }
+
+ return nil
+}
+
+func (r *githubClient) CreatePullRequestFileComment(ctx context.Context, owner, repository string, number int, comment FileComment) error {
+ commentRequest := &github.PullRequestComment{
+ Body: &comment.Content,
+ CommitID: &comment.Ref,
+ Path: &comment.Path,
+ Position: &comment.Position,
+ }
+
+ if _, _, err := r.gh.PullRequests.CreateComment(ctx, owner, repository, number, commentRequest); err != nil {
+ var ghErr *github.ErrorResponse
+ if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable {
+ return ErrServiceUnavailable
+ }
+
+ return err
+ }
+
+ return nil
+}
+
+func (r *githubClient) ClearAllPullRequestFileComments(ctx context.Context, owner, repository string, number int) error {
+ listFn := func(ctx context.Context, opts *github.ListOptions) ([]*github.PullRequestComment, *github.Response, error) {
+ return r.gh.PullRequests.ListComments(ctx, owner, repository, number, &github.PullRequestListCommentsOptions{
+ ListOptions: *opts,
+ })
+ }
+
+ comments, err := paginatedList(ctx, listFn, defaultListOptions(maxPullRequestsFileComments))
+ if errors.Is(err, ErrTooManyItems) {
+ return fmt.Errorf("too many comments to process (more than %d)", maxPullRequestsFileComments)
+ }
+ if err != nil {
+ return err
+ }
+
+ userLogin, _, err := r.gh.Users.Get(ctx, "")
+ if err != nil {
+ return fmt.Errorf("get user: %w", err)
+ }
+
+ for _, c := range comments {
+ // skip if comments were not created by us
+ if c.User.GetLogin() != userLogin.GetLogin() {
+ continue
+ }
+
+ if _, err := r.gh.PullRequests.DeleteComment(ctx, owner, repository, c.GetID()); err != nil {
+ return fmt.Errorf("delete comment: %w", err)
+ }
+ }
+
+ return nil
+}
+
+type realRepositoryContent struct {
+ real *github.RepositoryContent
+}
+
+var _ RepositoryContent = realRepositoryContent{}
+
+func (c realRepositoryContent) IsDirectory() bool {
+ return c.real.GetType() == "dir"
+}
+
+func (c realRepositoryContent) GetFileContent() (string, error) {
+ return c.real.GetContent()
+}
+
+func (c realRepositoryContent) IsSymlink() bool {
+ return c.real.Target != nil
+}
+
+func (c realRepositoryContent) GetPath() string {
+ return c.real.GetPath()
+}
+
+func (c realRepositoryContent) GetSHA() string {
+ return c.real.GetSHA()
+}
+
+func (c realRepositoryContent) GetSize() int64 {
+ if c.real.Size != nil {
+ return int64(*c.real.Size)
+ }
+ if c.real.Content != nil {
+ if c, err := c.real.GetContent(); err == nil {
+ return int64(len(c))
+ }
+ }
+ return 0
+}
+
+// listOptions represents pagination parameters for list operations
+type listOptions struct {
+ github.ListOptions
+ MaxItems int
+}
+
+// defaultListOptions returns a ListOptions with sensible defaults
+func defaultListOptions(maxItems int) listOptions {
+ return listOptions{
+ ListOptions: github.ListOptions{
+ Page: 1,
+ PerPage: 100,
+ },
+ MaxItems: maxItems,
+ }
+}
+
+// paginatedList is a generic function to handle GitHub API pagination
+func paginatedList[T any](
+ ctx context.Context,
+ listFn func(context.Context, *github.ListOptions) ([]T, *github.Response, error),
+ opts listOptions,
+) ([]T, error) {
+ var allItems []T
+
+ for {
+ items, resp, err := listFn(ctx, &opts.ListOptions)
+ if err != nil {
+ var ghErr *github.ErrorResponse
+ if !errors.As(err, &ghErr) {
+ return nil, err
+ }
+ if ghErr.Response.StatusCode == http.StatusServiceUnavailable {
+ return nil, ErrServiceUnavailable
+ }
+ if ghErr.Response.StatusCode == http.StatusNotFound {
+ return nil, ErrResourceNotFound
+ }
+ return nil, err
+ }
+
+ // Pre-allocate the slice if this is the first page
+ if allItems == nil {
+ allItems = make([]T, 0, len(items)*2) // Estimate double the first page size
+ }
+
+ allItems = append(allItems, items...)
+
+ // Check if we've exceeded the maximum allowed items
+ if len(allItems) > opts.MaxItems {
+ return nil, ErrTooManyItems
+ }
+
+ // If there are no more pages, break
+ if resp.NextPage == 0 {
+ break
+ }
+
+ // Set up next page
+ opts.Page = resp.NextPage
+ }
+
+ return allItems, nil
+}
diff --git a/pkg/registry/apis/provisioning/repository/github/testdata/webhook-issue_comment-created.json b/pkg/registry/apis/provisioning/repository/github/testdata/webhook-issue_comment-created.json
new file mode 100644
index 00000000000..88fef6e3b97
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/github/testdata/webhook-issue_comment-created.json
@@ -0,0 +1,231 @@
+{
+ "action": "created",
+ "issue": {
+ "id": 2726065547,
+ "number": 12,
+ "state": "open",
+ "locked": false,
+ "title": "Webhook test PR",
+ "author_association": "MEMBER",
+ "user": {
+ "login": "ryantxu",
+ "id": 705951,
+ "node_id": "MDQ6VXNlcjcwNTk1MQ==",
+ "avatar_url": "https://avatars.githubusercontent.com/u/705951?v=4",
+ "html_url": "https://github.com/ryantxu",
+ "gravatar_id": "",
+ "type": "User",
+ "site_admin": false,
+ "url": "https://api.github.com/users/ryantxu",
+ "events_url": "https://api.github.com/users/ryantxu/events{/privacy}",
+ "following_url": "https://api.github.com/users/ryantxu/following{/other_user}",
+ "followers_url": "https://api.github.com/users/ryantxu/followers",
+ "gists_url": "https://api.github.com/users/ryantxu/gists{/gist_id}",
+ "organizations_url": "https://api.github.com/users/ryantxu/orgs",
+ "received_events_url": "https://api.github.com/users/ryantxu/received_events",
+ "repos_url": "https://api.github.com/users/ryantxu/repos",
+ "starred_url": "https://api.github.com/users/ryantxu/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/ryantxu/subscriptions"
+ },
+ "comments": 1,
+ "created_at": "2024-12-09T05:53:14Z",
+ "updated_at": "2024-12-09T06:03:21Z",
+ "url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/12",
+ "html_url": "https://github.com/grafana/git-ui-sync-demo/pull/12",
+ "comments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/12/comments",
+ "events_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/12/events",
+ "labels_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/12/labels{/name}",
+ "repository_url": "https://api.github.com/repos/grafana/git-ui-sync-demo",
+ "pull_request": {
+ "url": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls/12",
+ "html_url": "https://github.com/grafana/git-ui-sync-demo/pull/12",
+ "diff_url": "https://github.com/grafana/git-ui-sync-demo/pull/12.diff",
+ "patch_url": "https://github.com/grafana/git-ui-sync-demo/pull/12.patch"
+ },
+ "reactions": {
+ "total_count": 0,
+ "+1": 0,
+ "-1": 0,
+ "laugh": 0,
+ "confused": 0,
+ "heart": 0,
+ "hooray": 0,
+ "rocket": 0,
+ "eyes": 0,
+ "url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/12/reactions"
+ },
+ "node_id": "PR_kwDONO4cS86EfDVR",
+ "draft": false
+ },
+ "comment": {
+ "id": 2527008082,
+ "node_id": "IC_kwDONO4cS86WnxVS",
+ "body": "comment in PR",
+ "user": {
+ "login": "ryantxu",
+ "id": 705951,
+ "node_id": "MDQ6VXNlcjcwNTk1MQ==",
+ "avatar_url": "https://avatars.githubusercontent.com/u/705951?v=4",
+ "html_url": "https://github.com/ryantxu",
+ "gravatar_id": "",
+ "type": "User",
+ "site_admin": false,
+ "url": "https://api.github.com/users/ryantxu",
+ "events_url": "https://api.github.com/users/ryantxu/events{/privacy}",
+ "following_url": "https://api.github.com/users/ryantxu/following{/other_user}",
+ "followers_url": "https://api.github.com/users/ryantxu/followers",
+ "gists_url": "https://api.github.com/users/ryantxu/gists{/gist_id}",
+ "organizations_url": "https://api.github.com/users/ryantxu/orgs",
+ "received_events_url": "https://api.github.com/users/ryantxu/received_events",
+ "repos_url": "https://api.github.com/users/ryantxu/repos",
+ "starred_url": "https://api.github.com/users/ryantxu/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/ryantxu/subscriptions"
+ },
+ "reactions": {
+ "total_count": 0,
+ "+1": 0,
+ "-1": 0,
+ "laugh": 0,
+ "confused": 0,
+ "heart": 0,
+ "hooray": 0,
+ "rocket": 0,
+ "eyes": 0,
+ "url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/comments/2527008082/reactions"
+ },
+ "created_at": "2024-12-09T06:03:19Z",
+ "updated_at": "2024-12-09T06:03:19Z",
+ "author_association": "MEMBER",
+ "url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/comments/2527008082",
+ "html_url": "https://github.com/grafana/git-ui-sync-demo/pull/12#issuecomment-2527008082",
+ "issue_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/12"
+ },
+ "repository": {
+ "id": 888020043,
+ "node_id": "R_kgDONO4cSw",
+ "owner": {
+ "login": "grafana",
+ "id": 7195757,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
+ "avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
+ "html_url": "https://github.com/grafana",
+ "gravatar_id": "",
+ "type": "Organization",
+ "site_admin": false,
+ "url": "https://api.github.com/users/grafana",
+ "events_url": "https://api.github.com/users/grafana/events{/privacy}",
+ "following_url": "https://api.github.com/users/grafana/following{/other_user}",
+ "followers_url": "https://api.github.com/users/grafana/followers",
+ "gists_url": "https://api.github.com/users/grafana/gists{/gist_id}",
+ "organizations_url": "https://api.github.com/users/grafana/orgs",
+ "received_events_url": "https://api.github.com/users/grafana/received_events",
+ "repos_url": "https://api.github.com/users/grafana/repos",
+ "starred_url": "https://api.github.com/users/grafana/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/grafana/subscriptions"
+ },
+ "name": "git-ui-sync-demo",
+ "full_name": "grafana/git-ui-sync-demo",
+ "description": "A repository containing Grafana dashboards to demo the Github Sync feature in Grafana.",
+ "default_branch": "main",
+ "created_at": "2024-11-13T17:13:33Z",
+ "pushed_at": "2024-12-09T05:58:00Z",
+ "updated_at": "2024-12-09T05:58:03Z",
+ "html_url": "https://github.com/grafana/git-ui-sync-demo",
+ "clone_url": "https://github.com/grafana/git-ui-sync-demo.git",
+ "git_url": "git://github.com/grafana/git-ui-sync-demo.git",
+ "ssh_url": "git@github.com:grafana/git-ui-sync-demo.git",
+ "svn_url": "https://github.com/grafana/git-ui-sync-demo",
+ "fork": false,
+ "forks_count": 0,
+ "open_issues_count": 9,
+ "open_issues": 9,
+ "stargazers_count": 0,
+ "watchers_count": 0,
+ "watchers": 0,
+ "size": 141,
+ "allow_forking": false,
+ "web_commit_signoff_required": false,
+ "archived": false,
+ "disabled": false,
+ "private": true,
+ "has_issues": true,
+ "has_wiki": true,
+ "has_pages": false,
+ "has_projects": true,
+ "has_downloads": true,
+ "has_discussions": false,
+ "is_template": false,
+ "url": "https://api.github.com/repos/grafana/git-ui-sync-demo",
+ "archive_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/{archive_format}{/ref}",
+ "assignees_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/assignees{/user}",
+ "blobs_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/blobs{/sha}",
+ "branches_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/branches{/branch}",
+ "collaborators_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/collaborators{/collaborator}",
+ "comments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/comments{/number}",
+ "commits_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/commits{/sha}",
+ "compare_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/compare/{base}...{head}",
+ "contents_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/contents/{+path}",
+ "contributors_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/contributors",
+ "deployments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/deployments",
+ "downloads_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/downloads",
+ "events_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/events",
+ "forks_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/forks",
+ "git_commits_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/commits{/sha}",
+ "git_refs_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/refs{/sha}",
+ "git_tags_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/tags{/sha}",
+ "hooks_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/hooks",
+ "issue_comment_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/comments{/number}",
+ "issue_events_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/events{/number}",
+ "issues_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues{/number}",
+ "keys_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/keys{/key_id}",
+ "labels_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/labels{/name}",
+ "languages_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/languages",
+ "merges_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/merges",
+ "milestones_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/milestones{/number}",
+ "notifications_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/notifications{?since,all,participating}",
+ "pulls_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls{/number}",
+ "releases_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/releases{/id}",
+ "stargazers_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/stargazers",
+ "statuses_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/statuses/{sha}",
+ "subscribers_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/subscribers",
+ "subscription_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/subscription",
+ "tags_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/tags",
+ "trees_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/trees{/sha}",
+ "teams_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/teams",
+ "visibility": "internal"
+ },
+ "sender": {
+ "login": "ryantxu",
+ "id": 705951,
+ "node_id": "MDQ6VXNlcjcwNTk1MQ==",
+ "avatar_url": "https://avatars.githubusercontent.com/u/705951?v=4",
+ "html_url": "https://github.com/ryantxu",
+ "gravatar_id": "",
+ "type": "User",
+ "site_admin": false,
+ "url": "https://api.github.com/users/ryantxu",
+ "events_url": "https://api.github.com/users/ryantxu/events{/privacy}",
+ "following_url": "https://api.github.com/users/ryantxu/following{/other_user}",
+ "followers_url": "https://api.github.com/users/ryantxu/followers",
+ "gists_url": "https://api.github.com/users/ryantxu/gists{/gist_id}",
+ "organizations_url": "https://api.github.com/users/ryantxu/orgs",
+ "received_events_url": "https://api.github.com/users/ryantxu/received_events",
+ "repos_url": "https://api.github.com/users/ryantxu/repos",
+ "starred_url": "https://api.github.com/users/ryantxu/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/ryantxu/subscriptions"
+ },
+ "organization": {
+ "login": "grafana",
+ "id": 7195757,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
+ "avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
+ "description": "Grafana Labs is behind leading open source projects Grafana and Loki, and the creator of the first open \u0026 composable observability platform.",
+ "url": "https://api.github.com/orgs/grafana",
+ "events_url": "https://api.github.com/orgs/grafana/events",
+ "hooks_url": "https://api.github.com/orgs/grafana/hooks",
+ "issues_url": "https://api.github.com/orgs/grafana/issues",
+ "members_url": "https://api.github.com/orgs/grafana/members{/member}",
+ "public_members_url": "https://api.github.com/orgs/grafana/public_members{/member}",
+ "repos_url": "https://api.github.com/orgs/grafana/repos"
+ }
+}
\ No newline at end of file
diff --git a/pkg/registry/apis/provisioning/repository/github/testdata/webhook-ping-check.json b/pkg/registry/apis/provisioning/repository/github/testdata/webhook-ping-check.json
new file mode 100644
index 00000000000..65db1848748
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/github/testdata/webhook-ping-check.json
@@ -0,0 +1,143 @@
+{
+ "zen": "Keep it logically awesome.",
+ "hook_id": 517704995,
+ "hook": {
+ "created_at": "2024-12-09T05:44:20Z",
+ "updated_at": "2024-12-09T05:44:20Z",
+ "url": "https://api.github.com/repos/grafana/git-ui-sync-demo/hooks/517704995",
+ "id": 517704995,
+ "type": "Repository",
+ "name": "web",
+ "test_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/hooks/517704995/test",
+ "ping_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/hooks/517704995/pings",
+ "last_response": {
+ "code": null,
+ "message": null,
+ "status": "unused"
+ },
+ "config": {
+ "content_type": "form",
+ "insecure_ssl": "0",
+ "url": "https://0b71-216-128-0-90.ngrok-free.app/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/github-example-ryan/webhook",
+ "secret": "********"
+ },
+ "events": [
+ "*"
+ ],
+ "active": true
+ },
+ "repository": {
+ "id": 888020043,
+ "node_id": "R_kgDONO4cSw",
+ "owner": {
+ "login": "grafana",
+ "id": 7195757,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
+ "avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
+ "html_url": "https://github.com/grafana",
+ "gravatar_id": "",
+ "type": "Organization",
+ "site_admin": false,
+ "url": "https://api.github.com/users/grafana",
+ "events_url": "https://api.github.com/users/grafana/events{/privacy}",
+ "following_url": "https://api.github.com/users/grafana/following{/other_user}",
+ "followers_url": "https://api.github.com/users/grafana/followers",
+ "gists_url": "https://api.github.com/users/grafana/gists{/gist_id}",
+ "organizations_url": "https://api.github.com/users/grafana/orgs",
+ "received_events_url": "https://api.github.com/users/grafana/received_events",
+ "repos_url": "https://api.github.com/users/grafana/repos",
+ "starred_url": "https://api.github.com/users/grafana/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/grafana/subscriptions"
+ },
+ "name": "git-ui-sync-demo",
+ "full_name": "grafana/git-ui-sync-demo",
+ "description": "A repository containing Grafana dashboards to demo the Github Sync feature in Grafana.",
+ "default_branch": "main",
+ "created_at": "2024-11-13T17:13:33Z",
+ "pushed_at": "2024-12-08T10:51:24Z",
+ "updated_at": "2024-11-28T12:53:26Z",
+ "html_url": "https://github.com/grafana/git-ui-sync-demo",
+ "clone_url": "https://github.com/grafana/git-ui-sync-demo.git",
+ "git_url": "git://github.com/grafana/git-ui-sync-demo.git",
+ "ssh_url": "git@github.com:grafana/git-ui-sync-demo.git",
+ "svn_url": "https://github.com/grafana/git-ui-sync-demo",
+ "fork": false,
+ "forks_count": 0,
+ "open_issues_count": 8,
+ "open_issues": 8,
+ "stargazers_count": 0,
+ "watchers_count": 0,
+ "watchers": 0,
+ "size": 141,
+ "allow_forking": false,
+ "web_commit_signoff_required": false,
+ "archived": false,
+ "disabled": false,
+ "private": true,
+ "has_issues": true,
+ "has_wiki": true,
+ "has_pages": false,
+ "has_projects": true,
+ "has_downloads": true,
+ "has_discussions": false,
+ "is_template": false,
+ "url": "https://api.github.com/repos/grafana/git-ui-sync-demo",
+ "archive_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/{archive_format}{/ref}",
+ "assignees_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/assignees{/user}",
+ "blobs_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/blobs{/sha}",
+ "branches_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/branches{/branch}",
+ "collaborators_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/collaborators{/collaborator}",
+ "comments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/comments{/number}",
+ "commits_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/commits{/sha}",
+ "compare_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/compare/{base}...{head}",
+ "contents_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/contents/{+path}",
+ "contributors_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/contributors",
+ "deployments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/deployments",
+ "downloads_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/downloads",
+ "events_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/events",
+ "forks_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/forks",
+ "git_commits_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/commits{/sha}",
+ "git_refs_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/refs{/sha}",
+ "git_tags_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/tags{/sha}",
+ "hooks_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/hooks",
+ "issue_comment_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/comments{/number}",
+ "issue_events_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/events{/number}",
+ "issues_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues{/number}",
+ "keys_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/keys{/key_id}",
+ "labels_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/labels{/name}",
+ "languages_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/languages",
+ "merges_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/merges",
+ "milestones_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/milestones{/number}",
+ "notifications_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/notifications{?since,all,participating}",
+ "pulls_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls{/number}",
+ "releases_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/releases{/id}",
+ "stargazers_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/stargazers",
+ "statuses_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/statuses/{sha}",
+ "subscribers_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/subscribers",
+ "subscription_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/subscription",
+ "tags_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/tags",
+ "trees_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/trees{/sha}",
+ "teams_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/teams",
+ "visibility": "internal"
+ },
+ "sender": {
+ "login": "ryantxu",
+ "id": 705951,
+ "node_id": "MDQ6VXNlcjcwNTk1MQ==",
+ "avatar_url": "https://avatars.githubusercontent.com/u/705951?v=4",
+ "html_url": "https://github.com/ryantxu",
+ "gravatar_id": "",
+ "type": "User",
+ "site_admin": false,
+ "url": "https://api.github.com/users/ryantxu",
+ "events_url": "https://api.github.com/users/ryantxu/events{/privacy}",
+ "following_url": "https://api.github.com/users/ryantxu/following{/other_user}",
+ "followers_url": "https://api.github.com/users/ryantxu/followers",
+ "gists_url": "https://api.github.com/users/ryantxu/gists{/gist_id}",
+ "organizations_url": "https://api.github.com/users/ryantxu/orgs",
+ "received_events_url": "https://api.github.com/users/ryantxu/received_events",
+ "repos_url": "https://api.github.com/users/ryantxu/repos",
+ "starred_url": "https://api.github.com/users/ryantxu/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/ryantxu/subscriptions"
+ }
+}
\ No newline at end of file
diff --git a/pkg/registry/apis/provisioning/repository/github/testdata/webhook-pull_request-opened.json b/pkg/registry/apis/provisioning/repository/github/testdata/webhook-pull_request-opened.json
new file mode 100644
index 00000000000..4963dce6eee
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/github/testdata/webhook-pull_request-opened.json
@@ -0,0 +1,470 @@
+
+{
+ "action": "opened",
+ "number": 12,
+ "pull_request": {
+ "id": 2222732625,
+ "number": 12,
+ "state": "open",
+ "locked": false,
+ "title": "Webhook test PR",
+ "created_at": "2024-12-09T05:53:14Z",
+ "updated_at": "2024-12-09T05:53:14Z",
+ "user": {
+ "login": "ryantxu",
+ "id": 705951,
+ "node_id": "MDQ6VXNlcjcwNTk1MQ==",
+ "avatar_url": "https://avatars.githubusercontent.com/u/705951?v=4",
+ "html_url": "https://github.com/ryantxu",
+ "gravatar_id": "",
+ "type": "User",
+ "site_admin": false,
+ "url": "https://api.github.com/users/ryantxu",
+ "events_url": "https://api.github.com/users/ryantxu/events{/privacy}",
+ "following_url": "https://api.github.com/users/ryantxu/following{/other_user}",
+ "followers_url": "https://api.github.com/users/ryantxu/followers",
+ "gists_url": "https://api.github.com/users/ryantxu/gists{/gist_id}",
+ "organizations_url": "https://api.github.com/users/ryantxu/orgs",
+ "received_events_url": "https://api.github.com/users/ryantxu/received_events",
+ "repos_url": "https://api.github.com/users/ryantxu/repos",
+ "starred_url": "https://api.github.com/users/ryantxu/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/ryantxu/subscriptions"
+ },
+ "draft": false,
+ "merged": false,
+ "mergeable_state": "unknown",
+ "comments": 0,
+ "commits": 1,
+ "additions": 1,
+ "deletions": 0,
+ "changed_files": 1,
+ "url": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls/12",
+ "html_url": "https://github.com/grafana/git-ui-sync-demo/pull/12",
+ "issue_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/12",
+ "statuses_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/statuses/ab5446a53df9e5f8bdeed52250f51fad08e822bc",
+ "diff_url": "https://github.com/grafana/git-ui-sync-demo/pull/12.diff",
+ "patch_url": "https://github.com/grafana/git-ui-sync-demo/pull/12.patch",
+ "commits_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls/12/commits",
+ "comments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/12/comments",
+ "review_comments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls/12/comments",
+ "review_comment_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls/comments{/number}",
+ "review_comments": 0,
+ "maintainer_can_modify": false,
+ "author_association": "MEMBER",
+ "node_id": "PR_kwDONO4cS86EfDVR",
+ "_links": {
+ "self": {
+ "href": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls/12"
+ },
+ "html": {
+ "href": "https://github.com/grafana/git-ui-sync-demo/pull/12"
+ },
+ "issue": {
+ "href": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/12"
+ },
+ "comments": {
+ "href": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/12/comments"
+ },
+ "review_comments": {
+ "href": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls/12/comments"
+ },
+ "review_comment": {
+ "href": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls/comments{/number}"
+ },
+ "commits": {
+ "href": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls/12/commits"
+ },
+ "statuses": {
+ "href": "https://api.github.com/repos/grafana/git-ui-sync-demo/statuses/ab5446a53df9e5f8bdeed52250f51fad08e822bc"
+ }
+ },
+ "head": {
+ "label": "grafana:dashboard/1733653266690",
+ "ref": "dashboard/1733653266690",
+ "sha": "ab5446a53df9e5f8bdeed52250f51fad08e822bc",
+ "repo": {
+ "id": 888020043,
+ "node_id": "R_kgDONO4cSw",
+ "owner": {
+ "login": "grafana",
+ "id": 7195757,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
+ "avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
+ "html_url": "https://github.com/grafana",
+ "gravatar_id": "",
+ "type": "Organization",
+ "site_admin": false,
+ "url": "https://api.github.com/users/grafana",
+ "events_url": "https://api.github.com/users/grafana/events{/privacy}",
+ "following_url": "https://api.github.com/users/grafana/following{/other_user}",
+ "followers_url": "https://api.github.com/users/grafana/followers",
+ "gists_url": "https://api.github.com/users/grafana/gists{/gist_id}",
+ "organizations_url": "https://api.github.com/users/grafana/orgs",
+ "received_events_url": "https://api.github.com/users/grafana/received_events",
+ "repos_url": "https://api.github.com/users/grafana/repos",
+ "starred_url": "https://api.github.com/users/grafana/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/grafana/subscriptions"
+ },
+ "name": "git-ui-sync-demo",
+ "full_name": "grafana/git-ui-sync-demo",
+ "description": "A repository containing Grafana dashboards to demo the Github Sync feature in Grafana.",
+ "default_branch": "main",
+ "created_at": "2024-11-13T17:13:33Z",
+ "pushed_at": "2024-12-08T10:51:24Z",
+ "updated_at": "2024-11-28T12:53:26Z",
+ "html_url": "https://github.com/grafana/git-ui-sync-demo",
+ "clone_url": "https://github.com/grafana/git-ui-sync-demo.git",
+ "git_url": "git://github.com/grafana/git-ui-sync-demo.git",
+ "ssh_url": "git@github.com:grafana/git-ui-sync-demo.git",
+ "svn_url": "https://github.com/grafana/git-ui-sync-demo",
+ "fork": false,
+ "forks_count": 0,
+ "open_issues_count": 9,
+ "open_issues": 9,
+ "stargazers_count": 0,
+ "watchers_count": 0,
+ "watchers": 0,
+ "size": 141,
+ "allow_rebase_merge": true,
+ "allow_update_branch": false,
+ "allow_squash_merge": true,
+ "allow_merge_commit": true,
+ "allow_auto_merge": false,
+ "allow_forking": false,
+ "web_commit_signoff_required": false,
+ "delete_branch_on_merge": false,
+ "use_squash_pr_title_as_default": false,
+ "squash_merge_commit_title": "COMMIT_OR_PR_TITLE",
+ "squash_merge_commit_message": "COMMIT_MESSAGES",
+ "merge_commit_title": "MERGE_MESSAGE",
+ "merge_commit_message": "PR_TITLE",
+ "archived": false,
+ "disabled": false,
+ "private": true,
+ "has_issues": true,
+ "has_wiki": true,
+ "has_pages": false,
+ "has_projects": true,
+ "has_downloads": true,
+ "has_discussions": false,
+ "is_template": false,
+ "url": "https://api.github.com/repos/grafana/git-ui-sync-demo",
+ "archive_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/{archive_format}{/ref}",
+ "assignees_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/assignees{/user}",
+ "blobs_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/blobs{/sha}",
+ "branches_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/branches{/branch}",
+ "collaborators_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/collaborators{/collaborator}",
+ "comments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/comments{/number}",
+ "commits_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/commits{/sha}",
+ "compare_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/compare/{base}...{head}",
+ "contents_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/contents/{+path}",
+ "contributors_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/contributors",
+ "deployments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/deployments",
+ "downloads_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/downloads",
+ "events_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/events",
+ "forks_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/forks",
+ "git_commits_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/commits{/sha}",
+ "git_refs_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/refs{/sha}",
+ "git_tags_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/tags{/sha}",
+ "hooks_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/hooks",
+ "issue_comment_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/comments{/number}",
+ "issue_events_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/events{/number}",
+ "issues_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues{/number}",
+ "keys_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/keys{/key_id}",
+ "labels_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/labels{/name}",
+ "languages_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/languages",
+ "merges_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/merges",
+ "milestones_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/milestones{/number}",
+ "notifications_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/notifications{?since,all,participating}",
+ "pulls_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls{/number}",
+ "releases_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/releases{/id}",
+ "stargazers_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/stargazers",
+ "statuses_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/statuses/{sha}",
+ "subscribers_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/subscribers",
+ "subscription_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/subscription",
+ "tags_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/tags",
+ "trees_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/trees{/sha}",
+ "teams_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/teams",
+ "visibility": "internal"
+ },
+ "user": {
+ "login": "grafana",
+ "id": 7195757,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
+ "avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
+ "html_url": "https://github.com/grafana",
+ "gravatar_id": "",
+ "type": "Organization",
+ "site_admin": false,
+ "url": "https://api.github.com/users/grafana",
+ "events_url": "https://api.github.com/users/grafana/events{/privacy}",
+ "following_url": "https://api.github.com/users/grafana/following{/other_user}",
+ "followers_url": "https://api.github.com/users/grafana/followers",
+ "gists_url": "https://api.github.com/users/grafana/gists{/gist_id}",
+ "organizations_url": "https://api.github.com/users/grafana/orgs",
+ "received_events_url": "https://api.github.com/users/grafana/received_events",
+ "repos_url": "https://api.github.com/users/grafana/repos",
+ "starred_url": "https://api.github.com/users/grafana/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/grafana/subscriptions"
+ }
+ },
+ "base": {
+ "label": "grafana:main",
+ "ref": "main",
+ "sha": "6c86a0cdfd220c2fe3518cfaa4a4babf030d9a7a",
+ "repo": {
+ "id": 888020043,
+ "node_id": "R_kgDONO4cSw",
+ "owner": {
+ "login": "grafana",
+ "id": 7195757,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
+ "avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
+ "html_url": "https://github.com/grafana",
+ "gravatar_id": "",
+ "type": "Organization",
+ "site_admin": false,
+ "url": "https://api.github.com/users/grafana",
+ "events_url": "https://api.github.com/users/grafana/events{/privacy}",
+ "following_url": "https://api.github.com/users/grafana/following{/other_user}",
+ "followers_url": "https://api.github.com/users/grafana/followers",
+ "gists_url": "https://api.github.com/users/grafana/gists{/gist_id}",
+ "organizations_url": "https://api.github.com/users/grafana/orgs",
+ "received_events_url": "https://api.github.com/users/grafana/received_events",
+ "repos_url": "https://api.github.com/users/grafana/repos",
+ "starred_url": "https://api.github.com/users/grafana/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/grafana/subscriptions"
+ },
+ "name": "git-ui-sync-demo",
+ "full_name": "grafana/git-ui-sync-demo",
+ "description": "A repository containing Grafana dashboards to demo the Github Sync feature in Grafana.",
+ "default_branch": "main",
+ "created_at": "2024-11-13T17:13:33Z",
+ "pushed_at": "2024-12-08T10:51:24Z",
+ "updated_at": "2024-11-28T12:53:26Z",
+ "html_url": "https://github.com/grafana/git-ui-sync-demo",
+ "clone_url": "https://github.com/grafana/git-ui-sync-demo.git",
+ "git_url": "git://github.com/grafana/git-ui-sync-demo.git",
+ "ssh_url": "git@github.com:grafana/git-ui-sync-demo.git",
+ "svn_url": "https://github.com/grafana/git-ui-sync-demo",
+ "fork": false,
+ "forks_count": 0,
+ "open_issues_count": 9,
+ "open_issues": 9,
+ "stargazers_count": 0,
+ "watchers_count": 0,
+ "watchers": 0,
+ "size": 141,
+ "allow_rebase_merge": true,
+ "allow_update_branch": false,
+ "allow_squash_merge": true,
+ "allow_merge_commit": true,
+ "allow_auto_merge": false,
+ "allow_forking": false,
+ "web_commit_signoff_required": false,
+ "delete_branch_on_merge": false,
+ "use_squash_pr_title_as_default": false,
+ "squash_merge_commit_title": "COMMIT_OR_PR_TITLE",
+ "squash_merge_commit_message": "COMMIT_MESSAGES",
+ "merge_commit_title": "MERGE_MESSAGE",
+ "merge_commit_message": "PR_TITLE",
+ "archived": false,
+ "disabled": false,
+ "private": true,
+ "has_issues": true,
+ "has_wiki": true,
+ "has_pages": false,
+ "has_projects": true,
+ "has_downloads": true,
+ "has_discussions": false,
+ "is_template": false,
+ "url": "https://api.github.com/repos/grafana/git-ui-sync-demo",
+ "archive_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/{archive_format}{/ref}",
+ "assignees_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/assignees{/user}",
+ "blobs_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/blobs{/sha}",
+ "branches_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/branches{/branch}",
+ "collaborators_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/collaborators{/collaborator}",
+ "comments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/comments{/number}",
+ "commits_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/commits{/sha}",
+ "compare_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/compare/{base}...{head}",
+ "contents_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/contents/{+path}",
+ "contributors_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/contributors",
+ "deployments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/deployments",
+ "downloads_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/downloads",
+ "events_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/events",
+ "forks_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/forks",
+ "git_commits_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/commits{/sha}",
+ "git_refs_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/refs{/sha}",
+ "git_tags_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/tags{/sha}",
+ "hooks_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/hooks",
+ "issue_comment_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/comments{/number}",
+ "issue_events_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/events{/number}",
+ "issues_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues{/number}",
+ "keys_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/keys{/key_id}",
+ "labels_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/labels{/name}",
+ "languages_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/languages",
+ "merges_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/merges",
+ "milestones_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/milestones{/number}",
+ "notifications_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/notifications{?since,all,participating}",
+ "pulls_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls{/number}",
+ "releases_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/releases{/id}",
+ "stargazers_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/stargazers",
+ "statuses_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/statuses/{sha}",
+ "subscribers_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/subscribers",
+ "subscription_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/subscription",
+ "tags_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/tags",
+ "trees_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/trees{/sha}",
+ "teams_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/teams",
+ "visibility": "internal"
+ },
+ "user": {
+ "login": "grafana",
+ "id": 7195757,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
+ "avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
+ "html_url": "https://github.com/grafana",
+ "gravatar_id": "",
+ "type": "Organization",
+ "site_admin": false,
+ "url": "https://api.github.com/users/grafana",
+ "events_url": "https://api.github.com/users/grafana/events{/privacy}",
+ "following_url": "https://api.github.com/users/grafana/following{/other_user}",
+ "followers_url": "https://api.github.com/users/grafana/followers",
+ "gists_url": "https://api.github.com/users/grafana/gists{/gist_id}",
+ "organizations_url": "https://api.github.com/users/grafana/orgs",
+ "received_events_url": "https://api.github.com/users/grafana/received_events",
+ "repos_url": "https://api.github.com/users/grafana/repos",
+ "starred_url": "https://api.github.com/users/grafana/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/grafana/subscriptions"
+ }
+ }
+ },
+ "repository": {
+ "id": 888020043,
+ "node_id": "R_kgDONO4cSw",
+ "owner": {
+ "login": "grafana",
+ "id": 7195757,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
+ "avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
+ "html_url": "https://github.com/grafana",
+ "gravatar_id": "",
+ "type": "Organization",
+ "site_admin": false,
+ "url": "https://api.github.com/users/grafana",
+ "events_url": "https://api.github.com/users/grafana/events{/privacy}",
+ "following_url": "https://api.github.com/users/grafana/following{/other_user}",
+ "followers_url": "https://api.github.com/users/grafana/followers",
+ "gists_url": "https://api.github.com/users/grafana/gists{/gist_id}",
+ "organizations_url": "https://api.github.com/users/grafana/orgs",
+ "received_events_url": "https://api.github.com/users/grafana/received_events",
+ "repos_url": "https://api.github.com/users/grafana/repos",
+ "starred_url": "https://api.github.com/users/grafana/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/grafana/subscriptions"
+ },
+ "name": "git-ui-sync-demo",
+ "full_name": "grafana/git-ui-sync-demo",
+ "description": "A repository containing Grafana dashboards to demo the Github Sync feature in Grafana.",
+ "default_branch": "main",
+ "created_at": "2024-11-13T17:13:33Z",
+ "pushed_at": "2024-12-08T10:51:24Z",
+ "updated_at": "2024-11-28T12:53:26Z",
+ "html_url": "https://github.com/grafana/git-ui-sync-demo",
+ "clone_url": "https://github.com/grafana/git-ui-sync-demo.git",
+ "git_url": "git://github.com/grafana/git-ui-sync-demo.git",
+ "ssh_url": "git@github.com:grafana/git-ui-sync-demo.git",
+ "svn_url": "https://github.com/grafana/git-ui-sync-demo",
+ "fork": false,
+ "forks_count": 0,
+ "open_issues_count": 9,
+ "open_issues": 9,
+ "stargazers_count": 0,
+ "watchers_count": 0,
+ "watchers": 0,
+ "size": 141,
+ "allow_forking": false,
+ "web_commit_signoff_required": false,
+ "archived": false,
+ "disabled": false,
+ "private": true,
+ "has_issues": true,
+ "has_wiki": true,
+ "has_pages": false,
+ "has_projects": true,
+ "has_downloads": true,
+ "has_discussions": false,
+ "is_template": false,
+ "url": "https://api.github.com/repos/grafana/git-ui-sync-demo",
+ "archive_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/{archive_format}{/ref}",
+ "assignees_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/assignees{/user}",
+ "blobs_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/blobs{/sha}",
+ "branches_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/branches{/branch}",
+ "collaborators_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/collaborators{/collaborator}",
+ "comments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/comments{/number}",
+ "commits_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/commits{/sha}",
+ "compare_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/compare/{base}...{head}",
+ "contents_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/contents/{+path}",
+ "contributors_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/contributors",
+ "deployments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/deployments",
+ "downloads_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/downloads",
+ "events_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/events",
+ "forks_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/forks",
+ "git_commits_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/commits{/sha}",
+ "git_refs_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/refs{/sha}",
+ "git_tags_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/tags{/sha}",
+ "hooks_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/hooks",
+ "issue_comment_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/comments{/number}",
+ "issue_events_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/events{/number}",
+ "issues_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues{/number}",
+ "keys_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/keys{/key_id}",
+ "labels_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/labels{/name}",
+ "languages_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/languages",
+ "merges_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/merges",
+ "milestones_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/milestones{/number}",
+ "notifications_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/notifications{?since,all,participating}",
+ "pulls_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls{/number}",
+ "releases_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/releases{/id}",
+ "stargazers_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/stargazers",
+ "statuses_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/statuses/{sha}",
+ "subscribers_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/subscribers",
+ "subscription_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/subscription",
+ "tags_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/tags",
+ "trees_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/trees{/sha}",
+ "teams_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/teams",
+ "visibility": "internal"
+ },
+ "sender": {
+ "login": "ryantxu",
+ "id": 705951,
+ "node_id": "MDQ6VXNlcjcwNTk1MQ==",
+ "avatar_url": "https://avatars.githubusercontent.com/u/705951?v=4",
+ "html_url": "https://github.com/ryantxu",
+ "gravatar_id": "",
+ "type": "User",
+ "site_admin": false,
+ "url": "https://api.github.com/users/ryantxu",
+ "events_url": "https://api.github.com/users/ryantxu/events{/privacy}",
+ "following_url": "https://api.github.com/users/ryantxu/following{/other_user}",
+ "followers_url": "https://api.github.com/users/ryantxu/followers",
+ "gists_url": "https://api.github.com/users/ryantxu/gists{/gist_id}",
+ "organizations_url": "https://api.github.com/users/ryantxu/orgs",
+ "received_events_url": "https://api.github.com/users/ryantxu/received_events",
+ "repos_url": "https://api.github.com/users/ryantxu/repos",
+ "starred_url": "https://api.github.com/users/ryantxu/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/ryantxu/subscriptions"
+ },
+ "organization": {
+ "login": "grafana",
+ "id": 7195757,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
+ "avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
+ "description": "Grafana Labs is behind leading open source projects Grafana and Loki, and the creator of the first open \u0026 composable observability platform.",
+ "url": "https://api.github.com/orgs/grafana",
+ "events_url": "https://api.github.com/orgs/grafana/events",
+ "hooks_url": "https://api.github.com/orgs/grafana/hooks",
+ "issues_url": "https://api.github.com/orgs/grafana/issues",
+ "members_url": "https://api.github.com/orgs/grafana/members{/member}",
+ "public_members_url": "https://api.github.com/orgs/grafana/public_members{/member}",
+ "repos_url": "https://api.github.com/orgs/grafana/repos"
+ }
+}
\ No newline at end of file
diff --git a/pkg/registry/apis/provisioning/repository/github/testdata/webhook-push-different_branch.json b/pkg/registry/apis/provisioning/repository/github/testdata/webhook-push-different_branch.json
new file mode 100644
index 00000000000..756ebd5d9f1
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/github/testdata/webhook-push-different_branch.json
@@ -0,0 +1,148 @@
+{
+ "ref": "refs/heads/not-main",
+ "commits": [
+ {
+ "message": "Update README.md\n\ntest message",
+ "author": {
+ "name": "Ryan McKinley",
+ "email": "ryantxu@gmail.com",
+ "username": "ryantxu"
+ },
+ "url": "https://github.com/grafana/git-ui-sync-demo/commit/72096e3adc646c5a5b8a91744f962b12bac06045",
+ "distinct": true,
+ "id": "72096e3adc646c5a5b8a91744f962b12bac06045",
+ "tree_id": "03ff034c54bcefae2f96041f3fb8172f2fe93df3",
+ "timestamp": "2024-12-09T08:58:00+03:00",
+ "committer": {
+ "name": "GitHub",
+ "email": "noreply@github.com",
+ "username": "web-flow"
+ },
+ "modified": [
+ "README.md"
+ ]
+ }
+ ],
+ "before": "6c86a0cdfd220c2fe3518cfaa4a4babf030d9a7a",
+ "after": "72096e3adc646c5a5b8a91744f962b12bac06045",
+ "created": false,
+ "deleted": false,
+ "forced": false,
+ "compare": "https://github.com/grafana/git-ui-sync-demo/compare/6c86a0cdfd22...72096e3adc64",
+ "repository": {
+ "id": 888020043,
+ "node_id": "R_kgDONO4cSw",
+ "name": "git-ui-sync-demo",
+ "full_name": "grafana/git-ui-sync-demo",
+ "owner": {
+ "login": "grafana",
+ "id": 7195757,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
+ "avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
+ "html_url": "https://github.com/grafana",
+ "gravatar_id": "",
+ "name": "grafana",
+ "email": "hello@grafana.com",
+ "type": "Organization",
+ "site_admin": false,
+ "url": "https://api.github.com/users/grafana",
+ "events_url": "https://api.github.com/users/grafana/events{/privacy}",
+ "following_url": "https://api.github.com/users/grafana/following{/other_user}",
+ "followers_url": "https://api.github.com/users/grafana/followers",
+ "gists_url": "https://api.github.com/users/grafana/gists{/gist_id}",
+ "organizations_url": "https://api.github.com/users/grafana/orgs",
+ "received_events_url": "https://api.github.com/users/grafana/received_events",
+ "repos_url": "https://api.github.com/users/grafana/repos",
+ "starred_url": "https://api.github.com/users/grafana/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/grafana/subscriptions"
+ },
+ "private": true,
+ "description": "A repository containing Grafana dashboards to demo the Github Sync feature in Grafana.",
+ "fork": false,
+ "created_at": "2024-11-13T20:13:33+03:00",
+ "pushed_at": "2024-12-09T08:58:00+03:00",
+ "updated_at": "2024-11-28T12:53:26Z",
+ "pulls_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls{/number}",
+ "size": 141,
+ "stargazers_count": 0,
+ "watchers_count": 0,
+ "has_issues": true,
+ "has_downloads": true,
+ "has_wiki": true,
+ "has_pages": false,
+ "forks_count": 0,
+ "archived": false,
+ "disabled": false,
+ "open_issues_count": 9,
+ "default_branch": "main",
+ "master_branch": "main",
+ "organization": "grafana",
+ "url": "https://github.com/grafana/git-ui-sync-demo",
+ "archive_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/{archive_format}{/ref}",
+ "html_url": "https://github.com/grafana/git-ui-sync-demo",
+ "statuses_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/statuses/{sha}",
+ "git_url": "git://github.com/grafana/git-ui-sync-demo.git",
+ "ssh_url": "git@github.com:grafana/git-ui-sync-demo.git",
+ "clone_url": "https://github.com/grafana/git-ui-sync-demo.git",
+ "svn_url": "https://github.com/grafana/git-ui-sync-demo"
+ },
+ "head_commit": {
+ "message": "Update README.md\n\ntest message",
+ "author": {
+ "name": "Ryan McKinley",
+ "email": "ryantxu@gmail.com",
+ "username": "ryantxu"
+ },
+ "url": "https://github.com/grafana/git-ui-sync-demo/commit/72096e3adc646c5a5b8a91744f962b12bac06045",
+ "distinct": true,
+ "id": "72096e3adc646c5a5b8a91744f962b12bac06045",
+ "tree_id": "03ff034c54bcefae2f96041f3fb8172f2fe93df3",
+ "timestamp": "2024-12-09T08:58:00+03:00",
+ "committer": {
+ "name": "GitHub",
+ "email": "noreply@github.com",
+ "username": "web-flow"
+ },
+ "modified": [
+ "README.md"
+ ]
+ },
+ "pusher": {
+ "name": "ryantxu",
+ "email": "ryantxu@gmail.com"
+ },
+ "sender": {
+ "login": "ryantxu",
+ "id": 705951,
+ "node_id": "MDQ6VXNlcjcwNTk1MQ==",
+ "avatar_url": "https://avatars.githubusercontent.com/u/705951?v=4",
+ "html_url": "https://github.com/ryantxu",
+ "gravatar_id": "",
+ "type": "User",
+ "site_admin": false,
+ "url": "https://api.github.com/users/ryantxu",
+ "events_url": "https://api.github.com/users/ryantxu/events{/privacy}",
+ "following_url": "https://api.github.com/users/ryantxu/following{/other_user}",
+ "followers_url": "https://api.github.com/users/ryantxu/followers",
+ "gists_url": "https://api.github.com/users/ryantxu/gists{/gist_id}",
+ "organizations_url": "https://api.github.com/users/ryantxu/orgs",
+ "received_events_url": "https://api.github.com/users/ryantxu/received_events",
+ "repos_url": "https://api.github.com/users/ryantxu/repos",
+ "starred_url": "https://api.github.com/users/ryantxu/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/ryantxu/subscriptions"
+ },
+ "organization": {
+ "login": "grafana",
+ "id": 7195757,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
+ "avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
+ "description": "Grafana Labs is behind leading open source projects Grafana and Loki, and the creator of the first open \u0026 composable observability platform.",
+ "url": "https://api.github.com/orgs/grafana",
+ "events_url": "https://api.github.com/orgs/grafana/events",
+ "hooks_url": "https://api.github.com/orgs/grafana/hooks",
+ "issues_url": "https://api.github.com/orgs/grafana/issues",
+ "members_url": "https://api.github.com/orgs/grafana/members{/member}",
+ "public_members_url": "https://api.github.com/orgs/grafana/public_members{/member}",
+ "repos_url": "https://api.github.com/orgs/grafana/repos"
+ }
+}
\ No newline at end of file
diff --git a/pkg/registry/apis/provisioning/repository/github/testdata/webhook-push-nested.json b/pkg/registry/apis/provisioning/repository/github/testdata/webhook-push-nested.json
new file mode 100644
index 00000000000..6e641869c7e
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/github/testdata/webhook-push-nested.json
@@ -0,0 +1,234 @@
+{
+ "ref": "refs/heads/main",
+ "before": "72096e3adc646c5a5b8a91744f962b12bac06045",
+ "after": "5c816f9812e391c62b0c5555d0b473b296d9179c",
+ "repository": {
+ "id": 888020043,
+ "node_id": "R_kgDONO4cSw",
+ "name": "git-ui-sync-demo",
+ "full_name": "grafana/git-ui-sync-demo",
+ "private": true,
+ "owner": {
+ "name": "grafana",
+ "email": "hello@grafana.com",
+ "login": "grafana",
+ "id": 7195757,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
+ "avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/grafana",
+ "html_url": "https://github.com/grafana",
+ "followers_url": "https://api.github.com/users/grafana/followers",
+ "following_url": "https://api.github.com/users/grafana/following{/other_user}",
+ "gists_url": "https://api.github.com/users/grafana/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/grafana/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/grafana/subscriptions",
+ "organizations_url": "https://api.github.com/users/grafana/orgs",
+ "repos_url": "https://api.github.com/users/grafana/repos",
+ "events_url": "https://api.github.com/users/grafana/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/grafana/received_events",
+ "type": "Organization",
+ "user_view_type": "public",
+ "site_admin": false
+ },
+ "html_url": "https://github.com/grafana/git-ui-sync-demo",
+ "description": "A repository containing Grafana dashboards to demo the Github Sync feature in Grafana.",
+ "fork": false,
+ "url": "https://github.com/grafana/git-ui-sync-demo",
+ "forks_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/forks",
+ "keys_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/keys{/key_id}",
+ "collaborators_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/collaborators{/collaborator}",
+ "teams_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/teams",
+ "hooks_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/hooks",
+ "issue_events_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/events{/number}",
+ "events_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/events",
+ "assignees_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/assignees{/user}",
+ "branches_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/branches{/branch}",
+ "tags_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/tags",
+ "blobs_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/blobs{/sha}",
+ "git_tags_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/tags{/sha}",
+ "git_refs_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/refs{/sha}",
+ "trees_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/trees{/sha}",
+ "statuses_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/statuses/{sha}",
+ "languages_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/languages",
+ "stargazers_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/stargazers",
+ "contributors_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/contributors",
+ "subscribers_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/subscribers",
+ "subscription_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/subscription",
+ "commits_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/commits{/sha}",
+ "git_commits_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/git/commits{/sha}",
+ "comments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/comments{/number}",
+ "issue_comment_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues/comments{/number}",
+ "contents_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/contents/{+path}",
+ "compare_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/compare/{base}...{head}",
+ "merges_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/merges",
+ "archive_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/{archive_format}{/ref}",
+ "downloads_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/downloads",
+ "issues_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/issues{/number}",
+ "pulls_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls{/number}",
+ "milestones_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/milestones{/number}",
+ "notifications_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/notifications{?since,all,participating}",
+ "labels_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/labels{/name}",
+ "releases_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/releases{/id}",
+ "deployments_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/deployments",
+ "created_at": 1731518013,
+ "updated_at": "2024-12-09T05:58:03Z",
+ "pushed_at": 1733731254,
+ "git_url": "git://github.com/grafana/git-ui-sync-demo.git",
+ "ssh_url": "git@github.com:grafana/git-ui-sync-demo.git",
+ "clone_url": "https://github.com/grafana/git-ui-sync-demo.git",
+ "svn_url": "https://github.com/grafana/git-ui-sync-demo",
+ "homepage": null,
+ "size": 142,
+ "stargazers_count": 0,
+ "watchers_count": 0,
+ "language": null,
+ "has_issues": true,
+ "has_projects": true,
+ "has_downloads": true,
+ "has_wiki": true,
+ "has_pages": false,
+ "has_discussions": false,
+ "forks_count": 0,
+ "mirror_url": null,
+ "archived": false,
+ "disabled": false,
+ "open_issues_count": 9,
+ "license": null,
+ "allow_forking": false,
+ "is_template": false,
+ "web_commit_signoff_required": false,
+ "topics": [
+
+ ],
+ "visibility": "internal",
+ "forks": 0,
+ "open_issues": 9,
+ "watchers": 0,
+ "default_branch": "main",
+ "stargazers": 0,
+ "master_branch": "main",
+ "organization": "grafana",
+ "custom_properties": {
+
+ }
+ },
+ "pusher": {
+ "name": "ryantxu",
+ "email": "ryantxu@gmail.com"
+ },
+ "organization": {
+ "login": "grafana",
+ "id": 7195757,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
+ "url": "https://api.github.com/orgs/grafana",
+ "repos_url": "https://api.github.com/orgs/grafana/repos",
+ "events_url": "https://api.github.com/orgs/grafana/events",
+ "hooks_url": "https://api.github.com/orgs/grafana/hooks",
+ "issues_url": "https://api.github.com/orgs/grafana/issues",
+ "members_url": "https://api.github.com/orgs/grafana/members{/member}",
+ "public_members_url": "https://api.github.com/orgs/grafana/public_members{/member}",
+ "avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
+ "description": "Grafana Labs is behind leading open source projects Grafana and Loki, and the creator of the first open & composable observability platform."
+ },
+ "enterprise": {
+ "id": 129980,
+ "slug": "grafana",
+ "name": "Grafana Labs Enterprise",
+ "node_id": "E_kgDOAAH7vA",
+ "avatar_url": "https://avatars.githubusercontent.com/b/129980?v=4",
+ "description": "Grafana Labs is behind leading open source projects Grafana and Loki, and the creator of the first open & composable observability platform.",
+ "website_url": "https://grafana.com",
+ "html_url": "https://github.com/enterprises/grafana",
+ "created_at": "2024-02-29T23:01:47Z",
+ "updated_at": "2024-10-21T08:45:28Z"
+ },
+ "sender": {
+ "login": "ryantxu",
+ "id": 705951,
+ "node_id": "MDQ6VXNlcjcwNTk1MQ==",
+ "avatar_url": "https://avatars.githubusercontent.com/u/705951?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/ryantxu",
+ "html_url": "https://github.com/ryantxu",
+ "followers_url": "https://api.github.com/users/ryantxu/followers",
+ "following_url": "https://api.github.com/users/ryantxu/following{/other_user}",
+ "gists_url": "https://api.github.com/users/ryantxu/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/ryantxu/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/ryantxu/subscriptions",
+ "organizations_url": "https://api.github.com/users/ryantxu/orgs",
+ "repos_url": "https://api.github.com/users/ryantxu/repos",
+ "events_url": "https://api.github.com/users/ryantxu/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/ryantxu/received_events",
+ "type": "User",
+ "user_view_type": "public",
+ "site_admin": false
+ },
+ "created": false,
+ "deleted": false,
+ "forced": false,
+ "base_ref": null,
+ "compare": "https://github.com/grafana/git-ui-sync-demo/compare/72096e3adc64...5c816f9812e3",
+ "commits": [
+ {
+ "id": "5c816f9812e391c62b0c5555d0b473b296d9179c",
+ "tree_id": "97c7ba756b07b6a2b7fd130e59f75deed53fe027",
+ "distinct": true,
+ "message": "nested folders",
+ "timestamp": "2024-12-09T11:00:48+03:00",
+ "url": "https://github.com/grafana/git-ui-sync-demo/commit/5c816f9812e391c62b0c5555d0b473b296d9179c",
+ "author": {
+ "name": "Ryan McKinley",
+ "email": "ryantxu@gmail.com",
+ "username": "ryantxu"
+ },
+ "committer": {
+ "name": "Ryan McKinley",
+ "email": "ryantxu@gmail.com",
+ "username": "ryantxu"
+ },
+ "added": [
+ "nested-1/README.md",
+ "nested-1/dash-1.json",
+ "nested-1/nested-2/README.md",
+ "nested-1/nested-2/dash-2.json"
+ ],
+ "removed": [
+
+ ],
+ "modified": [
+ "first-dashboard.json"
+ ]
+ }
+ ],
+ "head_commit": {
+ "id": "5c816f9812e391c62b0c5555d0b473b296d9179c",
+ "tree_id": "97c7ba756b07b6a2b7fd130e59f75deed53fe027",
+ "distinct": true,
+ "message": "nested folders",
+ "timestamp": "2024-12-09T11:00:48+03:00",
+ "url": "https://github.com/grafana/git-ui-sync-demo/commit/5c816f9812e391c62b0c5555d0b473b296d9179c",
+ "author": {
+ "name": "Ryan McKinley",
+ "email": "ryantxu@gmail.com",
+ "username": "ryantxu"
+ },
+ "committer": {
+ "name": "Ryan McKinley",
+ "email": "ryantxu@gmail.com",
+ "username": "ryantxu"
+ },
+ "added": [
+ "nested-1/README.md",
+ "nested-1/dash-1.json",
+ "nested-1/nested-2/README.md",
+ "nested-1/nested-2/dash-2.json"
+ ],
+ "removed": [
+
+ ],
+ "modified": [
+ "first-dashboard.json"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/pkg/registry/apis/provisioning/repository/github/testdata/webhook-push-nothing_relevant.json b/pkg/registry/apis/provisioning/repository/github/testdata/webhook-push-nothing_relevant.json
new file mode 100644
index 00000000000..b228d10a9c6
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/github/testdata/webhook-push-nothing_relevant.json
@@ -0,0 +1,148 @@
+{
+ "ref": "refs/heads/main",
+ "commits": [
+ {
+ "message": "Update README.md\n\ntest message",
+ "author": {
+ "name": "Ryan McKinley",
+ "email": "ryantxu@gmail.com",
+ "username": "ryantxu"
+ },
+ "url": "https://github.com/grafana/git-ui-sync-demo/commit/72096e3adc646c5a5b8a91744f962b12bac06045",
+ "distinct": true,
+ "id": "72096e3adc646c5a5b8a91744f962b12bac06045",
+ "tree_id": "03ff034c54bcefae2f96041f3fb8172f2fe93df3",
+ "timestamp": "2024-12-09T08:58:00+03:00",
+ "committer": {
+ "name": "GitHub",
+ "email": "noreply@github.com",
+ "username": "web-flow"
+ },
+ "modified": [
+ "README.md"
+ ]
+ }
+ ],
+ "before": "6c86a0cdfd220c2fe3518cfaa4a4babf030d9a7a",
+ "after": "72096e3adc646c5a5b8a91744f962b12bac06045",
+ "created": false,
+ "deleted": false,
+ "forced": false,
+ "compare": "https://github.com/grafana/git-ui-sync-demo/compare/6c86a0cdfd22...72096e3adc64",
+ "repository": {
+ "id": 888020043,
+ "node_id": "R_kgDONO4cSw",
+ "name": "git-ui-sync-demo",
+ "full_name": "grafana/git-ui-sync-demo",
+ "owner": {
+ "login": "grafana",
+ "id": 7195757,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
+ "avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
+ "html_url": "https://github.com/grafana",
+ "gravatar_id": "",
+ "name": "grafana",
+ "email": "hello@grafana.com",
+ "type": "Organization",
+ "site_admin": false,
+ "url": "https://api.github.com/users/grafana",
+ "events_url": "https://api.github.com/users/grafana/events{/privacy}",
+ "following_url": "https://api.github.com/users/grafana/following{/other_user}",
+ "followers_url": "https://api.github.com/users/grafana/followers",
+ "gists_url": "https://api.github.com/users/grafana/gists{/gist_id}",
+ "organizations_url": "https://api.github.com/users/grafana/orgs",
+ "received_events_url": "https://api.github.com/users/grafana/received_events",
+ "repos_url": "https://api.github.com/users/grafana/repos",
+ "starred_url": "https://api.github.com/users/grafana/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/grafana/subscriptions"
+ },
+ "private": true,
+ "description": "A repository containing Grafana dashboards to demo the Github Sync feature in Grafana.",
+ "fork": false,
+ "created_at": "2024-11-13T20:13:33+03:00",
+ "pushed_at": "2024-12-09T08:58:00+03:00",
+ "updated_at": "2024-11-28T12:53:26Z",
+ "pulls_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/pulls{/number}",
+ "size": 141,
+ "stargazers_count": 0,
+ "watchers_count": 0,
+ "has_issues": true,
+ "has_downloads": true,
+ "has_wiki": true,
+ "has_pages": false,
+ "forks_count": 0,
+ "archived": false,
+ "disabled": false,
+ "open_issues_count": 9,
+ "default_branch": "main",
+ "master_branch": "main",
+ "organization": "grafana",
+ "url": "https://github.com/grafana/git-ui-sync-demo",
+ "archive_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/{archive_format}{/ref}",
+ "html_url": "https://github.com/grafana/git-ui-sync-demo",
+ "statuses_url": "https://api.github.com/repos/grafana/git-ui-sync-demo/statuses/{sha}",
+ "git_url": "git://github.com/grafana/git-ui-sync-demo.git",
+ "ssh_url": "git@github.com:grafana/git-ui-sync-demo.git",
+ "clone_url": "https://github.com/grafana/git-ui-sync-demo.git",
+ "svn_url": "https://github.com/grafana/git-ui-sync-demo"
+ },
+ "head_commit": {
+ "message": "Update README.md\n\ntest message",
+ "author": {
+ "name": "Ryan McKinley",
+ "email": "ryantxu@gmail.com",
+ "username": "ryantxu"
+ },
+ "url": "https://github.com/grafana/git-ui-sync-demo/commit/72096e3adc646c5a5b8a91744f962b12bac06045",
+ "distinct": true,
+ "id": "72096e3adc646c5a5b8a91744f962b12bac06045",
+ "tree_id": "03ff034c54bcefae2f96041f3fb8172f2fe93df3",
+ "timestamp": "2024-12-09T08:58:00+03:00",
+ "committer": {
+ "name": "GitHub",
+ "email": "noreply@github.com",
+ "username": "web-flow"
+ },
+ "modified": [
+ "README.md"
+ ]
+ },
+ "pusher": {
+ "name": "ryantxu",
+ "email": "ryantxu@gmail.com"
+ },
+ "sender": {
+ "login": "ryantxu",
+ "id": 705951,
+ "node_id": "MDQ6VXNlcjcwNTk1MQ==",
+ "avatar_url": "https://avatars.githubusercontent.com/u/705951?v=4",
+ "html_url": "https://github.com/ryantxu",
+ "gravatar_id": "",
+ "type": "User",
+ "site_admin": false,
+ "url": "https://api.github.com/users/ryantxu",
+ "events_url": "https://api.github.com/users/ryantxu/events{/privacy}",
+ "following_url": "https://api.github.com/users/ryantxu/following{/other_user}",
+ "followers_url": "https://api.github.com/users/ryantxu/followers",
+ "gists_url": "https://api.github.com/users/ryantxu/gists{/gist_id}",
+ "organizations_url": "https://api.github.com/users/ryantxu/orgs",
+ "received_events_url": "https://api.github.com/users/ryantxu/received_events",
+ "repos_url": "https://api.github.com/users/ryantxu/repos",
+ "starred_url": "https://api.github.com/users/ryantxu/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/ryantxu/subscriptions"
+ },
+ "organization": {
+ "login": "grafana",
+ "id": 7195757,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjcxOTU3NTc=",
+ "avatar_url": "https://avatars.githubusercontent.com/u/7195757?v=4",
+ "description": "Grafana Labs is behind leading open source projects Grafana and Loki, and the creator of the first open \u0026 composable observability platform.",
+ "url": "https://api.github.com/orgs/grafana",
+ "events_url": "https://api.github.com/orgs/grafana/events",
+ "hooks_url": "https://api.github.com/orgs/grafana/hooks",
+ "issues_url": "https://api.github.com/orgs/grafana/issues",
+ "members_url": "https://api.github.com/orgs/grafana/members{/member}",
+ "public_members_url": "https://api.github.com/orgs/grafana/public_members{/member}",
+ "repos_url": "https://api.github.com/orgs/grafana/repos"
+ }
+}
\ No newline at end of file
diff --git a/pkg/registry/apis/provisioning/repository/github_test.go b/pkg/registry/apis/provisioning/repository/github_test.go
new file mode 100644
index 00000000000..dc24f6484e4
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/github_test.go
@@ -0,0 +1,149 @@
+package repository
+
+import (
+ "fmt"
+ "net/http"
+ "os"
+ "path"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+)
+
+func TestIsValidGitBranchName(t *testing.T) {
+ tests := []struct {
+ name string
+ branch string
+ expected bool
+ }{
+ {"Valid branch name", "feature/add-tests", true},
+ {"Valid branch name with numbers", "feature/123-add-tests", true},
+ {"Valid branch name with dots", "feature.add.tests", true},
+ {"Valid branch name with hyphens", "feature-add-tests", true},
+ {"Valid branch name with underscores", "feature_add_tests", true},
+ {"Valid branch name with mixed characters", "feature/add_tests-123", true},
+ {"Starts with /", "/feature", false},
+ {"Ends with /", "feature/", false},
+ {"Ends with .", "feature.", false},
+ {"Ends with space", "feature ", false},
+ {"Contains consecutive slashes", "feature//branch", false},
+ {"Contains consecutive dots", "feature..branch", false},
+ {"Contains @{", "feature@{branch", false},
+ {"Contains invalid character ~", "feature~branch", false},
+ {"Contains invalid character ^", "feature^branch", false},
+ {"Contains invalid character :", "feature:branch", false},
+ {"Contains invalid character ?", "feature?branch", false},
+ {"Contains invalid character *", "feature*branch", false},
+ {"Contains invalid character [", "feature[branch", false},
+ {"Contains invalid character ]", "feature]branch", false},
+ {"Contains invalid character \\", "feature\\branch", false},
+ {"Empty branch name", "", false},
+ {"Only whitespace", " ", false},
+ {"Single valid character", "a", true},
+ {"Ends with .lock", "feature.lock", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.expected, isValidGitBranchName(tt.branch))
+ })
+ }
+}
+
+func TestParseWebhooks(t *testing.T) {
+ tests := []struct {
+ messageType string
+ name string
+ expected provisioning.WebhookResponse
+ }{
+ {"ping", "check", provisioning.WebhookResponse{
+ Code: http.StatusOK,
+ }},
+ {"pull_request", "opened", provisioning.WebhookResponse{
+ Code: http.StatusAccepted, // 202
+ Job: &provisioning.JobSpec{
+ Repository: "unit-test-repo",
+ Action: provisioning.JobActionPullRequest,
+ PullRequest: &provisioning.PullRequestJobOptions{
+ Ref: "dashboard/1733653266690",
+ Hash: "ab5446a53df9e5f8bdeed52250f51fad08e822bc",
+ PR: 12,
+ URL: "https://github.com/grafana/git-ui-sync-demo/pull/12",
+ },
+ },
+ }},
+ {"push", "different_branch", provisioning.WebhookResponse{
+ Code: http.StatusOK, // we don't care about a branch that isn't the one we configured
+ }},
+ {"push", "nothing_relevant", provisioning.WebhookResponse{
+ Code: http.StatusAccepted,
+ Job: &provisioning.JobSpec{ // we want to always push a sync job
+ Repository: "unit-test-repo",
+ Action: provisioning.JobActionSync,
+ Pull: &provisioning.SyncJobOptions{
+ Incremental: true,
+ },
+ },
+ }},
+ {"push", "nested", provisioning.WebhookResponse{
+ Code: http.StatusAccepted,
+ Job: &provisioning.JobSpec{
+ Repository: "unit-test-repo",
+ Action: provisioning.JobActionSync,
+ Pull: &provisioning.SyncJobOptions{
+ Incremental: true,
+ },
+ },
+ }},
+ {"issue_comment", "created", provisioning.WebhookResponse{
+ Code: http.StatusNotImplemented,
+ }},
+ }
+
+ gh := &githubRepository{
+ config: &provisioning.Repository{
+ ObjectMeta: v1.ObjectMeta{
+ Name: "unit-test-repo",
+ },
+ Spec: provisioning.RepositorySpec{
+ Sync: provisioning.SyncOptions{
+ Enabled: true, // required to accept sync job
+ },
+ GitHub: &provisioning.GitHubRepositoryConfig{
+ URL: "https://github.com/grafana/git-ui-sync-demo",
+ Branch: "main",
+
+ GenerateDashboardPreviews: true,
+ },
+ },
+ },
+ }
+ var err error
+ gh.owner, gh.repo, err = parseOwnerRepo(gh.config.Spec.GitHub.URL)
+ require.NoError(t, err)
+
+ // Support parsing from a ".git" extension
+ owner, repo, err := parseOwnerRepo(gh.config.Spec.GitHub.URL + ".git")
+ require.NoError(t, err)
+ require.Equal(t, gh.owner, owner)
+ require.Equal(t, gh.repo, repo)
+
+ for _, tt := range tests {
+ name := fmt.Sprintf("webhook-%s-%s.json", tt.messageType, tt.name)
+ t.Run(name, func(t *testing.T) {
+ // nolint:gosec
+ payload, err := os.ReadFile(path.Join("github", "testdata", name))
+ require.NoError(t, err)
+
+ rsp, err := gh.parseWebhook(tt.messageType, payload)
+ require.NoError(t, err)
+
+ require.Equal(t, tt.expected.Code, rsp.Code)
+ require.Equal(t, tt.expected.Job, rsp.Job)
+ })
+ }
+}
diff --git a/pkg/registry/apis/provisioning/repository/go-git/transport.go b/pkg/registry/apis/provisioning/repository/go-git/transport.go
new file mode 100644
index 00000000000..652df25618a
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/go-git/transport.go
@@ -0,0 +1,71 @@
+package gogit
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "sync/atomic"
+)
+
+var errBytesLimitExceeded = fmt.Errorf("bytes limit exceeded")
+
+// ByteLimitedTransport wraps http.RoundTripper to enforce a max byte limit
+type ByteLimitedTransport struct {
+ Transport http.RoundTripper
+ Limit int64
+ Bytes int64
+}
+
+// NewByteLimitedTransport creates a new ByteLimitedTransport with the specified transport and byte limit.
+// If transport is nil, http.DefaultTransport will be used.
+func NewByteLimitedTransport(transport http.RoundTripper, limit int64) *ByteLimitedTransport {
+ if transport == nil {
+ transport = http.DefaultTransport
+ }
+ return &ByteLimitedTransport{
+ Transport: transport,
+ Limit: limit,
+ Bytes: 0,
+ }
+}
+
+// RoundTrip tracks downloaded bytes and aborts if limit is exceeded
+func (b *ByteLimitedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ resp, err := b.Transport.RoundTrip(req)
+ if err != nil {
+ return nil, err
+ }
+
+ // Wrap response body to track bytes read
+ resp.Body = &byteLimitedReader{
+ reader: resp.Body,
+ limit: b.Limit,
+ bytes: &b.Bytes,
+ }
+
+ return resp, nil
+}
+
+// byteLimitedReader tracks and enforces a download limit
+type byteLimitedReader struct {
+ reader io.ReadCloser
+ limit int64
+ bytes *int64
+}
+
+func (r *byteLimitedReader) Read(p []byte) (int, error) {
+ n, err := r.reader.Read(p)
+ if err != nil {
+ return n, err
+ }
+
+ if atomic.AddInt64(r.bytes, int64(n)) > r.limit {
+ return 0, errBytesLimitExceeded
+ }
+
+ return n, nil
+}
+
+func (r *byteLimitedReader) Close() error {
+ return r.reader.Close()
+}
diff --git a/pkg/registry/apis/provisioning/repository/go-git/transport_test.go b/pkg/registry/apis/provisioning/repository/go-git/transport_test.go
new file mode 100644
index 00000000000..77a59dff035
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/go-git/transport_test.go
@@ -0,0 +1,139 @@
+package gogit
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "net/http"
+ "sync/atomic"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type mockTransport struct {
+ response *http.Response
+ err error
+}
+
+func (m *mockTransport) RoundTrip(*http.Request) (*http.Response, error) {
+ return m.response, m.err
+}
+
+func TestNewByteLimitedTransport(t *testing.T) {
+ tests := []struct {
+ name string
+ transport http.RoundTripper
+ limit int64
+ }{
+ {
+ name: "with custom transport",
+ transport: &mockTransport{},
+ limit: 1000,
+ },
+ {
+ name: "with nil transport",
+ transport: nil,
+ limit: 1000,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ blt := NewByteLimitedTransport(tt.transport, tt.limit)
+ assert.NotNil(t, blt)
+ assert.Equal(t, tt.limit, blt.Limit)
+ assert.Equal(t, int64(0), blt.Bytes)
+
+ if tt.transport == nil {
+ assert.Equal(t, http.DefaultTransport, blt.Transport)
+ } else {
+ assert.Equal(t, tt.transport, blt.Transport)
+ }
+ })
+ }
+}
+
+func TestByteLimitedTransport_RoundTrip(t *testing.T) {
+ tests := []struct {
+ name string
+ responseBody string
+ limit int64
+ expectedError error
+ }{
+ {
+ name: "under limit",
+ responseBody: "small response",
+ limit: 100,
+ expectedError: nil,
+ },
+ {
+ name: "exceeds limit",
+ responseBody: "this response will exceed the byte limit",
+ limit: 10,
+ expectedError: errBytesLimitExceeded,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockResp := &http.Response{
+ Body: io.NopCloser(bytes.NewBufferString(tt.responseBody)),
+ }
+ mockTransport := &mockTransport{response: mockResp}
+
+ blt := NewByteLimitedTransport(mockTransport, tt.limit)
+ resp, err := blt.RoundTrip(&http.Request{})
+ require.NoError(t, err)
+ defer func() {
+ closeErr := resp.Body.Close()
+ assert.NoError(t, closeErr, "failed to close response body")
+ }()
+
+ data, err := io.ReadAll(resp.Body)
+ if tt.expectedError != nil {
+ assert.True(t, errors.Is(err, tt.expectedError), "expected error %v, got %v", tt.expectedError, err)
+ } else {
+ assert.NoError(t, err)
+ assert.Equal(t, tt.responseBody, string(data))
+ }
+ })
+ }
+}
+
+func TestByteLimitedReader_Close(t *testing.T) {
+ mockBody := io.NopCloser(bytes.NewBufferString("test"))
+ var byteCount int64
+ reader := &byteLimitedReader{
+ reader: mockBody,
+ limit: 100,
+ bytes: &byteCount,
+ }
+
+ err := reader.Close()
+ assert.NoError(t, err)
+}
+
+func TestByteLimitedReader_AtomicCounting(t *testing.T) {
+ var byteCount int64
+ reader := &byteLimitedReader{
+ reader: io.NopCloser(bytes.NewBufferString("test data")),
+ limit: 5,
+ bytes: &byteCount,
+ }
+
+ // First read should succeed
+ buf := make([]byte, 4)
+ n, err := reader.Read(buf)
+ assert.NoError(t, err)
+ assert.Equal(t, 4, n)
+
+ // Second read should fail due to limit
+ n, err = reader.Read(buf)
+ assert.True(t, errors.Is(err, errBytesLimitExceeded), "expected error %v, got %v", errBytesLimitExceeded, err)
+ assert.Equal(t, 0, n)
+
+ // Verify atomic counter
+ assert.Greater(t, atomic.LoadInt64(&byteCount), int64(5))
+}
diff --git a/pkg/registry/apis/provisioning/repository/go-git/wrapper.go b/pkg/registry/apis/provisioning/repository/go-git/wrapper.go
new file mode 100644
index 00000000000..4447aa2174a
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/go-git/wrapper.go
@@ -0,0 +1,423 @@
+package gogit
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/go-git/go-billy/v5/util"
+ "github.com/go-git/go-git/v5"
+ "github.com/go-git/go-git/v5/plumbing"
+ "github.com/go-git/go-git/v5/plumbing/object"
+ "github.com/go-git/go-git/v5/plumbing/transport/client"
+ githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/util/validation/field"
+
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
+)
+
+const (
+ // maxOperationBytes is the maximum size of a git operation in bytes (1 GB)
+ maxOperationBytes = int64(1 << 30)
+ maxOperationTimeout = 10 * time.Minute
+)
+
+func init() {
+ // Create a size-limited writer that will cancel the context if size is exceeded
+ limitedTransport := NewByteLimitedTransport(http.DefaultTransport, maxOperationBytes)
+ httpClient := githttp.NewClient(&http.Client{
+ Transport: limitedTransport,
+ })
+ client.InstallProtocol("https", httpClient)
+ client.InstallProtocol("http", httpClient)
+}
+
+var _ repository.Repository = (*GoGitRepo)(nil)
+
+type GoGitCloneOptions struct {
+ Root string // tempdir (when empty, memory??)
+
+ // If the branch does not exist, create it
+ CreateIfNotExists bool
+
+ // Skip intermediate commits and commit all before push
+ SingleCommitBeforePush bool
+
+ // Maximum allowed size for repository clone in bytes (0 means no limit)
+ MaxSize int64
+
+ // Maximum time allowed for clone operation in seconds (0 means no limit)
+ Timeout time.Duration
+}
+
+type GoGitPushOptions struct {
+ Timeout time.Duration
+}
+
+type GoGitRepo struct {
+ config *provisioning.Repository
+ opts GoGitCloneOptions
+ decryptedPassword string
+
+ repo *git.Repository
+ tree *git.Worktree
+ dir string // file path to worktree root (necessary? should use billy)
+}
+
+// This will create a new clone every time
+// As structured, it is valid for one context and should not be shared across multiple requests
+func Clone(
+ ctx context.Context,
+ config *provisioning.Repository,
+ opts GoGitCloneOptions,
+ secrets secrets.Service,
+ progress io.Writer, // os.Stdout
+) (*GoGitRepo, error) {
+ if opts.Root == "" {
+ return nil, fmt.Errorf("missing root config")
+ }
+
+ // add a timeout to the operation
+ timeout := maxOperationTimeout
+ if opts.Timeout > 0 {
+ timeout = opts.Timeout
+ }
+ ctx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+
+ decrypted, err := secrets.Decrypt(ctx, config.Spec.GitHub.EncryptedToken)
+ if err != nil {
+ return nil, fmt.Errorf("error decrypting token: %w", err)
+ }
+
+ if err := os.MkdirAll(opts.Root, 0700); err != nil {
+ return nil, fmt.Errorf("create root dir: %w", err)
+ }
+
+ dir, err := mkdirTempClone(opts.Root, config)
+ if err != nil {
+ return nil, fmt.Errorf("create temp clone dir: %w", err)
+ }
+
+ repo, worktree, err := clone(ctx, config, opts, decrypted, dir, progress)
+ if err != nil {
+ if err := os.RemoveAll(dir); err != nil {
+ return nil, fmt.Errorf("remove temp clone dir after clone failed: %w", err)
+ }
+
+ return nil, fmt.Errorf("clone: %w", err)
+ }
+
+ return &GoGitRepo{
+ config: config,
+ opts: opts,
+ tree: worktree,
+ decryptedPassword: string(decrypted),
+ repo: repo,
+ dir: dir,
+ }, nil
+}
+
+func clone(ctx context.Context, config *provisioning.Repository, opts GoGitCloneOptions, decrypted []byte, dir string, progress io.Writer) (*git.Repository, *git.Worktree, error) {
+ gitcfg := config.Spec.GitHub
+ url := fmt.Sprintf("%s.git", gitcfg.URL)
+
+ branch := plumbing.NewBranchReferenceName(gitcfg.Branch)
+ cloneOpts := &git.CloneOptions{
+ ReferenceName: branch,
+ Auth: &githttp.BasicAuth{
+ Username: "grafana", // this can be anything except an empty string for PAT
+ Password: string(decrypted), // TODO... will need to get from a service!
+ },
+ URL: url,
+ Progress: progress,
+ }
+
+ repo, err := git.PlainCloneContext(ctx, dir, false, cloneOpts)
+ if errors.Is(err, plumbing.ErrReferenceNotFound) && opts.CreateIfNotExists {
+ cloneOpts.ReferenceName = "" // empty
+ repo, err = git.PlainCloneContext(ctx, dir, false, cloneOpts)
+ if err == nil {
+ worktree, err := repo.Worktree()
+ if err != nil {
+ return nil, nil, err
+ }
+ err = worktree.Checkout(&git.CheckoutOptions{
+ Branch: branch,
+ Force: true,
+ Create: true,
+ })
+ if err != nil {
+ return nil, nil, fmt.Errorf("unable to create new branch: %w", err)
+ }
+ }
+ } else if err != nil {
+ return nil, nil, fmt.Errorf("clone error: %w", err)
+ }
+
+ rcfg, err := repo.Config()
+ if err != nil {
+ return nil, nil, fmt.Errorf("error reading repository config %w", err)
+ }
+
+ origin := rcfg.Remotes["origin"]
+ if origin == nil {
+ return nil, nil, fmt.Errorf("missing origin remote %w", err)
+ }
+
+ if url != origin.URLs[0] {
+ return nil, nil, fmt.Errorf("unexpected remote (expected: %s, found: %s)", url, origin.URLs[0])
+ }
+
+ worktree, err := repo.Worktree()
+ if err != nil {
+ return nil, nil, fmt.Errorf("get worktree: %w", err)
+ }
+
+ return repo, worktree, nil
+}
+
+func mkdirTempClone(root string, config *provisioning.Repository) (string, error) {
+ if config.Namespace == "" {
+ return "", fmt.Errorf("config is missing namespace")
+ }
+ if config.Name == "" {
+ return "", fmt.Errorf("config is missing name")
+ }
+ return os.MkdirTemp(root, fmt.Sprintf("clone-%s-%s-", config.Namespace, config.Name))
+}
+
+// Affer making changes to the worktree, push changes
+func (g *GoGitRepo) Push(ctx context.Context, opts GoGitPushOptions, progress io.Writer) error {
+ timeout := maxOperationTimeout
+ if opts.Timeout > 0 {
+ timeout = opts.Timeout
+ }
+
+ ctx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+
+ if g.opts.SingleCommitBeforePush {
+ _, err := g.tree.Commit("exported from grafana", &git.CommitOptions{
+ All: true, // Add everything that changed
+ })
+ if err != nil {
+ // empty commit is fine -- no change
+ if !errors.Is(err, git.ErrEmptyCommit) {
+ return err
+ }
+ }
+ }
+
+ err := g.repo.PushContext(ctx, &git.PushOptions{
+ Progress: progress,
+ Force: true, // avoid fast-forward-errors
+ Auth: &githttp.BasicAuth{ // reuse logic from clone?
+ Username: "grafana",
+ Password: g.decryptedPassword,
+ },
+ })
+ if errors.Is(err, git.NoErrAlreadyUpToDate) {
+ return nil // same as the target
+ }
+ return err
+}
+
+func (g *GoGitRepo) Remove(ctx context.Context) error {
+ return os.RemoveAll(g.dir)
+}
+
+// Config implements repository.Repository.
+func (g *GoGitRepo) Config() *provisioning.Repository {
+ return g.config
+}
+
+// ReadTree implements repository.Repository.
+func (g *GoGitRepo) ReadTree(ctx context.Context, ref string) ([]repository.FileTreeEntry, error) {
+ var treePath string
+ if g.config.Spec.GitHub.Path != "" {
+ treePath = g.config.Spec.GitHub.Path
+ }
+
+ // TODO: do we really need this?
+ if !strings.HasPrefix(treePath, "/") {
+ treePath = "/" + treePath
+ }
+
+ entries := make([]repository.FileTreeEntry, 0, 100)
+ err := util.Walk(g.tree.Filesystem, treePath, func(path string, info fs.FileInfo, err error) error {
+ if err != nil || path == "/" {
+ return err
+ }
+ entry := repository.FileTreeEntry{
+ Path: strings.TrimLeft(path, "/"),
+ Size: info.Size(),
+ }
+ if !info.IsDir() {
+ entry.Blob = true
+ // For a real instance, this will likely be based on:
+ // https://github.com/go-git/go-git/blob/main/_examples/ls/main.go#L25
+ entry.Hash = fmt.Sprintf("TODO/%d", info.Size()) // but not used for
+ }
+ entries = append(entries, entry)
+ return err
+ })
+ if errors.Is(err, fs.ErrNotExist) {
+ // We intentionally ignore this case, as
+ } else if err != nil {
+ return nil, fmt.Errorf("failed to walk tree for ref '%s': %w", ref, err)
+ }
+ return entries, nil
+}
+
+func (g *GoGitRepo) Test(ctx context.Context) (*provisioning.TestResults, error) {
+ return &provisioning.TestResults{
+ Success: g.tree != nil,
+ }, nil
+}
+
+// Update implements repository.Repository.
+func (g *GoGitRepo) Update(ctx context.Context, path string, ref string, data []byte, message string) error {
+ return g.Write(ctx, path, ref, data, message)
+}
+
+// Create implements repository.Repository.
+func (g *GoGitRepo) Create(ctx context.Context, path string, ref string, data []byte, message string) error {
+ return g.Write(ctx, path, ref, data, message)
+}
+
+// Write implements repository.Repository.
+func (g *GoGitRepo) Write(ctx context.Context, fpath string, ref string, data []byte, message string) error {
+ fpath = safepath.Join(g.config.Spec.GitHub.Path, fpath)
+ if err := verifyPathWithoutRef(fpath, ref); err != nil {
+ return err
+ }
+
+ // For folders, just create the folder and ignore the commit
+ if safepath.IsDir(fpath) {
+ return g.tree.Filesystem.MkdirAll(fpath, 0750)
+ }
+
+ dir := safepath.Dir(fpath)
+ if dir != "" {
+ err := g.tree.Filesystem.MkdirAll(dir, 0750)
+ if err != nil {
+ return err
+ }
+ }
+
+ file, err := g.tree.Filesystem.Create(fpath)
+ if err != nil {
+ return err
+ }
+ _, err = file.Write(data)
+ if err != nil {
+ return err
+ }
+
+ _, err = g.tree.Add(fpath)
+ if err != nil {
+ return err
+ }
+
+ // Skip commit for each file
+ if g.opts.SingleCommitBeforePush {
+ return nil
+ }
+
+ opts := &git.CommitOptions{}
+ sig := repository.GetAuthorSignature(ctx)
+ if sig != nil {
+ opts.Author = &object.Signature{
+ Name: sig.Name,
+ Email: sig.Email,
+ When: sig.When,
+ }
+ }
+ _, err = g.tree.Commit(message, opts)
+ if errors.Is(err, git.ErrEmptyCommit) {
+ return nil // empty commit is fine -- no change
+ }
+ return err
+}
+
+// Delete implements repository.Repository.
+func (g *GoGitRepo) Delete(ctx context.Context, path string, ref string, message string) error {
+ if _, err := g.tree.Remove(safepath.Join(g.config.Spec.GitHub.Path, path)); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// Read implements repository.Repository.
+func (g *GoGitRepo) Read(ctx context.Context, path string, ref string) (*repository.FileInfo, error) {
+ readPath := safepath.Join(g.config.Spec.GitHub.Path, path)
+ stat, err := g.tree.Filesystem.Lstat(readPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to stat path '%s': %w", readPath, err)
+ }
+ info := &repository.FileInfo{
+ Path: path,
+ Modified: &metav1.Time{
+ Time: stat.ModTime(),
+ },
+ }
+ if !stat.IsDir() {
+ f, err := g.tree.Filesystem.Open(readPath)
+ if err != nil {
+ return nil, err
+ }
+ info.Data, err = io.ReadAll(f)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return info, err
+}
+
+func verifyPathWithoutRef(path string, ref string) error {
+ if path == "" {
+ return fmt.Errorf("expected path")
+ }
+ if ref != "" {
+ return fmt.Errorf("ref unsupported")
+ }
+ return nil
+}
+
+// History implements repository.Repository.
+func (g *GoGitRepo) History(ctx context.Context, path string, ref string) ([]provisioning.HistoryItem, error) {
+ return nil, &apierrors.StatusError{
+ ErrStatus: metav1.Status{
+ Message: "history is not yet implemented",
+ Code: http.StatusNotImplemented,
+ },
+ }
+}
+
+// Validate implements repository.Repository.
+func (g *GoGitRepo) Validate() field.ErrorList {
+ return nil
+}
+
+// Webhook implements repository.Repository.
+func (g *GoGitRepo) Webhook(ctx context.Context, req *http.Request) (*provisioning.WebhookResponse, error) {
+ return nil, &apierrors.StatusError{
+ ErrStatus: metav1.Status{
+ Message: "history is not yet implemented",
+ Code: http.StatusNotImplemented,
+ },
+ }
+}
diff --git a/pkg/registry/apis/provisioning/repository/go-git/wrapper_test.go b/pkg/registry/apis/provisioning/repository/go-git/wrapper_test.go
new file mode 100644
index 00000000000..4ce86d18859
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/go-git/wrapper_test.go
@@ -0,0 +1,94 @@
+package gogit
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+)
+
+type dummySecret struct{}
+
+// Decrypt implements secrets.Service.
+func (d *dummySecret) Decrypt(ctx context.Context, data []byte) ([]byte, error) {
+ token, ok := os.LookupEnv("gitwraptoken")
+ if !ok {
+ return nil, fmt.Errorf("missing token in environment")
+ }
+ return []byte(token), nil
+}
+
+// Encrypt implements secrets.Service.
+func (d *dummySecret) Encrypt(ctx context.Context, data []byte) ([]byte, error) {
+ panic("unimplemented")
+}
+
+// FIXME!! NOTE!!!!!
+// This is really just a sketchpad while trying to get things working
+// the test makes destructive changes to a real git repository :)
+// this should be removed before committing to main (likely sooner)
+// and replaced with integration tests that check the more specific results
+func TestGoGitWrapper(t *testing.T) {
+ _, ok := os.LookupEnv("gitwraptoken")
+ if !ok {
+ t.Skipf("no token found in environment")
+ }
+
+ ctx := context.Background()
+ wrap, err := Clone(ctx, &v0alpha1.Repository{
+ ObjectMeta: v1.ObjectMeta{
+ Namespace: "ns",
+ Name: "unit-tester",
+ },
+ Spec: v0alpha1.RepositorySpec{
+ GitHub: &v0alpha1.GitHubRepositoryConfig{
+ URL: "https://github.com/grafana/git-ui-sync-demo",
+ Branch: "ryan-test",
+ },
+ },
+ },
+ GoGitCloneOptions{
+ Root: "testdata/clone", // where things are cloned,
+ // one commit (not 11)
+ SingleCommitBeforePush: true,
+ CreateIfNotExists: true,
+ },
+ &dummySecret{},
+ os.Stdout)
+ require.NoError(t, err)
+
+ tree, err := wrap.ReadTree(ctx, "")
+ require.NoError(t, err)
+
+ jj, err := json.MarshalIndent(tree, "", " ")
+ require.NoError(t, err)
+
+ fmt.Printf("TREE:%s\n", string(jj))
+
+ ctx = repository.WithAuthorSignature(ctx, repository.CommitSignature{
+ Name: "xxxxx",
+ Email: "rrr@yyyy.zzz",
+ When: time.Now(),
+ })
+
+ for i := 0; i < 10; i++ {
+ fname := fmt.Sprintf("deep/path/in/test_%d.txt", i)
+ fmt.Printf("Write:%s\n", fname)
+ err = wrap.Write(ctx, fname, "", []byte(fmt.Sprintf("body/%d %s", i, time.Now())), "the commit message")
+ require.NoError(t, err)
+ }
+
+ fmt.Printf("push...\n")
+ err = wrap.Push(ctx, GoGitPushOptions{
+ Timeout: 10,
+ }, os.Stdout)
+ require.NoError(t, err)
+}
diff --git a/pkg/registry/apis/provisioning/repository/local.go b/pkg/registry/apis/provisioning/repository/local.go
new file mode 100644
index 00000000000..42f568311d6
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/local.go
@@ -0,0 +1,383 @@
+package repository
+
+import (
+ "context"
+ "path"
+
+ // Git still uses sha1 for the most part: https://git-scm.com/docs/hash-function-transition
+ //nolint:gosec
+ "crypto/sha1"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/util/validation/field"
+
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
+)
+
+type LocalFolderResolver struct {
+ PermittedPrefixes []string
+ HomePath string
+}
+
+type InvalidLocalFolderError struct {
+ Path string
+ AdditionalInfo string
+}
+
+var (
+ _ error = (*InvalidLocalFolderError)(nil)
+ _ apierrors.APIStatus = (*InvalidLocalFolderError)(nil)
+)
+
+func (e *InvalidLocalFolderError) Error() string {
+ return fmt.Sprintf("the path given ('%s') is invalid for a local repository (%s)", e.Path, e.AdditionalInfo)
+}
+
+func (e *InvalidLocalFolderError) Status() metav1.Status {
+ return metav1.Status{
+ Status: metav1.StatusFailure,
+ Code: http.StatusBadRequest,
+ Reason: metav1.StatusReasonBadRequest,
+ Message: e.Error(),
+ }
+}
+
+func (r *LocalFolderResolver) LocalPath(p string) (string, error) {
+ if len(r.PermittedPrefixes) == 0 {
+ return "", &InvalidLocalFolderError{p, "no permitted prefixes were configured"}
+ }
+
+ originalPath := p
+ if !path.IsAbs(p) {
+ p = safepath.Join(r.HomePath, p)
+ } else {
+ p = safepath.Clean(p)
+ }
+
+ for _, permitted := range r.PermittedPrefixes {
+ if safepath.InDir(p, permitted) {
+ return p, nil
+ }
+ }
+ return "", &InvalidLocalFolderError{originalPath, "the path matches no permitted prefix"}
+}
+
+var (
+ _ Repository = (*localRepository)(nil)
+ _ Writer = (*localRepository)(nil)
+ _ Reader = (*localRepository)(nil)
+)
+
+type localRepository struct {
+ config *provisioning.Repository
+ resolver *LocalFolderResolver
+
+ // validated path that can be read if not empty
+ path string
+}
+
+func NewLocal(config *provisioning.Repository, resolver *LocalFolderResolver) *localRepository {
+ r := &localRepository{
+ config: config,
+ resolver: resolver,
+ }
+ if config.Spec.Local != nil {
+ r.path, _ = resolver.LocalPath(config.Spec.Local.Path)
+ if r.path != "" && !safepath.IsDir(r.path) {
+ r.path += "/"
+ }
+
+ for i, permitted := range r.resolver.PermittedPrefixes {
+ r.resolver.PermittedPrefixes[i] = safepath.Clean(permitted)
+ }
+ }
+
+ return r
+}
+
+func (r *localRepository) Config() *provisioning.Repository {
+ return r.config
+}
+
+// Validate implements provisioning.Repository.
+func (r *localRepository) Validate() (fields field.ErrorList) {
+ cfg := r.config.Spec.Local
+ if cfg == nil {
+ fields = append(fields, &field.Error{
+ Type: field.ErrorTypeRequired,
+ Field: "spec.local",
+ })
+ return fields
+ }
+
+ // The path value must be set for local provisioning
+ if cfg.Path == "" {
+ fields = append(fields, field.Required(field.NewPath("spec", "local", "path"),
+ "must enter a path to local file"))
+ }
+
+ // Check if it is valid
+ _, err := r.resolver.LocalPath(cfg.Path)
+ if err != nil {
+ fields = append(fields, field.Invalid(field.NewPath("spec", "local", "path"),
+ cfg.Path, err.Error()))
+ }
+
+ if err := safepath.IsSafe(cfg.Path); err != nil {
+ fields = append(fields, field.Invalid(field.NewPath("spec", "local", "path"),
+ cfg.Path, err.Error()))
+ }
+
+ return fields
+}
+
+// Test implements provisioning.Repository.
+// NOTE: Validate has been called (and passed) before this function should be called
+func (r *localRepository) Test(ctx context.Context) (*provisioning.TestResults, error) {
+ if r.config.Spec.Local.Path == "" {
+ return &provisioning.TestResults{
+ Code: http.StatusBadRequest,
+ Success: false,
+ Errors: []string{
+ "no path is configured",
+ },
+ }, nil
+ }
+
+ _, err := r.resolver.LocalPath(r.config.Spec.Local.Path)
+ if err != nil {
+ return &provisioning.TestResults{
+ Code: http.StatusBadRequest,
+ Success: false,
+ Errors: []string{
+ err.Error(),
+ },
+ }, nil
+ }
+
+ _, err = os.Stat(r.path)
+ if errors.Is(err, os.ErrNotExist) {
+ return &provisioning.TestResults{
+ Code: http.StatusBadRequest,
+ Success: false,
+ Errors: []string{
+ fmt.Sprintf("directory not found: %s", r.config.Spec.Local.Path),
+ },
+ }, nil
+ }
+
+ return &provisioning.TestResults{
+ Code: http.StatusOK,
+ Success: true,
+ }, nil
+}
+
+// Test implements provisioning.Repository.
+func (r *localRepository) validateRequest(ref string) error {
+ if ref != "" {
+ return apierrors.NewBadRequest("local repository does not support ref")
+ }
+ if r.path == "" {
+ _, err := r.resolver.LocalPath(r.config.Spec.Local.Path)
+ if err != nil {
+ return err
+ }
+ return &apierrors.StatusError{
+ ErrStatus: metav1.Status{
+ Message: "the service is missing a root path",
+ Code: http.StatusFailedDependency,
+ },
+ }
+ }
+ return nil
+}
+
+// ReadResource implements provisioning.Repository.
+func (r *localRepository) Read(ctx context.Context, filePath string, ref string) (*FileInfo, error) {
+ if err := r.validateRequest(ref); err != nil {
+ return nil, err
+ }
+
+ actualPath := safepath.Join(r.path, filePath)
+ info, err := os.Stat(actualPath)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, ErrFileNotFound
+ } else if err != nil {
+ return nil, fmt.Errorf("stat file: %w", err)
+ }
+
+ if info.IsDir() {
+ return &FileInfo{
+ Path: filePath,
+ Modified: &metav1.Time{
+ Time: info.ModTime(),
+ },
+ }, nil
+ }
+
+ //nolint:gosec
+ data, err := os.ReadFile(actualPath)
+ if err != nil {
+ return nil, fmt.Errorf("read file: %w", err)
+ }
+
+ hash, _, err := r.calculateFileHash(actualPath)
+ if err != nil {
+ return nil, fmt.Errorf("calculate hash of file: %w", err)
+ }
+
+ return &FileInfo{
+ Path: filePath,
+ Data: data,
+ Hash: hash,
+ Modified: &metav1.Time{
+ Time: info.ModTime(),
+ },
+ }, nil
+}
+
+// ReadResource implements provisioning.Repository.
+func (r *localRepository) ReadTree(ctx context.Context, ref string) ([]FileTreeEntry, error) {
+ if err := r.validateRequest(ref); err != nil {
+ return nil, err
+ }
+
+ // Return an empty list when folder does not exist
+ _, err := os.Stat(r.path)
+ if errors.Is(err, fs.ErrNotExist) {
+ return []FileTreeEntry{}, nil
+ }
+
+ rootlen := len(r.path)
+ entries := make([]FileTreeEntry, 0, 100)
+ err = filepath.Walk(r.path, func(path string, info fs.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ entry := FileTreeEntry{
+ Path: strings.TrimLeft(path[rootlen:], "/"),
+ Size: info.Size(),
+ }
+ if !info.IsDir() {
+ entry.Blob = true
+ entry.Hash, _, err = r.calculateFileHash(path)
+ if err != nil {
+ return fmt.Errorf("failed to read and calculate hash of path %s: %w", path, err)
+ }
+ }
+ // TODO: do folders have a trailing slash?
+ entries = append(entries, entry)
+ return err
+ })
+
+ return entries, err
+}
+
+func (r *localRepository) calculateFileHash(path string) (string, int64, error) {
+ // Treats https://securego.io/docs/rules/g304.html
+ if !safepath.InDir(path, r.path) {
+ return "", 0, ErrFileNotFound
+ }
+
+ // We've already made sure the path is safe, so we'll ignore the gosec lint.
+ //nolint:gosec
+ file, err := os.OpenFile(path, os.O_RDONLY, 0)
+ if err != nil {
+ return "", 0, err
+ }
+
+ // TODO: Define what hashing algorithm we want to use for the entire repository. Maybe a config option?
+ hasher := sha1.New()
+ // TODO: context-aware io.Copy? Is that even possible with a reasonable impl?
+ size, err := io.Copy(hasher, file)
+ if err != nil {
+ return "", 0, err
+ }
+ // NOTE: EncodeToString (& hex.Encode for that matter) return lower-case hex.
+ return hex.EncodeToString(hasher.Sum(nil)), size, nil
+}
+
+func (r *localRepository) Create(ctx context.Context, fpath string, ref string, data []byte, comment string) error {
+ if err := r.validateRequest(ref); err != nil {
+ return err
+ }
+
+ fpath = safepath.Join(r.path, fpath)
+ _, err := os.Stat(fpath)
+ if !errors.Is(err, os.ErrNotExist) {
+ if err != nil {
+ return apierrors.NewInternalError(fmt.Errorf("failed to check if file exists: %w", err))
+ }
+ return apierrors.NewAlreadyExists(provisioning.RepositoryResourceInfo.GroupResource(), fpath)
+ }
+
+ if safepath.IsDir(fpath) {
+ if data != nil {
+ return apierrors.NewBadRequest("data cannot be provided for a directory")
+ }
+
+ if err := os.MkdirAll(fpath, 0700); err != nil {
+ return apierrors.NewInternalError(fmt.Errorf("failed to create path: %w", err))
+ }
+
+ return nil
+ }
+
+ if err := os.MkdirAll(path.Dir(fpath), 0700); err != nil {
+ return apierrors.NewInternalError(fmt.Errorf("failed to create path: %w", err))
+ }
+
+ return os.WriteFile(fpath, data, 0600)
+}
+
+func (r *localRepository) Update(ctx context.Context, path string, ref string, data []byte, comment string) error {
+ if err := r.validateRequest(ref); err != nil {
+ return err
+ }
+
+ path = safepath.Join(r.path, path)
+ if safepath.IsDir(path) {
+ return apierrors.NewBadRequest("cannot update a directory")
+ }
+
+ if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("file does not exist")
+ }
+ return os.WriteFile(path, data, 0600)
+}
+
+func (r *localRepository) Write(ctx context.Context, fpath, ref string, data []byte, comment string) error {
+ if err := r.validateRequest(ref); err != nil {
+ return err
+ }
+
+ fpath = safepath.Join(r.path, fpath)
+ if safepath.IsDir(fpath) {
+ return os.MkdirAll(fpath, 0700)
+ }
+
+ if err := os.MkdirAll(path.Dir(fpath), 0700); err != nil {
+ return apierrors.NewInternalError(fmt.Errorf("failed to create path: %w", err))
+ }
+
+ return os.WriteFile(fpath, data, 0600)
+}
+
+func (r *localRepository) Delete(ctx context.Context, path string, ref string, comment string) error {
+ if err := r.validateRequest(ref); err != nil {
+ return err
+ }
+
+ return os.Remove(safepath.Join(r.path, path))
+}
diff --git a/pkg/registry/apis/provisioning/repository/local_test.go b/pkg/registry/apis/provisioning/repository/local_test.go
new file mode 100644
index 00000000000..0052582856c
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/local_test.go
@@ -0,0 +1,86 @@
+package repository
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+)
+
+func TestLocalResolver(t *testing.T) {
+ resolver := &LocalFolderResolver{
+ PermittedPrefixes: []string{
+ "/github/testdata",
+ },
+ HomePath: "/",
+ }
+
+ _, err := resolver.LocalPath("github/testdata")
+ require.NoError(t, err)
+
+ _, err = resolver.LocalPath("something")
+ require.Error(t, err)
+}
+
+func TestLocal(t *testing.T) {
+ // Valid paths test cases
+ for _, tc := range []struct {
+ Name string
+ Path string
+ PermittedPrefixes []string
+ ExpectedPath string
+ }{
+ {"relative path", "devenv/test", []string{"/home/grafana"}, "/home/grafana/devenv/test/"},
+ {"absolute path", "/devenv/test", []string{"/devenv"}, "/devenv/test/"},
+ {"relative path with multiple prefixes", "devenv/test", []string{"/home/grafana", "/devenv"}, "/home/grafana/devenv/test/"},
+ {"absolute path with multiple prefixes", "/devenv/test", []string{"/home/grafana", "/devenv"}, "/devenv/test/"},
+ } {
+ t.Run("valid: "+tc.Name, func(t *testing.T) {
+ r := NewLocal(&v0alpha1.Repository{
+ Spec: v0alpha1.RepositorySpec{
+ Local: &v0alpha1.LocalRepositoryConfig{
+ Path: tc.Path,
+ },
+ },
+ }, &LocalFolderResolver{PermittedPrefixes: tc.PermittedPrefixes, HomePath: "/home/grafana"})
+
+ assert.Equal(t, tc.ExpectedPath, r.path, "expected path to be resolved")
+ for _, err := range r.Validate() {
+ assert.Fail(t, "unexpected validation failure", "unexpected validation error on field %s: %s", err.Field, err.ErrorBody())
+ }
+ })
+ }
+
+ // Invalid paths test cases
+ for _, tc := range []struct {
+ Name string
+ Path string
+ PermittedPrefixes []string
+ }{
+ {"no configured paths", "invalid/path", nil},
+ {"path traversal escape", "../../etc/passwd", []string{"/home/grafana"}},
+ {"unconfigured prefix", "invalid/path", []string{"devenv", "/tmp", "test"}},
+ } {
+ t.Run("invalid: "+tc.Name, func(t *testing.T) {
+ r := NewLocal(&v0alpha1.Repository{
+ Spec: v0alpha1.RepositorySpec{
+ Local: &v0alpha1.LocalRepositoryConfig{
+ Path: tc.Path,
+ },
+ },
+ }, &LocalFolderResolver{PermittedPrefixes: tc.PermittedPrefixes, HomePath: "/home/grafana"})
+
+ require.Empty(t, r.path, "no path should be resolved")
+
+ errs := r.Validate()
+ require.NotEmpty(t, errs, "expected validation errors")
+ for _, err := range errs {
+ if !assert.Equal(t, "spec.local.path", err.Field) {
+ assert.FailNow(t, "unexpected validation failure", "unexpected validation error on field %s: %s", err.Field, err.ErrorBody())
+ }
+ }
+ })
+ }
+}
diff --git a/pkg/registry/apis/provisioning/repository/repository.go b/pkg/registry/apis/provisioning/repository/repository.go
index d4dd2aee63d..d9d3bc9a4ec 100644
--- a/pkg/registry/apis/provisioning/repository/repository.go
+++ b/pkg/registry/apis/provisioning/repository/repository.go
@@ -2,6 +2,8 @@ package repository
import (
"context"
+ "errors"
+ "fmt"
"io/fs"
"net/http"
@@ -59,6 +61,8 @@ type FileTreeEntry struct {
}
type Reader interface {
+ Repository
+
// Read a file from the resource
// This data will be parsed and validated before it is shown to end users
Read(ctx context.Context, path, ref string) (*FileInfo, error)
@@ -72,6 +76,8 @@ type Reader interface {
}
type Writer interface {
+ Repository
+
// Write a file to the repository.
// The data has already been validated and is ready for save
Create(ctx context.Context, path, ref string, data []byte, message string) error
@@ -88,8 +94,23 @@ type Writer interface {
Delete(ctx context.Context, path, ref, message string) error
}
+type ReaderWriter interface {
+ Reader
+ Writer
+}
+
+// Hooks called after the repository has been created, updated or deleted
+type RepositoryWithURLs interface {
+ Repository
+
+ // Get resource URLs for a file inside a repository
+ ResourceURLs(ctx context.Context, file *FileInfo) (*provisioning.ResourceURLs, error)
+}
+
// Hooks called after the repository has been created, updated or deleted
type Hooks interface {
+ Repository
+
// For repositories that support webhooks
Webhook(ctx context.Context, req *http.Request) (*provisioning.WebhookResponse, error)
OnCreate(ctx context.Context) (*provisioning.WebhookStatus, error)
@@ -126,3 +147,14 @@ type Versioned interface {
LatestRef(ctx context.Context) (string, error)
CompareFiles(ctx context.Context, base, ref string) ([]VersionedFileChange, error)
}
+
+func writeWithReadThenCreateOrUpdate(ctx context.Context, r ReaderWriter, path, ref string, data []byte, comment string) error {
+ _, err := r.Read(ctx, path, ref)
+ if err != nil && !(errors.Is(err, ErrFileNotFound)) {
+ return fmt.Errorf("failed to check if file exists before writing: %w", err)
+ }
+ if err == nil {
+ return r.Update(ctx, path, ref, data, comment)
+ }
+ return r.Create(ctx, path, ref, data, comment)
+}
diff --git a/pkg/registry/apis/provisioning/repository/repository_mock.go b/pkg/registry/apis/provisioning/repository/repository_mock.go
new file mode 100644
index 00000000000..b7f5bda049b
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/repository_mock.go
@@ -0,0 +1,295 @@
+// Code generated by mockery v2.50.0. DO NOT EDIT.
+
+package repository
+
+import (
+ context "context"
+ http "net/http"
+
+ field "k8s.io/apimachinery/pkg/util/validation/field"
+
+ mock "github.com/stretchr/testify/mock"
+
+ v0alpha1 "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+)
+
+// MockRepository is an autogenerated mock type for the Repository type
+type MockRepository struct {
+ mock.Mock
+}
+
+// Config provides a mock function with no fields
+func (_m *MockRepository) Config() *v0alpha1.Repository {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for Config")
+ }
+
+ var r0 *v0alpha1.Repository
+ if rf, ok := ret.Get(0).(func() *v0alpha1.Repository); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*v0alpha1.Repository)
+ }
+ }
+
+ return r0
+}
+
+// Create provides a mock function with given fields: ctx, path, ref, data, message
+func (_m *MockRepository) Create(ctx context.Context, path string, ref string, data []byte, message string) error {
+ ret := _m.Called(ctx, path, ref, data, message)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Create")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(context.Context, string, string, []byte, string) error); ok {
+ r0 = rf(ctx, path, ref, data, message)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// Delete provides a mock function with given fields: ctx, path, ref, message
+func (_m *MockRepository) Delete(ctx context.Context, path string, ref string, message string) error {
+ ret := _m.Called(ctx, path, ref, message)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Delete")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(context.Context, string, string, string) error); ok {
+ r0 = rf(ctx, path, ref, message)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// History provides a mock function with given fields: ctx, path, ref
+func (_m *MockRepository) History(ctx context.Context, path string, ref string) ([]v0alpha1.HistoryItem, error) {
+ ret := _m.Called(ctx, path, ref)
+
+ if len(ret) == 0 {
+ panic("no return value specified for History")
+ }
+
+ var r0 []v0alpha1.HistoryItem
+ var r1 error
+ if rf, ok := ret.Get(0).(func(context.Context, string, string) ([]v0alpha1.HistoryItem, error)); ok {
+ return rf(ctx, path, ref)
+ }
+ if rf, ok := ret.Get(0).(func(context.Context, string, string) []v0alpha1.HistoryItem); ok {
+ r0 = rf(ctx, path, ref)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]v0alpha1.HistoryItem)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
+ r1 = rf(ctx, path, ref)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// Read provides a mock function with given fields: ctx, path, ref
+func (_m *MockRepository) Read(ctx context.Context, path string, ref string) (*FileInfo, error) {
+ ret := _m.Called(ctx, path, ref)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Read")
+ }
+
+ var r0 *FileInfo
+ var r1 error
+ if rf, ok := ret.Get(0).(func(context.Context, string, string) (*FileInfo, error)); ok {
+ return rf(ctx, path, ref)
+ }
+ if rf, ok := ret.Get(0).(func(context.Context, string, string) *FileInfo); ok {
+ r0 = rf(ctx, path, ref)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*FileInfo)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
+ r1 = rf(ctx, path, ref)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// ReadTree provides a mock function with given fields: ctx, ref
+func (_m *MockRepository) ReadTree(ctx context.Context, ref string) ([]FileTreeEntry, error) {
+ ret := _m.Called(ctx, ref)
+
+ if len(ret) == 0 {
+ panic("no return value specified for ReadTree")
+ }
+
+ var r0 []FileTreeEntry
+ var r1 error
+ if rf, ok := ret.Get(0).(func(context.Context, string) ([]FileTreeEntry, error)); ok {
+ return rf(ctx, ref)
+ }
+ if rf, ok := ret.Get(0).(func(context.Context, string) []FileTreeEntry); ok {
+ r0 = rf(ctx, ref)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]FileTreeEntry)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
+ r1 = rf(ctx, ref)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// Test provides a mock function with given fields: ctx
+func (_m *MockRepository) Test(ctx context.Context) (*v0alpha1.TestResults, error) {
+ ret := _m.Called(ctx)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Test")
+ }
+
+ var r0 *v0alpha1.TestResults
+ var r1 error
+ if rf, ok := ret.Get(0).(func(context.Context) (*v0alpha1.TestResults, error)); ok {
+ return rf(ctx)
+ }
+ if rf, ok := ret.Get(0).(func(context.Context) *v0alpha1.TestResults); ok {
+ r0 = rf(ctx)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*v0alpha1.TestResults)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(context.Context) error); ok {
+ r1 = rf(ctx)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// Update provides a mock function with given fields: ctx, path, ref, data, message
+func (_m *MockRepository) Update(ctx context.Context, path string, ref string, data []byte, message string) error {
+ ret := _m.Called(ctx, path, ref, data, message)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Update")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(context.Context, string, string, []byte, string) error); ok {
+ r0 = rf(ctx, path, ref, data, message)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// Validate provides a mock function with no fields
+func (_m *MockRepository) Validate() field.ErrorList {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for Validate")
+ }
+
+ var r0 field.ErrorList
+ if rf, ok := ret.Get(0).(func() field.ErrorList); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(field.ErrorList)
+ }
+ }
+
+ return r0
+}
+
+// Webhook provides a mock function with given fields: ctx, req
+func (_m *MockRepository) Webhook(ctx context.Context, req *http.Request) (*v0alpha1.WebhookResponse, error) {
+ ret := _m.Called(ctx, req)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Webhook")
+ }
+
+ var r0 *v0alpha1.WebhookResponse
+ var r1 error
+ if rf, ok := ret.Get(0).(func(context.Context, *http.Request) (*v0alpha1.WebhookResponse, error)); ok {
+ return rf(ctx, req)
+ }
+ if rf, ok := ret.Get(0).(func(context.Context, *http.Request) *v0alpha1.WebhookResponse); ok {
+ r0 = rf(ctx, req)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*v0alpha1.WebhookResponse)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(context.Context, *http.Request) error); ok {
+ r1 = rf(ctx, req)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// Write provides a mock function with given fields: ctx, path, ref, data, message
+func (_m *MockRepository) Write(ctx context.Context, path string, ref string, data []byte, message string) error {
+ ret := _m.Called(ctx, path, ref, data, message)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Write")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(context.Context, string, string, []byte, string) error); ok {
+ r0 = rf(ctx, path, ref, data, message)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// NewMockRepository creates a new instance of MockRepository. 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 NewMockRepository(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *MockRepository {
+ mock := &MockRepository{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/pkg/registry/apis/provisioning/repository/test.go b/pkg/registry/apis/provisioning/repository/test.go
new file mode 100644
index 00000000000..8ba064ea08c
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/test.go
@@ -0,0 +1,87 @@
+package repository
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "slices"
+
+ "k8s.io/apimachinery/pkg/util/validation/field"
+
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+)
+
+// Tester is a struct that implements the Tester interface
+// it's temporary
+// FIXME: remove as soon as controller and jobs refactoring PRs are merged
+type Tester struct{}
+
+func (t *Tester) TestRepository(ctx context.Context, repo Repository) (*provisioning.TestResults, error) {
+ return TestRepository(ctx, repo)
+}
+
+func TestRepository(ctx context.Context, repo Repository) (*provisioning.TestResults, error) {
+ errors := ValidateRepository(repo)
+ if len(errors) > 0 {
+ rsp := &provisioning.TestResults{
+ Code: http.StatusUnprocessableEntity, // Invalid
+ Success: false,
+ Errors: make([]string, len(errors)),
+ }
+ for i, v := range errors {
+ rsp.Errors[i] = v.Error()
+ }
+ return rsp, nil
+ }
+
+ return repo.Test(ctx)
+}
+
+func ValidateRepository(repo Repository) field.ErrorList {
+ list := repo.Validate()
+ cfg := repo.Config()
+
+ if cfg.Spec.Title == "" {
+ list = append(list, field.Required(field.NewPath("spec", "title"), "a repository title must be given"))
+ }
+
+ if cfg.Spec.Sync.Enabled && cfg.Spec.Sync.Target == "" {
+ list = append(list, field.Required(field.NewPath("spec", "sync", "target"),
+ "The target type is required when sync is enabled"))
+ }
+
+ if cfg.Spec.Sync.Enabled && cfg.Spec.Sync.IntervalSeconds < 10 {
+ list = append(list, field.Invalid(field.NewPath("spec", "sync", "intervalSeconds"),
+ cfg.Spec.Sync.IntervalSeconds, fmt.Sprintf("Interval must be at least %d seconds", 10)))
+ }
+
+ // Reserved names (for now)
+ reserved := []string{"classic", "sql", "SQL", "plugins", "legacy", "new", "job", "github", "s3", "gcs", "file", "new", "create", "update", "delete"}
+ if slices.Contains(reserved, cfg.Name) {
+ list = append(list, field.Invalid(field.NewPath("metadata", "name"), cfg.Name, "Name is reserved, choose a different identifier"))
+ }
+
+ if cfg.Spec.Type != provisioning.LocalRepositoryType && cfg.Spec.Local != nil {
+ list = append(list, field.Invalid(field.NewPath("spec", "local"),
+ cfg.Spec.GitHub, "Local config only valid when type is local"))
+ }
+
+ if cfg.Spec.Type != provisioning.GitHubRepositoryType && cfg.Spec.GitHub != nil {
+ list = append(list, field.Invalid(field.NewPath("spec", "github"),
+ cfg.Spec.GitHub, "Github config only valid when type is github"))
+ }
+
+ for _, w := range cfg.Spec.Workflows {
+ switch w {
+ case provisioning.WriteWorkflow: // valid; no fall thru
+ case provisioning.BranchWorkflow:
+ if cfg.Spec.Type != provisioning.GitHubRepositoryType {
+ list = append(list, field.Invalid(field.NewPath("spec", "workflow"), w, "branch is only supported on git repositories"))
+ }
+ default:
+ list = append(list, field.Invalid(field.NewPath("spec", "workflow"), w, "invalid workflow"))
+ }
+ }
+
+ return list
+}
diff --git a/pkg/registry/apis/provisioning/repository/test_test.go b/pkg/registry/apis/provisioning/repository/test_test.go
new file mode 100644
index 00000000000..4e7b726ea56
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/test_test.go
@@ -0,0 +1,315 @@
+package repository
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "testing"
+
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/util/validation/field"
+)
+
+func TestValidateRepository(t *testing.T) {
+ tests := []struct {
+ name string
+ repository *MockRepository
+ expectedErrs int
+ validateError func(t *testing.T, errors field.ErrorList)
+ }{
+ {
+ name: "valid repository",
+ repository: func() *MockRepository {
+ m := NewMockRepository(t)
+ m.On("Config").Return(&provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ Title: "Test Repo",
+ },
+ })
+ m.On("Validate").Return(field.ErrorList{})
+ return m
+ }(),
+ expectedErrs: 0,
+ },
+ {
+ name: "missing title",
+ repository: func() *MockRepository {
+ m := NewMockRepository(t)
+ m.On("Config").Return(&provisioning.Repository{
+ Spec: provisioning.RepositorySpec{},
+ })
+ m.On("Validate").Return(field.ErrorList{})
+ return m
+ }(),
+ expectedErrs: 1,
+ validateError: func(t *testing.T, errors field.ErrorList) {
+ require.Contains(t, errors.ToAggregate().Error(), "spec.title: Required value")
+ },
+ },
+ {
+ name: "sync enabled without target",
+ repository: func() *MockRepository {
+ m := NewMockRepository(t)
+ m.On("Config").Return(&provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ Title: "Test Repo",
+ Sync: provisioning.SyncOptions{
+ Enabled: true,
+ IntervalSeconds: 10,
+ },
+ },
+ })
+ m.On("Validate").Return(field.ErrorList{})
+ return m
+ }(),
+ expectedErrs: 1,
+ validateError: func(t *testing.T, errors field.ErrorList) {
+ require.Contains(t, errors.ToAggregate().Error(), "spec.sync.target: Required value")
+ },
+ },
+ {
+ name: "sync interval too low",
+ repository: func() *MockRepository {
+ m := NewMockRepository(t)
+ m.On("Config").Return(&provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ Title: "Test Repo",
+ Sync: provisioning.SyncOptions{
+ Enabled: true,
+ Target: "test",
+ IntervalSeconds: 5,
+ },
+ },
+ })
+ m.On("Validate").Return(field.ErrorList{})
+ return m
+ }(),
+ expectedErrs: 1,
+ validateError: func(t *testing.T, errors field.ErrorList) {
+ require.Contains(t, errors.ToAggregate().Error(), "spec.sync.intervalSeconds: Invalid value")
+ },
+ },
+ {
+ name: "reserved name",
+ repository: func() *MockRepository {
+ m := NewMockRepository(t)
+ m.On("Config").Return(&provisioning.Repository{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "sql",
+ },
+ Spec: provisioning.RepositorySpec{
+ Title: "Test Repo",
+ },
+ })
+ m.On("Validate").Return(field.ErrorList{})
+ return m
+ }(),
+ expectedErrs: 1,
+ validateError: func(t *testing.T, errors field.ErrorList) {
+ require.Contains(t, errors.ToAggregate().Error(), "metadata.name: Invalid value")
+ },
+ },
+ {
+ name: "mismatched local config",
+ repository: func() *MockRepository {
+ m := NewMockRepository(t)
+ m.On("Config").Return(&provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ Title: "Test Repo",
+ Type: provisioning.GitHubRepositoryType,
+ Local: &provisioning.LocalRepositoryConfig{},
+ },
+ })
+ m.On("Validate").Return(field.ErrorList{})
+ return m
+ }(),
+ expectedErrs: 1,
+ validateError: func(t *testing.T, errors field.ErrorList) {
+ require.Contains(t, errors.ToAggregate().Error(), "spec.local: Invalid value")
+ },
+ },
+ {
+ name: "mismatched github config",
+ repository: func() *MockRepository {
+ m := NewMockRepository(t)
+ m.On("Config").Return(&provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ Title: "Test Repo",
+ Type: provisioning.LocalRepositoryType,
+ GitHub: &provisioning.GitHubRepositoryConfig{},
+ },
+ })
+ m.On("Validate").Return(field.ErrorList{})
+ return m
+ }(),
+ expectedErrs: 1,
+ validateError: func(t *testing.T, errors field.ErrorList) {
+ require.Contains(t, errors.ToAggregate().Error(), "spec.github: Invalid value")
+ },
+ },
+ {
+ name: "multiple validation errors",
+ repository: func() *MockRepository {
+ m := NewMockRepository(t)
+ m.On("Config").Return(&provisioning.Repository{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "sql",
+ },
+ Spec: provisioning.RepositorySpec{
+ Sync: provisioning.SyncOptions{
+ Enabled: true,
+ IntervalSeconds: 5,
+ },
+ },
+ })
+ m.On("Validate").Return(field.ErrorList{})
+ return m
+ }(),
+ expectedErrs: 4, // Updated from 3 to 4 to match actual errors:
+ // 1. missing title
+ // 2. sync target missing
+ // 3. sync interval too low
+ // 4. reserved name
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ errors := ValidateRepository(tt.repository)
+ require.Len(t, errors, tt.expectedErrs)
+ if tt.validateError != nil {
+ tt.validateError(t, errors)
+ }
+ })
+ }
+}
+
+func TestTestRepository(t *testing.T) {
+ tests := []struct {
+ name string
+ repository *MockRepository
+ expectedCode int
+ expectedErrs []string
+ expectedError error
+ }{
+ {
+ name: "validation fails",
+ repository: func() *MockRepository {
+ m := NewMockRepository(t)
+ m.On("Config").Return(&provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ // Missing required title
+ },
+ })
+ m.On("Validate").Return(field.ErrorList{})
+ return m
+ }(),
+ expectedCode: http.StatusUnprocessableEntity,
+ expectedErrs: []string{"spec.title: Required value: a repository title must be given"},
+ },
+ {
+ name: "test passes",
+ repository: func() *MockRepository {
+ m := NewMockRepository(t)
+ m.On("Config").Return(&provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ Title: "Test Repo",
+ },
+ })
+ m.On("Validate").Return(field.ErrorList{})
+ m.On("Test", mock.Anything).Return(&provisioning.TestResults{
+ Code: http.StatusOK,
+ Success: true,
+ }, nil)
+ return m
+ }(),
+ expectedCode: http.StatusOK,
+ expectedErrs: nil,
+ },
+ {
+ name: "test fails with error",
+ repository: func() *MockRepository {
+ m := NewMockRepository(t)
+ m.On("Config").Return(&provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ Title: "Test Repo",
+ },
+ })
+ m.On("Validate").Return(field.ErrorList{})
+ m.On("Test", mock.Anything).Return(nil, fmt.Errorf("test error"))
+ return m
+ }(),
+ expectedError: fmt.Errorf("test error"),
+ },
+ {
+ name: "test fails with results",
+ repository: func() *MockRepository {
+ m := NewMockRepository(t)
+ m.On("Config").Return(&provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ Title: "Test Repo",
+ },
+ })
+ m.On("Validate").Return(field.ErrorList{})
+ m.On("Test", mock.Anything).Return(&provisioning.TestResults{
+ Code: http.StatusBadRequest,
+ Success: false,
+ Errors: []string{"test failed"},
+ }, nil)
+ return m
+ }(),
+ expectedCode: http.StatusBadRequest,
+ expectedErrs: []string{"test failed"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ results, err := TestRepository(context.Background(), tt.repository)
+
+ if tt.expectedError != nil {
+ require.Error(t, err)
+ require.Equal(t, tt.expectedError.Error(), err.Error())
+ return
+ }
+
+ require.NoError(t, err)
+ require.NotNil(t, results)
+ require.Equal(t, tt.expectedCode, results.Code)
+
+ if tt.expectedErrs != nil {
+ require.Equal(t, tt.expectedErrs, results.Errors)
+ require.False(t, results.Success)
+ } else {
+ require.True(t, results.Success)
+ require.Empty(t, results.Errors)
+ }
+ })
+ }
+}
+
+func TestTester_TestRepository(t *testing.T) {
+ tester := &Tester{}
+
+ // Test that it properly delegates to TestRepository
+ repository := NewMockRepository(t)
+ repository.On("Config").Return(&provisioning.Repository{
+ Spec: provisioning.RepositorySpec{
+ Title: "Test Repo",
+ },
+ })
+ repository.On("Validate").Return(field.ErrorList{})
+ repository.On("Test", mock.Anything).Return(&provisioning.TestResults{
+ Code: http.StatusOK,
+ Success: true,
+ }, nil)
+
+ results, err := tester.TestRepository(context.Background(), repository)
+ require.NoError(t, err)
+ require.NotNil(t, results)
+ require.Equal(t, http.StatusOK, results.Code)
+ require.True(t, results.Success)
+}
diff --git a/pkg/registry/apis/provisioning/repository/workflows.go b/pkg/registry/apis/provisioning/repository/workflows.go
new file mode 100644
index 00000000000..2b98f2b438a
--- /dev/null
+++ b/pkg/registry/apis/provisioning/repository/workflows.go
@@ -0,0 +1,40 @@
+package repository
+
+import (
+ "net/http"
+
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+)
+
+func IsWriteAllowed(repo *provisioning.Repository, ref string) error {
+ if len(repo.Spec.Workflows) == 0 {
+ return apierrors.NewBadRequest("this repository is read only")
+ }
+
+ if ref == "" {
+ for _, v := range repo.Spec.Workflows {
+ if v == provisioning.WriteWorkflow {
+ return nil // found
+ }
+ }
+ return apierrors.NewBadRequest("this repository does not support the write workflow")
+ }
+
+ // Only github
+ if repo.Spec.Type != provisioning.GitHubRepositoryType {
+ return &apierrors.StatusError{ErrStatus: v1.Status{
+ Code: http.StatusPreconditionFailed,
+ Message: "Only github supports writing to a branch",
+ }}
+ }
+
+ for _, v := range repo.Spec.Workflows {
+ if v == provisioning.BranchWorkflow {
+ return nil
+ }
+ }
+ return apierrors.NewBadRequest("this repository does not support the branch workflow")
+}
diff --git a/pkg/registry/apis/provisioning/request.go b/pkg/registry/apis/provisioning/request.go
new file mode 100644
index 00000000000..616c86538f2
--- /dev/null
+++ b/pkg/registry/apis/provisioning/request.go
@@ -0,0 +1,69 @@
+package provisioning
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+)
+
+const (
+ // contentTypeJSON is the Content-Type for JSON requests as go standard library does not provide one
+ contentTypeJSON = "application/json"
+ // defaultMaxBodySize is the default max size for request bodies (10KB)
+ defaultMaxBodySize = 10 * 1024
+ // errMsgRequestTooLarge is the error message for request bodies that are too large
+ errMsgRequestTooLarge = "request body too large"
+)
+
+// readBody reads the request body and limits the size
+func readBody(r *http.Request, maxSize int64) ([]byte, error) {
+ limitedBody := http.MaxBytesReader(nil, r.Body, maxSize)
+ body, err := io.ReadAll(limitedBody)
+ if err != nil {
+ var maxBytesError *http.MaxBytesError
+ if errors.As(err, &maxBytesError) {
+ return nil, fmt.Errorf("%s: max size %d bytes", errMsgRequestTooLarge, maxSize)
+ }
+ return nil, fmt.Errorf("error reading request body: %w", err)
+ }
+
+ return body, nil
+}
+
+// isJSONContentType checks if the request has the JSON Content-Type
+func isJSONContentType(r *http.Request) bool {
+ contentType := r.Header.Get("Content-Type")
+ return strings.HasPrefix(contentType, contentTypeJSON)
+}
+
+// unmarshalJSON unmarshals the request body into the provided interface
+// it also checks the Content-Type and limits the size of the request body
+func unmarshalJSON(r *http.Request, maxSize int64, v interface{}) error {
+ if !isJSONContentType(r) {
+ return fmt.Errorf("content type is not JSON: %s", r.Header.Get("Content-Type"))
+ }
+
+ r.Body = http.MaxBytesReader(nil, r.Body, maxSize)
+ decoder := json.NewDecoder(r.Body)
+ decoder.DisallowUnknownFields()
+
+ if err := decoder.Decode(v); err != nil {
+ var maxBytesError *http.MaxBytesError
+ if errors.As(err, &maxBytesError) {
+ return fmt.Errorf("%s: max size %d bytes", errMsgRequestTooLarge, maxSize)
+ }
+ if err == io.EOF {
+ return fmt.Errorf("empty request body")
+ }
+ return fmt.Errorf("error decoding JSON: %w", err)
+ }
+
+ if decoder.More() {
+ return fmt.Errorf("multiple JSON objects not allowed")
+ }
+
+ return nil
+}
diff --git a/pkg/registry/apis/provisioning/request_test.go b/pkg/registry/apis/provisioning/request_test.go
new file mode 100644
index 00000000000..3fedd8d550b
--- /dev/null
+++ b/pkg/registry/apis/provisioning/request_test.go
@@ -0,0 +1,176 @@
+package provisioning
+
+import (
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestReadBody(t *testing.T) {
+ tests := []struct {
+ name string
+ body string
+ maxSize int64
+ wantErr bool
+ errContains string
+ }{
+ {
+ name: "valid small body",
+ body: "hello",
+ maxSize: 10,
+ },
+ {
+ name: "body too large",
+ body: "this is a very long body that exceeds the limit",
+ maxSize: 10,
+ wantErr: true,
+ errContains: errMsgRequestTooLarge,
+ },
+ {
+ name: "body exactly at limit",
+ body: "1234567890",
+ maxSize: 10,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := httptest.NewRequest("POST", "/", strings.NewReader(tt.body))
+ got, err := readBody(r, tt.maxSize)
+
+ if tt.wantErr {
+ if err == nil {
+ t.Error("readBody() expected error but got none")
+ }
+ if !strings.Contains(err.Error(), tt.errContains) {
+ t.Errorf("readBody() error = %v, want containing %v", err, tt.errContains)
+ }
+ return
+ }
+ if err != nil {
+ t.Errorf("readBody() unexpected error = %v", err)
+ return
+ }
+ if string(got) != tt.body {
+ t.Errorf("readBody() = %v, want %v", string(got), tt.body)
+ }
+ })
+ }
+}
+
+func TestIsJSONContentType(t *testing.T) {
+ tests := []struct {
+ name string
+ contentType string
+ want bool
+ }{
+ {
+ name: "valid JSON content type",
+ contentType: "application/json",
+ want: true,
+ },
+ {
+ name: "JSON with charset",
+ contentType: "application/json; charset=utf-8",
+ want: true,
+ },
+ {
+ name: "not JSON",
+ contentType: "text/plain",
+ want: false,
+ },
+ {
+ name: "empty content type",
+ contentType: "",
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := httptest.NewRequest("POST", "/", nil)
+ r.Header.Set("Content-Type", tt.contentType)
+ if got := isJSONContentType(r); got != tt.want {
+ t.Errorf("isJSONContentType() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestUnmarshalJSON(t *testing.T) {
+ type testStruct struct {
+ Name string `json:"name"`
+ Age int `json:"age"`
+ }
+
+ tests := []struct {
+ name string
+ body string
+ maxSize int64
+ contentType string
+ wantErr bool
+ errContains string
+ }{
+ {
+ name: "valid JSON",
+ body: `{"name":"test","age":30}`,
+ maxSize: 1024,
+ contentType: contentTypeJSON,
+ },
+ {
+ name: "invalid content type",
+ body: `{"name":"test"}`,
+ maxSize: 1024,
+ contentType: "text/plain",
+ wantErr: true,
+ errContains: "content type is not JSON",
+ },
+ {
+ name: "body too large",
+ body: `{"name":"test","age":30}`,
+ maxSize: 10,
+ contentType: contentTypeJSON,
+ wantErr: true,
+ errContains: errMsgRequestTooLarge,
+ },
+ {
+ name: "multiple JSON objects",
+ body: `{"name":"test"} {"name":"test2"}`,
+ maxSize: 1024,
+ contentType: contentTypeJSON,
+ wantErr: true,
+ errContains: "multiple JSON objects not allowed",
+ },
+ {
+ name: "empty body",
+ body: "",
+ maxSize: 1024,
+ contentType: contentTypeJSON,
+ wantErr: true,
+ errContains: "empty request body",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := httptest.NewRequest("POST", "/", strings.NewReader(tt.body))
+ r.Header.Set("Content-Type", tt.contentType)
+
+ var result testStruct
+ err := unmarshalJSON(r, tt.maxSize, &result)
+
+ if tt.wantErr {
+ if err == nil {
+ t.Error("unmarshalJSON() expected error but got none")
+ }
+ if !strings.Contains(err.Error(), tt.errContains) {
+ t.Errorf("unmarshalJSON() error = %v, want containing %v", err, tt.errContains)
+ }
+ return
+ }
+ if err != nil {
+ t.Errorf("unmarshalJSON() unexpected error = %v", err)
+ }
+ })
+ }
+}
diff --git a/pkg/registry/apis/provisioning/resources/client.go b/pkg/registry/apis/provisioning/resources/client.go
new file mode 100644
index 00000000000..29d13db3b58
--- /dev/null
+++ b/pkg/registry/apis/provisioning/resources/client.go
@@ -0,0 +1,167 @@
+package resources
+
+import (
+ "context"
+ "fmt"
+ "sync"
+
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/client-go/dynamic"
+
+ dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
+ folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
+ iam "github.com/grafana/grafana/pkg/apis/iam/v0alpha1"
+ "github.com/grafana/grafana/pkg/services/apiserver"
+ "github.com/grafana/grafana/pkg/services/apiserver/client"
+)
+
+type ClientFactory struct {
+ configProvider apiserver.RestConfigProvider
+}
+
+func NewClientFactory(configProvider apiserver.RestConfigProvider) *ClientFactory {
+ return &ClientFactory{configProvider}
+}
+
+func (f *ClientFactory) Clients(ctx context.Context, namespace string) (*ResourceClients, error) {
+ restConfig, err := f.configProvider.GetRestConfig(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ if namespace == "" {
+ return nil, fmt.Errorf("missing namespace")
+ }
+
+ discovery, err := client.NewDiscoveryClient(restConfig)
+ if err != nil {
+ return nil, err
+ }
+
+ client, err := dynamic.NewForConfig(restConfig)
+ if err != nil {
+ return nil, err
+ }
+
+ return &ResourceClients{
+ namespace: namespace,
+ discovery: discovery,
+ dynamic: client,
+ byKind: make(map[schema.GroupVersionKind]*clientInfo),
+ byResource: make(map[schema.GroupVersionResource]*clientInfo),
+ }, nil
+}
+
+type ResourceClients struct {
+ namespace string
+
+ dynamic dynamic.Interface
+ discovery client.DiscoveryClient
+
+ // ResourceInterface cache for this context + namespace
+ mutex sync.Mutex
+ byKind map[schema.GroupVersionKind]*clientInfo
+ byResource map[schema.GroupVersionResource]*clientInfo
+}
+
+type clientInfo struct {
+ gvk schema.GroupVersionKind
+ gvr schema.GroupVersionResource
+ client dynamic.ResourceInterface
+}
+
+func (c *ResourceClients) ForKind(gvk schema.GroupVersionKind) (dynamic.ResourceInterface, schema.GroupVersionResource, error) {
+ c.mutex.Lock()
+ defer c.mutex.Unlock()
+
+ info, ok := c.byKind[gvk]
+ if ok && info.client != nil {
+ return info.client, info.gvr, nil
+ }
+
+ gvr, err := c.discovery.GetResourceForKind(gvk)
+ if err != nil {
+ return nil, schema.GroupVersionResource{}, err
+ }
+ info = &clientInfo{
+ gvk: gvk,
+ gvr: gvr,
+ client: c.dynamic.Resource(gvr).Namespace(c.namespace),
+ }
+ c.byKind[gvk] = info
+ c.byResource[gvr] = info
+ return info.client, info.gvr, nil
+}
+
+func (c *ResourceClients) ForResource(gvr schema.GroupVersionResource) (dynamic.ResourceInterface, schema.GroupVersionKind, error) {
+ c.mutex.Lock()
+ defer c.mutex.Unlock()
+
+ info, ok := c.byResource[gvr]
+ if ok && info.client != nil {
+ return info.client, info.gvk, nil
+ }
+
+ var err error
+ var gvk schema.GroupVersionKind
+ var versionless schema.GroupVersionResource
+ if gvr.Version == "" {
+ versionless = gvr
+ gvr, gvk, err = c.discovery.GetPreferredVesion(schema.GroupResource{
+ Group: gvr.Group,
+ Resource: gvr.Resource,
+ })
+ if err != nil {
+ return nil, schema.GroupVersionKind{}, err
+ }
+
+ info, ok := c.byResource[gvr]
+ if ok && info.client != nil {
+ c.byResource[versionless] = info
+ return info.client, info.gvk, nil
+ }
+ } else {
+ gvk, err = c.discovery.GetKindForResource(gvr)
+ if err != nil {
+ return nil, schema.GroupVersionKind{}, err
+ }
+ }
+ info = &clientInfo{
+ gvk: gvk,
+ gvr: gvr,
+ client: c.dynamic.Resource(gvr).Namespace(c.namespace),
+ }
+ c.byKind[gvk] = info
+ c.byResource[gvr] = info
+ if versionless.Group != "" {
+ c.byResource[versionless] = info
+ }
+ return info.client, info.gvk, nil
+}
+
+func (c *ResourceClients) Folder() (dynamic.ResourceInterface, error) {
+ v, _, err := c.ForResource(schema.GroupVersionResource{
+ Group: folders.GROUP,
+ Version: folders.VERSION,
+ Resource: folders.RESOURCE,
+ })
+ return v, err
+}
+
+func (c *ResourceClients) User() (dynamic.ResourceInterface, error) {
+ v, _, err := c.ForResource(schema.GroupVersionResource{
+ Group: iam.GROUP,
+ Version: iam.VERSION,
+ Resource: iam.UserResourceInfo.GroupResource().Resource,
+ })
+ return v, err
+}
+
+func (c *ResourceClients) Dashboard() (dynamic.ResourceInterface, error) {
+ v, _, err := c.ForResource(schema.GroupVersionResource{
+ Group: dashboard.GROUP,
+ Version: dashboard.VERSION,
+ Resource: dashboard.DASHBOARD_RESOURCE,
+ })
+ return v, err
+}
diff --git a/pkg/registry/apis/provisioning/resources/fileformat.go b/pkg/registry/apis/provisioning/resources/fileformat.go
new file mode 100644
index 00000000000..e2805954343
--- /dev/null
+++ b/pkg/registry/apis/provisioning/resources/fileformat.go
@@ -0,0 +1,107 @@
+package resources
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/apimachinery/pkg/runtime/serializer/yaml"
+
+ "github.com/grafana/grafana-app-sdk/logging"
+ dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+)
+
+var ErrUnableToReadResourceBytes = errors.New("unable to read bytes as a resource")
+var ErrClassicResourceIsAlreadyK8sForm = errors.New("classic resource is already structured with apiVersion and kind")
+
+// This reads a "classic" file format and will convert it to an unstructured k8s resource
+// The file path may determine how the resource is parsed
+//
+// The context and logger are both only used for logging purposes. They do not control any logic.
+func ReadClassicResource(ctx context.Context, info *repository.FileInfo) (*unstructured.Unstructured, *schema.GroupVersionKind, provisioning.ClassicFileType, error) {
+ var value map[string]any
+
+ // Try parsing as JSON
+ if info.Data[0] == '{' {
+ err := json.Unmarshal(info.Data, &value)
+ if err != nil {
+ return nil, nil, "", err
+ }
+ } else {
+ return nil, nil, "", fmt.Errorf("yaml not yet implemented")
+ }
+
+ // regular version headers exist
+ // TODO: do we intend on this checking Kind or kind? document reasoning.
+ if value["apiVersion"] != nil {
+ if value["kind"] != nil {
+ return nil, nil, "", ErrClassicResourceIsAlreadyK8sForm
+ }
+
+ logging.FromContext(ctx).Debug("TODO... likely a provisioning",
+ "apiVersion", value["apiVersion"],
+ "kind", value["Kind"])
+ gv, err := schema.ParseGroupVersion(value["apiVersion"].(string))
+ if err != nil {
+ return nil, nil, "", fmt.Errorf("invalid apiVersion")
+ }
+ gvk := gv.WithKind(value["Kind"].(string))
+ return &unstructured.Unstructured{Object: value}, &gvk, "", nil
+ }
+
+ // If this is a dashboard, convert it
+ if value["panels"] != nil &&
+ value["schemaVersion"] != nil &&
+ value["tags"] != nil {
+ gvk := &schema.GroupVersionKind{
+ Group: dashboard.GROUP,
+ Version: dashboard.VERSION, // v1
+ Kind: "Dashboard"}
+ return &unstructured.Unstructured{
+ Object: map[string]interface{}{
+ "apiVersion": gvk.GroupVersion().String(),
+ "kind": gvk.Kind,
+ "metadata": map[string]any{
+ "name": value["uid"],
+ },
+ "spec": value,
+ },
+ }, gvk, provisioning.ClassicDashboard, nil
+ }
+
+ return nil, nil, "", ErrUnableToReadResourceBytes
+}
+
+// DecodeYAMLObject reads the input as YAML and outputs its Kubernetes resource, if it is one.
+// Note that all JSON is also valid YAML, so this can also be used for JSON data.
+func DecodeYAMLObject(input io.Reader) (*unstructured.Unstructured, *schema.GroupVersionKind, error) {
+ data, err := io.ReadAll(input)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ obj, gvk, err := yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme).
+ Decode(data, nil, nil)
+ if err != nil {
+ return nil, gvk, err
+ }
+
+ // The decoder should put it directly into an unstructured object
+ val, ok := obj.(*unstructured.Unstructured)
+ if ok {
+ return val, gvk, err
+ }
+
+ unstructuredMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj)
+ if err != nil {
+ return nil, gvk, err
+ }
+ return &unstructured.Unstructured{Object: unstructuredMap}, gvk, err
+}
diff --git a/pkg/registry/apis/provisioning/resources/fileformat_test.go b/pkg/registry/apis/provisioning/resources/fileformat_test.go
new file mode 100644
index 00000000000..925272dfa59
--- /dev/null
+++ b/pkg/registry/apis/provisioning/resources/fileformat_test.go
@@ -0,0 +1,118 @@
+package resources
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "path"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+)
+
+func TestUtils(t *testing.T) {
+ t.Run("load playlist json", func(t *testing.T) {
+ obj, gvk, err := DecodeYAMLObject(bytes.NewReader([]byte(`{
+ "kind": "Playlist",
+ "apiVersion": "playlist.grafana.app/v0alpha1",
+ "metadata": {
+ "name": "hello"
+ },
+ "spec": {
+ "title": "Playlist from provisioning"
+ }
+ }`)))
+
+ require.NoError(t, err)
+ require.Equal(t, &schema.GroupVersionKind{
+ Group: "playlist.grafana.app",
+ Version: "v0alpha1",
+ Kind: "Playlist",
+ }, gvk)
+ require.NotNil(t, obj)
+ })
+
+ t.Run("YAML Parsing", func(t *testing.T) {
+ obj, gvk, err := DecodeYAMLObject(bytes.NewReader([]byte("kind: xyz\npi: 3.1415")))
+ require.NoError(t, err)
+ require.NotNil(t, gvk)
+ require.Equal(t, "xyz", obj.Object["kind"])
+ require.Equal(t, 3.1415, obj.Object["pi"])
+
+ // // Tabs in the value
+ // _, _, err = LoadYAMLOrJSON(bytes.NewReader([]byte("kind: xyz\n\tpi: 3.1415")))
+ // require.Equal(t, ErrYamlContainsTabs, err)
+ })
+
+ t.Run("load playlist yaml", func(t *testing.T) {
+ obj, gvk, err := DecodeYAMLObject(bytes.NewReader([]byte(`
+apiVersion: playlist.grafana.app/v0alpha1
+kind: Playlist
+metadata:
+ name: hello
+spec:
+ title: a title
+`)))
+
+ require.NoError(t, err)
+ require.Equal(t, &schema.GroupVersionKind{
+ Group: "playlist.grafana.app",
+ Version: "v0alpha1",
+ Kind: "Playlist",
+ }, gvk)
+ require.NotNil(t, obj)
+ })
+
+ t.Run("load dashboard json", func(t *testing.T) {
+ // Support dashboard conversion
+ obj, gvk, classic, err := ReadClassicResource(context.Background(), &repository.FileInfo{
+ Data: []byte(`{
+ "schemaVersion": 7,
+ "panels": [],
+ "tags": []
+ }`),
+ })
+
+ require.NoError(t, err)
+ require.Equal(t, provisioning.ClassicDashboard, classic)
+ require.Equal(t, &schema.GroupVersionKind{
+ Group: "dashboard.grafana.app",
+ Version: "v0alpha1",
+ Kind: "Dashboard",
+ }, gvk)
+ require.NotNil(t, obj)
+ })
+
+ t.Run("lint dashboard", func(t *testing.T) {
+ var err error
+ info := &repository.FileInfo{
+ Path: "devenv/dev-dashboards/panel-timeline/timeline-demo.json",
+ }
+ info.Data, err = os.ReadFile(path.Join("../../../../..", info.Path))
+ require.NoError(t, err)
+
+ parser := &Parser{
+ repo: provisioning.ResourceRepositoryInfo{
+ Name: "test",
+ },
+ }
+
+ // try to validate (and lint)
+ validate := true
+
+ // Support dashboard conversion
+ parsed, err := parser.Parse(context.Background(), info, validate)
+ require.Error(t, err) // no clients configured!
+
+ require.Equal(t, provisioning.ClassicDashboard, parsed.Classic)
+ require.Equal(t, &schema.GroupVersionKind{
+ Group: "dashboard.grafana.app",
+ Version: "v0alpha1",
+ Kind: "Dashboard",
+ }, parsed.GVK)
+ })
+}
diff --git a/pkg/registry/apis/provisioning/resources/filepath.go b/pkg/registry/apis/provisioning/resources/filepath.go
new file mode 100644
index 00000000000..ca592904363
--- /dev/null
+++ b/pkg/registry/apis/provisioning/resources/filepath.go
@@ -0,0 +1,42 @@
+package resources
+
+import (
+ "errors"
+ "path"
+
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
+)
+
+var (
+ ErrPathTooDeep = errors.New("the path is too deep")
+ ErrUnsupportedFileExtension = errors.New("unsupported file extension")
+ ErrNotRelative = errors.New("path must be relative to the root")
+)
+
+const maxPathDepth = 8
+
+// IsPathSupported checks if the file path is supported by the provisioning API.
+// it also validates if the path is safe and if the file extension is supported.
+func IsPathSupported(filePath string) error {
+ // Validate the path for any traversal attempts first
+ if err := safepath.IsSafe(filePath); err != nil {
+ return err
+ }
+
+ if safepath.Depth(filePath) > maxPathDepth {
+ return ErrPathTooDeep
+ }
+
+ if safepath.IsAbs(filePath) {
+ return ErrNotRelative
+ }
+
+ // Only check file extension if it's not a folder path
+ if !safepath.IsDir(filePath) {
+ if ext := path.Ext(filePath); ext != ".yml" && ext != ".yaml" && ext != ".json" {
+ return ErrUnsupportedFileExtension
+ }
+ }
+
+ return nil
+}
diff --git a/pkg/registry/apis/provisioning/resources/filepath_test.go b/pkg/registry/apis/provisioning/resources/filepath_test.go
new file mode 100644
index 00000000000..89cf98180fa
--- /dev/null
+++ b/pkg/registry/apis/provisioning/resources/filepath_test.go
@@ -0,0 +1,72 @@
+package resources
+
+import (
+ "testing"
+
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
+ "github.com/stretchr/testify/require"
+)
+
+func TestIsPathSupported(t *testing.T) {
+ tests := []struct {
+ name string
+ path string
+ expectedErr error
+ }{
+ {
+ name: "valid yaml file",
+ path: "dashboards/my-dashboard.yaml",
+ },
+ {
+ name: "valid yml file",
+ path: "dashboards/my-dashboard.yml",
+ },
+ {
+ name: "valid json file",
+ path: "dashboards/my-dashboard.json",
+ },
+ {
+ name: "valid nested path",
+ path: "dashboards/folder1/folder2/my-dashboard.yaml",
+ },
+ {
+ name: "valid directory path",
+ path: "dashboards/folder1/",
+ },
+ {
+ name: "unsupported file extension",
+ path: "dashboards/my-dashboard.txt",
+ expectedErr: ErrUnsupportedFileExtension,
+ },
+ {
+ name: "path traversal attempt",
+ path: "../dashboards/my-dashboard.yaml",
+ expectedErr: safepath.ErrPathTraversalAttempt,
+ },
+ {
+ name: "path too deep",
+ path: "level1/level2/level3/level4/level5/level6/level7/level8/level9/dashboard.yaml",
+ expectedErr: ErrPathTooDeep,
+ },
+ {
+ name: "absolute path",
+ path: "/etc/dashboards/my-dashboard.yaml",
+ expectedErr: ErrNotRelative,
+ },
+ {
+ name: "empty directory path",
+ path: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := IsPathSupported(tt.path)
+ if tt.expectedErr != nil {
+ require.ErrorIs(t, err, tt.expectedErr)
+ } else {
+ require.NoError(t, err)
+ }
+ })
+ }
+}
diff --git a/pkg/registry/apis/provisioning/resources/folders.go b/pkg/registry/apis/provisioning/resources/folders.go
new file mode 100644
index 00000000000..c301d676720
--- /dev/null
+++ b/pkg/registry/apis/provisioning/resources/folders.go
@@ -0,0 +1,133 @@
+package resources
+
+import (
+ "context"
+ "fmt"
+
+ 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/client-go/dynamic"
+
+ "github.com/grafana/grafana/pkg/apimachinery/utils"
+ "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
+)
+
+type FolderManager struct {
+ repo repository.Repository
+ lookup *FolderTree
+ client dynamic.ResourceInterface
+}
+
+func NewFolderManager(repo repository.Repository, client dynamic.ResourceInterface) *FolderManager {
+ return &FolderManager{
+ repo: repo,
+ lookup: NewEmptyFolderTree(),
+ client: client,
+ }
+}
+
+func (fm *FolderManager) Client() dynamic.ResourceInterface {
+ return fm.client
+}
+
+// EnsureFoldersExist creates the folder structure in the cluster.
+func (fm *FolderManager) EnsureFolderPathExist(ctx context.Context, filePath string) (parent string, err error) {
+ cfg := fm.repo.Config()
+ parent = RootFolder(cfg)
+
+ dir := filePath
+ if !safepath.IsDir(filePath) {
+ dir = safepath.Dir(filePath)
+ }
+
+ if dir == "" {
+ return parent, nil
+ }
+
+ f := ParseFolder(dir, cfg.Name)
+ if fm.lookup.In(f.ID) {
+ return f.ID, nil
+ }
+
+ err = safepath.Walk(ctx, f.Path, func(ctx context.Context, traverse string) error {
+ f := ParseFolder(traverse, cfg.GetName())
+ if fm.lookup.In(f.ID) {
+ parent = f.ID
+ return nil
+ }
+
+ if err := fm.EnsureFolderExists(ctx, f, parent); err != nil {
+ return fmt.Errorf("ensure folder exists: %w", err)
+ }
+
+ fm.lookup.Add(f, parent)
+ parent = f.ID
+ return nil
+ })
+
+ if err != nil {
+ return "", err
+ }
+
+ return f.ID, nil
+}
+
+// EnsureFolderExists creates the folder if it doesn't exist.
+// If the folder already exists:
+// - it will error if the folder is not owned by this repository
+func (fm *FolderManager) EnsureFolderExists(ctx context.Context, folder Folder, parent string) error {
+ cfg := fm.repo.Config()
+ obj, err := fm.client.Get(ctx, folder.ID, metav1.GetOptions{})
+ if err == nil {
+ current, ok := obj.GetAnnotations()[utils.AnnoKeyManagerIdentity]
+ if !ok {
+ return fmt.Errorf("target folder is not managed by a repository")
+ }
+ if current != cfg.Name {
+ return fmt.Errorf("target folder is managed by a different repository (%s)", current)
+ }
+ return nil
+ } else if !apierrors.IsNotFound(err) {
+ return fmt.Errorf("failed to check if folder exists: %w", err)
+ }
+
+ obj = &unstructured.Unstructured{
+ Object: map[string]interface{}{
+ "spec": map[string]any{
+ "title": folder.Title,
+ },
+ },
+ }
+ obj.SetAPIVersion(v0alpha1.APIVERSION)
+ obj.SetKind(v0alpha1.FolderResourceInfo.GroupVersionKind().Kind)
+ obj.SetNamespace(cfg.GetNamespace())
+ obj.SetName(folder.ID)
+
+ meta, err := utils.MetaAccessor(obj)
+ if err != nil {
+ return fmt.Errorf("create meta accessor for the object: %w", err)
+ }
+
+ if parent != "" {
+ meta.SetFolder(parent)
+ }
+ meta.SetManagerProperties(utils.ManagerProperties{
+ Kind: utils.ManagerKindRepo,
+ Identity: cfg.GetName(),
+ })
+ meta.SetSourceProperties(utils.SourceProperties{
+ Path: folder.Path,
+ })
+
+ if _, err := fm.client.Create(ctx, obj, metav1.CreateOptions{}); err != nil {
+ return fmt.Errorf("failed to create folder: %w", err)
+ }
+ return nil
+}
+
+func (fm *FolderManager) GetFolder(ctx context.Context, name string) (*unstructured.Unstructured, error) {
+ return fm.client.Get(ctx, name, metav1.GetOptions{})
+}
diff --git a/pkg/registry/apis/provisioning/resources/id.go b/pkg/registry/apis/provisioning/resources/id.go
new file mode 100644
index 00000000000..b9d14734c10
--- /dev/null
+++ b/pkg/registry/apis/provisioning/resources/id.go
@@ -0,0 +1,141 @@
+package resources
+
+import (
+ "crypto/sha256"
+ "encoding/base64"
+ "strings"
+
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
+)
+
+// sanitiseKubeName removes all characters that don't fulfil the DNS subdomain name rules: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-subdomain-names
+// > contain no more than 253 characters
+// > contain only lowercase alphanumeric characters, '-' or '.'
+// > start with an alphanumeric character
+// > end with an alphanumeric character
+//
+// That said, the Kubernetes name is extended with one more rule: it must not contain more than 40 characters.
+// We do this to support storage modes that write to legacy storage, where UIDs are limited to 40 characters or less.
+//
+// If no characters are valid, this returns an empty string.
+func sanitiseKubeName(s string) string {
+ // Note: Builder never returns an error.
+ var b strings.Builder
+ lastHyphen := false // Having at most 1 hyphen in a row is not a requirement, but is closer to standard convention.
+ for _, r := range strings.ToLower(s) {
+ if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '-' {
+ // We don't want to have multiple hyphens following one another.
+ if !lastHyphen || r != '-' {
+ lastHyphen = r == '-'
+ _, _ = b.WriteRune(r)
+ }
+ } else if r >= 'A' && r <= 'Z' {
+ lastHyphen = false
+ r += 'a' - 'A'
+ _, _ = b.WriteRune(r)
+ } else if (r == '_' || r == '/') && !lastHyphen { // Special-case for common characters that we still want to keep somehow
+ lastHyphen = true
+ _, _ = b.WriteRune('-')
+ }
+ // Else, skip it, silently.
+
+ if b.Len() == 40 {
+ // Technically, we could be less than 40 after some more cleaning... but this is good enough.
+ break
+ }
+ }
+ str := b.String()
+ // We must start and end with alphanumerics.
+ str = strings.Trim(str, "-")
+ str = strings.Trim(str, ".")
+
+ return str
+}
+
+// appendHashSuffix creates a function that modifies an input string to include a hash suffix.
+// The goal of this function is to represent all needs for hashing IDs.
+//
+// The hash uses the input hashKey and repositoryName as a salt.
+// The output string is at most 40 characters long. (253 is Kubernetes' limit, but UIDs have a max of 40 characters.)
+// The output string is a valid Kubernetes name (see [sanitiseKubeName]).
+// The output string contains at least 12 characters of a hash, plus a 1 character hyphen to separate the input and the hash.
+// The function is deterministic given the same invocation parameters to this function.
+func appendHashSuffix(hashKey, repositoryName string) func(string) string {
+ salt := []byte(repositoryName + "/" + hashKey)
+
+ const maxLen = 40 // valid Kubernetes name
+ const minSuffix = 8 // excluding hyphen
+ const minSpace = minSuffix + 1 // +1 for hyphen
+
+ return func(s string) string {
+ hasher := sha256.New()
+ // From hash.Hash docs:
+ // > Write (via the embedded io.Writer interface) adds more data to the running hash.
+ // > It never returns an error.
+ // As such, we ignore all errors.
+ _, _ = hasher.Write(salt) // Input to the parent function
+ _, _ = hasher.Write([]byte(s))
+ hash := base64.URLEncoding.EncodeToString(hasher.Sum(nil))
+ hash = sanitiseKubeName(hash) // We have rules to follow, as per our doc contract
+
+ if len(s) > maxLen-minSpace {
+ s = s[:maxLen-minSpace]
+ }
+
+ spaceForHash := maxLen - len(s) - 1
+ if spaceForHash < len(hash) {
+ hash = hash[:spaceForHash]
+ }
+ return sanitiseKubeName(s + "-" + hash)
+ }
+}
+
+// Will pick a name based on the hashed repository and path
+func NamesFromHashedRepoPath(repo string, fpath string) (string, string) {
+ // Remove the extension: we don't want the extension to impact the ID. This lets the user change between all supported formats.
+ fpath = safepath.RemoveExt(fpath)
+ hasher := appendHashSuffix(fpath, repo)
+
+ return hasher(safepath.Base(fpath)), hasher(strings.Trim(safepath.Dir(fpath), "/"))
+}
+
+// Folder contains the data for a folder we use in provisioning.
+type Folder struct {
+ // Title is the human-readable name created by a human who wrote it.
+ Title string
+ // ID represents the name the folder should have, derived from the title.
+ // It contains a suffix calculated from the path of the folder.
+ // The ID is used in Kubernetes and the folders API server. This is the same as the legacy (and by the time you read this, hopefully removed) UID concept of folders.
+ ID string
+ // Path is the full path to the folder, as given to the parse function.
+ Path string
+}
+
+func ParseFolder(dirPath, repositoryName string) Folder {
+ hasher := appendHashSuffix(strings.TrimSuffix(dirPath, "/"), repositoryName)
+ base := safepath.Base(dirPath)
+
+ return Folder{
+ Title: base,
+ ID: hasher(sanitiseKubeName(base)),
+ Path: dirPath,
+ }
+}
+
+func RootFolder(repository *provisioning.Repository) string {
+ if repository.Spec.Sync.Target == provisioning.SyncTargetTypeFolder {
+ return repository.Name // a folder with the same identifier as the repository
+ }
+
+ return ""
+}
+
+func ParentFolder(filePath string, repository *provisioning.Repository) string {
+ parent := safepath.Dir(filePath)
+ if parent == "" {
+ return RootFolder(repository)
+ }
+
+ return ParseFolder(parent, repository.GetName()).ID
+}
diff --git a/pkg/registry/apis/provisioning/resources/id_test.go b/pkg/registry/apis/provisioning/resources/id_test.go
new file mode 100644
index 00000000000..77c319b72a1
--- /dev/null
+++ b/pkg/registry/apis/provisioning/resources/id_test.go
@@ -0,0 +1,109 @@
+package resources
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSanitiseKubeName(t *testing.T) {
+ for _, tc := range []struct{ Name, Input, Expected string }{
+ {"Valid Kubernetes name", "a-b.123.-c", "a-b.123.-c"},
+ {"Capital letters lowercase", "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"},
+ {"Trailing hyphen removed", "abc-", "abc"},
+ {"Trailing dot removed", "abc.", "abc"},
+ {"Leading hyphen removed", "-abc", "abc"},
+ {"Leading dot removed", ".abc", "abc"},
+ {"Double hyphen simplified", "ab--c", "ab-c"},
+ {"Five hyphens simplified", "ab-----c", "ab-c"},
+ {"Underscore converted", "ab_c", "ab-c"},
+ {"Slash converted", "ab/c", "ab-c"},
+ {"Stops at 40 characters", strings.Repeat("a", 300), strings.Repeat("a", 40)},
+ } {
+ t.Run(tc.Name, func(t *testing.T) {
+ assert.Equal(t, tc.Expected, sanitiseKubeName(tc.Input))
+ })
+ }
+}
+
+func TestAppendHashSuffix(t *testing.T) {
+ t.Run("hash key is part of hash", func(t *testing.T) {
+ keyA := appendHashSuffix("A", "unit-test")("test")
+ keyB := appendHashSuffix("B", "unit-test")("test")
+ assert.NotEqual(t, keyA, keyB, "hash key is not part of hash")
+ })
+ t.Run("repository name is part of hash", func(t *testing.T) {
+ repoA := appendHashSuffix("unit-test", "A")("test")
+ repoB := appendHashSuffix("unit-test", "B")("test")
+ assert.NotEqual(t, repoA, repoB, "repo name is not part of hash")
+ })
+ t.Run("is deterministic", func(t *testing.T) {
+ hasher := appendHashSuffix("unit", "test")
+ assert.Equal(t, hasher("a"), hasher("a"))
+ _ = hasher("b") // Assume it isn't deterministic: this would likely modify a hasher state
+ assert.Equal(t, hasher("a"), hasher("a")) // alas, it is deterministic!
+ })
+
+ // These are covered by the tests above, so we can just use static values from now on
+ const repoName = "repository"
+ const hashKey = "key"
+ hasher := appendHashSuffix(hashKey, repoName)
+
+ for _, tc := range []struct{ Name, Input, Expected string }{
+ {"Simple, short prefix", "test", "test-ae2h65vh3ygoxmprmnludpyhhpr3d-5iosy"},
+ {"Suffix requiring cutting hash to min", strings.Repeat("test", 200), "testtesttesttesttesttesttesttes-8dogmh7b"},
+ {"Suffix requiring partially cutting hash", strings.Repeat("test", 7), "testtesttesttesttesttesttest-icauzj-i5j5"},
+ } {
+ t.Run(tc.Name, func(t *testing.T) {
+ hashed := hasher(tc.Input)
+ assert.Equal(t, tc.Expected, hashed, "hashed value must be as expected")
+
+ // These exist both because they're helpful to understand how something isn't equal above, and for programmer errors. (e.g. what if I manually input a 41 char expected value? or one with too few hash chars?)
+ // We only want 40 characters because UIDs support no more. When we get rid of legacy storage, we can extend the support to 253 character long strings.
+ assert.LessOrEqual(t, len(hashed), 40, "string after hashing needs to be <=40 chars long")
+ assert.GreaterOrEqual(t, len(strings.SplitAfterN(hashed, "-", 2)[1]), 8, "hash must be at least 8 characters long")
+ })
+ }
+}
+
+func TestNamesFromHashedRepoPath(t *testing.T) {
+ dashName, folderName := NamesFromHashedRepoPath("xyz", "path/to/folder/dashboard.json")
+ assert.Equal(t, "dashboard-fy2kflbmskvt6u-9uecoahd1ekwbb7", dashName, "dashboard name of dashboard.json")
+ assert.Equal(t, "path-to-folder-3ehfurpmbvs4yxfp7a0r2uskr", folderName, "folder name of dashboard.json")
+ // We only want 40 characters because UIDs support no more. When we get rid of legacy storage, we can extend the support to 253 character long strings.
+ assert.LessOrEqual(t, len(dashName), 40, "dashName after hashing needs to be <=40 chars long")
+ assert.LessOrEqual(t, len(folderName), 40, "folderName after hashing needs to be <=40 chars long")
+
+ name1, f1 := NamesFromHashedRepoPath("xyz", "path/to/folder.json")
+ name2, f2 := NamesFromHashedRepoPath("xyz", "path/to/folder")
+ assert.Equal(t, name1, name2, "folder.json should have same object name as folder (no extension)")
+ assert.Equal(t, f1, f2, "folder.json and folder should have same folder name as each other")
+}
+
+func TestParseFolderID(t *testing.T) {
+ const repoName = "unit-test" // we have other tests verifying the repo name changes the id
+
+ cases := []struct {
+ Description string
+ Path string
+ Title string
+ KubeName string
+ }{
+ {"Short, simple path", "hello/world", "world", "world-wik-hjayboohlsvzzr2ob3he8cs7ffk0jd"},
+ {"Capital letters and punctuation", "Hello, World!", "Hello, World!", "helloworld-sbcnvdmezf0jnvgfhpk5ewaoawbeg"},
+ {"Very long name", strings.Repeat("/hello/world", 200), "world", "world-bc9jpbg6ctg-w-pexkul-f1ic-bwer5-3r"},
+ }
+
+ for _, c := range cases {
+ t.Run(c.Description, func(t *testing.T) {
+ id := ParseFolder(c.Path, repoName)
+ assert.Equal(t, c.Path, id.Path)
+ assert.Equal(t, c.KubeName, id.ID)
+ assert.Equal(t, c.Title, id.Title)
+
+ // We only want 40 characters because UIDs support no more. When we get rid of legacy storage, we can extend the support to 253 character long strings.
+ assert.LessOrEqual(t, len(id.ID), 40, "ID after hashing needs to be <=40 chars long")
+ })
+ }
+}
diff --git a/pkg/registry/apis/provisioning/resources/object.go b/pkg/registry/apis/provisioning/resources/object.go
new file mode 100644
index 00000000000..c5702dfe22e
--- /dev/null
+++ b/pkg/registry/apis/provisioning/resources/object.go
@@ -0,0 +1,165 @@
+package resources
+
+import (
+ "context"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
+ "github.com/grafana/grafana/pkg/apimachinery/utils"
+ folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
+ "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
+ "github.com/grafana/grafana/pkg/storage/unified/resource"
+)
+
+// Get repository stats
+type ResourceLister interface {
+ List(ctx context.Context, namespace, repository string) (*provisioning.ResourceList, error)
+ Stats(ctx context.Context, namespace, repository string) (*provisioning.ResourceStats, error)
+}
+
+type ResourceListerFromSearch struct {
+ managed resource.ManagedObjectIndexClient
+ index resource.ResourceIndexClient
+ legacyMigrator legacy.LegacyMigrator
+ storageStatus dualwrite.Service
+}
+
+func NewResourceLister(
+ managed resource.ManagedObjectIndexClient,
+ index resource.ResourceIndexClient,
+ legacyMigrator legacy.LegacyMigrator,
+ storageStatus dualwrite.Service,
+) ResourceLister {
+ return &ResourceListerFromSearch{
+ index: index,
+ managed: managed,
+ legacyMigrator: legacyMigrator,
+ storageStatus: storageStatus,
+ }
+}
+
+// List implements ResourceLister.
+func (o *ResourceListerFromSearch) List(ctx context.Context, namespace, repository string) (*provisioning.ResourceList, error) {
+ objects, err := o.managed.ListManagedObjects(ctx, &resource.ListManagedObjectsRequest{
+ Namespace: namespace,
+ Kind: string(utils.ManagerKindRepo),
+ Id: repository,
+ })
+ if err != nil {
+ return nil, err
+ }
+ if objects.Error != nil {
+ return nil, resource.GetError(objects.Error)
+ }
+
+ list := &provisioning.ResourceList{}
+ for _, v := range objects.Items {
+ list.Items = append(list.Items, provisioning.ResourceListItem{
+ Path: v.Path,
+ Group: v.Object.Group,
+ Resource: v.Object.Resource,
+ Name: v.Object.Name,
+ Hash: v.Hash,
+ Time: v.Time,
+ Title: v.Title,
+ Folder: v.Folder,
+ })
+ }
+ return list, nil
+}
+
+// Stats implements ResourceLister.
+func (o *ResourceListerFromSearch) Stats(ctx context.Context, namespace, repository string) (*provisioning.ResourceStats, error) {
+ req := &resource.CountManagedObjectsRequest{
+ Namespace: namespace,
+ }
+ if repository != "" {
+ req.Kind = string(utils.ManagerKindRepo)
+ req.Id = repository
+ }
+
+ counts, err := o.managed.CountManagedObjects(ctx, req)
+ if err != nil {
+ return nil, err
+ }
+ if counts.Error != nil {
+ return nil, resource.GetError(counts.Error)
+ }
+
+ lookup := make(map[string]*provisioning.ManagerStats)
+ for _, v := range counts.Items {
+ key := v.Kind + ":" + v.Id
+ m := lookup[key]
+ if m == nil {
+ m = &provisioning.ManagerStats{
+ Kind: utils.ManagerKind(v.Kind),
+ Identity: v.Id,
+ }
+ lookup[key] = m
+ }
+ m.Stats = append(m.Stats, provisioning.ResourceCount{
+ Group: v.Group,
+ Resource: v.Resource,
+ Count: v.Count,
+ })
+ }
+ stats := &provisioning.ResourceStats{
+ TypeMeta: metav1.TypeMeta{
+ APIVersion: provisioning.SchemeGroupVersion.String(),
+ Kind: "ResourceStats",
+ },
+ }
+ for _, v := range lookup {
+ stats.Managed = append(stats.Managed, *v)
+ }
+
+ // When selecting an explicit repository, do not fetch global stats
+ if repository != "" {
+ return stats, nil
+ }
+
+ // Get the stats based on what a migration could support
+ if dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, o.storageStatus) {
+ rsp, err := o.legacyMigrator.Migrate(ctx, legacy.MigrateOptions{
+ Namespace: namespace,
+ Resources: []schema.GroupResource{{
+ Group: dashboard.GROUP, Resource: dashboard.DASHBOARD_RESOURCE,
+ }, {
+ Group: folders.GROUP, Resource: folders.RESOURCE,
+ }},
+ WithHistory: false,
+ OnlyCount: true,
+ })
+ if err != nil {
+ return nil, err
+ }
+ for _, v := range rsp.Summary {
+ stats.Instance = append(stats.Instance, provisioning.ResourceCount{
+ Group: v.Group,
+ Resource: v.Resource,
+ Count: v.Count,
+ })
+ }
+ return stats, nil
+ }
+
+ // Get full instance stats
+ info, err := o.index.GetStats(ctx, &resource.ResourceStatsRequest{
+ Namespace: namespace,
+ })
+ if err != nil {
+ return nil, err
+ }
+ for _, v := range info.Stats {
+ stats.Instance = append(stats.Instance, provisioning.ResourceCount{
+ Group: v.Group,
+ Resource: v.Resource,
+ Count: v.Count,
+ })
+ }
+ return stats, nil
+}
diff --git a/pkg/registry/apis/provisioning/resources/parser.go b/pkg/registry/apis/provisioning/resources/parser.go
new file mode 100644
index 00000000000..a4a3f581025
--- /dev/null
+++ b/pkg/registry/apis/provisioning/resources/parser.go
@@ -0,0 +1,294 @@
+package resources
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "path"
+
+ "gopkg.in/yaml.v3"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/client-go/dynamic"
+
+ "github.com/grafana/grafana-app-sdk/logging"
+ dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
+ "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
+ "github.com/grafana/grafana/pkg/apimachinery/utils"
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+)
+
+var (
+ ErrNamespaceMismatch = errors.New("the file namespace does not match target namespace")
+)
+
+type ParserFactory struct {
+ ClientFactory *ClientFactory
+}
+
+func (f *ParserFactory) GetParser(ctx context.Context, repo repository.Reader) (*Parser, error) {
+ config := repo.Config()
+
+ clients, err := f.ClientFactory.Clients(ctx, config.GetNamespace())
+ if err != nil {
+ return nil, err
+ }
+
+ urls, _ := repo.(repository.RepositoryWithURLs)
+ return &Parser{
+ repo: provisioning.ResourceRepositoryInfo{
+ Type: config.Spec.Type,
+ Title: config.Spec.Title,
+ Namespace: config.Namespace,
+ Name: config.Name,
+ },
+ urls: urls,
+ clients: clients,
+ }, nil
+}
+
+type Parser struct {
+ // The target repository
+ repo provisioning.ResourceRepositoryInfo
+
+ // for repositories that have URL support
+ urls repository.RepositoryWithURLs
+
+ // ResourceClients give access to k8s apis
+ clients *ResourceClients
+}
+
+type ParsedResource struct {
+ // Original file Info
+ Info *repository.FileInfo
+
+ // The repository details
+ Repo provisioning.ResourceRepositoryInfo
+
+ // Resource URLs
+ URLs *provisioning.ResourceURLs
+
+ // Check for classic file types (dashboard.json, etc)
+ Classic provisioning.ClassicFileType
+
+ // Parsed contents
+ Obj *unstructured.Unstructured
+ // Metadata accessor for the file object
+ Meta utils.GrafanaMetaAccessor
+
+ // The Kind is defined in the file
+ GVK *schema.GroupVersionKind
+ // The Resource is found by mapping Kind to the right apiserver
+ GVR *schema.GroupVersionResource
+ // Client that can talk to this resource
+ Client dynamic.ResourceInterface
+
+ // The Existing object (same name)
+ // ?? do we need/want the whole thing??
+ Existing *unstructured.Unstructured
+
+ // Create or Update
+ Action provisioning.ResourceAction
+
+ // The results from dry run
+ DryRunResponse *unstructured.Unstructured
+
+ // When the value has been saved in the grafana database
+ Upsert *unstructured.Unstructured
+
+ // If we got some Errors
+ Errors []error
+}
+
+func (r *Parser) Clients() *ResourceClients {
+ return r.clients
+}
+
+func (r *Parser) Parse(ctx context.Context, info *repository.FileInfo, validate bool) (parsed *ParsedResource, err error) {
+ logger := logging.FromContext(ctx).With("path", info.Path, "validate", validate)
+ parsed = &ParsedResource{
+ Info: info,
+ Repo: r.repo,
+ }
+
+ if err := IsPathSupported(info.Path); err != nil {
+ return parsed, err
+ }
+
+ if info.Path == "" {
+ return parsed, errors.New("path is required")
+ }
+
+ parsed.Obj, parsed.GVK, err = DecodeYAMLObject(bytes.NewBuffer(info.Data))
+ if err != nil {
+ logger.Debug("failed to find GVK of the input data", "error", err)
+ parsed.Obj, parsed.GVK, parsed.Classic, err = ReadClassicResource(ctx, info)
+ if err != nil {
+ logger.Debug("also failed to get GVK from fallback loader?", "error", err)
+ return parsed, err
+ }
+ }
+
+ if r.urls != nil {
+ parsed.URLs, err = r.urls.ResourceURLs(ctx, info)
+ if err != nil {
+ logger.Debug("failed to load resource URLs", "error", err)
+ return parsed, err
+ }
+ }
+
+ // Remove the internal dashboard UID,version and id if they exist
+ if parsed.GVK.Group == dashboard.GROUP && parsed.GVK.Kind == "Dashboard" {
+ unstructured.RemoveNestedField(parsed.Obj.Object, "spec", "uid")
+ unstructured.RemoveNestedField(parsed.Obj.Object, "spec", "version")
+ unstructured.RemoveNestedField(parsed.Obj.Object, "spec", "id") // now managed as a label
+ }
+
+ parsed.Meta, err = utils.MetaAccessor(parsed.Obj)
+ if err != nil {
+ return nil, err
+ }
+ obj := parsed.Obj
+
+ // Validate the namespace
+ if obj.GetNamespace() != "" && obj.GetNamespace() != r.repo.Namespace {
+ parsed.Errors = append(parsed.Errors, ErrNamespaceMismatch)
+ }
+
+ obj.SetNamespace(r.repo.Namespace)
+ parsed.Meta.SetManagerProperties(utils.ManagerProperties{
+ Kind: utils.ManagerKindRepo,
+ Identity: r.repo.Name,
+ })
+ parsed.Meta.SetSourceProperties(utils.SourceProperties{
+ Path: info.Path, // joinPathWithRef(info.Path, info.Ref),
+ Checksum: info.Hash,
+ })
+
+ // Calculate name+folder from the file path
+ if info.Path != "" {
+ objName, folderName := NamesFromHashedRepoPath(r.repo.Name, info.Path)
+ parsed.Meta.SetFolder(folderName)
+ if obj.GetName() == "" {
+ obj.SetName(objName) // use the name saved in config
+ }
+ }
+ obj.SetUID("") // clear identifiers
+ obj.SetResourceVersion("") // clear identifiers
+
+ // We can not do anything more if no kind is defined
+ if parsed.GVK == nil {
+ return parsed, nil
+ }
+
+ if r.clients == nil {
+ return parsed, fmt.Errorf("no client configured")
+ }
+
+ client, gvr, err := r.clients.ForKind(*parsed.GVK)
+ if err != nil {
+ return nil, err // does not map to a resour e
+ }
+
+ parsed.GVR = &gvr
+ parsed.Client = client
+ if !validate {
+ return parsed, nil
+ }
+
+ if parsed.Client == nil {
+ parsed.Errors = append(parsed.Errors, fmt.Errorf("unable to find client"))
+ return parsed, nil
+ }
+
+ // Dry run CREATE or UPDATE
+ parsed.Existing, _ = parsed.Client.Get(ctx, obj.GetName(), metav1.GetOptions{})
+ if parsed.Existing == nil {
+ parsed.Action = provisioning.ResourceActionCreate
+ parsed.DryRunResponse, err = parsed.Client.Create(ctx, obj, metav1.CreateOptions{
+ DryRun: []string{"All"},
+ })
+ } else {
+ parsed.Action = provisioning.ResourceActionUpdate
+ parsed.DryRunResponse, err = parsed.Client.Update(ctx, obj, metav1.UpdateOptions{
+ DryRun: []string{"All"},
+ })
+ }
+ if err != nil {
+ parsed.Errors = append(parsed.Errors, err)
+ }
+ return parsed, nil
+}
+
+func (f *ParsedResource) ToSaveBytes() ([]byte, error) {
+ // TODO? should we use the dryRun (validated) version?
+ obj := make(map[string]any)
+ for k, v := range f.Obj.Object {
+ if k != "metadata" {
+ obj[k] = v
+ }
+ }
+
+ switch path.Ext(f.Info.Path) {
+ // JSON pretty print
+ case ".json":
+ return json.MarshalIndent(obj, "", " ")
+
+ // Write the value as yaml
+ case ".yaml", ".yml":
+ return yaml.Marshal(obj)
+
+ default:
+ return nil, fmt.Errorf("unexpected format")
+ }
+}
+
+func (f *ParsedResource) AsResourceWrapper() *provisioning.ResourceWrapper {
+ info := f.Info
+ res := provisioning.ResourceObjects{
+ Type: provisioning.ResourceType{
+ Classic: f.Classic,
+ },
+ Action: f.Action,
+ }
+
+ if f.GVK != nil {
+ res.Type.Group = f.GVK.Group
+ res.Type.Version = f.GVK.Version
+ res.Type.Kind = f.GVK.Kind
+ }
+
+ // The resource (GVR) is derived from the kind (GVK)
+ if f.GVR != nil {
+ res.Type.Resource = f.GVR.Resource
+ }
+
+ if f.Obj != nil {
+ res.File = v0alpha1.Unstructured{Object: f.Obj.Object}
+ }
+ if f.Existing != nil {
+ res.Existing = v0alpha1.Unstructured{Object: f.Existing.Object}
+ }
+ if f.Upsert != nil {
+ res.Upsert = v0alpha1.Unstructured{Object: f.Upsert.Object}
+ } else if f.DryRunResponse != nil {
+ res.DryRun = v0alpha1.Unstructured{Object: f.DryRunResponse.Object}
+ }
+ wrap := &provisioning.ResourceWrapper{
+ Path: info.Path,
+ Ref: info.Ref,
+ Hash: info.Hash,
+ Repository: f.Repo,
+ URLs: f.URLs,
+ Timestamp: info.Modified,
+ Resource: res,
+ }
+ for _, err := range f.Errors {
+ wrap.Errors = append(wrap.Errors, err.Error())
+ }
+ return wrap
+}
diff --git a/pkg/registry/apis/provisioning/resources/tree.go b/pkg/registry/apis/provisioning/resources/tree.go
new file mode 100644
index 00000000000..b11ac5a3cb7
--- /dev/null
+++ b/pkg/registry/apis/provisioning/resources/tree.go
@@ -0,0 +1,139 @@
+package resources
+
+import (
+ "context"
+ "fmt"
+ "sort"
+
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+
+ "github.com/grafana/grafana/pkg/apimachinery/utils"
+ folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
+)
+
+// FolderTree contains the entire set of folders (at a given snapshot in time) of the Grafana instance.
+// The folders are portrayed as a tree, where a folder has a parent, up until the root folder.
+// The root folder is special-cased as a folder that exists, but is not itself stored. It has no ID, no title, and no data, but will return `true` for OK bools.
+type FolderTree struct {
+ tree map[string]string
+ folders map[string]Folder
+}
+
+// In determines if the given folder is in the tree at all. That is, it answers "does the folder even exist in the Grafana instance?"
+// An empty folder string means the root folder, and is special-cased to always return true.
+func (t *FolderTree) In(folder string) bool {
+ _, ok := t.tree[folder]
+ return ok || folder == ""
+}
+
+// DirPath creates the path to the directory with slashes, up to but not including the baseFolder.
+// The baseFolder is expected to be the repository's root folder, as defined by its spec. If this is used in other contexts, it should still function.
+// An empty folder string means the root folder, and is special-cased to return no ID data.
+// The returned Path field in the ID is relative to the base folder. If it is the base folder, an empty string is returned.
+//
+// If In(folder) or In(baseFolder) is false, this will return ok=false, because it would be undefined behaviour.
+// If baseFolder is not a parent of folder, ok=false is returned.
+func (t *FolderTree) DirPath(folder, baseFolder string) (fid Folder, ok bool) {
+ if !t.In(folder) || !t.In(baseFolder) {
+ return Folder{}, false
+ }
+ if folder == "" && baseFolder != "" {
+ return Folder{}, false
+ } else if folder == baseFolder {
+ // Zero-value: we're fine with the zv if we're working with the root folder here.
+ // Any other folder ID will have the correct metadata and no path (which is correct).
+ return t.folders[folder], true
+ }
+
+ fid = t.folders[folder]
+ fid.Path = fid.Title
+ ok = baseFolder == ""
+
+ parent := t.tree[folder]
+ for parent != "" {
+ if parent == baseFolder {
+ ok = true
+ break
+ }
+ fid.Path = safepath.Join(t.folders[parent].Title, fid.Path)
+ parent = t.tree[parent]
+ }
+ return fid, ok
+}
+
+func (t *FolderTree) Add(folder Folder, parent string) {
+ t.tree[folder.ID] = parent
+ t.folders[folder.ID] = folder
+}
+
+type WalkFunc func(ctx context.Context, folder Folder) error
+
+func (t *FolderTree) Walk(ctx context.Context, fn WalkFunc) error {
+ toWalk := make([]Folder, 0, len(t.folders))
+ for _, folder := range t.folders {
+ folder, _ := t.DirPath(folder.ID, "")
+ toWalk = append(toWalk, folder)
+ }
+
+ // sort by depth of the paths
+ sort.Slice(toWalk, func(i, j int) bool {
+ return safepath.Depth(toWalk[i].Path) < safepath.Depth(toWalk[j].Path)
+ })
+
+ for _, folder := range toWalk {
+ if err := fn(ctx, folder); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func NewEmptyFolderTree() *FolderTree {
+ return &FolderTree{
+ tree: make(map[string]string, 0),
+ folders: make(map[string]Folder, 0),
+ }
+}
+
+func (t *FolderTree) AddUnstructured(item *unstructured.Unstructured, skipRepo string) error {
+ meta, err := utils.MetaAccessor(item)
+ if err != nil {
+ return fmt.Errorf("extract meta accessor: %w", err)
+ }
+ manager, _ := meta.GetManagerProperties()
+ if manager.Identity == skipRepo {
+ return nil // skip it... already in tree?
+ }
+ folder := Folder{
+ Title: meta.FindTitle(item.GetName()),
+ ID: item.GetName(),
+ }
+ t.tree[folder.ID] = meta.GetFolder()
+ t.folders[folder.ID] = folder
+ return nil
+}
+
+func NewFolderTreeFromResourceList(resources *provisioning.ResourceList) *FolderTree {
+ tree := make(map[string]string, len(resources.Items))
+ folderIDs := make(map[string]Folder, len(resources.Items))
+ for _, rf := range resources.Items {
+ if rf.Group != folders.GROUP {
+ continue
+ }
+
+ tree[rf.Name] = rf.Folder
+ folderIDs[rf.Name] = Folder{
+ Title: rf.Title,
+ ID: rf.Name,
+ Path: rf.Path,
+ }
+ }
+
+ return &FolderTree{
+ tree,
+ folderIDs,
+ }
+}
diff --git a/pkg/registry/apis/provisioning/resources/tree_test.go b/pkg/registry/apis/provisioning/resources/tree_test.go
new file mode 100644
index 00000000000..e19a0980b3e
--- /dev/null
+++ b/pkg/registry/apis/provisioning/resources/tree_test.go
@@ -0,0 +1,92 @@
+package resources
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestFolderTree(t *testing.T) {
+ newFid := func(kube, title string) Folder {
+ return Folder{ID: kube, Title: title}
+ }
+
+ t.Run("empty tree", func(t *testing.T) {
+ tree := &FolderTree{
+ tree: make(map[string]string),
+ folders: make(map[string]Folder),
+ }
+
+ assert.False(t, tree.In("x"), "x should not be in tree")
+ assert.False(t, tree.In("z"), "z should not be in tree")
+ _, ok := tree.DirPath("x", "")
+ assert.False(t, ok, "x should not have a DirPath")
+ })
+
+ t.Run("single directory in tree", func(t *testing.T) {
+ tree := &FolderTree{
+ tree: map[string]string{"x": ""},
+ folders: map[string]Folder{"x": newFid("x", "X!")},
+ }
+
+ assert.True(t, tree.In("x"), "x should be in tree")
+ id, ok := tree.DirPath("x", "x")
+ if assert.True(t, ok, "x should have DirPath with itself as base") {
+ assert.Equal(t, "x", id.ID, "KubernetesName")
+ assert.Equal(t, "X!", id.Title, "Title")
+ assert.Equal(t, "", id.Path, "Path")
+ }
+ id, ok = tree.DirPath("x", "")
+ if assert.True(t, ok, "x should have DirPath with empty base") {
+ assert.Equal(t, "x", id.ID, "KubernetesName")
+ assert.Equal(t, "X!", id.Title, "Title")
+ assert.Equal(t, "X!", id.Path, "Path")
+ }
+ })
+
+ t.Run("simple nesting tree", func(t *testing.T) {
+ tree := &FolderTree{
+ tree: map[string]string{"a": "b", "b": "c", "c": "x", "x": ""},
+ folders: map[string]Folder{
+ "x": newFid("x", "X!"),
+ "c": newFid("c", "C :)"),
+ "b": newFid("b", "!!B#!"),
+ "a": newFid("a", "[€]@£a"),
+ },
+ }
+
+ assert.True(t, tree.In("x"), "x should be in tree")
+ assert.True(t, tree.In("a"), "a should be in tree")
+ assert.False(t, tree.In("z"), "z should not be in tree, for it is undeclared")
+
+ id, ok := tree.DirPath("x", "")
+ if assert.True(t, ok, "x should have DirPath with empty base") {
+ assert.Equal(t, "x", id.ID, "KubernetesName")
+ assert.Equal(t, "X!", id.Title, "Title")
+ assert.Equal(t, "X!", id.Path, "Path")
+ }
+
+ id, ok = tree.DirPath("c", "c")
+ if assert.True(t, ok, "c should have DirPath with itself as base") {
+ assert.Equal(t, "c", id.ID, "KubernetesName")
+ assert.Equal(t, "C :)", id.Title, "Title")
+ assert.Equal(t, "", id.Path, "Path")
+ }
+
+ id, ok = tree.DirPath("a", "x")
+ if assert.True(t, ok, "a should have DirPath with x as base") {
+ assert.Equal(t, "a", id.ID, "KubernetesName")
+ assert.Equal(t, "[€]@£a", id.Title, "Title")
+ assert.Equal(t, "C :)/!!B#!/[€]@£a", id.Path, "Path")
+ }
+ _, ok = tree.DirPath("x", "a")
+ assert.False(t, ok, "x should not have DirPath with a as base, because a is a subfolder of x")
+
+ id, ok = tree.DirPath("", "")
+ if assert.True(t, ok, "the root folder should have a path to itself") {
+ assert.Empty(t, id.ID)
+ assert.Empty(t, id.Path)
+ assert.Empty(t, id.Title)
+ }
+ })
+}
diff --git a/pkg/registry/apis/provisioning/routes.go b/pkg/registry/apis/provisioning/routes.go
new file mode 100644
index 00000000000..6d9f89db34d
--- /dev/null
+++ b/pkg/registry/apis/provisioning/routes.go
@@ -0,0 +1,174 @@
+package provisioning
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "time"
+
+ "k8s.io/apimachinery/pkg/labels"
+ "k8s.io/kube-openapi/pkg/spec3"
+ "k8s.io/kube-openapi/pkg/validation/spec"
+
+ authlib "github.com/grafana/authlib/types"
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/services/apiserver/builder"
+ "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
+ "github.com/grafana/grafana/pkg/util/errhttp"
+)
+
+// TODO: Move the specific logic to the connector so that we don't have logic all over the place.
+// GetAPIRoutes implements the direct HTTP handlers that bypass k8s
+func (b *APIBuilder) GetAPIRoutes() *builder.APIRoutes {
+ return &builder.APIRoutes{
+ Namespace: []builder.APIRouteHandler{
+ {
+ Path: "stats",
+ Spec: &spec3.PathProps{
+ Get: &spec3.Operation{
+ OperationProps: spec3.OperationProps{
+ OperationId: "getResourceStats", // used for RTK client
+ Tags: []string{"Provisioning", "Repository"}, // includes stats for repositores and provisiong in general
+ Description: "Get resource stats for this namespace",
+ Parameters: []*spec3.Parameter{
+ {
+ ParameterProps: spec3.ParameterProps{
+ Name: "namespace",
+ In: "path",
+ Required: true,
+ Example: "default",
+ Description: "workspace",
+ Schema: spec.StringProperty(),
+ },
+ },
+ },
+ Responses: &spec3.Responses{
+ ResponsesProps: spec3.ResponsesProps{
+ StatusCodeResponses: map[int]*spec3.Response{
+ 200: {
+ ResponseProps: spec3.ResponseProps{
+ Content: map[string]*spec3.MediaType{
+ "application/json": {
+ MediaTypeProps: spec3.MediaTypeProps{
+ Schema: &spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Ref: spec.MustCreateRef("#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ResourceStats"),
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ Handler: withTimeoutFunc(b.handleStats, 30*time.Second),
+ },
+ {
+ Path: "settings",
+ Spec: &spec3.PathProps{
+ Get: &spec3.Operation{
+ OperationProps: spec3.OperationProps{
+ OperationId: "getFrontendSettings", // used for RTK client
+ // includes stats for repositores and provisiong in general
+ // This must include "Repository" so that the RTK client will invalidate when things are deleted
+ Tags: []string{"Provisioning", "Repository"},
+ Description: "Get the frontend settings for this namespace",
+ Parameters: []*spec3.Parameter{
+ {
+ ParameterProps: spec3.ParameterProps{
+ Name: "namespace",
+ In: "path",
+ Required: true,
+ Example: "default",
+ Description: "workspace",
+ Schema: spec.StringProperty(),
+ },
+ },
+ },
+ Responses: &spec3.Responses{
+ ResponsesProps: spec3.ResponsesProps{
+ StatusCodeResponses: map[int]*spec3.Response{
+ 200: {
+ ResponseProps: spec3.ResponseProps{
+ Content: map[string]*spec3.MediaType{
+ "application/json": {
+ MediaTypeProps: spec3.MediaTypeProps{
+ Schema: &spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Ref: spec.MustCreateRef("#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.RepositoryViewList"),
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ Handler: withTimeoutFunc(b.handleSettings, 30*time.Second),
+ },
+ },
+ }
+}
+
+// TODO: why didn't we create a connector as we did before or have a separate file?
+func (b *APIBuilder) handleStats(w http.ResponseWriter, r *http.Request) {
+ u, ok := authlib.AuthInfoFrom(r.Context())
+ if !ok {
+ w.WriteHeader(400)
+ _, _ = w.Write([]byte("expected user"))
+ return
+ }
+ // TODO: check if lister could list too many repositories or resources
+ stats, err := b.resourceLister.Stats(r.Context(), u.GetNamespace(), "")
+ if err != nil {
+ errhttp.Write(r.Context(), err, w)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(stats)
+}
+
+// TODO: why didn't we create a connector as we did before or have a separate file?
+// TODO: is there a better way to provide a filtered view of the repositories to the frontend?
+func (b *APIBuilder) handleSettings(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ u, ok := authlib.AuthInfoFrom(ctx)
+ if !ok {
+ errhttp.Write(ctx, fmt.Errorf("expected user"), w)
+ return
+ }
+
+ // TODO: check if lister could list too many repositories or resources
+ all, err := b.repositoryLister.Repositories(u.GetNamespace()).List(labels.Everything())
+ if err != nil {
+ errhttp.Write(r.Context(), err, w)
+ return
+ }
+
+ settings := provisioning.RepositoryViewList{
+ Items: make([]provisioning.RepositoryView, len(all)),
+ // FIXME: this shouldn't be here in provisioning but at the dual writer or something about the storage
+ LegacyStorage: dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, b.storageStatus),
+ }
+ for i, val := range all {
+ settings.Items[i] = provisioning.RepositoryView{
+ Name: val.ObjectMeta.Name,
+ Title: val.Spec.Title,
+ Type: val.Spec.Type,
+ ReadOnly: len(val.Spec.Workflows) == 0,
+ Target: val.Spec.Sync.Target,
+ }
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(settings)
+}
diff --git a/pkg/registry/apis/provisioning/safepath/dir.go b/pkg/registry/apis/provisioning/safepath/dir.go
new file mode 100644
index 00000000000..c6d0bbbe727
--- /dev/null
+++ b/pkg/registry/apis/provisioning/safepath/dir.go
@@ -0,0 +1,38 @@
+package safepath
+
+import (
+ "path"
+ "strings"
+)
+
+// IsDir returns true if the filePath ends with a slash.
+// Empty string is considered a directory.
+func IsDir(filePath string) bool {
+ if filePath == "" || filePath == "." {
+ return true
+ }
+
+ return strings.HasSuffix(filePath, "/")
+}
+
+// Dir behaves exactly as path.Dir, but returns "" for the root directory.
+// and returns a trailing slash for all other directories.
+func Dir(filePath string) string {
+ if filePath == "" {
+ return ""
+ }
+
+ // Trim trailing slash before getting the directory
+ cleanPath := strings.TrimSuffix(filePath, "/")
+ dir := path.Dir(cleanPath)
+ if dir == "." || dir == "/" {
+ return ""
+ }
+
+ return dir + "/"
+}
+
+// InDir returns true if the filePath is a subdirectory of the given directory.
+func InDir(filePath, dir string) bool {
+ return strings.HasPrefix(filePath, dir)
+}
diff --git a/pkg/registry/apis/provisioning/safepath/dir_test.go b/pkg/registry/apis/provisioning/safepath/dir_test.go
new file mode 100644
index 00000000000..bf55b160660
--- /dev/null
+++ b/pkg/registry/apis/provisioning/safepath/dir_test.go
@@ -0,0 +1,162 @@
+package safepath
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestIsFolderPath(t *testing.T) {
+ tests := []struct {
+ name string
+ filePath string
+ want bool
+ }{
+ {
+ name: "empty path",
+ filePath: "",
+ want: true,
+ },
+ {
+ name: "dot path",
+ filePath: ".",
+ want: true,
+ },
+ {
+ name: "file path without extension",
+ filePath: "test",
+ want: false,
+ },
+ {
+ name: "file path with extension",
+ filePath: "test.json",
+ want: false,
+ },
+ {
+ name: "folder path with trailing slash",
+ filePath: "folder/",
+ want: true,
+ },
+ {
+ name: "nested folder path with trailing slash",
+ filePath: "folder/subfolder/",
+ want: true,
+ },
+ {
+ name: "file path in folder without trailing slash",
+ filePath: "folder/test.json",
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := IsDir(tt.filePath)
+ require.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestDir(t *testing.T) {
+ tests := []struct {
+ name string
+ filePath string
+ want string
+ }{
+ {
+ name: "empty path",
+ filePath: "",
+ want: "",
+ },
+ {
+ name: "root path",
+ filePath: "/",
+ want: "",
+ },
+ {
+ name: "single directory",
+ filePath: "folder",
+ want: "",
+ },
+ {
+ name: "nested directory",
+ filePath: "folder/subfolder",
+ want: "folder/",
+ },
+ {
+ name: "file in directory",
+ filePath: "folder/file.txt",
+ want: "folder/",
+ },
+ {
+ name: "multiple nested directories",
+ filePath: "folder/subfolder/subsubfolder",
+ want: "folder/subfolder/",
+ },
+ {
+ name: "directory with trailing slash",
+ filePath: "folder/subfolder/",
+ want: "folder/",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := Dir(tt.filePath)
+ require.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestInDir(t *testing.T) {
+ tests := []struct {
+ name string
+ filePath string
+ dir string
+ want bool
+ }{
+ {
+ name: "file in directory",
+ filePath: "folder/file.txt",
+ dir: "folder/",
+ want: true,
+ },
+ {
+ name: "file not in directory",
+ filePath: "other/file.txt",
+ dir: "folder/",
+ want: false,
+ },
+ {
+ name: "subdirectory",
+ filePath: "folder/subfolder/",
+ dir: "folder/",
+ want: true,
+ },
+ {
+ name: "empty directory",
+ filePath: "folder/file.txt",
+ dir: "",
+ want: true,
+ },
+ {
+ name: "exact match",
+ filePath: "folder/",
+ dir: "folder/",
+ want: true,
+ },
+ {
+ name: "partial directory name match",
+ filePath: "folder2/file.txt",
+ dir: "folder/",
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := InDir(tt.filePath, tt.dir)
+ require.Equal(t, tt.want, got)
+ })
+ }
+}
diff --git a/pkg/registry/apis/provisioning/safepath/path.go b/pkg/registry/apis/provisioning/safepath/path.go
index 17f0adb18f9..d314fa80cdf 100644
--- a/pkg/registry/apis/provisioning/safepath/path.go
+++ b/pkg/registry/apis/provisioning/safepath/path.go
@@ -4,56 +4,69 @@ import (
"os"
"path"
"strings"
-
- apierrors "k8s.io/apimachinery/pkg/api/errors"
)
-// ErrUnsafePathTraversal indicates that an input path had a path traversal which led to escaping the required prefix.
-// E.g. Join("/test", "..") would return this, because it doesn't stay within the '/test' directory.
-var ErrUnsafePathTraversal = apierrors.NewBadRequest("the input path had an unacceptable path traversal")
-
-// Join joins any number of elements in a path under a common prefix path.
-// If the elems do path traversal, they are permitted to do so under their own directories.
-// The output result will _always_ have a prefix of the given prefix, and no path traversals in the output string.
-// The output result will not end with a trailing slash.
-// The output result will have a leading slash if one is given as a prefix.
-// If the prefix would ultimately be escaped, an error is returned.
-//
-// This function is safe for .
-func Join(prefix string, elem ...string) (string, error) {
- // We clean early to make the HasPrefix check be sensible after path.Join does a Clean for us.
- prefix = replaceOSSeparators(path.Clean(prefix))
- if len(elem) == 0 {
- return prefix, nil
- }
-
- for i, e := range elem {
- // We don't use Clean here because the output of path.Join will clean for us.
- elem[i] = replaceOSSeparators(e)
- }
- subPath := path.Join(elem...) // performs a Clean after joining
- completePath := path.Join(prefix, subPath)
- if !strings.HasPrefix(completePath, prefix) {
- return "", ErrUnsafePathTraversal
- }
- return completePath, nil
-}
-
-// Performs a [path.Clean] on the path, as well as replacing its OS separators.
-// Note that this does no effort to ensure the paths are safe to use. It only cleans them.
-func Clean(p string) string {
- return path.Clean(replaceOSSeparators(p))
-}
+// TODO: explore if we want to use our own type for safepath
+// to make it clearer that this is a safe path and not a regular path
// osSeparator is declared as a var here only to ensure we can change it in tests.
var osSeparator = os.PathSeparator
+// Performs a [path.Clean] on the path, as well as replacing its OS separators.
// This replaces the OS separator with a slash.
// All OSes we target (Linux, macOS, and Windows) support forward-slashes in path traversals, as such it's simpler to use the same character everywhere.
// BSDs do as well (even though they're not a target as of writing).
-func replaceOSSeparators(p string) string {
+func Clean(p string) string {
if osSeparator == '/' { // perf: nothing to do!
+ return path.Clean(p)
+ }
+
+ return path.Clean(strings.ReplaceAll(p, string(osSeparator), "/"))
+}
+
+// Join is like path.Join but preserves trailing slashes from the last element
+func Join(elem ...string) string {
+ if len(elem) == 0 {
+ return ""
+ }
+
+ joined := path.Join(elem...)
+ // Preserve trailing slash if the last element had one
+ if strings.HasSuffix(elem[len(elem)-1], "/") {
+ return joined + "/"
+ }
+
+ return joined
+}
+
+// Base returns the last element of the path.
+func Base(p string) string {
+ b := path.Base(p)
+ if b == "." || b == "/" {
+ return ""
+ }
+
+ return b
+}
+
+// RemoveExt returns the path without the extension.
+// It should not remove the dot if the filename is e.g. `.gitignore`
+func RemoveExt(p string) string {
+ // Special case: if the file starts with a dot and has no other dots,
+ // it's a hidden file and should not have its "extension" removed
+ base := Base(p)
+ if strings.HasPrefix(base, ".") && strings.Count(base, ".") == 1 {
return p
}
- return strings.ReplaceAll(p, string(osSeparator), "/")
+
+ ext := path.Ext(p)
+ if ext == "" {
+ return p
+ }
+
+ return p[0 : len(p)-len(ext)]
+}
+
+func IsAbs(p string) bool {
+ return path.IsAbs(p)
}
diff --git a/pkg/registry/apis/provisioning/safepath/path_test.go b/pkg/registry/apis/provisioning/safepath/path_test.go
index abd4f14abc1..d42bde6296e 100644
--- a/pkg/registry/apis/provisioning/safepath/path_test.go
+++ b/pkg/registry/apis/provisioning/safepath/path_test.go
@@ -7,41 +7,26 @@ import (
)
func TestPathJoin(t *testing.T) {
- orig := osSeparator
- osSeparator = '\\' // pretend we're on Windows
- defer func() { osSeparator = orig }()
-
testCases := []struct {
Comment string
In []string
Out any // string or error
}{
- {"Empty elements should not change input", []string{"/test/"}, "/test"},
- {"Empty elements without leading slash should not change input", []string{"test/"}, "test"},
+ {"Empty elements should not change input", []string{"/test/"}, "/test/"},
+ {"Empty elements without leading slash should not change input", []string{"test/"}, "test/"},
{"Single element should be added to path", []string{"/test/", "abc"}, "/test/abc"},
{"Single element should be added to path with current dir prefix", []string{"./test/", "abc"}, "test/abc"},
{"Single element with leading slash should be added to path", []string{"/test/", "/abc"}, "/test/abc"},
{"Many elements are all appended to path", []string{"/test/", "a", "b", "c"}, "/test/a/b/c"},
{"Path traversal within same directory should be expanded", []string{"/test/", "a", "..", "b", ".", "..", "c"}, "/test/c"},
- {"Path traversal escaping root dir prefix should return err", []string{"/test/", ".."}, ErrUnsafePathTraversal},
- {"Path traversal escaping no dir prefix should return err", []string{"test/", ".."}, ErrUnsafePathTraversal},
- {"Path traversal escaping current dir prefix should return err", []string{"./test/", ".."}, ErrUnsafePathTraversal},
- {"Complex path traversal escaping prefix should return err", []string{"/test/", "a/..///c/", "../../test/d/../a/../.."}, ErrUnsafePathTraversal},
- {"Complex path traversal remaining in prefix should be expanded", []string{"/test/", "a/..///c/", "../../test/d/"}, "/test/d"},
- {"Problematic code example from the g304 website", []string{"/safe/path", "../../private/path"}, ErrUnsafePathTraversal},
- {"Traversing beyond root should be expanded", []string{"/test/", "/../a"}, "/test/a"},
- {"OS separator should be replaced with a slash", []string{"/test\\test", "abc\\test"}, "/test/test/abc/test"},
+ {"Complex path traversal remaining in prefix should be expanded", []string{"/test/", "a/..///c/", "../../test/d/"}, "/test/d/"},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.Comment, func(t *testing.T) {
- path, err := Join(tc.In[0], tc.In[1:]...)
- if ee, ok := tc.Out.(error); ok {
- assert.ErrorIs(t, err, ee, "expected unsuccessful outcome")
- assert.Empty(t, path, "expected empty string when unsuccessful")
- } else if str, ok := tc.Out.(string); ok {
- assert.NoError(t, err, "expected successful outcome")
+ path := Join(tc.In...)
+ if str, ok := tc.Out.(string); ok {
assert.Equal(t, str, path)
} else {
panic("expected out was neither string nor error")
@@ -63,6 +48,13 @@ func TestPathClean(t *testing.T) {
{"Simple path", "/test/", "/test"},
{"Simple path with OS separators", "\\test\\here", "/test/here"},
{"Simple path with mixed separators", "\\test/here", "/test/here"},
+ {"Path traversal within directory", "/test/abc/../def", "/test/def"},
+ {"Multiple path traversals", "/test/abc/../../def", "/def"},
+ {"Path traversal beyond root", "/test/../../../def", "/def"},
+ {"Complex path traversal with mixed separators", "\\test\\abc\\..\\..\\def/ghi\\..", "/def"},
+ {"Path traversal with multiple slashes", "/test////abc/..//def", "/test/def"},
+ {"Path traversal with current directory", "/test/./abc/../def/./ghi", "/test/def/ghi"},
+ {"Empty path segments with traversal", "//test//abc//..//def", "/test/def"},
}
for _, tc := range testCases {
@@ -72,3 +64,105 @@ func TestPathClean(t *testing.T) {
})
}
}
+
+func TestBase(t *testing.T) {
+ testCases := []struct {
+ name string
+ path string
+ expected string
+ }{
+ {
+ name: "empty path",
+ path: "",
+ expected: "",
+ },
+ {
+ name: "root path",
+ path: "/",
+ expected: "",
+ },
+ {
+ name: "current directory",
+ path: ".",
+ expected: "",
+ },
+ {
+ name: "simple filename",
+ path: "file.txt",
+ expected: "file.txt",
+ },
+ {
+ name: "path with directory",
+ path: "/path/to/file.txt",
+ expected: "file.txt",
+ },
+ {
+ name: "path with trailing slash",
+ path: "/path/to/dir/",
+ expected: "dir",
+ },
+ {
+ name: "hidden file",
+ path: ".gitignore",
+ expected: ".gitignore",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ result := Base(tc.path)
+ assert.Equal(t, tc.expected, result)
+ })
+ }
+}
+
+func TestRemoveExt(t *testing.T) {
+ testCases := []struct {
+ name string
+ path string
+ expected string
+ }{
+ {
+ name: "empty path",
+ path: "",
+ expected: "",
+ },
+ {
+ name: "no extension",
+ path: "filename",
+ expected: "filename",
+ },
+ {
+ name: "simple extension",
+ path: "file.txt",
+ expected: "file",
+ },
+ {
+ name: "multiple dots",
+ path: "file.tar.gz",
+ expected: "file.tar",
+ },
+ {
+ name: "hidden file",
+ path: ".gitignore",
+ expected: ".gitignore",
+ },
+ {
+ name: "path with directory",
+ path: "/path/to/file.txt",
+ expected: "/path/to/file",
+ },
+ {
+ name: "path with trailing slash",
+ path: "/path/to/dir/",
+ expected: "/path/to/dir/",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ result := RemoveExt(tc.path)
+ assert.Equal(t, tc.expected, result)
+ })
+ }
+}
diff --git a/pkg/registry/apis/provisioning/safepath/safe.go b/pkg/registry/apis/provisioning/safepath/safe.go
new file mode 100644
index 00000000000..9e11220e00c
--- /dev/null
+++ b/pkg/registry/apis/provisioning/safepath/safe.go
@@ -0,0 +1,81 @@
+package safepath
+
+import (
+ "errors"
+ "regexp"
+ "strings"
+)
+
+var (
+ ErrPathTooLong = errors.New("path too long")
+ ErrInvalidCharacters = errors.New("path contains invalid characters")
+ ErrDoubleSlash = errors.New("path contains double slashes")
+ ErrInvalidFormat = errors.New("invalid path format")
+ ErrPercentChar = errors.New("path contains percent character which could be used for URL encoding attacks")
+ ErrHiddenPath = errors.New("path contains hidden file or directory (starting with dot)")
+ ErrPathTraversalAttempt = errors.New("path contains traversal attempt (./ or ../)")
+)
+
+const (
+ MaxPathLength = 1024 // Maximum allowed path length in characters
+)
+
+// validPathPattern matches valid path characters:
+// - Alphanumeric (a-z, A-Z, 0-9)
+// - Forward slash for path separation
+// - Dots for file extensions and current directory
+// - Underscores and hyphens for file/folder names
+var validPathPattern = regexp.MustCompile(`^[a-zA-Z0-9/_.-]+$`)
+
+func IsSafe(path string) error {
+ // Check path length
+ if len(path) > MaxPathLength {
+ return ErrPathTooLong
+ }
+
+ // Empty path is valid (represents current directory)
+ if path == "" {
+ return nil
+ }
+
+ // Check specifically for percent character first
+ if strings.Contains(path, "%") {
+ return ErrPercentChar
+ }
+
+ // Check for invalid characters
+ if !validPathPattern.MatchString(path) {
+ return ErrInvalidCharacters
+ }
+
+ // Check for double slashes
+ if strings.Contains(path, "//") {
+ return ErrDoubleSlash
+ }
+
+ parts := Split(path)
+
+ // Check for path traversal attempts first
+ for _, part := range parts {
+ if part == ".." || part == "." {
+ return ErrPathTraversalAttempt
+ }
+ }
+
+ // Check for hidden files/directories in any part of the path
+ for _, part := range parts {
+ if part != "" && part[0] == '.' && part != ".." && part != "." {
+ return ErrHiddenPath
+ }
+ }
+
+ // If it's not a directory, it should have a filename component
+ if !IsDir(path) && len(parts) > 0 {
+ filename := parts[len(parts)-1]
+ if filename == "" {
+ return ErrInvalidFormat
+ }
+ }
+
+ return nil
+}
diff --git a/pkg/registry/apis/provisioning/safepath/safe_test.go b/pkg/registry/apis/provisioning/safepath/safe_test.go
new file mode 100644
index 00000000000..3f99a83b9da
--- /dev/null
+++ b/pkg/registry/apis/provisioning/safepath/safe_test.go
@@ -0,0 +1,235 @@
+package safepath
+
+import (
+ "errors"
+ "strings"
+ "testing"
+)
+
+func TestIsSafe(t *testing.T) {
+ tests := []struct {
+ name string
+ path string
+ wantErr error
+ }{
+ // Valid paths
+ {
+ name: "valid simple path",
+ path: "path/to/resource",
+ wantErr: nil,
+ },
+ {
+ name: "valid path with extension",
+ path: "path/to/file.json",
+ wantErr: nil,
+ },
+ {
+ name: "valid directory path with trailing slash",
+ path: "path/to/folder/",
+ wantErr: nil,
+ },
+ {
+ name: "valid path with allowed special chars",
+ path: "my-path/to_file/resource.json",
+ wantErr: nil,
+ },
+ {
+ name: "empty path",
+ path: "",
+ wantErr: nil,
+ },
+ {
+ name: "path at max length",
+ path: strings.Repeat("a", MaxPathLength),
+ wantErr: nil,
+ },
+ {
+ name: "valid directory",
+ path: "path/to/",
+ wantErr: nil,
+ },
+ {
+ name: "valid path with dots in filename",
+ path: "path/to/file.min.js",
+ wantErr: nil,
+ },
+ // Length and depth limits
+ {
+ name: "path too long",
+ path: strings.Repeat("a/", 512) + "file", // Creates path > MaxPathLength
+ wantErr: ErrPathTooLong,
+ },
+ // Invalid characters and formats
+ {
+ name: "invalid special character hash",
+ path: "path/to/file#.json",
+ wantErr: ErrInvalidCharacters,
+ },
+ {
+ name: "invalid character space",
+ path: "path/to/my file.json",
+ wantErr: ErrInvalidCharacters,
+ },
+ {
+ name: "invalid character backslash",
+ path: "path\\to\\file.json",
+ wantErr: ErrInvalidCharacters,
+ },
+ {
+ name: "invalid character question mark",
+ path: "path/to/file?.json",
+ wantErr: ErrInvalidCharacters,
+ },
+ {
+ name: "invalid character asterisk",
+ path: "path/to/*.json",
+ wantErr: ErrInvalidCharacters,
+ },
+
+ // Double slashes
+ {
+ name: "double slashes in middle",
+ path: "path//to/file.json",
+ wantErr: ErrDoubleSlash,
+ },
+ {
+ name: "double slashes at start",
+ path: "//path/to/file.json",
+ wantErr: ErrDoubleSlash,
+ },
+ {
+ name: "double slashes at end",
+ path: "path/to/file//",
+ wantErr: ErrDoubleSlash,
+ },
+
+ // Hidden files and directories
+ {
+ name: "hidden file",
+ path: "path/to/.hidden",
+ wantErr: ErrHiddenPath,
+ },
+ {
+ name: "hidden directory",
+ path: "path/to/.git/",
+ wantErr: ErrHiddenPath,
+ },
+ {
+ name: "hidden file with extension",
+ path: "path/to/.gitignore",
+ wantErr: ErrHiddenPath,
+ },
+ {
+ name: "hidden path component in middle",
+ path: "path/.hidden/file.json",
+ wantErr: ErrHiddenPath,
+ },
+ {
+ name: "hidden path at root",
+ path: ".env/config.json",
+ wantErr: ErrHiddenPath,
+ },
+
+ // Path traversal attempts
+ {
+ name: "path traversal with parent directory",
+ path: "path/to/../file.json",
+ wantErr: ErrPathTraversalAttempt,
+ },
+ {
+ name: "path traversal at start",
+ path: "../path/file.json",
+ wantErr: ErrPathTraversalAttempt,
+ },
+ {
+ name: "path traversal with multiple levels",
+ path: "path/../../file.json",
+ wantErr: ErrPathTraversalAttempt,
+ },
+ {
+ name: "path traversal at end",
+ path: "path/to/folder/../",
+ wantErr: ErrPathTraversalAttempt,
+ },
+ {
+ name: "single dot path component",
+ path: "path/to/./file.json",
+ wantErr: ErrPathTraversalAttempt,
+ },
+ {
+ name: "double dot path component",
+ path: "path/to/../",
+ wantErr: ErrPathTraversalAttempt,
+ },
+
+ // Current directory references
+ {
+ name: "current directory at start",
+ path: "./path/file.json",
+ wantErr: ErrPathTraversalAttempt,
+ },
+ {
+ name: "current directory in middle",
+ path: "path/./file.json",
+ wantErr: ErrPathTraversalAttempt,
+ },
+ {
+ name: "current directory at end",
+ path: "path/to/./",
+ wantErr: ErrPathTraversalAttempt,
+ },
+
+ // URL encoding attempts
+ {
+ name: "percent character in filename",
+ path: "path/to/%20file.json",
+ wantErr: ErrPercentChar,
+ },
+ {
+ name: "url encoded slash",
+ path: "path/to%2Ffile.json",
+ wantErr: ErrPercentChar,
+ },
+ {
+ name: "url encoded dot",
+ path: "path/to%2E%2E/file.json",
+ wantErr: ErrPercentChar,
+ },
+ {
+ name: "url encoded path traversal",
+ path: "path/to/%2e%2e/file.json",
+ wantErr: ErrPercentChar,
+ },
+ {
+ name: "url encoded null byte",
+ path: "path/to/file%00.json",
+ wantErr: ErrPercentChar,
+ },
+
+ // Mixed invalid patterns
+ {
+ name: "mixed traversal attempts",
+ path: "./path/../file.json",
+ wantErr: ErrPathTraversalAttempt,
+ },
+ {
+ name: "mixed special chars and traversal",
+ path: "../path/#/file.json",
+ wantErr: ErrInvalidCharacters,
+ },
+ {
+ name: "mixed percent and special chars",
+ path: "path/%20/#/file.json",
+ wantErr: ErrPercentChar,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := IsSafe(tt.path)
+ if !errors.Is(err, tt.wantErr) {
+ t.Errorf("IsSafe() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/pkg/registry/apis/provisioning/safepath/trie.go b/pkg/registry/apis/provisioning/safepath/trie.go
new file mode 100644
index 00000000000..7b49699c4cf
--- /dev/null
+++ b/pkg/registry/apis/provisioning/safepath/trie.go
@@ -0,0 +1,98 @@
+// Package safepath provides utilities for safe path handling and validation
+// through a trie-based implementation.
+package safepath
+
+import (
+ "fmt"
+)
+
+// trieNode represents a single node in the trie data structure.
+type trieNode struct {
+ children map[string]*trieNode
+ isDir bool // marks if this node represents a directory
+}
+
+// Trie implements a trie data structure for efficient path lookups and validation.
+type Trie struct {
+ root *trieNode
+}
+
+// NewTrie creates and returns a new initialized Trie.
+func NewTrie() *Trie {
+ return &Trie{
+ root: &trieNode{
+ children: make(map[string]*trieNode),
+ },
+ }
+}
+
+// Add inserts a path into the trie. It returns an error if there's a conflict
+// between the path types (file vs directory) or if the path is invalid.
+func (t *Trie) Add(path string) error {
+ if path == "" || path == "/" {
+ return nil
+ }
+
+ current := t.root
+ segments := Split(path)
+
+ var accumulatedPath string
+ for i, segment := range segments {
+ accumulatedPath = Join(accumulatedPath, segment)
+ if current.children == nil {
+ current.children = make(map[string]*trieNode)
+ }
+
+ isLastSegment := i == len(segments)-1
+ node, exists := current.children[segment]
+ if !exists {
+ node = &trieNode{
+ children: make(map[string]*trieNode),
+ }
+ current.children[segment] = node
+ } else {
+ if (!isLastSegment && !node.isDir) || (isLastSegment && !node.isDir && IsDir(path)) {
+ return fmt.Errorf("path %q exists but is not a directory", accumulatedPath)
+ }
+
+ if isLastSegment && node.isDir && !IsDir(path) {
+ return fmt.Errorf("path %q exists but is not a file", accumulatedPath)
+ }
+ }
+
+ current = node
+ current.isDir = !isLastSegment || IsDir(path)
+ }
+
+ return nil
+}
+
+// Exists checks if a path exists in the trie and matches its expected type (file/directory).
+func (t *Trie) Exists(path string) bool {
+ if path == "" || path == "/" {
+ return true
+ }
+
+ current := t.root
+ segments := Split(path)
+
+ for i, segment := range segments {
+ if current.children == nil {
+ return false
+ }
+
+ next, exists := current.children[segment]
+ if !exists {
+ return false
+ }
+
+ current = next
+ isLastSegment := i == len(segments)-1
+
+ if isLastSegment {
+ return current.isDir == IsDir(path)
+ }
+ }
+
+ return false
+}
diff --git a/pkg/registry/apis/provisioning/safepath/trie_test.go b/pkg/registry/apis/provisioning/safepath/trie_test.go
new file mode 100644
index 00000000000..8daf93bc121
--- /dev/null
+++ b/pkg/registry/apis/provisioning/safepath/trie_test.go
@@ -0,0 +1,125 @@
+package safepath
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestTrie(t *testing.T) {
+ tests := []struct {
+ name string
+ pathsToAdd []string
+ pathsToCheck []string
+ expectedExist []bool
+ expectedError error
+ }{
+ {
+ name: "empty trie",
+ pathsToAdd: []string{},
+ pathsToCheck: []string{"test", "test/"},
+ expectedExist: []bool{false, false},
+ expectedError: nil,
+ },
+ {
+ name: "single file",
+ pathsToAdd: []string{"test.json"},
+ pathsToCheck: []string{"test.json", "test.json/"},
+ expectedExist: []bool{true, false},
+ expectedError: nil,
+ },
+ {
+ name: "single directory",
+ pathsToAdd: []string{"test/"},
+ pathsToCheck: []string{"test", "test/"},
+ expectedExist: []bool{false, true},
+ expectedError: nil,
+ },
+ {
+ name: "nested structure",
+ pathsToAdd: []string{"folder/", "folder/file.txt", "folder/subfolder/", "folder/subfolder/test.json"},
+ pathsToCheck: []string{"folder/", "folder/file.txt", "folder/file.txt/", "folder/subfolder/", "folder/subfolder/test.json", "folder/subfolder/test.json/"},
+ expectedExist: []bool{true, true, false, true, true, false},
+ expectedError: nil,
+ },
+ {
+ name: "partial paths",
+ pathsToAdd: []string{"a/b/c/d/"},
+ pathsToCheck: []string{"a/", "a/b/", "a/b/c/", "a/b/c/d/"},
+ expectedExist: []bool{true, true, true, true},
+ expectedError: nil,
+ },
+ {
+ name: "file in middle of path",
+ pathsToAdd: []string{"a/file.txt", "a/file.txt/b/"},
+ pathsToCheck: []string{},
+ expectedExist: []bool{},
+ expectedError: fmt.Errorf("path %q exists but is not a directory", "a/file.txt"),
+ },
+ {
+ name: "empty path",
+ pathsToAdd: []string{""},
+ pathsToCheck: []string{""},
+ expectedExist: []bool{true},
+ expectedError: nil,
+ },
+ {
+ name: "root directory",
+ pathsToAdd: []string{"/"},
+ pathsToCheck: []string{"/", ""},
+ expectedExist: []bool{true, true},
+ expectedError: nil,
+ },
+ {
+ name: "duplicate paths",
+ pathsToAdd: []string{"test/", "test/"},
+ pathsToCheck: []string{"test/"},
+ expectedExist: []bool{true},
+ expectedError: nil,
+ },
+ {
+ name: "file to directory conversion not allowed",
+ pathsToAdd: []string{"test.txt", "test.txt/file.txt"},
+ pathsToCheck: []string{},
+ expectedExist: []bool{},
+ expectedError: fmt.Errorf("path %q exists but is not a directory", "test.txt"),
+ },
+ {
+ name: "directory to file conversion not allowed",
+ pathsToAdd: []string{"test/", "test"},
+ pathsToCheck: []string{},
+ expectedExist: []bool{},
+ expectedError: fmt.Errorf("path %q exists but is not a file", "test"),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ trie := NewTrie()
+
+ // Add paths
+ var lastErr error
+ for _, path := range tt.pathsToAdd {
+ err := trie.Add(path)
+ if err != nil {
+ lastErr = err
+ break
+ }
+ }
+
+ if tt.expectedError != nil {
+ require.Error(t, lastErr)
+ require.Equal(t, tt.expectedError.Error(), lastErr.Error())
+ return
+ }
+ require.NoError(t, lastErr)
+
+ // Check existence
+ for i, path := range tt.pathsToCheck {
+ exists := trie.Exists(path)
+ require.Equal(t, tt.expectedExist[i], exists, "path: %s", path)
+ }
+ })
+ }
+}
diff --git a/pkg/registry/apis/provisioning/safepath/walk.go b/pkg/registry/apis/provisioning/safepath/walk.go
index 57a6c4a9bb6..048745da188 100644
--- a/pkg/registry/apis/provisioning/safepath/walk.go
+++ b/pkg/registry/apis/provisioning/safepath/walk.go
@@ -29,3 +29,17 @@ func Walk(ctx context.Context, p string, fn WalkFunc) error {
return nil
}
+
+// Depth returns the depth of the given path.
+func Depth(p string) int {
+ return len(Split(p))
+}
+
+// Split splits the given path into segments.
+func Split(p string) []string {
+ trimmed := strings.Trim(p, "/")
+ if trimmed == "" {
+ return []string{}
+ }
+ return strings.Split(trimmed, "/")
+}
diff --git a/pkg/registry/apis/provisioning/safepath/walk_test.go b/pkg/registry/apis/provisioning/safepath/walk_test.go
new file mode 100644
index 00000000000..c1694f2af53
--- /dev/null
+++ b/pkg/registry/apis/provisioning/safepath/walk_test.go
@@ -0,0 +1,178 @@
+package safepath
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestWalk(t *testing.T) {
+ tests := []struct {
+ name string
+ path string
+ expectedPaths []string
+ expectError bool
+ }{
+ {
+ name: "simple path",
+ path: "a/b/c",
+ expectedPaths: []string{
+ "a",
+ "a/b",
+ "a/b/c",
+ },
+ },
+ {
+ name: "path with leading slash",
+ path: "/a/b/c",
+ expectedPaths: []string{
+ "a",
+ "a/b",
+ "a/b/c",
+ },
+ },
+ {
+ name: "path with trailing slash",
+ path: "a/b/c/",
+ expectedPaths: []string{
+ "a",
+ "a/b",
+ "a/b/c",
+ },
+ },
+ {
+ name: "root path",
+ path: "/",
+ expectedPaths: nil,
+ },
+ {
+ name: "current directory",
+ path: ".",
+ expectedPaths: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var paths []string
+ err := Walk(context.Background(), tt.path, func(ctx context.Context, p string) error {
+ paths = append(paths, p)
+ return nil
+ })
+
+ if tt.expectError {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ assert.Equal(t, tt.expectedPaths, paths)
+ }
+ })
+ }
+}
+
+func TestDepth(t *testing.T) {
+ tests := []struct {
+ name string
+ path string
+ expectedDepth int
+ }{
+ {
+ name: "empty path",
+ path: "",
+ expectedDepth: 0,
+ },
+ {
+ name: "root path",
+ path: "/",
+ expectedDepth: 0,
+ },
+ {
+ name: "single level",
+ path: "a",
+ expectedDepth: 1,
+ },
+ {
+ name: "multiple levels",
+ path: "a/b/c",
+ expectedDepth: 3,
+ },
+ {
+ name: "path with leading slash",
+ path: "/a/b/c",
+ expectedDepth: 3,
+ },
+ {
+ name: "path with trailing slash",
+ path: "a/b/c/",
+ expectedDepth: 3,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ depth := Depth(tt.path)
+ assert.Equal(t, tt.expectedDepth, depth)
+ })
+ }
+}
+
+func TestSplit(t *testing.T) {
+ tests := []struct {
+ name string
+ path string
+ expectedSegments []string
+ }{
+ {
+ name: "empty path",
+ path: "",
+ expectedSegments: []string{},
+ },
+ {
+ name: "root path",
+ path: "/",
+ expectedSegments: []string{},
+ },
+ {
+ name: "single segment",
+ path: "a",
+ expectedSegments: []string{"a"},
+ },
+ {
+ name: "multiple segments",
+ path: "a/b/c",
+ expectedSegments: []string{"a", "b", "c"},
+ },
+ {
+ name: "path with leading slash",
+ path: "/a/b/c",
+ expectedSegments: []string{"a", "b", "c"},
+ },
+ {
+ name: "path with trailing slash",
+ path: "a/b/c/",
+ expectedSegments: []string{"a", "b", "c"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ segments := Split(tt.path)
+ assert.Equal(t, tt.expectedSegments, segments)
+ })
+ }
+}
+
+func TestWalkError(t *testing.T) {
+ expectedErr := errors.New("test error")
+ err := Walk(context.Background(), "a/b/c", func(ctx context.Context, p string) error {
+ if p == "a/b" {
+ return expectedErr
+ }
+ return nil
+ })
+
+ require.ErrorIs(t, err, expectedErr)
+}
diff --git a/pkg/registry/apis/provisioning/sync.go b/pkg/registry/apis/provisioning/sync.go
new file mode 100644
index 00000000000..3d5e6179a8f
--- /dev/null
+++ b/pkg/registry/apis/provisioning/sync.go
@@ -0,0 +1,87 @@
+package provisioning
+
+import (
+ "context"
+ "net/http"
+ "time"
+
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apiserver/pkg/registry/rest"
+
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
+)
+
+// TODO: should we have merge migrate and sync connectors and have a single repository job connector?
+type syncConnector struct {
+ repoGetter RepoGetter
+ jobs jobs.Queue
+}
+
+func (*syncConnector) New() runtime.Object {
+ return &provisioning.Job{}
+}
+
+func (*syncConnector) Destroy() {}
+
+func (*syncConnector) ProducesMIMETypes(verb string) []string {
+ return []string{"application/json"}
+}
+
+func (c *syncConnector) ProducesObject(verb string) any {
+ return c.New()
+}
+
+func (*syncConnector) ConnectMethods() []string {
+ return []string{http.MethodPost}
+}
+
+func (*syncConnector) NewConnectOptions() (runtime.Object, bool, string) {
+ return nil, false, ""
+}
+
+func (c *syncConnector) Connect(
+ ctx context.Context,
+ name string,
+ opts runtime.Object,
+ responder rest.Responder,
+) (http.Handler, error) {
+ repo, err := c.repoGetter.GetHealthyRepository(ctx, name)
+ if err != nil {
+ return nil, err
+ }
+ cfg := repo.Config()
+
+ return withTimeout(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var options provisioning.SyncJobOptions
+
+ if err := unmarshalJSON(r, defaultMaxBodySize, &options); err != nil {
+ responder.Error(apierrors.NewBadRequest("error decoding SyncJobOptions from request"))
+ return
+ }
+
+ job, err := c.jobs.Insert(ctx, &provisioning.Job{
+ ObjectMeta: v1.ObjectMeta{
+ Namespace: cfg.Namespace,
+ },
+ Spec: provisioning.JobSpec{
+ Action: provisioning.JobActionSync,
+ Repository: cfg.Name,
+ Pull: &options,
+ },
+ })
+ if err != nil {
+ responder.Error(err)
+ return
+ }
+ responder.Object(http.StatusAccepted, job)
+ }), 30*time.Second), nil
+}
+
+var (
+ _ rest.Connecter = (*syncConnector)(nil)
+ _ rest.Storage = (*syncConnector)(nil)
+ _ rest.StorageMetadata = (*syncConnector)(nil)
+)
diff --git a/pkg/registry/apis/provisioning/test.go b/pkg/registry/apis/provisioning/test.go
new file mode 100644
index 00000000000..6b26e8482d6
--- /dev/null
+++ b/pkg/registry/apis/provisioning/test.go
@@ -0,0 +1,140 @@
+package provisioning
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "reflect"
+ "time"
+
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apiserver/pkg/registry/rest"
+
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ client "github.com/grafana/grafana/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+)
+
+type testConnector struct {
+ getter RepoGetter
+}
+
+func (*testConnector) New() runtime.Object {
+ return &provisioning.TestResults{}
+}
+
+func (*testConnector) Destroy() {}
+
+func (*testConnector) ProducesMIMETypes(verb string) []string {
+ return []string{"application/json"}
+}
+
+func (*testConnector) ProducesObject(verb string) any {
+ return &provisioning.TestResults{}
+}
+
+func (*testConnector) ConnectMethods() []string {
+ return []string{http.MethodPost}
+}
+
+func (*testConnector) NewConnectOptions() (runtime.Object, bool, string) {
+ return nil, false, ""
+}
+
+func (s *testConnector) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
+ return withTimeout(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, err := readBody(r, defaultMaxBodySize)
+ if err != nil {
+ responder.Error(err)
+ return
+ }
+ var repo repository.Repository
+ if len(body) > 0 {
+ var cfg provisioning.Repository
+ err = json.Unmarshal(body, &cfg)
+ if err != nil {
+ responder.Error(err)
+ return
+ }
+
+ // In case the body is an empty object
+ if !reflect.ValueOf(cfg).IsZero() {
+ // Create a temporary repository
+ tmp, err := s.getter.AsRepository(ctx, &cfg)
+ if err != nil {
+ responder.Error(err)
+ return
+ }
+
+ // TODO: Explore how to better support synchronous validation for the UI (and likely remove this hack)
+ if name != "new" {
+ repo, err = s.getter.AsRepository(ctx, &cfg)
+ if err != nil {
+ responder.Error(err)
+ return
+ }
+
+ // Make sure we are OK with the changes
+ if cfg.Spec.Type != repo.Config().Spec.Type {
+ responder.Error(apierrors.NewBadRequest("test config must be the same type"))
+ return
+ }
+ }
+ repo = tmp
+ }
+ }
+
+ if repo == nil {
+ repo, err = s.getter.GetRepository(ctx, name)
+ if err != nil {
+ responder.Error(err)
+ return
+ }
+ }
+
+ // Only call test if field validation passes
+ rsp, err := repository.TestRepository(ctx, repo)
+ if err != nil {
+ responder.Error(err)
+ return
+ }
+ responder.Object(rsp.Code, rsp)
+ }), 30*time.Second), nil
+}
+
+// TODO: Move tester to a more suitable location out of the connector.
+type RepositoryTester struct {
+ // Repository+Jobs
+ client client.ProvisioningV0alpha1Interface
+}
+
+// This function will check if the repository is configured and functioning as expected
+func (t *RepositoryTester) UpdateHealthStatus(ctx context.Context, cfg *provisioning.Repository, res *provisioning.TestResults) (*provisioning.Repository, error) {
+ if res == nil {
+ res = &provisioning.TestResults{
+ Success: false,
+ Errors: []string{
+ "missing health status",
+ },
+ }
+ }
+
+ repo := cfg.DeepCopy()
+ repo.Status.Health = provisioning.HealthStatus{
+ Healthy: res.Success,
+ Checked: time.Now().UnixMilli(),
+ Message: res.Errors,
+ }
+
+ _, err := t.client.Repositories(repo.GetNamespace()).
+ UpdateStatus(ctx, repo, metav1.UpdateOptions{})
+ return repo, err
+}
+
+var (
+ _ rest.Storage = (*testConnector)(nil)
+ _ rest.Connecter = (*testConnector)(nil)
+ _ rest.StorageMetadata = (*testConnector)(nil)
+)
diff --git a/pkg/registry/apis/provisioning/timeout.go b/pkg/registry/apis/provisioning/timeout.go
new file mode 100644
index 00000000000..254b94e86db
--- /dev/null
+++ b/pkg/registry/apis/provisioning/timeout.go
@@ -0,0 +1,21 @@
+package provisioning
+
+import (
+ "context"
+ "net/http"
+ "time"
+)
+
+// withTimeout adds a timeout context to the request
+func withTimeout(h http.Handler, timeout time.Duration) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctx, cancel := context.WithTimeout(r.Context(), timeout)
+ defer cancel()
+ h.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
+// withTimeoutFunc adds a timeout context to the request
+func withTimeoutFunc(f func(w http.ResponseWriter, r *http.Request), timeout time.Duration) func(w http.ResponseWriter, r *http.Request) {
+ return withTimeout(http.HandlerFunc(f), timeout).ServeHTTP
+}
diff --git a/pkg/registry/apis/provisioning/timeout_test.go b/pkg/registry/apis/provisioning/timeout_test.go
new file mode 100644
index 00000000000..78425e9f4bf
--- /dev/null
+++ b/pkg/registry/apis/provisioning/timeout_test.go
@@ -0,0 +1,48 @@
+package provisioning
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+func TestWithTimeout(t *testing.T) {
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ select {
+ case <-r.Context().Done():
+ w.WriteHeader(http.StatusGatewayTimeout)
+ case <-time.After(50 * time.Millisecond):
+ w.WriteHeader(http.StatusOK)
+ }
+ })
+
+ tests := []struct {
+ name string
+ timeout time.Duration
+ wantStatus int
+ }{
+ {
+ name: "request completes",
+ timeout: 100 * time.Millisecond,
+ wantStatus: http.StatusOK,
+ },
+ {
+ name: "request times out",
+ timeout: 10 * time.Millisecond,
+ wantStatus: http.StatusGatewayTimeout,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", "/", nil)
+ withTimeout(handler, tt.timeout).ServeHTTP(w, r)
+
+ if w.Code != tt.wantStatus {
+ t.Errorf("withTimeout() status = %v, want %v", w.Code, tt.wantStatus)
+ }
+ })
+ }
+}
diff --git a/pkg/registry/apis/provisioning/types.go b/pkg/registry/apis/provisioning/types.go
new file mode 100644
index 00000000000..b2d3acdf3fa
--- /dev/null
+++ b/pkg/registry/apis/provisioning/types.go
@@ -0,0 +1,21 @@
+package provisioning
+
+import (
+ "context"
+
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+)
+
+type RepoGetter interface {
+ // This gets a repository with the provided name in the namespace from ctx
+ GetRepository(ctx context.Context, name string) (repository.Repository, error)
+
+ // This will return a healthy repository, or an error saying the repository is not healthy
+ GetHealthyRepository(ctx context.Context, name string) (repository.Repository, error)
+
+ // Given a repository configuration, return it as a repository instance
+ // This will only error for un-recoverable system errors
+ // the repository instance may or may not be valid/healthy
+ AsRepository(ctx context.Context, cfg *provisioning.Repository) (repository.Repository, error)
+}
diff --git a/pkg/registry/apis/provisioning/usage.go b/pkg/registry/apis/provisioning/usage.go
new file mode 100644
index 00000000000..b0fef85e468
--- /dev/null
+++ b/pkg/registry/apis/provisioning/usage.go
@@ -0,0 +1,55 @@
+package provisioning
+
+import (
+ "context"
+ "fmt"
+
+ "k8s.io/apimachinery/pkg/labels"
+
+ "github.com/grafana/grafana/pkg/storage/unified/resource"
+)
+
+func (b *APIBuilder) collectProvisioningStats(ctx context.Context) (map[string]any, error) {
+ m := map[string]any{}
+ if b.unified == nil {
+ return m, nil
+ }
+
+ // FIXME: hardcoded to "default" for now -- it works for single tenant deployments
+ // we could discover the set of valid namespaces, but that would count everything for
+ // each instance in cloud.
+ //
+ // We could get namespaces from the list of repos below, but that could be zero
+ // while we still have resources managed by terraform, etc
+ ns := "default"
+ count, err := b.unified.CountManagedObjects(ctx, &resource.CountManagedObjectsRequest{
+ Namespace: ns,
+ })
+ if err != nil {
+ return m, err
+ }
+ counts := make(map[string]int, 10)
+ for _, v := range count.Items {
+ counts[v.Kind] = counts[v.Kind] + int(v.Count)
+ }
+ for k, v := range counts {
+ m[fmt.Sprintf("stats.managed_by.%s.count", k)] = v
+ }
+
+ // Inspect all configs
+ repos, err := b.repositoryLister.List(labels.Everything())
+ if err != nil {
+ return m, err
+ }
+ clear(counts)
+ for _, repo := range repos {
+ counts[string(repo.Spec.Type)] = counts[string(repo.Spec.Type)] + 1
+ }
+
+ // Count how many items of each repository type
+ for k, v := range counts {
+ m[fmt.Sprintf("stats.repository.%s.count", k)] = v
+ }
+
+ return m, nil
+}
diff --git a/pkg/registry/apis/provisioning/webhook.go b/pkg/registry/apis/provisioning/webhook.go
new file mode 100644
index 00000000000..34c60260269
--- /dev/null
+++ b/pkg/registry/apis/provisioning/webhook.go
@@ -0,0 +1,124 @@
+package provisioning
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "time"
+
+ "k8s.io/apimachinery/pkg/api/errors"
+ v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apiserver/pkg/endpoints/request"
+ "k8s.io/apiserver/pkg/registry/rest"
+
+ "github.com/grafana/grafana-app-sdk/logging"
+ "github.com/grafana/grafana/pkg/apimachinery/identity"
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
+)
+
+// Webhook endpoint max size (25MB)
+// See https://docs.github.com/en/webhooks/webhook-events-and-payloads
+const webhookMaxBodySize = 25 * 1024 * 1024
+
+// This only works for github right now
+type webhookConnector struct {
+ getter RepoGetter
+ jobs jobs.Queue
+ webhooksEnabled bool
+}
+
+func (*webhookConnector) New() runtime.Object {
+ return &provisioning.WebhookResponse{}
+}
+
+func (*webhookConnector) Destroy() {}
+
+func (*webhookConnector) ProducesMIMETypes(verb string) []string {
+ return []string{"application/json"}
+}
+
+func (*webhookConnector) ProducesObject(verb string) any {
+ return &provisioning.WebhookResponse{}
+}
+
+func (*webhookConnector) ConnectMethods() []string {
+ return []string{
+ http.MethodPost,
+ http.MethodGet, // only useful for browser testing, should be removed
+ }
+}
+
+func (*webhookConnector) NewConnectOptions() (runtime.Object, bool, string) {
+ return nil, false, ""
+}
+
+func (s *webhookConnector) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
+ namespace := request.NamespaceValue(ctx)
+ ctx, _, err := identity.WithProvisioningIdentity(ctx, namespace)
+ if err != nil {
+ return nil, err
+ }
+
+ // Get the repository with the worker identity (since the request user is likely anonymous)
+ repo, err := s.getter.GetHealthyRepository(ctx, name)
+ if err != nil {
+ return nil, err
+ }
+
+ return withTimeout(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ logger := logging.FromContext(r.Context()).With("logger", "webhook-connector", "repo", name)
+ ctx := logging.Context(r.Context(), logger)
+ if !s.webhooksEnabled {
+ responder.Error(errors.NewBadRequest("webhooks are not enabled"))
+ return
+ }
+
+ hooks, ok := repo.(repository.Hooks)
+ if !ok {
+ responder.Error(errors.NewBadRequest("the repository does not support webhooks"))
+ return
+ }
+
+ // Limit the webhook request body size
+ r.Body = http.MaxBytesReader(w, r.Body, webhookMaxBodySize)
+
+ rsp, err := hooks.Webhook(ctx, r)
+ if err != nil {
+ responder.Error(err)
+ return
+ }
+ if rsp == nil {
+ responder.Error(fmt.Errorf("expecting a response"))
+ return
+ }
+ if rsp.Job != nil {
+ // Add the job to the job queue
+ job := &provisioning.Job{
+ ObjectMeta: v1.ObjectMeta{
+ Namespace: namespace,
+ Labels: map[string]string{
+ "repository": name,
+ },
+ },
+ Spec: *rsp.Job,
+ }
+ job, err := s.jobs.Insert(ctx, job)
+ if err != nil {
+ responder.Error(err)
+ return
+ }
+ responder.Object(rsp.Code, job)
+ return
+ }
+ responder.Object(rsp.Code, rsp)
+ }), 30*time.Second), nil
+}
+
+var (
+ _ rest.Storage = (*webhookConnector)(nil)
+ _ rest.Connecter = (*webhookConnector)(nil)
+ _ rest.StorageMetadata = (*webhookConnector)(nil)
+)
diff --git a/pkg/registry/apis/query/client/plugin.go b/pkg/registry/apis/query/client/plugin.go
index 434f3b049f9..7301f819be7 100644
--- a/pkg/registry/apis/query/client/plugin.go
+++ b/pkg/registry/apis/query/client/plugin.go
@@ -2,7 +2,6 @@ package client
import (
"context"
- "errors"
"fmt"
"net/http"
"sync"
@@ -13,9 +12,11 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
+ "github.com/grafana/grafana/pkg/apimachinery/identity"
query "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/registry/apis/query/clientapi"
+ "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
@@ -26,6 +27,7 @@ import (
type pluginClient struct {
pluginClient plugins.Client
pCtxProvider *plugincontext.Provider
+ ac accesscontrol.AccessControl
}
type pluginRegistry struct {
@@ -43,10 +45,11 @@ var _ clientapi.QueryDataClient = (*pluginClient)(nil)
var _ query.DataSourceApiServerRegistry = (*pluginRegistry)(nil)
// NewQueryClientForPluginClient creates a client that delegates to the internal plugins.Client stack
-func NewQueryClientForPluginClient(p plugins.Client, ctx *plugincontext.Provider) clientapi.QueryDataClient {
+func NewQueryClientForPluginClient(p plugins.Client, ctx *plugincontext.Provider, accessControl accesscontrol.AccessControl) clientapi.QueryDataClient {
return &pluginClient{
pluginClient: p,
pCtxProvider: ctx,
+ ac: accessControl,
}
}
@@ -59,6 +62,17 @@ func NewDataSourceRegistryFromStore(pluginStore pluginstore.Store,
}
}
+func (d *pluginClient) CanQueryDataSource(ctx context.Context, uid string) (bool, error) {
+ user, err := identity.GetRequester(ctx)
+ if err != nil {
+ return false, err
+ }
+
+ requiredScope := "datasources:uid:" + uid
+ evaluate := accesscontrol.EvalPermission(datasources.ActionQuery, requiredScope)
+ return d.ac.Evaluate(ctx, user, evaluate)
+}
+
// ExecuteQueryData implements QueryHelper.
func (d *pluginClient) QueryData(ctx context.Context, req data.QueryDataRequest) (*backend.QueryDataResponse, error) {
queries, dsRef, err := data.ToDataSourceQueries(req)
@@ -69,17 +83,23 @@ func (d *pluginClient) QueryData(ctx context.Context, req data.QueryDataRequest)
return nil, fmt.Errorf("expected single datasource request")
}
+ canQuery, err := d.CanQueryDataSource(ctx, dsRef.UID)
+ if err != nil {
+ return nil, err
+ }
+
+ if !canQuery {
+ status := metav1.Status{
+ Status: metav1.StatusFailure,
+ Code: http.StatusForbidden,
+ Message: "Access denied to the data source",
+ }
+ return nil, &apierrors.StatusError{ErrStatus: status}
+ }
+
// NOTE: this depends on uid unique across datasources
settings, err := d.pCtxProvider.GetDataSourceInstanceSettings(ctx, dsRef.UID)
if err != nil {
- if errors.Is(err, datasources.ErrDataSourceNotFound) {
- status := metav1.Status{
- Status: metav1.StatusFailure,
- Code: http.StatusNotFound,
- Message: "datasource not found",
- }
- return nil, &apierrors.StatusError{ErrStatus: status}
- }
return nil, err
}
diff --git a/pkg/registry/apis/query/register.go b/pkg/registry/apis/query/register.go
index 44f15087832..83ca935a662 100644
--- a/pkg/registry/apis/query/register.go
+++ b/pkg/registry/apis/query/register.go
@@ -127,7 +127,7 @@ func RegisterAPIService(features featuremgmt.FeatureToggles,
builder, err := NewQueryAPIBuilder(
features,
&CommonDataSourceClientSupplier{
- Client: client.NewQueryClientForPluginClient(pluginClient, pCtxProvider),
+ Client: client.NewQueryClientForPluginClient(pluginClient, pCtxProvider, accessControl),
},
ar,
client.NewDataSourceRegistryFromStore(pluginStore, dataSourcesService),
diff --git a/pkg/registry/apis/secret/accesscontrol.go b/pkg/registry/apis/secret/accesscontrol.go
new file mode 100644
index 00000000000..e93fe22d1cc
--- /dev/null
+++ b/pkg/registry/apis/secret/accesscontrol.go
@@ -0,0 +1,125 @@
+package secret
+
+import (
+ "github.com/grafana/grafana/pkg/services/accesscontrol"
+ "github.com/grafana/grafana/pkg/services/org"
+)
+
+const (
+ // SecureValues
+ ActionSecretSecureValuesCreate = "secret.securevalues:create" // CREATE.
+ ActionSecretSecureValuesWrite = "secret.securevalues:write" // UPDATE.
+ ActionSecretSecureValuesRead = "secret.securevalues:read" // GET + LIST.
+ ActionSecretSecureValuesDelete = "secret.securevalues:delete" // DELETE.
+
+ // Keepers
+ ActionSecretKeepersCreate = "secret.keepers:create" // CREATE.
+ ActionSecretKeepersWrite = "secret.keepers:write" // UPDATE.
+ ActionSecretKeepersRead = "secret.keepers:read" // GET + LIST.
+ ActionSecretKeepersDelete = "secret.keepers:delete" // DELETE.
+)
+
+var (
+ ScopeProviderSecretSecureValues = accesscontrol.NewScopeProvider("secret.securevalues")
+ ScopeProviderSecretKeepers = accesscontrol.NewScopeProvider("secret.keepers")
+
+ ScopeAllSecureValues = ScopeProviderSecretSecureValues.GetResourceAllScope()
+ ScopeAllKeepers = ScopeProviderSecretKeepers.GetResourceAllScope()
+)
+
+func RegisterAccessControlRoles(service accesscontrol.Service) error {
+ // SecureValues
+ secureValuesReader := accesscontrol.RoleRegistration{
+ Role: accesscontrol.RoleDTO{
+ Name: "fixed:secret.securevalues:reader",
+ DisplayName: "Secrets Manager secure values reader",
+ Description: "Read and list secure values.",
+ Group: "Secrets Manager",
+ Permissions: []accesscontrol.Permission{
+ {
+ Action: ActionSecretSecureValuesRead,
+ Scope: ScopeAllSecureValues,
+ },
+ },
+ },
+ Grants: []string{string(org.RoleAdmin)},
+ }
+
+ secureValuesWriter := accesscontrol.RoleRegistration{
+ Role: accesscontrol.RoleDTO{
+ Name: "fixed:secret.securevalues:writer",
+ DisplayName: "Secrets Manager secure values writer",
+ Description: "Create, update and delete secure values.",
+ Group: "Secrets Manager",
+ Permissions: []accesscontrol.Permission{
+ {
+ Action: ActionSecretSecureValuesCreate,
+ Scope: ScopeAllSecureValues,
+ },
+ {
+ Action: ActionSecretSecureValuesRead,
+ Scope: ScopeAllSecureValues,
+ },
+ {
+ Action: ActionSecretSecureValuesWrite,
+ Scope: ScopeAllSecureValues,
+ },
+ {
+ Action: ActionSecretSecureValuesDelete,
+ Scope: ScopeAllSecureValues,
+ },
+ },
+ },
+ Grants: []string{string(org.RoleAdmin)},
+ }
+
+ // Keepers
+ keepersReader := accesscontrol.RoleRegistration{
+ Role: accesscontrol.RoleDTO{
+ Name: "fixed:secret.keepers:reader",
+ DisplayName: "Secrets Manager keepers reader",
+ Description: "Read and list keepers.",
+ Group: "Secrets Manager",
+ Permissions: []accesscontrol.Permission{
+ {
+ Action: ActionSecretKeepersRead,
+ Scope: ScopeAllKeepers,
+ },
+ },
+ },
+ Grants: []string{string(org.RoleAdmin)},
+ }
+
+ keepersWriter := accesscontrol.RoleRegistration{
+ Role: accesscontrol.RoleDTO{
+ Name: "fixed:secret.keepers:writer",
+ DisplayName: "Secrets Manager keepers writer",
+ Description: "Create, update and delete keepers.",
+ Group: "Secrets Manager",
+ Permissions: []accesscontrol.Permission{
+ {
+ Action: ActionSecretKeepersCreate,
+ Scope: ScopeAllKeepers,
+ },
+ {
+ Action: ActionSecretKeepersRead,
+ Scope: ScopeAllKeepers,
+ },
+ {
+ Action: ActionSecretKeepersWrite,
+ Scope: ScopeAllKeepers,
+ },
+ {
+ Action: ActionSecretKeepersDelete,
+ Scope: ScopeAllKeepers,
+ },
+ },
+ },
+ Grants: []string{string(org.RoleAdmin)},
+ }
+
+ return service.DeclareFixedRoles(
+ secureValuesReader, secureValuesWriter,
+ keepersReader, keepersWriter,
+ )
+}
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
new file mode 100644
index 00000000000..2ba2cfb05dd
--- /dev/null
+++ b/pkg/registry/apis/secret/contracts/keeper.go
@@ -0,0 +1,55 @@
+package contracts
+
+import (
+ "context"
+ "errors"
+
+ secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/secret/xkube"
+ "k8s.io/apimachinery/pkg/apis/meta/internalversion"
+)
+
+var (
+ ErrKeeperNotFound = errors.New("keeper not found")
+)
+
+// KeeperMetadataStorage is the interface for wiring and dependency injection.
+type KeeperMetadataStorage interface {
+ Create(ctx context.Context, keeper *secretv0alpha1.Keeper) (*secretv0alpha1.Keeper, error)
+ Read(ctx context.Context, namespace xkube.Namespace, name string) (*secretv0alpha1.Keeper, error)
+ Update(ctx context.Context, keeper *secretv0alpha1.Keeper) (*secretv0alpha1.Keeper, error)
+ 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/contracts/secure_value.go b/pkg/registry/apis/secret/contracts/secure_value.go
new file mode 100644
index 00000000000..a3109e1d44f
--- /dev/null
+++ b/pkg/registry/apis/secret/contracts/secure_value.go
@@ -0,0 +1,23 @@
+package contracts
+
+import (
+ "context"
+ "errors"
+
+ secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/secret/xkube"
+ "k8s.io/apimachinery/pkg/apis/meta/internalversion"
+)
+
+var (
+ ErrSecureValueNotFound = errors.New("secure value not found")
+)
+
+// SecureValueMetadataStorage is the interface for wiring and dependency injection.
+type SecureValueMetadataStorage interface {
+ Create(ctx context.Context, sv *secretv0alpha1.SecureValue) (*secretv0alpha1.SecureValue, error)
+ Read(ctx context.Context, namespace xkube.Namespace, name string) (*secretv0alpha1.SecureValue, error)
+ Update(ctx context.Context, sv *secretv0alpha1.SecureValue) (*secretv0alpha1.SecureValue, error)
+ Delete(ctx context.Context, namespace xkube.Namespace, name string) error
+ List(ctx context.Context, namespace xkube.Namespace, options *internalversion.ListOptions) (*secretv0alpha1.SecureValueList, error)
+}
diff --git a/pkg/registry/apis/secret/register.go b/pkg/registry/apis/secret/register.go
new file mode 100644
index 00000000000..9b2ccfe85f6
--- /dev/null
+++ b/pkg/registry/apis/secret/register.go
@@ -0,0 +1,265 @@
+package secret
+
+import (
+ "context"
+ "fmt"
+
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/apimachinery/pkg/util/validation/field"
+ "k8s.io/apiserver/pkg/admission"
+ "k8s.io/apiserver/pkg/authorization/authorizer"
+ "k8s.io/apiserver/pkg/registry/rest"
+ genericapiserver "k8s.io/apiserver/pkg/server"
+ "k8s.io/kube-openapi/pkg/common"
+
+ claims "github.com/grafana/authlib/types"
+ 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"
+ "github.com/grafana/grafana/pkg/registry/apis/secret/reststorage"
+ "github.com/grafana/grafana/pkg/services/accesscontrol"
+ authsvc "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
+ "github.com/grafana/grafana/pkg/services/apiserver/builder"
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
+ "github.com/grafana/grafana/pkg/setting"
+ "github.com/grafana/grafana/pkg/util"
+)
+
+var (
+ _ builder.APIGroupBuilder = (*SecretAPIBuilder)(nil)
+ _ builder.APIGroupMutation = (*SecretAPIBuilder)(nil)
+ _ builder.APIGroupValidation = (*SecretAPIBuilder)(nil)
+ _ builder.APIGroupRouteProvider = (*SecretAPIBuilder)(nil)
+)
+
+type SecretAPIBuilder struct {
+ tracer tracing.Tracer
+ secureValueMetadataStorage contracts.SecureValueMetadataStorage
+ keeperMetadataStorage contracts.KeeperMetadataStorage
+ accessClient claims.AccessClient
+ decryptersAllowList map[string]struct{}
+}
+
+func NewSecretAPIBuilder(
+ tracer tracing.Tracer,
+ secureValueMetadataStorage contracts.SecureValueMetadataStorage,
+ keeperMetadataStorage contracts.KeeperMetadataStorage,
+ accessClient claims.AccessClient,
+ decryptersAllowList map[string]struct{},
+) *SecretAPIBuilder {
+ return &SecretAPIBuilder{tracer, secureValueMetadataStorage, keeperMetadataStorage, accessClient, decryptersAllowList}
+}
+
+func RegisterAPIService(
+ features featuremgmt.FeatureToggles,
+ cfg *setting.Cfg,
+ apiregistration builder.APIRegistrar,
+ tracer tracing.Tracer,
+ secureValueMetadataStorage contracts.SecureValueMetadataStorage,
+ keeperMetadataStorage contracts.KeeperMetadataStorage,
+ accessClient claims.AccessClient,
+ accessControlService accesscontrol.Service,
+) (*SecretAPIBuilder, error) {
+ // Skip registration unless opting into experimental apis and the secrets management app platform flag.
+ if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) ||
+ !features.IsEnabledGlobally(featuremgmt.FlagSecretsManagementAppPlatform) {
+ return nil, nil
+ }
+
+ if err := RegisterAccessControlRoles(accessControlService); err != nil {
+ return nil, fmt.Errorf("register secret access control roles: %w", err)
+ }
+
+ builder := NewSecretAPIBuilder(
+ tracer,
+ secureValueMetadataStorage,
+ keeperMetadataStorage,
+ accessClient,
+ nil, // OSS does not need an allow list.
+ )
+
+ apiregistration.RegisterAPI(builder)
+
+ return builder, nil
+}
+
+// GetGroupVersion returns the tuple of `group` and `version` for the API which uniquely identifies it.
+func (b *SecretAPIBuilder) GetGroupVersion() schema.GroupVersion {
+ return secretv0alpha1.SchemeGroupVersion
+}
+
+// InstallSchema is called by the `apiserver` which exposes the defined kinds.
+func (b *SecretAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
+ err := secretv0alpha1.AddKnownTypes(scheme, secretv0alpha1.VERSION)
+ if err != nil {
+ return err
+ }
+
+ // Link this version to the internal representation.
+ // This is used for server-side-apply (PATCH), and avoids the error:
+ // "no kind is registered for the type"
+ err = secretv0alpha1.AddKnownTypes(scheme, runtime.APIVersionInternal)
+ if err != nil {
+ return err
+ }
+
+ // Internal Kubernetes metadata API. Presumably to display the available APIs?
+ // e.g. http://localhost:3000/apis/secret.grafana.app/v0alpha1
+ metav1.AddToGroupVersion(scheme, secretv0alpha1.SchemeGroupVersion)
+
+ // This sets the priority in case we have multiple versions.
+ // By default Kubernetes will only let you use `kubectl get ` with one version.
+ // In case there are multiple versions, we'd need to pass the full path with the `--raw` flag.
+ if err := scheme.SetVersionPriority(secretv0alpha1.SchemeGroupVersion); err != nil {
+ return fmt.Errorf("scheme set version priority: %w", err)
+ }
+
+ return nil
+}
+
+// UpdateAPIGroupInfo is called when creating a generic API server for this group of kinds.
+func (b *SecretAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
+ secureValueResource := secretv0alpha1.SecureValuesResourceInfo
+ keeperResource := secretv0alpha1.KeeperResourceInfo
+
+ // rest.Storage is a generic interface for RESTful storage services.
+ // The constructors need to at least implement this interface, but will most likely implement
+ // other interfaces that equal to different operations like `get`, `list` and so on.
+ secureRestStorage := map[string]rest.Storage{
+ // Default path for `securevalue`.
+ // The `reststorage.SecureValueRest` struct will implement interfaces for CRUDL operations on `securevalue`.
+ secureValueResource.StoragePath(): reststorage.NewSecureValueRest(b.secureValueMetadataStorage, secureValueResource),
+
+ // The `reststorage.KeeperRest` struct will implement interfaces for CRUDL operations on `keeper`.
+ keeperResource.StoragePath(): reststorage.NewKeeperRest(b.keeperMetadataStorage, keeperResource),
+ }
+
+ apiGroupInfo.VersionedResourcesStorageMap[secretv0alpha1.VERSION] = secureRestStorage
+ return nil
+}
+
+// GetOpenAPIDefinitions, is this only for documentation?
+func (b *SecretAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
+ return secretv0alpha1.GetOpenAPIDefinitions
+}
+
+// GetAuthorizer decides whether the request is allowed, denied or no opinion based on credentials and request attributes.
+// Usually most resource are stored in folders (e.g. alerts, dashboards), which allows users to manage permissions at folder level,
+// rather than at resource level which also has the benefit of lowering the load on AuthZ side, since instead of storing access to
+// a single dashboard, you'd store access to all dashboards in a specific folder.
+// For Secrets, this is not the case, but if we want to make it so, we need to update this ResourceAuthorizer to check the containing folder.
+// If we ever want to do that, get guidance from IAM first as well.
+func (b *SecretAPIBuilder) GetAuthorizer() authorizer.Authorizer {
+ return authsvc.NewResourceAuthorizer(b.accessClient)
+}
+
+// Register additional routes with the server.
+func (b *SecretAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
+ return nil
+}
+
+// Validate is called in `Create`, `Update` and `Delete` REST funcs, if the body calls the argument `rest.ValidateObjectFunc`.
+func (b *SecretAPIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error {
+ obj := a.GetObject()
+ operation := a.GetOperation()
+
+ if obj == nil || operation == admission.Connect {
+ return nil // This is normal for sub-resource
+ }
+
+ groupKind := obj.GetObjectKind().GroupVersionKind().GroupKind()
+
+ // Generic validations for all kinds. At this point the name+namespace must not be empty.
+ if a.GetName() == "" {
+ return apierrors.NewInvalid(
+ groupKind,
+ a.GetName(),
+ field.ErrorList{field.Required(field.NewPath("metadata", "name"), "a `name` is required")},
+ )
+ }
+
+ if a.GetNamespace() == "" {
+ return apierrors.NewInvalid(
+ groupKind,
+ a.GetName(),
+ field.ErrorList{field.Required(field.NewPath("metadata", "namespace"), "a `namespace` is required")},
+ )
+ }
+
+ switch typedObj := obj.(type) {
+ case *secretv0alpha1.SecureValue:
+ var oldObj *secretv0alpha1.SecureValue
+
+ if a.GetOldObject() != nil {
+ var ok bool
+
+ oldObj, ok = a.GetOldObject().(*secretv0alpha1.SecureValue)
+ if !ok {
+ return apierrors.NewBadRequest(fmt.Sprintf("old object is not a SecureValue, found %T", a.GetOldObject()))
+ }
+ }
+
+ if errs := reststorage.ValidateSecureValue(typedObj, oldObj, operation, b.decryptersAllowList); len(errs) > 0 {
+ return apierrors.NewInvalid(groupKind, a.GetName(), errs)
+ }
+
+ return nil
+ case *secretv0alpha1.Keeper:
+ if errs := reststorage.ValidateKeeper(typedObj, operation); len(errs) > 0 {
+ return apierrors.NewInvalid(groupKind, a.GetName(), errs)
+ }
+
+ return nil
+ }
+
+ return apierrors.NewBadRequest(fmt.Sprintf("unknown spec %T", obj))
+}
+
+func (b *SecretAPIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error {
+ obj := a.GetObject()
+ operation := a.GetOperation()
+
+ if obj == nil || operation == admission.Connect {
+ return nil // This is normal for sub-resource
+ }
+
+ // When creating a resource and the name is empty, we need to generate one.
+ if operation == admission.Create && a.GetName() == "" {
+ generatedName, err := util.GetRandomString(8)
+ if err != nil {
+ return fmt.Errorf("generate random string: %w", err)
+ }
+
+ switch typedObj := obj.(type) {
+ case *secretv0alpha1.SecureValue:
+ optionalPrefix := typedObj.GenerateName
+ if optionalPrefix == "" {
+ optionalPrefix = "sv-"
+ }
+
+ typedObj.Name = optionalPrefix + generatedName
+
+ case *secretv0alpha1.Keeper:
+ optionalPrefix := typedObj.GenerateName
+ if optionalPrefix == "" {
+ optionalPrefix = "kp-"
+ }
+
+ typedObj.Name = optionalPrefix + generatedName
+ }
+ }
+
+ // On any mutation to a `SecureValue`, override the `phase` as `Pending` and an empty `message`.
+ if operation == admission.Create || operation == admission.Update {
+ sv, ok := obj.(*secretv0alpha1.SecureValue)
+ if ok && sv != nil {
+ sv.Status.Phase = secretv0alpha1.SecureValuePhasePending
+ sv.Status.Message = ""
+ }
+ }
+
+ return nil
+}
diff --git a/pkg/registry/apis/secret/reststorage/keeper_rest.go b/pkg/registry/apis/secret/reststorage/keeper_rest.go
new file mode 100644
index 00000000000..77926723e78
--- /dev/null
+++ b/pkg/registry/apis/secret/reststorage/keeper_rest.go
@@ -0,0 +1,356 @@
+package reststorage
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/apis/meta/internalversion"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/util/validation/field"
+ "k8s.io/apiserver/pkg/admission"
+ "k8s.io/apiserver/pkg/endpoints/request"
+ "k8s.io/apiserver/pkg/registry/rest"
+
+ "github.com/grafana/grafana/pkg/apimachinery/utils"
+ secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
+ "github.com/grafana/grafana/pkg/registry/apis/secret/xkube"
+)
+
+var (
+ _ rest.Scoper = (*KeeperRest)(nil)
+ _ rest.SingularNameProvider = (*KeeperRest)(nil)
+ _ rest.Getter = (*KeeperRest)(nil)
+ _ rest.Lister = (*KeeperRest)(nil)
+ _ rest.Storage = (*KeeperRest)(nil)
+ _ rest.Creater = (*KeeperRest)(nil)
+ _ rest.Updater = (*KeeperRest)(nil)
+ _ rest.GracefulDeleter = (*KeeperRest)(nil)
+)
+
+// KeeperRest is an implementation of CRUDL operations on a `keeper` backed by TODO.
+type KeeperRest struct {
+ storage contracts.KeeperMetadataStorage
+ resource utils.ResourceInfo
+ tableConverter rest.TableConvertor
+}
+
+// NewKeeperRest is a returns a constructed `*KeeperRest`.
+func NewKeeperRest(storage contracts.KeeperMetadataStorage, resource utils.ResourceInfo) *KeeperRest {
+ return &KeeperRest{storage, resource, resource.TableConverter()}
+}
+
+// New returns an empty `*Keeper` that is used by the `Create` method.
+func (s *KeeperRest) New() runtime.Object {
+ return s.resource.NewFunc()
+}
+
+// Destroy is called when? [TODO]
+func (s *KeeperRest) Destroy() {}
+
+// NamespaceScoped returns `true` because the storage is namespaced (== org).
+func (s *KeeperRest) NamespaceScoped() bool {
+ return true
+}
+
+// GetSingularName is used by `kubectl` discovery to have singular name representation of resources.
+func (s *KeeperRest) GetSingularName() string {
+ return s.resource.GetSingularName()
+}
+
+// NewList returns an empty `*KeeperList` that is used by the `List` method.
+func (s *KeeperRest) NewList() runtime.Object {
+ return s.resource.NewListFunc()
+}
+
+// ConvertToTable is used by Kubernetes and converts objects to `metav1.Table`.
+func (s *KeeperRest) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
+ return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
+}
+
+// List calls the inner `store` (persistence) and returns a list of `Keepers` within a `namespace` filtered by the `options`.
+func (s *KeeperRest) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
+ namespace, ok := request.NamespaceFrom(ctx)
+ if !ok {
+ return nil, fmt.Errorf("missing namespace")
+ }
+
+ keepersList, err := s.storage.List(ctx, xkube.Namespace(namespace), options)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list keepers: %w", err)
+ }
+
+ return keepersList, nil
+}
+
+// Get calls the inner `store` (persistence) and returns a `Keeper` by `name`.
+func (s *KeeperRest) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
+ namespace, ok := request.NamespaceFrom(ctx)
+ if !ok {
+ return nil, fmt.Errorf("missing namespace")
+ }
+
+ kp, err := s.storage.Read(ctx, xkube.Namespace(namespace), name)
+ if err != nil {
+ if errors.Is(err, contracts.ErrKeeperNotFound) {
+ return nil, s.resource.NewNotFound(name)
+ }
+ return nil, fmt.Errorf("failed to read keeper: %w", err)
+ }
+
+ return kp, nil
+}
+
+// Create a new `Keeper`. Does some validation and allows empty `name` (generated).
+func (s *KeeperRest) Create(
+ ctx context.Context,
+ obj runtime.Object,
+ createValidation rest.ValidateObjectFunc,
+ options *metav1.CreateOptions,
+) (runtime.Object, error) {
+ kp, ok := obj.(*secretv0alpha1.Keeper)
+ if !ok {
+ return nil, fmt.Errorf("expected Keeper for create")
+ }
+
+ if err := createValidation(ctx, obj); err != nil {
+ return nil, err
+ }
+
+ createdKeeper, err := s.storage.Create(ctx, kp)
+ if err != nil {
+ var kErr xkube.ErrorLister
+ if errors.As(err, &kErr) {
+ return nil, apierrors.NewInvalid(kp.GroupVersionKind().GroupKind(), kp.Name, kErr.ErrorList())
+ }
+
+ return nil, fmt.Errorf("failed to create keeper: %w", err)
+ }
+
+ return createdKeeper, nil
+}
+
+// Update a `Keeper`'s `value`. The second return parameter indicates whether the resource was newly created.
+func (s *KeeperRest) Update(
+ ctx context.Context,
+ name string,
+ objInfo rest.UpdatedObjectInfo,
+ createValidation rest.ValidateObjectFunc,
+ updateValidation rest.ValidateObjectUpdateFunc,
+ forceAllowCreate bool,
+ options *metav1.UpdateOptions,
+) (runtime.Object, bool, error) {
+ oldObj, err := s.Get(ctx, name, &metav1.GetOptions{})
+ if err != nil {
+ return nil, false, err
+ }
+
+ // Makes sure the UID and ResourceVersion are OK.
+ // TODO: this also makes it so the labels and annotations are additive, unless we check and remove manually.
+ newObj, err := objInfo.UpdatedObject(ctx, oldObj)
+ if err != nil {
+ return nil, false, fmt.Errorf("k8s updated object: %w", err)
+ }
+
+ // The current supported behavior for `Update` is to replace the entire `spec` with the new one.
+ // Each provider-specific setting of a keeper lives at the top-level, so it makes it possible to change a provider
+ // during an update. Otherwise both old and new providers would be merged in the `newObj` which is not allowed.
+ if err := updateValidation(ctx, newObj, oldObj); err != nil {
+ return nil, false, err
+ }
+
+ newKeeper, ok := newObj.(*secretv0alpha1.Keeper)
+ if !ok {
+ return nil, false, fmt.Errorf("expected Keeper for update")
+ }
+
+ // TODO: do we need to do this here again? Probably not, but double-check!
+ newKeeper.Annotations = xkube.CleanAnnotations(newKeeper.Annotations)
+
+ // Current implementation replaces everything passed in the spec, so it is not a PATCH. Do we want/need to support that?
+ updatedKeeper, err := s.storage.Update(ctx, newKeeper)
+ if err != nil {
+ var kErr xkube.ErrorLister
+ if errors.As(err, &kErr) {
+ return nil, false, apierrors.NewInvalid(newKeeper.GroupVersionKind().GroupKind(), newKeeper.Name, kErr.ErrorList())
+ }
+
+ return nil, false, fmt.Errorf("failed to update keeper: %w", err)
+ }
+
+ return updatedKeeper, false, nil
+}
+
+// Delete calls the inner `store` (persistence) in order to delete the `Keeper`.
+// The second return parameter `bool` indicates whether the delete was intant or not. It always is for `Keepers`.
+func (s *KeeperRest) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
+ namespace, ok := request.NamespaceFrom(ctx)
+ if !ok {
+ return nil, false, fmt.Errorf("missing namespace")
+ }
+
+ if err := s.storage.Delete(ctx, xkube.Namespace(namespace), name); err != nil {
+ return nil, false, fmt.Errorf("failed to delete keeper: %w", err)
+ }
+
+ return nil, true, nil
+}
+
+// ValidateKeeper does basic spec validation of a keeper.
+func ValidateKeeper(keeper *secretv0alpha1.Keeper, operation admission.Operation) field.ErrorList {
+ // Only validate Create and Update for now.
+ if operation != admission.Create && operation != admission.Update {
+ return nil
+ }
+
+ errs := make(field.ErrorList, 0)
+
+ if keeper.Spec.Title == "" {
+ errs = append(errs, field.Required(field.NewPath("spec", "title"), "a `title` is required"))
+ }
+
+ // Only one keeper type can be configured. Return early and don't validate the specific keeper fields.
+ if err := validateKeepers(keeper); err != nil {
+ errs = append(errs, err)
+
+ return errs
+ }
+
+ // TODO: Improve SQL keeper validation.
+ // SQL keeper is not allowed to use `secureValueName` in credentials fields to avoid depending on another keeper.
+ if keeper.IsSqlKeeper() {
+ if keeper.Spec.SQL.Encryption.AWS != nil {
+ if keeper.Spec.SQL.Encryption.AWS.AccessKeyID.SecureValueName != "" {
+ errs = append(errs, field.Forbidden(field.NewPath("spec", "aws", "accessKeyId"), "secureValueName cannot be used with SQL keeper"))
+ }
+
+ if keeper.Spec.SQL.Encryption.AWS.SecretAccessKey.SecureValueName != "" {
+ errs = append(errs, field.Forbidden(field.NewPath("spec", "aws", "secretAccessKey"), "secureValueName cannot be used with SQL keeper"))
+ }
+ }
+
+ if keeper.Spec.SQL.Encryption.Azure != nil && keeper.Spec.SQL.Encryption.Azure.ClientSecret.SecureValueName != "" {
+ errs = append(errs, field.Forbidden(field.NewPath("spec", "azure", "clientSecret"), "secureValueName cannot be used with SQL keeper"))
+ }
+
+ if keeper.Spec.SQL.Encryption.HashiCorp != nil && keeper.Spec.SQL.Encryption.HashiCorp.Token.SecureValueName != "" {
+ errs = append(errs, field.Forbidden(field.NewPath("spec", "hashicorp", "token"), "secureValueName cannot be used with SQL keeper"))
+ }
+ }
+
+ if keeper.Spec.AWS != nil {
+ if err := validateCredentialValue(field.NewPath("spec", "aws", "accessKeyId"), keeper.Spec.AWS.AccessKeyID); err != nil {
+ errs = append(errs, err)
+ }
+
+ if err := validateCredentialValue(field.NewPath("spec", "aws", "secretAccessKey"), keeper.Spec.AWS.SecretAccessKey); err != nil {
+ errs = append(errs, err)
+ }
+ }
+
+ if keeper.Spec.Azure != nil {
+ if keeper.Spec.Azure.KeyVaultName == "" {
+ errs = append(errs, field.Required(field.NewPath("spec", "azure", "keyVaultName"), "a `keyVaultName` is required"))
+ }
+
+ if keeper.Spec.Azure.TenantID == "" {
+ errs = append(errs, field.Required(field.NewPath("spec", "azure", "tenantId"), "a `tenantId` is required"))
+ }
+
+ if keeper.Spec.Azure.ClientID == "" {
+ errs = append(errs, field.Required(field.NewPath("spec", "azure", "clientId"), "a `clientId` is required"))
+ }
+
+ if err := validateCredentialValue(field.NewPath("spec", "azure", "clientSecret"), keeper.Spec.Azure.ClientSecret); err != nil {
+ errs = append(errs, err)
+ }
+ }
+
+ if keeper.Spec.GCP != nil {
+ if keeper.Spec.GCP.ProjectID == "" {
+ errs = append(errs, field.Required(field.NewPath("spec", "gcp", "projectId"), "a `projectId` is required"))
+ }
+
+ if keeper.Spec.GCP.CredentialsFile == "" {
+ errs = append(errs, field.Required(field.NewPath("spec", "gcp", "credentialsFile"), "a `credentialsFile` is required"))
+ }
+ }
+
+ if keeper.Spec.HashiCorp != nil {
+ if keeper.Spec.HashiCorp.Address == "" {
+ errs = append(errs, field.Required(field.NewPath("spec", "hashicorp", "address"), "a `address` is required"))
+ }
+
+ if err := validateCredentialValue(field.NewPath("spec", "hashicorp", "token"), keeper.Spec.HashiCorp.Token); err != nil {
+ errs = append(errs, err)
+ }
+ }
+
+ return errs
+}
+
+func validateKeepers(keeper *secretv0alpha1.Keeper) *field.Error {
+ availableKeepers := map[string]bool{
+ "sql": keeper.Spec.SQL != nil,
+ "aws": keeper.Spec.AWS != nil,
+ "azure": keeper.Spec.Azure != nil,
+ "gcp": keeper.Spec.GCP != nil,
+ "hashicorp": keeper.Spec.HashiCorp != nil,
+ }
+
+ configuredKeepers := make([]string, 0)
+
+ for keeperKind, notNil := range availableKeepers {
+ if notNil {
+ configuredKeepers = append(configuredKeepers, keeperKind)
+ }
+ }
+
+ if len(configuredKeepers) == 0 {
+ return field.Required(field.NewPath("spec"), "at least one `keeper` must be present")
+ }
+
+ if len(configuredKeepers) > 1 {
+ return field.Invalid(
+ field.NewPath("spec"),
+ strings.Join(configuredKeepers, " & "),
+ "only one `keeper` can be present at a time but found more",
+ )
+ }
+
+ return nil
+}
+
+func validateCredentialValue(path *field.Path, credentials secretv0alpha1.CredentialValue) *field.Error {
+ availableOptions := map[string]bool{
+ "secureValueName": credentials.SecureValueName != "",
+ "valueFromEnv": credentials.ValueFromEnv != "",
+ "valueFromConfig": credentials.ValueFromConfig != "",
+ }
+
+ configuredCredentials := make([]string, 0)
+
+ for credentialKind, notEmpty := range availableOptions {
+ if notEmpty {
+ configuredCredentials = append(configuredCredentials, credentialKind)
+ }
+ }
+
+ if len(configuredCredentials) == 0 {
+ return field.Required(path, "one of `secureValueName`, `valueFromEnv` or `valueFromConfig` must be present")
+ }
+
+ if len(configuredCredentials) > 1 {
+ return field.Invalid(
+ path,
+ strings.Join(configuredCredentials, " & "),
+ "only one of `secureValueName`, `valueFromEnv` or `valueFromConfig` must be present at a time but found more",
+ )
+ }
+
+ return nil
+}
diff --git a/pkg/registry/apis/secret/reststorage/keeper_rest_test.go b/pkg/registry/apis/secret/reststorage/keeper_rest_test.go
new file mode 100644
index 00000000000..abc1ac52b63
--- /dev/null
+++ b/pkg/registry/apis/secret/reststorage/keeper_rest_test.go
@@ -0,0 +1,332 @@
+package reststorage
+
+import (
+ "testing"
+
+ secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1"
+ "github.com/stretchr/testify/require"
+ "k8s.io/apiserver/pkg/admission"
+)
+
+func TestValidateKeeper(t *testing.T) {
+ t.Run("when creating a new keeper", func(t *testing.T) {
+ t.Run("the `title` must be present", func(t *testing.T) {
+ keeper := &secretv0alpha1.Keeper{
+ Spec: secretv0alpha1.KeeperSpec{
+ SQL: &secretv0alpha1.SQLKeeperConfig{},
+ },
+ }
+
+ errs := ValidateKeeper(keeper, admission.Create)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec.title", errs[0].Field)
+ })
+ })
+
+ t.Run("only one `keeper` must be present", func(t *testing.T) {
+ keeper := &secretv0alpha1.Keeper{
+ Spec: secretv0alpha1.KeeperSpec{
+ Title: "title",
+ SQL: &secretv0alpha1.SQLKeeperConfig{},
+ AWS: &secretv0alpha1.AWSKeeperConfig{},
+ Azure: &secretv0alpha1.AzureKeeperConfig{},
+ GCP: &secretv0alpha1.GCPKeeperConfig{},
+ HashiCorp: &secretv0alpha1.HashiCorpKeeperConfig{},
+ },
+ }
+
+ errs := ValidateKeeper(keeper, admission.Create)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec", errs[0].Field)
+ })
+
+ t.Run("at least one `keeper` must be present", func(t *testing.T) {
+ keeper := &secretv0alpha1.Keeper{
+ Spec: secretv0alpha1.KeeperSpec{
+ Title: "title",
+ },
+ }
+
+ errs := ValidateKeeper(keeper, admission.Create)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec", errs[0].Field)
+ })
+
+ t.Run("aws keeper validation", func(t *testing.T) {
+ validKeeperAWS := &secretv0alpha1.Keeper{
+ Spec: secretv0alpha1.KeeperSpec{
+ Title: "title",
+ AWS: &secretv0alpha1.AWSKeeperConfig{
+ AWSCredentials: secretv0alpha1.AWSCredentials{
+ AccessKeyID: secretv0alpha1.CredentialValue{
+ ValueFromEnv: "some-value",
+ },
+ SecretAccessKey: secretv0alpha1.CredentialValue{
+ SecureValueName: "some-value",
+ },
+ KMSKeyID: "optional",
+ },
+ },
+ },
+ }
+
+ t.Run("`accessKeyId` must be present", func(t *testing.T) {
+ t.Run("at least one of the credential value must be present", func(t *testing.T) {
+ keeper := validKeeperAWS.DeepCopy()
+ keeper.Spec.AWS.AccessKeyID = secretv0alpha1.CredentialValue{}
+
+ errs := ValidateKeeper(keeper, admission.Create)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec.aws.accessKeyId", errs[0].Field)
+ })
+
+ t.Run("at most one of the credential value must be present", func(t *testing.T) {
+ keeper := validKeeperAWS.DeepCopy()
+ keeper.Spec.AWS.AccessKeyID = secretv0alpha1.CredentialValue{
+ SecureValueName: "a",
+ ValueFromEnv: "b",
+ ValueFromConfig: "c",
+ }
+
+ errs := ValidateKeeper(keeper, admission.Create)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec.aws.accessKeyId", errs[0].Field)
+ })
+ })
+
+ t.Run("`secretAccessKey` must be present", func(t *testing.T) {
+ t.Run("at least one of the credential value must be present", func(t *testing.T) {
+ keeper := validKeeperAWS.DeepCopy()
+ keeper.Spec.AWS.SecretAccessKey = secretv0alpha1.CredentialValue{}
+
+ errs := ValidateKeeper(keeper, admission.Create)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec.aws.secretAccessKey", errs[0].Field)
+ })
+
+ t.Run("at most one of the credential value must be present", func(t *testing.T) {
+ keeper := validKeeperAWS.DeepCopy()
+ keeper.Spec.AWS.SecretAccessKey = secretv0alpha1.CredentialValue{
+ SecureValueName: "a",
+ ValueFromEnv: "b",
+ ValueFromConfig: "c",
+ }
+
+ errs := ValidateKeeper(keeper, admission.Create)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec.aws.secretAccessKey", errs[0].Field)
+ })
+ })
+ })
+
+ t.Run("azure keeper validation", func(t *testing.T) {
+ validKeeperAzure := &secretv0alpha1.Keeper{
+ Spec: secretv0alpha1.KeeperSpec{
+ Title: "title",
+ Azure: &secretv0alpha1.AzureKeeperConfig{
+ AzureCredentials: secretv0alpha1.AzureCredentials{
+ KeyVaultName: "kv-name",
+ TenantID: "tenant-id",
+ ClientID: "client-id",
+ ClientSecret: secretv0alpha1.CredentialValue{
+ ValueFromConfig: "config.path.value",
+ },
+ },
+ },
+ },
+ }
+
+ t.Run("`keyVaultName` must be present", func(t *testing.T) {
+ keeper := validKeeperAzure.DeepCopy()
+ keeper.Spec.Azure.KeyVaultName = ""
+
+ errs := ValidateKeeper(keeper, admission.Create)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec.azure.keyVaultName", errs[0].Field)
+ })
+
+ t.Run("`tenantId` must be present", func(t *testing.T) {
+ keeper := validKeeperAzure.DeepCopy()
+ keeper.Spec.Azure.TenantID = ""
+
+ errs := ValidateKeeper(keeper, admission.Create)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec.azure.tenantId", errs[0].Field)
+ })
+
+ t.Run("`clientId` must be present", func(t *testing.T) {
+ keeper := validKeeperAzure.DeepCopy()
+ keeper.Spec.Azure.ClientID = ""
+
+ errs := ValidateKeeper(keeper, admission.Create)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec.azure.clientId", errs[0].Field)
+ })
+
+ t.Run("`clientSecret` must be present", func(t *testing.T) {
+ t.Run("at least one of the credential value must be present", func(t *testing.T) {
+ keeper := validKeeperAzure.DeepCopy()
+ keeper.Spec.Azure.ClientSecret = secretv0alpha1.CredentialValue{}
+
+ errs := ValidateKeeper(keeper, admission.Create)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec.azure.clientSecret", errs[0].Field)
+ })
+
+ t.Run("at most one of the credential value must be present", func(t *testing.T) {
+ keeper := validKeeperAzure.DeepCopy()
+ keeper.Spec.Azure.ClientSecret = secretv0alpha1.CredentialValue{
+ SecureValueName: "a",
+ ValueFromEnv: "b",
+ ValueFromConfig: "c",
+ }
+
+ errs := ValidateKeeper(keeper, admission.Create)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec.azure.clientSecret", errs[0].Field)
+ })
+ })
+ })
+
+ t.Run("gcp keeper validation", func(t *testing.T) {
+ validKeeperGCP := &secretv0alpha1.Keeper{
+ Spec: secretv0alpha1.KeeperSpec{
+ Title: "title",
+ GCP: &secretv0alpha1.GCPKeeperConfig{
+ GCPCredentials: secretv0alpha1.GCPCredentials{
+ ProjectID: "project-id",
+ CredentialsFile: "/path/to/credentials/file.json",
+ },
+ },
+ },
+ }
+
+ t.Run("`projectId` must be present", func(t *testing.T) {
+ keeper := validKeeperGCP.DeepCopy()
+ keeper.Spec.GCP.ProjectID = ""
+
+ errs := ValidateKeeper(keeper, admission.Create)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec.gcp.projectId", errs[0].Field)
+ })
+
+ t.Run("`credentialsFile` must be present", func(t *testing.T) {
+ keeper := validKeeperGCP.DeepCopy()
+ keeper.Spec.GCP.CredentialsFile = ""
+
+ errs := ValidateKeeper(keeper, admission.Create)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec.gcp.credentialsFile", errs[0].Field)
+ })
+ })
+
+ t.Run("hashicorp keeper validation", func(t *testing.T) {
+ validKeeperHashiCorp := &secretv0alpha1.Keeper{
+ Spec: secretv0alpha1.KeeperSpec{
+ Title: "title",
+ HashiCorp: &secretv0alpha1.HashiCorpKeeperConfig{
+ HashiCorpCredentials: secretv0alpha1.HashiCorpCredentials{
+ Address: "http://address",
+ Token: secretv0alpha1.CredentialValue{
+ ValueFromConfig: "config.path.value",
+ },
+ },
+ },
+ },
+ }
+
+ t.Run("`address` must be present", func(t *testing.T) {
+ keeper := validKeeperHashiCorp.DeepCopy()
+ keeper.Spec.HashiCorp.Address = ""
+
+ errs := ValidateKeeper(keeper, admission.Create)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec.hashicorp.address", errs[0].Field)
+ })
+
+ t.Run("`token` must be present", func(t *testing.T) {
+ t.Run("at least one of the credential value must be present", func(t *testing.T) {
+ keeper := validKeeperHashiCorp.DeepCopy()
+ keeper.Spec.HashiCorp.Token = secretv0alpha1.CredentialValue{}
+
+ errs := ValidateKeeper(keeper, admission.Create)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec.hashicorp.token", errs[0].Field)
+ })
+
+ t.Run("at most one of the credential value must be present", func(t *testing.T) {
+ keeper := validKeeperHashiCorp.DeepCopy()
+ keeper.Spec.HashiCorp.Token = secretv0alpha1.CredentialValue{
+ SecureValueName: "a",
+ ValueFromEnv: "b",
+ ValueFromConfig: "c",
+ }
+
+ errs := ValidateKeeper(keeper, admission.Create)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec.hashicorp.token", errs[0].Field)
+ })
+ })
+ })
+
+ t.Run("sql keeper validation", func(t *testing.T) {
+ t.Run("does not allow usage of `secureValueName` in credentials", func(t *testing.T) {
+ providers := []struct {
+ name string
+ enc secretv0alpha1.Encryption
+ expectedErrors int
+ }{
+ {
+ name: "aws",
+ enc: secretv0alpha1.Encryption{
+ AWS: &secretv0alpha1.AWSCredentials{
+ AccessKeyID: secretv0alpha1.CredentialValue{
+ SecureValueName: "not-empty",
+ },
+ SecretAccessKey: secretv0alpha1.CredentialValue{
+ SecureValueName: "not-empty",
+ },
+ },
+ },
+ expectedErrors: 2,
+ },
+ {
+ name: "azure",
+ enc: secretv0alpha1.Encryption{
+ Azure: &secretv0alpha1.AzureCredentials{
+ ClientSecret: secretv0alpha1.CredentialValue{
+ SecureValueName: "not-empty",
+ },
+ },
+ },
+ expectedErrors: 1,
+ },
+ {
+ name: "hashicorp",
+ enc: secretv0alpha1.Encryption{
+ HashiCorp: &secretv0alpha1.HashiCorpCredentials{
+ Token: secretv0alpha1.CredentialValue{
+ SecureValueName: "not-empty",
+ },
+ },
+ },
+ expectedErrors: 1,
+ },
+ }
+
+ for _, tc := range providers {
+ t.Run("when using credentials for "+tc.name, func(t *testing.T) {
+ keeper := &secretv0alpha1.Keeper{
+ Spec: secretv0alpha1.KeeperSpec{
+ Title: "title",
+ SQL: &secretv0alpha1.SQLKeeperConfig{Encryption: &tc.enc},
+ },
+ }
+
+ errs := ValidateKeeper(keeper, admission.Create)
+ require.Len(t, errs, tc.expectedErrors)
+ })
+ }
+ })
+ })
+}
diff --git a/pkg/registry/apis/secret/reststorage/secure_value_rest.go b/pkg/registry/apis/secret/reststorage/secure_value_rest.go
new file mode 100644
index 00000000000..f0b043108e9
--- /dev/null
+++ b/pkg/registry/apis/secret/reststorage/secure_value_rest.go
@@ -0,0 +1,315 @@
+package reststorage
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+
+ "k8s.io/apimachinery/pkg/apis/meta/internalversion"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/util/validation/field"
+ "k8s.io/apiserver/pkg/admission"
+ "k8s.io/apiserver/pkg/endpoints/request"
+ "k8s.io/apiserver/pkg/registry/rest"
+
+ "github.com/grafana/grafana/pkg/apimachinery/utils"
+ secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1"
+ "github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
+ "github.com/grafana/grafana/pkg/registry/apis/secret/xkube"
+)
+
+var (
+ _ rest.Scoper = (*SecureValueRest)(nil)
+ _ rest.SingularNameProvider = (*SecureValueRest)(nil)
+ _ rest.Getter = (*SecureValueRest)(nil)
+ _ rest.Lister = (*SecureValueRest)(nil)
+ _ rest.Storage = (*SecureValueRest)(nil)
+ _ rest.Creater = (*SecureValueRest)(nil)
+ _ rest.Updater = (*SecureValueRest)(nil)
+ _ rest.GracefulDeleter = (*SecureValueRest)(nil)
+)
+
+// SecureValueRest is an implementation of CRUDL operations on a `securevalue` backed by a persistence layer `store`.
+type SecureValueRest struct {
+ storage contracts.SecureValueMetadataStorage
+ resource utils.ResourceInfo
+ tableConverter rest.TableConvertor
+}
+
+// NewSecureValueRest is a returns a constructed `*SecureValueRest`.
+func NewSecureValueRest(storage contracts.SecureValueMetadataStorage, resource utils.ResourceInfo) *SecureValueRest {
+ return &SecureValueRest{storage, resource, resource.TableConverter()}
+}
+
+// New returns an empty `*SecureValue` that is used by the `Create` method.
+func (s *SecureValueRest) New() runtime.Object {
+ return s.resource.NewFunc()
+}
+
+// Destroy is called when? [TODO]
+func (s *SecureValueRest) Destroy() {}
+
+// NamespaceScoped returns `true` because the storage is namespaced (== org).
+func (s *SecureValueRest) NamespaceScoped() bool {
+ return true
+}
+
+// GetSingularName is used by `kubectl` discovery to have singular name representation of resources.
+func (s *SecureValueRest) GetSingularName() string {
+ return s.resource.GetSingularName()
+}
+
+// NewList returns an empty `*SecureValueList` that is used by the `List` method.
+func (s *SecureValueRest) NewList() runtime.Object {
+ return s.resource.NewListFunc()
+}
+
+// ConvertToTable is used by Kubernetes and converts objects to `metav1.Table`.
+func (s *SecureValueRest) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
+ return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
+}
+
+// List calls the inner `store` (persistence) and returns a list of `securevalues` within a `namespace` filtered by the `options`.
+func (s *SecureValueRest) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
+ namespace, ok := request.NamespaceFrom(ctx)
+ if !ok {
+ return nil, fmt.Errorf("missing namespace")
+ }
+
+ secureValueList, err := s.storage.List(ctx, xkube.Namespace(namespace), options)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list secure values: %w", err)
+ }
+
+ return secureValueList, nil
+}
+
+// Get calls the inner `store` (persistence) and returns a `securevalue` by `name`. It will NOT return the decrypted `value`.
+func (s *SecureValueRest) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
+ namespace, ok := request.NamespaceFrom(ctx)
+ if !ok {
+ return nil, fmt.Errorf("missing namespace")
+ }
+
+ sv, err := s.storage.Read(ctx, xkube.Namespace(namespace), name)
+ if err != nil {
+ if errors.Is(err, contracts.ErrSecureValueNotFound) {
+ return nil, s.resource.NewNotFound(name)
+ }
+
+ return nil, fmt.Errorf("failed to read secure value: %w", err)
+ }
+
+ return sv, nil
+}
+
+// Create a new `securevalue`. Does some validation and allows empty `name` (generated).
+func (s *SecureValueRest) Create(
+ ctx context.Context,
+ obj runtime.Object,
+ createValidation rest.ValidateObjectFunc,
+ options *metav1.CreateOptions,
+) (runtime.Object, error) {
+ sv, ok := obj.(*secretv0alpha1.SecureValue)
+ if !ok {
+ return nil, fmt.Errorf("expected SecureValue for create")
+ }
+
+ if err := createValidation(ctx, obj); err != nil {
+ return nil, err
+ }
+
+ createdSecureValue, err := s.storage.Create(ctx, sv)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create secure value: %w", err)
+ }
+
+ return createdSecureValue, nil
+}
+
+// Update a `securevalue`'s `value`. The second return parameter indicates whether the resource was newly created.
+// Currently does not support "create on update" functionality. If the securevalue does not yet exist, it returns an error.
+func (s *SecureValueRest) Update(
+ ctx context.Context,
+ name string,
+ objInfo rest.UpdatedObjectInfo,
+ createValidation rest.ValidateObjectFunc,
+ updateValidation rest.ValidateObjectUpdateFunc,
+ forceAllowCreate bool,
+ options *metav1.UpdateOptions,
+) (runtime.Object, bool, error) {
+ oldObj, err := s.Get(ctx, name, &metav1.GetOptions{})
+ if err != nil {
+ return nil, false, err
+ }
+
+ // Makes sure the UID and ResourceVersion are OK.
+ // TODO: this also makes it so the labels and annotations are additive, unless we check and remove manually.
+ newObj, err := objInfo.UpdatedObject(ctx, oldObj)
+ if err != nil {
+ return nil, false, fmt.Errorf("k8s updated object: %w", err)
+ }
+
+ if err := updateValidation(ctx, newObj, oldObj); err != nil {
+ return nil, false, err
+ }
+
+ newSecureValue, ok := newObj.(*secretv0alpha1.SecureValue)
+ if !ok {
+ return nil, false, fmt.Errorf("expected SecureValue for update")
+ }
+
+ // TODO: do we need to do this here again? Probably not, but double-check!
+ newSecureValue.Annotations = xkube.CleanAnnotations(newSecureValue.Annotations)
+
+ // Current implementation replaces everything passed in the spec, so it is not a PATCH. Do we want/need to support that?
+ updatedSecureValue, err := s.storage.Update(ctx, newSecureValue)
+ if err != nil {
+ return nil, false, fmt.Errorf("failed to update secure value: %w", err)
+ }
+
+ return updatedSecureValue, false, nil
+}
+
+// Delete calls the inner `store` (persistence) in order to delete the `securevalue`.
+// The second return parameter `bool` indicates whether the delete was instant or not. It always is for `securevalues`.
+func (s *SecureValueRest) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
+ namespace, ok := request.NamespaceFrom(ctx)
+ if !ok {
+ return nil, false, fmt.Errorf("missing namespace")
+ }
+
+ if err := s.storage.Delete(ctx, xkube.Namespace(namespace), name); err != nil {
+ return nil, false, fmt.Errorf("delete secure value: %w", err)
+ }
+
+ return nil, true, nil
+}
+
+// ValidateSecureValue does basic spec validation of a securevalue.
+func ValidateSecureValue(sv, oldSv *secretv0alpha1.SecureValue, operation admission.Operation, decryptersAllowList map[string]struct{}) field.ErrorList {
+ errs := make(field.ErrorList, 0)
+
+ // Operation-specific field validation.
+ switch operation {
+ case admission.Create:
+ errs = validateSecureValueCreate(sv)
+
+ // If we plan to support PATCH-style updates, we shouldn't be requiring fields to be set.
+ case admission.Update:
+ errs = validateSecureValueUpdate(sv, oldSv)
+
+ case admission.Delete:
+ case admission.Connect:
+ }
+
+ // General validations.
+ if errs := validateDecrypters(sv.Spec.Decrypters, decryptersAllowList); len(errs) > 0 {
+ return errs
+ }
+
+ return errs
+}
+
+// validateSecureValueCreate does basic spec validation of a securevalue for the Create operation.
+func validateSecureValueCreate(sv *secretv0alpha1.SecureValue) field.ErrorList {
+ errs := make(field.ErrorList, 0)
+
+ if sv.Spec.Title == "" {
+ errs = append(errs, field.Required(field.NewPath("spec", "title"), "a `title` is required"))
+ }
+
+ if sv.Spec.Keeper == "" {
+ errs = append(errs, field.Required(field.NewPath("spec", "keeper"), "a `keeper` is required"))
+ }
+
+ if sv.Spec.Value == "" && sv.Spec.Ref == "" {
+ errs = append(errs, field.Required(field.NewPath("spec"), "either a `value` or `ref` is required"))
+ }
+
+ if sv.Spec.Value != "" && sv.Spec.Ref != "" {
+ errs = append(errs, field.Forbidden(field.NewPath("spec"), "only one of `value` or `ref` can be set"))
+ }
+
+ return errs
+}
+
+// validateSecureValueUpdate does basic spec validation of a securevalue for the Update operation.
+func validateSecureValueUpdate(sv, oldSv *secretv0alpha1.SecureValue) field.ErrorList {
+ errs := make(field.ErrorList, 0)
+
+ // For updates, an `old` object is required.
+ if oldSv == nil {
+ errs = append(errs, field.InternalError(field.NewPath("spec"), errors.New("old object is nil")))
+
+ return errs
+ }
+
+ // Only validate if one of the fields is being changed/set.
+ if sv.Spec.Value != "" || sv.Spec.Ref != "" {
+ if oldSv.Spec.Ref != "" && sv.Spec.Value != "" {
+ errs = append(errs, field.Forbidden(field.NewPath("spec"), "cannot set `value` when `ref` was already previously set"))
+ }
+
+ if oldSv.Spec.Ref == "" && sv.Spec.Ref != "" {
+ errs = append(errs, field.Forbidden(field.NewPath("spec"), "cannot set `ref` when `value` was already previously set"))
+ }
+ }
+
+ // Keeper cannot be changed.
+ if sv.Spec.Keeper != oldSv.Spec.Keeper {
+ errs = append(errs, field.Forbidden(field.NewPath("spec"), "the `keeper` cannot be changed"))
+ }
+
+ return errs
+}
+
+// validateDecrypters validates that (if populated) the `decrypters` must match "actor_{name}" and must be unique.
+func validateDecrypters(decrypters []string, decryptersAllowList map[string]struct{}) field.ErrorList {
+ errs := make(field.ErrorList, 0)
+
+ decrypterNames := make(map[string]struct{}, 0)
+
+ for i, decrypter := range decrypters {
+ // Allow List: decrypters must match exactly and be in the allowed list to be able to decrypt.
+ // This means an allow list item should have the format "actor_{name}" and not just "{name}".
+ if len(decryptersAllowList) > 0 {
+ if _, exists := decryptersAllowList[decrypter]; !exists {
+ errs = append(
+ errs,
+ field.Invalid(field.NewPath("spec", "decrypters", "["+strconv.Itoa(i)+"]"), decrypter, fmt.Sprintf("allowed values: %v", decryptersAllowList)),
+ )
+
+ return errs
+ }
+
+ continue
+ }
+
+ actor, name, found := strings.Cut(strings.TrimSpace(decrypter), "_")
+ if !found || actor != "actor" || name == "" {
+ errs = append(
+ errs,
+ field.Invalid(field.NewPath("spec", "decrypters", "["+strconv.Itoa(i)+"]"), decrypter, "a decrypter must have the format `actor_{name}`"),
+ )
+
+ continue
+ }
+
+ if _, exists := decrypterNames[name]; exists {
+ errs = append(
+ errs,
+ field.Invalid(field.NewPath("spec", "decrypters", "["+strconv.Itoa(i)+"]"), decrypter, "decrypters must be unique"),
+ )
+
+ continue
+ }
+
+ decrypterNames[name] = struct{}{}
+ }
+
+ return errs
+}
diff --git a/pkg/registry/apis/secret/reststorage/secure_value_rest_test.go b/pkg/registry/apis/secret/reststorage/secure_value_rest_test.go
new file mode 100644
index 00000000000..3002be94706
--- /dev/null
+++ b/pkg/registry/apis/secret/reststorage/secure_value_rest_test.go
@@ -0,0 +1,269 @@
+package reststorage
+
+import (
+ "fmt"
+ "maps"
+ "slices"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "k8s.io/apiserver/pkg/admission"
+
+ secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1"
+)
+
+func TestValidateSecureValue(t *testing.T) {
+ t.Run("when creating a new securevalue", func(t *testing.T) {
+ validSecureValue := &secretv0alpha1.SecureValue{
+ Spec: secretv0alpha1.SecureValueSpec{
+ Title: "title",
+ Value: "value",
+ Keeper: "keeper",
+ Decrypters: []string{"actor_app1", "actor_app2"},
+ },
+ }
+
+ t.Run("the `title` must be present", func(t *testing.T) {
+ sv := validSecureValue.DeepCopy()
+ sv.Spec.Title = ""
+
+ errs := ValidateSecureValue(sv, nil, admission.Create, nil)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec.title", errs[0].Field)
+ })
+
+ t.Run("the `keeper` must be present", func(t *testing.T) {
+ sv := validSecureValue.DeepCopy()
+ sv.Spec.Keeper = ""
+
+ errs := ValidateSecureValue(sv, nil, admission.Create, nil)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec.keeper", errs[0].Field)
+ })
+
+ t.Run("either a `value` or `ref` must be present but not both", func(t *testing.T) {
+ sv := validSecureValue.DeepCopy()
+ sv.Spec.Value = ""
+ sv.Spec.Ref = ""
+
+ errs := ValidateSecureValue(sv, nil, admission.Create, nil)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec", errs[0].Field)
+
+ sv.Spec.Value = "value"
+ sv.Spec.Ref = "value"
+
+ errs = ValidateSecureValue(sv, nil, admission.Create, nil)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec", errs[0].Field)
+ })
+ })
+
+ t.Run("when updating a securevalue", func(t *testing.T) {
+ t.Run("when trying to switch from a `value` (old) to a `ref` (new), it returns an error", func(t *testing.T) {
+ oldSv := &secretv0alpha1.SecureValue{
+ Spec: secretv0alpha1.SecureValueSpec{
+ Ref: "", // empty `ref` means a `value` was present.
+ },
+ }
+
+ sv := &secretv0alpha1.SecureValue{
+ Spec: secretv0alpha1.SecureValueSpec{
+ Ref: "ref",
+ },
+ }
+
+ errs := ValidateSecureValue(sv, oldSv, admission.Update, nil)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec", errs[0].Field)
+ })
+
+ t.Run("when trying to switch from a `ref` (old) to a `value` (new), it returns an error", func(t *testing.T) {
+ oldSv := &secretv0alpha1.SecureValue{
+ Spec: secretv0alpha1.SecureValueSpec{
+ Ref: "non-empty",
+ },
+ }
+
+ sv := &secretv0alpha1.SecureValue{
+ Spec: secretv0alpha1.SecureValueSpec{
+ Value: "value",
+ },
+ }
+
+ errs := ValidateSecureValue(sv, oldSv, admission.Update, nil)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec", errs[0].Field)
+ })
+
+ t.Run("when both `value` and `ref` are set, it returns an error", func(t *testing.T) {
+ oldSv := &secretv0alpha1.SecureValue{
+ Spec: secretv0alpha1.SecureValueSpec{
+ Ref: "non-empty",
+ },
+ }
+
+ sv := &secretv0alpha1.SecureValue{
+ Spec: secretv0alpha1.SecureValueSpec{
+ Value: "value",
+ Ref: "ref",
+ },
+ }
+
+ errs := ValidateSecureValue(sv, oldSv, admission.Update, nil)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec", errs[0].Field)
+
+ oldSv = &secretv0alpha1.SecureValue{
+ Spec: secretv0alpha1.SecureValueSpec{
+ Value: "non-empty",
+ },
+ }
+
+ errs = ValidateSecureValue(sv, oldSv, admission.Update, nil)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec", errs[0].Field)
+ })
+
+ t.Run("when no changes are made, it returns no errors", func(t *testing.T) {
+ oldSv := &secretv0alpha1.SecureValue{
+ Spec: secretv0alpha1.SecureValueSpec{
+ Title: "old-title",
+ },
+ }
+
+ sv := &secretv0alpha1.SecureValue{
+ Spec: secretv0alpha1.SecureValueSpec{
+ Title: "new-title",
+ },
+ }
+
+ errs := ValidateSecureValue(sv, oldSv, admission.Update, nil)
+ require.Empty(t, errs)
+ })
+
+ t.Run("when the old object is `nil` it returns an error", func(t *testing.T) {
+ sv := &secretv0alpha1.SecureValue{}
+
+ errs := ValidateSecureValue(sv, nil, admission.Update, nil)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec", errs[0].Field)
+ })
+
+ t.Run("when trying to change the `keeper`, it returns an error", func(t *testing.T) {
+ oldSv := &secretv0alpha1.SecureValue{
+ Spec: secretv0alpha1.SecureValueSpec{
+ Keeper: "a-keeper",
+ },
+ }
+
+ sv := &secretv0alpha1.SecureValue{
+ Spec: secretv0alpha1.SecureValueSpec{
+ Keeper: "another-keeper",
+ },
+ }
+
+ errs := ValidateSecureValue(sv, oldSv, admission.Update, nil)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec", errs[0].Field)
+ })
+ })
+
+ t.Run("`decrypters` must have unique items", func(t *testing.T) {
+ sv := &secretv0alpha1.SecureValue{
+ Spec: secretv0alpha1.SecureValueSpec{
+ Title: "title", Keeper: "keeper", Ref: "ref",
+
+ Decrypters: []string{
+ "actor_app1",
+ "actor_app1",
+ },
+ },
+ }
+
+ errs := ValidateSecureValue(sv, nil, admission.Create, nil)
+ require.Len(t, errs, 1)
+ require.Equal(t, "spec.decrypters.[1]", errs[0].Field)
+ })
+
+ t.Run("`decrypters` must match the expected format", func(t *testing.T) {
+ sv := &secretv0alpha1.SecureValue{
+ Spec: secretv0alpha1.SecureValueSpec{
+ Title: "title", Keeper: "keeper", Ref: "ref",
+
+ Decrypters: []string{
+ "app1",
+ "_app1",
+ "actr_app1",
+ "actor_ ",
+ "actor_",
+ },
+ },
+ }
+
+ errs := ValidateSecureValue(sv, nil, admission.Create, nil)
+ require.Len(t, errs, len(sv.Spec.Decrypters))
+
+ for i, err := range errs {
+ require.Equal(t, fmt.Sprintf("spec.decrypters.[%d]", i), err.Field)
+ require.Contains(t, err.Error(), "a decrypter must have the format `actor_{name}`")
+ }
+ })
+
+ t.Run("when set, the `decrypters` must be one of the allowed in the allow list", func(t *testing.T) {
+ allowList := map[string]struct{}{"actor_app1": {}, "actor_app2": {}}
+ decrypters := slices.Collect(maps.Keys(allowList))
+
+ t.Run("no matches, returns an error", func(t *testing.T) {
+ sv := &secretv0alpha1.SecureValue{
+ Spec: secretv0alpha1.SecureValueSpec{
+ Title: "title", Keeper: "keeper", Ref: "ref",
+
+ Decrypters: []string{"actor_app3"},
+ },
+ }
+
+ errs := ValidateSecureValue(sv, nil, admission.Create, allowList)
+ require.Len(t, errs, 1)
+ })
+
+ t.Run("no decrypters, returns no error", func(t *testing.T) {
+ sv := &secretv0alpha1.SecureValue{
+ Spec: secretv0alpha1.SecureValueSpec{
+ Title: "title", Keeper: "keeper", Ref: "ref",
+
+ Decrypters: []string{},
+ },
+ }
+
+ errs := ValidateSecureValue(sv, nil, admission.Create, allowList)
+ require.Empty(t, errs)
+ })
+
+ t.Run("one match, returns no errors", func(t *testing.T) {
+ sv := &secretv0alpha1.SecureValue{
+ Spec: secretv0alpha1.SecureValueSpec{
+ Title: "title", Keeper: "keeper", Ref: "ref",
+
+ Decrypters: []string{decrypters[0]},
+ },
+ }
+
+ errs := ValidateSecureValue(sv, nil, admission.Create, allowList)
+ require.Empty(t, errs)
+ })
+
+ t.Run("all matches, returns no errors", func(t *testing.T) {
+ sv := &secretv0alpha1.SecureValue{
+ Spec: secretv0alpha1.SecureValueSpec{
+ Title: "title", Keeper: "keeper", Ref: "ref",
+
+ Decrypters: decrypters,
+ },
+ }
+
+ errs := ValidateSecureValue(sv, nil, admission.Create, allowList)
+ require.Empty(t, errs)
+ })
+ })
+}
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/apis/secret/xkube/annotations.go b/pkg/registry/apis/secret/xkube/annotations.go
new file mode 100644
index 00000000000..21e7cbbac74
--- /dev/null
+++ b/pkg/registry/apis/secret/xkube/annotations.go
@@ -0,0 +1,24 @@
+package xkube
+
+import "github.com/grafana/grafana/pkg/apimachinery/utils"
+
+var (
+ // Exclude these annotations
+ skipAnnotations = map[string]bool{
+ "kubectl.kubernetes.io/last-applied-configuration": true, // force server side apply
+ utils.AnnoKeyCreatedBy: true,
+ utils.AnnoKeyUpdatedBy: true,
+ utils.AnnoKeyUpdatedTimestamp: true,
+ }
+)
+
+func CleanAnnotations(anno map[string]string) map[string]string {
+ copy := make(map[string]string)
+ for k, v := range anno {
+ if skipAnnotations[k] {
+ continue
+ }
+ copy[k] = v
+ }
+ return copy
+}
diff --git a/pkg/registry/apis/secret/xkube/errors.go b/pkg/registry/apis/secret/xkube/errors.go
new file mode 100644
index 00000000000..3de10eeffcf
--- /dev/null
+++ b/pkg/registry/apis/secret/xkube/errors.go
@@ -0,0 +1,9 @@
+package xkube
+
+import "k8s.io/apimachinery/pkg/util/validation/field"
+
+// ErrorLister is an interface compatible with errors that also returns a list of Kubernetes field errors.
+type ErrorLister interface {
+ error
+ ErrorList() field.ErrorList
+}
diff --git a/pkg/registry/apis/secret/xkube/namespace.go b/pkg/registry/apis/secret/xkube/namespace.go
new file mode 100644
index 00000000000..72fb3f3afd2
--- /dev/null
+++ b/pkg/registry/apis/secret/xkube/namespace.go
@@ -0,0 +1,8 @@
+package xkube
+
+// Namespace is a newtype of string that improves type safety.
+type Namespace string
+
+func (n Namespace) String() string {
+ return string(n)
+}
diff --git a/pkg/registry/apis/wireset.go b/pkg/registry/apis/wireset.go
index ecd0cba0afb..3ff07823e1a 100644
--- a/pkg/registry/apis/wireset.go
+++ b/pkg/registry/apis/wireset.go
@@ -12,6 +12,7 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/iam"
"github.com/grafana/grafana/pkg/registry/apis/provisioning"
"github.com/grafana/grafana/pkg/registry/apis/query"
+ "github.com/grafana/grafana/pkg/registry/apis/secret"
"github.com/grafana/grafana/pkg/registry/apis/service"
"github.com/grafana/grafana/pkg/registry/apis/userstorage"
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext"
@@ -36,5 +37,6 @@ var WireSet = wire.NewSet(
service.RegisterAPIService,
query.RegisterAPIService,
notifications.RegisterAPIService,
+ secret.RegisterAPIService,
userstorage.RegisterAPIService,
)
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/semconv/attributes.go b/pkg/semconv/attributes.go
index c05fd9f4e5b..07fc5fa6fd6 100644
--- a/pkg/semconv/attributes.go
+++ b/pkg/semconv/attributes.go
@@ -4,25 +4,6 @@ package semconv
import "go.opentelemetry.io/otel/attribute"
-// Describes Grafana service attributes.
-const (
- // GrafanaServiceNameKey is the attribute Key conforming to the
- // "grafana.service.name" semantic conventions. It represents the service
- // name.
- //
- // Type: string
- // RequirementLevel: Optional
- // Stability: stable
- // Examples: 'grafana-apiserver'
- grafanaServiceNameKey = attribute.Key("grafana.service.name")
-)
-
-// GrafanaServiceName returns an attribute KeyValue conforming to the
-// "grafana.service.name" semantic conventions. It represents the service name.
-func GrafanaServiceName(val string) attribute.KeyValue {
- return grafanaServiceNameKey.String(val)
-}
-
// Describes Grafana datasource attributes.
const (
// GrafanaDatasourceTypeKey is the attribute Key conforming to the
@@ -131,8 +112,6 @@ var (
GrafanaPluginTypeApp = grafanaPluginTypeKey.String("app")
// Renderer Plugin
GrafanaPluginTypeRenderer = grafanaPluginTypeKey.String("renderer")
- // Secret Manager Plugin
- GrafanaPluginTypeSecretmanager = grafanaPluginTypeKey.String("secretmanager")
)
// GrafanaPluginId returns an attribute KeyValue conforming to the
@@ -140,3 +119,22 @@ var (
func GrafanaPluginId(val string) attribute.KeyValue {
return grafanaPluginIdKey.String(val)
}
+
+// Describes Grafana service attributes.
+const (
+ // GrafanaServiceNameKey is the attribute Key conforming to the
+ // "grafana.service.name" semantic conventions. It represents the service
+ // name.
+ //
+ // Type: string
+ // RequirementLevel: Optional
+ // Stability: stable
+ // Examples: 'grafana-apiserver'
+ grafanaServiceNameKey = attribute.Key("grafana.service.name")
+)
+
+// GrafanaServiceName returns an attribute KeyValue conforming to the
+// "grafana.service.name" semantic conventions. It represents the service name.
+func GrafanaServiceName(val string) attribute.KeyValue {
+ return grafanaServiceNameKey.String(val)
+}
diff --git a/pkg/semconv/model/registry/plugin.yml b/pkg/semconv/model/registry/plugin.yml
index 89f088fe2cb..431794bcd34 100644
--- a/pkg/semconv/model/registry/plugin.yml
+++ b/pkg/semconv/model/registry/plugin.yml
@@ -31,10 +31,6 @@ groups:
value: "renderer"
brief: 'Renderer Plugin'
stability: stable
- - id: secretmanager
- value: "secretmanager"
- brief: 'Secret Manager Plugin'
- stability: stable
brief: The plugin type.
examples: datasource
- stability: stable
\ No newline at end of file
+ stability: stable
diff --git a/pkg/server/instrumentation_service.go b/pkg/server/instrumentation_service.go
index 7eca2a54fda..9a200ec7f38 100644
--- a/pkg/server/instrumentation_service.go
+++ b/pkg/server/instrumentation_service.go
@@ -9,19 +9,21 @@ import (
"github.com/grafana/dskit/services"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/setting"
+ "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
type instrumentationService struct {
*services.BasicService
- cfg *setting.Cfg
- httpServ *http.Server
- log log.Logger
- errChan chan error
+ cfg *setting.Cfg
+ httpServ *http.Server
+ log log.Logger
+ errChan chan error
+ promGatherer prometheus.Gatherer
}
-func NewInstrumentationService(log log.Logger, cfg *setting.Cfg) (*instrumentationService, error) {
- s := &instrumentationService{log: log, cfg: cfg}
+func NewInstrumentationService(log log.Logger, cfg *setting.Cfg, promGatherer prometheus.Gatherer) (*instrumentationService, error) {
+ s := &instrumentationService{log: log, cfg: cfg, promGatherer: promGatherer}
s.BasicService = services.NewBasicService(s.start, s.running, s.stop)
return s, nil
}
@@ -56,7 +58,7 @@ func (s *instrumentationService) stop(failureReason error) error {
func (s *instrumentationService) newInstrumentationServer(ctx context.Context) *http.Server {
router := http.NewServeMux()
- router.Handle("/metrics", promhttp.Handler())
+ router.Handle("/metrics", promhttp.HandlerFor(s.promGatherer, promhttp.HandlerOpts{EnableOpenMetrics: true}))
srv := &http.Server{
// 5s timeout for header reads to avoid Slowloris attacks (https://thetooth.io/blog/slowloris-attack/)
diff --git a/pkg/server/instrumentation_service_test.go b/pkg/server/instrumentation_service_test.go
index 51af5ee4032..1dc8ffa8349 100644
--- a/pkg/server/instrumentation_service_test.go
+++ b/pkg/server/instrumentation_service_test.go
@@ -18,7 +18,7 @@ import (
func TestRunInstrumentationService(t *testing.T) {
cfg := setting.NewCfg()
cfg.HTTPPort = "3001"
- s, err := NewInstrumentationService(log.New("test-logger"), cfg)
+ s, err := NewInstrumentationService(log.New("test-logger"), cfg, prometheus.DefaultGatherer)
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second)
diff --git a/pkg/server/module_server.go b/pkg/server/module_server.go
index 82fd314f5e1..11db7c204d3 100644
--- a/pkg/server/module_server.go
+++ b/pkg/server/module_server.go
@@ -18,12 +18,13 @@ import (
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/sql"
+ "github.com/prometheus/client_golang/prometheus"
)
// NewModule returns an instance of a ModuleServer, responsible for managing
// dskit modules (services).
-func NewModule(opts Options, apiOpts api.ServerOptions, features featuremgmt.FeatureToggles, cfg *setting.Cfg, storageMetrics *resource.StorageMetrics, indexMetrics *resource.BleveIndexMetrics) (*ModuleServer, error) {
- s, err := newModuleServer(opts, apiOpts, features, cfg, storageMetrics, indexMetrics)
+func NewModule(opts Options, apiOpts api.ServerOptions, features featuremgmt.FeatureToggles, cfg *setting.Cfg, storageMetrics *resource.StorageMetrics, indexMetrics *resource.BleveIndexMetrics, promGatherer prometheus.Gatherer) (*ModuleServer, error) {
+ s, err := newModuleServer(opts, apiOpts, features, cfg, storageMetrics, indexMetrics, promGatherer)
if err != nil {
return nil, err
}
@@ -35,7 +36,7 @@ func NewModule(opts Options, apiOpts api.ServerOptions, features featuremgmt.Fea
return s, nil
}
-func newModuleServer(opts Options, apiOpts api.ServerOptions, features featuremgmt.FeatureToggles, cfg *setting.Cfg, storageMetrics *resource.StorageMetrics, indexMetrics *resource.BleveIndexMetrics) (*ModuleServer, error) {
+func newModuleServer(opts Options, apiOpts api.ServerOptions, features featuremgmt.FeatureToggles, cfg *setting.Cfg, storageMetrics *resource.StorageMetrics, indexMetrics *resource.BleveIndexMetrics, promGatherer prometheus.Gatherer) (*ModuleServer, error) {
rootCtx, shutdownFn := context.WithCancel(context.Background())
s := &ModuleServer{
@@ -53,6 +54,7 @@ func newModuleServer(opts Options, apiOpts api.ServerOptions, features featuremg
buildBranch: opts.BuildBranch,
storageMetrics: storageMetrics,
indexMetrics: indexMetrics,
+ promGatherer: promGatherer,
}
return s, nil
@@ -81,6 +83,8 @@ type ModuleServer struct {
version string
commit string
buildBranch string
+
+ promGatherer prometheus.Gatherer
}
// init initializes the server and its services.
@@ -119,7 +123,7 @@ func (s *ModuleServer) Run() error {
if m.IsModuleEnabled(modules.All) || m.IsModuleEnabled(modules.Core) {
return services.NewBasicService(nil, nil, nil).WithName(modules.InstrumentationServer), nil
}
- return NewInstrumentationService(s.log, s.cfg)
+ return NewInstrumentationService(s.log, s.cfg, s.promGatherer)
})
m.RegisterModule(modules.Core, func() (services.Service, error) {
diff --git a/pkg/server/test_env.go b/pkg/server/test_env.go
index f60f9c6703e..fdf52796a8a 100644
--- a/pkg/server/test_env.go
+++ b/pkg/server/test_env.go
@@ -4,6 +4,7 @@ import (
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/httpclient"
"github.com/grafana/grafana/pkg/plugins/manager/registry"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/grpcserver"
@@ -12,9 +13,14 @@ import (
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/web"
+ "github.com/stretchr/testify/mock"
)
func ProvideTestEnv(
+ testingT interface {
+ mock.TestingT
+ Cleanup(func())
+ },
server *Server,
db db.DB,
cfg *setting.Cfg,
@@ -26,8 +32,10 @@ func ProvideTestEnv(
featureMgmt featuremgmt.FeatureToggles,
resourceClient resource.ResourceClient,
idService auth.IDService,
+ githubFactory *github.Factory,
) (*TestEnv, error) {
return &TestEnv{
+ TestingT: testingT,
Server: server,
SQLStore: db,
Cfg: cfg,
@@ -39,10 +47,15 @@ func ProvideTestEnv(
FeatureToggles: featureMgmt,
ResourceClient: resourceClient,
IDService: idService,
+ GitHubFactory: githubFactory,
}, nil
}
type TestEnv struct {
+ TestingT interface {
+ mock.TestingT
+ Cleanup(func())
+ }
Server *Server
SQLStore db.DB
Cfg *setting.Cfg
@@ -55,4 +68,5 @@ type TestEnv struct {
FeatureToggles featuremgmt.FeatureToggles
ResourceClient resource.ResourceClient
IDService auth.IDService
+ GitHubFactory *github.Factory
}
diff --git a/pkg/server/wire.go b/pkg/server/wire.go
index e211b739ecb..993c580171a 100644
--- a/pkg/server/wire.go
+++ b/pkg/server/wire.go
@@ -8,6 +8,7 @@ package server
import (
"github.com/google/wire"
+ "github.com/stretchr/testify/mock"
sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
@@ -37,6 +38,7 @@ import (
"github.com/grafana/grafana/pkg/middleware/loggermw"
apiregistry "github.com/grafana/grafana/pkg/registry/apis"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
+ "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
appregistry "github.com/grafana/grafana/pkg/registry/apps"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
@@ -159,6 +161,7 @@ import (
"github.com/grafana/grafana/pkg/services/user/userimpl"
"github.com/grafana/grafana/pkg/setting"
legacydualwrite "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
+ secretmetadata "github.com/grafana/grafana/pkg/storage/secret/metadata"
"github.com/grafana/grafana/pkg/storage/unified/resource"
unifiedsearch "github.com/grafana/grafana/pkg/storage/unified/search"
"github.com/grafana/grafana/pkg/tsdb/azuremonitor"
@@ -258,6 +261,7 @@ var wireBasicSet = wire.NewSet(
wire.Bind(new(libraryelements.Service), new(*libraryelements.LibraryElementService)),
notifications.ProvideService,
notifications.ProvideSmtpService,
+ github.ProvideFactory,
tracing.ProvideService,
tracing.ProvideTracingConfig,
wire.Bind(new(tracing.Tracer), new(*tracing.TracingService)),
@@ -362,8 +366,6 @@ var wireBasicSet = wire.NewSet(
loginattemptimpl.ProvideService,
wire.Bind(new(loginattempt.Service), new(*loginattemptimpl.Service)),
secretsMigrations.ProvideDataSourceMigrationService,
- secretsMigrations.ProvideMigrateToPluginService,
- secretsMigrations.ProvideMigrateFromPluginService,
secretsMigrations.ProvideSecretMigrationProvider,
wire.Bind(new(secretsMigrations.SecretMigrationProvider), new(*secretsMigrations.SecretMigrationProviderImpl)),
resourcepermissions.NewActionSetService,
@@ -402,6 +404,9 @@ var wireBasicSet = wire.NewSet(
connectors.ProvideOrgRoleMapper,
wire.Bind(new(user.Verifier), new(*userimpl.Verifier)),
authz.WireSet,
+ // Secrets Manager
+ secretmetadata.ProvideSecureValueMetadataStorage,
+ secretmetadata.ProvideKeeperMetadataStorage,
// Unified storage
resource.ProvideStorageMetrics,
resource.ProvideIndexMetrics,
@@ -464,9 +469,12 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser
return &Server{}, nil
}
-func InitializeForTest(t sqlutil.ITestDB, cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*TestEnv, error) {
+func InitializeForTest(t sqlutil.ITestDB, testingT interface {
+ mock.TestingT
+ Cleanup(func())
+}, cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*TestEnv, error) {
wire.Build(wireExtsTestSet)
- return &TestEnv{Server: &Server{}, SQLStore: &sqlstore.SQLStore{}, Cfg: &setting.Cfg{}}, nil
+ return &TestEnv{Server: &Server{}, TestingT: testingT, SQLStore: &sqlstore.SQLStore{}, Cfg: &setting.Cfg{}}, nil
}
func InitializeForCLI(cfg *setting.Cfg) (Runner, error) {
diff --git a/pkg/services/accesscontrol/permreg/permreg.go b/pkg/services/accesscontrol/permreg/permreg.go
index 361b2600d08..1d46d749b38 100644
--- a/pkg/services/accesscontrol/permreg/permreg.go
+++ b/pkg/services/accesscontrol/permreg/permreg.go
@@ -76,24 +76,26 @@ func ProvidePermissionRegistry() PermissionRegistry {
func newPermissionRegistry() *permissionRegistry {
// defaultKindScopes maps the most specific accepted scope prefix for a given kind (folders, dashboards, etc)
defaultKindScopes := map[string]string{
- "teams": "teams:id:",
- "users": "users:id:",
- "datasources": "datasources:uid:",
- "dashboards": "dashboards:uid:",
- "folders": "folders:uid:",
- "annotations": "annotations:type:",
- "apikeys": "apikeys:id:",
- "orgs": "orgs:id:",
- "plugins": "plugins:id:",
- "provisioners": "provisioners:",
- "reports": "reports:id:",
- "permissions": "permissions:type:",
- "serviceaccounts": "serviceaccounts:id:",
- "settings": "settings:",
- "global.users": "global.users:id:",
- "roles": "roles:uid:",
- "services": "services:",
- "receivers": "receivers:uid:",
+ "teams": "teams:id:",
+ "users": "users:id:",
+ "datasources": "datasources:uid:",
+ "dashboards": "dashboards:uid:",
+ "folders": "folders:uid:",
+ "annotations": "annotations:type:",
+ "apikeys": "apikeys:id:",
+ "orgs": "orgs:id:",
+ "plugins": "plugins:id:",
+ "provisioners": "provisioners:",
+ "reports": "reports:id:",
+ "permissions": "permissions:type:",
+ "serviceaccounts": "serviceaccounts:id:",
+ "settings": "settings:",
+ "global.users": "global.users:id:",
+ "roles": "roles:uid:",
+ "services": "services:",
+ "receivers": "receivers:uid:",
+ "secret.securevalues": "secret.securevalues:uid:",
+ "secret.keepers": "secret.keepers:uid:",
}
return &permissionRegistry{
actionScopePrefixes: make(map[string]PrefixSet, 200),
diff --git a/pkg/services/accesscontrol/resourcepermissions/store.go b/pkg/services/accesscontrol/resourcepermissions/store.go
index c8d8e8db88e..f7444376ee9 100644
--- a/pkg/services/accesscontrol/resourcepermissions/store.go
+++ b/pkg/services/accesscontrol/resourcepermissions/store.go
@@ -449,7 +449,7 @@ func (s *store) getResourcePermissions(sess *db.Session, orgID int64, query GetR
builtin := builtinSelect + builtinFrom + where
args = append(args, args[:initialLength]...)
- sql := userQuery + " UNION " + team + " UNION " + builtin
+ sql := userQuery + " " + s.sql.GetDialect().UnionDistinct() + " " + team + " " + s.sql.GetDialect().UnionDistinct() + " " + builtin
queryResults := make([]flatResourcePermission, 0)
if err := sess.SQL(sql, args...).Find(&queryResults); err != nil {
return nil, err
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/apiserver/builder/helper.go b/pkg/services/apiserver/builder/helper.go
index cb6d2626061..4152f084b07 100644
--- a/pkg/services/apiserver/builder/helper.go
+++ b/pkg/services/apiserver/builder/helper.go
@@ -170,6 +170,8 @@ func SetupConfig(
operationAlt = "get" + operationAlt[len("read"):]
} else if strings.HasPrefix(operationAlt, "patch") {
operationAlt = "update" + operationAlt[len("patch"):]
+ } else if strings.HasPrefix(operationAlt, "put") {
+ operationAlt = "replace" + operationAlt[len("put"):]
}
// Audit our options here
diff --git a/pkg/services/apiserver/client/discovery.go b/pkg/services/apiserver/client/discovery.go
index bdf2a46a29e..3c9b91f1ab4 100644
--- a/pkg/services/apiserver/client/discovery.go
+++ b/pkg/services/apiserver/client/discovery.go
@@ -2,6 +2,7 @@ package client
import (
"fmt"
+ "strings"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/discovery"
@@ -12,6 +13,7 @@ type DiscoveryClient interface {
discovery.DiscoveryInterface
GetResourceForKind(gvk schema.GroupVersionKind) (schema.GroupVersionResource, error)
GetKindForResource(gvr schema.GroupVersionResource) (schema.GroupVersionKind, error)
+ GetPreferredVesion(gr schema.GroupResource) (schema.GroupVersionResource, schema.GroupVersionKind, error)
}
type DiscoveryClientImpl struct {
@@ -63,3 +65,30 @@ func (d *DiscoveryClientImpl) GetKindForResource(gvr schema.GroupVersionResource
}
return schema.GroupVersionKind{}, fmt.Errorf("kind not found for %s", gvr.String())
}
+
+func (d *DiscoveryClientImpl) GetPreferredVesion(gr schema.GroupResource) (schema.GroupVersionResource, schema.GroupVersionKind, error) {
+ apiList, err := d.ServerPreferredResources()
+ if err != nil {
+ return schema.GroupVersionResource{}, schema.GroupVersionKind{}, err
+ }
+ for _, apis := range apiList {
+ if !strings.HasPrefix(apis.GroupVersion, gr.Group) {
+ continue
+ }
+ gv := strings.Split(apis.GroupVersion, "/")
+ for _, resource := range apis.APIResources {
+ if resource.Name == gr.Resource {
+ return schema.GroupVersionResource{
+ Group: gv[0],
+ Version: gv[1],
+ Resource: resource.Name,
+ }, schema.GroupVersionKind{
+ Group: gv[0],
+ Version: gv[1],
+ Kind: resource.Kind,
+ }, nil
+ }
+ }
+ }
+ return schema.GroupVersionResource{}, schema.GroupVersionKind{}, fmt.Errorf("preferred version not found for %s", gr.String())
+}
diff --git a/pkg/services/apiserver/service.go b/pkg/services/apiserver/service.go
index e37ac50fda9..f7c2be03cbc 100644
--- a/pkg/services/apiserver/service.go
+++ b/pkg/services/apiserver/service.go
@@ -87,6 +87,15 @@ func init() {
Scheme.AddUnversionedTypes(unversionedVersion, unversionedTypes...)
}
+// ClearRestConfig clears the package level restConfig.
+// This is intended to be used in tests only.
+//
+// TODO: Refactor such that there is no global state.
+func ClearRestConfig() {
+ restConfig = nil
+ ready = make(chan struct{})
+}
+
// GetRestConfig return a client Config mounted at package level
// This resolves circular dependency issues between apiserver, authz,
// and Folder Service.
diff --git a/pkg/services/authn/authn.go b/pkg/services/authn/authn.go
index fecfc17bf3a..d7fab0a65b3 100644
--- a/pkg/services/authn/authn.go
+++ b/pkg/services/authn/authn.go
@@ -31,6 +31,7 @@ const (
ClientSAML = "auth.client.saml"
ClientPasswordless = "auth.client.passwordless"
ClientLDAP = "ldap"
+ ClientProvisioning = "auth.client.apiserver.provisioning"
)
const (
diff --git a/pkg/services/authn/authnimpl/registration.go b/pkg/services/authn/authnimpl/registration.go
index c5af37a3098..8143c4e755a 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 {
@@ -124,6 +126,10 @@ func ProvideRegistration(
authnSvc.RegisterClient(clients.ProvideOAuth(clientName, cfg, oauthTokenService, socialService, settingsProviderService, features))
}
+ if features.IsEnabledGlobally(featuremgmt.FlagProvisioning) {
+ authnSvc.RegisterClient(clients.ProvideProvisioning())
+ }
+
// FIXME (jguer): move to User package
userSync := sync.ProvideUserSync(userService, userProtectionService, authInfoService, quotaService, tracer, features)
orgSync := sync.ProvideOrgSync(userService, orgService, accessControlService, cfg, tracer)
@@ -142,6 +148,7 @@ func ProvideRegistration(
authnSvc.RegisterPostAuthHook(rbacSync.SyncPermissionsHook, 120)
authnSvc.RegisterPostLoginHook(orgSync.SetDefaultOrgHook, 140)
+ authnSvc.RegisterPostLoginHook(rbacSync.ClearUserPermissionCacheHook, 170)
nsSync := sync.ProvideNamespaceSync(cfg)
authnSvc.RegisterPostAuthHook(nsSync.SyncNamespace, 150)
diff --git a/pkg/services/authn/authnimpl/sync/rbac_sync.go b/pkg/services/authn/authnimpl/sync/rbac_sync.go
index 8af08a38f51..75f6324382f 100644
--- a/pkg/services/authn/authnimpl/sync/rbac_sync.go
+++ b/pkg/services/authn/authnimpl/sync/rbac_sync.go
@@ -186,3 +186,22 @@ func (s *RBACSync) SyncCloudRoles(ctx context.Context, ident *authn.Identity, r
RolesToRemove: rolesToRemove,
})
}
+
+// ClearUserPermissionCacheHook clears a user's permission cache if user Login succeeded. Necessary so that if a user logs in
+// through different SSO providers with different roles assigned in each, they do not get the wrong permissions.
+func (s *RBACSync) ClearUserPermissionCacheHook(ctx context.Context, ident *authn.Identity, r *authn.Request, err error) {
+ ctx, span := s.tracer.Start(ctx, "rbac.sync.ClearUserPermissionCacheHook")
+ defer span.End()
+
+ if err != nil {
+ return
+ }
+
+ ctxLogger := s.log.FromContext(ctx)
+ if !ident.IsIdentityType(claims.TypeUser) {
+ ctxLogger.Debug("Skipping user permission cache clear, not a user", "type", ident.GetIdentityType())
+ return
+ }
+
+ s.ac.ClearUserPermissionCache(ident)
+}
diff --git a/pkg/services/authn/authnimpl/sync/rbac_sync_test.go b/pkg/services/authn/authnimpl/sync/rbac_sync_test.go
index 594e9d80a1c..97c27942f22 100644
--- a/pkg/services/authn/authnimpl/sync/rbac_sync_test.go
+++ b/pkg/services/authn/authnimpl/sync/rbac_sync_test.go
@@ -2,6 +2,7 @@ package sync
import (
"context"
+ "errors"
"testing"
"github.com/stretchr/testify/assert"
@@ -365,6 +366,61 @@ func TestRBACSync_cloudRolesToAddAndRemove(t *testing.T) {
}
}
+func TestRBACSync_ClearUserPermissionCacheHook(t *testing.T) {
+ type testCase struct {
+ desc string
+ identityType claims.IdentityType
+ loginErr error
+ expectedCalled bool
+ }
+
+ tests := []testCase{
+ {
+ desc: "should clear the permission cache when the user logged in successfully",
+ identityType: claims.TypeUser,
+ loginErr: nil,
+ expectedCalled: true,
+ },
+ {
+ desc: "should skip clearing the permission cache when the user failed to log in",
+ identityType: claims.TypeUser,
+ loginErr: errors.New("failed to log in"),
+ expectedCalled: false,
+ },
+ {
+ desc: "should skip clearing the permission cache when the identity is not a user",
+ identityType: claims.TypeServiceAccount,
+ loginErr: nil,
+ expectedCalled: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.desc, func(t *testing.T) {
+ var called bool
+ s := &RBACSync{
+ ac: &acmock.Mock{
+ ClearUserPermissionCacheFunc: func(_ identity.Requester) {
+ called = true
+ },
+ },
+ log: log.NewNopLogger(),
+ tracer: tracing.InitializeTracerForTest(),
+ }
+ identity := &authn.Identity{
+ ID: "1",
+ Type: tt.identityType,
+ OrgID: 1,
+ OrgRoles: map[int64]org.RoleType{1: org.RoleViewer},
+ }
+ req := &authn.Request{}
+
+ s.ClearUserPermissionCacheHook(context.Background(), identity, req, tt.loginErr)
+ assert.Equal(t, tt.expectedCalled, called)
+ })
+ }
+}
+
func setupTestEnv(t *testing.T) *RBACSync {
acMock := &acmock.Mock{
GetUserPermissionsFunc: func(ctx context.Context, siu identity.Requester, o accesscontrol.Options) ([]accesscontrol.Permission, error) {
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/clients/provisioning.go b/pkg/services/authn/clients/provisioning.go
new file mode 100644
index 00000000000..d50a0ff600c
--- /dev/null
+++ b/pkg/services/authn/clients/provisioning.go
@@ -0,0 +1,62 @@
+package clients
+
+import (
+ "context"
+ "fmt"
+ "regexp"
+ "time"
+
+ claims "github.com/grafana/authlib/types"
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/services/authn"
+)
+
+var (
+ _ authn.ContextAwareClient = (*Provisioning)(nil)
+ _ fmt.Stringer = (*Provisioning)(nil) // for debugging
+)
+
+type Provisioning struct {
+ webhookRegexp *regexp.Regexp
+}
+
+func ProvideProvisioning() *Provisioning {
+ // It's fine to compile a regexp here. The function is only called once per instance of APIBuilder, of which there should only ever be 1.
+ // Assumption: APIVERSION has no leading or trailing slashes.
+ webhookRegexp := regexp.MustCompile("^/apis/" + regexp.QuoteMeta(provisioning.APIVERSION) + "/namespaces/[^/]+/repositories/[^/]+/(webhook|render/.*)$")
+ return &Provisioning{webhookRegexp}
+}
+
+func (p *Provisioning) String() string {
+ return p.Name()
+}
+
+func (*Provisioning) Name() string {
+ return authn.ClientProvisioning
+}
+
+func (p *Provisioning) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
+ return &authn.Identity{
+ Type: claims.TypeAnonymous,
+ Name: p.Name(),
+ UID: p.Name(),
+ Login: p.Name(),
+ AuthID: p.Name(),
+ OrgID: r.OrgID,
+ AuthenticatedBy: authn.ClientProvisioning,
+ LastSeenAt: time.Now(),
+ }, nil
+}
+
+func (*Provisioning) IsEnabled() bool {
+ return true
+}
+
+func (p *Provisioning) Test(ctx context.Context, r *authn.Request) bool {
+ path := r.HTTPRequest.URL.Path
+ return p.webhookRegexp.MatchString(path)
+}
+
+func (*Provisioning) Priority() uint {
+ return 5 // let most other clients go first
+}
diff --git a/pkg/services/authn/clients/provisioning_test.go b/pkg/services/authn/clients/provisioning_test.go
new file mode 100644
index 00000000000..065e24a7343
--- /dev/null
+++ b/pkg/services/authn/clients/provisioning_test.go
@@ -0,0 +1,74 @@
+package clients
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/grafana/authlib/types"
+ provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
+ "github.com/grafana/grafana/pkg/services/authn"
+)
+
+func TestProvisioning_Test(t *testing.T) {
+ t.Parallel()
+ ctx := context.Background()
+
+ for _, tt := range []struct {
+ Name, URL string
+ Valid bool
+ }{
+ {"Root path is invalid", "https://grafana.localhost/", false},
+ {"Non-provisioning path", "https://grafana.localhost/hello/world", false},
+ {"Provisioning path that isn't webhook", "https://grafana.localhost/apis/provisioning.grafana.app/v0alpha1/namespaces/x/repositories/y/unittest", false},
+ {"Webhook path", "https://grafana.localhost/apis/provisioning.grafana.app/v0alpha1/namespaces/x/repositories/y/webhook", true},
+ {"Webhook path with subpath", "https://grafana.localhost/apis/provisioning.grafana.app/v0alpha1/namespaces/x/repositories/y/webhook/unittest", false}, // this'll have to change if we ever want subpaths
+ {"Render image url", "https://grafana.localhost/apis/provisioning.grafana.app/v0alpha1/namespaces/x/repositories/y/render/image-uid", true},
+ } {
+ t.Run(tt.Name, func(t *testing.T) {
+ t.Parallel()
+ svc := ProvideProvisioning()
+
+ url, err := url.Parse(tt.URL)
+ require.NoError(t, err, "couldn't parse input URL")
+ req := &authn.Request{HTTPRequest: &http.Request{
+ URL: url,
+ }}
+
+ assert.Equal(t, tt.Valid, svc.Test(ctx, req))
+ })
+ }
+}
+
+func TestProvisioning_Authenticate(t *testing.T) {
+ t.Parallel()
+ ctx := context.Background()
+
+ url, err := url.Parse("https://grafana.localhost/apis/provisioning.grafana.app/v0alpha1/namespaces/x/repositories/y/webhook")
+ require.NoError(t, err, "couldn't parse URL known to be good?")
+ req := &authn.Request{HTTPRequest: &http.Request{
+ URL: url,
+ }}
+
+ svc := ProvideProvisioning()
+ identity, err := svc.Authenticate(ctx, req)
+ require.NoError(t, err, "Authenticate shouldn't err")
+
+ assert.Equal(t, types.TypeAnonymous, identity.GetIdentityType(), "IdentityType")
+ assert.Equal(t, "auth.client.apiserver.provisioning", identity.UID, "UID")
+ assert.Equal(t, "auth.client.apiserver.provisioning", identity.Login, "Login")
+ assert.Equal(t, "auth.client.apiserver.provisioning", identity.AuthenticatedBy, "AuthenticatedBy")
+ assert.Equal(t, false, identity.GetIsGrafanaAdmin(), "IsAdmin")
+}
+
+func TestProvisioning_Assumptions(t *testing.T) {
+ t.Run("APIVERSION constant has no leading or trailing slashes", func(t *testing.T) {
+ assert.False(t, strings.HasPrefix(provisioning.APIVERSION, "/"), "found leading / in APIVERSION: %s", provisioning.APIVERSION)
+ assert.False(t, strings.HasSuffix(provisioning.APIVERSION, "/"), "found trailing / in APIVERSION: %s", provisioning.APIVERSION)
+ })
+}
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/authz/rbac/mapper.go b/pkg/services/authz/rbac/mapper.go
index 7635a193f7f..c930bb92fcb 100644
--- a/pkg/services/authz/rbac/mapper.go
+++ b/pkg/services/authz/rbac/mapper.go
@@ -63,6 +63,10 @@ func newMapper() mapper {
"iam.grafana.app": {
"teams": newResourceTranslation("teams", "id", false),
},
+ "secret.grafana.app": {
+ "securevalues": newResourceTranslation("secret.securevalues", "uid", false),
+ "keepers": newResourceTranslation("secret.keepers", "uid", false),
+ },
}
}
diff --git a/pkg/services/authz/rbac/service.go b/pkg/services/authz/rbac/service.go
index 910796e29f0..0c84c2d963e 100644
--- a/pkg/services/authz/rbac/service.go
+++ b/pkg/services/authz/rbac/service.go
@@ -103,6 +103,14 @@ func (s *Service) Check(ctx context.Context, req *authzv1.CheckRequest) (*authzv
}
ctx = request.WithNamespace(ctx, req.GetNamespace())
+ span.SetAttributes(
+ attribute.String("subject", req.Subject),
+ attribute.String("namespace", checkReq.Namespace.Value),
+ attribute.String("action", checkReq.Action),
+ attribute.String("name", checkReq.Name),
+ attribute.String("folder", checkReq.ParentFolder),
+ )
+
permissions, err := s.getIdentityPermissions(ctx, checkReq.Namespace, checkReq.IdentityType, checkReq.UserUID, checkReq.Action)
if err != nil {
ctxLogger.Error("could not get user permissions", "subject", req.GetSubject(), "error", err)
@@ -134,6 +142,12 @@ func (s *Service) List(ctx context.Context, req *authzv1.ListRequest) (*authzv1.
}
ctx = request.WithNamespace(ctx, req.GetNamespace())
+ span.SetAttributes(
+ attribute.String("subject", req.Subject),
+ attribute.String("namespace", listReq.Namespace.Value),
+ attribute.String("action", listReq.Action),
+ )
+
permissions, err := s.getIdentityPermissions(ctx, listReq.Namespace, listReq.IdentityType, listReq.UserUID, listReq.Action)
if err != nil {
ctxLogger.Error("could not get user permissions", "subject", req.GetSubject(), "error", err)
diff --git a/pkg/services/authz/rbac/store/permission_query.sql b/pkg/services/authz/rbac/store/permission_query.sql
index dfa82390541..fea4353c259 100644
--- a/pkg/services/authz/rbac/store/permission_query.sql
+++ b/pkg/services/authz/rbac/store/permission_query.sql
@@ -1,21 +1,21 @@
SELECT p.kind, p.attribute, p.identifier, p.scope FROM {{ .Ident .PermissionTable }} as p
+INNER JOIN (
+ SELECT role_id FROM {{ .Ident .BuiltinRoleTable }} as br WHERE (br.role = {{ .Arg .Query.Role }} AND (br.org_id = {{ .Arg .Query.OrgID }} OR br.org_id = 0))
+ {{ if .Query.IsServerAdmin }}
+ OR (br.role = 'Grafana Admin')
+ {{ end }}
+ {{ if .Query.UserID }}
+ UNION ALL
+ SELECT role_id FROM {{ .Ident .UserRoleTable }} as ur WHERE ur.user_id = {{ .Arg .Query.UserID }} AND (ur.org_id = {{ .Arg .Query.OrgID }} OR ur.org_id = 0)
+ {{ end }}
+ {{ if .Query.TeamIDs }}
+ UNION ALL
+ SELECT role_id FROM {{ .Ident .TeamRoleTable }} as tr WHERE tr.team_id IN ({{ .ArgList .Query.TeamIDs }}) AND tr.org_id = {{ .Arg .Query.OrgID }}
+ {{ end }}
+) as roles ON p.role_id = roles.role_id
WHERE
{{ if .Query.ActionSets }}
p.action IN ({{ .ArgList .Query.ActionSets }}, {{ .Arg .Query.Action }})
{{ else }}
p.action = {{ .Arg .Query.Action }}
- {{ end }}
-AND p.role_id IN (
- SELECT role_id FROM {{ .Ident .BuiltinRoleTable }} as br WHERE (br.role = {{ .Arg .Query.Role }} AND (br.org_id = {{ .Arg .Query.OrgID }} OR br.org_id = 0))
- {{ if .Query.IsServerAdmin }}
- OR (br.role = 'Grafana Admin')
- {{ end }}
- {{ if .Query.UserID }}
- UNION
- SELECT role_id FROM {{ .Ident .UserRoleTable }} as ur WHERE ur.user_id = {{ .Arg .Query.UserID }} AND (ur.org_id = {{ .Arg .Query.OrgID }} OR ur.org_id = 0)
- {{ end }}
- {{ if .Query.TeamIDs }}
- UNION
- SELECT role_id FROM {{ .Ident .TeamRoleTable }} as tr WHERE tr.team_id IN ({{ .ArgList .Query.TeamIDs }}) AND tr.org_id = {{ .Arg .Query.OrgID }}
- {{ end }}
-)
+ {{ end }}
\ No newline at end of file
diff --git a/pkg/services/authz/rbac/store/testdata/mysql--permission_query-With_action_sets.sql b/pkg/services/authz/rbac/store/testdata/mysql--permission_query-With_action_sets.sql
index 0393be57d3f..443e4b6f518 100755
--- a/pkg/services/authz/rbac/store/testdata/mysql--permission_query-With_action_sets.sql
+++ b/pkg/services/authz/rbac/store/testdata/mysql--permission_query-With_action_sets.sql
@@ -1,8 +1,8 @@
SELECT p.kind, p.attribute, p.identifier, p.scope FROM `grafana`.`permission` as p
+INNER JOIN (
+ SELECT role_id FROM `grafana`.`builtin_role` as br WHERE (br.role = 'Viewer' AND (br.org_id = 1 OR br.org_id = 0))
+ UNION ALL
+ SELECT role_id FROM `grafana`.`user_role` as ur WHERE ur.user_id = 1 AND (ur.org_id = 1 OR ur.org_id = 0)
+) as roles ON p.role_id = roles.role_id
WHERE
p.action IN ('folders:edit', 'folders:admin', 'folders:create')
-AND p.role_id IN (
- SELECT role_id FROM `grafana`.`builtin_role` as br WHERE (br.role = 'Viewer' AND (br.org_id = 1 OR br.org_id = 0))
- UNION
- SELECT role_id FROM `grafana`.`user_role` as ur WHERE ur.user_id = 1 AND (ur.org_id = 1 OR ur.org_id = 0)
-)
diff --git a/pkg/services/authz/rbac/store/testdata/mysql--permission_query-admin_user.sql b/pkg/services/authz/rbac/store/testdata/mysql--permission_query-admin_user.sql
index ba9f7064c91..39cdf20bd5d 100755
--- a/pkg/services/authz/rbac/store/testdata/mysql--permission_query-admin_user.sql
+++ b/pkg/services/authz/rbac/store/testdata/mysql--permission_query-admin_user.sql
@@ -1,9 +1,9 @@
SELECT p.kind, p.attribute, p.identifier, p.scope FROM `grafana`.`permission` as p
+INNER JOIN (
+ SELECT role_id FROM `grafana`.`builtin_role` as br WHERE (br.role = 'Admin' AND (br.org_id = 1 OR br.org_id = 0))
+ OR (br.role = 'Grafana Admin')
+ UNION ALL
+ SELECT role_id FROM `grafana`.`user_role` as ur WHERE ur.user_id = 1 AND (ur.org_id = 1 OR ur.org_id = 0)
+) as roles ON p.role_id = roles.role_id
WHERE
p.action = 'folders:read'
-AND p.role_id IN (
- SELECT role_id FROM `grafana`.`builtin_role` as br WHERE (br.role = 'Admin' AND (br.org_id = 1 OR br.org_id = 0))
- OR (br.role = 'Grafana Admin')
- UNION
- SELECT role_id FROM `grafana`.`user_role` as ur WHERE ur.user_id = 1 AND (ur.org_id = 1 OR ur.org_id = 0)
-)
diff --git a/pkg/services/authz/rbac/store/testdata/mysql--permission_query-anonymous_user.sql b/pkg/services/authz/rbac/store/testdata/mysql--permission_query-anonymous_user.sql
index 99959aeef2d..040afd50301 100755
--- a/pkg/services/authz/rbac/store/testdata/mysql--permission_query-anonymous_user.sql
+++ b/pkg/services/authz/rbac/store/testdata/mysql--permission_query-anonymous_user.sql
@@ -1,6 +1,6 @@
SELECT p.kind, p.attribute, p.identifier, p.scope FROM `grafana`.`permission` as p
+INNER JOIN (
+ SELECT role_id FROM `grafana`.`builtin_role` as br WHERE (br.role = 'Viewer' AND (br.org_id = 1 OR br.org_id = 0))
+) as roles ON p.role_id = roles.role_id
WHERE
p.action = 'folders:read'
-AND p.role_id IN (
- SELECT role_id FROM `grafana`.`builtin_role` as br WHERE (br.role = 'Viewer' AND (br.org_id = 1 OR br.org_id = 0))
-)
diff --git a/pkg/services/authz/rbac/store/testdata/mysql--permission_query-user_with_teams.sql b/pkg/services/authz/rbac/store/testdata/mysql--permission_query-user_with_teams.sql
index d47a6b8226d..efd22a9b118 100755
--- a/pkg/services/authz/rbac/store/testdata/mysql--permission_query-user_with_teams.sql
+++ b/pkg/services/authz/rbac/store/testdata/mysql--permission_query-user_with_teams.sql
@@ -1,10 +1,10 @@
SELECT p.kind, p.attribute, p.identifier, p.scope FROM `grafana`.`permission` as p
+INNER JOIN (
+ SELECT role_id FROM `grafana`.`builtin_role` as br WHERE (br.role = 'None' AND (br.org_id = 1 OR br.org_id = 0))
+ UNION ALL
+ SELECT role_id FROM `grafana`.`user_role` as ur WHERE ur.user_id = 1 AND (ur.org_id = 1 OR ur.org_id = 0)
+ UNION ALL
+ SELECT role_id FROM `grafana`.`team_role` as tr WHERE tr.team_id IN (1, 2) AND tr.org_id = 1
+) as roles ON p.role_id = roles.role_id
WHERE
p.action = 'folders:read'
-AND p.role_id IN (
- SELECT role_id FROM `grafana`.`builtin_role` as br WHERE (br.role = 'None' AND (br.org_id = 1 OR br.org_id = 0))
- UNION
- SELECT role_id FROM `grafana`.`user_role` as ur WHERE ur.user_id = 1 AND (ur.org_id = 1 OR ur.org_id = 0)
- UNION
- SELECT role_id FROM `grafana`.`team_role` as tr WHERE tr.team_id IN (1, 2) AND tr.org_id = 1
-)
diff --git a/pkg/services/authz/rbac/store/testdata/mysql--permission_query-viewer_user.sql b/pkg/services/authz/rbac/store/testdata/mysql--permission_query-viewer_user.sql
index 15f8dde3169..17746dd3319 100755
--- a/pkg/services/authz/rbac/store/testdata/mysql--permission_query-viewer_user.sql
+++ b/pkg/services/authz/rbac/store/testdata/mysql--permission_query-viewer_user.sql
@@ -1,8 +1,8 @@
SELECT p.kind, p.attribute, p.identifier, p.scope FROM `grafana`.`permission` as p
+INNER JOIN (
+ SELECT role_id FROM `grafana`.`builtin_role` as br WHERE (br.role = 'Viewer' AND (br.org_id = 1 OR br.org_id = 0))
+ UNION ALL
+ SELECT role_id FROM `grafana`.`user_role` as ur WHERE ur.user_id = 1 AND (ur.org_id = 1 OR ur.org_id = 0)
+) as roles ON p.role_id = roles.role_id
WHERE
p.action = 'folders:read'
-AND p.role_id IN (
- SELECT role_id FROM `grafana`.`builtin_role` as br WHERE (br.role = 'Viewer' AND (br.org_id = 1 OR br.org_id = 0))
- UNION
- SELECT role_id FROM `grafana`.`user_role` as ur WHERE ur.user_id = 1 AND (ur.org_id = 1 OR ur.org_id = 0)
-)
diff --git a/pkg/services/authz/rbac/store/testdata/postgres--permission_query-With_action_sets.sql b/pkg/services/authz/rbac/store/testdata/postgres--permission_query-With_action_sets.sql
index 8c35badfb31..5759d3b0017 100755
--- a/pkg/services/authz/rbac/store/testdata/postgres--permission_query-With_action_sets.sql
+++ b/pkg/services/authz/rbac/store/testdata/postgres--permission_query-With_action_sets.sql
@@ -1,8 +1,8 @@
SELECT p.kind, p.attribute, p.identifier, p.scope FROM "grafana"."permission" as p
+INNER JOIN (
+ SELECT role_id FROM "grafana"."builtin_role" as br WHERE (br.role = 'Viewer' AND (br.org_id = 1 OR br.org_id = 0))
+ UNION ALL
+ SELECT role_id FROM "grafana"."user_role" as ur WHERE ur.user_id = 1 AND (ur.org_id = 1 OR ur.org_id = 0)
+) as roles ON p.role_id = roles.role_id
WHERE
p.action IN ('folders:edit', 'folders:admin', 'folders:create')
-AND p.role_id IN (
- SELECT role_id FROM "grafana"."builtin_role" as br WHERE (br.role = 'Viewer' AND (br.org_id = 1 OR br.org_id = 0))
- UNION
- SELECT role_id FROM "grafana"."user_role" as ur WHERE ur.user_id = 1 AND (ur.org_id = 1 OR ur.org_id = 0)
-)
diff --git a/pkg/services/authz/rbac/store/testdata/postgres--permission_query-admin_user.sql b/pkg/services/authz/rbac/store/testdata/postgres--permission_query-admin_user.sql
index c78b0cbd2aa..45e8dc2b6fd 100755
--- a/pkg/services/authz/rbac/store/testdata/postgres--permission_query-admin_user.sql
+++ b/pkg/services/authz/rbac/store/testdata/postgres--permission_query-admin_user.sql
@@ -1,9 +1,9 @@
SELECT p.kind, p.attribute, p.identifier, p.scope FROM "grafana"."permission" as p
+INNER JOIN (
+ SELECT role_id FROM "grafana"."builtin_role" as br WHERE (br.role = 'Admin' AND (br.org_id = 1 OR br.org_id = 0))
+ OR (br.role = 'Grafana Admin')
+ UNION ALL
+ SELECT role_id FROM "grafana"."user_role" as ur WHERE ur.user_id = 1 AND (ur.org_id = 1 OR ur.org_id = 0)
+) as roles ON p.role_id = roles.role_id
WHERE
p.action = 'folders:read'
-AND p.role_id IN (
- SELECT role_id FROM "grafana"."builtin_role" as br WHERE (br.role = 'Admin' AND (br.org_id = 1 OR br.org_id = 0))
- OR (br.role = 'Grafana Admin')
- UNION
- SELECT role_id FROM "grafana"."user_role" as ur WHERE ur.user_id = 1 AND (ur.org_id = 1 OR ur.org_id = 0)
-)
diff --git a/pkg/services/authz/rbac/store/testdata/postgres--permission_query-anonymous_user.sql b/pkg/services/authz/rbac/store/testdata/postgres--permission_query-anonymous_user.sql
index 20544064211..8af7430873d 100755
--- a/pkg/services/authz/rbac/store/testdata/postgres--permission_query-anonymous_user.sql
+++ b/pkg/services/authz/rbac/store/testdata/postgres--permission_query-anonymous_user.sql
@@ -1,6 +1,6 @@
SELECT p.kind, p.attribute, p.identifier, p.scope FROM "grafana"."permission" as p
+INNER JOIN (
+ SELECT role_id FROM "grafana"."builtin_role" as br WHERE (br.role = 'Viewer' AND (br.org_id = 1 OR br.org_id = 0))
+) as roles ON p.role_id = roles.role_id
WHERE
p.action = 'folders:read'
-AND p.role_id IN (
- SELECT role_id FROM "grafana"."builtin_role" as br WHERE (br.role = 'Viewer' AND (br.org_id = 1 OR br.org_id = 0))
-)
diff --git a/pkg/services/authz/rbac/store/testdata/postgres--permission_query-user_with_teams.sql b/pkg/services/authz/rbac/store/testdata/postgres--permission_query-user_with_teams.sql
index ae269975c83..e07071250ea 100755
--- a/pkg/services/authz/rbac/store/testdata/postgres--permission_query-user_with_teams.sql
+++ b/pkg/services/authz/rbac/store/testdata/postgres--permission_query-user_with_teams.sql
@@ -1,10 +1,10 @@
SELECT p.kind, p.attribute, p.identifier, p.scope FROM "grafana"."permission" as p
+INNER JOIN (
+ SELECT role_id FROM "grafana"."builtin_role" as br WHERE (br.role = 'None' AND (br.org_id = 1 OR br.org_id = 0))
+ UNION ALL
+ SELECT role_id FROM "grafana"."user_role" as ur WHERE ur.user_id = 1 AND (ur.org_id = 1 OR ur.org_id = 0)
+ UNION ALL
+ SELECT role_id FROM "grafana"."team_role" as tr WHERE tr.team_id IN (1, 2) AND tr.org_id = 1
+) as roles ON p.role_id = roles.role_id
WHERE
p.action = 'folders:read'
-AND p.role_id IN (
- SELECT role_id FROM "grafana"."builtin_role" as br WHERE (br.role = 'None' AND (br.org_id = 1 OR br.org_id = 0))
- UNION
- SELECT role_id FROM "grafana"."user_role" as ur WHERE ur.user_id = 1 AND (ur.org_id = 1 OR ur.org_id = 0)
- UNION
- SELECT role_id FROM "grafana"."team_role" as tr WHERE tr.team_id IN (1, 2) AND tr.org_id = 1
-)
diff --git a/pkg/services/authz/rbac/store/testdata/postgres--permission_query-viewer_user.sql b/pkg/services/authz/rbac/store/testdata/postgres--permission_query-viewer_user.sql
index 3a78a730602..a2a6695ef1d 100755
--- a/pkg/services/authz/rbac/store/testdata/postgres--permission_query-viewer_user.sql
+++ b/pkg/services/authz/rbac/store/testdata/postgres--permission_query-viewer_user.sql
@@ -1,8 +1,8 @@
SELECT p.kind, p.attribute, p.identifier, p.scope FROM "grafana"."permission" as p
+INNER JOIN (
+ SELECT role_id FROM "grafana"."builtin_role" as br WHERE (br.role = 'Viewer' AND (br.org_id = 1 OR br.org_id = 0))
+ UNION ALL
+ SELECT role_id FROM "grafana"."user_role" as ur WHERE ur.user_id = 1 AND (ur.org_id = 1 OR ur.org_id = 0)
+) as roles ON p.role_id = roles.role_id
WHERE
p.action = 'folders:read'
-AND p.role_id IN (
- SELECT role_id FROM "grafana"."builtin_role" as br WHERE (br.role = 'Viewer' AND (br.org_id = 1 OR br.org_id = 0))
- UNION
- SELECT role_id FROM "grafana"."user_role" as ur WHERE ur.user_id = 1 AND (ur.org_id = 1 OR ur.org_id = 0)
-)
diff --git a/pkg/services/authz/rbac/store/testdata/sqlite--permission_query-With_action_sets.sql b/pkg/services/authz/rbac/store/testdata/sqlite--permission_query-With_action_sets.sql
index 8c35badfb31..5759d3b0017 100755
--- a/pkg/services/authz/rbac/store/testdata/sqlite--permission_query-With_action_sets.sql
+++ b/pkg/services/authz/rbac/store/testdata/sqlite--permission_query-With_action_sets.sql
@@ -1,8 +1,8 @@
SELECT p.kind, p.attribute, p.identifier, p.scope FROM "grafana"."permission" as p
+INNER JOIN (
+ SELECT role_id FROM "grafana"."builtin_role" as br WHERE (br.role = 'Viewer' AND (br.org_id = 1 OR br.org_id = 0))
+ UNION ALL
+ SELECT role_id FROM "grafana"."user_role" as ur WHERE ur.user_id = 1 AND (ur.org_id = 1 OR ur.org_id = 0)
+) as roles ON p.role_id = roles.role_id
WHERE
p.action IN ('folders:edit', 'folders:admin', 'folders:create')
-AND p.role_id IN (
- SELECT role_id FROM "grafana"."builtin_role" as br WHERE (br.role = 'Viewer' AND (br.org_id = 1 OR br.org_id = 0))
- UNION
- SELECT role_id FROM "grafana"."user_role" as ur WHERE ur.user_id = 1 AND (ur.org_id = 1 OR ur.org_id = 0)
-)
diff --git a/pkg/services/authz/rbac/store/testdata/sqlite--permission_query-admin_user.sql b/pkg/services/authz/rbac/store/testdata/sqlite--permission_query-admin_user.sql
index c78b0cbd2aa..45e8dc2b6fd 100755
--- a/pkg/services/authz/rbac/store/testdata/sqlite--permission_query-admin_user.sql
+++ b/pkg/services/authz/rbac/store/testdata/sqlite--permission_query-admin_user.sql
@@ -1,9 +1,9 @@
SELECT p.kind, p.attribute, p.identifier, p.scope FROM "grafana"."permission" as p
+INNER JOIN (
+ SELECT role_id FROM "grafana"."builtin_role" as br WHERE (br.role = 'Admin' AND (br.org_id = 1 OR br.org_id = 0))
+ OR (br.role = 'Grafana Admin')
+ UNION ALL
+ SELECT role_id FROM "grafana"."user_role" as ur WHERE ur.user_id = 1 AND (ur.org_id = 1 OR ur.org_id = 0)
+) as roles ON p.role_id = roles.role_id
WHERE
p.action = 'folders:read'
-AND p.role_id IN (
- SELECT role_id FROM "grafana"."builtin_role" as br WHERE (br.role = 'Admin' AND (br.org_id = 1 OR br.org_id = 0))
- OR (br.role = 'Grafana Admin')
- UNION
- SELECT role_id FROM "grafana"."user_role" as ur WHERE ur.user_id = 1 AND (ur.org_id = 1 OR ur.org_id = 0)
-)
diff --git a/pkg/services/authz/rbac/store/testdata/sqlite--permission_query-anonymous_user.sql b/pkg/services/authz/rbac/store/testdata/sqlite--permission_query-anonymous_user.sql
index 20544064211..8af7430873d 100755
--- a/pkg/services/authz/rbac/store/testdata/sqlite--permission_query-anonymous_user.sql
+++ b/pkg/services/authz/rbac/store/testdata/sqlite--permission_query-anonymous_user.sql
@@ -1,6 +1,6 @@
SELECT p.kind, p.attribute, p.identifier, p.scope FROM "grafana"."permission" as p
+INNER JOIN (
+ SELECT role_id FROM "grafana"."builtin_role" as br WHERE (br.role = 'Viewer' AND (br.org_id = 1 OR br.org_id = 0))
+) as roles ON p.role_id = roles.role_id
WHERE
p.action = 'folders:read'
-AND p.role_id IN (
- SELECT role_id FROM "grafana"."builtin_role" as br WHERE (br.role = 'Viewer' AND (br.org_id = 1 OR br.org_id = 0))
-)
diff --git a/pkg/services/authz/rbac/store/testdata/sqlite--permission_query-user_with_teams.sql b/pkg/services/authz/rbac/store/testdata/sqlite--permission_query-user_with_teams.sql
index ae269975c83..e07071250ea 100755
--- a/pkg/services/authz/rbac/store/testdata/sqlite--permission_query-user_with_teams.sql
+++ b/pkg/services/authz/rbac/store/testdata/sqlite--permission_query-user_with_teams.sql
@@ -1,10 +1,10 @@
SELECT p.kind, p.attribute, p.identifier, p.scope FROM "grafana"."permission" as p
+INNER JOIN (
+ SELECT role_id FROM "grafana"."builtin_role" as br WHERE (br.role = 'None' AND (br.org_id = 1 OR br.org_id = 0))
+ UNION ALL
+ SELECT role_id FROM "grafana"."user_role" as ur WHERE ur.user_id = 1 AND (ur.org_id = 1 OR ur.org_id = 0)
+ UNION ALL
+ SELECT role_id FROM "grafana"."team_role" as tr WHERE tr.team_id IN (1, 2) AND tr.org_id = 1
+) as roles ON p.role_id = roles.role_id
WHERE
p.action = 'folders:read'
-AND p.role_id IN (
- SELECT role_id FROM "grafana"."builtin_role" as br WHERE (br.role = 'None' AND (br.org_id = 1 OR br.org_id = 0))
- UNION
- SELECT role_id FROM "grafana"."user_role" as ur WHERE ur.user_id = 1 AND (ur.org_id = 1 OR ur.org_id = 0)
- UNION
- SELECT role_id FROM "grafana"."team_role" as tr WHERE tr.team_id IN (1, 2) AND tr.org_id = 1
-)
diff --git a/pkg/services/authz/rbac/store/testdata/sqlite--permission_query-viewer_user.sql b/pkg/services/authz/rbac/store/testdata/sqlite--permission_query-viewer_user.sql
index 3a78a730602..a2a6695ef1d 100755
--- a/pkg/services/authz/rbac/store/testdata/sqlite--permission_query-viewer_user.sql
+++ b/pkg/services/authz/rbac/store/testdata/sqlite--permission_query-viewer_user.sql
@@ -1,8 +1,8 @@
SELECT p.kind, p.attribute, p.identifier, p.scope FROM "grafana"."permission" as p
+INNER JOIN (
+ SELECT role_id FROM "grafana"."builtin_role" as br WHERE (br.role = 'Viewer' AND (br.org_id = 1 OR br.org_id = 0))
+ UNION ALL
+ SELECT role_id FROM "grafana"."user_role" as ur WHERE ur.user_id = 1 AND (ur.org_id = 1 OR ur.org_id = 0)
+) as roles ON p.role_id = roles.role_id
WHERE
p.action = 'folders:read'
-AND p.role_id IN (
- SELECT role_id FROM "grafana"."builtin_role" as br WHERE (br.role = 'Viewer' AND (br.org_id = 1 OR br.org_id = 0))
- UNION
- SELECT role_id FROM "grafana"."user_role" as ur WHERE ur.user_id = 1 AND (ur.org_id = 1 OR ur.org_id = 0)
-)
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 16c68cff3e3..d85dd62405b 100644
--- a/pkg/services/dashboards/service/dashboard_service.go
+++ b/pkg/services/dashboards/service/dashboard_service.go
@@ -11,7 +11,6 @@ import (
"time"
"github.com/google/uuid"
- "github.com/grafana/grafana/pkg/util/retryer"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel"
"golang.org/x/exp/maps"
@@ -30,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"
@@ -42,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"
@@ -56,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 (
@@ -68,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
@@ -82,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(
@@ -96,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)
@@ -113,6 +384,8 @@ func ProvideDashboardServiceImpl(
metrics: newDashboardsMetrics(r),
dashboardPermissionsReady: make(chan struct{}),
publicDashboardService: publicDashboardService,
+ serverLockService: serverLockService,
+ kvstore: kvstore,
}
defaultLimits, err := readQuotaConfig(cfg)
@@ -412,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
}
@@ -438,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
@@ -560,11 +820,36 @@ 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 {
return retryer.Retry(func() (retryer.RetrySignal, error) {
results, err := dr.searchDashboardsThroughK8sRaw(ctx, query)
+ dr.log.Debug("waitForSearchQuery", "dashboardUIDs", strings.Join(query.DashboardUIDs, ","), "total_hits", results.TotalHits, "err", err)
if err != nil {
return retryer.FuncError, err
}
@@ -594,6 +879,7 @@ func (dr *DashboardServiceImpl) DeleteOrphanedProvisionedDashboards(ctx context.
if err != nil {
return err
}
+ dr.log.Debug("Found dashboards to be deleted", "orgId", org.ID, "count", len(foundDashs))
// delete them
var deletedUids []string
@@ -603,10 +889,12 @@ func (dr *DashboardServiceImpl) DeleteOrphanedProvisionedDashboards(ctx context.
}
deletedUids = append(deletedUids, foundDash.DashboardUID)
}
- // wait for deleted dashboards to be removed from the index
- err = dr.waitForSearchQuery(ctx, &dashboards.FindPersistedDashboardsQuery{OrgId: org.ID, DashboardUIDs: deletedUids}, 5, 0)
- if err != nil {
- return err
+ if len(deletedUids) > 0 {
+ // wait for deleted dashboards to be removed from the index
+ err = dr.waitForSearchQuery(ctx, &dashboards.FindPersistedDashboardsQuery{OrgId: org.ID, DashboardUIDs: deletedUids}, 5, 0)
+ if err != nil {
+ return err
+ }
}
}
return nil
@@ -615,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
@@ -782,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)
@@ -885,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 {
@@ -1295,6 +1532,14 @@ func (dr *DashboardServiceImpl) FindDashboards(ctx context.Context, query *dashb
Tags: hit.Tags,
}
+ if hit.Field != nil && query.Sort.Name != "" {
+ fieldName, _, err := legacysearcher.ParseSortName(query.Sort.Name)
+ if err != nil {
+ return nil, err
+ }
+ result.SortMeta = hit.Field.GetNestedInt64(fieldName)
+ }
+
if hit.Resource == folderv0alpha1.RESOURCE {
result.IsFolder = true
}
@@ -1511,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()
@@ -1537,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{
@@ -1555,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 {
@@ -1593,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) {
@@ -1609,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 {
@@ -1840,12 +2071,12 @@ func (dr *DashboardServiceImpl) searchDashboardsThroughK8sRaw(ctx context.Contex
request.Federated = []*resource.ResourceKey{federate}
}
- // technically, there exists the ability to register multiple ways of sorting using the legacy database
- // see RegisterSortOption in pkg/services/search/sorting.go
- // however, it doesn't look like we are taking advantage of that. And since by default the legacy
- // sql will sort by title ascending, we only really need to handle the "alpha-desc" case
- if query.Sort.Name == "alpha-desc" {
- request.SortBy = append(request.SortBy, &resource.ResourceSearchRequest_Sort{Field: resource.SEARCH_FIELD_TITLE, Desc: true})
+ if query.Sort.Name != "" {
+ sortName, isDesc, err := legacysearcher.ParseSortName(query.Sort.Name)
+ if err != nil {
+ return dashboardv0alpha1.SearchResults{}, err
+ }
+ request.SortBy = append(request.SortBy, &resource.ResourceSearchRequest_Sort{Field: sortName, Desc: isDesc})
}
res, err := dr.k8sclient.Search(ctx, query.OrgId, request)
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 4c9562e11de..82600d9f2cd 100644
--- a/pkg/services/dashboards/service/dashboard_service_test.go
+++ b/pkg/services/dashboards/service/dashboard_service_test.go
@@ -18,20 +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"
@@ -49,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,
}
@@ -56,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{}
@@ -816,6 +818,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
ExpectedOrgs: []*org.OrgDTO{{ID: 1}, {ID: 2}},
},
publicDashboardService: fakePublicDashboardService,
+ log: log.NewNopLogger(),
}
t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) {
@@ -834,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",
@@ -969,6 +969,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
ExpectedOrgs: []*org.OrgDTO{{ID: 1}},
},
publicDashboardService: fakePublicDashboardService,
+ log: log.NewNopLogger(),
}
ctx, k8sCliMock := setupK8sDashboardTests(singleOrgService)
provisioningTimestamp := int64(1234567)
@@ -1033,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
@@ -1055,6 +1054,37 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
require.NoError(t, err)
k8sCliMock.AssertExpectations(t)
})
+
+ t.Run("Will not wait for indexer when no dashboards were deleted", func(t *testing.T) {
+ repo := "test"
+ singleOrgService := &DashboardServiceImpl{
+ cfg: setting.NewCfg(),
+ dashboardStore: &fakeStore,
+ orgService: &orgtest.FakeOrgService{
+ ExpectedOrgs: []*org.OrgDTO{{ID: 1}},
+ },
+ publicDashboardService: fakePublicDashboardService,
+ log: log.NewNopLogger(),
+ }
+ ctx, k8sCliMock := setupK8sDashboardTests(singleOrgService)
+
+ // Call to searchProvisionedDashboardsThroughK8s()
+ k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default")
+ k8sCliMock.On("Search", mock.Anything, int64(1), mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool {
+ // make sure the kind is added to the query
+ return req.Options.Fields[0].Values[0] == string(utils.ManagerKindClassicFP) && // nolint:staticcheck
+ req.Options.Fields[1].Values[0] == repo
+ })).Return(&resource.ResourceSearchResponse{
+ Results: &resource.ResourceTable{},
+ TotalHits: 0,
+ }, nil)
+
+ err := singleOrgService.DeleteOrphanedProvisionedDashboards(ctx, &dashboards.DeleteOrphanedProvisionedDashboardsCommand{
+ ReaderNames: []string{"test"},
+ })
+ require.NoError(t, err)
+ k8sCliMock.AssertExpectations(t)
+ })
}
func TestUnprovisionDashboard(t *testing.T) {
@@ -1258,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,
@@ -1292,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},
@@ -1335,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{})
@@ -1358,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},
@@ -1400,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)
@@ -1412,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)
@@ -1424,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)
@@ -1458,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)
@@ -1470,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{
@@ -2132,9 +2145,15 @@ func TestSearchDashboardsThroughK8sRaw(t *testing.T) {
service := &DashboardServiceImpl{k8sclient: k8sCliMock}
query := &dashboards.FindPersistedDashboardsQuery{
OrgId: 1,
+ Sort: sort.SortAlphaAsc,
}
k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default")
- k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resource.ResourceSearchResponse{
+ k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool {
+ return len(req.SortBy) == 1 &&
+ // should be converted to "title" due to ParseSortName
+ req.SortBy[0].Field == "title" &&
+ !req.SortBy[0].Desc
+ })).Return(&resource.ResourceSearchResponse{
Results: &resource.ResourceTable{
Columns: []*resource.ResourceTableColumnDefinition{
{
@@ -2290,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/datasources/fakes/fake_datasource_service.go b/pkg/services/datasources/fakes/fake_datasource_service.go
index 43a71852c43..c23024b4e5b 100644
--- a/pkg/services/datasources/fakes/fake_datasource_service.go
+++ b/pkg/services/datasources/fakes/fake_datasource_service.go
@@ -122,11 +122,7 @@ func (s *FakeDataSourceService) GetHTTPTransport(ctx context.Context, ds *dataso
}
func (s *FakeDataSourceService) DecryptedValues(ctx context.Context, ds *datasources.DataSource) (map[string]string, error) {
- if s.SimulatePluginFailure {
- return nil, datasources.ErrDatasourceSecretsPluginUserFriendly{Err: "unknown error"}
- }
- values := make(map[string]string)
- return values, nil
+ return make(map[string]string), nil
}
func (s *FakeDataSourceService) DecryptedValue(ctx context.Context, ds *datasources.DataSource, key string) (string, bool, error) {
diff --git a/pkg/services/datasources/models.go b/pkg/services/datasources/models.go
index 84592aadda6..eb4f51f389c 100644
--- a/pkg/services/datasources/models.go
+++ b/pkg/services/datasources/models.go
@@ -145,15 +145,6 @@ func (ds DataSource) AllowedCookies() []string {
return []string{}
}
-// Specific error type for grpc secrets management so that we can show more detailed plugin errors to users
-type ErrDatasourceSecretsPluginUserFriendly struct {
- Err string
-}
-
-func (e ErrDatasourceSecretsPluginUserFriendly) Error() string {
- return e.Err
-}
-
// ----------------------
// COMMANDS
diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go
index 33cd628c9db..89f2d6f8220 100644
--- a/pkg/services/featuremgmt/registry.go
+++ b/pkg/services/featuremgmt/registry.go
@@ -376,7 +376,7 @@ var (
{
Name: "frontendSandboxMonitorOnly",
Description: "Enables monitor only in the plugin frontend sandbox (if enabled)",
- Stage: FeatureStageExperimental,
+ Stage: FeatureStagePrivatePreview,
FrontendOnly: true,
Owner: grafanaPluginsPlatformSquad,
},
@@ -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",
@@ -951,6 +938,12 @@ var (
Owner: grafanaOperatorExperienceSquad,
Expression: "true",
},
+ {
+ Name: "secretsManagementAppPlatform",
+ Description: "Enable the secrets management API and services under app platform",
+ Stage: FeatureStageExperimental,
+ Owner: grafanaOperatorExperienceSquad,
+ },
{
Name: "alertingSaveStatePeriodic",
Description: "Writes the state periodically to the database, asynchronous to rule evaluation",
@@ -1399,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,
},
@@ -1415,8 +1409,9 @@ var (
{
Name: "pluginsSriChecks",
Description: "Enables SRI checks for plugin assets",
- Stage: FeatureStageExperimental,
+ Stage: FeatureStageGeneralAvailability,
Owner: grafanaPluginsPlatformSquad,
+ Expression: "false", // disabled by default
},
{
Name: "unifiedStorageBigObjectsSupport",
@@ -1829,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 0b2cded925a..62bd4844236 100644
--- a/pkg/services/featuremgmt/toggles_gen.csv
+++ b/pkg/services/featuremgmt/toggles_gen.csv
@@ -49,7 +49,7 @@ enableDatagridEditing,preview,@grafana/dataviz-squad,false,false,true
extraThemes,experimental,@grafana/grafana-frontend-platform,false,false,true
lokiPredefinedOperations,experimental,@grafana/observability-logs,false,false,true
pluginsFrontendSandbox,privatePreview,@grafana/plugins-platform-backend,false,false,false
-frontendSandboxMonitorOnly,experimental,@grafana/plugins-platform-backend,false,false,true
+frontendSandboxMonitorOnly,privatePreview,@grafana/plugins-platform-backend,false,false,true
pluginsDetailsRightPanel,privatePreview,@grafana/plugins-platform-backend,false,false,true
sqlDatasourceDatabaseSelection,preview,@grafana/dataviz-squad,false,false,true
recordedQueriesMulti,GA,@grafana/observability-metrics,false,false,false
@@ -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
@@ -125,6 +123,7 @@ alertingQueryOptimization,GA,@grafana/alerting-squad,false,false,false
newFolderPicker,experimental,@grafana/grafana-frontend-platform,false,false,true
jitterAlertRulesWithinGroups,preview,@grafana/alerting-squad,false,true,false
onPremToCloudMigrations,preview,@grafana/grafana-operator-experience-squad,false,false,false
+secretsManagementAppPlatform,experimental,@grafana/grafana-operator-experience-squad,false,false,false
alertingSaveStatePeriodic,privatePreview,@grafana/alerting-squad,false,false,false
alertingSaveStateCompressed,preview,@grafana/alerting-squad,false,false,false
scopeApi,experimental,@grafana/grafana-app-platform-squad,false,false,false
@@ -183,9 +182,9 @@ 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,experimental,@grafana/plugins-platform-backend,false,false,false
+pluginsSriChecks,GA,@grafana/plugins-platform-backend,false,false,false
unifiedStorageBigObjectsSupport,experimental,@grafana/search-and-storage,false,false,false
timeRangeProvider,experimental,@grafana/grafana-frontend-platform,false,false,false
prometheusUsesCombobox,experimental,@grafana/oss-big-tent,false,false,false
@@ -241,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 1f4dee5e21d..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"
@@ -511,6 +503,10 @@ const (
// Enable the Grafana Migration Assistant, which helps you easily migrate on-prem resources, such as dashboards, folders, and data source configurations, to your Grafana Cloud stack.
FlagOnPremToCloudMigrations = "onPremToCloudMigrations"
+ // FlagSecretsManagementAppPlatform
+ // Enable the secrets management API and services under app platform
+ FlagSecretsManagementAppPlatform = "secretsManagementAppPlatform"
+
// FlagAlertingSaveStatePeriodic
// Writes the state periodically to the database, asynchronous to rule evaluation
FlagAlertingSaveStatePeriodic = "alertingSaveStatePeriodic"
@@ -974,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 64036ecda10..1d0e357c378 100644
--- a/pkg/services/featuremgmt/toggles_gen.json
+++ b/pkg/services/featuremgmt/toggles_gen.json
@@ -1913,12 +1913,15 @@
{
"metadata": {
"name": "frontendSandboxMonitorOnly",
- "resourceVersion": "1718727528075",
- "creationTimestamp": "2023-07-05T11:48:25Z"
+ "resourceVersion": "1741965568775",
+ "creationTimestamp": "2023-07-05T11:48:25Z",
+ "annotations": {
+ "grafana.app/updatedTimestamp": "2025-03-14 15:19:28.77575 +0000 UTC"
+ }
},
"spec": {
"description": "Enables monitor only in the plugin frontend sandbox (if enabled)",
- "stage": "experimental",
+ "stage": "privatePreview",
"codeowner": "@grafana/plugins-platform-backend",
"frontend": true
}
@@ -2401,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",
@@ -2777,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",
@@ -3281,13 +3273,17 @@
{
"metadata": {
"name": "pluginsSriChecks",
- "resourceVersion": "1727785264632",
- "creationTimestamp": "2024-10-04T12:55:09Z"
+ "resourceVersion": "1742405194835",
+ "creationTimestamp": "2024-10-04T12:55:09Z",
+ "annotations": {
+ "grafana.app/updatedTimestamp": "2025-03-19 17:26:34.83562 +0000 UTC"
+ }
},
"spec": {
"description": "Enables SRI checks for plugin assets",
- "stage": "experimental",
- "codeowner": "@grafana/plugins-platform-backend"
+ "stage": "GA",
+ "codeowner": "@grafana/plugins-platform-backend",
+ "expression": "false"
}
},
{
@@ -3819,6 +3815,18 @@
"hideFromDocs": true
}
},
+ {
+ "metadata": {
+ "name": "secretsManagementAppPlatform",
+ "resourceVersion": "1742370596238",
+ "creationTimestamp": "2025-03-19T07:49:56Z"
+ },
+ "spec": {
+ "description": "Enable the secrets management API and services under app platform",
+ "stage": "experimental",
+ "codeowner": "@grafana/grafana-operator-experience-squad"
+ }
+ },
{
"metadata": {
"name": "showDashboardValidationWarnings",
@@ -4196,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",
@@ -4242,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/features/watch_test.go b/pkg/services/live/features/watch_test.go
new file mode 100644
index 00000000000..fc82966b18a
--- /dev/null
+++ b/pkg/services/live/features/watch_test.go
@@ -0,0 +1,67 @@
+package features
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+)
+
+func TestParseWatchRqeust(t *testing.T) {
+ userid := "userid" // dummy
+ tests := []struct {
+ testCase string
+ channel string
+ userid string // override
+
+ // Expect
+ gvr schema.GroupVersionResource
+ name string
+ err bool
+ }{
+ {
+ testCase: "dashbaords",
+ channel: "watch/dashboard.grafana.app/v0alpha1/dashboards/userid",
+ gvr: schema.GroupVersionResource{
+ Group: "dashboard.grafana.app",
+ Version: "v0alpha1",
+ Resource: "dashboards",
+ },
+ },
+ {
+ testCase: "dashbaords with anme",
+ channel: "watch/dashboard.grafana.app/v0alpha1/dashboards=abc/userid",
+ gvr: schema.GroupVersionResource{
+ Group: "dashboard.grafana.app",
+ Version: "v0alpha1",
+ Resource: "dashboards",
+ },
+ name: "abc",
+ },
+ {
+ testCase: "bad user id",
+ channel: "watch/dashboard.grafana.app/v0alpha1/dashboards/x",
+ err: true, // bad user id
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.testCase, func(t *testing.T) {
+ gvr, name, err := parseWatchRequest(tt.channel, first(tt.userid, userid))
+ if tt.err {
+ require.Error(t, err)
+ return
+ }
+ require.Equal(t, tt.gvr, gvr)
+ require.Equal(t, tt.name, name)
+ })
+ }
+}
+
+func first(vals ...string) string {
+ for _, v := range vals {
+ if v != "" {
+ return v
+ }
+ }
+ return ""
+}
diff --git a/pkg/services/live/live.go b/pkg/services/live/live.go
index 35c2be26d69..f7576c5dd6f 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 live.ScopeWatch:
+ 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/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go
index facf246f171..e859596520f 100644
--- a/pkg/services/navtree/navtreeimpl/navtree.go
+++ b/pkg/services/navtree/navtreeimpl/navtree.go
@@ -23,6 +23,8 @@ import (
"github.com/grafana/grafana/pkg/services/star"
"github.com/grafana/grafana/pkg/services/supportbundles/supportbundlesimpl"
"github.com/grafana/grafana/pkg/setting"
+
+ "github.com/open-feature/go-sdk/openfeature"
)
type ServiceImpl struct {
@@ -34,6 +36,7 @@ type ServiceImpl struct {
pluginSettings pluginsettings.Service
starService star.Service
features featuremgmt.FeatureToggles
+ openFeature *featuremgmt.OpenFeatureService
dashboardService dashboards.DashboardService
accesscontrolService ac.Service
kvStore kvstore.KVStore
@@ -54,7 +57,7 @@ type NavigationAppConfig struct {
func ProvideService(cfg *setting.Cfg, accessControl ac.AccessControl, pluginStore pluginstore.Store, pluginSettings pluginsettings.Service, starService star.Service,
features featuremgmt.FeatureToggles, dashboardService dashboards.DashboardService, accesscontrolService ac.Service, kvStore kvstore.KVStore, apiKeyService apikey.Service,
- license licensing.Licensing, authnService authn.Service) navtree.Service {
+ license licensing.Licensing, authnService authn.Service, openFeature *featuremgmt.OpenFeatureService) navtree.Service {
service := &ServiceImpl{
cfg: cfg,
log: log.New("navtree service"),
@@ -64,6 +67,7 @@ func ProvideService(cfg *setting.Cfg, accessControl ac.AccessControl, pluginStor
pluginSettings: pluginSettings,
starService: starService,
features: features,
+ openFeature: openFeature,
dashboardService: dashboardService,
accesscontrolService: accesscontrolService,
kvStore: kvStore,
@@ -80,6 +84,7 @@ func ProvideService(cfg *setting.Cfg, accessControl ac.AccessControl, pluginStor
func (s *ServiceImpl) GetNavTree(c *contextmodel.ReqContext, prefs *pref.Preference) (*navtree.NavTreeRoot, error) {
hasAccess := ac.HasAccess(s.accessControl, c)
treeRoot := &navtree.NavTreeRoot{}
+ ctx := c.Req.Context()
treeRoot.AddSection(s.getHomeNode(c, prefs))
@@ -184,7 +189,8 @@ func (s *ServiceImpl) GetNavTree(c *contextmodel.ReqContext, prefs *pref.Prefere
treeRoot.RemoveSectionByID(navtree.NavIDCfg)
}
- if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagPinNavItems) && c.IsSignedIn {
+ enabled := s.openFeature.Client.Boolean(ctx, featuremgmt.FlagPinNavItems, true, openfeature.TransactionContext(ctx))
+ if enabled && c.IsSignedIn {
treeRoot.AddSection(&navtree.NavLink{
Text: "Bookmarks",
Id: navtree.NavIDBookmarks,
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/api_convert_prometheus_test.go b/pkg/services/ngalert/api/api_convert_prometheus_test.go
index 437ec3ffef3..720bde8349b 100644
--- a/pkg/services/ngalert/api/api_convert_prometheus_test.go
+++ b/pkg/services/ngalert/api/api_convert_prometheus_test.go
@@ -2,7 +2,6 @@ package api
import (
"context"
- "fmt"
"net/http"
"net/http/httptest"
"testing"
@@ -123,15 +122,13 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
expectedRules := make(map[string]string)
for _, rule := range simpleGroup.Rules {
if rule.Alert != "" {
- title := fmt.Sprintf("[%s] %s", simpleGroup.Name, rule.Alert)
promRuleYAML, err := yaml.Marshal(rule)
require.NoError(t, err)
- expectedRules[title] = string(promRuleYAML)
+ expectedRules[rule.Alert] = string(promRuleYAML)
} else if rule.Record != "" {
- title := fmt.Sprintf("[%s] %s", simpleGroup.Name, rule.Record)
promRuleYAML, err := yaml.Marshal(rule)
require.NoError(t, err)
- expectedRules[title] = string(promRuleYAML)
+ expectedRules[rule.Record] = string(promRuleYAML)
}
}
diff --git a/pkg/services/ngalert/api/api_provisioning.go b/pkg/services/ngalert/api/api_provisioning.go
index 44e2fd9a935..f21050c0501 100644
--- a/pkg/services/ngalert/api/api_provisioning.go
+++ b/pkg/services/ngalert/api/api_provisioning.go
@@ -8,6 +8,8 @@ import (
"regexp"
"strings"
+ alertmanager_config "github.com/prometheus/alertmanager/config"
+
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/log"
@@ -21,7 +23,6 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/util"
- alertmanager_config "github.com/prometheus/alertmanager/config"
)
const disableProvenanceHeaderName = "X-Disable-Provenance"
@@ -357,9 +358,6 @@ func (srv *ProvisioningSrv) RoutePostAlertRule(c *contextmodel.ReqContext, ar de
return ErrResp(http.StatusBadRequest, err, "")
}
if err != nil {
- if errors.Is(err, alerting_models.ErrAlertRuleUniqueConstraintViolation) {
- return ErrResp(http.StatusBadRequest, err, "")
- }
if errors.Is(err, store.ErrOptimisticLock) {
return ErrResp(http.StatusConflict, err, "")
}
@@ -396,9 +394,6 @@ func (srv *ProvisioningSrv) RoutePutAlertRule(c *contextmodel.ReqContext, ar def
updated.UID = UID
provenance := determineProvenance(c)
updatedAlertRule, err := srv.alertRules.UpdateAlertRule(c.Req.Context(), c.SignedInUser, updated, alerting_models.Provenance(provenance))
- if errors.Is(err, alerting_models.ErrAlertRuleUniqueConstraintViolation) {
- return ErrResp(http.StatusBadRequest, err, "")
- }
if errors.Is(err, alerting_models.ErrAlertRuleNotFound) {
return response.Empty(http.StatusNotFound)
}
@@ -514,9 +509,6 @@ func (srv *ProvisioningSrv) RoutePutAlertRuleGroup(c *contextmodel.ReqContext, a
}
provenance := determineProvenance(c)
err = srv.alertRules.ReplaceRuleGroup(c.Req.Context(), c.SignedInUser, groupModel, alerting_models.Provenance(provenance))
- if errors.Is(err, alerting_models.ErrAlertRuleUniqueConstraintViolation) {
- return ErrResp(http.StatusBadRequest, err, "")
- }
if errors.Is(err, alerting_models.ErrAlertRuleFailedValidation) {
return ErrResp(http.StatusBadRequest, err, "")
}
diff --git a/pkg/services/ngalert/api/prometheus_conversion.go b/pkg/services/ngalert/api/prometheus_conversion.go
index d48aa5444b9..9862c862b9a 100644
--- a/pkg/services/ngalert/api/prometheus_conversion.go
+++ b/pkg/services/ngalert/api/prometheus_conversion.go
@@ -1,15 +1,20 @@
package api
import (
+ "encoding/json"
"io"
+ "mime"
"gopkg.in/yaml.v3"
"github.com/grafana/grafana/pkg/api/response"
+ "github.com/grafana/grafana/pkg/apimachinery/errutil"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
)
+var errorUnsupportedMediaType = errutil.UnsupportedMediaType("alerting.unsupportedMediaType")
+
type ConvertPrometheusApiHandler struct {
svc *ConvertPrometheusSrv
}
@@ -49,8 +54,30 @@ func (f *ConvertPrometheusApiHandler) handleRouteConvertPrometheusPostRuleGroup(
defer func() { _ = ctx.Req.Body.Close() }()
var promGroup apimodels.PrometheusRuleGroup
- if err := yaml.Unmarshal(body, &promGroup); err != nil {
- return errorToResponse(err)
+ var m string
+
+ // Parse content-type only if it's not empty,
+ // otherwise we'll assume it's yaml
+ contentType := ctx.Req.Header.Get("content-type")
+ if contentType != "" {
+ m, _, err = mime.ParseMediaType(contentType)
+ if err != nil {
+ return errorToResponse(err)
+ }
+ }
+
+ switch m {
+ case "application/yaml", "":
+ // mimirtool does not send content-type, so if it's empty, we assume it's yaml
+ if err := yaml.Unmarshal(body, &promGroup); err != nil {
+ return errorToResponse(err)
+ }
+ case "application/json":
+ if err := json.Unmarshal(body, &promGroup); err != nil {
+ return errorToResponse(err)
+ }
+ default:
+ return errorToResponse(errorUnsupportedMediaType.Errorf("unsupported media type: %s, only application/yaml and application/json are supported", m))
}
return f.svc.RouteConvertPrometheusPostRuleGroup(ctx, namespaceTitle, promGroup)
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/alertmanager.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go
index 5a9c6a5b868..23cfdae71d8 100644
--- a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go
+++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go
@@ -7,7 +7,6 @@ import (
"time"
"github.com/go-openapi/strfmt"
- "github.com/mohae/deepcopy"
amv2 "github.com/prometheus/alertmanager/api/v2/models"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/common/model"
@@ -698,26 +697,6 @@ func (c *PostableUserConfig) validate() error {
return nil
}
-// Decrypt returns a copy of the configuration struct with decrypted secure settings in receivers.
-func (c *PostableUserConfig) Decrypt(decryptFn func(payload []byte) ([]byte, error)) (PostableUserConfig, error) {
- newCfg, ok := deepcopy.Copy(c).(*PostableUserConfig)
- if !ok {
- return PostableUserConfig{}, fmt.Errorf("failed to copy config")
- }
-
- // Iterate through receivers and decrypt secure settings.
- for _, rcv := range newCfg.AlertmanagerConfig.Receivers {
- for _, gmr := range rcv.PostableGrafanaReceivers.GrafanaManagedReceivers {
- decrypted, err := gmr.DecryptSecureSettings(decryptFn)
- if err != nil {
- return PostableUserConfig{}, err
- }
- gmr.SecureSettings = decrypted
- }
- }
- return *newCfg, nil
-}
-
// GetGrafanaReceiverMap returns a map that associates UUIDs to grafana receivers
func (c *PostableUserConfig) GetGrafanaReceiverMap() map[string]*PostableGrafanaReceiver {
UIDs := make(map[string]*PostableGrafanaReceiver)
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/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go
index 58501b2cda8..c45de3ca093 100644
--- a/pkg/services/ngalert/models/alert_rule.go
+++ b/pkg/services/ngalert/models/alert_rule.go
@@ -34,11 +34,10 @@ var (
// ErrAlertRuleFailedGenerateUniqueUID is an error for failure to generate alert rule UID
ErrAlertRuleFailedGenerateUniqueUID = errors.New("failed to generate alert rule UID")
// ErrCannotEditNamespace is an error returned if the user does not have permissions to edit the namespace
- ErrCannotEditNamespace = errors.New("user does not have permissions to edit the namespace")
- ErrRuleGroupNamespaceNotFound = errors.New("rule group not found under this namespace")
- ErrAlertRuleFailedValidation = errors.New("invalid alert rule")
- ErrAlertRuleUniqueConstraintViolation = errors.New("rule title under the same organisation and folder should be unique")
- ErrQuotaReached = errors.New("quota has been exceeded")
+ ErrCannotEditNamespace = errors.New("user does not have permissions to edit the namespace")
+ ErrRuleGroupNamespaceNotFound = errors.New("rule group not found under this namespace")
+ ErrAlertRuleFailedValidation = errors.New("invalid alert rule")
+ ErrQuotaReached = errors.New("quota has been exceeded")
// ErrNoDashboard is returned when the alert rule does not have a Dashboard UID
// in its annotations or the dashboard does not exist.
ErrNoDashboard = errors.New("no dashboard")
diff --git a/pkg/services/ngalert/models/errors.go b/pkg/services/ngalert/models/errors.go
index 338cc7709f2..ea2a1b047ab 100644
--- a/pkg/services/ngalert/models/errors.go
+++ b/pkg/services/ngalert/models/errors.go
@@ -28,11 +28,13 @@ func ErrAlertRuleConflictVerbose(existingPartialRule, rule AlertRule, underlying
"RuleUID": rule.UID,
"Title": rule.Title,
"NamespaceUID": rule.NamespaceUID,
+ "RuleGroup": rule.RuleGroup,
},
"Existing": map[string]any{
"RuleUID": existingPartialRule.UID,
"Title": existingPartialRule.Title,
"NamespaceUID": existingPartialRule.NamespaceUID,
+ "RuleGroup": existingPartialRule.RuleGroup,
},
"Error": underlying.Error(),
}, Error: underlying})
diff --git a/pkg/services/ngalert/prom/convert.go b/pkg/services/ngalert/prom/convert.go
index 483ec67929e..b433a2ec76b 100644
--- a/pkg/services/ngalert/prom/convert.go
+++ b/pkg/services/ngalert/prom/convert.go
@@ -134,7 +134,6 @@ func (p *Converter) PrometheusRulesToGrafana(orgID int64, namespaceUID string, g
}
func (p *Converter) convertRuleGroup(orgID int64, namespaceUID string, promGroup PrometheusRuleGroup) (*models.AlertRuleGroup, error) {
- uniqueNames := map[string]int{}
rules := make([]models.AlertRule, 0, len(promGroup.Rules))
interval := time.Duration(promGroup.Interval)
@@ -150,12 +149,6 @@ func (p *Converter) convertRuleGroup(orgID int64, namespaceUID string, promGroup
gr.RuleGroupIndex = i + 1
gr.IntervalSeconds = int64(interval.Seconds())
- // Check rule title uniqueness within the group.
- uniqueNames[gr.Title]++
- if val := uniqueNames[gr.Title]; val > 1 {
- gr.Title = fmt.Sprintf("%s (%d)", gr.Title, val)
- }
-
uid, err := getUID(orgID, namespaceUID, promGroup.Name, i, rule)
if err != nil {
return nil, fmt.Errorf("failed to generate UID for rule '%s': %w", gr.Title, err)
@@ -206,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
}
@@ -225,13 +218,6 @@ func (p *Converter) convertRule(orgID int64, namespaceUID string, promGroup Prom
title = rule.Alert
}
- // Temporary workaround for avoiding the uniqueness check for the rule title.
- // In Grafana alert rule titles must be unique within the same org and folder,
- // but Prometheus allows multiple rules with the same name. By adding the group name
- // to the title we ensure that the title is unique within the group.
- // TODO: Remove this workaround when we have a proper solution for handling rule title uniqueness.
- title = fmt.Sprintf("[%s] %s", promGroup.Name, title)
-
labels := make(map[string]string, len(rule.Labels)+len(promGroup.Labels))
maps.Copy(labels, promGroup.Labels)
maps.Copy(labels, rule.Labels)
@@ -255,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 {
@@ -279,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 5f039e69831..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 {
@@ -214,7 +238,7 @@ func TestPrometheusRulesToGrafana(t *testing.T) {
grafanaRule := grafanaGroup.Rules[j]
if promRule.Record != "" {
- require.Equal(t, fmt.Sprintf("[%s] %s", tc.promGroup.Name, promRule.Record), grafanaRule.Title)
+ require.Equal(t, promRule.Record, grafanaRule.Title)
require.NotNil(t, grafanaRule.Record)
require.Equal(t, grafanaRule.Record.From, queryRefID)
require.Equal(t, promRule.Record, grafanaRule.Record.Metric)
@@ -225,7 +249,7 @@ func TestPrometheusRulesToGrafana(t *testing.T) {
}
require.Equal(t, targetDatasourceUID, grafanaRule.Record.TargetDatasourceUID)
} else {
- require.Equal(t, fmt.Sprintf("[%s] %s", tc.promGroup.Name, promRule.Alert), grafanaRule.Title)
+ require.Equal(t, promRule.Alert, grafanaRule.Title)
}
var expectedFor time.Duration
@@ -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)
@@ -292,10 +327,10 @@ func TestPrometheusRulesToGrafanaWithDuplicateRuleNames(t *testing.T) {
require.Equal(t, "test-group-1", group.Title)
require.Len(t, group.Rules, 4)
- require.Equal(t, "[test-group-1] alert", group.Rules[0].Title)
- require.Equal(t, "[test-group-1] alert (2)", group.Rules[1].Title)
- require.Equal(t, "[test-group-1] another alert", group.Rules[2].Title)
- require.Equal(t, "[test-group-1] alert (3)", group.Rules[3].Title)
+ require.Equal(t, "alert", group.Rules[0].Title)
+ require.Equal(t, "alert", group.Rules[1].Title)
+ require.Equal(t, "another alert", group.Rules[2].Title)
+ require.Equal(t, "alert", group.Rules[3].Title)
}
func TestCreateMathNode(t *testing.T) {
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/remote/alertmanager.go b/pkg/services/ngalert/remote/alertmanager.go
index 5928c8f6dcb..3f0821889d2 100644
--- a/pkg/services/ngalert/remote/alertmanager.go
+++ b/pkg/services/ngalert/remote/alertmanager.go
@@ -255,17 +255,22 @@ func (am *Alertmanager) CompareAndSendConfiguration(ctx context.Context, config
if err := am.autogenFn(ctx, am.log, am.orgID, &c.AlertmanagerConfig, true); err != nil {
return err
}
- decrypted, err := am.decryptConfiguration(ctx, c)
+ rawDecrypted, configHash, err := am.decryptConfiguration(ctx, c)
if err != nil {
return err
}
// Send the configuration only if we need to.
- if !am.shouldSendConfig(ctx, decrypted) {
+ if !am.shouldSendConfig(ctx, configHash) {
return nil
}
- isDefault, err := am.isDefaultConfiguration(decrypted)
+ isDefault, err := am.isDefaultConfiguration(configHash)
+ if err != nil {
+ return err
+ }
+
+ decrypted, err := notifier.Load(rawDecrypted)
if err != nil {
return err
}
@@ -273,27 +278,37 @@ func (am *Alertmanager) CompareAndSendConfiguration(ctx context.Context, config
return am.sendConfiguration(ctx, decrypted, config.ConfigurationHash, config.CreatedAt, isDefault)
}
-func (am *Alertmanager) isDefaultConfiguration(cfg *apimodels.PostableUserConfig) (bool, error) {
- rawCfg, err := json.Marshal(cfg)
- if err != nil {
- return false, err
- }
-
- configHash := fmt.Sprintf("%x", md5.Sum(rawCfg))
-
- return configHash == am.defaultConfigHash, nil
+func (am *Alertmanager) isDefaultConfiguration(configHash [16]byte) (bool, error) {
+ return fmt.Sprintf("%x", configHash) == am.defaultConfigHash, nil
}
-func (am *Alertmanager) decryptConfiguration(ctx context.Context, cfg *apimodels.PostableUserConfig) (*apimodels.PostableUserConfig, error) {
+// decryptConfiguration decrypts the configuration in-place and returns the decrypted configuration alongside its hash.
+// Should not be used outside of this package and the specific use case of decrypting the configuration before sending
+// it to the remote Alertmanager.
+func (am *Alertmanager) decryptConfiguration(ctx context.Context, cfg *apimodels.PostableUserConfig) ([]byte, [16]byte, error) {
fn := func(payload []byte) ([]byte, error) {
return am.decrypt(ctx, payload)
}
- decrypted, err := cfg.Decrypt(fn)
- if err != nil {
- return nil, fmt.Errorf("unable to decrypt the configuration: %w", err)
+
+ // Iterate through receivers and decrypt secure settings.
+ // It's not necessary to be careful about not modifying the original, as it's used only in a specific context where
+ // the config is read from json and then immediately sent to the remote Alertmanager.
+ for _, rcv := range cfg.AlertmanagerConfig.Receivers {
+ for _, gmr := range rcv.PostableGrafanaReceivers.GrafanaManagedReceivers {
+ decrypted, err := gmr.DecryptSecureSettings(fn)
+ if err != nil {
+ return nil, [16]byte{}, fmt.Errorf("unable to decrypt settings on receiver %q (uid: %q): %w", gmr.Name, gmr.UID, err)
+ }
+ gmr.SecureSettings = decrypted
+ }
}
- return &decrypted, nil
+ rawDecrypted, err := json.Marshal(cfg)
+ if err != nil {
+ return nil, [16]byte{}, fmt.Errorf("unable to marshal decrypted configuration: %w", err)
+ }
+
+ return rawDecrypted, md5.Sum(rawDecrypted), nil
}
func (am *Alertmanager) sendConfiguration(ctx context.Context, decrypted *apimodels.PostableUserConfig, hash string, createdAt int64, isDefault bool) error {
@@ -345,7 +360,12 @@ func (am *Alertmanager) SaveAndApplyConfig(ctx context.Context, cfg *apimodels.P
if err := am.autogenFn(ctx, am.log, am.orgID, &cfg.AlertmanagerConfig, false); err != nil {
return err
}
- decrypted, err := am.decryptConfiguration(ctx, cfg)
+ rawDecrypted, _, err := am.decryptConfiguration(ctx, cfg)
+ if err != nil {
+ return err
+ }
+
+ decrypted, err := notifier.Load(rawDecrypted)
if err != nil {
return err
}
@@ -364,7 +384,12 @@ func (am *Alertmanager) SaveAndApplyDefaultConfig(ctx context.Context) error {
if err := am.autogenFn(ctx, am.log, am.orgID, &c.AlertmanagerConfig, true); err != nil {
return err
}
- decrypted, err := am.decryptConfiguration(ctx, c)
+ rawDecrypted, _, err := am.decryptConfiguration(ctx, c)
+ if err != nil {
+ return err
+ }
+
+ decrypted, err := notifier.Load(rawDecrypted)
if err != nil {
return err
}
@@ -634,7 +659,7 @@ func (am *Alertmanager) getFullState(ctx context.Context) (string, error) {
// shouldSendConfig compares the remote Alertmanager configuration with our local one.
// It returns true if the configurations are different.
-func (am *Alertmanager) shouldSendConfig(ctx context.Context, config *apimodels.PostableUserConfig) bool {
+func (am *Alertmanager) shouldSendConfig(ctx context.Context, hash [16]byte) bool {
rc, err := am.mimirClient.GetGrafanaAlertmanagerConfig(ctx)
if err != nil {
// Log the error and return true so we try to upload our config anyway.
@@ -651,12 +676,7 @@ func (am *Alertmanager) shouldSendConfig(ctx context.Context, config *apimodels.
am.log.Error("Unable to marshal the remote Alertmanager configuration for comparison", "err", err)
return true
}
- rawInternal, err := json.Marshal(config)
- if err != nil {
- am.log.Error("Unable to marshal the internal Alertmanager configuration for comparison", "err", err)
- return true
- }
- return md5.Sum(rawRemote) != md5.Sum(rawInternal)
+ return md5.Sum(rawRemote) != hash
}
// shouldSendState compares the remote Alertmanager state with our local one.
diff --git a/pkg/services/ngalert/remote/alertmanager_test.go b/pkg/services/ngalert/remote/alertmanager_test.go
index 7ad086dc7f2..3d9f10e4396 100644
--- a/pkg/services/ngalert/remote/alertmanager_test.go
+++ b/pkg/services/ngalert/remote/alertmanager_test.go
@@ -23,6 +23,8 @@ import (
"github.com/grafana/alerting/definition"
alertingModels "github.com/grafana/alerting/models"
"github.com/grafana/alerting/notify"
+ "gopkg.in/yaml.v3"
+
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
@@ -39,13 +41,12 @@ import (
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tests/testsuite"
"github.com/grafana/grafana/pkg/util"
- "gopkg.in/yaml.v3"
)
const (
// Valid Grafana Alertmanager configurations.
- testGrafanaConfig = `{"template_files":{},"alertmanager_config":{"route":{"receiver":"grafana-default-email","group_by":["grafana_folder","alertname"]},"receivers":[{"name":"grafana-default-email","grafana_managed_receiver_configs":[{"uid":"","name":"some other name","type":"email","disableResolveMessage":false,"settings":{"addresses":"\u003cexample@email.com\u003e"}}]}]}}`
- testGrafanaConfigWithSecret = `{"template_files":{},"alertmanager_config":{"route":{"receiver":"grafana-default-email","group_by":["grafana_folder","alertname"]},"receivers":[{"name":"grafana-default-email","grafana_managed_receiver_configs":[{"uid":"dde6ntuob69dtf","name":"WH","type":"webhook","disableResolveMessage":false,"settings":{"url":"http://localhost:8080","username":"test"},"secureSettings":{"password":"test"}}]}]}}`
+ testGrafanaConfig = `{"template_files":{},"alertmanager_config":{"time_intervals":[{"name":"weekends","time_intervals":[{"weekdays":["saturday","sunday"],"location":"Africa/Accra"}]}],"route":{"receiver":"grafana-default-email","group_by":["grafana_folder","alertname"]},"receivers":[{"name":"grafana-default-email","grafana_managed_receiver_configs":[{"uid":"","name":"some other name","type":"email","disableResolveMessage":false,"settings":{"addresses":"\u003cexample@email.com\u003e"}}]}]}}`
+ testGrafanaConfigWithSecret = `{"template_files":{},"alertmanager_config":{"time_intervals":[{"name":"weekends","time_intervals":[{"weekdays":["saturday","sunday"],"location":"Africa/Accra"}]}],"route":{"receiver":"grafana-default-email","group_by":["grafana_folder","alertname"]},"receivers":[{"name":"grafana-default-email","grafana_managed_receiver_configs":[{"uid":"dde6ntuob69dtf","name":"WH","type":"webhook","disableResolveMessage":false,"settings":{"url":"http://localhost:8080","username":"test"},"secureSettings":{"password":"test"}}]}]}}`
testGrafanaDefaultConfigWithDifferentFieldOrder = `{"alertmanager_config":{"route":{"group_by":["alertname","grafana_folder"],"receiver":"grafana-default-email"},"receivers":[{"grafana_managed_receiver_configs":[{"uid":"","name":"email receiver","type":"email","settings":{"addresses":""}}],"name":"grafana-default-email"}]}}`
// Valid Alertmanager state base64 encoded.
@@ -287,14 +288,14 @@ func TestCompareAndSendConfiguration(t *testing.T) {
strings.Replace(testGrafanaConfigWithSecret, `"password":"test"`, `"password":"!"`, 1),
NoopAutogenFn,
nil,
- "unable to decrypt the configuration: failed to decode value for key 'password': illegal base64 data at input byte 0",
+ `unable to decrypt settings on receiver "WH" (uid: "dde6ntuob69dtf"): failed to decode value for key 'password': illegal base64 data at input byte 0`,
},
{
"decrypt error",
testGrafanaConfigWithSecret,
NoopAutogenFn,
nil,
- fmt.Sprintf("unable to decrypt the configuration: failed to decrypt value for key 'password': %s", errTest.Error()),
+ fmt.Sprintf(`unable to decrypt settings on receiver "WH" (uid: "dde6ntuob69dtf"): failed to decrypt value for key 'password': %s`, errTest.Error()),
},
{
"error from autogen function",
@@ -443,7 +444,9 @@ func Test_isDefaultConfiguration(t *testing.T) {
defaultConfig: string(rawDefaultCfg),
defaultConfigHash: fmt.Sprintf("%x", md5.Sum(rawDefaultCfg)),
}
- isDefault, _ := am.isDefaultConfiguration(test.config)
+ raw, err := json.Marshal(test.config)
+ require.NoError(tt, err)
+ isDefault, _ := am.isDefaultConfiguration(md5.Sum(raw))
require.Equal(tt, test.expected, isDefault)
})
}
diff --git a/pkg/services/ngalert/remote/client/mimir.go b/pkg/services/ngalert/remote/client/mimir.go
index 4482ee0495a..0ae532b01ad 100644
--- a/pkg/services/ngalert/remote/client/mimir.go
+++ b/pkg/services/ngalert/remote/client/mimir.go
@@ -13,6 +13,7 @@ import (
"strings"
alertingNotify "github.com/grafana/alerting/notify"
+
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
@@ -139,7 +140,12 @@ func (mc *Mimir) do(ctx context.Context, p, method string, payload io.Reader, ou
ct := resp.Header.Get("Content-Type")
if !strings.HasPrefix(ct, "application/json") {
msg := "Response content-type is not application/json"
- mc.logger.Error(msg, "content-type", "url", r.URL.String(), "method", r.Method, ct, "status", resp.StatusCode)
+ body, err := io.ReadAll(resp.Body)
+ bodyStr := string(body)
+ if err != nil {
+ bodyStr = fmt.Sprintf("fail_to_read: %s", err)
+ }
+ mc.logger.Error(msg, "content-type", "url", r.URL.String(), "method", r.Method, ct, "status", resp.StatusCode, "body", bodyStr)
return nil, fmt.Errorf("%s: %s", msg, ct)
}
diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go
index 4df2410ed05..c747e4461e5 100644
--- a/pkg/services/ngalert/store/alert_rule.go
+++ b/pkg/services/ngalert/store/alert_rule.go
@@ -15,7 +15,6 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/db"
- "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -374,7 +373,7 @@ func (st DBstore) InsertAlertRules(ctx context.Context, user *ngmodels.UserUID,
for i := range newRules {
if _, err := sess.Insert(&newRules[i]); err != nil {
if st.SQLStore.GetDialect().IsUniqueConstraintViolation(err) {
- return ruleConstraintViolationToErr(sess, rules[i], err, st.Logger)
+ return ruleConstraintViolationToErr(rules[i], err)
}
return fmt.Errorf("failed to create new rules: %w", err)
}
@@ -432,7 +431,7 @@ func (st DBstore) UpdateAlertRules(ctx context.Context, user *ngmodels.UserUID,
if updated, err := sess.ID(r.Existing.ID).AllCols().Omit("rule_guid").Update(converted); err != nil || updated == 0 {
if err != nil {
if st.SQLStore.GetDialect().IsUniqueConstraintViolation(err) {
- return ruleConstraintViolationToErr(sess, r.New, err, st.Logger)
+ return ruleConstraintViolationToErr(r.New, err)
}
return fmt.Errorf("failed to update rule [%s] %s: %w", r.New.UID, r.New.Title, err)
}
@@ -1201,22 +1200,9 @@ func (st DBstore) RenameTimeIntervalInNotificationSettings(
return result, nil, st.UpdateAlertRules(ctx, &ngmodels.AlertingUserUID, updates)
}
-func ruleConstraintViolationToErr(sess *db.Session, rule ngmodels.AlertRule, err error, logger log.Logger) error {
+func ruleConstraintViolationToErr(rule ngmodels.AlertRule, err error) error {
msg := err.Error()
- if strings.Contains(msg, "UQE_alert_rule_org_id_namespace_uid_title") || strings.Contains(msg, "alert_rule.org_id, alert_rule.namespace_uid, alert_rule.title") {
- // return verbose conflicting alert rule error response
- // see: https://github.com/grafana/grafana/issues/89755
- var fetched_uid string
- var existingPartialAlertRule ngmodels.AlertRule
- ok, uid_fetch_err := sess.Table("alert_rule").Cols("uid").Where("org_id = ? AND title = ? AND namespace_uid = ?", rule.OrgID, rule.Title, rule.NamespaceUID).Get(&fetched_uid)
- if uid_fetch_err != nil {
- logger.Error("Error fetching uid from alert_rule table", "reason", uid_fetch_err.Error())
- }
- if ok {
- existingPartialAlertRule = ngmodels.AlertRule{UID: fetched_uid, Title: rule.Title, NamespaceUID: rule.NamespaceUID}
- }
- return ngmodels.ErrAlertRuleConflictVerbose(existingPartialAlertRule, rule, ngmodels.ErrAlertRuleUniqueConstraintViolation)
- } else if strings.Contains(msg, "UQE_alert_rule_org_id_uid") || strings.Contains(msg, "alert_rule.org_id, alert_rule.uid") {
+ if strings.Contains(msg, "UQE_alert_rule_org_id_uid") || strings.Contains(msg, "alert_rule.org_id, alert_rule.uid") {
// return verbose conflicting alert rule error response
// see: https://github.com/grafana/grafana/issues/89755
existingPartialAlertRule := ngmodels.AlertRule{UID: rule.UID}
diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go
index 1567740b7dd..11614bb6841 100644
--- a/pkg/services/ngalert/store/alert_rule_test.go
+++ b/pkg/services/ngalert/store/alert_rule_test.go
@@ -9,6 +9,7 @@ import (
"testing"
"time"
+ "github.com/benbjohnson/clock"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -230,257 +231,6 @@ func TestIntegrationUpdateAlertRules(t *testing.T) {
})
}
-func TestIntegrationUpdateAlertRulesWithUniqueConstraintViolation(t *testing.T) {
- if testing.Short() {
- t.Skip("skipping integration test")
- }
- usr := models.UserUID("test")
- cfg := setting.NewCfg()
- cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{BaseInterval: time.Duration(rand.Int63n(100)+1) * time.Second}
- sqlStore := db.InitTestDB(t)
- folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures())
- b := &fakeBus{}
- store := createTestStore(sqlStore, folderService, &logtest.Fake{}, cfg.UnifiedAlerting, b)
-
- gen := models.RuleGen
- createRuleInFolder := func(title string, orgID int64, namespaceUID string) *models.AlertRule {
- gen := gen.With(
- gen.WithOrgID(orgID),
- gen.WithIntervalMatching(store.Cfg.BaseInterval),
- gen.WithNamespaceUID(namespaceUID),
- )
- return createRule(t, store, gen)
- }
-
- t.Run("should handle update chains without unique constraint violation", func(t *testing.T) {
- rule1 := createRuleInFolder("chain-rule1", 1, "my-namespace")
- rule2 := createRuleInFolder("chain-rule2", 1, "my-namespace")
-
- newRule1 := models.CopyRule(rule1)
- newRule2 := models.CopyRule(rule2)
- newRule1.Title = rule2.Title
- newRule2.Title = util.GenerateShortUID()
-
- err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{
- Existing: rule1,
- New: *newRule1,
- }, {
- Existing: rule2,
- New: *newRule2,
- },
- })
- require.NoError(t, err)
-
- dbrule1 := &alertRule{}
- dbrule2 := &alertRule{}
- err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error {
- exist, err := sess.Table(alertRule{}).ID(rule1.ID).Get(dbrule1)
- if err != nil {
- return err
- }
- require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule1.ID))
-
- exist, err = sess.Table(alertRule{}).ID(rule2.ID).Get(dbrule2)
- if err != nil {
- return err
- }
- require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule2.ID))
- return nil
- })
-
- require.NoError(t, err)
- require.Equal(t, newRule1.Title, dbrule1.Title)
- require.Equal(t, newRule2.Title, dbrule2.Title)
- })
-
- t.Run("should handle update chains with cycle without unique constraint violation", func(t *testing.T) {
- rule1 := createRuleInFolder("cycle-rule1", 1, "my-namespace")
- rule2 := createRuleInFolder("cycle-rule2", 1, "my-namespace")
- rule3 := createRuleInFolder("cycle-rule3", 1, "my-namespace")
-
- newRule1 := models.CopyRule(rule1)
- newRule2 := models.CopyRule(rule2)
- newRule3 := models.CopyRule(rule3)
- newRule1.Title = rule2.Title
- newRule2.Title = rule3.Title
- newRule3.Title = rule1.Title
-
- err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{
- Existing: rule1,
- New: *newRule1,
- }, {
- Existing: rule2,
- New: *newRule2,
- }, {
- Existing: rule3,
- New: *newRule3,
- },
- })
- require.NoError(t, err)
-
- dbrule1 := &alertRule{}
- dbrule2 := &alertRule{}
- dbrule3 := &alertRule{}
- err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error {
- exist, err := sess.Table(alertRule{}).ID(rule1.ID).Get(dbrule1)
- if err != nil {
- return err
- }
- require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule1.ID))
-
- exist, err = sess.Table(alertRule{}).ID(rule2.ID).Get(dbrule2)
- if err != nil {
- return err
- }
- require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule2.ID))
-
- exist, err = sess.Table(alertRule{}).ID(rule3.ID).Get(dbrule3)
- if err != nil {
- return err
- }
- require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule3.ID))
- return nil
- })
-
- require.NoError(t, err)
- require.Equal(t, newRule1.Title, dbrule1.Title)
- require.Equal(t, newRule2.Title, dbrule2.Title)
- require.Equal(t, newRule3.Title, dbrule3.Title)
- })
-
- t.Run("should handle case-insensitive intermediate collision without unique constraint violation", func(t *testing.T) {
- rule1 := createRuleInFolder("case-cycle-rule1", 1, "my-namespace")
- rule2 := createRuleInFolder("case-cycle-rule2", 1, "my-namespace")
-
- newRule1 := models.CopyRule(rule1)
- newRule2 := models.CopyRule(rule2)
- newRule1.Title = strings.ToUpper(rule2.Title)
- newRule2.Title = strings.ToUpper(rule1.Title)
-
- err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{
- Existing: rule1,
- New: *newRule1,
- }, {
- Existing: rule2,
- New: *newRule2,
- },
- })
- require.NoError(t, err)
-
- dbrule1 := &alertRule{}
- dbrule2 := &alertRule{}
- err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error {
- exist, err := sess.Table(alertRule{}).ID(rule1.ID).Get(dbrule1)
- if err != nil {
- return err
- }
- require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule1.ID))
-
- exist, err = sess.Table(alertRule{}).ID(rule2.ID).Get(dbrule2)
- if err != nil {
- return err
- }
- require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule2.ID))
- return nil
- })
-
- require.NoError(t, err)
- require.Equal(t, newRule1.Title, dbrule1.Title)
- require.Equal(t, newRule2.Title, dbrule2.Title)
- })
-
- t.Run("should handle update multiple chains in different folders without unique constraint violation", func(t *testing.T) {
- rule1 := createRuleInFolder("multi-cycle-rule1", 1, "my-namespace")
- rule2 := createRuleInFolder("multi-cycle-rule2", 1, "my-namespace")
- rule3 := createRuleInFolder("multi-cycle-rule1", 1, "my-namespace2")
- rule4 := createRuleInFolder("multi-cycle-rule2", 1, "my-namespace2")
-
- newRule1 := models.CopyRule(rule1)
- newRule2 := models.CopyRule(rule2)
- newRule3 := models.CopyRule(rule3)
- newRule4 := models.CopyRule(rule4)
- newRule1.Title = rule2.Title
- newRule2.Title = rule1.Title
- newRule3.Title = rule4.Title
- newRule4.Title = rule3.Title
-
- err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{
- Existing: rule1,
- New: *newRule1,
- }, {
- Existing: rule2,
- New: *newRule2,
- }, {
- Existing: rule3,
- New: *newRule3,
- }, {
- Existing: rule4,
- New: *newRule4,
- },
- })
- require.NoError(t, err)
-
- dbrule1 := &alertRule{}
- dbrule2 := &alertRule{}
- dbrule3 := &alertRule{}
- dbrule4 := &alertRule{}
- err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error {
- exist, err := sess.Table(alertRule{}).ID(rule1.ID).Get(dbrule1)
- if err != nil {
- return err
- }
- require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule1.ID))
-
- exist, err = sess.Table(alertRule{}).ID(rule2.ID).Get(dbrule2)
- if err != nil {
- return err
- }
- require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule2.ID))
-
- exist, err = sess.Table(alertRule{}).ID(rule3.ID).Get(dbrule3)
- if err != nil {
- return err
- }
- require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule3.ID))
-
- exist, err = sess.Table(alertRule{}).ID(rule4.ID).Get(dbrule4)
- if err != nil {
- return err
- }
- require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule4.ID))
- return nil
- })
-
- require.NoError(t, err)
- require.Equal(t, newRule1.Title, dbrule1.Title)
- require.Equal(t, newRule2.Title, dbrule2.Title)
- require.Equal(t, newRule3.Title, dbrule3.Title)
- require.Equal(t, newRule4.Title, dbrule4.Title)
- })
-
- t.Run("should fail with unique constraint violation", func(t *testing.T) {
- rule1 := createRuleInFolder("unique-rule1", 1, "my-namespace")
- rule2 := createRuleInFolder("unique-rule2", 1, "my-namespace")
-
- newRule1 := models.CopyRule(rule1)
- newRule2 := models.CopyRule(rule2)
- newRule2.Title = newRule1.Title
-
- err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{
- Existing: rule2,
- New: *newRule2,
- },
- })
- require.ErrorIs(t, err, models.ErrAlertRuleUniqueConstraintViolation)
- require.NotEqual(t, newRule2.UID, "")
- require.NotEqual(t, newRule2.Title, "")
- require.NotEqual(t, newRule2.NamespaceUID, "")
- require.ErrorContains(t, err, newRule2.UID)
- require.ErrorContains(t, err, newRule2.Title)
- require.ErrorContains(t, err, newRule2.NamespaceUID)
- })
-}
-
func TestIntegration_GetAlertRulesForScheduling(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
@@ -1078,18 +828,11 @@ func TestIntegrationInsertAlertRules(t *testing.T) {
_, err = store.InsertAlertRules(context.Background(), &usr, []models.AlertRule{rules[0]})
require.ErrorIs(t, err, models.ErrAlertRuleConflictBase)
})
- t.Run("fail insert rules with the same title in a folder", func(t *testing.T) {
+ t.Run("should not fail insert rules with the same title in a folder", func(t *testing.T) {
cp := models.CopyRule(&rules[0])
cp.UID = cp.UID + "-new"
_, err = store.InsertAlertRules(context.Background(), &usr, []models.AlertRule{*cp})
- require.ErrorIs(t, err, models.ErrAlertRuleConflictBase)
- require.ErrorIs(t, err, models.ErrAlertRuleUniqueConstraintViolation)
- require.NotEqual(t, rules[0].UID, "")
- require.NotEqual(t, rules[0].Title, "")
- require.NotEqual(t, rules[0].NamespaceUID, "")
- require.ErrorContains(t, err, rules[0].UID)
- require.ErrorContains(t, err, rules[0].Title)
- require.ErrorContains(t, err, rules[0].NamespaceUID)
+ require.NoError(t, err)
})
t.Run("should not let insert rules with the same UID", func(t *testing.T) {
cp := models.CopyRule(&rules[0])
@@ -2078,6 +1821,15 @@ func TestIntegration_ListDeletedRules(t *testing.T) {
store := createTestStore(sqlStore, folderService, &logtest.Fake{}, cfg.UnifiedAlerting, b)
store.FeatureToggles = featuremgmt.WithFeatures(featuremgmt.FlagAlertRuleRestore)
+ oldT := TimeNow
+ t.Cleanup(func() {
+ TimeNow = oldT
+ })
+ clk := clock.NewMock()
+ TimeNow = func() time.Time {
+ return clk.Now()
+ }
+
orgID := int64(1)
gen := models.RuleGen
gen = gen.With(gen.WithIntervalMatching(store.Cfg.BaseInterval), gen.WithOrgID(orgID))
@@ -2087,6 +1839,7 @@ func TestIntegration_ListDeletedRules(t *testing.T) {
rule, err := store.GetAlertRuleByUID(context.Background(), &models.GetAlertRuleByUIDQuery{UID: result[0].UID})
require.NoError(t, err)
+ clk.Add(1 * time.Hour)
rule2 := models.CopyRule(rule, gen.WithTitle(util.GenerateShortUID()))
err = store.UpdateAlertRules(context.Background(), &models.AlertingUserUID, []models.UpdateRule{
{
@@ -2108,7 +1861,8 @@ func TestIntegration_ListDeletedRules(t *testing.T) {
require.Empty(t, list)
})
- err = store.DeleteAlertRulesByUID(context.Background(), orgID, &models.AlertingUserUID, false, rule.UID)
+ clk.Add(1 * time.Hour)
+ err = store.DeleteAlertRulesByUID(context.Background(), orgID, util.Pointer(models.UserUID("test")), false, rule.UID)
require.NoError(t, err)
t.Run("should return the last deleted rule", func(t *testing.T) {
@@ -2116,7 +1870,9 @@ func TestIntegration_ListDeletedRules(t *testing.T) {
require.NoError(t, err)
require.Len(t, list, 1)
assert.Empty(t, list[0].UID)
- assert.Empty(t, rule2.Diff(list[0], "ID", "UID", "DashboardUID", "PanelID"))
+ assert.Empty(t, rule2.Diff(list[0], "ID", "UID", "DashboardUID", "PanelID", "Updated", "UpdatedBy")) // ignore updated because it's not
+ assert.Equal(t, list[0].Updated.UTC(), clk.Now().UTC())
+ assert.EqualValues(t, list[0].UpdatedBy, util.Pointer(models.UserUID("test")))
})
}
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/ngalert/writer/prom.go b/pkg/services/ngalert/writer/prom.go
index 5ec05f56571..40648e65628 100644
--- a/pkg/services/ngalert/writer/prom.go
+++ b/pkg/services/ngalert/writer/prom.go
@@ -24,12 +24,80 @@ import (
const backendType = "prometheus"
const (
- // Fixed error messages
- MimirDuplicateTimestampError = "err-mimir-sample-duplicate-timestamp"
- MimirInvalidLabelError = "err-mimir-label-invalid"
- MimirLabelValueTooLongError = "err-mimir-label-value-too-long"
- MimirMaxLabelNamesPerSeriesError = "err-mimir-max-label-names-per-series"
- MimirMaxSeriesPerUserError = "err-mimir-max-series-per-user"
+ // NOTE: Mimir errors were copied from globalerror package:
+ // https://github.com/grafana/mimir/blob/1ff367ef58987cd1941de03a8d6923fde82dfdd3/pkg/util/globalerror/user.go
+ // Variable names have been standardized as Mimir+{globalerror.ID}+Error for consistency
+ // We could consider importing those directly from mimir or moving them to a shared package
+ // Other than that, error codes are mapped in errorCauseToHTTPStatusCode (distributor package):
+ // https://github.com/grafana/mimir/blob/1ff367ef58987cd1941de03a8d6923fde82dfdd3/pkg/distributor/errors.go#L301-L301
+ // The following causes are mapped to Bad Request (400):
+ // - mimirpb.TOO_MANY_CLUSTERS:
+ // - mimirpb.BAD_DATA:
+ // - mimirpb.TENANT_LIMIT:
+
+ // Handler checks for write message size limits
+ // https://github.com/grafana/mimir/blob/1ff367ef58987cd1941de03a8d6923fde82dfdd3/pkg/distributor/push.go#L92-L92
+ MimirDistributorMaxWriteMessageSizeError = "err-mimir-distributor-max-write-message-size"
+ MimirDistributorMaxWriteRequestDataItemSizeError = "err-mimir-distributor-max-write-request-data-item-size"
+
+ // Distributor.prePushValidationMiddleware calls: 1. validateLabels, 2. validateSamples, 3. validateHistograms,
+ // 4. validateExamplars, 5. cleanAndValidateMetadata, then 6. checks for ingestion rate limits
+ // 1. validateLabel errors
+ // https://github.com/grafana/mimir/blob/1ff367ef58987cd1941de03a8d6923fde82dfdd3/pkg/distributor/validate.go#L402-L402
+ MimirInvalidMetricNameError = "err-mimir-metric-name-invalid"
+ MimirMaxLabelNamesPerInfoSeriesError = "err-mimir-max-label-names-per-info-series"
+ MimirMaxLabelNamesPerSeriesError = "err-mimir-max-label-names-per-series"
+ MimirMissingMetricNameError = "err-mimir-missing-metric-name"
+ MimirSeriesInvalidLabelError = "err-mimir-label-invalid"
+ MimirSeriesInvalidLabelValueError = "err-mimir-label-value-invalid"
+ MimirSeriesLabelNameTooLongError = "err-mimir-label-name-too-long"
+ MimirSeriesLabelValueTooLongError = "err-mimir-label-value-too-long"
+ MimirSeriesWithDuplicateLabelNamesError = "err-mimir-duplicate-label-names"
+
+ // 2. validateSamples errors
+ MimirSampleTooFarInFutureError = "err-mimir-too-far-in-future"
+ MimirSampleTooFarInPastError = "err-mimir-too-far-in-past"
+
+ // 3. validateHistograms
+ MimirInvalidSchemaNativeHistogramError = "err-mimir-invalid-native-histogram-schema"
+ MimirMaxNativeHistogramBucketsError = "err-mimir-max-native-histogram-buckets"
+ MimirNotReducibleNativeHistogramError = "err-mimir-not-reducible-native-histogram"
+
+ // 4. validateExemplars
+ MimirExemplarLabelsMissingError = "err-mimir-exemplar-labels-missing"
+ MimirExemplarLabelsTooLongError = "err-mimir-exemplar-labels-too-long"
+ MimirExemplarTimestampInvalidError = "err-mimir-exemplar-timestamp-invalid"
+
+ // 5. cleanAndValidateMetadata errors
+ // https://github.com/grafana/mimir/blob/1ff367ef58987cd1941de03a8d6923fde82dfdd3/pkg/distributor/validate.go#L491-L491
+ MimirMetricMetadataMetricNameTooLongError = "err-mimir-metric-name-too-long"
+ MimirMetricMetadataMissingMetricNameError = "err-mimir-metadata-missing-metric-name"
+ MimirMetricMetadataUnitTooLongError = "err-mimir-unit-too-long"
+
+ // 6. ingestion rate limited error
+ // https://github.com/grafana/mimir/blob/1ff367ef58987cd1941de03a8d6923fde82dfdd3/pkg/distributor/distributor.go#L1317-L1317
+ // https://github.com/grafana/mimir/blob/1ff367ef58987cd1941de03a8d6923fde82dfdd3/pkg/distributor/distributor.go#L1324-L1324
+ MimirIngestionRateLimitedError = "err-mimir-tenant-max-ingestion-rate"
+
+ // Ingester.PushWithCleanup errors
+ // https://github.com/grafana/mimir/blob/1ff367ef58987cd1941de03a8d6923fde82dfdd3/pkg/ingester/ingester.go#L1254-L1254
+ MimirExemplarSeriesMissingError = "err-mimir-exemplar-series-missing"
+ MimirExemplarTooFarInFutureError = "err-mimir-exemplar-too-far-in-future"
+ MimirExemplarTooFarInPastError = "err-mimir-exemplar-too-far-in-past"
+ MimirMaxMetadataPerMetricError = "err-mimir-max-metadata-per-metric"
+ MimirMaxMetadataPerUserError = "err-mimir-max-metadata-per-user"
+ MimirMaxSeriesPerMetricError = "err-mimir-max-series-per-metric"
+ MimirMaxSeriesPerUserError = "err-mimir-max-series-per-user"
+ MimirNativeHistogramCountMismatchError = "err-mimir-native-histogram-count-mismatch"
+ MimirNativeHistogramCountNotBigEnoughError = "err-mimir-native-histogram-count-not-big-enough"
+ MimirNativeHistogramNegativeBucketCountError = "err-mimir-native-histogram-negative-bucket-count"
+ MimirNativeHistogramOOODisabledError = "err-mimir-native-histogram-ooo-disabled"
+ MimirNativeHistogramSpanNegativeOffsetError = "err-mimir-native-histogram-span-negative-offset"
+ MimirNativeHistogramSpansBucketsMismatchError = "err-mimir-native-histogram-spans-buckets-mismatch"
+ MimirSampleDuplicateTimestampError = "err-mimir-sample-duplicate-timestamp"
+ MimirSampleOutOfOrderError = "err-mimir-sample-out-of-order"
+ MimirSampleTimestampTooOldError = "err-mimir-sample-timestamp-too-old"
+ MimirTooManyHAClustersError = "err-mimir-tenant-too-many-ha-clusters"
// Best effort error messages
PrometheusDuplicateTimestampError = "duplicate sample for timestamp"
@@ -41,12 +109,56 @@ var (
// Expected, user-level write errors like trying to write an invalid series.
ErrRejectedWrite = errors.New("series was rejected")
ErrBadFrame = errors.New("failed to read dataframe")
-)
-var DuplicateTimestampErrors = [...]string{
- MimirDuplicateTimestampError,
- PrometheusDuplicateTimestampError,
-}
+ // IgnoredErrors don't cause the Write to fail, but are still logged.
+ IgnoredErrors = []string{
+ MimirSampleDuplicateTimestampError,
+ PrometheusDuplicateTimestampError,
+ }
+
+ // ExpectedErrors are user-level write errors like trying to write an invalid series.
+ ExpectedErrors = []string{
+ MimirDistributorMaxWriteMessageSizeError,
+ MimirDistributorMaxWriteRequestDataItemSizeError,
+ MimirExemplarLabelsMissingError,
+ MimirExemplarLabelsTooLongError,
+ MimirExemplarSeriesMissingError,
+ MimirExemplarTimestampInvalidError,
+ MimirExemplarTooFarInFutureError,
+ MimirExemplarTooFarInPastError,
+ MimirIngestionRateLimitedError,
+ MimirInvalidMetricNameError,
+ MimirInvalidSchemaNativeHistogramError,
+ MimirMaxLabelNamesPerInfoSeriesError,
+ MimirMaxLabelNamesPerSeriesError,
+ MimirMaxMetadataPerMetricError,
+ MimirMaxMetadataPerUserError,
+ MimirMaxNativeHistogramBucketsError,
+ MimirMaxSeriesPerMetricError,
+ MimirMaxSeriesPerUserError,
+ MimirMetricMetadataMetricNameTooLongError,
+ MimirMetricMetadataMissingMetricNameError,
+ MimirMetricMetadataUnitTooLongError,
+ MimirMissingMetricNameError,
+ MimirNativeHistogramCountMismatchError,
+ MimirNativeHistogramCountNotBigEnoughError,
+ MimirNativeHistogramNegativeBucketCountError,
+ MimirNativeHistogramOOODisabledError,
+ MimirNativeHistogramSpanNegativeOffsetError,
+ MimirNativeHistogramSpansBucketsMismatchError,
+ MimirNotReducibleNativeHistogramError,
+ MimirSampleOutOfOrderError,
+ MimirSampleTimestampTooOldError,
+ MimirSampleTooFarInFutureError,
+ MimirSampleTooFarInPastError,
+ MimirSeriesInvalidLabelError,
+ MimirSeriesInvalidLabelValueError,
+ MimirSeriesLabelNameTooLongError,
+ MimirSeriesLabelValueTooLongError,
+ MimirSeriesWithDuplicateLabelNamesError,
+ MimirTooManyHAClustersError,
+ }
+)
// Metric represents a Prometheus time series metric.
type Metric struct {
@@ -296,19 +408,17 @@ func checkWriteError(writeErr promremote.WriteError) (err error, ignored bool) {
msg := writeErr.Error()
// HA may potentially write different values for the same timestamp, so we ignore this error
// TODO: this may not be needed, further testing needed
- for _, e := range DuplicateTimestampErrors {
+ for _, e := range IgnoredErrors {
if strings.Contains(msg, e) {
return nil, true
}
}
// Check for expected user errors.
- switch {
- case strings.Contains(msg, MimirInvalidLabelError),
- strings.Contains(msg, MimirMaxSeriesPerUserError),
- strings.Contains(msg, MimirMaxLabelNamesPerSeriesError),
- strings.Contains(msg, MimirLabelValueTooLongError):
- return errors.Join(ErrRejectedWrite, writeErr), false
+ for _, e := range ExpectedErrors {
+ if strings.Contains(msg, e) {
+ return errors.Join(ErrRejectedWrite, writeErr), false
+ }
}
// For now, all 400s that are not previously known are considered unexpected.
diff --git a/pkg/services/ngalert/writer/prom_test.go b/pkg/services/ngalert/writer/prom_test.go
index c300d700d93..862cd5c339f 100644
--- a/pkg/services/ngalert/writer/prom_test.go
+++ b/pkg/services/ngalert/writer/prom_test.go
@@ -191,7 +191,7 @@ func TestPrometheusWriter_Write(t *testing.T) {
})
t.Run("ignores client error when status code is 400 and message contains duplicate timestamp error", func(t *testing.T) {
- for _, msg := range DuplicateTimestampErrors {
+ for _, msg := range IgnoredErrors {
t.Run(msg, func(t *testing.T) {
clientErr := testClientWriteError{
statusCode: http.StatusBadRequest,
@@ -208,7 +208,7 @@ func TestPrometheusWriter_Write(t *testing.T) {
})
t.Run("bad labels fit under the client error category", func(t *testing.T) {
- msg := MimirInvalidLabelError
+ msg := MimirSeriesInvalidLabelError
clientErr := testClientWriteError{
statusCode: http.StatusBadRequest,
msg: &msg,
diff --git a/pkg/services/pluginsintegration/loader/loader_test.go b/pkg/services/pluginsintegration/loader/loader_test.go
index 016e1e4dc26..95ab7d70af5 100644
--- a/pkg/services/pluginsintegration/loader/loader_test.go
+++ b/pkg/services/pluginsintegration/loader/loader_test.go
@@ -89,6 +89,7 @@ func TestLoader_Load(t *testing.T) {
},
Links: []plugins.InfoLink{
{Name: "Raise issue", URL: "https://github.com/grafana/grafana/issues/new"},
+ {Name: "Documentation", URL: "https://grafana.com/docs/grafana/latest/datasources/aws-cloudwatch/"},
},
},
Includes: []*plugins.Includes{
diff --git a/pkg/services/pluginsintegration/pipeline/pipeline.go b/pkg/services/pluginsintegration/pipeline/pipeline.go
index 458e02694bd..f43f95c3268 100644
--- a/pkg/services/pluginsintegration/pipeline/pipeline.go
+++ b/pkg/services/pluginsintegration/pipeline/pipeline.go
@@ -27,7 +27,7 @@ func ProvideDiscoveryStage(cfg *config.PluginManagementCfg, pf finder.Finder, pr
FindFunc: pf.Find,
FindFilterFuncs: []discovery.FindFilterFunc{
discovery.NewPermittedPluginTypesFilterStep([]plugins.Type{
- plugins.TypeDataSource, plugins.TypeApp, plugins.TypePanel, plugins.TypeSecretsManager,
+ plugins.TypeDataSource, plugins.TypeApp, plugins.TypePanel,
}),
func(ctx context.Context, _ plugins.Class, b []*plugins.FoundBundle) ([]*plugins.FoundBundle, error) {
return NewDuplicatePluginIDFilterStep(pr).Filter(ctx, b)
diff --git a/pkg/services/pluginsintegration/pluginsintegration.go b/pkg/services/pluginsintegration/pluginsintegration.go
index da0319b3d7f..afe0ae6c5b4 100644
--- a/pkg/services/pluginsintegration/pluginsintegration.go
+++ b/pkg/services/pluginsintegration/pluginsintegration.go
@@ -70,7 +70,6 @@ var WireSet = wire.NewSet(
wire.Bind(new(pluginconfig.PluginRequestConfigProvider), new(*pluginconfig.RequestConfigProvider)),
pluginstore.ProvideService,
wire.Bind(new(pluginstore.Store), new(*pluginstore.Service)),
- wire.Bind(new(plugins.SecretsPluginManager), new(*pluginstore.Service)),
wire.Bind(new(plugins.StaticRouteResolver), new(*pluginstore.Service)),
process.ProvideService,
wire.Bind(new(process.Manager), new(*process.Service)),
diff --git a/pkg/services/pluginsintegration/pluginstore/store.go b/pkg/services/pluginsintegration/pluginstore/store.go
index e4d188572d2..20ef15379cf 100644
--- a/pkg/services/pluginsintegration/pluginstore/store.go
+++ b/pkg/services/pluginsintegration/pluginstore/store.go
@@ -94,15 +94,6 @@ func (s *Service) Plugins(ctx context.Context, pluginTypes ...plugins.Type) []Pl
return pluginsList
}
-func (s *Service) SecretsManager(ctx context.Context) *plugins.Plugin {
- for _, p := range s.availablePlugins(ctx) {
- if p.IsSecretsManager() {
- return p
- }
- }
- return nil
-}
-
// plugin finds a plugin with `pluginID` from the registry that is not decommissioned
func (s *Service) plugin(ctx context.Context, pluginID string) (*plugins.Plugin, bool) {
p, exists := s.pluginRegistry.Plugin(ctx, pluginID, "") // version is not required since Grafana only supports single versions of a plugin
diff --git a/pkg/services/pluginsintegration/pluginstore/store_test.go b/pkg/services/pluginsintegration/pluginstore/store_test.go
index 587d7e6d68c..cead243e33e 100644
--- a/pkg/services/pluginsintegration/pluginstore/store_test.go
+++ b/pkg/services/pluginsintegration/pluginstore/store_test.go
@@ -120,7 +120,6 @@ func TestStore_Routes(t *testing.T) {
t.Run("Routes returns all static routes for non-decommissioned plugins", func(t *testing.T) {
p1 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "a-test-renderer", Type: plugins.TypeRenderer}, FS: fakes.NewFakePluginFS("/some/dir")}
p2 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "b-test-panel", Type: plugins.TypePanel}, FS: fakes.NewFakePluginFS("/grafana/")}
- p3 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "c-test-secrets", Type: plugins.TypeSecretsManager}, FS: fakes.NewFakePluginFS("./secrets"), Class: plugins.ClassCore}
p4 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "d-test-datasource", Type: plugins.TypeDataSource}, FS: fakes.NewFakePluginFS("../test")}
p5 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "e-test-app", Type: plugins.TypeApp}, FS: fakes.NewFakePluginFS("any/path")}
p6 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "f-test-app", Type: plugins.TypeApp}}
@@ -130,7 +129,6 @@ func TestStore_Routes(t *testing.T) {
Store: map[string]*plugins.Plugin{
p1.ID: p1,
p2.ID: p2,
- p3.ID: p3,
p4.ID: p4,
p5.ID: p5,
p6.ID: p6,
@@ -146,27 +144,6 @@ func TestStore_Routes(t *testing.T) {
})
}
-func TestStore_SecretsManager(t *testing.T) {
- t.Run("Renderer returns a single (non-decommissioned) secrets manager plugin", func(t *testing.T) {
- p1 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "test-renderer", Type: plugins.TypeRenderer}}
- p2 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "test-panel", Type: plugins.TypePanel}}
- p3 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "test-secrets", Type: plugins.TypeSecretsManager}}
- p4 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "test-datasource", Type: plugins.TypeDataSource}}
-
- ps := New(&fakes.FakePluginRegistry{
- Store: map[string]*plugins.Plugin{
- p1.ID: p1,
- p2.ID: p2,
- p3.ID: p3,
- p4.ID: p4,
- },
- }, &fakes.FakeLoader{})
-
- r := ps.SecretsManager(context.Background())
- require.Equal(t, p3, r)
- })
-}
-
func TestProcessManager_shutdown(t *testing.T) {
p := &plugins.Plugin{JSONData: plugins.JSONData{ID: "test-datasource", Type: plugins.TypeDataSource}} // Backend: true
backend := &fakes.FakeBackendPlugin{}
diff --git a/pkg/services/pluginsintegration/renderer/renderer_test.go b/pkg/services/pluginsintegration/renderer/renderer_test.go
index 2f7f5d0c913..755622d2e52 100644
--- a/pkg/services/pluginsintegration/renderer/renderer_test.go
+++ b/pkg/services/pluginsintegration/renderer/renderer_test.go
@@ -39,7 +39,7 @@ func TestRenderer(t *testing.T) {
r, exists := m.Renderer(context.Background())
require.False(t, exists)
- require.Equal(t, 4, numLoaded)
+ require.Equal(t, 3, numLoaded)
require.Equal(t, 0, numUnloaded)
require.Nil(t, r)
})
diff --git a/pkg/services/pluginsintegration/renderer/testdata/plugins/secrets-manager/plugin.json b/pkg/services/pluginsintegration/renderer/testdata/plugins/secrets-manager/plugin.json
deleted file mode 100644
index 635a3ff47b9..00000000000
--- a/pkg/services/pluginsintegration/renderer/testdata/plugins/secrets-manager/plugin.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "type": "secretsmanager"
-}
diff --git a/pkg/services/pluginsintegration/sandbox/sandbox.go b/pkg/services/pluginsintegration/sandbox/sandbox.go
index 4ec5ac27c69..cf82d25e71a 100644
--- a/pkg/services/pluginsintegration/sandbox/sandbox.go
+++ b/pkg/services/pluginsintegration/sandbox/sandbox.go
@@ -1,9 +1,13 @@
package sandbox
-import "github.com/grafana/grafana/pkg/setting"
+import (
+ "context"
+
+ "github.com/grafana/grafana/pkg/setting"
+)
type Sandbox interface {
- Plugins() ([]string, error)
+ Plugins(ctx context.Context) ([]string, error)
}
type Service struct {
@@ -16,6 +20,6 @@ func ProvideService(cfg *setting.Cfg) *Service {
}
}
-func (s *Service) Plugins() ([]string, error) {
+func (s *Service) Plugins(ctx context.Context) ([]string, error) {
return s.cfg.EnableFrontendSandboxForPlugins, nil
}
diff --git a/pkg/services/pluginsintegration/sandbox/sandbox_test.go b/pkg/services/pluginsintegration/sandbox/sandbox_test.go
index b318ae19978..1b271647db2 100644
--- a/pkg/services/pluginsintegration/sandbox/sandbox_test.go
+++ b/pkg/services/pluginsintegration/sandbox/sandbox_test.go
@@ -1,6 +1,7 @@
package sandbox
import (
+ "context"
"testing"
"github.com/grafana/grafana/pkg/setting"
@@ -13,7 +14,7 @@ func TestService_Plugins(t *testing.T) {
}
service := ProvideService(cfg)
- plugins, err := service.Plugins()
+ plugins, err := service.Plugins(context.Background())
assert.NoError(t, err)
assert.Equal(t, []string{"plugin1", "plugin2"}, plugins)
}
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/secrets/kvstore/kvstore.go b/pkg/services/secrets/kvstore/kvstore.go
index 50a37421f45..2025c7c3118 100644
--- a/pkg/services/secrets/kvstore/kvstore.go
+++ b/pkg/services/secrets/kvstore/kvstore.go
@@ -5,13 +5,8 @@ import (
"time"
"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/plugins"
- "github.com/grafana/grafana/pkg/plugins/backendplugin/secretsmanagerplugin"
- "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/secrets"
- "github.com/grafana/grafana/pkg/setting"
)
const (
@@ -22,45 +17,8 @@ const (
func ProvideService(
sqlStore db.DB,
secretsService secrets.Service,
- pluginsManager plugins.SecretsPluginManager,
- kvstore kvstore.KVStore,
- features featuremgmt.FeatureToggles,
- cfg *setting.Cfg,
) (SecretsKVStore, error) {
- var logger = log.New("secrets.kvstore")
- var store SecretsKVStore
- ctx := context.Background()
- store = NewSQLSecretsKVStore(sqlStore, secretsService, logger)
- err := EvaluateRemoteSecretsPlugin(ctx, pluginsManager, cfg)
- if err != nil {
- logger.Debug("secrets manager evaluator returned false", "reason", err.Error())
- } else {
- // Attempt to start the plugin
- var secretsPlugin secretsmanagerplugin.SecretsManagerPlugin
- secretsPlugin, err = StartAndReturnPlugin(pluginsManager, ctx)
- namespacedKVStore := GetNamespacedKVStore(kvstore)
- if err != nil || secretsPlugin == nil {
- logger.Error("failed to start remote secrets management plugin")
- if isFatal, readErr := IsPluginStartupErrorFatal(ctx, namespacedKVStore); isFatal || readErr != nil {
- // plugin error was fatal or there was an error determining if the error was fatal
- logger.Error("secrets management plugin is required to start -- exiting app")
- if readErr != nil {
- return nil, readErr
- }
- return nil, err
- }
- } else {
- // as the plugin is installed, SecretsKVStoreSQL is now replaced with
- // an instance of SecretsKVStorePlugin with the sql store as a fallback
- // (used for migration and in case a secret is not found).
- store = NewPluginSecretsKVStore(secretsPlugin, secretsService, namespacedKVStore, features, WithCache(store, 5*time.Second, 5*time.Minute), logger)
- }
- }
-
- if err != nil {
- logger.Debug("secrets kvstore is using the default (SQL) implementation for secrets management")
- }
-
+ store := NewSQLSecretsKVStore(sqlStore, secretsService, log.New("secrets.kvstore"))
return WithCache(store, 5*time.Second, 5*time.Minute), nil
}
diff --git a/pkg/services/secrets/kvstore/migrations/from_plugin_mig.go b/pkg/services/secrets/kvstore/migrations/from_plugin_mig.go
deleted file mode 100644
index d8232e9b229..00000000000
--- a/pkg/services/secrets/kvstore/migrations/from_plugin_mig.go
+++ /dev/null
@@ -1,108 +0,0 @@
-package migrations
-
-import (
- "context"
- "errors"
- "fmt"
-
- "github.com/grafana/grafana/pkg/infra/db"
- "github.com/grafana/grafana/pkg/infra/kvstore"
- "github.com/grafana/grafana/pkg/plugins"
- "github.com/grafana/grafana/pkg/plugins/backendplugin/secretsmanagerplugin"
- "github.com/grafana/grafana/pkg/services/secrets"
- secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore"
- "github.com/grafana/grafana/pkg/setting"
-)
-
-var errPluginUnavailable = errors.New("remote secret management plugin is unavailable")
-
-// MigrateFromPluginService This migrator will handle migration of the configured plugin secrets back to Grafana unified secrets
-type MigrateFromPluginService struct {
- cfg *setting.Cfg
- sqlStore db.DB
- secretsService secrets.Service
- manager plugins.SecretsPluginManager
- kvstore kvstore.KVStore
-}
-
-func ProvideMigrateFromPluginService(
- cfg *setting.Cfg,
- sqlStore db.DB,
- secretsService secrets.Service,
- manager plugins.SecretsPluginManager,
- kvstore kvstore.KVStore,
-
-) *MigrateFromPluginService {
- return &MigrateFromPluginService{
- cfg: cfg,
- sqlStore: sqlStore,
- secretsService: secretsService,
- manager: manager,
- kvstore: kvstore,
- }
-}
-
-func (s *MigrateFromPluginService) Migrate(ctx context.Context) error {
- logger.Debug("starting migration of plugin secrets to unified secrets")
- // access the plugin directly
- plugin, err := secretskvs.StartAndReturnPlugin(s.manager, context.Background())
- if err != nil {
- return errPluginUnavailable
- }
- // Get full list of secrets from the plugin
- res, err := plugin.GetAllSecrets(ctx, &secretsmanagerplugin.GetAllSecretsRequest{})
- if err != nil {
- logger.Error("Failed to retrieve all secrets from plugin")
- return err
- }
- totalSecrets := len(res.Items)
- logger.Debug("retrieved all secrets from plugin", "num secrets", totalSecrets)
- // create a secret sql store manually
- secretsSql := secretskvs.NewSQLSecretsKVStore(s.sqlStore, s.secretsService, logger)
- for i, item := range res.Items {
- logger.Debug(fmt.Sprintf("Migrating secret %d of %d", i+1, totalSecrets), "current", i+1, "secretCount", totalSecrets)
- // Add to sql store
- err = secretsSql.Set(ctx, item.Key.OrgId, item.Key.Namespace, item.Key.Type, item.Value)
- if err != nil {
- logger.Error("Error adding secret to unified secrets", "orgId", item.Key.OrgId,
- "namespace", item.Key.Namespace, "type", item.Key.Type)
- return err
- }
- }
-
- for i, item := range res.Items {
- logger.Debug(fmt.Sprintf("Cleaning secret %d of %d", i+1, totalSecrets), "current", i+1, "secretCount", totalSecrets)
- // Delete from the plugin
- _, err := plugin.DeleteSecret(ctx, &secretsmanagerplugin.DeleteSecretRequest{
- KeyDescriptor: &secretsmanagerplugin.Key{
- OrgId: item.Key.OrgId,
- Namespace: item.Key.Namespace,
- Type: item.Key.Type,
- }})
- if err != nil {
- logger.Error("Error deleting secret from plugin after migration", "orgId", item.Key.OrgId,
- "namespace", item.Key.Namespace, "type", item.Key.Type)
- continue
- }
- }
- logger.Debug("Completed migration of secrets from plugin")
-
- // The plugin is no longer needed at the moment
- err = secretskvs.SetPluginStartupErrorFatal(ctx, secretskvs.GetNamespacedKVStore(s.kvstore), false)
- if err != nil {
- logger.Error("Failed to remove plugin error fatal flag", "error", err.Error())
- }
- // Reset the fatal flag setter in case another secret is created on the plugin
- secretskvs.ResetPlugin()
-
- logger.Debug("Shutting down secrets plugin now that migration is complete")
- // if `use_plugin` wasn't set, stop the plugin after migration
- if !s.cfg.SectionWithEnvOverrides("secrets").Key("use_plugin").MustBool(false) {
- err := s.manager.SecretsManager(ctx).Stop(ctx)
- if err != nil {
- // Log a warning but don't throw an error
- logger.Error("Error stopping secrets plugin after migration", "error", err.Error())
- }
- }
- return nil
-}
diff --git a/pkg/services/secrets/kvstore/migrations/from_plugin_mig_test.go b/pkg/services/secrets/kvstore/migrations/from_plugin_mig_test.go
deleted file mode 100644
index 7181fa168ac..00000000000
--- a/pkg/services/secrets/kvstore/migrations/from_plugin_mig_test.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package migrations
-
-import (
- "context"
- "testing"
-
- "github.com/stretchr/testify/require"
-
- "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/plugins/backendplugin/secretsmanagerplugin"
- "github.com/grafana/grafana/pkg/services/secrets/fakes"
- secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore"
- secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager"
- "github.com/grafana/grafana/pkg/setting"
-)
-
-// This tests will create a mock sql database and an inmemory
-// implementation of the secret manager to simulate the plugin.
-func TestPluginSecretMigrationService_MigrateFromPlugin(t *testing.T) {
- ctx := context.Background()
-
- t.Run("migrate secrets from secrets plugin to Grafana", func(t *testing.T) {
- // --- SETUP
- migratorService, plugin, sqlStore := setupTestMigrateFromPluginService(t)
-
- addSecretToPluginStore(t, plugin, ctx, 1, "secret-1", "bogus", "value-1")
- addSecretToPluginStore(t, plugin, ctx, 1, "secret-2", "bogus", "value-2")
-
- // --- EXECUTION
- err := migratorService.Migrate(ctx)
- require.NoError(t, err)
-
- // --- VALIDATIONS
- validatePluginSecretsWereDeleted(t, plugin, ctx)
-
- validateSecretWasStoredInSql(t, sqlStore, ctx, 1, "secret-1", "bogus", "value-1")
- validateSecretWasStoredInSql(t, sqlStore, ctx, 1, "secret-2", "bogus", "value-2")
- })
-}
-
-// Set up services used in migration
-func setupTestMigrateFromPluginService(t *testing.T) (*MigrateFromPluginService, secretsmanagerplugin.SecretsManagerPlugin, *secretskvs.SecretsKVStoreSQL) {
- t.Helper()
-
- // this is to init the sql secret store inside the migration
- sqlStore := db.InitTestDB(t)
- secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- manager := secretskvs.NewFakeSecretsPluginManager(t, false)
- migratorService := ProvideMigrateFromPluginService(
- setting.NewCfg(),
- sqlStore,
- secretsService,
- manager,
- kvstore.ProvideService(sqlStore),
- )
-
- secretsSql := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
-
- return migratorService, manager.SecretsManager(context.Background()).SecretsManager, secretsSql
-}
-
-func addSecretToPluginStore(t *testing.T, plugin secretsmanagerplugin.SecretsManagerPlugin, ctx context.Context, orgId int64, namespace string, typ string, value string) {
- t.Helper()
- _, err := plugin.SetSecret(ctx, &secretsmanagerplugin.SetSecretRequest{
- KeyDescriptor: &secretsmanagerplugin.Key{
- OrgId: orgId,
- Namespace: namespace,
- Type: typ,
- },
- Value: value,
- })
- require.NoError(t, err)
-}
-
-// validates that secrets on the plugin were deleted
-func validatePluginSecretsWereDeleted(t *testing.T, plugin secretsmanagerplugin.SecretsManagerPlugin, ctx context.Context) {
- t.Helper()
- res, err := plugin.GetAllSecrets(ctx, &secretsmanagerplugin.GetAllSecretsRequest{})
- require.NoError(t, err)
- require.Equal(t, 0, len(res.Items))
-}
-
-// validates that secrets are in sql
-func validateSecretWasStoredInSql(t *testing.T, sqlStore *secretskvs.SecretsKVStoreSQL, ctx context.Context, orgId int64, namespace string, typ string, expectedValue string) {
- t.Helper()
- res, exists, err := sqlStore.Get(ctx, orgId, namespace, typ)
- require.NoError(t, err)
- require.True(t, exists)
- require.Equal(t, expectedValue, res)
-}
diff --git a/pkg/services/secrets/kvstore/migrations/migrator.go b/pkg/services/secrets/kvstore/migrations/migrator.go
index 3c2671327ca..7cfce10183a 100644
--- a/pkg/services/secrets/kvstore/migrations/migrator.go
+++ b/pkg/services/secrets/kvstore/migrations/migrator.go
@@ -8,7 +8,6 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/registry"
- "github.com/grafana/grafana/pkg/setting"
)
var logger = log.New("secret.migration")
@@ -22,40 +21,20 @@ type SecretMigrationService interface {
type SecretMigrationProvider interface {
registry.BackgroundService
- TriggerPluginMigration(ctx context.Context, toPlugin bool) error
}
type SecretMigrationProviderImpl struct {
- services []SecretMigrationService
- ServerLockService *serverlock.ServerLockService
- migrateToPluginService *MigrateToPluginService
- migrateFromPluginService *MigrateFromPluginService
+ services []SecretMigrationService
+ ServerLockService *serverlock.ServerLockService
}
func ProvideSecretMigrationProvider(
- cfg *setting.Cfg,
serverLockService *serverlock.ServerLockService,
dataSourceSecretMigrationService *DataSourceSecretMigrationService,
- migrateToPluginService *MigrateToPluginService,
- migrateFromPluginService *MigrateFromPluginService,
) *SecretMigrationProviderImpl {
- services := make([]SecretMigrationService, 0)
- services = append(services, dataSourceSecretMigrationService)
- // Plugin migration should always be last; should either migrate to or from, not both
- // This is because the migrateTo checks for use_plugin = true, in which case we should always
- // migrate by default to ensure users don't lose access to secrets. If migration has
- // already occurred, the migrateTo function will be called but it won't do anything
- if cfg.SectionWithEnvOverrides("secrets").Key("migrate_from_plugin").MustBool(false) {
- services = append(services, migrateFromPluginService)
- } else {
- services = append(services, migrateToPluginService)
- }
-
return &SecretMigrationProviderImpl{
- ServerLockService: serverLockService,
- services: services,
- migrateToPluginService: migrateToPluginService,
- migrateFromPluginService: migrateFromPluginService,
+ ServerLockService: serverLockService,
+ services: []SecretMigrationService{dataSourceSecretMigrationService},
}
}
@@ -83,23 +62,3 @@ func (s *SecretMigrationProviderImpl) Migrate(ctx context.Context) error {
}
return nil
}
-
-// TriggerPluginMigration Kick off a migration to or from the plugin. This will block until all services have exited.
-func (s *SecretMigrationProviderImpl) TriggerPluginMigration(ctx context.Context, toPlugin bool) error {
- // Don't migrate if there is already one happening
- return s.ServerLockService.LockExecuteAndRelease(ctx, actionName, time.Minute*10, func(context.Context) {
- var err error
- if toPlugin {
- err = s.migrateToPluginService.Migrate(ctx)
- } else {
- err = s.migrateFromPluginService.Migrate(ctx)
- }
- if err != nil {
- direction := "from_plugin"
- if toPlugin {
- direction = "to_plugin"
- }
- logger.Error("Failed to migrate plugin secrets", "direction", direction, "error", err.Error())
- }
- })
-}
diff --git a/pkg/services/secrets/kvstore/migrations/to_plugin_mig.go b/pkg/services/secrets/kvstore/migrations/to_plugin_mig.go
deleted file mode 100644
index 5a0f0d4047b..00000000000
--- a/pkg/services/secrets/kvstore/migrations/to_plugin_mig.go
+++ /dev/null
@@ -1,120 +0,0 @@
-package migrations
-
-import (
- "context"
- "errors"
- "fmt"
-
- "github.com/grafana/grafana/pkg/infra/db"
- "github.com/grafana/grafana/pkg/infra/kvstore"
- "github.com/grafana/grafana/pkg/plugins"
- "github.com/grafana/grafana/pkg/services/secrets"
- secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore"
- "github.com/grafana/grafana/pkg/setting"
-)
-
-var errSecretStoreIsNotPlugin = errors.New("SecretsKVStore is not a SecretsKVStorePlugin")
-
-// MigrateToPluginService This migrator will handle migration of datasource secrets (aka Unified secrets)
-// into the plugin secrets configured
-type MigrateToPluginService struct {
- secretsStore secretskvs.SecretsKVStore
- cfg *setting.Cfg
- sqlStore db.DB
- secretsService secrets.Service
- kvstore kvstore.KVStore
- manager plugins.SecretsPluginManager
-}
-
-func ProvideMigrateToPluginService(
- secretsStore secretskvs.SecretsKVStore,
- cfg *setting.Cfg,
- sqlStore db.DB,
- secretsService secrets.Service,
- kvstore kvstore.KVStore,
- manager plugins.SecretsPluginManager,
-) *MigrateToPluginService {
- return &MigrateToPluginService{
- secretsStore: secretsStore,
- cfg: cfg,
- sqlStore: sqlStore,
- secretsService: secretsService,
- kvstore: kvstore,
- manager: manager,
- }
-}
-
-func (s *MigrateToPluginService) Migrate(ctx context.Context) error {
- err := secretskvs.EvaluateRemoteSecretsPlugin(ctx, s.manager, s.cfg)
- hasStarted := secretskvs.HasPluginStarted(ctx, s.manager)
- if err == nil && hasStarted {
- logger.Debug("starting migration of unified secrets to the plugin")
- // we need to get the fallback store since in this scenario the secrets store would be the plugin.
- tmpStore, err := secretskvs.GetUnwrappedStoreFromCache(s.secretsStore)
- if err != nil {
- tmpStore = s.secretsStore
- logger.Warn("secret store is not cached, this is unexpected - continuing migration anyway.")
- }
- pluginStore, ok := tmpStore.(*secretskvs.SecretsKVStorePlugin)
- if !ok {
- return errSecretStoreIsNotPlugin
- }
- fallbackStore := pluginStore.Fallback()
-
- // before we start migrating, check see if plugin startup failures were already fatal
- namespacedKVStore := secretskvs.GetNamespacedKVStore(s.kvstore)
- wasFatal, err := secretskvs.IsPluginStartupErrorFatal(ctx, namespacedKVStore)
- if err != nil {
- logger.Warn("unable to determine whether plugin startup failures are fatal - continuing migration anyway.")
- }
-
- var allSec []secretskvs.Item
- var totalSec int
- // during migration we need to have fallback enabled while we move secrets to plugin
- err = pluginStore.WithFallbackEnabled(func() error {
- // get all secrets in the fallback store
- allSec, err = fallbackStore.GetAll(ctx)
- if err != nil {
- return nil
- }
- totalSec := len(allSec)
- logger.Debug(fmt.Sprintf("Total amount of secrets to migrate: %d", totalSec))
-
- // We just set it again as the current secret store should be the plugin secret
- for i, sec := range allSec {
- logger.Debug(fmt.Sprintf("Migrating secret %d of %d", i+1, totalSec), "current", i+1, "secretCount", totalSec)
- err = pluginStore.Set(ctx, *sec.OrgId, *sec.Namespace, *sec.Type, sec.Value)
- if err != nil {
- return err
- }
- }
- return nil
- })
- if err != nil {
- return err
- }
-
- // as no err was returned, when we delete all the secrets from the sql store
- logger.Debug("migrated unified secrets to plugin", "number of secrets", totalSec)
- for index, sec := range allSec {
- logger.Debug(fmt.Sprintf("Cleaning secret %d of %d", index+1, totalSec), "current", index+1, "secretCount", totalSec)
-
- err = fallbackStore.Del(ctx, *sec.OrgId, *sec.Namespace, *sec.Type)
- if err != nil {
- logger.Error("plugin migrator encountered error while deleting unified secrets")
- if index == 0 && !wasFatal {
- // old unified secrets still exists, so plugin startup errors are still not fatal, unless they were before we started
- err := secretskvs.SetPluginStartupErrorFatal(ctx, namespacedKVStore, false)
- if err != nil {
- logger.Error("error reverting plugin failure fatal status", "error", err.Error())
- } else {
- logger.Debug("application will continue to function without the secrets plugin")
- }
- }
- return err
- }
- }
- logger.Debug("deleted unified secrets after migration", "number of secrets", totalSec)
- }
- return nil
-}
diff --git a/pkg/services/secrets/kvstore/migrations/to_plugin_mig_test.go b/pkg/services/secrets/kvstore/migrations/to_plugin_mig_test.go
deleted file mode 100644
index b2ad6dbf956..00000000000
--- a/pkg/services/secrets/kvstore/migrations/to_plugin_mig_test.go
+++ /dev/null
@@ -1,152 +0,0 @@
-package migrations
-
-import (
- "context"
- "errors"
- "testing"
- "time"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- "gopkg.in/ini.v1"
-
- "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/services/featuremgmt"
- "github.com/grafana/grafana/pkg/services/secrets/fakes"
- secretskvs "github.com/grafana/grafana/pkg/services/secrets/kvstore"
- secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager"
- "github.com/grafana/grafana/pkg/setting"
-)
-
-// This tests will create a mock sql database and an inmemory
-// implementation of the secret manager to simulate the plugin.
-func TestPluginSecretMigrationService_MigrateToPlugin(t *testing.T) {
- ctx := context.Background()
-
- t.Run("migration run ok - 2 secrets migrated", func(t *testing.T) {
- // --- SETUP
- migratorService, secretsStore, sqlSecretStore := setupTestMigrateToPluginService(t)
- var orgId int64 = 1
- namespace1, namespace2 := "namespace-test", "namespace-test2"
- typ := "type-test"
- value := "SUPER_SECRET"
-
- addSecretToSqlStore(t, sqlSecretStore, ctx, orgId, namespace1, typ, value)
- addSecretToSqlStore(t, sqlSecretStore, ctx, orgId, namespace2, typ, value)
-
- // --- EXECUTION
- err := migratorService.Migrate(ctx)
- require.NoError(t, err)
-
- // --- VALIDATIONS
- validateSqlSecretWasDeleted(t, sqlSecretStore, ctx, orgId, namespace1, typ)
- validateSqlSecretWasDeleted(t, sqlSecretStore, ctx, orgId, namespace2, typ)
-
- validateSecretWasStoredInPlugin(t, secretsStore, ctx, orgId, namespace1, typ)
- validateSecretWasStoredInPlugin(t, secretsStore, ctx, orgId, namespace1, typ)
- })
-}
-
-// With fatal flag unset, do a migration with backwards compatibility disabled. When unified secrets are deleted, return an error on the first deletion
-// Should result in the fatal flag remaining unset
-func TestFatalPluginErr_MigrationTestWithErrorDeletingUnifiedSecrets(t *testing.T) {
- p, err := secretskvs.SetupFatalCrashTest(t, false, false, true)
- assert.NoError(t, err)
-
- migration := setupTestMigratorServiceWithDeletionError(t, p.SecretsKVStore, &dbtest.FakeDB{
- ExpectedError: errors.New("random error"),
- }, p.KVStore)
- err = migration.Migrate(context.Background())
- assert.Error(t, err)
- assert.Equal(t, "mocked del error", err.Error())
-
- isFatal, err := secretskvs.IsPluginStartupErrorFatal(context.Background(), secretskvs.GetNamespacedKVStore(p.KVStore))
- assert.NoError(t, err)
- assert.False(t, isFatal)
-}
-
-func addSecretToSqlStore(t *testing.T, sqlSecretStore secretskvs.SecretsKVStore, ctx context.Context, orgId int64, namespace1 string, typ string, value string) {
- t.Helper()
- err := sqlSecretStore.Set(ctx, orgId, namespace1, typ, value)
- require.NoError(t, err)
-}
-
-// validates that secrets on the sql store were deleted.
-func validateSqlSecretWasDeleted(t *testing.T, sqlSecretStore secretskvs.SecretsKVStore, ctx context.Context, orgId int64, namespace1 string, typ string) {
- t.Helper()
- res, err := sqlSecretStore.Keys(ctx, orgId, namespace1, typ)
- require.NoError(t, err)
- require.Equal(t, 0, len(res))
-}
-
-// validates that secrets should be on the plugin
-func validateSecretWasStoredInPlugin(t *testing.T, secretsStore secretskvs.SecretsKVStore, ctx context.Context, orgId int64, namespace1 string, typ string) {
- t.Helper()
- resPlugin, err := secretsStore.Keys(ctx, orgId, namespace1, typ)
- require.NoError(t, err)
- require.Equal(t, 1, len(resPlugin))
-}
-
-// Set up services used in migration
-func setupTestMigrateToPluginService(t *testing.T) (*MigrateToPluginService, secretskvs.SecretsKVStore, secretskvs.SecretsKVStore) {
- t.Helper()
-
- rawCfg := `
- [secrets]
- use_plugin = true
- `
- raw, err := ini.Load([]byte(rawCfg))
- require.NoError(t, err)
- cfg := &setting.Cfg{Raw: raw}
- sqlStore := db.InitTestDB(t)
-
- // this would be the plugin - mocked at the moment
- fallbackStore := secretskvs.WithCache(secretskvs.NewFakeSQLSecretsKVStore(t, sqlStore), time.Minute*5, time.Minute*5)
- secretsStoreForPlugin := secretskvs.WithCache(secretskvs.NewFakePluginSecretsKVStore(t, featuremgmt.WithFeatures(), fallbackStore), time.Minute*5, time.Minute*5)
-
- // this is to init the sql secret store inside the migration
- secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- manager := secretskvs.NewFakeSecretsPluginManager(t, false)
- migratorService := ProvideMigrateToPluginService(
- secretsStoreForPlugin,
- cfg,
- sqlStore,
- secretsService,
- kvstore.ProvideService(sqlStore),
- manager,
- )
-
- return migratorService, secretsStoreForPlugin, fallbackStore
-}
-
-func setupTestMigratorServiceWithDeletionError(
- t *testing.T,
- secretskv secretskvs.SecretsKVStore,
- sqlStore db.DB,
- kvstore kvstore.KVStore,
-) *MigrateToPluginService {
- t.Helper()
- t.Cleanup(secretskvs.ResetPlugin)
- cfg := secretskvs.SetupTestConfig(t)
- secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- manager := secretskvs.NewFakeSecretsPluginManager(t, false)
- migratorService := ProvideMigrateToPluginService(
- secretskv,
- cfg,
- sqlStore,
- secretsService,
- kvstore,
- manager,
- )
- fallback := secretskvs.NewFakeSecretsKVStore()
- var orgId int64 = 1
- str := "random string"
- err := fallback.Set(context.Background(), orgId, str, str, "bogus")
- require.NoError(t, err)
- fallback.DeletionError(true)
- err = secretskvs.ReplaceFallback(t, secretskv, fallback)
- require.NoError(t, err)
- return migratorService
-}
diff --git a/pkg/services/secrets/kvstore/model.go b/pkg/services/secrets/kvstore/model.go
index 463fd7c2563..c89869d0afb 100644
--- a/pkg/services/secrets/kvstore/model.go
+++ b/pkg/services/secrets/kvstore/model.go
@@ -5,9 +5,7 @@ import (
)
const (
- QuitOnPluginStartupFailureKey = "quit_on_secrets_plugin_startup_failure"
- PluginNamespace = "secretsmanagerplugin"
- DataSourceSecretType = "datasource"
+ DataSourceSecretType = "datasource"
)
// Item stored in k/v store.
diff --git a/pkg/services/secrets/kvstore/plugin.go b/pkg/services/secrets/kvstore/plugin.go
deleted file mode 100644
index a944da822f2..00000000000
--- a/pkg/services/secrets/kvstore/plugin.go
+++ /dev/null
@@ -1,288 +0,0 @@
-package kvstore
-
-import (
- "context"
- "errors"
- "fmt"
- "sync"
-
- "github.com/grafana/grafana/pkg/infra/kvstore"
- "github.com/grafana/grafana/pkg/infra/log"
- "github.com/grafana/grafana/pkg/plugins"
- smp "github.com/grafana/grafana/pkg/plugins/backendplugin/secretsmanagerplugin"
- "github.com/grafana/grafana/pkg/services/datasources"
- "github.com/grafana/grafana/pkg/services/featuremgmt"
- "github.com/grafana/grafana/pkg/services/secrets"
- "github.com/grafana/grafana/pkg/setting"
-)
-
-var (
- fatalFlagOnce sync.Once
- startupOnce sync.Once
- errPluginDisabledByConfig = errors.New("remote secret management plugin disabled because the property `secrets.use_plugin` is not set to `true`")
- errPluginNotInstalled = errors.New("remote secret management plugin disabled because there is no installed plugin of type `secretsmanager`")
-)
-
-// SecretsKVStorePlugin provides a key/value store backed by the Grafana plugin gRPC interface
-type SecretsKVStorePlugin struct {
- sync.Mutex
- log log.Logger
- secretsPlugin smp.SecretsManagerPlugin
- secretsService secrets.Service
- kvstore *kvstore.NamespacedKVStore
- backwardsCompatibilityDisabled bool
- fallbackEnabled bool
- fallbackStore SecretsKVStore
-}
-
-func NewPluginSecretsKVStore(
- secretsPlugin smp.SecretsManagerPlugin,
- secretsService secrets.Service,
- kvstore *kvstore.NamespacedKVStore,
- features featuremgmt.FeatureToggles,
- fallback SecretsKVStore,
- logger log.Logger,
-) *SecretsKVStorePlugin {
- return &SecretsKVStorePlugin{
- secretsPlugin: secretsPlugin,
- secretsService: secretsService,
- log: logger,
- kvstore: kvstore,
- backwardsCompatibilityDisabled: features.IsEnabledGlobally(featuremgmt.FlagDisableSecretsCompatibility),
- fallbackStore: fallback,
- }
-}
-
-// Get an item from the store
-// If it is the first time a secret has been retrieved and backwards compatibility is disabled, mark plugin startup errors fatal
-func (kv *SecretsKVStorePlugin) Get(ctx context.Context, orgId int64, namespace string, typ string) (string, bool, error) {
- req := &smp.GetSecretRequest{
- KeyDescriptor: &smp.Key{
- OrgId: orgId,
- Namespace: namespace,
- Type: typ,
- },
- }
-
- res, err := kv.secretsPlugin.GetSecret(ctx, req)
- if res.UserFriendlyError != "" {
- err = wrapUserFriendlySecretError(res.UserFriendlyError)
- }
-
- if res.Exists {
- updateFatalFlag(ctx, kv)
- }
-
- if kv.fallbackEnabled {
- if err != nil || res.UserFriendlyError != "" || !res.Exists {
- res.DecryptedValue, res.Exists, err = kv.fallbackStore.Get(ctx, orgId, namespace, typ)
- }
- }
-
- return res.DecryptedValue, res.Exists, err
-}
-
-// Set an item in the store
-// If it is the first time a secret has been set and backwards compatibility is disabled, mark plugin startup errors fatal
-func (kv *SecretsKVStorePlugin) Set(ctx context.Context, orgId int64, namespace string, typ string, value string) error {
- req := &smp.SetSecretRequest{
- KeyDescriptor: &smp.Key{
- OrgId: orgId,
- Namespace: namespace,
- Type: typ,
- },
- Value: value,
- }
-
- res, err := kv.secretsPlugin.SetSecret(ctx, req)
- if err == nil && res.UserFriendlyError != "" {
- err = wrapUserFriendlySecretError(res.UserFriendlyError)
- }
-
- updateFatalFlag(ctx, kv)
-
- return err
-}
-
-// Del deletes an item from the store.
-func (kv *SecretsKVStorePlugin) Del(ctx context.Context, orgId int64, namespace string, typ string) error {
- req := &smp.DeleteSecretRequest{
- KeyDescriptor: &smp.Key{
- OrgId: orgId,
- Namespace: namespace,
- Type: typ,
- },
- }
-
- res, err := kv.secretsPlugin.DeleteSecret(ctx, req)
- if err == nil && res.UserFriendlyError != "" {
- err = wrapUserFriendlySecretError(res.UserFriendlyError)
- }
-
- return err
-}
-
-// Keys get all keys for a given namespace. To query for all
-// organizations the constant 'kvstore.AllOrganizations' can be passed as orgId.
-func (kv *SecretsKVStorePlugin) Keys(ctx context.Context, orgId int64, namespace string, typ string) ([]Key, error) {
- req := &smp.ListSecretsRequest{
- KeyDescriptor: &smp.Key{
- OrgId: orgId,
- Namespace: namespace,
- Type: typ,
- },
- AllOrganizations: orgId == AllOrganizations,
- }
-
- res, err := kv.secretsPlugin.ListSecrets(ctx, req)
- if err != nil {
- return nil, err
- } else if res.UserFriendlyError != "" {
- err = wrapUserFriendlySecretError(res.UserFriendlyError)
- }
-
- return parseKeys(res.Keys), err
-}
-
-// Rename an item in the store
-func (kv *SecretsKVStorePlugin) Rename(ctx context.Context, orgId int64, namespace string, typ string, newNamespace string) error {
- req := &smp.RenameSecretRequest{
- KeyDescriptor: &smp.Key{
- OrgId: orgId,
- Namespace: namespace,
- Type: typ,
- },
- NewNamespace: newNamespace,
- }
-
- res, err := kv.secretsPlugin.RenameSecret(ctx, req)
- if err == nil && res.UserFriendlyError != "" {
- err = wrapUserFriendlySecretError(res.UserFriendlyError)
- }
-
- return err
-}
-
-func (kv *SecretsKVStorePlugin) GetAll(ctx context.Context) ([]Item, error) {
- req := &smp.GetAllSecretsRequest{}
-
- res, err := kv.secretsPlugin.GetAllSecrets(ctx, req)
- if err != nil {
- return nil, err
- } else if res.UserFriendlyError != "" {
- err = wrapUserFriendlySecretError(res.UserFriendlyError)
- }
-
- return parseItems(res.Items), err
-}
-
-func (kv *SecretsKVStorePlugin) Fallback() SecretsKVStore {
- return kv.fallbackStore
-}
-
-func (kv *SecretsKVStorePlugin) WithFallbackEnabled(fn func() error) error {
- kv.Lock()
- defer kv.Unlock()
- kv.fallbackEnabled = true
- err := fn()
- kv.fallbackEnabled = false
- return err
-}
-
-func parseKeys(keys []*smp.Key) []Key {
- newKeys := make([]Key, 0, len(keys))
-
- for _, k := range keys {
- newKey := Key{OrgId: k.OrgId, Namespace: k.Namespace, Type: k.Type}
- newKeys = append(newKeys, newKey)
- }
-
- return newKeys
-}
-
-func parseItems(items []*smp.Item) []Item {
- newItems := make([]Item, 0, len(items))
-
- for _, i := range items {
- newItem := Item{OrgId: &i.Key.OrgId, Namespace: &i.Key.Namespace, Type: &i.Key.Type, Value: i.Value}
- newItems = append(newItems, newItem)
- }
-
- return newItems
-}
-
-func updateFatalFlag(ctx context.Context, skv *SecretsKVStorePlugin) {
- // This function makes the most sense in here because it handles all possible scenarios:
- // - User changed backwards compatibility flag, so we have to migrate secrets either to or from the plugin (get or set)
- // - Migration is on, so we migrate secrets to the plugin (set)
- // - User doesn't migrate, but stores a new secret in the plugin (set)
- // Rather than updating the flag in several places, it is cleaner to just do this check once
- // Very early on. Once backwards compatibility to legacy secrets is gone in Grafana 10, this can go away as well
- fatalFlagOnce.Do(func() {
- skv.log.Debug("Updating plugin startup error fatal flag")
- var err error
- if isFatal, _ := IsPluginStartupErrorFatal(ctx, skv.kvstore); !isFatal && skv.backwardsCompatibilityDisabled {
- err = SetPluginStartupErrorFatal(ctx, skv.kvstore, true)
- } else if isFatal && !skv.backwardsCompatibilityDisabled {
- err = SetPluginStartupErrorFatal(ctx, skv.kvstore, false)
- }
- if err != nil {
- skv.log.Error("failed to set plugin error fatal flag", err.Error())
- }
- })
-}
-
-func wrapUserFriendlySecretError(ufe string) datasources.ErrDatasourceSecretsPluginUserFriendly {
- return datasources.ErrDatasourceSecretsPluginUserFriendly{Err: ufe}
-}
-
-func GetNamespacedKVStore(kv kvstore.KVStore) *kvstore.NamespacedKVStore {
- return kvstore.WithNamespace(kv, kvstore.AllOrganizations, PluginNamespace)
-}
-
-func IsPluginStartupErrorFatal(ctx context.Context, kvstore *kvstore.NamespacedKVStore) (bool, error) {
- _, exists, err := kvstore.Get(ctx, QuitOnPluginStartupFailureKey)
- if err != nil {
- return false, fmt.Errorf("error retrieving key %s from kvstore. error: %w", QuitOnPluginStartupFailureKey, err)
- }
- return exists, nil
-}
-
-func SetPluginStartupErrorFatal(ctx context.Context, kvstore *kvstore.NamespacedKVStore, isFatal bool) error {
- if !isFatal {
- return kvstore.Del(ctx, QuitOnPluginStartupFailureKey)
- }
- return kvstore.Set(ctx, QuitOnPluginStartupFailureKey, "true")
-}
-
-func EvaluateRemoteSecretsPlugin(ctx context.Context, mg plugins.SecretsPluginManager, cfg *setting.Cfg) error {
- usePlugin := cfg.SectionWithEnvOverrides("secrets").Key("use_plugin").MustBool()
- if !usePlugin {
- return errPluginDisabledByConfig
- }
- pluginInstalled := mg.SecretsManager(ctx) != nil
- if !pluginInstalled {
- return errPluginNotInstalled
- }
- return nil
-}
-
-func HasPluginStarted(ctx context.Context, mg plugins.SecretsPluginManager) bool {
- return mg.SecretsManager(ctx) != nil && mg.SecretsManager(ctx).SecretsManager != nil
-}
-
-func StartAndReturnPlugin(mg plugins.SecretsPluginManager, ctx context.Context) (smp.SecretsManagerPlugin, error) {
- var err error
- startupOnce.Do(func() {
- err = mg.SecretsManager(ctx).Start(ctx)
- })
- if err != nil {
- return nil, err
- }
- return mg.SecretsManager(ctx).SecretsManager, nil
-}
-
-func ResetPlugin() {
- fatalFlagOnce = sync.Once{}
- startupOnce = sync.Once{}
-}
diff --git a/pkg/services/secrets/kvstore/plugin_test.go b/pkg/services/secrets/kvstore/plugin_test.go
deleted file mode 100644
index 8cd70129df6..00000000000
--- a/pkg/services/secrets/kvstore/plugin_test.go
+++ /dev/null
@@ -1,82 +0,0 @@
-package kvstore
-
-import (
- "context"
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-
- "github.com/grafana/grafana/pkg/plugins/backendplugin/secretsmanagerplugin"
- "github.com/grafana/grafana/pkg/tests/testsuite"
-)
-
-func TestMain(m *testing.M) {
- testsuite.Run(m)
-}
-
-// Set fatal flag to true, then simulate a plugin start failure
-// Should result in an error from the secret store provider
-func TestFatalPluginErr_PluginFailsToStartWithFatalFlagSet(t *testing.T) {
- p, err := SetupFatalCrashTest(t, true, true, false)
- assert.Error(t, err)
- assert.Equal(t, "mocked failed to start", err.Error())
- assert.Nil(t, p.SecretsKVStore)
-}
-
-// Set fatal flag to false, then simulate a plugin start failure
-// Should result in the secret store provider returning the sql impl
-func TestFatalPluginErr_PluginFailsToStartWithFatalFlagNotSet(t *testing.T) {
- p, err := SetupFatalCrashTest(t, true, false, false)
- assert.NoError(t, err)
- require.IsType(t, &CachedKVStore{}, p.SecretsKVStore)
-
- cachedKv, _ := p.SecretsKVStore.(*CachedKVStore)
- store, err := GetUnwrappedStoreFromCache(cachedKv)
- require.NoError(t, err)
- assert.IsType(t, &SecretsKVStoreSQL{}, store)
-}
-
-// With fatal flag not set, store a secret in the plugin while backwards compatibility is disabled
-// Should result in the fatal flag going from unset -> set to true
-func TestFatalPluginErr_FatalFlagGetsSetWithBackwardsCompatDisabled(t *testing.T) {
- p, err := SetupFatalCrashTest(t, false, false, true)
- assert.NoError(t, err)
- require.NotNil(t, p.SecretsKVStore)
-
- err = p.SecretsKVStore.Set(context.Background(), 0, "datasource", "postgres", "my secret")
- assert.NoError(t, err)
-
- isFatal, err := IsPluginStartupErrorFatal(context.Background(), GetNamespacedKVStore(p.KVStore))
- assert.NoError(t, err)
- assert.True(t, isFatal)
-}
-
-// With fatal flag set, retrieve a secret from the plugin while backwards compatibility is enabled
-// Should result in the fatal flag going from set to true -> unset
-func TestFatalPluginErr_FatalFlagGetsUnSetWithBackwardsCompatEnabled(t *testing.T) {
- p, err := SetupFatalCrashTest(t, false, true, false)
- assert.NoError(t, err)
- require.NotNil(t, p.SecretsKVStore)
-
- // setup - store secret and manually bypassing the remote plugin impl
- _, err = p.PluginManager.SecretsManager(context.Background()).SecretsManager.SetSecret(context.Background(), &secretsmanagerplugin.SetSecretRequest{
- KeyDescriptor: &secretsmanagerplugin.Key{
- OrgId: 0,
- Namespace: "postgres",
- Type: "datasource",
- },
- Value: "bogus",
- })
- assert.NoError(t, err)
-
- // retrieve the secret and check values
- val, exists, err := p.SecretsKVStore.Get(context.Background(), 0, "postgres", "datasource")
- assert.NoError(t, err)
- assert.NotNil(t, val)
- assert.True(t, exists)
-
- isFatal, err := IsPluginStartupErrorFatal(context.Background(), GetNamespacedKVStore(p.KVStore))
- assert.NoError(t, err)
- assert.False(t, isFatal)
-}
diff --git a/pkg/services/secrets/kvstore/sql_test.go b/pkg/services/secrets/kvstore/sql_test.go
index bb73e7d80bf..eeee727b9f0 100644
--- a/pkg/services/secrets/kvstore/sql_test.go
+++ b/pkg/services/secrets/kvstore/sql_test.go
@@ -12,6 +12,7 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/secrets/fakes"
"github.com/grafana/grafana/pkg/services/secrets/manager"
+ "github.com/grafana/grafana/pkg/tests/testsuite"
)
type TestCase struct {
@@ -25,6 +26,10 @@ func (t *TestCase) Value() string {
return fmt.Sprintf("%d:%s:%s:%d", t.OrgId, t.Namespace, t.Type, t.Revision)
}
+func TestMain(m *testing.M) {
+ testsuite.Run(m)
+}
+
func TestSecretsKVStoreSQL(t *testing.T) {
sqlStore := db.InitTestDB(t)
secretsService := manager.SetupTestService(t, fakes.NewFakeSecretsStore())
diff --git a/pkg/services/secrets/kvstore/test_helpers.go b/pkg/services/secrets/kvstore/test_helpers.go
index 7c19def969c..479bb5d9172 100644
--- a/pkg/services/secrets/kvstore/test_helpers.go
+++ b/pkg/services/secrets/kvstore/test_helpers.go
@@ -3,25 +3,13 @@ package kvstore
import (
"context"
"errors"
- "sync"
"testing"
- "github.com/stretchr/testify/require"
- "google.golang.org/grpc"
- "gopkg.in/ini.v1"
-
- "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/plugins"
- "github.com/grafana/grafana/pkg/plugins/backendplugin"
- "github.com/grafana/grafana/pkg/plugins/backendplugin/secretsmanagerplugin"
- pluginsLogger "github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/secrets/fakes"
secretsmng "github.com/grafana/grafana/pkg/services/secrets/manager"
"github.com/grafana/grafana/pkg/services/sqlstore"
- "github.com/grafana/grafana/pkg/setting"
)
func NewFakeSQLSecretsKVStore(t *testing.T, sqlStore *sqlstore.SQLStore) *SecretsKVStoreSQL {
@@ -30,17 +18,6 @@ func NewFakeSQLSecretsKVStore(t *testing.T, sqlStore *sqlstore.SQLStore) *Secret
return NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
}
-func NewFakePluginSecretsKVStore(t *testing.T, features featuremgmt.FeatureToggles, fallback SecretsKVStore) *SecretsKVStorePlugin {
- t.Helper()
- sqlStore := db.InitTestDB(t)
- secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
- store := kvstore.ProvideService(sqlStore)
- namespacedKVStore := GetNamespacedKVStore(store)
- manager := NewFakeSecretsPluginManager(t, false)
- plugin := manager.SecretsManager(context.Background()).SecretsManager
- return NewPluginSecretsKVStore(plugin, secretsService, namespacedKVStore, features, fallback, log.New("test.logger"))
-}
-
// In memory kv store used for testing
type FakeSecretsKVStore struct {
store map[Key]string
@@ -127,14 +104,6 @@ func buildKey(orgId int64, namespace string, typ string) Key {
}
}
-func internalToProtoKey(k Key) *secretsmanagerplugin.Key {
- return &secretsmanagerplugin.Key{
- OrgId: k.OrgId,
- Namespace: k.Namespace,
- Type: k.Type,
- }
-}
-
// Fake feature toggle - only need to check the backwards compatibility disabled flag
type fakeFeatureToggles struct {
returnValue bool
@@ -158,172 +127,3 @@ func (f fakeFeatureToggles) IsEnabled(ctx context.Context, feature string) bool
func (f fakeFeatureToggles) GetEnabled(ctx context.Context) map[string]bool {
return map[string]bool{}
}
-
-// Fake grpc secrets plugin impl
-type fakeGRPCSecretsPlugin struct {
- kv map[Key]string
-}
-
-func (c *fakeGRPCSecretsPlugin) GetSecret(ctx context.Context, in *secretsmanagerplugin.GetSecretRequest, opts ...grpc.CallOption) (*secretsmanagerplugin.GetSecretResponse, error) {
- val, ok := c.kv[buildKey(in.KeyDescriptor.OrgId, in.KeyDescriptor.Namespace, in.KeyDescriptor.Type)]
- return &secretsmanagerplugin.GetSecretResponse{
- DecryptedValue: val,
- Exists: ok,
- }, nil
-}
-
-func (c *fakeGRPCSecretsPlugin) SetSecret(ctx context.Context, in *secretsmanagerplugin.SetSecretRequest, opts ...grpc.CallOption) (*secretsmanagerplugin.SetSecretResponse, error) {
- c.kv[buildKey(in.KeyDescriptor.OrgId, in.KeyDescriptor.Namespace, in.KeyDescriptor.Type)] = in.Value
- return &secretsmanagerplugin.SetSecretResponse{}, nil
-}
-
-func (c *fakeGRPCSecretsPlugin) DeleteSecret(ctx context.Context, in *secretsmanagerplugin.DeleteSecretRequest, opts ...grpc.CallOption) (*secretsmanagerplugin.DeleteSecretResponse, error) {
- delete(c.kv, buildKey(in.KeyDescriptor.OrgId, in.KeyDescriptor.Namespace, in.KeyDescriptor.Type))
- return &secretsmanagerplugin.DeleteSecretResponse{}, nil
-}
-
-func (c *fakeGRPCSecretsPlugin) ListSecrets(ctx context.Context, in *secretsmanagerplugin.ListSecretsRequest, opts ...grpc.CallOption) (*secretsmanagerplugin.ListSecretsResponse, error) {
- res := make([]*secretsmanagerplugin.Key, 0)
- for k := range c.kv {
- if in.KeyDescriptor.OrgId == AllOrganizations && in.KeyDescriptor.Namespace == "" && in.KeyDescriptor.Type == "" {
- res = append(res, internalToProtoKey(k))
- } else if k.OrgId == in.KeyDescriptor.OrgId && k.Namespace == in.KeyDescriptor.Namespace && k.Type == in.KeyDescriptor.Type {
- res = append(res, internalToProtoKey(k))
- }
- }
- return &secretsmanagerplugin.ListSecretsResponse{
- Keys: res,
- }, nil
-}
-
-func (c *fakeGRPCSecretsPlugin) RenameSecret(ctx context.Context, in *secretsmanagerplugin.RenameSecretRequest, opts ...grpc.CallOption) (*secretsmanagerplugin.RenameSecretResponse, error) {
- oldKey := buildKey(in.KeyDescriptor.OrgId, in.KeyDescriptor.Namespace, in.KeyDescriptor.Type)
- val := c.kv[oldKey]
- delete(c.kv, oldKey)
- c.kv[buildKey(in.KeyDescriptor.OrgId, in.NewNamespace, in.KeyDescriptor.Type)] = val
- return &secretsmanagerplugin.RenameSecretResponse{}, nil
-}
-
-func (c *fakeGRPCSecretsPlugin) GetAllSecrets(ctx context.Context, in *secretsmanagerplugin.GetAllSecretsRequest, opts ...grpc.CallOption) (*secretsmanagerplugin.GetAllSecretsResponse, error) {
- items := make([]*secretsmanagerplugin.Item, 0)
- for k, v := range c.kv {
- items = append(items, &secretsmanagerplugin.Item{
- Key: internalToProtoKey(k),
- Value: v,
- })
- }
- return &secretsmanagerplugin.GetAllSecretsResponse{
- Items: items,
- }, nil
-}
-
-var _ SecretsKVStore = &FakeSecretsKVStore{}
-var _ secretsmanagerplugin.SecretsManagerPlugin = &fakeGRPCSecretsPlugin{}
-
-// Fake plugin manager
-type fakePluginManager struct {
- shouldFailOnStart bool
- plugin *plugins.Plugin
-}
-
-func (mg *fakePluginManager) SecretsManager(_ context.Context) *plugins.Plugin {
- if mg.plugin != nil {
- return mg.plugin
- }
- p := &plugins.Plugin{
- SecretsManager: &fakeGRPCSecretsPlugin{
- kv: make(map[Key]string),
- },
- }
- p.RegisterClient(&fakePluginClient{
- shouldFailOnStart: mg.shouldFailOnStart,
- })
- mg.plugin = p
- return p
-}
-
-func NewFakeSecretsPluginManager(t *testing.T, shouldFailOnStart bool) plugins.SecretsPluginManager {
- t.Helper()
- return &fakePluginManager{
- shouldFailOnStart: shouldFailOnStart,
- }
-}
-
-// Fake plugin client
-type fakePluginClient struct {
- shouldFailOnStart bool
- backendplugin.Plugin
-}
-
-func (pc *fakePluginClient) Start(_ context.Context) error {
- if pc.shouldFailOnStart {
- return errors.New("mocked failed to start")
- }
- return nil
-}
-
-func (pc *fakePluginClient) Stop(_ context.Context) error {
- return nil
-}
-
-func (pc *fakePluginClient) Logger() pluginsLogger.Logger {
- return pluginsLogger.NewTestLogger()
-}
-
-func SetupFatalCrashTest(
- t *testing.T,
- shouldFailOnStart bool,
- isPluginErrorFatal bool,
- isBackwardsCompatDisabled bool,
-) (fatalCrashTestFields, error) {
- t.Helper()
- fatalFlagOnce = sync.Once{}
- startupOnce = sync.Once{}
- cfg := SetupTestConfig(t)
- sqlStore := db.InitTestDB(t)
- secretService := fakes.FakeSecretsService{}
- kvstore := kvstore.ProvideService(sqlStore)
- if isPluginErrorFatal {
- _ = SetPluginStartupErrorFatal(context.Background(), GetNamespacedKVStore(kvstore), true)
- }
- features := NewFakeFeatureToggles(t, isBackwardsCompatDisabled)
- manager := NewFakeSecretsPluginManager(t, shouldFailOnStart)
- svc, err := ProvideService(sqlStore, secretService, manager, kvstore, features, cfg)
- t.Cleanup(ResetPlugin)
- return fatalCrashTestFields{
- SecretsKVStore: svc,
- PluginManager: manager,
- KVStore: kvstore,
- SqlStore: sqlStore,
- }, err
-}
-
-type fatalCrashTestFields struct {
- SecretsKVStore SecretsKVStore
- PluginManager plugins.SecretsPluginManager
- KVStore kvstore.KVStore
- SqlStore db.DB
-}
-
-func SetupTestConfig(t *testing.T) *setting.Cfg {
- t.Helper()
- rawCfg := `
- [secrets]
- use_plugin = true
- `
- raw, err := ini.Load([]byte(rawCfg))
- require.NoError(t, err)
- return &setting.Cfg{Raw: raw}
-}
-
-func ReplaceFallback(t *testing.T, kv SecretsKVStore, fb SecretsKVStore) error {
- t.Helper()
- if store, ok := kv.(*CachedKVStore); ok {
- kv = store.store
- }
- if store, ok := kv.(*SecretsKVStorePlugin); ok {
- store.fallbackStore = fb
- return nil
- }
- return errors.New("not a plugin store")
-}
diff --git a/pkg/services/secrets/migrator/migrator.go b/pkg/services/secrets/migrator/migrator.go
index 3581c2afd99..e4324a14104 100644
--- a/pkg/services/secrets/migrator/migrator.go
+++ b/pkg/services/secrets/migrator/migrator.go
@@ -51,6 +51,7 @@ func ProvideSecretsMigrator(
b64Secret{simpleSecret: simpleSecret{tableName: "user_external_session", columnName: "refresh_token"}, encoding: base64.StdEncoding},
b64Secret{simpleSecret: simpleSecret{tableName: "user_external_session", columnName: "session_id"}, encoding: base64.StdEncoding},
b64Secret{simpleSecret: simpleSecret{tableName: "user_external_session", columnName: "name_id"}, encoding: base64.StdEncoding},
+ provisioningSecrets{},
}
return &SecretsMigrator{
diff --git a/pkg/services/secrets/migrator/provisioning.go b/pkg/services/secrets/migrator/provisioning.go
new file mode 100644
index 00000000000..8e4e6c3a669
--- /dev/null
+++ b/pkg/services/secrets/migrator/provisioning.go
@@ -0,0 +1,187 @@
+package migrator
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+
+ "github.com/grafana/grafana/pkg/infra/db"
+ "github.com/grafana/grafana/pkg/services/encryption"
+ "github.com/grafana/grafana/pkg/services/secrets"
+ "github.com/grafana/grafana/pkg/services/secrets/manager"
+)
+
+var errNoEncryptedValue = errors.New("no encrypted value was found")
+
+var _ SecretsRotator = provisioningSecrets{}
+
+type provisioningSecrets struct{}
+
+func (p provisioningSecrets) ReEncrypt(ctx context.Context, svc *manager.SecretsService, sqlStore db.DB) bool {
+ return p.reEncrypt(ctx, svc, func(ctx context.Context, payload []byte) ([]byte, error) {
+ return svc.Encrypt(ctx, payload, secrets.WithoutScope())
+ }, sqlStore, "rotation")
+}
+
+func (p provisioningSecrets) Rollback(ctx context.Context, svc *manager.SecretsService, internal encryption.Internal, sqlStore db.DB, secretKey string) bool {
+ return p.reEncrypt(ctx, svc, func(ctx context.Context, payload []byte) ([]byte, error) {
+ return internal.Encrypt(ctx, payload, secretKey)
+ }, sqlStore, "rollback")
+}
+
+func (p provisioningSecrets) reEncrypt(
+ ctx context.Context,
+ svc *manager.SecretsService,
+ encrypt func(context.Context, []byte) ([]byte, error),
+ sqlStore db.DB,
+ action string,
+) bool {
+ var rows []struct {
+ Guid string
+ Value []byte
+ }
+
+ if err := sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
+ return sess.Table("resource").Where("`group` = 'provisioning.grafana.app' AND `resource` = 'repositories'").
+ Select("guid, value").
+ Find(&rows)
+ }); err != nil {
+ logger.Warn("Could not find any provisioning secrets to re-encrypt", "error", err, "action", action)
+ return false
+ }
+
+ var failures int
+
+ for _, row := range rows {
+ var resource map[string]any
+ if err := json.Unmarshal(row.Value, &resource); err != nil {
+ logger.Error("Failed to decode resource", "guid", row.Guid, "error", err, "action", action)
+ failures++
+ continue
+ }
+
+ err := p.reEncryptGitHubToken(ctx, svc, encrypt, sqlStore, resource)
+ if err != nil && !errors.Is(err, errNoEncryptedValue) {
+ logger.Error("Failed to rotate GitHub token", "guid", row.Guid, "error", err, "action", action)
+ failures++
+ }
+ update := err == nil
+
+ err = p.reEncryptWebhookSecret(ctx, svc, encrypt, sqlStore, resource)
+ if err != nil && !errors.Is(err, errNoEncryptedValue) {
+ logger.Error("Failed to rotate webhook secret", "guid", row.Guid, "error", err, "action", action)
+ failures++
+ }
+ update = update || err == nil
+
+ if update {
+ // Do it...
+ encoded, err := json.Marshal(resource)
+ if err != nil {
+ logger.Error("Failed to marshal resource to JSON", "guid", row.Guid, "error", err, "action", action)
+ failures++
+ continue
+ }
+
+ if err := sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
+ // TODO: Figure out what resource_version is supposed to contain, and update it.
+ _, err = sess.Exec("UPDATE resource SET value = ? WHERE guid = ?", string(encoded), row.Guid)
+ return err
+ }); err != nil {
+ logger.Error("Failed to update resource with re-encrypted values", "guid", row.Guid, "error", err, "action", action)
+ failures++
+ }
+ }
+ }
+
+ if failures > 0 {
+ logger.Warn("Failed to rotate provisioning secrets", "failures", failures, "action", action)
+ } else {
+ logger.Info("Successfully rotated provisioning secrets", "action", action)
+ }
+ return failures > 0
+}
+
+func (provisioningSecrets) reEncryptGitHubToken(
+ ctx context.Context,
+ svc *manager.SecretsService,
+ encrypt func(context.Context, []byte) ([]byte, error),
+ sqlStore db.DB,
+ obj map[string]any,
+) error {
+ spec, _ := getCast[map[string]any](obj, "spec")
+ github, _ := getCast[map[string]any](spec, "github")
+ b64Token, ok := getCast[string](github, "encryptedToken")
+ if !ok {
+ return errNoEncryptedValue
+ }
+
+ token, err := base64.StdEncoding.DecodeString(b64Token)
+ if err != nil {
+ return fmt.Errorf("failed to decode base64 encrypted token: %w", err)
+ }
+
+ decrypted, err := svc.Decrypt(ctx, token)
+ if err != nil {
+ return fmt.Errorf("failed to decrypt existing encrypted token: %w", err)
+ }
+
+ encrypted, err := encrypt(ctx, decrypted)
+ if err != nil {
+ return fmt.Errorf("failed to encrypt decrypted token: %w", err)
+ }
+
+ b64Token = base64.StdEncoding.EncodeToString(encrypted)
+ github["encryptedToken"] = b64Token
+ return nil
+}
+
+func (provisioningSecrets) reEncryptWebhookSecret(
+ ctx context.Context,
+ svc *manager.SecretsService,
+ encrypt func(context.Context, []byte) ([]byte, error),
+ sqlStore db.DB,
+ obj map[string]any,
+) error {
+ status, _ := getCast[map[string]any](obj, "status")
+ webhook, _ := getCast[map[string]any](status, "webhook")
+ b64Token, ok := getCast[string](webhook, "encryptedSecret")
+ if !ok {
+ return errNoEncryptedValue
+ }
+
+ token, err := base64.StdEncoding.DecodeString(b64Token)
+ if err != nil {
+ return fmt.Errorf("failed to decode base64 encrypted secret: %w", err)
+ }
+
+ decrypted, err := svc.Decrypt(ctx, token)
+ if err != nil {
+ return fmt.Errorf("failed to decrypt existing encrypted secret: %w", err)
+ }
+
+ encrypted, err := encrypt(ctx, decrypted)
+ if err != nil {
+ return fmt.Errorf("failed to encrypt decrypted secret: %w", err)
+ }
+
+ b64Token = base64.StdEncoding.EncodeToString(encrypted)
+ webhook["encryptedSecret"] = b64Token
+ return nil
+}
+
+func getCast[T any](m map[string]any, name string) (T, bool) {
+ if m == nil {
+ var zero T
+ return zero, false
+ }
+ anyV, ok := m[name]
+ if ok {
+ t, ok := anyV.(T)
+ return t, ok
+ }
+ var zero T
+ return zero, false
+}
diff --git a/pkg/services/serviceaccounts/tests/fakes.go b/pkg/services/serviceaccounts/tests/fakes.go
index 6e2a4427338..294069398d9 100644
--- a/pkg/services/serviceaccounts/tests/fakes.go
+++ b/pkg/services/serviceaccounts/tests/fakes.go
@@ -2,7 +2,6 @@ package tests
import (
"context"
- "fmt"
"github.com/grafana/grafana/pkg/services/apikey"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
@@ -58,7 +57,6 @@ func (f *FakeServiceAccountService) MigrateApiKey(ctx context.Context, orgID, ke
}
func (f *FakeServiceAccountService) MigrateApiKeysToServiceAccounts(ctx context.Context, orgID int64) (*serviceaccounts.MigrationResult, error) {
- fmt.Printf("fake migration result: %v", f.ExpectedMigrationResult)
return f.ExpectedMigrationResult, f.ExpectedErr
}
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/migrations.go b/pkg/services/sqlstore/migrations/migrations.go
index 2b7be2663d6..bc95e01dc16 100644
--- a/pkg/services/sqlstore/migrations/migrations.go
+++ b/pkg/services/sqlstore/migrations/migrations.go
@@ -152,7 +152,7 @@ func (oss *OSSMigrations) AddMigration(mg *Migrator) {
ualert.AddAlertRuleMissingSeriesEvalsToResolve(mg)
- ualert.AddAlertRuleVersionUIDIndex(mg)
-
accesscontrol.AddDatasourceDrilldownRemovalMigration(mg)
+
+ ualert.DropTitleUniqueIndexMigration(mg)
}
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/ualert/alert_rule_version_uid_index.go b/pkg/services/sqlstore/migrations/ualert/alert_rule_version_uid_index.go
deleted file mode 100644
index 7d24bc8086c..00000000000
--- a/pkg/services/sqlstore/migrations/ualert/alert_rule_version_uid_index.go
+++ /dev/null
@@ -1,15 +0,0 @@
-package ualert
-
-import (
- "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
-)
-
-// AddAlertRuleVersionUIDIndex adds an index to the alert_rule_version table on (rule_org_id, rule_uid) columns.
-func AddAlertRuleVersionUIDIndex(mg *migrator.Migrator) {
- mg.AddMigration("add index to alert_rule_version table on (rule_org_id, rule_uid)",
- migrator.NewAddIndexMigration(
- migrator.Table{Name: "alert_rule_version"},
- &migrator.Index{Cols: []string{"rule_org_id", "rule_uid"}, Type: migrator.IndexType},
- ),
- )
-}
diff --git a/pkg/services/sqlstore/migrations/ualert/tables.go b/pkg/services/sqlstore/migrations/ualert/tables.go
index 023deb2a3cb..4990939c670 100644
--- a/pkg/services/sqlstore/migrations/ualert/tables.go
+++ b/pkg/services/sqlstore/migrations/ualert/tables.go
@@ -195,6 +195,8 @@ func alertInstanceMigration(mg *migrator.Migrator) {
}))
}
+var titleUniqueIndex = &migrator.Index{Cols: []string{"org_id", "namespace_uid", "title"}, Type: migrator.UniqueIndex}
+
func addAlertRuleMigrations(mg *migrator.Migrator, defaultIntervalSeconds int64) {
// DO NOT EDIT
alertRule := migrator.Table{
@@ -245,9 +247,7 @@ func addAlertRuleMigrations(mg *migrator.Migrator, defaultIntervalSeconds int64)
Cols: []string{"org_id", "title"}, Type: migrator.UniqueIndex,
}))
- mg.AddMigration("add index in alert_rule on org_id, namespase_uid and title columns", migrator.NewAddIndexMigration(alertRule, &migrator.Index{
- Cols: []string{"org_id", "namespace_uid", "title"}, Type: migrator.UniqueIndex,
- }))
+ mg.AddMigration("add index in alert_rule on org_id, namespase_uid and title columns", migrator.NewAddIndexMigration(alertRule, titleUniqueIndex))
mg.AddMigration("add dashboard_uid column to alert_rule", migrator.NewAddColumnMigration(
migrator.Table{Name: "alert_rule"},
@@ -571,3 +571,8 @@ func (c extractAlertmanagerConfigurationHistory) Exec(sess *xorm.Session, migrat
}
return nil
}
+
+func DropTitleUniqueIndexMigration(mg *migrator.Migrator) {
+ mg.AddMigration("remove title in folder unique index",
+ migrator.NewDropIndexMigration(migrator.Table{Name: "alert_rule"}, titleUniqueIndex))
+}
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/migrations/usermig/service_account_multiple_org_login_migrator.go b/pkg/services/sqlstore/migrations/usermig/service_account_multiple_org_login_migrator.go
index b1ad97f621e..bb2c878e296 100644
--- a/pkg/services/sqlstore/migrations/usermig/service_account_multiple_org_login_migrator.go
+++ b/pkg/services/sqlstore/migrations/usermig/service_account_multiple_org_login_migrator.go
@@ -72,6 +72,20 @@ func (p *ServiceAccountsSameLoginCrossOrgs) Exec(sess *xorm.Session, mg *migrato
AND is_service_account = 1
AND login NOT LIKE 'sa-' || CAST(org_id AS TEXT) || '-%';
`)
+ case migrator.Spanner:
+ _, err = p.sess.Exec(`
+ UPDATE user
+ SET login = CONCAT('sa-', CAST(org_id AS STRING), '-',
+ CASE
+ WHEN login LIKE 'sa-%' THEN SUBSTRING(login, 4)
+ ELSE login
+ END
+ )
+ WHERE login IS NOT NULL
+ AND is_service_account
+ AND login NOT LIKE CONCAT('sa-', CAST(org_id AS STRING), '-%')
+ `)
+
default:
return fmt.Errorf("dialect not supported: %s", p.dialect)
}
@@ -128,6 +142,19 @@ func (p *ServiceAccountsDeduplicateOrgInLogin) Exec(sess *xorm.Session, mg *migr
WHERE u2.login = 'sa-' || CAST(u.org_id AS TEXT) || SUBSTRING(u.login, LENGTH('sa-'||CAST(u.org_id AS TEXT)||'-'||CAST(u.org_id AS TEXT))+1)
);;
`)
+ case migrator.Spanner:
+ _, err = sess.Exec(`
+ UPDATE ` + dialect.Quote("user") + ` AS u
+ SET login = 'sa-' || CAST(u.org_id AS STRING) || SUBSTRING(u.login, LENGTH('sa-'||CAST(u.org_id AS STRING)||'-'||CAST(u.org_id AS STRING))+1)
+ WHERE u.login IS NOT NULL
+ AND u.is_service_account
+ AND u.login LIKE 'sa-'||CAST(u.org_id AS STRING)||'-'||CAST(u.org_id AS STRING)||'-%'
+ AND NOT EXISTS (
+ SELECT 1
+ FROM ` + dialect.Quote("user") + `AS u2
+ WHERE u2.login = 'sa-' || CAST(u.org_id AS STRING) || SUBSTRING(u.login, LENGTH('sa-'||CAST(u.org_id AS STRING)||'-'||CAST(u.org_id AS STRING))+1)
+ );;
+ `)
default:
return fmt.Errorf("dialect not supported: %s", dialect)
}
diff --git a/pkg/services/sqlstore/migrations/usermig/user_lowercase_login_and_email.go b/pkg/services/sqlstore/migrations/usermig/user_lowercase_login_and_email.go
index 570b79ba52e..2d85a64a426 100644
--- a/pkg/services/sqlstore/migrations/usermig/user_lowercase_login_and_email.go
+++ b/pkg/services/sqlstore/migrations/usermig/user_lowercase_login_and_email.go
@@ -30,7 +30,7 @@ func (p *UsersLowerCaseLoginAndEmail) SQL(dialect migrator.Dialect) string {
func (p *UsersLowerCaseLoginAndEmail) Exec(sess *xorm.Session, mg *migrator.Migrator) error {
// Get all users
users := make([]*user.User, 0)
- err := sess.Table("user").Find(&users)
+ err := sess.Table("user").Asc("created").Find(&users)
if err != nil {
return err
}
diff --git a/pkg/services/sqlstore/migrator/mysql_dialect.go b/pkg/services/sqlstore/migrator/mysql_dialect.go
index 4a3c51af7ec..55a10768a80 100644
--- a/pkg/services/sqlstore/migrator/mysql_dialect.go
+++ b/pkg/services/sqlstore/migrator/mysql_dialect.go
@@ -171,7 +171,7 @@ func (db *MySQLDialect) CleanDB(engine *xorm.Engine) error {
// TruncateDBTables truncates all the tables.
// A special case is the dashboard_acl table where we keep the default permissions.
func (db *MySQLDialect) TruncateDBTables(engine *xorm.Engine) error {
- tables, err := engine.DBMetas()
+ tables, err := engine.Dialect().GetTables()
if err != nil {
return err
}
diff --git a/pkg/services/sqlstore/migrator/postgres_dialect.go b/pkg/services/sqlstore/migrator/postgres_dialect.go
index 73c02adf64e..e2401d980bc 100644
--- a/pkg/services/sqlstore/migrator/postgres_dialect.go
+++ b/pkg/services/sqlstore/migrator/postgres_dialect.go
@@ -141,7 +141,7 @@ func (db *PostgresDialect) CleanDB(engine *xorm.Engine) error {
// TruncateDBTables truncates all the tables.
// A special case is the dashboard_acl table where we keep the default permissions.
func (db *PostgresDialect) TruncateDBTables(engine *xorm.Engine) error {
- tables, err := engine.DBMetas()
+ tables, err := engine.Dialect().GetTables()
if err != nil {
return err
}
diff --git a/pkg/services/sqlstore/migrator/snapshot/spanner-ddl.json b/pkg/services/sqlstore/migrator/snapshot/spanner-ddl.json
index a205475caa6..3fe36f0b903 100644
--- a/pkg/services/sqlstore/migrator/snapshot/spanner-ddl.json
+++ b/pkg/services/sqlstore/migrator/snapshot/spanner-ddl.json
@@ -16,7 +16,7 @@
"CREATE TABLE `alert_notification_state` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `alert_id` INT64 NOT NULL, `notifier_id` INT64 NOT NULL, `state` STRING(50) NOT NULL, `version` INT64 NOT NULL, `updated_at` INT64 NOT NULL, `alert_rule_state_updated_version` INT64 NOT NULL) PRIMARY KEY (id)",
"CREATE INDEX `IDX_alert_notification_state_alert_id` ON `alert_notification_state` (alert_id)",
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_alert_notification_state_org_id_alert_id_notifier_id` ON `alert_notification_state` (org_id, alert_id, notifier_id)",
- "CREATE TABLE `alert_rule` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `title` STRING(190) NOT NULL, `condition` STRING(190) NOT NULL, `data` STRING(MAX), `updated` TIMESTAMP NOT NULL, `interval_seconds` INT64 NOT NULL DEFAULT (60), `version` INT64 NOT NULL DEFAULT (0), `uid` STRING(40) NOT NULL DEFAULT ('0'), `namespace_uid` STRING(40) NOT NULL, `rule_group` STRING(190) NOT NULL, `no_data_state` STRING(15) NOT NULL DEFAULT ('NoData'), `exec_err_state` STRING(15) NOT NULL DEFAULT ('Alerting'), `for` INT64 NOT NULL DEFAULT (0), `annotations` STRING(MAX), `labels` STRING(MAX), `dashboard_uid` STRING(40), `panel_id` INT64, `rule_group_idx` INT64 NOT NULL DEFAULT (1), `is_paused` BOOL NOT NULL DEFAULT (false), `notification_settings` STRING(MAX), `record` STRING(MAX), `metadata` STRING(MAX), `updated_by` STRING(40), `guid` STRING(36) NOT NULL DEFAULT ('')) PRIMARY KEY (id)",
+ "CREATE TABLE `alert_rule` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `org_id` INT64 NOT NULL, `title` STRING(190) NOT NULL, `condition` STRING(190) NOT NULL, `data` STRING(MAX), `updated` TIMESTAMP NOT NULL, `interval_seconds` INT64 NOT NULL DEFAULT (60), `version` INT64 NOT NULL DEFAULT (0), `uid` STRING(40) NOT NULL DEFAULT ('0'), `namespace_uid` STRING(40) NOT NULL, `rule_group` STRING(190) NOT NULL, `no_data_state` STRING(15) NOT NULL DEFAULT ('NoData'), `exec_err_state` STRING(15) NOT NULL DEFAULT ('Alerting'), `for` INT64 NOT NULL DEFAULT (0), `annotations` STRING(MAX), `labels` STRING(MAX), `dashboard_uid` STRING(40), `panel_id` INT64, `rule_group_idx` INT64 NOT NULL DEFAULT (1), `is_paused` BOOL NOT NULL DEFAULT (false), `notification_settings` STRING(MAX), `record` STRING(MAX), `metadata` STRING(MAX), `updated_by` STRING(40), `guid` STRING(36) NOT NULL DEFAULT (''), `missing_series_evals_to_resolve` INT64) PRIMARY KEY (id)",
"CREATE INDEX `IDX_alert_rule_org_id_dashboard_uid_panel_id` ON `alert_rule` (org_id, dashboard_uid, panel_id)",
"CREATE INDEX `IDX_alert_rule_org_id_namespace_uid_rule_group` ON `alert_rule` (org_id, namespace_uid, rule_group)",
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_alert_rule_guid` ON `alert_rule` (guid)",
@@ -27,7 +27,7 @@
"CREATE TABLE `alert_rule_tag` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `alert_id` INT64 NOT NULL, `tag_id` INT64 NOT NULL) PRIMARY KEY (id)",
"CREATE INDEX `IDX_alert_rule_tag_alert_id` ON `alert_rule_tag` (alert_id)",
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_alert_rule_tag_alert_id_tag_id` ON `alert_rule_tag` (alert_id, tag_id)",
- "CREATE TABLE `alert_rule_version` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `rule_org_id` INT64 NOT NULL, `rule_uid` STRING(40) NOT NULL DEFAULT ('0'), `rule_namespace_uid` STRING(40) NOT NULL, `rule_group` STRING(190) NOT NULL, `parent_version` INT64 NOT NULL, `restored_from` INT64 NOT NULL, `version` INT64 NOT NULL, `created` TIMESTAMP NOT NULL, `title` STRING(190) NOT NULL, `condition` STRING(190) NOT NULL, `data` STRING(MAX), `interval_seconds` INT64 NOT NULL, `no_data_state` STRING(15) NOT NULL DEFAULT ('NoData'), `exec_err_state` STRING(15) NOT NULL DEFAULT ('Alerting'), `for` INT64 NOT NULL DEFAULT (0), `annotations` STRING(MAX), `labels` STRING(MAX), `rule_group_idx` INT64 NOT NULL DEFAULT (1), `is_paused` BOOL NOT NULL DEFAULT (false), `notification_settings` STRING(MAX), `record` STRING(MAX), `metadata` STRING(MAX), `created_by` STRING(40), `rule_guid` STRING(36) NOT NULL DEFAULT ('')) PRIMARY KEY (id)",
+ "CREATE TABLE `alert_rule_version` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `rule_org_id` INT64 NOT NULL, `rule_uid` STRING(40) NOT NULL DEFAULT ('0'), `rule_namespace_uid` STRING(40) NOT NULL, `rule_group` STRING(190) NOT NULL, `parent_version` INT64 NOT NULL, `restored_from` INT64 NOT NULL, `version` INT64 NOT NULL, `created` TIMESTAMP NOT NULL, `title` STRING(190) NOT NULL, `condition` STRING(190) NOT NULL, `data` STRING(MAX), `interval_seconds` INT64 NOT NULL, `no_data_state` STRING(15) NOT NULL DEFAULT ('NoData'), `exec_err_state` STRING(15) NOT NULL DEFAULT ('Alerting'), `for` INT64 NOT NULL DEFAULT (0), `annotations` STRING(MAX), `labels` STRING(MAX), `rule_group_idx` INT64 NOT NULL DEFAULT (1), `is_paused` BOOL NOT NULL DEFAULT (false), `notification_settings` STRING(MAX), `record` STRING(MAX), `metadata` STRING(MAX), `created_by` STRING(40), `rule_guid` STRING(36) NOT NULL DEFAULT (''), `missing_series_evals_to_resolve` INT64) PRIMARY KEY (id)",
"CREATE INDEX `IDX_alert_rule_version_rule_org_id_rule_namespace_uid_rule_group` ON `alert_rule_version` (rule_org_id, rule_namespace_uid, rule_group)",
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_alert_rule_version_rule_guid_version` ON `alert_rule_version` (rule_guid, version)",
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_alert_rule_version_rule_org_id_rule_uid_rule_guid_version` ON `alert_rule_version` (rule_org_id, rule_uid, rule_guid, version)",
@@ -253,5 +253,9 @@
"CREATE INDEX `IDX_user_role_user_id` ON `user_role` (user_id)",
"CREATE UNIQUE NULL_FILTERED INDEX `UQE_user_role_org_id_user_id_role_id_group_mapping_uid` ON `user_role` (org_id, user_id, role_id, group_mapping_uid)",
"CREATE TABLE `user_stats` (`id` INT64 NOT NULL GENERATED BY DEFAULT AS IDENTITY (BIT_REVERSED_POSITIVE), `user_id` INT64 NOT NULL, `billing_role` STRING(40) NOT NULL, `created` TIMESTAMP NOT NULL, `updated` TIMESTAMP NOT NULL) PRIMARY KEY (id)",
- "CREATE UNIQUE NULL_FILTERED INDEX `UQE_user_stats_user_id` ON `user_stats` (user_id)"
+ "CREATE UNIQUE NULL_FILTERED INDEX `UQE_user_stats_user_id` ON `user_stats` (user_id)",
+ "CREATE TABLE resource ( namespace STRING(63), resource_group STRING(190), resource STRING(190), name STRING(253), folder STRING(253), value BYTES(MAX), resource_version TIMESTAMP NOT NULL OPTIONS ( allow_commit_timestamp = true ), previous_resource_version TIMESTAMP, ) PRIMARY KEY (namespace, resource_group, resource, name)",
+ "CREATE TABLE resource_history ( namespace STRING(63), resource_group STRING(190), resource STRING(190), name STRING(253), folder STRING(253), value BYTES(MAX), resource_version TIMESTAMP NOT NULL OPTIONS ( allow_commit_timestamp = true ), previous_resource_version TIMESTAMP, action INT64, ) PRIMARY KEY (namespace, resource_group, resource, name, resource_version DESC)",
+ "CREATE TABLE resource_blob ( uid STRING(36) NOT NULL, resource_key STRING(MAX) NOT NULL, content_type STRING(100), value BYTES(MAX), ) PRIMARY KEY (uid)",
+ "CREATE CHANGE STREAM resource_stream FOR resource"
]
diff --git a/pkg/services/sqlstore/migrator/snapshot/spanner-log.json b/pkg/services/sqlstore/migrator/snapshot/spanner-log.json
index f9f39d4083b..e1944599ca3 100644
--- a/pkg/services/sqlstore/migrator/snapshot/spanner-log.json
+++ b/pkg/services/sqlstore/migrator/snapshot/spanner-log.json
@@ -642,6 +642,8 @@
"add index in alert_rule_version table on rule_org_id, rule_uid, rule_guid and version columns",
"add index in alert_rule_version table on rule_guid and version columns",
"add index in alert_rule table on guid columns",
+ "add missing_series_evals_to_resolve column to alert_rule",
+ "add missing_series_evals_to_resolve column to alert_rule_version",
"create data_source_usage_by_day table",
"create data_source_usage_by_day(data_source_id) index",
"create data_source_usage_by_day(data_source_id, day) unique index",
diff --git a/pkg/services/sqlstore/migrator/spanner_dialect.go b/pkg/services/sqlstore/migrator/spanner_dialect.go
index 31a6fbb354d..1bd540ea4b1 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,10 +14,7 @@ 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"
"xorm.io/xorm"
@@ -147,6 +143,9 @@ func (s *SpannerDialect) TruncateDBTables(engine *xorm.Engine) error {
switch table.Name {
case "":
continue
+ case "autoincrement_sequences":
+ // Don't delete sequence number for migration_log.id column.
+ statements = append(statements, fmt.Sprintf("DELETE FROM %v WHERE name <> 'migration_log:id'", s.Quote(table.Name)))
case "migration_log":
continue
case "dashboard_acl":
@@ -173,12 +172,15 @@ func (s *SpannerDialect) CleanDB(engine *xorm.Engine) error {
// Collect all DROP statements.
var statements []string
- for _, table := range tables {
- // Ignore these tables used by Unified storage.
- if table.Name == "resource" || table.Name == "resource_blob" || table.Name == "resource_history" {
- continue
- }
+ changeStreams, err := s.findChangeStreams(engine)
+ if err != nil {
+ return err
+ }
+ for _, cs := range changeStreams {
+ statements = append(statements, fmt.Sprintf("DROP CHANGE STREAM `%s`", cs))
+ }
+ for _, table := range tables {
// Indexes must be dropped first, otherwise dropping tables fails.
for _, index := range table.Indexes {
if !index.IsRegular {
@@ -288,7 +290,7 @@ func (s *SpannerDialect) executeDDLStatements(ctx context.Context, engine *xorm.
return err
}
- opts := SpannerConnectorConfigToClientOptions(cfg)
+ opts := xorm.SpannerConnectorConfigToClientOptions(cfg)
databaseAdminClient, err := database.NewDatabaseAdminClient(ctx, opts...)
if err != nil {
@@ -313,28 +315,27 @@ 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"
}
+
+func (s *SpannerDialect) findChangeStreams(engine *xorm.Engine) ([]string, error) {
+ var result []string
+ query := `SELECT c.CHANGE_STREAM_NAME
+ FROM INFORMATION_SCHEMA.CHANGE_STREAMS AS C
+ WHERE C.CHANGE_STREAM_CATALOG=''
+ AND C.CHANGE_STREAM_SCHEMA=''`
+ rows, err := engine.DB().Query(query)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var name string
+ if err := rows.Scan(&name); err != nil {
+ return nil, err
+ }
+ result = append(result, name)
+ }
+ return result, nil
+}
diff --git a/pkg/services/sqlstore/migrator/sqlite_dialect.go b/pkg/services/sqlstore/migrator/sqlite_dialect.go
index 7a691b4d5d2..8303527ba40 100644
--- a/pkg/services/sqlstore/migrator/sqlite_dialect.go
+++ b/pkg/services/sqlstore/migrator/sqlite_dialect.go
@@ -103,7 +103,7 @@ func (db *SQLite3) CleanDB(engine *xorm.Engine) error {
// TruncateDBTables deletes all data from all the tables and resets the sequences.
// A special case is the dashboard_acl table where we keep the default permissions.
func (db *SQLite3) TruncateDBTables(engine *xorm.Engine) error {
- tables, err := engine.DBMetas()
+ tables, err := engine.Dialect().GetTables()
if err != nil {
return err
}
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/store/testdata/public_testdata.golden.jsonc b/pkg/services/store/testdata/public_testdata.golden.jsonc
index 03f9a7f16cc..ea77fd3d487 100644
--- a/pkg/services/store/testdata/public_testdata.golden.jsonc
+++ b/pkg/services/store/testdata/public_testdata.golden.jsonc
@@ -87,5 +87,4 @@
}
}
]
-}
-
+}
\ No newline at end of file
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..43d78edacef 100644
--- a/pkg/setting/setting.go
+++ b/pkg/setting/setting.go
@@ -129,12 +129,13 @@ type Cfg struct {
Packaging string
// Paths
- HomePath string
- ProvisioningPath string
- DataPath string
- LogsPath string
- PluginsPath string
- EnterpriseLicensePath string
+ HomePath string
+ ProvisioningPath string
+ PermittedProvisioningPaths []string
+ DataPath string
+ LogsPath string
+ PluginsPath string
+ EnterpriseLicensePath string
// SMTP email settings
Smtp SmtpSettings
@@ -181,6 +182,9 @@ type Cfg struct {
DataProxyWhiteList map[string]bool
ActionsAllowPostURL string
+ // K8s Dashboard Cleanup
+ K8sDashboardCleanup K8sDashboardCleanupSettings
+
TempDataLifetime time.Duration
// Plugins
@@ -549,8 +553,6 @@ type Cfg struct {
HttpsSkipVerify bool
}
-const UnifiedStorageConfigKeyDashboard = "dashboards.dashboard.grafana.app"
-
type UnifiedStorageConfig struct {
DualWriterMode rest.DualWriterMode
DualWriterPeriodicDataSyncJobEnabled bool
@@ -1138,6 +1140,10 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error {
return err
}
+ if err := cfg.readProvisioningSettings(iniFile); err != nil {
+ return err
+ }
+
// read dashboard settings
dashboards := iniFile.Section("dashboards")
cfg.DashboardVersionsToKeep = dashboards.Key("versions_to_keep").MustInt(20)
@@ -1291,6 +1297,7 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error {
cfg.readDataSourcesSettings()
cfg.readDataSourceSecuritySettings()
+ cfg.readK8sDashboardCleanupSettings()
cfg.readSqlDataSourceSettings()
cfg.Storage = readStorageSettings(iniFile)
@@ -2022,6 +2029,24 @@ func (cfg *Cfg) readLiveSettings(iniFile *ini.File) error {
return nil
}
+func (cfg *Cfg) readProvisioningSettings(iniFile *ini.File) error {
+ provisioning := valueAsString(iniFile.Section("paths"), "provisioning", "")
+ cfg.ProvisioningPath = makeAbsolute(provisioning, cfg.HomePath)
+
+ provisioningPaths := strings.TrimSpace(valueAsString(iniFile.Section("paths"), "permitted_provisioning_paths", ""))
+ if provisioningPaths != "|" && provisioningPaths != "" {
+ cfg.PermittedProvisioningPaths = strings.Split(provisioningPaths, "|")
+ for i, s := range cfg.PermittedProvisioningPaths {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return fmt.Errorf("a provisioning path is empty in '%s' (at index %d)", provisioningPaths, i)
+ }
+ cfg.PermittedProvisioningPaths[i] = makeAbsolute(s, cfg.HomePath)
+ }
+ }
+ return nil
+}
+
func (cfg *Cfg) readPublicDashboardsSettings() {
publicDashboards := cfg.Raw.Section("public_dashboards")
cfg.PublicDashboardsEnabled = publicDashboards.Key("enabled").MustBool(true)
diff --git a/pkg/setting/setting_cloud_migration.go b/pkg/setting/setting_cloud_migration.go
index 57bacc1d20b..dce1ac5fa7c 100644
--- a/pkg/setting/setting_cloud_migration.go
+++ b/pkg/setting/setting_cloud_migration.go
@@ -32,7 +32,6 @@ type CloudMigrationSettings struct {
CreateTokenTimeout time.Duration
DeleteTokenTimeout time.Duration
TokenExpiresAfter time.Duration
- FeedbackURL string
FrontendPollInterval time.Duration
AlertRulesState string
@@ -60,7 +59,6 @@ func (cfg *Cfg) readCloudMigrationSettings() {
cfg.CloudMigration.DeleteTokenTimeout = cloudMigration.Key("delete_token_timeout").MustDuration(5 * time.Second)
cfg.CloudMigration.TokenExpiresAfter = cloudMigration.Key("token_expires_after").MustDuration(7 * 24 * time.Hour)
cfg.CloudMigration.IsDeveloperMode = cloudMigration.Key("developer_mode").MustBool(false)
- cfg.CloudMigration.FeedbackURL = cloudMigration.Key("feedback_url").MustString("")
cfg.CloudMigration.FrontendPollInterval = cloudMigration.Key("frontend_poll_interval").MustDuration(2 * time.Second)
cfg.CloudMigration.AlertRulesState = cloudMigration.Key("alert_rules_state").In(GMSAlertRulesPaused, []string{GMSAlertRulesPaused, GMSAlertRulesUnchanged})
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 b2389d3d8b6..7e3c955fa26 100644
--- a/pkg/storage/legacysql/dualwrite/dualwriter.go
+++ b/pkg/storage/legacysql/dualwrite/dualwriter.go
@@ -3,6 +3,7 @@ package dualwrite
import (
"context"
"fmt"
+ "time"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
@@ -12,6 +13,7 @@ import (
"k8s.io/apiserver/pkg/registry/rest"
"github.com/grafana/grafana-app-sdk/logging"
+
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
)
@@ -19,6 +21,8 @@ var (
_ grafanarest.Storage = (*dualWriter)(nil)
)
+const backgroundReqTimeout = 5 * time.Second
+
// dualWriter will write first to legacy, then to unified keeping the same internal ID
type dualWriter struct {
legacy grafanarest.Storage
@@ -29,35 +33,60 @@ type dualWriter struct {
}
func (d *dualWriter) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
- // Call get (send read traffic in cloud)
- unifiedGet, unifiedErr := d.unified.Get(ctx, name, options)
+ // If we read from unified, we can just do that and return.
if d.readUnified {
- return unifiedGet, unifiedErr
+ return d.unified.Get(ctx, name, options)
}
-
+ // If legacy is still our main store, lets first read from it.
legacyGet, err := d.legacy.Get(ctx, name, options)
if err != nil {
return nil, err
}
-
- if unifiedErr != nil && !apierrors.IsNotFound(unifiedErr) && !d.errorIsOK {
- return nil, unifiedErr // the unified error
+ // 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 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.
+ _, unifiedErr := d.unified.Get(ctx, name, options)
+ if unifiedErr != nil && !apierrors.IsNotFound(unifiedErr) {
+ return nil, unifiedErr
}
return legacyGet, nil
}
func (d *dualWriter) List(ctx context.Context, options *metainternalversion.ListOptions) (runtime.Object, error) {
- // Call list (send read traffic in cloud)
- unifiedList, err := d.unified.List(ctx, options)
+ // If we read from unified, we can just do that and return.
if d.readUnified {
- return unifiedList, err
+ return d.unified.List(ctx, options)
}
-
- if err != nil && !d.errorIsOK {
+ // If legacy is still the main store, lets first read from it.
+ legacyList, err := d.legacy.List(ctx, options)
+ if err != nil {
return nil, err
}
-
- return d.legacy.List(ctx, options)
+ // 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 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.
+ if _, err := d.unified.List(ctx, options); err != nil {
+ return nil, err
+ }
+ return legacyList, nil
}
// Create overrides the behavior of the generic DualWriter and writes to LegacyStorage and Storage.
@@ -93,23 +122,42 @@ func (d *dualWriter) Create(ctx context.Context, in runtime.Object, createValida
accCreated.SetResourceVersion("")
accCreated.SetUID("")
- storageObj, errObjectSt := d.unified.Create(ctx, createdCopy, createValidation, options)
- if errObjectSt != nil {
- log.Error("unable to create object in unified storage", "err", errObjectSt)
- if d.errorIsOK {
- return createdFromLegacy, nil
- }
-
- // if we cannot create in unistore, attempt to clean up legacy
- _, _, err = d.legacy.Delete(ctx, accCreated.GetName(), nil, &metav1.DeleteOptions{})
- if err != nil {
- log.Error("unable to cleanup object in legacy storage", "err", err)
- }
- return nil, errObjectSt
- }
-
+ // If unified storage is the primary storage, let's just create it in the foreground and return it.
if d.readUnified {
+ storageObj, errObjectSt := d.unified.Create(ctx, createdCopy, createValidation, options)
+ if errObjectSt != nil {
+ log.Error("unable to create object in unified storage", "err", errObjectSt)
+ // If we cannot create in unified storage, attempt to clean up legacy.
+ _, _, err = d.legacy.Delete(ctx, accCreated.GetName(), nil, &metav1.DeleteOptions{})
+ if err != nil {
+ log.Error("unable to cleanup object in legacy storage", "err", err)
+ }
+ return nil, errObjectSt
+ }
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 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 {
+ log.Error("unable to create object in unified storage", "err", err)
+ if d.errorIsOK {
+ return createdFromLegacy, nil
+ }
+
+ // If we cannot create in unified storage, attempt to clean up legacy.
+ _, _, errLegacy := d.legacy.Delete(ctx, accCreated.GetName(), nil, &metav1.DeleteOptions{})
+ if errLegacy != nil {
+ log.Error("unable to cleanup object in legacy storage", "err", errLegacy)
+ }
+ return nil, err
+ }
}
return createdFromLegacy, nil
}
@@ -125,16 +173,28 @@ func (d *dualWriter) Delete(ctx context.Context, name string, deleteValidation r
if err != nil && (!d.readUnified || !d.errorIsOK && !apierrors.IsNotFound(err)) {
return nil, false, err
}
-
- objFromStorage, asyncStorage, err := d.unified.Delete(ctx, name, deleteValidation, options)
+ // If unified storage is our primary store, just delete it and return
+ if d.readUnified {
+ objFromStorage, asyncStorage, err := d.unified.Delete(ctx, name, deleteValidation, options)
+ if err != nil && !apierrors.IsNotFound(err) && !d.errorIsOK {
+ return nil, false, err
+ }
+ 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 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)
if err != nil && !apierrors.IsNotFound(err) && !d.errorIsOK {
return nil, false, err
}
-
- if d.readUnified {
- return objFromStorage, asyncStorage, nil
- }
-
return objFromLegacy, asyncLegacy, nil
}
@@ -157,20 +217,23 @@ func (d *dualWriter) Update(ctx context.Context, name string, objInfo rest.Updat
log.With("object", objFromLegacy).Error("could not update in legacy storage", "err", err)
return nil, false, err
}
-
- objFromStorage, created, err := d.unified.Update(ctx, name, objInfo, createValidation, updateValidation, forceAllowCreate, options)
- if err != nil {
- log.With("object", objFromStorage).Error("could not update in storage", "err", err)
- if d.errorIsOK {
- return objFromLegacy, createdLegacy, nil
- }
+ // If unified storage is our primary store, just update it there and return.
+ if d.readUnified {
+ 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 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.
+ if _, _, err := d.unified.Update(ctx, name, objInfo, createValidation, updateValidation, forceAllowCreate, options); err != nil {
return nil, false, err
}
-
- if d.readUnified {
- return objFromStorage, created, nil
- }
-
return objFromLegacy, createdLegacy, nil
}
@@ -190,19 +253,24 @@ func (d *dualWriter) DeleteCollection(ctx context.Context, deleteValidation rest
return nil, err
}
- deletedStorage, err := d.unified.DeleteCollection(ctx, deleteValidation, options, listOptions)
- if err != nil {
+ // If unified is the primary store, we can just delete it there and return.
+ if d.readUnified {
+ 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 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.
+ if deletedStorage, err := d.unified.DeleteCollection(ctx, deleteValidation, options, listOptions); err != nil {
log.With("deleted", deletedStorage).Error("failed to delete collection successfully from Storage", "err", err)
- if d.errorIsOK {
- return deletedLegacy, nil
- }
return nil, err
}
-
- if d.readUnified {
- return deletedStorage, nil
- }
-
return deletedLegacy, nil
}
diff --git a/pkg/storage/legacysql/dualwrite/dualwriter_mode1_test.go b/pkg/storage/legacysql/dualwrite/dualwriter_mode1_test.go
index 74a73a573c2..0de214c4596 100644
--- a/pkg/storage/legacysql/dualwrite/dualwriter_mode1_test.go
+++ b/pkg/storage/legacysql/dualwrite/dualwriter_mode1_test.go
@@ -129,6 +129,15 @@ func TestMode1_Get(t *testing.T) {
m.On("Get", mock.Anything, name, mock.Anything).Return(nil, errors.New("error"))
},
},
+ {
+ name: "should not block for unified storage",
+ setupLegacyFn: func(m *mock.Mock, name string) {
+ m.On("Get", mock.Anything, name, mock.Anything).Return(exampleObj, nil)
+ },
+ setupStorageFn: func(m *mock.Mock, name string) {
+ m.On("Get", mock.Anything, name, mock.Anything).WaitUntil(time.After(time.Hour)).Return(anotherObj, nil)
+ },
+ },
}
name := "foo"
diff --git a/pkg/storage/secret/metadata/keeper_store.go b/pkg/storage/secret/metadata/keeper_store.go
new file mode 100644
index 00000000000..ebc58801fd8
--- /dev/null
+++ b/pkg/storage/secret/metadata/keeper_store.go
@@ -0,0 +1,48 @@
+package metadata
+
+import (
+ "context"
+
+ claims "github.com/grafana/authlib/types"
+ secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1"
+ "github.com/grafana/grafana/pkg/infra/db"
+ "github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
+ "github.com/grafana/grafana/pkg/registry/apis/secret/xkube"
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
+ "k8s.io/apimachinery/pkg/apis/meta/internalversion"
+)
+
+func ProvideKeeperMetadataStorage(db db.DB, features featuremgmt.FeatureToggles, accessClient claims.AccessClient) (contracts.KeeperMetadataStorage, error) {
+ if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) ||
+ !features.IsEnabledGlobally(featuremgmt.FlagSecretsManagementAppPlatform) {
+ return &keeperMetadataStorage{}, nil
+ }
+
+ return &keeperMetadataStorage{db: db, accessClient: accessClient}, nil
+}
+
+// keeperMetadataStorage is the actual implementation of the keeper metadata storage.
+type keeperMetadataStorage struct {
+ db db.DB
+ accessClient claims.AccessClient
+}
+
+func (s *keeperMetadataStorage) Create(ctx context.Context, keeper *secretv0alpha1.Keeper) (*secretv0alpha1.Keeper, error) {
+ return nil, nil
+}
+
+func (s *keeperMetadataStorage) Read(ctx context.Context, namespace xkube.Namespace, name string) (*secretv0alpha1.Keeper, error) {
+ return nil, nil
+}
+
+func (s *keeperMetadataStorage) Update(ctx context.Context, newKeeper *secretv0alpha1.Keeper) (*secretv0alpha1.Keeper, error) {
+ return nil, nil
+}
+
+func (s *keeperMetadataStorage) Delete(ctx context.Context, namespace xkube.Namespace, name string) error {
+ return nil
+}
+
+func (s *keeperMetadataStorage) List(ctx context.Context, namespace xkube.Namespace, options *internalversion.ListOptions) (*secretv0alpha1.KeeperList, error) {
+ return nil, nil
+}
diff --git a/pkg/storage/secret/metadata/secure_value_store.go b/pkg/storage/secret/metadata/secure_value_store.go
new file mode 100644
index 00000000000..1c55a430548
--- /dev/null
+++ b/pkg/storage/secret/metadata/secure_value_store.go
@@ -0,0 +1,49 @@
+package metadata
+
+import (
+ "context"
+
+ claims "github.com/grafana/authlib/types"
+
+ secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1"
+ "github.com/grafana/grafana/pkg/infra/db"
+ "github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
+ "github.com/grafana/grafana/pkg/registry/apis/secret/xkube"
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
+ "k8s.io/apimachinery/pkg/apis/meta/internalversion"
+)
+
+func ProvideSecureValueMetadataStorage(db db.DB, features featuremgmt.FeatureToggles, accessClient claims.AccessClient) (contracts.SecureValueMetadataStorage, error) {
+ if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) ||
+ !features.IsEnabledGlobally(featuremgmt.FlagSecretsManagementAppPlatform) {
+ return &secureValueMetadataStorage{}, nil
+ }
+
+ return &secureValueMetadataStorage{db: db, accessClient: accessClient}, nil
+}
+
+// secureValueMetadataStorage is the actual implementation of the secure value metadata storage.
+type secureValueMetadataStorage struct {
+ db db.DB
+ accessClient claims.AccessClient
+}
+
+func (s *secureValueMetadataStorage) Create(ctx context.Context, sv *secretv0alpha1.SecureValue) (*secretv0alpha1.SecureValue, error) {
+ return nil, nil
+}
+
+func (s *secureValueMetadataStorage) Read(ctx context.Context, namespace xkube.Namespace, name string) (*secretv0alpha1.SecureValue, error) {
+ return nil, nil
+}
+
+func (s *secureValueMetadataStorage) Update(ctx context.Context, newSecureValue *secretv0alpha1.SecureValue) (*secretv0alpha1.SecureValue, error) {
+ return nil, nil
+}
+
+func (s *secureValueMetadataStorage) Delete(ctx context.Context, namespace xkube.Namespace, name string) error {
+ return nil
+}
+
+func (s *secureValueMetadataStorage) List(ctx context.Context, namespace xkube.Namespace, options *internalversion.ListOptions) (*secretv0alpha1.SecureValueList, error) {
+ return nil, nil
+}
diff --git a/pkg/storage/unified/README.md b/pkg/storage/unified/README.md
index d19d679b77c..8efde1c2c91 100644
--- a/pkg/storage/unified/README.md
+++ b/pkg/storage/unified/README.md
@@ -203,37 +203,6 @@ then run:
kubectl --kubeconfig=./grafana.kubeconfig create -f folder-generate.yaml
```
-### Use a separate database
-
-By default Unified Storage uses the Grafana database. To run against a separate database, update `custom.ini` by adding the following section to it:
-
-```
-[resource_api]
-db_type = mysql
-db_host = localhost:3306
-db_name = grafana
-db_user =
-db_pass =
-```
-
-MySQL and Postgres are both supported. The `` and `