diff --git a/.betterer.eslint.config.js b/.betterer.eslint.config.js index c1a38731291..ae8b600826f 100644 --- a/.betterer.eslint.config.js +++ b/.betterer.eslint.config.js @@ -157,4 +157,23 @@ module.exports = [ 'no-barrel-files/no-barrel-files': 'error', }, }, + { + // custom rule for Table to avoid performance regressions + files: ['packages/grafana-ui/src/components/Table/TableNG/Cells/**/*.{ts,tsx}'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['**/themes/ThemeContext'], + importNames: ['useStyles2', 'useTheme2'], + message: + 'Do not use "useStyles2" or "useTheme2" in a cell directly. Instead, provide styles to cells via `getDefaultCellStyles` or `getCellSpecificStyles`.', + }, + ], + }, + ], + }, + }, ]; diff --git a/.betterer.results b/.betterer.results index f8eaec8ae3f..6aec09ff9c7 100644 --- a/.betterer.results +++ b/.betterer.results @@ -502,8 +502,8 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Do not use any type assertions.", "2"], [0, 0, 0, "Do not use any type assertions.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"] + [0, 0, 0, "Do not use any type assertions.", "4"], + [0, 0, 0, "Do not use any type assertions.", "5"] ], "packages/grafana-runtime/src/services/EchoSrv.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -1063,8 +1063,7 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Do not use any type assertions.", "2"], [0, 0, 0, "Do not use any type assertions.", "3"], - [0, 0, 0, "Do not use any type assertions.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"] + [0, 0, 0, "Do not use any type assertions.", "4"] ], "public/app/core/utils/object.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], @@ -2076,10 +2075,6 @@ exports[`better eslint`] = { [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"] ], "public/app/features/dashboard/components/TransformationsEditor/TransformationPicker.tsx:5381": [ - [0, 0, 0, "\'VerticalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"], - [0, 0, 0, "Add noMargin prop to Card components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"] - ], - "public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.tsx:5381": [ [0, 0, 0, "Add noMargin prop to Card components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"] ], "public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx:5381": [ @@ -2153,6 +2148,9 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "26"], [0, 0, 0, "Unexpected any. Specify a different type.", "27"] ], + "public/app/features/dashboard/state/DashboardMigratorToBackend.test.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], "public/app/features/dashboard/state/DashboardModel.repeat.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -2299,9 +2297,6 @@ exports[`better eslint`] = { "public/app/features/dimensions/editors/ValueMappingsEditor/ValueMappingEditRow.tsx:5381": [ [0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"] ], - "public/app/features/dimensions/editors/ValueMappingsEditor/ValueMappingsEditor.tsx:5381": [ - [0, 0, 0, "\'VerticalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"] - ], "public/app/features/dimensions/scale.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], @@ -2404,9 +2399,6 @@ exports[`better eslint`] = { "public/app/features/expressions/guards.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/features/geo/editor/locationModeEditor.tsx:5381": [ - [0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"] - ], "public/app/features/geo/gazetteer/gazetteer.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] @@ -2496,8 +2488,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "3"], [0, 0, 0, "Unexpected any. Specify a different type.", "4"], [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"] + [0, 0, 0, "Unexpected any. Specify a different type.", "6"] ], "public/app/features/manage-dashboards/state/reducers.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], @@ -2805,9 +2796,6 @@ exports[`better eslint`] = { "public/app/features/transformers/FilterByValueTransformer/ValueMatchers/valueMatchersUI.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/features/transformers/calculateHeatmap/editor/AxisEditor.tsx:5381": [ - [0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"] - ], "public/app/features/transformers/calculateHeatmap/editor/helper.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -2827,10 +2815,6 @@ exports[`better eslint`] = { "public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/features/transformers/editors/EnumMappingEditor.tsx:5381": [ - [0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"], - [0, 0, 0, "\'VerticalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "1"] - ], "public/app/features/transformers/editors/EnumMappingRow.tsx:5381": [ [0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"] ], @@ -2866,15 +2850,9 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "3"], [0, 0, 0, "Unexpected any. Specify a different type.", "4"] ], - "public/app/features/transformers/joinByLabels/JoinByLabelsTransformerEditor.tsx:5381": [ - [0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"] - ], "public/app/features/transformers/lookupGazetteer/FieldLookupTransformerEditor.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/features/transformers/partitionByValues/PartitionByValuesEditor.tsx:5381": [ - [0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"] - ], "public/app/features/transformers/spatial/optionsHelper.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], @@ -3683,9 +3661,6 @@ exports[`better eslint`] = { "public/app/plugins/panel/annolist/AnnotationListItem.tsx:5381": [ [0, 0, 0, "Add noMargin prop to Card components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"] ], - "public/app/plugins/panel/barchart/TickSpacingEditor.tsx:5381": [ - [0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"] - ], "public/app/plugins/panel/barchart/bars.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], @@ -3747,11 +3722,8 @@ exports[`better eslint`] = { "public/app/plugins/panel/geomap/editor/GeomapStyleRulesEditor.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/plugins/panel/geomap/editor/MapViewEditor.tsx:5381": [ - [0, 0, 0, "\'VerticalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"] - ], "public/app/plugins/panel/geomap/editor/StyleEditor.tsx:5381": [ - [0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"], + [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"], [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"], [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "2"], [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "3"], @@ -3765,7 +3737,7 @@ exports[`better eslint`] = { [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "11"], [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "12"], [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "13"], - [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "14"], + [0, 0, 0, "Do not use any type assertions.", "14"], [0, 0, 0, "Do not use any type assertions.", "15"], [0, 0, 0, "Do not use any type assertions.", "16"], [0, 0, 0, "Do not use any type assertions.", "17"], @@ -3776,8 +3748,7 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "22"], [0, 0, 0, "Do not use any type assertions.", "23"], [0, 0, 0, "Do not use any type assertions.", "24"], - [0, 0, 0, "Do not use any type assertions.", "25"], - [0, 0, 0, "Do not use any type assertions.", "26"] + [0, 0, 0, "Do not use any type assertions.", "25"] ], "public/app/plugins/panel/geomap/editor/StyleRuleEditor.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] @@ -3903,8 +3874,7 @@ exports[`better eslint`] = { [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"] ], "public/app/plugins/panel/table/cells/SparklineCellOptionsEditor.tsx:5381": [ - [0, 0, 0, "\'VerticalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"], - [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"] + [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"] ], "public/app/plugins/panel/table/migrations.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -3947,15 +3917,6 @@ exports[`better eslint`] = { "public/app/plugins/panel/text/textPanelMigrationHandler.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/plugins/panel/timeseries/InsertNullsEditor.tsx:5381": [ - [0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"] - ], - "public/app/plugins/panel/timeseries/LineStyleEditor.tsx:5381": [ - [0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"] - ], - "public/app/plugins/panel/timeseries/SpanNullsEditor.tsx:5381": [ - [0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"] - ], "public/app/plugins/panel/timeseries/migrations.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], @@ -3980,8 +3941,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/plugins/panel/timeseries/plugins/annotations2/AnnotationTooltip2.tsx:5381": [ - [0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/plugins/panel/xychart/SeriesEditor.tsx:5381": [ [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"], @@ -4221,36 +4181,24 @@ exports[`no skipping a11y tests in stories`] = { "packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.story.tsx:5381": [ [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] ], - "packages/grafana-ui/src/components/ColorPicker/ColorPicker.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], "packages/grafana-ui/src/components/Combobox/MultiCombobox.story.tsx:5381": [ [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] ], "packages/grafana-ui/src/components/FileDropzone/FileDropzone.story.tsx:5381": [ [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] ], - "packages/grafana-ui/src/components/FormField/FormField.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], "packages/grafana-ui/src/components/Forms/Checkbox.story.tsx:5381": [ [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] ], "packages/grafana-ui/src/components/Forms/FieldArray.story.tsx:5381": [ [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] ], - "packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], "packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.story.tsx:5381": [ [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] ], "packages/grafana-ui/src/components/Layout/Stack/Stack.story.tsx:5381": [ [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] ], - "packages/grafana-ui/src/components/Link/TextLink.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], "packages/grafana-ui/src/components/Menu/Menu.story.tsx:5381": [ [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] ], @@ -4281,9 +4229,6 @@ exports[`no skipping a11y tests in stories`] = { "packages/grafana-ui/src/components/Table/Table.story.tsx:5381": [ [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] ], - "packages/grafana-ui/src/components/VizLayout/VizLayout.story.tsx:5381": [ - [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] - ], "packages/grafana-ui/src/components/VizLegend/VizLegend.story.tsx:5381": [ [0, 0, 0, "No skipping of a11y tests in stories. Please fix the component or story instead.", "5381"] ] diff --git a/.drone.yml b/.drone.yml index 52bdd9dc125..bbd3e820619 100644 --- a/.drone.yml +++ b/.drone.yml @@ -50,7 +50,7 @@ steps: - apk add --update make - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: golang:1.24.5-alpine + image: golang:1.24.6-alpine name: verify-gen-cue trigger: branch: main @@ -117,7 +117,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.24.5-alpine + image: golang:1.24.6-alpine name: compile-build-cmd - commands: - '# It is required that code generated from Thema/CUE be committed and in sync @@ -127,7 +127,7 @@ steps: - apk add --update make - CODEGEN_VERIFY=1 make gen-cue depends_on: [] - image: golang:1.24.5-alpine + image: golang:1.24.6-alpine name: verify-gen-cue - commands: - '# It is required that generated jsonnet is committed and in sync with its inputs.' @@ -136,7 +136,7 @@ steps: - apk add --update make - CODEGEN_VERIFY=1 make gen-jsonnet depends_on: [] - image: golang:1.24.5-alpine + image: golang:1.24.6-alpine name: verify-gen-jsonnet - commands: - yarn install --immutable || yarn install --immutable @@ -186,7 +186,7 @@ steps: environment: _EXPERIMENTAL_DAGGER_CLOUD_TOKEN: from_secret: dagger_token - image: golang:1.24.5-alpine + image: golang:1.24.6-alpine name: rgm-package pull: always volumes: @@ -664,7 +664,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.24.5-alpine + image: golang:1.24.6-alpine name: compile-build-cmd - commands: - ./bin/build artifacts docker fetch --edition oss @@ -794,7 +794,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.24.5-alpine + image: golang:1.24.6-alpine name: compile-build-cmd - commands: - ./bin/build artifacts docker fetch --edition oss @@ -935,7 +935,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.24.5-alpine + image: golang:1.24.6-alpine name: compile-build-cmd - commands: - ./bin/build artifacts packages --artifacts-editions=oss --tag $${DRONE_TAG} --src-bucket @@ -1027,7 +1027,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.24.5-alpine + image: golang:1.24.6-alpine name: compile-build-cmd - commands: - yarn install --immutable || yarn install --immutable @@ -1127,7 +1127,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.24.5-alpine + image: golang:1.24.6-alpine name: compile-build-cmd - depends_on: - compile-build-cmd @@ -1224,7 +1224,7 @@ steps: depends_on: [] environment: CGO_ENABLED: 0 - image: golang:1.24.5-alpine + image: golang:1.24.6-alpine name: compile-build-cmd - commands: - ./bin/build publish grafana-com --edition oss ${DRONE_TAG} @@ -1298,7 +1298,7 @@ steps: STORYBOOK_DESTINATION: from_secret: rgm_storybook_destination UBUNTU_BASE: ubuntu:22.04 - image: golang:1.24.5-alpine + image: golang:1.24.6-alpine name: rgm-build pull: always volumes: @@ -1375,7 +1375,7 @@ steps: STORYBOOK_DESTINATION: from_secret: rgm_storybook_destination UBUNTU_BASE: ubuntu:22.04 - image: golang:1.24.5-alpine + image: golang:1.24.6-alpine name: rgm-build pull: always volumes: @@ -1494,7 +1494,7 @@ steps: STORYBOOK_DESTINATION: from_secret: rgm_storybook_destination UBUNTU_BASE: ubuntu:22.04 - image: golang:1.24.5-alpine + image: golang:1.24.6-alpine name: rgm-build pull: always volumes: @@ -1619,7 +1619,7 @@ steps: STORYBOOK_DESTINATION: from_secret: rgm_storybook_destination UBUNTU_BASE: ubuntu:22.04 - image: golang:1.24.5-alpine + image: golang:1.24.6-alpine name: rgm-build pull: always volumes: @@ -1873,3 +1873,8 @@ get: path: secret/data/common/gcr kind: secret name: gcr_credentials +--- +kind: signature +hmac: 70171a24c7e9ab7af105bd39d02f9aa6d28a4505963dfc9140f53cdfe4965ef8 + +... diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 350790df440..67ef71a4cf9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -43,8 +43,8 @@ /docs/sources/datasources/ @lwandz13 /docs/sources/panels-visualizations/ @imatwawana /docs/sources/release-notes/ @irenerl24 @GrafanaWriter -/docs/sources/upgrade-guide/ @imatwawana -/docs/sources/whatsnew/ @imatwawana +/docs/sources/upgrade-guide/ @jtvdez +/docs/sources/whatsnew/ @jtvdez /docs/sources/developers/plugins/ @grafana/plugins-platform-frontend @grafana/plugins-platform-backend @@ -115,6 +115,7 @@ /pkg/components/loki/ @grafana/grafana-backend-group /pkg/components/null/ @grafana/grafana-backend-group /pkg/components/simplejson/ @grafana/grafana-backend-group +/pkg/configprovider/ @grafana/grafana-backend-services-squad /pkg/events/ @grafana/grafana-backend-group /pkg/extensions/ @grafana/grafana-backend-group /pkg/ifaces/ @grafana/grafana-backend-group @@ -302,7 +303,7 @@ /devenv/docker/blocks/sensugo/ @grafana/grafana-backend-group /devenv/docker/blocks/slow_proxy/ @bergquist /devenv/docker/blocks/smtp/ @bergquist -/devenv/docker/blocks/tempo/ @grafana/observability-traces-and-profiling +/devenv/docker/blocks/tempo/ @grafana/oss-big-tent @grafana/observability-traces-and-profiling /devenv/docker/blocks/traefik/ @mckn /devenv/docker/blocks/zipkin/ @grafana/oss-big-tent /devenv/docker/blocks/webdav/ @grafana/alerting-backend @@ -349,7 +350,7 @@ /pkg/tsdb/prometheus/ @grafana/oss-big-tent /pkg/tsdb/elasticsearch/ @grafana/partner-datasources /pkg/tsdb/loki/ @grafana/oss-big-tent -/pkg/tsdb/tempo/ @grafana/observability-traces-and-profiling +/pkg/tsdb/tempo/ @grafana/oss-big-tent @grafana/observability-traces-and-profiling /pkg/tsdb/grafana-pyroscope-datasource/ @grafana/observability-traces-and-profiling /pkg/tsdb/parca/ @grafana/oss-big-tent @@ -554,10 +555,10 @@ /packages/grafana-o11y-ds-frontend/src/IntervalInput/ @grafana/observability-traces-and-profiling /packages/grafana-o11y-ds-frontend/src/NodeGraph/ @grafana/observability-traces-and-profiling /packages/grafana-o11y-ds-frontend/src/pyroscope/ @grafana/observability-traces-and-profiling -/packages/grafana-o11y-ds-frontend/src/SpanBar/ @grafana/observability-traces-and-profiling -/packages/grafana-o11y-ds-frontend/src/TraceToLogs/ @grafana/observability-traces-and-profiling -/packages/grafana-o11y-ds-frontend/src/TraceToMetrics/ @grafana/observability-traces-and-profiling -/packages/grafana-o11y-ds-frontend/src/TraceToProfiles/ @grafana/observability-traces-and-profiling +/packages/grafana-o11y-ds-frontend/src/SpanBar/ @grafana/oss-big-tent @grafana/observability-traces-and-profiling +/packages/grafana-o11y-ds-frontend/src/TraceToLogs/ @grafana/oss-big-tent @grafana/observability-traces-and-profiling +/packages/grafana-o11y-ds-frontend/src/TraceToMetrics/ @grafana/oss-big-tent @grafana/observability-traces-and-profiling +/packages/grafana-o11y-ds-frontend/src/TraceToProfiles/ @grafana/oss-big-tent @grafana/observability-traces-and-profiling # @grafana/plugin-configs /packages/grafana-plugin-configs/ @grafana/plugins-platform-frontend @@ -607,6 +608,7 @@ /packages/grafana-runtime/src/utils/returnToPrevious.ts @grafana/grafana-search-navigate-organise /packages/grafana-runtime/src/utils/toDataQueryError.ts @grafana/grafana-datasources-core-services /packages/grafana-runtime/src/utils/userStorage* @grafana/plugins-platform-frontend @grafana/grafana-frontend-platform +/packages/grafana-runtime/src/utils/useFavoriteDatasources* @grafana/plugins-platform-frontend # @grafana/schema /packages/grafana-schema/ @grafana/grafana-app-platform-squad @@ -642,7 +644,7 @@ /packages/grafana-schema/src/**/statetimeline @grafana/dataviz-squad /packages/grafana-schema/src/**/statushistory @grafana/dataviz-squad /packages/grafana-schema/src/**/table @grafana/dataviz-squad -/packages/grafana-schema/src/**/tempo @grafana/observability-traces-and-profiling +/packages/grafana-schema/src/**/tempo @grafana/oss-big-tent @grafana/observability-traces-and-profiling /packages/grafana-schema/src/**/text @grafana/dataviz-squad /packages/grafana-schema/src/**/timeseries @grafana/dataviz-squad /packages/grafana-schema/src/**/trend @grafana/dataviz-squad @@ -737,7 +739,6 @@ playwright.storybook.config.ts @grafana/grafana-frontend-platform /public/app/core/components/GraphNG/ @grafana/dataviz-squad /public/app/core/components/help/ @grafana/grafana-search-navigate-organise /public/app/core/components/Indent/ @grafana/grafana-search-navigate-organise -/public/app/core/components/InviteUserButton/ @grafana/sharing-squad /public/app/core/components/Layers/ @grafana/dataviz-squad /public/app/core/components/Login/ @grafana/grafana-search-navigate-organise /public/app/core/components/NativeScrollbar.tsx @grafana/grafana-search-navigate-organise @@ -1029,7 +1030,7 @@ playwright.storybook.config.ts @grafana/grafana-frontend-platform /public/app/plugins/datasource/prometheus/ @grafana/oss-big-tent /public/app/plugins/datasource/cloud-monitoring/ @grafana/partner-datasources /public/app/plugins/datasource/zipkin/ @grafana/oss-big-tent -/public/app/plugins/datasource/tempo/ @grafana/observability-traces-and-profiling +/public/app/plugins/datasource/tempo/ @grafana/oss-big-tent @grafana/observability-traces-and-profiling /public/app/plugins/datasource/grafana-pyroscope-datasource/ @grafana/observability-traces-and-profiling /public/app/plugins/datasource/parca/ @grafana/oss-big-tent /public/app/plugins/datasource/alertmanager/ @grafana/alerting-squad @@ -1134,6 +1135,7 @@ embed.go @grafana/grafana-as-code /.github/actions/setup-grafana-bench/ @Proximyst /.github/actions/build-package @grafana/grafana-developer-enablement-squad /.github/actions/change-detection @grafana/grafana-developer-enablement-squad +/.github/actions/setup-node @grafana/grafana-frontend-platform /.github/workflows/actionlint-format.txt @grafana/grafana-developer-enablement-squad /.github/workflows/actionlint.yml @grafana/grafana-developer-enablement-squad /.github/workflows/add-to-whats-new.yml @grafana/docs-tooling diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index d54ad5d2f1f..aac54bd425c 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -6,3 +6,23 @@ self-hosted-runner: - github-hosted-ubuntu-x64-small - github-hosted-ubuntu-x64-large - github-hosted-windows-x64-large + - ubuntu-x64 + - ubuntu-x64-io + - ubuntu-x64-small + - ubuntu-x64-small-io + - ubuntu-x64-large + - ubuntu-x64-large-io + - ubuntu-x64-xlarge + - ubuntu-x64-xlarge-io + - ubuntu-x64-2xlarge + - ubuntu-x64-2xlarge-io + - ubuntu-arm64 + - ubuntu-arm64-io + - ubuntu-arm64-small + - ubuntu-arm64-small-io + - ubuntu-arm64-large + - ubuntu-arm64-large-io + - ubuntu-arm64-xlarge + - ubuntu-arm64-xlarge-io + - ubuntu-arm64-2xlarge + - ubuntu-arm64-2xlarge-io \ No newline at end of file diff --git a/.github/actions/setup-node/action.yml b/.github/actions/setup-node/action.yml new file mode 100644 index 00000000000..5762389f83b --- /dev/null +++ b/.github/actions/setup-node/action.yml @@ -0,0 +1,11 @@ +name: Setup Node.js +description: Sets up a node.js environment with presets for the Grafana repository. + +runs: + using: "composite" + steps: + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + cache-dependency-path: 'yarn.lock' \ No newline at end of file diff --git a/.github/commands.json b/.github/commands.json index 06f91a56c59..9923a064fd2 100644 --- a/.github/commands.json +++ b/.github/commands.json @@ -947,6 +947,14 @@ "url": "https://github.com/orgs/grafana/projects/202" } }, + { + "type": "label", + "name": "area/gitsync", + "action": "addToProject", + "addToProject": { + "url": "https://github.com/orgs/grafana/projects/792" + } + }, { "type": "label", "name": "area/backend/db/sqlite", diff --git a/.github/workflows/alerting-update-module.yml b/.github/workflows/alerting-update-module.yml index 0b8d5730618..ea7e5d27e22 100644 --- a/.github/workflows/alerting-update-module.yml +++ b/.github/workflows/alerting-update-module.yml @@ -51,7 +51,7 @@ jobs: run: | TO_COMMIT="$(gh api repos/grafana/alerting/commits/"$BRANCH" --jq '.sha')" if [ -z "$TO_COMMIT" ]; then - echo "Branch $BRANCH not found in alerting repo, falling back to main branch" + echo "Branch $BRANCH not found in alerting repo" exit 1 fi echo "to_commit=$TO_COMMIT" >> "$GITHUB_OUTPUT" @@ -85,7 +85,7 @@ jobs: printf "%s\n" "$ALERTING_COMMITS" # make the list for markdown and replace PR numbers with links - ALERTING_COMMITS_FORMATTED="$(echo "$ALERTING_COMMITS" | while read -r line; do echo "- $line" | sed -E 's/\(#([0-9]+)\)/[#\1](https:\/\/github.com\/grafana\/grafana\/pull\/\1)/g'; done)" + ALERTING_COMMITS_FORMATTED="$(echo "$ALERTING_COMMITS" | while read -r line; do echo "- $line" | sed -E 's/\(#([0-9]+)\)/[#\1](https:\/\/github.com\/grafana\/alerting\/pull\/\1)/g'; done)" { echo "alerting_commits<> "$GITHUB_STEP_SUMMARY" + - name: Send Slack Message + uses: grafana/shared-workflows/actions/send-slack-message@send-slack-message/v2.0.3 + with: + method: 'chat.postMessage' + # send to alerting-reviews channel + payload-templated: true + payload: | + { + "channel": "C076RNRRZ2N", + "text": "Update alerting module in Grafana ${{ steps.create-pr.outputs.pull-request-url }}" + } \ No newline at end of file diff --git a/.github/workflows/auto-triager/labels.txt b/.github/workflows/auto-triager/labels.txt index 517a6ad867d..18ef2df93d8 100644 --- a/.github/workflows/auto-triager/labels.txt +++ b/.github/workflows/auto-triager/labels.txt @@ -14,6 +14,7 @@ area/backend/db/mysql area/backend/db/postgres area/backend/db/sql area/backend/db/sqlite +area/gitsync area/configuration area/dashboard/annotations area/dashboard/data-links diff --git a/.github/workflows/backend-code-checks.yml b/.github/workflows/backend-code-checks.yml index bf7252a95f5..b33e2c259ba 100644 --- a/.github/workflows/backend-code-checks.yml +++ b/.github/workflows/backend-code-checks.yml @@ -14,12 +14,31 @@ on: - 'docs/**' - 'latest.json' -permissions: - contents: read - id-token: write - jobs: + detect-changes: + name: Detect whether code changed + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + changed: ${{ steps.detect-changes.outputs.backend }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: true # required to get more history in the changed-files action + fetch-depth: 2 + - name: Detect changes + id: detect-changes + uses: ./.github/actions/change-detection + with: + self: .github/workflows/backend-code-checks.yml + validate-configs: + permissions: + contents: read + id-token: write + needs: detect-changes + if: needs.detect-changes.outputs.changed == 'true' name: Validate Backend Configs runs-on: ubuntu-latest steps: @@ -39,7 +58,7 @@ jobs: run: | CODEGEN_VERIFY=1 make gen-cue CODEGEN_VERIFY=1 make gen-jsonnet - + - name: Validate go.mod run: go run scripts/modowners/modowners.go check go.mod @@ -48,7 +67,7 @@ jobs: - name: Setup Grafana Enterprise if: github.event.pull_request.head.repo.fork == false uses: ./.github/actions/setup-enterprise - + - name: Generate and Validate OpenAPI Specs run: | # For PRs from forks, we'll just run the basic swagger-gen without validation @@ -57,10 +76,10 @@ jobs: make swagger-gen exit 0 fi - + # Clean and regenerate OpenAPI specs make swagger-clean && make openapi3-gen - + # Check if the generated specs differ from what's in the repository for f in public/api-merged.json public/openapi3.json; do git add $f; done if [ -z "$(git diff --name-only --cached)" ]; then diff --git a/.github/workflows/deploy-storybook-preview.yml b/.github/workflows/deploy-storybook-preview.yml index a1fecce67cb..57c4ee5bbd4 100644 --- a/.github/workflows/deploy-storybook-preview.yml +++ b/.github/workflows/deploy-storybook-preview.yml @@ -32,19 +32,7 @@ jobs: persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - - - name: Cache node_modules - uses: actions/cache@v4 - with: - path: | - node_modules - key: node_modules-${{ hashFiles('yarn.lock') }} - restore-keys: | - node_modules- + uses: ./.github/actions/setup-node - name: Install dependencies env: diff --git a/.github/workflows/go-lint.yml b/.github/workflows/go-lint.yml index f870c16a847..ec9bfc62bec 100644 --- a/.github/workflows/go-lint.yml +++ b/.github/workflows/go-lint.yml @@ -13,7 +13,27 @@ permissions: contents: read jobs: + detect-changes: + name: Detect whether code changed + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + changed: ${{ steps.detect-changes.outputs.backend }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: true # required to get more history in the changed-files action + fetch-depth: 2 + - name: Detect changes + id: detect-changes + uses: ./.github/actions/change-detection + with: + self: .github/workflows/go-lint.yml + lint-go: + needs: detect-changes + if: needs.detect-changes.outputs.changed == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/pr-dependabot-update-go-workspace.yml b/.github/workflows/pr-dependabot-update-go-workspace.yml index b0dbc68c266..5e9eb65c81e 100644 --- a/.github/workflows/pr-dependabot-update-go-workspace.yml +++ b/.github/workflows/pr-dependabot-update-go-workspace.yml @@ -17,7 +17,7 @@ permissions: jobs: update: runs-on: "ubuntu-latest" - if: ${{ github.actor == 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository }} + if: ${{ github.event.pull_request.user.login == 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository }} continue-on-error: true steps: - name: Retrieve GitHub App secrets diff --git a/.github/workflows/pr-e2e-tests.yml b/.github/workflows/pr-e2e-tests.yml index 824b621d198..03ba03b8031 100644 --- a/.github/workflows/pr-e2e-tests.yml +++ b/.github/workflows/pr-e2e-tests.yml @@ -55,7 +55,7 @@ jobs: - uses: actions/cache@v4 id: cache with: - key: "build-grafana-${{ runner.os }}-${{ hashFiles('yarn.lock', 'public/*', 'packages/*', 'pkg/**/*.go', '**/go.mod', '**/go.sum', '!**_test.go', '!**.test.ts', '!**.test.tsx', 'Dockerfile') }}" + key: "build-grafana-${{ runner.os }}-${{ hashFiles('yarn.lock', 'public/*', 'packages/*', 'conf/*', 'pkg/**/*.go', '**/go.mod', '**/go.sum', '!**_test.go', '!**.test.ts', '!**.test.tsx', 'Dockerfile') }}" path: | build-dir diff --git a/.github/workflows/pr-frontend-unit-tests.yml b/.github/workflows/pr-frontend-unit-tests.yml index 283a186c50d..1d320df49e9 100644 --- a/.github/workflows/pr-frontend-unit-tests.yml +++ b/.github/workflows/pr-frontend-unit-tests.yml @@ -11,7 +11,7 @@ permissions: {} jobs: detect-changes: name: Detect whether code changed - runs-on: ubuntu-latest + runs-on: ubuntu-x64-small permissions: contents: read outputs: @@ -31,100 +31,115 @@ jobs: permissions: contents: read id-token: write + needs: + - detect-changes # Run this workflow only for PRs from forks; if it gets merged into `main` or `release-*`, # the `frontend-unit-tests-enterprise` workflow will run instead - needs: detect-changes if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true && needs.detect-changes.outputs.changed == 'true' - runs-on: ubuntu-latest-8-cores - name: "Unit tests (${{ matrix.chunk }} / 8)" + runs-on: ubuntu-x64-large + name: "Unit tests (${{ matrix.shard }} / ${{ matrix.total }})" strategy: fail-fast: false matrix: - chunk: [1, 2, 3, 4, 5, 6, 7, 8] + shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16] + total: [16] steps: - uses: actions/checkout@v4 with: persist-credentials: false - - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - cache-dependency-path: 'yarn.lock' - - run: yarn install --immutable --check-cache + - name: Setup Node.js + uses: ./.github/actions/setup-node + - name: Yarn install + run: yarn install --immutable + env: + PUPPETEER_SKIP_DOWNLOAD: true + CYPRESS_INSTALL_BINARY: 0 - run: yarn run test:ci env: - TEST_MAX_WORKERS: 2 - TEST_SHARD: ${{ matrix.chunk }} - TEST_SHARD_TOTAL: 8 + TEST_MAX_WORKERS: 4 + TEST_SHARD: ${{ matrix.shard }} + TEST_SHARD_TOTAL: ${{ matrix.total }} frontend-unit-tests-enterprise: permissions: contents: read id-token: write + needs: + - detect-changes # Run this workflow for non-PR events (like pushes to `main` or `release-*`) OR for internal PRs (PRs not from forks) - needs: detect-changes if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false && needs.detect-changes.outputs.changed == 'true' - runs-on: ubuntu-latest-8-cores - name: "Unit tests (${{ matrix.chunk }} / 8)" + runs-on: ubuntu-x64-large + name: "Unit tests (${{ matrix.shard }} / ${{ matrix.total }})" strategy: fail-fast: false matrix: - chunk: [1, 2, 3, 4, 5, 6, 7, 8] + shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16] + total: [16] steps: - uses: actions/checkout@v4 with: persist-credentials: false - - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - cache-dependency-path: 'yarn.lock' - name: Setup Enterprise uses: ./.github/actions/setup-enterprise with: github-app-name: 'grafana-ci-bot' - - run: yarn install --immutable --check-cache + - name: Setup Node.js + uses: ./.github/actions/setup-node + - name: Yarn install + run: yarn install --immutable + env: + # Switch from default hardened mode to faster mode for internal PRs + YARN_ENABLE_HARDENED_MODE: ${{ github.event.pull_request.head.repo.fork == false && '1' || '0' }} + PUPPETEER_SKIP_DOWNLOAD: true + CYPRESS_INSTALL_BINARY: 0 - run: yarn run test:ci env: - TEST_MAX_WORKERS: 2 - TEST_SHARD: ${{ matrix.chunk }} - TEST_SHARD_TOTAL: 8 + TEST_MAX_WORKERS: 4 + TEST_SHARD: ${{ matrix.shard }} + TEST_SHARD_TOTAL: ${{ matrix.total }} frontend-decoupled-plugin-tests: - needs: detect-changes + needs: + - detect-changes if: needs.detect-changes.outputs.changed == 'true' - runs-on: ubuntu-latest-8-cores + runs-on: ubuntu-x64-large name: "Decoupled plugin tests" steps: - uses: actions/checkout@v4 with: persist-credentials: false - - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - cache-dependency-path: 'yarn.lock' - - run: yarn install --immutable --check-cache + - name: Setup Node.js + uses: ./.github/actions/setup-node + - name: Yarn install + run: yarn install --immutable + env: + # Switch from default hardened mode to faster mode for internal PRs + YARN_ENABLE_HARDENED_MODE: ${{ github.event.pull_request.head.repo.fork == false && '1' || '0' }} + PUPPETEER_SKIP_DOWNLOAD: true + CYPRESS_INSTALL_BINARY: 0 - run: yarn run plugin:test:ci frontend-packages-unit-tests: - needs: detect-changes + needs: + - detect-changes if: needs.detect-changes.outputs.changed == 'true' - runs-on: ubuntu-latest-8-cores + runs-on: ubuntu-x64-large name: "Packages unit tests" steps: - uses: actions/checkout@v4 with: persist-credentials: false - - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - cache-dependency-path: 'yarn.lock' - - run: yarn install --immutable --check-cache + - name: Setup Node.js + uses: ./.github/actions/setup-node + - name: Yarn install + run: yarn install --immutable + env: + # Switch from default hardened mode to faster mode for internal PRs + YARN_ENABLE_HARDENED_MODE: ${{ github.event.pull_request.head.repo.fork == false && '1' || '0' }} + PUPPETEER_SKIP_DOWNLOAD: true + CYPRESS_INSTALL_BINARY: 0 - run: yarn run packages:test:ci - # This is the job that is actually required by rulesets. # We need to require EITHER the OSS or the Enterprise job to pass. # However, if one is skipped, GitHub won't flat-map the shards, @@ -141,15 +156,14 @@ jobs: if: always() name: All frontend unit tests complete - runs-on: ubuntu-latest + runs-on: ubuntu-x64-small steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Check test suites - env: - NEEDS: ${{ toJson(needs) }} - run: | - FAILURES="$(echo "$NEEDS" | jq 'with_entries(select(.value.result == "failure")) | map_values(.result)')" - echo "$FAILURES" - if [ "$(echo "$FAILURES" | jq '. | length')" != "0" ]; then - exit 1 - fi - echo "All OK!" + uses: ./.github/actions/check-jobs + with: + needs: ${{ toJson(needs) }} + failure-message: "One or more unit test jobs have failed" + success-message: "All unit tests completed successfully" \ No newline at end of file diff --git a/.github/workflows/pr-test-integration.yml b/.github/workflows/pr-test-integration.yml index 314ca335979..6a2bb5cede7 100644 --- a/.github/workflows/pr-test-integration.yml +++ b/.github/workflows/pr-test-integration.yml @@ -18,7 +18,27 @@ concurrency: permissions: {} jobs: + detect-changes: + name: Detect whether code changed + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + changed: ${{ steps.detect-changes.outputs.backend }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: true # required to get more history in the changed-files action + fetch-depth: 2 + - name: Detect changes + id: detect-changes + uses: ./.github/actions/change-detection + with: + self: .github/workflows/pr-test-integration.yml + sqlite: + needs: detect-changes + if: needs.detect-changes.outputs.changed == 'true' strategy: matrix: shard: [ @@ -47,8 +67,10 @@ jobs: run: | set -euo pipefail readarray -t PACKAGES <<< "$(./scripts/ci/backend-tests/pkgs-with-tests-named.sh -b TestIntegration | ./scripts/ci/backend-tests/shard.sh -N"$SHARD" -d-)" - go test -tags=sqlite -timeout=5m -run '^TestIntegration' "${PACKAGES[@]}" + go test -tags=sqlite -timeout=8m -run '^TestIntegration' "${PACKAGES[@]}" mysql: + needs: detect-changes + if: needs.detect-changes.outputs.changed == 'true' strategy: matrix: shard: [ @@ -93,8 +115,10 @@ jobs: run: | set -euo pipefail readarray -t PACKAGES <<< "$(./scripts/ci/backend-tests/pkgs-with-tests-named.sh -b TestIntegration | ./scripts/ci/backend-tests/shard.sh -N"$SHARD" -d-)" - go test -p=1 -tags=mysql -timeout=5m -run '^TestIntegration' "${PACKAGES[@]}" + CGO_ENABLED=0 go test -p=1 -tags=mysql -timeout=8m -run '^TestIntegration' "${PACKAGES[@]}" postgres: + needs: detect-changes + if: needs.detect-changes.outputs.changed == 'true' strategy: matrix: shard: [ @@ -138,7 +162,7 @@ jobs: run: | set -euo pipefail readarray -t PACKAGES <<< "$(./scripts/ci/backend-tests/pkgs-with-tests-named.sh -b TestIntegration | ./scripts/ci/backend-tests/shard.sh -N"$SHARD" -d-)" - go test -p=1 -tags=postgres -timeout=5m -run '^TestIntegration' "${PACKAGES[@]}" + CGO_ENABLED=0 go test -p=1 -tags=postgres -timeout=8m -run '^TestIntegration' "${PACKAGES[@]}" # This is the job that is actually required by rulesets. # We want to only require one job instead of all the individual tests and shards. diff --git a/.github/workflows/release-comms.yml b/.github/workflows/release-comms.yml index 7d149fad297..58ebe84dbc0 100644 --- a/.github/workflows/release-comms.yml +++ b/.github/workflows/release-comms.yml @@ -80,7 +80,7 @@ jobs: create_security_branch_grafana: name: Create security branch (Grafana Security Mirror) needs: setup - uses: ./.github/workflows/create-security-branch.yml + uses: grafana/grafana/.github/workflows/create-security-branch.yml@main with: release_branch: ${{ needs.setup.outputs.release_branch }} security_branch_number: "01" @@ -88,7 +88,7 @@ jobs: create_security_branch_enterprise: name: Create security branch (Enterprise) needs: setup - uses: ./.github/workflows/create-security-branch.yml + uses: grafana/grafana/.github/workflows/create-security-branch.yml@main with: release_branch: ${{ needs.setup.outputs.release_branch }} security_branch_number: "01" diff --git a/.github/workflows/swagger-gen.yml b/.github/workflows/swagger-gen.yml index 0e42e0e7441..9399a366933 100644 --- a/.github/workflows/swagger-gen.yml +++ b/.github/workflows/swagger-gen.yml @@ -14,10 +14,29 @@ concurrency: cancel-in-progress: true jobs: + detect-changes: + name: Detect whether code changed + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + changed: ${{ steps.detect-changes.outputs.backend }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: true # required to get more history in the changed-files action + fetch-depth: 2 + - name: Detect changes + id: detect-changes + uses: ./.github/actions/change-detection + with: + self: .github/workflows/swagger-gen.yml + verify: name: Verify committed API specs match runs-on: ubuntu-latest - if: ${{ github.repository == 'grafana/grafana' }} + needs: detect-changes + if: github.repository == 'grafana/grafana' && needs.detect-changes.outputs.changed == 'true' permissions: contents: read # clone the repository id-token: write # required for Vault access diff --git a/.github/workflows/trufflehog.yml b/.github/workflows/trufflehog.yml index 057a708c129..0a15e4ee13f 100644 --- a/.github/workflows/trufflehog.yml +++ b/.github/workflows/trufflehog.yml @@ -30,7 +30,6 @@ jobs: with: persist-credentials: false fetch-depth: ${{ steps.fetch_depth.outputs.fetch_depth }} - ref: ${{ github.event.pull_request.head.ref }} - name: Trufflehog uses: trufflesecurity/trufflehog@eafb8c5f6a06175141c27f17bcc17941853d0047 # v3.90.0 with: diff --git a/.gitignore b/.gitignore index 7457c8148b3..66a35404ee5 100644 --- a/.gitignore +++ b/.gitignore @@ -117,6 +117,9 @@ profile.cov /pkg/server/wireexts_enterprise.go /pkg/server/enterprise_wire_gen.go /pkg/build/cmd/enterprise.go +/pkg/build/cmd/artifactspage.go +/pkg/build/cmd/artifactspage.tmpl.html +/pkg/build/cmd/exportversion.go /pkg/extensions/* !/pkg/extensions/.keep !/pkg/extensions/main.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a979b3b88e..596ca47fe22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,68 @@ + + +# 12.1.1 (2025-08-13) + +### Features and enhancements + +- **Alerting:** Add rule group name validation to the Prometheus conversion API [#108767](https://github.com/grafana/grafana/pull/108767), [@alexander-akhmetov](https://github.com/alexander-akhmetov) +- **CloudWatch:** Update grafana/aws-sdk-go with STS endpo… [#109357](https://github.com/grafana/grafana/pull/109357), [@iwysiu](https://github.com/iwysiu) +- **Go:** Update to 1.24.6 [#109318](https://github.com/grafana/grafana/pull/109318), [@Proximyst](https://github.com/Proximyst) + +### Bug fixes + +- **Alerting:** Fix active time intervals when time interval is renamed [#108547](https://github.com/grafana/grafana/pull/108547), [@yuri-tceretian](https://github.com/yuri-tceretian) +- **Alerting:** Fix subpath handling in the alerting package [#109505](https://github.com/grafana/grafana/pull/109505), [@konrad147](https://github.com/konrad147) +- **Config:** Fix date_formats options being moved to a different section [#109366](https://github.com/grafana/grafana/pull/109366), [@joshhunt](https://github.com/joshhunt) + + + + +# 12.0.4 (2025-08-13) + +### Features and enhancements + +- **Go:** Update to 1.24.6 [#109317](https://github.com/grafana/grafana/pull/109317), [@Proximyst](https://github.com/Proximyst) + +### Bug fixes + +- **Azure:** Fix time management field [#108481](https://github.com/grafana/grafana/pull/108481), [@aangelisc](https://github.com/aangelisc) +- **Azure:** Fix time management field [#108481](https://github.com/grafana/grafana/pull/108481), [@aangelisc](https://github.com/aangelisc) +- **Azure:** Fix time management field [#108481](https://github.com/grafana/grafana/pull/108481), [@aangelisc](https://github.com/aangelisc) +- **Azure:** Fix time management field [#108481](https://github.com/grafana/grafana/pull/108481), [@aangelisc](https://github.com/aangelisc) + + + + +# 11.6.5 (2025-08-13) + +### Features and enhancements + +- **Alerting:** Bump alerting package to include change to NewTLSClient [#108817](https://github.com/grafana/grafana/pull/108817), [@rwwiv](https://github.com/rwwiv) +- **Go:** Update to 1.24.6 [#109314](https://github.com/grafana/grafana/pull/109314), [@Proximyst](https://github.com/Proximyst) + + + + +# 11.5.8 (2025-08-12) + +### Features and enhancements + +- **Go:** Update to 1.24.6 [#109315](https://github.com/grafana/grafana/pull/109315), [@Proximyst](https://github.com/Proximyst) + + + + +# 11.4.8 (2025-08-12) + +### Features and enhancements + +- **Go:** Update to 1.24.6 [#109316](https://github.com/grafana/grafana/pull/109316), [@Proximyst](https://github.com/Proximyst) + +### Bug fixes + +- **Dashboards:** Panel react for `timeFrom` and `timeShift` changes using variables [#98659](https://github.com/grafana/grafana/pull/98659), [@Sergej-Vlasov](https://github.com/Sergej-Vlasov) + + # 12.1.0 (2025-07-23) diff --git a/Dockerfile b/Dockerfile index b059c1c1a47..82e0053f42c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ ARG JS_SRC=js-builder # By using FROM instructions we can delegate dependency updates to dependabot FROM alpine:3.21.3 AS alpine-base FROM ubuntu:22.04 AS ubuntu-base -FROM golang:1.24.5-alpine AS go-builder-base +FROM golang:1.24.6-alpine AS go-builder-base FROM --platform=${JS_PLATFORM} node:22-alpine AS js-builder-base # Javascript build stage diff --git a/Makefile b/Makefile index 11bf0062277..28595926660 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ include .bingo/Variables.mk include .citools/Variables.mk GO = go -GO_VERSION = 1.24.5 +GO_VERSION = 1.24.6 GO_LINT_FILES ?= $(shell ./scripts/go-workspace/golangci-lint-includes.sh) GO_TEST_FILES ?= $(shell ./scripts/go-workspace/test-includes.sh) SH_FILES ?= $(shell find ./scripts -name *.sh) diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index b6f7f8b800c..06c0acfa3a7 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -1,6 +1,6 @@ module github.com/grafana/grafana/apps/advisor -go 1.24.5 +go 1.24.6 require ( github.com/Masterminds/semver/v3 v3.4.0 diff --git a/apps/alerting/notifications/go.mod b/apps/alerting/notifications/go.mod index 17d35151499..9a620ce12ad 100644 --- a/apps/alerting/notifications/go.mod +++ b/apps/alerting/notifications/go.mod @@ -1,6 +1,6 @@ module github.com/grafana/grafana/apps/alerting/notifications -go 1.24.5 +go 1.24.6 require ( github.com/grafana/grafana-app-sdk v0.40.3 diff --git a/apps/dashboard/go.mod b/apps/dashboard/go.mod index 4cf0617f40b..ed72fa1aa90 100644 --- a/apps/dashboard/go.mod +++ b/apps/dashboard/go.mod @@ -1,12 +1,13 @@ module github.com/grafana/grafana/apps/dashboard -go 1.24.5 +go 1.24.6 require ( cuelang.org/go v0.11.1 github.com/grafana/grafana-app-sdk v0.40.3 github.com/grafana/grafana-plugin-sdk-go v0.278.0 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e + github.com/prometheus/client_golang v1.23.0 github.com/stretchr/testify v1.10.0 k8s.io/apimachinery v0.33.3 k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff @@ -80,7 +81,6 @@ require ( github.com/perimeterx/marshmallow v1.1.5 // 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_golang v1.23.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.65.0 // indirect github.com/prometheus/procfs v0.16.1 // indirect diff --git a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue index 8ab1891e626..2aba16b9489 100644 --- a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue +++ b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue @@ -941,7 +941,7 @@ ConditionalRenderingVariableKind: { ConditionalRenderingVariableSpec: { variable: string - operator: "equals" | "notEquals" + operator: "equals" | "notEquals" | "matches" | "notMatches" value: string } diff --git a/apps/dashboard/kinds/v2beta1/dashboard_spec.cue b/apps/dashboard/kinds/v2beta1/dashboard_spec.cue index cf71e6b2dc4..1c7ca148a08 100644 --- a/apps/dashboard/kinds/v2beta1/dashboard_spec.cue +++ b/apps/dashboard/kinds/v2beta1/dashboard_spec.cue @@ -942,7 +942,7 @@ ConditionalRenderingVariableKind: { ConditionalRenderingVariableSpec: { variable: string - operator: "equals" | "notEquals" + operator: "equals" | "notEquals" | "matches" | "notMatches" value: string } diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue index 160adf4e7bf..f566d3775b6 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue @@ -945,7 +945,7 @@ ConditionalRenderingVariableKind: { ConditionalRenderingVariableSpec: { variable: string - operator: "equals" | "notEquals" + operator: "equals" | "notEquals" | "matches" | "notMatches" value: string } diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go index 9ddbe89502f..f1e36092724 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go @@ -1859,8 +1859,10 @@ const ( type DashboardConditionalRenderingVariableSpecOperator string const ( - DashboardConditionalRenderingVariableSpecOperatorEquals DashboardConditionalRenderingVariableSpecOperator = "equals" - DashboardConditionalRenderingVariableSpecOperatorNotEquals DashboardConditionalRenderingVariableSpecOperator = "notEquals" + DashboardConditionalRenderingVariableSpecOperatorEquals DashboardConditionalRenderingVariableSpecOperator = "equals" + DashboardConditionalRenderingVariableSpecOperatorNotEquals DashboardConditionalRenderingVariableSpecOperator = "notEquals" + DashboardConditionalRenderingVariableSpecOperatorMatches DashboardConditionalRenderingVariableSpecOperator = "matches" + DashboardConditionalRenderingVariableSpecOperatorNotMatches DashboardConditionalRenderingVariableSpecOperator = "notMatches" ) // +k8s:openapi-gen=true diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue index a35eaa482f7..d110a33fa01 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue @@ -946,7 +946,7 @@ ConditionalRenderingVariableKind: { ConditionalRenderingVariableSpec: { variable: string - operator: "equals" | "notEquals" + operator: "equals" | "notEquals" | "matches" | "notMatches" value: string } diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go index d66f582e42a..10e17f2c5e2 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go @@ -1884,8 +1884,10 @@ const ( type DashboardConditionalRenderingVariableSpecOperator string const ( - DashboardConditionalRenderingVariableSpecOperatorEquals DashboardConditionalRenderingVariableSpecOperator = "equals" - DashboardConditionalRenderingVariableSpecOperatorNotEquals DashboardConditionalRenderingVariableSpecOperator = "notEquals" + DashboardConditionalRenderingVariableSpecOperatorEquals DashboardConditionalRenderingVariableSpecOperator = "equals" + DashboardConditionalRenderingVariableSpecOperatorNotEquals DashboardConditionalRenderingVariableSpecOperator = "notEquals" + DashboardConditionalRenderingVariableSpecOperatorMatches DashboardConditionalRenderingVariableSpecOperator = "matches" + DashboardConditionalRenderingVariableSpecOperatorNotMatches DashboardConditionalRenderingVariableSpecOperator = "notMatches" ) // +k8s:openapi-gen=true diff --git a/apps/dashboard/pkg/migration/conversion/conversion.go b/apps/dashboard/pkg/migration/conversion/conversion.go index fb78859fc3c..ca93badb127 100644 --- a/apps/dashboard/pkg/migration/conversion/conversion.go +++ b/apps/dashboard/pkg/migration/conversion/conversion.go @@ -8,8 +8,11 @@ import ( dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" dashv2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" dashv2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" + "github.com/grafana/grafana/pkg/infra/log" ) +var logger = log.New("dashboard.conversion") + func RegisterConversions(s *runtime.Scheme) error { // v0 conversions if err := s.AddConversionFunc((*dashv0.Dashboard)(nil), (*dashv1.Dashboard)(nil), func(a, b interface{}, scope conversion.Scope) error { diff --git a/apps/dashboard/pkg/migration/conversion/v0.go b/apps/dashboard/pkg/migration/conversion/v0.go index a63fe00f611..6ea720a3fb5 100644 --- a/apps/dashboard/pkg/migration/conversion/v0.go +++ b/apps/dashboard/pkg/migration/conversion/v0.go @@ -1,6 +1,9 @@ package conversion import ( + "errors" + "fmt" + "k8s.io/apimachinery/pkg/conversion" dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" @@ -25,8 +28,45 @@ func Convert_V0_to_V1(in *dashv0.Dashboard, out *dashv1.Dashboard, scope convers if err := migration.Migrate(out.Spec.Object, schemaversion.LATEST_VERSION); err != nil { out.Status.Conversion.Failed = true out.Status.Conversion.Error = err.Error() + + // Classify error type for metrics + errorType := "conversion_error" + var migrationErr *schemaversion.MigrationError + var minVersionErr *schemaversion.MinimumVersionError + if errors.As(err, &migrationErr) { + errorType = "schema_version_migration_error" + } else if errors.As(err, &minVersionErr) { + errorType = "schema_minimum_version_error" + } + + // Record failure metrics + migration.MDashboardConversionFailureTotal.WithLabelValues( + dashv0.APIVERSION, + dashv1.APIVERSION, + fmt.Sprintf("%v", in.Spec.Object["schemaVersion"]), + fmt.Sprintf("%d", schemaversion.LATEST_VERSION), + errorType, + ).Inc() + + logger.Error("Dashboard conversion failed", + "sourceVersionAPI", dashv0.APIVERSION, + "targetVersionAPI", dashv1.APIVERSION, + "dashboardUID", in.UID, + "sourceSchemaVersion", in.Spec.Object["schemaVersion"], + "targetSchemaVersion", schemaversion.LATEST_VERSION, + "errorType", errorType, + "error", err) + + return nil } + migration.MDashboardConversionSuccessTotal.WithLabelValues( + dashv0.APIVERSION, + dashv1.APIVERSION, + fmt.Sprintf("%v", in.Spec.Object["schemaVersion"]), + fmt.Sprintf("%d", schemaversion.LATEST_VERSION), + ).Inc() + return nil } diff --git a/apps/dashboard/pkg/migration/metrics.go b/apps/dashboard/pkg/migration/metrics.go new file mode 100644 index 00000000000..04e88370e74 --- /dev/null +++ b/apps/dashboard/pkg/migration/metrics.go @@ -0,0 +1,44 @@ +package migration + +import ( + "github.com/prometheus/client_golang/prometheus" +) + +const ( + metricsNamespace = "grafana" + metricsSubSystem = "dashboard_migration" +) + +var ( + // MDashboardConversionSuccessTotal is a metric counter for successful dashboard conversions + MDashboardConversionSuccessTotal *prometheus.CounterVec + + // MDashboardConversionFailureTotal is a metric counter for failed dashboard conversions + MDashboardConversionFailureTotal *prometheus.CounterVec +) + +func init() { + MDashboardConversionSuccessTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: metricsNamespace, + Subsystem: metricsSubSystem, + Name: "conversion_success_total", + Help: "Total number of successful dashboard conversions", + }, []string{"source_version_api", "target_version_api", "source_schema_version", "target_schema_version"}) + + MDashboardConversionFailureTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: metricsNamespace, + Subsystem: metricsSubSystem, + Name: "conversion_failure_total", + Help: "Total number of failed dashboard conversions", + }, []string{"source_version_api", "target_version_api", "source_schema_version", "target_schema_version", "error_type"}) +} + +// RegisterMetrics registers all migration metrics with the provided Prometheus registerer +func RegisterMetrics(reg prometheus.Registerer) { + if reg != nil { + reg.MustRegister( + MDashboardConversionSuccessTotal, + MDashboardConversionFailureTotal, + ) + } +} diff --git a/apps/dashboard/pkg/migration/schemaversion/angular_migration.go b/apps/dashboard/pkg/migration/schemaversion/angular_migration.go new file mode 100644 index 00000000000..7dee79d8c29 --- /dev/null +++ b/apps/dashboard/pkg/migration/schemaversion/angular_migration.go @@ -0,0 +1,89 @@ +package schemaversion + +var notPersistedProperties = []string{ + "events", + "isViewing", + "isEditing", + "isInView", + "hasRefreshed", + "cachedPluginOptions", + "plugin", + "queryRunner", + "replaceVariables", + "configRev", + "hasSavedPanelEditChange", + "getDisplayTitle", + "dataSupport", + "key", + "isNew", + "refreshWhenInView", +} + +var mustKeepProperties = []string{ + "id", + "gridPos", + "type", + "title", + "scopedVars", + "repeat", + "repeatPanelId", + "repeatDirection", + "repeatedByRow", + "minSpan", + "collapsed", + "panels", + "targets", + "datasource", + "timeFrom", + "timeShift", + "hideTimeOverride", + "description", + "links", + "fullscreen", + "isEditing", + "isViewing", + "hasRefreshed", + "events", + "cacheTimeout", + "queryCachingTTL", + "cachedPluginOptions", + "transparent", + "pluginVersion", + "queryRunner", + "transformations", + "fieldConfig", + "maxDataPoints", + "interval", + "replaceVariables", + "libraryPanel", + "getDisplayTitle", + "configRev", + "key", +} + +// getOptionsToRemember returns a map of panel properties that should be remembered +// during panel type changes, excluding notPersistedProperties and mustKeepProperties +func getOptionsToRemember(panel map[string]interface{}) map[string]interface{} { + // Create sets for faster lookup + notPersistedSet := make(map[string]bool) + for _, prop := range notPersistedProperties { + notPersistedSet[prop] = true + } + + mustKeepSet := make(map[string]bool) + for _, prop := range mustKeepProperties { + mustKeepSet[prop] = true + } + + // Filter the panel properties + result := make(map[string]interface{}) + for key, value := range panel { + // Skip properties that are in notPersistedProperties or mustKeepProperties + if notPersistedSet[key] || mustKeepSet[key] { + continue + } + result[key] = value + } + + return result +} diff --git a/apps/dashboard/pkg/migration/schemaversion/migrations.go b/apps/dashboard/pkg/migration/schemaversion/migrations.go index 1863661e133..e4c2ec13ddc 100644 --- a/apps/dashboard/pkg/migration/schemaversion/migrations.go +++ b/apps/dashboard/pkg/migration/schemaversion/migrations.go @@ -5,7 +5,7 @@ import ( ) const ( - MIN_VERSION = 25 + MIN_VERSION = 16 LATEST_VERSION = 41 ) @@ -38,6 +38,15 @@ type PanelPluginInfoProvider interface { func GetMigrations(dsInfoProvider DataSourceInfoProvider, panelProvider PanelPluginInfoProvider) map[int]SchemaVersionMigrationFunc { return map[int]SchemaVersionMigrationFunc{ + 17: V17, + 18: V18, + 19: V19, + 20: V20, + 21: V21, + 22: V22, + 23: V23, + 24: V24(panelProvider), + 25: V25, 26: V26, 27: V27, 28: V28(panelProvider), diff --git a/apps/dashboard/pkg/migration/schemaversion/v17.go b/apps/dashboard/pkg/migration/schemaversion/v17.go new file mode 100644 index 00000000000..dc1142ef7e5 --- /dev/null +++ b/apps/dashboard/pkg/migration/schemaversion/v17.go @@ -0,0 +1,132 @@ +package schemaversion + +import ( + "math" + "sort" +) + +// V17 migrates panel minSpan property to maxPerRow property. +// This migration converts the deprecated minSpan property to the newer maxPerRow property +// which controls how many panels can be displayed in a single row. +// +// The conversion algorithm: +// 1. Calculate max = GRID_COLUMN_COUNT / panel.minSpan +// 2. Get all factors of GRID_COLUMN_COUNT (24): [1, 2, 3, 4, 6, 8, 12, 24] +// 3. Find the first factor greater than max +// 4. Use the previous factor as maxPerRow +// +// Example before migration: +// +// "panels": [ +// { +// "id": 1, +// "type": "graph", +// "minSpan": 8 +// } +// ] +// +// Example after migration: +// +// "panels": [ +// { +// "id": 1, +// "type": "graph", +// "maxPerRow": 3 +// } +// ] +// +// The minSpan property is removed after conversion. +func V17(dashboard map[string]interface{}) error { + dashboard["schemaVersion"] = 17 + + panels, ok := dashboard["panels"].([]interface{}) + if !ok { + return nil + } + + for _, p := range panels { + panel, ok := p.(map[string]interface{}) + if !ok { + continue + } + + migrateMinSpanToMaxPerRow(panel) + } + + return nil +} + +// migrateMinSpanToMaxPerRow converts minSpan to maxPerRow using the same algorithm as the frontend. +func migrateMinSpanToMaxPerRow(panel map[string]interface{}) { + minSpanValue, ok := panel["minSpan"] + if !ok { + return + } + + // Convert minSpan to a number (could be int, float64, etc.) + var minSpan float64 + switch v := minSpanValue.(type) { + case int: + minSpan = float64(v) + case float64: + minSpan = v + case int64: + minSpan = float64(v) + default: + // If we can't convert minSpan to a number, just delete it and return + delete(panel, "minSpan") + return + } + + // Ensure minSpan is positive to avoid division by zero + if minSpan <= 0 { + delete(panel, "minSpan") + return + } + + const gridColumnCount = 24 + max := gridColumnCount / minSpan + factors := getFactors(gridColumnCount) + + // Find the first factor greater than max + factorIndex := -1 + for i, factor := range factors { + if float64(factor) > max { + factorIndex = i + break + } + } + + // Use the previous factor as maxPerRow + if factorIndex > 0 { + panel["maxPerRow"] = factors[factorIndex-1] + } else if factorIndex == 0 { + // If the first factor is already greater than max, use 1 + panel["maxPerRow"] = 1 + } else { + // If no factor is greater than max, use the largest factor + panel["maxPerRow"] = factors[len(factors)-1] + } + + // Remove the minSpan property + delete(panel, "minSpan") +} + +// getFactors returns all factors of a number +// Example: getFactors(24) returns [1, 2, 3, 4, 6, 8, 12, 24] +func getFactors(num int) []int { + factors := []int{} + for i := 1; i <= int(math.Sqrt(float64(num))); i++ { + if num%i == 0 { + factors = append(factors, i) + if i != num/i { + factors = append(factors, num/i) + } + } + } + + // Sort factors in ascending order + sort.Ints(factors) + + return factors +} diff --git a/apps/dashboard/pkg/migration/schemaversion/v17_test.go b/apps/dashboard/pkg/migration/schemaversion/v17_test.go new file mode 100644 index 00000000000..9339a6b1a1b --- /dev/null +++ b/apps/dashboard/pkg/migration/schemaversion/v17_test.go @@ -0,0 +1,297 @@ +package schemaversion_test + +import ( + "testing" + + "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" +) + +func TestV17(t *testing.T) { + tests := []migrationTestCase{ + { + name: "panel with minSpan 8 gets converted to maxPerRow 3", + input: map[string]interface{}{ + "title": "V17 MinSpan to MaxPerRow Migration Test Dashboard", + "schemaVersion": 16, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "graph", + "title": "Test Panel", + "minSpan": 8, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V17 MinSpan to MaxPerRow Migration Test Dashboard", + "schemaVersion": 17, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "graph", + "title": "Test Panel", + "maxPerRow": 3, + }, + }, + }, + }, + { + name: "panel with minSpan 4 gets converted to maxPerRow 6", + input: map[string]interface{}{ + "title": "V17 MinSpan Migration Test", + "schemaVersion": 16, + "panels": []interface{}{ + map[string]interface{}{ + "id": 2, + "type": "singlestat", + "title": "Single Stat Panel", + "minSpan": 4, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V17 MinSpan Migration Test", + "schemaVersion": 17, + "panels": []interface{}{ + map[string]interface{}{ + "id": 2, + "type": "singlestat", + "title": "Single Stat Panel", + "maxPerRow": 6, + }, + }, + }, + }, + { + name: "panel with minSpan 2 gets converted to maxPerRow 12", + input: map[string]interface{}{ + "title": "V17 MinSpan Migration Test", + "schemaVersion": 16, + "panels": []interface{}{ + map[string]interface{}{ + "id": 3, + "type": "graph", + "title": "Wide Panel", + "minSpan": 2, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V17 MinSpan Migration Test", + "schemaVersion": 17, + "panels": []interface{}{ + map[string]interface{}{ + "id": 3, + "type": "graph", + "title": "Wide Panel", + "maxPerRow": 12, + }, + }, + }, + }, + { + name: "panel with minSpan 12 gets converted to maxPerRow 2", + input: map[string]interface{}{ + "title": "V17 MinSpan Migration Test", + "schemaVersion": 16, + "panels": []interface{}{ + map[string]interface{}{ + "id": 4, + "type": "graph", + "title": "Narrow Panel", + "minSpan": 12, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V17 MinSpan Migration Test", + "schemaVersion": 17, + "panels": []interface{}{ + map[string]interface{}{ + "id": 4, + "type": "graph", + "title": "Narrow Panel", + "maxPerRow": 2, + }, + }, + }, + }, + { + name: "panel with minSpan 24 gets converted to maxPerRow 1", + input: map[string]interface{}{ + "title": "V17 MinSpan Migration Test", + "schemaVersion": 16, + "panels": []interface{}{ + map[string]interface{}{ + "id": 5, + "type": "graph", + "title": "Full Width Panel", + "minSpan": 24, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V17 MinSpan Migration Test", + "schemaVersion": 17, + "panels": []interface{}{ + map[string]interface{}{ + "id": 5, + "type": "graph", + "title": "Full Width Panel", + "maxPerRow": 1, + }, + }, + }, + }, + { + name: "panel with minSpan 1 gets converted to maxPerRow 24", + input: map[string]interface{}{ + "title": "V17 MinSpan Migration Test", + "schemaVersion": 16, + "panels": []interface{}{ + map[string]interface{}{ + "id": 6, + "type": "graph", + "title": "Tiny Panel", + "minSpan": 1, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V17 MinSpan Migration Test", + "schemaVersion": 17, + "panels": []interface{}{ + map[string]interface{}{ + "id": 6, + "type": "graph", + "title": "Tiny Panel", + "maxPerRow": 24, + }, + }, + }, + }, + { + name: "multiple panels with different minSpan values", + input: map[string]interface{}{ + "title": "V17 Multiple Panels Migration Test", + "schemaVersion": 16, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "graph", + "title": "Panel 1", + "minSpan": 8, + }, + map[string]interface{}{ + "id": 2, + "type": "singlestat", + "title": "Panel 2", + "minSpan": 4, + }, + map[string]interface{}{ + "id": 3, + "type": "table", + "title": "Panel 3 - No minSpan", + }, + }, + }, + expected: map[string]interface{}{ + "title": "V17 Multiple Panels Migration Test", + "schemaVersion": 17, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "graph", + "title": "Panel 1", + "maxPerRow": 3, + }, + map[string]interface{}{ + "id": 2, + "type": "singlestat", + "title": "Panel 2", + "maxPerRow": 6, + }, + map[string]interface{}{ + "id": 3, + "type": "table", + "title": "Panel 3 - No minSpan", + }, + }, + }, + }, + { + name: "panel with invalid minSpan gets cleaned up", + input: map[string]interface{}{ + "title": "V17 Invalid MinSpan Test", + "schemaVersion": 16, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "graph", + "title": "Invalid MinSpan Panel", + "minSpan": "invalid", + }, + map[string]interface{}{ + "id": 2, + "type": "graph", + "title": "Zero MinSpan Panel", + "minSpan": 0, + }, + map[string]interface{}{ + "id": 3, + "type": "graph", + "title": "Negative MinSpan Panel", + "minSpan": -5, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V17 Invalid MinSpan Test", + "schemaVersion": 17, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "graph", + "title": "Invalid MinSpan Panel", + }, + map[string]interface{}{ + "id": 2, + "type": "graph", + "title": "Zero MinSpan Panel", + }, + map[string]interface{}{ + "id": 3, + "type": "graph", + "title": "Negative MinSpan Panel", + }, + }, + }, + }, + { + name: "dashboard with no panels", + input: map[string]interface{}{ + "title": "V17 No Panels Test", + "schemaVersion": 16, + }, + expected: map[string]interface{}{ + "title": "V17 No Panels Test", + "schemaVersion": 17, + }, + }, + { + name: "dashboard with empty panels array", + input: map[string]interface{}{ + "title": "V17 Empty Panels Test", + "schemaVersion": 16, + "panels": []interface{}{}, + }, + expected: map[string]interface{}{ + "title": "V17 Empty Panels Test", + "schemaVersion": 17, + "panels": []interface{}{}, + }, + }, + } + + runMigrationTests(t, tests, schemaversion.V17) +} diff --git a/apps/dashboard/pkg/migration/schemaversion/v18.go b/apps/dashboard/pkg/migration/schemaversion/v18.go new file mode 100644 index 00000000000..275c1123fb5 --- /dev/null +++ b/apps/dashboard/pkg/migration/schemaversion/v18.go @@ -0,0 +1,117 @@ +package schemaversion + +// V18 migrates gauge panel options from the legacy `options-gauge` format to the new `options` format. +// This migration restructures gauge panel configuration to use the modern options structure with valueOptions. +// +// Example before migration: +// +// "panels": [ +// { +// "type": "gauge", +// "options-gauge": { +// "unit": "ms", +// "stat": "last", +// "decimals": 2, +// "prefix": "Value: ", +// "suffix": " ms", +// "thresholds": [ +// { "color": "green", "value": 0 }, +// { "color": "red", "value": 100 } +// ] +// } +// } +// ] +// +// Example after migration: +// +// "panels": [ +// { +// "type": "gauge", +// "options": { +// "valueOptions": { +// "unit": "ms", +// "stat": "last", +// "decimals": 2, +// "prefix": "Value: ", +// "suffix": " ms" +// }, +// "thresholds": [ +// { "color": "red", "value": 100 }, +// { "color": "green", "value": 0 } +// ] +// } +// } +// ] +func V18(dashboard map[string]interface{}) error { + dashboard["schemaVersion"] = 18 + + panels, ok := dashboard["panels"].([]interface{}) + if !ok { + return nil + } + + for _, p := range panels { + panel, ok := p.(map[string]interface{}) + if !ok { + continue + } + + migrateGaugePanelOptions(panel) + } + + return nil +} + +func migrateGaugePanelOptions(panel map[string]interface{}) { + optionsGauge, hasOptionsGauge := panel["options-gauge"].(map[string]interface{}) + if !hasOptionsGauge { + return + } + + options := map[string]interface{}{} + + valueOptions := map[string]interface{}{} + if unit, ok := optionsGauge["unit"]; ok { + valueOptions["unit"] = unit + } + if stat, ok := optionsGauge["stat"]; ok { + valueOptions["stat"] = stat + } + if decimals, ok := optionsGauge["decimals"]; ok { + valueOptions["decimals"] = decimals + } + if prefix, ok := optionsGauge["prefix"]; ok { + valueOptions["prefix"] = prefix + } + if suffix, ok := optionsGauge["suffix"]; ok { + valueOptions["suffix"] = suffix + } + + options["valueOptions"] = valueOptions + + if thresholds, ok := optionsGauge["thresholds"].([]interface{}); ok && len(thresholds) > 0 { + reversedThresholds := make([]interface{}, len(thresholds)) + for i, threshold := range thresholds { + reversedThresholds[len(thresholds)-1-i] = threshold + } + options["thresholds"] = reversedThresholds + } + + // Copy any other properties from options-gauge to options + for key, value := range optionsGauge { + // Skip properties that were moved to valueOptions or are being deleted + if key == "options" || key == "unit" || key == "stat" || key == "decimals" || key == "prefix" || key == "suffix" || key == "thresholds" { + continue + } + options[key] = value + } + + panel["options"] = options + delete(panel, "options-gauge") + + // Clean up options.options property if it exists + // This options prop was due to a bug + if panelOptions, ok := panel["options"].(map[string]interface{}); ok { + delete(panelOptions, "options") + } +} diff --git a/apps/dashboard/pkg/migration/schemaversion/v18_test.go b/apps/dashboard/pkg/migration/schemaversion/v18_test.go new file mode 100644 index 00000000000..94fc93f30d5 --- /dev/null +++ b/apps/dashboard/pkg/migration/schemaversion/v18_test.go @@ -0,0 +1,219 @@ +package schemaversion_test + +import ( + "testing" + + "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" +) + +func TestV18(t *testing.T) { + tests := []migrationTestCase{ + { + name: "gauge panel with legacy options-gauge gets migrated to new options format", + input: map[string]interface{}{ + "title": "V18 Gauge Options Migration Test Dashboard", + "schemaVersion": 17, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "gauge", + "title": "Gauge Panel", + "options-gauge": map[string]interface{}{ + "unit": "ms", + "stat": "last", + "decimals": 2, + "prefix": "Value: ", + "suffix": " ms", + "thresholds": []interface{}{ + map[string]interface{}{"color": "green", "value": 0}, + map[string]interface{}{"color": "red", "value": 100}, + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V18 Gauge Options Migration Test Dashboard", + "schemaVersion": 18, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "gauge", + "title": "Gauge Panel", + "options": map[string]interface{}{ + "valueOptions": map[string]interface{}{ + "unit": "ms", + "stat": "last", + "decimals": 2, + "prefix": "Value: ", + "suffix": " ms", + }, + "thresholds": []interface{}{ + map[string]interface{}{"color": "red", "value": 100}, + map[string]interface{}{"color": "green", "value": 0}, + }, + }, + }, + }, + }, + }, + { + name: "gauge panel with only some gauge options gets migrated correctly", + input: map[string]interface{}{ + "title": "V18 Partial Gauge Options Migration Test Dashboard", + "schemaVersion": 17, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "gauge", + "title": "Partial Gauge Panel", + "options-gauge": map[string]interface{}{ + "unit": "percent", + "decimals": 1, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V18 Partial Gauge Options Migration Test Dashboard", + "schemaVersion": 18, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "gauge", + "title": "Partial Gauge Panel", + "options": map[string]interface{}{ + "valueOptions": map[string]interface{}{ + "unit": "percent", + "decimals": 1, + }, + }, + }, + }, + }, + }, + { + name: "gauge panel with buggy options property gets cleaned up", + input: map[string]interface{}{ + "title": "V18 Buggy Options Cleanup Test Dashboard", + "schemaVersion": 17, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "gauge", + "title": "Buggy Gauge Panel", + "options-gauge": map[string]interface{}{ + "unit": "bytes", + "options": "this should be deleted", + "stat": "avg", + "decimals": 0, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V18 Buggy Options Cleanup Test Dashboard", + "schemaVersion": 18, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "gauge", + "title": "Buggy Gauge Panel", + "options": map[string]interface{}{ + "valueOptions": map[string]interface{}{ + "unit": "bytes", + "stat": "avg", + "decimals": 0, + }, + }, + }, + }, + }, + }, + { + name: "gauge panel with additional custom properties gets migrated correctly", + input: map[string]interface{}{ + "title": "V18 Custom Properties Migration Test Dashboard", + "schemaVersion": 17, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "gauge", + "title": "Custom Gauge Panel", + "options-gauge": map[string]interface{}{ + "unit": "short", + "customProperty": "customValue", + "anotherProp": 42, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V18 Custom Properties Migration Test Dashboard", + "schemaVersion": 18, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "gauge", + "title": "Custom Gauge Panel", + "options": map[string]interface{}{ + "valueOptions": map[string]interface{}{ + "unit": "short", + }, + "customProperty": "customValue", + "anotherProp": 42, + }, + }, + }, + }, + }, + { + name: "non-gauge panel remains unchanged", + input: map[string]interface{}{ + "title": "V18 Non-Gauge Panel Test Dashboard", + "schemaVersion": 17, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "graph", + "title": "Graph Panel", + "options": map[string]interface{}{ + "legend": map[string]interface{}{ + "show": true, + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V18 Non-Gauge Panel Test Dashboard", + "schemaVersion": 18, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "graph", + "title": "Graph Panel", + "options": map[string]interface{}{ + "legend": map[string]interface{}{ + "show": true, + }, + }, + }, + }, + }, + }, + { + name: "dashboard with no panels remains unchanged", + input: map[string]interface{}{ + "title": "V18 No Panels Test Dashboard", + "schemaVersion": 17, + }, + expected: map[string]interface{}{ + "title": "V18 No Panels Test Dashboard", + "schemaVersion": 18, + }, + }, + } + + runMigrationTests(t, tests, schemaversion.V18) +} diff --git a/apps/dashboard/pkg/migration/schemaversion/v19.go b/apps/dashboard/pkg/migration/schemaversion/v19.go new file mode 100644 index 00000000000..b9ca23a131b --- /dev/null +++ b/apps/dashboard/pkg/migration/schemaversion/v19.go @@ -0,0 +1,166 @@ +package schemaversion + +import ( + "regexp" + "strings" +) + +// V19 migrates panel links to ensure they have proper URL structure and handle legacy properties. +// This migration converts legacy panel link properties to the new URL-based format. +// +// Example before migration: +// +// "panels": [ +// { +// "links": [ +// { +// "dashboard": "my dashboard", +// "keepTime": true, +// "includeVars": true, +// "params": "customParam" +// } +// ] +// } +// ] +// +// Example after migration: +// +// "panels": [ +// { +// "links": [ +// { +// "url": "dashboard/db/my-dashboard?$__keepTime&$__includeVars&customParam", +// "title": "", +// "targetBlank": false +// } +// ] +// } +// ] +func V19(dashboard map[string]interface{}) error { + dashboard["schemaVersion"] = 19 + + panels, ok := dashboard["panels"].([]interface{}) + if !ok { + return nil + } + + for _, p := range panels { + panel, ok := p.(map[string]interface{}) + if !ok { + continue + } + + links, ok := panel["links"].([]interface{}) + if !ok { + continue + } + + panel["links"] = upgradePanelLinks(links) + } + + return nil +} + +func upgradePanelLinks(links []interface{}) []interface{} { + if len(links) == 0 { + return links + } + + result := []interface{}{} + for _, link := range links { + linkMap, ok := link.(map[string]interface{}) + if !ok { + continue + } + + result = append(result, upgradePanelLink(linkMap)) + } + + return result +} + +func upgradePanelLink(link map[string]interface{}) map[string]interface{} { + url := buildPanelLinkURL(link) + + result := map[string]interface{}{ + "url": url, + "title": getStringValue(link, "title"), + "targetBlank": getBoolValue(link, "targetBlank"), + } + + return result +} + +// buildPanelLinkURL builds the URL for a panel link based on legacy properties +func buildPanelLinkURL(link map[string]interface{}) string { + var url string + + // Check for existing URL first + if existingURL, ok := link["url"].(string); ok && existingURL != "" { + url = existingURL + } else if dashboard, ok := link["dashboard"].(string); ok && dashboard != "" { + // Convert dashboard name to slugified URL + url = "dashboard/db/" + slugifyForURL(dashboard) + } else if dashUri, ok := link["dashUri"].(string); ok && dashUri != "" { + url = "dashboard/" + dashUri + } else { + // Default fallback + url = "/" + } + + // Add query parameters + params := []string{} + + if getBoolValue(link, "keepTime") { + params = append(params, "$__url_time_range") + } + + if getBoolValue(link, "includeVars") { + params = append(params, "$__all_variables") + } + + if customParams, ok := link["params"].(string); ok && customParams != "" { + params = append(params, customParams) + } + + // Append parameters to URL + paramUsed := false + for _, param := range params { + if param != "" { + if paramUsed { + url += "&" + } else { + url += "?" + paramUsed = true + } + url += param + } + } + + return url +} + +var reNonWordOrSpace = regexp.MustCompile(`[^a-z0-9_ ]+`) +var reSpaces = regexp.MustCompile(` +`) + +// slugifyForURL converts a dashboard name to a URL-friendly slug +func slugifyForURL(name string) string { + name = strings.ToLower(name) + name = reNonWordOrSpace.ReplaceAllString(name, "") + name = reSpaces.ReplaceAllString(name, "-") + return name +} + +func getStringValue(m map[string]interface{}, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +func getBoolValue(m map[string]interface{}, key string) bool { + if v, ok := m[key].(bool); ok { + return v + } + return false +} diff --git a/apps/dashboard/pkg/migration/schemaversion/v19_test.go b/apps/dashboard/pkg/migration/schemaversion/v19_test.go new file mode 100644 index 00000000000..e2f4b5244ab --- /dev/null +++ b/apps/dashboard/pkg/migration/schemaversion/v19_test.go @@ -0,0 +1,292 @@ +package schemaversion_test + +import ( + "testing" + + "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" +) + +func TestV19(t *testing.T) { + tests := []migrationTestCase{ + { + name: "panel with legacy dashboard link gets upgraded to URL format", + input: map[string]interface{}{ + "title": "V19 Panel Links Migration Test Dashboard", + "schemaVersion": 18, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "links": []interface{}{ + map[string]interface{}{ + "dashboard": "my dashboard", + "title": "Dashboard Link", + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V19 Panel Links Migration Test Dashboard", + "schemaVersion": 19, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "links": []interface{}{ + map[string]interface{}{ + "url": "dashboard/db/my-dashboard", + "title": "Dashboard Link", + "targetBlank": false, + }, + }, + }, + }, + }, + }, + { + name: "panel with legacy dashUri link gets upgraded to URL format", + input: map[string]interface{}{ + "title": "V19 DashUri Links Migration Test Dashboard", + "schemaVersion": 18, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "links": []interface{}{ + map[string]interface{}{ + "dashUri": "my-dashboard-uid", + "title": "DashUri Link", + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V19 DashUri Links Migration Test Dashboard", + "schemaVersion": 19, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "links": []interface{}{ + map[string]interface{}{ + "url": "dashboard/my-dashboard-uid", + "title": "DashUri Link", + "targetBlank": false, + }, + }, + }, + }, + }, + }, + { + name: "panel with keepTime flag gets upgraded with keepTime parameter", + input: map[string]interface{}{ + "title": "V19 KeepTime Links Migration Test Dashboard", + "schemaVersion": 18, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "links": []interface{}{ + map[string]interface{}{ + "url": "http://example.com", + "keepTime": true, + "title": "KeepTime Link", + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V19 KeepTime Links Migration Test Dashboard", + "schemaVersion": 19, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "links": []interface{}{ + map[string]interface{}{ + "url": "http://example.com?$__url_time_range", + "title": "KeepTime Link", + "targetBlank": false, + }, + }, + }, + }, + }, + }, + { + name: "panel with includeVars flag gets upgraded with includeVars parameter", + input: map[string]interface{}{ + "title": "V19 IncludeVars Links Migration Test Dashboard", + "schemaVersion": 18, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "links": []interface{}{ + map[string]interface{}{ + "url": "http://example.com", + "includeVars": true, + "title": "IncludeVars Link", + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V19 IncludeVars Links Migration Test Dashboard", + "schemaVersion": 19, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "links": []interface{}{ + map[string]interface{}{ + "url": "http://example.com?$__all_variables", + "title": "IncludeVars Link", + "targetBlank": false, + }, + }, + }, + }, + }, + }, + { + name: "panel with custom params gets upgraded with params in URL", + input: map[string]interface{}{ + "title": "V19 Custom Params Links Migration Test Dashboard", + "schemaVersion": 18, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "links": []interface{}{ + map[string]interface{}{ + "url": "http://example.com", + "params": "customParam=value", + "title": "Custom Params Link", + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V19 Custom Params Links Migration Test Dashboard", + "schemaVersion": 19, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "links": []interface{}{ + map[string]interface{}{ + "url": "http://example.com?customParam=value", + "title": "Custom Params Link", + "targetBlank": false, + }, + }, + }, + }, + }, + }, + { + name: "panel with multiple flags and params gets upgraded correctly", + input: map[string]interface{}{ + "title": "V19 Complex Links Migration Test Dashboard", + "schemaVersion": 18, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "links": []interface{}{ + map[string]interface{}{ + "dashboard": "my dashboard", + "keepTime": true, + "includeVars": true, + "params": "customParam=value", + "title": "Complex Link", + "targetBlank": true, + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V19 Complex Links Migration Test Dashboard", + "schemaVersion": 19, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "links": []interface{}{ + map[string]interface{}{ + "url": "dashboard/db/my-dashboard?$__url_time_range&$__all_variables&customParam=value", + "title": "Complex Link", + "targetBlank": true, + }, + }, + }, + }, + }, + }, + { + name: "panel with existing URL and no legacy properties remains unchanged", + input: map[string]interface{}{ + "title": "V19 Existing URL Links Migration Test Dashboard", + "schemaVersion": 18, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "links": []interface{}{ + map[string]interface{}{ + "url": "http://example.com", + "title": "Existing URL Link", + "targetBlank": false, + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V19 Existing URL Links Migration Test Dashboard", + "schemaVersion": 19, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "links": []interface{}{ + map[string]interface{}{ + "url": "http://example.com", + "title": "Existing URL Link", + "targetBlank": false, + }, + }, + }, + }, + }, + }, + { + name: "panel with no links remains unchanged", + input: map[string]interface{}{ + "title": "V19 No Links Migration Test Dashboard", + "schemaVersion": 18, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V19 No Links Migration Test Dashboard", + "schemaVersion": 19, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + }, + }, + }, + }, + { + name: "dashboard with no panels remains unchanged", + input: map[string]interface{}{ + "title": "V19 No Panels Migration Test Dashboard", + "schemaVersion": 18, + }, + expected: map[string]interface{}{ + "title": "V19 No Panels Migration Test Dashboard", + "schemaVersion": 19, + }, + }, + } + + runMigrationTests(t, tests, schemaversion.V19) +} diff --git a/apps/dashboard/pkg/migration/schemaversion/v20.go b/apps/dashboard/pkg/migration/schemaversion/v20.go new file mode 100644 index 00000000000..069bf745fc9 --- /dev/null +++ b/apps/dashboard/pkg/migration/schemaversion/v20.go @@ -0,0 +1,163 @@ +package schemaversion + +import ( + "regexp" + + "github.com/grafana/grafana/apps/dashboard/pkg/migration/utils" +) + +// V20 migrates legacy variable syntax in data links and field options. +// This migration updates variable names from old syntax to new dotted syntax +// used in data links URLs and field option titles. +// +// Variable syntax changes: +// - __series_name → __series.name +// - $__series_name → ${__series.name} +// - __value_time → __value.time +// - __field_name → __field.name +// - $__field_name → ${__field.name} +// +// Example before migration: +// +// "panels": [ +// { +// "options": { +// "dataLinks": [ +// { +// "url": "http://example.com?series=$__series_name&time=__value_time" +// } +// ], +// "fieldOptions": { +// "defaults": { +// "title": "Field: __field_name", +// "links": [ +// { +// "url": "http://example.com?field=$__field_name" +// } +// ] +// } +// } +// } +// } +// ] +// +// Example after migration: +// +// "panels": [ +// { +// "options": { +// "dataLinks": [ +// { +// "url": "http://example.com?series=${__series.name}&time=__value.time" +// } +// ], +// "fieldOptions": { +// "defaults": { +// "title": "Field: __field.name", +// "links": [ +// { +// "url": "http://example.com?field=${__field.name}" +// } +// ] +// } +// } +// } +// } +// ] +func V20(dashboard map[string]interface{}) error { + dashboard["schemaVersion"] = 20 + + panels, ok := dashboard["panels"].([]interface{}) + if !ok { + return nil + } + + for _, p := range panels { + panel, ok := p.(map[string]interface{}) + if !ok { + continue + } + + // Update data links and field options in panel options + if options, ok := panel["options"].(map[string]interface{}); ok { + updateDataLinksVariableSyntax(options) + updateFieldOptionsVariableSyntax(options) + } + } + + return nil +} + +// updateDataLinksVariableSyntax updates variable syntax in panel data links +func updateDataLinksVariableSyntax(options map[string]interface{}) { + dataLinks, ok := options["dataLinks"].([]interface{}) + if !ok || !utils.IsArray(dataLinks) { + return + } + + for _, link := range dataLinks { + if linkMap, ok := link.(map[string]interface{}); ok { + if url, ok := linkMap["url"].(string); ok { + linkMap["url"] = updateVariablesSyntax(url) + } + } + } +} + +// updateFieldOptionsVariableSyntax updates variable syntax in field options +func updateFieldOptionsVariableSyntax(options map[string]interface{}) { + fieldOptions, ok := options["fieldOptions"].(map[string]interface{}) + if !ok { + return + } + + defaults, ok := fieldOptions["defaults"].(map[string]interface{}) + if !ok { + return + } + + // Update field option title + if title, ok := defaults["title"].(string); ok { + defaults["title"] = updateVariablesSyntax(title) + } + + // Update field option links + links, ok := defaults["links"].([]interface{}) + if !ok || !utils.IsArray(links) { + return + } + + for _, link := range links { + if linkMap, ok := link.(map[string]interface{}); ok { + if url, ok := linkMap["url"].(string); ok { + linkMap["url"] = updateVariablesSyntax(url) + } + } + } +} + +// Define the regex pattern to match legacy variable names +// Pattern matches: __series_name, $__series_name, __value_time, __field_name, $__field_name +// Defined here to avoid compilation for every function call +var legacyVariableNamesRegex = regexp.MustCompile(`(__series_name)|(\$__series_name)|(__value_time)|(__field_name)|(\$__field_name)`) + +// updateVariablesSyntax updates legacy variable names to new dotted syntax +// This function replicates the frontend updateVariablesSyntax behavior +func updateVariablesSyntax(text string) string { + return legacyVariableNamesRegex.ReplaceAllStringFunc(text, func(match string) string { + switch match { + case "__series_name": + return "__series.name" + case "$__series_name": + return "${__series.name}" + case "__value_time": + return "__value.time" + case "__field_name": + return "__field.name" + case "$__field_name": + return "${__field.name}" + default: + return match + } + }) +} diff --git a/apps/dashboard/pkg/migration/schemaversion/v20_test.go b/apps/dashboard/pkg/migration/schemaversion/v20_test.go new file mode 100644 index 00000000000..c08048d69a0 --- /dev/null +++ b/apps/dashboard/pkg/migration/schemaversion/v20_test.go @@ -0,0 +1,322 @@ +package schemaversion_test + +import ( + "testing" + + "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" +) + +func TestV20(t *testing.T) { + tests := []migrationTestCase{ + { + name: "panel with data links gets variable syntax migrated", + input: map[string]interface{}{ + "title": "V20 Data Links Variable Syntax Migration Test Dashboard", + "schemaVersion": 19, + "panels": []interface{}{ + map[string]interface{}{ + "type": "timeseries", + "title": "Panel with data links", + "id": 1, + "options": map[string]interface{}{ + "dataLinks": []interface{}{ + map[string]interface{}{ + "url": "http://mylink.com?series=$__series_name&time=__value_time&field=__field_name", + }, + map[string]interface{}{ + "url": "http://another.com?series=${__series_name}&field=${__field_name}", + }, + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V20 Data Links Variable Syntax Migration Test Dashboard", + "schemaVersion": 20, + "panels": []interface{}{ + map[string]interface{}{ + "type": "timeseries", + "title": "Panel with data links", + "id": 1, + "options": map[string]interface{}{ + "dataLinks": []interface{}{ + map[string]interface{}{ + "url": "http://mylink.com?series=${__series.name}&time=__value.time&field=__field.name", + }, + map[string]interface{}{ + "url": "http://another.com?series=${__series.name}&field=${__field.name}", + }, + }, + }, + }, + }, + }, + }, + { + name: "panel with field options title gets variable syntax migrated", + input: map[string]interface{}{ + "title": "V20 Field Options Title Migration Test Dashboard", + "schemaVersion": 19, + "panels": []interface{}{ + map[string]interface{}{ + "type": "stat", + "title": "Panel with field options title", + "id": 2, + "options": map[string]interface{}{ + "fieldOptions": map[string]interface{}{ + "defaults": map[string]interface{}{ + "title": "Series: __series_name, Field: $__field_name, Time: __value_time", + }, + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V20 Field Options Title Migration Test Dashboard", + "schemaVersion": 20, + "panels": []interface{}{ + map[string]interface{}{ + "type": "stat", + "title": "Panel with field options title", + "id": 2, + "options": map[string]interface{}{ + "fieldOptions": map[string]interface{}{ + "defaults": map[string]interface{}{ + "title": "Series: __series.name, Field: ${__field.name}, Time: __value.time", + }, + }, + }, + }, + }, + }, + }, + { + name: "panel with field options links gets variable syntax migrated", + input: map[string]interface{}{ + "title": "V20 Field Options Links Migration Test Dashboard", + "schemaVersion": 19, + "panels": []interface{}{ + map[string]interface{}{ + "type": "gauge", + "title": "Panel with field options links", + "id": 3, + "options": map[string]interface{}{ + "fieldOptions": map[string]interface{}{ + "defaults": map[string]interface{}{ + "links": []interface{}{ + map[string]interface{}{ + "url": "http://example.com?series=__series_name&field=$__field_name", + }, + map[string]interface{}{ + "url": "http://test.com?time=__value_time", + }, + }, + }, + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V20 Field Options Links Migration Test Dashboard", + "schemaVersion": 20, + "panels": []interface{}{ + map[string]interface{}{ + "type": "gauge", + "title": "Panel with field options links", + "id": 3, + "options": map[string]interface{}{ + "fieldOptions": map[string]interface{}{ + "defaults": map[string]interface{}{ + "links": []interface{}{ + map[string]interface{}{ + "url": "http://example.com?series=__series.name&field=${__field.name}", + }, + map[string]interface{}{ + "url": "http://test.com?time=__value.time", + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "panel with both data links and field options gets variable syntax migrated", + input: map[string]interface{}{ + "title": "V20 Combined Migration Test Dashboard", + "schemaVersion": 19, + "panels": []interface{}{ + map[string]interface{}{ + "type": "table", + "title": "Panel with both data links and field options", + "id": 4, + "options": map[string]interface{}{ + "dataLinks": []interface{}{ + map[string]interface{}{ + "url": "http://datalink.com?series=$__series_name", + }, + }, + "fieldOptions": map[string]interface{}{ + "defaults": map[string]interface{}{ + "title": "Field Name: __field_name", + "links": []interface{}{ + map[string]interface{}{ + "url": "http://fieldlink.com?field=$__field_name&time=__value_time", + }, + }, + }, + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V20 Combined Migration Test Dashboard", + "schemaVersion": 20, + "panels": []interface{}{ + map[string]interface{}{ + "type": "table", + "title": "Panel with both data links and field options", + "id": 4, + "options": map[string]interface{}{ + "dataLinks": []interface{}{ + map[string]interface{}{ + "url": "http://datalink.com?series=${__series.name}", + }, + }, + "fieldOptions": map[string]interface{}{ + "defaults": map[string]interface{}{ + "title": "Field Name: __field.name", + "links": []interface{}{ + map[string]interface{}{ + "url": "http://fieldlink.com?field=${__field.name}&time=__value.time", + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "panel without data links or field options remains unchanged", + input: map[string]interface{}{ + "title": "V20 No Links Migration Test Dashboard", + "schemaVersion": 19, + "panels": []interface{}{ + map[string]interface{}{ + "type": "singlestat", + "title": "Panel without links", + "id": 5, + "options": map[string]interface{}{ + "someOtherOption": "value", + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V20 No Links Migration Test Dashboard", + "schemaVersion": 20, + "panels": []interface{}{ + map[string]interface{}{ + "type": "singlestat", + "title": "Panel without links", + "id": 5, + "options": map[string]interface{}{ + "someOtherOption": "value", + }, + }, + }, + }, + }, + { + name: "dashboard without panels remains unchanged", + input: map[string]interface{}{ + "title": "V20 No Panels Migration Test Dashboard", + "schemaVersion": 19, + }, + expected: map[string]interface{}{ + "title": "V20 No Panels Migration Test Dashboard", + "schemaVersion": 20, + }, + }, + { + name: "panel with empty data links array remains unchanged", + input: map[string]interface{}{ + "title": "V20 Empty Data Links Migration Test Dashboard", + "schemaVersion": 19, + "panels": []interface{}{ + map[string]interface{}{ + "type": "graph", + "title": "Panel with empty data links", + "id": 6, + "options": map[string]interface{}{ + "dataLinks": []interface{}{}, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V20 Empty Data Links Migration Test Dashboard", + "schemaVersion": 20, + "panels": []interface{}{ + map[string]interface{}{ + "type": "graph", + "title": "Panel with empty data links", + "id": 6, + "options": map[string]interface{}{ + "dataLinks": []interface{}{}, + }, + }, + }, + }, + }, + { + name: "panel with legacy variables that don't need migration", + input: map[string]interface{}{ + "title": "V20 No Legacy Variables Migration Test Dashboard", + "schemaVersion": 19, + "panels": []interface{}{ + map[string]interface{}{ + "type": "text", + "title": "Panel with modern variables", + "id": 7, + "options": map[string]interface{}{ + "dataLinks": []interface{}{ + map[string]interface{}{ + "url": "http://modern.com?series=${__series.name}&field=${__field.name}", + }, + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V20 No Legacy Variables Migration Test Dashboard", + "schemaVersion": 20, + "panels": []interface{}{ + map[string]interface{}{ + "type": "text", + "title": "Panel with modern variables", + "id": 7, + "options": map[string]interface{}{ + "dataLinks": []interface{}{ + map[string]interface{}{ + "url": "http://modern.com?series=${__series.name}&field=${__field.name}", + }, + }, + }, + }, + }, + }, + }, + } + + runMigrationTests(t, tests, schemaversion.V20) +} diff --git a/apps/dashboard/pkg/migration/schemaversion/v21.go b/apps/dashboard/pkg/migration/schemaversion/v21.go new file mode 100644 index 00000000000..a9361f8871b --- /dev/null +++ b/apps/dashboard/pkg/migration/schemaversion/v21.go @@ -0,0 +1,120 @@ +package schemaversion + +import ( + "strings" + + "github.com/grafana/grafana/apps/dashboard/pkg/migration/utils" +) + +// V21 migrates data links to replace __series.labels with __field.labels. +// This migration updates the variable syntax used in data links from the old series-based +// syntax to the new field-based syntax. +// +// Example before migration: +// +// "panels": [ +// { +// "options": { +// "dataLinks": [ +// { +// "url": "http://example.com?series=${__series.labels}&${__series.labels.a}" +// } +// ], +// "fieldOptions": { +// "defaults": { +// "links": [ +// { +// "url": "http://example.com?series=${__series.labels}&${__series.labels.x}" +// } +// ] +// } +// } +// } +// } +// ] +// +// Example after migration: +// +// "panels": [ +// { +// "options": { +// "dataLinks": [ +// { +// "url": "http://example.com?series=${__field.labels}&${__field.labels.a}" +// } +// ], +// "fieldOptions": { +// "defaults": { +// "links": [ +// { +// "url": "http://example.com?series=${__field.labels}&${__field.labels.x}" +// } +// ] +// } +// } +// } +// } +// ] +func V21(dashboard map[string]interface{}) error { + dashboard["schemaVersion"] = 21 + + panels, ok := dashboard["panels"].([]interface{}) + if !ok { + return nil + } + + for _, p := range panels { + panel, ok := p.(map[string]interface{}) + if !ok { + continue + } + + // Update data links in panel options + if options, ok := panel["options"].(map[string]interface{}); ok { + updateDataLinks(options) + updateFieldOptionsLinks(options) + } + } + + return nil +} + +func updateDataLinks(options map[string]interface{}) { + dataLinks, ok := options["dataLinks"].([]interface{}) + if !ok || !utils.IsArray(dataLinks) { + return + } + + for _, link := range dataLinks { + if linkMap, ok := link.(map[string]interface{}); ok { + if url, ok := linkMap["url"].(string); ok { + linkMap["url"] = strings.ReplaceAll(url, "__series.labels", "__field.labels") + } + } + } +} + +func updateFieldOptionsLinks(options map[string]interface{}) { + fieldOptions, ok := options["fieldOptions"].(map[string]interface{}) + if !ok { + return + } + + defaults, ok := fieldOptions["defaults"].(map[string]interface{}) + if !ok { + return + } + + links, ok := defaults["links"].([]interface{}) + if !ok { + return + } + + for _, link := range links { + if linkMap, ok := link.(map[string]interface{}); ok { + if url, ok := linkMap["url"].(string); ok { + linkMap["url"] = strings.ReplaceAll(url, "__series.labels", "__field.labels") + } + } + } +} diff --git a/apps/dashboard/pkg/migration/schemaversion/v21_test.go b/apps/dashboard/pkg/migration/schemaversion/v21_test.go new file mode 100644 index 00000000000..e621c794ced --- /dev/null +++ b/apps/dashboard/pkg/migration/schemaversion/v21_test.go @@ -0,0 +1,232 @@ +package schemaversion_test + +import ( + "testing" + + "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" +) + +func TestV21(t *testing.T) { + tests := []migrationTestCase{ + { + name: "panel with data links gets migrated", + input: map[string]interface{}{ + "title": "V21 Data Links Migration Test Dashboard", + "schemaVersion": 20, + "panels": []interface{}{ + map[string]interface{}{ + "type": "timeseries", + "title": "Panel with data links", + "id": 1, + "options": map[string]interface{}{ + "dataLinks": []interface{}{ + map[string]interface{}{ + "url": "http://mylink.com?series=${__series.labels}&${__series.labels.a}", + }, + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V21 Data Links Migration Test Dashboard", + "schemaVersion": 21, + "panels": []interface{}{ + map[string]interface{}{ + "type": "timeseries", + "title": "Panel with data links", + "id": 1, + "options": map[string]interface{}{ + "dataLinks": []interface{}{ + map[string]interface{}{ + "url": "http://mylink.com?series=${__field.labels}&${__field.labels.a}", + }, + }, + }, + }, + }, + }, + }, + { + name: "panel with field options links gets migrated", + input: map[string]interface{}{ + "title": "V21 Field Options Links Migration Test Dashboard", + "schemaVersion": 20, + "panels": []interface{}{ + map[string]interface{}{ + "type": "stat", + "title": "Panel with field options links", + "id": 2, + "options": map[string]interface{}{ + "fieldOptions": map[string]interface{}{ + "defaults": map[string]interface{}{ + "links": []interface{}{ + map[string]interface{}{ + "url": "http://mylink.com?series=${__series.labels}&${__series.labels.x}", + }, + }, + }, + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V21 Field Options Links Migration Test Dashboard", + "schemaVersion": 21, + "panels": []interface{}{ + map[string]interface{}{ + "type": "stat", + "title": "Panel with field options links", + "id": 2, + "options": map[string]interface{}{ + "fieldOptions": map[string]interface{}{ + "defaults": map[string]interface{}{ + "links": []interface{}{ + map[string]interface{}{ + "url": "http://mylink.com?series=${__field.labels}&${__field.labels.x}", + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "panel with both data links and field options links gets migrated", + input: map[string]interface{}{ + "title": "V21 Both Links Migration Test Dashboard", + "schemaVersion": 20, + "panels": []interface{}{ + map[string]interface{}{ + "type": "graph", + "title": "Panel with both link types", + "id": 3, + "options": map[string]interface{}{ + "dataLinks": []interface{}{ + map[string]interface{}{ + "url": "http://mylink.com?series=${__series.labels}", + }, + }, + "fieldOptions": map[string]interface{}{ + "defaults": map[string]interface{}{ + "links": []interface{}{ + map[string]interface{}{ + "url": "http://mylink.com?field=${__series.labels}", + }, + }, + }, + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V21 Both Links Migration Test Dashboard", + "schemaVersion": 21, + "panels": []interface{}{ + map[string]interface{}{ + "type": "graph", + "title": "Panel with both link types", + "id": 3, + "options": map[string]interface{}{ + "dataLinks": []interface{}{ + map[string]interface{}{ + "url": "http://mylink.com?series=${__field.labels}", + }, + }, + "fieldOptions": map[string]interface{}{ + "defaults": map[string]interface{}{ + "links": []interface{}{ + map[string]interface{}{ + "url": "http://mylink.com?field=${__field.labels}", + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "panel without __series.labels is unchanged", + input: map[string]interface{}{ + "title": "V21 No Series Labels Test Dashboard", + "schemaVersion": 20, + "panels": []interface{}{ + map[string]interface{}{ + "type": "timeseries", + "title": "Panel without series labels", + "id": 4, + "options": map[string]interface{}{ + "dataLinks": []interface{}{ + map[string]interface{}{ + "url": "http://mylink.com?other=${__field.labels}", + }, + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V21 No Series Labels Test Dashboard", + "schemaVersion": 21, + "panels": []interface{}{ + map[string]interface{}{ + "type": "timeseries", + "title": "Panel without series labels", + "id": 4, + "options": map[string]interface{}{ + "dataLinks": []interface{}{ + map[string]interface{}{ + "url": "http://mylink.com?other=${__field.labels}", + }, + }, + }, + }, + }, + }, + }, + { + name: "panel without options is unchanged", + input: map[string]interface{}{ + "title": "V21 No Options Test Dashboard", + "schemaVersion": 20, + "panels": []interface{}{ + map[string]interface{}{ + "type": "timeseries", + "title": "Panel without options", + "id": 5, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V21 No Options Test Dashboard", + "schemaVersion": 21, + "panels": []interface{}{ + map[string]interface{}{ + "type": "timeseries", + "title": "Panel without options", + "id": 5, + }, + }, + }, + }, + { + name: "dashboard without panels is unchanged", + input: map[string]interface{}{ + "title": "V21 No Panels Test Dashboard", + "schemaVersion": 20, + }, + expected: map[string]interface{}{ + "title": "V21 No Panels Test Dashboard", + "schemaVersion": 21, + }, + }, + } + + runMigrationTests(t, tests, schemaversion.V21) +} diff --git a/apps/dashboard/pkg/migration/schemaversion/v22.go b/apps/dashboard/pkg/migration/schemaversion/v22.go new file mode 100644 index 00000000000..e6e2c2f5175 --- /dev/null +++ b/apps/dashboard/pkg/migration/schemaversion/v22.go @@ -0,0 +1,66 @@ +package schemaversion + +// V22 migrates table panel styles to set align property to 'auto'. +// This migration ensures that all table panel styles have their align property +// set to 'auto' for consistent alignment behavior. +// +// Example before migration: +// +// "panels": [ +// { +// "type": "table", +// "styles": [ +// { "type": "number", "pattern": "Time", "align": "left" }, +// { "type": "string", "pattern": "Value", "align": "right" } +// ] +// } +// ] +// +// Example after migration: +// +// "panels": [ +// { +// "type": "table", +// "styles": [ +// { "type": "number", "pattern": "Time", "align": "auto" }, +// { "type": "string", "pattern": "Value", "align": "auto" } +// ] +// } +// ] +func V22(dashboard map[string]interface{}) error { + dashboard["schemaVersion"] = 22 + + panels, ok := dashboard["panels"].([]interface{}) + if !ok { + return nil + } + + for _, p := range panels { + panel, ok := p.(map[string]interface{}) + if !ok { + continue + } + + // Only process table panels + panelType, ok := panel["type"].(string) + if !ok || panelType != "table" { + continue + } + + styles, ok := panel["styles"].([]interface{}) + if !ok { + continue + } + + // Update each style to set align to 'auto' + for _, s := range styles { + style, ok := s.(map[string]interface{}) + if !ok { + continue + } + style["align"] = "auto" + } + } + + return nil +} diff --git a/apps/dashboard/pkg/migration/schemaversion/v22_test.go b/apps/dashboard/pkg/migration/schemaversion/v22_test.go new file mode 100644 index 00000000000..6da11aadad1 --- /dev/null +++ b/apps/dashboard/pkg/migration/schemaversion/v22_test.go @@ -0,0 +1,123 @@ +package schemaversion_test + +import ( + "testing" + + "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" +) + +func TestV22(t *testing.T) { + tests := []migrationTestCase{ + { + name: "table panel styles align is set to auto", + input: map[string]interface{}{ + "title": "V22 Table Panel Styles Test", + "schemaVersion": 21, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "table", + "styles": []interface{}{ + map[string]interface{}{ + "type": "number", + "pattern": "Time", + "align": "left", + }, + map[string]interface{}{ + "type": "string", + "pattern": "Value", + "align": "right", + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V22 Table Panel Styles Test", + "schemaVersion": 22, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "table", + "styles": []interface{}{ + map[string]interface{}{ + "type": "number", + "pattern": "Time", + "align": "auto", + }, + map[string]interface{}{ + "type": "string", + "pattern": "Value", + "align": "auto", + }, + }, + }, + }, + }, + }, + { + name: "non-table panel is unchanged except schemaVersion", + input: map[string]interface{}{ + "title": "V22 Non-Table Panel Test", + "schemaVersion": 21, + "panels": []interface{}{ + map[string]interface{}{ + "id": 2, + "type": "graph", + "styles": []interface{}{ + map[string]interface{}{ + "type": "number", + "pattern": "Time", + "align": "left", + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V22 Non-Table Panel Test", + "schemaVersion": 22, + "panels": []interface{}{ + map[string]interface{}{ + "id": 2, + "type": "graph", + "styles": []interface{}{ + map[string]interface{}{ + "type": "number", + "pattern": "Time", + "align": "left", + }, + }, + }, + }, + }, + }, + { + name: "table panel with no styles is unchanged except schemaVersion", + input: map[string]interface{}{ + "title": "V22 Table Panel No Styles Test", + "schemaVersion": 21, + "panels": []interface{}{ + map[string]interface{}{ + "id": 3, + "type": "table", + "styles": []interface{}{}, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V22 Table Panel No Styles Test", + "schemaVersion": 22, + "panels": []interface{}{ + map[string]interface{}{ + "id": 3, + "type": "table", + "styles": []interface{}{}, + }, + }, + }, + }, + } + + runMigrationTests(t, tests, schemaversion.V22) +} diff --git a/apps/dashboard/pkg/migration/schemaversion/v23.go b/apps/dashboard/pkg/migration/schemaversion/v23.go new file mode 100644 index 00000000000..6270a75af57 --- /dev/null +++ b/apps/dashboard/pkg/migration/schemaversion/v23.go @@ -0,0 +1,133 @@ +package schemaversion + +import "github.com/grafana/grafana/apps/dashboard/pkg/migration/utils" + +// V23 migrates multi variables to ensure their current property is aligned with their multi property. +// This migration ensures that variables with multi=true have current.value and current.text as arrays, +// and variables with multi=false have current.value and current.text as single values. +// +// Example before migration: +// +// "templating": { +// "list": [ +// { "type": "query", "multi": true, "current": { "value": "A", "text": "A" } }, +// { "type": "query", "multi": false, "current": { "value": ["B"], "text": ["B"] } } +// ] +// } +// +// Example after migration: +// +// "templating": { +// "list": [ +// { "type": "query", "multi": true, "current": { "value": ["A"], "text": ["A"] } }, +// { "type": "query", "multi": false, "current": { "value": "B", "text": "B" } } +// ] +// } +func V23(dashboard map[string]interface{}) error { + dashboard["schemaVersion"] = 23 + + templating, ok := dashboard["templating"].(map[string]interface{}) + if !ok { + return nil + } + list, ok := templating["list"].([]interface{}) + if !ok { + return nil + } + + for _, v := range list { + variable, ok := v.(map[string]interface{}) + if !ok { + continue + } + + if !isMulti(variable) { + continue + } + + current, ok := variable["current"].(map[string]interface{}) + if !ok { + continue + } + + if isEmptyObject(current) { + continue + } + + multi, ok := variable["multi"].(bool) + if !ok { + continue + } + + variable["current"] = alignCurrentWithMulti(current, multi) + } + + return nil +} + +// isMulti checks if a variable supports multi-selection +func isMulti(variable map[string]interface{}) bool { + _, hasMulti := variable["multi"] + return hasMulti +} + +func isEmptyObject(value interface{}) bool { + if value == nil { + return true + } + + obj, ok := value.(map[string]interface{}) + if !ok { + return false + } + + return len(obj) == 0 +} + +// alignCurrentWithMulti aligns the current property with the multi property +func alignCurrentWithMulti(current map[string]interface{}, multi bool) map[string]interface{} { + if current == nil { + return current + } + + result := make(map[string]interface{}) + for k, v := range current { + result[k] = v + } + + if multi { + // Convert single values to arrays + if value, ok := result["value"]; ok { + if !utils.IsArray(value) { + result["value"] = []interface{}{value} + } + } + if text, ok := result["text"]; ok { + if !utils.IsArray(text) { + result["text"] = []interface{}{text} + } + } + } else { + // Convert arrays to single values + if value, ok := result["value"]; ok { + if utils.IsArray(value) { + if arr, ok := value.([]interface{}); ok && len(arr) > 0 { + result["value"] = arr[0] + } else { + result["value"] = "" + } + } + } + if text, ok := result["text"]; ok { + if utils.IsArray(text) { + if arr, ok := text.([]interface{}); ok && len(arr) > 0 { + result["text"] = arr[0] + } else { + result["text"] = "" + } + } + } + } + + return result +} diff --git a/apps/dashboard/pkg/migration/schemaversion/v23_test.go b/apps/dashboard/pkg/migration/schemaversion/v23_test.go new file mode 100644 index 00000000000..c40fc4480fa --- /dev/null +++ b/apps/dashboard/pkg/migration/schemaversion/v23_test.go @@ -0,0 +1,229 @@ +package schemaversion_test + +import ( + "testing" + + "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" +) + +func TestV23(t *testing.T) { + tests := []migrationTestCase{ + { + name: "multi variable with single value gets converted to array", + input: map[string]interface{}{ + "title": "V23 Multi Variable Single Value Test", + "schemaVersion": 22, + "templating": map[string]interface{}{ + "list": []interface{}{ + map[string]interface{}{ + "type": "query", + "name": "multi_single_value", + "multi": true, + "current": map[string]interface{}{"value": "A", "text": "A", "selected": true}, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V23 Multi Variable Single Value Test", + "schemaVersion": 23, + "templating": map[string]interface{}{ + "list": []interface{}{ + map[string]interface{}{ + "type": "query", + "name": "multi_single_value", + "multi": true, + "current": map[string]interface{}{"value": []interface{}{"A"}, "text": []interface{}{"A"}, "selected": true}, + }, + }, + }, + }, + }, + { + name: "multi variable with array value stays as array", + input: map[string]interface{}{ + "title": "V23 Multi Variable Array Value Test", + "schemaVersion": 22, + "templating": map[string]interface{}{ + "list": []interface{}{ + map[string]interface{}{ + "type": "query", + "name": "multi_array_value", + "multi": true, + "current": map[string]interface{}{"value": []interface{}{"B", "C"}, "text": []interface{}{"B", "C"}, "selected": true}, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V23 Multi Variable Array Value Test", + "schemaVersion": 23, + "templating": map[string]interface{}{ + "list": []interface{}{ + map[string]interface{}{ + "type": "query", + "name": "multi_array_value", + "multi": true, + "current": map[string]interface{}{"value": []interface{}{"B", "C"}, "text": []interface{}{"B", "C"}, "selected": true}, + }, + }, + }, + }, + }, + { + name: "non-multi variable with array value gets converted to single value", + input: map[string]interface{}{ + "title": "V23 Non-Multi Variable Array Value Test", + "schemaVersion": 22, + "templating": map[string]interface{}{ + "list": []interface{}{ + map[string]interface{}{ + "type": "query", + "name": "non_multi_array_value", + "multi": false, + "current": map[string]interface{}{"value": []interface{}{"D"}, "text": []interface{}{"D"}, "selected": true}, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V23 Non-Multi Variable Array Value Test", + "schemaVersion": 23, + "templating": map[string]interface{}{ + "list": []interface{}{ + map[string]interface{}{ + "type": "query", + "name": "non_multi_array_value", + "multi": false, + "current": map[string]interface{}{"value": "D", "text": "D", "selected": true}, + }, + }, + }, + }, + }, + { + name: "non-multi variable with single value stays as single value", + input: map[string]interface{}{ + "title": "V23 Non-Multi Variable Single Value Test", + "schemaVersion": 22, + "templating": map[string]interface{}{ + "list": []interface{}{ + map[string]interface{}{ + "type": "query", + "name": "non_multi_single_value", + "multi": false, + "current": map[string]interface{}{"value": "E", "text": "E", "selected": true}, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V23 Non-Multi Variable Single Value Test", + "schemaVersion": 23, + "templating": map[string]interface{}{ + "list": []interface{}{ + map[string]interface{}{ + "type": "query", + "name": "non_multi_single_value", + "multi": false, + "current": map[string]interface{}{"value": "E", "text": "E", "selected": true}, + }, + }, + }, + }, + }, + { + name: "variable without multi property is unchanged", + input: map[string]interface{}{ + "title": "V23 No Multi Property Test", + "schemaVersion": 22, + "templating": map[string]interface{}{ + "list": []interface{}{ + map[string]interface{}{ + "type": "query", + "name": "no_multi_property", + "current": map[string]interface{}{"value": "F", "text": "F", "selected": true}, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V23 No Multi Property Test", + "schemaVersion": 23, + "templating": map[string]interface{}{ + "list": []interface{}{ + map[string]interface{}{ + "type": "query", + "name": "no_multi_property", + "current": map[string]interface{}{"value": "F", "text": "F", "selected": true}, + }, + }, + }, + }, + }, + { + name: "variable with empty current is unchanged", + input: map[string]interface{}{ + "title": "V23 Empty Current Test", + "schemaVersion": 22, + "templating": map[string]interface{}{ + "list": []interface{}{ + map[string]interface{}{ + "type": "query", + "name": "empty_current", + "multi": true, + "current": map[string]interface{}{}, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V23 Empty Current Test", + "schemaVersion": 23, + "templating": map[string]interface{}{ + "list": []interface{}{ + map[string]interface{}{ + "type": "query", + "name": "empty_current", + "multi": true, + "current": map[string]interface{}{}, + }, + }, + }, + }, + }, + { + name: "variable with nil current is unchanged", + input: map[string]interface{}{ + "title": "V23 Nil Current Test", + "schemaVersion": 22, + "templating": map[string]interface{}{ + "list": []interface{}{ + map[string]interface{}{ + "type": "query", + "name": "nil_current", + "multi": true, + "current": nil, + }, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V23 Nil Current Test", + "schemaVersion": 23, + "templating": map[string]interface{}{ + "list": []interface{}{ + map[string]interface{}{ + "type": "query", + "name": "nil_current", + "multi": true, + "current": nil, + }, + }, + }, + }, + }, + } + + runMigrationTests(t, tests, schemaversion.V23) +} diff --git a/apps/dashboard/pkg/migration/schemaversion/v24.go b/apps/dashboard/pkg/migration/schemaversion/v24.go new file mode 100644 index 00000000000..542938351f6 --- /dev/null +++ b/apps/dashboard/pkg/migration/schemaversion/v24.go @@ -0,0 +1,631 @@ +package schemaversion + +import ( + "strconv" +) + +// V24 migration migrates the angular table panel to the standard table panel +// In the frontend, this is an auto-migration meaning that this angular panel is always migrated to table panel. +// The backend replicates the complete frontend auto-migration logic since it cannot rely on frontend auto-migration. +// +// This migration performs: +// 1. Converts 'styles' array to 'fieldConfig' with 'defaults' and 'overrides' +// 2. Migrates thresholds and colors to new threshold format +// 3. Converts column-specific styles to field overrides +// 4. Migrates transformations from old format to new transformation system +// 5. Handles various style properties: unit, decimals, alignment, color modes, links, date formatting, hidden columns +// 6. Removes deprecated properties: styles, transform, columns + +// Example 1: Basic table with defaults +// Before migration: +// { +// "panels": [ +// { +// "id": 1, +// "type": "table", +// "title": "Basic Table", +// "styles": [ +// { +// "pattern": "/.*/", +// "thresholds": ["10", "20", "30"], +// "colors": ["green", "yellow", "red"], +// "unit": "bytes", +// "decimals": 2 +// } +// ], +// "targets": [{ "refId": "A" }] +// } +// ] +// } +// +// After migration: +// { +// "panels": [ +// { +// "id": 1, +// "type": "table", +// "title": "Basic Table", +// "fieldConfig": { +// "defaults": { +// "unit": "bytes", +// "decimals": 2, +// "custom": {}, +// "thresholds": { +// "mode": "absolute", +// "steps": [ +// { "color": "green", "value": null }, +// { "color": "green", "value": 10 }, +// { "color": "yellow", "value": 20 }, +// { "color": "red", "value": 30 } +// ] +// } +// }, +// "overrides": [] +// }, +// "transformations": [], +// "targets": [{ "refId": "A" }], +// "pluginVersion": "1.0.0" +// } +// ] +// } + +// Example 2: Complex table with overrides and transformations +// Before migration: +// { +// "panels": [ +// { +// "id": 2, +// "type": "table", +// "title": "Complex Table", +// "styles": [ +// { +// "pattern": "/.*/", +// "unit": "percent", +// "align": "center", +// "colorMode": "cell" +// }, +// { +// "pattern": "Status", +// "alias": "Current Status", +// "colorMode": "value", +// "align": "left" +// }, +// { +// "pattern": "/Error.*/", +// "link": true, +// "linkUrl": "http://example.com/errors", +// "linkTooltip": "View errors", +// "linkTargetBlank": true +// }, +// { +// "pattern": "Time", +// "type": "date", +// "dateFormat": "YYYY-MM-DD HH:mm:ss", +// "alias": "Timestamp" +// }, +// { +// "pattern": "Hidden", +// "type": "hidden" +// } +// ], +// "transform": "timeseries_aggregations", +// "columns": [ +// { "value": "avg", "text": "Average" }, +// { "value": "max", "text": "Maximum" } +// ], +// "targets": [{ "refId": "A" }] +// } +// ] +// } +// +// After migration: +// { +// "panels": [ +// { +// "id": 2, +// "type": "table", +// "title": "Complex Table", +// "fieldConfig": { +// "defaults": { +// "unit": "percent", +// "custom": { +// "align": "center", +// "cellOptions": { "type": "color-background" } +// } +// }, +// "overrides": [ +// { +// "matcher": { "id": "byName", "options": "Status" }, +// "properties": [ +// { "id": "displayName", "value": "Current Status" }, +// { "id": "custom.cellOptions", "value": { "type": "color-text" } }, +// { "id": "custom.align", "value": "left" } +// ] +// }, +// { +// "matcher": { "id": "byRegexp", "options": "/Error.*/" }, +// "properties": [ +// { +// "id": "links", +// "value": [{ +// "title": "View errors", +// "url": "http://example.com/errors", +// "targetBlank": true +// }] +// } +// ] +// }, +// { +// "matcher": { "id": "byName", "options": "Time" }, +// "properties": [ +// { "id": "displayName", "value": "Timestamp" }, +// { "id": "unit", "value": "time: YYYY-MM-DD HH:mm:ss" } +// ] +// }, +// { +// "matcher": { "id": "byName", "options": "Hidden" }, +// "properties": [ +// { "id": "custom.hidden", "value": true } +// ] +// } +// ] +// }, +// "transformations": [ +// { +// "id": "reduce", +// "options": { +// "reducers": ["mean", "max"], +// "includeTimeField": false +// } +// } +// ], +// "targets": [{ "refId": "A" }], +// "pluginVersion": "1.0.0" +// } +// ] +// } + +type v24Migrator struct { + panelProvider PanelPluginInfoProvider + panelPlugins []PanelPluginInfo +} + +func V24(panelProvider PanelPluginInfoProvider) SchemaVersionMigrationFunc { + migrator := &v24Migrator{ + panelProvider: panelProvider, + panelPlugins: panelProvider.GetPanels(), + } + + return migrator.migrate +} + +func (m *v24Migrator) migrate(dashboard map[string]interface{}) error { + dashboard["schemaVersion"] = 24 + + panels, ok := dashboard["panels"].([]interface{}) + if !ok { + return nil + } + + for _, panel := range panels { + panelMap, ok := panel.(map[string]interface{}) + if !ok { + continue + } + + wasAngularTable := panelMap["type"] == "table" + wasReactTable := panelMap["table"] == "table2" + + if wasAngularTable && panelMap["styles"] == nil { + continue + } + + if !wasAngularTable || wasReactTable { + continue + } + + // Find if the panel plugin exists + tablePanelPlugin := m.panelProvider.GetPanelPlugin("table") + if tablePanelPlugin.ID == "" { + return NewMigrationError("table panel plugin not found when migrating dashboard to schema version 24", 24, LATEST_VERSION) + } + panelMap["pluginVersion"] = tablePanelPlugin.Version + err := tablePanelChangedHandler(panelMap) + if err != nil { + return err + } + } + + return nil +} + +func tablePanelChangedHandler(panel map[string]interface{}) error { + prevOptions := getOptionsToRemember(panel) + + transformations := migrateTransformations(panel, prevOptions) + + prevDefaults := findDefaultStyle(prevOptions) + defaults := migrateDefaults(prevDefaults) + + overrides := findNonDefaultStyles(prevOptions) + + if len(overrides) == 0 { + overrides = []interface{}{} + } + + panel["transformations"] = transformations + panel["fieldConfig"] = map[string]interface{}{ + "defaults": defaults, + "overrides": overrides, + } + + // Add default table panel options to match frontend behavior + panel["options"] = map[string]interface{}{ + "cellHeight": "sm", + "footer": map[string]interface{}{ + "countRows": false, + "fields": "", + "reducer": []interface{}{"sum"}, + "show": false, + }, + "showHeader": true, + } + + // Remove deprecated properties + delete(panel, "styles") + delete(panel, "transform") + delete(panel, "columns") + + return nil +} + +// findDefaultStyle finds the style with pattern '/.*/' (default style) +func findDefaultStyle(prevOptions map[string]interface{}) map[string]interface{} { + if styles, ok := prevOptions["styles"].([]interface{}); ok { + for _, style := range styles { + if styleMap, ok := style.(map[string]interface{}); ok { + if pattern, ok := styleMap["pattern"].(string); ok && pattern == "/.*/" { + return styleMap + } + } + } + } + return nil +} + +// findNonDefaultStyles finds all styles that don't have pattern '/.*/' +func findNonDefaultStyles(prevOptions map[string]interface{}) []interface{} { + var overrides []interface{} + + if styles, ok := prevOptions["styles"].([]interface{}); ok { + for _, style := range styles { + if styleMap, ok := style.(map[string]interface{}); ok { + if pattern, ok := styleMap["pattern"].(string); ok && pattern != "/.*/" { + override := migrateTableStyleToOverride(styleMap) + overrides = append(overrides, override) + } + } + } + } + return overrides +} + +// migrateTransformations converts old table transformations to new format +func migrateTransformations(panel map[string]interface{}, oldOpts map[string]interface{}) []interface{} { + transformations := []interface{}{} + if existing, ok := panel["transformations"].([]interface{}); ok { + transformations = existing + } + + // Check if oldOpts has a transform that we can map + if transform, ok := oldOpts["transform"].(string); ok { + if newTransformID, exists := transformsMap[transform]; exists { + opts := map[string]interface{}{ + "reducers": []interface{}{}, + } + + // Handle timeseries_aggregations specifically + if transform == "timeseries_aggregations" { + opts["includeTimeField"] = false + + // Map columns to reducers + if columns, ok := oldOpts["columns"].([]interface{}); ok { + var reducers []interface{} + for _, column := range columns { + if columnMap, ok := column.(map[string]interface{}); ok { + if value, ok := columnMap["value"].(string); ok { + if reducer, exists := columnsMap[value]; exists { + reducers = append(reducers, reducer) + } + } + } + } + opts["reducers"] = reducers + } + } + + // Add the transformation + transformation := map[string]interface{}{ + "id": newTransformID, + "options": opts, + } + transformations = append(transformations, transformation) + } + } + + return transformations +} + +// transformsMap maps old transform names to new transformation IDs +var transformsMap = map[string]string{ + "timeseries_to_rows": "seriesToRows", + "timeseries_to_columns": "seriesToColumns", + "timeseries_aggregations": "reduce", + "table": "merge", +} + +// columnsMap maps old column values to new reducer names +var columnsMap = map[string]string{ + "avg": "mean", + "min": "min", + "max": "max", + "total": "sum", + "current": "lastNotNull", + "count": "count", +} + +// migrateTableStyleToOverride converts a table style to a field config override +func migrateTableStyleToOverride(style map[string]interface{}) map[string]interface{} { + pattern, _ := style["pattern"].(string) + + // Determine field matcher ID based on pattern + fieldMatcherID := "byName" + if pattern != "" && len(pattern) >= 2 && pattern[0] == '/' && pattern[len(pattern)-1] == '/' { + fieldMatcherID = "byRegexp" + } + + override := map[string]interface{}{ + "matcher": map[string]interface{}{ + "id": fieldMatcherID, + "options": pattern, + }, + "properties": []interface{}{}, + } + + properties := override["properties"].([]interface{}) + + // Add display name + if alias, ok := style["alias"].(string); ok && alias != "" { + properties = append(properties, map[string]interface{}{ + "id": "displayName", + "value": alias, + }) + } + + // Add unit + if unit, ok := style["unit"].(string); ok && unit != "" { + properties = append(properties, map[string]interface{}{ + "id": "unit", + "value": unit, + }) + } + + // Add decimals + if decimals, ok := style["decimals"].(float64); ok { + properties = append(properties, map[string]interface{}{ + "id": "decimals", + "value": int(decimals), + }) + } else if decimals, ok := style["decimals"].(int); ok { + properties = append(properties, map[string]interface{}{ + "id": "decimals", + "value": decimals, + }) + } + + // Handle date type + if styleType, ok := style["type"].(string); ok && styleType == "date" { + if dateFormat, ok := style["dateFormat"].(string); ok { + properties = append(properties, map[string]interface{}{ + "id": "unit", + "value": "time: " + dateFormat, + }) + } + } + + // Handle hidden type + if styleType, ok := style["type"].(string); ok && styleType == "hidden" { + properties = append(properties, map[string]interface{}{ + "id": "custom.hidden", + "value": true, + }) + } + + // Handle links + if link, ok := style["link"].(bool); ok && link { + linkTooltip, _ := style["linkTooltip"].(string) + linkUrl, _ := style["linkUrl"].(string) + linkTargetBlank, _ := style["linkTargetBlank"].(bool) + + properties = append(properties, map[string]interface{}{ + "id": "links", + "value": []interface{}{ + map[string]interface{}{ + "title": linkTooltip, + "url": linkUrl, + "targetBlank": linkTargetBlank, + }, + }, + }) + } + + // Handle color mode + if colorMode, ok := style["colorMode"].(string); ok && colorMode != "" { + if newColorMode, exists := colorModeMap[colorMode]; exists { + properties = append(properties, map[string]interface{}{ + "id": "custom.cellOptions", + "value": map[string]interface{}{ + "type": newColorMode, + }, + }) + } + } + + // Handle alignment + if align, ok := style["align"].(string); ok && align != "" { + alignValue := align + if align == "auto" { + alignValue = "" + } + properties = append(properties, map[string]interface{}{ + "id": "custom.align", + "value": alignValue, + }) + } + + // Handle thresholds + if thresholds, ok := style["thresholds"].([]interface{}); ok && len(thresholds) > 0 { + if colors, ok := style["colors"].([]interface{}); ok && len(colors) > 0 { + steps := generateThresholds(thresholds, colors) + properties = append(properties, map[string]interface{}{ + "id": "thresholds", + "value": map[string]interface{}{ + "mode": "absolute", + "steps": steps, + }, + }) + } + } + + override["properties"] = properties + return override +} + +// migrateDefaults converts default table styles to field config defaults +func migrateDefaults(prevDefaults map[string]interface{}) map[string]interface{} { + defaults := map[string]interface{}{ + "custom": map[string]interface{}{ + "align": "auto", + "cellOptions": map[string]interface{}{ + "type": "auto", + }, + "inspect": false, + }, + "mappings": []interface{}{}, + } + + // Add default thresholds for all table panels to match frontend behavior + // The frontend applies the table panel's default field config which includes thresholds + hasThresholds := false + if prevDefaults != nil { + if thresholds, ok := prevDefaults["thresholds"].([]interface{}); ok && len(thresholds) > 0 { + hasThresholds = true + } + } + + // Add default thresholds for all table panels (when prevDefaults exists) without existing thresholds + if !hasThresholds { + defaults["thresholds"] = map[string]interface{}{ + "mode": "absolute", + "steps": []interface{}{ + map[string]interface{}{"color": "green"}, + map[string]interface{}{"color": "red", "value": 80}, + }, + } + } + + if prevDefaults == nil { + return defaults + } + + if unit, ok := prevDefaults["unit"].(string); ok && unit != "" { + defaults["unit"] = unit + } + + if decimals, ok := prevDefaults["decimals"].(float64); ok { + defaults["decimals"] = int(decimals) + } + + if alias, ok := prevDefaults["alias"].(string); ok && alias != "" { + defaults["displayName"] = alias + } + + if align, ok := prevDefaults["align"].(string); ok && align != "" { + alignValue := align + if align == "auto" { + alignValue = "" + } + defaults["custom"].(map[string]interface{})["align"] = alignValue + } + + if thresholds, ok := prevDefaults["thresholds"].([]interface{}); ok && len(thresholds) > 0 { + if colors, ok := prevDefaults["colors"].([]interface{}); ok && len(colors) > 0 { + steps := generateThresholds(thresholds, colors) + defaults["thresholds"] = map[string]interface{}{ + "mode": "absolute", + "steps": steps, + } + } + } + + if colorMode, ok := prevDefaults["colorMode"].(string); ok && colorMode != "" { + if newColorMode, exists := colorModeMap[colorMode]; exists { + defaults["custom"].(map[string]interface{})["cellOptions"] = map[string]interface{}{ + "type": newColorMode, + } + } + } + + return defaults +} + +func generateThresholds(thresholds []interface{}, colors []interface{}) []interface{} { + steps := []interface{}{} + + // Add the base step (equivalent to -Infinity) + var baseColor interface{} = "red" // default fallback + if len(colors) > 0 && colors[0] != nil { + baseColor = colors[0] + } + + steps = append(steps, map[string]interface{}{ + "color": baseColor, + "value": nil, + }) + + // Add threshold steps + for i, threshold := range thresholds { + var color interface{} + // Use colors[i+1] for the i-th threshold (colors[0] was used for base step) + if i+1 < len(colors) && colors[i+1] != nil { + color = colors[i+1] + } else { + color = "red" + } + + var value float64 + switch v := threshold.(type) { + case string: + if parsed, err := strconv.ParseFloat(v, 64); err == nil { + value = parsed + } + case float64: + value = v + case int: + value = float64(v) + } + + steps = append(steps, map[string]interface{}{ + "color": color, + "value": value, + }) + } + + return steps +} + +var colorModeMap = map[string]string{ + "cell": "color-background", + "row": "color-background", + "value": "color-text", +} diff --git a/apps/dashboard/pkg/migration/schemaversion/v24_test.go b/apps/dashboard/pkg/migration/schemaversion/v24_test.go new file mode 100644 index 00000000000..ec093974fdb --- /dev/null +++ b/apps/dashboard/pkg/migration/schemaversion/v24_test.go @@ -0,0 +1,823 @@ +package schemaversion_test + +import ( + "testing" + + "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" + "github.com/grafana/grafana/apps/dashboard/pkg/migration/testutil" +) + +func TestV24(t *testing.T) { + tests := []migrationTestCase{ + { + name: "should migrate basic Angular table with defaults", + input: map[string]interface{}{ + "schemaVersion": 23, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "table", + "title": "Basic Table", + "legend": true, + "styles": []interface{}{ + map[string]interface{}{ + "thresholds": []interface{}{"10", "20", "30"}, + "colors": []interface{}{"red", "yellow", "green"}, + "pattern": "/.*/", + }, + }, + "targets": []interface{}{ + map[string]interface{}{"refId": "A"}, + }, + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 24, + "panels": []interface{}{ + map[string]interface{}{ + "id": 1, + "type": "table", + "title": "Basic Table", + "legend": true, + "fieldConfig": map[string]interface{}{ + "defaults": map[string]interface{}{ + "custom": map[string]interface{}{ + "align": "auto", + "cellOptions": map[string]interface{}{ + "type": "auto", + }, + "inspect": false, + }, + "mappings": []interface{}{}, + "thresholds": map[string]interface{}{ + "mode": "absolute", + "steps": []interface{}{ + map[string]interface{}{"value": nil, "color": "red"}, + map[string]interface{}{"value": float64(10), "color": "yellow"}, + map[string]interface{}{"value": float64(20), "color": "green"}, + map[string]interface{}{"value": float64(30), "color": "red"}, + }, + }, + }, + "overrides": []interface{}{}, + }, + "options": map[string]interface{}{ + "cellHeight": "sm", + "footer": map[string]interface{}{ + "countRows": false, + "fields": "", + "reducer": []interface{}{"sum"}, + "show": false, + }, + "showHeader": true, + }, + "transformations": []interface{}{}, + "targets": []interface{}{ + map[string]interface{}{"refId": "A"}, + }, + "pluginVersion": "1.0.0", + }, + }, + }, + }, + { + name: "should migrate table with complex defaults and overrides", + input: map[string]interface{}{ + "schemaVersion": 23, + "panels": []interface{}{ + map[string]interface{}{ + "id": 2, + "type": "table", + "title": "Complex Table", + "styles": []interface{}{ + // Default style + map[string]interface{}{ + "pattern": "/.*/", + "unit": "bytes", + "decimals": float64(2), + "align": "center", + "colorMode": "cell", + "thresholds": []interface{}{"100", "500"}, + "colors": []interface{}{"green", "yellow", "red"}, + }, + // Column-specific override with exact name + map[string]interface{}{ + "pattern": "Status", + "alias": "Current Status", + "unit": "short", + "decimals": float64(0), + "colorMode": "value", + "align": "left", + }, + // Column-specific override with regex pattern + map[string]interface{}{ + "pattern": "/Error.*/", + "link": true, + "linkUrl": "http://example.com/errors", + "linkTooltip": "View error details", + "linkTargetBlank": true, + "colorMode": "row", + "colors": []interface{}{"red", "orange"}, + }, + // Date column + map[string]interface{}{ + "pattern": "Time", + "type": "date", + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "alias": "Timestamp", + }, + // Hidden column + map[string]interface{}{ + "pattern": "Hidden", + "type": "hidden", + }, + }, + "targets": []interface{}{ + map[string]interface{}{"refId": "A"}, + }, + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 24, + "panels": []interface{}{ + map[string]interface{}{ + "id": 2, + "type": "table", + "title": "Complex Table", + "fieldConfig": map[string]interface{}{ + "defaults": map[string]interface{}{ + "unit": "bytes", + "decimals": 2, + "custom": map[string]interface{}{ + "align": "center", + "cellOptions": map[string]interface{}{ + "type": "color-background", + }, + "inspect": false, + }, + "mappings": []interface{}{}, + "thresholds": map[string]interface{}{ + "mode": "absolute", + "steps": []interface{}{ + map[string]interface{}{"value": nil, "color": "green"}, + map[string]interface{}{"value": float64(100), "color": "yellow"}, + map[string]interface{}{"value": float64(500), "color": "red"}, + }, + }, + }, + "overrides": []interface{}{ + map[string]interface{}{ + "matcher": map[string]interface{}{ + "id": "byName", + "options": "Status", + }, + "properties": []interface{}{ + map[string]interface{}{"id": "displayName", "value": "Current Status"}, + map[string]interface{}{"id": "unit", "value": "short"}, + map[string]interface{}{"id": "decimals", "value": 0}, + map[string]interface{}{"id": "custom.cellOptions", "value": map[string]interface{}{"type": "color-text"}}, + map[string]interface{}{"id": "custom.align", "value": "left"}, + }, + }, + map[string]interface{}{ + "matcher": map[string]interface{}{ + "id": "byRegexp", + "options": "/Error.*/", + }, + "properties": []interface{}{ + map[string]interface{}{ + "id": "links", + "value": []interface{}{ + map[string]interface{}{ + "title": "View error details", + "url": "http://example.com/errors", + "targetBlank": true, + }, + }, + }, + map[string]interface{}{"id": "custom.cellOptions", "value": map[string]interface{}{"type": "color-background"}}, + }, + }, + map[string]interface{}{ + "matcher": map[string]interface{}{ + "id": "byName", + "options": "Time", + }, + "properties": []interface{}{ + map[string]interface{}{"id": "displayName", "value": "Timestamp"}, + map[string]interface{}{"id": "unit", "value": "time: YYYY-MM-DD HH:mm:ss"}, + }, + }, + map[string]interface{}{ + "matcher": map[string]interface{}{ + "id": "byName", + "options": "Hidden", + }, + "properties": []interface{}{ + map[string]interface{}{"id": "custom.hidden", "value": true}, + }, + }, + }, + }, + "options": map[string]interface{}{ + "cellHeight": "sm", + "footer": map[string]interface{}{ + "countRows": false, + "fields": "", + "reducer": []interface{}{"sum"}, + "show": false, + }, + "showHeader": true, + }, + "transformations": []interface{}{}, + "targets": []interface{}{ + map[string]interface{}{"refId": "A"}, + }, + "pluginVersion": "1.0.0", + }, + }, + }, + }, + { + name: "should migrate table with timeseries_aggregations transform", + input: map[string]interface{}{ + "schemaVersion": 23, + "panels": []interface{}{ + map[string]interface{}{ + "id": 3, + "type": "table", + "title": "Table with Aggregations", + "styles": []interface{}{ + map[string]interface{}{ + "pattern": "/.*/", + "unit": "percent", + "decimals": float64(1), + }, + }, + "transform": "timeseries_aggregations", + "columns": []interface{}{ + map[string]interface{}{"value": "avg", "text": "Average"}, + map[string]interface{}{"value": "max", "text": "Maximum"}, + map[string]interface{}{"value": "min", "text": "Minimum"}, + map[string]interface{}{"value": "total", "text": "Total"}, + map[string]interface{}{"value": "current", "text": "Current"}, + map[string]interface{}{"value": "count", "text": "Count"}, + }, + "targets": []interface{}{ + map[string]interface{}{"refId": "A"}, + }, + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 24, + "panels": []interface{}{ + map[string]interface{}{ + "id": 3, + "type": "table", + "title": "Table with Aggregations", + "fieldConfig": map[string]interface{}{ + "defaults": map[string]interface{}{ + "unit": "percent", + "decimals": 1, + "custom": map[string]interface{}{ + "align": "auto", + "cellOptions": map[string]interface{}{ + "type": "auto", + }, + "inspect": false, + }, + "mappings": []interface{}{}, + "thresholds": map[string]interface{}{ + "mode": "absolute", + "steps": []interface{}{ + map[string]interface{}{"color": "green"}, + map[string]interface{}{"color": "red", "value": 80}, + }, + }, + }, + "overrides": []interface{}{}, + }, + "options": map[string]interface{}{ + "cellHeight": "sm", + "footer": map[string]interface{}{ + "countRows": false, + "fields": "", + "reducer": []interface{}{"sum"}, + "show": false, + }, + "showHeader": true, + }, + "transformations": []interface{}{ + map[string]interface{}{ + "id": "reduce", + "options": map[string]interface{}{ + "reducers": []interface{}{"mean", "max", "min", "sum", "lastNotNull", "count"}, + "includeTimeField": false, + }, + }, + }, + "targets": []interface{}{ + map[string]interface{}{"refId": "A"}, + }, + "pluginVersion": "1.0.0", + }, + }, + }, + }, + { + name: "should migrate table with timeseries_to_rows transform", + input: map[string]interface{}{ + "schemaVersion": 23, + "panels": []interface{}{ + map[string]interface{}{ + "id": 4, + "type": "table", + "title": "Table with Rows Transform", + "styles": []interface{}{ + map[string]interface{}{ + "pattern": "/.*/", + "unit": "short", + }, + }, + "transform": "timeseries_to_rows", + "targets": []interface{}{ + map[string]interface{}{"refId": "A"}, + }, + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 24, + "panels": []interface{}{ + map[string]interface{}{ + "id": 4, + "type": "table", + "title": "Table with Rows Transform", + "fieldConfig": map[string]interface{}{ + "defaults": map[string]interface{}{ + "unit": "short", + "custom": map[string]interface{}{ + "align": "auto", + "cellOptions": map[string]interface{}{ + "type": "auto", + }, + "inspect": false, + }, + "mappings": []interface{}{}, + "thresholds": map[string]interface{}{ + "mode": "absolute", + "steps": []interface{}{ + map[string]interface{}{"color": "green"}, + map[string]interface{}{"color": "red", "value": 80}, + }, + }, + }, + "overrides": []interface{}{}, + }, + "options": map[string]interface{}{ + "cellHeight": "sm", + "footer": map[string]interface{}{ + "countRows": false, + "fields": "", + "reducer": []interface{}{"sum"}, + "show": false, + }, + "showHeader": true, + }, + "transformations": []interface{}{ + map[string]interface{}{ + "id": "seriesToRows", + "options": map[string]interface{}{ + "reducers": []interface{}{}, + }, + }, + }, + "targets": []interface{}{ + map[string]interface{}{"refId": "A"}, + }, + "pluginVersion": "1.0.0", + }, + }, + }, + }, + { + name: "should migrate table with timeseries_to_columns transform", + input: map[string]interface{}{ + "schemaVersion": 23, + "panels": []interface{}{ + map[string]interface{}{ + "id": 5, + "type": "table", + "title": "Table with Columns Transform", + "styles": []interface{}{ + map[string]interface{}{ + "pattern": "/.*/", + "unit": "bytes", + }, + }, + "transform": "timeseries_to_columns", + "targets": []interface{}{ + map[string]interface{}{"refId": "A"}, + }, + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 24, + "panels": []interface{}{ + map[string]interface{}{ + "id": 5, + "type": "table", + "title": "Table with Columns Transform", + "fieldConfig": map[string]interface{}{ + "defaults": map[string]interface{}{ + "unit": "bytes", + "custom": map[string]interface{}{ + "align": "auto", + "cellOptions": map[string]interface{}{ + "type": "auto", + }, + "inspect": false, + }, + "mappings": []interface{}{}, + "thresholds": map[string]interface{}{ + "mode": "absolute", + "steps": []interface{}{ + map[string]interface{}{"color": "green"}, + map[string]interface{}{"color": "red", "value": 80}, + }, + }, + }, + "overrides": []interface{}{}, + }, + "options": map[string]interface{}{ + "cellHeight": "sm", + "footer": map[string]interface{}{ + "countRows": false, + "fields": "", + "reducer": []interface{}{"sum"}, + "show": false, + }, + "showHeader": true, + }, + "transformations": []interface{}{ + map[string]interface{}{ + "id": "seriesToColumns", + "options": map[string]interface{}{ + "reducers": []interface{}{}, + }, + }, + }, + "targets": []interface{}{ + map[string]interface{}{"refId": "A"}, + }, + "pluginVersion": "1.0.0", + }, + }, + }, + }, + { + name: "should migrate table with table merge transform", + input: map[string]interface{}{ + "schemaVersion": 23, + "panels": []interface{}{ + map[string]interface{}{ + "id": 6, + "type": "table", + "title": "Table with Merge Transform", + "styles": []interface{}{ + map[string]interface{}{ + "pattern": "/.*/", + "align": "auto", + }, + }, + "transform": "table", + "targets": []interface{}{ + map[string]interface{}{"refId": "A"}, + }, + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 24, + "panels": []interface{}{ + map[string]interface{}{ + "id": 6, + "type": "table", + "title": "Table with Merge Transform", + "fieldConfig": map[string]interface{}{ + "defaults": map[string]interface{}{ + "custom": map[string]interface{}{ + "align": "", + "cellOptions": map[string]interface{}{ + "type": "auto", + }, + "inspect": false, + }, + "mappings": []interface{}{}, + "thresholds": map[string]interface{}{ + "mode": "absolute", + "steps": []interface{}{ + map[string]interface{}{"color": "green"}, + map[string]interface{}{"color": "red", "value": 80}, + }, + }, + }, + "overrides": []interface{}{}, + }, + "options": map[string]interface{}{ + "cellHeight": "sm", + "footer": map[string]interface{}{ + "countRows": false, + "fields": "", + "reducer": []interface{}{"sum"}, + "show": false, + }, + "showHeader": true, + }, + "transformations": []interface{}{ + map[string]interface{}{ + "id": "merge", + "options": map[string]interface{}{ + "reducers": []interface{}{}, + }, + }, + }, + "targets": []interface{}{ + map[string]interface{}{"refId": "A"}, + }, + "pluginVersion": "1.0.0", + }, + }, + }, + }, + { + name: "should migrate table with existing transformations", + input: map[string]interface{}{ + "schemaVersion": 23, + "panels": []interface{}{ + map[string]interface{}{ + "id": 7, + "type": "table", + "title": "Table with Existing Transformations", + "styles": []interface{}{ + map[string]interface{}{ + "pattern": "/.*/", + "unit": "short", + }, + }, + "transformations": []interface{}{ + map[string]interface{}{ + "id": "filterFieldsByName", + "options": map[string]interface{}{ + "include": map[string]interface{}{ + "names": []interface{}{"field1", "field2"}, + }, + }, + }, + }, + "transform": "timeseries_to_rows", + "targets": []interface{}{ + map[string]interface{}{"refId": "A"}, + }, + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 24, + "panels": []interface{}{ + map[string]interface{}{ + "id": 7, + "type": "table", + "title": "Table with Existing Transformations", + "fieldConfig": map[string]interface{}{ + "defaults": map[string]interface{}{ + "unit": "short", + "custom": map[string]interface{}{ + "align": "auto", + "cellOptions": map[string]interface{}{ + "type": "auto", + }, + "inspect": false, + }, + "mappings": []interface{}{}, + "thresholds": map[string]interface{}{ + "mode": "absolute", + "steps": []interface{}{ + map[string]interface{}{"color": "green"}, + map[string]interface{}{"color": "red", "value": 80}, + }, + }, + }, + "overrides": []interface{}{}, + }, + "options": map[string]interface{}{ + "cellHeight": "sm", + "footer": map[string]interface{}{ + "countRows": false, + "fields": "", + "reducer": []interface{}{"sum"}, + "show": false, + }, + "showHeader": true, + }, + "transformations": []interface{}{ + map[string]interface{}{ + "id": "filterFieldsByName", + "options": map[string]interface{}{ + "include": map[string]interface{}{ + "names": []interface{}{"field1", "field2"}, + }, + }, + }, + map[string]interface{}{ + "id": "seriesToRows", + "options": map[string]interface{}{ + "reducers": []interface{}{}, + }, + }, + }, + "targets": []interface{}{ + map[string]interface{}{"refId": "A"}, + }, + "pluginVersion": "1.0.0", + }, + }, + }, + }, + { + name: "should not migrate angular table without styles", + input: map[string]interface{}{ + "schemaVersion": 23, + "panels": []interface{}{ + map[string]interface{}{ + "id": 8, + "type": "table", + "title": "Table without styles", + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 24, + "panels": []interface{}{ + map[string]interface{}{ + "id": 8, + "type": "table", + "title": "Table without styles", + }, + }, + }, + }, + { + name: "should not migrate react table (table2)", + input: map[string]interface{}{ + "schemaVersion": 23, + "panels": []interface{}{ + map[string]interface{}{ + "id": 9, + "type": "table", + "table": "table2", + "title": "React table", + "styles": []interface{}{ + map[string]interface{}{ + "pattern": "/.*/", + "unit": "short", + }, + }, + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 24, + "panels": []interface{}{ + map[string]interface{}{ + "id": 9, + "type": "table", + "table": "table2", + "title": "React table", + "styles": []interface{}{ + map[string]interface{}{ + "pattern": "/.*/", + "unit": "short", + }, + }, + }, + }, + }, + }, + { + name: "should not migrate non-table panels", + input: map[string]interface{}{ + "schemaVersion": 23, + "panels": []interface{}{ + map[string]interface{}{ + "id": 10, + "type": "graph", + "title": "Graph panel", + "targets": []interface{}{ + map[string]interface{}{"refId": "A"}, + }, + }, + map[string]interface{}{ + "id": 11, + "type": "singlestat", + "title": "Singlestat panel", + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 24, + "panels": []interface{}{ + map[string]interface{}{ + "id": 10, + "type": "graph", + "title": "Graph panel", + "targets": []interface{}{ + map[string]interface{}{"refId": "A"}, + }, + }, + map[string]interface{}{ + "id": 11, + "type": "singlestat", + "title": "Singlestat panel", + }, + }, + }, + }, + { + name: "should handle mixed numeric and string thresholds", + input: map[string]interface{}{ + "schemaVersion": 23, + "panels": []interface{}{ + map[string]interface{}{ + "id": 12, + "type": "table", + "title": "Mixed threshold types", + "styles": []interface{}{ + map[string]interface{}{ + "pattern": "/.*/", + "thresholds": []interface{}{10, "20", 30.5}, + "colors": []interface{}{"green", "yellow", "orange", "red"}, + }, + }, + "targets": []interface{}{ + map[string]interface{}{"refId": "A"}, + }, + }, + }, + }, + expected: map[string]interface{}{ + "schemaVersion": 24, + "panels": []interface{}{ + map[string]interface{}{ + "id": 12, + "type": "table", + "title": "Mixed threshold types", + "fieldConfig": map[string]interface{}{ + "defaults": map[string]interface{}{ + "custom": map[string]interface{}{ + "align": "auto", + "cellOptions": map[string]interface{}{ + "type": "auto", + }, + "inspect": false, + }, + "mappings": []interface{}{}, + "thresholds": map[string]interface{}{ + "mode": "absolute", + "steps": []interface{}{ + map[string]interface{}{"value": nil, "color": "green"}, + map[string]interface{}{"value": float64(10), "color": "yellow"}, + map[string]interface{}{"value": float64(20), "color": "orange"}, + map[string]interface{}{"value": float64(30.5), "color": "red"}, + }, + }, + }, + "overrides": []interface{}{}, + }, + "options": map[string]interface{}{ + "cellHeight": "sm", + "footer": map[string]interface{}{ + "countRows": false, + "fields": "", + "reducer": []interface{}{"sum"}, + "show": false, + }, + "showHeader": true, + }, + "transformations": []interface{}{}, + "targets": []interface{}{ + map[string]interface{}{"refId": "A"}, + }, + "pluginVersion": "1.0.0", + }, + }, + }, + }, + } + + runMigrationTests(t, tests, schemaversion.V24(testutil.GetTestPanelProvider())) +} diff --git a/apps/dashboard/pkg/migration/schemaversion/v25.go b/apps/dashboard/pkg/migration/schemaversion/v25.go new file mode 100644 index 00000000000..82d9e47b63d --- /dev/null +++ b/apps/dashboard/pkg/migration/schemaversion/v25.go @@ -0,0 +1,49 @@ +package schemaversion + +// V25 migration is a no-op migration +// It only updates the schema version to 25 +// It's created to keep the migration history consistent with frontend migrator +// Variable tag removal is handled in v28 migration + +// Example before migration: +// { +// "templating": { +// "list": [ +// { +// "name": "tags should not be removed", +// "type": "query", +// "datasource": "prometheus", +// "tags": ["tags should not be removed"], +// "tagsQuery": "tag should not be removed", +// "tagValuesQuery": "tag should not be removed", +// "useTags": true, +// "options": [] + +// } +// ] +// } +// } + +// Example after migration: +// { +// "templating": { +// "list": [ +// { +// "name": "tags should not be removed", +// "type": "query", +// "datasource": "prometheus", +// "tags": ["tags should not be removed"], +// "tagsQuery": "tag should not be removed", +// "tagValuesQuery": "tag should not be removed", +// "useTags": true, +// "options": [] + +// } +// ] +// } +// } + +func V25(dashboard map[string]interface{}) error { + dashboard["schemaVersion"] = int(25) + return nil +} diff --git a/apps/dashboard/pkg/migration/schemaversion/v25_test.go b/apps/dashboard/pkg/migration/schemaversion/v25_test.go new file mode 100644 index 00000000000..3e9cff1bc35 --- /dev/null +++ b/apps/dashboard/pkg/migration/schemaversion/v25_test.go @@ -0,0 +1,38 @@ +package schemaversion_test + +import ( + "testing" + + "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" +) + +func TestV25(t *testing.T) { + tests := []migrationTestCase{ + { + name: "v25 no-op migration, updates schema version only", + input: map[string]interface{}{ + "title": "V25 No-Op Migration Test Dashboard", + "schemaVersion": 24, + "panels": []interface{}{ + map[string]interface{}{ + "type": "timeseries", + "title": "Panel remains unchanged", + "id": 1, + }, + }, + }, + expected: map[string]interface{}{ + "title": "V25 No-Op Migration Test Dashboard", + "schemaVersion": 25, + "panels": []interface{}{ + map[string]interface{}{ + "type": "timeseries", + "title": "Panel remains unchanged", + "id": 1, + }, + }, + }, + }, + } + runMigrationTests(t, tests, schemaversion.V25) +} diff --git a/apps/dashboard/pkg/migration/schemaversion/v28.go b/apps/dashboard/pkg/migration/schemaversion/v28.go index 9509e8a8a87..df899c86c1d 100644 --- a/apps/dashboard/pkg/migration/schemaversion/v28.go +++ b/apps/dashboard/pkg/migration/schemaversion/v28.go @@ -258,6 +258,24 @@ func (m *v28Migrator) migratetSinglestat(panel map[string]interface{}, defaults // Migrate thresholds FIRST (consolidated: both panel types create DEFAULT_THRESHOLDS for empty strings) m.migrateThresholds(angularOpts, defaults) + // If no thresholds were set from angular migration, add default stat panel thresholds + // This matches the behavior of frontend pluginLoaded which adds default thresholds + if _, hasThresholds := defaults["thresholds"]; !hasThresholds { + defaults["thresholds"] = map[string]interface{}{ + "mode": "absolute", + "steps": []interface{}{ + map[string]interface{}{ + "color": "green", + "value": nil, + }, + map[string]interface{}{ + "color": "red", + "value": 80, + }, + }, + } + } + // Apply common angular option migrations (value mappings can now use threshold colors) m.applyCommonAngularMigration(panel, defaults, options, angularOpts) @@ -298,6 +316,24 @@ func (m *v28Migrator) migrateGrafanaSinglestatPanel(panel map[string]interface{} // Migrate thresholds FIRST (consolidated: both panel types create DEFAULT_THRESHOLDS for empty strings) m.migrateThresholds(angularOpts, defaults) + // If no thresholds were set from angular migration, add default stat panel thresholds + // This matches the behavior of frontend pluginLoaded which adds default thresholds + if _, hasThresholds := defaults["thresholds"]; !hasThresholds { + defaults["thresholds"] = map[string]interface{}{ + "mode": "absolute", + "steps": []interface{}{ + map[string]interface{}{ + "color": "green", + "value": nil, + }, + map[string]interface{}{ + "color": "red", + "value": 80, + }, + }, + } + } + // Apply common angular option migrations (value mappings can now use threshold colors) m.applyCommonAngularMigration(panel, defaults, options, angularOpts) diff --git a/apps/dashboard/pkg/migration/schemaversion/v28_test.go b/apps/dashboard/pkg/migration/schemaversion/v28_test.go index f61e332f1ac..8692fef6096 100644 --- a/apps/dashboard/pkg/migration/schemaversion/v28_test.go +++ b/apps/dashboard/pkg/migration/schemaversion/v28_test.go @@ -160,6 +160,19 @@ func TestV28(t *testing.T) { "fixedColor": "#ff0000", }, "mappings": []interface{}{}, + "thresholds": map[string]interface{}{ + "mode": "absolute", + "steps": []interface{}{ + map[string]interface{}{ + "color": "green", + "value": nil, + }, + map[string]interface{}{ + "color": "red", + "value": 80, + }, + }, + }, }, "overrides": []interface{}{}, }, @@ -228,6 +241,19 @@ func TestV28(t *testing.T) { "fixedColor": "#ff0000", }, "mappings": []interface{}{}, + "thresholds": map[string]interface{}{ + "mode": "absolute", + "steps": []interface{}{ + map[string]interface{}{ + "color": "green", + "value": nil, + }, + map[string]interface{}{ + "color": "red", + "value": 80, + }, + }, + }, }, "overrides": []interface{}{}, }, @@ -512,7 +538,20 @@ func TestV28(t *testing.T) { }, "fieldConfig": map[string]interface{}{ "defaults": map[string]interface{}{ - "unit": "short", + "unit": "short", + "thresholds": map[string]interface{}{ + "mode": "absolute", + "steps": []interface{}{ + map[string]interface{}{ + "color": "green", + "value": nil, + }, + map[string]interface{}{ + "color": "red", + "value": 80, + }, + }, + }, "mappings": []interface{}{}, }, "overrides": []interface{}{}, @@ -564,7 +603,20 @@ func TestV28(t *testing.T) { }, "fieldConfig": map[string]interface{}{ "defaults": map[string]interface{}{ - "unit": "short", + "unit": "short", + "thresholds": map[string]interface{}{ + "mode": "absolute", + "steps": []interface{}{ + map[string]interface{}{ + "color": "green", + "value": nil, + }, + map[string]interface{}{ + "color": "red", + "value": 80, + }, + }, + }, "mappings": []interface{}{}, }, "overrides": []interface{}{}, @@ -626,7 +678,20 @@ func TestV28(t *testing.T) { }, "fieldConfig": map[string]interface{}{ "defaults": map[string]interface{}{ - "unit": "bytes", + "unit": "bytes", + "thresholds": map[string]interface{}{ + "mode": "absolute", + "steps": []interface{}{ + map[string]interface{}{ + "color": "green", + "value": nil, + }, + map[string]interface{}{ + "color": "red", + "value": 80, + }, + }, + }, "mappings": []interface{}{}, }, "overrides": []interface{}{}, diff --git a/apps/dashboard/pkg/migration/testdata/input/v17.minspan_to_maxperrow.json b/apps/dashboard/pkg/migration/testdata/input/v17.minspan_to_maxperrow.json new file mode 100644 index 00000000000..278ed37b13f --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/input/v17.minspan_to_maxperrow.json @@ -0,0 +1,125 @@ +{ + "title": "V17 MinSpan to MaxPerRow Migration Test Dashboard", + "schemaVersion": 16, + "panels": [ + { + "id": 1, + "type": "graph", + "title": "Panel with minSpan 8", + "minSpan": 8, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + } + }, + { + "id": 2, + "type": "singlestat", + "title": "Panel with minSpan 4", + "minSpan": 4, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 8 + } + }, + { + "id": 3, + "type": "table", + "title": "Panel with minSpan 2", + "minSpan": 2, + "gridPos": { + "h": 6, + "w": 24, + "x": 0, + "y": 12 + } + }, + { + "id": 4, + "type": "graph", + "title": "Panel with minSpan 12", + "minSpan": 12, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + } + }, + { + "id": 5, + "type": "gauge", + "title": "Panel with minSpan 24", + "minSpan": 24, + "gridPos": { + "h": 4, + "w": 24, + "x": 0, + "y": 18 + } + }, + { + "id": 6, + "type": "stat", + "title": "Panel with minSpan 1", + "minSpan": 1, + "gridPos": { + "h": 3, + "w": 4, + "x": 6, + "y": 8 + } + }, + { + "id": 7, + "type": "graph", + "title": "Panel without minSpan", + "gridPos": { + "h": 6, + "w": 8, + "x": 12, + "y": 8 + } + }, + { + "id": 8, + "type": "text", + "title": "Panel with invalid minSpan", + "minSpan": "invalid", + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 8 + } + }, + { + "id": 9, + "type": "graph", + "title": "Panel with zero minSpan", + "minSpan": 0, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 22 + } + }, + { + "id": 10, + "type": "graph", + "title": "Panel with negative minSpan", + "minSpan": -5, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 22 + } + } + ] +} diff --git a/apps/dashboard/pkg/migration/testdata/input/v18.gauge_options.json b/apps/dashboard/pkg/migration/testdata/input/v18.gauge_options.json new file mode 100644 index 00000000000..1e8566dd793 --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/input/v18.gauge_options.json @@ -0,0 +1,66 @@ +{ + "title": "V18 Gauge Options Migration Test Dashboard", + "schemaVersion": 17, + "panels": [ + { + "id": 1, + "type": "gauge", + "title": "Complete Gauge Panel", + "options-gauge": { + "unit": "ms", + "stat": "last", + "decimals": 2, + "prefix": "Value: ", + "suffix": " ms", + "thresholds": [ + {"color": "green", "value": 0}, + {"color": "yellow", "value": 50}, + {"color": "red", "value": 100} + ] + } + }, + { + "id": 2, + "type": "gauge", + "title": "Partial Gauge Panel", + "options-gauge": { + "unit": "percent", + "decimals": 1 + } + }, + { + "id": 3, + "type": "gauge", + "title": "Buggy Gauge Panel", + "options-gauge": { + "unit": "bytes", + "options": "this should be deleted", + "stat": "avg", + "decimals": 0 + } + }, + { + "id": 4, + "type": "gauge", + "title": "Custom Properties Gauge Panel", + "options-gauge": { + "unit": "short", + "customProperty": "customValue", + "anotherProp": 42, + "thresholds": [ + {"color": "blue", "value": 10} + ] + } + }, + { + "id": 5, + "type": "graph", + "title": "Non-Gauge Panel", + "options": { + "legend": { + "show": true + } + } + } + ] +} diff --git a/apps/dashboard/pkg/migration/testdata/input/v19.panel_links.json b/apps/dashboard/pkg/migration/testdata/input/v19.panel_links.json new file mode 100644 index 00000000000..3ddef30484b --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/input/v19.panel_links.json @@ -0,0 +1,73 @@ +{ + "title": "V19 Panel Links Migration Test Dashboard", + "schemaVersion": 18, + "panels": [ + { + "id": 1, + "type": "graph", + "title": "Panel with legacy dashboard link", + "links": [ + { + "dashboard": "my dashboard", + "title": "Dashboard Link", + "keepTime": true + } + ] + }, + { + "id": 2, + "type": "stat", + "title": "Panel with dashUri link", + "links": [ + { + "dashUri": "my-dashboard-uid", + "title": "DashUri Link", + "includeVars": true + } + ] + }, + { + "id": 3, + "type": "table", + "title": "Panel with custom params", + "links": [ + { + "url": "http://example.com", + "params": "customParam=value", + "title": "Custom Params Link" + } + ] + }, + { + "id": 4, + "type": "timeseries", + "title": "Panel with complex link", + "links": [ + { + "dashboard": "complex dashboard", + "keepTime": true, + "includeVars": true, + "params": "param1=value1¶m2=value2", + "title": "Complex Link", + "targetBlank": true + } + ] + }, + { + "id": 5, + "type": "gauge", + "title": "Panel with existing URL", + "links": [ + { + "url": "http://existing-url.com", + "title": "Existing URL Link" + } + ] + }, + { + "id": 6, + "type": "singlestat", + "title": "Panel with no links" + } + ] +} diff --git a/apps/dashboard/pkg/migration/testdata/input/v20.variable_syntax_links.json b/apps/dashboard/pkg/migration/testdata/input/v20.variable_syntax_links.json new file mode 100644 index 00000000000..1b1a1e7fb9e --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/input/v20.variable_syntax_links.json @@ -0,0 +1,152 @@ +{ + "title": "V20 Variable Syntax Migration Test Dashboard", + "schemaVersion": 19, + "panels": [ + { + "type": "timeseries", + "title": "Panel with data links using legacy variable syntax", + "id": 1, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "options": { + "dataLinks": [ + { + "title": "Link with series name", + "url": "http://example.com?series=$__series_name×tamp=__value_time", + "targetBlank": true + }, + { + "title": "Link with field name", + "url": "http://grafana.com/dashboard?field=__field_name&series=$__series_name", + "targetBlank": false + } + ] + } + }, + { + "type": "stat", + "title": "Panel with field options using legacy variable syntax", + "id": 2, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "options": { + "fieldOptions": { + "defaults": { + "title": "Series: __series_name, Field: $__field_name, Time: __value_time", + "links": [ + { + "title": "Field link", + "url": "http://monitoring.com?field=$__field_name&series=__series_name", + "targetBlank": true + }, + { + "title": "Time-based link", + "url": "http://logs.com?time=__value_time&field=__field_name", + "targetBlank": false + } + ] + } + } + } + }, + { + "type": "gauge", + "title": "Panel with both data links and field options", + "id": 3, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 8 + }, + "options": { + "dataLinks": [ + { + "title": "Combined link", + "url": "http://combined.com?series=$__series_name&field=$__field_name&time=__value_time", + "targetBlank": true + } + ], + "fieldOptions": { + "defaults": { + "title": "Complete: __series_name / __field_name / __value_time", + "links": [ + { + "title": "Comprehensive link", + "url": "http://comprehensive.com?s=$__series_name&f=__field_name&t=__value_time", + "targetBlank": false + } + ] + } + } + } + }, + { + "type": "table", + "title": "Panel with no legacy variables (should remain unchanged)", + "id": 4, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "options": { + "dataLinks": [ + { + "title": "Modern link", + "url": "http://modern.com?series=${__series.name}&field=${__field.name}&time=${__value.time}", + "targetBlank": true + } + ], + "fieldOptions": { + "defaults": { + "title": "Modern: ${__series.name} / ${__field.name} / ${__value.time}", + "links": [ + { + "title": "Modern field link", + "url": "http://modern-field.com?s=${__series.name}&f=${__field.name}", + "targetBlank": false + } + ] + } + } + } + }, + { + "type": "text", + "title": "Panel with no data links or field options", + "id": 5, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "options": { + "content": "This panel has no data links or field options to migrate." + } + } + ], + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "tags": [ + "migration-test" + ], + "style": "dark", + "refresh": "5s", + "version": 0, + "uid": "v20-migration-test" +} diff --git a/apps/dashboard/pkg/migration/testdata/input/v21.data_links_series_to_field.json b/apps/dashboard/pkg/migration/testdata/input/v21.data_links_series_to_field.json new file mode 100644 index 00000000000..6a9c046137d --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/input/v21.data_links_series_to_field.json @@ -0,0 +1,85 @@ +{ + "title": "V21 Data Links Series to Field Migration Test Dashboard", + "schemaVersion": 20, + "panels": [ + { + "type": "timeseries", + "title": "Panel with data links", + "id": 1, + "options": { + "dataLinks": [ + { + "url": "http://mylink.com?series=${__series.labels}&${__series.labels.a}", + "title": "Data Link 1" + }, + { + "url": "http://anotherlink.com?param=${__series.labels}", + "title": "Data Link 2" + } + ] + } + }, + { + "type": "stat", + "title": "Panel with field options links", + "id": 2, + "options": { + "fieldOptions": { + "defaults": { + "links": [ + { + "url": "http://mylink.com?series=${__series.labels}&${__series.labels.x}", + "title": "Field Link 1" + }, + { + "url": "http://fieldlink.com?field=${__series.labels}", + "title": "Field Link 2" + } + ] + } + } + } + }, + { + "type": "graph", + "title": "Panel with both link types", + "id": 3, + "options": { + "dataLinks": [ + { + "url": "http://mylink.com?series=${__series.labels}", + "title": "Graph Data Link" + } + ], + "fieldOptions": { + "defaults": { + "links": [ + { + "url": "http://mylink.com?field=${__series.labels}", + "title": "Graph Field Link" + } + ] + } + } + } + }, + { + "type": "timeseries", + "title": "Panel without series labels", + "id": 4, + "options": { + "dataLinks": [ + { + "url": "http://mylink.com?other=${__field.labels}", + "title": "No Series Labels Link" + } + ] + } + }, + { + "type": "timeseries", + "title": "Panel without options", + "id": 5 + } + ] +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/testdata/input/v22.table_panel_align.json b/apps/dashboard/pkg/migration/testdata/input/v22.table_panel_align.json new file mode 100644 index 00000000000..c376f546ddf --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/input/v22.table_panel_align.json @@ -0,0 +1,14 @@ +{ + "title": "V22 Table Panel Styles Test", + "schemaVersion": 21, + "panels": [ + { + "id": 1, + "type": "table", + "styles": [ + { "type": "number", "pattern": "Time", "align": "left" }, + { "type": "string", "pattern": "Value", "align": "right" } + ] + } + ] +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/testdata/input/v23.multi_variable_alignment.json b/apps/dashboard/pkg/migration/testdata/input/v23.multi_variable_alignment.json new file mode 100644 index 00000000000..1b26cb9b002 --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/input/v23.multi_variable_alignment.json @@ -0,0 +1,129 @@ +{ + "title": "V23 Multi Variables Migration Test Dashboard", + "schemaVersion": 22, + "templating": { + "list": [ + { + "name": "multi_single_value", + "type": "query", + "datasource": "prometheus", + "multi": true, + "current": { + "value": "A", + "text": "A", + "selected": true + } + }, + { + "name": "multi_array_value", + "type": "query", + "datasource": "prometheus", + "multi": true, + "current": { + "value": ["B", "C"], + "text": ["B", "C"], + "selected": true + } + }, + { + "name": "non_multi_array_value", + "type": "query", + "datasource": "prometheus", + "multi": false, + "current": { + "value": ["D"], + "text": ["D"], + "selected": true + } + }, + { + "name": "non_multi_single_value", + "type": "query", + "datasource": "prometheus", + "multi": false, + "current": { + "value": "E", + "text": "E", + "selected": true + } + }, + { + "name": "no_multi_property", + "type": "query", + "datasource": "prometheus", + "current": { + "value": "F", + "text": "F", + "selected": true + } + }, + { + "name": "empty_current", + "type": "query", + "datasource": "prometheus", + "multi": true, + "current": {} + }, + { + "name": "nil_current", + "type": "query", + "datasource": "prometheus", + "multi": true, + "current": null + }, + { + "name": "custom_variable", + "type": "custom", + "multi": true, + "current": { + "value": "G", + "text": "G", + "selected": true + } + }, + { + "name": "textbox_variable", + "type": "textbox", + "multi": false, + "current": { + "value": "H", + "text": "H", + "selected": true + } + }, + { + "name": "datasource_variable", + "type": "datasource", + "multi": true, + "current": { + "value": ["prometheus", "influxdb"], + "text": ["Prometheus", "InfluxDB"], + "selected": true + } + }, + { + "name": "interval_variable", + "type": "interval", + "multi": false, + "current": { + "value": ["1m"], + "text": ["1m"], + "selected": true + } + } + ] + }, + "panels": [ + { + "id": 1, + "title": "Test Panel", + "type": "stat", + "targets": [ + { + "refId": "A", + "expr": "up" + } + ] + } + ] +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/testdata/input/v24.table-angular.json b/apps/dashboard/pkg/migration/testdata/input/v24.table-angular.json new file mode 100644 index 00000000000..2b714bb67a2 --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/input/v24.table-angular.json @@ -0,0 +1,345 @@ +{ + "schemaVersion": 23, + "panels": [ + { + "id": 1, + "type": "table", + "title": "Basic Angular Table with Defaults", + "description": "Tests basic migration with default style pattern (/.*/) containing thresholds and colors. Should convert styles to fieldConfig.defaults with threshold steps.", + "legend": true, + "styles": [ + { + "thresholds": ["10", "20", "30"], + "colors": ["red", "yellow", "green"], + "pattern": "/.*/" + } + ], + "targets": [{ "refId": "A" }, {}] + }, + { + "id": 2, + "type": "table", + "title": "Complex Table with All Style Features", + "description": "Tests comprehensive migration including: default style with thresholds/colors/unit/decimals/align/colorMode, column overrides with exact name and regex patterns, date formatting, hidden columns, and links with tooltips.", + "styles": [ + { + "pattern": "/.*/", + "thresholds": ["100", "500"], + "colors": ["green", "yellow", "red"], + "unit": "bytes", + "decimals": 2, + "align": "center", + "colorMode": "cell" + }, + { + "pattern": "Status", + "alias": "Current Status", + "unit": "short", + "decimals": 0, + "colorMode": "value", + "align": "left" + }, + { + "pattern": "/Error.*/", + "link": true, + "linkUrl": "http://example.com/errors", + "linkTooltip": "View error details", + "linkTargetBlank": true, + "colorMode": "row" + }, + { + "pattern": "Time", + "type": "date", + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "alias": "Timestamp" + }, + { + "pattern": "Hidden", + "type": "hidden" + } + ], + "targets": [{ "refId": "A" }] + }, + { + "id": 3, + "type": "table", + "title": "Table with Timeseries Aggregations Transform", + "description": "Tests migration of timeseries_aggregations transform to reduce transformation with column mappings (avg->mean, max->max, min->min, total->sum, current->lastNotNull, count->count).", + "styles": [ + { + "pattern": "/.*/", + "unit": "percent", + "decimals": 1 + } + ], + "transform": "timeseries_aggregations", + "columns": [ + { "value": "avg", "text": "Average" }, + { "value": "max", "text": "Maximum" }, + { "value": "min", "text": "Minimum" }, + { "value": "total", "text": "Total" }, + { "value": "current", "text": "Current" }, + { "value": "count", "text": "Count" } + ], + "targets": [{ "refId": "A" }] + }, + { + "id": 4, + "type": "table", + "title": "Table with Timeseries to Rows Transform", + "description": "Tests migration of timeseries_to_rows transform to seriesToRows transformation.", + "styles": [ + { + "pattern": "/.*/", + "unit": "short" + } + ], + "transform": "timeseries_to_rows", + "targets": [{ "refId": "A" }] + }, + { + "id": 5, + "type": "table", + "title": "Table with Timeseries to Columns Transform", + "description": "Tests migration of timeseries_to_columns transform to seriesToColumns transformation.", + "styles": [ + { + "pattern": "/.*/", + "unit": "bytes" + } + ], + "transform": "timeseries_to_columns", + "targets": [{ "refId": "A" }] + }, + { + "id": 6, + "type": "table", + "title": "Table with Merge Transform", + "description": "Tests migration of table transform to merge transformation. Also tests auto alignment conversion to empty string.", + "styles": [ + { + "pattern": "/.*/", + "align": "auto" + } + ], + "transform": "table", + "targets": [{ "refId": "A" }] + }, + { + "id": 7, + "type": "table", + "title": "Table with Existing Transformations", + "description": "Tests that existing transformations are preserved and new transformation from old format is appended to the list.", + "styles": [ + { + "pattern": "/.*/", + "unit": "short" + } + ], + "transformations": [ + { + "id": "filterFieldsByName", + "options": { + "include": { + "names": ["field1", "field2"] + } + } + } + ], + "transform": "timeseries_to_rows", + "targets": [{ "refId": "A" }] + }, + { + "id": 8, + "type": "table", + "title": "Mixed Threshold Types", + "description": "Tests handling of mixed numeric and string threshold values (int, string, float) with proper type conversion.", + "styles": [ + { + "pattern": "/.*/", + "thresholds": [10, "20", 30.5], + "colors": ["green", "yellow", "orange", "red"] + } + ], + "targets": [{ "refId": "A" }] + }, + { + "id": 9, + "type": "table", + "title": "All Color Modes Test", + "description": "Tests all color mode mappings: cell->color-background, row->color-background, value->color-text.", + "styles": [ + { + "pattern": "/.*/", + "colorMode": "cell" + }, + { + "pattern": "CellColumn", + "colorMode": "cell" + }, + { + "pattern": "RowColumn", + "colorMode": "row" + }, + { + "pattern": "ValueColumn", + "colorMode": "value" + } + ], + "targets": [{ "refId": "A" }] + }, + { + "id": 10, + "type": "table", + "title": "All Alignment Options", + "description": "Tests all alignment options: left, center, right, and auto (should convert to empty string).", + "styles": [ + { + "pattern": "/.*/", + "align": "center" + }, + { + "pattern": "LeftColumn", + "align": "left" + }, + { + "pattern": "RightColumn", + "align": "right" + }, + { + "pattern": "AutoColumn", + "align": "auto" + } + ], + "targets": [{ "refId": "A" }] + }, + { + "id": 11, + "type": "table", + "title": "Field Matcher Types Test", + "description": "Tests both field matcher types: byName for exact matches and byRegexp for regex patterns.", + "styles": [ + { + "pattern": "/.*/", + "unit": "short" + }, + { + "pattern": "ExactColumnName", + "alias": "Exact Match" + }, + { + "pattern": "/Regex.*Pattern/", + "alias": "Regex Match" + }, + { + "pattern": "/^Start/", + "alias": "Start Pattern" + }, + { + "pattern": "/End$/", + "alias": "End Pattern" + } + ], + "targets": [{ "refId": "A" }] + }, + { + "id": 12, + "type": "table", + "title": "Link Configuration Test", + "description": "Tests various link configurations: with and without tooltip, with and without target blank.", + "styles": [ + { + "pattern": "/.*/", + "unit": "short" + }, + { + "pattern": "LinkWithTooltip", + "link": true, + "linkUrl": "http://example.com/with-tooltip", + "linkTooltip": "Click to view details", + "linkTargetBlank": true + }, + { + "pattern": "LinkWithoutTooltip", + "link": true, + "linkUrl": "http://example.com/no-tooltip", + "linkTargetBlank": false + }, + { + "pattern": "LinkMinimal", + "link": true, + "linkUrl": "http://example.com/minimal" + } + ], + "targets": [{ "refId": "A" }] + }, + { + "id": 13, + "type": "table", + "title": "Date Format Variations", + "description": "Tests various date format patterns and aliases.", + "styles": [ + { + "pattern": "/.*/", + "unit": "short" + }, + { + "pattern": "DateISO", + "type": "date", + "dateFormat": "YYYY-MM-DD", + "alias": "ISO Date" + }, + { + "pattern": "DateTime", + "type": "date", + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "alias": "Full DateTime" + }, + { + "pattern": "TimeOnly", + "type": "date", + "dateFormat": "HH:mm:ss", + "alias": "Time Only" + } + ], + "targets": [{ "refId": "A" }] + }, + { + "id": 14, + "type": "table", + "title": "React Table - Should NOT Migrate", + "description": "React table (table2) should not be migrated. Properties should remain unchanged.", + "table": "table2", + "styles": [ + { + "pattern": "/.*/", + "unit": "short", + "thresholds": ["10", "20"], + "colors": ["red", "yellow", "green"] + } + ], + "targets": [{ "refId": "A" }] + }, + { + "id": 15, + "type": "table", + "title": "Angular Table without Styles - Should NOT Migrate", + "description": "Angular table without styles property should not be migrated.", + "targets": [{ "refId": "A" }] + }, + { + "id": 16, + "type": "graph", + "title": "Non-Table Panel - Should NOT Migrate", + "description": "Non-table panels should remain completely unchanged.", + "targets": [{ "refId": "A" }] + }, + { + "id": 17, + "type": "singlestat", + "title": "Singlestat Panel - Should NOT Migrate", + "description": "Other panel types should not be affected by table migration.", + "targets": [{ "refId": "A" }] + } + ] +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/testdata/input/v25.no-op-migration.json b/apps/dashboard/pkg/migration/testdata/input/v25.no-op-migration.json new file mode 100644 index 00000000000..39cbf10d8f4 --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/input/v25.no-op-migration.json @@ -0,0 +1,56 @@ +{ + "title": "V25 No-Op Migration Test Dashboard", + "schemaVersion": 24, + "panels": [ + { + "type": "timeseries", + "title": "Panel with transformations remains unchanged", + "id": 1, + "transformations": [ + ] + }, + { + "type": "graph", + "title": "Graph", + "id": 2, + "yAxes": [ + { + "show": true, + "min": null, + "max": null + } + ] + } + ], + "templating": { + "list": [ + { + "name": "tags should not be removed", + "type": "query", + "datasource": "prometheus", + "tags": ["tags should not be removed"], + "tagsQuery": "tag should not be removed", + "tagValuesQuery": "tag should not be removed", + "useTags": true, + "options": [] + + } + ] + }, + "annotations": { + "list": [ + { + "name": "Deployments", + "datasource": "grafana", + "enable": true + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] + } + } \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/testdata/output/v17.minspan_to_maxperrow.json b/apps/dashboard/pkg/migration/testdata/output/v17.minspan_to_maxperrow.json new file mode 100644 index 00000000000..ce7cb9fc7e9 --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/output/v17.minspan_to_maxperrow.json @@ -0,0 +1,317 @@ +{ + "panels": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "maxPerRow": 3, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel with minSpan 8", + "type": "graph" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 8 + }, + "id": 2, + "maxPerRow": 6, + "options": { + "colorMode": "none", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "mean" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "1.0.0", + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel with minSpan 4", + "type": "stat" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "gridPos": { + "h": 6, + "w": 24, + "x": 0, + "y": 12 + }, + "id": 3, + "maxPerRow": 12, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel with minSpan 2", + "type": "table" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 4, + "maxPerRow": 2, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel with minSpan 12", + "type": "graph" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "gridPos": { + "h": 4, + "w": 24, + "x": 0, + "y": 18 + }, + "id": 5, + "maxPerRow": 1, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel with minSpan 24", + "type": "gauge" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "gridPos": { + "h": 3, + "w": 4, + "x": 6, + "y": 8 + }, + "id": 6, + "maxPerRow": 24, + "options": { + "justifyMode": "auto", + "percentChangeColorMode": "standard", + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel with minSpan 1", + "type": "stat" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 12, + "y": 8 + }, + "id": 7, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel without minSpan", + "type": "graph" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 8 + }, + "id": 8, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel with invalid minSpan", + "type": "text" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 22 + }, + "id": 9, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel with zero minSpan", + "type": "graph" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 22 + }, + "id": 10, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel with negative minSpan", + "type": "graph" + } + ], + "refresh": "", + "schemaVersion": 41, + "title": "V17 MinSpan to MaxPerRow Migration Test Dashboard" +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/testdata/output/v18.gauge_options.json b/apps/dashboard/pkg/migration/testdata/output/v18.gauge_options.json new file mode 100644 index 00000000000..f6dc7161c1b --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/output/v18.gauge_options.json @@ -0,0 +1,162 @@ +{ + "panels": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "id": 1, + "options": { + "thresholds": [ + { + "color": "red", + "value": 100 + }, + { + "color": "yellow", + "value": 50 + }, + { + "color": "green", + "value": 0 + } + ], + "valueOptions": { + "decimals": 2, + "prefix": "Value: ", + "stat": "last", + "suffix": " ms", + "unit": "ms" + } + }, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Complete Gauge Panel", + "type": "gauge" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "id": 2, + "options": { + "valueOptions": { + "decimals": 1, + "unit": "percent" + } + }, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Partial Gauge Panel", + "type": "gauge" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "id": 3, + "options": { + "valueOptions": { + "decimals": 0, + "stat": "avg", + "unit": "bytes" + } + }, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Buggy Gauge Panel", + "type": "gauge" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "id": 4, + "options": { + "anotherProp": 42, + "customProperty": "customValue", + "thresholds": [ + { + "color": "blue", + "value": 10 + } + ], + "valueOptions": { + "unit": "short" + } + }, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Custom Properties Gauge Panel", + "type": "gauge" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "id": 5, + "options": { + "legend": { + "show": true, + "showLegend": true + } + }, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Non-Gauge Panel", + "type": "graph" + } + ], + "refresh": "", + "schemaVersion": 41, + "title": "V18 Gauge Options Migration Test Dashboard" +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/testdata/output/v19.panel_links.json b/apps/dashboard/pkg/migration/testdata/output/v19.panel_links.json new file mode 100644 index 00000000000..e423c8f6c13 --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/output/v19.panel_links.json @@ -0,0 +1,206 @@ +{ + "panels": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "id": 1, + "links": [ + { + "targetBlank": false, + "title": "Dashboard Link", + "url": "dashboard/db/my-dashboard?$__url_time_range" + } + ], + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel with legacy dashboard link", + "type": "graph" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "id": 2, + "links": [ + { + "targetBlank": false, + "title": "DashUri Link", + "url": "dashboard/my-dashboard-uid?$__all_variables" + } + ], + "options": { + "justifyMode": "auto", + "percentChangeColorMode": "standard", + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel with dashUri link", + "type": "stat" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "id": 3, + "links": [ + { + "targetBlank": false, + "title": "Custom Params Link", + "url": "http://example.com?customParam=value" + } + ], + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel with custom params", + "type": "table" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "id": 4, + "links": [ + { + "targetBlank": true, + "title": "Complex Link", + "url": "dashboard/db/complex-dashboard?$__url_time_range\u0026$__all_variables\u0026param1=value1\u0026param2=value2" + } + ], + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel with complex link", + "type": "timeseries" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "id": 5, + "links": [ + { + "targetBlank": false, + "title": "Existing URL Link", + "url": "http://existing-url.com" + } + ], + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel with existing URL", + "type": "gauge" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "id": 6, + "options": { + "colorMode": "none", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "mean" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "1.0.0", + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel with no links", + "type": "stat" + } + ], + "refresh": "", + "schemaVersion": 41, + "title": "V19 Panel Links Migration Test Dashboard" +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/testdata/output/v20.variable_syntax_links.json b/apps/dashboard/pkg/migration/testdata/output/v20.variable_syntax_links.json new file mode 100644 index 00000000000..b40cd0a66c5 --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/output/v20.variable_syntax_links.json @@ -0,0 +1,232 @@ +{ + "panels": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "dataLinks": [ + { + "targetBlank": true, + "title": "Link with series name", + "url": "http://example.com?series=${__series.name}\u0026timestamp=__value.time" + }, + { + "targetBlank": false, + "title": "Link with field name", + "url": "http://grafana.com/dashboard?field=__field.name\u0026series=${__series.name}" + } + ] + }, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel with data links using legacy variable syntax", + "type": "timeseries" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "fieldOptions": { + "defaults": { + "links": [ + { + "targetBlank": true, + "title": "Field link", + "url": "http://monitoring.com?field=${__field.name}\u0026series=__series.name" + }, + { + "targetBlank": false, + "title": "Time-based link", + "url": "http://logs.com?time=__value.time\u0026field=__field.name" + } + ], + "title": "Series: __series.name, Field: ${__field.name}, Time: __value.time" + } + }, + "justifyMode": "auto", + "percentChangeColorMode": "standard", + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel with field options using legacy variable syntax", + "type": "stat" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "dataLinks": [ + { + "targetBlank": true, + "title": "Combined link", + "url": "http://combined.com?series=${__series.name}\u0026field=${__field.name}\u0026time=__value.time" + } + ], + "fieldOptions": { + "defaults": { + "links": [ + { + "targetBlank": false, + "title": "Comprehensive link", + "url": "http://comprehensive.com?s=${__series.name}\u0026f=__field.name\u0026t=__value.time" + } + ], + "title": "Complete: __series.name / __field.name / __value.time" + } + } + }, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel with both data links and field options", + "type": "gauge" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 4, + "options": { + "dataLinks": [ + { + "targetBlank": true, + "title": "Modern link", + "url": "http://modern.com?series=${__series.name}\u0026field=${__field.name}\u0026time=${__value.time}" + } + ], + "fieldOptions": { + "defaults": { + "links": [ + { + "targetBlank": false, + "title": "Modern field link", + "url": "http://modern-field.com?s=${__series.name}\u0026f=${__field.name}" + } + ], + "title": "Modern: ${__series.name} / ${__field.name} / ${__value.time}" + } + } + }, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel with no legacy variables (should remain unchanged)", + "type": "table" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 5, + "options": { + "content": "This panel has no data links or field options to migrate." + }, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel with no data links or field options", + "type": "text" + } + ], + "refresh": "5s", + "schemaVersion": 41, + "style": "dark", + "tags": [ + "migration-test" + ], + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "V20 Variable Syntax Migration Test Dashboard", + "uid": "v20-migration-test", + "version": 0 +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/testdata/output/v21.data_links_series_to_field.json b/apps/dashboard/pkg/migration/testdata/output/v21.data_links_series_to_field.json new file mode 100644 index 00000000000..722fe7e8e0e --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/output/v21.data_links_series_to_field.json @@ -0,0 +1,166 @@ +{ + "panels": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "id": 1, + "options": { + "dataLinks": [ + { + "title": "Data Link 1", + "url": "http://mylink.com?series=${__field.labels}\u0026${__field.labels.a}" + }, + { + "title": "Data Link 2", + "url": "http://anotherlink.com?param=${__field.labels}" + } + ] + }, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel with data links", + "type": "timeseries" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "id": 2, + "options": { + "fieldOptions": { + "defaults": { + "links": [ + { + "title": "Field Link 1", + "url": "http://mylink.com?series=${__field.labels}\u0026${__field.labels.x}" + }, + { + "title": "Field Link 2", + "url": "http://fieldlink.com?field=${__field.labels}" + } + ] + } + }, + "justifyMode": "auto", + "percentChangeColorMode": "standard", + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel with field options links", + "type": "stat" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "id": 3, + "options": { + "dataLinks": [ + { + "title": "Graph Data Link", + "url": "http://mylink.com?series=${__field.labels}" + } + ], + "fieldOptions": { + "defaults": { + "links": [ + { + "title": "Graph Field Link", + "url": "http://mylink.com?field=${__field.labels}" + } + ] + } + } + }, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel with both link types", + "type": "graph" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "id": 4, + "options": { + "dataLinks": [ + { + "title": "No Series Labels Link", + "url": "http://mylink.com?other=${__field.labels}" + } + ] + }, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel without series labels", + "type": "timeseries" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "id": 5, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel without options", + "type": "timeseries" + } + ], + "refresh": "", + "schemaVersion": 41, + "title": "V21 Data Links Series to Field Migration Test Dashboard" +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/testdata/output/v22.table_panel_align.json b/apps/dashboard/pkg/migration/testdata/output/v22.table_panel_align.json new file mode 100644 index 00000000000..56482fefdfa --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/output/v22.table_panel_align.json @@ -0,0 +1,90 @@ +{ + "panels": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Time" + }, + "properties": [ + { + "id": "custom.align", + "value": "" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Value" + }, + "properties": [ + { + "id": "custom.align", + "value": "" + } + ] + } + ] + }, + "id": 1, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "1.0.0", + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "transformations": [], + "type": "table" + } + ], + "refresh": "", + "schemaVersion": 41, + "title": "V22 Table Panel Styles Test" +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/testdata/output/v23.multi_variable_alignment.json b/apps/dashboard/pkg/migration/testdata/output/v23.multi_variable_alignment.json new file mode 100644 index 00000000000..91a0cb50520 --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/output/v23.multi_variable_alignment.json @@ -0,0 +1,188 @@ +{ + "panels": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "id": 1, + "options": { + "justifyMode": "auto", + "percentChangeColorMode": "standard", + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "expr": "up", + "refId": "A" + } + ], + "title": "Test Panel", + "type": "stat" + } + ], + "refresh": "", + "schemaVersion": 41, + "templating": { + "list": [ + { + "current": { + "selected": true, + "text": [ + "A" + ], + "value": [ + "A" + ] + }, + "datasource": { + "uid": "prometheus" + }, + "multi": true, + "name": "multi_single_value", + "refresh": 1, + "type": "query" + }, + { + "current": { + "selected": true, + "text": [ + "B", + "C" + ], + "value": [ + "B", + "C" + ] + }, + "datasource": { + "uid": "prometheus" + }, + "multi": true, + "name": "multi_array_value", + "refresh": 1, + "type": "query" + }, + { + "current": { + "selected": true, + "text": "D", + "value": "D" + }, + "datasource": { + "uid": "prometheus" + }, + "multi": false, + "name": "non_multi_array_value", + "refresh": 1, + "type": "query" + }, + { + "current": { + "selected": true, + "text": "E", + "value": "E" + }, + "datasource": { + "uid": "prometheus" + }, + "multi": false, + "name": "non_multi_single_value", + "refresh": 1, + "type": "query" + }, + { + "current": { + "selected": true, + "text": "F", + "value": "F" + }, + "datasource": { + "uid": "prometheus" + }, + "name": "no_multi_property", + "refresh": 1, + "type": "query" + }, + { + "current": {}, + "datasource": { + "uid": "prometheus" + }, + "multi": true, + "name": "empty_current", + "refresh": 1, + "type": "query" + }, + { + "current": null, + "datasource": { + "uid": "prometheus" + }, + "multi": true, + "name": "nil_current", + "refresh": 1, + "type": "query" + }, + { + "current": { + "selected": true, + "text": [ + "G" + ], + "value": [ + "G" + ] + }, + "multi": true, + "name": "custom_variable", + "type": "custom" + }, + { + "current": { + "selected": true, + "text": "H", + "value": "H" + }, + "multi": false, + "name": "textbox_variable", + "type": "textbox" + }, + { + "current": { + "selected": true, + "text": [ + "Prometheus", + "InfluxDB" + ], + "value": [ + "prometheus", + "influxdb" + ] + }, + "multi": true, + "name": "datasource_variable", + "type": "datasource" + }, + { + "current": { + "selected": true, + "text": "1m", + "value": "1m" + }, + "multi": false, + "name": "interval_variable", + "type": "interval" + } + ] + }, + "title": "V23 Multi Variables Migration Test Dashboard" +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/testdata/output/v24.table-angular.json b/apps/dashboard/pkg/migration/testdata/output/v24.table-angular.json new file mode 100644 index 00000000000..f9ccbcd0dc4 --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/output/v24.table-angular.json @@ -0,0 +1,1334 @@ +{ + "panels": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "description": "Tests basic migration with default style pattern (/.*/) containing thresholds and colors. Should convert styles to fieldConfig.defaults with threshold steps.", + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 10 + }, + { + "color": "green", + "value": 20 + }, + { + "color": "red", + "value": 30 + } + ] + } + }, + "overrides": [] + }, + "id": 1, + "legend": true, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "1.0.0", + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + } + } + ], + "title": "Basic Angular Table with Defaults", + "transformations": [], + "type": "table" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "description": "Tests comprehensive migration including: default style with thresholds/colors/unit/decimals/align/colorMode, column overrides with exact name and regex patterns, date formatting, hidden columns, and links with tooltips.", + "fieldConfig": { + "defaults": { + "custom": { + "align": "center", + "cellOptions": { + "type": "color-background" + }, + "inspect": false + }, + "decimals": 2, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 100 + }, + { + "color": "red", + "value": 500 + } + ] + }, + "unit": "bytes" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Status" + }, + "properties": [ + { + "id": "displayName", + "value": "Current Status" + }, + { + "id": "unit", + "value": "short" + }, + { + "id": "decimals", + "value": 0 + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-text" + } + }, + { + "id": "custom.align", + "value": "left" + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/Error.*/" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "targetBlank": true, + "title": "View error details", + "url": "http://example.com/errors" + } + ] + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-background" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Time" + }, + "properties": [ + { + "id": "displayName", + "value": "Timestamp" + }, + { + "id": "unit", + "value": "time: YYYY-MM-DD HH:mm:ss" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Hidden" + }, + "properties": [ + { + "id": "custom.hidden", + "value": true + } + ] + } + ] + }, + "id": 2, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "1.0.0", + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Complex Table with All Style Features", + "transformations": [], + "type": "table" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "description": "Tests migration of timeseries_aggregations transform to reduce transformation with column mappings (avg-\u003emean, max-\u003emax, min-\u003emin, total-\u003esum, current-\u003elastNotNull, count-\u003ecount).", + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "decimals": 1, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "id": 3, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "1.0.0", + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Table with Timeseries Aggregations Transform", + "transformations": [ + { + "id": "reduce", + "options": { + "includeTimeField": false, + "reducers": [ + "mean", + "max", + "min", + "sum", + "lastNotNull", + "count" + ] + } + } + ], + "type": "table" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "description": "Tests migration of timeseries_to_rows transform to seriesToRows transformation.", + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 4, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "1.0.0", + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Table with Timeseries to Rows Transform", + "transformations": [ + { + "id": "seriesToRows", + "options": { + "reducers": [] + } + } + ], + "type": "table" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "description": "Tests migration of timeseries_to_columns transform to seriesToColumns transformation.", + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "id": 5, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "1.0.0", + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Table with Timeseries to Columns Transform", + "transformations": [ + { + "id": "seriesToColumns", + "options": { + "reducers": [] + } + } + ], + "type": "table" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "description": "Tests migration of table transform to merge transformation. Also tests auto alignment conversion to empty string.", + "fieldConfig": { + "defaults": { + "custom": { + "align": "", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "id": 6, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "1.0.0", + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Table with Merge Transform", + "transformations": [ + { + "id": "merge", + "options": { + "reducers": [] + } + } + ], + "type": "table" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "description": "Tests that existing transformations are preserved and new transformation from old format is appended to the list.", + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 7, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "1.0.0", + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Table with Existing Transformations", + "transformations": [ + { + "id": "filterFieldsByName", + "options": { + "include": { + "names": [ + "field1", + "field2" + ] + } + } + }, + { + "id": "seriesToRows", + "options": { + "reducers": [] + } + } + ], + "type": "table" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "description": "Tests handling of mixed numeric and string threshold values (int, string, float) with proper type conversion.", + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 10 + }, + { + "color": "orange", + "value": 20 + }, + { + "color": "red", + "value": 30.5 + } + ] + } + }, + "overrides": [] + }, + "id": 8, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "1.0.0", + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Mixed Threshold Types", + "transformations": [], + "type": "table" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "description": "Tests all color mode mappings: cell-\u003ecolor-background, row-\u003ecolor-background, value-\u003ecolor-text.", + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "color-background" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "CellColumn" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "type": "color-background" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "RowColumn" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "type": "color-background" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "ValueColumn" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "type": "color-text" + } + } + ] + } + ] + }, + "id": 9, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "1.0.0", + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "All Color Modes Test", + "transformations": [], + "type": "table" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "description": "Tests all alignment options: left, center, right, and auto (should convert to empty string).", + "fieldConfig": { + "defaults": { + "custom": { + "align": "center", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "LeftColumn" + }, + "properties": [ + { + "id": "custom.align", + "value": "left" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "RightColumn" + }, + "properties": [ + { + "id": "custom.align", + "value": "right" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "AutoColumn" + }, + "properties": [ + { + "id": "custom.align", + "value": "" + } + ] + } + ] + }, + "id": 10, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "1.0.0", + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "All Alignment Options", + "transformations": [], + "type": "table" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "description": "Tests both field matcher types: byName for exact matches and byRegexp for regex patterns.", + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "ExactColumnName" + }, + "properties": [ + { + "id": "displayName", + "value": "Exact Match" + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/Regex.*Pattern/" + }, + "properties": [ + { + "id": "displayName", + "value": "Regex Match" + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/^Start/" + }, + "properties": [ + { + "id": "displayName", + "value": "Start Pattern" + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/End$/" + }, + "properties": [ + { + "id": "displayName", + "value": "End Pattern" + } + ] + } + ] + }, + "id": 11, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "1.0.0", + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Field Matcher Types Test", + "transformations": [], + "type": "table" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "description": "Tests various link configurations: with and without tooltip, with and without target blank.", + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "LinkWithTooltip" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "targetBlank": true, + "title": "Click to view details", + "url": "http://example.com/with-tooltip" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "LinkWithoutTooltip" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "targetBlank": false, + "title": "", + "url": "http://example.com/no-tooltip" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "LinkMinimal" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "targetBlank": false, + "title": "", + "url": "http://example.com/minimal" + } + ] + } + ] + } + ] + }, + "id": 12, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "1.0.0", + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Link Configuration Test", + "transformations": [], + "type": "table" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "description": "Tests various date format patterns and aliases.", + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "DateISO" + }, + "properties": [ + { + "id": "displayName", + "value": "ISO Date" + }, + { + "id": "unit", + "value": "time: YYYY-MM-DD" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "DateTime" + }, + "properties": [ + { + "id": "displayName", + "value": "Full DateTime" + }, + { + "id": "unit", + "value": "time: YYYY-MM-DD HH:mm:ss" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "TimeOnly" + }, + "properties": [ + { + "id": "displayName", + "value": "Time Only" + }, + { + "id": "unit", + "value": "time: HH:mm:ss" + } + ] + } + ] + }, + "id": 13, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "1.0.0", + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Date Format Variations", + "transformations": [], + "type": "table" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "description": "React table (table2) should not be migrated. Properties should remain unchanged.", + "id": 14, + "styles": [ + { + "colors": [ + "red", + "yellow", + "green" + ], + "pattern": "/.*/", + "thresholds": [ + "10", + "20" + ], + "unit": "short" + } + ], + "table": "table2", + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "React Table - Should NOT Migrate", + "type": "table" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "description": "Angular table without styles property should not be migrated.", + "id": 15, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Angular Table without Styles - Should NOT Migrate", + "type": "table" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "description": "Non-table panels should remain completely unchanged.", + "id": 16, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Non-Table Panel - Should NOT Migrate", + "type": "graph" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "description": "Other panel types should not be affected by table migration.", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "id": 17, + "options": { + "colorMode": "none", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "mean" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "1.0.0", + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Singlestat Panel - Should NOT Migrate", + "type": "stat" + } + ], + "refresh": "", + "schemaVersion": 41 +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/testdata/output/v25.no-op-migration.json b/apps/dashboard/pkg/migration/testdata/output/v25.no-op-migration.json new file mode 100644 index 00000000000..82b2574e3a5 --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/output/v25.no-op-migration.json @@ -0,0 +1,97 @@ +{ + "annotations": { + "list": [ + { + "datasource": { + "uid": "grafana" + }, + "enable": true, + "name": "Deployments" + } + ] + }, + "panels": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "id": 1, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Panel with transformations remains unchanged", + "transformations": [], + "type": "timeseries" + }, + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "id": 2, + "targets": [ + { + "datasource": { + "apiVersion": "v1", + "type": "prometheus", + "uid": "default-ds-uid" + }, + "refId": "A" + } + ], + "title": "Graph", + "type": "graph", + "yAxes": [ + { + "max": null, + "min": null, + "show": true + } + ] + } + ], + "refresh": "", + "schemaVersion": 41, + "templating": { + "list": [ + { + "datasource": { + "uid": "prometheus" + }, + "name": "tags should not be removed", + "options": [], + "refresh": 1, + "type": "query" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "title": "V25 No-Op Migration Test Dashboard" +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/testutil/mocks.go b/apps/dashboard/pkg/migration/testutil/mocks.go index 6835d7f2fcc..16a0ce6dc44 100644 --- a/apps/dashboard/pkg/migration/testutil/mocks.go +++ b/apps/dashboard/pkg/migration/testutil/mocks.go @@ -78,6 +78,10 @@ func (m *TestPanelProvider) GetPanels() []schemaversion.PanelPluginInfo { ID: "stat", Version: "1.0.0", }, + { + ID: "table", + Version: "1.0.0", + }, // Note: grafana-singlestat-panel is not included to match frontend test environment // This ensures both frontend and backend migrations produce the same result } diff --git a/apps/dashboard/pkg/migration/utils/utils.go b/apps/dashboard/pkg/migration/utils/utils.go new file mode 100644 index 00000000000..0f11cdc5b4c --- /dev/null +++ b/apps/dashboard/pkg/migration/utils/utils.go @@ -0,0 +1,9 @@ +package utils + +func IsArray(value interface{}) bool { + if value == nil { + return false + } + _, ok := value.([]interface{}) + return ok +} diff --git a/apps/folder/go.mod b/apps/folder/go.mod index 713d1da9cda..30bcc4d5a99 100644 --- a/apps/folder/go.mod +++ b/apps/folder/go.mod @@ -1,6 +1,6 @@ module github.com/grafana/grafana/apps/folder -go 1.24.5 +go 1.24.6 require ( github.com/grafana/grafana-app-sdk v0.40.3 diff --git a/apps/iam/go.mod b/apps/iam/go.mod index 2c9d80d827b..13c5115aff5 100644 --- a/apps/iam/go.mod +++ b/apps/iam/go.mod @@ -1,6 +1,6 @@ module github.com/grafana/grafana/apps/iam -go 1.24.5 +go 1.24.6 require ( github.com/grafana/grafana-app-sdk v0.40.3 diff --git a/apps/investigations/go.mod b/apps/investigations/go.mod index d1a3a5f3b6f..f94e2710e12 100644 --- a/apps/investigations/go.mod +++ b/apps/investigations/go.mod @@ -1,197 +1,70 @@ module github.com/grafana/grafana/apps/investigations -go 1.24.5 +go 1.24.6 require ( - github.com/grafana/grafana v0.0.0-00010101000000-000000000000 - github.com/grafana/grafana-app-sdk v0.40.2 - github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2 - github.com/stretchr/testify v1.10.0 + github.com/grafana/grafana-app-sdk v0.40.3 k8s.io/apimachinery v0.33.3 - k8s.io/apiserver v0.33.3 k8s.io/klog/v2 v2.130.1 k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff ) -// transitive dependencies that need replaced -// TODO: stop depending on grafana core -replace github.com/grafana/grafana => ../.. - -replace github.com/prometheus/alertmanager => github.com/grafana/prometheus-alertmanager v0.25.1-0.20250620093340-be61a673dee6 - require ( - filippo.io/edwards25519 v1.1.0 // indirect - github.com/BurntSushi/toml v1.5.0 // indirect - github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f // indirect - github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/apache/arrow-go/v18 v18.3.0 // indirect - github.com/armon/go-metrics v0.4.1 // indirect - github.com/aws/aws-sdk-go-v2 v1.36.5 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.17.70 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 // indirect - github.com/aws/smithy-go v1.22.4 // indirect - github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/blang/semver v3.5.1+incompatible // indirect - github.com/blang/semver/v4 v4.0.0 // indirect - github.com/bluele/gcache v0.0.2 // indirect github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect - github.com/bwmarrin/snowflake v0.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cheekybits/genny v1.0.0 // indirect - github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4 // indirect - github.com/coreos/go-systemd/v22 v22.5.0 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/diegoholiveira/jsonlogic/v3 v3.7.4 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/elazarl/goproxy v1.7.2 // indirect github.com/emicklei/go-restful/v3 v3.12.1 // indirect - github.com/evanphx/json-patch v5.6.0+incompatible // indirect - github.com/fatih/color v1.18.0 // indirect + github.com/evanphx/json-patch v5.9.11+incompatible // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/getkin/kin-openapi v0.132.0 // indirect - github.com/go-jose/go-jose/v3 v3.0.4 // indirect - github.com/go-kit/log v0.2.1 // indirect - github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect 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/go-sql-driver/mysql v1.9.3 // indirect - github.com/go-stack/stack v1.8.1 // indirect - github.com/gobwas/glob v0.2.3 // indirect - github.com/goccy/go-json v0.10.5 // indirect - github.com/gogo/googleapis v1.4.1 // indirect + github.com/go-test/deep v1.1.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-migrate/migrate/v4 v4.7.0 // indirect - github.com/golang/protobuf v1.5.4 // indirect - github.com/google/btree v1.1.3 // indirect - github.com/google/flatbuffers v25.2.10+incompatible // indirect github.com/google/gnostic-models v0.6.9 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/mux v1.8.1 // indirect - github.com/grafana/alerting v0.0.0-20250729175202-b4b881b7b263 // indirect - github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43 // indirect - github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43 // indirect - github.com/grafana/dataplane/sdata v0.0.9 // indirect - github.com/grafana/dskit v0.0.0-20250611075409-46f51e1ce914 // indirect - github.com/grafana/grafana-app-sdk/logging v0.40.1 // indirect - github.com/grafana/grafana-aws-sdk v1.1.0 // indirect - github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0 // indirect - github.com/grafana/grafana-plugin-sdk-go v0.278.0 // indirect - github.com/grafana/grafana/pkg/apiserver v0.0.0-20250804150913-990f1c69ecc2 // indirect - github.com/grafana/otel-profiling-go v0.5.1 // indirect - github.com/grafana/pyroscope-go/godeltaprof v0.1.8 // indirect - github.com/grafana/sqlds/v4 v4.2.4 // indirect - github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect - github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect + github.com/grafana/grafana-app-sdk/logging v0.40.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/hashicorp/go-hclog v1.6.3 // indirect - github.com/hashicorp/go-immutable-radix v1.3.1 // indirect - github.com/hashicorp/go-metrics v0.5.4 // indirect - github.com/hashicorp/go-msgpack/v2 v2.1.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/hashicorp/go-plugin v1.6.3 // indirect - github.com/hashicorp/go-sockaddr v1.0.7 // indirect - github.com/hashicorp/golang-lru v1.0.2 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/hashicorp/memberlist v0.5.2 // indirect - github.com/hashicorp/yamux v0.1.1 // indirect - github.com/jaegertracing/jaeger-idl v0.5.0 // indirect - github.com/jmespath-community/go-jmespath v1.1.1 // indirect - github.com/jmoiron/sqlx v1.3.5 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/jpillora/backoff 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.18.0 // indirect - github.com/klauspost/cpuid/v2 v2.2.10 // indirect - github.com/lib/pq v1.10.9 // indirect - github.com/magefile/mage v1.15.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect - github.com/mattetti/filebuffer v1.0.1 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/mattn/go-sqlite3 v1.14.22 // indirect - github.com/mdlayher/socket v0.4.1 // indirect - github.com/mdlayher/vsock v1.2.1 // indirect - github.com/miekg/dns v1.1.63 // indirect - github.com/mitchellh/go-homedir v1.1.0 // indirect - github.com/mithrandie/csvq v1.18.1 // indirect - github.com/mithrandie/csvq-driver v1.7.0 // indirect - github.com/mithrandie/go-file/v2 v2.1.0 // indirect - github.com/mithrandie/go-text v1.6.0 // indirect - github.com/mithrandie/ternary v1.1.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect - github.com/ncruces/go-strftime v0.1.9 // indirect - github.com/nikunjy/rules v1.5.0 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect - github.com/oklog/run v1.1.0 // indirect - github.com/oklog/ulid v1.3.1 // indirect - github.com/olekukonko/tablewriter v0.0.5 // indirect - github.com/open-feature/go-sdk v1.14.1 // indirect - github.com/open-feature/go-sdk-contrib/providers/go-feature-flag v0.2.3 // indirect - github.com/open-feature/go-sdk-contrib/providers/ofrep v0.1.5 // indirect - github.com/patrickmn/go-cache v2.1.0+incompatible // indirect + github.com/onsi/ginkgo/v2 v2.22.2 // indirect + github.com/onsi/gomega v1.36.2 // indirect github.com/perimeterx/marshmallow v1.1.5 // 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/alertmanager v0.28.0 // indirect - github.com/prometheus/client_golang v1.22.0 // indirect + github.com/prometheus/client_golang v1.23.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.65.0 // indirect - github.com/prometheus/exporter-toolkit v0.14.0 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/spf13/pflag v1.0.7 // indirect - github.com/stretchr/objx v0.5.2 // indirect - github.com/thomaspoignant/go-feature-flag v1.42.0 // indirect - github.com/tjhop/slog-gokit v0.1.3 // indirect - github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 // indirect - github.com/unknwon/com v1.0.1 // indirect - github.com/unknwon/log v0.0.0-20200308114134-929b1006e34a // indirect - github.com/urfave/cli v1.22.16 // indirect github.com/x448/float16 v0.8.4 // indirect - github.com/zeebo/xxh3 v1.0.2 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.61.0 // indirect - go.opentelemetry.io/contrib/propagators/jaeger v1.36.0 // indirect - go.opentelemetry.io/contrib/samplers/jaegerremote v0.30.0 // indirect go.opentelemetry.io/otel v1.37.0 // indirect - go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 // indirect go.opentelemetry.io/otel/metric v1.37.0 // indirect go.opentelemetry.io/otel/sdk v1.37.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect go.opentelemetry.io/proto/otlp v1.7.0 // indirect - go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/crypto v0.40.0 // indirect - golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect - golang.org/x/mod v0.26.0 // indirect golang.org/x/net v0.42.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.16.0 // indirect @@ -199,30 +72,19 @@ require ( golang.org/x/term v0.33.0 // indirect golang.org/x/text v0.27.0 // indirect golang.org/x/time v0.11.0 // indirect - golang.org/x/tools v0.35.0 // indirect - golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/grpc v1.74.2 // indirect google.golang.org/protobuf v1.36.6 // indirect - gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.33.3 // indirect k8s.io/apiextensions-apiserver v0.33.3 // indirect k8s.io/client-go v0.33.3 // indirect - k8s.io/component-base v0.33.3 // indirect k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect - modernc.org/libc v1.65.0 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.10.0 // indirect - modernc.org/sqlite v1.37.0 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect sigs.k8s.io/yaml v1.5.0 // indirect - xorm.io/builder v0.3.6 // indirect ) diff --git a/apps/investigations/go.sum b/apps/investigations/go.sum index fdc15812b80..c586ffa88d2 100644 --- a/apps/investigations/go.sum +++ b/apps/investigations/go.sum @@ -1,1031 +1,229 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw= -cuelang.org/go v0.11.1 h1:pV+49MX1mmvDm8Qh3Za3M786cty8VKPWzQ1Ho4gZRP0= -cuelang.org/go v0.11.1/go.mod h1:PBY6XvPUswPPJ2inpvUozP9mebDVTXaeehQikhZPBz0= -dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= -dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1 h1:Wc1ml6QlJs2BHQ/9Bqu1jiyggbsSjramq2oUmp5WeIo= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= -github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= -github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e h1:NeAW1fUYUEWhft7pkxDf6WoUvEZJ/uOKsvtpjLnn8MU= -github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= -github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= -github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= -github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= -github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= -github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= -github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= -github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= -github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= -github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= -github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= -github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= -github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= -github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= -github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f h1:HR5nRmUQgXrwqZOwZ2DAc/aCi3Bu3xENpspW935vxu0= -github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f/go.mod h1:f3HiCrHjHBdcm6E83vGaXh1KomZMA2P6aeo3hKx/wg0= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= -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/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.3.0 h1:Xq4A6dZj9Nu33sqZibzn012LNnewkTUlfKVUFD/RX/I= -github.com/apache/arrow-go/v18 v18.3.0/go.mod h1:eEM1DnUTHhgGAjf/ChvOAQbUQ+EPohtDrArffvUjPg8= -github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= -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/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= -github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= -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/at-wat/mqtt-go v0.19.4 h1:R2cbCU7O5PHQ38unbe1Y51ncG3KsFEJV6QeipDoqdLQ= -github.com/at-wat/mqtt-go v0.19.4/go.mod h1:AsiWc9kqVOhqq7LzUeWT/AkKUBfx3Sw5cEe8lc06fqA= -github.com/aws/aws-sdk-go v1.17.7/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= -github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= -github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= -github.com/aws/aws-sdk-go-v2 v1.36.5 h1:0OF9RiEMEdDdZEMqF9MRjevyxAQcf6gY+E7vwBILFj0= -github.com/aws/aws-sdk-go-v2 v1.36.5/go.mod h1:EYrzvCCN9CMUTa5+6lf6MM4tq3Zjp8UhSGR/cBsjai0= -github.com/aws/aws-sdk-go-v2/credentials v1.17.70 h1:ONnH5CM16RTXRkS8Z1qg7/s2eDOhHhaXVd72mmyv4/0= -github.com/aws/aws-sdk-go-v2/credentials v1.17.70/go.mod h1:M+lWhhmomVGgtuPOhO85u4pEa3SmssPTdcYpP/5J/xc= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36 h1:SsytQyTMHMDPspp+spo7XwXTP44aJZZAC7fBV2C5+5s= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36/go.mod h1:Q1lnJArKRXkenyog6+Y+zr7WDpk4e6XlR6gs20bbeNo= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36 h1:i2vNHQiXUvKhs3quBR6aqlgJaiaexz/aNvdCktW/kAM= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36/go.mod h1:UdyGa7Q91id/sdyHPwth+043HhmP6yP9MBHgbZM0xo8= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 h1:CXV68E2dNqhuynZJPB80bhPQwAKqBWVer887figW6Jc= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4/go.mod h1:/xFi9KtvBXP97ppCz1TAEvU1Uf66qvid89rbem3wCzQ= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17 h1:t0E6FzREdtCsiLIoLCWsYliNsRBgyGD/MCK571qk4MI= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17/go.mod h1:ygpklyoaypuyDvOM5ujWGrYWpAK3h7ugnmKCU/76Ys4= -github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 h1:NFOJ/NXEGV4Rq//71Hs1jC/NvPs1ezajK+yQmkwnPV0= -github.com/aws/aws-sdk-go-v2/service/sts v1.34.0/go.mod h1:7ph2tGpfQvwzgistp2+zga9f+bCjlQJPkPUmMgDSD7w= -github.com/aws/smithy-go v1.22.4 h1:uqXzVZNuNexwc/xrh6Tb56u89WDlJY6HS+KC0S4QSjw= -github.com/aws/smithy-go v1.22.4/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= -github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df h1:GSoSVRLoBaFpOOds6QyY1L8AX7uoY+Ln3BHc22W40X0= -github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df/go.mod h1:hiVxq5OP2bUGBRNS3Z/bt/reCLFNbdcST6gISi1fiOM= -github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= -github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k= -github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= -github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= -github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= -github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= -github.com/bluele/gcache v0.0.2 h1:WcbfdXICg7G/DGBh1PFfcirkWOQV+v077yF1pSy3DGw= -github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0= -github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous= github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= -github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= -github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= -github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= -github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cheekybits/genny v1.0.0 h1:uGGa4nei+j20rOSeDeP5Of12XVm7TGUd4dJA9RDitfE= -github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ= -github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4 h1:UZdrvid2JFwnvPlUSEFlE794XZL4Jmrj8fuxfcLECJE= -github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= -github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= -github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= -github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= -github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= -github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= -github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg= -github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= -github.com/cockroachdb/cockroach-go v0.0.0-20181001143604-e0a95dfd547c/go.mod h1:XGLbWH/ujMcbPbhZq52Nv6UrCghb1yGn//133kEsvDk= -github.com/containerd/containerd v1.2.7/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= -github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/cznic/b v0.0.0-20180115125044-35e9bbe41f07/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8= -github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f/go.mod h1:8S58EK26zhXSxzv7NQFpnliaOQsmDUxvoQO3rt154Vg= -github.com/cznic/golex v0.0.0-20170803123110-4ab7c5e190e4/go.mod h1:+bmmJDNmKlhWNG+gwWCkaBoTy39Fs+bzRxVBzoTQbIc= -github.com/cznic/internal v0.0.0-20180608152220-f44710a21d00/go.mod h1:olo7eAdKwJdXxb55TKGLiJ6xt1H0/tiiRCWKVLmtjY4= -github.com/cznic/lldb v1.1.0/go.mod h1:FIZVUmYUVhPwRiPzL8nD/mpFcJ/G7SSXjjXYG4uRI3A= -github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM= -github.com/cznic/ql v1.2.0/go.mod h1:FbpzhyZrqr0PVlK6ury+PoW3T0ODUV22OeWIxcaOrSE= -github.com/cznic/sortutil v0.0.0-20150617083342-4c7342852e65/go.mod h1:q2w6Bg5jeox1B+QkJ6Wp/+Vn0G/bo3f1uY7Fn3vivIQ= -github.com/cznic/strutil v0.0.0-20171016134553-529a34b1c186/go.mod h1:AHHPPPXTw0h6pVabbcbyGRK1DckRn7r/STdZEeIDzZc= -github.com/cznic/zappy v0.0.0-20160723133515-2533cb5b45cc/go.mod h1:Y1SNZ4dRUOKXshKUbwUapqNncRrho4mkjQebgEHZLj8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/denisenkom/go-mssqldb v0.0.0-20190515213511-eb9f6a1743f3/go.mod h1:zAg7JM8CkOJ43xKXIj7eRO9kmWm/TW578qo+oDO6tuM= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/dhui/dktest v0.3.0/go.mod h1:cyzIUfGsBEbZ6BT7tnXqAShHSXCZhSNmFl70sZ7c1yc= -github.com/diegoholiveira/jsonlogic/v3 v3.7.4 h1:92HSmB9bwM/o0ZvrCpcvTP2EsPXSkKtAniIr2W/dcIM= -github.com/diegoholiveira/jsonlogic/v3 v3.7.4/go.mod h1:OYRb6FSTVmMM+MNQ7ElmMsczyNSepw+OU4Z8emDSi4w= -github.com/dlmiddlecote/sqlstats v1.0.2 h1:gSU11YN23D/iY50A2zVYwgXgy072khatTsIW6UPjUtI= -github.com/dlmiddlecote/sqlstats v1.0.2/go.mod h1:0CWaIh/Th+z2aI6Q9Jpfg/o21zmGxWhbByHgQSCUQvY= -github.com/docker/distribution v2.7.0+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= -github.com/docker/docker v0.7.3-0.20190103212154-2b7e084dc98b/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/docker v0.7.3-0.20190817195342-4760db040282/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= -github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= -github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/dolthub/flatbuffers/v23 v23.3.3-dh.2 h1:u3PMzfF8RkKd3lB9pZ2bfn0qEG+1Gms9599cr0REMww= -github.com/dolthub/flatbuffers/v23 v23.3.3-dh.2/go.mod h1:mIEZOHnFx4ZMQeawhw9rhsj+0zwQj7adVsnBX7t+eKY= -github.com/dolthub/go-icu-regex v0.0.0-20250327004329-6799764f2dad h1:66ZPawHszNu37VPQckdhX1BPPVzREsGgNxQeefnlm3g= -github.com/dolthub/go-icu-regex v0.0.0-20250327004329-6799764f2dad/go.mod h1:ylU4XjUpsMcvl/BKeRRMXSH7e7WBrPXdSLvnRJYrxEA= -github.com/dolthub/go-mysql-server v0.19.1-0.20250410182021-5632d67cd46e h1:7pAttAqWaudUAsM9iHASi/4eFBK+qn4qeaNto7g8bK4= -github.com/dolthub/go-mysql-server v0.19.1-0.20250410182021-5632d67cd46e/go.mod h1:KZyoO3jngyZCLyCf100FEQTrwAHj33AIMj4Zv4u3MNE= -github.com/dolthub/jsonpath v0.0.2-0.20240227200619-19675ab05c71 h1:bMGS25NWAGTEtT5tOBsCuCrlYnLRKpbJVJkDbrTRhwQ= -github.com/dolthub/jsonpath v0.0.2-0.20240227200619-19675ab05c71/go.mod h1:2/2zjLQ/JOOSbbSboojeg+cAwcRV0fDLzIiWch/lhqI= -github.com/dolthub/vitess v0.0.0-20250410090211-143e6b272ad4 h1:LGTt2LtYX8vaai32d+c9L0sMcP+Dg9w1kO6+lbsxxYg= -github.com/dolthub/vitess v0.0.0-20250410090211-143e6b272ad4/go.mod h1:1gQZs/byeHLMSul3Lvl3MzioMtOW1je79QYGyi2fd70= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= -github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= -github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= -github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= -github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= -github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU= github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -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.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= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/fsouza/fake-gcs-server v1.7.0/go.mod h1:5XIRs4YvwNbNoz+1JF8j6KLAyDh7RHGAyAK3EP2EsNk= +github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= +github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= -github.com/gchaincl/sqlhooks v1.3.0 h1:yKPXxW9a5CjXaVf2HkQn6wn7TZARvbAOAelr3H8vK2Y= -github.com/gchaincl/sqlhooks v1.3.0/go.mod h1:9BypXnereMT0+Ys8WGWHqzgkkOfHIhyeUCqXC24ra34= github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk= github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58= -github.com/go-asn1-ber/asn1-ber v1.5.4 h1:vXT6d/FNDiELJnLb6hGNa309LMsrCoYFvpwHDF0+Y1A= -github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= -github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= -github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= -github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= -github.com/go-ldap/ldap/v3 v3.4.4 h1:qPjipEpt+qDa6SI/h1fzuGWoRUY+qqQ9sOZq67/PYUs= -github.com/go-ldap/ldap/v3 v3.4.4/go.mod h1:fe1MsuN5eJJ1FeLT/LEBVdWfNWKh459R7aXgXtJC+aI= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= -github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU= -github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= -github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w= -github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= -github.com/go-openapi/loads v0.22.0 h1:ECPGd4jX1U6NApCGG1We+uEozOAvXvJSF4nnwHZ8Aco= -github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5Stn1oF+rs= -github.com/go-openapi/runtime v0.28.0 h1:gpPPmWSNGo214l6n8hzdXYhPuJcGtziTOgUpvsFWGIQ= -github.com/go-openapi/runtime v0.28.0/go.mod h1:QN7OzcS+XuYmkQLw05akXk0jRH/eZ3kb18+1KwW9gyc= -github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= -github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= -github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= -github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= -github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= -github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= -github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= -github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= -github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= -github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= -github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= -github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= -github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 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/go-xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:9wScpmSP5A3Bk8V3XHWUcJmYTh+ZnlHVyc+A4oZYS3Y= -github.com/go-xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:56xuuqnHyryaerycW3BfssRdxQstACi0Epw/yC5E2xM= -github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= -github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= -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/gocql/gocql v0.0.0-20190301043612-f6df8288f9b4/go.mod h1:4Fw1eo5iaEhDUs8XyuhSVCVy52Jq3L+/3GJgYkwc+/0= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= -github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= -github.com/gogo/googleapis v1.4.1 h1:1Yx4Myt7BxzvUr5ldGSbwYiZG6t9wGBZ+8/fX3Wvtq0= -github.com/gogo/googleapis v1.4.1/go.mod h1:2lpHqI5OcWCtVElxXnPt+s8oJvMpySlOyM6xDCrzib4= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 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.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= -github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= -github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang-migrate/migrate/v4 v4.7.0 h1:gONcHxHApDTKXDyLH/H97gEHmpu1zcnnbAaq2zgrPrs= -github.com/golang-migrate/migrate/v4 v4.7.0/go.mod h1:Qvut3N4xKWjoH3sokBccML6WyHSnggXm/DvMMnTsQIc= -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/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U= -github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 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.0-20170215233205-553a64147049/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= -github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -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/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.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= -github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI= -github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg= -github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= -github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= -github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/grafana/alerting v0.0.0-20250729175202-b4b881b7b263 h1:hcr/AmPB0KL4H+gCEFIdKUnkihTxGAkAOiZA7GDYoL8= -github.com/grafana/alerting v0.0.0-20250729175202-b4b881b7b263/go.mod h1:VKxaR93Gff0ZlO2sPcdPVob1a/UzArFEW5zx3Bpyhls= -github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43 h1:vVPT0i5Y1vI6qzecYStV2yk7cHKrC3Pc7AgvwT5KydQ= -github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43/go.mod h1:1fWkOiL+m32NBgRHZtlZGz2ji868tPZACYbqP3nBRJI= -github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43 h1:NlkGMnVi/oUn6Cr90QbJYpQJ4FnjyAIG9Ex5GtTZIzw= -github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= -github.com/grafana/dataplane/sdata v0.0.9 h1:AGL1LZnCUG4MnQtnWpBPbQ8ZpptaZs14w6kE/MWfg7s= -github.com/grafana/dataplane/sdata v0.0.9/go.mod h1:Jvs5ddpGmn6vcxT7tCTWAZ1mgi4sbcdFt9utQx5uMAU= -github.com/grafana/dskit v0.0.0-20250611075409-46f51e1ce914 h1:qcSGhr691f1mmPHwg2svGyO40Ex92G02aOyHzP6XHCE= -github.com/grafana/dskit v0.0.0-20250611075409-46f51e1ce914/go.mod h1:OiN4P4aC6LwLzLbEupH3Ue83VfQoNMfG48rsna8jI/E= -github.com/grafana/grafana-app-sdk v0.40.2 h1:j2ftFuqhX+exYUipfEjeWDs3i7oiJkweTF8gFLL7wWU= -github.com/grafana/grafana-app-sdk v0.40.2/go.mod h1:BbNXPNki3mtbkWxYqJsyA1Cj9AShSyaY33z8WkyfVv0= -github.com/grafana/grafana-app-sdk/logging v0.40.1 h1:ru+GqbaQk6jthA5l2Yo1WI/JbNXKNQmLiqNrxz7HGP4= -github.com/grafana/grafana-app-sdk/logging v0.40.1/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= -github.com/grafana/grafana-aws-sdk v1.1.0 h1:G0fvwbQmHw14c5RXPd7Gnw9ZQcgzl139LtMDoe0KhmE= -github.com/grafana/grafana-aws-sdk v1.1.0/go.mod h1:7e+47EdHynteYWGoT5Ere9KeOXQObsk8F0vkOLQ1tz8= -github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0 h1:0TYrkzAc3u0HX+9GK86cGrLTUAcmQfl3/LEB3tL+SOA= -github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0/go.mod h1:H9sVh9A4yg5egMGZeh0mifxT1Q/uqwKe1LBjBJU6pN8= -github.com/grafana/grafana-plugin-sdk-go v0.278.0 h1:5/rIYparLi02pofdaag8wnjspMMVNCi8cZhC4cdC3Ho= -github.com/grafana/grafana-plugin-sdk-go v0.278.0/go.mod h1:+8NXT/XUJ/89GV6FxGQ366NZ3nU+cAXDMd0OUESF9H4= -github.com/grafana/grafana/apps/dashboard v0.0.0-20250804150913-990f1c69ecc2 h1:M5kMQVRjcjtnGuw0RM9/iab5VpmJyBwpEDbSfS6IQV4= -github.com/grafana/grafana/apps/dashboard v0.0.0-20250804150913-990f1c69ecc2/go.mod h1:Ik6PLuCNceqTeOesGeQLn4hVL5wCbfscuAEhBEdq8js= -github.com/grafana/grafana/apps/provisioning v0.0.0-20250804150913-990f1c69ecc2 h1:83OGBndFNYijNjgQpSf4y1bSNlscVDq8ru97Q6PyU6o= -github.com/grafana/grafana/apps/provisioning v0.0.0-20250804150913-990f1c69ecc2/go.mod h1:qzFUVwLI1b5UIbVFxFydUYAsnOK27AgtA5so3EW8jM0= -github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2 h1:X0cnaFdR+iz+sDSuoZmkryFSjOirchHe2MdKSRwBWgM= -github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2/go.mod h1:RRvSjHH12/PnQaXraMO65jUhVu8n59mzvhfIMBETnV4= -github.com/grafana/grafana/pkg/apiserver v0.0.0-20250804150913-990f1c69ecc2 h1:oUINBBiA25EwjOFXJVa4rmU8jHlOGn5EdPCmqpIqy4Q= -github.com/grafana/grafana/pkg/apiserver v0.0.0-20250804150913-990f1c69ecc2/go.mod h1:BA9Rvm6co9tbrZ0lS8ScqLFvAnDQ4lZOyplJgB+yMQQ= -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/prometheus-alertmanager v0.25.1-0.20250620093340-be61a673dee6 h1:oJnbhG6ZNy10AjsgNeAtAKeGHogIGOMfAsBH6fYYa5M= -github.com/grafana/prometheus-alertmanager v0.25.1-0.20250620093340-be61a673dee6/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU= -github.com/grafana/pyroscope-go/godeltaprof v0.1.8 h1:iwOtYXeeVSAeYefJNaxDytgjKtUuKQbJqgAIjlnicKg= -github.com/grafana/pyroscope-go/godeltaprof v0.1.8/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= -github.com/grafana/sqlds/v4 v4.2.4 h1:Xlxy1udWqDK0dlbuJ1qXL7K3EYaf+aKMl38zhd3VbQY= -github.com/grafana/sqlds/v4 v4.2.4/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU= -github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o= -github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20= -github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0= -github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc= +github.com/grafana/grafana-app-sdk v0.40.3 h1:JFo7uAfbAJUfZ9neD7/4sODKm1xgu9zhckclH/N4DYU= +github.com/grafana/grafana-app-sdk v0.40.3/go.mod h1:j0KzHo3Sa6kd+lnwSScBNoV9Vobkg/YY9HtEjxpyPrk= +github.com/grafana/grafana-app-sdk/logging v0.40.2 h1:HQ1+y9Od92iMbWWB54QxiYpNtCvYGUVpyxvxZ7ywB1k= +github.com/grafana/grafana-app-sdk/logging v0.40.2/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= -github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= -github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= -github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-metrics v0.5.4 h1:8mmPiIJkTPPEbAiV97IxdAGNdRdaWwVap1BU6elejKY= -github.com/hashicorp/go-metrics v0.5.4/go.mod h1:CG5yz4NZ/AI/aQt9Ucm/vdBnbh7fvmv4lxZ350i+QQI= -github.com/hashicorp/go-msgpack/v2 v2.1.2 h1:4Ee8FTp834e+ewB71RDrQ0VKpyFdrKOjvYtnQ/ltVj0= -github.com/hashicorp/go-msgpack/v2 v2.1.2/go.mod h1:upybraOAblm4S7rx0+jeNy+CWWhzywQsSRV5033mMu4= -github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/go-plugin v1.6.3 h1:xgHB+ZUSYeuJi96WtxEjzi23uh7YQpznjGh0U0UUrwg= -github.com/hashicorp/go-plugin v1.6.3/go.mod h1:MRobyh+Wc/nYy1V4KAXUiYfzxoYhs7V1mlH1Z7iY2h0= -github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= -github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= -github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= -github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= -github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= -github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/hashicorp/memberlist v0.5.2 h1:rJoNPWZ0juJBgqn48gjy59K5H4rNgvUoM1kUD7bXiuI= -github.com/hashicorp/memberlist v0.5.2/go.mod h1:Ri9p/tRShbjYnpNf4FFPXG7wxEGY4Nrcn6E7jrVa//4= -github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= -github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= -github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/jackc/fake v0.0.0-20150926172116-812a484cc733/go.mod h1:WrMFNQdiFJ80sQsxDoMokWK1W5TQtxBFNpzWTD84ibQ= -github.com/jackc/pgx v3.2.0+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I= -github.com/jaegertracing/jaeger-idl v0.5.0 h1:zFXR5NL3Utu7MhPg8ZorxtCBjHrL3ReM1VoB65FOFGE= -github.com/jaegertracing/jaeger-idl v0.5.0/go.mod h1:ON90zFo9eoyXrt9F/KN8YeF3zxcnujaisMweFY/rg5k= -github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= -github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= -github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= -github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= -github.com/jmespath-community/go-jmespath v1.1.1 h1:bFikPhsi/FdmlZhVgSCd2jj1e7G/rw+zyQfyg5UF+L4= -github.com/jmespath-community/go-jmespath v1.1.1/go.mod h1:4gOyFJsR/Gk+05RgTKYrifT7tBPWD8Lubtb5jRrfy9I= -github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= -github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= -github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= -github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= -github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= -github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6 h1:SwcnSwBR7X/5EHJQlXBockkJVIMRVt5yKaesBPMtyZQ= -github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6/go.mod h1:WrYiIuiXUMIvTDAQw97C+9l0CnBmCcvosPjN3XDqS/o= -github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= -github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= -github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= 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/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.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/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kshvakov/clickhouse v1.3.5/go.mod h1:DMzX7FxRymoNkVgizH0DWAL8Cur7wHLgx3MUnGwJqpE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lestrrat-go/strftime v1.0.4 h1:T1Rb9EPkAhgxKqbcMIPguPq8glqXTA1koF8n9BHElA8= -github.com/lestrrat-go/strftime v1.0.4/go.mod h1:E1nN3pCbtMSu1yjSVeyuRFVm/U0xoR76fd03sz+Qz4g= -github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= -github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/mattetti/filebuffer v1.0.1 h1:gG7pyfnSIZCxdoKq+cPa8T0hhYtD9NxCdI4D7PTjRLM= -github.com/mattetti/filebuffer v1.0.1/go.mod h1:YdMURNDOttIiruleeVr6f56OrMc+MydEnTcXwtkxNVs= -github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= -github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= -github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= -github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= -github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= -github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= -github.com/mdlayher/vsock v1.2.1 h1:pC1mTJTvjo1r9n9fbm7S1j04rCgCzhCOS5DY0zqHlnQ= -github.com/mdlayher/vsock v1.2.1/go.mod h1:NRfCibel++DgeMD8z/hP+PPTjlNJsdPOmxcnENvE+SE= -github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY= -github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs= -github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs= -github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= -github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI= -github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= -github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= -github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= -github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE= -github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= -github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/mithrandie/csvq v1.18.1 h1:f7NB2scbb7xx2ffPduJ2VtZ85RpWXfvanYskAkGlCBU= -github.com/mithrandie/csvq v1.18.1/go.mod h1:MRJj7AtcXfk7jhNGxLuJGP3LORmh4lpiPWxQ7VyCRn8= -github.com/mithrandie/csvq-driver v1.7.0 h1:ejiavXNWwTPMyr3fJFnhcqd1L1cYudA0foQy9cZrqhw= -github.com/mithrandie/csvq-driver v1.7.0/go.mod h1:HcN3xL9UCJnBYA/AIQOOB/KlyfXAiYr5yxDmiwrGk5o= -github.com/mithrandie/go-file/v2 v2.1.0 h1:XA5Tl+73GXMDvgwSE3Sg0uC5FkLr3hnXs8SpUas0hyg= -github.com/mithrandie/go-file/v2 v2.1.0/go.mod h1:9YtTF3Xo59GqC1Pxw6KyGVcM/qubAMlxVsqI/u9r++c= -github.com/mithrandie/go-text v1.6.0 h1:8gOXTMPbMY8DJbKMTv8kHhADcJlDWXqS/YQH4SyWO6s= -github.com/mithrandie/go-text v1.6.0/go.mod h1:xCgj1xiNbI/d4xA9sLVvXkjh5B2tNx2ZT2/3rpmh8to= -github.com/mithrandie/ternary v1.1.1 h1:k/joD6UGVYxHixYmSR8EGgDFNONBMqyD373xT4QRdC4= -github.com/mithrandie/ternary v1.1.1/go.mod h1:0D9Ba3+09K2TdSZO7/bFCC0GjSXetCvYuYq0u8FY/1g= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= -github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/nakagami/firebirdsql v0.0.0-20190310045651-3c02a58cfed8/go.mod h1:86wM1zFnC6/uDBfZGNwB65O+pR2OFi5q/YQaEUid1qA= -github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/nikunjy/rules v1.5.0 h1:KJDSLOsFhwt7kcXUyZqwkgrQg5YoUwj+TVu6ItCQShw= -github.com/nikunjy/rules v1.5.0/go.mod h1:TlZtZdBChrkqi8Lr2AXocme8Z7EsbxtFdDoKeI6neBQ= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= -github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= -github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= -github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= -github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= -github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU= github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= -github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= -github.com/open-feature/go-sdk v1.14.1 h1:jcxjCIG5Up3XkgYwWN5Y/WWfc6XobOhqrIwjyDBsoQo= -github.com/open-feature/go-sdk v1.14.1/go.mod h1:t337k0VB/t/YxJ9S0prT30ISUHwYmUd/jhUZgFcOvGg= -github.com/open-feature/go-sdk-contrib/providers/go-feature-flag v0.2.3 h1:6jpO63NCEZv4xunJj+aNlDuFVuRkVBPMcIuxvFPYRWQ= -github.com/open-feature/go-sdk-contrib/providers/go-feature-flag v0.2.3/go.mod h1:dPUHjAIFzg+ci/wt6XxlNiiMkOh5Yw4SGyeRY0AFT0g= -github.com/open-feature/go-sdk-contrib/providers/ofrep v0.1.5 h1:ZdqlGnNwhWf3luhBQlIpbglvcCzjkcuEgOEhYhr5Emc= -github.com/open-feature/go-sdk-contrib/providers/ofrep v0.1.5/go.mod h1:jrD4UG3ZCzuwImKHlyuIN2iWeYjlOX5+zJ/sX45efuE= -github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= -github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= -github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= -github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= -github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= -github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= -github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= -github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= -github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= -github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= -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.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -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= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= -github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= -github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= -github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= +github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= -github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= -github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= -github.com/prometheus/common/sigv4 v0.1.0 h1:qoVebwtwwEhS85Czm2dSROY5fTo2PAPEVdDeppTwGX4= -github.com/prometheus/common/sigv4 v0.1.0/go.mod h1:2Jkxxk9yYvCkE5G1sQT7GuEXm57JrvHu9k5YwTjsNtI= -github.com/prometheus/exporter-toolkit v0.14.0 h1:NMlswfibpcZZ+H0sZBiTjrA3/aBFHkNZqE+iCj5EmRg= -github.com/prometheus/exporter-toolkit v0.14.0/go.mod h1:Gu5LnVvt7Nr/oqTBUC23WILZepW0nffNo10XdhQcwWA= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= -github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= -github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= github.com/puzpuzpuz/xsync/v2 v2.5.1/go.mod h1:gD2H2krq/w52MfPLE+Uy64TzJDVY7lP2znR9qmR35kU= -github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= -github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= -github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= -github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= -github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= -github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= -github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c h1:aqg5Vm5dwtvL+YgDpBcK1ITf3o96N/K7/wsRXQnUTEs= -github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c/go.mod h1:owqhoLW1qZoYLZzLnBw+QkPP9WZnjlSWihhxAJC1+/M= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 h1:pXY9qYc/MP5zdvqWEUH6SjNiu7VhSjuVFTFiTcphaLU= -github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 h1:Jpy1PXuP99tXNrhbq2BaPz9B+jNAvH1JPQQpG/9GCXY= -github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c h1:Ho+uVpkel/udgjbwB5Lktg9BtvJSh2DT0Hi6LPSyI2w= -github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= -github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= -github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf h1:Z2X3Os7oRzpdJ75iPqWZc0HeJWFYNCvKsfpQwFpRNTA= -github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf/go.mod h1:M8agBzgqHIhgj7wEn9/0hJUZcrvt9VY+Ln+S1I5Mha0= -github.com/tetratelabs/wazero v1.8.2 h1:yIgLR/b2bN31bjxwXHD8a3d+BogigR952csSDdLYEv4= -github.com/tetratelabs/wazero v1.8.2/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs= -github.com/thejerf/slogassert v0.3.4 h1:VoTsXixRbXMrRSSxDjYTiEDCM4VWbsYPW5rB/hX24kM= -github.com/thejerf/slogassert v0.3.4/go.mod h1:0zn9ISLVKo1aPMTqcGfG1o6dWwt+Rk574GlUxHD4rs8= -github.com/thomaspoignant/go-feature-flag v1.42.0 h1:C7embmOTzaLyRki+OoU2RvtVjJE9IrvgBA2C1mRN1lc= -github.com/thomaspoignant/go-feature-flag v1.42.0/go.mod h1:y0QiWH7chHWhGATb/+XqwAwErORmPSH2MUsQlCmmWlM= -github.com/tidwall/pretty v0.0.0-20180105212114-65a9db5fad51/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= -github.com/tjhop/slog-gokit v0.1.3 h1:6SdexP3UIeg93KLFeiM1Wp1caRwdTLgsD/THxBUy1+o= -github.com/tjhop/slog-gokit v0.1.3/go.mod h1:Bbu5v2748qpAWH7k6gse/kw3076IJf6owJmh7yArmJs= -github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 h1:aVGB3YnaS/JNfOW3tiHIlmNmTDg618va+eT0mVomgyI= -github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8/go.mod h1:fVle4kNr08ydeohzYafr20oZzbAkhQT39gKK/pFQ5M4= -github.com/unknwon/com v1.0.1 h1:3d1LTxD+Lnf3soQiD4Cp/0BRB+Rsa/+RTvz8GMMzIXs= -github.com/unknwon/com v1.0.1/go.mod h1:tOOxU81rwgoCLoOVVPHb6T/wt8HZygqH5id+GNnlCXM= -github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3/go.mod h1:1xEUf2abjfP92w2GZTV+GgaRxXErwRXcClbUwrNJffU= -github.com/unknwon/log v0.0.0-20200308114134-929b1006e34a h1:vcrhXnj9g9PIE+cmZgaPSwOyJ8MAQTRmsgGrB0x5rF4= -github.com/unknwon/log v0.0.0-20200308114134-929b1006e34a/go.mod h1:1xEUf2abjfP92w2GZTV+GgaRxXErwRXcClbUwrNJffU= -github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= -github.com/urfave/cli v1.22.16 h1:MH0k6uJxdwdeWQTwhSO42Pwr4YLrNLwBtg1MRgTqPdQ= -github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/xanzy/go-gitlab v0.15.0/go.mod h1:8zdQa/ri1dfn8eS3Ir1SyfvOKlw7WBJ8DVThkpGiXrs= -github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I= -github.com/xdg/stringprep v1.0.0/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= -github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= -github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= -github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= -gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b/go.mod h1:T3BPAOm2cqquPa0MKWeNkmOM5RQsRhkrwMWonFMN7fE= -go.mongodb.org/mongo-driver v1.1.0/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= -go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= -go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= -go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= 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/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= -go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.61.0 h1:lREC4C0ilyP4WibDhQ7Gg2ygAQFP8oR07Fst/5cafwI= -go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.61.0/go.mod h1:HfvuU0kW9HewH14VCOLImqKvUgONodURG7Alj/IrnGI= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= -go.opentelemetry.io/contrib/propagators/jaeger v1.36.0 h1:SoCgXYF4ISDtNyfLUzsGDaaudZVTx2yJhOyBO0+/GYk= -go.opentelemetry.io/contrib/propagators/jaeger v1.36.0/go.mod h1:VHu48l0YTRKSObdPQ+Sb8xMZvdnJlN7yhHuHoPgNqHM= -go.opentelemetry.io/contrib/samplers/jaegerremote v0.30.0 h1:bQ1Gvah4Sp8z7epSkgJaNTuZm7sutfA6Fji2/7cKFMc= -go.opentelemetry.io/contrib/samplers/jaegerremote v0.30.0/go.mod h1:9b8Q9rH52NgYH3ShiTFB5wf18Vt3RTH/VMB7LDcC1ug= -go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4= -go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 h1:EtFWSnwW9hGObjkIdmlnWSydO+Qs8OwzfzXLUPg4xOc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h1:QjUEoiGCPkvFZ/MjK6ZZfNOS6mfVEVKYE99dFhuN2LI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 h1:bDMKF3RUSxshZ5OjOTi8rsHGaPKsAt76FaqgvIUySLc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0/go.mod h1:dDT67G/IkA46Mr2l9Uj7HsQVwsjASyV9SjGofsiUZDA= -go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E= go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= -go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= -golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= -golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181108082009-03003ca0c849/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190424112056-4829fb13d2c6/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190102155601-82a175fd1598/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190426135247-a129542de9ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191020152052-9984515f0562/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.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= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425222832-ad9eeb80039a/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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= -golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= -golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= -google.golang.org/api v0.3.2/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= -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= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= -gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= -gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/fsnotify/fsnotify.v1 v1.4.7 h1:XNNYLJHt73EyYiCZi6+xjupS9CpvmiDgjPTAjrBlQbo= -gopkg.in/fsnotify/fsnotify.v1 v1.4.7/go.mod h1:Fyux9zXlo4rWoMSIzpn9fDAYjalPqJ/K1qJ27s+7ltE= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk= -gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw= -gopkg.in/src-d/go-errors.v1 v1.0.0 h1:cooGdZnCjYbeS1zb1s6pVAAimTdKceRrpn7aKOnNIfc= -gopkg.in/src-d/go-errors.v1 v1.0.0/go.mod h1:q1cBlomlw2FnDBDNGlnh6X0jPihy+QxZfMMNxPCbdYg= -gopkg.in/telebot.v3 v3.2.1 h1:3I4LohaAyJBiivGmkfB+CiVu7QFOWkuZ4+KHgO/G3rs= -gopkg.in/telebot.v3 v3.2.1/go.mod h1:GJKwwWqp9nSkIVN51eRKU78aB5f5OnQuWdwiIZfPbko= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= -honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= k8s.io/api v0.33.3 h1:SRd5t//hhkI1buzxb288fy2xvjubstenEKL9K51KBI8= k8s.io/api v0.33.3/go.mod h1:01Y/iLUjNBM3TAvypct7DIj0M0NIZc+PzAHCIo0CYGE= k8s.io/apiextensions-apiserver v0.33.3 h1:qmOcAHN6DjfD0v9kxL5udB27SRP6SG/MTopmge3MwEs= k8s.io/apiextensions-apiserver v0.33.3/go.mod h1:oROuctgo27mUsyp9+Obahos6CWcMISSAPzQ77CAQGz8= k8s.io/apimachinery v0.33.3 h1:4ZSrmNa0c/ZpZJhAgRdcsFcZOw1PQU1bALVQ0B3I5LA= k8s.io/apimachinery v0.33.3/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= -k8s.io/apiserver v0.33.3 h1:Wv0hGc+QFdMJB4ZSiHrCgN3zL3QRatu56+rpccKC3J4= -k8s.io/apiserver v0.33.3/go.mod h1:05632ifFEe6TxwjdAIrwINHWE2hLwyADFk5mBsQa15E= k8s.io/client-go v0.33.3 h1:M5AfDnKfYmVJif92ngN532gFqakcGi6RvaOF16efrpA= k8s.io/client-go v0.33.3/go.mod h1:luqKBQggEf3shbxHY4uVENAxrDISLOarxpTKMiUuujg= -k8s.io/component-base v0.33.3 h1:mlAuyJqyPlKZM7FyaoM/LcunZaaY353RXiOd2+B5tGA= -k8s.io/component-base v0.33.3/go.mod h1:ktBVsBzkI3imDuxYXmVxZ2zxJnYTZ4HAsVj9iF09qp4= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= k8s.io/utils v0.0.0-20241210054802-24370beab758 h1:sdbE21q2nlQtFh65saZY+rRM6x6aJJI8IUa1AmH/qa0= k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA= -modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc= -modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY= -modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= -modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= -modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= -modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y= -modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4= -modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= -modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= -modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= @@ -1036,5 +234,3 @@ sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vt sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= sigs.k8s.io/yaml v1.5.0 h1:M10b2U7aEUY6hRtU870n2VTPgR5RZiL/I6Lcc2F4NUQ= sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4= -xorm.io/builder v0.3.6 h1:ha28mQ2M+TFx96Hxo+iq6tQgnkC9IZkM6D8w9sKHHF8= -xorm.io/builder v0.3.6/go.mod h1:LEFAPISnRzG+zxaxj2vPicRwz67BdhFreKg8yv8/TgU= diff --git a/apps/playlist/go.mod b/apps/playlist/go.mod index e4a0e59576c..8e1ae857041 100644 --- a/apps/playlist/go.mod +++ b/apps/playlist/go.mod @@ -1,6 +1,6 @@ module github.com/grafana/grafana/apps/playlist -go 1.24.5 +go 1.24.6 require ( github.com/grafana/grafana-app-sdk v0.40.3 diff --git a/apps/preferences/go.mod b/apps/preferences/go.mod index 1fc0f8a0c2f..fa02d31a234 100644 --- a/apps/preferences/go.mod +++ b/apps/preferences/go.mod @@ -1,3 +1,3 @@ module github.com/grafana/grafana/apps/preferences -go 1.24.5 +go 1.24.6 diff --git a/apps/provisioning/go.mod b/apps/provisioning/go.mod index e568b4c36c4..58b0f331981 100644 --- a/apps/provisioning/go.mod +++ b/apps/provisioning/go.mod @@ -1,6 +1,6 @@ module github.com/grafana/grafana/apps/provisioning -go 1.24.5 +go 1.24.6 require ( github.com/grafana/grafana-app-sdk v0.40.3 diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/jobs.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/jobs.go index e11ab36d147..847da88fbc5 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/jobs.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/jobs.go @@ -204,6 +204,9 @@ type JobStatus struct { // Summary of processed actions Summary []*JobResourceSummary `json:"summary,omitempty"` + + // URLs contains URLs for the reference branch or commit if applicable. + URLs *RepositoryURLs `json:"url,omitempty"` } // Convert a JOB to a @@ -235,3 +238,28 @@ type JobResourceSummary struct { // This may not be an exhaustive list and recommend looking at the logs for more info Errors []string `json:"errors,omitempty"` } + +// HistoricJob is an append only log, saving all jobs that have been processed. +// +// NOTE: This should not be used directly by any external consumer. +// When there is a more stable integration with loki (an appropriate append only store) +// this may be removed without notice. +// +// The repository name and type are stored as labels. +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +genclient +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"` +} diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/register.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/register.go index c7db279034c..000ff7f22ff 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/register.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/register.go @@ -87,6 +87,34 @@ var JobResourceInfo = utils.NewResourceInfo(GROUP, VERSION, }, }) +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{}{ + 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 ( // SchemeGroupVersion is group version used to register these objects SchemeGroupVersion = schema.GroupVersion{Group: GROUP, Version: VERSION} @@ -124,6 +152,8 @@ func AddKnownTypes(gv schema.GroupVersion, scheme *runtime.Scheme) error { &Job{}, &JobList{}, &RefList{}, + &HistoricJob{}, + &HistoricJobList{}, ) return nil } diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/types.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/types.go index edcf2f790fa..219db2907fb 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/types.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/types.go @@ -316,7 +316,7 @@ type ResourceWrapper struct { Repository ResourceRepositoryInfo `json:"repository"` // Typed links for this file (only supported by external systems, github etc) - URLs *ResourceURLs `json:"urls,omitempty"` + URLs *RepositoryURLs `json:"urls,omitempty"` // The modified time in the remote file system Timestamp *metav1.Time `json:"timestamp,omitempty"` @@ -374,14 +374,14 @@ type ResourceRepositoryInfo struct { Name string `json:"name"` } -type ResourceURLs struct { - // A URL pointing to the this file in the repository - SourceURL string `json:"sourceURL,omitempty"` - +type RepositoryURLs struct { // A URL pointing to the repository this lives in RepositoryURL string `json:"repositoryURL,omitempty"` - // A URL that will create a new pull requeset for this branch + // A URL pointing to the file or ref in the repository + SourceURL string `json:"sourceURL,omitempty"` + + // A URL that will create a new pull request for this branch NewPullRequestURL string `json:"newPullRequestURL,omitempty"` // Compare this version to the target branch @@ -439,6 +439,11 @@ type ResourceStats struct { // +listType=atomic Instance []ResourceCount `json:"instance,omitempty"` + // Stats across all unified storage + // When legacy storage is still used, this will offer a shim + // +listType=atomic + Unmanaged []ResourceCount `json:"unmanaged,omitempty"` + // Stats for each manager // +listType=atomic Managed []ManagerStats `json:"managed,omitempty"` diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go index c5438fccd0e..868437c9e85 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go @@ -237,6 +237,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 @@ -438,6 +499,11 @@ func (in *JobStatus) DeepCopyInto(out *JobStatus) { } } } + if in.URLs != nil { + in, out := &in.URLs, &out.URLs + *out = new(RepositoryURLs) + **out = **in + } return } @@ -729,6 +795,22 @@ func (in *RepositoryStatus) DeepCopy() *RepositoryStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RepositoryURLs) DeepCopyInto(out *RepositoryURLs) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryURLs. +func (in *RepositoryURLs) DeepCopy() *RepositoryURLs { + if in == nil { + return nil + } + out := new(RepositoryURLs) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RepositoryView) DeepCopyInto(out *RepositoryView) { *out = *in @@ -913,6 +995,11 @@ func (in *ResourceStats) DeepCopyInto(out *ResourceStats) { *out = make([]ResourceCount, len(*in)) copy(*out, *in) } + if in.Unmanaged != nil { + in, out := &in.Unmanaged, &out.Unmanaged + *out = make([]ResourceCount, len(*in)) + copy(*out, *in) + } if in.Managed != nil { in, out := &in.Managed, &out.Managed *out = make([]ManagerStats, len(*in)) @@ -957,22 +1044,6 @@ func (in *ResourceType) DeepCopy() *ResourceType { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ResourceURLs) DeepCopyInto(out *ResourceURLs) { - *out = *in - return -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceURLs. -func (in *ResourceURLs) DeepCopy() *ResourceURLs { - if in == nil { - return nil - } - out := new(ResourceURLs) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ResourceWrapper) DeepCopyInto(out *ResourceWrapper) { *out = *in @@ -980,7 +1051,7 @@ func (in *ResourceWrapper) DeepCopyInto(out *ResourceWrapper) { out.Repository = in.Repository if in.URLs != nil { in, out := &in.URLs, &out.URLs - *out = new(ResourceURLs) + *out = new(RepositoryURLs) **out = **in } if in.Timestamp != nil { diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go index 19911653735..0b4c4e42377 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go @@ -25,6 +25,8 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.GitLabRepositoryConfig": schema_pkg_apis_provisioning_v0alpha1_GitLabRepositoryConfig(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.GitRepositoryConfig": schema_pkg_apis_provisioning_v0alpha1_GitRepositoryConfig(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.HealthStatus": schema_pkg_apis_provisioning_v0alpha1_HealthStatus(ref), + "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.HistoricJob": schema_pkg_apis_provisioning_v0alpha1_HistoricJob(ref), + "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.HistoricJobList": schema_pkg_apis_provisioning_v0alpha1_HistoricJobList(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.HistoryItem": schema_pkg_apis_provisioning_v0alpha1_HistoryItem(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.HistoryList": schema_pkg_apis_provisioning_v0alpha1_HistoryList(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.Job": schema_pkg_apis_provisioning_v0alpha1_Job(ref), @@ -43,6 +45,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.RepositoryList": schema_pkg_apis_provisioning_v0alpha1_RepositoryList(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.RepositorySpec": schema_pkg_apis_provisioning_v0alpha1_RepositorySpec(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.RepositoryStatus": schema_pkg_apis_provisioning_v0alpha1_RepositoryStatus(ref), + "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.RepositoryURLs": schema_pkg_apis_provisioning_v0alpha1_RepositoryURLs(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.RepositoryView": schema_pkg_apis_provisioning_v0alpha1_RepositoryView(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.RepositoryViewList": schema_pkg_apis_provisioning_v0alpha1_RepositoryViewList(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ResourceCount": schema_pkg_apis_provisioning_v0alpha1_ResourceCount(ref), @@ -53,7 +56,6 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ResourceRepositoryInfo": schema_pkg_apis_provisioning_v0alpha1_ResourceRepositoryInfo(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ResourceStats": schema_pkg_apis_provisioning_v0alpha1_ResourceStats(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ResourceType": schema_pkg_apis_provisioning_v0alpha1_ResourceType(ref), - "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ResourceURLs": schema_pkg_apis_provisioning_v0alpha1_ResourceURLs(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ResourceWrapper": schema_pkg_apis_provisioning_v0alpha1_ResourceWrapper(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.SyncJobOptions": schema_pkg_apis_provisioning_v0alpha1_SyncJobOptions(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.SyncOptions": schema_pkg_apis_provisioning_v0alpha1_SyncOptions(ref), @@ -602,6 +604,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 an append only log, saving all jobs that have been processed.\n\nNOTE: This should not be used directly by any external consumer. When there is a more stable integration with loki (an appropriate append only store) this may be removed without notice.\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/apps/provisioning/pkg/apis/provisioning/v0alpha1.JobSpec"), + }, + }, + "status": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.JobStatus"), + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.JobSpec", "github.com/grafana/grafana/apps/provisioning/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/apps/provisioning/pkg/apis/provisioning/v0alpha1.HistoricJob"), + }, + }, + }, + }, + }, + }, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/provisioning/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{ @@ -1020,11 +1116,17 @@ func schema_pkg_apis_provisioning_v0alpha1_JobStatus(ref common.ReferenceCallbac }, }, }, + "url": { + SchemaProps: spec.SchemaProps{ + Description: "URLs contains URLs for the reference branch or commit if applicable.", + Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.RepositoryURLs"), + }, + }, }, }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.JobResourceSummary"}, + "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.JobResourceSummary", "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.RepositoryURLs"}, } } @@ -1555,6 +1657,46 @@ func schema_pkg_apis_provisioning_v0alpha1_RepositoryStatus(ref common.Reference } } +func schema_pkg_apis_provisioning_v0alpha1_RepositoryURLs(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "repositoryURL": { + SchemaProps: spec.SchemaProps{ + Description: "A URL pointing to the repository this lives in", + Type: []string{"string"}, + Format: "", + }, + }, + "sourceURL": { + SchemaProps: spec.SchemaProps{ + Description: "A URL pointing to the file or ref in the repository", + Type: []string{"string"}, + Format: "", + }, + }, + "newPullRequestURL": { + SchemaProps: spec.SchemaProps{ + Description: "A URL that will create a new pull request for this branch", + Type: []string{"string"}, + Format: "", + }, + }, + "compareURL": { + SchemaProps: spec.SchemaProps{ + Description: "Compare this version to the target branch", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + } +} + func schema_pkg_apis_provisioning_v0alpha1_RepositoryView(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -2030,6 +2172,25 @@ func schema_pkg_apis_provisioning_v0alpha1_ResourceStats(ref common.ReferenceCal }, }, }, + "unmanaged": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "atomic", + }, + }, + SchemaProps: spec.SchemaProps{ + Description: "Stats across all unified storage When legacy storage is still used, this will offer a shim", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ResourceCount"), + }, + }, + }, + }, + }, "managed": { VendorExtensible: spec.VendorExtensible{ Extensions: spec.Extensions{ @@ -2101,46 +2262,6 @@ func schema_pkg_apis_provisioning_v0alpha1_ResourceType(ref common.ReferenceCall } } -func schema_pkg_apis_provisioning_v0alpha1_ResourceURLs(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "sourceURL": { - SchemaProps: spec.SchemaProps{ - Description: "A URL pointing to the this file in the repository", - Type: []string{"string"}, - Format: "", - }, - }, - "repositoryURL": { - SchemaProps: spec.SchemaProps{ - Description: "A URL pointing to the repository this lives in", - Type: []string{"string"}, - Format: "", - }, - }, - "newPullRequestURL": { - SchemaProps: spec.SchemaProps{ - Description: "A URL that will create a new pull requeset for this branch", - Type: []string{"string"}, - Format: "", - }, - }, - "compareURL": { - SchemaProps: spec.SchemaProps{ - Description: "Compare this version to the target branch", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - } -} - func schema_pkg_apis_provisioning_v0alpha1_ResourceWrapper(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -2193,7 +2314,7 @@ func schema_pkg_apis_provisioning_v0alpha1_ResourceWrapper(ref common.ReferenceC "urls": { SchemaProps: spec.SchemaProps{ Description: "Typed links for this file (only supported by external systems, github etc)", - Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ResourceURLs"), + Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.RepositoryURLs"), }, }, "timestamp": { @@ -2234,7 +2355,7 @@ func schema_pkg_apis_provisioning_v0alpha1_ResourceWrapper(ref common.ReferenceC }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ResourceObjects", "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ResourceRepositoryInfo", "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ResourceURLs", "k8s.io/apimachinery/pkg/apis/meta/v1.Time"}, + "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.RepositoryURLs", "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ResourceObjects", "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ResourceRepositoryInfo", "k8s.io/apimachinery/pkg/apis/meta/v1.Time"}, } } diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list index a92f95837a2..2eca68f79d2 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -18,9 +18,11 @@ API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioni API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,TestResults,Errors API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,WebhookStatus,SubscribedEvents API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobSpec,PullRequest +API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobStatus,URLs API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ManagerStats,Identity API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,RepositorySpec,GitHub API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,RepositorySpec,GitLab API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ResourceWrapper,URLs API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,SyncStatus,JobID API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,WebhookResponse,Message +API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,HistoricJobList,Items diff --git a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/historicjob.go b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/historicjob.go new file mode 100644 index 00000000000..66db5359b54 --- /dev/null +++ b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/historicjob.go @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + v1 "k8s.io/client-go/applyconfigurations/meta/v1" +) + +// HistoricJobApplyConfiguration represents a declarative configuration of the HistoricJob type for use +// with apply. +type HistoricJobApplyConfiguration struct { + v1.TypeMetaApplyConfiguration `json:",inline"` + *v1.ObjectMetaApplyConfiguration `json:"metadata,omitempty"` + Spec *JobSpecApplyConfiguration `json:"spec,omitempty"` + Status *JobStatusApplyConfiguration `json:"status,omitempty"` +} + +// HistoricJob constructs a declarative configuration of the HistoricJob type for use with +// apply. +func HistoricJob(name, namespace string) *HistoricJobApplyConfiguration { + b := &HistoricJobApplyConfiguration{} + b.WithName(name) + b.WithNamespace(namespace) + b.WithKind("HistoricJob") + b.WithAPIVersion("provisioning.grafana.app/v0alpha1") + return b +} + +// WithKind sets the Kind field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Kind field is set to the value of the last call. +func (b *HistoricJobApplyConfiguration) WithKind(value string) *HistoricJobApplyConfiguration { + b.TypeMetaApplyConfiguration.Kind = &value + return b +} + +// WithAPIVersion sets the APIVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the APIVersion field is set to the value of the last call. +func (b *HistoricJobApplyConfiguration) WithAPIVersion(value string) *HistoricJobApplyConfiguration { + b.TypeMetaApplyConfiguration.APIVersion = &value + return b +} + +// WithName sets the Name field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Name field is set to the value of the last call. +func (b *HistoricJobApplyConfiguration) WithName(value string) *HistoricJobApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.Name = &value + return b +} + +// WithGenerateName sets the GenerateName field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the GenerateName field is set to the value of the last call. +func (b *HistoricJobApplyConfiguration) WithGenerateName(value string) *HistoricJobApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.GenerateName = &value + return b +} + +// WithNamespace sets the Namespace field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Namespace field is set to the value of the last call. +func (b *HistoricJobApplyConfiguration) WithNamespace(value string) *HistoricJobApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.Namespace = &value + return b +} + +// WithUID sets the UID field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the UID field is set to the value of the last call. +func (b *HistoricJobApplyConfiguration) WithUID(value types.UID) *HistoricJobApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.UID = &value + return b +} + +// WithResourceVersion sets the ResourceVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ResourceVersion field is set to the value of the last call. +func (b *HistoricJobApplyConfiguration) WithResourceVersion(value string) *HistoricJobApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.ResourceVersion = &value + return b +} + +// WithGeneration sets the Generation field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Generation field is set to the value of the last call. +func (b *HistoricJobApplyConfiguration) WithGeneration(value int64) *HistoricJobApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.Generation = &value + return b +} + +// WithCreationTimestamp sets the CreationTimestamp field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the CreationTimestamp field is set to the value of the last call. +func (b *HistoricJobApplyConfiguration) WithCreationTimestamp(value metav1.Time) *HistoricJobApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.CreationTimestamp = &value + return b +} + +// WithDeletionTimestamp sets the DeletionTimestamp field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the DeletionTimestamp field is set to the value of the last call. +func (b *HistoricJobApplyConfiguration) WithDeletionTimestamp(value metav1.Time) *HistoricJobApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.DeletionTimestamp = &value + return b +} + +// WithDeletionGracePeriodSeconds sets the DeletionGracePeriodSeconds field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the DeletionGracePeriodSeconds field is set to the value of the last call. +func (b *HistoricJobApplyConfiguration) WithDeletionGracePeriodSeconds(value int64) *HistoricJobApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.DeletionGracePeriodSeconds = &value + return b +} + +// WithLabels puts the entries into the Labels field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Labels field, +// overwriting an existing map entries in Labels field with the same key. +func (b *HistoricJobApplyConfiguration) WithLabels(entries map[string]string) *HistoricJobApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + if b.ObjectMetaApplyConfiguration.Labels == nil && len(entries) > 0 { + b.ObjectMetaApplyConfiguration.Labels = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.ObjectMetaApplyConfiguration.Labels[k] = v + } + return b +} + +// WithAnnotations puts the entries into the Annotations field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Annotations field, +// overwriting an existing map entries in Annotations field with the same key. +func (b *HistoricJobApplyConfiguration) WithAnnotations(entries map[string]string) *HistoricJobApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + if b.ObjectMetaApplyConfiguration.Annotations == nil && len(entries) > 0 { + b.ObjectMetaApplyConfiguration.Annotations = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.ObjectMetaApplyConfiguration.Annotations[k] = v + } + return b +} + +// WithOwnerReferences adds the given value to the OwnerReferences field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the OwnerReferences field. +func (b *HistoricJobApplyConfiguration) WithOwnerReferences(values ...*v1.OwnerReferenceApplyConfiguration) *HistoricJobApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + for i := range values { + if values[i] == nil { + panic("nil value passed to WithOwnerReferences") + } + b.ObjectMetaApplyConfiguration.OwnerReferences = append(b.ObjectMetaApplyConfiguration.OwnerReferences, *values[i]) + } + return b +} + +// WithFinalizers adds the given value to the Finalizers field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Finalizers field. +func (b *HistoricJobApplyConfiguration) WithFinalizers(values ...string) *HistoricJobApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + for i := range values { + b.ObjectMetaApplyConfiguration.Finalizers = append(b.ObjectMetaApplyConfiguration.Finalizers, values[i]) + } + return b +} + +func (b *HistoricJobApplyConfiguration) ensureObjectMetaApplyConfigurationExists() { + if b.ObjectMetaApplyConfiguration == nil { + b.ObjectMetaApplyConfiguration = &v1.ObjectMetaApplyConfiguration{} + } +} + +// WithSpec sets the Spec field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Spec field is set to the value of the last call. +func (b *HistoricJobApplyConfiguration) WithSpec(value *JobSpecApplyConfiguration) *HistoricJobApplyConfiguration { + b.Spec = value + return b +} + +// WithStatus sets the Status field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Status field is set to the value of the last call. +func (b *HistoricJobApplyConfiguration) WithStatus(value *JobStatusApplyConfiguration) *HistoricJobApplyConfiguration { + b.Status = value + return b +} + +// GetName retrieves the value of the Name field in the declarative configuration. +func (b *HistoricJobApplyConfiguration) GetName() *string { + b.ensureObjectMetaApplyConfigurationExists() + return b.ObjectMetaApplyConfiguration.Name +} diff --git a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/jobstatus.go b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/jobstatus.go index 37a025730e3..ea9228473a5 100644 --- a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/jobstatus.go +++ b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/jobstatus.go @@ -18,6 +18,7 @@ type JobStatusApplyConfiguration struct { Errors []string `json:"errors,omitempty"` Progress *float64 `json:"progress,omitempty"` Summary []*provisioningv0alpha1.JobResourceSummary `json:"summary,omitempty"` + URLs *RepositoryURLsApplyConfiguration `json:"url,omitempty"` } // JobStatusApplyConfiguration constructs a declarative configuration of the JobStatus type for use with @@ -88,3 +89,11 @@ func (b *JobStatusApplyConfiguration) WithSummary(values ...**provisioningv0alph } return b } + +// WithURLs sets the URLs field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the URLs field is set to the value of the last call. +func (b *JobStatusApplyConfiguration) WithURLs(value *RepositoryURLsApplyConfiguration) *JobStatusApplyConfiguration { + b.URLs = value + return b +} diff --git a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/repositoryurls.go b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/repositoryurls.go new file mode 100644 index 00000000000..d18ae864988 --- /dev/null +++ b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/repositoryurls.go @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v0alpha1 + +// RepositoryURLsApplyConfiguration represents a declarative configuration of the RepositoryURLs type for use +// with apply. +type RepositoryURLsApplyConfiguration struct { + RepositoryURL *string `json:"repositoryURL,omitempty"` + SourceURL *string `json:"sourceURL,omitempty"` + NewPullRequestURL *string `json:"newPullRequestURL,omitempty"` + CompareURL *string `json:"compareURL,omitempty"` +} + +// RepositoryURLsApplyConfiguration constructs a declarative configuration of the RepositoryURLs type for use with +// apply. +func RepositoryURLs() *RepositoryURLsApplyConfiguration { + return &RepositoryURLsApplyConfiguration{} +} + +// WithRepositoryURL sets the RepositoryURL field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the RepositoryURL field is set to the value of the last call. +func (b *RepositoryURLsApplyConfiguration) WithRepositoryURL(value string) *RepositoryURLsApplyConfiguration { + b.RepositoryURL = &value + return b +} + +// WithSourceURL sets the SourceURL field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the SourceURL field is set to the value of the last call. +func (b *RepositoryURLsApplyConfiguration) WithSourceURL(value string) *RepositoryURLsApplyConfiguration { + b.SourceURL = &value + return b +} + +// WithNewPullRequestURL sets the NewPullRequestURL field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the NewPullRequestURL field is set to the value of the last call. +func (b *RepositoryURLsApplyConfiguration) WithNewPullRequestURL(value string) *RepositoryURLsApplyConfiguration { + b.NewPullRequestURL = &value + return b +} + +// WithCompareURL sets the CompareURL field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the CompareURL field is set to the value of the last call. +func (b *RepositoryURLsApplyConfiguration) WithCompareURL(value string) *RepositoryURLsApplyConfiguration { + b.CompareURL = &value + return b +} diff --git a/apps/provisioning/pkg/generated/applyconfiguration/utils.go b/apps/provisioning/pkg/generated/applyconfiguration/utils.go index 6fc85d35cda..8725415c377 100644 --- a/apps/provisioning/pkg/generated/applyconfiguration/utils.go +++ b/apps/provisioning/pkg/generated/applyconfiguration/utils.go @@ -32,6 +32,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &provisioningv0alpha1.GitRepositoryConfigApplyConfiguration{} case v0alpha1.SchemeGroupVersion.WithKind("HealthStatus"): return &provisioningv0alpha1.HealthStatusApplyConfiguration{} + case v0alpha1.SchemeGroupVersion.WithKind("HistoricJob"): + return &provisioningv0alpha1.HistoricJobApplyConfiguration{} case v0alpha1.SchemeGroupVersion.WithKind("Job"): return &provisioningv0alpha1.JobApplyConfiguration{} case v0alpha1.SchemeGroupVersion.WithKind("JobResourceSummary"): @@ -54,6 +56,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &provisioningv0alpha1.RepositorySpecApplyConfiguration{} case v0alpha1.SchemeGroupVersion.WithKind("RepositoryStatus"): return &provisioningv0alpha1.RepositoryStatusApplyConfiguration{} + case v0alpha1.SchemeGroupVersion.WithKind("RepositoryURLs"): + return &provisioningv0alpha1.RepositoryURLsApplyConfiguration{} case v0alpha1.SchemeGroupVersion.WithKind("ResourceCount"): return &provisioningv0alpha1.ResourceCountApplyConfiguration{} case v0alpha1.SchemeGroupVersion.WithKind("ResourceRef"): diff --git a/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/fake/fake_historicjob.go b/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/fake/fake_historicjob.go new file mode 100644 index 00000000000..94611b64ce0 --- /dev/null +++ b/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/fake/fake_historicjob.go @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + provisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1" + typedprovisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1" + gentype "k8s.io/client-go/gentype" +) + +// fakeHistoricJobs implements HistoricJobInterface +type fakeHistoricJobs struct { + *gentype.FakeClientWithListAndApply[*v0alpha1.HistoricJob, *v0alpha1.HistoricJobList, *provisioningv0alpha1.HistoricJobApplyConfiguration] + Fake *FakeProvisioningV0alpha1 +} + +func newFakeHistoricJobs(fake *FakeProvisioningV0alpha1, namespace string) typedprovisioningv0alpha1.HistoricJobInterface { + return &fakeHistoricJobs{ + gentype.NewFakeClientWithListAndApply[*v0alpha1.HistoricJob, *v0alpha1.HistoricJobList, *provisioningv0alpha1.HistoricJobApplyConfiguration]( + fake.Fake, + namespace, + v0alpha1.SchemeGroupVersion.WithResource("historicjobs"), + v0alpha1.SchemeGroupVersion.WithKind("HistoricJob"), + func() *v0alpha1.HistoricJob { return &v0alpha1.HistoricJob{} }, + func() *v0alpha1.HistoricJobList { return &v0alpha1.HistoricJobList{} }, + func(dst, src *v0alpha1.HistoricJobList) { dst.ListMeta = src.ListMeta }, + func(list *v0alpha1.HistoricJobList) []*v0alpha1.HistoricJob { + return gentype.ToPointerSlice(list.Items) + }, + func(list *v0alpha1.HistoricJobList, items []*v0alpha1.HistoricJob) { + list.Items = gentype.FromPointerSlice(items) + }, + ), + fake, + } +} diff --git a/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/fake/fake_provisioning_client.go b/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/fake/fake_provisioning_client.go index 4a6afceb6de..d6fe94156be 100644 --- a/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/fake/fake_provisioning_client.go +++ b/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/fake/fake_provisioning_client.go @@ -14,6 +14,10 @@ type FakeProvisioningV0alpha1 struct { *testing.Fake } +func (c *FakeProvisioningV0alpha1) HistoricJobs(namespace string) v0alpha1.HistoricJobInterface { + return newFakeHistoricJobs(c, namespace) +} + func (c *FakeProvisioningV0alpha1) Jobs(namespace string) v0alpha1.JobInterface { return newFakeJobs(c, namespace) } diff --git a/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/generated_expansion.go b/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/generated_expansion.go index 9916a25266d..21b7a18b414 100644 --- a/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/generated_expansion.go +++ b/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/generated_expansion.go @@ -4,6 +4,8 @@ package v0alpha1 +type HistoricJobExpansion interface{} + type JobExpansion interface{} type RepositoryExpansion interface{} diff --git a/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/historicjob.go b/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/historicjob.go new file mode 100644 index 00000000000..f59daff7e25 --- /dev/null +++ b/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/historicjob.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + context "context" + + provisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + applyconfigurationprovisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1" + scheme "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + gentype "k8s.io/client-go/gentype" +) + +// HistoricJobsGetter has a method to return a HistoricJobInterface. +// A group's client should implement this interface. +type HistoricJobsGetter interface { + HistoricJobs(namespace string) HistoricJobInterface +} + +// HistoricJobInterface has methods to work with HistoricJob resources. +type HistoricJobInterface interface { + Create(ctx context.Context, historicJob *provisioningv0alpha1.HistoricJob, opts v1.CreateOptions) (*provisioningv0alpha1.HistoricJob, error) + Update(ctx context.Context, historicJob *provisioningv0alpha1.HistoricJob, opts v1.UpdateOptions) (*provisioningv0alpha1.HistoricJob, error) + // Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). + UpdateStatus(ctx context.Context, historicJob *provisioningv0alpha1.HistoricJob, opts v1.UpdateOptions) (*provisioningv0alpha1.HistoricJob, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*provisioningv0alpha1.HistoricJob, error) + List(ctx context.Context, opts v1.ListOptions) (*provisioningv0alpha1.HistoricJobList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *provisioningv0alpha1.HistoricJob, err error) + Apply(ctx context.Context, historicJob *applyconfigurationprovisioningv0alpha1.HistoricJobApplyConfiguration, opts v1.ApplyOptions) (result *provisioningv0alpha1.HistoricJob, err error) + // Add a +genclient:noStatus comment above the type to avoid generating ApplyStatus(). + ApplyStatus(ctx context.Context, historicJob *applyconfigurationprovisioningv0alpha1.HistoricJobApplyConfiguration, opts v1.ApplyOptions) (result *provisioningv0alpha1.HistoricJob, err error) + HistoricJobExpansion +} + +// historicJobs implements HistoricJobInterface +type historicJobs struct { + *gentype.ClientWithListAndApply[*provisioningv0alpha1.HistoricJob, *provisioningv0alpha1.HistoricJobList, *applyconfigurationprovisioningv0alpha1.HistoricJobApplyConfiguration] +} + +// newHistoricJobs returns a HistoricJobs +func newHistoricJobs(c *ProvisioningV0alpha1Client, namespace string) *historicJobs { + return &historicJobs{ + gentype.NewClientWithListAndApply[*provisioningv0alpha1.HistoricJob, *provisioningv0alpha1.HistoricJobList, *applyconfigurationprovisioningv0alpha1.HistoricJobApplyConfiguration]( + "historicjobs", + c.RESTClient(), + scheme.ParameterCodec, + namespace, + func() *provisioningv0alpha1.HistoricJob { return &provisioningv0alpha1.HistoricJob{} }, + func() *provisioningv0alpha1.HistoricJobList { return &provisioningv0alpha1.HistoricJobList{} }, + ), + } +} diff --git a/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/provisioning_client.go b/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/provisioning_client.go index b260183135e..401bc533cfe 100644 --- a/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/provisioning_client.go +++ b/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1/provisioning_client.go @@ -14,6 +14,7 @@ import ( type ProvisioningV0alpha1Interface interface { RESTClient() rest.Interface + HistoricJobsGetter JobsGetter RepositoriesGetter } @@ -23,6 +24,10 @@ type ProvisioningV0alpha1Client struct { restClient rest.Interface } +func (c *ProvisioningV0alpha1Client) HistoricJobs(namespace string) HistoricJobInterface { + return newHistoricJobs(c, namespace) +} + func (c *ProvisioningV0alpha1Client) Jobs(namespace string) JobInterface { return newJobs(c, namespace) } diff --git a/apps/provisioning/pkg/generated/informers/externalversions/generic.go b/apps/provisioning/pkg/generated/informers/externalversions/generic.go index cdbcc682e3d..4a62aab044a 100644 --- a/apps/provisioning/pkg/generated/informers/externalversions/generic.go +++ b/apps/provisioning/pkg/generated/informers/externalversions/generic.go @@ -39,6 +39,8 @@ func (f *genericInformer) Lister() cache.GenericLister { func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) { switch resource { // Group=provisioning.grafana.app, Version=v0alpha1 + case v0alpha1.SchemeGroupVersion.WithResource("historicjobs"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Provisioning().V0alpha1().HistoricJobs().Informer()}, nil case v0alpha1.SchemeGroupVersion.WithResource("jobs"): return &genericInformer{resource: resource.GroupResource(), informer: f.Provisioning().V0alpha1().Jobs().Informer()}, nil case v0alpha1.SchemeGroupVersion.WithResource("repositories"): diff --git a/apps/provisioning/pkg/generated/informers/externalversions/provisioning/v0alpha1/historicjob.go b/apps/provisioning/pkg/generated/informers/externalversions/provisioning/v0alpha1/historicjob.go new file mode 100644 index 00000000000..fae7e78c2f6 --- /dev/null +++ b/apps/provisioning/pkg/generated/informers/externalversions/provisioning/v0alpha1/historicjob.go @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by informer-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + context "context" + time "time" + + apisprovisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + versioned "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned" + internalinterfaces "github.com/grafana/grafana/apps/provisioning/pkg/generated/informers/externalversions/internalinterfaces" + provisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/generated/listers/provisioning/v0alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// HistoricJobInformer provides access to a shared informer and lister for +// HistoricJobs. +type HistoricJobInformer interface { + Informer() cache.SharedIndexInformer + Lister() provisioningv0alpha1.HistoricJobLister +} + +type historicJobInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewHistoricJobInformer constructs a new informer for HistoricJob type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewHistoricJobInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredHistoricJobInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredHistoricJobInformer constructs a new informer for HistoricJob type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredHistoricJobInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.ProvisioningV0alpha1().HistoricJobs(namespace).List(context.Background(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.ProvisioningV0alpha1().HistoricJobs(namespace).Watch(context.Background(), options) + }, + ListWithContextFunc: func(ctx context.Context, options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.ProvisioningV0alpha1().HistoricJobs(namespace).List(ctx, options) + }, + WatchFuncWithContext: func(ctx context.Context, options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.ProvisioningV0alpha1().HistoricJobs(namespace).Watch(ctx, options) + }, + }, + &apisprovisioningv0alpha1.HistoricJob{}, + resyncPeriod, + indexers, + ) +} + +func (f *historicJobInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredHistoricJobInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *historicJobInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&apisprovisioningv0alpha1.HistoricJob{}, f.defaultInformer) +} + +func (f *historicJobInformer) Lister() provisioningv0alpha1.HistoricJobLister { + return provisioningv0alpha1.NewHistoricJobLister(f.Informer().GetIndexer()) +} diff --git a/apps/provisioning/pkg/generated/informers/externalversions/provisioning/v0alpha1/interface.go b/apps/provisioning/pkg/generated/informers/externalversions/provisioning/v0alpha1/interface.go index 0b7c499f035..cbe2fcefaf3 100644 --- a/apps/provisioning/pkg/generated/informers/externalversions/provisioning/v0alpha1/interface.go +++ b/apps/provisioning/pkg/generated/informers/externalversions/provisioning/v0alpha1/interface.go @@ -10,6 +10,8 @@ import ( // Interface provides access to all the informers in this group version. type Interface interface { + // HistoricJobs returns a HistoricJobInformer. + HistoricJobs() HistoricJobInformer // Jobs returns a JobInformer. Jobs() JobInformer // Repositories returns a RepositoryInformer. @@ -27,6 +29,11 @@ func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakList return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} } +// HistoricJobs returns a HistoricJobInformer. +func (v *version) HistoricJobs() HistoricJobInformer { + return &historicJobInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} + // Jobs returns a JobInformer. func (v *version) Jobs() JobInformer { return &jobInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} diff --git a/apps/provisioning/pkg/generated/listers/provisioning/v0alpha1/expansion_generated.go b/apps/provisioning/pkg/generated/listers/provisioning/v0alpha1/expansion_generated.go index 94ecc5121e1..7f0649542b3 100644 --- a/apps/provisioning/pkg/generated/listers/provisioning/v0alpha1/expansion_generated.go +++ b/apps/provisioning/pkg/generated/listers/provisioning/v0alpha1/expansion_generated.go @@ -4,6 +4,14 @@ package v0alpha1 +// HistoricJobListerExpansion allows custom methods to be added to +// HistoricJobLister. +type HistoricJobListerExpansion interface{} + +// HistoricJobNamespaceListerExpansion allows custom methods to be added to +// HistoricJobNamespaceLister. +type HistoricJobNamespaceListerExpansion interface{} + // JobListerExpansion allows custom methods to be added to // JobLister. type JobListerExpansion interface{} diff --git a/apps/provisioning/pkg/generated/listers/provisioning/v0alpha1/historicjob.go b/apps/provisioning/pkg/generated/listers/provisioning/v0alpha1/historicjob.go new file mode 100644 index 00000000000..56cb38c5515 --- /dev/null +++ b/apps/provisioning/pkg/generated/listers/provisioning/v0alpha1/historicjob.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by lister-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + provisioningv0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + labels "k8s.io/apimachinery/pkg/labels" + listers "k8s.io/client-go/listers" + cache "k8s.io/client-go/tools/cache" +) + +// HistoricJobLister helps list HistoricJobs. +// All objects returned here must be treated as read-only. +type HistoricJobLister interface { + // List lists all HistoricJobs in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*provisioningv0alpha1.HistoricJob, err error) + // HistoricJobs returns an object that can list and get HistoricJobs. + HistoricJobs(namespace string) HistoricJobNamespaceLister + HistoricJobListerExpansion +} + +// historicJobLister implements the HistoricJobLister interface. +type historicJobLister struct { + listers.ResourceIndexer[*provisioningv0alpha1.HistoricJob] +} + +// NewHistoricJobLister returns a new HistoricJobLister. +func NewHistoricJobLister(indexer cache.Indexer) HistoricJobLister { + return &historicJobLister{listers.New[*provisioningv0alpha1.HistoricJob](indexer, provisioningv0alpha1.Resource("historicjob"))} +} + +// HistoricJobs returns an object that can list and get HistoricJobs. +func (s *historicJobLister) HistoricJobs(namespace string) HistoricJobNamespaceLister { + return historicJobNamespaceLister{listers.NewNamespaced[*provisioningv0alpha1.HistoricJob](s.ResourceIndexer, namespace)} +} + +// HistoricJobNamespaceLister helps list and get HistoricJobs. +// All objects returned here must be treated as read-only. +type HistoricJobNamespaceLister interface { + // List lists all HistoricJobs in the indexer for a given namespace. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*provisioningv0alpha1.HistoricJob, err error) + // Get retrieves the HistoricJob from the indexer for a given namespace and name. + // Objects returned here must be treated as read-only. + Get(name string) (*provisioningv0alpha1.HistoricJob, error) + HistoricJobNamespaceListerExpansion +} + +// historicJobNamespaceLister implements the HistoricJobNamespaceLister +// interface. +type historicJobNamespaceLister struct { + listers.ResourceIndexer[*provisioningv0alpha1.HistoricJob] +} diff --git a/apps/secret/go.mod b/apps/secret/go.mod index e9ee004cd8e..f153f3be5a5 100644 --- a/apps/secret/go.mod +++ b/apps/secret/go.mod @@ -1,6 +1,6 @@ module github.com/grafana/grafana/apps/secret -go 1.24.5 +go 1.24.6 require ( github.com/grafana/grafana-app-sdk v0.40.3 diff --git a/apps/shorturl/go.mod b/apps/shorturl/go.mod index 954eff80397..d3f050f910c 100644 --- a/apps/shorturl/go.mod +++ b/apps/shorturl/go.mod @@ -2,7 +2,7 @@ module github.com/grafana/grafana/apps/shorturl go 1.24.0 -toolchain go1.24.5 +toolchain go1.24.6 require ( github.com/grafana/grafana-app-sdk v0.40.3 diff --git a/apps/shorturl/kinds/shorturl.cue b/apps/shorturl/kinds/shorturl.cue index d88e10ad3e3..ab6d1c5cff2 100644 --- a/apps/shorturl/kinds/shorturl.cue +++ b/apps/shorturl/kinds/shorturl.cue @@ -3,6 +3,7 @@ package kinds shorturl: { kind: "ShortURL" pluralName: "ShortURLs" + validation: operations: ["CREATE","UPDATE"] schema: { spec: { // The original path to where the short url is linking too e.g. https://localhost:3000/eer8i1kictngga/new-dashboard-with-lib-panel diff --git a/apps/shorturl/pkg/apis/shorturl_manifest.go b/apps/shorturl/pkg/apis/shorturl_manifest.go index 5cb4b1b2d07..d5198754064 100644 --- a/apps/shorturl/pkg/apis/shorturl_manifest.go +++ b/apps/shorturl/pkg/apis/shorturl_manifest.go @@ -35,7 +35,15 @@ var appManifestData = app.ManifestData{ Plural: "ShortURLs", Scope: "Namespaced", Conversion: false, - Schema: &versionSchemaShortURLv1alpha1, + Admission: &app.AdmissionCapabilities{ + Validation: &app.ValidationCapability{ + Operations: []app.AdmissionOperation{ + app.AdmissionOperationCreate, + app.AdmissionOperationUpdate, + }, + }, + }, + Schema: &versionSchemaShortURLv1alpha1, }, }, }, diff --git a/apps/shorturl/pkg/app/app.go b/apps/shorturl/pkg/app/app.go index 519ebd5f187..9cda5c374af 100644 --- a/apps/shorturl/pkg/app/app.go +++ b/apps/shorturl/pkg/app/app.go @@ -3,6 +3,8 @@ package app import ( "context" "fmt" + "path" + "strings" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/klog/v2" @@ -41,6 +43,24 @@ func New(cfg app.Config) (app.App, error) { ManagedKinds: []simple.AppManagedKind{ { Kind: shorturlv1alpha1.ShortURLKind(), + Validator: &simple.Validator{ + ValidateFunc: func(ctx context.Context, req *app.AdmissionRequest) error { + // Cast the incoming object to ShortURL for validation + shortURL, ok := req.Object.(*shorturlv1alpha1.ShortURL) + if !ok { + return fmt.Errorf("expected ShortURL object, got %T", req.Object) + } + + relPath := strings.TrimSpace(shortURL.Spec.Path) + if path.IsAbs(relPath) { + return fmt.Errorf("%w: %s", ErrShortURLAbsolutePath, relPath) + } + if strings.Contains(relPath, "../") { + return fmt.Errorf("%w: %s", ErrShortURLInvalidPath, relPath) + } + return nil + }, + }, }, }, } diff --git a/conf/defaults.ini b/conf/defaults.ini index 65cec28cfbe..081d660a5d8 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -2030,17 +2030,17 @@ provider = static # This is EXPERIMENTAL. Please, do not use this section # foo = bar +[time_picker] +# Custom quick ranges for the time picker. Each quick range has a display name, a from value, and a to value. +# Format: [{"from":"now-5m","to":"now","display":"Last 5 minutes"},{"from":"now-15m","to":"now","display":"Last 15 minutes"}] +quick_ranges = + [date_formats] # For information on what formatting patterns that are supported https://momentjs.com/docs/#/displaying/ # Default system date format used in time range picker and other places where full time is displayed full_date = YYYY-MM-DD HH:mm:ss -[time_picker] -# Custom quick ranges for the time picker. Each quick range has a display name, a from value, and a to value. -# Format: [{"from":"now-5m","to":"now","display":"Last 5 minutes"},{"from":"now-15m","to":"now","display":"Last 15 minutes"}] -quick_ranges = - # Used by graph and other places where we only show small intervals interval_second = HH:mm:ss interval_minute = HH:mm diff --git a/contribute/backend/instrumentation.md b/contribute/backend/instrumentation.md index b2cd4d64869..18d0071e75d 100644 --- a/contribute/backend/instrumentation.md +++ b/contribute/backend/instrumentation.md @@ -156,9 +156,120 @@ A distributed trace is data that tracks an application request as it flows throu ### Usage -Grafana uses [OpenTelemetry](https://opentelemetry.io/) for distributed tracing. There's an interface `Tracer` in the `pkg/infra/tracing` package that implements the [OpenTelemetry Tracer interface](https://pkg.go.dev/go.opentelemetry.io/otel/trace), which you can use to create traces and spans. To access `Tracer` you need to get it injected as a dependency of your service. Refer to [Services](services.md) for more details. For more information, you may also refer to [The OpenTelemetry documentation](https://opentelemetry.io/docs/instrumentation/go/manual/). +Grafana uses [OpenTelemetry](https://opentelemetry.io/) for distributed tracing. For more information, you may also refer to [The OpenTelemetry documentation](https://opentelemetry.io/docs/instrumentation/go/manual/). -For example: +1. Import the otel package + + ```Go + "go.opentelemetry.io/otel" + ``` + +2. Initialize a package level tracer + + We don't want to change the provider signature for every service across Grafana, especially in a testing scenario. otel.Tracer gets the global tracer configured in `pkg/infra/tracing` and handles whether or not we use a noop when otel is disabled. + + Using the package location as the provided name allows us to determine where the trace is coming from in the event of a naming collision. + + ```Go + var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/dashboards") + ``` + + For enterprise, refer to the enterprise repo + + ```Go + var tracer = otel.Tracer("github.com/grafana/grafana-enterprise/src/pkg/extensions/accesscontrol") + ``` + +3. Create a span + + Spans are connected via the context.Context named ctx by convention. Name your span according to `.`. In some cases, there is a folder inside a package with a name commonly used throughout the codebase. In that case use the form `..`. + + It is important to match casing for each of these as it's common convention to have an internal and external functions with the same name. + + Be sure to always terminate your spans. + + ```Go + ctx, span := tracer.Start(ctx, "dashboards.store.GetDashboardByUID") + defer span.End() + ``` + +4. Ensure spans are connected + + Grafana's api package uses `*contextmodel.ReqContext` to pass between api functions with context.Context as a field on the request. + + Given `func (hs *HTTPServer) isDashboardStarredByUser(c *contextmodel.ReqContext)` + + you can fetch the context via `c.Req.Context()` + + If you create a span, you need to ensure the new context is set on `ReqContext` + + ```Go + ctx, span := tracer.Start(c.Req.Context(), "api.isDashboardStarredByUser") + defer span.End() + c.Req = c.Req.WithContext(ctx) + ``` + + If the function does not pass a `*contextmodel.ReqContext` to another function, do not set the `ctx`. + +5. Spans with no children + Often in the context of cpu bound work you want a context, but won't pass the child down. In this case use a blank identifier like any other variable and we will get appropriate timing from the span. + + ```Go + _, span := tracer.Start(c.Req.Context(), "api.isDashboardStarredByUser") + defer span.End() + ``` + +6. Deciding when to create a span + + Only instrument functions that perform IO bound work like database calls or quantifiable cpu bound work. Simple wrappers in the service layer that make a call to the database should not be instrumented as they're just creating more data without giving us more insights. + + For example, the following function is performing business logic, but not doing a meaningful amount of work. In this case, ensure ctx is passed to the child function and perform the tracing in `pd.store.FindByAccessToken` + + ```Go + func (pd *PublicDashboardServiceImpl) FindByAccessToken(ctx context.Context, accessToken string) (*PublicDashboard, error) { + ctx, span := tracer.Start(ctx, "publicdashboards.FindByAccessToken") + defer span.End() + + pubdash, err := pd.store.FindByAccessToken(ctx, accessToken) + if err != nil { + return nil, ErrInternalServerError.Errorf("FindByAccessToken: failed to find a public dashboard: %w", err) + } + if pubdash == nil { + return nil, ErrPublicDashboardNotFound.Errorf("FindByAccessToken: Public dashboard not found accessToken: %s", accessToken) + } + return pubdash, nil + } + ``` + +7. Adding attributes for investigation + + It's often helpful understand the context of a span. To help with debugging we can add attributes to a span that tell us more about the state of the function. This is very helpful for cpu bound function. In the following example we're performing a cpu bound operation and adding an attribute to the span telling us the number of permissions that were passed into the function. + + Import the otel attributes and trace packages. + + ```Go + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + ``` + + Add your attributes when creating the span. + + ```Go + func GroupScopesByActionContext(ctx context.Context, permissions []Permission) map[string][]string { + _, span := tracer.Start(ctx, "accesscontrol.GroupScopesByActionContext", trace.WithAttributes( + attribute.Int("permissions_count", len(permissions)), + )) + defer span.End() + + m := make(map[string][]string) + for i := range permissions { + m[permissions[i].Action] = append(m[permissions[i].Action], permissions[i].Scope) + } + return m + } + ``` + +Complete example ```go import ( @@ -169,23 +280,12 @@ import ( "go.opentelemetry.io/otel/trace" ) -type MyService struct { - tracer tracing.Tracer -} - -func ProvideService(tracer tracing.Tracer) *MyService { - return &MyService{ - tracer: tracer, - } -} +var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/myservice") func (s *MyService) Hello(ctx context.Context, name string) (string, error) { ctx, span := s.tracer.Start(ctx, "MyService.Hello", trace.WithAttributes( attribute.String("my_attribute", "val"), )) - // make sure the span is marked as finished when this - // method ends to allow the span to be flushed and sent to - // storage backend. defer span.End() // Add some event to show Events usage @@ -215,7 +315,7 @@ func (s *MyService) Hello(ctx context.Context, name string) (string, error) { ``` -### Naming conventions +### Naming conventions for spans Span names should follow the [guidelines from OpenTelemetry](https://opentelemetry.io/docs/reference/specification/trace/api/#span). diff --git a/devenv/dev-dashboards/panel-table/table_kitchen_sink.json b/devenv/dev-dashboards/panel-table/table_kitchen_sink.json index 9c05634b066..6e806785038 100644 --- a/devenv/dev-dashboards/panel-table/table_kitchen_sink.json +++ b/devenv/dev-dashboards/panel-table/table_kitchen_sink.json @@ -18,7 +18,7 @@ "editable": true, "fiscalYearStartMonth": 0, "graphTooltip": 0, - "id": 138, + "id": 21409, "links": [], "panels": [ { @@ -78,6 +78,10 @@ { "id": "max", "value": 100 + }, + { + "id": "custom.inspect", + "value": true } ] }, @@ -96,6 +100,22 @@ "url": "https://google.com/search?q=${__value:percentencode}" } ] + }, + { + "id": "actions", + "value": [ + { + "fetch": { + "body": "{}", + "headers": [["Content-Type", "application/json"]], + "method": "GET", + "queryParams": [], + "url": "https://grafana.com" + }, + "title": "My action", + "type": "fetch" + } + ] } ] }, @@ -140,14 +160,14 @@ }, { "matcher": { - "id": "byName", - "options": "Image" + "id": "byRegexp", + "options": "/Image/" }, "properties": [ { "id": "custom.cellOptions", "value": { - "alt": "${__value}", + "alt": "Grafana icon", "type": "image" } } @@ -278,7 +298,15 @@ }, { "id": "custom.width", - "value": 300 + "value": 255 + }, + { + "id": "custom.tooltip.field", + "value": "State" + }, + { + "id": "custom.tooltip.placement", + "value": "left" } ] }, @@ -291,7 +319,8 @@ { "id": "custom.cellOptions", "value": { - "type": "pill" + "type": "pill", + "wrapText": true } }, { @@ -309,7 +338,8 @@ { "id": "custom.cellOptions", "value": { - "type": "data-links" + "type": "data-links", + "wrapText": true } }, { @@ -329,7 +359,7 @@ }, { "id": "custom.width", - "value": 180 + "value": 139 } ] }, @@ -339,18 +369,74 @@ "options": "Gauge" }, "properties": [] + }, + { + "matcher": { + "id": "byName", + "options": "Action" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "type": "actions" + } + }, + { + "id": "actions", + "value": [ + { + "confirmation": "", + "fetch": { + "body": "{}", + "headers": [["Content-Type", "application/json"]], + "method": "GET", + "queryParams": [], + "url": "/api/health" + }, + "title": "Get instance health", + "type": "fetch" + } + ] + }, + { + "id": "custom.width", + "value": 142 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Image w/ Link" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "targetBlank": true, + "title": "Grafana Cloud Products", + "url": "https://grafana.com/products/cloud/" + } + ] + } + ] } ] }, "gridPos": { - "h": 15, - "w": 24, + "h": 12, + "w": 16, "x": 0, "y": 0 }, "id": 1, "options": { "cellHeight": "sm", + "frozenColumns": { + "left": 1 + }, "footer": { "countRows": false, "enablePagination": false, @@ -360,9 +446,14 @@ }, "frameIndex": 0, "showHeader": true, - "sortBy": [] + "sortBy": [ + { + "desc": false, + "displayName": "Long Text" + } + ] }, - "pluginVersion": "12.1.0-pre", + "pluginVersion": "12.2.0-pre", "targets": [ { "datasource": { @@ -372,7 +463,7 @@ "scenarioId": "random_walk_table" }, { - "csvContent": "Info,Image,Pills,Data Link,Long Text\ndown,https://placecats.com/millie/200/400,hello,https://grafana.com,\"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse tempus et augue et lacinia. Interdum et malesuada fames ac ante ipsum primis in faucibus. Donec eu pretium tortor. Cras venenatis sapien sed mauris gravida, ut scelerisque est fringilla. Cras lorem diam, facilisis nec malesuada in, vulputate vel enim. Etiam fringilla nisi quis felis blandit tincidunt. Cras id lacus ornare, ullamcorper nisl eget, bibendum odio. Pellentesque imperdiet, leo a imperdiet venenatis, ligula risus venenatis quam, vel euismod magna nisi sit amet leo.\"\nup,https://placecats.com/neo/200/400,\"[1,2,3,\"\"foo\"\",\"\"bar\"\"]\",https://grafana.com/solutions/kubernetes/,\"Sed imperdiet eget diam sit amet fringilla. Curabitur quis lacus blandit, mollis diam non, accumsan tortor. Aliquam ac tellus eget dui facilisis tempor eu id nulla. Maecenas ultrices turpis eu elementum imperdiet. Fusce eget rhoncus mi, et egestas lectus. Mauris facilisis auctor enim sed malesuada. Maecenas placerat ultricies metus vitae viverra. In hac habitasse platea dictumst. Mauris ipsum nisl, dictum eu aliquam eleifend, rutrum id orci. Nullam eget dui et odio eleifend porttitor.\"\nup fast,https://placecats.com/bella/200/400,\"foo,1,4,beep\",https://k6.io/,\"Proin ac libero vulputate ex vulputate pharetra ut vel lacus. Phasellus quis dolor sed leo finibus scelerisque. Ut vel finibus leo, sed viverra ipsum. Suspendisse vitae rutrum arcu. Donec sed tellus vel lectus bibendum vestibulum. Sed eu felis non velit dictum pulvinar eu et leo. Aenean et dignissim arcu. Nam luctus at neque quis efficitur. Fusce tempus at nibh a imperdiet. Nullam malesuada ac magna at facilisis. Duis pretium aliquam eros. Donec pharetra dignissim dolor non bibendum. Ut gravida mi id urna tempus, at ullamcorper felis vulputate. Duis congue augue ex, sed finibus leo ornare ut. Mauris non quam sodales, dignissim lorem eget, tincidunt mauris. Aliquam ut velit auctor, vestibulum metus sed, mollis massa.\"\ndown fast,https://placecats.com/neo_2/200/400,\"foo,bar,baz,a longer one,bim\",https://grafana.com/products/cloud/,\"Nullam in pulvinar justo. Nunc dictum arcu ac pellentesque bibendum. Sed in erat turpis. Vestibulum eu orci ac ligula lobortis tempus. Fusce consectetur feugiat magna, eu tempor nibh vestibulum ac. Aliquam erat volutpat. Vivamus sit amet viverra enim. Quisque mollis odio nulla, nec vulputate sem placerat in. Etiam dolor sapien, pulvinar in accumsan at, consequat eget nisi. Nunc condimentum neque magna, congue consectetur dui efficitur interdum. Nam lobortis fringilla maximus. Vestibulum eu dui a velit condimentum eleifend consequat nec lectus.\"", + "csvContent": "Info,Image,Image w/ Link,Pills,Data Link,Long Text\ndown,https://grafana.com/media/menus/products/grafana-menu-icon.svg,https://grafana.com/media/menus/products/grafana-menu-icon.svg,hello,https://grafana.com,\"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse tempus et augue et lacinia. Interdum et malesuada fames ac ante ipsum primis in faucibus.\"\nup,https://grafana.com/media/menus/products/grafana-menu-icon-logs.svg,https://grafana.com/media/menus/products/grafana-menu-icon-logs.svg,\"[1,2,3,\"\"foo\"\",\"\"bar\"\"]\",https://grafana.com/solutions/kubernetes/,\"Sed imperdiet eget diam sit amet fringilla. Curabitur quis lacus blandit, mollis diam non, accumsan tortor.\"\nup fast,https://grafana.com/media/menus/products/grafana-menu-icon-traces.svg,https://grafana.com/media/menus/products/grafana-menu-icon-traces.svg,\"foo,1,4,beep\",https://k6.io/,\"Proin ac libero vulputate ex vulputate pharetra ut vel lacus. Phasellus quis dolor sed leo finibus scelerisque. Ut vel finibus leo, sed viverra ipsum.\"\ndown fast,https://grafana.com/media/menus/products/grafana-menu-icon-metrics.svg,https://grafana.com/media/menus/products/grafana-menu-icon-metrics.svg,\"foo,bar,baz,a longer one,bim\",https://grafana.com/products/cloud/,\"Nullam in pulvinar justo. Nunc dictum arcu ac pellentesque bibendum. Sed in erat turpis. Vestibulum eu orci ac ligula lobortis tempus.\"", "datasource": { "type": "grafana-testdata-datasource" }, @@ -389,6 +480,30 @@ "mode": "outerTabular" } }, + { + "id": "calculateField", + "options": { + "alias": "Action", + "binary": { + "left": { + "matcher": { + "id": "byName", + "options": "Image" + } + }, + "right": { + "matcher": { + "id": "byName", + "options": "$Filters" + } + } + }, + "mode": "index", + "reduce": { + "reducer": "sum" + } + } + }, { "id": "organize", "options": { @@ -397,14 +512,16 @@ }, "includeByName": {}, "indexByName": { - "A": 9, - "Data Link": 8, + "A": 11, + "Action": 7, + "Data Link": 10, "Image": 5, + "Image w/ Link": 6, "Info": 1, - "Long Text": 7, + "Long Text": 9, "Max A": 3, "Min A": 2, - "Pills": 6, + "Pills": 8, "State A": 4, "Time A": 0 }, @@ -426,17 +543,1082 @@ "type": "table" }, { - "id": 2, - "type": "table", - "title": "Empty Table Panel", - "gridPos": { - "x": 0, - "y": 0, - "h": 6, - "w": 24 + "datasource": { + "type": "grafana-testdata-datasource" }, "fieldConfig": { "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "color-background" + }, + "filterable": true, + "inspect": true, + "wrapHeaderText": false + }, + "fieldMinMax": true, + "links": [], + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 12, + "w": 8, + "x": 16, + "y": 0 + }, + "id": 4, + "maxDataPoints": 100, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "enablePagination": false, + "fields": "", + "reducer": ["max"], + "show": false + }, + "showHeader": true, + "sortBy": [ + { + "desc": true, + "displayName": "State" + } + ] + }, + "pluginVersion": "12.2.0-pre", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "A", + "scenarioId": "random_walk_table" + } + ], + "title": "Nested tables", + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "A": false + }, + "includeByName": {}, + "indexByName": { + "A": 5, + "Info": 2, + "Max": 4, + "Min": 3, + "State": 1, + "Time": 0 + }, + "renameByName": {} + } + }, + { + "id": "groupToNestedTable", + "options": { + "fields": { + "State": { + "aggregations": [], + "operation": "groupby" + } + }, + "showSubframeHeaders": true + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false, + "minWidth": 50, + "wrapHeaderText": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 3 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "/\\b(bg|color)\\b/" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "options": { + "a": { + "color": "dark-red", + "index": 0 + }, + "b": { + "color": "super-light-orange", + "index": 1 + }, + "c": { + "color": "green", + "index": 2 + }, + "d": { + "color": "super-light-blue", + "index": 3 + } + }, + "type": "value" + } + ] + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/\\bcolor\\b/" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "type": "color-text" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/\\bbg\\b/" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "type": "color-background" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/\\blink\\b/" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "targetBlank": true, + "title": "goto foo", + "url": "https://foo" + } + ] + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/\\blinks\\b/" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "targetBlank": true, + "title": "goto foo", + "url": "https://foo" + }, + { + "targetBlank": true, + "title": "goto bar", + "url": "https://bar" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "links-cellType" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "type": "data-links" + } + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 24, + "x": 0, + "y": 12 + }, + "id": 2, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": ["sum"], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "12.2.0-pre", + "targets": [ + { + "csvContent": "auto,link,links,bg,bg-link,bg-links,color,color-link,color-links,bg-row,bg-row-link,bg-row-links,color-row,color-row-link,color-row-links,links-cellType\na,a,a,a,a,a,a,a,a,1,1,1,1,1,1,1\nb,b,b,b,b,b,b,b,b,2,2,2,2,2,2,2\nc,c,c,c,c,c,c,c,c,3,3,3,3,3,3,3\nd,d,d,d,d,d,d,d,d,4,4,4,4,4,4,4", + "datasource": { + "type": "grafana-testdata-datasource" + }, + "refId": "A", + "scenarioId": "csv_content" + } + ], + "title": "Colors and Links", + "type": "table" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false, + "wrapHeaderText": false + }, + "fieldMinMax": true, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "A" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "applyToRow": true, + "mode": "basic", + "type": "color-background" + } + }, + { + "id": "color", + "value": { + "mode": "continuous-greens" + } + }, + { + "id": "custom.width", + "value": 50 + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/(Min|Max)/" + }, + "properties": [ + { + "id": "custom.hidden", + "value": true + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Info" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "targetBlank": true, + "title": "Google this term", + "url": "https://google.com/search?q=${__value.raw}" + } + ] + }, + { + "id": "custom.width", + "value": 79 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Time" + }, + "properties": [ + { + "id": "custom.width", + "value": 156 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "State" + }, + "properties": [ + { + "id": "custom.width", + "value": 99 + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 4, + "x": 0, + "y": 19 + }, + "id": 6, + "maxDataPoints": 10, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": ["sum"], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "12.2.0-pre", + "targets": [ + { + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"name\": \"A\",\n \"fields\": [\n {\n \"name\": \"Time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Min\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Max\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Info\",\n \"type\": \"string\",\n \"typeInfo\": {\n \"frame\": \"string\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"State\",\n \"type\": \"enum\",\n \"typeInfo\": {\n \"frame\": \"enum\"\n },\n \"config\": {\n \"type\": {\n \"enum\": {\n \"text\": [\n \"Unknown\",\n \"Up\",\n \"Down\"\n ]\n }\n }\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1754578636810,\n 1754580436810,\n 1754582236810,\n 1754584036810,\n 1754585836810,\n 1754587636810,\n 1754589436810,\n 1754591236810,\n 1754593036810,\n 1754594836810\n ],\n [\n 68.67815082171406,\n 68.21038738705283,\n 67.85748579518524,\n 67.48088827530566,\n 67.36362085363314,\n 67.75399477824531,\n 67.9006935145112,\n 68.2893033558627,\n 68.20547872017494,\n 67.96251158324088\n ],\n [\n 67.52655915076163,\n 68.03367017865995,\n 65.44308825471086,\n 66.94142900198796,\n 66.33531057489242,\n 66.97007154625013,\n 67.88134129373145,\n 65.92102089546943,\n 65.7896781452792,\n 67.77680599320166\n ],\n [\n 68.78026730954913,\n 68.46495903270079,\n 68.29118217530531,\n 67.90561937677037,\n 68.05316512901861,\n 70.26033810424696,\n 68.05691975265877,\n 68.76874724839202,\n 70.02772232081647,\n 70.38628221361253\n ],\n [\n \"up\",\n \"down fast\",\n \"down\",\n \"down\",\n \"down\",\n \"up\",\n \"up\",\n \"up\",\n \"down\",\n \"down\"\n ],\n [\n 1,\n 2,\n 2,\n 2,\n 2,\n 1,\n 1,\n 1,\n 2,\n 2\n ]\n ]\n }\n }\n]", + "refId": "A", + "scenarioId": "raw_frame" + } + ], + "title": "Apply to row - basic", + "type": "table" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false, + "wrapHeaderText": false + }, + "fieldMinMax": true, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "A" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "applyToRow": true, + "type": "color-background" + } + }, + { + "id": "color", + "value": { + "mode": "continuous-blues" + } + }, + { + "id": "custom.width", + "value": 50 + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/(Min|Max)/" + }, + "properties": [ + { + "id": "custom.hidden", + "value": true + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Info" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "targetBlank": true, + "title": "Google this term", + "url": "https://google.com/search?q=${__value.raw}" + } + ] + }, + { + "id": "custom.width", + "value": 59 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Time" + }, + "properties": [ + { + "id": "custom.width", + "value": 161 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "State" + }, + "properties": [ + { + "id": "custom.width", + "value": 114 + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 4, + "x": 4, + "y": 19 + }, + "id": 5, + "maxDataPoints": 10, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": ["sum"], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "12.2.0-pre", + "targets": [ + { + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"name\": \"A\",\n \"fields\": [\n {\n \"name\": \"Time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Min\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Max\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Info\",\n \"type\": \"string\",\n \"typeInfo\": {\n \"frame\": \"string\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"State\",\n \"type\": \"enum\",\n \"typeInfo\": {\n \"frame\": \"enum\"\n },\n \"config\": {\n \"type\": {\n \"enum\": {\n \"text\": [\n \"Unknown\",\n \"Up\",\n \"Down\"\n ]\n }\n }\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1754579008785,\n 1754580808785,\n 1754582608785,\n 1754584408785,\n 1754586208785,\n 1754588008785,\n 1754589808785,\n 1754591608785,\n 1754593408785,\n 1754595208785\n ],\n [\n 89.82213040422468,\n 89.53763263564208,\n 90.03103064454615,\n 90.51535567776907,\n 90.88960652895187,\n 91.30921636258488,\n 91.12427953645643,\n 91.1507153149385,\n 90.81021008780039,\n 91.11327042147582\n ],\n [\n 87.33191559754641,\n 88.46410173314496,\n 88.06920794235972,\n 88.98473401700574,\n 90.60349141857057,\n 90.5858441719162,\n 90.42154517148411,\n 90.32413455176213,\n 90.72055904641739,\n 88.74774424339796\n ],\n [\n 90.58845140604446,\n 90.08173133413374,\n 90.58230197880795,\n 92.55531809442493,\n 93.09165646181742,\n 93.38354884838586,\n 92.68544239576967,\n 92.15770413350477,\n 92.77335012158814,\n 92.52115072193222\n ],\n [\n \"down\",\n \"down\",\n \"up fast\",\n \"up fast\",\n \"up\",\n \"up fast\",\n \"down\",\n \"up\",\n \"down\",\n \"up\"\n ],\n [\n 2,\n 2,\n 1,\n 1,\n 1,\n 1,\n 2,\n 1,\n 2,\n 1\n ]\n ]\n }\n }\n]", + "refId": "A", + "scenarioId": "raw_frame" + } + ], + "title": "Apply to row - gradient", + "type": "table" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false, + "wrapHeaderText": false + }, + "fieldMinMax": true, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "A" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "applyToRow": true, + "type": "color-background" + } + }, + { + "id": "color", + "value": { + "mode": "thresholds" + } + }, + { + "id": "custom.width", + "value": 50 + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/(Min|Max)/" + }, + "properties": [ + { + "id": "custom.hidden", + "value": true + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Info" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "targetBlank": true, + "title": "Google this term", + "url": "https://google.com/search?q=${__value.raw}" + } + ] + }, + { + "id": "custom.width", + "value": 79 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Time" + }, + "properties": [ + { + "id": "custom.width", + "value": 158 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "State" + }, + "properties": [ + { + "id": "custom.width", + "value": 97 + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 4, + "x": 8, + "y": 19 + }, + "id": 7, + "maxDataPoints": 10, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": ["sum"], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "12.2.0-pre", + "targets": [ + { + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"name\": \"A\",\n \"fields\": [\n {\n \"name\": \"Time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Min\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Max\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Info\",\n \"type\": \"string\",\n \"typeInfo\": {\n \"frame\": \"string\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"State\",\n \"type\": \"enum\",\n \"typeInfo\": {\n \"frame\": \"enum\"\n },\n \"config\": {\n \"type\": {\n \"enum\": {\n \"text\": [\n \"Unknown\",\n \"Up\",\n \"Down\"\n ]\n }\n }\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1754579078813,\n 1754580878813,\n 1754582678813,\n 1754584478813,\n 1754586278813,\n 1754588078813,\n 1754589878813,\n 1754591678813,\n 1754593478813,\n 1754595278813\n ],\n [\n 77.30289281564126,\n 87.14357945690202,\n 76.67466266903213,\n 76.27969352662608,\n 95.80292639037513,\n 75.68396049965999,\n 75.76117912054544,\n 76.05073308839697,\n 75.93744737071192,\n 75.82930696129104\n ],\n [\n 76.58900057075734,\n 76.01493865596112,\n 75.25501274661029,\n 75.52871130918913,\n 73.53324017648568,\n 74.74670600818675,\n 73.4627470190636,\n 75.38455781635166,\n 74.04318166296315,\n 74.79620005730261\n ],\n [\n 77.58417866514938,\n 77.9390885538468,\n 78.37258492198521,\n 76.82242506518101,\n 75.81423643776408,\n 75.86349731305505,\n 76.79031910137064,\n 77.71934637974913,\n 76.11981207380533,\n 77.29746416618009\n ],\n [\n \"down fast\",\n \"down\",\n \"down fast\",\n \"down\",\n \"down fast\",\n \"down\",\n \"up\",\n \"up\",\n \"down\",\n \"down\"\n ],\n [\n 2,\n 2,\n 2,\n 2,\n 2,\n 2,\n 1,\n 1,\n 2,\n 2\n ]\n ]\n }\n }\n]", + "refId": "A", + "scenarioId": "raw_frame" + } + ], + "title": "Apply to row - thresholds", + "type": "table" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false, + "wrapHeaderText": false + }, + "fieldMinMax": true, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "A" + }, + "properties": [ + { + "id": "custom.width", + "value": 50 + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/(Min|Max)/" + }, + "properties": [ + { + "id": "custom.hidden", + "value": true + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Info" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "targetBlank": true, + "title": "Google this term", + "url": "https://google.com/search?q=${__value.raw}" + } + ] + }, + { + "id": "custom.width", + "value": 79 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Time" + }, + "properties": [ + { + "id": "custom.width", + "value": 158 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "State" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "applyToRow": true, + "type": "color-background" + } + }, + { + "id": "custom.width", + "value": 97 + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 4, + "x": 12, + "y": 19 + }, + "id": 8, + "maxDataPoints": 10, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": ["sum"], + "show": false + }, + "showHeader": true, + "sortBy": [ + { + "desc": false, + "displayName": "A" + } + ] + }, + "pluginVersion": "12.2.0-pre", + "targets": [ + { + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"name\": \"A\",\n \"fields\": [\n {\n \"name\": \"Time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Min\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Max\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Info\",\n \"type\": \"string\",\n \"typeInfo\": {\n \"frame\": \"string\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"State\",\n \"type\": \"enum\",\n \"typeInfo\": {\n \"frame\": \"enum\"\n },\n \"config\": {\n \"type\": {\n \"enum\": {\n \"text\": [\n \"Unknown\",\n \"Up\",\n \"Down\"\n ]\n }\n }\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1754579078813,\n 1754580878813,\n 1754582678813,\n 1754584478813,\n 1754586278813,\n 1754588078813,\n 1754589878813,\n 1754591678813,\n 1754593478813,\n 1754595278813\n ],\n [\n 77.30289281564126,\n 87.14357945690202,\n 76.67466266903213,\n 76.27969352662608,\n 95.80292639037513,\n 75.68396049965999,\n 75.76117912054544,\n 76.05073308839697,\n 75.93744737071192,\n 75.82930696129104\n ],\n [\n 76.58900057075734,\n 76.01493865596112,\n 75.25501274661029,\n 75.52871130918913,\n 73.53324017648568,\n 74.74670600818675,\n 73.4627470190636,\n 75.38455781635166,\n 74.04318166296315,\n 74.79620005730261\n ],\n [\n 77.58417866514938,\n 77.9390885538468,\n 78.37258492198521,\n 76.82242506518101,\n 75.81423643776408,\n 75.86349731305505,\n 76.79031910137064,\n 77.71934637974913,\n 76.11981207380533,\n 77.29746416618009\n ],\n [\n \"down fast\",\n \"down\",\n \"down fast\",\n \"down\",\n \"down fast\",\n \"down\",\n \"up\",\n \"up\",\n \"down\",\n \"down\"\n ],\n [\n 2,\n 2,\n 2,\n 2,\n 2,\n 2,\n 1,\n 1,\n 2,\n 2\n ]\n ]\n }\n }\n]", + "refId": "A", + "scenarioId": "raw_frame" + } + ], + "title": "Apply to row - enum", + "type": "table" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false, + "wrapHeaderText": false + }, + "fieldMinMax": true, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "A" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "palette-classic" + } + }, + { + "id": "custom.width", + "value": 50 + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/(Min|Max)/" + }, + "properties": [ + { + "id": "custom.hidden", + "value": true + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Info" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "targetBlank": true, + "title": "Google this term", + "url": "https://google.com/search?q=${__value.raw}" + } + ] + }, + { + "id": "custom.width", + "value": 79 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Time" + }, + "properties": [ + { + "id": "custom.width", + "value": 158 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "A" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "options": { + "from": 0, + "result": { + "color": "purple", + "index": 0 + }, + "to": 76.999 + }, + "type": "range" + }, + { + "options": { + "from": 77, + "result": { + "color": "orange", + "index": 1 + } + }, + "type": "range" + } + ] + }, + { + "id": "custom.cellOptions", + "value": { + "applyToRow": true, + "type": "color-background" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "State" + }, + "properties": [ + { + "id": "custom.width", + "value": 97 + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 4, + "x": 16, + "y": 19 + }, + "id": 9, + "maxDataPoints": 10, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": ["sum"], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "12.2.0-pre", + "targets": [ + { + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"name\": \"A\",\n \"fields\": [\n {\n \"name\": \"Time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Min\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Max\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"Info\",\n \"type\": \"string\",\n \"typeInfo\": {\n \"frame\": \"string\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"State\",\n \"type\": \"enum\",\n \"typeInfo\": {\n \"frame\": \"enum\"\n },\n \"config\": {\n \"type\": {\n \"enum\": {\n \"text\": [\n \"Unknown\",\n \"Up\",\n \"Down\"\n ]\n }\n }\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1754579078813,\n 1754580878813,\n 1754582678813,\n 1754584478813,\n 1754586278813,\n 1754588078813,\n 1754589878813,\n 1754591678813,\n 1754593478813,\n 1754595278813\n ],\n [\n 77.30289281564126,\n 87.14357945690202,\n 76.67466266903213,\n 76.27969352662608,\n 95.80292639037513,\n 75.68396049965999,\n 75.76117912054544,\n 76.05073308839697,\n 75.93744737071192,\n 75.82930696129104\n ],\n [\n 76.58900057075734,\n 76.01493865596112,\n 75.25501274661029,\n 75.52871130918913,\n 73.53324017648568,\n 74.74670600818675,\n 73.4627470190636,\n 75.38455781635166,\n 74.04318166296315,\n 74.79620005730261\n ],\n [\n 77.58417866514938,\n 77.9390885538468,\n 78.37258492198521,\n 76.82242506518101,\n 75.81423643776408,\n 75.86349731305505,\n 76.79031910137064,\n 77.71934637974913,\n 76.11981207380533,\n 77.29746416618009\n ],\n [\n \"down fast\",\n \"down\",\n \"down fast\",\n \"down\",\n \"down fast\",\n \"down\",\n \"up\",\n \"up\",\n \"down\",\n \"down\"\n ],\n [\n 2,\n 2,\n 2,\n 2,\n 2,\n 2,\n 1,\n 1,\n 2,\n 2\n ]\n ]\n }\n }\n]", + "refId": "A", + "scenarioId": "raw_frame" + } + ], + "title": "Apply to row - mappings", + "type": "table" + }, + { + "datasource": { + "type": "grafana-testdata-datasource" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, "custom": { "align": "auto", "cellOptions": { @@ -450,42 +1632,45 @@ "mode": "absolute", "steps": [ { - "value": null, - "color": "green" + "color": "green", + "value": 0 }, { - "value": 80, - "color": "red" + "color": "red", + "value": 80 } ] - }, - "color": { - "mode": "thresholds" } }, "overrides": [] }, - "pluginVersion": "12.1.0-pre", - "targets": [ - { - "scenarioId": "csv_content", - "refId": "A", - "csvContent": "a,b,c" - } - ], - "datasource": { - "type": "grafana-testdata-datasource" + "gridPos": { + "h": 7, + "w": 4, + "x": 20, + "y": 19 }, + "id": 3, "options": { - "showHeader": true, "cellHeight": "sm", "footer": { - "show": false, - "reducer": ["sum"], "countRows": false, - "fields": "" + "fields": "", + "reducer": ["sum"], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "12.2.0-pre", + "targets": [ + { + "csvContent": "a,b,c", + "refId": "A", + "scenarioId": "csv_content" } - } + ], + "title": "Empty Table Panel", + "type": "table" } ], "preload": false, @@ -512,5 +1697,5 @@ "timezone": "", "title": "Panel Tests - Table - Kitchen Sink", "uid": "dcb9f5e9-8066-4397-889e-864b99555dbb", - "version": 2 + "version": 9 } diff --git a/devenv/dev-dashboards/panel-table/table_markdown.json b/devenv/dev-dashboards/panel-table/table_markdown.json index 863edd09fed..06f3d7c310c 100644 --- a/devenv/dev-dashboards/panel-table/table_markdown.json +++ b/devenv/dev-dashboards/panel-table/table_markdown.json @@ -93,7 +93,7 @@ "pluginVersion": "12.1.0-pre", "targets": [ { - "csvContent": "id,markdown\n1,\"
\n
Definition list
\n
Is something people use sometimes.
\n
Markdown in HTML
\n
Does *not* work **very** well. Use HTML tags.
\n
\"\n2,\"Three or more...\n\n---\n\nHyphens\n\n***\n\nAsterisks\n\n___\n\nUnderscores\"\n3,\"Here's a line for us to start with.\n\nThis line is separated from the one above by two newlines, so it will be a *separate paragraph*.\n\nThis line is also a separate paragraph, but...\nThis line is only separated by a single newline, so it's a separate line in the *same paragraph*.\"\n4,\"red, green, blue\"\n5,\"\"\n6,\"[Link](https://grafana.com), or HTML link\"\n7,\"1. foo\n1. bar\n - baz\n * bim\n3. bip\"\n8,\"# heading 1\n## heading 2\n### heading 3\n#### heading 4\n##### heading 5\n###### heading 6\"\n9,\"Emphasis, aka italics, with *asterisks* or _underscores_.\n\nStrong emphasis, aka bold, with **asterisks** or __underscores__.\n\nCombined emphasis with **asterisks and _underscores_**.\n\nStrikethrough uses two tildes. ~~Scratch this.~~\n\nunderline does require an HTML element tho.\"\n10,\"```javascript\nvar s = 'JavaScript syntax highlighting';\nalert(s);\n```\"\n11,\"\n| Month | Savings |\n| -------- | ------- |\n| I heard | $250 |\n| you like | $80 |\n| tables | $365 |\"\n", + "csvContent": "id,markdown\n1,\"
\n
Definition list
\n
Is something people use sometimes.
\n
Markdown in HTML
\n
Does *not* work **very** well. Use HTML tags.
\n
\"\n2,\"Three or more...\n\n---\n\nHyphens\n\n***\n\nAsterisks\n\n___\n\nUnderscores\"\n3,\"Here's a line for us to start with.\n\nThis line is separated from the one above by two newlines, so it will be a *separate paragraph*.\n\nThis line is also a separate paragraph, but...\nThis line is only separated by a single newline, so it's a separate line in the *same paragraph*.\"\n4,\"red, green, blue\"\n5,\"\"\n6,\"[Link](https://grafana.com), or HTML link\"\n7,\"1. foo\n1. bar\n - baz\n * bim\n3. bip\"\n8,\"# heading 1\n## heading 2\n### heading 3\n#### heading 4\n##### heading 5\n###### heading 6\"\n9,\"Emphasis, aka italics, with *asterisks* or _underscores_.\n\nStrong emphasis, aka bold, with **asterisks** or __underscores__.\n\nCombined emphasis with **asterisks and _underscores_**.\n\nStrikethrough uses two tildes. ~~Scratch this.~~\n\nunderline does require an HTML element tho.\"\n10,\"```javascript\nvar s = 'JavaScript syntax highlighting';\nalert(s);\n```\"\n11,\"\n| Month | Savings |\n| -------- | ------- |\n| I heard | $250 |\n| you like | $80 |\n| tables | $365 |\"\n", "datasource": { "type": "grafana-testdata-datasource", "uid": "gdev-testdata" diff --git a/devenv/docker/blocks/prometheus_high_card/go.mod b/devenv/docker/blocks/prometheus_high_card/go.mod index 53cd8058c27..a6d2b78e072 100644 --- a/devenv/docker/blocks/prometheus_high_card/go.mod +++ b/devenv/docker/blocks/prometheus_high_card/go.mod @@ -1,6 +1,6 @@ module high-card -go 1.24.5 +go 1.24.6 require github.com/prometheus/client_golang v1.22.0 diff --git a/devenv/docker/blocks/prometheus_utf8/go.mod b/devenv/docker/blocks/prometheus_utf8/go.mod index 77bc82d2b2d..7f7bb1f3b44 100644 --- a/devenv/docker/blocks/prometheus_utf8/go.mod +++ b/devenv/docker/blocks/prometheus_utf8/go.mod @@ -1,6 +1,6 @@ module utf8-support -go 1.24.5 +go 1.24.6 require ( github.com/prometheus/client_golang v1.22.0 diff --git a/devenv/docker/blocks/self-instrumentation/agent.flow b/devenv/docker/blocks/self-instrumentation/agent.flow deleted file mode 100644 index d1deb12b5aa..00000000000 --- a/devenv/docker/blocks/self-instrumentation/agent.flow +++ /dev/null @@ -1,87 +0,0 @@ -loki.write "default" { - endpoint { - url = "http://loki:3100/loki/api/v1/push" - } - external_labels = {} -} - -logging { - level = "debug" - format = "json" - write_to = [loki.write.default.receiver] -} - -tracing { - sampling_fraction = 1 - write_to = [otelcol.exporter.otlp.tempo.input] -} - -otelcol.exporter.otlp "tempo" { - client { - endpoint = "tempo:4317" - tls { - insecure = true - insecure_skip_verify = true - } - } -} - -faro.receiver "frontend" { - server { - listen_address = "0.0.0.0" - listen_port = 12347 - cors_allowed_origins = ["*"] - } - - output { - logs = [loki.write.default.receiver] - traces = [otelcol.exporter.otlp.tempo.input] - } -} - -pyroscope.write "default" { - endpoint { - url = "http://pyroscope:4040" - } -} - -pyroscope.scrape "default" { - targets = [ - {"__address__" = "host.docker.internal:6000", "service_name"="grafana"}, - ] - forward_to = [pyroscope.write.default.receiver] - - profiling_config { - profile.process_cpu { - enabled = true - } - - profile.godeltaprof_memory { - enabled = true - } - - profile.memory { // disable memory, use godeltaprof_memory instead - enabled = false - } - - profile.godeltaprof_mutex { - enabled = true - } - - profile.mutex { // disable mutex, use godeltaprof_mutex instead - enabled = false - } - - profile.godeltaprof_block { - enabled = true - } - - profile.block { // disable block, use godeltaprof_block instead - enabled = false - } - - profile.goroutine { - enabled = true - } - } -} diff --git a/devenv/docker/blocks/self-instrumentation/config.alloy b/devenv/docker/blocks/self-instrumentation/config.alloy new file mode 100644 index 00000000000..bf480ddf4b8 --- /dev/null +++ b/devenv/docker/blocks/self-instrumentation/config.alloy @@ -0,0 +1,100 @@ +loki.write "default" { + endpoint { + url = "http://loki:3100/loki/api/v1/push" + } +} + +// Grafana log file discovery +local.file_match "grafana_logs" { + path_targets = [ + { + __path__ = "/var/log/grafana/*.log", + }, + ] +} + +// Grafana log source +loki.source.file "grafana" { + targets = local.file_match.grafana_logs.targets + forward_to = [loki.relabel.grafana.receiver] +} + +// Add labels to Grafana logs +loki.relabel "grafana" { + rule { + action = "replace" + target_label = "job" + replacement = "grafana" + } + + rule { + action = "replace" + target_label = "service" + replacement = "grafana" + } + + forward_to = [loki.write.default.receiver] +} + +logging { + level = "debug" + format = "json" + // Forward internal logs to the local Loki instance. + write_to = [loki.write.default.receiver] +} + +tracing { + sampling_fraction = 1.0 + + // Forward internal spans to the local Tempo instance. + write_to = [otelcol.exporter.otlp.tempo.input] +} + +otelcol.receiver.otlp "otlp_receiver" { + grpc { + endpoint = "0.0.0.0:4317" + } + + output { + traces = [otelcol.exporter.otlp.tempo.input] + } +} + +otelcol.exporter.otlp "tempo" { + client { + endpoint = "tempo:4317" + tls { + insecure = true + } + } +} + +pyroscope.write "default" { + // Send profiles to a locally running Pyroscope instance. + endpoint { + url = "http://pyroscope:4040" + } +} + + +pyroscope.scrape "default" { + targets = [ + {"__address__" = "pyroscope:4040", "service_name"="pyroscope"}, + {"__address__" = "alloy:12345", "service_name"="alloy"}, + {"__address__" = "host.docker.internal:6000", "service_name"="grafana"}, + ] + forward_to = [pyroscope.write.default.receiver] +} + +faro.receiver "frontend" { + server { + listen_address = "0.0.0.0" + listen_port = 12347 + cors_allowed_origins = ["*"] + } + + output { + logs = [loki.write.default.receiver] + traces = [otelcol.exporter.otlp.tempo.input] + } +} diff --git a/devenv/docker/blocks/self-instrumentation/docker-compose.yaml b/devenv/docker/blocks/self-instrumentation/docker-compose.yaml index c7583cd62d3..fb7f7b62090 100644 --- a/devenv/docker/blocks/self-instrumentation/docker-compose.yaml +++ b/devenv/docker/blocks/self-instrumentation/docker-compose.yaml @@ -1,56 +1,54 @@ prometheus: - image: prom/prometheus:v2.45.0 + image: prom/prometheus:v3.5.0 ports: - - "9090:9090" + - '9090:9090' extra_hosts: - - "host.docker.internal:host-gateway" + - 'host.docker.internal:host-gateway' command: > - --enable-feature=remote-write-receiver --enable-feature=exemplar-storage --enable-feature=native-histograms + --web.enable-remote-write-receiver --config.file=/etc/prometheus/prometheus.yml --storage.tsdb.path=/prometheus volumes: - ./docker/blocks/self-instrumentation/prometheus.yaml:/etc/prometheus/prometheus.yml loki: - image: grafana/loki:2.8.2 + image: grafana/loki:3.5.3 ports: - - "3100:3100" + - '3100:3100' command: -config.file=/etc/loki/local-config.yaml - promtail: - image: grafana/promtail:2.8.2 - command: -config.file=/etc/promtail/promtail.yaml - volumes: - - ./docker/blocks/self-instrumentation/promtail.yaml:/etc/promtail/promtail.yaml - - ../data/log:/var/log/grafana tempo: - image: grafana/tempo:2.1.1 + image: grafana/tempo:2.8.1 command: -config.file=/etc/tempo.yaml volumes: - ./docker/blocks/self-instrumentation/tempo.yaml:/etc/tempo.yaml ports: - - "14268:14268" - - "3200:3200" - - "4317:4317" + - '14268:14268' + - '3200:3200' + - '4317:4317' pyroscope: - image: "grafana/pyroscope:latest" + image: 'grafana/pyroscope:1.14.0' ports: - - "4040:4040" + - '4040:4040' extra_hosts: - - "host.docker.internal:host-gateway" - agent: - image: grafana/agent:v0.37.0 - environment: - - AGENT_MODE=flow - entrypoint: - - /bin/grafana-agent + - 'host.docker.internal:host-gateway' + alloy: + image: grafana/alloy:v1.10.0 + command: - run + - /etc/alloy/config.alloy + - --storage.path=/var/lib/alloy/data - --server.http.listen-addr=0.0.0.0:12345 - - /etc/agent/config.river - volumes: - - ./docker/blocks/self-instrumentation/agent.flow:/etc/agent/config.river + - --stability.level=experimental # Enable all functionality ports: - - "12345:12345" - - "12347:12347" + - '12345:12345' + volumes: + - ./docker/blocks/self-instrumentation/config.alloy:/etc/alloy/config.alloy + - ../data/log:/var/log/grafana:ro # Mount Grafana logs directory + depends_on: + - tempo + - loki + - prometheus + - pyroscope extra_hosts: - - "host.docker.internal:host-gateway" + - 'host.docker.internal:host-gateway' diff --git a/devenv/docker/blocks/self-instrumentation/prometheus.yaml b/devenv/docker/blocks/self-instrumentation/prometheus.yaml index 5533f0c0fd6..76db0c65b89 100644 --- a/devenv/docker/blocks/self-instrumentation/prometheus.yaml +++ b/devenv/docker/blocks/self-instrumentation/prometheus.yaml @@ -1,10 +1,10 @@ global: - scrape_interval: 15s + scrape_interval: 15s evaluation_interval: 15s scrape_configs: - job_name: grafana - scrape_classic_histograms: true + always_scrape_classic_histograms: true static_configs: - targets: - - host.docker.internal:3000 + - host.docker.internal:3000 diff --git a/devenv/docker/blocks/self-instrumentation/promtail.yaml b/devenv/docker/blocks/self-instrumentation/promtail.yaml deleted file mode 100644 index eb7787ef789..00000000000 --- a/devenv/docker/blocks/self-instrumentation/promtail.yaml +++ /dev/null @@ -1,18 +0,0 @@ -server: - http_listen_port: 9080 - grpc_listen_port: 0 - -positions: - filename: /tmp/positions.yaml - -clients: - - url: http://loki:3100/loki/api/v1/push - -scrape_configs: -- job_name: grafana - static_configs: - - targets: - - localhost - labels: - job: grafana - __path__: /var/log/grafana/*log diff --git a/devenv/docker/blocks/self-instrumentation/readme.md b/devenv/docker/blocks/self-instrumentation/readme.md index 07fc5856cf6..c7fe04139b4 100644 --- a/devenv/docker/blocks/self-instrumentation/readme.md +++ b/devenv/docker/blocks/self-instrumentation/readme.md @@ -18,8 +18,9 @@ format = json enabled = true custom_endpoint=http://localhost:12347/collect -[tracing.opentelemetry.jaeger] -address = http://localhost:14268/api/traces +[tracing.opentelemetry.otlp] +address = localhost:4317 +insecure = true ``` To collect profiles with pyroscope, you need to run Grafana with the following env vars: @@ -29,3 +30,19 @@ export GF_DIAGNOSTICS_PROFILING_ENABLED=true export GF_DIAGNOSTICS_PROFILING_ADDR=0.0.0.0 make run ``` + +To enable profiling in your plugin add the following to your `custom.ini`: + +```ini +[plugin.grafana-bigquery-datasource] +profiling_enabled = true +profiling_port = 6161 +profiling_block_rate = 1 +profiling_mutex_rate = 1 +``` + +and the following to your `config.alloy` in the `pyroscope.scrape.targets` section: + +```yaml + {"__address__" = "host.docker.internal:6161", "service_name"="grafana-bigquery-datasource"}, +``` diff --git a/devenv/docker/blocks/self-instrumentation/tempo.yaml b/devenv/docker/blocks/self-instrumentation/tempo.yaml index acc5c1b8ea6..c1eae73fa74 100644 --- a/devenv/docker/blocks/self-instrumentation/tempo.yaml +++ b/devenv/docker/blocks/self-instrumentation/tempo.yaml @@ -1,24 +1,31 @@ +stream_over_http_enabled: true server: http_listen_port: 3200 + log_level: info + +query_frontend: + search: + duration_slo: 5s + throughput_bytes_slo: 1.073741824e+09 + metadata_slo: + duration_slo: 5s + throughput_bytes_slo: 1.073741824e+09 + trace_by_id: + duration_slo: 5s distributor: - receivers: # this configuration will listen on all ports and protocols that tempo is capable of. - jaeger: # the receives all come from the OpenTelemetry collector. more configuration information can - protocols: # be found there: https://github.com/open-telemetry/opentelemetry-collector/tree/main/receiver - thrift_http: # - grpc: # for a production deployment you should only enable the receivers you need! - thrift_binary: - thrift_compact: - zipkin: + receivers: otlp: protocols: - http: grpc: - opencensus: + endpoint: 'tempo:4317' + +ingester: + max_block_duration: 5m # cut the headblock when this much time passes. this is being set for demo purposes and should probably be left alone normally compactor: compaction: - block_retention: 1h + block_retention: 1h # overall Tempo trace retention. set for demo purposes metrics_generator: registry: @@ -26,19 +33,23 @@ metrics_generator: source: tempo cluster: docker-compose storage: - path: /tmp/tempo/generator/wal + path: /var/tempo/generator/wal remote_write: - url: http://prometheus:9090/api/v1/write send_exemplars: true + traces_storage: + path: /var/tempo/generator/traces storage: trace: - backend: local # backend configuration to use + backend: local # backend configuration to use wal: - path: /tmp/tempo/wal # where to store the wal locally + path: /var/tempo/wal # where to store the wal locally local: - path: /tmp/tempo/blocks + path: /var/tempo/blocks overrides: - metrics_generator_processors: [service-graphs, span-metrics] - \ No newline at end of file + defaults: + metrics_generator: + processors: [service-graphs, span-metrics, local-blocks] # enables metrics generator + generate_native_histograms: both diff --git a/devenv/docker/blocks/stateful_webhook/Dockerfile b/devenv/docker/blocks/stateful_webhook/Dockerfile index e6527450805..258eec3d891 100644 --- a/devenv/docker/blocks/stateful_webhook/Dockerfile +++ b/devenv/docker/blocks/stateful_webhook/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.24.5 +FROM golang:1.24.6 ADD main.go /go/src/webhook/main.go diff --git a/devenv/frontend-service/Tiltfile b/devenv/frontend-service/Tiltfile index 885d8eddaf3..a753e475e80 100644 --- a/devenv/frontend-service/Tiltfile +++ b/devenv/frontend-service/Tiltfile @@ -69,6 +69,8 @@ dc_resource("frontend-service", ) dc_resource("alloy", labels=["observability"]) dc_resource("loki", labels=["observability"]) +dc_resource("tempo-init", labels=["observability"]) +dc_resource("tempo", labels=["observability"]) # paths in tilt files are confusing.... # - if tilt is dealing the the path, it is relative to the Tiltfile @@ -111,10 +113,10 @@ docker_build('grafana-proxy', # Path relative to the docker context (this folder) only=[ - "./nginx.conf", + "./configs/nginx.conf", ], live_update = [ - sync('./nginx.conf', '/etc/nginx/conf.d/default.conf'), + sync('./configs/nginx.conf', '/etc/nginx/conf.d/default.conf'), restart_container() ] ) diff --git a/devenv/frontend-service/configs/alloy/_databases.alloy b/devenv/frontend-service/configs/alloy/_databases.alloy index eb1766c1346..9e050d70fd9 100644 --- a/devenv/frontend-service/configs/alloy/_databases.alloy +++ b/devenv/frontend-service/configs/alloy/_databases.alloy @@ -1,3 +1,5 @@ +// +// Logs loki.relabel "publish_logs" { rule { action = "replace" @@ -13,3 +15,31 @@ loki.write "tilt_loki" { url = "http://loki:3100/loki/api/v1/push" } } + +// +// Traces +otelcol.processor.transform "publish_traces" { + error_mode = "ignore" + + trace_statements { + context = "resource" + statements = [ + string.format(`set(attributes["instance"], "%s")`, constants.hostname), + ] + } + + output { + traces = [otelcol.exporter.otlp.tilt_tempo.input] + } +} + +otelcol.exporter.otlp "tilt_tempo" { + client { + endpoint = "tempo:4317" + + tls { + insecure = true + insecure_skip_verify = true + } + } +} diff --git a/devenv/frontend-service/configs/alloy/otel-receiver.alloy b/devenv/frontend-service/configs/alloy/otel-receiver.alloy new file mode 100644 index 00000000000..df6b7d53178 --- /dev/null +++ b/devenv/frontend-service/configs/alloy/otel-receiver.alloy @@ -0,0 +1,13 @@ +// +// Traces +otelcol.receiver.otlp "grafana_dev" { + output { + metrics = [] + logs = [] + traces = [otelcol.processor.transform.publish_traces.input] + } + + grpc { + endpoint = "0.0.0.0:4317" + } +} diff --git a/devenv/frontend-service/nginx.conf b/devenv/frontend-service/configs/nginx.conf similarity index 83% rename from devenv/frontend-service/nginx.conf rename to devenv/frontend-service/configs/nginx.conf index 5e2ad228eb3..0636ff0a231 100644 --- a/devenv/frontend-service/nginx.conf +++ b/devenv/frontend-service/configs/nginx.conf @@ -1,3 +1,9 @@ +otel_exporter { + endpoint alloy:4317; +} + +otel_service_name "proxy"; + ### # Instance ### @@ -14,10 +20,19 @@ map "$request_method:$cookie_fs_unavailable" $reject_login { "POST:1" 1; } +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + server { listen 80; server_name _; + otel_trace on; + otel_trace_context inject; + otel_span_name "$request_method Request"; + location ~ ^/-/down/?$ { add_header Set-Cookie "fs_unavailable=true; Max-Age=60; Path=/; HttpOnly" always; return 302 $scheme://$http_host/; @@ -58,9 +73,11 @@ server { } # API calls go to the backend - # Cheat with app plugin paths and route them to the backend. These should come from - # the Plugin CDN location ~ ^/(api|apis|avatar|bootdata|render|logout|public\/plugins) { + # Add debug headers to the response + add_header Nginx-Trace-Id $otel_trace_id always; + add_header Nginx-Route "backend" always; + if ($cookie_fs_unavailable) { add_header Content-Type application/json always; return 503 '{"code":"Loading", "message": "Soon!"}'; @@ -71,12 +88,18 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Upgrade $http_upgrade; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } # Everything else to the frontend location / { + # Add debug headers to the response + add_header Nginx-Trace-Id $otel_trace_id always; + add_header Nginx-Route "frontend" always; + proxy_pass http://frontend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/devenv/frontend-service/configs/tempo.yaml b/devenv/frontend-service/configs/tempo.yaml new file mode 100644 index 00000000000..58457e755c8 --- /dev/null +++ b/devenv/frontend-service/configs/tempo.yaml @@ -0,0 +1,48 @@ +stream_over_http_enabled: true + +server: + http_listen_port: 3200 + log_level: info + +distributor: + usage: + cost_attribution: + enabled: true + receivers: + otlp: + protocols: + grpc: + endpoint: 'tempo:4317' + http: + endpoint: 'tempo:4318' + +storage: + trace: + backend: local # Specify local storage explicitly + + # The Write-Ahead Log (WAL) is a logging mechanism used to ensure data integrity. In the context of Tempo, + # the WAL is used to record changes to trace data before they are committed to the main storage. + #This helps in recovering data in case of a failure. The WAL stores these changes in a sequential log file, + #which can be replayed to reconstruct the state of the system. + wal: + path: /var/lib/tempo/wal + # indicates that the storage backend is local, meaning the data will be stored on the local filesystem. + local: + path: /var/lib/tempo/blocks # Path for block storage + +metrics_generator: + registry: + external_labels: + source: tempo + cluster: docker-compose + storage: + path: /var/lib/tempo/generator/wal + remote_write: + - url: http://prometheus:9090/api/v1/write + send_exemplars: true + traces_storage: + path: /var/lib/tempo/generator/traces + processor: + local_blocks: + filter_server_spans: false + flush_to_storage: true diff --git a/devenv/frontend-service/docker-compose.yaml b/devenv/frontend-service/docker-compose.yaml index 9e6939d6e97..3a238ba890d 100644 --- a/devenv/frontend-service/docker-compose.yaml +++ b/devenv/frontend-service/docker-compose.yaml @@ -30,15 +30,18 @@ services: - ./provisioning/dashboards:/grafana/conf/provisioning/dashboards - ../dev-dashboards:/grafana/conf/dev-dashboards environment: + OTEL_BSP_SCHEDULE_DELAY: 500 GF_DEFAULT_APP_MODE: development GF_PANELS_ENABLE_ALPHA: true GF_SERVER_CDN_URL: http://localhost:3010 GF_FEATURE_TOGGLES_ENABLE: multiTenantFrontend - GF_DATABASE_TYPE: postgres - GF_DATABASE_HOST: postgres - GF_DATABASE_NAME: grafana - GF_DATABASE_USER: grafana - GF_DATABASE_PASSWORD: grafana + GF_DATABASE_URL: postgres://grafana:grafana@postgres:5432/grafana + GF_SERVER_ROUTER_LOGGING: true + GF_LOG_LEVEL: info + GF_AUTH_LOGIN_COOKIE_NAME: grafana_fs_dev_login # set a custom cookie name to not conflict with other instances running on localhost + OTEL_SERVICE_NAME: grafana-api + GF_TRACING_OPENTELEMETRY_OTLP_ADDRESS: 'alloy:4317' + GF_TRACING_OPENTELEMETRY_OTLP_PROPAGATION: jaeger,w3c ports: - '3011:3000' labels: @@ -55,10 +58,16 @@ services: labels: - 'alloy.logs=true' environment: + OTEL_BSP_SCHEDULE_DELAY: 500 GF_DEFAULT_APP_MODE: development GF_DEFAULT_TARGET: frontend-server GF_SECURITY_CONTENT_SECURITY_POLICY: false GF_SERVER_CDN_URL: http://localhost:3010 + GF_SERVER_ROUTER_LOGGING: true + GF_LOG_LEVEL: info + OTEL_SERVICE_NAME: frontend-service + GF_TRACING_OPENTELEMETRY_OTLP_ADDRESS: 'alloy:4317' + GF_TRACING_OPENTELEMETRY_OTLP_PROPAGATION: jaeger,w3c postgres: image: postgres:16.1-alpine3.19 @@ -95,8 +104,30 @@ services: labels: - 'alloy.logs=true' + tempo-init: + image: grafana/tempo + user: root + entrypoint: + - 'chown' + - '10001:10001' + - '/var/tempo' + volumes: + - tempo-data:/var/tempo + + tempo: + image: grafana/tempo + volumes: + - tempo-data:/var/lib/tempo + - ./configs/tempo.yaml:/etc/tempo/tempo.yaml + command: ['-config.file=/etc/tempo/tempo.yaml'] + + depends_on: + tempo-init: + condition: service_completed_successfully + volumes: backend-data: postgres-data: alloy-data: loki-data: + tempo-data: diff --git a/devenv/frontend-service/grafana-fs-dev.dockerfile b/devenv/frontend-service/grafana-fs-dev.dockerfile index 61f5389ffb9..b0d7d7ffd01 100644 --- a/devenv/frontend-service/grafana-fs-dev.dockerfile +++ b/devenv/frontend-service/grafana-fs-dev.dockerfile @@ -1,5 +1,12 @@ FROM ubuntu:24.04 +RUN --mount=type=cache,target=/var/lib/apt/lists \ + --mount=type=cache,target=/var/cache/apt \ + set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends ca-certificates; \ + update-ca-certificates + WORKDIR /grafana RUN mkdir -p "conf/provisioning/datasources" \ diff --git a/devenv/frontend-service/provisioning/datasources/default.yaml b/devenv/frontend-service/provisioning/datasources/default.yaml index c99a93fd0b2..005bd631498 100644 --- a/devenv/frontend-service/provisioning/datasources/default.yaml +++ b/devenv/frontend-service/provisioning/datasources/default.yaml @@ -12,3 +12,16 @@ datasources: uid: fs-loki type: loki url: http://loki:3100 + + - name: Tempo Traces + uid: fs-tempo + type: tempo + url: http://tempo:3200 + jsonData: + tracesToLogsV2: + # Field with an internal link pointing to a logs data source in Grafana. + # datasourceUid value must match the uid value of the logs data source. + datasourceUid: 'fs-loki' + spanStartTimeShift: '-1m' + spanEndTimeShift: '1m' + filterByTraceID: true diff --git a/devenv/frontend-service/proxy.dockerfile b/devenv/frontend-service/proxy.dockerfile index cd66cfd1187..9e0d8a4ceea 100644 --- a/devenv/frontend-service/proxy.dockerfile +++ b/devenv/frontend-service/proxy.dockerfile @@ -1,5 +1,18 @@ -# Runtime stage - just a simple nginx to serve static files -# We bake the config into the image so we can live-update it with Tilt when it changes -FROM nginx:alpine +FROM nginx:1.29.0-alpine -COPY nginx.conf /etc/nginx/conf.d/default.conf +RUN apk add --no-cache openssl curl ca-certificates + +RUN printf "%s%s%s%s\n" \ + "@nginx " \ + "http://nginx.org/packages/mainline/alpine/v" \ + `egrep -o '^[0-9]+\.[0-9]+' /etc/alpine-release` \ + "/main" \ + | tee -a /etc/apk/repositories + +RUN curl -o /tmp/nginx_signing.rsa.pub https://nginx.org/keys/nginx_signing.rsa.pub +RUN mv /tmp/nginx_signing.rsa.pub /etc/apk/keys/ +RUN apk add --no-cache nginx-module-otel@nginx --force-overwrite + +RUN sed -i '1iload_module modules/ngx_otel_module.so;' /etc/nginx/nginx.conf + +COPY configs/nginx.conf /etc/nginx/conf.d/default.conf diff --git a/docs/sources/administration/plugin-management/_index.md b/docs/sources/administration/plugin-management/_index.md index b7c674b4b18..77e9e57f26f 100644 --- a/docs/sources/administration/plugin-management/_index.md +++ b/docs/sources/administration/plugin-management/_index.md @@ -16,53 +16,19 @@ weight: 600 # Plugin management -You can enhance your Grafana experience with _plugins_, extensions to Grafana beyond the wide range of visualizations and data sources that are built-in. +Plugins enhance your Grafana experience with new ways to connect to and visualize data. -This guide shows you how to [install](#install-a-plugin) plugins that are built by Grafana Labs, commercial partners, our community, or plugins that you have [built yourself](/developers/plugin-tools). +Read on for an overview on how to get started with plugins: -## Types of plugins +- Plugins are available in the [plugin catalog](#plugin-catalog). They can be built by Grafana Labs, commercial partners, our community, or you can [build a plugin yourself](/developers/plugin-tools). +- There are three [types of plugins](#types-of-plugins): panel, data source, and app plugins. +- Learn [how to install](#install-a-plugin), [update](#update-a-plugin) and [verify](#verify-your-plugins) your plugins. -Grafana supports three types of plugins: +[Advanced options](#advanced-options) allow you to: -- [Panels](/grafana/plugins/panel-plugins) - These plugins make it easy to create and add any kind of panel, to show your data, or improve your favorite dashboards. -- [Data sources](/grafana/plugins/data-source-plugins) - These plugins allow you to pull data from various data sources such as databases, APIs, log files, and so on, and display it in the form of graphs, charts, and dashboards in Grafana. -- [Apps](/grafana/plugins/app-plugins) - These plugins enable the bundling of data sources, panels, dashboards, and Grafana pages into a cohesive experience. - -## Panel plugins - -Add new visualizations to your dashboard with panel plugins, such as the [Clock](/grafana/plugins/grafana-clock-panel), [Mosaic](/grafana/plugins/boazreicher-mosaicplot-panel) and [Variable](/grafana/plugins/volkovlabs-variable-panel) panels. - -Use panel plugins when you want to: - -- Visualize data returned by data source queries. -- Navigate between dashboards. -- Control external systems, such as smart home devices. - -## Data source plugins - -Data source plugins add support for new databases, such as [Google BigQuery](/grafana/plugins/grafana-bigquery-datasource). - -Data source plugins communicate with external sources of data and return the data in a format that Grafana understands. By adding a data source plugin, you can immediately use the data in any of your existing dashboards. - -Use data source plugins when you want to query data from external or third-party systems. - -## App plugins - -Applications, or _app plugins_, bundle data sources and panels to provide a cohesive experience, such as the [Zabbix](/grafana/plugins/alexanderzobnin-zabbix-app) app. - -Apps can also add custom pages for things like control panels. - -Use app plugins when you want an out-of-the-box monitoring experience. - -### Managing access for app plugins - -Customize access to app plugins with [RBAC](../roles-and-permissions/access-control/rbac-for-app-plugins/). - -By default, the Viewer, Editor and Admin roles have access to all app plugins that their Organization role allows them to access. Access is granted by the `fixed:plugins.app:reader` role. - -{{< admonition type="note" >}} -To prevent users from seeing an app plugin, refer to [these permissions scenarios](../roles-and-permissions/access-control/plan-rbac-rollout-strategy/#prevent-viewers-from-accessing-an-app-plugin). -{{< /admonition >}} +- Customize where app plugin pages appear in the navigation menu. +- Configure backend communication between installed plugins. +- Improve security by isolating plugins with the Plugin Frontend Sandbox. ## Plugin catalog @@ -93,6 +59,16 @@ If required, the Grafana plugin catalog can be disabled using the `plugin_admin_ +### Types of plugins + +Grafana supports three types of plugins: + +- [Panels](/grafana/plugins/panel-plugins) - These plugins make it easy to create and add any kind of panel, to show your data, or improve your favorite dashboards. +- [Data sources](/grafana/plugins/data-source-plugins) - These plugins allow you to pull data from various data sources such as databases, APIs, log files, and so on, and display it in the form of graphs, charts, and dashboards in Grafana. +- [Apps](/grafana/plugins/app-plugins) - These plugins enable the bundling of data sources, panels, dashboards, and Grafana pages into a cohesive experience. + +Read more in [Types of plugins](plugin-types). + ### Browse plugins To browse for available plugins: @@ -101,69 +77,23 @@ To browse for available plugins: 1. Use the search to filter based on name, keywords, organization and other metadata. 1. Click the **Data sources**, **Panels**, or **Applications** buttons to filter by plugin type. +## Manage your plugins + +We strongly recommend running the latest plugin version. Use [Grafana Advisor](https://grafana.com/docs/grafana//administration/grafana-advisor) to check the status of your data sources and plugins. + ### Install a plugin -The most common way to install a plugin is through the Grafana UI, but alternative methods are also available. +The most common way to install a plugin is through the Grafana UI. 1. In Grafana, click **Administration > Plugins and data > Plugins** in the side navigation menu to view all plugins. 1. Browse and find a plugin. 1. Click the plugin's logo. 1. Click **Install**. -There are also additional ways to install plugins depending on your setup. - -#### Install a plugin using Grafana CLI - -Grafana CLI allows you to install, upgrade, and manage your Grafana plugins using a command line. For more information about Grafana CLI plugin commands, refer to [Plugin commands](../../cli/#plugins-commands). - -#### Install a plugin from a ZIP file - -This method is typically used for plugins not available in the Plugin Catalog or in environments without internet access. - -Download the archive containing the plugin assets, and install it by extracting the archive into the plugin directory. For example: - -```bash -unzip my-plugin-0.2.0.zip -d YOUR_PLUGIN_DIR/my-plugin -``` - -The path to the plugin directory is defined in the configuration file. For more information, refer to [Configuration](../../setup-grafana/configure-grafana/#plugins). - -#### Install a plugin using Grafana configuration - {{< admonition type="note" >}} -This feature requires Grafana 11.5.0 or later. +To see additional ways to install plugins refer to [Install a plugin](plugin-install). {{< /admonition >}} -You can install plugins by adding the plugin ID to the `plugins.preinstall` section in the Grafana configuration file. This prevents the plugin from being accidentally uninstalled and can be auto-updated. For more information, refer to [Configuration](../../setup-grafana/configure-grafana/#plugins). - -#### Install a plugin in air-gapped environment - -Plugin installation usually requires an internet connection. You can check which endpoints are used during the installation on your instance and add them to your instance’s allowlist. - -If this is not possible you can go via installing a plugin using [Grafana CLI](#install-a-plugin-using-grafana-cli) or as a [ZIP file](#install-a-plugin-from-a-zip-file). - -You can fetch any plugin from Grafana.com API following the download link referenced in the API. -Here is an example based on `grafana-lokiexplore-app` plugins. - -1. Open `https://grafana.com/api/plugins/grafana-lokiexplore-app` and look for `links` section -1. Find a `download` url which looks something like `https://grafana.com/api/plugins/grafana-lokiexplore-app/versions/1.0.2/download` -1. Use this URL to download the plugin ZIP file, which you can then install as described above. - -#### Install plugins using the Grafana Helm chart - -With the Grafana Helm chart, add the plugins you want to install as a list using the `plugins` field in the your values file. For more information about the configuration, refer to [the Helm chart configuration reference](https://github.com/grafana/helm-charts/tree/main/charts/grafana#configuration). - -The following YAML snippet installs v1.9.0 of the Grafana OnCall App plugin and the Redis data source plugin. -You must incorporate this snippet within your Helm values file. - -```yaml -plugins: - - https://grafana.com/api/plugins/grafana-oncall-app/versions/v1.9.0/download;grafana-oncall-app - - redis-datasource -``` - -When the update is complete, a confirmation message will indicate the installation was successful. - ### Update a plugin To update a plugin: @@ -186,135 +116,26 @@ To uninstall a plugin: When the update is complete, a confirmation message will indicate the installation was successful. -## Plugin signatures +### Verify your plugins -Plugin signature verification, also known as _signing_, is a security measure to make sure plugins haven't been tampered with. Upon loading, Grafana checks to see if a plugin is signed or unsigned when inspecting and verifying its digital signature. +Plugin signature verification, also known as _signing_, is a security measure to make sure plugins haven't been tampered with. Upon loading, Grafana checks to see if a plugin is signed or unsigned. Read more in [Plugin signatures](plugin-sign). -At startup, Grafana verifies the signatures of every plugin in the plugin directory. If a plugin is unsigned, then Grafana neither loads nor starts it. To see the result of this verification for each plugin, navigate to **Configuration** -> **Plugins**. +## Advanced options -Grafana also writes an error message to the server log: +### Customize navigation placement of plugin pages -```bash -WARN[05-26|12:00:00] Some plugin scanning errors were found errors="plugin '' is unsigned, plugin '' has an invalid signature" -``` +You can relocate app plugin pages to customize the navigation menu structure, as explained in [Customize navigation placement of plugin pages](customize-nav-bar). -If you are a plugin developer and want to know how to sign your plugin, refer to [Sign a plugin](/developers/plugin-tools/publish-a-plugin/sign-a-plugin). +### Integrate plugins -| Signature status | Description | -| ------------------ | ------------------------------------------------------------------------------- | -| Core | Core plugin built into Grafana. | -| Invalid signature | The plugin has an invalid signature. | -| Modified signature | The plugin has changed since it was signed. This may indicate malicious intent. | -| Unsigned | The plugin is not signed. | -| Signed | The plugin signature was successfully verified. | +You can configure your Grafana instance to let the frontends of installed plugins directly communicate locally with the backends of other installed plugins. See how in [Configure backend communication between installed plugins](plugin-integrate). -### Plugin signature levels +### Isolate plugin code with the Frontend Sandbox -All plugins are signed under a _signature level_. The signature level determines how the plugin can be distributed. +You can use the [Plugin Frontend Sandbox](plugin-frontend-sandbox) to securely isolate plugin frontend code from the main Grafana application. -| **Plugin Level** | **Description** | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Private |

Private plugins are for use on your own Grafana. They may not be distributed to the Grafana community, and are not published in the Grafana catalog.

| -| Community |

Community plugins have dependent technologies that are open source and not for profit.

Community plugins are published in the official Grafana catalog, and are available to the Grafana community.

| -| Commercial |

Commercial plugins have dependent technologies that are closed source or commercially backed.

Commercial plugins are published on the official Grafana catalog, and are available to the Grafana community.

| +### Learn more -### Allow unsigned plugins - -{{< admonition type="note" >}} -Unsigned plugins are not supported in Grafana Cloud. -{{< /admonition >}} - -We strongly recommend that you don't run unsigned plugins in your Grafana instance. However, if you're aware of the risks and you still want to load an unsigned plugin, refer to [Configuration](../../setup-grafana/configure-grafana/#allow_loading_unsigned_plugins). - -If you've allowed loading of an unsigned plugin, then Grafana writes a warning message to the server log: - -```bash -WARN[06-01|16:45:59] Running an unsigned plugin pluginID= -``` - -{{< admonition type="note" >}} -If you're developing a plugin, then you can enable development mode to allow all unsigned plugins. -{{< /admonition >}} - -## Integrate plugins - -You can configure your Grafana instance to let the frontends of installed plugins directly communicate locally with the backends of other installed plugins. By default, you can only communicate with plugin backends remotely. You can use this configuration to, for example, enable a [canvas panel](https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/canvas/) to call an application resource API that is permitted by the `actions_allow_post_url` option. - -To enable backend communication between plugins: - -1. Set the plugins you want to communicate with. In your configuration file (`grafana.ini` or `custom.ini` depending on your operating system) remove the semicolon to enable and then set the following configuration option: - - ``` - actions_allow_post_url= - ``` - - This is a comma-separated list that uses glob matching. - - To allow access to all plugins that have a backend: - - ``` - actions_allow_post_url=/api/plugins/* - ``` - - - To access to the backend of only one plugin: - - ``` - actions_allow_post_url=/api/plugins/ - ``` - -## Plugin Frontend Sandbox - -{{< admonition type="caution" >}} -Plugin Frontend Sandbox is currently in [public preview](/docs/release-life-cycle/). Grafana Labs offers limited support, and breaking changes might occur prior to the feature being made generally available. -{{< /admonition >}} - -The Plugin Frontend Sandbox is a security feature that isolates plugin frontend code from the main Grafana application. -When enabled, plugins run in a separate JavaScript context, which provides several security benefits: - -- Prevents plugins from modifying parts of the Grafana interface outside their designated areas -- Stops plugins from interfering with other plugins functionality -- Protects core Grafana features from being altered by plugins -- Prevents plugins from modifying global browser objects and behaviors - -Plugins running inside the Frontend Sandbox should continue to work normally without any noticeable changes in their intended functionality. - -### Enable Frontend Sandbox - -The Frontend Sandbox feature is currently behind the `pluginsFrontendSandbox` feature flag. To enable it, you'll need to: - -1. Enable the feature flag in your Grafana configuration. For more information about enabling feature flags, refer to [Configure feature toggles](/docs/grafana//setup-grafana/configure-grafana/feature-toggles/). - -2. For self-hosted Grafana installations, add the plugin IDs you want to sandbox in the `security` section using the `enable_frontend_sandbox_for_plugins` configuration option. - -For Grafana Cloud users, you can simply use the toggle switch in the plugin catalog page to enable or disable the sandbox for each plugin. By default, the sandbox is disabled for all plugins. - -{{< admonition type="note" >}} -Enabling the Frontend Sandbox might impact the performance of certain plugins. Only disable the sandbox if you fully trust the plugin and understand the security implications. -{{< /admonition >}} - -### Compatibility - -The Frontend Sandbox is available in public preview in Grafana >=11.5. It is compatible with all types of plugins including app plugins, panel plugins, and data source plugins. Angular-based plugins are not supported. Plugins developed and signed by Grafana Labs are excluded and cannot be sandboxed. - -### When to Use Frontend Sandbox - -We strongly recommend enabling the Frontend Sandbox for plugins that allow users to write custom JavaScript code for data visualization or manipulation. These plugins, while powerful, can potentially execute arbitrary JavaScript code in your Grafana instance. The sandbox provides an additional layer of security by restricting what this code can access and modify. - -Examples of plugins where the sandbox is particularly important include: - -- Panel plugins that allow users to write custom JavaScript code -- Plugins from untrusted sources - -### Troubleshooting - -If a plugin isn't functioning correctly with the Frontend Sandbox enabled: - -1. Temporarily disable the sandbox for that specific plugin -1. Test if the plugin works correctly without the sandbox -1. If the plugin only works with the sandbox disabled, ensure you trust the plugin source before continuing to use it without sandbox protection -1. Report any sandbox-related issues to the plugin developer - -## Learn more - -- [Browse plugins](/grafana/plugins) -- [Develop plugins](/developers/plugin-tools) -- [Plugin development Community](https://community.grafana.com/c/plugin-development/30) +- [Browse available plugins](/grafana/plugins) +- [Develop your own plugins](/developers/plugin-tools) +- [Reach out to the plugin development Community](https://community.grafana.com/c/plugin-development/30) diff --git a/docs/sources/administration/plugin-management/customize-nav-bar.md b/docs/sources/administration/plugin-management/customize-nav-bar.md index 3a4e82880c4..0e227c9a4f3 100644 --- a/docs/sources/administration/plugin-management/customize-nav-bar.md +++ b/docs/sources/administration/plugin-management/customize-nav-bar.md @@ -5,7 +5,6 @@ labels: products: - enterprise - oss - - cloud keywords: - grafana - plugins @@ -14,7 +13,7 @@ keywords: - customize - configuration - grafana.ini -weight: 100 +weight: 300 --- # Customize navigation placement of app plugin pages @@ -73,12 +72,12 @@ org-example-app = explore 50 /a/org-example-app/logs = alerting 75 ``` -## Understanding page paths +## Find page paths -To move individual pages, you need to know their paths. Page paths in app plugins follow this format: -`/a/PLUGIN_ID/PAGE_PATH` +To move individual pages you need to know their paths. To identify a plugin page path: -You can identify a plugin page path by visiting the page in the browser and observing the URL in the address bar. +- Visit the page in the browser and check the URL in the address bar. +- Page paths in app plugins follow the format `/a/PLUGIN_ID/PAGE_PATH` ## Troubleshooting diff --git a/docs/sources/administration/plugin-management/plugin-frontend-sandbox.md b/docs/sources/administration/plugin-management/plugin-frontend-sandbox.md new file mode 100644 index 00000000000..19c5c6f72b3 --- /dev/null +++ b/docs/sources/administration/plugin-management/plugin-frontend-sandbox.md @@ -0,0 +1,75 @@ +--- +title: Isolate plugin code with the Plugin Frontend Sandbox +description: Use the Plugin Frontend Sandbox to securely isolate plugin frontend code from the main Grafana application. +labels: + products: + - enterprise + - oss + - cloud +keywords: + - grafana + - plugins + - plugin + - navigation + - customize + - configuration + - grafana.ini + - sandbox + - frontend +weight: 400 +--- + +# Isolate plugin code with the Plugin Frontend Sandbox + +{{< admonition type="caution" >}} +Plugin Frontend Sandbox is currently in [public preview](https://grafana.com/docs/release-life-cycle/). Grafana Labs offers limited support, and breaking changes might occur prior to the feature being made generally available. +{{< /admonition >}} + +The Plugin Frontend Sandbox is a security feature that isolates plugin frontend code from the main Grafana application. When enabled, plugins run in a separate JavaScript context, which provides several security benefits: + +- Prevents plugins from modifying parts of the Grafana interface outside their designated areas +- Stops plugins from interfering with other plugins functionality +- Protects core Grafana features from being altered by plugins +- Prevents plugins from modifying global browser objects and behaviors + +Plugins running inside the Frontend Sandbox should continue to work normally without any noticeable changes in their intended functionality. + +## When to use the Plugin Frontend Sandbox + +We strongly recommend enabling the Frontend Sandbox for plugins that allow users to write custom JavaScript code for data visualization or manipulation, since they can potentially execute arbitrary JavaScript code in your Grafana instance. The sandbox provides an additional layer of security by restricting what this code can access and modify. + +These are examples of plugins where the sandbox is particularly useful: + +- Panel plugins that allow users to write custom JavaScript code +- Plugins from untrusted sources + +## Compatibility and requirements + +The following applies: + +- The Frontend Sandbox is available in public preview in Grafana >=11.5. It's compatible with all types of plugins including app plugins, panel plugins, and data source plugins. +- Angular-based plugins are not supported. +- Plugins developed and signed by Grafana Labs are excluded and cannot be sandboxed. + +## Enable the Frontend Sandbox + +The Frontend Sandbox feature is currently behind the `pluginsFrontendSandbox` feature flag. To enable it, you need to: + +1. Enable the feature flag in your Grafana configuration. For more information about enabling feature flags, refer to [Configure feature toggles](/docs/grafana//setup-grafana/configure-grafana/feature-toggles/). + +2. For self-hosted Grafana installations, add the plugin IDs you want to sandbox in the `security` section using the `enable_frontend_sandbox_for_plugins` configuration option. + +For Grafana Cloud users, you can simply use the toggle switch in the plugin catalog page to enable or disable the sandbox for each plugin. By default, the sandbox is disabled for all plugins. + +{{< admonition type="note" >}} +Enabling the Frontend Sandbox might impact the performance of certain plugins. Only disable the sandbox if you fully trust the plugin and understand the security implications. +{{< /admonition >}} + +## Troubleshooting + +If a plugin isn't functioning correctly with the Frontend Sandbox enabled: + +1. Temporarily disable the sandbox for that specific plugin +1. Test if the plugin works correctly without the sandbox +1. If the plugin only works with the sandbox disabled, ensure you trust the plugin source before continuing to use it without sandbox protection +1. Report any sandbox-related issues to the plugin developer diff --git a/docs/sources/administration/plugin-management/plugin-install.md b/docs/sources/administration/plugin-management/plugin-install.md new file mode 100644 index 00000000000..18be4ea58fa --- /dev/null +++ b/docs/sources/administration/plugin-management/plugin-install.md @@ -0,0 +1,101 @@ +--- +title: Install a plugin +description: Learn about alternative ways to install a plugin. +labels: + products: + - enterprise + - oss + - cloud +keywords: + - grafana + - plugins + - plugin + - navigation + - customize + - configuration + - grafana.ini + - sandbox + - frontend +weight: 120 +--- + +# Install a plugin + +Besides the UI, you can use alternative methods to install a plugin depending on your environment or set-up. + +## Install a plugin using Grafana CLI + +The Grafana CLI allows you to install, upgrade, and manage your Grafana plugins using a command line tool. For more information about Grafana CLI plugin commands, refer to [Plugin commands](/docs/grafana//cli/#plugins-commands). + +## Install a plugin from a ZIP file + +This method is typically used for plugins not available in the Plugin Catalog or in environments without internet access. + +Download the archive containing the plugin assets, and install it by extracting the archive into the plugin directory. For example: + +```bash +unzip my-plugin-0.2.0.zip -d YOUR_PLUGIN_DIR/my-plugin +``` + +The path to the plugin directory is defined in the configuration file. For more information, refer to [Configuration](/docs/grafana//setup-grafana/configure-grafana/#plugins). + +## Install a plugin using Grafana configuration + +{{< admonition type="note" >}} +This feature requires Grafana 11.5.0 or later. +{{< /admonition >}} + +You can install plugins by adding the plugin ID to the `plugins.preinstall` section in the Grafana configuration file. This prevents the plugin from being accidentally uninstalled and can be auto-updated. For more information, refer to [Configuration](https://grafana.com/docs/grafana//setup-grafana/configure-grafana/#preinstall). + +## Install a plugin in air-gapped environment + +Plugin installation usually requires an Internet connection. You can check which endpoints are used during the installation on your instance and add them to your instance’s allow list. + +If this is not possible try installing a plugin using the [Grafana CLI](#install-a-plugin-using-grafana-cli) or as a [ZIP file](#install-a-plugin-from-a-zip-file). + +You can fetch any plugin from Grafana.com API following the download link referenced in the API. +Here's an example based on `grafana-lokiexplore-app` plugins. + +1. Open `https://grafana.com/api/plugins/grafana-lokiexplore-app` and look for `links` section +1. Find a `download` url which looks something like `https://grafana.com/api/plugins/grafana-lokiexplore-app/versions/1.0.2/download` +1. Use this URL to download the plugin ZIP file, which you can then install as described above. + +## Install plugins using the Grafana Helm chart + +With the Grafana Helm chart, you can install plugins using one of the methods described in this section. All the YAML snippets install v1.9.0 of the Grafana OnCall App plugin and the Redis data source plugin. When installation is complete you'll get a confirmation message indicating that the plugins were successfully installed. + +### Method 1: Use the `plugins` field + +Add the plugins you want to install as a list in your values file. For more information about the configuration, refer to [the Helm chart configuration reference](https://github.com/grafana/helm-charts/tree/main/charts/grafana#configuration). + +```yaml +plugins: + - https://grafana.com/api/plugins/grafana-oncall-app/versions/v1.9.0/download;grafana-oncall-app + - redis-datasource +``` + +### Method 2: Use `GF_PLUGINS_PREINSTALL_SYNC` + +Add the following to your `values.yaml` file: + +```yaml +env: + # Format: @[]@ + GF_PLUGINS_PREINSTALL_SYNC: grafana-oncall-app@1.9.0@https://grafana.com/api/plugins/grafana-oncall-app/versions/v1.9.0/download + + # Or without version and URL (latest version will be used) + # GF_PLUGINS_PREINSTALL_SYNC: grafana-oncall-app + + # Multiple plugins (comma-separated) + # GF_PLUGINS_PREINSTALL_SYNC: grafana-oncall-app,redis-datasource +``` + +### Method 3: Use `GF_PLUGINS_INSTALL` (Deprecated since v12.1.0) + +Add the following to your `values.yaml` file: + +```yaml +env: + # Comma-separated list of plugin IDs + GF_PLUGINS_INSTALL: grafana-oncall-app,redis-datasource +``` diff --git a/docs/sources/administration/plugin-management/plugin-integrate.md b/docs/sources/administration/plugin-management/plugin-integrate.md new file mode 100644 index 00000000000..7d688d92bd4 --- /dev/null +++ b/docs/sources/administration/plugin-management/plugin-integrate.md @@ -0,0 +1,47 @@ +--- +title: Plugin backend communication +description: Allow plugin frontends to communicate locally with the backends of other installed plugins. +labels: + products: + - enterprise + - oss +keywords: + - grafana + - plugins + - plugin + - navigation + - customize + - configuration + - grafana.ini + - sandbox + - frontend +weight: 350 +--- + +# Allow plugin backend communication + +By default, you can only communicate with plugin backends remotely. + +However, you can configure your Grafana instance to let the frontends of installed plugins to directly communicate with the backends of other plugins installed locally. You can use this configuration to, for example, enable a [canvas panel](https://grafana.com/docs/grafana//panels-visualizations/visualizations/canvas/) to call an application resource API that is permitted by the `actions_allow_post_url` option. + +## Integrate your plugins + +To enable backend communication between plugins, set the plugins you want to communicate with. In your configuration file (`grafana.ini` or `custom.ini` depending on your operating system), remove the semicolon to enable and then set the following configuration option: + +``` + actions_allow_post_url= +``` + +This is a comma-separated list that uses glob matching. + +- To allow access to all plugins that have a backend, use: + +``` +actions_allow_post_url=/api/plugins/* +``` + +- To access the backend of only one plugin, use: + +``` +actions_allow_post_url=/api/plugins/ +``` diff --git a/docs/sources/administration/plugin-management/plugin-sign.md b/docs/sources/administration/plugin-management/plugin-sign.md new file mode 100644 index 00000000000..7850996d0f6 --- /dev/null +++ b/docs/sources/administration/plugin-management/plugin-sign.md @@ -0,0 +1,80 @@ +--- +title: Plugin signatures +description: Sign your plugins to make sure they haven't been tampered with. +labels: + products: + - enterprise + - oss + - cloud +keywords: + - grafana + - plugins + - plugin + - navigation + - customize + - configuration + - grafana.ini + - sandbox + - frontend +weight: 200 +--- + +# Plugin signatures + +Plugin signature verification, also known as _signing_, is a security measure to make sure plugins haven't been tampered with. Upon loading, Grafana checks to see if a plugin is signed or unsigned when inspecting and verifying its digital signature. + +Learn more at [plugin policies](https://grafana.com/legal/plugins/). + +## How does verifiction work? + +At startup, Grafana verifies the signatures of every plugin in the plugin directory. + +To see the result of this verification for each plugin, navigate to **Configuration** -> **Plugins**. A signature can have any of the following signature status: + +| Signature status | Description | +| ------------------ | ------------------------------------------------------------------------------- | +| Core | Core plugin built into Grafana. | +| Invalid signature | The plugin has an invalid signature. | +| Modified signature | The plugin has changed since it was signed. This may indicate malicious intent. | +| Unsigned | The plugin is not signed. | +| Signed | The plugin signature was successfully verified. | + +### What happens if a plugin is not signed? + +If a plugin is unsigned, then Grafana neither loads nor starts it. Grafana also writes an error message to the server log: + +```bash +WARN[05-26|12:00:00] Some plugin scanning errors were found errors="plugin '' is unsigned, plugin '' has an invalid signature" +``` + +## Plugin signature levels + +All plugins are signed under a _signature level_. The signature level determines how the plugin can be distributed. + +| **Plugin Level** | **Description** | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Private |

Private plugins are for use on your own Grafana. They may not be distributed to the Grafana community, and are not published in the Grafana catalog.

| +| Community |

Community plugins have dependent technologies that are open source and not for profit.

Community plugins are published in the official Grafana catalog, and are available to the Grafana community.

| +| Commercial |

Commercial plugins have dependent technologies that are closed source or commercially backed.

Commercial plugins are published on the official Grafana catalog, and are available to the Grafana community.

| + +## Allow unsigned plugins + +{{< admonition type="note" >}} +Unsigned plugins are not supported in Grafana Cloud. +{{< /admonition >}} + +We strongly recommend that you don't run unsigned plugins in your Grafana instance. However, if you're aware of the risks and you still want to load an unsigned plugin, refer to [Configuration](/docs/grafana//setup-grafana/configure-grafana/#allow_loading_unsigned_plugins). + +If you've allowed loading of an unsigned plugin, then Grafana writes a warning message to the server log: + +```bash +WARN[06-01|16:45:59] Running an unsigned plugin pluginID= +``` + +{{< admonition type="note" >}} +If you're developing a plugin, then you can enable development mode to allow all unsigned plugins. +{{< /admonition >}} + +## Sign a plugin you've developed + +If you are a plugin developer and want to know how to sign your plugin, refer to [Sign a plugin](https://grafana.com/developers/plugin-tools/publish-a-plugin/sign-a-plugin). diff --git a/docs/sources/administration/plugin-management/plugin-types.md b/docs/sources/administration/plugin-management/plugin-types.md new file mode 100644 index 00000000000..fe8cf4de0ff --- /dev/null +++ b/docs/sources/administration/plugin-management/plugin-types.md @@ -0,0 +1,64 @@ +--- +title: Types of plugins +description: Learn about the types of plugins available in Grafana. +labels: + products: + - enterprise + - oss + - cloud +keywords: + - grafana + - plugins + - plugin + - navigation + - customize + - configuration + - grafana.ini + - sandbox + - frontend +weight: 100 +--- + +# Types of plugins + +Grafana supports three types of plugins: + +- [Panels](/grafana/plugins/panel-plugins) - These plugins make it easy to create and add any kind of panel, to show your data, or improve your favorite dashboards. +- [Data sources](/grafana/plugins/data-source-plugins) - These plugins allow you to pull data from various data sources such as databases, APIs, log files, and so on, and display it in the form of graphs, charts, and dashboards in Grafana. +- [Apps](/grafana/plugins/app-plugins) - These plugins enable the bundling of data sources, panels, dashboards, and Grafana pages into a cohesive experience. + +## Panel plugins + +Add new visualizations to your dashboard with panel plugins, such as the [Clock](/grafana/plugins/grafana-clock-panel), [Mosaic](/grafana/plugins/boazreicher-mosaicplot-panel) and [Variable](/grafana/plugins/volkovlabs-variable-panel) panels. + +Use panel plugins when you want to: + +- Visualize data returned by data source queries. +- Navigate between dashboards. +- Control external systems, such as smart home devices. + +## Data source plugins + +Data source plugins add support for new databases, such as [Google BigQuery](/grafana/plugins/grafana-bigquery-datasource). + +Data source plugins communicate with external sources of data and return the data in a format that Grafana understands. By adding a data source plugin, you can immediately use the data in any of your existing dashboards. + +Use data source plugins when you want to query data from external or third-party systems. + +## App plugins + +Applications, or app plugins, bundle data sources and panels to provide a cohesive experience, such as the [Zabbix](/grafana/plugins/alexanderzobnin-zabbix-app) app. + +Apps can also add custom pages for things like control panels. + +Use app plugins when you want an out-of-the-box monitoring experience. + +### Managing access for app plugins + +Customize access to app plugins with [RBAC](/docs/grafana//administration/roles-and-permissions/access-control/rbac-for-app-plugins/). + +By default, the Viewer, Editor and Admin roles have access to all app plugins that their Organization role allows them to access. Access is granted by the `fixed:plugins.app:reader` role. + +{{< admonition type="note" >}} +To prevent users from seeing an app plugin, refer to [these permissions scenarios](/docs/grafana//administration/roles-and-permissions/access-control/plan-rbac-rollout-strategy/#prevent-viewers-from-accessing-an-app-plugin). +{{< /admonition >}} diff --git a/docs/sources/dashboards/_index.md b/docs/sources/dashboards/_index.md index 69696e00405..c919c48debb 100644 --- a/docs/sources/dashboards/_index.md +++ b/docs/sources/dashboards/_index.md @@ -1,6 +1,7 @@ --- aliases: - - features/dashboard/dashboards/ + - features/dashboard/dashboards/ # /docs/grafana/latest/features/dashboard/dashboards/ + - ./dashboards/previews/ # /docs/grafana/latest/dashboards/previews/ labels: products: - cloud diff --git a/docs/sources/dashboards/build-dashboards/create-dynamic-dashboard/index.md b/docs/sources/dashboards/build-dashboards/create-dynamic-dashboard/index.md index 638ed32d79e..0e9f99946bc 100644 --- a/docs/sources/dashboards/build-dashboards/create-dynamic-dashboard/index.md +++ b/docs/sources/dashboards/build-dashboards/create-dynamic-dashboard/index.md @@ -2,7 +2,6 @@ labels: products: - cloud - - enterprise - oss stage: - experimental @@ -158,6 +157,14 @@ To create a dashboard, follow these steps: 1. When you've saved all the changes you want to make to the dashboard, click **Back to dashboard**. 1. Toggle off the edit mode switch. +{{< admonition type="caution" >}} + +Dynamic dashboards is an [experimental](https://grafana.com/docs/release-life-cycle/) feature. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. To get early access to this feature, request it through [this form](https://docs.google.com/forms/d/e/1FAIpQLSd73nQzuhzcHJOrLFK4ef_uMxHAQiPQh1-rsQUT2MRqbeMLpg/viewform?usp=dialog). + +**Do not enable this feature in production environments as it may result in the irreversible loss of data.** + +{{< /admonition >}} + ## Group panels To help create meaningful sections in your dashboard, you can group panels into rows or tabs. @@ -287,6 +294,14 @@ To configure show/hide rules, follow these steps: 1. Click **Save**. 1. Toggle off the edit mode switch. +{{< admonition type="caution" >}} + +Dynamic dashboards is an [experimental](https://grafana.com/docs/release-life-cycle/) feature. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. To get early access to this feature, request it through [this form](https://docs.google.com/forms/d/e/1FAIpQLSd73nQzuhzcHJOrLFK4ef_uMxHAQiPQh1-rsQUT2MRqbeMLpg/viewform?usp=dialog). + +**Do not enable this feature in production environments as it may result in the irreversible loss of data.** + +{{< /admonition >}} + ## Edit dashboards When the dashboard is in edit mode, the edit pane that opens displays options associated with the part of the dashboard that it's in focus. @@ -326,7 +341,7 @@ When a dashboard is in edit mode, you can undo and redo changes you've made usin When you've made a change and hover the cursor over the buttons, the tooltip displays the change you're about to undo or redo. Also, you can continue undoing or redoing as many changes as you need: -{{< video-embed src="/media/docs/grafana/dashboards/screenrecord-undo-redo-v12.0.mp4" >}} +{{< video-embed src="/media/docs/grafana/dashboards/screen-record-undo-redo-v12.0.mp4" >}} The undo and redo buttons are only available at the dashboard level and only apply to changes made there, such as dashboard layout and grouping and high-level dashboard or panel updates. They aren't visible and don't apply when you're configuring a panel or making changes in the dashboard settings. @@ -388,3 +403,11 @@ To make a copy of a dashboard, follow these steps: By default, the copied dashboard has the same name as the original dashboard with the word "Copy" appended and is in the same folder. 1. Click **Save**. + +{{< admonition type="caution" >}} + +Dynamic dashboards is an [experimental](https://grafana.com/docs/release-life-cycle/) feature. Engineering and on-call support is not available. Documentation is either limited or not provided outside of code comments. No SLA is provided. To get early access to this feature, request it through [this form](https://docs.google.com/forms/d/e/1FAIpQLSd73nQzuhzcHJOrLFK4ef_uMxHAQiPQh1-rsQUT2MRqbeMLpg/viewform?usp=dialog). + +**Do not enable this feature in production environments as it may result in the irreversible loss of data.** + +{{< /admonition >}} diff --git a/docs/sources/dashboards/variables/add-template-variables/index.md b/docs/sources/dashboards/variables/add-template-variables/index.md index 38b2bbc27e1..fba0ae43969 100644 --- a/docs/sources/dashboards/variables/add-template-variables/index.md +++ b/docs/sources/dashboards/variables/add-template-variables/index.md @@ -351,6 +351,23 @@ InfluxDB and Prometheus use regular expressions, so the same variable would be i Elasticsearch uses Lucene query syntax, so the same variable would be formatted as `("host1" OR "host2" OR "host3")`. In this case, every value must be escaped so that the value only contains Lucene control words and quotation marks. +#### Variable indexing + +If you have a multi-value variable that's formatted as an array, you can use array positions to reference the values rather than the actual values. +You can use this functionality in dashboard panels to filter data, and when you do so, the array is maintained. + +To reference variable values this way, use the following syntax: + +```text +${query0.0} +``` + +The preceding syntax references the first, or `0`, position in the array. + +In the following example, there's an array of three values, `1t`, `2t`, and `3t`, and rather than referencing those values, the panel query references the second value in the array using the syntax `${query0.1}`: + +{{< figure src="/media/docs/grafana/dashboards/screenshot-indexed-variables-v12.1.png" max-width="750px" alt="Panel query using variable indexing to reference a value" >}} + #### Troubleshoot multi-value variables Automatic escaping and formatting can cause problems and it can be tricky to grasp the logic behind it. Especially for InfluxDB and Prometheus where the use of regular expression syntax requires that the variable is used in regular expression operator context. diff --git a/docs/sources/datasources/mysql/query-editor/_index.md b/docs/sources/datasources/mysql/query-editor/_index.md index b34bcf65935..5139a04d2f3 100644 --- a/docs/sources/datasources/mysql/query-editor/_index.md +++ b/docs/sources/datasources/mysql/query-editor/_index.md @@ -70,7 +70,7 @@ Grafana’s query editors are unique for each data source. For general informati The MySQL query editor is located on the [Explore page](ref:explore). You can also access the MySQL query editor from a dashboard panel. Click the ellipsis in the upper right of the panel and select **Edit**. {{< admonition type="note" >}} -If a default database is configured in the **Data Source Configuration page**, or via a provisioning configuration file, users will be restricted to querying only that pre-configured database. This feature is behind a feature flag and is available once you enable `sqlDatasourceDatabaseSelection`. +If a default database is configured in the **Data Source Configuration page**, or via a provisioning configuration file, users will be restricted to querying only that pre-configured database. {{< /admonition >}} ## MySQL query editor components diff --git a/docs/sources/datasources/postgres/configure/_index.md b/docs/sources/datasources/postgres/configure/_index.md index 7b6a66c0a70..ed229013e9f 100644 --- a/docs/sources/datasources/postgres/configure/_index.md +++ b/docs/sources/datasources/postgres/configure/_index.md @@ -9,7 +9,7 @@ labels: - cloud - enterprise - oss -menuTitle: Configure the PostgreSQL data source +menuTitle: Configure title: Configure the PostgreSQL data source weight: 10 refs: @@ -51,10 +51,14 @@ This document provides instructions for configuring the PostgreSQL data source a ## Before you begin -You must have the `Organization administrator` role to configure the Postgres data source. -Organization administrators can also [configure the data source via YAML](#provision-the-data-source) with the Grafana provisioning system. +- You must have the `Organization administrator` role to configure the Postgres data source. + Organization administrators can also [configure the data source via YAML](#provision-the-data-source) with the Grafana provisioning system. -Grafana comes with a built-in PostgreSQL data source plugin, eliminating the need to install a plugin. +- Grafana comes with a built-in PostgreSQL data source plugin, eliminating the need to install a plugin. + +- Familiarize yourself with your PostgreSQL security configuration and gather any necessary security certificates, client certificates, and client keys. + +- Know which version of PostgreSQL you are running. You will be prompted for this information during the configuration process. {{< admonition type="note" >}} When adding a data source, the database user you specify should have only `SELECT` permissions on the relevant database and tables. Grafana does not validate the safety of queries, which means they can include potentially harmful SQL statements, such as `USE otherdb;` or `DROP TABLE user;`, that could be executed. To mitigate this risk, Grafana strongly recommends creating a dedicated PostgreSQL user with restricted permissions. @@ -84,56 +88,76 @@ You are taken to the **Settings** tab where you will configure the data source. Following is a list of PostgreSQL configuration options: -- **Name** - Sets the name you use to refer to the data source in panels and queries. Examples: `PostgreSQL-DB-1`. -- **Default** - Toggle to set this specific PostgreSQL data source as the default pre-selected data source in panels and visualizations. +| Setting | Description | +| ------- | ------------------------------------------------------------------------------------------------------------------------ | +| Name | Sets the name you use to refer to the data source in panels and queries. Examples: `PostgreSQL-DB-1`. | +| Default | Toggle to set this specific PostgreSQL data source as the default pre-selected data source in panels and visualizations. | **Connection section:** -- **Host URL** - The IP address/hostname and optional port of your PostgreSQL instance. -- **Database name** - The name of your PostgreSQL database. +| Setting | Description | +| ------------- | ---------------------------------------------------------------------- | +| Host URL | The IP address/hostname and optional port of your PostgreSQL instance. | +| Database name | The name of your PostgreSQL database. | **Authentication section:** -- **Username** - Enter the username used to connect to your PostgreSQL database. -- **Password** - Enter the password used to connect to the PostgreSQL database. -- **TLS/SSL Mode** - Determines whether or with what priority a secure SSL TCP/IP connection will be negotiated with the server. When **TLS/SSL Mode** is disabled, **TLS/SSL Method** and **TLS/SSL Auth Details** aren't visible options. -- **TLS/SSL Method** - Determines how TLS/SSL certificates are configured. - - **File system path** - This option allows you to configure certificates by specifying paths to existing certificates on the local file system where Grafana is running. Ensure this file is readable by the user executing the Grafana process. - - **Certificate content** - This option allows you to configure certificate by specifying their content. The content is stored and encrypted in the Grafana database. When connecting to the database, the certificates are saved as files, on the local filesystem, in the Grafana data path. +| Setting | Description | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Username | Enter the username used to connect to your PostgreSQL database. | +| Password | Enter the password used to connect to the PostgreSQL database. | +| TLS/SSL Mode | Determines whether or with what priority a secure SSL TCP/IP connection will be negotiated with the server. When TLS/SSL Mode is disabled, TLS/SSL Method and TLS/SSL Auth Details aren’t visible options. | +| TLS/SSL Method | Determines how TLS/SSL certificates are configured. | +| - File system path | This option allows you to configure certificates by specifying paths to existing certificates on the local file system where Grafana is running. Ensure this file is readable by the user executing the Grafana process. | +| - Certificate content | This option allows you to configure certificate by specifying their content. The content is stored and encrypted in the Grafana database. When connecting to the database, the certificates are saved as files, on the local filesystem, in the Grafana data path. | -**TLS/SSL Auth Details** +**TLS/SSL Auth Details:** If you select the TLS/SSL Mode options **require**, **verify-ca** or **verify-full** and **file system path** the following are required: -- **TLS/SSL Root Certificate** - Specify the path to the root certificate file. -- **TLS/SSL Client Certificate** - Specify the path to the client certificate and ensure the file is accessible to the user running the Grafana process. -- **TLS/SSL Client Key** - Specify the path to the client key file and ensure the file is accessible to the user running the Grafana process. +| Setting | Description | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| TLS/SSL Root Certificate | Specify the path to the root certificate file. | +| TLS/SSL Client Certificate | Specify the path to the client certificate and ensure the file is accessible to the user running the Grafana process. | +| TLS/SSL Client Key | Specify the path to the client key file and ensure the file is accessible to the user running the Grafana process. | If you select the TLS/SSL Mode option **require** and TLS/SSL Method certificate content the following are required: -- **TLS/SSL Client Certificate** - Provide the client certificate. -- **TLS/SSL Client Key** - Provide the client key. +| Setting | Description | +| -------------------------- | ------------------------------- | +| TLS/SSL Client Certificate | Provide the client certificate. | +| TLS/SSL Client Key | Provide the client key. | If you select the TLS/SSL Mode options **verify-ca** or **verify-full** with the TLS/SSL Method certificate content the following are required: -- **TLS/SSL Client Certificate** - Provide the client certificate. -- **TLS/SSL Root Certificate** - Provide the root certificate. -- **TLS/SSL Client Key** - Provide the client key. +| Setting | Description | +| -------------------------- | ------------------------------- | +| TLS/SSL Client Certificate | Provide the client certificate. | +| TLS/SSL Root Certificate | Provide the root certificate. | +| TLS/SSL Client Key | Provide the client key. | **PostgreSQL Options:** -- **Version** - Determines which functions are available in the query builder. The default is the current version. -- **Min time interval** - Defines a lower limit for the auto group by by time interval. Grafana recommends aligning this setting with the data write frequency. For example, set it to `1m` if your data is written every minute. Refer to [Min time interval](#min-time-interval) for format examples. -- **TimescaleDB** - A time-series database built as a PostgreSQL extension. When enabled, Grafana uses `time_bucket` in the `$__timeGroup` macro to display TimescaleDB specific aggregate functions in the query builder. For more information, refer to [TimescaleDB documentation](https://docs.timescale.com/timescaledb/latest/tutorials/grafana/grafana-timescalecloud/#connect-timescaledb-and-grafana). +| Setting | Description | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Version | Determines which functions are available in the query builder. The default is the current version. | +| Min time interval | Defines a lower limit for the auto group by time interval. Grafana recommends aligning this setting with the data write frequency. For example, set it to `1m` if your data is written every minute. Refer to [Min time interval](#min-time-interval) for format examples. | +| TimescaleDB | A time-series database built as a PostgreSQL extension. When enabled, Grafana uses `time_bucket` in the `$__timeGroup` macro to display TimescaleDB-specific aggregate functions in the query builder. For more information, refer to [TimescaleDB documentation](https://docs.timescale.com/timescaledb/latest/tutorials/grafana/grafana-timescalecloud/#connect-timescaledb-and-grafana). | **Connection limits:** -- **Max open** - The maximum number of open connections to the database. The default `100`. -- **Auto max idle** - Toggle to set the maximum number of idle connections to the number of maximum open connections. This setting is toggled on by default. -- **Max idle** - The maximum number of connections in the idle connection pool. The default `100`. -- **Max lifetime** - The maximum amount of time in seconds a connection may be reused. The default is `14400`, or 4 hours. +| Setting | Description | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| Max open | The maximum number of open connections to the database. The default is `100`. | +| Auto max idle | Toggle to set the maximum number of idle connections to the number of maximum open connections. This setting is toggled on by default. | +| Max idle | The maximum number of connections in the idle connection pool. The default is `100`. | +| Max lifetime | The maximum amount of time in seconds a connection may be reused. The default is `14400`, or 4 hours. | -**Private data source connect** - _Only for Grafana Cloud users._ Private data source connect, or PDC, allows you to establish a private, secured connection between a Grafana Cloud instance, or stack, and data sources secured within a private network. Click the drop-down to locate the URL for PDC. For more information regarding Grafana PDC refer to [Private data source connect (PDC)](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/). +**Private data source connect:** + +| Setting | Description | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Private data source connect | _Only for Grafana Cloud users._ Private data source connect, or PDC, allows you to establish a private, secured connection between a Grafana Cloud instance, or stack, and data sources secured within a private network. Click the drop-down to locate the URL for PDC. For more information, refer to [Private data source connect (PDC)](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/). | Click **Manage private data source connect** to be taken to your PDC connection page, where you’ll find your PDC configuration details. @@ -191,4 +215,4 @@ datasources: If you encounter metric request errors or other issues: - Ensure that the parameters in your data source YAML file precisely match the example provided, including parameter names and the correct use of quotation marks. -- Verify that the database name _isn't_ included in the URL. +- Verify that the database name **IS NOT** included in the URL. diff --git a/docs/sources/datasources/postgres/query-editor/_index.md b/docs/sources/datasources/postgres/query-editor/_index.md index 5c37f7047e7..822ef681c9a 100644 --- a/docs/sources/datasources/postgres/query-editor/_index.md +++ b/docs/sources/datasources/postgres/query-editor/_index.md @@ -9,7 +9,7 @@ labels: - cloud - enterprise - oss -menuTitle: PostgreSQL query editor +menuTitle: Query editor title: PostgreSQL query editor weight: 20 refs: diff --git a/docs/sources/developers/http_api/folder.md b/docs/sources/developers/http_api/folder.md index 6a8e2b1f771..cd66653e0e6 100644 --- a/docs/sources/developers/http_api/folder.md +++ b/docs/sources/developers/http_api/folder.md @@ -14,13 +14,24 @@ labels: - enterprise - oss title: Folder HTTP API +refs: + apis: + - pattern: /docs/grafana/ + destination: /docs/grafana//developers/http_api/apis/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/developer-resources/api-reference/http-api/apis/ + alerting: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/ --- # New Folders APIs > If you are running Grafana Enterprise, for some endpoints you'll need to have specific permissions. Refer to [Role-based access control permissions](/docs/grafana/latest/administration/roles-and-permissions/access-control/custom-role-actions-scopes/) for more information. -> To view more about the new api structure, refer to [API overview]({{< ref "apis" >}}). +> To view more about the new api structure, refer to [API overview](ref:apis). ### Get all folders @@ -28,7 +39,7 @@ title: Folder HTTP API Returns all folders that the authenticated user has permission to view within the given organization. Use the `limit` query parameter to control the maximum number of dashboards returned. To retrieve additional dashboards, utilize the `continue` token provided in the response to fetch the next page. -- namespace: to read more about the namespace to use, see the [API overview]({{< ref "apis" >}}). +- namespace: to read more about the namespace to use, see the [API overview](ref:apis). **Required permissions** @@ -94,7 +105,7 @@ Status Codes: Will return the folder given the folder uid. -- namespace: to read more about the namespace to use, see the [API overview]({{< ref "apis" >}}). +- namespace: to read more about the namespace to use, see the [API overview](ref:apis). - uid: the unique identifier of the folder to update. this will be the _name_ in the folder response **Required permissions** @@ -156,7 +167,7 @@ Status Codes: Creates a new folder. -- namespace: to read more about the namespace to use, see the [API overview]({{< ref "apis" >}}). +- namespace: to read more about the namespace to use, see the [API overview](ref:apis). **Required permissions** @@ -238,7 +249,7 @@ Status Codes: Updates an existing folder identified by uid. -- namespace: to read more about the namespace to use, see the [API overview]({{< ref "apis" >}}). +- namespace: to read more about the namespace to use, see the [API overview](ref:apis). - uid: the unique identifier of the folder to update. this will be the _name_ in the folder response **Required permissions** @@ -328,9 +339,9 @@ Content-Length: 97 Deletes an existing folder identified by UID along with all dashboards (and their alerts) stored in the folder. This operation cannot be reverted. -If [Grafana Alerting]({{< relref "/docs/grafana/latest/alerting" >}}) is enabled, you can set an optional query parameter `forceDeleteRules=false` so that requests will fail with 400 (Bad Request) error if the folder contains any Grafana alerts. However, if this parameter is set to `true` then it will delete any Grafana alerts under this folder. +If [Grafana Alerting](ref:alerting) is enabled, you can set an optional query parameter `forceDeleteRules=false` so that requests will fail with 400 (Bad Request) error if the folder contains any Grafana alerts. However, if this parameter is set to `true` then it will delete any Grafana alerts under this folder. -- namespace: to read more about the namespace to use, see the [API overview]({{< ref "apis" >}}). +- namespace: to read more about the namespace to use, see the [API overview](ref:apis). - uid: the unique identifier of the folder to delete. this will be the _name_ in the folder response **Required permissions** diff --git a/docs/sources/observability-as-code/foundation-sdk/dashboard-automation.md b/docs/sources/observability-as-code/foundation-sdk/dashboard-automation.md index d4b98f46c80..16149c71d2c 100644 --- a/docs/sources/observability-as-code/foundation-sdk/dashboard-automation.md +++ b/docs/sources/observability-as-code/foundation-sdk/dashboard-automation.md @@ -171,7 +171,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: 1.24.5 + go-version: 1.24.6 - name: Verify Go version run: go version @@ -212,7 +212,7 @@ This GitHub Action automates the deployment of a Grafana dashboard using the Fou The first few steps: - Check out the repository to access the project code. -- Install Go 1.24.5 using the `actions/setup-go` action. +- Install Go 1.24.6 using the `actions/setup-go` action. - Verify Go is properly installed. ### 2. Download and install `grafanactl` diff --git a/docs/sources/observability-as-code/grafana-cli/set-up-grafana-cli.md b/docs/sources/observability-as-code/grafana-cli/set-up-grafana-cli.md index fb64ae00625..d2498b15ee9 100644 --- a/docs/sources/observability-as-code/grafana-cli/set-up-grafana-cli.md +++ b/docs/sources/observability-as-code/grafana-cli/set-up-grafana-cli.md @@ -19,10 +19,14 @@ weight: 200 You can configure Grafana CLI in two ways: using environment variables or through a configuration file. -- **Environment variables** are ideal for CI environments and support a single context. +- **Environment variables** are ideal for CI environments and support a single context. A full list of supported environment variables is available in the [reference documentation](https://github.com/grafana/grafanactl/blob/main/docs/reference/environment-variables/index.md#environment-variables-reference). - **Configuration files** can manage multiple contexts, making it easier to switch between different Grafana instances. -## Use environment variables +{{< admonition type="note" >}} +Configuration items may change depending on your set-up. For example, use `org-id` for Grafana on-prem, but use `stack-id` for Grafana Cloud. +{{< /admonition >}} + +## Configure Grafana CLI with environment variables Grafana CLI communicates with Grafana via its REST API, which requires authentication credentials. @@ -37,15 +41,13 @@ Depending on your authentication method, you may also need to set: - A [token](https://github.com/grafana/grafanactl/blob/main/docs/reference/environment-variables/index.md#grafana_token) for a [Grafana service account](https://grafana.com/docs/grafana/latest/administration/service-accounts/) (recommended) - A [username](https://github.com/grafana/grafanactl/blob/main/docs/reference/environment-variables/index.md#grafana_user) and [password](https://github.com/grafana/grafanactl/blob/main/docs/reference/environment-variables/index.md#grafana_password) for basic authentication -To persist your configuration, consider [creating a context](#defining-contexts). +To persist your configuration, consider [creating a context](#use-configuration-contexts). -A full list of supported environment variables is available in the [reference documentation](https://github.com/grafana/grafanactl/blob/main/docs/reference/environment-variables/index.md#environment-variables-reference). +### Use configuration contexts -## Define contexts +Contexts allow you to easily switch between multiple Grafana instances. -Contexts allow you to easily switch between multiple Grafana instances. By default, the CLI uses a context named `default`. - -To configure the `default` context: +By default, the CLI uses a context named `default`. To configure it use: ```bash grafanactl config set contexts.default.grafana.server http://localhost:3000 @@ -70,7 +72,7 @@ grafanactl config set contexts.staging.grafana.org-id 1 In these examples, `default` and `staging` are the names of the contexts. {{< /admonition >}} -## Configuration file +## Configure Grafana CLI with configuration files Grafana CLI stores its configuration in a YAML file. The CLI determines the configuration file location in the following order: diff --git a/docs/sources/panels-visualizations/configure-standard-options/index.md b/docs/sources/panels-visualizations/configure-standard-options/index.md index 2e737ed7de2..c4ed1caa981 100644 --- a/docs/sources/panels-visualizations/configure-standard-options/index.md +++ b/docs/sources/panels-visualizations/configure-standard-options/index.md @@ -7,6 +7,7 @@ aliases: - ../panels/reference-standard-field-definitions/ - ../panels/standard-field-definitions/ - ../panels/working-with-panels/format-standard-fields/ + - ../panels/field-configuration-options/ # /docs/grafana/latest/panels/field-configuration-options/ keywords: - panel - dashboard diff --git a/docs/sources/panels-visualizations/visualizations/canvas/index.md b/docs/sources/panels-visualizations/visualizations/canvas/index.md index a5f4712c502..63daf9b63e8 100644 --- a/docs/sources/panels-visualizations/visualizations/canvas/index.md +++ b/docs/sources/panels-visualizations/visualizations/canvas/index.md @@ -262,6 +262,10 @@ Use the following pointer and keyboard strokes: {{< video-embed src="/media/docs/grafana/2024-01-05-Canvas-Pan-&-Zoom-Enablement-Video.mp4" max-width="750px" alt="Canvas pan and zoom enablement video" >}} +##### Zoom to content + +When you toggle on the **Zoom to content** switch, Grafana automatically adjusts the view to fit all visible elements in your canvas visualization into the viewport, adding a small margin around the edges. This makes it easy to reset your view, present content, or switch between devices without losing your framing. The content will re‑fit even if you resize the panel. + ##### Infinite panning You can enable infinite panning in a canvas when pan and zoom is enabled. This allows you to pan and zoom the canvas and uncover larger designs. diff --git a/docs/sources/panels-visualizations/visualizations/geomap/index.md b/docs/sources/panels-visualizations/visualizations/geomap/index.md index 3a10dea71d6..fb9511a2ac5 100644 --- a/docs/sources/panels-visualizations/visualizations/geomap/index.md +++ b/docs/sources/panels-visualizations/visualizations/geomap/index.md @@ -53,7 +53,7 @@ Geomaps allow you to view and customize the world map using geospatial data. It' You can configure and overlay [map layers](#layer-type), like heatmaps and networks, and blend included basemaps or your own custom maps. This helps you to easily focus on the important location-based characteristics of the data. -{{< figure src="/static/img/docs/geomap-panel/geomap-example-8-1-0.png" max-width="1200px" alt="Geomap visualization" >}} +{{< figure src="/static/img/docs/geomap-panel/geomap-example-8-1-0.png" max-width="750px" alt="Geomap visualization" >}} When a geomap is in focus, in addition to typical mouse controls, you can pan around using the arrow keys or zoom in and out using the plus (`+`) and minus (`-`) keys or icons. @@ -186,11 +186,7 @@ You might need to reload the dashboard for this feature to work. #### No map repeating -The **No map repeating** option prevents the base map tiles from repeating horizontally when you pan across the world. This constrains the view to a single instance of the world map and avoids visual confusion when displaying global datasets. - -{{< admonition type="note" >}} -Enabling this option requires the map to reinitialize. -{{< /admonition >}} +The **No map repeating** option prevents the base map tiles from repeating horizontally when you pan across the world. This constrains the view to a single instance of the world map and avoids visual confusion when displaying global datasets. Enabling this option requires the map to reinitialize. ### Map layers options @@ -349,7 +345,7 @@ Styles can be set within the "properties" object of the GeoJSON with support for The Night / Day layer displays night and day regions based on the current time range. -{{< figure src="/static/img/docs/geomap-panel/geomap-day-night-9-1-0.png" max-width="1200px" alt="Geomap panel Night / Day" >}} +{{< figure src="/static/img/docs/geomap-panel/geomap-day-night-9-1-0.png" max-width="600px" alt="Geomap panel Night / Day" >}} | Option | Description | @@ -372,11 +368,11 @@ The Route layer is currently in [public preview](/docs/release-life-cycle/). Gra The Route layer renders data points as a route. -{{< figure src="/media/docs/grafana/geomap-route-layer-basic-9-4-0.png" max-width="1200px" alt="Geomap panel Route" >}} +{{< figure src="/media/docs/grafana/geomap-route-layer-basic-9-4-0.png" max-width="600px" alt="Geomap panel Route" >}} The layer can also render a route with arrows. -{{< figure src="/media/docs/grafana/geomap-route-layer-arrow-size-9-4-0.png" max-width="1200px" alt="Geomap panel Route arrows with size" >}} +{{< figure src="/media/docs/grafana/geomap-route-layer-arrow-size-9-4-0.png" max-width="600px" alt="Geomap panel Route arrows with size" >}} | Option | Description | @@ -401,7 +397,7 @@ The Photos layer is currently in [public preview](/docs/release-life-cycle/). Gr The Photos layer renders a photo at each data point. -{{< figure src="/static/img/docs/geomap-panel/geomap-photos-9-3-0.png" max-width="1200px" alt="Geomap panel Photos" >}} +{{< figure src="/static/img/docs/geomap-panel/geomap-photos-9-3-0.png" max-width="600px" alt="Geomap panel Photos" >}} | Option | Description | @@ -471,7 +467,7 @@ You can convert node graph data to a network layer: A map from a collaborative free geographic world database. -{{< figure src="/static/img/docs/geomap-panel/geomap-osm-9-1-0.png" max-width="1200px" alt="Geomap panel Open Street Map" >}} +{{< figure src="/static/img/docs/geomap-panel/geomap-osm-9-1-0.png" max-width="600px" alt="Geomap panel Open Street Map" >}} - **Opacity** from 0 (transparent) to 1 (opaque) - **Display tooltip** - allows you to toggle tooltips for the layer. @@ -485,9 +481,9 @@ A CARTO layer is from CARTO Raster basemaps. - **Theme** - Auto - Light - {{< figure src="/static/img/docs/geomap-panel/geomap-carto-light-9-1-0.png" max-width="1200px" alt="Geomap panel CARTO light example" >}} + {{< figure src="/static/img/docs/geomap-panel/geomap-carto-light-9-1-0.png" max-width="600px" alt="Geomap panel CARTO light example" >}} - Dark - {{< figure src="/static/img/docs/geomap-panel/geomap-carto-dark-9-1-0.png" max-width="1200px" alt="Geomap panel CARTO dark example" >}} + {{< figure src="/static/img/docs/geomap-panel/geomap-carto-dark-9-1-0.png" max-width="600px" alt="Geomap panel CARTO dark example" >}} - **Show labels** shows the Country details on top of the map. - **Opacity** from 0 (transparent) to 1 (opaque) - **Display tooltip** - allows you to toggle tooltips for the layer. @@ -500,17 +496,17 @@ An ArcGIS layer is a layer from an ESRI ArcGIS MapServer. - **Server Instance** to select the map type. - World Street Map - {{< figure src="/static/img/docs/geomap-panel/geomap-arcgis-wsm-9-1-0.png" max-width="1200px" alt="Geomap panel ArcGIS World Street Map" >}} + {{< figure src="/static/img/docs/geomap-panel/geomap-arcgis-wsm-9-1-0.png" max-width="600px" alt="Geomap panel ArcGIS World Street Map" >}} - World Imagery - {{< figure src="/static/img/docs/geomap-panel/geomap-arcgis-wi-9-1-0.png" max-width="1200px" alt="Geomap panel ArcGIS World Imagery" >}} + {{< figure src="/static/img/docs/geomap-panel/geomap-arcgis-wi-9-1-0.png" max-width="600px" alt="Geomap panel ArcGIS World Imagery" >}} - World Physical - {{< figure src="/static/img/docs/geomap-panel/geomap-arcgis-wp-9-1-0.png" max-width="1200px" alt="Geomap panel ArcGIS World Physical" >}} + {{< figure src="/static/img/docs/geomap-panel/geomap-arcgis-wp-9-1-0.png" max-width="600px" alt="Geomap panel ArcGIS World Physical" >}} - Topographic - {{< figure src="/static/img/docs/geomap-panel/geomap-arcgis-topographic-9-1-0.png" max-width="1200px" alt="Geomap panel ArcGIS Topographic" >}} + {{< figure src="/static/img/docs/geomap-panel/geomap-arcgis-topographic-9-1-0.png" max-width="600px" alt="Geomap panel ArcGIS Topographic" >}} - USA Topographic - {{< figure src="/static/img/docs/geomap-panel/geomap-arcgis-usa-topographic-9-1-0.png" max-width="1200px" alt="Geomap panel ArcGIS USA Topographic" >}} + {{< figure src="/static/img/docs/geomap-panel/geomap-arcgis-usa-topographic-9-1-0.png" max-width="600px" alt="Geomap panel ArcGIS USA Topographic" >}} - World Ocean - {{< figure src="/static/img/docs/geomap-panel/geomap-arcgis-ocean-9-1-0.png" max-width="1200px" alt="Geomap panel ArcGIS World Ocean" >}} + {{< figure src="/static/img/docs/geomap-panel/geomap-arcgis-ocean-9-1-0.png" max-width="600px" alt="Geomap panel ArcGIS World Ocean" >}} - Custom MapServer (see [XYZ](#xyz-tile-layer) for formatting) - URL template - Attribution @@ -526,7 +522,7 @@ An ArcGIS layer is a layer from an ESRI ArcGIS MapServer. The XYZ Tile layer is a map from a generic tile layer. -{{< figure src="/static/img/docs/geomap-panel/geomap-xyz-9-1-0.png" max-width="1200px" alt="Geomap panel xyz example" >}} +{{< figure src="/static/img/docs/geomap-panel/geomap-xyz-9-1-0.png" max-width="600px" alt="Geomap panel xyz example" >}} - **URL template** - Set a valid tile server url, with {z}/{x}/{y} for example: https://tile.openstreetmap.org/{z}/{x}/{y}.png - **Attribution** sets the reference string for the layer if displayed in [map controls](#show-attribution) @@ -647,7 +643,7 @@ The map controls section contains various options for map information and tool o Displays zoom controls in the upper left corner. This control can be useful when using systems that don't have a mouse. -{{< figure src="/static/img/docs/geomap-panel/geomap-map-controls-zoom-9-1-0.png" max-width="1200px" alt="Geomap panel zoom" >}} +{{< figure src="/static/img/docs/geomap-panel/geomap-map-controls-zoom-9-1-0.png" max-width="300px" alt="Geomap panel zoom" >}} #### Mouse wheel zoom @@ -657,19 +653,19 @@ Enables the mouse wheel to be used for zooming in or out. Displays attribution for basemap layers. -{{< figure src="/static/img/docs/geomap-panel/geomap-map-controls-attribution-9-1-0.png" max-width="1200px" alt="Geomap panel attribution" >}} +{{< figure src="/static/img/docs/geomap-panel/geomap-map-controls-attribution-9-1-0.png" max-width="400px" alt="Geomap panel attribution" >}} #### Show scale Displays scale information in the bottom left corner in meters (m) or kilometers (km). -{{< figure src="/static/img/docs/geomap-panel/geomap-map-controls-scale-9-1-0.png" max-width="1200px" alt="Geomap panel scale" >}} +{{< figure src="/static/img/docs/geomap-panel/geomap-map-controls-scale-9-1-0.png" max-width="400px" alt="Geomap panel scale" >}} #### Show measure tools Displays measure tools in the upper right corner. Measurements appear only when this control is open. -{{< figure src="/static/img/docs/geomap-panel/geomap-map-controls-measure-9-1-0.png" max-width="1200px" alt="Geomap panel measure" >}} +{{< figure src="/static/img/docs/geomap-panel/geomap-map-controls-measure-9-1-0.png" max-width="400px" alt="Geomap panel measure" >}} - **Click** to start measuring - **Continue clicking** to continue measurement @@ -686,7 +682,7 @@ Get the spherical length of a geometry. This length is the sum of the great circ - **Miles (mi)** - **Nautical miles (nmi)** -{{< figure src="/static/img/docs/geomap-panel/geomap-map-controls-measure-length-9-1-0.png" max-width="1200px" alt="Geomap panel measure length" >}} +{{< figure src="/static/img/docs/geomap-panel/geomap-map-controls-measure-length-9-1-0.png" max-width="400px" alt="Geomap panel measure length" >}} ##### Area @@ -699,7 +695,7 @@ Get the spherical area of a geometry. This area is calculated assuming that poly - **Acres (acre)** - **Hectare (ha)** -{{< figure src="/static/img/docs/geomap-panel/geomap-map-controls-measure-area-9-1-0.png" max-width="1200px" alt="Geomap panel measure area" >}} +{{< figure src="/static/img/docs/geomap-panel/geomap-map-controls-measure-area-9-1-0.png" max-width="550px" alt="Geomap panel measure area" >}} #### Show debug @@ -708,13 +704,31 @@ Displays debug information in the upper right corner. This can be useful for deb - **Zoom** displays current zoom level of the map. - **Center** displays the current **longitude**, **latitude** of the map center. -{{< figure src="/static/img/docs/geomap-panel/geomap-map-controls-debug-9-1-0.png" max-width="1200px" alt="Geomap panel debug" >}} +{{< figure src="/static/img/docs/geomap-panel/geomap-map-controls-debug-9-1-0.png" max-width="400px" alt="Geomap panel debug" >}} #### Tooltip +Tooltips are supported for the **Markers**, **Heatmap**, **Photos** (beta) layers. +For these layer types, choose from the following tooltip options: + - **None** displays tooltips only when a data point is clicked. - **Details** displays tooltips when a mouse pointer hovers over a data point. +When a data point on the geomap represents one row—that is, only a single row of response data is relevant to that point—the tooltip displays a grid with the row's names and values: + +{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-single-row-marker-v12.1.png" max-width="750px" alt="A data point with one row of associated data" >}} + +When a data point represents more than one row—that is, different rows but with the same geographical information—then each row appears as a single entry: + +{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-multiple-row-marker-v12.1.png" max-width="750px" alt="A data point with mulitple rows of associated data" >}} + +The text displayed in each tooltip row is associated with the first field value in each data row. +Click it to expand and display the full details of the data row. + +{{< admonition type="note" >}} +The data appearing in each detail row is determined by the underlying query and transformations applied to the query's results, and can't be directly controlled using tooltip options. +{{< /admonition >}} + ### Standard options {{< docs/shared lookup="visualizations/standard-options.md" source="grafana" version="" >}} diff --git a/docs/sources/panels-visualizations/visualizations/pie-chart/index.md b/docs/sources/panels-visualizations/visualizations/pie-chart/index.md index 679f4e61f3b..0c46b734d1d 100644 --- a/docs/sources/panels-visualizations/visualizations/pie-chart/index.md +++ b/docs/sources/panels-visualizations/visualizations/pie-chart/index.md @@ -136,6 +136,15 @@ Select the pie chart display style. Choose from **Pie** or **Donut**. ![Pie chart types](/media/docs/grafana/panels-visualizations/screenshot-pie-chart-types.png) +#### Slice sorting + +By default, the pie chart is sorted so that the slices decrease in size clockwise around the circle. +You can configure the sorting of the slices, and by extension the legend, with the following options: + +- **Descending** - The slices decrease in size, clockwise (default). +- **Ascending** - The slices increase in size, clockwise. +- **None** - No sorting is applied. The original order of the data is maintained. + #### Labels Select labels to display on the pie chart. You can select more than one. diff --git a/docs/sources/panels-visualizations/visualizations/table/index.md b/docs/sources/panels-visualizations/visualizations/table/index.md index 10f87a87953..cc6e5f46441 100644 --- a/docs/sources/panels-visualizations/visualizations/table/index.md +++ b/docs/sources/panels-visualizations/visualizations/table/index.md @@ -155,7 +155,7 @@ To filter column values, follow these steps: Grafana displays the filter options for that column. - {{< figure src="/static/img/docs/tables/filter-column-values.png" max-width="300px" alt="Filter column values" class="docs-image--no-shadow" >}} + {{< figure src="/media/docs/grafana/panels-visualizations/filter-column-values_12.2.png" max-width="300px" alt="Filter column values" >}} 1. Click the checkbox next to the values that you want to display or click **Select all**. 1. Enter text in the search field at the top to show those values in the display so that you can select them rather than scroll to find them. @@ -178,7 +178,8 @@ To remove the filter, click the blue filter icon and then click **Clear filter** Click a column title to change the sort order from default to descending to ascending. Each time you click, the sort order changes to the next option in the cycle. -You can sort multiple columns by holding the `Shift` key and clicking the column name. +You can sort multiple columns by holding the `Cmd` or `Ctrl` key +and clicking the column name. {{< figure src="/static/img/docs/tables/sort-descending.png" max-width="350px" alt="Sort descending" class="docs-image--no-shadow" >}} @@ -197,15 +198,19 @@ This option is only available when you're editing the panel. ### Table options -| Option | Description | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Show table header | Show or hide column names imported from your data source. | -| Cell height | Set the height of the cell. Choose from **Small**, **Medium**, or **Large**. | -| Enable pagination | Toggle the switch to control how many table rows are visible at once. When switched on, the page size automatically adjusts to the height of the table. This option doesn't affect queries. | + +| Option | Description | +| -------------------- | --------------------------------------------------------- | +| Show table header | Show or hide column names imported from your data source. | +| Frozen columns | Freeze columns starting from the left side of the table. Enter a value to set how many columns are frozen. | +| Cell height | Set the height of the cell. Choose from **Small**, **Medium**, or **Large**. | +| Enable pagination | Toggle the switch to control how many table rows are visible at once. When switched on, the page size automatically adjusts to the height of the table. This option doesn't affect queries. | | Minimum column width | Define the lower limit of the column width, in pixels. By default, the minimum width of the table column is 150 pixels. For small-screen devices, such as mobile phones or tablets, reduce the value to `50` to allow table-based panels to render correctly in dashboards. | -| Column width | Define a column width, in pixels, rather than allowing the width to be set automatically. By default, Grafana calculates the column width based on the table size and the minimum column width. | -| Column alignment | Set how Grafana should align cell contents. Choose from: **Auto** (default), **Left**, **Center**, or **Right**. | -| Column filter | Temporarily change how column data is displayed. For example, show or hide specific values. For more information, refer to [Column filtering](#column-filtering). | +| Column width | Define a column width, in pixels, rather than allowing the width to be set automatically. By default, Grafana calculates the column width based on the table size and the minimum column width. | +| Column alignment | Set how Grafana should align cell contents. Choose from: **Auto** (default), **Left**, **Center**, or **Right**. | +| Column filter | Temporarily change how column data is displayed. For example, show or hide specific values. For more information, refer to [Column filtering](#column-filtering). | +| Wrap header text | Enables text wrapping for column headers. | + ### Table footer options @@ -238,10 +243,12 @@ If you want to apply a cell type to only some fields instead of all fields, you | [Auto](#auto) | A basic text and number cell. | | [Colored text](#colored-text) | If thresholds, value mappings, or color schemes are set, then the cell text is displayed in the appropriate color. | | [Colored background](#colored-background) | If thresholds, value mappings, or color schemes are set, then the cell background is displayed in the appropriate color. | -| Data links | If you've configured data links, when the cell type is **Auto**, the cell text becomes clickable. If you change the cell type to **Data links**, the cell text reflects the titles of the configured data links. To control the application of data link text more granularly, use a **Cell option > Cell type > Data links** field override. | +| [Data links](#data-links) | The cell text reflects the titles of the configured data links.| | [Gauge](#gauge) | Values are displayed as a horizontal bar gauge. You can set the [Gauge display mode](#gauge-display-mode) and the [Value display](#value-display) options. | | [Sparkline](#sparkline) | Shows values rendered as a sparkline. | | [JSON View](#json-view) | Shows values formatted as code. | +| [Pill](#pill) | Displays each item in a comma-separated string in a colored block. | +| [Markdown + HTML](#markdown--html) | Displays rich markdown or HTML content. | | [Image](#image) | Displays an image when the value is a URL or a base64 encoded image. | | [Actions](#actions) | The cell displays a button that triggers a basic, unauthenticated API call when clicked. | @@ -281,12 +288,23 @@ The colored background cell type has the following options: | ------ | ----------- | | Background display mode | Choose between **Basic** and **Gradient**. | | Apply to entire row | Toggle the switch on to apply the background color that's configured for the cell to the whole row. | -| Wrap text |

Toggle the **Wrap text** switch to wrap text in the cell that contains the longest content in your table. To wrap the text _in a specific column only_, use a **Fields with name** [field override](ref:field-override), select the **Cell options > Cell type** override property, and toggle on the **Wrap text** switch.

Text wrapping is in [public preview](https://grafana.com/docs/release-life-cycle/#public-preview), however, it’s available to use by default.

| +| Wrap text | Toggle the **Wrap text** switch to wrap text in the cell that contains the longest content in your table. To wrap the text _in a specific column only_, use a **Fields with name** [field override](ref:field-override), select the **Cell options > Cell type** override property, and toggle on the **Wrap text** switch. | | Cell value inspect |

Enables value inspection from table cells. When the switch is toggled on, clicking the inspect icon in a cell opens the **Inspect value** drawer which contains two tabs: **Plain text** and **Code editor**.

Grafana attempts to automatically detect the type of data in the cell and opens the drawer with the associated tab showing. However, you can switch back and forth between tabs.

| +#### Data links + +If you've configured data links, when the cell type is **Auto**, the cell text becomes clickable. +If you change the cell type to **Data links**, the cell text reflects the titles of the configured data links. To control the application of data link text more granularly, use a **Cell option > Cell type > Data links** field override. + +Data links cells also support text wrapping. +Toggle the **Wrap text** switch to wrap text in the cell that contains the longest content in your table. +To wrap the text _in a specific column only_, use a **Fields with name** [field override](ref:field-override), select the **Cell options > Cell type** override property, and toggle on the **Wrap text** switch. + + + #### Gauge With this cell type, cells can be displayed as a graphical gauge, with several different presentation types controlled by the [gauge display mode](#gauge-display-mode) and the [value display](#value-display). @@ -358,7 +376,42 @@ This enables value inspection from table cells. When the switch is toggled on, clicking the inspect icon in a cell opens the **Inspect value** drawer which contains two tabs: **Plain text** and **Code editor**. Grafana attempts to automatically detect the type of data in the cell and opens the drawer with the associated tab showing. -However, you can switch back and forth between tabs +However, you can switch back and forth between tabs. + +#### Pill + +The **Pill** cell type displays each item in a comma-separated string in a colored block. + +{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-table-pills-v12.1.png" max-width="750px" alt="Table using the pill cell type" >}} + +The colors applied to each piece of text are maintained throughout the table. +For example, if the word "test" is first displayed in a red pill, it will always be displayed in a red pill. + +Pill cells also support text wrapping. +Toggle the **Wrap text** switch to wrap text in the cell that contains the longest content in your table. +To wrap the text _in a specific column only_, use a **Fields with name** [field override](ref:field-override), select the **Cell options > Cell type** override property, and toggle on the **Wrap text** switch. + + + +The following data formats are supported for the pill cell type: + +- Comma-separated values (`cows,chickens,goats`) +- JSON arrays of uniform (`(["cows","chickens","goats"])`) or mixed (`[1,2,3,"foo",42,"bar"]`) types + +#### Markdown + HTML + +The **Markdown + HTML** cell type displays rich Markdown or HTML content, rendered using the +[GitHub-Flavored Markdown](https://github.github.com/gfm/) spec. This is useful if you need to display +customized, pre-formatted information alongside tabular data, such as formatted strings, +lists of links, or other dynamic cases. + +For this cell type, you can toggle the **Dynamic height** switch, which allows the cell to resize +dynamically based on the cell content. If you use dynamic height, we strongly recommend that you +also toggle on **Pagination** to avoid performance issues in larger tables, since enabling +Dynamic height disables table {{< term "virtualization" >}}virtualization{{< /term >}}. + +By default, the HTML rendered is sanitized, and un-sanitized HTML can only be rendered +in these cells if the [`disable_sanitize_html`](https://grafana.com/docs/grafana//setup-grafana/configure-grafana/#disable_sanitize_html) option is set to true for your Grafana instance. #### Image diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 49062997533..c7ec80a9662 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -73,10 +73,10 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `alertingNotificationsStepMode` | Enables simplified step mode in the notifications section | Yes | | `lokiLabelNamesQueryApi` | Defaults to using the Loki `/labels` API instead of `/series` | Yes | | `improvedExternalSessionHandlingSAML` | Enables improved support for SAML external sessions. Ensure the NameID format is correctly configured in Grafana for SAML Single Logout to function properly. | Yes | -| `teamHttpHeadersMimir` | Enables LBAC for datasources for Mimir to apply LBAC filtering of metrics to the client requests for users in teams | Yes | | `alertingMigrationUI` | Enables the alerting migration UI, to migrate data source-managed rules to Grafana-managed rules | Yes | | `alertingImportYAMLUI` | Enables a UI feature for importing rules from a Prometheus file to Grafana-managed rules | Yes | | `unifiedNavbars` | Enables unified navbars | | +| `grafanaAssistantInProfilesDrilldown` | Enables integration with Grafana Assistant in Profiles Drilldown | Yes | | `tabularNumbers` | Use fixed-width numbers globally in the UI | | ## Public preview feature toggles @@ -91,7 +91,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `refactorVariablesTimeRange` | Refactor time range variables flow to reduce number of API calls made when query variables are chained | | `faroDatasourceSelector` | Enable the data source selector within the Frontend Apps section of the Frontend Observability | | `enableDatagridEditing` | Enables the edit functionality in the datagrid panel | -| `sqlDatasourceDatabaseSelection` | Enables previous SQL data source dataset dropdown behavior | | `reportingRetries` | Enables rendering retries for the reporting feature | | `externalServiceAccounts` | Automatic service account and token setup for plugins | | `cloudWatchBatchQueries` | Runs CloudWatch metrics queries as separate batches | diff --git a/docs/sources/setup-grafana/configure-security/audit-grafana.md b/docs/sources/setup-grafana/configure-security/audit-grafana.md index 597b4269faa..624c81432c4 100644 --- a/docs/sources/setup-grafana/configure-security/audit-grafana.md +++ b/docs/sources/setup-grafana/configure-security/audit-grafana.md @@ -420,9 +420,11 @@ tenant_id = If you have multiple Grafana instances sending logs to the same Loki service or if you are using Loki for non-audit logs, audit logs come with additional labels to help identifying them: -- **host** - OS hostname on which the Grafana instance is running. -- **grafana_instance** - Application URL. -- **kind** - `auditing` +| Label | Value | +| ---------------- | ---------------------------------------------------- | +| host | OS hostname on which the Grafana instance is running | +| grafana_instance | Application URL | +| kind | `auditing` | When basic authentication is needed to ingest logs in your Loki instance, you can specify credentials in the URL field. For example: diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md index f3d2c5bea74..f1d507bd526 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/azuread/index.md @@ -462,7 +462,7 @@ You can make Grafana always get group information from the Microsoft Graph API b 1. Under the **GroupMember** section, select **GroupMember.Read.All**. 1. Click **Add permissions**. 1. Select **Microsoft Graph** from the list of APIs. -1. Select **Delegated permissions**.. +1. Select **Delegated permissions**. 1. In the **Select permissions** pane, under the **User** section, select **User.Read**. 1. Click the **Add permissions** button at the bottom of the page. @@ -472,9 +472,10 @@ Admin consent may be required for this permission. ### Force fetching groups from Microsoft Graph API -To force fetching groups from Microsoft Graph API instead of the `id_token`. You can use the `force_use_graph_api` config option. +To force fetching groups from Microsoft Graph API instead of the `id_token`, you can use the `force_use_graph_api` configuration option. -``` +```ini +[auth.azuread] force_use_graph_api = true ``` diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/saml/configure-saml-with-azuread/_index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/saml/configure-saml-with-azuread/_index.md index 97942f0341a..ee2784c3aba 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/saml/configure-saml-with-azuread/_index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/saml/configure-saml-with-azuread/_index.md @@ -124,7 +124,7 @@ This app registration will be used as a Service Account to retrieve more informa 1. In the **Request API permissions** pane, select **Microsoft Graph**, and click **Delegated permissions**. 1. In the **Select permissions** pane, under the **User** section, select **User.Read**. 1. Click the **Add permissions** button at the bottom of the page. -1. In the **API permissions** section, select **Grant admin consent for **. +1. In the **API permissions** section, select **Grant admin consent for ``**. The following table shows what the permissions look like from the Entra ID portal: @@ -135,3 +135,5 @@ The following table shows what the permissions look like from the Entra ID porta | `User.Read.All` | Application | Yes | Granted | {{< figure src="/media/docs/grafana/saml/graph-api-app-permissions.png" caption="Screen shot of the permissions listed in Entra ID for the App registration" >}} + +To test that Graph API has the correct permissions, refer to the [Troubleshoot Graph API calls](../troubleshoot-saml/#troubleshoot-graph-api-calls) section. diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/saml/troubleshoot-saml/_index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/saml/troubleshoot-saml/_index.md index 326d480ca1b..cd11cbf81b3 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/saml/troubleshoot-saml/_index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/saml/troubleshoot-saml/_index.md @@ -107,3 +107,50 @@ cookie_secure = true ``` Ensure `cookie_secure` is set to true to ensure that cookies are only sent over HTTPS. + +### Troubleshoot Graph API calls + +When setting up SAML authentication with Azure AD, you may encounter issues with Graph API calls. This can happen if the Azure AD application is not properly configured to allow Graph API access. + +To help in the troubleshooting process, test the Graph API calls using the following commands: + +```bash +curl -X POST "{token_url}" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials&client_id={client_id}&client_secret={client_secret}&scope=https://graph.microsoft.com/.default" +``` + +Where the following values come from your [SAML configuration](../saml-configuration-options/_index.md#saml-configuration-options): + +- `token_url`: The token URL of your Azure AD application. +- `client_id`: The client ID of your Azure AD application. +- `client_secret`: The client secret of your Azure AD application. + +The response should look like: + +```json +{ + "access_token": "...ACCESS_TOKEN...", + "token_type": "Bearer", + "expires_in": 3600 +} +``` + +Use the `access_token` to test the Graph API calls. + +```bash +curl -X GET "https://graph.microsoft.com/v1.0/groups" \ + -H "Authorization: Bearer ${access_token}" \ + -H "Content-Type: application/json" +``` + +The response should look like: + +```json +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#Collection(Edm.String)", + "value": ["29f2e7c8-9b9d-443c-bc62-7d8cdcfcfe59", "f0224e82-0eb8-4eda-8979-0c36e98deb00"] +} +``` + +If the second call fails due to 401 or 403, you may need to check the Azure AD application settings to ensure that Graph API access is enabled. diff --git a/docs/sources/shared/visualizations/cell-options.md b/docs/sources/shared/visualizations/cell-options.md index 03c0e4a76dc..f2382fbee84 100644 --- a/docs/sources/shared/visualizations/cell-options.md +++ b/docs/sources/shared/visualizations/cell-options.md @@ -5,6 +5,6 @@ title: Cell options | Option | Description | | ------ | ----------- | -| Wrap text |

Toggle the **Wrap text** switch to wrap text in the cell that contains the longest content in your table. To wrap the text _in a specific column only_, use a **Fields with name** [field override](https://grafana.com/docs/grafana//panels-visualizations/configure-overrides/), select the **Cell options > Cell type** override property, and toggle on the **Wrap text** switch.

Text wrapping is in [public preview](https://grafana.com/docs/release-life-cycle/#public-preview), however, it’s available to use by default.

| +| Wrap text | Toggle the **Wrap text** switch to wrap text in the cell that contains the longest content in your table. To wrap the text _in a specific column only_, use a **Fields with name** [field override](https://grafana.com/docs/grafana//panels-visualizations/configure-overrides/), select the **Cell options > Cell type** override property, and toggle on the **Wrap text** switch. | | Cell value inspect |

Enables value inspection from table cells. When the switch is toggled on, clicking the inspect icon in a cell opens the **Inspect value** drawer which contains two tabs: **Plain text** and **Code editor**.

Grafana attempts to automatically detect the type of data in the cell and opens the drawer with the associated tab showing. However, you can switch back and forth between tabs.

| diff --git a/docs/sources/troubleshooting/har-captures/index.md b/docs/sources/troubleshooting/har-captures/index.md new file mode 100644 index 00000000000..1db407bd84b --- /dev/null +++ b/docs/sources/troubleshooting/har-captures/index.md @@ -0,0 +1,72 @@ +--- +description: Learn how to generate a HAR capture to send to Grafana Labs support for troubleshooting +keywords: + - grafana + - troubleshooting + - networking +labels: + products: + - cloud + - enterprise + - oss +menutitle: Generate a HAR capture +title: Generate a HAR capture to send to Grafana Labs support +weight: 300 +--- + +# Troubleshoot with HAR captures + +Support may request information about the network requests your browser generated in the form of a HAR file. + +To generate a HAR capture, follow the steps for your browser: + +## Chrome + +1. In your Chrome browser, go to the page where the issue is occurring. +1. Click the menu icon and go to **More Tools** > **Developer Tools**. +1. In the drawer that opens, click the **Network** tab. +1. In the upper left hand corner of the drawer, make sure the round Record button is red. If it's grey, click the button to begin recording. +1. Select the **Preserve log** option box if it isn't already selected. +1. Click the grey crossed circle button to clear any existing requests from the panel. +1. Reproduce the issue while recording the network requests. +1. Click the download button to download the HAR file. + image +1. Upload the HAR file to your Grafana Support ticket for investigation. + +## Firefox + +1. In your Firefox browser, go to the page where the issue is occurring. +1. Click the menu icon and go to **More Tools** > **Web Developer Tools**. +1. In the drawer that opens, click the **Network** tab. +1. In the upper left corner of the drawer, click the trash can icon to clear any existing requests from the recording. +1. Reproduce the issue while recording the network requests. +1. To save the HAR file, click the gear icon in the upper right hand corner of the panel and select **Save All As HAR**. + image +1. Upload the HAR file to your Grafana Support ticket for investigation. + +## Safari + +1. In your Safari browser, go to the page where the issue is occurring. +1. Open the **Develop** menu option and select **Show Web Inspector**. + {{< admonition type="note" >}} + The **Show features for web developers** option needs to be enabled for the **Develop** option to appear. For more information, refer to [Use the developer tools in the Develop menu in Safari on Mac](https://support.apple.com/en-ie/guide/safari/sfri20948/mac). + {{< /admonition >}} +1. In the panel that appears, click the **Network** tab. +1. In the upper right corner of the panel, click the trash can icon to clear any existing requests from the recording. +1. Reproduce the issue while recording the network requests. +1. Click the **Export** button to save the HAR file: + image +1. Upload the HAR file to your Grafana Support ticket for investigation. + +## Edge + +1. In your Edge browser, go to the page where the issue is occurring. +1. Click the ... button and go to **More Tools** > **Developer Tools**. +1. In the panel that appears, click on the **Network** tab. +1. In the upper left hand corner of the panel, make sure the round Record button is red. If it's grey, click the button to begin recording +1. Verify that the **Preserve log** option box is checked. +1. Click the grey crossed circle button to clear any existing requests from the panel. +1. Reproduce the issue while the network requests are recorded. +1. Click the download button to save the HAR file: + image +1. Upload the HAR file to your Grafana Support ticket for investigation. diff --git a/e2e-playwright/dashboard-new-layouts/dashboards-edit-panel-transparent-bg.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboards-edit-panel-transparent-bg.spec.ts index d483b7b8005..a7ef95b1f4d 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboards-edit-panel-transparent-bg.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboards-edit-panel-transparent-bg.spec.ts @@ -34,7 +34,7 @@ test.describe( const initialBackground = await panelTitle.evaluate((el) => getComputedStyle(el).background); expect(initialBackground).not.toMatch(/rgba\(0, 0, 0, 0\)/); - await page.locator('#transparent-background').click({ force: true }); + await page.getByRole('switch', { name: 'Transparent background' }).click({ force: true }); const transparentBackground = await panelTitle.evaluate((el) => getComputedStyle(el).background); expect(transparentBackground).toMatch(/rgba\(0, 0, 0, 0\)/); diff --git a/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts b/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts index 2d21b0b6af5..3dc86271227 100644 --- a/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts @@ -17,7 +17,7 @@ test.describe( tag: ['@dashboards'], }, () => { - test('Tests dashboard time zone scenarios', async ({ page, gotoDashboardPage, selectors }) => { + test.fixme('Tests dashboard time zone scenarios', async ({ page, gotoDashboardPage, selectors }) => { const dashboardPage = await gotoDashboardPage({ uid: TIMEZONE_DASHBOARD_UID }); const fromTimeZone = 'UTC'; diff --git a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts index 8ac43ceebb1..739646813f8 100644 --- a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts +++ b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts @@ -1,6 +1,6 @@ import { Page, Locator } from '@playwright/test'; -import { test, expect } from '@grafana/plugin-e2e'; +import { test, expect, E2ESelectorGroups } from '@grafana/plugin-e2e'; const DASHBOARD_UID = 'dcb9f5e9-8066-4397-889e-864b99555dbb'; @@ -41,6 +41,21 @@ const getColumnIdx = async (loc: Page | Locator, columnName: string) => { return result; }; +const disableAllTextWrap = async (loc: Page | Locator, selectors: E2ESelectorGroups) => { + // disable text wrapping for all of the columns, since long text with links in them can push the links off the screen. + const wrapTextToggle = loc.locator( + `[aria-label="${selectors.components.PanelEditor.OptionsPane.fieldLabel('Wrap text')}"]` + ); + const count = await wrapTextToggle.count(); + + for (let i = 0; i < count; i++) { + const toggle = wrapTextToggle.nth(i); + if ((await toggle.locator('//preceding-sibling::input').getAttribute('checked')) !== null) { + await toggle.click(); + } + } +}; + test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table'] }, () => { test('Tests word wrap, hover overflow, and cell inspect', async ({ gotoDashboardPage, selectors, page }) => { const dashboardPage = await gotoDashboardPage({ @@ -80,8 +95,8 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table'] await dashboardPage .getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.fieldLabel('Cell options Cell value inspect')) .first() - .locator('label[for="custom.inspect"]') - .click(); + .getByRole('switch', { name: 'Cell value inspect' }) + .click({ force: true }); await loremIpsumCell.hover(); await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeLessThan(100); @@ -214,11 +229,7 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table'] // because of text wrapping, we're guaranteed to only be showing a single row when we enable pagination. await expect(page.getByText(/([\d]+) - ([\d]+) of ([\d]+) rows/)).toBeVisible(); - // FIXME horrible selector for the "Wrap text" toggle for the "Long text" column. - await page - .locator('[id="Override 13"]') - .locator(`[aria-label="${selectors.components.PanelEditor.OptionsPane.fieldLabel('Wrap text')}"]`) - .click(); + await disableAllTextWrap(page, selectors); // any number of rows that is not "1" is allowed here, we don't want to police the exact number of rows that // are rendered since there are tons of factors which could effect this. we do want to grab this number for comparison @@ -281,13 +292,7 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table'] dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')) ).toBeVisible(); - // disable text wrapping for this test to make it easier to click the links, the long lorem ipsum - // can push the links off the screen. - // FIXME very bad selector to get the correct "wrap text" toggle here. - await page - .locator('[id="Override 13"]') - .locator(`[aria-label="${selectors.components.PanelEditor.OptionsPane.fieldLabel('Wrap text')}"]`) - .click(); + await disableAllTextWrap(page, selectors); const infoColumnIdx = await getColumnIdx(page, 'Info'); const pillColIdx = await getColumnIdx(page, 'Pills'); @@ -348,10 +353,73 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table'] // TODO -- saving for another day. }); + test('Tests tooltip interactions', async ({ gotoDashboardPage, selectors }) => { + const dashboardPage = await gotoDashboardPage({ + uid: DASHBOARD_UID, + queryParams: new URLSearchParams({ editPanel: '1' }), + }); + + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')) + ).toBeVisible(); + + const firstCaret = dashboardPage + .getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Tooltip.Caret) + .first(); + + // test hovering over and blurring the caret, and whether the tooltip appears and disappears as expected. + await firstCaret.hover(); + + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Tooltip.Wrapper) + ).toBeVisible(); + + await dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')).hover(); + + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Tooltip.Wrapper) + ).not.toBeVisible(); + + // when a pinned tooltip is open, clicking outside of it should close it. + await firstCaret.click(); + + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Tooltip.Wrapper) + ).toBeVisible(); + + await dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')).click(); + + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Tooltip.Wrapper) + ).not.toBeVisible(); + + // when a pinned tooltip is open, clicking inside of it should NOT close it. + await firstCaret.click(); + + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Tooltip.Wrapper) + ).toBeVisible(); + + const tooltip = dashboardPage.getByGrafanaSelector( + selectors.components.Panels.Visualization.TableNG.Tooltip.Wrapper + ); + await tooltip.click(); + + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Tooltip.Wrapper) + ).toBeVisible(); + + await dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')).click(); + + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Tooltip.Wrapper) + ).not.toBeVisible(); + }); + test('Empty Table panel', async ({ gotoDashboardPage, selectors }) => { const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID, - queryParams: new URLSearchParams({ editPanel: '2' }), + queryParams: new URLSearchParams({ editPanel: '3' }), }); await expect( diff --git a/e2e-playwright/plugin-e2e/mysql/mocks/mysql.mocks.ts b/e2e-playwright/plugin-e2e/mysql/mocks/mysql.mocks.ts index 63718c6d1a4..0b235c01a31 100644 --- a/e2e-playwright/plugin-e2e/mysql/mocks/mysql.mocks.ts +++ b/e2e-playwright/plugin-e2e/mysql/mocks/mysql.mocks.ts @@ -64,7 +64,7 @@ export const datasetResponse = { }, fields: [{ name: 'TABLE_SCHEMA', type: 'string', typeInfo: { frame: 'string', nullable: true } }], }, - data: { values: [['DataMaker', 'mysql', 'performance_schema', 'sys']] }, + data: { values: [['grafana', 'mysql', 'performance_schema', 'sys']] }, }, ], }, diff --git a/e2e-playwright/plugin-e2e/mysql/visual-query-builder.spec.ts b/e2e-playwright/plugin-e2e/mysql/visual-query-builder.spec.ts index 9cf6c580927..e54c8749300 100644 --- a/e2e-playwright/plugin-e2e/mysql/visual-query-builder.spec.ts +++ b/e2e-playwright/plugin-e2e/mysql/visual-query-builder.spec.ts @@ -48,7 +48,7 @@ test.describe( await expect( explorePage.getByGrafanaSelector(selectors.components.CodeEditor.container).getByRole('textbox') ).toHaveValue( - `SELECT\n $__timeGroupAlias(createdAt, $__interval),\n AVG(\`bigint\`)\nFROM\n DataMaker.normalTable\nLIMIT\n 50` + `SELECT\n $__timeGroupAlias(createdAt, $__interval),\n AVG(\`bigint\`)\nFROM\n grafana.normalTable\nLIMIT\n 50` ); }); @@ -83,9 +83,7 @@ test.describe( // Validate that the timeFilter macro was added await expect( explorePage.getByGrafanaSelector(selectors.components.CodeEditor.container).getByRole('textbox') - ).toHaveValue( - `SELECT\n createdAt\nFROM\n DataMaker.normalTable\nWHERE\n $__timeFilter(createdAt)\nLIMIT\n 50` - ); + ).toHaveValue(`SELECT\n createdAt\nFROM\n grafana.normalTable\nWHERE\n $__timeFilter(createdAt)\nLIMIT\n 50`); // Validate that the timeFilter macro was removed when changed to equals operator await explorePage.getByGrafanaSelector(selectors.components.SQLQueryEditor.filterOperator).click(); @@ -96,7 +94,7 @@ test.describe( await expect( explorePage.getByGrafanaSelector(selectors.components.CodeEditor.container).getByRole('textbox') - ).not.toHaveValue(`SELECT\n createdAt\nFROM\n DataMaker.normalTable\nWHERE\n createdAt = NULL\nLIMIT\n 50`); + ).not.toHaveValue(`SELECT\n createdAt\nFROM\n grafana.normalTable\nWHERE\n createdAt = NULL\nLIMIT\n 50`); }); test('visual query builder should not crash when filter is set to select_any_in', async ({ explorePage, page }) => { @@ -114,7 +112,7 @@ test.describe( uid: 'P4FDCC188E688367F', }, format: 'table', - rawSql: "SELECT * FROM DataMaker.normalTable WHERE name IN ('a') LIMIT 50 ", + rawSql: "SELECT * FROM grafana.normalTable WHERE name IN ('a') LIMIT 50 ", editorMode: 'builder', sql: { columns: [ @@ -157,7 +155,7 @@ test.describe( }, whereString: "name IN ('a')", }, - dataset: 'DataMaker', + dataset: 'grafana', table: 'normalTable', }, ], @@ -170,7 +168,7 @@ test.describe( // Validate the query await expect( explorePage.getByGrafanaSelector(selectors.components.CodeEditor.container).getByRole('textbox') - ).toHaveValue(`SELECT\n *\nFROM\n DataMaker.normalTable\nWHERE\n name IN ('a')\nLIMIT\n 50`); + ).toHaveValue(`SELECT\n *\nFROM\n grafana.normalTable\nWHERE\n name IN ('a')\nLIMIT\n 50`); }); } ); diff --git a/e2e-playwright/smoke-tests-suite/panels.spec.ts b/e2e-playwright/smoke-tests-suite/panels.spec.ts index c65bd0d28ce..822b0ad90b4 100644 --- a/e2e-playwright/smoke-tests-suite/panels.spec.ts +++ b/e2e-playwright/smoke-tests-suite/panels.spec.ts @@ -1,5 +1,5 @@ +import { BootData } from '@grafana/data'; import { test, expect } from '@grafana/plugin-e2e'; -import { GrafanaBootConfig } from '@grafana/runtime'; test.describe( 'Panels smokescreen', @@ -25,7 +25,7 @@ test.describe( const panelTypes = await page.evaluate(() => { // @grafana/plugin-e2e doesn't export the full bootdata config // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const win = window as typeof window & { grafanaBootData: GrafanaBootConfig['bootData'] }; + const win = window as typeof window & { grafanaBootData: BootData }; return win.grafanaBootData?.settings?.panels ?? {}; }); diff --git a/e2e-playwright/various-suite/inspect-drawer.spec.ts b/e2e-playwright/various-suite/inspect-drawer.spec.ts index 60f0dd42b06..4d18de6677b 100644 --- a/e2e-playwright/various-suite/inspect-drawer.spec.ts +++ b/e2e-playwright/various-suite/inspect-drawer.spec.ts @@ -61,8 +61,7 @@ test.describe( await expect(queryTab).toBeVisible(); // Query should be the active tab - const activeTab = page.locator('a[class*="-activeTabStyle"]'); - await expect(activeTab).toHaveText('Query'); + await expect(queryTab).toHaveClass(/.*-activeTabStyle/); const queryContent = dashboardPage.getByGrafanaSelector(selectors.components.PanelInspector.Query.content); await expect(queryContent).toBeVisible(); diff --git a/e2e-playwright/various-suite/solo-route.spec.ts b/e2e-playwright/various-suite/solo-route.spec.ts index 81c9e8baf7b..63dd4e6439d 100644 --- a/e2e-playwright/various-suite/solo-route.spec.ts +++ b/e2e-playwright/various-suite/solo-route.spec.ts @@ -35,12 +35,12 @@ test.describe( test('Can view solo repeated panel in scenes', async ({ page, selectors }) => { // open Panel Tests - Graph NG const soloPanelUrl = selectors.pages.SoloPanel.url( - 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-0&__feature.dashboardSceneSolo=true' + 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-1&__feature.dashboardSceneSolo=true' ); await page.goto(soloPanelUrl); // Check that the panel title exists - const panelTitle = page.getByTestId(selectors.components.Panels.Panel.title('server=A')); + const panelTitle = page.getByTestId(selectors.components.Panels.Panel.title('server=B')); await expect(panelTitle).toBeVisible(); // Check that uplot-main-div does not exist diff --git a/e2e/old-arch/panels-suite/geomap-map-controls.spec.ts b/e2e/old-arch/panels-suite/geomap-map-controls.spec.ts index b344dae5abe..fcadcb55a47 100644 --- a/e2e/old-arch/panels-suite/geomap-map-controls.spec.ts +++ b/e2e/old-arch/panels-suite/geomap-map-controls.spec.ts @@ -17,35 +17,35 @@ describe('Geomap layer controls options', () => { e2e.components.PanelEditor.showZoomField() .should('be.visible') .within(() => { - cy.get('input[type="checkbox"]').check({ force: true }).should('be.checked'); + cy.get('input[type="checkbox"]').check({ force: true }); }); // Show attribution e2e.components.PanelEditor.showAttributionField() .should('be.visible') .within(() => { - cy.get('input[type="checkbox"]').check({ force: true }).should('be.checked'); + cy.get('input[type="checkbox"]').check({ force: true }); }); // Show scale e2e.components.PanelEditor.showScaleField() .should('be.visible') .within(() => { - cy.get('input[type="checkbox"]').check({ force: true }).should('be.checked'); + cy.get('input[type="checkbox"]').check({ force: true }); }); // Show measure tool e2e.components.PanelEditor.showMeasureField() .should('be.visible') .within(() => { - cy.get('input[type="checkbox"]').check({ force: true }).should('be.checked'); + cy.get('input[type="checkbox"]').check({ force: true }); }); // Show debug e2e.components.PanelEditor.showDebugField() .should('be.visible') .within(() => { - cy.get('input[type="checkbox"]').check({ force: true }).should('be.checked'); + cy.get('input[type="checkbox"]').check({ force: true }); }); e2e.components.Panels.Panel.content({ timeout: TIMEOUT }) diff --git a/e2e/old-arch/smoke-tests-suite/panels_smokescreen.spec.ts b/e2e/old-arch/smoke-tests-suite/panels_smokescreen.spec.ts index 4e546c88e1b..36f091803d7 100644 --- a/e2e/old-arch/smoke-tests-suite/panels_smokescreen.spec.ts +++ b/e2e/old-arch/smoke-tests-suite/panels_smokescreen.spec.ts @@ -1,4 +1,4 @@ -import { GrafanaBootConfig } from '@grafana/runtime'; +import { BootData } from '@grafana/data'; import { e2e } from '../utils'; @@ -22,7 +22,7 @@ describe('Panels smokescreen', () => { visitDashboardAtStart: false, }); - cy.window().then((win: Cypress.AUTWindow & { grafanaBootData: GrafanaBootConfig['bootData'] }) => { + cy.window().then((win: Cypress.AUTWindow & { grafanaBootData: BootData }) => { // Loop through every panel type and ensure no crash Object.entries(win.grafanaBootData.settings.panels).forEach(([_, panel]) => { // TODO: Remove Flame Graph check as part of addressing #66803 diff --git a/e2e/old-arch/various-suite/solo-route.spec.ts b/e2e/old-arch/various-suite/solo-route.spec.ts index 415257ead7c..2abd2717d5b 100644 --- a/e2e/old-arch/various-suite/solo-route.spec.ts +++ b/e2e/old-arch/various-suite/solo-route.spec.ts @@ -25,10 +25,10 @@ describe('Solo Route', () => { it('Can view solo repeated panel in scenes', () => { // open Panel Tests - Graph NG e2e.pages.SoloPanel.visit( - 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-0&__feature.dashboardSceneSolo=true' + 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-1&__feature.dashboardSceneSolo=true' ); - e2e.components.Panels.Panel.title('server=A').should('exist'); + e2e.components.Panels.Panel.title('server=B').should('exist'); cy.contains('uplot-main-div').should('not.exist'); }); diff --git a/e2e/various-suite/solo-route.spec.ts b/e2e/various-suite/solo-route.spec.ts index 415257ead7c..2abd2717d5b 100644 --- a/e2e/various-suite/solo-route.spec.ts +++ b/e2e/various-suite/solo-route.spec.ts @@ -25,10 +25,10 @@ describe('Solo Route', () => { it('Can view solo repeated panel in scenes', () => { // open Panel Tests - Graph NG e2e.pages.SoloPanel.visit( - 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-0&__feature.dashboardSceneSolo=true' + 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-1&__feature.dashboardSceneSolo=true' ); - e2e.components.Panels.Panel.title('server=A').should('exist'); + e2e.components.Panels.Panel.title('server=B').should('exist'); cy.contains('uplot-main-div').should('not.exist'); }); diff --git a/go.mod b/go.mod index 8de5333353b..e5f070f04d1 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/grafana/grafana -go 1.24.5 +go 1.24.6 require ( buf.build/gen/go/parca-dev/parca/connectrpc/go v1.18.1-20250703125925-3f0fcf4bff96.1 // @grafana/observability-traces-and-profiling @@ -85,7 +85,7 @@ require ( github.com/googleapis/gax-go/v2 v2.14.2 // @grafana/grafana-backend-group github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // @grafana/grafana-app-platform-squad - github.com/grafana/alerting v0.0.0-20250729175202-b4b881b7b263 // @grafana/alerting-backend + github.com/grafana/alerting v0.0.0-20250812203446-a38b2187d4e1 // @grafana/alerting-backend github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43 // @grafana/identity-access-team github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43 // @grafana/identity-access-team github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics @@ -518,7 +518,7 @@ require ( github.com/oklog/ulid/v2 v2.1.0 // indirect github.com/open-feature/go-sdk-contrib/providers/ofrep v0.1.5 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opentracing-contrib/go-stdlib v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect diff --git a/go.sum b/go.sum index aa7c88a369c..45bec762338 100644 --- a/go.sum +++ b/go.sum @@ -1580,8 +1580,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grafana/alerting v0.0.0-20250729175202-b4b881b7b263 h1:hcr/AmPB0KL4H+gCEFIdKUnkihTxGAkAOiZA7GDYoL8= -github.com/grafana/alerting v0.0.0-20250729175202-b4b881b7b263/go.mod h1:VKxaR93Gff0ZlO2sPcdPVob1a/UzArFEW5zx3Bpyhls= +github.com/grafana/alerting v0.0.0-20250812203446-a38b2187d4e1 h1:nn19bwMFBGWOvUTxZsdewOfRiXERhKHp0jEira8uQA8= +github.com/grafana/alerting v0.0.0-20250812203446-a38b2187d4e1/go.mod h1:VKxaR93Gff0ZlO2sPcdPVob1a/UzArFEW5zx3Bpyhls= github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43 h1:vVPT0i5Y1vI6qzecYStV2yk7cHKrC3Pc7AgvwT5KydQ= github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43/go.mod h1:1fWkOiL+m32NBgRHZtlZGz2ji868tPZACYbqP3nBRJI= github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43 h1:NlkGMnVi/oUn6Cr90QbJYpQJ4FnjyAIG9Ex5GtTZIzw= @@ -2108,8 +2108,8 @@ github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= -github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= -github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/openfga/api/proto v0.0.0-20250127102726-f9709139a369 h1:wEsCZ4oBuu8LfEJ3VXbveXO8uEhCthrxA40WSvxO044= github.com/openfga/api/proto v0.0.0-20250127102726-f9709139a369/go.mod h1:m74TNgnAAIJ03gfHcx+xaRWnr+IbQy3y/AVNwwCFrC0= github.com/openfga/language/pkg/go v0.2.0-beta.2.0.20250220223040-ed0cfba54336 h1:pYuYanFfgYrvDoSu/nnThT9P60mw5Yx7PMEI7FYychM= diff --git a/go.work b/go.work index 50347ae04ba..737d3363e91 100644 --- a/go.work +++ b/go.work @@ -1,4 +1,4 @@ -go 1.24.5 +go 1.24.6 // The `skip:golangci-lint` comment tag is used to exclude the package from the `golangci-lint` GitHub Action. // The module at the root of the repo (`.`) is excluded because ./pkg/... is included manually in the `golangci-lint` configuration. diff --git a/go.work.sum b/go.work.sum index 34c0540f8ce..9702910b972 100644 --- a/go.work.sum +++ b/go.work.sum @@ -629,6 +629,8 @@ github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3 h1:ZSTrOEhi github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e h1:QEF07wC0T1rKkctt1RINW/+RMTVmiwxETico2l3gxJA= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6 h1:G1bPvciwNyF7IUmKXNt9Ak3m6u9DE1rF+RmtIkBpVdA= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/atc0005/go-teams-notify/v2 v2.13.0 h1:nbDeHy89NjYlF/PEfLVF6lsserY9O5SnN1iOIw3AxXw= github.com/atc0005/go-teams-notify/v2 v2.13.0/go.mod h1:WSv9moolRsBcpZbwEf6gZxj7h0uJlJskJq5zkEWKO8Y= github.com/aws/aws-lambda-go v1.47.0 h1:0H8s0vumYx/YKs4sE7YM0ktwL2eWse+kfopsRI1sXVI= @@ -639,6 +641,8 @@ github.com/aws/aws-sdk-go-v2/service/kinesis v1.33.0 h1:JPXkrQk5OS/+Q81fKH97Ll/V github.com/aws/aws-sdk-go-v2/service/kinesis v1.33.0/go.mod h1:dJngkoVMrq0K7QvRkdRZYM4NUp6cdWa2GBdpm8zoY8U= github.com/aws/aws-sdk-go-v2/service/kms v1.35.3 h1:UPTdlTOwWUX49fVi7cymEN6hDqCwe3LNv1vi7TXUutk= github.com/aws/aws-sdk-go-v2/service/kms v1.35.3/go.mod h1:gjDP16zn+WWalyaUqwCCioQ8gU8lzttCCc9jYsiQI/8= +github.com/aws/aws-sdk-go-v2/service/kms v1.38.1 h1:tecq7+mAav5byF+Mr+iONJnCBf4B4gon8RSp4BrweSc= +github.com/aws/aws-sdk-go-v2/service/kms v1.38.1/go.mod h1:cQn6tAF77Di6m4huxovNM7NVAozWTZLsDRp9t8Z/WYk= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.32.4 h1:NgRFYyFpiMD62y4VPXh4DosPFbZd4vdMVBWKk0VmWXc= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.32.4/go.mod h1:TKKN7IQoM7uTnyuFm9bm9cw5P//ZYTl4m3htBWQ1G/c= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.35.2 h1:vlYXbindmagyVA3RS2SPd47eKZ00GZZQcr+etTviHtc= @@ -775,10 +779,12 @@ github.com/d4l3k/messagediff v1.2.1 h1:ZcAIMYsUg0EAp9X+tt8/enBE/Q8Yd5kzPynLyKptt github.com/d4l3k/messagediff v1.2.1/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo= github.com/davecgh/go-xdr v0.0.0-20161123171359-e6a2ba005892 h1:qg9VbHo1TlL0KDM0vYvBG9EY0X0Yku5WYIPoFWt8f6o= github.com/davecgh/go-xdr v0.0.0-20161123171359-e6a2ba005892/go.mod h1:CTDl0pzVzE5DEzZhPfvhY/9sPFMQIxaJ9VAMs9AagrE= +github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA= github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc= github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g= github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY= github.com/denisenkom/go-mssqldb v0.0.0-20190515213511-eb9f6a1743f3 h1:tkum0XDgfR0jcVVXuTsYv/erY2NnEDqwRojbxR1rBYA= +github.com/dgryski/go-ddmin v0.0.0-20210904190556-96a6d69f1034 h1:BuCyszxPxUjBrYW2HNVrimC0rBUs2U27jCJGVh0IKTM= github.com/dgryski/go-ddmin v0.0.0-20210904190556-96a6d69f1034/go.mod h1:zz4KxBkcXUWKjIcrc+uphJ1gPh/t18ymGm3PmQ+VGTk= github.com/dgryski/go-sip13 v0.0.0-20190329191031-25c5027a8c7b h1:Yqiad0+sloMPdd/0Fg22actpFx0dekpzt1xJmVNVkU0= github.com/dhui/dktest v0.3.0 h1:kwX5a7EkLcjo7VpsPQSYJcKGbXBXdjI9FGjuUj1jn6I= @@ -1189,6 +1195,8 @@ github.com/mithrandie/readline-csvq v1.3.0 h1:VTJEOGouJ8j27jJCD4kBBbNTxM0OdBvE1a github.com/mithrandie/readline-csvq v1.3.0/go.mod h1:FKyYqDgf/G4SNov7SMFXRWO6LQLXIOeTog/NB97FZl0= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= +github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= @@ -1201,6 +1209,7 @@ github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8 github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/mostynb/go-grpc-compression v1.2.3 h1:42/BKWMy0KEJGSdWvzqIyOZ95YcR9mLPqKctH7Uo//I= github.com/mostynb/go-grpc-compression v1.2.3/go.mod h1:AghIxF3P57umzqM9yz795+y1Vjs47Km/Y2FE6ouQ7Lg= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= github.com/nakagami/firebirdsql v0.0.0-20190310045651-3c02a58cfed8 h1:P48LjvUQpTReR3TQRbxSeSBsMXzfK0uol7eRcr7VBYQ= github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= diff --git a/hack/go.mod b/hack/go.mod index b1685be7028..3ec36848618 100644 --- a/hack/go.mod +++ b/hack/go.mod @@ -1,6 +1,6 @@ module github.com/grafana/grafana/hack -go 1.24.5 +go 1.24.6 require k8s.io/code-generator v0.33.1 diff --git a/nx.json b/nx.json index e60803eee58..f5f0322645b 100644 --- a/nx.json +++ b/nx.json @@ -8,5 +8,8 @@ "cache": true } }, + "tui": { + "autoExit": true + }, "defaultBase": "main" } diff --git a/package.json b/package.json index 90c32b62b0e..1c326a3352d 100644 --- a/package.json +++ b/package.json @@ -223,7 +223,7 @@ "msw": "2.10.4", "mutationobserver-shim": "0.3.7", "node-notifier": "10.0.1", - "nx": "20.7.1", + "nx": "21.3.11", "openapi-types": "^12.1.3", "pa11y-ci": "^4.0.0", "pdf-parse": "^1.1.1", @@ -286,11 +286,11 @@ "@grafana/llm": "0.22.1", "@grafana/monaco-logql": "^0.0.8", "@grafana/o11y-ds-frontend": "workspace:*", - "@grafana/plugin-ui": "0.10.8", + "@grafana/plugin-ui": "0.10.9", "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", - "@grafana/scenes": "6.29.3", - "@grafana/scenes-react": "6.29.3", + "@grafana/scenes": "6.29.7", + "@grafana/scenes-react": "6.29.7", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", @@ -300,9 +300,9 @@ "@lezer/common": "1.2.3", "@lezer/highlight": "1.2.1", "@lezer/lr": "1.4.2", - "@locker/near-membrane-dom": "0.13.6", - "@locker/near-membrane-shared": "0.13.6", - "@locker/near-membrane-shared-dom": "0.13.6", + "@locker/near-membrane-dom": "0.14.0", + "@locker/near-membrane-shared": "0.14.0", + "@locker/near-membrane-shared-dom": "0.14.0", "@msagl/core": "^1.1.19", "@msagl/parser": "^1.1.19", "@opentelemetry/api": "1.9.0", @@ -448,7 +448,8 @@ "refractor/prismjs": "^1.27.0", "@mapbox/jsonlint-lines-primitives": "github:mapbox/jsonlint#commit=e31b7289baedf3e1000d7ae7edd42268212c9954", "get-document": "github:webmodules/get-document#commit=a04ccb499d6e0433a368c3bb150b4899b698461f", - "gitconfiglocal": "2.1.0" + "gitconfiglocal": "2.1.0", + "tmp@npm:^0.0.33": "~0.2.1" }, "workspaces": { "packages": [ @@ -462,6 +463,9 @@ }, "packageManager": "yarn@4.9.2", "dependenciesMeta": { + "nx": { + "built": false + }, "prettier@3.6.2": { "unplugged": true } diff --git a/packages/grafana-alerting/src/grafana/api/util.test.ts b/packages/grafana-alerting/src/grafana/api/util.test.ts new file mode 100644 index 00000000000..215cba04187 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/api/util.test.ts @@ -0,0 +1,67 @@ +import { config } from '@grafana/runtime'; + +import { getAPIBaseURL, getAPINamespace, getAPIReducerPath } from './util'; + +describe('API utilities', () => { + const originalAppSubUrl = config.appSubUrl; + const originalNamespace = config.namespace; + + afterEach(() => { + // Restore original config after each test + config.appSubUrl = originalAppSubUrl; + config.namespace = originalNamespace; + }); + + describe('getAPIBaseURL', () => { + const group = 'notifications.alerting.grafana.app'; + const version = 'v0alpha1'; + + it('should generate correct API base URL without subpath', () => { + config.appSubUrl = ''; + config.namespace = 'default'; + + const result = getAPIBaseURL(group, version); + + expect(result).toBe('/apis/notifications.alerting.grafana.app/v0alpha1/namespaces/default'); + }); + + it('should generate correct API base URL with subpath', () => { + config.appSubUrl = '/grafana'; + config.namespace = 'default'; + + const result = getAPIBaseURL(group, version); + + expect(result).toBe('/grafana/apis/notifications.alerting.grafana.app/v0alpha1/namespaces/default'); + }); + + it('should handle different namespace', () => { + config.appSubUrl = '/grafana'; + config.namespace = 'custom-namespace'; + + const result = getAPIBaseURL(group, version); + + expect(result).toBe('/grafana/apis/notifications.alerting.grafana.app/v0alpha1/namespaces/custom-namespace'); + }); + }); + + describe('getAPINamespace', () => { + it('should return configured namespace', () => { + config.namespace = 'test-namespace'; + + const result = getAPINamespace(); + + expect(result).toBe('test-namespace'); + }); + }); + + describe('getAPIReducerPath', () => { + it('should generate correct reducer path', () => { + const group = 'notifications.alerting.grafana.app'; + const version = 'v0alpha1'; + + const result = getAPIReducerPath(group, version); + + expect(result).toBe('notifications.alerting.grafana.app/v0alpha1'); + }); + }); +}); diff --git a/packages/grafana-alerting/src/grafana/api/util.ts b/packages/grafana-alerting/src/grafana/api/util.ts index fa9a623d592..a562e151366 100644 --- a/packages/grafana-alerting/src/grafana/api/util.ts +++ b/packages/grafana-alerting/src/grafana/api/util.ts @@ -6,8 +6,10 @@ import { config } from '@grafana/runtime'; export const getAPINamespace = () => config.namespace; -export const getAPIBaseURL = (group: string, version: string) => - `/apis/${group}/${version}/namespaces/${getAPINamespace()}` as const; +export const getAPIBaseURL = (group: string, version: string) => { + const subPath = config.appSubUrl || ''; + return `${subPath}/apis/${group}/${version}/namespaces/${getAPINamespace()}` as const; +}; // By including the version in the reducer path we can prevent cache bugs when different versions of the API are used for the same entities export const getAPIReducerPath = (group: string, version: string) => `${group}/${version}` as const; diff --git a/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.tsx b/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.tsx index 56dacd8dca5..674dbc76dd3 100644 --- a/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.tsx +++ b/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.tsx @@ -17,7 +17,10 @@ export type ContactPointSelectorProps = CustomComboBoxProps; * @TODO make ComboBox accept a ReactNode so we can use icons and such */ function ContactPointSelector(props: ContactPointSelectorProps) { - const { currentData: contactPoints, isLoading } = useListContactPoints(); + const { currentData: contactPoints, isLoading } = useListContactPoints( + {}, + { refetchOnFocus: true, refetchOnMountOrArgChange: true } + ); // Create a mapping of options with their corresponding contact points const contactPointOptions = chain(contactPoints?.items) diff --git a/packages/grafana-alerting/src/grafana/contactPoints/hooks/v0alpha1/useContactPoints.tsx b/packages/grafana-alerting/src/grafana/contactPoints/hooks/v0alpha1/useContactPoints.tsx index 25d1ab9a6bf..e72577e91d0 100644 --- a/packages/grafana-alerting/src/grafana/contactPoints/hooks/v0alpha1/useContactPoints.tsx +++ b/packages/grafana-alerting/src/grafana/contactPoints/hooks/v0alpha1/useContactPoints.tsx @@ -15,6 +15,16 @@ type ListContactPointsHookResult = TypedUseQueryHookResult< ReturnType >; +// Type for the options that can be passed to the hook +// Based on the pattern used for mutation options in this file +type ListContactPointsQueryArgs = Parameters< + typeof alertingAPI.endpoints.listReceiver.useQuery +>[0]; + +type ListContactPointsQueryOptions = Parameters< + typeof alertingAPI.endpoints.listReceiver.useQuery +>[1]; + /** * useListContactPoints is a hook that fetches a list of contact points * @@ -22,9 +32,15 @@ type ListContactPointsHookResult = TypedUseQueryHookResult< * to ensure that the returned ContactPoints are correctly typed in the data.items array. * * It automatically uses the configured namespace for the query. + * + * @param queryOptions - Optional query options that will be passed to the underlying useListReceiverQuery hook. + * These options can include refetchOnFocus, refetchOnMountOrArgChange, skip, etc. */ -export function useListContactPoints() { - return alertingAPI.useListReceiverQuery({}); +export function useListContactPoints( + queryArgs: ListContactPointsQueryArgs = {}, + queryOptions: ListContactPointsQueryOptions = {} +) { + return alertingAPI.useListReceiverQuery(queryArgs, queryOptions); } // type narrowing mutations requires us to define a few helper types diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index b767026c7a2..d1d302275d1 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -451,8 +451,11 @@ export { getLinksSupplier } from './field/fieldOverrides'; // Types export { isUnsignedPluginSignature } from './types/pluginSignature'; export type { + AzureSettings, + AzureCloudInfo, CurrentUserDTO, AnalyticsSettings, + AppPluginConfig, BootData, OAuth, OAuthSettings, @@ -460,6 +463,8 @@ export type { GrafanaConfig, BuildInfo, LicenseInfo, + PreinstalledPlugin, + UnifiedAlertingConfig, } from './types/config'; export { availableIconsIndex, type IconName, isIconName, toIconName } from './types/icon'; export type { WithAccessControlMetadata } from './types/accesscontrol'; @@ -567,6 +572,8 @@ export { type PluginExtensionPanelContext, type PluginExtensionQueryEditorRowAdaptiveTelemetryV1Context, type PluginExtensionDataSourceConfigContext, + type PluginExtensionDataSourceConfigActionsContext, + type PluginExtensionDataSourceConfigStatusContext, type PluginExtensionCommandPaletteContext, type PluginExtensionOpenModalOptions, type PluginExtensionExposedComponentConfig, diff --git a/packages/grafana-data/src/panel/PanelPlugin.ts b/packages/grafana-data/src/panel/PanelPlugin.ts index b879066b469..61985074be9 100644 --- a/packages/grafana-data/src/panel/PanelPlugin.ts +++ b/packages/grafana-data/src/panel/PanelPlugin.ts @@ -3,6 +3,7 @@ import { ComponentClass, ComponentType } from 'react'; import { FieldConfigOptionsRegistry } from '../field/FieldConfigOptionsRegistry'; import { StandardEditorContext } from '../field/standardFieldConfigEditorRegistry'; +import { PanelModel } from '../types/dashboard'; import { FieldConfigProperty, FieldConfigSource } from '../types/fieldOverrides'; import { PanelPluginMeta, @@ -113,7 +114,7 @@ export class PanelPlugin< panel: ComponentType> | null; editor?: ComponentClass>; onPanelMigration?: PanelMigrationHandler; - shouldMigrate?: (panel: ComponentType> | null) => boolean; + shouldMigrate?: (panel: PanelModel) => boolean; onPanelTypeChanged?: PanelTypeChangedHandler; noPadding?: boolean; dataSupport: PanelPluginDataSupport = { @@ -208,10 +209,7 @@ export class PanelPlugin< * * This is a good place to support any changes to the options model */ - setMigrationHandler( - handler: PanelMigrationHandler, - shouldMigrate?: (panel: ComponentType> | null) => boolean - ) { + setMigrationHandler(handler: PanelMigrationHandler, shouldMigrate?: (panel: PanelModel) => boolean) { this.onPanelMigration = handler; this.shouldMigrate = shouldMigrate; return this; diff --git a/packages/grafana-data/src/themes/colorManipulator.test.ts b/packages/grafana-data/src/themes/colorManipulator.test.ts index 4f65f50c4d5..5619ddc4928 100644 --- a/packages/grafana-data/src/themes/colorManipulator.test.ts +++ b/packages/grafana-data/src/themes/colorManipulator.test.ts @@ -11,6 +11,7 @@ import { getLuminance, lighten, asRgbString, + onBackground, } from './colorManipulator'; describe('utils/colorManipulator', () => { @@ -423,4 +424,16 @@ describe('utils/colorManipulator', () => { expect(asRgbString('#000000')).toEqual('rgb(0, 0, 0)'); }); }); + + describe('onBackground', () => { + it('should calculate the foreground color given a background color', () => { + expect(onBackground('#ffffff', '#000').toHexString()).toBe('#ffffff'); + expect(onBackground('#ffffff00', '#000').toHexString()).toBe('#000000'); + expect(onBackground('#ffffff77', '#000').toHexString()).toBe('#777777'); + expect(onBackground('#262a6d82', '#644242').toHexString()).toBe('#443658'); + expect(onBackground('rgba(255,0,0,0.5)', 'rgba(0,255,0,0.5)').toRgbString()).toBe('rgba(170, 85, 0, 0.75)'); + expect(onBackground('rgba(255,0,0,0.5)', 'rgba(0,0,255,1)').toRgbString()).toBe('rgb(128, 0, 128)'); + expect(onBackground('rgba(0,0,255,1)', 'rgba(0,0,0,0.5)').toRgbString()).toBe('rgb(0, 0, 255)'); + }); + }); }); diff --git a/packages/grafana-data/src/themes/colorManipulator.ts b/packages/grafana-data/src/themes/colorManipulator.ts index 9964e9a09bc..eccb661b7d8 100644 --- a/packages/grafana-data/src/themes/colorManipulator.ts +++ b/packages/grafana-data/src/themes/colorManipulator.ts @@ -366,6 +366,32 @@ export function lighten(color: string, coefficient: number) { return recomposeColor(parts); } +/** + * given foreground and background colors, returns the color of the foreground color on the background color. + * this is valuable for foreground colors with alpha. + * + * adapted from https://github.com/scttcper/tinycolor/blob/2927a9d2aa03e037486a79a295542a7848621691/src/index.ts#L583-L594 + * + * @param foreground + * @param background + * @returns a tinycolor instance + */ +export const onBackground = ( + foreground: tinycolor.ColorInput, + background: tinycolor.ColorInput +): tinycolor.Instance => { + const fg = tinycolor(foreground).toRgb(); + const bg = tinycolor(background).toRgb(); + const alpha = fg.a + bg.a * (1 - fg.a); + + return tinycolor({ + r: (fg.r * fg.a + bg.r * bg.a * (1 - fg.a)) / alpha, + g: (fg.g * fg.a + bg.g * bg.a * (1 - fg.a)) / alpha, + b: (fg.b * fg.a + bg.b * bg.a * (1 - fg.a)) / alpha, + a: alpha, + }); +}; + interface DecomposeColor { type: string; values: any; @@ -387,4 +413,5 @@ export const colorManipulator = { alpha, darken, lighten, + onBackground, }; diff --git a/packages/grafana-data/src/transformations/matchers/nameMatcher.test.ts b/packages/grafana-data/src/transformations/matchers/nameMatcher.test.ts index f55bc1fce61..a8259b226d9 100644 --- a/packages/grafana-data/src/transformations/matchers/nameMatcher.test.ts +++ b/packages/grafana-data/src/transformations/matchers/nameMatcher.test.ts @@ -1,19 +1,18 @@ import { toDataFrame } from '../../dataframe/processDataFrame'; +import { BootData } from '../../types/config'; import { DataFrame, FieldType } from '../../types/dataFrame'; import { getFieldMatcher } from '../matchers'; import { FieldMatcherID } from './ids'; import { ByNamesMatcherMode } from './nameMatcher'; -// mock the default window.grafanaBootData settings -// eslint-disable-next-line -(window as any).grafanaBootData = { +window.grafanaBootData = { settings: { featureToggles: { dataplaneFrontendFallback: true, }, }, -}; +} as BootData; describe('Field Name by Regexp Matcher', () => { it('Match all with wildcard regex', () => { diff --git a/packages/grafana-data/src/transformations/matchers/nameMatcher.ts b/packages/grafana-data/src/transformations/matchers/nameMatcher.ts index 52d44a36b19..5a344ed4abd 100644 --- a/packages/grafana-data/src/transformations/matchers/nameMatcher.ts +++ b/packages/grafana-data/src/transformations/matchers/nameMatcher.ts @@ -109,8 +109,7 @@ export function fieldNameFallback(fields: Set) { // grafana-data does not have access to runtime so we are accessing the window object // to get access to the feature toggle - // eslint-disable-next-line - const useMatcherFallback = (window as any)?.grafanaBootData?.settings?.featureToggles?.dataplaneFrontendFallback; + const useMatcherFallback = window.grafanaBootData?.settings?.featureToggles?.dataplaneFrontendFallback; if (useMatcherFallback) { if (fields.has(TIME_SERIES_VALUE_FIELD_NAME)) { fallback = (field: Field, frame: DataFrame) => { diff --git a/packages/grafana-data/src/transformations/transformers/groupBy.test.ts b/packages/grafana-data/src/transformations/transformers/groupBy.test.ts index 169fbd6d470..89bcb6ef7b3 100644 --- a/packages/grafana-data/src/transformations/transformers/groupBy.test.ts +++ b/packages/grafana-data/src/transformations/transformers/groupBy.test.ts @@ -8,6 +8,29 @@ import { transformDataFrame } from '../transformDataFrame'; import { GroupByOperationID, groupByTransformer, GroupByTransformerOptions } from './groupBy'; import { DataTransformerID } from './ids'; +// returns a simple group by / reducer pair +const getSimpleGroupByConfig = ( + groupName: string, + valuesName: string, + reducer: ReducerID +): DataTransformerConfig => { + return { + id: DataTransformerID.groupBy, + options: { + fields: { + [groupName]: { + operation: GroupByOperationID.groupBy, + aggregations: [], + }, + [valuesName]: { + operation: GroupByOperationID.aggregate, + aggregations: [reducer], + }, + }, + }, + }; +}; + describe('GroupBy transformer', () => { beforeAll(() => { mockTransformationsRegistry([groupByTransformer]); @@ -88,21 +111,7 @@ describe('GroupBy transformer', () => { ], }); - const cfg: DataTransformerConfig = { - id: DataTransformerID.groupBy, - options: { - fields: { - message: { - operation: GroupByOperationID.groupBy, - aggregations: [], - }, - values: { - operation: GroupByOperationID.aggregate, - aggregations: [ReducerID.sum], - }, - }, - }, - }; + const cfg = getSimpleGroupByConfig('message', 'values', ReducerID.sum); await expect(transformDataFrame([cfg], [testSeries])).toEmitValuesWith((received) => { const result = received[0]; @@ -208,21 +217,7 @@ describe('GroupBy transformer', () => { }), ]; - const cfg: DataTransformerConfig = { - id: DataTransformerID.groupBy, - options: { - fields: { - message: { - operation: GroupByOperationID.groupBy, - aggregations: [], - }, - values: { - operation: GroupByOperationID.aggregate, - aggregations: [ReducerID.sum], - }, - }, - }, - }; + const cfg = getSimpleGroupByConfig('message', 'values', ReducerID.sum); await expect(transformDataFrame([cfg], testSeries)).toEmitValuesWith((received) => { const result = received[0]; @@ -270,21 +265,7 @@ describe('GroupBy transformer', () => { ], }); - const cfg: DataTransformerConfig = { - id: DataTransformerID.groupBy, - options: { - fields: { - message: { - operation: GroupByOperationID.groupBy, - aggregations: [], - }, - values: { - operation: GroupByOperationID.aggregate, - aggregations: [ReducerID.sum], - }, - }, - }, - }; + const cfg = getSimpleGroupByConfig('message', 'values', ReducerID.sum); await expect(transformDataFrame([cfg], [testSeries])).toEmitValuesWith((received) => { const result = received[0]; @@ -378,21 +359,7 @@ describe('GroupBy transformer', () => { ], }); - const cfg: DataTransformerConfig = { - id: DataTransformerID.groupBy, - options: { - fields: { - user: { - operation: GroupByOperationID.groupBy, - aggregations: [], - }, - time: { - operation: GroupByOperationID.aggregate, - aggregations: [ReducerID.max], - }, - }, - }, - }; + const cfg = getSimpleGroupByConfig('user', 'time', ReducerID.max); await expect(transformDataFrame([cfg], [testSeries])).toEmitValuesWith((received) => { const result = received[0]; @@ -425,21 +392,7 @@ describe('GroupBy transformer', () => { ], }); - const cfg: DataTransformerConfig = { - id: DataTransformerID.groupBy, - options: { - fields: { - user: { - operation: GroupByOperationID.groupBy, - aggregations: [], - }, - time: { - operation: GroupByOperationID.aggregate, - aggregations: [ReducerID.max], - }, - }, - }, - }; + const cfg = getSimpleGroupByConfig('user', 'time', ReducerID.max); await expect(transformDataFrame([cfg], [testSeries])).toEmitValuesWith((received) => { const result = received[0]; @@ -477,21 +430,7 @@ describe('GroupBy transformer', () => { ], }); - const cfg: DataTransformerConfig = { - id: DataTransformerID.groupBy, - options: { - fields: { - message: { - operation: GroupByOperationID.groupBy, - aggregations: [], - }, - values: { - operation: GroupByOperationID.aggregate, - aggregations: [ReducerID.sum], - }, - }, - }, - }; + const cfg = getSimpleGroupByConfig('message', 'values', ReducerID.sum); await expect(transformDataFrame([cfg], [testSeries])).toEmitValuesWith((received) => { const result = received[0]; @@ -513,4 +452,44 @@ describe('GroupBy transformer', () => { expect(result[0].fields).toEqual(expected); }); }); + + it('should not aggregate fields without an operation', async () => { + const testSeries = toDataFrame({ + name: 'A', + fields: [ + { name: 'category', type: FieldType.string, values: ['A', 'A', 'B', 'B'], config: {} }, + { name: 'values', type: FieldType.number, values: [1, 2, 3, 4], config: {} }, + ], + }); + + // when the operation field is cleared the aggregations are kept in state in case they are needed, but they should not be used by the transformation + let cfg = { + id: DataTransformerID.groupBy, + options: { + fields: { + category: { + operation: GroupByOperationID.groupBy, + aggregations: [], + }, + values: { + aggregations: [ReducerID.sum], + operation: null, + }, + }, + }, + }; + + await expect(transformDataFrame([cfg], [testSeries])).toEmitValuesWith((received) => { + const result = received[0]; + const expected: Field[] = [ + { + name: 'category', + type: FieldType.string, + values: ['A', 'B'], + config: {}, + }, + ]; + expect(result[0].fields).toEqual(expected); + }); + }); }); diff --git a/packages/grafana-data/src/transformations/transformers/groupBy.ts b/packages/grafana-data/src/transformations/transformers/groupBy.ts index f6eb212a138..d76b34d9475 100644 --- a/packages/grafana-data/src/transformations/transformers/groupBy.ts +++ b/packages/grafana-data/src/transformations/transformers/groupBy.ts @@ -146,7 +146,11 @@ export const groupByTransformer: DataTransformerInfo const shouldCalculateField = (field: Field, options: GroupByTransformerOptions): boolean => { const fieldName = getFieldDisplayName(field); - return Array.isArray(options?.fields[fieldName]?.aggregations) && options?.fields[fieldName].aggregations.length > 0; + return ( + options?.fields[fieldName]?.operation === GroupByOperationID.aggregate && + Array.isArray(options?.fields[fieldName]?.aggregations) && + options?.fields[fieldName].aggregations.length > 0 + ); }; /** diff --git a/packages/grafana-data/src/transformations/transformers/groupingToMatrix.ts b/packages/grafana-data/src/transformations/transformers/groupingToMatrix.ts index 2a9ae7eb03b..39614c52042 100644 --- a/packages/grafana-data/src/transformations/transformers/groupingToMatrix.ts +++ b/packages/grafana-data/src/transformations/transformers/groupingToMatrix.ts @@ -28,8 +28,7 @@ const DEFAULT_EMPTY_VALUE = SpecialValue.Empty; // grafana-data does not have access to runtime so we are accessing the window object // to get access to the feature toggle -// eslint-disable-next-line -const supportDataplaneFallback = (window as any)?.grafanaBootData?.settings?.featureToggles?.dataplaneFrontendFallback; +const supportDataplaneFallback = window.grafanaBootData?.settings?.featureToggles?.dataplaneFrontendFallback; export const groupingToMatrixTransformer: DataTransformerInfo = { id: DataTransformerID.groupingToMatrix, diff --git a/packages/grafana-data/src/transformations/transformers/utils.ts b/packages/grafana-data/src/transformations/transformers/utils.ts index 29ac89b29ee..9aa4d96a9e8 100644 --- a/packages/grafana-data/src/transformations/transformers/utils.ts +++ b/packages/grafana-data/src/transformations/transformers/utils.ts @@ -1,12 +1,6 @@ -import { BootData } from '../../types/config'; import { DataFrame } from '../../types/dataFrame'; import { SpecialValue } from '../../types/transformations'; -declare global { - interface Window { - grafanaBootData?: BootData; - } -} /** * Retrieve the maximum number of fields in a series of a dataframe. */ diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index 4c9e91e5c95..2ef0d54b2de 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -1,6 +1,5 @@ import { SystemDateFormatSettings } from '../datetime/formats'; import { MapLayerOptions } from '../geo/layer'; -import { GrafanaTheme2 } from '../themes/types'; import { DataSourceInstanceSettings } from './datasource'; import { FeatureToggles } from './featureToggles.gen'; @@ -8,9 +7,41 @@ import { IconName } from './icon'; import { NavLinkDTO } from './navModel'; import { OrgRole } from './orgs'; import { PanelPluginMeta } from './panel'; -import { GrafanaTheme } from './theme'; +import { AngularMeta, PluginDependencies, PluginExtensions, PluginLoadingStrategy } from './plugin'; import { TimeOption } from './time'; +export interface AzureSettings { + cloud?: string; + clouds?: AzureCloudInfo[]; + managedIdentityEnabled: boolean; + workloadIdentityEnabled: boolean; + userIdentityEnabled: boolean; + userIdentityFallbackCredentialsEnabled: boolean; + azureEntraPasswordCredentialsEnabled: boolean; +} + +export interface AzureCloudInfo { + name: string; + displayName: string; +} + +export type AppPluginConfig = { + id: string; + path: string; + version: string; + preload: boolean; + angular: AngularMeta; + loadingStrategy: PluginLoadingStrategy; + dependencies: PluginDependencies; + extensions: PluginExtensions; + moduleHash?: string; +}; + +export type PreinstalledPlugin = { + id: string; + version: string; +}; + /** * Describes the build information that will be available via the Grafana configuration. * @@ -21,7 +52,9 @@ export interface BuildInfo { version: string; // Version to show in the UI instead of version versionString: string; + buildstamp: number; commit: string; + commitShort: string; env: string; edition: GrafanaEdition; latestVersion: string; @@ -108,8 +141,9 @@ export interface AnalyticsSettings { intercomIdentifier?: string; } -/** Current user info included in bootData - * +/** + * Current user info included in bootData. + * Corresponds to `window.grafanaBootData.user` * @internal */ export interface CurrentUserDTO { @@ -139,8 +173,9 @@ export interface CurrentUserDTO { lightTheme: boolean; } -/** Contains essential user and config info - * +/** + * Contains essential user and config info. + * Corresponds to `window.grafanaBootData`. * @internal */ export interface BootData { @@ -155,21 +190,26 @@ export interface BootData { /** * Describes all the different Grafana configuration values available for an instance. - * + * Corresponds to `window.grafanaBootData.settings`. + * If you want to access these values, use the `config` object from `@grafana/runtime`. * @internal */ export interface GrafanaConfig { - publicDashboardAccessToken?: string; + publicDashboardAccessToken: string; publicDashboardsEnabled: boolean; snapshotEnabled: boolean; datasources: { [str: string]: DataSourceInstanceSettings }; panels: { [key: string]: PanelPluginMeta }; + apps: Record; auth: AuthSettings; minRefreshInterval: string; + appUrl: string; appSubUrl: string; + azure: AzureSettings; + jwtHeaderName: string; + jwtUrlLogin: boolean; windowTitlePrefix: string; buildInfo: BuildInfo; - bootData: BootData; externalUserMngLinkUrl: string; externalUserMngLinkName: string; externalUserMngInfo: string; @@ -178,6 +218,8 @@ export interface GrafanaConfig { allowOrgCreate: boolean; disableLoginForm: boolean; defaultDatasource: string; + defaultDatasourceManageAlertsUIToggle: boolean; + defaultAllowRecordingRulesTargetAlertsUIToggle: boolean; authProxyEnabled: boolean; exploreEnabled: boolean; queryHistoryEnabled: boolean; @@ -188,6 +230,9 @@ export interface GrafanaConfig { sigV4AuthEnabled: boolean; azureAuthEnabled: boolean; samlEnabled: boolean; + samlName: string; + awsAllowedAuthProviders: string[]; + awsAssumeRoleProvided: boolean; autoAssignOrg: boolean; verifyEmailEnabled: boolean; oauth: OAuthSettings; @@ -201,57 +246,77 @@ export interface GrafanaConfig { disableSanitizeHtml: boolean; trustedTypesDefaultPolicyEnabled: boolean; cspReportOnlyEnabled: boolean; + expressionsEnabled: boolean; liveEnabled: boolean; liveMessageSizeLimit: number; - /** @deprecated Use `theme2` instead. */ - theme: GrafanaTheme; - theme2: GrafanaTheme2; anonymousEnabled: boolean; - anonymousDeviceLimit: number | undefined; + anonymousDeviceLimit: number; featureToggles: FeatureToggles; licenseInfo: LicenseInfo; http2Enabled: boolean; dateFormats?: SystemDateFormatSettings; grafanaJavascriptAgent: GrafanaJavascriptAgentConfig; - geomapDefaultBaseLayer?: MapLayerOptions; - geomapDisableCustomBaseLayer?: boolean; + geomapDefaultBaseLayerConfig?: MapLayerOptions; + geomapDisableCustomBaseLayer: boolean; unifiedAlertingEnabled: boolean; unifiedAlerting: UnifiedAlertingConfig; feedbackLinksEnabled: boolean; supportBundlesEnabled: boolean; secureSocksDSProxyEnabled: boolean; - googleAnalyticsId: string | undefined; - googleAnalytics4Id: string | undefined; + enableFrontendSandboxForPlugins: string[]; + googleAnalyticsId: string; + googleAnalytics4Id: string; googleAnalytics4SendManualPageViews: boolean; - rudderstackWriteKey: string | undefined; - rudderstackDataPlaneUrl: string | undefined; - rudderstackSdkUrl: string | undefined; - rudderstackConfigUrl: string | undefined; - rudderstackIntegrationsUrl: string | undefined; + rudderstackWriteKey: string; + rudderstackDataPlaneUrl: string; + rudderstackSdkUrl: string; + rudderstackConfigUrl: string; + rudderstackIntegrationsUrl: string; + applicationInsightsConnectionString: string; + applicationInsightsEndpointUrl: string; analyticsConsoleReporting: boolean; + rendererAvailable: boolean; + rendererVersion: string; + rendererDefaultImageWidth: number; + rendererDefaultImageHeight: number; + rendererDefaultImageScale: number; dashboardPerformanceMetrics: string[]; panelSeriesLimit: number; sqlConnectionLimits: SqlConnectionLimits; - sharedWithMeFolderUID?: string; - rootFolderUID?: string; - localFileSystemAvailable?: boolean; - cloudMigrationIsTarget?: boolean; - listDashboardScopesEndpoint?: string; - listScopesEndpoint?: string; - reportingStaticContext?: Record; - exploreDefaultTimeOffset?: string; - exploreHideLogsDownload?: boolean; + sharedWithMeFolderUID: string; + rootFolderUID: string; + localFileSystemAvailable: boolean; + cloudMigrationIsTarget: boolean; + cloudMigrationPollIntervalMs: number; + pluginCatalogURL: string; + pluginAdminEnabled: boolean; + pluginAdminExternalManageEnabled: boolean; + pluginCatalogHiddenPlugins: string[]; + pluginCatalogManagedPlugins: string[]; + pluginCatalogPreinstalledPlugins: PreinstalledPlugin[]; + pluginsCDNBaseURL: string; + tokenExpirationDayLimit: number; + listDashboardScopesEndpoint: string; + listScopesEndpoint: string; + reportingStaticContext: Record; + exploreDefaultTimeOffset: string; + exploreHideLogsDownload: boolean; quickRanges?: TimeOption[]; // The namespace to use for kubernetes apiserver requests namespace: string; - - /** - * Language used in Grafana's UI. This is after the user's preference (or deteceted locale) is resolved to one of - * Grafana's supported language. - */ - language: string | undefined; - regionalFormat: string; + caching: { + enabled: boolean; + }; + recordedQueries: { + enabled: boolean; + }; + reporting: { + enabled: boolean; + }; + analytics: { + enabled: boolean; + }; } export interface SqlConnectionLimits { diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index cb400b4e5fa..cc2d2b8b63b 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -161,10 +161,6 @@ export interface FeatureToggles { */ pluginsFrontendSandbox?: boolean; /** - * Enables previous SQL data source dataset dropdown behavior - */ - sqlDatasourceDatabaseSelection?: boolean; - /** * Enables writing multiple items from a single query within Recorded Queries * @default true */ @@ -447,7 +443,7 @@ export interface FeatureToggles { alertingSaveStatePeriodic?: boolean; /** * Enables the compressed protobuf-based alert state storage - * @default false + * @default true */ alertingSaveStateCompressed?: boolean; /** @@ -815,11 +811,6 @@ export interface FeatureToggles { */ improvedExternalSessionHandlingSAML?: boolean; /** - * Enables LBAC for datasources for Mimir to apply LBAC filtering of metrics to the client requests for users in teams - * @default true - */ - teamHttpHeadersMimir?: boolean; - /** * Enables LBAC for datasources for Tempo to apply LBAC filtering of traces to the client requests for users in teams */ teamHttpHeadersTempo?: boolean; @@ -959,6 +950,11 @@ export interface FeatureToggles { */ metricsFromProfiles?: boolean; /** + * Enables integration with Grafana Assistant in Profiles Drilldown + * @default true + */ + grafanaAssistantInProfilesDrilldown?: boolean; + /** * Enables using PGX instead of libpq for PostgreSQL datasource */ postgresDSUsePGX?: boolean; @@ -993,6 +989,10 @@ export interface FeatureToggles { */ kubernetesAuthzApis?: boolean; /** + * Registers AuthZ resource permission /apis endpoints + */ + kubernetesAuthzResourcePermissionApis?: boolean; + /** * Enables create, delete, and update mutations for resources owned by IAM identity */ kubernetesAuthnMutation?: boolean; @@ -1017,6 +1017,11 @@ export interface FeatureToggles { */ alertingImportAlertmanagerAPI?: boolean; /** + * Enables the UI to see imported Alertmanager configuration + * @default false + */ + alertingImportAlertmanagerUI?: boolean; + /** * Enables image sharing functionality for dashboards */ sharingDashboardImage?: boolean; @@ -1086,6 +1091,10 @@ export interface FeatureToggles { */ adhocFiltersInTooltips?: boolean; /** + * Enable favorite datasources + */ + favoriteDatasources?: boolean; + /** * New Log Context component */ newLogContext?: boolean; @@ -1094,4 +1103,14 @@ export interface FeatureToggles { * @default false */ newClickhouseConfigPageDesign?: boolean; + /** + * Enable experimental search-after-write guarantees to unified-storage search endpoints + * @default false + */ + unifiedStorageSearchAfterWriteExperimentalAPI?: boolean; + /** + * Enables team folders functionality + * @default false + */ + teamFolders?: boolean; } diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 3ed7de0c766..788fd7b3135 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -194,7 +194,9 @@ export enum PluginExtensionPoints { CommandPalette = 'grafana/commandpalette/action', DashboardPanelMenu = 'grafana/dashboard/panel/menu', DataSourceConfig = 'grafana/datasources/config', + DataSourceConfigActions = 'grafana/datasources/config/actions', DataSourceConfigErrorStatus = 'grafana/datasources/config/error-status', + DataSourceConfigStatus = 'grafana/datasources/config/status', ExploreToolbarAction = 'grafana/explore/toolbar/action', UserProfileTab = 'grafana/user/profile/tab', TraceViewDetails = 'grafana/traceview/details', @@ -271,6 +273,30 @@ export type DataSourceConfigErrorStatusContext = { }; }; +export type PluginExtensionDataSourceConfigActionsContext = { + dataSource: { + type: string; + uid: string; + name: string; + typeName: string; + }; +}; + +export type PluginExtensionDataSourceConfigStatusContext = { + dataSource: { + type: string; + uid: string; + name: string; + typeName: string; + }; + testingStatus?: { + message?: string | null; + status?: string | null; + details?: Record; + }; + severity: 'success' | 'error' | 'warning' | 'info'; +}; + type Dashboard = { uid: string; title: string; diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 9ac23a5b24c..3b3ccab5cf0 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -493,6 +493,14 @@ export const versionedComponents = { '12.1.0': 'data-testid tableng filter select-all', }, }, + Tooltip: { + Wrapper: { + '12.2.0': 'data-testid tableng tooltip wrapper', + }, + Caret: { + '12.2.0': 'data-testid tableng tooltip caret', + }, + }, }, }, }, diff --git a/packages/grafana-flamegraph/package.json b/packages/grafana-flamegraph/package.json index 06914eb8915..fb5b0fa7939 100644 --- a/packages/grafana-flamegraph/package.json +++ b/packages/grafana-flamegraph/package.json @@ -83,6 +83,7 @@ "typescript": "5.9.2" }, "peerDependencies": { + "@grafana/assistant": "^0.0.12", "react": "^18.0.0", "react-dom": "^18.0.0" } diff --git a/packages/grafana-flamegraph/src/AnalyzeFlameGraphButton.tsx b/packages/grafana-flamegraph/src/AnalyzeFlameGraphButton.tsx new file mode 100644 index 00000000000..005e1d9b21a --- /dev/null +++ b/packages/grafana-flamegraph/src/AnalyzeFlameGraphButton.tsx @@ -0,0 +1,34 @@ +import { ChatContextItem, useAssistant } from '@grafana/assistant'; +import { Button } from '@grafana/ui'; + +type Props = { + assistantContext: ChatContextItem[]; + className?: string; +}; + +export function AnalyzeFlameGraphButton(props: Props) { + const { assistantContext, className } = props; + const [isAvailable, openAssistant] = useAssistant(); + + if (!isAvailable || !openAssistant || assistantContext.length === 0) { + return null; + } + + return ( + + ); +} diff --git a/packages/grafana-flamegraph/src/FlameGraphContainer.test.tsx b/packages/grafana-flamegraph/src/FlameGraphContainer.test.tsx index 5be3bb3c3db..85ce8dd3470 100644 --- a/packages/grafana-flamegraph/src/FlameGraphContainer.test.tsx +++ b/packages/grafana-flamegraph/src/FlameGraphContainer.test.tsx @@ -9,6 +9,11 @@ import { data } from './FlameGraph/testData/dataNestedSet'; import FlameGraphContainer, { labelSearch } from './FlameGraphContainer'; import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from './constants'; +jest.mock('@grafana/assistant', () => ({ + useAssistant: jest.fn(() => [false, null]), // [isAvailable, openAssistant] + createContext: jest.fn(), +})); + jest.mock('react-use', () => ({ ...jest.requireActual('react-use'), useMeasure: () => { diff --git a/packages/grafana-flamegraph/src/FlameGraphContainer.tsx b/packages/grafana-flamegraph/src/FlameGraphContainer.tsx index 706702142db..da09ea26739 100644 --- a/packages/grafana-flamegraph/src/FlameGraphContainer.tsx +++ b/packages/grafana-flamegraph/src/FlameGraphContainer.tsx @@ -14,6 +14,7 @@ import FlameGraphHeader from './FlameGraphHeader'; import FlameGraphTopTableContainer from './TopTable/FlameGraphTopTableContainer'; import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from './constants'; import { ClickedItemData, ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from './types'; +import { getAssistantContextFromDataFrame } from './utils'; const ufuzzy = new uFuzzy(); @@ -77,6 +78,14 @@ export type Props = { * Whether or not to keep any focused item when the profile data changes. */ keepFocusOnDataChange?: boolean; + + /** + * If true, the assistant button will be shown in the header if available. + * This is needed mainly for Profiles Drilldown where in some cases we need to hide the button to show alternative + * option to use AI. + * @default true + */ + showAnalyzeWithAssistant?: boolean; }; const FlameGraphContainer = ({ @@ -93,6 +102,7 @@ const FlameGraphContainer = ({ disableCollapsing, keepFocusOnDataChange, getExtraContextMenuButtons, + showAnalyzeWithAssistant = true, }: Props) => { const [focusedItemData, setFocusedItemData] = useState(); @@ -293,6 +303,7 @@ const FlameGraphContainer = ({ isDiffMode={dataContainer.isDiffFlamegraph()} setCollapsedMap={setCollapsedMap} collapsedMap={collapsedMap} + assistantContext={data && showAnalyzeWithAssistant ? getAssistantContextFromDataFrame(data) : undefined} /> )} diff --git a/packages/grafana-flamegraph/src/FlameGraphHeader.test.tsx b/packages/grafana-flamegraph/src/FlameGraphHeader.test.tsx index 546450dad2b..0ca621d9f64 100644 --- a/packages/grafana-flamegraph/src/FlameGraphHeader.test.tsx +++ b/packages/grafana-flamegraph/src/FlameGraphHeader.test.tsx @@ -7,6 +7,11 @@ import { CollapsedMap } from './FlameGraph/dataTransform'; import FlameGraphHeader from './FlameGraphHeader'; import { ColorScheme, SelectedView } from './types'; +jest.mock('@grafana/assistant', () => ({ + useAssistant: jest.fn(() => [false, null]), // [isAvailable, openAssistant] + createContext: jest.fn(), +})); + describe('FlameGraphHeader', () => { function setup(props: Partial> = {}) { const setSearch = jest.fn(); diff --git a/packages/grafana-flamegraph/src/FlameGraphHeader.tsx b/packages/grafana-flamegraph/src/FlameGraphHeader.tsx index 91541f216b7..e51dd78b051 100644 --- a/packages/grafana-flamegraph/src/FlameGraphHeader.tsx +++ b/packages/grafana-flamegraph/src/FlameGraphHeader.tsx @@ -3,9 +3,11 @@ import { useEffect, useState } from 'react'; import * as React from 'react'; import { useDebounce, usePrevious } from 'react-use'; +import { ChatContextItem } from '@grafana/assistant'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { Button, ButtonGroup, Dropdown, Input, Menu, RadioButtonGroup, useStyles2 } from '@grafana/ui'; +import { AnalyzeFlameGraphButton } from './AnalyzeFlameGraphButton'; import { byPackageGradient, byValueGradient, diffColorBlindGradient, diffDefaultGradient } from './FlameGraph/colors'; import { CollapsedMap } from './FlameGraph/dataTransform'; import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from './constants'; @@ -30,6 +32,8 @@ type Props = { collapsedMap: CollapsedMap; extraHeaderElements?: React.ReactNode; + + assistantContext?: ChatContextItem[]; }; const FlameGraphHeader = ({ @@ -50,6 +54,7 @@ const FlameGraphHeader = ({ isDiffMode, setCollapsedMap, collapsedMap, + assistantContext, }: Props) => { const styles = useStyles2(getStyles); const [localSearch, setLocalSearch] = useSearchInput(search, setSearch); @@ -84,6 +89,9 @@ const FlameGraphHeader = ({
+ {assistantContext && ( + + )} {showResetButton && (
- +
diff --git a/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts b/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts index 14c87b124ec..15c30dadd82 100644 --- a/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts +++ b/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts @@ -141,6 +141,15 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => { top: 0, }, }), + optionInfo: css({ + label: 'combobox-option-info', + color: theme.colors.text.disabled, + cursor: 'not-allowed', + pointerEvents: 'none', + '&:hover': { + background: 'transparent', + }, + }), clear: css({ label: 'combobox-clear', cursor: 'pointer', diff --git a/packages/grafana-ui/src/components/Combobox/types.ts b/packages/grafana-ui/src/components/Combobox/types.ts index 9bc5cde0204..537982d5865 100644 --- a/packages/grafana-ui/src/components/Combobox/types.ts +++ b/packages/grafana-ui/src/components/Combobox/types.ts @@ -5,4 +5,5 @@ export type ComboboxOption = { value: T; description?: string; group?: string; + infoOption?: boolean; }; diff --git a/packages/grafana-ui/src/components/DataSourceSettings/CertificationKey.tsx b/packages/grafana-ui/src/components/DataSourceSettings/CertificationKey.tsx index 86f00684e1b..d8382cd2301 100644 --- a/packages/grafana-ui/src/components/DataSourceSettings/CertificationKey.tsx +++ b/packages/grafana-ui/src/components/DataSourceSettings/CertificationKey.tsx @@ -12,15 +12,16 @@ interface Props { label: string; hasCert: boolean; placeholder: string; + useGrow?: boolean; onChange: (event: ChangeEvent) => void; onClick: (event: MouseEvent) => void; } -export const CertificationKey = ({ hasCert, label, onChange, onClick, placeholder }: Props) => { +export const CertificationKey = ({ hasCert, label, onChange, onClick, placeholder, useGrow }: Props) => { return ( - + {hasCert ? ( ) : ( diff --git a/packages/grafana-ui/src/components/DateTimePickers/WeekStartPicker.tsx b/packages/grafana-ui/src/components/DateTimePickers/WeekStartPicker.tsx index 99ebf2d3b3a..0207dcb67a8 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/WeekStartPicker.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/WeekStartPicker.tsx @@ -1,6 +1,5 @@ import { useCallback, useMemo } from 'react'; -import { BootData } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { t } from '@grafana/i18n'; @@ -23,12 +22,6 @@ export function isWeekStart(value: string): value is WeekStart { return ['saturday', 'sunday', 'monday'].includes(value); } -declare global { - interface Window { - grafanaBootData?: BootData; - } -} - /** * Returns the system or user defined week start (as defined in bootData) * Or you can pass in an override weekStart string and have it be validated and returned as WeekStart type if valid diff --git a/packages/grafana-ui/src/components/Drawer/Drawer.story.tsx b/packages/grafana-ui/src/components/Drawer/Drawer.story.tsx index 9cb4e548919..b59b7bf1a8d 100644 --- a/packages/grafana-ui/src/components/Drawer/Drawer.story.tsx +++ b/packages/grafana-ui/src/components/Drawer/Drawer.story.tsx @@ -140,6 +140,90 @@ LongContent.args = { title: 'Drawer title with long content', }; +export const LongTitle: StoryFn = (args) => { + const [isOpen, setIsOpen] = useState(false); + return ( + <> + + {isOpen && ( + setIsOpen(false)}> +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et + dolore magna aliqua. Iaculis nunc sed augue lacus viverra vitae. Malesuada pellentesque elit eget gravida + cum sociis. Pretium vulputate sapien nec sagittis aliquam malesuada bibendum arcu. Cras adipiscing enim eu + turpis egestas. Ut lectus arcu bibendum at varius. Nulla pellentesque dignissim enim sit amet venenatis + urna. Tempus urna et pharetra pharetra massa massa ultricies mi quis. Vitae congue mauris rhoncus aenean. + Enim ut tellus elementum sagittis vitae et. +

+

+ Arcu non odio euismod lacinia at quis risus sed vulputate. Sit amet consectetur adipiscing elit ut. Dictum + fusce ut placerat orci nulla pellentesque dignissim. Lectus nulla at volutpat diam ut venenatis tellus. Sed + cras ornare arcu dui. Eget mauris pharetra et ultrices neque ornare aenean euismod. Mi quis hendrerit dolor + magna. Commodo viverra maecenas accumsan lacus vel facilisis. Eget mi proin sed libero enim sed. Magna ac + placerat vestibulum lectus mauris ultrices eros in. Mattis nunc sed blandit libero volutpat. +

+

+ Cursus in hac habitasse platea dictumst quisque sagittis purus. Viverra adipiscing at in tellus. Semper eget + duis at tellus at urna condimentum. Egestas fringilla phasellus faucibus scelerisque eleifend. Sem nulla + pharetra diam sit amet nisl. Ut ornare lectus sit amet est placerat in egestas erat. Id neque aliquam + vestibulum morbi blandit cursus risus. In iaculis nunc sed augue. Eu volutpat odio facilisis mauris sit. + Quisque egestas diam in arcu cursus euismod. At quis risus sed vulputate odio ut enim blandit volutpat. + Cursus risus at ultrices mi tempus imperdiet nulla malesuada. Sed adipiscing diam donec adipiscing tristique + risus nec. Id neque aliquam vestibulum morbi. Pretium nibh ipsum consequat nisl vel pretium lectus quam. + Platea dictumst quisque sagittis purus sit. Nascetur ridiculus mus mauris vitae ultricies leo. +

+

+ Cursus in hac habitasse platea dictumst quisque sagittis purus. Viverra adipiscing at in tellus. Semper eget + duis at tellus at urna condimentum. Egestas fringilla phasellus faucibus scelerisque eleifend. Sem nulla + pharetra diam sit amet nisl. Ut ornare lectus sit amet est placerat in egestas erat. Id neque aliquam + vestibulum morbi blandit cursus risus. In iaculis nunc sed augue. Eu volutpat odio facilisis mauris sit. + Quisque egestas diam in arcu cursus euismod. At quis risus sed vulputate odio ut enim blandit volutpat. + Cursus risus at ultrices mi tempus imperdiet nulla malesuada. Sed adipiscing diam donec adipiscing tristique + risus nec. Id neque aliquam vestibulum morbi. Pretium nibh ipsum consequat nisl vel pretium lectus quam. + Platea dictumst quisque sagittis purus sit. Nascetur ridiculus mus mauris vitae ultricies leo. +

+

+ Cursus in hac habitasse platea dictumst quisque sagittis purus. Viverra adipiscing at in tellus. Semper eget + duis at tellus at urna condimentum. Egestas fringilla phasellus faucibus scelerisque eleifend. Sem nulla + pharetra diam sit amet nisl. Ut ornare lectus sit amet est placerat in egestas erat. Id neque aliquam + vestibulum morbi blandit cursus risus. In iaculis nunc sed augue. Eu volutpat odio facilisis mauris sit. + Quisque egestas diam in arcu cursus euismod. At quis risus sed vulputate odio ut enim blandit volutpat. + Cursus risus at ultrices mi tempus imperdiet nulla malesuada. Sed adipiscing diam donec adipiscing tristique + risus nec. Id neque aliquam vestibulum morbi. Pretium nibh ipsum consequat nisl vel pretium lectus quam. + Platea dictumst quisque sagittis purus sit. Nascetur ridiculus mus mauris vitae ultricies leo. +

+

+ Cursus in hac habitasse platea dictumst quisque sagittis purus. Viverra adipiscing at in tellus. Semper eget + duis at tellus at urna condimentum. Egestas fringilla phasellus faucibus scelerisque eleifend. Sem nulla + pharetra diam sit amet nisl. Ut ornare lectus sit amet est placerat in egestas erat. Id neque aliquam + vestibulum morbi blandit cursus risus. In iaculis nunc sed augue. Eu volutpat odio facilisis mauris sit. + Quisque egestas diam in arcu cursus euismod. At quis risus sed vulputate odio ut enim blandit volutpat. + Cursus risus at ultrices mi tempus imperdiet nulla malesuada. Sed adipiscing diam donec adipiscing tristique + risus nec. Id neque aliquam vestibulum morbi. Pretium nibh ipsum consequat nisl vel pretium lectus quam. + Platea dictumst quisque sagittis purus sit. Nascetur ridiculus mus mauris vitae ultricies leo. +

+

+ Cursus in hac habitasse platea dictumst quisque sagittis purus. Viverra adipiscing at in tellus. Semper eget + duis at tellus at urna condimentum. Egestas fringilla phasellus faucibus scelerisque eleifend. Sem nulla + pharetra diam sit amet nisl. Ut ornare lectus sit amet est placerat in egestas erat. Id neque aliquam + vestibulum morbi blandit cursus risus. In iaculis nunc sed augue. Eu volutpat odio facilisis mauris sit. + Quisque egestas diam in arcu cursus euismod. At quis risus sed vulputate odio ut enim blandit volutpat. + Cursus risus at ultrices mi tempus imperdiet nulla malesuada. Sed adipiscing diam donec adipiscing tristique + risus nec. Id neque aliquam vestibulum morbi. Pretium nibh ipsum consequat nisl vel pretium lectus quam. + Platea dictumst quisque sagittis purus sit. Nascetur ridiculus mus mauris vitae ultricies leo. +

+
+ )} + + ); +}; +LongTitle.args = { + title: `This is a very long title! Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. Cursus in hac habitasse platea dictumst quisque sagittis + purus. Viverra adipiscing at in tellus. Semper eget duis at tellus at urna condimentum. Egestas fringilla + phasellus faucibus scelerisque eleifend. Sem nulla`, +}; + export const WithTabs: StoryFn = (args) => { const [isOpen, setIsOpen] = useState(false); const [activeTab, setActiveTab] = useState('options'); diff --git a/packages/grafana-ui/src/components/Drawer/Drawer.tsx b/packages/grafana-ui/src/components/Drawer/Drawer.tsx index 070bef4372e..34a59be9775 100644 --- a/packages/grafana-ui/src/components/Drawer/Drawer.tsx +++ b/packages/grafana-ui/src/components/Drawer/Drawer.tsx @@ -13,6 +13,7 @@ import { t } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; import { getDragStyles } from '../DragHandle/DragHandle'; import { IconButton } from '../IconButton/IconButton'; +import { Stack } from '../Layout/Stack/Stack'; import { ScrollContainer } from '../ScrollContainer/ScrollContainer'; import { Text } from '../Text/Text'; @@ -154,8 +155,8 @@ export function Drawer({ />
{typeof title === 'string' ? ( -
- + + {title} {subtitle && ( @@ -163,7 +164,7 @@ export function Drawer({ {subtitle}
)} - + ) : ( title )} @@ -325,14 +326,9 @@ const getStyles = (theme: GrafanaTheme2) => { right: theme.spacing(1), top: theme.spacing(1), }), - titleWrapper: css({ - label: 'drawer-title', - overflowWrap: 'break-word', - }), subtitle: css({ label: 'drawer-subtitle', color: theme.colors.text.secondary, - paddingTop: theme.spacing(1), }), content: css({ padding: theme.spacing(theme.components.drawer?.padding ?? 2), diff --git a/packages/grafana-ui/src/components/FormField/FormField.story.tsx b/packages/grafana-ui/src/components/FormField/FormField.story.tsx index 9d39b20536b..845001dcacc 100644 --- a/packages/grafana-ui/src/components/FormField/FormField.story.tsx +++ b/packages/grafana-ui/src/components/FormField/FormField.story.tsx @@ -9,8 +9,6 @@ const meta: Meta = { controls: { exclude: ['inputEl'], }, - // TODO fix a11y issue in story and remove this - a11y: { test: 'off' }, }, args: { inputWidth: 20, diff --git a/packages/grafana-ui/src/components/FormField/FormField.tsx b/packages/grafana-ui/src/components/FormField/FormField.tsx index b949a02d106..4d9bca9ded8 100644 --- a/packages/grafana-ui/src/components/FormField/FormField.tsx +++ b/packages/grafana-ui/src/components/FormField/FormField.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { InputHTMLAttributes } from 'react'; +import { InputHTMLAttributes, useId } from 'react'; import * as React from 'react'; import { InlineFormLabel } from '../FormLabel/FormLabel'; @@ -36,13 +36,15 @@ export const FormField = ({ ...inputProps }: Props) => { const styles = getStyles(); + const id = useId(); return (
- + {label} {inputEl || ( { ]; const optionsWithOnlyIcons = [ - { description: 'Prometheus', value: 'prometheus', icon: 'gf-interpolation-linear' }, - { description: 'Graphite', value: 'graphite', icon: 'gf-interpolation-smooth' }, - { description: 'Elastic', value: 'elastic', icon: 'gf-interpolation-step-after' }, + { ariaLabel: 'Prometheus', description: 'Prometheus', value: 'prometheus', icon: 'gf-interpolation-linear' }, + { ariaLabel: 'Graphite', description: 'Graphite', value: 'graphite', icon: 'gf-interpolation-smooth' }, + { ariaLabel: 'Elastic', description: 'Elastic', value: 'elastic', icon: 'gf-interpolation-step-after' }, ]; return ( diff --git a/packages/grafana-ui/src/components/Link/TextLink.story.tsx b/packages/grafana-ui/src/components/Link/TextLink.story.tsx index b353b15674f..6c1b9eb5fff 100644 --- a/packages/grafana-ui/src/components/Link/TextLink.story.tsx +++ b/packages/grafana-ui/src/components/Link/TextLink.story.tsx @@ -15,8 +15,6 @@ const meta: Meta = { page: mdx, }, controls: { exclude: ['href', 'external'] }, - // TODO fix a11y issue in story and remove this - a11y: { test: 'off' }, }, argTypes: { variant: { diff --git a/packages/grafana-ui/src/components/MatchersUI/FieldNamePicker.tsx b/packages/grafana-ui/src/components/MatchersUI/FieldNamePicker.tsx index 63240a9b0eb..cce936f01f7 100644 --- a/packages/grafana-ui/src/components/MatchersUI/FieldNamePicker.tsx +++ b/packages/grafana-ui/src/components/MatchersUI/FieldNamePicker.tsx @@ -10,7 +10,7 @@ import { useFieldDisplayNames, useSelectOptions, frameHasName } from './utils'; type Props = StandardEditorProps; // Pick a field name out of the fields -export const FieldNamePicker = ({ value, onChange, context, item }: Props) => { +export const FieldNamePicker = ({ value, onChange, context, item, id }: Props) => { const settings: FieldNamePickerConfigSettings = item.settings ?? {}; const names = useFieldDisplayNames(context.data, settings?.filter); const selectOptions = useSelectOptions(names, value, undefined, undefined, settings.baseNameMode); @@ -29,6 +29,7 @@ export const FieldNamePicker = ({ value, onChange, context, item }: Props) => { return ( <> void; placeholder?: string; + id?: string; } -export function RefIDMultiPicker({ value, data, onChange, placeholder }: MultiProps) { +export function RefIDMultiPicker({ value, data, onChange, placeholder, id }: MultiProps) { const listOfRefIds = useMemo(() => getListOfQueryRefIds(data), [data]); const [priorSelectionState, updatePriorSelectionState] = useState<{ @@ -172,6 +175,7 @@ export function RefIDMultiPicker({ value, data, onChange, placeholder }: MultiPr } return ( ; } @@ -26,7 +28,7 @@ export function Portal(props: PropsWithChildren) { node.current.className = className; } node.current.style.position = 'relative'; - node.current.style.zIndex = `${theme.zIndex.portal}`; + node.current.style.zIndex = `${props.zIndex ?? theme.zIndex.portal}`; } useLayoutEffect(() => { diff --git a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx index 6d4547e3fbe..8de8ed1b893 100644 --- a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx +++ b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx @@ -26,7 +26,7 @@ import { UPlotChart } from '../uPlot/Plot'; import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder'; import { preparePlotData2, getStackingGroups } from '../uPlot/utils'; -import { preparePlotFrame } from './utils'; +import { getYRange, preparePlotFrame } from './utils'; export interface SparklineProps extends Themeable2 { width: number; @@ -102,26 +102,7 @@ export class Sparkline extends PureComponent { } getYRange(field: Field): Range.MinMax { - let { min, max } = this.state.alignedDataFrame.fields[1].state?.range!; - const noValue = +this.state.alignedDataFrame.fields[1].config?.noValue!; - - if (!Number.isNaN(noValue)) { - min = Math.min(min!, +noValue); - max = Math.max(max!, +noValue); - } - - if (min === max) { - if (min === 0) { - max = 100; - } else { - min = 0; - max! *= 2; - } - - return [min, max!]; - } - - return [Math.max(min!, field.config.min ?? -Infinity), Math.min(max!, field.config.max ?? Infinity)]; + return getYRange(field, this.state.alignedDataFrame); } prepareConfig(data: DataFrame) { diff --git a/packages/grafana-ui/src/components/Sparkline/utils.test.ts b/packages/grafana-ui/src/components/Sparkline/utils.test.ts index d563d83f334..9eb914af438 100644 --- a/packages/grafana-ui/src/components/Sparkline/utils.test.ts +++ b/packages/grafana-ui/src/components/Sparkline/utils.test.ts @@ -1,6 +1,6 @@ -import { FieldSparkline, FieldType } from '@grafana/data'; +import { Field, FieldSparkline, FieldType } from '@grafana/data'; -import { preparePlotFrame } from './utils'; +import { getYRange, preparePlotFrame } from './utils'; describe('Prepare Sparkline plot frame', () => { it('should return sorted array if x-axis numeric', () => { @@ -91,3 +91,89 @@ describe('Prepare Sparkline plot frame', () => { expect(frame.fields[1].values).toEqual([2, null, 3, null, null, 1]); }); }); + +describe('Get y range', () => { + const defaultYField: Field = { + name: 'y', + values: [1, 2, 3, 4, 5], + type: FieldType.number, + config: {}, + state: { range: { min: 1, max: 5, delta: 4 } }, + }; + const straightLineYField: Field = { + name: 'y', + values: [2, 2, 2, 2, 2], + type: FieldType.number, + config: {}, + state: { range: { min: 2, max: 2, delta: 0 } }, + }; + const straightLineNegYField: Field = { + name: 'y', + values: [-2, -2, -2, -2, -2], + type: FieldType.number, + config: {}, + state: { range: { min: -2, max: -2, delta: 0 } }, + }; + const xField: Field = { + name: 'x', + values: [1000, 2000, 3000, 4000, 5000], + type: FieldType.time, + config: {}, + }; + const getAlignedFrame = (yField: Field) => ({ + refId: 'sparkline', + fields: [xField, yField], + length: yField.values.length, + }); + it.each([ + { + description: 'inferred min and max', + field: defaultYField, + expected: [1, 5], + }, + { + description: 'min from config', + field: { ...defaultYField, config: { min: 3 }, state: { range: { min: 3, max: 5, delta: 2 } } }, + expected: [3, 5], + }, + { + description: 'max from config', + field: { ...defaultYField, config: { max: 30 }, state: { range: { min: 1, max: 30, delta: 29 } } }, + expected: [1, 30], + }, + { + description: 'no value is set', + field: { ...defaultYField, config: { noValue: '0' } }, + expected: [0, 5], + }, + { + description: 'NaN no value is set', + field: { ...defaultYField, config: { noValue: 'foo' } }, + expected: [1, 5], + }, + { + description: 'straight line', + field: straightLineYField, + expected: [0, 4], + }, + { + description: 'straight line, negative values', + field: straightLineNegYField, + expected: [-4, 0], + }, + { + description: 'straight line with config min and max', + field: { ...straightLineYField, config: { min: 1, max: 3 }, state: { range: { min: 1, max: 3, delta: 2 } } }, + expected: [1, 3], + }, + { + description: 'straight line with config no value', + field: { ...straightLineYField, config: { noValue: '0' } }, + expected: [0, 2], + }, + ])(`should return correct range for $description`, ({ field, expected }) => { + const actual = getYRange(field, getAlignedFrame(field)); + expect(actual).toEqual(expected); + expect(actual[0]).toBeLessThan(actual[1]!); + }); +}); diff --git a/packages/grafana-ui/src/components/Sparkline/utils.ts b/packages/grafana-ui/src/components/Sparkline/utils.ts index aa77fd64dc4..5c2afd8a1cb 100644 --- a/packages/grafana-ui/src/components/Sparkline/utils.ts +++ b/packages/grafana-ui/src/components/Sparkline/utils.ts @@ -1,3 +1,5 @@ +import { Range } from 'uplot'; + import { DataFrame, FieldConfig, @@ -6,6 +8,7 @@ import { isLikelyAscendingVector, sortDataFrame, applyNullInsertThreshold, + Field, } from '@grafana/data'; import { GraphFieldConfig } from '@grafana/schema'; @@ -48,3 +51,37 @@ export function preparePlotFrame(sparkline: FieldSparkline, config?: FieldConfig refFieldPseudoMax: sparkline.timeRange?.to.valueOf(), }); } + +/** + * apply configuration defaults and ensure that the range is never two equal values. + */ +export function getYRange(field: Field, alignedFrame: DataFrame): Range.MinMax { + let { min, max } = alignedFrame.fields[1].state?.range!; + + // enure that the min/max from the field config are respected + min = Math.max(min!, field.config.min ?? -Infinity); + max = Math.min(max!, field.config.max ?? Infinity); + + // if noValue is set, ensure that it is included in the range as well + const noValue = +alignedFrame.fields[1].config?.noValue!; + if (!Number.isNaN(noValue)) { + min = Math.min(min, noValue); + max = Math.max(max, noValue); + } + + // if min and max are equal after all of that, create a range + // that allows the sparkline to be visible in the center of the viz + if (min === max) { + if (min === 0) { + max = 100; + } else if (min < 0) { + max = 0; + min *= 2; + } else { + min = 0; + max *= 2; + } + } + + return [min, max]; +} diff --git a/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.tsx b/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.tsx index 6686f76d49b..d0c9a479042 100644 --- a/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.tsx +++ b/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.tsx @@ -5,7 +5,7 @@ import { useMemo, ReactNode } from 'react'; import { ActionModel, GrafanaTheme2, LinkModel } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { useStyles2 } from '../../themes/ThemeContext'; +import { useStyles2, useTheme2 } from '../../themes/ThemeContext'; import { Portal } from '../Portal/Portal'; import { VizTooltipFooter } from '../VizTooltip/VizTooltipFooter'; import { VizTooltipWrapper } from '../VizTooltip/VizTooltipWrapper'; @@ -25,6 +25,7 @@ interface Props { * @internal */ export const DataLinksActionsTooltip = ({ links, actions, value, coords, onTooltipClose }: Props) => { + const theme = useTheme2(); const styles = useStyles2(getStyles); // the order of middleware is important! @@ -83,7 +84,7 @@ export const DataLinksActionsTooltip = ({ links, actions, value, coords, onToolt <> {/* TODO: we can remove `value` from this component when tableNextGen is fully rolled out */} {value} - +
{ return { tooltipWrapper: css({ - zIndex: theme.zIndex.portal, whiteSpace: 'pre', borderRadius: theme.shape.radius.default, background: theme.colors.background.primary, diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/ActionsCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/ActionsCell.tsx index 087efa47ecc..c8c2465a07a 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/ActionsCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/ActionsCell.tsx @@ -1,29 +1,16 @@ import { css } from '@emotion/css'; -import { useMemo } from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; - -import { useStyles2 } from '../../../../themes/ThemeContext'; import { ActionButton } from '../../../Actions/ActionButton'; -import { ActionCellProps } from '../types'; +import { ActionCellProps, TableCellStyles } from '../types'; export const ActionsCell = ({ field, rowIdx, getActions }: ActionCellProps) => { - const styles = useStyles2(getStyles); + const actions = getActions(field, rowIdx); - const actions = useMemo(() => getActions(field, rowIdx), [getActions, field, rowIdx]); + if (actions.length === 0) { + return null; + } - return ( -
- {actions.map((action, i) => ( - - ))} -
- ); + return actions.map((action, i) => ); }; -const getStyles = (theme: GrafanaTheme2) => ({ - buttonsGap: css({ - display: 'flex', - gap: 6, - }), -}); +export const getStyles: TableCellStyles = (theme) => css({ gap: theme.spacing(0.75) }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx index 43de0b5bb1e..e169c756a94 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx @@ -1,9 +1,11 @@ +import { css } from '@emotion/css'; + import { formattedValueToString } from '@grafana/data'; import { MaybeWrapWithLink } from '../MaybeWrapWithLink'; -import { AutoCellProps } from '../types'; +import { AutoCellProps, TableCellStyles } from '../types'; -export default function AutoCell({ value, field, rowIdx }: AutoCellProps) { +export function AutoCell({ value, field, rowIdx }: AutoCellProps) { const displayValue = field.display!(value); const formattedValue = formattedValueToString(displayValue); return ( @@ -12,3 +14,24 @@ export default function AutoCell({ value, field, rowIdx }: AutoCellProps) { ); } + +export const getStyles: TableCellStyles = (_theme, { textWrap, shouldOverflow }) => + css({ + ...(textWrap && { whiteSpace: 'pre-line' }), + ...(shouldOverflow && { + '&:hover, &[aria-selected=true]': { + whiteSpace: 'pre-line', + }, + }), + }); + +export const getJsonCellStyles: TableCellStyles = (_theme, { textWrap, shouldOverflow }) => + css({ + fontFamily: 'monospace', + ...(textWrap && { whiteSpace: 'pre' }), + ...(shouldOverflow && { + '&:hover, &[aria-selected=true]': { + whiteSpace: 'pre', + }, + }), + }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/BarGaugeCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/BarGaugeCell.tsx index 3f762218553..700dbf2e33e 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/BarGaugeCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/BarGaugeCell.tsx @@ -3,8 +3,9 @@ import { BarGaugeDisplayMode, BarGaugeValueMode, TableCellDisplayMode } from '@g import { BarGauge } from '../../../BarGauge/BarGauge'; import { MaybeWrapWithLink } from '../MaybeWrapWithLink'; +import { TABLE } from '../constants'; import { BarGaugeCellProps } from '../types'; -import { extractPixelValue, getCellOptions, getAlignmentFactor } from '../utils'; +import { getCellOptions, getAlignmentFactor } from '../utils'; const defaultScale: ThresholdsConfig = { mode: ThresholdsMode.Absolute, @@ -23,7 +24,7 @@ const defaultScale: ThresholdsConfig = { export const BarGaugeCell = ({ value, field, theme, height, width, rowIdx }: BarGaugeCellProps) => { const displayValue = field.display!(value); const cellOptions = getCellOptions(field); - const heightOffset = extractPixelValue(theme.spacing(1)); + const heightOffset = TABLE.CELL_PADDING * 2; let config = getFieldConfigWithMinMax(field, false); if (!config.thresholds) { @@ -45,12 +46,14 @@ export const BarGaugeCell = ({ value, field, theme, height, width, rowIdx }: Bar } const alignmentFactors = getAlignmentFactor(field, displayValue, rowIdx!); + // clamp the height of the gauge so it isn't stretched for large rows + const renderedHeight = Math.min(height - heightOffset, TABLE.MAX_CELL_HEIGHT); return ( { const links = getCellLinks(field, rowIdx); @@ -14,3 +16,26 @@ export const DataLinksCell = ({ field, rowIdx }: DataLinksCellProps) => { )); }; + +export const getStyles: TableCellStyles = (theme, { textWrap, textAlign }) => + css({ + ...(textWrap && { + flexDirection: 'column', + justifyContent: 'center', + alignItems: getJustifyContent(textAlign), + }), + '> a': { + flexWrap: 'nowrap', + ...(!textWrap && { + paddingInline: theme.spacing(0.5), + borderRight: `2px solid ${theme.colors.border.medium}`, + '&:first-child': { + paddingInlineStart: 0, + }, + '&:last-child': { + paddingInlineEnd: 0, + borderRight: 'none', + }, + }), + }, + }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/GeoCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/GeoCell.tsx index 33d1530f8a9..94c5498cece 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/GeoCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/GeoCell.tsx @@ -2,13 +2,10 @@ import { css } from '@emotion/css'; import WKT from 'ol/format/WKT'; import { Geometry } from 'ol/geom'; -import { useStyles2 } from '../../../../themes/ThemeContext'; -import { GeoCellProps } from '../types'; +import { GeoCellProps, TableCellStyles } from '../types'; -export function GeoCell({ value, justifyContent, height }: GeoCellProps) { - const styles = useStyles2(getStyles); - - let disp = ''; +export function GeoCell({ value }: GeoCellProps) { + let disp = null; if (value instanceof Geometry) { disp = new WKT().writeGeometry(value, { @@ -19,25 +16,13 @@ export function GeoCell({ value, justifyContent, height }: GeoCellProps) { disp = `${value}`; } - return ( -
-
- {disp} -
-
- ); + return disp; } -const getStyles = () => ({ - cell: css({ - height: '100%', - display: 'flex', - alignItems: 'center', - padding: '0 8px', - }), - cellText: css({ +export const getStyles: TableCellStyles = () => + css({ + fontFamily: 'monospace', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', - }), -}); + }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx index a9fd4420ff3..c3b085cbb5b 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx @@ -1,36 +1,28 @@ import { css } from '@emotion/css'; -import { Property } from 'csstype'; -import { GrafanaTheme2 } from '@grafana/data'; - -import { useStyles2 } from '../../../../themes/ThemeContext'; import { TableCellDisplayMode } from '../../types'; import { MaybeWrapWithLink } from '../MaybeWrapWithLink'; -import { ImageCellProps } from '../types'; - -export const ImageCell = ({ cellOptions, field, height, justifyContent, value, rowIdx }: ImageCellProps) => { - const styles = useStyles2(getStyles, height, justifyContent); +import { ImageCellProps, TableCellStyles } from '../types'; +export const ImageCell = ({ cellOptions, field, value, rowIdx }: ImageCellProps) => { const { text } = field.display!(value); const { alt, title } = cellOptions.type === TableCellDisplayMode.Image ? cellOptions : { alt: undefined, title: undefined }; return ( -
- - {alt} - -
+ + {alt} + ); }; -const getStyles = (_theme: GrafanaTheme2, height: number, justifyContent: Property.JustifyContent) => ({ - image: css({ - height, - width: 'auto', - }), - imageContainer: css({ - display: 'flex', - justifyContent, - }), -}); +export const getStyles: TableCellStyles = () => + css({ + 'a, img': { + width: '100%', + height: '100%', + }, + img: { + objectFit: 'contain', + }, + }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/MarkdownCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/MarkdownCell.tsx index 7d4922c2434..7d3e36e3f65 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/MarkdownCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/MarkdownCell.tsx @@ -1,17 +1,49 @@ +import { css } from '@emotion/css'; + import { renderMarkdown } from '@grafana/data'; import { MaybeWrapWithLink } from '../MaybeWrapWithLink'; -import { MarkdownCellProps } from '../types'; +import { MarkdownCellProps, TableCellStyles } from '../types'; export function MarkdownCell({ field, rowIdx, disableSanitizeHtml }: MarkdownCellProps) { + const rawValue = field.values[rowIdx]; + if (rawValue == null) { + return null; + } + + const renderValue = field.display!(rawValue); + return (
); } + +export const getStyles: TableCellStyles = (theme) => + css({ + '&, &:hover, &[aria-selected=true]': { + whiteSpace: 'normal', + }, + + '.markdown-container': { + width: '100%', + // for elements like `p`, `h*`, etc. which have an inherent margin, + // we want to remove the bottom margin for the last one in the container. + '> *:last-child': { + marginBottom: 0, + }, + }, + + 'ol, ul': { + paddingLeft: theme.spacing(2.5), + }, + p: { + whiteSpace: 'pre-line', + }, + }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.tsx index 35b759aa224..2b85560c792 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/PillCell.tsx @@ -1,3 +1,4 @@ +import { css } from '@emotion/css'; import { useMemo } from 'react'; import { @@ -11,7 +12,7 @@ import { } from '@grafana/data'; import { FieldColorModeId } from '@grafana/schema'; -import { PillCellProps, TableCellValue } from '../types'; +import { PillCellProps, TableCellStyles, TableCellValue } from '../types'; export function PillCell({ rowIdx, field, theme }: PillCellProps) { const value = field.values[rowIdx]; @@ -102,3 +103,25 @@ function getPillColor(value: string, field: Field, theme: GrafanaTheme2): string return getColorByStringHash(colors, value); } + +export const getStyles: TableCellStyles = (theme, { textWrap, shouldOverflow }) => + css({ + display: 'inline-flex', + gap: theme.spacing(0.5), + flexWrap: textWrap ? 'wrap' : 'nowrap', + + ...(shouldOverflow && { + '&:hover, &[aria-selected=true]': { + flexWrap: 'wrap', + }, + }), + + '> span': { + display: 'flex', + padding: theme.spacing(0.25, 0.75), + borderRadius: theme.shape.radius.default, + fontSize: theme.typography.bodySmall.fontSize, + lineHeight: theme.typography.bodySmall.lineHeight, + whiteSpace: 'nowrap', + }, + }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/SparklineCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/SparklineCell.tsx index 4979981995b..08f8b8b6cd1 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/SparklineCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/SparklineCell.tsx @@ -1,5 +1,4 @@ import { css } from '@emotion/css'; -import { Property } from 'csstype'; import * as React from 'react'; import { @@ -10,7 +9,6 @@ import { isDataFrame, Field, isDataFrameWithValue, - GrafanaTheme2, } from '@grafana/data'; import { t } from '@grafana/i18n'; import { @@ -24,11 +22,10 @@ import { VisibilityMode, } from '@grafana/schema'; -import { useStyles2 } from '../../../../themes/ThemeContext'; import { measureText } from '../../../../utils/measureText'; import { FormattedValueDisplay } from '../../../FormattedValueDisplay/FormattedValueDisplay'; import { Sparkline } from '../../../Sparkline/Sparkline'; -import { SparklineCellProps } from '../types'; +import { SparklineCellProps, TableCellStyles } from '../types'; import { getAlignmentFactor, getCellOptions } from '../utils'; export const defaultSparklineCellConfig: TableSparklineCellOptions = { @@ -45,8 +42,7 @@ export const defaultSparklineCellConfig: TableSparklineCellOptions = { }; export const SparklineCell = (props: SparklineCellProps) => { - const { field, value, theme, timeRange, rowIdx, justifyContent, width } = props; - const styles = useStyles2(getStyles, justifyContent); + const { field, value, theme, timeRange, rowIdx, width } = props; const sparkline = getSparkline(value, field); if (!sparkline) { @@ -95,26 +91,14 @@ export const SparklineCell = (props: SparklineCellProps) => { measureText(`${alignmentFactor.prefix ?? ''}${alignmentFactor.text}${alignmentFactor.suffix ?? ''}`, 16).width + theme.spacing.gridSize; - valueElement = ( - - ); + valueElement = ; } - // @TODO update width, height return ( -
+ <> {valueElement} -
+ ); }; @@ -153,14 +137,10 @@ function getTableSparklineCellOptions(field: Field): TableSparklineCellOptions { throw new Error(`Expected options type ${TableCellDisplayMode.Sparkline} but got ${options.type}`); } -const getStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent | undefined) => ({ - cellContainer: css({ - display: 'flex', +export const getStyles: TableCellStyles = (theme, { textAlign }) => + css({ width: '100%', - alignItems: 'center', - justifyContent, - }), - valueContainer: css({ - div: { width: 'inherit' }, - }), -}); + gap: theme.spacing(1), + justifyContent: 'space-between', + ...(textAlign === 'right' && { flexDirection: 'row-reverse' }), + }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.test.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.test.tsx index 44221577315..3c4e5421c4c 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.test.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.test.tsx @@ -89,7 +89,9 @@ describe('TableNG Cells renderers', () => { cellOptions, cellInspect: false, showFilters: false, - justifyContent: 'flex-start', + getActions: jest.fn(() => [ + { title: 'Action', onClick: jest.fn(() => {}), confirmation: jest.fn(), style: {} }, + ]), }) ); @@ -110,7 +112,6 @@ describe('TableNG Cells renderers', () => { cellOptions, cellInspect: false, showFilters: false, - justifyContent: 'flex-start', }) ); }, iterations); @@ -123,8 +124,8 @@ describe('TableNG Cells renderers', () => { { type: TableCellDisplayMode.JSONView, fieldType: FieldType.string }, { type: TableCellDisplayMode.Image, fieldType: FieldType.string }, { type: TableCellDisplayMode.DataLinks, fieldType: FieldType.string }, - { type: TableCellDisplayMode.Actions, fieldType: FieldType.string }, { type: TableCellDisplayMode.ColorText, fieldType: FieldType.string }, + { type: TableCellDisplayMode.Actions, fieldType: FieldType.string }, { type: TableCellDisplayMode.ColorBackground, fieldType: FieldType.string }, { type: TableCellDisplayMode.Auto, fieldType: FieldType.string }, ] as const)('should render $type cell into the document', ({ type, fieldType }) => { diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx index c406d7ffc3f..c0e2801d7f7 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx @@ -1,21 +1,20 @@ -import { ReactNode } from 'react'; +import { clsx } from 'clsx'; -import { Field, FieldType, isDataFrame, isTimeSeriesFrame } from '@grafana/data'; +import { Field, FieldType, GrafanaTheme2, isDataFrame, isTimeSeriesFrame } from '@grafana/data'; import { TableCellDisplayMode, TableCellOptions, TableCustomCellOptions } from '../../types'; -import { TableCellRendererProps } from '../types'; +import { TableCellRenderer, TableCellStyleOptions, TableCellStyles } from '../types'; +import { getCellOptions } from '../utils'; -import { ActionsCell } from './ActionsCell'; -import AutoCell from './AutoCell'; +import { ActionsCell, getStyles as getActionsCellStyles } from './ActionsCell'; +import { AutoCell, getStyles as getAutoCellStyles, getJsonCellStyles } from './AutoCell'; import { BarGaugeCell } from './BarGaugeCell'; -import { DataLinksCell } from './DataLinksCell'; -import { GeoCell } from './GeoCell'; -import { ImageCell } from './ImageCell'; -import { MarkdownCell } from './MarkdownCell'; -import { PillCell } from './PillCell'; -import { SparklineCell } from './SparklineCell'; - -export type TableCellRenderer = (props: TableCellRendererProps) => ReactNode; +import { DataLinksCell, getStyles as getDataLinksStyles } from './DataLinksCell'; +import { GeoCell, getStyles as getGeoCellStyles } from './GeoCell'; +import { ImageCell, getStyles as getImageStyles } from './ImageCell'; +import { MarkdownCell, getStyles as getMarkdownCellStyles } from './MarkdownCell'; +import { PillCell, getStyles as getPillStyles } from './PillCell'; +import { SparklineCell, getStyles as getSparklineCellStyles } from './SparklineCell'; const GAUGE_RENDERER: TableCellRenderer = (props) => ( ( ( /> ); -const GEO_RENDERER: TableCellRenderer = (props) => ( - -); +const GEO_RENDERER: TableCellRenderer = (props) => ; const IMAGE_RENDERER: TableCellRenderer = (props) => ( - + ); const DATA_LINKS_RENDERER: TableCellRenderer = (props) => ; @@ -65,12 +54,23 @@ const ACTIONS_RENDERER: TableCellRenderer = ({ field, rowIdx, getActions = () => ); +const MARKDOWN_RENDERER: TableCellRenderer = (props) => ( + +); + const PILL_RENDERER: TableCellRenderer = (props) => ; function isCustomCellOptions(options: TableCellOptions): options is TableCustomCellOptions { return options.type === TableCellDisplayMode.Custom; } +function mixinAutoCellStyles(fn: TableCellStyles): TableCellStyles { + return (theme, options) => { + const styles = fn(theme, options); + return clsx(styles, getAutoCellStyles(theme, options)); + }; +} + const CUSTOM_RENDERER: TableCellRenderer = (props) => { if (!isCustomCellOptions(props.cellOptions) || !props.cellOptions.cellComponent) { return null; // nonsensical case, but better to typeguard it than throw. @@ -79,23 +79,57 @@ const CUSTOM_RENDERER: TableCellRenderer = (props) => { return ; }; -const MARKDOWN_RENDERER: TableCellRenderer = (props) => ( - -); - -const CELL_RENDERERS: Record = { - [TableCellDisplayMode.Sparkline]: SPARKLINE_RENDERER, - [TableCellDisplayMode.Gauge]: GAUGE_RENDERER, - [TableCellDisplayMode.JSONView]: AUTO_RENDERER, - [TableCellDisplayMode.Image]: IMAGE_RENDERER, - [TableCellDisplayMode.DataLinks]: DATA_LINKS_RENDERER, - [TableCellDisplayMode.Actions]: ACTIONS_RENDERER, - [TableCellDisplayMode.Custom]: CUSTOM_RENDERER, - [TableCellDisplayMode.ColorText]: AUTO_RENDERER, - [TableCellDisplayMode.ColorBackground]: AUTO_RENDERER, - [TableCellDisplayMode.Auto]: AUTO_RENDERER, - [TableCellDisplayMode.Markdown]: MARKDOWN_RENDERER, - [TableCellDisplayMode.Pill]: PILL_RENDERER, +const CELL_RENDERERS: Record = { + [TableCellDisplayMode.Actions]: { + renderer: ACTIONS_RENDERER, + getStyles: getActionsCellStyles, + }, + [TableCellDisplayMode.Auto]: { + renderer: AUTO_RENDERER, + getStyles: getAutoCellStyles, + }, + [TableCellDisplayMode.ColorBackground]: { + renderer: AUTO_RENDERER, + getStyles: getAutoCellStyles, + }, + [TableCellDisplayMode.ColorText]: { + renderer: AUTO_RENDERER, + getStyles: getAutoCellStyles, + }, + [TableCellDisplayMode.Custom]: { + renderer: CUSTOM_RENDERER, + }, + [TableCellDisplayMode.DataLinks]: { + renderer: DATA_LINKS_RENDERER, + getStyles: getDataLinksStyles, + }, + [TableCellDisplayMode.Gauge]: { + renderer: GAUGE_RENDERER, + }, + [TableCellDisplayMode.Geo]: { + renderer: GEO_RENDERER, + getStyles: getGeoCellStyles, + }, + [TableCellDisplayMode.Image]: { + renderer: IMAGE_RENDERER, + getStyles: getImageStyles, + }, + [TableCellDisplayMode.JSONView]: { + renderer: AUTO_RENDERER, + getStyles: mixinAutoCellStyles(getJsonCellStyles), + }, + [TableCellDisplayMode.Pill]: { + renderer: PILL_RENDERER, + getStyles: getPillStyles, + }, + [TableCellDisplayMode.Sparkline]: { + renderer: SPARKLINE_RENDERER, + getStyles: getSparklineCellStyles, + }, + [TableCellDisplayMode.Markdown]: { + renderer: MARKDOWN_RENDERER, + getStyles: getMarkdownCellStyles, + }, }; // TODO: come up with a more elegant way to handle this. @@ -105,29 +139,59 @@ const STRING_ONLY_RENDERERS = new Set([ ]); /** @internal */ -export function getCellRenderer(field: Field, cellOptions: TableCellOptions): TableCellRenderer { +export function getCellRenderer( + field: Field, + cellOptions: TableCellOptions = getCellOptions(field) +): TableCellRenderer { const cellType = cellOptions?.type ?? TableCellDisplayMode.Auto; if (cellType === TableCellDisplayMode.Auto) { - return getAutoRendererResult(field); + return CELL_RENDERERS[getAutoRendererDisplayMode(field)].renderer; } if (STRING_ONLY_RENDERERS.has(cellType) && field.type !== FieldType.string) { return AUTO_RENDERER; } - return CELL_RENDERERS[cellType] ?? AUTO_RENDERER; + // cautious fallback to Auto renderer in case some garbage cell type has been provided. + return CELL_RENDERERS[cellType]?.renderer ?? AUTO_RENDERER; } /** @internal */ -export function getAutoRendererResult(field: Field): TableCellRenderer { +export function getCellSpecificStyles( + cellType: TableCellOptions['type'], + field: Field, + theme: GrafanaTheme2, + options: TableCellStyleOptions +): string | undefined { + if (cellType === TableCellDisplayMode.Auto) { + return getAutoRendererStyles(theme, options, field); + } + return CELL_RENDERERS[cellType]?.getStyles?.(theme, options); +} + +/** @internal */ +export function getAutoRendererStyles( + theme: GrafanaTheme2, + options: TableCellStyleOptions, + field: Field +): string | undefined { + const impliedDisplayMode = getAutoRendererDisplayMode(field); + if (impliedDisplayMode !== TableCellDisplayMode.Auto) { + return CELL_RENDERERS[impliedDisplayMode]?.getStyles?.(theme, options); + } + return getAutoCellStyles(theme, options); +} + +/** @internal */ +export function getAutoRendererDisplayMode(field: Field): TableCellOptions['type'] { if (field.type === FieldType.geo) { - return GEO_RENDERER; + return TableCellDisplayMode.Geo; } if (field.type === FieldType.frame) { const firstValue = field.values[0]; if (isDataFrame(firstValue) && isTimeSeriesFrame(firstValue)) { - return SPARKLINE_RENDERER; + return TableCellDisplayMode.Sparkline; } } - return AUTO_RENDERER; + return TableCellDisplayMode.Auto; } diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.test.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.test.tsx index 9dfe1a2719b..336f37f01f7 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.test.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { @@ -1242,57 +1242,6 @@ describe('TableNG', () => { }); }); - // TODO we need to test this with an e2e rather than a unit test, because the element dimensions calcs - // don't work in unit tests (no clientWidth/Height) - describe.skip('Resizing', () => { - beforeEach(() => { - window.HTMLElement.prototype.scrollIntoView = jest.fn(); - window.HTMLElement.prototype.setPointerCapture = jest.fn(); - window.HTMLElement.prototype.hasPointerCapture = jest.fn(); - window.HTMLElement.prototype.releasePointerCapture = jest.fn(); - window.HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({ - width: 100, - height: 20, - top: 0, - left: 0, - bottom: 0, - right: 0, - x: 0, - y: 0, - toJSON: jest.fn(() => ''), - })); - }); - - it('calls onColumnResize when column is resized', async () => { - const onColumnResize = jest.fn(); - - const { container } = render( - - ); - - // Find resize handle - const resizeHandles = container.querySelectorAll('.rdg-header-row > [role="columnheader"] > div:last-child'); - const handle = resizeHandles[0]; - - if (!handle) { - throw new Error('Resize handle not found'); - } - - // simulate a click, then drag, then release. - await userEvent.pointer({ keys: '[MouseLeft>]', coords: { x: 0, y: 0 }, target: handle }); - await userEvent.pointer({ coords: { x: 250, y: 0 }, target: handle }); - await userEvent.pointer({ keys: '[/MouseLeft]', coords: { x: 250, y: 0 }, target: handle }); - - await waitFor(() => expect(onColumnResize).toHaveBeenCalled()); - }); - }); - describe('Text wrapping', () => { it('defaults to not wrapping text', () => { const { container } = render( @@ -1344,34 +1293,6 @@ describe('TableNG', () => { }); }); - describe('Context menu', () => { - it('should show context menu on right-click', async () => { - const { container } = render( - - ); - - const cell = container.querySelector('[role="gridcell"]'); - expect(cell).toBeInTheDocument(); - - // Trigger context menu directly on the cell element - if (cell) { - fireEvent.contextMenu(cell); - } - - // Check that context menu is shown - const menu = await screen.findByRole('menu'); - expect(menu).toBeInTheDocument(); - - // Check for the Inspect value menu item - const menuItem = await screen.findByText('Inspect value'); - expect(menuItem).toBeInTheDocument(); - - // close the menu - await userEvent.click(container); - expect(menuItem).not.toBeInTheDocument(); - }); - }); - describe('Cell inspection', () => { it('shows inspect icon when hovering over a cell with inspection enabled', async () => { const inspectDataFrame = { diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx index f8bf1c16491..c461dd23a33 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx @@ -1,8 +1,7 @@ import 'react-data-grid/lib/styles.css'; -import { css } from '@emotion/css'; + import { clsx } from 'clsx'; -import { Property } from 'csstype'; -import { CSSProperties, Key, ReactNode, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { CSSProperties, Key, ReactNode, useCallback, useMemo, useRef, useState } from 'react'; import { Cell, CellRendererProps, @@ -22,15 +21,12 @@ import { Field, FieldType, getDisplayProcessor, - GrafanaTheme2, ReducerID, } from '@grafana/data'; -import { t, Trans } from '@grafana/i18n'; -import { FieldColorModeId } from '@grafana/schema'; +import { Trans } from '@grafana/i18n'; +import { FieldColorModeId, TableCellTooltipPlacement } from '@grafana/schema'; import { useStyles2, useTheme2 } from '../../../themes/ThemeContext'; -import { ContextMenu } from '../../ContextMenu/ContextMenu'; -import { MenuItem } from '../../Menu/MenuItem'; import { Pagination } from '../../Pagination/Pagination'; import { PanelContext, usePanelContext } from '../../PanelChrome'; import { DataLinksActionsTooltip } from '../DataLinksActionsTooltip'; @@ -38,10 +34,11 @@ import { TableCellInspector, TableCellInspectorMode } from '../TableCellInspecto import { TableCellDisplayMode } from '../types'; import { DataLinksActionsTooltipState } from '../utils'; -import { HeaderCell } from './Cells/HeaderCell'; -import { RowExpander } from './Cells/RowExpander'; -import { TableCellActions } from './Cells/TableCellActions'; -import { getCellRenderer } from './Cells/renderers'; +import { getCellRenderer, getCellSpecificStyles } from './Cells/renderers'; +import { HeaderCell } from './components/HeaderCell'; +import { RowExpander } from './components/RowExpander'; +import { TableCellActions } from './components/TableCellActions'; +import { TableCellTooltip } from './components/TableCellTooltip'; import { COLUMN, TABLE } from './constants'; import { useColumnResize, @@ -53,9 +50,18 @@ import { useScrollbarWidth, useSortedRows, } from './hooks'; -import { TableNGProps, TableRow, TableSummaryRow, TableColumn, ContextMenuProps } from './types'; +import { + getDefaultCellStyles, + getFooterStyles, + getGridStyles, + getHeaderCellStyles, + getLinkStyles, + getTooltipStyles, +} from './styles'; +import { TableNGProps, TableRow, TableSummaryRow, TableColumn, InspectCellProps, TableCellStyleOptions } from './types'; import { applySort, + canFieldBeColorized, computeColWidths, createTypographyContext, displayJsonValue, @@ -63,7 +69,7 @@ import { frameToRecords, getAlignment, getApplyToRowBgFn, - getCellColors, + getCellColorInlineStyles, getCellLinks, getCellOptions, getDefaultRowHeight, @@ -72,9 +78,9 @@ import { getJustifyContent, getVisibleFields, isCellInspectEnabled, + predicateByName, shouldTextOverflow, shouldTextWrap, - TextAlign, withDataLinksActionsTooltip, } from './utils'; @@ -89,6 +95,7 @@ export function TableNG(props: TableNGProps) { enableSharedCrosshair = false, enableVirtualization, footerOptions, + frozenColumns = 0, getActions = () => [], height, initialSortBy, @@ -98,6 +105,7 @@ export function TableNG(props: TableNGProps) { onSortByChange, showTypeIcons, structureRev, + timeRange, transparent, width, } = props; @@ -123,27 +131,8 @@ export function TableNG(props: TableNGProps) { footerOptions.reducer[0] === ReducerID.count ); - const [contextMenuProps, setContextMenuProps] = useState(null); - const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); - const resizeHandler = useColumnResize(onColumnResize); - useLayoutEffect(() => { - if (!isContextMenuOpen) { - return; - } - - function onClick(_event: MouseEvent) { - setIsContextMenuOpen(false); - } - - window.addEventListener('click', onClick); - - return () => { - window.removeEventListener('click', onClick); - }; - }, [isContextMenuOpen]); - const rows = useMemo(() => frameToRecords(data), [data]); const hasNestedFrames = useMemo(() => getIsNestedTable(data.fields), [data]); @@ -161,7 +150,7 @@ export function TableNG(props: TableNGProps) { setSortColumns, } = useSortedRows(filteredRows, data.fields, { hasNestedFrames, initialSortBy }); - const [isInspecting, setIsInspecting] = useState(false); + const [inspectCell, setInspectCell] = useState(null); const [expandedRows, setExpandedRows] = useState(() => new Set()); // vt scrollbar accounting for column auto-sizing @@ -186,6 +175,19 @@ export function TableNG(props: TableNGProps) { [theme] ); const widths = useMemo(() => computeColWidths(visibleFields, availableWidth), [visibleFields, availableWidth]); + const numColsFullyInView = useMemo( + () => + widths.reduce( + ([count, remainingWidth], nextWidth) => { + if (remainingWidth - nextWidth >= 0) { + return [count + 1, remainingWidth - nextWidth]; + } + return [count, 0]; + }, + [0, availableWidth] + )[0], + [widths, availableWidth] + ); const headerHeight = useHeaderHeight({ columnWidths: widths, fields: visibleFields, @@ -254,27 +256,6 @@ export function TableNG(props: TableNGProps) { sortable: true, // draggable: true, }, - onCellContextMenu: ({ row, column }, event) => { - // in nested tables, it's possible for this event to trigger in a column header - // when holding Ctrl for multi-row sort. - if (column.key === 'expanded') { - return; - } - - event.preventGridDefault(); - // Do not show the default context menu - event.preventDefault(); - - const cellValue = row[column.key]; - setContextMenuProps({ - // rowIdx: rows.indexOf(row), - value: String(cellValue ?? ''), - top: event.clientY, - left: event.clientX, - }); - - setIsContextMenuOpen(true); - }, onColumnResize: resizeHandler, onSortColumnsChange: (newSortColumns: SortColumn[]) => { setSortColumns(newSortColumns); @@ -364,37 +345,16 @@ export function TableNG(props: TableNGProps) { : undefined; const shouldOverflow = rowHeight !== 'auto' && shouldTextOverflow(field); - const shouldWrap = rowHeight === 'auto' || shouldTextWrap(field); + const textWrap = rowHeight === 'auto' || shouldTextWrap(field); const withTooltip = withDataLinksActionsTooltip(field, cellType); - const canBeColorized = - cellType === TableCellDisplayMode.ColorBackground || cellType === TableCellDisplayMode.ColorText; - const isMonospace = cellType === TableCellDisplayMode.JSONView; + const canBeColorized = canFieldBeColorized(cellType, applyToRowBgFn); + const cellStyleOptions: TableCellStyleOptions = { textAlign, textWrap, shouldOverflow }; result.colsWithTooltip[displayName] = withTooltip; - // get static cell class based on col props - - let cellClass = ''; - - switch (cellType) { - case TableCellDisplayMode.Auto: - case TableCellDisplayMode.ColorBackground: - case TableCellDisplayMode.ColorText: - case TableCellDisplayMode.DataLinks: - case TableCellDisplayMode.JSONView: - case TableCellDisplayMode.Pill: - case TableCellDisplayMode.Markdown: - cellClass = getCellStyles( - theme, - cellType, - textAlign, - shouldWrap, - shouldOverflow, - canBeColorized, - isMonospace - ); - break; - } + const defaultCellStyles = getDefaultCellStyles(theme, cellStyleOptions); + const cellSpecificStyles = getCellSpecificStyles(cellType, field, theme, cellStyleOptions); + const linkStyles = getLinkStyles(theme, canBeColorized); // TODO: in future extend this to ensure a non-classic color scheme is set with AutoCell @@ -411,9 +371,7 @@ export function TableNG(props: TableNGProps) { // generate shared styles for whole row if (applyToRowBgFn != null) { - let { textColor, bgColor } = applyToRowBgFn(rowIdx); - rowCellStyle.color = textColor; - rowCellStyle.background = bgColor; + rowCellStyle = { ...rowCellStyle, ...applyToRowBgFn(rowIdx) }; } } @@ -421,25 +379,25 @@ export function TableNG(props: TableNGProps) { if (rowCellStyle.color != null || rowCellStyle.background != null) { style = rowCellStyle; - } - // apply background for cell types which can have a background and have proper - else if (canBeColorized) { + } else if (canBeColorized) { const value = props.row[props.column.key]; const displayValue = field.display!(value); // this fires here to get colors, then again to get rendered value? - let { textColor, bgColor } = getCellColors(theme, cellOptions, displayValue); - style = { - color: textColor, - background: bgColor, - }; + style = getCellColorInlineStyles(theme, cellOptions, displayValue); } - return ; // TODO: remove expensive concat + return ( + + ); }; result.cellRootRenderers[displayName] = renderCellRoot; - // this fires second - const renderCellContent = (props: RenderCellProps): JSX.Element => { + const renderBasicCellContent = (props: RenderCellProps): JSX.Element => { const rowIdx = props.row.__index; const value = props.row[props.column.key]; // TODO: it would be nice to get rid of passing height down as a prop. but this value @@ -456,11 +414,11 @@ export function TableNG(props: TableNGProps) { frame, field, height, - justifyContent, rowIdx, theme, value, width, + timeRange, cellInspect, showFilters, getActions: getCellActions, @@ -475,8 +433,7 @@ export function TableNG(props: TableNGProps) { cellInspect={cellInspect} showFilters={showFilters} className={cellActionClassName} - setIsInspecting={setIsInspecting} - setContextMenuProps={setContextMenuProps} + setInspectCell={setInspectCell} onCellFilterAdded={onCellFilterAdded} /> )} @@ -484,12 +441,84 @@ export function TableNG(props: TableNGProps) { ); }; + // renderCellContent fires second. + let renderCellContent = renderBasicCellContent; + + const tooltipFieldName = field.config.custom?.tooltip?.field; + if (tooltipFieldName) { + const tooltipField = data.fields.find(predicateByName(tooltipFieldName)); + if (tooltipField) { + const tooltipDisplayName = getDisplayName(tooltipField); + const tooltipCellOptions = getCellOptions(tooltipField); + const tooltipFieldRenderer = getCellRenderer(tooltipField, tooltipCellOptions); + const tooltipCellStyleOptions = { + textAlign: getAlignment(tooltipField), + textWrap: shouldTextWrap(tooltipField), + shouldOverflow: false, + } satisfies TableCellStyleOptions; + const tooltipCanBeColorized = canFieldBeColorized(tooltipCellOptions.type, applyToRowBgFn); + const tooltipDefaultStyles = getDefaultCellStyles(theme, tooltipCellStyleOptions); + const tooltipSpecificStyles = getCellSpecificStyles( + tooltipCellOptions.type, + tooltipField, + theme, + tooltipCellStyleOptions + ); + const tooltipLinkStyles = getLinkStyles(theme, tooltipCanBeColorized); + const tooltipClasses = getTooltipStyles(theme, textAlign); + + const placement = field.config.custom?.tooltip?.placement ?? TableCellTooltipPlacement.Auto; + const tooltipWidth = + placement === TableCellTooltipPlacement.Left || placement === TableCellTooltipPlacement.Right + ? tooltipField.config.custom?.width + : width; + + const tooltipProps = { + cellOptions: tooltipCellOptions, + classes: tooltipClasses, + className: clsx( + tooltipClasses.tooltipContent, + tooltipDefaultStyles, + tooltipSpecificStyles, + tooltipLinkStyles + ), + data, + disableSanitizeHtml, + field: tooltipField, + getActions: getCellActions, + gridRef, + placement, + renderer: tooltipFieldRenderer, + tooltipField, + theme, + width: tooltipWidth, + } satisfies Partial>; + + renderCellContent = (props: RenderCellProps): JSX.Element => { + // cached so we don't care about multiple calls. + const height = rowHeightFn(props.row); + let tooltipStyle: CSSProperties | undefined; + if (tooltipCanBeColorized) { + const tooltipDisplayValue = tooltipField.display!(props.row[tooltipDisplayName]); // this is yet another call to field.display() for the tooltip field + tooltipStyle = getCellColorInlineStyles(theme, tooltipCellOptions, tooltipDisplayValue); + } + + return ( + + {renderBasicCellContent(props)} + + ); + }; + } + } + const column: TableColumn = { field, key: displayName, name: displayName, width, headerCellClass, + frozen: Math.min(frozenColumns, numColsFullyInView) > i, renderCell: renderCellContent, renderHeaderCell: ({ column, sortDirection }) => ( )} - {isContextMenuOpen && ( - ( - setIsInspecting(true)} - className={styles.menuItem} - /> - )} - focusOnOpen={false} - /> - )} - - {isInspecting && ( + {inspectCell && ( { - setIsInspecting(false); - setContextMenuProps(null); - }} + mode={inspectCell.mode ?? TableCellInspectorMode.text} + value={inspectCell.value} + onDismiss={() => setInspectCell(null)} /> )} @@ -812,264 +826,3 @@ const renderRowFactory = return ; }; - -const getGridStyles = ( - theme: GrafanaTheme2, - { enablePagination, transparent }: { enablePagination?: boolean; transparent?: boolean } -) => ({ - grid: css({ - '--rdg-background-color': transparent ? theme.colors.background.canvas : theme.colors.background.primary, - '--rdg-header-background-color': transparent ? theme.colors.background.canvas : theme.colors.background.primary, - '--rdg-border-color': theme.colors.border.weak, - '--rdg-color': theme.colors.text.primary, - '--rdg-summary-border-color': theme.colors.border.weak, - '--rdg-summary-border-width': '1px', - - // note: this cannot have any transparency since default cells that - // overlay/overflow on hover inherit this background and need to occlude cells below - '--rdg-row-background-color': transparent ? theme.colors.background.canvas : theme.colors.background.primary, - '--rdg-row-hover-background-color': transparent - ? theme.colors.background.primary - : theme.colors.background.secondary, - - // TODO: magic 32px number is unfortunate. it would be better to have the content - // flow using flexbox rather than hard-coding this size via a calc - blockSize: enablePagination ? 'calc(100% - 32px)' : '100%', - scrollbarWidth: 'thin', - scrollbarColor: theme.isDark ? '#fff5 #fff1' : '#0005 #0001', - - border: 'none', - - '.rdg-cell': { - padding: TABLE.CELL_PADDING, - '&:last-child': { - borderInlineEnd: 'none', - }, - }, - - // add a box shadow on hover and selection for all body cells - '& > :not(.rdg-summary-row, .rdg-header-row) > .rdg-cell': { - '&:hover, &[aria-selected=true]': { - boxShadow: theme.shadows.z2, - }, - // selected cells should appear below hovered cells. - '&:hover': { - zIndex: theme.zIndex.tooltip - 2, - }, - '&[aria-selected=true]': { - zIndex: theme.zIndex.tooltip - 3, - }, - }, - - '.rdg-header-row, .rdg-summary-row': { - '.rdg-cell': { - zIndex: theme.zIndex.tooltip - 1, - }, - }, - }), - gridNested: css({ - height: '100%', - width: `calc(100% - ${COLUMN.EXPANDER_WIDTH - TABLE.CELL_PADDING * 2 - 1}px)`, - overflow: 'visible', - marginLeft: COLUMN.EXPANDER_WIDTH - TABLE.CELL_PADDING - 1, - marginBlock: TABLE.CELL_PADDING, - }), - cellNested: css({ - '&[aria-selected=true]': { - outline: 'none', - }, - }), - noDataNested: css({ - height: TABLE.NESTED_NO_DATA_HEIGHT, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - color: theme.colors.text.secondary, - fontSize: theme.typography.h4.fontSize, - }), - cellActions: css({ - display: 'none', - position: 'absolute', - top: 0, - margin: 'auto', - height: '100%', - color: theme.colors.text.primary, - background: theme.isDark ? 'rgba(0, 0, 0, 0.7)' : 'rgba(255, 255, 255, 0.7)', - padding: theme.spacing.x0_5, - paddingInlineStart: theme.spacing.x1, - }), - cellActionsEnd: css({ - left: 0, - }), - cellActionsStart: css({ - right: 0, - }), - headerRow: css({ - paddingBlockStart: 0, - fontWeight: 'normal', - '& .rdg-cell': { - height: '100%', - alignItems: 'flex-end', - }, - }), - displayNone: css({ - display: 'none', - }), - paginationContainer: css({ - alignItems: 'center', - display: 'flex', - justifyContent: 'center', - marginTop: '8px', - width: '100%', - }), - paginationSummary: css({ - color: theme.colors.text.secondary, - fontSize: theme.typography.bodySmall.fontSize, - display: 'flex', - justifyContent: 'flex-end', - padding: theme.spacing(0, 1, 0, 2), - }), - menuItem: css({ - maxWidth: '200px', - }), -}); - -const getFooterStyles = (justifyContent: Property.JustifyContent) => ({ - footerCellCountRows: css({ - display: 'flex', - justifyContent: 'space-between', - }), - footerCell: css({ - display: 'flex', - justifyContent: justifyContent || 'space-between', - }), -}); - -const getHeaderCellStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent) => - css({ - display: 'flex', - gap: theme.spacing(0.5), - zIndex: theme.zIndex.tooltip - 1, - paddingInline: TABLE.CELL_PADDING, - paddingBlockEnd: TABLE.CELL_PADDING, - justifyContent, - '&:last-child': { - borderInlineEnd: 'none', - }, - }); - -const getCellStyles = ( - theme: GrafanaTheme2, - cellType: TableCellDisplayMode, - textAlign: TextAlign, - shouldWrap: boolean, - shouldOverflow: boolean, - isColorized: boolean, - isMonospace: boolean -) => { - const whiteSpace: CSSProperties['whiteSpace'] = (() => { - if (isMonospace) { - return 'pre'; - } - if (cellType === TableCellDisplayMode.Markdown) { - return 'normal'; - } - return 'pre-line'; - })(); - - return css({ - display: 'flex', - alignItems: 'center', - textAlign, - justifyContent: getJustifyContent(textAlign), - - ...(isColorized && { backgroundClip: 'padding-box !important' }), - ...(shouldOverflow && { minHeight: '100%' }), - ...(shouldWrap && { whiteSpace }), - ...(isMonospace && { fontFamily: 'monospace' }), - - '&:hover, &[aria-selected=true]': { - '.table-cell-actions': { - display: 'flex', - }, - ...(shouldOverflow && { - zIndex: theme.zIndex.tooltip - 2, - whiteSpace, - height: 'fit-content', - minWidth: 'fit-content', - ...(cellType === TableCellDisplayMode.Pill && { - flexWrap: 'wrap', - }), - }), - }, - - a: { - cursor: 'pointer', - ...(isColorized - ? { - color: 'inherit', - textDecoration: 'underline', - } - : { - color: theme.colors.text.link, - textDecoration: 'none', - '&:hover': { - textDecoration: 'underline', - }, - }), - }, - - ...(cellType === TableCellDisplayMode.DataLinks && { - ...(shouldWrap && { - flexDirection: 'column', - justifyContent: 'center', - alignItems: getJustifyContent(textAlign), - }), - '> a': { - flexWrap: 'nowrap', - ...(!shouldWrap && { - paddingInline: theme.spacing(0.5), - borderRight: `2px solid ${theme.colors.border.medium}`, - '&:first-child': { - paddingInlineStart: 0, - }, - '&:last-child': { - paddingInlineEnd: 0, - borderRight: 'none', - }, - }), - }, - }), - - ...(cellType === TableCellDisplayMode.Pill && { - display: 'inline-flex', - gap: theme.spacing(0.5), - flexWrap: shouldWrap ? 'wrap' : 'nowrap', - '> span': { - display: 'flex', - padding: theme.spacing(0.25, 0.75), - borderRadius: theme.shape.radius.default, - fontSize: theme.typography.bodySmall.fontSize, - lineHeight: theme.typography.bodySmall.lineHeight, - whiteSpace: 'nowrap', - }, - }), - - ...(cellType === TableCellDisplayMode.Markdown && { - '& ol, & ul': { - paddingLeft: theme.spacing(1.5), - }, - '& p': { - whiteSpace: 'pre-line', - }, - '& a': { - color: theme.colors.primary.text, - }, - // for elements like `p`, `h*`, etc. which have an inherent margin, - // we want to remove the bottom margin for the last one in the container. - '& > .markdown-container > *:last-child': { - marginBottom: 0, - }, - }), - }); -}; diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/FooterCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/components/FooterCell.tsx similarity index 100% rename from packages/grafana-ui/src/components/Table/TableNG/Cells/FooterCell.tsx rename to packages/grafana-ui/src/components/Table/TableNG/components/FooterCell.tsx diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/HeaderCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/components/HeaderCell.tsx similarity index 96% rename from packages/grafana-ui/src/components/Table/TableNG/Cells/HeaderCell.tsx rename to packages/grafana-ui/src/components/Table/TableNG/components/HeaderCell.tsx index a62cbeb6beb..8ccc73a98b9 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/HeaderCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/components/HeaderCell.tsx @@ -89,7 +89,10 @@ const getStyles = (theme: GrafanaTheme2, headerTextWrap?: boolean) => ({ whiteSpace: headerTextWrap ? 'pre-line' : 'nowrap', '&:hover': { textDecoration: 'underline', - color: theme.colors.text.link, + }, + '&::selection': { + backgroundColor: 'var(--rdg-background-color)', + color: theme.colors.text.secondary, }, }), headerCellIcon: css({ diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/RowExpander.tsx b/packages/grafana-ui/src/components/Table/TableNG/components/RowExpander.tsx similarity index 100% rename from packages/grafana-ui/src/components/Table/TableNG/Cells/RowExpander.tsx rename to packages/grafana-ui/src/components/Table/TableNG/components/RowExpander.tsx diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellActions.tsx b/packages/grafana-ui/src/components/Table/TableNG/components/TableCellActions.tsx similarity index 95% rename from packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellActions.tsx rename to packages/grafana-ui/src/components/Table/TableNG/components/TableCellActions.tsx index 746ca7027d1..cdbcde443c1 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellActions.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/components/TableCellActions.tsx @@ -15,8 +15,7 @@ export function TableCellActions(props: TableCellActionsProps) { value, cellOptions, displayName, - setIsInspecting, - setContextMenuProps, + setInspectCell, onCellFilterAdded, className, cellInspect, @@ -47,11 +46,10 @@ export function TableCellActions(props: TableCellActionsProps) { mode = TableCellInspectorMode.code; } - setContextMenuProps({ + setInspectCell({ value: String(inspectValue ?? ''), mode, }); - setIsInspecting(true); }} /> )} diff --git a/packages/grafana-ui/src/components/Table/TableNG/components/TableCellTooltip.tsx b/packages/grafana-ui/src/components/Table/TableNG/components/TableCellTooltip.tsx new file mode 100644 index 00000000000..a81f083a2f1 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/TableNG/components/TableCellTooltip.tsx @@ -0,0 +1,159 @@ +import { CSSProperties, ReactElement, useMemo, useState, useRef, useEffect, RefObject } from 'react'; +import { DataGridHandle } from 'react-data-grid'; + +import { ActionModel, DataFrame, Field, GrafanaTheme2 } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { TableCellTooltipPlacement } from '@grafana/schema'; + +import { Popover } from '../../../Tooltip/Popover'; +import { TableCellOptions } from '../../types'; +import { getTooltipStyles } from '../styles'; +import { TableCellRenderer, TableCellRendererProps } from '../types'; + +export interface Props { + cellOptions: TableCellOptions; + children: ReactElement; + classes: ReturnType; + className?: string; + data: DataFrame; + disableSanitizeHtml?: boolean; + field: Field; + getActions: (field: Field, rowIdx: number) => ActionModel[]; + gridRef: RefObject; + height: number; + placement?: TableCellTooltipPlacement; + renderer: TableCellRenderer; + rowIdx: number; + style?: CSSProperties; + tooltipField: Field; + theme: GrafanaTheme2; + width?: number; +} + +export function TableCellTooltip({ + cellOptions, + children, + classes, + className, + data, + disableSanitizeHtml, + field, + getActions, + gridRef, + height, + placement, + renderer, + rowIdx, + style, + theme, + tooltipField, + width = 300, +}: Props) { + const rawValue = field.values[rowIdx]; + const tooltipCaretRef = useRef(null); + + const [hovered, setHovered] = useState(false); + const [pinned, setPinned] = useState(false); + + const show = hovered || pinned; + const dynamicHeight = tooltipField.config.custom?.cellOptions?.dynamicHeight; + + useEffect(() => { + if (pinned) { + const gridRoot = gridRef.current?.element; + + const windowListener = (ev: Event) => { + if (ev.target === tooltipCaretRef.current) { + return; + } + + setPinned(false); + window.removeEventListener('click', windowListener); + }; + + window.addEventListener('click', windowListener); + + // right now, we kill the pinned tooltip on any form of scrolling to avoid awkward rendering + // where the tooltip bumps up against the edge of the scrollable container. we could try to + // kill the tooltip when it hits these boundaries rather than when scrolling starts. + const scrollListener = () => { + setPinned(false); + }; + gridRoot?.addEventListener('scroll', scrollListener, { once: true }); + + return () => { + window.removeEventListener('click', windowListener); + gridRoot?.removeEventListener('scroll', scrollListener); + }; + } + + return; + }, [pinned, gridRef]); + + const rendererProps = useMemo( + () => + ({ + cellInspect: false, + cellOptions, + disableSanitizeHtml, + field, + frame: data, + getActions, + height, + rowIdx, + showFilters: false, + theme, + value: rawValue, + width, + }) satisfies TableCellRendererProps, + [cellOptions, data, disableSanitizeHtml, field, getActions, height, rawValue, rowIdx, theme, width] + ); + + const cellElement = tooltipCaretRef.current?.closest('.rdg-cell'); + + if (rawValue === null || rawValue === undefined) { + return children; + } + + const body = <>{renderer(rendererProps)}; + + // TODO: perist the hover if you mouse out of the trigger and into the popover + const onMouseLeave = () => setHovered(false); + const onMouseEnter = () => setHovered(true); + + return ( + <> + {cellElement && ( + ev.stopPropagation()} // prevent click from bubbling to the global click listener for un-pinning + data-testid={selectors.components.Panels.Visualization.TableNG.Tooltip.Wrapper} + /> + )} + + {/* TODO: figure out an accessible way to trigger the tooltip. */} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} +
setPinned((prev) => !prev)} + onMouseLeave={onMouseLeave} + onMouseEnter={onMouseEnter} + onBlur={onMouseLeave} + onFocus={onMouseEnter} + /> + + {children} + + ); +} diff --git a/packages/grafana-ui/src/components/Table/TableNG/constants.ts b/packages/grafana-ui/src/components/Table/TableNG/constants.ts index 7638dd98804..e88f7181591 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/constants.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/constants.ts @@ -13,7 +13,6 @@ export const TABLE = { PAGINATION_LIMIT: 750, SCROLL_BAR_WIDTH: 8, SCROLL_BAR_MARGIN: 2, - FONT_SIZE: 14, LINE_HEIGHT: 22, HEADER_HEIGHT: 28, NESTED_NO_DATA_HEIGHT: 60, diff --git a/packages/grafana-ui/src/components/Table/TableNG/styles.ts b/packages/grafana-ui/src/components/Table/TableNG/styles.ts new file mode 100644 index 00000000000..ca837f66dbc --- /dev/null +++ b/packages/grafana-ui/src/components/Table/TableNG/styles.ts @@ -0,0 +1,210 @@ +import { css } from '@emotion/css'; +import { Property } from 'csstype'; + +import { GrafanaTheme2, colorManipulator } from '@grafana/data'; + +import { COLUMN, TABLE } from './constants'; +import { TableCellStyles } from './types'; +import { getJustifyContent, TextAlign } from './utils'; + +export const getGridStyles = ( + theme: GrafanaTheme2, + { enablePagination, transparent }: { enablePagination?: boolean; transparent?: boolean } +) => { + const bgColor = transparent ? theme.colors.background.canvas : theme.colors.background.primary; + // this needs to be pre-calc'd since the theme colors have alpha and the border color becomes + // unpredictable for background color cells + const borderColor = colorManipulator.onBackground(theme.colors.border.weak, bgColor).toHexString(); + + return { + grid: css({ + '--rdg-background-color': bgColor, + '--rdg-header-background-color': bgColor, + '--rdg-border-color': borderColor, + '--rdg-color': theme.colors.text.primary, + '--rdg-summary-border-color': borderColor, + '--rdg-summary-border-width': '1px', + + '--rdg-selection-color': theme.colors.info.transparent, + + // note: this cannot have any transparency since default cells that + // overlay/overflow on hover inherit this background and need to occlude cells below + '--rdg-row-background-color': bgColor, + '--rdg-row-hover-background-color': transparent + ? theme.colors.background.primary + : theme.colors.background.secondary, + + // TODO: magic 32px number is unfortunate. it would be better to have the content + // flow using flexbox rather than hard-coding this size via a calc + blockSize: enablePagination ? 'calc(100% - 32px)' : '100%', + scrollbarWidth: 'thin', + scrollbarColor: theme.isDark ? '#fff5 #fff1' : '#0005 #0001', + + border: 'none', + + '.rdg-cell': { + padding: TABLE.CELL_PADDING, + + '&:last-child': { + borderInlineEnd: 'none', + }, + }, + + // add a box shadow on hover and selection for all body cells + '& > :not(.rdg-summary-row, .rdg-header-row) > .rdg-cell': { + '&:hover, &[aria-selected=true]': { boxShadow: theme.shadows.z2 }, + // selected cells should appear below hovered cells. + '&:hover': { zIndex: theme.zIndex.tooltip - 7 }, + '&[aria-selected=true]': { zIndex: theme.zIndex.tooltip - 6 }, + }, + + '.rdg-cell.rdg-cell-frozen': { + backgroundColor: '--rdg-row-background-color', + zIndex: theme.zIndex.tooltip - 4, + '&:hover': { zIndex: theme.zIndex.tooltip - 2 }, + '&[aria-selected=true]': { zIndex: theme.zIndex.tooltip - 3 }, + }, + + '.rdg-header-row, .rdg-summary-row': { + '.rdg-cell': { + zIndex: theme.zIndex.tooltip - 5, + '&.rdg-cell-frozen': { + zIndex: theme.zIndex.tooltip - 1, + }, + }, + }, + }), + gridNested: css({ + height: '100%', + width: `calc(100% - ${COLUMN.EXPANDER_WIDTH - TABLE.CELL_PADDING * 2 - 1}px)`, + overflowX: 'scroll', + overflowY: 'hidden', + marginLeft: COLUMN.EXPANDER_WIDTH - TABLE.CELL_PADDING - 1, + marginBlock: TABLE.CELL_PADDING, + }), + cellNested: css({ '&[aria-selected=true]': { outline: 'none' } }), + noDataNested: css({ + height: TABLE.NESTED_NO_DATA_HEIGHT, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: theme.colors.text.secondary, + fontSize: theme.typography.h4.fontSize, + }), + cellActions: css({ + display: 'none', + position: 'absolute', + top: 0, + margin: 'auto', + height: '100%', + color: theme.colors.text.primary, + background: theme.isDark ? 'rgba(0, 0, 0, 0.7)' : 'rgba(255, 255, 255, 0.7)', + padding: theme.spacing.x0_5, + paddingInlineStart: theme.spacing.x1, + }), + cellActionsEnd: css({ left: 0 }), + cellActionsStart: css({ right: 0 }), + headerRow: css({ + paddingBlockStart: 0, + fontWeight: 'normal', + '& .rdg-cell': { height: '100%', alignItems: 'flex-end' }, + }), + displayNone: css({ display: 'none' }), + paginationContainer: css({ + alignItems: 'center', + display: 'flex', + justifyContent: 'center', + marginTop: '8px', + width: '100%', + }), + paginationSummary: css({ + color: theme.colors.text.secondary, + fontSize: theme.typography.bodySmall.fontSize, + display: 'flex', + justifyContent: 'flex-end', + padding: theme.spacing(0, 1, 0, 2), + }), + menuItem: css({ maxWidth: '200px' }), + }; +}; + +export const getFooterStyles = (justifyContent: Property.JustifyContent) => ({ + footerCellCountRows: css({ display: 'flex', justifyContent: 'space-between' }), + footerCell: css({ display: 'flex', justifyContent: justifyContent || 'space-between' }), +}); + +export const getHeaderCellStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent) => + css({ + display: 'flex', + gap: theme.spacing(0.5), + zIndex: theme.zIndex.tooltip - 1, + paddingInline: TABLE.CELL_PADDING, + paddingBlockEnd: TABLE.CELL_PADDING, + justifyContent, + '&:last-child': { borderInlineEnd: 'none' }, + }); + +export const getDefaultCellStyles: TableCellStyles = (theme, { textAlign, shouldOverflow }) => + css({ + display: 'flex', + alignItems: 'center', + textAlign, + justifyContent: getJustifyContent(textAlign), + ...(shouldOverflow && { minHeight: '100%' }), + '&:hover, &[aria-selected=true]': { + '.table-cell-actions': { display: 'flex' }, + ...(shouldOverflow && { + zIndex: theme.zIndex.tooltip - 2, + height: 'fit-content', + minWidth: 'fit-content', + }), + }, + }); + +export const getLinkStyles = (theme: GrafanaTheme2, canBeColorized: boolean) => + css({ + a: { + cursor: 'pointer', + ...(canBeColorized + ? { + color: 'inherit', + textDecoration: 'underline', + } + : { + color: theme.colors.text.link, + textDecoration: 'none', + '&:hover': { textDecoration: 'underline' }, + }), + }, + }); + +const caretTriangle = (direction: 'left' | 'right', bgColor: string) => + `linear-gradient(to top ${direction}, transparent 62.5%, ${bgColor} 50%)`; + +export const getTooltipStyles = (theme: GrafanaTheme2, textAlign: TextAlign) => ({ + tooltipContent: css({ + height: '100%', + width: '100%', + }), + tooltipWrapper: css({ + background: theme.colors.background.primary, + border: `1px solid ${theme.colors.border.weak}`, + borderRadius: theme.shape.radius.default, + boxShadow: theme.shadows.z3, + overflow: 'hidden', + padding: theme.spacing(1), + width: 'inherit', + }), + tooltipCaret: css({ + cursor: 'pointer', + position: 'absolute', + top: theme.spacing(0.25), + [textAlign === 'right' ? 'right' : 'left']: theme.spacing(0.25), + width: theme.spacing(1.75), + height: theme.spacing(1.75), + background: caretTriangle(textAlign === 'right' ? 'right' : 'left', theme.colors.border.medium), + '&:hover, &[aria-pressed=true]': { + background: caretTriangle(textAlign === 'right' ? 'right' : 'left', theme.colors.border.strong), + }, + }), +}); diff --git a/packages/grafana-ui/src/components/Table/TableNG/types.ts b/packages/grafana-ui/src/components/Table/TableNG/types.ts index eaca19734ea..03705ef669d 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/types.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/types.ts @@ -1,5 +1,4 @@ -import { Property } from 'csstype'; -import { SyntheticEvent } from 'react'; +import { ReactNode, SyntheticEvent } from 'react'; import { Column } from 'react-data-grid'; import { @@ -19,6 +18,8 @@ import { TableCellHeight, TableFieldOptions } from '@grafana/schema'; import { TableCellInspectorMode } from '../TableCellInspector'; import { TableCellOptions } from '../types'; +import { TextAlign } from './utils'; + export const FILTER_FOR_OPERATOR = '='; export const FILTER_OUT_OPERATOR = '!='; @@ -128,6 +129,7 @@ export interface BaseTableProps { onCellFilterAdded?: TableFilterActionCallback; footerOptions?: TableFooterCalc; footerValues?: FooterItem[]; + frozenColumns?: number; enablePagination?: boolean; cellHeight?: TableCellHeight; structureRev?: number; @@ -148,6 +150,8 @@ export interface BaseTableProps { /* ---------------------------- Table cell props ---------------------------- */ export interface TableNGProps extends BaseTableProps {} +export type TableCellRenderer = (props: TableCellRendererProps) => ReactNode; + export interface TableCellRendererProps { rowIdx: number; frame: DataFrame; @@ -161,17 +165,14 @@ export interface TableCellRendererProps { theme: GrafanaTheme2; cellInspect: boolean; showFilters: boolean; - justifyContent: Property.JustifyContent; getActions?: GetActionsFunctionLocal; disableSanitizeHtml?: boolean; } -export type ContextMenuProps = { +export type InspectCellProps = { rowIdx?: number; value: string; mode?: TableCellInspectorMode.code | TableCellInspectorMode.text; - top?: number; - left?: number; }; export interface TableCellActionsProps { @@ -181,8 +182,7 @@ export interface TableCellActionsProps { displayName: string; cellInspect: boolean; showFilters: boolean; - setIsInspecting: React.Dispatch>; - setContextMenuProps: React.Dispatch>; + setInspectCell: React.Dispatch>; className?: string; onCellFilterAdded?: TableFilterActionCallback; } @@ -195,7 +195,6 @@ export interface RowExpanderNGProps { export interface SparklineCellProps { field: Field; - justifyContent: Property.JustifyContent; rowIdx: number; theme: GrafanaTheme2; timeRange?: TimeRange; @@ -215,8 +214,6 @@ export interface BarGaugeCellProps { export interface ImageCellProps { cellOptions: TableCellOptions; field: Field; - height: number; - justifyContent: Property.JustifyContent; value: TableCellValue; rowIdx: number; } @@ -228,16 +225,9 @@ export interface DataLinksCellProps { export interface GeoCellProps { value: TableCellValue; - justifyContent: Property.JustifyContent; height: number; } -export interface CellColors { - textColor?: string; - bgColor?: string; - bgHoverColor?: string; -} - export interface AutoCellProps { field: Field; value: TableCellValue; @@ -262,6 +252,14 @@ export interface PillCellProps { rowIdx: number; } +export interface TableCellStyleOptions { + textWrap: boolean; + textAlign: TextAlign; + shouldOverflow: boolean; +} + +export type TableCellStyles = (theme: GrafanaTheme2, options: TableCellStyleOptions) => string; + // Comparator for sorting table values export type Comparator = (a: TableCellValue, b: TableCellValue) => number; diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts index e159c9933cb..5ebd14336b9 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts @@ -23,7 +23,7 @@ import { extractPixelValue, frameToRecords, getAlignmentFactor, - getCellColors, + getCellColorInlineStyles, getCellLinks, getCellOptions, getComparator, @@ -44,6 +44,8 @@ import { getDataLinksCounter, getPillLineCounter, getDefaultRowHeight, + getDisplayName, + predicateByName, } from './utils'; describe('TableNG utils', () => { @@ -110,9 +112,9 @@ describe('TableNG utils', () => { const displayValue = { text: '100', numeric: 100, color: '#ff0000' }; - const colors = getCellColors(theme, field, displayValue); - expect(colors.bgColor).toBe('rgb(255, 0, 0)'); - expect(colors.textColor).toBe('rgb(247, 248, 250)'); + const colors = getCellColorInlineStyles(theme, field, displayValue); + expect(colors.background).toBe('rgb(255, 0, 0)'); + expect(colors.color).toBe('rgb(247, 248, 250)'); }); it('should handle color background gradient mode', () => { @@ -123,9 +125,9 @@ describe('TableNG utils', () => { const displayValue = { text: '100', numeric: 100, color: '#ff0000' }; - const colors = getCellColors(theme, field, displayValue); - expect(colors.bgColor).toBe('linear-gradient(120deg, rgb(255, 54, 36), #ff0000)'); - expect(colors.textColor).toBe('rgb(247, 248, 250)'); + const colors = getCellColorInlineStyles(theme, field, displayValue); + expect(colors.background).toBe('linear-gradient(120deg, rgb(255, 54, 36), #ff0000)'); + expect(colors.color).toBe('rgb(247, 248, 250)'); }); }); @@ -1268,4 +1270,54 @@ describe('TableNG utils', () => { ]); }); }); + + describe('getDisplayName', () => { + it('should return the display name if set', () => { + const field: Field = { + name: 'test', + type: FieldType.string, + config: {}, + state: { displayName: 'Test Display Name' }, + values: [], + }; + expect(getDisplayName(field)).toBe('Test Display Name'); + }); + + it('should return the field name if no display name is set', () => { + const field: Field = { + name: 'test', + type: FieldType.string, + state: {}, + config: {}, + values: [], + }; + expect(getDisplayName(field)).toBe('test'); + }); + }); + + describe('predicateByName', () => { + it('should return true for matching field names', () => { + const field: Field = { name: 'test', type: FieldType.string, config: {}, values: [] }; + const predicate = predicateByName('test'); + expect(predicate(field)).toBe(true); + }); + + it('should return true for a matching display name', () => { + const field: Field = { + name: 'test', + type: FieldType.string, + config: {}, + state: { displayName: 'Test Display Name' }, + values: [], + }; + const predicate = predicateByName('Test Display Name'); + expect(predicate(field)).toBe(true); + }); + + it('should return false for non-matching field names', () => { + const field: Field = { name: 'test', type: FieldType.string, config: {}, values: [] }; + const predicate = predicateByName('other'); + expect(predicate(field)).toBe(false); + }); + }); }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.ts index 9ba4a5eddca..3c36e150a3c 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.ts @@ -29,7 +29,6 @@ import { TableCellOptions } from '../types'; import { inferPills } from './Cells/PillCell'; import { COLUMN, TABLE } from './constants'; import { - CellColors, TableRow, ColumnTypes, FrameToRowsConverter, @@ -489,18 +488,17 @@ const CELL_GRADIENT_HUE_ROTATION_DEGREES = 5; * @internal * Returns the text and background colors for a table cell based on its options and display value. */ -export function getCellColors( +export function getCellColorInlineStyles( theme: GrafanaTheme2, cellOptions: TableCellOptions, displayValue: DisplayValue -): CellColors { +): CSSProperties { // How much to darken elements depends upon if we're in dark mode const darkeningFactor = theme.isDark ? 1 : -0.7; // Setup color variables let textColor: string | undefined = undefined; let bgColor: string | undefined = undefined; - // let bgHoverColor: string | undefined = undefined; if (cellOptions.type === TableCellDisplayMode.ColorText) { textColor = displayValue.color; @@ -510,23 +508,16 @@ export function getCellColors( if (mode === TableCellBackgroundDisplayMode.Basic) { textColor = getTextColorForAlphaBackground(displayValue.color!, theme.isDark); bgColor = tinycolor(displayValue.color).toRgbString(); - // bgHoverColor = tinycolor(displayValue.color) - // .darken(CELL_COLOR_DARKENING_MULTIPLIER * darkeningFactor) - // .toRgbString(); } else if (mode === TableCellBackgroundDisplayMode.Gradient) { - // const hoverColor = tinycolor(displayValue.color) - // .darken(CELL_GRADIENT_DARKENING_MULTIPLIER * darkeningFactor) - // .toRgbString(); const bgColor2 = tinycolor(displayValue.color) .darken(CELL_COLOR_DARKENING_MULTIPLIER * darkeningFactor) .spin(CELL_GRADIENT_HUE_ROTATION_DEGREES); textColor = getTextColorForAlphaBackground(displayValue.color!, theme.isDark); bgColor = `linear-gradient(120deg, ${bgColor2.toRgbString()}, ${displayValue.color})`; - // bgHoverColor = `linear-gradient(120deg, ${bgColor2.toRgbString()}, ${hoverColor})`; } } - return { textColor, bgColor }; + return { color: textColor, background: bgColor }; } /** @@ -814,6 +805,11 @@ export const getDisplayName = (field: Field): string => { return field.state?.displayName ?? field.name; }; +/** + * @internal given a field name or display name, returns a predicate function that checks if a field matches that name. + */ +export const predicateByName = (name: string) => (f: Field) => f.name === name || getDisplayName(f) === name; + /** * @internal * returns only fields that are not nested tables and not explicitly hidden @@ -874,7 +870,7 @@ export function computeColWidths(fields: Field[], availWidth: number) { * @internal * if applyToRow is true in any field, return a function that gets the row background color */ -export function getApplyToRowBgFn(fields: Field[], theme: GrafanaTheme2): ((rowIndex: number) => CellColors) | void { +export function getApplyToRowBgFn(fields: Field[], theme: GrafanaTheme2): ((rowIndex: number) => CSSProperties) | void { for (const field of fields) { const cellOptions = getCellOptions(field); const fieldDisplay = field.display; @@ -883,7 +879,7 @@ export function getApplyToRowBgFn(fields: Field[], theme: GrafanaTheme2): ((rowI cellOptions.type === TableCellDisplayMode.ColorBackground && cellOptions.applyToRow === true ) { - return (rowIndex: number) => getCellColors(theme, cellOptions, fieldDisplay(field.values[rowIndex])); + return (rowIndex: number) => getCellColorInlineStyles(theme, cellOptions, fieldDisplay(field.values[rowIndex])); } } } @@ -897,6 +893,18 @@ export function withDataLinksActionsTooltip(field: Field, cellType: TableCellDis ); } +/** @internal */ +export function canFieldBeColorized( + cellType: TableCellDisplayMode, + applyToRowBgFn?: (rowIndex: number) => CSSProperties +) { + return ( + cellType === TableCellDisplayMode.ColorBackground || + cellType === TableCellDisplayMode.ColorText || + Boolean(applyToRowBgFn) + ); +} + export const displayJsonValue: DisplayProcessor = (value: unknown): DisplayValue => { let displayValue: string; diff --git a/packages/grafana-ui/src/components/Tooltip/Popover.tsx b/packages/grafana-ui/src/components/Tooltip/Popover.tsx index cb4b5468707..369f3b46698 100644 --- a/packages/grafana-ui/src/components/Tooltip/Popover.tsx +++ b/packages/grafana-ui/src/components/Tooltip/Popover.tsx @@ -25,6 +25,7 @@ interface Props extends Omit, 'content'> { wrapperClassName?: string; renderArrow?: boolean; hidePopper?: () => void; + style?: React.CSSProperties; } export function Popover({ @@ -36,6 +37,7 @@ export function Popover({ referenceElement, renderArrow, hidePopper, + style: styleOverrides, ...rest }: Props) { const theme = useTheme2(); @@ -89,6 +91,7 @@ export function Popover({ style={{ ...floatingStyles, ...placementStyles, + ...styleOverrides, }} className={wrapperClassName} {...rest} diff --git a/packages/grafana-ui/src/components/UnitPicker/UnitPicker.tsx b/packages/grafana-ui/src/components/UnitPicker/UnitPicker.tsx index 2709bfa067c..dff45cb2a18 100644 --- a/packages/grafana-ui/src/components/UnitPicker/UnitPicker.tsx +++ b/packages/grafana-ui/src/components/UnitPicker/UnitPicker.tsx @@ -9,6 +9,7 @@ export interface UnitPickerProps { onChange: (item?: string) => void; value?: string; width?: number; + id?: string; } function formatCreateLabel(input: string) { @@ -21,7 +22,7 @@ export class UnitPicker extends PureComponent { }; render() { - const { value, width } = this.props; + const { value, width, id } = this.props; // Set the current selection let current: SelectableValue | undefined = undefined; @@ -56,6 +57,7 @@ export class UnitPicker extends PureComponent { return ( { const legend = ( {items.map((_, index) => ( -
+
Legend item {index}
))} @@ -66,7 +64,10 @@ export const RightLegend: StoryFn = ({ height, width, legendItems, legendWidth } const legend = ( {items.map((_, index) => ( -
+
Legend item {index}
))} diff --git a/packages/grafana-ui/src/components/uPlot/geometries/EventsCanvas.tsx b/packages/grafana-ui/src/components/uPlot/geometries/EventsCanvas.tsx index a246bfd59f8..f4f2e71b7ae 100644 --- a/packages/grafana-ui/src/components/uPlot/geometries/EventsCanvas.tsx +++ b/packages/grafana-ui/src/components/uPlot/geometries/EventsCanvas.tsx @@ -1,9 +1,9 @@ -import { useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useLayoutEffect, useMemo, useRef, useState, ReactNode } from 'react'; import * as React from 'react'; import { useMountedState } from 'react-use'; import uPlot from 'uplot'; -import { DataFrame, DataFrameFieldIndex } from '@grafana/data'; +import { DataFrame } from '@grafana/data'; import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder'; @@ -14,11 +14,8 @@ interface EventsCanvasProps { id: string; config: UPlotConfigBuilder; events: DataFrame[]; - renderEventMarker: (dataFrame: DataFrame, dataFrameFieldIndex: DataFrameFieldIndex) => React.ReactNode; - mapEventToXYCoords: ( - dataFrame: DataFrame, - dataFrameFieldIndex: DataFrameFieldIndex - ) => { x: number; y: number } | undefined; + renderEventMarker: (dataFrame: DataFrame, rowIndex: number) => ReactNode; + mapEventToXYCoords: (dataFrame: DataFrame, rowIndex: number) => { x: number; y: number } | undefined; } export function EventsCanvas({ id, events, renderEventMarker, mapEventToXYCoords, config }: EventsCanvasProps) { @@ -50,14 +47,15 @@ export function EventsCanvas({ id, events, renderEventMarker, mapEventToXYCoords for (let i = 0; i < events.length; i++) { const frame = events[i]; + for (let j = 0; j < frame.length; j++) { - const coords = mapEventToXYCoords(frame, { fieldIndex: j, frameIndex: i }); + const coords = mapEventToXYCoords(frame, j); if (!coords) { continue; } markers.push( - {renderEventMarker(frame, { fieldIndex: j, frameIndex: i })} + {renderEventMarker(frame, j)} ); } diff --git a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx index 703b6a85ed1..17147e8661b 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx @@ -749,7 +749,7 @@ const getStyles = (theme: GrafanaTheme2, maxWidth?: number) => ({ tooltipWrapper: css({ top: 0, left: 0, - zIndex: theme.zIndex.portal, + zIndex: theme.zIndex.tooltip, whiteSpace: 'pre', borderRadius: theme.shape.radius.default, position: 'fixed', diff --git a/packages/grafana-ui/src/slate-plugins/suggestions.tsx b/packages/grafana-ui/src/slate-plugins/suggestions.tsx index 1d22c988822..de89e5c9263 100644 --- a/packages/grafana-ui/src/slate-plugins/suggestions.tsx +++ b/packages/grafana-ui/src/slate-plugins/suggestions.tsx @@ -1,8 +1,6 @@ import { debounce, sortBy } from 'lodash'; import { Editor, Plugin as SlatePlugin } from 'slate-react'; -import { BootData } from '@grafana/data'; - import { Typeahead } from '../components/Typeahead/Typeahead'; import { CompletionItem, SuggestionsState, TypeaheadInput, TypeaheadOutput } from '../types/completion'; import { SearchFunctionType, SearchFunctionMap } from '../utils/searchFunctions'; @@ -12,12 +10,6 @@ import TOKEN_MARK from './slate-prism/TOKEN_MARK'; export const TYPEAHEAD_DEBOUNCE = 250; -declare global { - interface Window { - grafanaBootData?: BootData; - } -} - // Commands added to the editor by this plugin. interface SuggestionsPluginCommands { selectSuggestion: (suggestion: CompletionItem) => Editor; diff --git a/pkg/aggregator/go.mod b/pkg/aggregator/go.mod index d639d68f749..c6da7f2336d 100644 --- a/pkg/aggregator/go.mod +++ b/pkg/aggregator/go.mod @@ -1,6 +1,6 @@ module github.com/grafana/grafana/pkg/aggregator -go 1.24.5 +go 1.24.6 require ( github.com/emicklei/go-restful/v3 v3.12.1 diff --git a/pkg/api/ds_query.go b/pkg/api/ds_query.go index eee746165b2..23827fdcb34 100644 --- a/pkg/api/ds_query.go +++ b/pkg/api/ds_query.go @@ -71,7 +71,16 @@ func (hs *HTTPServer) QueryMetricsV2(c *contextmodel.ReqContext) response.Respon return response.Error(http.StatusBadRequest, "bad request data", err) } - resp, err := hs.queryDataService.QueryData(c.Req.Context(), c.SignedInUser, c.SkipDSCache, reqDTO) + handleTimeInQuery := c.Req.Header.Get("X-Query-V2") == "true" + + var resp *backend.QueryDataResponse + var err error + if handleTimeInQuery { + resp, err = hs.queryDataService.QueryDataNew(c.Req.Context(), c.SignedInUser, c.SkipDSCache, reqDTO) + } else { + resp, err = hs.queryDataService.QueryData(c.Req.Context(), c.SignedInUser, c.SkipDSCache, reqDTO) + } + if err != nil { return hs.handleQueryMetricsError(err) } diff --git a/pkg/api/dtos/short_url.go b/pkg/api/dtos/short_url.go index ca185c491e0..e71fcae3aaa 100644 --- a/pkg/api/dtos/short_url.go +++ b/pkg/api/dtos/short_url.go @@ -7,4 +7,5 @@ type ShortURL struct { type CreateShortURLCmd struct { Path string `json:"path"` + UID string `json:"uid,omitempty"` } diff --git a/pkg/api/org_users_test.go b/pkg/api/org_users_test.go index 585c746f647..4a2e301bbf0 100644 --- a/pkg/api/org_users_test.go +++ b/pkg/api/org_users_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/grafana/grafana/pkg/configprovider" "github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authn/authntest" "github.com/stretchr/testify/assert" @@ -43,7 +44,9 @@ func setUpGetOrgUsersDB(t *testing.T, sqlStore db.DB, cfg *setting.Cfg) { cfg.AutoAssignOrg = true cfg.AutoAssignOrgId = int(testOrgID) - quotaService := quotaimpl.ProvideService(sqlStore, cfg) + cfgProvider, err := configprovider.ProvideService(cfg) + require.NoError(t, err) + quotaService := quotaimpl.ProvideService(context.Background(), sqlStore, cfgProvider) orgService, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( diff --git a/pkg/api/short_url.go b/pkg/api/short_url.go index 9be4bdab028..1731fd52673 100644 --- a/pkg/api/short_url.go +++ b/pkg/api/short_url.go @@ -3,46 +3,62 @@ package api import ( "fmt" "net/http" - "strings" + "time" + "github.com/teris-io/shortid" + + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + + "github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1alpha1" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/middleware" + "github.com/grafana/grafana/pkg/registry/apps/shorturl" + grafanaapiserver "github.com/grafana/grafana/pkg/services/apiserver" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/shorturls" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/util/errhttp" "github.com/grafana/grafana/pkg/web" ) func (hs *HTTPServer) registerShortURLAPI(apiRoute routing.RouteRegister) { reqSignedIn := middleware.ReqSignedIn - apiRoute.Post("/api/short-urls", reqSignedIn, hs.createShortURL) - apiRoute.Get("/goto/:uid", reqSignedIn, hs.redirectFromShortURL, hs.Index) + if hs.Features.IsEnabledGlobally(featuremgmt.FlagKubernetesShortURLs) { + handler := newShortURLK8sHandler(hs) + apiRoute.Post("/api/short-urls", reqSignedIn, handler.createKubernetesShortURLsHandler) + apiRoute.Get("/api/short-urls/:uid", reqSignedIn, handler.getKubernetesShortURLsHandler) + apiRoute.Get("/goto/:uid", reqSignedIn, handler.getKubernetesRedirectFromShortURL, hs.Index) + } else { + apiRoute.Post("/api/short-urls", reqSignedIn, hs.createShortURL) + apiRoute.Get("/api/short-urls/:uid", reqSignedIn, hs.getShortURL) + apiRoute.Get("/goto/:uid", reqSignedIn, hs.redirectFromShortURL, hs.Index) + } } // createShortURL handles requests to create short URLs. func (hs *HTTPServer) createShortURL(c *contextmodel.ReqContext) response.Response { - cmd := dtos.CreateShortURLCmd{} + cmd := &dtos.CreateShortURLCmd{} if err := web.Bind(c.Req, &cmd); err != nil { return response.Err(shorturls.ErrShortURLBadRequest.Errorf("bad request data: %w", err)) } hs.log.Debug("Received request to create short URL", "path", cmd.Path) - shortURL, err := hs.ShortURLService.CreateShortURL(c.Req.Context(), c.SignedInUser, cmd.Path) + shortURL, err := hs.ShortURLService.CreateShortURL(c.Req.Context(), c.SignedInUser, cmd) if err != nil { return response.Err(err) } - url := fmt.Sprintf("%s/goto/%s?orgId=%d", strings.TrimSuffix(hs.Cfg.AppURL, "/"), shortURL.Uid, c.GetOrgID()) - c.Logger.Debug("Created short URL", "url", url) + shortURLDTO := hs.ShortURLService.ConvertShortURLToDTO(shortURL, hs.Cfg.AppURL) + c.Logger.Debug("Created short URL", "url", shortURLDTO.URL) - dto := dtos.ShortURL{ - UID: shortURL.Uid, - URL: url, - } - - return response.JSON(http.StatusOK, dto) + return response.JSON(http.StatusOK, shortURLDTO) } func (hs *HTTPServer) redirectFromShortURL(c *contextmodel.ReqContext) { @@ -59,11 +75,11 @@ func (hs *HTTPServer) redirectFromShortURL(c *contextmodel.ReqContext) { // we would try to redirect again. if shorturls.ErrShortURLNotFound.Is(err) { hs.log.Debug("Not redirecting short URL since not found", "uid", shortURLUID) - c.Redirect(hs.Cfg.AppURL, 308) + c.Redirect(hs.Cfg.AppURL, http.StatusPermanentRedirect) return } hs.log.Error("Short URL redirection error", "err", err) - c.Redirect(hs.Cfg.AppURL, 307) + c.Redirect(hs.Cfg.AppURL, http.StatusTemporaryRedirect) return } @@ -73,5 +89,167 @@ func (hs *HTTPServer) redirectFromShortURL(c *contextmodel.ReqContext) { } hs.log.Debug("Redirecting short URL", "path", shortURL.Path) - c.Redirect(setting.ToAbsUrl(shortURL.Path), 302) + c.Redirect(setting.ToAbsUrl(shortURL.Path), http.StatusFound) +} + +// getShortURL handles requests to get short URLs. +func (hs *HTTPServer) getShortURL(c *contextmodel.ReqContext) response.Response { + shortURLUID := web.Params(c.Req)[":uid"] + + if !util.IsValidShortUID(shortURLUID) { + return response.Err(shorturls.ErrShortURLBadRequest.Errorf("invalid uid")) + } + + shortURL, err := hs.ShortURLService.GetShortURLByUID(c.Req.Context(), c.SignedInUser, shortURLUID) + if err != nil { + if shorturls.ErrShortURLNotFound.Is(err) { + return response.Err(shorturls.ErrShortURLNotFound.Errorf("shorturl not found: %w", err)) + } + } + + return response.JSON(http.StatusOK, shortURL) +} + +type shortURLK8sHandler struct { + namespacer request.NamespaceMapper + gvr schema.GroupVersionResource + clientConfigProvider grafanaapiserver.DirectRestConfigProvider + cfg *setting.Cfg +} + +func newShortURLK8sHandler(hs *HTTPServer) *shortURLK8sHandler { + gvr := schema.GroupVersionResource{ + Group: v1alpha1.ShortURLKind().Group(), + Version: v1alpha1.ShortURLKind().Version(), + Resource: v1alpha1.ShortURLKind().Plural(), + } + return &shortURLK8sHandler{ + gvr: gvr, + namespacer: request.GetNamespaceMapper(hs.Cfg), + clientConfigProvider: hs.clientConfigProvider, + cfg: hs.Cfg, + } +} + +func (sk8s *shortURLK8sHandler) getKubernetesShortURLsHandler(c *contextmodel.ReqContext) { + client, ok := sk8s.getClient(c) + if !ok { + return + } + + shortURLUID := web.Params(c.Req)[":uid"] + if !util.IsValidShortUID(shortURLUID) { + c.JsonApiErr(http.StatusBadRequest, "Invalid short URL UID format", fmt.Errorf("invalid short URL UID: %s", shortURLUID)) + return + } + + c.Logger.Debug("Fetching short URL", "uid", shortURLUID) + out, err := client.Get(c.Req.Context(), shortURLUID, v1.GetOptions{}) + if err != nil { + sk8s.writeError(c, err) + return + } + + c.JSON(http.StatusOK, shorturl.UnstructuredToLegacyShortURL(*out)) +} + +func (sk8s *shortURLK8sHandler) getKubernetesRedirectFromShortURL(c *contextmodel.ReqContext) { + client, ok := sk8s.getClient(c) + if !ok { + return + } + + shortURLUID := web.Params(c.Req)[":uid"] + if !util.IsValidShortUID(shortURLUID) { + c.Logger.Warn("Invalid short URL UID format", "uid", shortURLUID) + c.Redirect(sk8s.cfg.AppURL, http.StatusFound) + return + } + + // Get Object + obj, err := client.Get(c.Req.Context(), shortURLUID, v1.GetOptions{}) + if err != nil { + sk8s.writeError(c, err) + return + } + + // Modify status + status := obj.Object["status"].(map[string]interface{}) + newTimestamp := time.Now().Unix() + status["lastSeenAt"] = newTimestamp + + // Try status subresource first (works in Mode 5), fallback to main resource (works in Mode 0) + out, err := client.Update(c.Req.Context(), obj, v1.UpdateOptions{}, "status") + if err != nil { + c.Logger.Debug("Status subresource update failed, trying main resource", "error", err) + // Fallback to main resource update (for Mode 0) + out, err = client.Update(c.Req.Context(), obj, v1.UpdateOptions{}) + if err != nil { + c.Logger.Error("Both status and main resource updates failed", "error", err) + sk8s.writeError(c, err) + return + } + } + + spec := out.Object["spec"].(map[string]any) + path := spec["path"].(string) + c.Logger.Debug("Redirecting short URL", "uid", shortURLUID, "path", path) + c.Redirect(setting.ToAbsUrl(path), http.StatusFound) +} + +func (sk8s *shortURLK8sHandler) createKubernetesShortURLsHandler(c *contextmodel.ReqContext) { + client, ok := sk8s.getClient(c) + if !ok { + return + } + + cmd := dtos.CreateShortURLCmd{} + if err := web.Bind(c.Req, &cmd); err != nil { + c.Logger.Error("Failed to bind request data", "error", err) + c.JsonApiErr(http.StatusBadRequest, "bad request data", err) + return + } + + c.Logger.Debug("Creating short URL", "path", cmd.Path) + obj := shorturl.LegacyCreateCommandToUnstructured(cmd) + + uid, err := shortid.Generate() + if err != nil { + c.JsonApiErr(http.StatusInternalServerError, "failed to generate uid", err) + return + } + obj.SetGenerateName(uid) + + out, err := client.Create(c.Req.Context(), &obj, v1.CreateOptions{}) + if err != nil { + c.Logger.Error("Failed to create short URL in Kubernetes", "path", cmd.Path, "error", err) + sk8s.writeError(c, err) + return + } + + c.Logger.Info("Successfully created short URL", "path", cmd.Path, "uid", out.GetName()) + c.JSON(http.StatusOK, shorturl.UnstructuredToLegacyShortURLDTO(*out, sk8s.cfg.AppURL)) +} + +//----------------------------------------------------------------------------------------- +// Utility functions +//----------------------------------------------------------------------------------------- + +func (sk8s *shortURLK8sHandler) getClient(c *contextmodel.ReqContext) (dynamic.ResourceInterface, bool) { + dyn, err := dynamic.NewForConfig(sk8s.clientConfigProvider.GetDirectRestConfig(c)) + if err != nil { + c.JsonApiErr(500, "client", err) + return nil, false + } + return dyn.Resource(sk8s.gvr).Namespace(sk8s.namespacer(c.OrgID)), true +} + +func (sk8s *shortURLK8sHandler) writeError(c *contextmodel.ReqContext, err error) { + //nolint:errorlint + statusError, ok := err.(*errors.StatusError) + if ok { + c.JsonApiErr(int(statusError.Status().Code), statusError.Status().Message, err) + return + } + errhttp.Write(c.Req.Context(), err, c.Resp) } diff --git a/pkg/api/short_url_test.go b/pkg/api/short_url_test.go index e84efbc9ce3..09059dc91c9 100644 --- a/pkg/api/short_url_test.go +++ b/pkg/api/short_url_test.go @@ -31,9 +31,12 @@ func TestShortURLAPIEndpoint(t *testing.T) { Path: cmd.Path, } service := &fakeShortURLService{ - createShortURLFunc: func(ctx context.Context, user *user.SignedInUser, path string) (*shorturls.ShortUrl, error) { + createShortURLFunc: func(ctx context.Context, user *user.SignedInUser, cmd *dtos.CreateShortURLCmd) (*shorturls.ShortUrl, error) { return createResp, nil }, + createConvertShortURLToDTO: func(shortURL *shorturls.ShortUrl, appURL string) *dtos.ShortURL { + return &dtos.ShortURL{UID: createResp.Uid, URL: "http://localhost:3000/goto/N1u6L4eGz?orgId=1"} + }, } createShortURLScenario(t, "When calling POST on", "/api/short-urls", "/api/short-urls", cmd, service, @@ -44,7 +47,7 @@ func TestShortURLAPIEndpoint(t *testing.T) { err := json.NewDecoder(sc.resp.Body).Decode(&shortUrl) require.NoError(t, err) require.Equal(t, 200, sc.resp.Code) - require.Equal(t, fmt.Sprintf("/goto/%s?orgId=%d", createResp.Uid, createResp.OrgId), shortUrl.URL) + require.Equal(t, fmt.Sprintf("http://localhost:3000/goto/%s?orgId=%d", createResp.Uid, createResp.OrgId), shortUrl.URL) }) }) } @@ -78,16 +81,17 @@ func createShortURLScenario(t *testing.T, desc string, url string, routePattern } type fakeShortURLService struct { - createShortURLFunc func(ctx context.Context, user *user.SignedInUser, path string) (*shorturls.ShortUrl, error) + createShortURLFunc func(ctx context.Context, user *user.SignedInUser, cmd *dtos.CreateShortURLCmd) (*shorturls.ShortUrl, error) + createConvertShortURLToDTO func(shortURL *shorturls.ShortUrl, appURL string) *dtos.ShortURL } func (s *fakeShortURLService) GetShortURLByUID(ctx context.Context, user *user.SignedInUser, uid string) (*shorturls.ShortUrl, error) { return nil, nil } -func (s *fakeShortURLService) CreateShortURL(ctx context.Context, user *user.SignedInUser, path string) (*shorturls.ShortUrl, error) { +func (s *fakeShortURLService) CreateShortURL(ctx context.Context, user *user.SignedInUser, cmd *dtos.CreateShortURLCmd) (*shorturls.ShortUrl, error) { if s.createShortURLFunc != nil { - return s.createShortURLFunc(ctx, user, path) + return s.createShortURLFunc(ctx, user, cmd) } return nil, nil @@ -100,3 +104,10 @@ func (s *fakeShortURLService) UpdateLastSeenAt(ctx context.Context, shortURL *sh func (s *fakeShortURLService) DeleteStaleShortURLs(ctx context.Context, cmd *shorturls.DeleteShortUrlCommand) error { return nil } + +func (s *fakeShortURLService) ConvertShortURLToDTO(shortURL *shorturls.ShortUrl, appURL string) *dtos.ShortURL { + if s.createConvertShortURLToDTO != nil { + return s.createConvertShortURLToDTO(shortURL, appURL) + } + return nil +} diff --git a/pkg/apimachinery/apis/common/v0alpha1/secure_values.go b/pkg/apimachinery/apis/common/v0alpha1/secure_values.go index 3413753cee4..fd10d9c32a7 100644 --- a/pkg/apimachinery/apis/common/v0alpha1/secure_values.go +++ b/pkg/apimachinery/apis/common/v0alpha1/secure_values.go @@ -6,6 +6,9 @@ import ( "strconv" "gopkg.in/yaml.v3" + openapi "k8s.io/kube-openapi/pkg/common" + spec "k8s.io/kube-openapi/pkg/validation/spec" + ptr "k8s.io/utils/ptr" ) const redacted = "[REDACTED]" @@ -21,7 +24,8 @@ var ( _ yaml.Marshaler = (*RawSecureValue)(nil) ) -// Allow access to a secure value inside +// Reference a secure value from within an owner resource +// Only one property is valid at any time: OneOf(name,create,remove) // +k8s:openapi-gen=true type InlineSecureValue struct { // Create a secure value -- this is only used for POST/PUT @@ -30,6 +34,8 @@ type InlineSecureValue struct { Create RawSecureValue `json:"create,omitempty"` // Name in the secret service (reference) + // +k8s:validation:minLength=1 + // +k8s:validation:maxLength=253 Name string `json:"name,omitempty"` // Remove this value from the secure value map @@ -41,6 +47,50 @@ func (v InlineSecureValue) IsZero() bool { return v.Create.IsZero() && v.Name == "" && !v.Remove } +// OpenAPIDefinition returns the JSONSchema that manually ensures oneOf(create | name | remove) is set. +func (InlineSecureValue) OpenAPIDefinition() openapi.OpenAPIDefinition { + return openapi.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "Allow access to a secure value inside", + AdditionalProperties: &spec.SchemaOrBool{Allows: false}, + Properties: map[string]spec.Schema{ + "name": { + SchemaProps: spec.SchemaProps{ + Description: "Name in the secret service (reference)", + Type: []string{"string"}, + MinLength: ptr.To[int64](1), + MaxLength: ptr.To[int64](253), + Format: ""}}, + "create": { + SchemaProps: spec.SchemaProps{ + Description: "Create a secure value -- this is only used for POST/PUT", + MinLength: ptr.To[int64](1), + MaxLength: ptr.To[int64](24576), + Type: []string{"string"}, + Format: ""}}, + "remove": { + SchemaProps: spec.SchemaProps{ + Description: "Remove this value from the secure value map Values owned by this resource will be deleted if necessary", + Type: []string{"boolean"}, + }}, + }, + OneOf: []spec.Schema{ + {SchemaProps: spec.SchemaProps{ + Required: []string{"name"}, + }}, + {SchemaProps: spec.SchemaProps{ + Required: []string{"create"}, + }}, + {SchemaProps: spec.SchemaProps{ + Required: []string{"remove"}, + }}, + }, + }, + }, + } +} + // Collection of secure values // +k8s:openapi-gen=true type InlineSecureValues = map[string]InlineSecureValue diff --git a/pkg/apimachinery/apis/common/v0alpha1/secure_values_test.go b/pkg/apimachinery/apis/common/v0alpha1/secure_values_test.go index 53f180753a9..83dd8ec6762 100644 --- a/pkg/apimachinery/apis/common/v0alpha1/secure_values_test.go +++ b/pkg/apimachinery/apis/common/v0alpha1/secure_values_test.go @@ -1,4 +1,4 @@ -package v0alpha1 +package v0alpha1_test import ( "bytes" @@ -8,40 +8,114 @@ import ( "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" + "k8s.io/kube-openapi/pkg/validation/strfmt" + "k8s.io/kube-openapi/pkg/validation/validate" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" ) func TestSecureValues(t *testing.T) { - expected := "[REDACTED]" + t.Run("redaction", func(t *testing.T) { + expected := "[REDACTED]" - rawValue := "a-password" - esv := NewSecretValue(rawValue) + rawValue := "a-password" + esv := common.NewSecretValue(rawValue) - // String must not return the exposed secure value. - require.Equal(t, expected, esv.String()) + // String must not return the exposed secure value. + require.Equal(t, expected, esv.String()) + require.Equal(t, expected, esv.GoString()) - // Format/GoString must not return the exposed secure value. - require.Equal(t, expected, fmt.Sprintf("%+#v", esv)) - require.Equal(t, expected, fmt.Sprintf("%v", esv)) - require.Equal(t, expected, fmt.Sprintf("%s", esv)) + // Format/GoString must not return the exposed secure value. + require.Equal(t, expected, fmt.Sprintf("%+#v", esv)) + require.Equal(t, expected, fmt.Sprintf("%v", esv)) + require.Equal(t, expected, fmt.Sprintf("%s", esv)) - buf := new(bytes.Buffer) - _, err := fmt.Fprintf(buf, "%#v", esv) - require.NoError(t, err) - require.Equal(t, expected, buf.String()) + buf := new(bytes.Buffer) + _, err := fmt.Fprintf(buf, "%#v", esv) + require.NoError(t, err) + require.Equal(t, expected, buf.String()) - // MarshalJSON must not return the exposed secure value. - bytes, err := json.Marshal(esv) - require.NoError(t, err) - require.Equal(t, `"`+expected+`"`, string(bytes)) + // MarshalJSON must not return the exposed secure value. + bytes, err := json.Marshal(esv) + require.NoError(t, err) + require.Equal(t, `"`+expected+`"`, string(bytes)) - // MarshalYAML must not return the exposed secure value. - bytes, err = yaml.Marshal(esv) - require.NoError(t, err) - require.Equal(t, "'"+expected+"'\n", string(bytes)) + // MarshalYAML must not return the exposed secure value. + bytes, err = yaml.Marshal(esv) + require.NoError(t, err) + require.Equal(t, "'"+expected+"'\n", string(bytes)) - // DangerouslyExposeAndConsumeValue returns the raw value. - require.Equal(t, rawValue, esv.DangerouslyExposeAndConsumeValue()) + // DangerouslyExposeAndConsumeValue returns the raw value. + require.Equal(t, rawValue, esv.DangerouslyExposeAndConsumeValue()) - // Further calls to DangerouslyExposeAndConsumeValue will panic. - require.Panics(t, func() { esv.DangerouslyExposeAndConsumeValue() }) + // Further calls to DangerouslyExposeAndConsumeValue will panic. + require.Panics(t, func() { esv.DangerouslyExposeAndConsumeValue() }) + }) + + t.Run("Inline", func(t *testing.T) { + t.Run("IsZero", func(t *testing.T) { + require.True(t, common.InlineSecureValue{}.IsZero()) + require.True(t, common.NewSecretValue("").IsZero()) + require.True(t, common.InlineSecureValue{Remove: false}.IsZero()) + + require.False(t, common.NewSecretValue("X").IsZero()) + require.False(t, common.InlineSecureValue{Name: "X"}.IsZero()) + require.False(t, common.InlineSecureValue{Remove: true}.IsZero()) + }) + + t.Run("Validate OneOf", func(t *testing.T) { + def := common.InlineSecureValue{}.OpenAPIDefinition() + // jj, _ := json.MarshalIndent(def.Schema, "", " ") + // fmt.Printf("%s", string(jj)) + // t.FailNow() + + validator := validate.NewSchemaValidator(&def.Schema, nil, "", strfmt.Default) + + tests := []struct { + name string + input map[string]any + valid bool + err string + }{ + { + name: "with name", + input: map[string]any{"name": "x"}, + valid: true, + }, + { + name: "with create", + input: map[string]any{"create": "x"}, + valid: true, + }, + { + name: "with remove", + input: map[string]any{"remove": true}, + valid: true, + }, + { + name: "empty", + input: map[string]any{}, + valid: false, + err: "must validate one and only one", + }, + { + name: "with unknown property", + input: map[string]any{"unknown": "property"}, + valid: false, + err: "unknown", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := validator.Validate(tt.input) + require.Equal(t, tt.valid, result.IsValid()) + if tt.err == "" { + require.NoError(t, result.AsError()) + } else { + require.ErrorContains(t, result.AsError(), tt.err) + } + }) + } + }) + }) } diff --git a/pkg/apimachinery/apis/common/v0alpha1/zz_generated.openapi.go b/pkg/apimachinery/apis/common/v0alpha1/zz_generated.openapi.go index 2f92840b5e6..34424e88e3d 100644 --- a/pkg/apimachinery/apis/common/v0alpha1/zz_generated.openapi.go +++ b/pkg/apimachinery/apis/common/v0alpha1/zz_generated.openapi.go @@ -11,12 +11,11 @@ import ( v1 "k8s.io/apimachinery/pkg/apis/meta/v1" common "k8s.io/kube-openapi/pkg/common" spec "k8s.io/kube-openapi/pkg/validation/spec" - ptr "k8s.io/utils/ptr" ) func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { return map[string]common.OpenAPIDefinition{ - "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.InlineSecureValue": schema_apimachinery_apis_common_v0alpha1_InlineSecureValue(ref), + "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.InlineSecureValue": InlineSecureValue{}.OpenAPIDefinition(), "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.ObjectReference": schema_apimachinery_apis_common_v0alpha1_ObjectReference(ref), "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Scope": schema_apimachinery_apis_common_v0alpha1_Scope(ref), "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.ScopeFilter": schema_apimachinery_apis_common_v0alpha1_ScopeFilter(ref), @@ -78,42 +77,6 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA } } -func schema_apimachinery_apis_common_v0alpha1_InlineSecureValue(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "Allow access to a secure value inside", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "create": { - SchemaProps: spec.SchemaProps{ - Description: "Create a secure value -- this is only used for POST/PUT", - MinLength: ptr.To[int64](1), - MaxLength: ptr.To[int64](24576), - Type: []string{"string"}, - Format: "", - }, - }, - "name": { - SchemaProps: spec.SchemaProps{ - Description: "Name in the secret service (reference)", - Type: []string{"string"}, - Format: "", - }, - }, - "remove": { - SchemaProps: spec.SchemaProps{ - Description: "Remove this value from the secure value map Values owned by this resource will be deleted if necessary", - Type: []string{"boolean"}, - Format: "", - }, - }, - }, - }, - }, - } -} - func schema_apimachinery_apis_common_v0alpha1_ObjectReference(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ diff --git a/pkg/apimachinery/go.mod b/pkg/apimachinery/go.mod index d1fa3b6f5e0..78854c60506 100644 --- a/pkg/apimachinery/go.mod +++ b/pkg/apimachinery/go.mod @@ -1,6 +1,6 @@ module github.com/grafana/grafana/pkg/apimachinery -go 1.24.5 +go 1.24.6 require ( github.com/go-jose/go-jose/v3 v3.0.4 // @grafana/identity-access-team diff --git a/pkg/apimachinery/utils/folder_key.go b/pkg/apimachinery/utils/folder_key.go new file mode 100644 index 00000000000..a7f6d4cd03e --- /dev/null +++ b/pkg/apimachinery/utils/folder_key.go @@ -0,0 +1,20 @@ +package utils + +import ( + "context" +) + +type folderRemovePermissions string + +const removePermissionKey = folderRemovePermissions("RemovePermission") + +func SetFolderRemovePermissions(ctx context.Context, value bool) context.Context { + return context.WithValue(ctx, removePermissionKey, value) +} + +func GetFolderRemovePermissions(ctx context.Context, defaultVal bool) bool { + if val, ok := ctx.Value(removePermissionKey).(bool); ok { + return val + } + return defaultVal +} diff --git a/pkg/apimachinery/utils/meta.go b/pkg/apimachinery/utils/meta.go index 17f69da01a5..453099e8add 100644 --- a/pkg/apimachinery/utils/meta.go +++ b/pkg/apimachinery/utils/meta.go @@ -836,7 +836,7 @@ func (m *grafanaMetaAccessor) SetSourceProperties(v SourceProperties) { func (m *grafanaMetaAccessor) GetSecureValues() (vals common.InlineSecureValues, err error) { defer func() { if r := recover(); r != nil { - err = fmt.Errorf("error reading spec") + err = fmt.Errorf("error reading secure values") } }() @@ -869,16 +869,18 @@ func (m *grafanaMetaAccessor) GetSecureValues() (vals common.InlineSecureValues, if ok { vals = make(common.InlineSecureValues, len(u)) for k, v := range u { - sv, ok := v.(map[string]any) + inline, ok := v.(common.InlineSecureValue) if !ok { - return nil, fmt.Errorf("unsupported nested secure value: %t", v) - } - inline := common.InlineSecureValue{} - inline.Name, _, _ = unstructured.NestedString(sv, "name") - inline.Remove, _, _ = unstructured.NestedBool(sv, "remove") - create, _, _ := unstructured.NestedString(sv, "create") - if create != "" { - inline.Create = common.NewSecretValue(create) + sv, ok := v.(map[string]any) + if !ok { + return nil, fmt.Errorf("unsupported nested secure value: %t", v) + } + inline.Name, _, _ = unstructured.NestedString(sv, "name") + inline.Remove, _, _ = unstructured.NestedBool(sv, "remove") + create, _, _ := unstructured.NestedString(sv, "create") + if create != "" { + inline.Create = common.NewSecretValue(create) + } } vals[k] = inline } @@ -888,7 +890,7 @@ func (m *grafanaMetaAccessor) GetSecureValues() (vals common.InlineSecureValues, if f.Kind() == reflect.Struct { num := f.NumField() vals = make(common.InlineSecureValues, num) - for i := 0; i < f.NumField(); i++ { + for i := range num { val := f.Field(i) if val.IsValid() && val.CanInterface() { property = val.Interface() @@ -901,14 +903,7 @@ func (m *grafanaMetaAccessor) GetSecureValues() (vals common.InlineSecureValues, continue // nothing } - field := f.Type().Field(i) - fname := field.Tag.Get("json") - if fname == "" { - fname = field.Name - } else { - fname, _ = strings.CutSuffix(fname, ",omitempty") - } - vals[fname] = inline + vals[getJSONFieldName(f, i)] = inline continue } return nil, fmt.Errorf("value not an interface") @@ -923,12 +918,36 @@ func (m *grafanaMetaAccessor) GetSecureValues() (vals common.InlineSecureValues, func (m *grafanaMetaAccessor) SetSecureValues(vals common.InlineSecureValues) (err error) { defer func() { if r := recover(); r != nil { - err = fmt.Errorf("error setting spec") + fmt.Printf("ERR: %v", r) + err = fmt.Errorf("error writing secure values") } }() f := m.r.FieldByName("Secure") if f.IsValid() && f.CanSet() { + if f.Kind() == reflect.Struct { + keys := make(map[string]bool, len(vals)) + for k := range vals { + keys[k] = true + } + for i := 0; i < f.NumField(); i++ { + val := f.Field(i) + if val.IsValid() && val.CanInterface() && val.CanSet() { + k := getJSONFieldName(f, i) + sv := vals[k] + val.Set(reflect.ValueOf(sv)) + delete(keys, k) + } else { + return fmt.Errorf("invalid secure value: %v", val) + } + } + if len(keys) > 0 { + return fmt.Errorf("invalid secure value key: %v", keys) + } + return + } + + // It should be a generic map f.Set(reflect.ValueOf(vals)) return } @@ -943,6 +962,16 @@ func (m *grafanaMetaAccessor) SetSecureValues(vals common.InlineSecureValues) (e return fmt.Errorf("unable to set secure values on (%T)", m.raw) } +func getJSONFieldName(f reflect.Value, idx int) string { + field := f.Type().Field(idx) + fname := field.Tag.Get("json") + if fname == "" { + return field.Name + } + fname, _ = strings.CutSuffix(fname, ",omitempty") + return fname +} + func ToObjectReference(obj GrafanaMetaAccessor) common.ObjectReference { gvk := obj.GetGroupVersionKind() return common.ObjectReference{ diff --git a/pkg/apimachinery/utils/meta_test.go b/pkg/apimachinery/utils/meta_test.go index 7bc63884699..1e79f267430 100644 --- a/pkg/apimachinery/utils/meta_test.go +++ b/pkg/apimachinery/utils/meta_test.go @@ -157,7 +157,10 @@ func TestMetaAccessor(t *testing.T) { } t.Run("fails for non resource objects", func(t *testing.T) { - _, err := utils.MetaAccessor("hello") + _, err := utils.MetaAccessor(nil) + require.Error(t, err) + + _, err = utils.MetaAccessor("hello") require.Error(t, err) _, err = utils.MetaAccessor(unstructured.Unstructured{}) @@ -174,6 +177,45 @@ func TestMetaAccessor(t *testing.T) { require.NoError(t, err) // Must be a pointer }) + t.Run("get and set properties", func(t *testing.T) { + obj, err := utils.MetaAccessor(&unstructured.Unstructured{ + Object: map[string]any{}, + }) + require.NoError(t, err) + rv := obj.GetResourceVersion() + require.Equal(t, "", rv) + + _, ok := obj.GetRuntimeObject() + require.True(t, ok) + anno := obj.GetAnnotations() + require.Nil(t, anno) + + rvInt, err := obj.GetResourceVersionInt64() + require.NoError(t, err) + require.Equal(t, int64(0), rvInt) + + obj.SetResourceVersion("not a number") + rv = obj.GetResourceVersion() + require.Equal(t, "not a number", rv) + rvInt, err = obj.GetResourceVersionInt64() + require.Error(t, err) + require.Equal(t, int64(0), rvInt) + + obj.SetUpdatedBy("updatedBy") + require.Equal(t, "updatedBy", obj.GetUpdatedBy()) + anno = obj.GetAnnotations() + require.Len(t, anno, 1) // One key + obj.SetAnnotation(utils.AnnoKeyUpdatedBy, "") + anno = obj.GetAnnotations() + require.Empty(t, anno) // removed the key + + obj.SetCreatedBy("createdBy") + require.Equal(t, "createdBy", obj.GetCreatedBy()) + + obj.SetFolder("folder") + require.Equal(t, "folder", obj.GetFolder()) + }) + t.Run("get and set grafana labels (unstructured)", func(t *testing.T) { res := &unstructured.Unstructured{ Object: map[string]any{}, @@ -400,6 +442,20 @@ func TestMetaAccessor(t *testing.T) { secure, err := meta.GetSecureValues() require.NoError(t, err) require.JSONEq(t, `{"v1": {"name": "hello"}}`, asJSON(secure, true)) + + // Check that we can write + err = meta.SetSecureValues(common.InlineSecureValues{ + "v2": {Name: "bbb"}, + }) + require.NoError(t, err) + secure, err = meta.GetSecureValues() + require.NoError(t, err) + require.JSONEq(t, `{"v2": {"name": "bbb"}}`, asJSON(secure, true)) // NOTE: v1 was removed + + err = meta.SetSecureValues(common.InlineSecureValues{ + "UNKNOWN": {Name: "bbb"}, // not a valid name + }) + require.Error(t, err) }) t.Run("test reading old repo fields (now manager+source)", func(t *testing.T) { @@ -579,6 +635,32 @@ func TestMetaAccessor(t *testing.T) { } }) + t.Run("manage secure values", func(t *testing.T) { + raw := &unstructured.Unstructured{ + Object: map[string]any{}, + } + obj, _ := utils.MetaAccessor(raw) + sv, err := obj.GetSecureValues() + require.NoError(t, err) + require.Nil(t, sv) + + err = obj.SetSecureValues(common.InlineSecureValues{ + "A": common.InlineSecureValue{Name: "NameForA"}, + }) + require.NoError(t, err) + require.NotNil(t, raw.Object["secure"]) + sv, err = obj.GetSecureValues() + require.NoError(t, err) + require.Equal(t, "NameForA", sv["A"].Name) + + // Manually set secure to an invalid property: + raw.Object["secure"] = t + sv, err = obj.GetSecureValues() + require.Error(t, err) + require.Nil(t, sv) + delete(raw.Object, "secure") + }) + t.Run("SourceProperties", func(t *testing.T) { tests := []struct { name string diff --git a/pkg/apiserver/go.mod b/pkg/apiserver/go.mod index af1b7887789..41f81051b82 100644 --- a/pkg/apiserver/go.mod +++ b/pkg/apiserver/go.mod @@ -1,6 +1,6 @@ module github.com/grafana/grafana/pkg/apiserver -go 1.24.5 +go 1.24.6 require ( github.com/google/go-cmp v0.7.0 diff --git a/pkg/build/cmd.go b/pkg/build/cmd.go index 226bd15245f..1bad7828946 100644 --- a/pkg/build/cmd.go +++ b/pkg/build/cmd.go @@ -203,6 +203,10 @@ func doBuild(binaryName, pkg string, opts BuildOpts) error { args = append(args, "-race") } + // We should not publish Grafana as a Go module, disabling vcs changes the version to (devel) + // and works better with SBOM and Vulnerability Scanners. + args = append(args, "-buildvcs=false") + args = append(args, "-o", binary) args = append(args, pkg) diff --git a/pkg/build/daggerbuild/artifacts/package_targz.go b/pkg/build/daggerbuild/artifacts/package_targz.go index 56ea04030b3..bf3fe4c2073 100644 --- a/pkg/build/daggerbuild/artifacts/package_targz.go +++ b/pkg/build/daggerbuild/artifacts/package_targz.go @@ -55,9 +55,7 @@ type Tarball struct { // Dependent artifacts Backend *pipeline.Artifact Frontend *pipeline.Artifact - NPMPackages *pipeline.Artifact BundledPlugins *pipeline.Artifact - Storybook *pipeline.Artifact } func NewTarballFromString(ctx context.Context, log *slog.Logger, artifact string, state pipeline.StateHandler) (*pipeline.Artifact, error) { @@ -174,15 +172,6 @@ func NewTarball( return nil, err } - npmArtifact, err := NewNPMPackages(ctx, log, artifact, src, version, cache) - if err != nil { - return nil, err - } - - storybookArtifact, err := NewStorybook(ctx, log, artifact, src, version, cache) - if err != nil { - return nil, err - } tarball := &Tarball{ Name: name, Distribution: distro, @@ -195,9 +184,7 @@ func NewTarball( Backend: backendArtifact, Frontend: frontendArtifact, - NPMPackages: npmArtifact, BundledPlugins: bundledPluginsArtifact, - Storybook: storybookArtifact, } return pipeline.ArtifactWithLogging(ctx, log, &pipeline.Artifact{ @@ -239,16 +226,6 @@ func (t *Tarball) BuildFile(ctx context.Context, b *dagger.Container, opts *pipe return nil, err } - npmDir, err := opts.Store.Directory(ctx, t.NPMPackages) - if err != nil { - return nil, err - } - - storybookDir, err := opts.Store.Directory(ctx, t.Storybook) - if err != nil { - return nil, err - } - pluginsDir, err := opts.Store.Directory(ctx, t.BundledPlugins) if err != nil { return nil, err @@ -277,8 +254,6 @@ func (t *Tarball) BuildFile(ctx context.Context, b *dagger.Container, opts *pipe targz.NewMappedDir("packaging/wrappers", grafanaDir.Directory("packaging/wrappers")), targz.NewMappedDir("bin", backendDir), targz.NewMappedDir("public", frontendDir), - targz.NewMappedDir("npm-artifacts", npmDir), - targz.NewMappedDir("storybook", storybookDir), targz.NewMappedDir("plugins-bundled", pluginsDir), } @@ -329,9 +304,7 @@ func (t *Tarball) Dependencies(ctx context.Context) ([]*pipeline.Artifact, error return []*pipeline.Artifact{ t.Backend, t.Frontend, - t.NPMPackages, t.BundledPlugins, - t.Storybook, }, nil } diff --git a/pkg/build/daggerbuild/backend/build.go b/pkg/build/daggerbuild/backend/build.go index 4b0f69d9fa5..a80862770ab 100644 --- a/pkg/build/daggerbuild/backend/build.go +++ b/pkg/build/daggerbuild/backend/build.go @@ -34,6 +34,9 @@ func GoLDFlags(flags []LDFlag) string { // GoBuildCommand returns the arguments for go build to be used in 'WithExec'. func GoBuildCommand(output string, ldflags []LDFlag, tags []string, main string) []string { args := []string{"go", "build", + // We should not publish Grafana as a Go module, disabling vcs changes the version to (devel) + // and works better with SBOM and Vulnerability Scanners. + "-buildvcs=false", fmt.Sprintf("-ldflags=\"%s\"", GoLDFlags(ldflags)), fmt.Sprintf("-o=%s", output), "-trimpath", diff --git a/pkg/build/daggerbuild/docker/verify.go b/pkg/build/daggerbuild/docker/verify.go index 56e8ff3118e..d3eec68fae4 100644 --- a/pkg/build/daggerbuild/docker/verify.go +++ b/pkg/build/daggerbuild/docker/verify.go @@ -40,7 +40,10 @@ func Verify( WithExposedPort(3000) // TODO: Add LICENSE to containers and implement validation - container, err := e2e.ValidatePackage(ctx, d, service.AsService(), src, yarnCache, nodeVersion) + container, err := e2e.ValidatePackage(ctx, d, service.AsService(dagger.ContainerAsServiceOpts{ + UseEntrypoint: false, + Args: []string{"grafana", "server"}, + }), src, yarnCache, nodeVersion) if err != nil { return err } diff --git a/pkg/build/go.mod b/pkg/build/go.mod index 012923fe3c1..3012a2bab49 100644 --- a/pkg/build/go.mod +++ b/pkg/build/go.mod @@ -1,6 +1,6 @@ module github.com/grafana/grafana/pkg/build -go 1.24.5 +go 1.24.6 // Override docker/docker to avoid: // go: github.com/drone-runners/drone-runner-docker@v1.8.2 requires @@ -62,7 +62,7 @@ require ( github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect diff --git a/pkg/build/go.sum b/pkg/build/go.sum index d49747c61fb..abb46b91775 100644 --- a/pkg/build/go.sum +++ b/pkg/build/go.sum @@ -207,8 +207,8 @@ github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= -github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= -github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= 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= diff --git a/pkg/build/wire/go.mod b/pkg/build/wire/go.mod index 5d0be4a4925..a26d501eb78 100644 --- a/pkg/build/wire/go.mod +++ b/pkg/build/wire/go.mod @@ -1,6 +1,6 @@ module github.com/grafana/grafana/pkg/build/wire -go 1.24.5 +go 1.24.6 require ( github.com/google/go-cmp v0.7.0 diff --git a/pkg/cmd/grafana-cli/commands/commands.go b/pkg/cmd/grafana-cli/commands/commands.go index ae7f5f93f67..519ae771b3e 100644 --- a/pkg/cmd/grafana-cli/commands/commands.go +++ b/pkg/cmd/grafana-cli/commands/commands.go @@ -1,6 +1,7 @@ package commands import ( + "context" "fmt" "strings" @@ -19,7 +20,7 @@ import ( func runRunnerCommand(command func(commandLine utils.CommandLine, runner server.Runner) error) func(context *cli.Context) error { return func(context *cli.Context) error { cmd := &utils.ContextCommandLine{Context: context} - runner, err := initializeRunner(cmd) + runner, err := initializeRunner(context.Context, cmd) if err != nil { return fmt.Errorf("%v: %w", "failed to initialize runner", err) } @@ -34,7 +35,7 @@ func runRunnerCommand(command func(commandLine utils.CommandLine, runner server. func runDbCommand(command func(commandLine utils.CommandLine, cfg *setting.Cfg, sqlStore db.DB) error) func(context *cli.Context) error { return func(context *cli.Context) error { cmd := &utils.ContextCommandLine{Context: context} - runner, err := initializeRunner(cmd) + runner, err := initializeRunner(context.Context, cmd) if err != nil { return fmt.Errorf("%v: %w", "failed to initialize runner", err) } @@ -50,7 +51,7 @@ func runDbCommand(command func(commandLine utils.CommandLine, cfg *setting.Cfg, } } -func initializeRunner(cmd *utils.ContextCommandLine) (server.Runner, error) { +func initializeRunner(ctx context.Context, cmd *utils.ContextCommandLine) (server.Runner, error) { configOptions := strings.Split(cmd.String("configOverrides"), " ") cfg, err := setting.NewCfgFromArgs(setting.CommandLineArgs{ Config: cmd.ConfigFile(), @@ -62,7 +63,7 @@ func initializeRunner(cmd *utils.ContextCommandLine) (server.Runner, error) { return server.Runner{}, err } - runner, err := server.InitializeForCLI(cfg) + runner, err := server.InitializeForCLI(ctx, cfg) if err != nil { return server.Runner{}, fmt.Errorf("%v: %w", "failed to initialize runner", err) } diff --git a/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go b/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go index 89053e6b932..f6bdc211f15 100644 --- a/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go +++ b/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go @@ -81,6 +81,7 @@ func ToUnifiedStorage(c utils.CommandLine, cfg *setting.Cfg, sqlStore db.DB) err provisioning, nil, // no librarypanels.Service sort.ProvideService(), + nil, // we don't delete during migration, and this is only need to delete permission. acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), featureToggles, ) diff --git a/pkg/cmd/grafana-server/commands/cli.go b/pkg/cmd/grafana-server/commands/cli.go index 949c865060a..8850132105f 100644 --- a/pkg/cmd/grafana-server/commands/cli.go +++ b/pkg/cmd/grafana-server/commands/cli.go @@ -112,6 +112,7 @@ func RunServer(opts standalone.BuildInfo, cli *cli.Context) error { } s, err := server.Initialize( + cli.Context, cfg, server.Options{ PidFile: PidFile, @@ -125,8 +126,7 @@ func RunServer(opts standalone.BuildInfo, cli *cli.Context) error { return err } - ctx := context.Background() - go listenToSystemSignals(ctx, s) + go listenToSystemSignals(cli.Context, s) return s.Run() } diff --git a/pkg/codegen/go.mod b/pkg/codegen/go.mod index c35ceba70d0..c215182743a 100644 --- a/pkg/codegen/go.mod +++ b/pkg/codegen/go.mod @@ -1,6 +1,6 @@ module github.com/grafana/grafana/pkg/codegen -go 1.24.5 +go 1.24.6 require ( cuelang.org/go v0.11.1 diff --git a/pkg/configprovider/configprovider.go b/pkg/configprovider/configprovider.go new file mode 100644 index 00000000000..8f20e87eaa2 --- /dev/null +++ b/pkg/configprovider/configprovider.go @@ -0,0 +1,26 @@ +package configprovider + +import ( + "context" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/setting" +) + +type ConfigProvider interface { + Get(context.Context) *setting.Cfg +} + +type OSSConfigProvider struct { + Cfg *setting.Cfg + log log.Logger +} + +func (c *OSSConfigProvider) Get(_ context.Context) *setting.Cfg { + c.log.Debug("OSSConfigProvider Get") + return c.Cfg +} + +func ProvideService(cfg *setting.Cfg) (ConfigProvider, error) { + return &OSSConfigProvider{Cfg: cfg, log: log.New("configprovider")}, nil +} diff --git a/pkg/expr/dataplane_test.go b/pkg/expr/dataplane_test.go index ac3e7c42f95..a8247a2bb9b 100644 --- a/pkg/expr/dataplane_test.go +++ b/pkg/expr/dataplane_test.go @@ -92,7 +92,7 @@ func framesPassThroughService(t *testing.T, frames data.Frames) (data.Frames, er User: &user.SignedInUser{}, } - pl, err := s.BuildPipeline(req) + pl, err := s.BuildPipeline(t.Context(), req) require.NoError(t, err) res, err := s.ExecutePipeline(context.Background(), time.Now(), pl) diff --git a/pkg/expr/graph.go b/pkg/expr/graph.go index e19b73dd15e..ad60027d988 100644 --- a/pkg/expr/graph.go +++ b/pkg/expr/graph.go @@ -175,12 +175,12 @@ func (dp *DataPipeline) GetCommandTypes() []string { // BuildPipeline builds a graph of the nodes, and returns the nodes in an // executable order. -func (s *Service) buildPipeline(req *Request) (DataPipeline, error) { +func (s *Service) buildPipeline(ctx context.Context, req *Request) (DataPipeline, error) { if req != nil && len(req.Headers) == 0 { req.Headers = map[string]string{} } - graph, err := s.buildDependencyGraph(req) + graph, err := s.buildDependencyGraph(ctx, req) if err != nil { return nil, err } @@ -194,8 +194,8 @@ func (s *Service) buildPipeline(req *Request) (DataPipeline, error) { } // buildDependencyGraph returns a dependency graph for a set of queries. -func (s *Service) buildDependencyGraph(req *Request) (*simple.DirectedGraph, error) { - graph, err := s.buildGraph(req) +func (s *Service) buildDependencyGraph(ctx context.Context, req *Request) (*simple.DirectedGraph, error) { + graph, err := s.buildGraph(ctx, req) if err != nil { return nil, err } @@ -252,7 +252,7 @@ func buildNodeRegistry(g *simple.DirectedGraph) map[string]Node { } // buildGraph creates a new graph populated with nodes for every query. -func (s *Service) buildGraph(req *Request) (*simple.DirectedGraph, error) { +func (s *Service) buildGraph(ctx context.Context, req *Request) (*simple.DirectedGraph, error) { dp := simple.NewDirectedGraph() for i, query := range req.Queries { @@ -287,7 +287,7 @@ func (s *Service) buildGraph(req *Request) (*simple.DirectedGraph, error) { case TypeDatasourceNode: node, err = s.buildDSNode(dp, rn, req) case TypeCMDNode: - node, err = buildCMDNode(rn, s.features, s.cfg) + node, err = buildCMDNode(ctx, rn, s.features, s.cfg) case TypeMLNode: if s.features.IsEnabledGlobally(featuremgmt.FlagMlExpressions) { node, err = s.buildMLNode(dp, rn, req) diff --git a/pkg/expr/graph_test.go b/pkg/expr/graph_test.go index dfa9f5f5b0a..bcfee8fb306 100644 --- a/pkg/expr/graph_test.go +++ b/pkg/expr/graph_test.go @@ -239,7 +239,7 @@ func TestServicebuildPipeLine(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - nodes, err := s.buildPipeline(tt.req) + nodes, err := s.buildPipeline(t.Context(), tt.req) if tt.expectErrContains != "" { require.Error(t, err) require.Contains(t, err.Error(), tt.expectErrContains) diff --git a/pkg/expr/nodes.go b/pkg/expr/nodes.go index 300488ae643..eaaff0f1388 100644 --- a/pkg/expr/nodes.go +++ b/pkg/expr/nodes.go @@ -107,7 +107,7 @@ func (gn *CMDNode) Execute(ctx context.Context, now time.Time, vars mathexp.Vars return gn.Command.Execute(ctx, now, vars, s.tracer, s.metrics) } -func buildCMDNode(rn *rawNode, toggles featuremgmt.FeatureToggles, cfg *setting.Cfg) (*CMDNode, error) { +func buildCMDNode(ctx context.Context, rn *rawNode, toggles featuremgmt.FeatureToggles, cfg *setting.Cfg) (*CMDNode, error) { commandType, err := GetExpressionCommandType(rn.Query) if err != nil { return nil, fmt.Errorf("invalid command type in expression '%v': %w", rn.RefID, err) @@ -141,7 +141,7 @@ func buildCMDNode(rn *rawNode, toggles featuremgmt.FeatureToggles, cfg *setting. if err != nil { return nil, err } - q, err := reader.ReadQuery(data.NewDataQuery(map[string]any{ + q, err := reader.ReadQuery(ctx, data.NewDataQuery(map[string]any{ "refId": rn.RefID, "type": rn.QueryType, }), iter) @@ -164,7 +164,7 @@ func buildCMDNode(rn *rawNode, toggles featuremgmt.FeatureToggles, cfg *setting. case TypeThreshold: node.Command, err = UnmarshalThresholdCommand(rn) case TypeSQL: - node.Command, err = UnmarshalSQLCommand(rn, cfg) + node.Command, err = UnmarshalSQLCommand(ctx, rn, cfg) default: return nil, fmt.Errorf("expression command type '%v' in expression '%v' not implemented", commandType, rn.RefID) } @@ -395,7 +395,11 @@ func (dn *DSNode) Execute(ctx context.Context, now time.Time, _ mathexp.Vars, s }() var resp *backend.QueryDataResponse - mtDSClient, ok := s.mtDatasourceClientBuilder.BuildClient(dn.datasource.Type, dn.datasource.UID) + mtDSClient, ok, err := s.mtDatasourceClientBuilder.BuildClient(dn.datasource.Type, dn.datasource.UID) + if err != nil { + return mathexp.Results{}, MakeQueryError(dn.refID, dn.datasource.UID, err) + } + if !ok { // use single tenant client pCtx, err := s.pCtxProvider.GetWithDataSource(ctx, dn.datasource.Type, dn.request.User, dn.datasource) if err != nil { diff --git a/pkg/expr/reader.go b/pkg/expr/reader.go index 62cd6af4539..5c85b9d61fb 100644 --- a/pkg/expr/reader.go +++ b/pkg/expr/reader.go @@ -1,6 +1,7 @@ package expr import ( + "context" "fmt" "strings" @@ -44,6 +45,7 @@ func NewExpressionQueryReader(features featuremgmt.FeatureToggles) *ExpressionQu // nolint:gocyclo func (h *ExpressionQueryReader) ReadQuery( + ctx context.Context, // Properties that have been parsed off the same node common data.DataQuery, // An iterator with context for the full node (include common values) @@ -135,7 +137,7 @@ func (h *ExpressionQueryReader) ReadQuery( eq.Properties = q // TODO: Cascade limit from Grafana config in this (new Expression Parser) branch of the code cellLimit := 0 // zero means no limit - eq.Command, err = NewSQLCommand(common.RefID, q.Format, q.Expression, int64(cellLimit), 0, 0) + eq.Command, err = NewSQLCommand(ctx, common.RefID, q.Format, q.Expression, int64(cellLimit), 0, 0) } case QueryTypeThreshold: diff --git a/pkg/expr/reader_test.go b/pkg/expr/reader_test.go index ee0ec2265f4..39b99a21ac0 100644 --- a/pkg/expr/reader_test.go +++ b/pkg/expr/reader_test.go @@ -141,7 +141,7 @@ func TestReaderReduceMode(t *testing.T) { reader := NewExpressionQueryReader(featuremgmt.WithFeatures()) - eq, err := reader.ReadQuery(q, iter) + eq, err := reader.ReadQuery(t.Context(), q, iter) if test.expectError { require.Error(t, err) diff --git a/pkg/expr/service.go b/pkg/expr/service.go index 19b124b70d2..b624685c38e 100644 --- a/pkg/expr/service.go +++ b/pkg/expr/service.go @@ -102,8 +102,8 @@ func (s *Service) isDisabled() bool { } // BuildPipeline builds a pipeline from a request. -func (s *Service) BuildPipeline(req *Request) (DataPipeline, error) { - return s.buildPipeline(req) +func (s *Service) BuildPipeline(ctx context.Context, req *Request) (DataPipeline, error) { + return s.buildPipeline(ctx, req) } // ExecutePipeline executes an expression pipeline and returns all the results. diff --git a/pkg/expr/service_sql_test.go b/pkg/expr/service_sql_test.go index 0a1d991da68..714c1b98d7b 100644 --- a/pkg/expr/service_sql_test.go +++ b/pkg/expr/service_sql_test.go @@ -56,14 +56,14 @@ func TestSQLService(t *testing.T) { t.Run("no feature flag no queries for you", func(t *testing.T) { s, req := newMockQueryService(resp, newABSQLQueries("")) - _, err := s.BuildPipeline(req) + _, err := s.BuildPipeline(t.Context(), req) require.Error(t, err, "should not be able to build pipeline without feature flag") }) t.Run("with feature flag basic select works", func(t *testing.T) { s, req := newMockQueryService(resp, newABSQLQueries("SELECT * FROM A")) s.features = featuremgmt.WithFeatures(featuremgmt.FlagSqlExpressions) - pl, err := s.BuildPipeline(req) + pl, err := s.BuildPipeline(t.Context(), req) require.NoError(t, err) res, err := s.ExecutePipeline(context.Background(), time.Now(), pl) @@ -83,7 +83,7 @@ func TestSQLService(t *testing.T) { s.features = featuremgmt.WithFeatures(featuremgmt.FlagSqlExpressions) - pl, err := s.BuildPipeline(req) + pl, err := s.BuildPipeline(t.Context(), req) require.NoError(t, err) rsp, err := s.ExecutePipeline(context.Background(), time.Now(), pl) @@ -100,7 +100,7 @@ func TestSQLService(t *testing.T) { s.features = featuremgmt.WithFeatures(featuremgmt.FlagSqlExpressions) - pl, err := s.BuildPipeline(req) + pl, err := s.BuildPipeline(t.Context(), req) require.NoError(t, err) rsp, err := s.ExecutePipeline(context.Background(), time.Now(), pl) diff --git a/pkg/expr/service_test.go b/pkg/expr/service_test.go index 779791a7812..42f495d67ac 100644 --- a/pkg/expr/service_test.go +++ b/pkg/expr/service_test.go @@ -61,7 +61,7 @@ func TestService(t *testing.T) { s, req := newMockQueryService(resp, queries) - pl, err := s.BuildPipeline(req) + pl, err := s.BuildPipeline(t.Context(), req) require.NoError(t, err) res, err := s.ExecutePipeline(context.Background(), time.Now(), pl) @@ -135,7 +135,7 @@ func TestDSQueryError(t *testing.T) { s, req := newMockQueryService(resp, queries) - pl, err := s.BuildPipeline(req) + pl, err := s.BuildPipeline(t.Context(), req) require.NoError(t, err) res, err := s.ExecutePipeline(context.Background(), time.Now(), pl) @@ -200,7 +200,7 @@ func TestSQLExpressionCellLimitFromConfig(t *testing.T) { req := &Request{Queries: queries, User: &user.SignedInUser{}} // Build the pipeline - pipeline, err := s.BuildPipeline(req) + pipeline, err := s.BuildPipeline(t.Context(), req) require.NoError(t, err) node := pipeline[0] diff --git a/pkg/expr/sql/parser.go b/pkg/expr/sql/parser.go index 0e401c94a15..f2308ece9ae 100644 --- a/pkg/expr/sql/parser.go +++ b/pkg/expr/sql/parser.go @@ -1,19 +1,19 @@ package sql import ( + "context" "fmt" "sort" "strings" "github.com/dolthub/vitess/go/vt/sqlparser" - "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana-plugin-sdk-go/backend" ) -var logger = log.New("sql_expr") - // TablesList returns a list of tables for the sql statement excluding // CTEs and the 'dual' table. The list is sorted alphabetically. -func TablesList(rawSQL string) ([]string, error) { +func TablesList(ctx context.Context, rawSQL string) ([]string, error) { + logger := backend.NewLoggerWith("logger", "expr.sql").FromContext(ctx) stmt, err := sqlparser.Parse(rawSQL) if err != nil { logger.Error("error parsing sql", "error", err.Error(), "sql", rawSQL) diff --git a/pkg/expr/sql/parser_test.go b/pkg/expr/sql/parser_test.go index 44ac1ccb102..02d7ce344f7 100644 --- a/pkg/expr/sql/parser_test.go +++ b/pkg/expr/sql/parser_test.go @@ -121,7 +121,7 @@ func TestTablesList(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - tables, err := TablesList(tc.sql) + tables, err := TablesList(t.Context(), tc.sql) if tc.expectError { require.NotNil(t, err, "expected error for SQL: %s", tc.sql) } else { diff --git a/pkg/expr/sql_command.go b/pkg/expr/sql_command.go index 3ebf6a78d02..016b1c87f5e 100644 --- a/pkg/expr/sql_command.go +++ b/pkg/expr/sql_command.go @@ -6,7 +6,9 @@ import ( "fmt" "time" + "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" + "go.opentelemetry.io/otel/codes" "github.com/grafana/grafana/pkg/apimachinery/errutil" "github.com/grafana/grafana/pkg/expr/mathexp" @@ -40,11 +42,11 @@ type SQLCommand struct { } // NewSQLCommand creates a new SQLCommand. -func NewSQLCommand(refID, format, rawSQL string, intputLimit, outputLimit int64, timeout time.Duration) (*SQLCommand, error) { +func NewSQLCommand(ctx context.Context, refID, format, rawSQL string, intputLimit, outputLimit int64, timeout time.Duration) (*SQLCommand, error) { if rawSQL == "" { return nil, ErrMissingSQLQuery } - tables, err := sql.TablesList(rawSQL) + tables, err := sql.TablesList(ctx, rawSQL) if err != nil { logger.Warn("invalid sql query", "sql", rawSQL, "error", err) return nil, ErrInvalidSQLQuery.Build(errutil.TemplateData{ @@ -76,7 +78,8 @@ func NewSQLCommand(refID, format, rawSQL string, intputLimit, outputLimit int64, } // UnmarshalSQLCommand creates a SQLCommand from Grafana's frontend query. -func UnmarshalSQLCommand(rn *rawNode, cfg *setting.Cfg) (*SQLCommand, error) { +func UnmarshalSQLCommand(ctx context.Context, rn *rawNode, cfg *setting.Cfg) (*SQLCommand, error) { + sqlLogger := backend.NewLoggerWith("logger", "expr.sql").FromContext(ctx) if rn.TimeRange == nil { logger.Error("time range must be specified for refID", "refID", rn.RefID) return nil, fmt.Errorf("time range must be specified for refID %s", rn.RefID) @@ -84,19 +87,19 @@ func UnmarshalSQLCommand(rn *rawNode, cfg *setting.Cfg) (*SQLCommand, error) { expressionRaw, ok := rn.Query["expression"] if !ok { - logger.Error("no expression in the query", "query", rn.Query) + sqlLogger.Error("no expression in the query", "query", rn.Query) return nil, errors.New("no expression in the query") } expression, ok := expressionRaw.(string) if !ok { - logger.Error("expected sql expression to be type string", "expression", expressionRaw) + sqlLogger.Error("expected sql expression to be type string", "expression", expressionRaw) return nil, fmt.Errorf("expected sql expression to be type string, but got type %T", expressionRaw) } formatRaw := rn.Query["format"] format, _ := formatRaw.(string) - return NewSQLCommand(rn.RefID, format, expression, cfg.SQLExpressionCellLimit, cfg.SQLExpressionOutputCellLimit, cfg.SQLExpressionTimeout) + return NewSQLCommand(ctx, rn.RefID, format, expression, cfg.SQLExpressionCellLimit, cfg.SQLExpressionOutputCellLimit, cfg.SQLExpressionTimeout) } // NeedsVars returns the variable names (refIds) that are dependencies @@ -110,17 +113,21 @@ func (gr *SQLCommand) NeedsVars() []string { func (gr *SQLCommand) Execute(ctx context.Context, now time.Time, vars mathexp.Vars, tracer tracing.Tracer, metrics *metrics.ExprMetrics) (mathexp.Results, error) { _, span := tracer.Start(ctx, "SSE.ExecuteSQL") start := time.Now() + sqlLogger := backend.NewLoggerWith("logger", "expr.sql").FromContext(ctx) tc := int64(0) rsp := mathexp.Results{} defer func() { - span.End() duration := float64(time.Since(start).Milliseconds()) statusLabel := "ok" if rsp.Error != nil { statusLabel = "error" + span.RecordError(rsp.Error) + span.SetStatus(codes.Error, rsp.Error.Error()) + sqlLogger.Error("SQL command execution failed", "error", rsp.Error.Error()) } + span.End() metrics.SqlCommandCount.WithLabelValues(statusLabel).Inc() metrics.SqlCommandDuration.WithLabelValues(statusLabel).Observe(duration) @@ -131,7 +138,7 @@ func (gr *SQLCommand) Execute(ctx context.Context, now time.Time, vars mathexp.V for _, ref := range gr.varsToQuery { results, ok := vars[ref] if !ok { - logger.Warn("no results found for", "ref", ref) + sqlLogger.Warn("no results found for", "ref", ref) continue } frames := results.Values.AsDataFrames(ref) @@ -150,17 +157,16 @@ func (gr *SQLCommand) Execute(ctx context.Context, now time.Time, vars mathexp.V return rsp, nil } - logger.Debug("Executing query", "query", gr.query, "frames", len(allFrames)) + sqlLogger.Debug("Executing query", "query", gr.query, "frames", len(allFrames)) db := sql.DB{} frame, err := db.QueryFrames(ctx, tracer, gr.refID, gr.query, allFrames, sql.WithMaxOutputCells(gr.outputLimit), sql.WithTimeout(gr.timeout)) - if err != nil { - logger.Error("Failed to query frames", "error", err.Error()) rsp.Error = err return rsp, nil } - logger.Debug("Done Executing query", "query", gr.query, "rows", frame.Rows()) + + sqlLogger.Debug("Done Executing query", "query", gr.query, "rows", frame.Rows()) if frame.Rows() == 0 { rsp.Values = mathexp.Values{ diff --git a/pkg/expr/sql_command_test.go b/pkg/expr/sql_command_test.go index 34591fdb110..dd9b274a8cd 100644 --- a/pkg/expr/sql_command_test.go +++ b/pkg/expr/sql_command_test.go @@ -13,11 +13,12 @@ import ( "github.com/grafana/grafana/pkg/expr/metrics" "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" ) func TestNewCommand(t *testing.T) { - cmd, err := NewSQLCommand("a", "", "select a from foo, bar", 0, 0, 0) + cmd, err := NewSQLCommand(t.Context(), "a", "", "select a from foo, bar", 0, 0, 0) if err != nil && strings.Contains(err.Error(), "feature is not enabled") { return } @@ -125,7 +126,7 @@ func TestSQLCommandCellLimits(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cmd, err := NewSQLCommand("a", "", "select a from foo, bar", tt.limit, 0, 0) + cmd, err := NewSQLCommand(t.Context(), "a", "", "select a from foo, bar", tt.limit, 0, 0) require.NoError(t, err, "Failed to create SQL command") vars := mathexp.Vars{} @@ -153,7 +154,7 @@ func TestSQLCommandMetrics(t *testing.T) { m := metrics.NewTestMetrics() // Create a command - cmd, err := NewSQLCommand("A", "someformat", "select * from foo", 0, 0, 0) + cmd, err := NewSQLCommand(t.Context(), "A", "someformat", "select * from foo", 0, 0, 0) require.NoError(t, err) // Execute successful command @@ -187,3 +188,8 @@ type testSpan struct { func (ts *testSpan) End(opt ...trace.SpanEndOption) { } + +func (ts *testSpan) RecordError(err error, opt ...trace.EventOption) { +} + +func (ts *testSpan) SetStatus(code codes.Code, msg string) {} diff --git a/pkg/expr/transform.go b/pkg/expr/transform.go index db49522eff1..049bcb117a4 100644 --- a/pkg/expr/transform.go +++ b/pkg/expr/transform.go @@ -88,7 +88,7 @@ func (s *Service) TransformData(ctx context.Context, now time.Time, req *Request // Build the pipeline from the request, checking for ordering issues (e.g. loops) // and parsing graph nodes from the queries. - pipeline, err := s.BuildPipeline(req) + pipeline, err := s.BuildPipeline(ctx, req) if err != nil { return nil, err } diff --git a/pkg/extensions/enterprise_imports.go b/pkg/extensions/enterprise_imports.go index 3585c68ebf3..d33ccf561aa 100644 --- a/pkg/extensions/enterprise_imports.go +++ b/pkg/extensions/enterprise_imports.go @@ -27,6 +27,7 @@ import ( _ "github.com/robfig/cron/v3" _ "github.com/russellhaering/goxmldsig" _ "github.com/spf13/cobra" // used by the standalone apiserver cli + _ "github.com/spyzhov/ajson" _ "github.com/stretchr/testify/require" _ "gocloud.dev/secrets/awskms" _ "gocloud.dev/secrets/azurekeyvault" @@ -52,5 +53,4 @@ import ( _ "github.com/grafana/e2e" _ "github.com/grafana/gofpdf" _ "github.com/grafana/gomemcache/memcache" - _ "github.com/spyzhov/ajson" ) diff --git a/pkg/plugins/codegen/go.mod b/pkg/plugins/codegen/go.mod index 538918b3076..088304f6522 100644 --- a/pkg/plugins/codegen/go.mod +++ b/pkg/plugins/codegen/go.mod @@ -1,6 +1,6 @@ module github.com/grafana/grafana/pkg/plugins/codegen -go 1.24.5 +go 1.24.6 replace github.com/grafana/grafana/pkg/codegen => ../../codegen diff --git a/pkg/plugins/manager/loader/loader_test.go b/pkg/plugins/manager/loader/loader_test.go index dcd9a536baa..3cbaf8968da 100644 --- a/pkg/plugins/manager/loader/loader_test.go +++ b/pkg/plugins/manager/loader/loader_test.go @@ -122,11 +122,12 @@ func TestLoader_Load(t *testing.T) { Backend: true, QueryOptions: map[string]bool{"minInterval": true}, }, - Module: "core:plugin/cloudwatch", - BaseURL: "public/plugins/cloudwatch", - FS: mustNewStaticFSForTests(t, filepath.Join(corePluginDir, "app/plugins/datasource/cloudwatch")), - Signature: plugins.SignatureStatusInternal, - Class: plugins.ClassCore, + Module: "core:plugin/cloudwatch", + BaseURL: "public/plugins/cloudwatch", + FS: mustNewStaticFSForTests(t, filepath.Join(corePluginDir, "app/plugins/datasource/cloudwatch")), + Signature: plugins.SignatureStatusInternal, + Class: plugins.ClassCore, + Translations: map[string]string{}, }, }, }, @@ -221,6 +222,7 @@ func TestLoader_Load(t *testing.T) { Signature: "valid", SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", + Translations: map[string]string{}, }, }, }, @@ -266,11 +268,12 @@ func TestLoader_Load(t *testing.T) { Backend: true, State: plugins.ReleaseStateAlpha, }, - Class: plugins.ClassExternal, - Module: "public/plugins/test-datasource/module.js", - BaseURL: "public/plugins/test-datasource", - FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/unsigned-datasource/plugin")), - Signature: "unsigned", + Class: plugins.ClassExternal, + Module: "public/plugins/test-datasource/module.js", + BaseURL: "public/plugins/test-datasource", + FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/unsigned-datasource/plugin")), + Signature: "unsigned", + Translations: map[string]string{}, }, }, }, @@ -323,11 +326,12 @@ func TestLoader_Load(t *testing.T) { Backend: true, State: plugins.ReleaseStateAlpha, }, - Class: plugins.ClassExternal, - Module: "public/plugins/test-datasource/module.js", - BaseURL: "public/plugins/test-datasource", - FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/unsigned-datasource/plugin")), - Signature: plugins.SignatureStatusUnsigned, + Class: plugins.ClassExternal, + Module: "public/plugins/test-datasource/module.js", + BaseURL: "public/plugins/test-datasource", + FS: mustNewStaticFSForTests(t, filepath.Join(parentDir, "testdata/unsigned-datasource/plugin")), + Signature: plugins.SignatureStatusUnsigned, + Translations: map[string]string{}, }, }, }, @@ -424,6 +428,7 @@ func TestLoader_Load(t *testing.T) { Signature: plugins.SignatureStatusUnsigned, Module: "public/plugins/test-app/module.js", BaseURL: "public/plugins/test-app", + Translations: map[string]string{}, }, }, }, diff --git a/pkg/plugins/manager/pipeline/bootstrap/factory.go b/pkg/plugins/manager/pipeline/bootstrap/factory.go index e3a31476624..de617075fc0 100644 --- a/pkg/plugins/manager/pipeline/bootstrap/factory.go +++ b/pkg/plugins/manager/pipeline/bootstrap/factory.go @@ -78,10 +78,8 @@ func (f *DefaultPluginFactory) newPlugin(p plugins.FoundPlugin, class plugins.Cl return nil, err } - if f.features.LocalizationForPlugins { - if err := setTranslations(plugin, f.assetPath, info); err != nil { - return nil, err - } + if err := setTranslations(plugin, f.assetPath, info); err != nil { + return nil, err } return plugin, nil diff --git a/pkg/promlib/go.mod b/pkg/promlib/go.mod index 786a6515c90..ac034b92ba2 100644 --- a/pkg/promlib/go.mod +++ b/pkg/promlib/go.mod @@ -1,6 +1,6 @@ module github.com/grafana/grafana/pkg/promlib -go 1.24.5 +go 1.24.6 require ( github.com/grafana/dskit v0.0.0-20250611075409-46f51e1ce914 diff --git a/pkg/registry/apis/dashboard/dashboard_storage.go b/pkg/registry/apis/dashboard/dashboard_storage.go new file mode 100644 index 00000000000..d2d54c8d815 --- /dev/null +++ b/pkg/registry/apis/dashboard/dashboard_storage.go @@ -0,0 +1,35 @@ +package dashboard + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" +) + +// dashboardStoragePermissionWrapper is a wrapper around the grafanarest.Storage that adds dashboard permissions handling +// when dual writing is enabled. +type dashboardStoragePermissionWrapper struct { + dashboardPermissionsSvc accesscontrol.DashboardPermissionsService + grafanarest.Storage +} + +func (d dashboardStoragePermissionWrapper) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, false, err + } + obj, async, err := d.Storage.Delete(ctx, name, deleteValidation, options) + if err != nil { + return obj, async, err + } + if accessErr := d.dashboardPermissionsSvc.DeleteResourcePermissions(ctx, info.OrgID, name); accessErr != nil { + return obj, async, accessErr + } + return obj, async, nil +} diff --git a/pkg/registry/apis/dashboard/legacy/migrate.go b/pkg/registry/apis/dashboard/legacy/migrate.go index 73d01caaade..bdc47bf985a 100644 --- a/pkg/registry/apis/dashboard/legacy/migrate.go +++ b/pkg/registry/apis/dashboard/legacy/migrate.go @@ -11,6 +11,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" authlib "github.com/grafana/authlib/types" + dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" "github.com/grafana/grafana/pkg/apimachinery/utils" @@ -51,11 +52,12 @@ func ProvideLegacyMigrator( sql db.DB, // direct access to tables provisioning provisioning.ProvisioningService, // only needed for dashboard settings libraryPanelSvc librarypanels.Service, + dashboardPermissionSvc accesscontrol.DashboardPermissionsService, accessControl accesscontrol.AccessControl, features featuremgmt.FeatureToggles, ) LegacyMigrator { dbp := legacysql.NewDatabaseProvider(sql) - return NewDashboardAccess(dbp, authlib.OrgNamespaceFormatter, nil, provisioning, libraryPanelSvc, sort.ProvideService(), accessControl, features) + return NewDashboardAccess(dbp, authlib.OrgNamespaceFormatter, nil, provisioning, libraryPanelSvc, sort.ProvideService(), dashboardPermissionSvc, accessControl, features) } type BlobStoreInfo struct { diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go index f019a39a799..ae5e54393e3 100644 --- a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go @@ -15,6 +15,7 @@ import ( "k8s.io/utils/ptr" claims "github.com/grafana/authlib/types" + dashboardOG "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard" dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" @@ -66,8 +67,9 @@ type dashboardSqlAccess struct { invalidDashboardParseFallbackEnabled bool // Use for writing (not reading) - dashStore dashboards.Store - dashboardSearchClient legacysearcher.DashboardSearchClient + dashStore dashboards.Store + dashboardSearchClient legacysearcher.DashboardSearchClient + dashboardPermissionSvc accesscontrol.DashboardPermissionsService accessControl accesscontrol.AccessControl libraryPanelSvc librarypanels.Service @@ -84,6 +86,7 @@ func NewDashboardAccess(sql legacysql.LegacyDatabaseProvider, provisioning provisioning.ProvisioningService, libraryPanelSvc librarypanels.Service, sorter sort.Service, + dashboardPermissionSvc accesscontrol.DashboardPermissionsService, accessControl accesscontrol.AccessControl, features featuremgmt.FeatureToggles, ) DashboardAccess { @@ -94,6 +97,7 @@ func NewDashboardAccess(sql legacysql.LegacyDatabaseProvider, dashStore: dashStore, provisioning: provisioning, dashboardSearchClient: *dashboardSearchClient, + dashboardPermissionSvc: dashboardPermissionSvc, libraryPanelSvc: libraryPanelSvc, accessControl: accessControl, log: log.New("dashboard.legacysql"), diff --git a/pkg/registry/apis/dashboard/legacy_storage.go b/pkg/registry/apis/dashboard/legacy_storage.go index 7fb9d54340e..97bcf1ce4f3 100644 --- a/pkg/registry/apis/dashboard/legacy_storage.go +++ b/pkg/registry/apis/dashboard/legacy_storage.go @@ -42,7 +42,7 @@ func (s *DashboardStorage) NewStore(dash utils.ResourceInfo, scheme *runtime.Sch return nil, err } client := legacy.NewDirectResourceClient(server) // same context - optsGetter := apistore.NewRESTOptionsGetterForClient(client, + optsGetter := apistore.NewRESTOptionsGetterForClient(client, nil, defaultOpts.StorageConfig.Config, nil, ) optsGetter.RegisterOptions(dash.GroupResource(), apistore.StorageOptions{ diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index f2ed3ee138c..3014893ea71 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -20,6 +20,7 @@ import ( "k8s.io/kube-openapi/pkg/validation/spec" claims "github.com/grafana/authlib/types" + internal "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard" dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" @@ -82,6 +83,7 @@ type DashboardsAPIBuilder struct { unified resource.ResourceClient dashboardProvisioningService dashboards.DashboardProvisioningService dashboardPermissions dashboards.PermissionsRegistrationService + dashboardPermissionsSvc accesscontrol.DashboardPermissionsService scheme *runtime.Scheme search *SearchHandler dashStore dashboards.Store @@ -105,6 +107,7 @@ func RegisterAPIService( pluginStore pluginstore.Store, datasourceService datasources.DataSourceService, dashboardPermissions dashboards.PermissionsRegistrationService, + dashboardPermissionsSvc accesscontrol.DashboardPermissionsService, accessControl accesscontrol.AccessControl, accessClient claims.AccessClient, provisioning provisioning.ProvisioningService, @@ -130,6 +133,7 @@ func RegisterAPIService( dashboardService: dashboardService, dashboardPermissions: dashboardPermissions, + dashboardPermissionsSvc: dashboardPermissionsSvc, features: features, accessControl: accessControl, accessClient: accessClient, @@ -145,7 +149,7 @@ func RegisterAPIService( folderClient: folderClient, legacy: &DashboardStorage{ - Access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning, libraryPanelSvc, sorter, accessControl, features), + Access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning, libraryPanelSvc, sorter, dashboardPermissionsSvc, accessControl, features), DashboardService: dashboardService, }, reg: reg, @@ -530,10 +534,14 @@ func (b *DashboardsAPIBuilder) storageForVersion( } gr := dashboards.GroupResource() - storage[dashboards.StoragePath()], err = opts.DualWriteBuilder(gr, legacyStore, store) + dw, err := opts.DualWriteBuilder(gr, legacyStore, store) if err != nil { return err } + storage[dashboards.StoragePath()] = dashboardStoragePermissionWrapper{ + dashboardPermissionsSvc: b.dashboardPermissionsSvc, + Storage: dw, + } // Register the DTO endpoint that will consolidate all dashboard bits storage[dashboards.StoragePath("dto")], err = NewDTOConnector( diff --git a/pkg/registry/apis/folders/continue.go b/pkg/registry/apis/folders/continue.go index 8ff86b627ea..cb186f91612 100644 --- a/pkg/registry/apis/folders/continue.go +++ b/pkg/registry/apis/folders/continue.go @@ -14,9 +14,13 @@ type continueToken struct { limit int64 } +const defaultPageLimit = 100 +const defaultPageNumber = 1 + func readContinueToken(options *internalversion.ListOptions) (*continueToken, error) { t := &continueToken{ - limit: 100, // default page size + limit: defaultPageLimit, // default page size + page: defaultPageNumber, // default page number } if options.Continue == "" { if options.Limit > 0 { diff --git a/pkg/registry/apis/folders/continue_test.go b/pkg/registry/apis/folders/continue_test.go index 2763c3522c6..7bde6d4b4e6 100644 --- a/pkg/registry/apis/folders/continue_test.go +++ b/pkg/registry/apis/folders/continue_test.go @@ -10,15 +10,15 @@ import ( func TestContinueToken(t *testing.T) { token, err := readContinueToken(&internalversion.ListOptions{}) require.NoError(t, err) - require.Equal(t, int64(100), token.limit) - require.Equal(t, int64(0), token.page) + require.Equal(t, int64(defaultPageLimit), token.limit) + require.Equal(t, int64(defaultPageNumber), token.page) next := token.GetNextPageToken() - require.Equal(t, "MTAwfDE=", next) + require.Equal(t, "MTAwfDI=", next) token, err = readContinueToken(&internalversion.ListOptions{Continue: next}) require.NoError(t, err) - require.Equal(t, int64(100), token.limit) - require.Equal(t, int64(1), token.page) // <<< +1 + require.Equal(t, int64(defaultPageLimit), token.limit) + require.Equal(t, int64(defaultPageNumber+1), token.page) // <<< +1 // Error if the limit has changed _, err = readContinueToken(&internalversion.ListOptions{Continue: next, Limit: 50}) diff --git a/pkg/registry/apis/folders/folder_storage.go b/pkg/registry/apis/folders/folder_storage.go index b2084051824..3e544d46cb8 100644 --- a/pkg/registry/apis/folders/folder_storage.go +++ b/pkg/registry/apis/folders/folder_storage.go @@ -10,11 +10,13 @@ import ( "k8s.io/apiserver/pkg/registry/rest" claims "github.com/grafana/authlib/types" + folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" "github.com/grafana/grafana/pkg/api/apierrors" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" @@ -127,7 +129,23 @@ func (s *folderStorage) Update(ctx context.Context, // GracefulDeleter func (s *folderStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { - return s.store.Delete(ctx, name, deleteValidation, options) + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, false, err + } + + obj, async, err := s.store.Delete(ctx, name, deleteValidation, options) + if err != nil { + return obj, async, err + } + + if accessErr := s.folderPermissionsSvc.DeleteResourcePermissions(ctx, info.OrgID, name); accessErr != nil { + // TODO: add a proper logger to this struct. + logger := log.New().FromContext(ctx) + logger.Warn("failed to delete folder permission after successfully deleting folder resource", "folder", name, "error", accessErr) + } + + return obj, async, err } // GracefulDeleter diff --git a/pkg/registry/apis/folders/legacy_storage.go b/pkg/registry/apis/folders/legacy_storage.go index f71993bfea2..4ae4a94a7f5 100644 --- a/pkg/registry/apis/folders/legacy_storage.go +++ b/pkg/registry/apis/folders/legacy_storage.go @@ -95,16 +95,10 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO SignedInUser: user, OrgID: orgId, } - if options.Continue != "" { - query.Page = paging.page - query.Limit = paging.limit - } else if options.Limit > 0 { - query.Limit = options.Limit - query.Page = 1 - // also need to update the paging token so the continue token is correct - paging.limit = options.Limit - paging.page = 1 - } + + // paging is always retrieved from the continue token + query.Limit = paging.limit + query.Page = paging.page if options.LabelSelector != nil && options.LabelSelector.Matches(labels.Set{utils.LabelGetFullpath: "true"}) { query.WithFullpath = true @@ -321,13 +315,15 @@ func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidatio if !ok { return v, false, fmt.Errorf("expected a folder response from Get") } + err = s.service.DeleteLegacy(ctx, &folder.DeleteFolderCommand{ UID: name, OrgID: info.OrgID, SignedInUser: user, // This would cascade delete into alert rules - ForceDeleteRules: false, + ForceDeleteRules: false, + RemovePermissions: utils.GetFolderRemovePermissions(ctx, true), }) return p, true, err // true is instant delete } diff --git a/pkg/registry/apis/folders/legacy_storage_test.go b/pkg/registry/apis/folders/legacy_storage_test.go index 4871c511a20..0edabebb791 100644 --- a/pkg/registry/apis/folders/legacy_storage_test.go +++ b/pkg/registry/apis/folders/legacy_storage_test.go @@ -121,6 +121,31 @@ func TestLegacyStorage_List_Pagination(t *testing.T) { require.Equal(t, int64(2), folderService.LastQuery.Limit) require.Equal(t, int64(1), folderService.LastQuery.Page) }) + + t.Run("should set page limit to default when no limit is specified in options", func(t *testing.T) { + options := &metainternalversion.ListOptions{} + folders := make([]*folder.Folder, defaultPageLimit) + for i := range folders { + folders[i] = &folder.Folder{ + UID: fmt.Sprintf("folder-%d", i), + Title: fmt.Sprintf("Folder %d", i), + } + } + folderService.ExpectedFolders = folders + + result, err := storage.List(ctx, options) + require.NoError(t, err) + + list, ok := result.(*folderv1.FolderList) + require.True(t, ok) + + // assert returned continue token is correct and previous paging was correct + token, err := base64.StdEncoding.DecodeString(list.Continue) + require.NoError(t, err) + require.Equal(t, "100|2", string(token)) + require.Equal(t, int64(defaultPageLimit), folderService.LastQuery.Limit) + require.Equal(t, int64(1), folderService.LastQuery.Page) + }) } func TestLegacyStorage_List_LabelSelector(t *testing.T) { diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index f1de0924641..4a233b6090b 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -333,7 +333,7 @@ func NewLocalStore(resourceInfo utils.ResourceInfo, scheme *runtime.Scheme, defa } client := resource.NewLocalResourceClient(server) - optsGetter := apistore.NewRESTOptionsGetterForClient(client, defaultOpts.StorageConfig.Config, nil) + optsGetter := apistore.NewRESTOptionsGetterForClient(client, nil, defaultOpts.StorageConfig.Config, nil) store, err := grafanaregistry.NewRegistryStore(scheme, resourceInfo, optsGetter) return store, err diff --git a/pkg/registry/apis/provisioning/controller/historyjob.go b/pkg/registry/apis/provisioning/controller/historyjob.go new file mode 100644 index 00000000000..2e4e13f359d --- /dev/null +++ b/pkg/registry/apis/provisioning/controller/historyjob.go @@ -0,0 +1,89 @@ +package controller + +import ( + "context" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apiserver/pkg/endpoints/request" + "k8s.io/client-go/tools/cache" + + "github.com/grafana/grafana-app-sdk/logging" + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1" + informer "github.com/grafana/grafana/apps/provisioning/pkg/generated/informers/externalversions/provisioning/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/identity" +) + +const ( + historyJobControllerLoggerName = "provisioning-historyjob-controller" +) + +// HistoryJobController manages the cleanup of old HistoryJob entries. +type HistoryJobController struct { + client client.ProvisioningV0alpha1Interface + logger logging.Logger + expirationTime time.Duration +} + +// NewHistoryJobController creates a new HistoryJobController. +func NewHistoryJobController( + provisioningClient client.ProvisioningV0alpha1Interface, + historyJobInformer informer.HistoricJobInformer, + expirationTime time.Duration, +) (*HistoryJobController, error) { + c := &HistoryJobController{ + client: provisioningClient, + logger: logging.DefaultLogger.With("logger", historyJobControllerLoggerName), + expirationTime: expirationTime, + } + + // Use the resync events from the shared informer to trigger cleanup for each job + _, err := historyJobInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + c.cleanupJob(obj) + }, + UpdateFunc: func(oldObj, newObj interface{}) { + c.cleanupJob(newObj) + }, + }) + if err != nil { + return nil, err + } + + return c, nil +} + +func (c *HistoryJobController) cleanupJob(obj interface{}) { + job, ok := obj.(*provisioning.HistoricJob) + if !ok { + c.logger.Error("Expected HistoricJob but got", "type", obj) + return + } + + age := time.Since(job.CreationTimestamp.Time) + if age > c.expirationTime { + namespace := job.Namespace + ctx, _, err := identity.WithProvisioningIdentity(context.Background(), namespace) + if err != nil { + c.logger.Error("Failed to set provisioning identity for cleanup", "error", err) + return + } + + ctx = request.WithNamespace(ctx, namespace) + err = c.client.HistoricJobs(job.Namespace).Delete(ctx, job.Name, metav1.DeleteOptions{}) + if err != nil && !apierrors.IsNotFound(err) { + c.logger.Error("Failed to delete expired HistoryJob", + "namespace", job.Namespace, + "name", job.Name, + "age", age, + "error", err) + } else { + c.logger.Info("Deleted expired HistoryJob", + "namespace", job.Namespace, + "name", job.Name, + "age", age) + } + } +} diff --git a/pkg/registry/apis/provisioning/controller/job.go b/pkg/registry/apis/provisioning/controller/job.go new file mode 100644 index 00000000000..c3af398d2c1 --- /dev/null +++ b/pkg/registry/apis/provisioning/controller/job.go @@ -0,0 +1,58 @@ +package controller + +import ( + "k8s.io/client-go/tools/cache" + + "github.com/grafana/grafana-app-sdk/logging" + informer "github.com/grafana/grafana/apps/provisioning/pkg/generated/informers/externalversions/provisioning/v0alpha1" +) + +const ( + jobControllerLoggerName = "provisioning-job-controller" +) + +// JobController manages job create notifications. +type JobController struct { + jobSynced cache.InformerSynced + logger logging.Logger + + // notification channel for job create events (replaces InsertNotifications) + notifications chan struct{} +} + +// NewJobController creates a new JobController. +func NewJobController( + jobInformer informer.JobInformer, +) (*JobController, error) { + jc := &JobController{ + jobSynced: jobInformer.Informer().HasSynced, + logger: logging.DefaultLogger.With("logger", jobControllerLoggerName), + notifications: make(chan struct{}, 1), + } + + _, err := jobInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + // Send notification for job create events (replaces InsertNotifications) + jc.sendNotification() + }, + }) + if err != nil { + return nil, err + } + + return jc, nil +} + +// InsertNotifications returns a channel that receives notifications when jobs are created. +// This replaces the InsertNotifications method from persistentstore.go. +func (jc *JobController) InsertNotifications() chan struct{} { + return jc.notifications +} + +func (jc *JobController) sendNotification() { + select { + case jc.notifications <- struct{}{}: + default: + // Don't block if there's already a notification waiting + } +} diff --git a/pkg/registry/apis/provisioning/controller/job_test.go b/pkg/registry/apis/provisioning/controller/job_test.go new file mode 100644 index 00000000000..8c84abefb0f --- /dev/null +++ b/pkg/registry/apis/provisioning/controller/job_test.go @@ -0,0 +1,85 @@ +package controller + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + provisioningfake "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/fake" + provisioninginformers "github.com/grafana/grafana/apps/provisioning/pkg/generated/informers/externalversions" +) + +func TestJobController_New(t *testing.T) { + client := provisioningfake.NewSimpleClientset() + informerFactory := provisioninginformers.NewSharedInformerFactory(client, time.Second) + jobInformer := informerFactory.Provisioning().V0alpha1().Jobs() + + controller, err := NewJobController(jobInformer) + + require.NoError(t, err) + assert.NotNil(t, controller) + assert.NotNil(t, controller.notifications) +} + +func TestJobController_InsertNotifications(t *testing.T) { + client := provisioningfake.NewSimpleClientset() + informerFactory := provisioninginformers.NewSharedInformerFactory(client, time.Second) + jobInformer := informerFactory.Provisioning().V0alpha1().Jobs() + + controller, err := NewJobController(jobInformer) + require.NoError(t, err) + + notifications := controller.InsertNotifications() + assert.NotNil(t, notifications) + + // Test that notification is sent + controller.sendNotification() + + select { + case <-notifications: + // Success - notification received + case <-time.After(time.Second): + t.Fatal("Expected notification but didn't receive one") + } +} + +func TestJobController_NotificationOnJobCreate(t *testing.T) { + client := provisioningfake.NewSimpleClientset() + informerFactory := provisioninginformers.NewSharedInformerFactory(client, time.Second) + jobInformer := informerFactory.Provisioning().V0alpha1().Jobs() + + controller, err := NewJobController(jobInformer) + require.NoError(t, err) + + // Start informer and wait for cache sync + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + informerFactory.Start(ctx.Done()) + informerFactory.WaitForCacheSync(ctx.Done()) + + // Get notifications channel + notifications := controller.InsertNotifications() + + // Create a job - this should trigger a notification + _, err = client.ProvisioningV0alpha1().Jobs("default").Create(ctx, &provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-job", + Namespace: "default", + }, + }, metav1.CreateOptions{}) + require.NoError(t, err) + + // Wait for notification + select { + case <-notifications: + // Success - notification received + case <-time.After(time.Second * 2): + t.Fatal("Expected notification but didn't receive one") + } +} diff --git a/pkg/registry/apis/provisioning/files.go b/pkg/registry/apis/provisioning/files.go index 1df860b9738..8fe1a933e37 100644 --- a/pkg/registry/apis/provisioning/files.go +++ b/pkg/registry/apis/provisioning/files.go @@ -135,7 +135,7 @@ func (c *filesConnector) Connect(ctx context.Context, name string, opts runtime. case http.MethodGet: resource, err := dualReadWriter.Read(ctx, opts.Path, opts.Ref) if err != nil { - responder.Error(err) + respondWithError(responder, err) return } obj = resource.AsResourceWrapper() @@ -153,7 +153,7 @@ func (c *filesConnector) Connect(ctx context.Context, name string, opts runtime. resource, err := dualReadWriter.MoveResource(ctx, opts) if err != nil { - responder.Error(err) + respondWithError(responder, err) return } obj = resource.AsResourceWrapper() @@ -169,7 +169,7 @@ func (c *filesConnector) Connect(ctx context.Context, name string, opts runtime. var resource *resources.ParsedResource resource, err = dualReadWriter.CreateResource(ctx, opts) if err != nil { - responder.Error(err) + respondWithError(responder, err) return } obj = resource.AsResourceWrapper() @@ -187,7 +187,7 @@ func (c *filesConnector) Connect(ctx context.Context, name string, opts runtime. resource, err := dualReadWriter.UpdateResource(ctx, opts) if err != nil { - responder.Error(err) + respondWithError(responder, err) return } obj = resource.AsResourceWrapper() @@ -195,7 +195,7 @@ func (c *filesConnector) Connect(ctx context.Context, name string, opts runtime. case http.MethodDelete: resource, err := dualReadWriter.Delete(ctx, opts) if err != nil { - responder.Error(err) + respondWithError(responder, err) return } obj = resource.AsResourceWrapper() diff --git a/pkg/registry/apis/provisioning/jobs/concurrent_driver.go b/pkg/registry/apis/provisioning/jobs/concurrent_driver.go index 23935113a36..4f508c25a8d 100644 --- a/pkg/registry/apis/provisioning/jobs/concurrent_driver.go +++ b/pkg/registry/apis/provisioning/jobs/concurrent_driver.go @@ -20,6 +20,7 @@ type ConcurrentJobDriver struct { repoGetter RepoGetter historicJobs History workers []Worker + notifications chan struct{} } // NewConcurrentJobDriver creates a new concurrent job driver that spawns multiple job drivers. @@ -29,6 +30,7 @@ func NewConcurrentJobDriver( store Store, repoGetter RepoGetter, historicJobs History, + notifications chan struct{}, workers ...Worker, ) (*ConcurrentJobDriver, error) { if numDrivers <= 0 { @@ -63,6 +65,7 @@ func NewConcurrentJobDriver( repoGetter: repoGetter, historicJobs: historicJobs, workers: workers, + notifications: notifications, }, nil } @@ -117,6 +120,7 @@ func (c *ConcurrentJobDriver) Run(ctx context.Context) error { c.store, c.repoGetter, c.historicJobs, + c.notifications, c.workers..., ) if err != nil { diff --git a/pkg/registry/apis/provisioning/jobs/delete/worker.go b/pkg/registry/apis/provisioning/jobs/delete/worker.go index 63db00ef224..9beff8e953e 100644 --- a/pkg/registry/apis/provisioning/jobs/delete/worker.go +++ b/pkg/registry/apis/provisioning/jobs/delete/worker.go @@ -70,6 +70,7 @@ func (w *Worker) Process(ctx context.Context, repo repository.Repository, job pr CommitOnlyOnceMessage: msg, PushOnWrites: false, Timeout: 10 * time.Minute, + Ref: opts.Ref, } err := w.wrapFn(ctx, repo, stageOptions, fn) @@ -77,6 +78,15 @@ func (w *Worker) Process(ctx context.Context, repo repository.Repository, job pr return fmt.Errorf("delete files from repository: %w", err) } + // Set RefURLs if the repository supports it and we have a target ref + if opts.Ref != "" { + if repoWithURLs, ok := repo.(repository.RepositoryWithURLs); ok { + if refURLs, urlErr := repoWithURLs.RefURLs(ctx, opts.Ref); urlErr == nil && refURLs != nil { + progress.SetRefURLs(ctx, refURLs) + } + } + } + if opts.Ref == "" { progress.ResetResults() progress.SetMessage(ctx, "pull resources") @@ -106,7 +116,9 @@ func (w *Worker) deleteFiles(ctx context.Context, rw repository.ReaderWriter, pr } progress.SetMessage(ctx, "Deleting "+path) - result.Error = rw.Delete(ctx, path, opts.Ref, "Delete "+path) + if err := rw.Delete(ctx, path, opts.Ref, "Delete "+path); err != nil { + result.Error = fmt.Errorf("deleting file %s: %w", path, err) + } progress.Record(ctx, result) if err := progress.TooManyErrors(); err != nil { return err diff --git a/pkg/registry/apis/provisioning/jobs/delete/worker_test.go b/pkg/registry/apis/provisioning/jobs/delete/worker_test.go index 7f2fa3c22bb..372ce5d29d5 100644 --- a/pkg/registry/apis/provisioning/jobs/delete/worker_test.go +++ b/pkg/registry/apis/provisioning/jobs/delete/worker_test.go @@ -6,10 +6,11 @@ import ( "testing" "time" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/validation/field" - provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + v0alpha1 "github.com/grafana/grafana/apps/provisioning/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" @@ -29,41 +30,41 @@ func (m *mockReaderWriter) Delete(ctx context.Context, path, ref, message string // simpleRepository implements only the base Repository interface, not ReaderWriter type simpleRepository struct{} -func (s *simpleRepository) Config() *provisioning.Repository { return nil } -func (s *simpleRepository) Validate() field.ErrorList { return nil } -func (s *simpleRepository) Test(ctx context.Context) (*provisioning.TestResults, error) { +func (s *simpleRepository) Config() *v0alpha1.Repository { return nil } +func (s *simpleRepository) Validate() field.ErrorList { return nil } +func (s *simpleRepository) Test(ctx context.Context) (*v0alpha1.TestResults, error) { return nil, nil } func TestDeleteWorker_IsSupported(t *testing.T) { tests := []struct { name string - job provisioning.Job + job v0alpha1.Job expected bool }{ { name: "delete action is supported", - job: provisioning.Job{ - Spec: provisioning.JobSpec{ - Action: provisioning.JobActionDelete, + job: v0alpha1.Job{ + Spec: v0alpha1.JobSpec{ + Action: v0alpha1.JobActionDelete, }, }, expected: true, }, { name: "pull action is not supported", - job: provisioning.Job{ - Spec: provisioning.JobSpec{ - Action: provisioning.JobActionPull, + job: v0alpha1.Job{ + Spec: v0alpha1.JobSpec{ + Action: v0alpha1.JobActionPull, }, }, expected: false, }, { name: "push action is not supported", - job: provisioning.Job{ - Spec: provisioning.JobSpec{ - Action: provisioning.JobActionPush, + job: v0alpha1.Job{ + Spec: v0alpha1.JobSpec{ + Action: v0alpha1.JobActionPush, }, }, expected: false, @@ -80,9 +81,9 @@ func TestDeleteWorker_IsSupported(t *testing.T) { } func TestDeleteWorker_ProcessMissingDeleteSettings(t *testing.T) { - job := provisioning.Job{ - Spec: provisioning.JobSpec{ - Action: provisioning.JobActionDelete, + job := v0alpha1.Job{ + Spec: v0alpha1.JobSpec{ + Action: v0alpha1.JobActionDelete, }, } @@ -92,10 +93,10 @@ func TestDeleteWorker_ProcessMissingDeleteSettings(t *testing.T) { } func TestDeleteWorker_ProcessNotReaderWriter(t *testing.T) { - job := provisioning.Job{ - Spec: provisioning.JobSpec{ - Action: provisioning.JobActionDelete, - Delete: &provisioning.DeleteJobOptions{ + job := v0alpha1.Job{ + Spec: v0alpha1.JobSpec{ + Action: v0alpha1.JobActionDelete, + Delete: &v0alpha1.DeleteJobOptions{ Paths: []string{"test/path"}, }, }, @@ -121,10 +122,10 @@ func TestDeleteWorker_ProcessNotReaderWriter(t *testing.T) { } func TestDeleteWorker_ProcessWrapFnError(t *testing.T) { - job := provisioning.Job{ - Spec: provisioning.JobSpec{ - Action: provisioning.JobActionDelete, - Delete: &provisioning.DeleteJobOptions{ + job := v0alpha1.Job{ + Spec: v0alpha1.JobSpec{ + Action: v0alpha1.JobActionDelete, + Delete: &v0alpha1.DeleteJobOptions{ Paths: []string{"test/path"}, }, }, @@ -144,10 +145,10 @@ func TestDeleteWorker_ProcessWrapFnError(t *testing.T) { } func TestDeleteWorker_ProcessDeleteFilesSuccess(t *testing.T) { - job := provisioning.Job{ - Spec: provisioning.JobSpec{ - Action: provisioning.JobActionDelete, - Delete: &provisioning.DeleteJobOptions{ + job := v0alpha1.Job{ + Spec: v0alpha1.JobSpec{ + Action: v0alpha1.JobActionDelete, + Delete: &v0alpha1.DeleteJobOptions{ Paths: []string{"test/path1", "test/path2"}, Ref: "main", }, @@ -164,6 +165,7 @@ func TestDeleteWorker_ProcessDeleteFilesSuccess(t *testing.T) { return !opts.PushOnWrites && opts.Timeout == 10*time.Minute && opts.Mode == repository.StageModeCommitOnlyOnce && + opts.Ref == "main" && opts.CommitOnlyOnceMessage == "Delete from Grafana "+job.Name }), mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error { return fn(mockRepo, false) @@ -191,10 +193,10 @@ func TestDeleteWorker_ProcessDeleteFilesSuccess(t *testing.T) { } func TestDeleteWorker_ProcessDeleteFilesWithError(t *testing.T) { - job := provisioning.Job{ - Spec: provisioning.JobSpec{ - Action: provisioning.JobActionDelete, - Delete: &provisioning.DeleteJobOptions{ + job := v0alpha1.Job{ + Spec: v0alpha1.JobSpec{ + Action: v0alpha1.JobActionDelete, + Delete: &v0alpha1.DeleteJobOptions{ Paths: []string{"test/path1", "test/path2"}, Ref: "main", }, @@ -229,10 +231,10 @@ func TestDeleteWorker_ProcessDeleteFilesWithError(t *testing.T) { } func TestDeleteWorker_ProcessWithSyncWorker(t *testing.T) { - job := provisioning.Job{ - Spec: provisioning.JobSpec{ - Action: provisioning.JobActionDelete, - Delete: &provisioning.DeleteJobOptions{ + job := v0alpha1.Job{ + Spec: v0alpha1.JobSpec{ + Action: v0alpha1.JobActionDelete, + Delete: &v0alpha1.DeleteJobOptions{ Paths: []string{"test/path"}, }, }, @@ -263,7 +265,7 @@ func TestDeleteWorker_ProcessWithSyncWorker(t *testing.T) { mockProgress.On("ResetResults").Return() mockProgress.On("SetMessage", mock.Anything, "pull resources").Return() - mockSyncWorker.On("Process", mock.Anything, mockRepo, mock.MatchedBy(func(syncJob provisioning.Job) bool { + mockSyncWorker.On("Process", mock.Anything, mockRepo, mock.MatchedBy(func(syncJob v0alpha1.Job) bool { return syncJob.Spec.Pull != nil && !syncJob.Spec.Pull.Incremental }), mockProgress).Return(nil) @@ -273,10 +275,10 @@ func TestDeleteWorker_ProcessWithSyncWorker(t *testing.T) { } func TestDeleteWorker_ProcessSyncWorkerError(t *testing.T) { - job := provisioning.Job{ - Spec: provisioning.JobSpec{ - Action: provisioning.JobActionDelete, - Delete: &provisioning.DeleteJobOptions{ + job := v0alpha1.Job{ + Spec: v0alpha1.JobSpec{ + Action: v0alpha1.JobActionDelete, + Delete: &v0alpha1.DeleteJobOptions{ Paths: []string{"test/path"}, }, }, @@ -356,7 +358,7 @@ func TestDeleteWorker_deleteFiles(t *testing.T) { } mockProgress := jobs.NewMockJobProgressRecorder(t) - opts := provisioning.DeleteJobOptions{ + opts := v0alpha1.DeleteJobOptions{ Ref: "main", } @@ -392,12 +394,12 @@ func TestDeleteWorker_deleteFiles(t *testing.T) { } func TestDeleteWorker_ProcessWithResourceRefs(t *testing.T) { - job := provisioning.Job{ - Spec: provisioning.JobSpec{ - Action: provisioning.JobActionDelete, - Delete: &provisioning.DeleteJobOptions{ + job := v0alpha1.Job{ + Spec: v0alpha1.JobSpec{ + Action: v0alpha1.JobActionDelete, + Delete: &v0alpha1.DeleteJobOptions{ Paths: []string{"test/path1"}, - Resources: []provisioning.ResourceRef{ + Resources: []v0alpha1.ResourceRef{ { Name: "test-dashboard", Kind: "Dashboard", @@ -480,11 +482,11 @@ func TestDeleteWorker_ProcessWithResourceRefs(t *testing.T) { } func TestDeleteWorker_ProcessResourceRefsOnly(t *testing.T) { - job := provisioning.Job{ - Spec: provisioning.JobSpec{ - Action: provisioning.JobActionDelete, - Delete: &provisioning.DeleteJobOptions{ - Resources: []provisioning.ResourceRef{ + job := v0alpha1.Job{ + Spec: v0alpha1.JobSpec{ + Action: v0alpha1.JobActionDelete, + Delete: &v0alpha1.DeleteJobOptions{ + Resources: []v0alpha1.ResourceRef{ { Name: "test-dashboard", Kind: "Dashboard", @@ -535,11 +537,11 @@ func TestDeleteWorker_ProcessResourceRefsOnly(t *testing.T) { } func TestDeleteWorker_ProcessResourceResolutionError(t *testing.T) { - job := provisioning.Job{ - Spec: provisioning.JobSpec{ - Action: provisioning.JobActionDelete, - Delete: &provisioning.DeleteJobOptions{ - Resources: []provisioning.ResourceRef{ + job := v0alpha1.Job{ + Spec: v0alpha1.JobSpec{ + Action: v0alpha1.JobActionDelete, + Delete: &v0alpha1.DeleteJobOptions{ + Resources: []v0alpha1.ResourceRef{ { Name: "nonexistent-dashboard", Kind: "Dashboard", @@ -590,7 +592,7 @@ func TestDeleteWorker_ProcessResourceResolutionError(t *testing.T) { mockProgress.On("SetMessage", mock.Anything, "pull resources").Return() mockSyncWorker := jobs.NewMockWorker(t) - mockSyncWorker.On("Process", mock.Anything, mockRepo, mock.MatchedBy(func(syncJob provisioning.Job) bool { + mockSyncWorker.On("Process", mock.Anything, mockRepo, mock.MatchedBy(func(syncJob v0alpha1.Job) bool { return syncJob.Spec.Pull != nil && !syncJob.Spec.Pull.Incremental }), mockProgress).Return(nil) @@ -600,11 +602,11 @@ func TestDeleteWorker_ProcessResourceResolutionError(t *testing.T) { } func TestDeleteWorker_ProcessResourcesFactoryError(t *testing.T) { - job := provisioning.Job{ - Spec: provisioning.JobSpec{ - Action: provisioning.JobActionDelete, - Delete: &provisioning.DeleteJobOptions{ - Resources: []provisioning.ResourceRef{ + job := v0alpha1.Job{ + Spec: v0alpha1.JobSpec{ + Action: v0alpha1.JobActionDelete, + Delete: &v0alpha1.DeleteJobOptions{ + Resources: []v0alpha1.ResourceRef{ { Name: "test-dashboard", Kind: "Dashboard", @@ -639,11 +641,11 @@ func TestDeleteWorker_ProcessResourcesFactoryError(t *testing.T) { } func TestDeleteWorker_ProcessResourceRefsNotReaderWriter(t *testing.T) { - job := provisioning.Job{ - Spec: provisioning.JobSpec{ - Action: provisioning.JobActionDelete, - Delete: &provisioning.DeleteJobOptions{ - Resources: []provisioning.ResourceRef{ + job := v0alpha1.Job{ + Spec: v0alpha1.JobSpec{ + Action: v0alpha1.JobActionDelete, + Delete: &v0alpha1.DeleteJobOptions{ + Resources: []v0alpha1.ResourceRef{ { Name: "test-dashboard", Kind: "Dashboard", @@ -675,11 +677,11 @@ func TestDeleteWorker_ProcessResourceRefsNotReaderWriter(t *testing.T) { } func TestDeleteWorker_ProcessResourceResolutionTooManyErrors(t *testing.T) { - job := provisioning.Job{ - Spec: provisioning.JobSpec{ - Action: provisioning.JobActionDelete, - Delete: &provisioning.DeleteJobOptions{ - Resources: []provisioning.ResourceRef{ + job := v0alpha1.Job{ + Spec: v0alpha1.JobSpec{ + Action: v0alpha1.JobActionDelete, + Delete: &v0alpha1.DeleteJobOptions{ + Resources: []v0alpha1.ResourceRef{ { Name: "nonexistent-dashboard", Kind: "Dashboard", @@ -728,11 +730,11 @@ func TestDeleteWorker_ProcessResourceResolutionTooManyErrors(t *testing.T) { } func TestDeleteWorker_ProcessMixedResourcesWithPartialFailure(t *testing.T) { - job := provisioning.Job{ - Spec: provisioning.JobSpec{ - Action: provisioning.JobActionDelete, - Delete: &provisioning.DeleteJobOptions{ - Resources: []provisioning.ResourceRef{ + job := v0alpha1.Job{ + Spec: v0alpha1.JobSpec{ + Action: v0alpha1.JobActionDelete, + Delete: &v0alpha1.DeleteJobOptions{ + Resources: []v0alpha1.ResourceRef{ { Name: "valid-dashboard", Kind: "Dashboard", @@ -826,13 +828,13 @@ func TestDeleteWorker_ProcessMixedResourcesWithPartialFailure(t *testing.T) { func TestDeleteWorker_ProcessWithPathDeduplication(t *testing.T) { // Test that duplicate paths from explicit paths and resource resolution are deduplicated - job := provisioning.Job{ - Spec: provisioning.JobSpec{ - Action: provisioning.JobActionDelete, - Delete: &provisioning.DeleteJobOptions{ + job := v0alpha1.Job{ + Spec: v0alpha1.JobSpec{ + Action: v0alpha1.JobActionDelete, + Delete: &v0alpha1.DeleteJobOptions{ Ref: "main", // Add ref to avoid sync worker execution Paths: []string{"dashboards/test-dashboard.json", "folders/test-folder/"}, // Explicit paths - Resources: []provisioning.ResourceRef{ + Resources: []v0alpha1.ResourceRef{ { Name: "test-dashboard", // This will resolve to "dashboards/test-dashboard.json" (duplicate) Kind: "Dashboard", @@ -983,3 +985,167 @@ func TestDeduplicatePaths(t *testing.T) { }) } } + +func TestDeleteWorker_RefURLsSetWithRef(t *testing.T) { + mockRepoWithURLs := repository.NewMockRepositoryWithURLs(t) + config := &v0alpha1.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "test-namespace", + }, + Spec: v0alpha1.RepositorySpec{ + Type: v0alpha1.GitHubRepositoryType, + }, + } + mockRepoWithURLs.On("Config").Return(config).Maybe() // Config may be called multiple times + + // Mock RefURLs method to return expected URLs + expectedRefURLs := &v0alpha1.RepositoryURLs{ + SourceURL: "https://github.com/grafana/grafana/tree/feature-branch", + CompareURL: "https://github.com/grafana/grafana/compare/main...feature-branch", + NewPullRequestURL: "https://github.com/grafana/grafana/compare/main...feature-branch?quick_pull=1&labels=grafana", + } + mockRepoWithURLs.On("RefURLs", mock.Anything, "feature-branch").Return(expectedRefURLs, nil) + + mockProgress := jobs.NewMockJobProgressRecorder(t) + mockProgress.On("SetTotal", mock.Anything, 1).Once() + mockProgress.On("StrictMaxErrors", 1).Once() + mockProgress.On("SetMessage", mock.Anything, "Deleting test.json").Once() + mockProgress.On("Record", mock.Anything, mock.Anything).Once() + mockProgress.On("TooManyErrors").Return(nil).Once() + mockProgress.On("SetRefURLs", mock.Anything, expectedRefURLs).Once() + + mockReaderWriter := repository.NewMockReaderWriter(t) + mockReaderWriter.On("Delete", mock.Anything, "test.json", "feature-branch", "Delete test.json").Return(nil) + + mockWrapFn := repository.NewMockWrapWithStageFn(t) + mockWrapFn.On("Execute", mock.Anything, mockRepoWithURLs, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, opts repository.StageOptions, fn func(repository.Repository, bool) error) error { + return fn(mockReaderWriter, true) + }) + + mockResourcesFactory := resources.NewMockRepositoryResourcesFactory(t) + + job := v0alpha1.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "test-job"}, + Spec: v0alpha1.JobSpec{ + Action: v0alpha1.JobActionDelete, + Delete: &v0alpha1.DeleteJobOptions{ + Ref: "feature-branch", + Paths: []string{"test.json"}, + }, + }, + } + + worker := NewWorker(nil, mockWrapFn.Execute, mockResourcesFactory) + err := worker.Process(context.Background(), mockRepoWithURLs, job, mockProgress) + require.NoError(t, err) + + // Verify that SetRefURLs was called with the expected RefURLs + mockProgress.AssertExpectations(t) + mockRepoWithURLs.AssertExpectations(t) +} + +func TestDeleteWorker_RefURLsNotSetWithoutRef(t *testing.T) { + mockRepoWithURLs := repository.NewMockRepositoryWithURLs(t) + config := &v0alpha1.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "test-namespace", + }, + Spec: v0alpha1.RepositorySpec{ + Type: v0alpha1.GitHubRepositoryType, + }, + } + mockRepoWithURLs.On("Config").Return(config).Maybe() // Config may be called multiple times + + mockProgress := jobs.NewMockJobProgressRecorder(t) + mockProgress.On("SetTotal", mock.Anything, 1).Once() + mockProgress.On("StrictMaxErrors", 1).Once() + mockProgress.On("SetMessage", mock.Anything, "Deleting test.json").Once() + mockProgress.On("Record", mock.Anything, mock.Anything).Once() + mockProgress.On("TooManyErrors").Return(nil).Once() + mockProgress.On("ResetResults").Once() + mockProgress.On("SetMessage", mock.Anything, "pull resources").Once() + // SetRefURLs should NOT be called since no ref is specified + + mockReaderWriter := repository.NewMockReaderWriter(t) + mockReaderWriter.On("Delete", mock.Anything, "test.json", "", "Delete test.json").Return(nil) + + mockWrapFn := repository.NewMockWrapWithStageFn(t) + mockWrapFn.On("Execute", mock.Anything, mockRepoWithURLs, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, opts repository.StageOptions, fn func(repository.Repository, bool) error) error { + return fn(mockReaderWriter, true) + }) + + mockSyncWorker := jobs.NewMockWorker(t) + mockSyncWorker.On("Process", mock.Anything, mockRepoWithURLs, mock.Anything, mockProgress).Return(nil) + + mockResourcesFactory := resources.NewMockRepositoryResourcesFactory(t) + + job := v0alpha1.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "test-job"}, + Spec: v0alpha1.JobSpec{ + Action: v0alpha1.JobActionDelete, + Delete: &v0alpha1.DeleteJobOptions{ + // No ref specified + Paths: []string{"test.json"}, + }, + }, + } + + worker := NewWorker(mockSyncWorker, mockWrapFn.Execute, mockResourcesFactory) + err := worker.Process(context.Background(), mockRepoWithURLs, job, mockProgress) + require.NoError(t, err) + + // Verify that SetRefURLs was NOT called since no ref was specified + mockProgress.AssertExpectations(t) +} + +func TestDeleteWorker_RefURLsNotSetForNonURLRepository(t *testing.T) { + mockRepo := repository.NewMockRepository(t) + config := &v0alpha1.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "test-namespace", + }, + Spec: v0alpha1.RepositorySpec{ + Type: v0alpha1.GitRepositoryType, // Regular git repo, not GitHub + }, + } + mockRepo.On("Config").Return(config).Maybe() // Config may be called multiple times + + mockProgress := jobs.NewMockJobProgressRecorder(t) + mockProgress.On("SetTotal", mock.Anything, 1).Once() + mockProgress.On("StrictMaxErrors", 1).Once() + mockProgress.On("SetMessage", mock.Anything, "Deleting test.json").Once() + mockProgress.On("Record", mock.Anything, mock.Anything).Once() + mockProgress.On("TooManyErrors").Return(nil).Once() + // SetRefURLs should NOT be called since repo doesn't support URLs + + mockReaderWriter := repository.NewMockReaderWriter(t) + mockReaderWriter.On("Delete", mock.Anything, "test.json", "feature-branch", "Delete test.json").Return(nil) + + mockWrapFn := repository.NewMockWrapWithStageFn(t) + mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, opts repository.StageOptions, fn func(repository.Repository, bool) error) error { + return fn(mockReaderWriter, true) + }) + + mockResourcesFactory := resources.NewMockRepositoryResourcesFactory(t) + + job := v0alpha1.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "test-job"}, + Spec: v0alpha1.JobSpec{ + Action: v0alpha1.JobActionDelete, + Delete: &v0alpha1.DeleteJobOptions{ + Ref: "feature-branch", + Paths: []string{"test.json"}, + }, + }, + } + + worker := NewWorker(nil, mockWrapFn.Execute, mockResourcesFactory) + err := worker.Process(context.Background(), mockRepo, job, mockProgress) + require.NoError(t, err) + + // Verify that SetRefURLs was NOT called since repo doesn't support URLs + mockProgress.AssertExpectations(t) +} diff --git a/pkg/registry/apis/provisioning/jobs/driver.go b/pkg/registry/apis/provisioning/jobs/driver.go index a348d9988b0..f64eebe3528 100644 --- a/pkg/registry/apis/provisioning/jobs/driver.go +++ b/pkg/registry/apis/provisioning/jobs/driver.go @@ -36,10 +36,6 @@ type Store interface { // 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) @@ -77,6 +73,9 @@ type jobDriver struct { // Workers process the job. // Only the first worker who supports the job will process it; the rest are ignored. workers []Worker + + // notifications channel for job create events + notifications chan struct{} } func NewJobDriver( @@ -84,6 +83,7 @@ func NewJobDriver( store Store, repoGetter RepoGetter, historicJobs History, + notifications chan struct{}, workers ...Worker, ) (*jobDriver, error) { return &jobDriver{ @@ -94,6 +94,7 @@ func NewJobDriver( repoGetter: repoGetter, historicJobs: historicJobs, workers: workers, + notifications: notifications, }, nil } @@ -120,7 +121,7 @@ func (d *jobDriver) Run(ctx context.Context) error { return ctx.Err() case <-jobTicker.C: d.processJobsUntilDoneOrError(ctx) - case <-d.store.InsertNotifications(): + case <-d.notifications: d.processJobsUntilDoneOrError(ctx) } } diff --git a/pkg/registry/apis/provisioning/jobs/export/folders.go b/pkg/registry/apis/provisioning/jobs/export/folders.go index 48a4f7839ae..794a419adc6 100644 --- a/pkg/registry/apis/provisioning/jobs/export/folders.go +++ b/pkg/registry/apis/provisioning/jobs/export/folders.go @@ -50,7 +50,10 @@ func ExportFolders(ctx context.Context, repoName string, options provisioning.Ex Resource: resources.FolderResource.Resource, Group: resources.FolderResource.Group, Path: folder.Path, - Error: err, + } + + if err != nil { + result.Error = fmt.Errorf("creating folder %s at path %s: %w", folder.ID, folder.Path, err) } if !created { diff --git a/pkg/registry/apis/provisioning/jobs/export/folders_test.go b/pkg/registry/apis/provisioning/jobs/export/folders_test.go index 2eaeeabd373..d68e6ff33bb 100644 --- a/pkg/registry/apis/provisioning/jobs/export/folders_test.go +++ b/pkg/registry/apis/provisioning/jobs/export/folders_test.go @@ -189,7 +189,7 @@ func TestExportFolders(t *testing.T) { progress.On("SetMessage", mock.Anything, "read folder tree from API server").Return() progress.On("SetMessage", mock.Anything, "write folders to repository").Return() progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Name == "folder-1-uid" && result.Action == repository.FileActionIgnored && result.Error != nil && result.Error.Error() == "didn't work" + return result.Name == "folder-1-uid" && result.Action == repository.FileActionIgnored && result.Error != nil && result.Error.Error() == "creating folder folder-1-uid at path grafana/folder-1: didn't work" })).Return() progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { return result.Name == "folder-2-uid" && result.Action == repository.FileActionCreated diff --git a/pkg/registry/apis/provisioning/jobs/export/resources.go b/pkg/registry/apis/provisioning/jobs/export/resources.go index 8b1baff08ca..e9c253fea44 100644 --- a/pkg/registry/apis/provisioning/jobs/export/resources.go +++ b/pkg/registry/apis/provisioning/jobs/export/resources.go @@ -108,7 +108,7 @@ func exportResource(ctx context.Context, meta, err := utils.MetaAccessor(item) if err != nil { result.Action = repository.FileActionIgnored - result.Error = fmt.Errorf("extract meta accessor: %w", err) + result.Error = fmt.Errorf("extracting meta accessor for resource %s: %w", result.Name, err) progress.Record(ctx, result) return nil } @@ -136,7 +136,7 @@ func exportResource(ctx context.Context, result.Action = repository.FileActionIgnored } else if err != nil { result.Action = repository.FileActionIgnored - result.Error = err + result.Error = fmt.Errorf("writing resource file for %s: %w", result.Name, err) } progress.Record(ctx, result) diff --git a/pkg/registry/apis/provisioning/jobs/export/resources_test.go b/pkg/registry/apis/provisioning/jobs/export/resources_test.go index 8be32cebed9..0aa591a4493 100644 --- a/pkg/registry/apis/provisioning/jobs/export/resources_test.go +++ b/pkg/registry/apis/provisioning/jobs/export/resources_test.go @@ -141,7 +141,7 @@ func TestExportResources_Dashboards_WithErrors(t *testing.T) { progress.On("SetMessage", mock.Anything, "start resource export").Return() progress.On("SetMessage", mock.Anything, "export dashboards").Return() progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Name == "dashboard-1" && result.Action == repository.FileActionIgnored && result.Error != nil && result.Error.Error() == "failed to export dashboard" + return result.Name == "dashboard-1" && result.Action == repository.FileActionIgnored && result.Error != nil && result.Error.Error() == "writing resource file for dashboard-1: failed to export dashboard" })).Return() progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { return result.Name == "dashboard-2" && result.Action == repository.FileActionCreated @@ -179,7 +179,7 @@ func TestExportResources_Dashboards_TooManyErrors(t *testing.T) { progress.On("SetMessage", mock.Anything, "start resource export").Return() progress.On("SetMessage", mock.Anything, "export dashboards").Return() progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Name == "dashboard-1" && result.Action == repository.FileActionIgnored && result.Error != nil && result.Error.Error() == "failed to export dashboard" + return result.Name == "dashboard-1" && result.Action == repository.FileActionIgnored && result.Error != nil && result.Error.Error() == "writing resource file for dashboard-1: failed to export dashboard" })).Return() progress.On("TooManyErrors").Return(fmt.Errorf("too many errors encountered")) } @@ -417,7 +417,7 @@ func TestExportResources_Dashboards_V2Alpha1_ClientError(t *testing.T) { return false } - if result.Error.Error() != "v2 client error" { + if result.Error.Error() != "writing resource file for v2-dashboard-error: v2 client error" { return false } @@ -516,7 +516,7 @@ func TestExportResources_Dashboards_V2beta1_ClientError(t *testing.T) { return false } - if result.Error.Error() != "v2 client error" { + if result.Error.Error() != "writing resource file for v2-dashboard-error: v2 client error" { return false } diff --git a/pkg/registry/apis/provisioning/jobs/export/worker.go b/pkg/registry/apis/provisioning/jobs/export/worker.go index 524b01dce4c..df394cfcd00 100644 --- a/pkg/registry/apis/provisioning/jobs/export/worker.go +++ b/pkg/registry/apis/provisioning/jobs/export/worker.go @@ -88,5 +88,16 @@ func (r *ExportWorker) Process(ctx context.Context, repo repository.Repository, return r.exportFn(ctx, cfg.Name, *options, clients, repositoryResources, progress) } - return r.wrapWithStageFn(ctx, repo, cloneOptions, fn) + err := r.wrapWithStageFn(ctx, repo, cloneOptions, fn) + + // Set RefURLs if the repository supports it and we have a target branch + if options.Branch != "" { + if repoWithURLs, ok := repo.(repository.RepositoryWithURLs); ok { + if refURLs, urlErr := repoWithURLs.RefURLs(ctx, options.Branch); urlErr == nil && refURLs != nil { + progress.SetRefURLs(ctx, refURLs) + } + } + } + + return err } diff --git a/pkg/registry/apis/provisioning/jobs/export/worker_test.go b/pkg/registry/apis/provisioning/jobs/export/worker_test.go index d78b0ec5b8b..36e50e25494 100644 --- a/pkg/registry/apis/provisioning/jobs/export/worker_test.go +++ b/pkg/registry/apis/provisioning/jobs/export/worker_test.go @@ -549,3 +549,181 @@ func TestExportWorker_ProcessGitRepositoryExportFnError(t *testing.T) { err := r.Process(context.Background(), mockRepo, job, mockProgress) require.EqualError(t, err, "export failed") } + +func TestExportWorker_RefURLsSetWithBranch(t *testing.T) { + job := v0alpha1.Job{ + Spec: v0alpha1.JobSpec{ + Action: v0alpha1.JobActionPush, + Push: &v0alpha1.ExportJobOptions{ + Branch: "feature-branch", + Message: "test commit", + }, + }, + } + + // Create a repository that implements both Repository and RepositoryWithURLs + mockRepoWithURLs := repository.NewMockRepositoryWithURLs(t) + + mockRepoWithURLs.On("Config").Return(&v0alpha1.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "test-namespace", + }, + Spec: v0alpha1.RepositorySpec{ + Type: v0alpha1.GitHubRepositoryType, + Workflows: []v0alpha1.Workflow{v0alpha1.WriteWorkflow, v0alpha1.BranchWorkflow}, + }, + }) + + // Mock RefURLs method to return expected URLs + expectedRefURLs := &v0alpha1.RepositoryURLs{ + SourceURL: "https://github.com/grafana/grafana/tree/feature-branch", + CompareURL: "https://github.com/grafana/grafana/compare/main...feature-branch", + NewPullRequestURL: "https://github.com/grafana/grafana/compare/main...feature-branch?quick_pull=1&labels=grafana", + } + mockRepoWithURLs.On("RefURLs", mock.Anything, "feature-branch").Return(expectedRefURLs, nil) + + // Mock progress recorder to expect SetRefURLs call + mockProgress := jobs.NewMockJobProgressRecorder(t) + mockProgress.On("SetRefURLs", mock.Anything, expectedRefURLs).Once() + + // Mock other dependencies + mockClients := resources.NewMockClientFactory(t) + mockResourceClients := resources.NewMockResourceClients(t) + mockClients.On("Clients", mock.Anything, "test-namespace").Return(mockResourceClients, nil) + + mockRepoResources := resources.NewMockRepositoryResourcesFactory(t) + mockRepoResourcesClient := resources.NewMockRepositoryResources(t) + mockRepoResources.On("Client", mock.Anything, mock.Anything).Return(mockRepoResourcesClient, nil) + + mockExportFn := NewMockExportFn(t) + mockExportFn.On("Execute", mock.Anything, "test-repo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + + // Mock the ReaderWriter interface that the export function expects + mockReaderWriter := repository.NewMockReaderWriter(t) + + mockStageFn := NewMockWrapWithStageFn(t) + mockStageFn.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error { + // The staging function needs to call the inner function with a ReaderWriter + return fn(mockReaderWriter, true) + }) + + r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute) + err := r.Process(context.Background(), mockRepoWithURLs, job, mockProgress) + require.NoError(t, err) + + // Verify that SetRefURLs was called with the expected RefURLs + mockProgress.AssertExpectations(t) + mockRepoWithURLs.AssertExpectations(t) +} + +func TestExportWorker_RefURLsNotSetWithoutBranch(t *testing.T) { + job := v0alpha1.Job{ + Spec: v0alpha1.JobSpec{ + Action: v0alpha1.JobActionPush, + Push: &v0alpha1.ExportJobOptions{ + Message: "test commit", + // No branch specified + }, + }, + } + + mockRepoWithURLs := repository.NewMockRepositoryWithURLs(t) + + mockRepoWithURLs.On("Config").Return(&v0alpha1.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "test-namespace", + }, + Spec: v0alpha1.RepositorySpec{ + Type: v0alpha1.GitHubRepositoryType, + Workflows: []v0alpha1.Workflow{v0alpha1.WriteWorkflow}, + }, + }) + + // Mock progress recorder - SetRefURLs should NOT be called + mockProgress := jobs.NewMockJobProgressRecorder(t) + // Explicitly NOT expecting SetRefURLs call + + // Mock other dependencies + mockClients := resources.NewMockClientFactory(t) + mockResourceClients := resources.NewMockResourceClients(t) + mockClients.On("Clients", mock.Anything, "test-namespace").Return(mockResourceClients, nil) + + mockRepoResources := resources.NewMockRepositoryResourcesFactory(t) + mockRepoResourcesClient := resources.NewMockRepositoryResources(t) + mockRepoResources.On("Client", mock.Anything, mock.Anything).Return(mockRepoResourcesClient, nil) + + mockExportFn := NewMockExportFn(t) + mockExportFn.On("Execute", mock.Anything, "test-repo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + + mockReaderWriter := repository.NewMockReaderWriter(t) + + mockStageFn := NewMockWrapWithStageFn(t) + mockStageFn.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error { + return fn(mockReaderWriter, true) + }) + + r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute) + err := r.Process(context.Background(), mockRepoWithURLs, job, mockProgress) + require.NoError(t, err) + + // Verify that SetRefURLs was NOT called since no branch was specified + mockProgress.AssertExpectations(t) +} + +func TestExportWorker_RefURLsNotSetForNonURLRepository(t *testing.T) { + job := v0alpha1.Job{ + Spec: v0alpha1.JobSpec{ + Action: v0alpha1.JobActionPush, + Push: &v0alpha1.ExportJobOptions{ + Branch: "feature-branch", + Message: "test commit", + }, + }, + } + + // Use a regular Repository that doesn't implement RepositoryWithURLs + mockRepo := repository.NewMockRepository(t) + + mockRepo.On("Config").Return(&v0alpha1.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "test-namespace", + }, + Spec: v0alpha1.RepositorySpec{ + Type: v0alpha1.GitRepositoryType, // Regular git repo, not GitHub + Workflows: []v0alpha1.Workflow{v0alpha1.WriteWorkflow, v0alpha1.BranchWorkflow}, + }, + }) + + // Mock progress recorder - SetRefURLs should NOT be called + mockProgress := jobs.NewMockJobProgressRecorder(t) + // Explicitly NOT expecting SetRefURLs call + + // Mock other dependencies + mockClients := resources.NewMockClientFactory(t) + mockResourceClients := resources.NewMockResourceClients(t) + mockClients.On("Clients", mock.Anything, "test-namespace").Return(mockResourceClients, nil) + + mockRepoResources := resources.NewMockRepositoryResourcesFactory(t) + mockRepoResourcesClient := resources.NewMockRepositoryResources(t) + mockRepoResources.On("Client", mock.Anything, mock.Anything).Return(mockRepoResourcesClient, nil) + + mockExportFn := NewMockExportFn(t) + mockExportFn.On("Execute", mock.Anything, "test-repo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + + mockReaderWriter := repository.NewMockReaderWriter(t) + + mockStageFn := NewMockWrapWithStageFn(t) + mockStageFn.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error { + return fn(mockReaderWriter, true) + }) + + r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute) + err := r.Process(context.Background(), mockRepo, job, mockProgress) + require.NoError(t, err) + + // Verify that SetRefURLs was NOT called since repo doesn't support URLs + mockProgress.AssertExpectations(t) +} diff --git a/pkg/registry/apis/provisioning/jobs/history.go b/pkg/registry/apis/provisioning/jobs/history.go index 0a7897319f9..95fc4b73b3d 100644 --- a/pkg/registry/apis/provisioning/jobs/history.go +++ b/pkg/registry/apis/provisioning/jobs/history.go @@ -3,9 +3,13 @@ package jobs import ( "context" "fmt" - "sync" 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/apiserver/pkg/endpoints/request" + "k8s.io/apiserver/pkg/registry/rest" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" ) @@ -24,63 +28,100 @@ type History interface { GetJob(ctx context.Context, namespace, repo, uid string) (*provisioning.Job, error) } -// NewJobHistoryCache creates a History client -func NewJobHistoryCache() History { - history := &recentHistory{ - maxJobs: 9, // 10-1 - repoHistory: make(map[string]provisioning.JobList), +// NewStorageBackedHistory creates a History client backed by unified storage +// This should be replaced by loki when running in cloud +func NewStorageBackedHistory(store rest.Storage) (History, error) { + var ok bool + history := &storageBackedHistory{} + history.creator, ok = store.(rest.Creater) + if !ok { + return nil, fmt.Errorf("storage does not implement rest.Creater") } - return history + + history.lister, ok = store.(rest.Lister) + if !ok { + return nil, fmt.Errorf("storage does not implement rest.Lister") + } + + return history, nil } -type recentHistory struct { - maxJobs int - repoMu sync.Mutex - repoHistory map[string]provisioning.JobList +type storageBackedHistory struct { + creator rest.Creater + lister rest.Lister } // Write implements History. -func (h *recentHistory) WriteJob(ctx context.Context, job *provisioning.Job) error { - h.repoMu.Lock() - defer h.repoMu.Unlock() - - copy := job.DeepCopy() - delete(copy.Labels, LabelJobClaim) - - items := []provisioning.Job{*copy} - key := fmt.Sprintf("%s/%s", job.Namespace, job.Spec.Repository) - v, ok := h.repoHistory[key] - if ok { - max := min(len(v.Items), h.maxJobs) - items = append(items, v.Items[0:max]...) +func (s *storageBackedHistory) WriteJob(ctx context.Context, job *provisioning.Job) error { + if job.UID == "" { + return fmt.Errorf("missing UID in job '%s'", job.GetName()) } - h.repoHistory[key] = provisioning.JobList{Items: items} - return nil + if job.Labels == nil { + job.Labels = make(map[string]string) + } + job.Labels[LabelRepository] = job.Spec.Repository + job.Labels[LabelJobOriginalUID] = string(job.UID) + + // Generate a new name based on the input job + job.GenerateName = job.Name + "-" + job.Name = "" + // We also reset the UID as this is not the same object. + job.UID = "" + // We aren't allowed to write with ResourceVersion set. + job.ResourceVersion = "" + + _, err := s.creator.Create(ctx, &provisioning.HistoricJob{ + ObjectMeta: job.ObjectMeta, + Spec: job.Spec, + Status: job.Status, + }, nil, &metav1.CreateOptions{}) + return err } -// Recent implements History. -func (h *recentHistory) RecentJobs(ctx context.Context, namespace, repo string) (*provisioning.JobList, error) { - h.repoMu.Lock() - defer h.repoMu.Unlock() - - rsp := &provisioning.JobList{} - key := fmt.Sprintf("%s/%s", namespace, repo) - v, ok := h.repoHistory[key] - if ok { - rsp.Items = v.Items - } - return rsp, nil -} - -func (h *recentHistory) GetJob(ctx context.Context, namespace, repo, job string) (*provisioning.Job, error) { - jobs, err := h.RecentJobs(ctx, namespace, repo) +func (s *storageBackedHistory) getJobs(ctx context.Context, namespace string, labels labels.Set) (*provisioning.JobList, error) { + ctx = request.WithNamespace(ctx, namespace) + obj, err := s.lister.List(ctx, &internalversion.ListOptions{ + LabelSelector: labels.AsSelector(), + }) if err != nil { return nil, err } - for _, item := range jobs.Items { - if string(item.UID) == job { - return &item, nil - } + + historic, ok := obj.(*provisioning.HistoricJobList) + if !ok { + return nil, fmt.Errorf("expected HistoricJobList, found %T", historic) + } + + jobs := &provisioning.JobList{ + ListMeta: historic.ListMeta, + } + for _, job := range historic.Items { + jobs.Items = append(jobs.Items, provisioning.Job{ + ObjectMeta: job.ObjectMeta, + Spec: job.Spec, + Status: job.Status, + }) + } + return jobs, nil +} + +// Recent implements History. +func (s *storageBackedHistory) RecentJobs(ctx context.Context, namespace, repo string) (*provisioning.JobList, error) { + return s.getJobs(ctx, namespace, labels.Set{ + LabelRepository: repo, + }) +} + +// GetJob implements History. +func (s *storageBackedHistory) GetJob(ctx context.Context, namespace, repo, job string) (*provisioning.Job, error) { + jobs, err := s.getJobs(ctx, namespace, labels.Set{ + LabelJobOriginalUID: job, + }) + if err != nil { + return nil, err + } + if len(jobs.Items) == 1 { + return &jobs.Items[0], nil } return nil, apierrors.NewNotFound(provisioning.JobResourceInfo.GroupResource(), job) } diff --git a/pkg/registry/apis/provisioning/jobs/job_progress_recorder_mock.go b/pkg/registry/apis/provisioning/jobs/job_progress_recorder_mock.go index be0b6d3d9b0..3cefca9e5af 100644 --- a/pkg/registry/apis/provisioning/jobs/job_progress_recorder_mock.go +++ b/pkg/registry/apis/provisioning/jobs/job_progress_recorder_mock.go @@ -203,6 +203,40 @@ func (_c *MockJobProgressRecorder_SetMessage_Call) RunAndReturn(run func(context return _c } +// SetRefURLs provides a mock function with given fields: ctx, refURLs +func (_m *MockJobProgressRecorder) SetRefURLs(ctx context.Context, refURLs *v0alpha1.RepositoryURLs) { + _m.Called(ctx, refURLs) +} + +// MockJobProgressRecorder_SetRefURLs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetRefURLs' +type MockJobProgressRecorder_SetRefURLs_Call struct { + *mock.Call +} + +// SetRefURLs is a helper method to define mock.On call +// - ctx context.Context +// - refURLs *v0alpha1.RepositoryURLs +func (_e *MockJobProgressRecorder_Expecter) SetRefURLs(ctx interface{}, refURLs interface{}) *MockJobProgressRecorder_SetRefURLs_Call { + return &MockJobProgressRecorder_SetRefURLs_Call{Call: _e.mock.On("SetRefURLs", ctx, refURLs)} +} + +func (_c *MockJobProgressRecorder_SetRefURLs_Call) Run(run func(ctx context.Context, refURLs *v0alpha1.RepositoryURLs)) *MockJobProgressRecorder_SetRefURLs_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*v0alpha1.RepositoryURLs)) + }) + return _c +} + +func (_c *MockJobProgressRecorder_SetRefURLs_Call) Return() *MockJobProgressRecorder_SetRefURLs_Call { + _c.Call.Return() + return _c +} + +func (_c *MockJobProgressRecorder_SetRefURLs_Call) RunAndReturn(run func(context.Context, *v0alpha1.RepositoryURLs)) *MockJobProgressRecorder_SetRefURLs_Call { + _c.Run(run) + return _c +} + // SetTotal provides a mock function with given fields: ctx, total func (_m *MockJobProgressRecorder) SetTotal(ctx context.Context, total int) { _m.Called(ctx, total) diff --git a/pkg/registry/apis/provisioning/jobs/migrate/clean.go b/pkg/registry/apis/provisioning/jobs/migrate/clean.go index 8d1b951fa11..e5cac782ebd 100644 --- a/pkg/registry/apis/provisioning/jobs/migrate/clean.go +++ b/pkg/registry/apis/provisioning/jobs/migrate/clean.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/grafana/grafana/pkg/apimachinery/utils" "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" @@ -45,8 +46,24 @@ func (c *namespaceCleaner) Clean(ctx context.Context, namespace string, progress Action: repository.FileActionDeleted, } + // Skip provisioned resources - only delete unprovisioned (unmanaged) resources + meta, err := utils.MetaAccessor(item) + if err != nil { + result.Error = fmt.Errorf("extracting meta accessor for resource %s: %w", result.Name, err) + progress.Record(ctx, result) + return nil // Continue with next resource + } + + manager, _ := meta.GetManagerProperties() + // Skip if resource is managed by any provisioning system + if manager.Identity != "" { + result.Action = repository.FileActionIgnored + progress.Record(ctx, result) + return nil // Skip this resource + } + if err := client.Delete(ctx, item.GetName(), metav1.DeleteOptions{}); err != nil { - result.Error = err + result.Error = fmt.Errorf("deleting resource %s/%s %s: %w", result.Group, result.Resource, result.Name, err) progress.Record(ctx, result) return fmt.Errorf("delete resource: %w", err) } diff --git a/pkg/registry/apis/provisioning/jobs/migrate/clean_test.go b/pkg/registry/apis/provisioning/jobs/migrate/clean_test.go index 72eff3eb090..109dd9f6e45 100644 --- a/pkg/registry/apis/provisioning/jobs/migrate/clean_test.go +++ b/pkg/registry/apis/provisioning/jobs/migrate/clean_test.go @@ -127,7 +127,7 @@ func TestNamespaceCleaner_Clean(t *testing.T) { return result.Action == repository.FileActionDeleted && result.Name == "test-folder" && result.Error != nil && - result.Error.Error() == "delete failed" + result.Error.Error() == "deleting resource folder.grafana.app/Folder test-folder: delete failed" })).Return() err := cleaner.Clean(context.Background(), "test-namespace", progress) @@ -139,8 +139,8 @@ func TestNamespaceCleaner_Clean(t *testing.T) { progress.AssertExpectations(t) }) - t.Run("should successfully clean namespace", func(t *testing.T) { - // Create a mock dynamic client that returns a list with multiple items + t.Run("should only delete unprovisioned resources", func(t *testing.T) { + // Create a mock dynamic client that returns a list with mixed provisioned and unprovisioned items mockDynamicClient := &mockDynamicInterface{ items: []unstructured.Unstructured{ { @@ -148,7 +148,8 @@ func TestNamespaceCleaner_Clean(t *testing.T) { "apiVersion": "folder.grafana.app/v1alpha1", "kind": "Folder", "metadata": map[string]interface{}{ - "name": "folder-1", + "name": "unprovisioned-folder", + // No manager annotations - this is unprovisioned }, }, }, @@ -157,7 +158,21 @@ func TestNamespaceCleaner_Clean(t *testing.T) { "apiVersion": "dashboard.grafana.app/v1alpha1", "kind": "Dashboard", "metadata": map[string]interface{}{ - "name": "dashboard-1", + "name": "provisioned-dashboard", + "annotations": map[string]interface{}{ + "grafana.app/managerKind": "repo", + "grafana.app/managerId": "test-repo", + }, + }, + }, + }, + { + Object: map[string]interface{}{ + "apiVersion": "dashboard.grafana.app/v1alpha1", + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": "unprovisioned-dashboard", + // No manager annotations - this is unprovisioned }, }, }, @@ -177,15 +192,88 @@ func TestNamespaceCleaner_Clean(t *testing.T) { progress.On("SetMessage", mock.Anything, "remove unprovisioned folders").Return() progress.On("SetMessage", mock.Anything, "remove unprovisioned dashboards").Return() - // Expect two successful deletions + // Expect only unprovisioned resources to be deleted (2 deletions) progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { return result.Action == repository.FileActionDeleted && - result.Name == "dashboard-1" && + result.Name == "unprovisioned-folder" && result.Error == nil })).Return() progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { return result.Action == repository.FileActionDeleted && - result.Name == "folder-1" && + result.Name == "unprovisioned-dashboard" && + result.Error == nil + })).Return() + + // Expect provisioned resource to be ignored (1 ignore) + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Action == repository.FileActionIgnored && + result.Name == "provisioned-dashboard" && + result.Error == nil + })).Return() + + err := cleaner.Clean(context.Background(), "test-namespace", progress) + require.NoError(t, err) + + mockClientFactory.AssertExpectations(t) + clients.AssertExpectations(t) + progress.AssertExpectations(t) + }) + + t.Run("should skip all provisioned resources", func(t *testing.T) { + // Create a mock dynamic client with only provisioned resources + mockDynamicClient := &mockDynamicInterface{ + items: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "apiVersion": "dashboard.grafana.app/v1alpha1", + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": "repo-managed-dashboard", + "annotations": map[string]interface{}{ + "grafana.app/managerKind": "repo", + "grafana.app/managerId": "my-repo", + }, + }, + }, + }, + { + Object: map[string]interface{}{ + "apiVersion": "folder.grafana.app/v1alpha1", + "kind": "Folder", + "metadata": map[string]interface{}{ + "name": "file-provisioned-folder", + "annotations": map[string]interface{}{ + "grafana.app/managerKind": "classic-file-provisioning", + "grafana.app/managerId": "file-provisioner", + }, + }, + }, + }, + }, + } + + clients := &mockClients{} + clients.On("ForResource", mock.Anything). + Return(mockDynamicClient, schema.GroupVersionKind{}, nil) + + mockClientFactory := resources.NewMockClientFactory(t) + mockClientFactory.On("Clients", mock.Anything, "test-namespace"). + Return(clients, nil) + + cleaner := NewNamespaceCleaner(mockClientFactory) + progress := jobs.NewMockJobProgressRecorder(t) + progress.On("SetMessage", mock.Anything, "remove unprovisioned folders").Return() + progress.On("SetMessage", mock.Anything, "remove unprovisioned dashboards").Return() + + // Expect both resources to be ignored (no deletions) + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Action == repository.FileActionIgnored && + result.Name == "repo-managed-dashboard" && + result.Error == nil + })).Return() + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Action == repository.FileActionIgnored && + result.Name == "file-provisioned-folder" && result.Error == nil })).Return() diff --git a/pkg/registry/apis/provisioning/jobs/migrate/legacy_resources.go b/pkg/registry/apis/provisioning/jobs/migrate/legacy_resources.go index 03711c485d6..0d50d70aaad 100644 --- a/pkg/registry/apis/provisioning/jobs/migrate/legacy_resources.go +++ b/pkg/registry/apis/provisioning/jobs/migrate/legacy_resources.go @@ -208,10 +208,13 @@ func (r *legacyResourceResourceMigrator) Write(ctx context.Context, key *resourc Resource: r.kind.Resource, Group: r.kind.Group, Action: repository.FileActionCreated, - Error: err, Path: fileName, } + if err != nil { + result.Error = fmt.Errorf("writing resource %s/%s %s to file %s: %w", r.kind.Group, r.kind.Resource, parsed.Meta.GetName(), fileName, err) + } + r.progress.Record(ctx, result) if err := r.progress.TooManyErrors(); err != nil { return err diff --git a/pkg/registry/apis/provisioning/jobs/migrate/legacy_resources_test.go b/pkg/registry/apis/provisioning/jobs/migrate/legacy_resources_test.go index 88e60341f6b..15c0339775c 100644 --- a/pkg/registry/apis/provisioning/jobs/migrate/legacy_resources_test.go +++ b/pkg/registry/apis/provisioning/jobs/migrate/legacy_resources_test.go @@ -424,7 +424,7 @@ func TestLegacyResourceResourceMigrator_Write(t *testing.T) { return result.Action == repository.FileActionCreated && result.Name == "test" && result.Error != nil && - result.Error.Error() == "create file error" + result.Error.Error() == "writing resource test.grafana.app/tests test to file : create file error" })).Return() progress.On("TooManyErrors").Return(nil) diff --git a/pkg/registry/apis/provisioning/jobs/migrate/unifiedstorage.go b/pkg/registry/apis/provisioning/jobs/migrate/unifiedstorage.go index aa6ce0b596d..a8bb3daaafb 100644 --- a/pkg/registry/apis/provisioning/jobs/migrate/unifiedstorage.go +++ b/pkg/registry/apis/provisioning/jobs/migrate/unifiedstorage.go @@ -32,6 +32,24 @@ func NewUnifiedStorageMigrator( func (m *UnifiedStorageMigrator) Migrate(ctx context.Context, repo repository.ReaderWriter, options provisioning.MigrateJobOptions, progress jobs.JobProgressRecorder) error { namespace := repo.Config().GetNamespace() + + // For folder-type repositories, only run sync (skip export and cleaner) + if repo.Config().Spec.Sync.Target == provisioning.SyncTargetTypeFolder { + progress.SetMessage(ctx, "pull resources") + syncJob := provisioning.Job{ + Spec: provisioning.JobSpec{ + Pull: &provisioning.SyncJobOptions{ + Incremental: false, + }, + }, + } + if err := m.syncWorker.Process(ctx, repo, syncJob, progress); err != nil { + return fmt.Errorf("pull resources: %w", err) + } + return nil + } + + // For instance-type repositories, run the full workflow: export -> sync -> clean progress.SetMessage(ctx, "export resources") progress.StrictMaxErrors(1) // strict as we want the entire instance to be managed @@ -48,8 +66,8 @@ func (m *UnifiedStorageMigrator) Migrate(ctx context.Context, repo repository.Re // Reset the results after the export as pull will operate on the same resources progress.ResetResults() - progress.SetMessage(ctx, "pull resources") + progress.SetMessage(ctx, "pull resources") syncJob := provisioning.Job{ Spec: provisioning.JobSpec{ Pull: &provisioning.SyncJobOptions{ @@ -57,7 +75,6 @@ func (m *UnifiedStorageMigrator) Migrate(ctx context.Context, repo repository.Re }, }, } - if err := m.syncWorker.Process(ctx, repo, syncJob, progress); err != nil { return fmt.Errorf("pull resources: %w", err) } diff --git a/pkg/registry/apis/provisioning/jobs/migrate/unifiedstorage_test.go b/pkg/registry/apis/provisioning/jobs/migrate/unifiedstorage_test.go index 5bc028f138d..9e066ef26a8 100644 --- a/pkg/registry/apis/provisioning/jobs/migrate/unifiedstorage_test.go +++ b/pkg/registry/apis/provisioning/jobs/migrate/unifiedstorage_test.go @@ -28,6 +28,11 @@ func TestUnifiedStorageMigrator_Migrate(t *testing.T) { Name: "test-repo", Namespace: "test-namespace", }, + Spec: provisioning.RepositorySpec{ + Sync: provisioning.SyncOptions{ + Target: provisioning.SyncTargetTypeInstance, + }, + }, }) pr.On("SetMessage", mock.Anything, "export resources").Return() pr.On("StrictMaxErrors", 1).Return() @@ -45,6 +50,11 @@ func TestUnifiedStorageMigrator_Migrate(t *testing.T) { Name: "test-repo", Namespace: "test-namespace", }, + Spec: provisioning.RepositorySpec{ + Sync: provisioning.SyncOptions{ + Target: provisioning.SyncTargetTypeInstance, + }, + }, }) pr.On("SetMessage", mock.Anything, "export resources").Return() pr.On("StrictMaxErrors", 1).Return() @@ -67,6 +77,11 @@ func TestUnifiedStorageMigrator_Migrate(t *testing.T) { Name: "test-repo", Namespace: "test-namespace", }, + Spec: provisioning.RepositorySpec{ + Sync: provisioning.SyncOptions{ + Target: provisioning.SyncTargetTypeInstance, + }, + }, }) pr.On("SetMessage", mock.Anything, "export resources").Return() pr.On("StrictMaxErrors", 1).Return() @@ -93,6 +108,11 @@ func TestUnifiedStorageMigrator_Migrate(t *testing.T) { Name: "test-repo", Namespace: "test-namespace", }, + Spec: provisioning.RepositorySpec{ + Sync: provisioning.SyncOptions{ + Target: provisioning.SyncTargetTypeInstance, + }, + }, }) pr.On("SetMessage", mock.Anything, "export resources").Return() pr.On("StrictMaxErrors", 1).Return() @@ -113,6 +133,144 @@ func TestUnifiedStorageMigrator_Migrate(t *testing.T) { }, expectedError: "", }, + { + name: "should only run sync for folder-type repositories", + setupMocks: func(nc *MockNamespaceCleaner, ew *jobs.MockWorker, sw *jobs.MockWorker, pr *jobs.MockJobProgressRecorder, rw *repository.MockRepository) { + rw.On("Config").Return(&provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "test-namespace", + }, + Spec: provisioning.RepositorySpec{ + Sync: provisioning.SyncOptions{ + Target: provisioning.SyncTargetTypeFolder, + }, + }, + }) + // Export should be skipped - no export-related mocks + // Cleaner should also be skipped - no cleaner-related mocks + // Only sync job should run + pr.On("SetMessage", mock.Anything, "pull resources").Return() + sw.On("Process", mock.Anything, rw, mock.MatchedBy(func(job provisioning.Job) bool { + return job.Spec.Pull != nil && !job.Spec.Pull.Incremental + }), pr).Return(nil) + }, + expectedError: "", + }, + { + name: "should fail when sync job fails for folder-type repositories", + setupMocks: func(nc *MockNamespaceCleaner, ew *jobs.MockWorker, sw *jobs.MockWorker, pr *jobs.MockJobProgressRecorder, rw *repository.MockRepository) { + rw.On("Config").Return(&provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "test-namespace", + }, + Spec: provisioning.RepositorySpec{ + Sync: provisioning.SyncOptions{ + Target: provisioning.SyncTargetTypeFolder, + }, + }, + }) + // Only sync job should run and fail + pr.On("SetMessage", mock.Anything, "pull resources").Return() + sw.On("Process", mock.Anything, rw, mock.MatchedBy(func(job provisioning.Job) bool { + return job.Spec.Pull != nil && !job.Spec.Pull.Incremental + }), pr).Return(errors.New("folder sync failed")) + }, + expectedError: "pull resources: folder sync failed", + }, + { + name: "should run complete workflow for instance-type repositories", + setupMocks: func(nc *MockNamespaceCleaner, ew *jobs.MockWorker, sw *jobs.MockWorker, pr *jobs.MockJobProgressRecorder, rw *repository.MockRepository) { + rw.On("Config").Return(&provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "test-namespace", + }, + Spec: provisioning.RepositorySpec{ + Sync: provisioning.SyncOptions{ + Target: provisioning.SyncTargetTypeInstance, + }, + }, + }) + // Export should run for instance repositories + pr.On("SetMessage", mock.Anything, "export resources").Return() + pr.On("StrictMaxErrors", 1).Return() + ew.On("Process", mock.Anything, rw, mock.MatchedBy(func(job provisioning.Job) bool { + return job.Spec.Push != nil + }), pr).Return(nil) + pr.On("ResetResults").Return() + + // Sync job and cleanup should also run + pr.On("SetMessage", mock.Anything, "pull resources").Return() + sw.On("Process", mock.Anything, rw, mock.MatchedBy(func(job provisioning.Job) bool { + return job.Spec.Pull != nil && !job.Spec.Pull.Incremental + }), pr).Return(nil) + pr.On("SetMessage", mock.Anything, "clean namespace").Return() + nc.On("Clean", mock.Anything, "test-namespace", pr).Return(nil) + }, + expectedError: "", + }, + { + name: "should handle empty target type as instance", + setupMocks: func(nc *MockNamespaceCleaner, ew *jobs.MockWorker, sw *jobs.MockWorker, pr *jobs.MockJobProgressRecorder, rw *repository.MockRepository) { + rw.On("Config").Return(&provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "test-namespace", + }, + Spec: provisioning.RepositorySpec{ + Sync: provisioning.SyncOptions{ + Target: "", // Empty target should default to instance behavior + }, + }, + }) + // Should run full workflow like instance type + pr.On("SetMessage", mock.Anything, "export resources").Return() + pr.On("StrictMaxErrors", 1).Return() + ew.On("Process", mock.Anything, rw, mock.MatchedBy(func(job provisioning.Job) bool { + return job.Spec.Push != nil + }), pr).Return(nil) + pr.On("ResetResults").Return() + pr.On("SetMessage", mock.Anything, "pull resources").Return() + sw.On("Process", mock.Anything, rw, mock.MatchedBy(func(job provisioning.Job) bool { + return job.Spec.Pull != nil && !job.Spec.Pull.Incremental + }), pr).Return(nil) + pr.On("SetMessage", mock.Anything, "clean namespace").Return() + nc.On("Clean", mock.Anything, "test-namespace", pr).Return(nil) + }, + expectedError: "", + }, + { + name: "should pass migrate options to export job", + setupMocks: func(nc *MockNamespaceCleaner, ew *jobs.MockWorker, sw *jobs.MockWorker, pr *jobs.MockJobProgressRecorder, rw *repository.MockRepository) { + rw.On("Config").Return(&provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "test-namespace", + }, + Spec: provisioning.RepositorySpec{ + Sync: provisioning.SyncOptions{ + Target: provisioning.SyncTargetTypeInstance, + }, + }, + }) + pr.On("SetMessage", mock.Anything, "export resources").Return() + pr.On("StrictMaxErrors", 1).Return() + // Verify that the export job receives the migrate message + ew.On("Process", mock.Anything, rw, mock.MatchedBy(func(job provisioning.Job) bool { + return job.Spec.Push != nil && job.Spec.Push.Message == "test migration message" + }), pr).Return(nil) + pr.On("ResetResults").Return() + pr.On("SetMessage", mock.Anything, "pull resources").Return() + sw.On("Process", mock.Anything, rw, mock.MatchedBy(func(job provisioning.Job) bool { + return job.Spec.Pull != nil && !job.Spec.Pull.Incremental + }), pr).Return(nil) + pr.On("SetMessage", mock.Anything, "clean namespace").Return() + nc.On("Clean", mock.Anything, "test-namespace", pr).Return(nil) + }, + expectedError: "", + }, } for _, tt := range tests { @@ -129,7 +287,14 @@ func TestUnifiedStorageMigrator_Migrate(t *testing.T) { migrator := NewUnifiedStorageMigrator(mockNamespaceCleaner, exportWorker, syncWorker) - err := migrator.Migrate(context.Background(), readerWriter, provisioning.MigrateJobOptions{}, progressRecorder) + var migrateOptions provisioning.MigrateJobOptions + if tt.name == "should pass migrate options to export job" { + migrateOptions = provisioning.MigrateJobOptions{ + Message: "test migration message", + } + } + + err := migrator.Migrate(context.Background(), readerWriter, migrateOptions, progressRecorder) if tt.expectedError != "" { require.Error(t, err) diff --git a/pkg/registry/apis/provisioning/jobs/migrate/worker.go b/pkg/registry/apis/provisioning/jobs/migrate/worker.go index 1293f18afa4..9f441c8e311 100644 --- a/pkg/registry/apis/provisioning/jobs/migrate/worker.go +++ b/pkg/registry/apis/provisioning/jobs/migrate/worker.go @@ -55,6 +55,13 @@ func (w *MigrationWorker) Process(ctx context.Context, repo repository.Repositor } } + // Block migrate for legacy resources if repository type is folder + if repo.Config().Spec.Sync.Target == provisioning.SyncTargetTypeFolder { + if dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, w.storageStatus) { + return errors.New("migration of legacy resources is not supported for folder-type repositories") + } + } + if dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, w.storageStatus) { return w.legacyMigrator.Migrate(ctx, rw, *options, progress) } diff --git a/pkg/registry/apis/provisioning/jobs/migrate/worker_test.go b/pkg/registry/apis/provisioning/jobs/migrate/worker_test.go index 3993b422492..e7f8a7f3dad 100644 --- a/pkg/registry/apis/provisioning/jobs/migrate/worker_test.go +++ b/pkg/registry/apis/provisioning/jobs/migrate/worker_test.go @@ -114,6 +114,7 @@ func TestMigrationWorker_Process(t *testing.T) { tests := []struct { name string setupMocks func(*MockMigrator, *MockMigrator, *dualwrite.MockService, *jobs.MockJobProgressRecorder) + setupRepo func(*repository.MockRepository) job provisioning.Job expectedError string isLegacyActive bool @@ -128,6 +129,9 @@ func TestMigrationWorker_Process(t *testing.T) { }, setupMocks: func(lm *MockMigrator, um *MockMigrator, ds *dualwrite.MockService, pr *jobs.MockJobProgressRecorder) { }, + setupRepo: func(repo *repository.MockRepository) { + // No Config() call expected since we fail before that + }, expectedError: "missing migrate settings", }, { @@ -144,6 +148,15 @@ func TestMigrationWorker_Process(t *testing.T) { ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(false, nil) lm.On("Migrate", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) }, + setupRepo: func(repo *repository.MockRepository) { + repo.On("Config").Return(&provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Sync: provisioning.SyncOptions{ + Target: provisioning.SyncTargetTypeInstance, + }, + }, + }) + }, }, { name: "should use unified storage migrator when legacy storage is not active", @@ -159,6 +172,15 @@ func TestMigrationWorker_Process(t *testing.T) { ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil) um.On("Migrate", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) }, + setupRepo: func(repo *repository.MockRepository) { + repo.On("Config").Return(&provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Sync: provisioning.SyncOptions{ + Target: provisioning.SyncTargetTypeInstance, + }, + }, + }) + }, }, { name: "should propagate migrator errors", @@ -174,8 +196,92 @@ func TestMigrationWorker_Process(t *testing.T) { ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(false, nil) lm.On("Migrate", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(errors.New("migration failed")) }, + setupRepo: func(repo *repository.MockRepository) { + repo.On("Config").Return(&provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Sync: provisioning.SyncOptions{ + Target: provisioning.SyncTargetTypeInstance, + }, + }, + }) + }, expectedError: "migration failed", }, + { + name: "should block migration of legacy resources for folder-type repositories", + job: provisioning.Job{ + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMigrate, + Migrate: &provisioning.MigrateJobOptions{}, + }, + }, + isLegacyActive: true, + setupMocks: func(lm *MockMigrator, um *MockMigrator, ds *dualwrite.MockService, pr *jobs.MockJobProgressRecorder) { + pr.On("SetTotal", mock.Anything, 10).Return() + ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(false, nil) + // legacyMigrator should not be called as we block before reaching it + }, + setupRepo: func(repo *repository.MockRepository) { + repo.On("Config").Return(&provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Sync: provisioning.SyncOptions{ + Target: provisioning.SyncTargetTypeFolder, + }, + }, + }) + }, + expectedError: "migration of legacy resources is not supported for folder-type repositories", + }, + { + name: "should allow migration of legacy resources for instance-type repositories", + job: provisioning.Job{ + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMigrate, + Migrate: &provisioning.MigrateJobOptions{}, + }, + }, + isLegacyActive: true, + setupMocks: func(lm *MockMigrator, um *MockMigrator, ds *dualwrite.MockService, pr *jobs.MockJobProgressRecorder) { + pr.On("SetTotal", mock.Anything, 10).Return() + ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(false, nil) + lm.On("Migrate", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + }, + setupRepo: func(repo *repository.MockRepository) { + repo.On("Config").Return(&provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Sync: provisioning.SyncOptions{ + Target: provisioning.SyncTargetTypeInstance, + }, + }, + }) + }, + expectedError: "", + }, + { + name: "should allow migration for folder-type repositories when legacy storage is not active", + job: provisioning.Job{ + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMigrate, + Migrate: &provisioning.MigrateJobOptions{}, + }, + }, + isLegacyActive: false, + setupMocks: func(lm *MockMigrator, um *MockMigrator, ds *dualwrite.MockService, pr *jobs.MockJobProgressRecorder) { + pr.On("SetTotal", mock.Anything, 10).Return() + ds.On("ReadFromUnified", mock.Anything, mock.Anything).Return(true, nil) + um.On("Migrate", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + }, + setupRepo: func(repo *repository.MockRepository) { + repo.On("Config").Return(&provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Sync: provisioning.SyncOptions{ + Target: provisioning.SyncTargetTypeFolder, + }, + }, + }) + }, + expectedError: "", + }, } for _, tt := range tests { @@ -192,6 +298,9 @@ func TestMigrationWorker_Process(t *testing.T) { } rw := repository.NewMockRepository(t) + if tt.setupRepo != nil { + tt.setupRepo(rw) + } err := worker.Process(context.Background(), rw, tt.job, progressRecorder) if tt.expectedError != "" { diff --git a/pkg/registry/apis/provisioning/jobs/move/worker.go b/pkg/registry/apis/provisioning/jobs/move/worker.go index b2ff1912bd5..5e409db7e4b 100644 --- a/pkg/registry/apis/provisioning/jobs/move/worker.go +++ b/pkg/registry/apis/provisioning/jobs/move/worker.go @@ -81,6 +81,7 @@ func (w *Worker) Process(ctx context.Context, repo repository.Repository, job pr CommitOnlyOnceMessage: msg, PushOnWrites: false, Timeout: 10 * time.Minute, + Ref: opts.Ref, } err := w.wrapFn(ctx, repo, stageOptions, fn) @@ -88,6 +89,15 @@ func (w *Worker) Process(ctx context.Context, repo repository.Repository, job pr return fmt.Errorf("move files in repository: %w", err) } + // Set RefURLs if the repository supports it and we have a target ref + if opts.Ref != "" { + if repoWithURLs, ok := repo.(repository.RepositoryWithURLs); ok { + if refURLs, urlErr := repoWithURLs.RefURLs(ctx, opts.Ref); urlErr == nil && refURLs != nil { + progress.SetRefURLs(ctx, refURLs) + } + } + } + if opts.Ref == "" { progress.ResetResults() progress.SetMessage(ctx, "pull resources") @@ -120,7 +130,9 @@ func (w *Worker) moveFiles(ctx context.Context, rw repository.ReaderWriter, prog targetPath := w.constructTargetPath(opts.TargetPath, path) progress.SetMessage(ctx, "Moving "+path+" to "+targetPath) - result.Error = rw.Move(ctx, path, targetPath, opts.Ref, "Move "+path+" to "+targetPath) + if err := rw.Move(ctx, path, targetPath, opts.Ref, "Move "+path+" to "+targetPath); err != nil { + result.Error = fmt.Errorf("moving file %s to %s: %w", path, targetPath, err) + } progress.Record(ctx, result) if err := progress.TooManyErrors(); err != nil { return err diff --git a/pkg/registry/apis/provisioning/jobs/move/worker_test.go b/pkg/registry/apis/provisioning/jobs/move/worker_test.go index c398cedc830..a728bd5d9f0 100644 --- a/pkg/registry/apis/provisioning/jobs/move/worker_test.go +++ b/pkg/registry/apis/provisioning/jobs/move/worker_test.go @@ -202,7 +202,7 @@ func TestMoveWorker_ProcessMoveFilesSuccess(t *testing.T) { mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.MatchedBy(func(opts repository.StageOptions) bool { return !opts.PushOnWrites && opts.Timeout == 10*time.Minute && opts.Mode == repository.StageModeCommitOnlyOnce && - opts.CommitOnlyOnceMessage != "" + opts.CommitOnlyOnceMessage != "" && opts.Ref == "main" }), mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error { return fn(mockRepo, false) }) @@ -833,3 +833,170 @@ func TestMoveWorker_deduplicatePaths(t *testing.T) { }) } } + +func TestMoveWorker_RefURLsSetWithRef(t *testing.T) { + mockRepoWithURLs := repository.NewMockRepositoryWithURLs(t) + config := &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "test-namespace", + }, + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + } + mockRepoWithURLs.On("Config").Return(config).Maybe() // Config may be called multiple times + + // Mock RefURLs method to return expected URLs + expectedRefURLs := &provisioning.RepositoryURLs{ + SourceURL: "https://github.com/grafana/grafana/tree/feature-branch", + CompareURL: "https://github.com/grafana/grafana/compare/main...feature-branch", + NewPullRequestURL: "https://github.com/grafana/grafana/compare/main...feature-branch?quick_pull=1&labels=grafana", + } + mockRepoWithURLs.On("RefURLs", mock.Anything, "feature-branch").Return(expectedRefURLs, nil) + + mockProgress := jobs.NewMockJobProgressRecorder(t) + mockProgress.On("SetTotal", mock.Anything, 1).Once() + mockProgress.On("StrictMaxErrors", 1).Once() + mockProgress.On("SetMessage", mock.Anything, "Moving test.json to target/test.json").Once() + mockProgress.On("Record", mock.Anything, mock.Anything).Once() + mockProgress.On("TooManyErrors").Return(nil).Once() + mockProgress.On("SetRefURLs", mock.Anything, expectedRefURLs).Once() + + mockReaderWriter := repository.NewMockReaderWriter(t) + mockReaderWriter.On("Move", mock.Anything, "test.json", "target/test.json", "feature-branch", "Move test.json to target/test.json").Return(nil) + + mockWrapFn := repository.NewMockWrapWithStageFn(t) + mockWrapFn.On("Execute", mock.Anything, mockRepoWithURLs, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, opts repository.StageOptions, fn func(repository.Repository, bool) error) error { + return fn(mockReaderWriter, true) + }) + + mockResourcesFactory := resources.NewMockRepositoryResourcesFactory(t) + + job := provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "test-job"}, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + Ref: "feature-branch", + Paths: []string{"test.json"}, + TargetPath: "target/", + }, + }, + } + + worker := NewWorker(nil, mockWrapFn.Execute, mockResourcesFactory) + err := worker.Process(context.Background(), mockRepoWithURLs, job, mockProgress) + require.NoError(t, err) + + // Verify that SetRefURLs was called with the expected RefURLs + mockProgress.AssertExpectations(t) + mockRepoWithURLs.AssertExpectations(t) +} + +func TestMoveWorker_RefURLsNotSetWithoutRef(t *testing.T) { + mockRepoWithURLs := repository.NewMockRepositoryWithURLs(t) + config := &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "test-namespace", + }, + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + } + mockRepoWithURLs.On("Config").Return(config).Maybe() // Config may be called multiple times + + mockProgress := jobs.NewMockJobProgressRecorder(t) + mockProgress.On("SetTotal", mock.Anything, 1).Once() + mockProgress.On("StrictMaxErrors", 1).Once() + mockProgress.On("SetMessage", mock.Anything, "Moving test.json to target/test.json").Once() + mockProgress.On("Record", mock.Anything, mock.Anything).Once() + mockProgress.On("TooManyErrors").Return(nil).Once() + mockProgress.On("ResetResults").Once() + mockProgress.On("SetMessage", mock.Anything, "pull resources").Once() + // SetRefURLs should NOT be called since no ref is specified + + mockReaderWriter := repository.NewMockReaderWriter(t) + mockReaderWriter.On("Move", mock.Anything, "test.json", "target/test.json", "", "Move test.json to target/test.json").Return(nil) + + mockWrapFn := repository.NewMockWrapWithStageFn(t) + mockWrapFn.On("Execute", mock.Anything, mockRepoWithURLs, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, opts repository.StageOptions, fn func(repository.Repository, bool) error) error { + return fn(mockReaderWriter, true) + }) + + mockSyncWorker := jobs.NewMockWorker(t) + mockSyncWorker.On("Process", mock.Anything, mockRepoWithURLs, mock.Anything, mockProgress).Return(nil) + + mockResourcesFactory := resources.NewMockRepositoryResourcesFactory(t) + + job := provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "test-job"}, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + // No ref specified + Paths: []string{"test.json"}, + TargetPath: "target/", + }, + }, + } + + worker := NewWorker(mockSyncWorker, mockWrapFn.Execute, mockResourcesFactory) + err := worker.Process(context.Background(), mockRepoWithURLs, job, mockProgress) + require.NoError(t, err) + + // Verify that SetRefURLs was NOT called since no ref was specified + mockProgress.AssertExpectations(t) +} + +func TestMoveWorker_RefURLsNotSetForNonURLRepository(t *testing.T) { + mockRepo := repository.NewMockRepository(t) + config := &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "test-namespace", + }, + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitRepositoryType, // Regular git repo, not GitHub + }, + } + mockRepo.On("Config").Return(config).Maybe() // Config may be called multiple times + + mockProgress := jobs.NewMockJobProgressRecorder(t) + mockProgress.On("SetTotal", mock.Anything, 1).Once() + mockProgress.On("StrictMaxErrors", 1).Once() + mockProgress.On("SetMessage", mock.Anything, "Moving test.json to target/test.json").Once() + mockProgress.On("Record", mock.Anything, mock.Anything).Once() + mockProgress.On("TooManyErrors").Return(nil).Once() + // SetRefURLs should NOT be called since repo doesn't support URLs + + mockReaderWriter := repository.NewMockReaderWriter(t) + mockReaderWriter.On("Move", mock.Anything, "test.json", "target/test.json", "feature-branch", "Move test.json to target/test.json").Return(nil) + + mockWrapFn := repository.NewMockWrapWithStageFn(t) + mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, opts repository.StageOptions, fn func(repository.Repository, bool) error) error { + return fn(mockReaderWriter, true) + }) + + mockResourcesFactory := resources.NewMockRepositoryResourcesFactory(t) + + job := provisioning.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "test-job"}, + Spec: provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + Ref: "feature-branch", + Paths: []string{"test.json"}, + TargetPath: "target/", + }, + }, + } + + worker := NewWorker(nil, mockWrapFn.Execute, mockResourcesFactory) + err := worker.Process(context.Background(), mockRepo, job, mockProgress) + require.NoError(t, err) + + // Verify that SetRefURLs was NOT called since repo doesn't support URLs + mockProgress.AssertExpectations(t) +} diff --git a/pkg/registry/apis/provisioning/jobs/persistentstore.go b/pkg/registry/apis/provisioning/jobs/persistentstore.go index 2dfcf216b6b..2649337b76f 100644 --- a/pkg/registry/apis/provisioning/jobs/persistentstore.go +++ b/pkg/registry/apis/provisioning/jobs/persistentstore.go @@ -30,6 +30,8 @@ const ( LabelJobClaim = "provisioning.grafana.app/claim" // 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" + // LabelJobOriginalUID contains the Job's original uid as a label. This allows for label selectors to find the archived version of a job. + LabelJobOriginalUID = "provisioning.grafana.app/original-uid" ) var ( @@ -87,28 +89,17 @@ type persistentStore struct { // 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 NewJobStore( - jobStore jobStorage, - expiry time.Duration, -) (*persistentStore, error) { +func NewJobStore(jobStore jobStorage, expiry time.Duration) (*persistentStore, error) { if expiry <= 0 { expiry = time.Second * 30 } return &persistentStore{ jobStore: jobStore, - - clock: time.Now, - expiry: expiry, - - notifications: make(chan struct{}, 1), + clock: time.Now, + expiry: expiry, }, nil } @@ -446,19 +437,9 @@ func (s *persistentStore) Insert(ctx context.Context, namespace string, spec pro 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 { diff --git a/pkg/registry/apis/provisioning/jobs/progress.go b/pkg/registry/apis/provisioning/jobs/progress.go index 4f7bb2ad53d..17d0fb60248 100644 --- a/pkg/registry/apis/provisioning/jobs/progress.go +++ b/pkg/registry/apis/provisioning/jobs/progress.go @@ -53,6 +53,7 @@ type jobProgressRecorder struct { resultCount int errorCount int errors []string + refURLs *provisioning.RepositoryURLs notifyImmediatelyFn ProgressFn maybeNotifyFn ProgressFn summaries map[string]*provisioning.JobResourceSummary @@ -117,6 +118,18 @@ func (r *jobProgressRecorder) SetFinalMessage(ctx context.Context, msg string) { logging.FromContext(ctx).Info("job final message", "message", msg) } +func (r *jobProgressRecorder) SetRefURLs(ctx context.Context, refURLs *provisioning.RepositoryURLs) { + r.mu.Lock() + r.refURLs = refURLs + r.mu.Unlock() + + if refURLs != nil { + logging.FromContext(ctx).Debug("job ref URLs set", "sourceURL", refURLs.SourceURL, "compareURL", refURLs.CompareURL, "newPullRequestURL", refURLs.NewPullRequestURL) + } else { + logging.FromContext(ctx).Debug("job ref URLs cleared") + } +} + func (r *jobProgressRecorder) SetTotal(ctx context.Context, total int) { r.mu.Lock() r.total = total @@ -171,7 +184,8 @@ func (r *jobProgressRecorder) updateSummary(result JobResourceResult) { } if result.Error != nil { - summary.Errors = append(summary.Errors, result.Error.Error()) + errorMsg := fmt.Sprintf("%s (file: %s, name: %s, action: %s)", result.Error.Error(), result.Path, result.Name, result.Action) + summary.Errors = append(summary.Errors, errorMsg) summary.Error++ } else { switch result.Action { @@ -252,6 +266,7 @@ func (r *jobProgressRecorder) Complete(ctx context.Context, err error) provision jobStatus.Summary = r.summary() jobStatus.Errors = r.errors + jobStatus.URLs = r.refURLs // Check for errors during execution if len(jobStatus.Errors) > 0 && jobStatus.State != provisioning.JobStateError { diff --git a/pkg/registry/apis/provisioning/jobs/progress_test.go b/pkg/registry/apis/provisioning/jobs/progress_test.go new file mode 100644 index 00000000000..caf44c767ff --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/progress_test.go @@ -0,0 +1,85 @@ +package jobs + +import ( + "context" + "testing" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestJobProgressRecorderSetRefURLs(t *testing.T) { + ctx := context.Background() + + // Create a progress recorder + mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error { + return nil + } + recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder) + + // Test setting RefURLs + expectedRefURLs := &provisioning.RepositoryURLs{ + SourceURL: "https://github.com/grafana/grafana/tree/feature-branch", + CompareURL: "https://github.com/grafana/grafana/compare/main...feature-branch", + NewPullRequestURL: "https://github.com/grafana/grafana/compare/main...feature-branch?quick_pull=1&labels=grafana", + } + + recorder.SetRefURLs(ctx, expectedRefURLs) + + // Verify RefURLs are stored + recorder.mu.RLock() + assert.Equal(t, expectedRefURLs, recorder.refURLs) + recorder.mu.RUnlock() + + // Test that RefURLs are included in the final status + finalStatus := recorder.Complete(ctx, nil) + assert.Equal(t, expectedRefURLs, finalStatus.URLs) +} + +func TestJobProgressRecorderSetRefURLsNil(t *testing.T) { + ctx := context.Background() + + // Create a progress recorder + mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error { + return nil + } + recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder) + + // Test setting nil RefURLs + recorder.SetRefURLs(ctx, nil) + + // Verify nil RefURLs are stored + recorder.mu.RLock() + assert.Nil(t, recorder.refURLs) + recorder.mu.RUnlock() + + // Test that nil RefURLs are included in the final status + finalStatus := recorder.Complete(ctx, nil) + assert.Nil(t, finalStatus.URLs) +} + +func TestJobProgressRecorderCompleteIncludesRefURLs(t *testing.T) { + ctx := context.Background() + + // Create a progress recorder + mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error { + return nil + } + recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder) + + // Set some RefURLs + refURLs := &provisioning.RepositoryURLs{ + SourceURL: "https://github.com/grafana/grafana/tree/test-branch", + } + recorder.SetRefURLs(ctx, refURLs) + + // Complete the job + finalStatus := recorder.Complete(ctx, nil) + + // Verify the final status includes RefURLs + require.NotNil(t, finalStatus.URLs) + assert.Equal(t, refURLs.SourceURL, finalStatus.URLs.SourceURL) + assert.Equal(t, provisioning.JobStateSuccess, finalStatus.State) + assert.Equal(t, "completed successfully", finalStatus.Message) +} diff --git a/pkg/registry/apis/provisioning/jobs/queue.go b/pkg/registry/apis/provisioning/jobs/queue.go index 3a9757d93a1..f7e2a60e4f5 100644 --- a/pkg/registry/apis/provisioning/jobs/queue.go +++ b/pkg/registry/apis/provisioning/jobs/queue.go @@ -25,6 +25,7 @@ type JobProgressRecorder interface { SetTotal(ctx context.Context, total int) TooManyErrors() error StrictMaxErrors(maxErrors int) + SetRefURLs(ctx context.Context, refURLs *provisioning.RepositoryURLs) Complete(ctx context.Context, err error) provisioning.JobStatus } diff --git a/pkg/registry/apis/provisioning/jobs/store_mock.go b/pkg/registry/apis/provisioning/jobs/store_mock.go index 114a621561e..aebcac9bbbe 100644 --- a/pkg/registry/apis/provisioning/jobs/store_mock.go +++ b/pkg/registry/apis/provisioning/jobs/store_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.53.4. DO NOT EDIT. +// Code generated by mockery v2.52.4. DO NOT EDIT. package jobs @@ -182,49 +182,108 @@ func (_c *MockStore_Complete_Call) RunAndReturn(run func(context.Context, *v0alp return _c } -// InsertNotifications provides a mock function with no fields -func (_m *MockStore) InsertNotifications() chan struct{} { - ret := _m.Called() +// Get provides a mock function with given fields: ctx, name +func (_m *MockStore) Get(ctx context.Context, name string) (*v0alpha1.Job, error) { + ret := _m.Called(ctx, name) if len(ret) == 0 { - panic("no return value specified for InsertNotifications") + panic("no return value specified for Get") } - var r0 chan struct{} - if rf, ok := ret.Get(0).(func() chan struct{}); ok { - r0 = rf() + var r0 *v0alpha1.Job + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (*v0alpha1.Job, error)); ok { + return rf(ctx, name) + } + if rf, ok := ret.Get(0).(func(context.Context, string) *v0alpha1.Job); ok { + r0 = rf(ctx, name) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(chan struct{}) + r0 = ret.Get(0).(*v0alpha1.Job) } } + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockStore_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' +type MockStore_Get_Call struct { + *mock.Call +} + +// Get is a helper method to define mock.On call +// - ctx context.Context +// - name string +func (_e *MockStore_Expecter) Get(ctx interface{}, name interface{}) *MockStore_Get_Call { + return &MockStore_Get_Call{Call: _e.mock.On("Get", ctx, name)} +} + +func (_c *MockStore_Get_Call) Run(run func(ctx context.Context, name string)) *MockStore_Get_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *MockStore_Get_Call) Return(_a0 *v0alpha1.Job, _a1 error) *MockStore_Get_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockStore_Get_Call) RunAndReturn(run func(context.Context, string) (*v0alpha1.Job, error)) *MockStore_Get_Call { + _c.Call.Return(run) + return _c +} + +// RenewLease provides a mock function with given fields: ctx, job +func (_m *MockStore) RenewLease(ctx context.Context, job *v0alpha1.Job) error { + ret := _m.Called(ctx, job) + + if len(ret) == 0 { + panic("no return value specified for RenewLease") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Job) error); ok { + r0 = rf(ctx, job) + } else { + r0 = ret.Error(0) + } + return r0 } -// MockStore_InsertNotifications_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InsertNotifications' -type MockStore_InsertNotifications_Call struct { +// MockStore_RenewLease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RenewLease' +type MockStore_RenewLease_Call struct { *mock.Call } -// InsertNotifications is a helper method to define mock.On call -func (_e *MockStore_Expecter) InsertNotifications() *MockStore_InsertNotifications_Call { - return &MockStore_InsertNotifications_Call{Call: _e.mock.On("InsertNotifications")} +// RenewLease is a helper method to define mock.On call +// - ctx context.Context +// - job *v0alpha1.Job +func (_e *MockStore_Expecter) RenewLease(ctx interface{}, job interface{}) *MockStore_RenewLease_Call { + return &MockStore_RenewLease_Call{Call: _e.mock.On("RenewLease", ctx, job)} } -func (_c *MockStore_InsertNotifications_Call) Run(run func()) *MockStore_InsertNotifications_Call { +func (_c *MockStore_RenewLease_Call) Run(run func(ctx context.Context, job *v0alpha1.Job)) *MockStore_RenewLease_Call { _c.Call.Run(func(args mock.Arguments) { - run() + run(args[0].(context.Context), args[1].(*v0alpha1.Job)) }) return _c } -func (_c *MockStore_InsertNotifications_Call) Return(_a0 chan struct{}) *MockStore_InsertNotifications_Call { +func (_c *MockStore_RenewLease_Call) Return(_a0 error) *MockStore_RenewLease_Call { _c.Call.Return(_a0) return _c } -func (_c *MockStore_InsertNotifications_Call) RunAndReturn(run func() chan struct{}) *MockStore_InsertNotifications_Call { +func (_c *MockStore_RenewLease_Call) RunAndReturn(run func(context.Context, *v0alpha1.Job) error) *MockStore_RenewLease_Call { _c.Call.Return(run) return _c } diff --git a/pkg/registry/apis/provisioning/jobs/sync/full.go b/pkg/registry/apis/provisioning/jobs/sync/full.go index cfd8ac3cee8..4912abe2606 100644 --- a/pkg/registry/apis/provisioning/jobs/sync/full.go +++ b/pkg/registry/apis/provisioning/jobs/sync/full.go @@ -2,7 +2,6 @@ package sync import ( "context" - "errors" "fmt" "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" @@ -68,7 +67,7 @@ func applyChanges(ctx context.Context, changes []ResourceFileChange, clients res } if change.Existing == nil || change.Existing.Name == "" { - result.Error = errors.New("missing existing reference") + result.Error = fmt.Errorf("processing deletion for file %s: missing existing reference", change.Path) progress.Record(ctx, result) continue } @@ -90,7 +89,9 @@ func applyChanges(ctx context.Context, changes []ResourceFileChange, clients res continue } - result.Error = client.Delete(ctx, change.Existing.Name, metav1.DeleteOptions{}) + if err := client.Delete(ctx, change.Existing.Name, metav1.DeleteOptions{}); err != nil { + result.Error = fmt.Errorf("deleting resource %s/%s %s: %w", change.Existing.Group, change.Existing.Resource, change.Existing.Name, err) + } progress.Record(ctx, result) continue } @@ -106,7 +107,7 @@ func applyChanges(ctx context.Context, changes []ResourceFileChange, clients res folder, err := repositoryResources.EnsureFolderPathExist(ctx, change.Path) if err != nil { - result.Error = err + result.Error = fmt.Errorf("ensuring folder exists at path %s: %w", change.Path, err) progress.Record(ctx, result) continue } @@ -122,10 +123,13 @@ func applyChanges(ctx context.Context, changes []ResourceFileChange, clients res Path: change.Path, Action: change.Action, Name: name, - Error: err, Resource: gvk.Kind, Group: gvk.Group, } + + if err != nil { + result.Error = fmt.Errorf("writing resource from file %s: %w", change.Path, err) + } progress.Record(ctx, result) } diff --git a/pkg/registry/apis/provisioning/jobs/sync/full_test.go b/pkg/registry/apis/provisioning/jobs/sync/full_test.go index 3885700b331..08081ca90f0 100644 --- a/pkg/registry/apis/provisioning/jobs/sync/full_test.go +++ b/pkg/registry/apis/provisioning/jobs/sync/full_test.go @@ -174,7 +174,7 @@ func TestFullSync_FolderCreationFailedWithInstanceTarget(t *testing.T) { require.Contains(t, err.Error(), "compare changes: compare error") } -func TestFullSync_ApplyChanges(t *testing.T) { +func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo tests := []struct { name string setupMocks func(*repository.MockRepository, *resources.MockRepositoryResources, *resources.MockResourceClients, *jobs.MockJobProgressRecorder, *MockCompareFn) @@ -256,14 +256,15 @@ func TestFullSync_ApplyChanges(t *testing.T) { repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", ""). Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write error")) - progress.On("Record", mock.Anything, jobs.JobResourceResult{ - Action: repository.FileActionCreated, - Path: "dashboards/test.json", - Name: "test-dashboard", - Resource: "Dashboard", - Group: "dashboards", - Error: fmt.Errorf("write error"), - }).Return() + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Action == repository.FileActionCreated && + result.Path == "dashboards/test.json" && + result.Name == "test-dashboard" && + result.Resource == "Dashboard" && + result.Group == "dashboards" && + result.Error != nil && + result.Error.Error() == "writing resource from file dashboards/test.json: write error" + })).Return() }, }, { @@ -305,14 +306,15 @@ func TestFullSync_ApplyChanges(t *testing.T) { repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", ""). Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write error")) - progress.On("Record", mock.Anything, jobs.JobResourceResult{ - Action: repository.FileActionUpdated, - Path: "dashboards/test.json", - Name: "test-dashboard", - Resource: "Dashboard", - Group: "dashboards", - Error: fmt.Errorf("write error"), - }).Return() + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Action == repository.FileActionUpdated && + result.Path == "dashboards/test.json" && + result.Name == "test-dashboard" && + result.Resource == "Dashboard" && + result.Group == "dashboards" && + result.Error != nil && + result.Error.Error() == "writing resource from file dashboards/test.json: write error" + })).Return() }, }, { @@ -355,15 +357,15 @@ func TestFullSync_ApplyChanges(t *testing.T) { mock.Anything, "one/two/three/", ).Return("some-folder", errors.New("folder creation error")) - progress.On("Record", mock.Anything, jobs.JobResourceResult{ - Action: repository.FileActionCreated, - Path: "one/two/three/", - Name: "", - // FIXME: this is probably inconsistent across the codebase - Resource: "folders", - Group: "folder.grafana.app", - Error: errors.New("folder creation error"), - }).Return() + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Action == repository.FileActionCreated && + result.Path == "one/two/three/" && + result.Name == "" && + result.Resource == "folders" && + result.Group == "folder.grafana.app" && + result.Error != nil && + result.Error.Error() == "ensuring folder exists at path one/two/three/: folder creation error" + })).Return() }, }, { @@ -474,14 +476,15 @@ func TestFullSync_ApplyChanges(t *testing.T) { Version: "v1", }, nil) - progress.On("Record", mock.Anything, jobs.JobResourceResult{ - Action: repository.FileActionDeleted, - Path: "dashboards/test.json", - Name: "test-dashboard", - Resource: "Dashboard", - Group: "dashboards", - Error: fmt.Errorf("delete failed"), - }).Return() + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Action == repository.FileActionDeleted && + result.Path == "dashboards/test.json" && + result.Name == "test-dashboard" && + result.Resource == "Dashboard" && + result.Group == "dashboards" && + result.Error != nil && + result.Error.Error() == "deleting resource dashboards/Dashboard test-dashboard: delete failed" + })).Return() }, }, { @@ -496,11 +499,12 @@ func TestFullSync_ApplyChanges(t *testing.T) { }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) - progress.On("Record", mock.Anything, jobs.JobResourceResult{ - Action: repository.FileActionDeleted, - Path: "dashboards/test.json", - Error: fmt.Errorf("missing existing reference"), - }).Return() + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Action == repository.FileActionDeleted && + result.Path == "dashboards/test.json" && + result.Error != nil && + result.Error.Error() == "processing deletion for file dashboards/test.json: missing existing reference" + })).Return() }, }, { @@ -515,11 +519,12 @@ func TestFullSync_ApplyChanges(t *testing.T) { }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) - progress.On("Record", mock.Anything, jobs.JobResourceResult{ - Action: repository.FileActionDeleted, - Path: "dashboards/test.json", - Error: fmt.Errorf("missing existing reference"), - }).Return() + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Action == repository.FileActionDeleted && + result.Path == "dashboards/test.json" && + result.Error != nil && + result.Error.Error() == "processing deletion for file dashboards/test.json: missing existing reference" + })).Return() }, }, { @@ -662,14 +667,15 @@ func TestFullSync_ApplyChanges(t *testing.T) { Version: "v1", }, nil) - progress.On("Record", mock.Anything, jobs.JobResourceResult{ - Action: repository.FileActionDeleted, - Path: "to-be-deleted/", - Name: "test-folder", - Resource: "Folder", - Group: "folders", - Error: fmt.Errorf("delete failed"), - }).Return() + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Action == repository.FileActionDeleted && + result.Path == "to-be-deleted/" && + result.Name == "test-folder" && + result.Resource == "Folder" && + result.Group == "folders" && + result.Error != nil && + result.Error.Error() == "deleting resource folders/Folder test-folder: delete failed" + })).Return() }, }, } diff --git a/pkg/registry/apis/provisioning/jobs/sync/incremental.go b/pkg/registry/apis/provisioning/jobs/sync/incremental.go index 9bbfdb43921..fcd3023a7da 100644 --- a/pkg/registry/apis/provisioning/jobs/sync/incremental.go +++ b/pkg/registry/apis/provisioning/jobs/sync/incremental.go @@ -78,7 +78,7 @@ func IncrementalSync(ctx context.Context, repo repository.Versioned, previousRef case repository.FileActionCreated, repository.FileActionUpdated: name, gvk, err := repositoryResources.WriteResourceFromFile(ctx, change.Path, change.Ref) if err != nil { - result.Error = err + result.Error = fmt.Errorf("writing resource from file %s: %w", change.Path, err) } result.Name = name result.Resource = gvk.Kind @@ -86,7 +86,7 @@ func IncrementalSync(ctx context.Context, repo repository.Versioned, previousRef case repository.FileActionDeleted: name, gvk, err := repositoryResources.RemoveResourceFromFile(ctx, change.Path, change.PreviousRef) if err != nil { - result.Error = err + result.Error = fmt.Errorf("removing resource from file %s: %w", change.Path, err) } result.Name = name result.Resource = gvk.Kind @@ -94,7 +94,7 @@ func IncrementalSync(ctx context.Context, repo repository.Versioned, previousRef case repository.FileActionRenamed: name, gvk, err := repositoryResources.RenameResourceFile(ctx, change.PreviousPath, change.PreviousRef, change.Path, change.Ref) if err != nil { - result.Error = err + result.Error = fmt.Errorf("renaming resource file from %s to %s: %w", change.PreviousPath, change.Path, err) } result.Name = name result.Resource = gvk.Kind diff --git a/pkg/registry/apis/provisioning/jobs/sync/incremental_test.go b/pkg/registry/apis/provisioning/jobs/sync/incremental_test.go index 8cf8906470b..671c788d38a 100644 --- a/pkg/registry/apis/provisioning/jobs/sync/incremental_test.go +++ b/pkg/registry/apis/provisioning/jobs/sync/incremental_test.go @@ -305,14 +305,15 @@ func TestIncrementalSync(t *testing.T) { Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write failed")) // Mock progress recording with error - progress.On("Record", mock.Anything, jobs.JobResourceResult{ - Action: repository.FileActionCreated, - Path: "dashboards/test.json", - Name: "test-dashboard", - Resource: "Dashboard", - Group: "dashboards", - Error: fmt.Errorf("write failed"), - }).Return() + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Action == repository.FileActionCreated && + result.Path == "dashboards/test.json" && + result.Name == "test-dashboard" && + result.Resource == "Dashboard" && + result.Group == "dashboards" && + result.Error != nil && + result.Error.Error() == "writing resource from file dashboards/test.json: write failed" + })).Return() progress.On("TooManyErrors").Return(nil) }, @@ -340,14 +341,15 @@ func TestIncrementalSync(t *testing.T) { Return("old-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("delete failed")) // Mock progress recording with error - progress.On("Record", mock.Anything, jobs.JobResourceResult{ - Action: repository.FileActionDeleted, - Path: "dashboards/old.json", - Name: "old-dashboard", - Resource: "Dashboard", - Group: "dashboards", - Error: fmt.Errorf("delete failed"), - }).Return() + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Action == repository.FileActionDeleted && + result.Path == "dashboards/old.json" && + result.Name == "old-dashboard" && + result.Resource == "Dashboard" && + result.Group == "dashboards" && + result.Error != nil && + result.Error.Error() == "removing resource from file dashboards/old.json: delete failed" + })).Return() progress.On("TooManyErrors").Return(nil) }, previousRef: "old-ref", diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index 4ad94af2687..79559bb2238 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -59,6 +59,7 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath" "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets" "github.com/grafana/grafana/pkg/registry/apis/provisioning/usage" + "github.com/grafana/grafana/pkg/registry/apis/secret" "github.com/grafana/grafana/pkg/services/apiserver" "github.com/grafana/grafana/pkg/services/apiserver/builder" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -99,6 +100,7 @@ type APIBuilder struct { jobs.Queue jobs.Store } + jobHistoryConfig *JobHistoryConfig jobHistory jobs.History tester *RepositoryTester resourceLister resources.ResourceLister @@ -106,7 +108,8 @@ type APIBuilder struct { legacyMigrator legacy.LegacyMigrator storageStatus dualwrite.Service unified resource.ResourceClient - repositorySecrets secrets.RepositorySecrets + decryptSvc secret.DecryptService + repositorySecrets secrets.RepositorySecrets // << Will be removed when the decryptSvc usage is stable client client.ProvisioningV0alpha1Interface access authlib.AccessChecker mutators []controller.Mutator @@ -129,6 +132,7 @@ func NewAPIBuilder( legacyMigrator legacy.LegacyMigrator, storageStatus dualwrite.Service, usageStats usagestats.Service, + decryptSvc secret.DecryptService, repositorySecrets secrets.RepositorySecrets, access authlib.AccessChecker, tracer tracing.Tracer, @@ -143,12 +147,6 @@ func NewAPIBuilder( git.Mutator(repositorySecrets), github.Mutator(repositorySecrets), } - // Create job history based on configuration - // Default to in-memory cache if no config provided - jobHistory := jobs.NewJobHistoryCache() - if jobHistoryConfig != nil && jobHistoryConfig.Loki != nil { - jobHistory = jobs.NewLokiJobHistory(*jobHistoryConfig.Loki) - } b := &APIBuilder{ mutators: mutators, @@ -165,9 +163,10 @@ func NewAPIBuilder( legacyMigrator: legacyMigrator, storageStatus: storageStatus, unified: unified, + decryptSvc: decryptSvc, repositorySecrets: repositorySecrets, access: access, - jobHistory: jobHistory, + jobHistoryConfig: jobHistoryConfig, availableRepositoryTypes: map[provisioning.RepositoryType]bool{ provisioning.LocalRepositoryType: true, provisioning.GitHubRepositoryType: true, @@ -237,6 +236,7 @@ func RegisterAPIService( legacyMigrator legacy.LegacyMigrator, storageStatus dualwrite.Service, usageStats usagestats.Service, + decryptSvc secret.DecryptService, repositorySecrets secrets.RepositorySecrets, tracer tracing.Tracer, extraBuilders []ExtraBuilder, @@ -255,6 +255,7 @@ func RegisterAPIService( configProvider, ghFactory, legacyMigrator, storageStatus, usageStats, + decryptSvc, repositorySecrets, access, tracer, @@ -275,6 +276,27 @@ func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer { return authorizer.DecisionAllow, "", nil } + info, ok := authlib.AuthInfoFrom(ctx) + if ok && authlib.IsIdentityType(info.GetIdentityType(), authlib.TypeAccessPolicy) { + res, err := b.access.Check(ctx, info, authlib.CheckRequest{ + Verb: a.GetVerb(), + Group: a.GetAPIGroup(), + Resource: a.GetResource(), + Name: a.GetName(), + Namespace: a.GetNamespace(), + Subresource: a.GetSubresource(), + }) + + if err != nil { + return authorizer.DecisionDeny, "failed to perform authorization", err + } + + if !res.Allowed { + return authorizer.DecisionDeny, "permission denied", nil + } + + return authorizer.DecisionAllow, "", 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. @@ -351,7 +373,8 @@ func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer { } return authorizer.DecisionDeny, "viewer role is required", nil - case provisioning.JobResourceInfo.GetName(): + case provisioning.JobResourceInfo.GetName(), + provisioning.HistoricJobResourceInfo.GetName(): // Jobs are shown on the configuration page. if id.GetOrgRole().Includes(identity.RoleAdmin) { return authorizer.DecisionAllow, "", nil @@ -418,13 +441,32 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI return fmt.Errorf("failed to create job storage: %w", err) } + storage := map[string]rest.Storage{} + // Create job history based on configuration + // Default to in-memory cache if no config provided + var jobHistory jobs.History + if b.jobHistoryConfig != nil && b.jobHistoryConfig.Loki != nil { + jobHistory = jobs.NewLokiJobHistory(*b.jobHistoryConfig.Loki) + } else { + historicJobStore, err := grafanaregistry.NewCompleteRegistryStore(opts.Scheme, provisioning.HistoricJobResourceInfo, opts.OptsGetter) + if err != nil { + return fmt.Errorf("failed to create historic job storage: %w", err) + } + + jobHistory, err = jobs.NewStorageBackedHistory(historicJobStore) + if err != nil { + return fmt.Errorf("failed to create historic job wrapper: %w", err) + } + + storage[provisioning.HistoricJobResourceInfo.StoragePath()] = historicJobStore + } + + b.jobHistory = jobHistory b.jobs, err = jobs.NewJobStore(realJobStore, 30*time.Second) // FIXME: this timeout if err != nil { return fmt.Errorf("failed to create job store: %w", err) } - storage := map[string]rest.Storage{} - // Although we never interact with jobs via the API, we want them to be readable (watchable!) from the API. storage[provisioning.JobResourceInfo.StoragePath()] = readonly.Wrap(realJobStore) @@ -570,15 +612,31 @@ func (b *APIBuilder) verifyAgaintsExistingRepositories(cfg *provisioning.Reposit } if cfg.Spec.Sync.Target == provisioning.SyncTargetTypeInstance { + // Instance sync can only be created if NO other repositories exist for _, v := range all { - if v.Name != cfg.Name && v.Spec.Sync.Target == provisioning.SyncTargetTypeInstance { + if v.Name != cfg.Name { return field.Forbidden(field.NewPath("spec", "sync", "target"), - "Another repository is already targeting root: "+v.Name) + "Instance repository can only be created when no other repositories exist. Found: "+v.Name) + } + } + } else { + // Folder sync cannot be created if an instance repository exists + for _, v := range all { + if v.Spec.Sync.Target == provisioning.SyncTargetTypeInstance { + return field.Forbidden(field.NewPath("spec", "sync", "target"), + "Cannot create folder repository when instance repository exists: "+v.Name) } } } - if len(all) >= 10 { + // Count repositories excluding the current one being created/updated + count := 0 + for _, v := range all { + if v.Name != cfg.Name { + count++ + } + } + if count >= 10 { return field.Forbidden(field.NewPath("spec"), "Maximum number of 10 repositories reached") } @@ -594,16 +652,10 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH 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.Done()) + jobInformer := sharedInformerFactory.Provisioning().V0alpha1().Jobs() b.client = c.ProvisioningV0alpha1() @@ -614,6 +666,20 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH b.repositoryLister = repoInformer.Lister() + // if running solely CRUD, skip the rest of the setup + if b.localFileResolver == nil { + return nil + } + + go repoInformer.Informer().Run(postStartHookCtx.Done()) + go jobInformer.Informer().Run(postStartHookCtx.Done()) + + // When starting with an empty instance -- swith to "mode 4+" + err = b.tryRunningOnlyUnifiedStorage() + if err != nil { + return err + } + // Create the repository resources factory usageMetricCollector := usage.MetricCollector(b.tracer, b.repositoryLister, b.unified) b.usageStats.RegisterMetricsFunc(usageMetricCollector) @@ -675,6 +741,12 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH syncWorker, } + // Create JobController to handle job create notifications + jobController, err := controller.NewJobController(jobInformer) + if err != nil { + return err + } + // Add any extra workers for _, extra := range b.extras { workers = append(workers, extra.GetJobWorkers()...) @@ -688,6 +760,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH 30*time.Second, // Periodically look for new jobs 30*time.Second, // Lease renewal interval b.jobs, b, b.jobHistory, + jobController.InsertNotifications(), workers..., ) if err != nil { @@ -716,6 +789,25 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH } go repoController.Run(postStartHookCtx.Context, repoControllerWorkers) + + // If Loki not used, start the controller for history jobs + if b.jobHistoryConfig == nil || b.jobHistoryConfig.Loki == nil { + // Create HistoryJobController for cleanup of old job history entries + // Separate informer factory for HistoryJob cleanup with resync interval + historyJobExpiration := 30 * time.Second + historyJobInformerFactory := informers.NewSharedInformerFactory(c, historyJobExpiration) + historyJobInformer := historyJobInformerFactory.Provisioning().V0alpha1().HistoricJobs() + go historyJobInformer.Informer().Run(postStartHookCtx.Done()) + _, err = controller.NewHistoryJobController( + b.GetClient(), + historyJobInformer, + historyJobExpiration, + ) + if err != nil { + return fmt.Errorf("create history job controller: %w", err) + } + } + return nil }, } @@ -732,11 +824,19 @@ func (b *APIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, err oas.Info.Description = "Provisioning" root := "/apis/" + b.GetGroupVersion().String() + "/" - repoprefix := root + "namespaces/{namespace}/repositories/{name}" + // Hide the internal historic jobs endpoint from the OpenAPI spec. + historicjobs := root + "namespaces/{namespace}/historicjobs" + for path := range oas.Paths.Paths { + if strings.HasPrefix(path, historicjobs) { + delete(oas.Paths.Paths, path) + } + } + + repoprefix := root + "namespaces/{namespace}/repositories/{name}" defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} }) defsBase := "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1." - refsBase := "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1." + refsBase := "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1." sub := oas.Paths.Paths[repoprefix+"/test"] if sub != nil { @@ -1019,12 +1119,12 @@ spec: // Add any missing definitions //----------------------------- for k, v := range defs { - clean := strings.Replace(k, defsBase, "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.", 1) + clean := strings.Replace(k, defsBase, "com.github.grafana.grafana.apps.provisioning.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." + compBase := "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1." schema := oas.Components.Schemas[compBase+"RepositoryViewList"].Properties["items"] schema.Items = &spec.SchemaOrArray{ Schema: &spec.Schema{ @@ -1071,6 +1171,10 @@ spec: schema.Items = countSpec oas.Components.Schemas[compBase+"ResourceStats"].Properties["instance"] = schema + schema = oas.Components.Schemas[compBase+"ResourceStats"].Properties["unmanaged"] + schema.Items = countSpec + oas.Components.Schemas[compBase+"ResourceStats"].Properties["unmanaged"] = schema + schema = oas.Components.Schemas[compBase+"ResourceStats"].Properties["managed"] schema.Items = managerSpec oas.Components.Schemas[compBase+"ResourceStats"].Properties["managed"] = schema diff --git a/pkg/registry/apis/provisioning/repository/git/staged.go b/pkg/registry/apis/provisioning/repository/git/staged.go index f55eb709341..01a375ce7f4 100644 --- a/pkg/registry/apis/provisioning/repository/git/staged.go +++ b/pkg/registry/apis/provisioning/repository/git/staged.go @@ -31,13 +31,9 @@ func NewStagedGitRepository(ctx context.Context, repo *gitRepository, opts repos branch = repo.gitConfig.Branch } - ref, err := repo.client.GetRef(ctx, "refs/heads/"+branch) + ref, err := repo.ensureBranchExists(ctx, branch) if err != nil { - // TODO: opts.CreateIfNotExists doesn't make sense in the context of the staged repository - // because we only support the branch that is passed in. - // we should probably add branch to the repository.CloneOptions which should be repurposed - // as some kind of branch creation options. - return nil, err + return nil, fmt.Errorf("ensure branch exists: %w", err) } writer, err := repo.client.NewStagedWriter(ctx, ref) diff --git a/pkg/registry/apis/provisioning/repository/git/staged_test.go b/pkg/registry/apis/provisioning/repository/git/staged_test.go index 2b18fd3445f..8f3940f10f8 100644 --- a/pkg/registry/apis/provisioning/repository/git/staged_test.go +++ b/pkg/registry/apis/provisioning/repository/git/staged_test.go @@ -131,7 +131,48 @@ func TestNewStagedGitRepository(t *testing.T) { opts: repository.StageOptions{ Mode: repository.StageModeCommitOnEach, }, - wantError: errors.New("ref not found"), + wantError: errors.New("ensure branch exists: check branch exists: ref not found"), + }, + { + name: "creates branch when it doesn't exist", + setupMock: func(mockClient *mocks.FakeClient) { + // First call to GetRef for feature-branch returns not found + // Second call to GetRef for main branch (source) returns success + // Third call to CreateRef creates the feature branch + // Fourth call to GetRef for feature-branch returns the created branch + callCount := 0 + mockClient.GetRefStub = func(ctx context.Context, ref string) (nanogit.Ref, error) { + callCount++ + switch callCount { + case 1: + // First call: feature-branch doesn't exist + if ref == "refs/heads/feature-branch" { + return nanogit.Ref{}, nanogit.ErrObjectNotFound + } + case 2: + // Second call: get source branch (main) + if ref == "refs/heads/main" { + return nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil + } + } + return nanogit.Ref{}, errors.New("unexpected call") + } + + // CreateRef should be called to create the new branch + mockClient.CreateRefReturns(nil) + + mockWriter := &mocks.FakeStagedWriter{} + mockClient.NewStagedWriterReturns(mockWriter, nil) + }, + opts: repository.StageOptions{ + Ref: "feature-branch", + Mode: repository.StageModeCommitOnEach, + }, + expectedRef: "refs/heads/feature-branch", + wantError: nil, }, { name: "fails with NewStagedWriter error", diff --git a/pkg/registry/apis/provisioning/repository/github/github_repository_mock.go b/pkg/registry/apis/provisioning/repository/github/github_repository_mock.go index 7b5509935f1..e5fc32897cf 100644 --- a/pkg/registry/apis/provisioning/repository/github/github_repository_mock.go +++ b/pkg/registry/apis/provisioning/repository/github/github_repository_mock.go @@ -829,6 +829,65 @@ func (_c *MockGithubRepository_ReadTree_Call) RunAndReturn(run func(context.Cont return _c } +// RefURLs provides a mock function with given fields: ctx, ref +func (_m *MockGithubRepository) RefURLs(ctx context.Context, ref string) (*v0alpha1.RepositoryURLs, error) { + ret := _m.Called(ctx, ref) + + if len(ret) == 0 { + panic("no return value specified for RefURLs") + } + + var r0 *v0alpha1.RepositoryURLs + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (*v0alpha1.RepositoryURLs, error)); ok { + return rf(ctx, ref) + } + if rf, ok := ret.Get(0).(func(context.Context, string) *v0alpha1.RepositoryURLs); ok { + r0 = rf(ctx, ref) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v0alpha1.RepositoryURLs) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, ref) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockGithubRepository_RefURLs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RefURLs' +type MockGithubRepository_RefURLs_Call struct { + *mock.Call +} + +// RefURLs is a helper method to define mock.On call +// - ctx context.Context +// - ref string +func (_e *MockGithubRepository_Expecter) RefURLs(ctx interface{}, ref interface{}) *MockGithubRepository_RefURLs_Call { + return &MockGithubRepository_RefURLs_Call{Call: _e.mock.On("RefURLs", ctx, ref)} +} + +func (_c *MockGithubRepository_RefURLs_Call) Run(run func(ctx context.Context, ref string)) *MockGithubRepository_RefURLs_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *MockGithubRepository_RefURLs_Call) Return(_a0 *v0alpha1.RepositoryURLs, _a1 error) *MockGithubRepository_RefURLs_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockGithubRepository_RefURLs_Call) RunAndReturn(run func(context.Context, string) (*v0alpha1.RepositoryURLs, error)) *MockGithubRepository_RefURLs_Call { + _c.Call.Return(run) + return _c +} + // Repo provides a mock function with no fields func (_m *MockGithubRepository) Repo() string { ret := _m.Called() @@ -875,23 +934,23 @@ func (_c *MockGithubRepository_Repo_Call) RunAndReturn(run func() string) *MockG } // ResourceURLs provides a mock function with given fields: ctx, file -func (_m *MockGithubRepository) ResourceURLs(ctx context.Context, file *repository.FileInfo) (*v0alpha1.ResourceURLs, error) { +func (_m *MockGithubRepository) ResourceURLs(ctx context.Context, file *repository.FileInfo) (*v0alpha1.RepositoryURLs, error) { ret := _m.Called(ctx, file) if len(ret) == 0 { panic("no return value specified for ResourceURLs") } - var r0 *v0alpha1.ResourceURLs + var r0 *v0alpha1.RepositoryURLs var r1 error - if rf, ok := ret.Get(0).(func(context.Context, *repository.FileInfo) (*v0alpha1.ResourceURLs, error)); ok { + if rf, ok := ret.Get(0).(func(context.Context, *repository.FileInfo) (*v0alpha1.RepositoryURLs, error)); ok { return rf(ctx, file) } - if rf, ok := ret.Get(0).(func(context.Context, *repository.FileInfo) *v0alpha1.ResourceURLs); ok { + if rf, ok := ret.Get(0).(func(context.Context, *repository.FileInfo) *v0alpha1.RepositoryURLs); ok { r0 = rf(ctx, file) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*v0alpha1.ResourceURLs) + r0 = ret.Get(0).(*v0alpha1.RepositoryURLs) } } @@ -923,12 +982,12 @@ func (_c *MockGithubRepository_ResourceURLs_Call) Run(run func(ctx context.Conte return _c } -func (_c *MockGithubRepository_ResourceURLs_Call) Return(_a0 *v0alpha1.ResourceURLs, _a1 error) *MockGithubRepository_ResourceURLs_Call { +func (_c *MockGithubRepository_ResourceURLs_Call) Return(_a0 *v0alpha1.RepositoryURLs, _a1 error) *MockGithubRepository_ResourceURLs_Call { _c.Call.Return(_a0, _a1) return _c } -func (_c *MockGithubRepository_ResourceURLs_Call) RunAndReturn(run func(context.Context, *repository.FileInfo) (*v0alpha1.ResourceURLs, error)) *MockGithubRepository_ResourceURLs_Call { +func (_c *MockGithubRepository_ResourceURLs_Call) RunAndReturn(run func(context.Context, *repository.FileInfo) (*v0alpha1.RepositoryURLs, error)) *MockGithubRepository_ResourceURLs_Call { _c.Call.Return(run) return _c } diff --git a/pkg/registry/apis/provisioning/repository/github/repository.go b/pkg/registry/apis/provisioning/repository/github/repository.go index ac95e54312f..00ff70eb213 100644 --- a/pkg/registry/apis/provisioning/repository/github/repository.go +++ b/pkg/registry/apis/provisioning/repository/github/repository.go @@ -198,7 +198,7 @@ func (r *githubRepository) ListRefs(ctx context.Context) ([]provisioning.RefItem } // ResourceURLs implements RepositoryWithURLs. -func (r *githubRepository) ResourceURLs(ctx context.Context, file *repository.FileInfo) (*provisioning.ResourceURLs, error) { +func (r *githubRepository) ResourceURLs(ctx context.Context, file *repository.FileInfo) (*provisioning.RepositoryURLs, error) { cfg := r.config.Spec.GitHub if file.Path == "" || cfg == nil { return nil, nil @@ -209,7 +209,7 @@ func (r *githubRepository) ResourceURLs(ctx context.Context, file *repository.Fi ref = cfg.Branch } - urls := &provisioning.ResourceURLs{ + urls := &provisioning.RepositoryURLs{ RepositoryURL: cfg.URL, SourceURL: fmt.Sprintf("%s/blob/%s/%s", cfg.URL, ref, file.Path), } @@ -224,6 +224,25 @@ func (r *githubRepository) ResourceURLs(ctx context.Context, file *repository.Fi return urls, nil } +// RefURLs implements RepositoryWithURLs. +func (r *githubRepository) RefURLs(ctx context.Context, ref string) (*provisioning.RepositoryURLs, error) { + cfg := r.config.Spec.GitHub + if cfg == nil || ref == "" { + return nil, nil + } + + urls := &provisioning.RepositoryURLs{ + SourceURL: fmt.Sprintf("%s/tree/%s", cfg.URL, ref), + } + + if ref != cfg.Branch { + urls.CompareURL = fmt.Sprintf("%s/compare/%s...%s", cfg.URL, cfg.Branch, ref) + urls.NewPullRequestURL = fmt.Sprintf("%s?quick_pull=1&labels=grafana", urls.CompareURL) + } + + return urls, nil +} + func (r *githubRepository) OnCreate(_ context.Context) ([]map[string]interface{}, error) { return nil, nil } diff --git a/pkg/registry/apis/provisioning/repository/github/repository_test.go b/pkg/registry/apis/provisioning/repository/github/repository_test.go index 3a11127e9da..94d9c938cd5 100644 --- a/pkg/registry/apis/provisioning/repository/github/repository_test.go +++ b/pkg/registry/apis/provisioning/repository/github/repository_test.go @@ -688,7 +688,7 @@ func TestGitHubRepositoryResourceURLs(t *testing.T) { name string file *repository.FileInfo config *provisioning.Repository - expectedURLs *provisioning.ResourceURLs + expectedURLs *provisioning.RepositoryURLs expectedError error }{ { @@ -705,7 +705,7 @@ func TestGitHubRepositoryResourceURLs(t *testing.T) { }, }, }, - expectedURLs: &provisioning.ResourceURLs{ + expectedURLs: &provisioning.RepositoryURLs{ RepositoryURL: "https://github.com/grafana/grafana", SourceURL: "https://github.com/grafana/grafana/blob/feature-branch/dashboards/test.json", CompareURL: "https://github.com/grafana/grafana/compare/main...feature-branch", @@ -726,7 +726,7 @@ func TestGitHubRepositoryResourceURLs(t *testing.T) { }, }, }, - expectedURLs: &provisioning.ResourceURLs{ + expectedURLs: &provisioning.RepositoryURLs{ RepositoryURL: "https://github.com/grafana/grafana", SourceURL: "https://github.com/grafana/grafana/blob/main/dashboards/test.json", }, @@ -783,6 +783,92 @@ func TestGitHubRepositoryResourceURLs(t *testing.T) { } } +func TestGitHubRepositoryRefURLs(t *testing.T) { + tests := []struct { + name string + ref string + config *provisioning.Repository + expectedURLs *provisioning.RepositoryURLs + expectedError error + }{ + { + name: "ref different from branch", + ref: "feature-branch", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + URL: "https://github.com/grafana/grafana", + Branch: "main", + }, + }, + }, + expectedURLs: &provisioning.RepositoryURLs{ + SourceURL: "https://github.com/grafana/grafana/tree/feature-branch", + CompareURL: "https://github.com/grafana/grafana/compare/main...feature-branch", + NewPullRequestURL: "https://github.com/grafana/grafana/compare/main...feature-branch?quick_pull=1&labels=grafana", + }, + }, + { + name: "ref same as branch", + ref: "main", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + URL: "https://github.com/grafana/grafana", + Branch: "main", + }, + }, + }, + expectedURLs: &provisioning.RepositoryURLs{ + SourceURL: "https://github.com/grafana/grafana/tree/main", + }, + }, + { + name: "empty ref returns nil", + ref: "", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: &provisioning.GitHubRepositoryConfig{ + URL: "https://github.com/grafana/grafana", + Branch: "main", + }, + }, + }, + expectedURLs: nil, + }, + { + name: "nil github config returns nil", + ref: "feature-branch", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + GitHub: nil, + }, + }, + expectedURLs: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &githubRepository{ + config: tt.config, + owner: "grafana", + repo: "grafana", + } + + urls, err := repo.RefURLs(context.Background(), tt.ref) + + if tt.expectedError != nil { + require.Error(t, err) + require.Equal(t, tt.expectedError.Error(), err.Error()) + } else { + require.NoError(t, err) + require.Equal(t, tt.expectedURLs, urls) + } + }) + } +} + // Test simple delegation functions func TestGitHubRepositoryDelegation(t *testing.T) { ctx := context.Background() diff --git a/pkg/registry/apis/provisioning/repository/reader_writer_mock.go b/pkg/registry/apis/provisioning/repository/reader_writer_mock.go new file mode 100644 index 00000000000..5990d5b3eb2 --- /dev/null +++ b/pkg/registry/apis/provisioning/repository/reader_writer_mock.go @@ -0,0 +1,559 @@ +// Code generated by mockery v2.52.4. DO NOT EDIT. + +package repository + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + field "k8s.io/apimachinery/pkg/util/validation/field" + + v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +// MockReaderWriter is an autogenerated mock type for the ReaderWriter type +type MockReaderWriter struct { + mock.Mock +} + +type MockReaderWriter_Expecter struct { + mock *mock.Mock +} + +func (_m *MockReaderWriter) EXPECT() *MockReaderWriter_Expecter { + return &MockReaderWriter_Expecter{mock: &_m.Mock} +} + +// Config provides a mock function with no fields +func (_m *MockReaderWriter) 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 +} + +// MockReaderWriter_Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Config' +type MockReaderWriter_Config_Call struct { + *mock.Call +} + +// Config is a helper method to define mock.On call +func (_e *MockReaderWriter_Expecter) Config() *MockReaderWriter_Config_Call { + return &MockReaderWriter_Config_Call{Call: _e.mock.On("Config")} +} + +func (_c *MockReaderWriter_Config_Call) Run(run func()) *MockReaderWriter_Config_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockReaderWriter_Config_Call) Return(_a0 *v0alpha1.Repository) *MockReaderWriter_Config_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockReaderWriter_Config_Call) RunAndReturn(run func() *v0alpha1.Repository) *MockReaderWriter_Config_Call { + _c.Call.Return(run) + return _c +} + +// Create provides a mock function with given fields: ctx, path, ref, data, message +func (_m *MockReaderWriter) 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 +} + +// MockReaderWriter_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' +type MockReaderWriter_Create_Call struct { + *mock.Call +} + +// Create is a helper method to define mock.On call +// - ctx context.Context +// - path string +// - ref string +// - data []byte +// - message string +func (_e *MockReaderWriter_Expecter) Create(ctx interface{}, path interface{}, ref interface{}, data interface{}, message interface{}) *MockReaderWriter_Create_Call { + return &MockReaderWriter_Create_Call{Call: _e.mock.On("Create", ctx, path, ref, data, message)} +} + +func (_c *MockReaderWriter_Create_Call) Run(run func(ctx context.Context, path string, ref string, data []byte, message string)) *MockReaderWriter_Create_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].([]byte), args[4].(string)) + }) + return _c +} + +func (_c *MockReaderWriter_Create_Call) Return(_a0 error) *MockReaderWriter_Create_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockReaderWriter_Create_Call) RunAndReturn(run func(context.Context, string, string, []byte, string) error) *MockReaderWriter_Create_Call { + _c.Call.Return(run) + return _c +} + +// Delete provides a mock function with given fields: ctx, path, ref, message +func (_m *MockReaderWriter) 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 +} + +// MockReaderWriter_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' +type MockReaderWriter_Delete_Call struct { + *mock.Call +} + +// Delete is a helper method to define mock.On call +// - ctx context.Context +// - path string +// - ref string +// - message string +func (_e *MockReaderWriter_Expecter) Delete(ctx interface{}, path interface{}, ref interface{}, message interface{}) *MockReaderWriter_Delete_Call { + return &MockReaderWriter_Delete_Call{Call: _e.mock.On("Delete", ctx, path, ref, message)} +} + +func (_c *MockReaderWriter_Delete_Call) Run(run func(ctx context.Context, path string, ref string, message string)) *MockReaderWriter_Delete_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string)) + }) + return _c +} + +func (_c *MockReaderWriter_Delete_Call) Return(_a0 error) *MockReaderWriter_Delete_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockReaderWriter_Delete_Call) RunAndReturn(run func(context.Context, string, string, string) error) *MockReaderWriter_Delete_Call { + _c.Call.Return(run) + return _c +} + +// Move provides a mock function with given fields: ctx, oldPath, newPath, ref, message +func (_m *MockReaderWriter) Move(ctx context.Context, oldPath string, newPath string, ref string, message string) error { + ret := _m.Called(ctx, oldPath, newPath, ref, message) + + if len(ret) == 0 { + panic("no return value specified for Move") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string) error); ok { + r0 = rf(ctx, oldPath, newPath, ref, message) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockReaderWriter_Move_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Move' +type MockReaderWriter_Move_Call struct { + *mock.Call +} + +// Move is a helper method to define mock.On call +// - ctx context.Context +// - oldPath string +// - newPath string +// - ref string +// - message string +func (_e *MockReaderWriter_Expecter) Move(ctx interface{}, oldPath interface{}, newPath interface{}, ref interface{}, message interface{}) *MockReaderWriter_Move_Call { + return &MockReaderWriter_Move_Call{Call: _e.mock.On("Move", ctx, oldPath, newPath, ref, message)} +} + +func (_c *MockReaderWriter_Move_Call) Run(run func(ctx context.Context, oldPath string, newPath string, ref string, message string)) *MockReaderWriter_Move_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string), args[4].(string)) + }) + return _c +} + +func (_c *MockReaderWriter_Move_Call) Return(_a0 error) *MockReaderWriter_Move_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockReaderWriter_Move_Call) RunAndReturn(run func(context.Context, string, string, string, string) error) *MockReaderWriter_Move_Call { + _c.Call.Return(run) + return _c +} + +// Read provides a mock function with given fields: ctx, path, ref +func (_m *MockReaderWriter) 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 +} + +// MockReaderWriter_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read' +type MockReaderWriter_Read_Call struct { + *mock.Call +} + +// Read is a helper method to define mock.On call +// - ctx context.Context +// - path string +// - ref string +func (_e *MockReaderWriter_Expecter) Read(ctx interface{}, path interface{}, ref interface{}) *MockReaderWriter_Read_Call { + return &MockReaderWriter_Read_Call{Call: _e.mock.On("Read", ctx, path, ref)} +} + +func (_c *MockReaderWriter_Read_Call) Run(run func(ctx context.Context, path string, ref string)) *MockReaderWriter_Read_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *MockReaderWriter_Read_Call) Return(_a0 *FileInfo, _a1 error) *MockReaderWriter_Read_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockReaderWriter_Read_Call) RunAndReturn(run func(context.Context, string, string) (*FileInfo, error)) *MockReaderWriter_Read_Call { + _c.Call.Return(run) + return _c +} + +// ReadTree provides a mock function with given fields: ctx, ref +func (_m *MockReaderWriter) 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 +} + +// MockReaderWriter_ReadTree_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadTree' +type MockReaderWriter_ReadTree_Call struct { + *mock.Call +} + +// ReadTree is a helper method to define mock.On call +// - ctx context.Context +// - ref string +func (_e *MockReaderWriter_Expecter) ReadTree(ctx interface{}, ref interface{}) *MockReaderWriter_ReadTree_Call { + return &MockReaderWriter_ReadTree_Call{Call: _e.mock.On("ReadTree", ctx, ref)} +} + +func (_c *MockReaderWriter_ReadTree_Call) Run(run func(ctx context.Context, ref string)) *MockReaderWriter_ReadTree_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *MockReaderWriter_ReadTree_Call) Return(_a0 []FileTreeEntry, _a1 error) *MockReaderWriter_ReadTree_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockReaderWriter_ReadTree_Call) RunAndReturn(run func(context.Context, string) ([]FileTreeEntry, error)) *MockReaderWriter_ReadTree_Call { + _c.Call.Return(run) + return _c +} + +// Test provides a mock function with given fields: ctx +func (_m *MockReaderWriter) 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 +} + +// MockReaderWriter_Test_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Test' +type MockReaderWriter_Test_Call struct { + *mock.Call +} + +// Test is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockReaderWriter_Expecter) Test(ctx interface{}) *MockReaderWriter_Test_Call { + return &MockReaderWriter_Test_Call{Call: _e.mock.On("Test", ctx)} +} + +func (_c *MockReaderWriter_Test_Call) Run(run func(ctx context.Context)) *MockReaderWriter_Test_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockReaderWriter_Test_Call) Return(_a0 *v0alpha1.TestResults, _a1 error) *MockReaderWriter_Test_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockReaderWriter_Test_Call) RunAndReturn(run func(context.Context) (*v0alpha1.TestResults, error)) *MockReaderWriter_Test_Call { + _c.Call.Return(run) + return _c +} + +// Update provides a mock function with given fields: ctx, path, ref, data, message +func (_m *MockReaderWriter) 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 +} + +// MockReaderWriter_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update' +type MockReaderWriter_Update_Call struct { + *mock.Call +} + +// Update is a helper method to define mock.On call +// - ctx context.Context +// - path string +// - ref string +// - data []byte +// - message string +func (_e *MockReaderWriter_Expecter) Update(ctx interface{}, path interface{}, ref interface{}, data interface{}, message interface{}) *MockReaderWriter_Update_Call { + return &MockReaderWriter_Update_Call{Call: _e.mock.On("Update", ctx, path, ref, data, message)} +} + +func (_c *MockReaderWriter_Update_Call) Run(run func(ctx context.Context, path string, ref string, data []byte, message string)) *MockReaderWriter_Update_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].([]byte), args[4].(string)) + }) + return _c +} + +func (_c *MockReaderWriter_Update_Call) Return(_a0 error) *MockReaderWriter_Update_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockReaderWriter_Update_Call) RunAndReturn(run func(context.Context, string, string, []byte, string) error) *MockReaderWriter_Update_Call { + _c.Call.Return(run) + return _c +} + +// Validate provides a mock function with no fields +func (_m *MockReaderWriter) 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 +} + +// MockReaderWriter_Validate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Validate' +type MockReaderWriter_Validate_Call struct { + *mock.Call +} + +// Validate is a helper method to define mock.On call +func (_e *MockReaderWriter_Expecter) Validate() *MockReaderWriter_Validate_Call { + return &MockReaderWriter_Validate_Call{Call: _e.mock.On("Validate")} +} + +func (_c *MockReaderWriter_Validate_Call) Run(run func()) *MockReaderWriter_Validate_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockReaderWriter_Validate_Call) Return(_a0 field.ErrorList) *MockReaderWriter_Validate_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockReaderWriter_Validate_Call) RunAndReturn(run func() field.ErrorList) *MockReaderWriter_Validate_Call { + _c.Call.Return(run) + return _c +} + +// Write provides a mock function with given fields: ctx, path, ref, data, message +func (_m *MockReaderWriter) 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 +} + +// MockReaderWriter_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write' +type MockReaderWriter_Write_Call struct { + *mock.Call +} + +// Write is a helper method to define mock.On call +// - ctx context.Context +// - path string +// - ref string +// - data []byte +// - message string +func (_e *MockReaderWriter_Expecter) Write(ctx interface{}, path interface{}, ref interface{}, data interface{}, message interface{}) *MockReaderWriter_Write_Call { + return &MockReaderWriter_Write_Call{Call: _e.mock.On("Write", ctx, path, ref, data, message)} +} + +func (_c *MockReaderWriter_Write_Call) Run(run func(ctx context.Context, path string, ref string, data []byte, message string)) *MockReaderWriter_Write_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].([]byte), args[4].(string)) + }) + return _c +} + +func (_c *MockReaderWriter_Write_Call) Return(_a0 error) *MockReaderWriter_Write_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockReaderWriter_Write_Call) RunAndReturn(run func(context.Context, string, string, []byte, string) error) *MockReaderWriter_Write_Call { + _c.Call.Return(run) + return _c +} + +// NewMockReaderWriter creates a new instance of MockReaderWriter. 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 NewMockReaderWriter(t interface { + mock.TestingT + Cleanup(func()) +}) *MockReaderWriter { + mock := &MockReaderWriter{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/registry/apis/provisioning/repository/repository.go b/pkg/registry/apis/provisioning/repository/repository.go index 5608366060b..6ac356fc6c5 100644 --- a/pkg/registry/apis/provisioning/repository/repository.go +++ b/pkg/registry/apis/provisioning/repository/repository.go @@ -119,17 +119,19 @@ type Writer interface { Move(ctx context.Context, oldPath, newPath, ref, message string) error } +//go:generate mockery --name ReaderWriter --structname MockReaderWriter --inpackage --filename reader_writer_mock.go --with-expecter type ReaderWriter interface { Reader Writer } -// Hooks called after the repository has been created, updated or deleted +//go:generate mockery --name RepositoryWithURLs --structname MockRepositoryWithURLs --inpackage --filename repository_with_urls_mock.go --with-expecter type RepositoryWithURLs interface { Repository // Get resource URLs for a file inside a repository - ResourceURLs(ctx context.Context, file *FileInfo) (*provisioning.ResourceURLs, error) + ResourceURLs(ctx context.Context, file *FileInfo) (*provisioning.RepositoryURLs, error) + RefURLs(ctx context.Context, ref string) (*provisioning.RepositoryURLs, error) } // Hooks called after the repository has been created, updated or deleted diff --git a/pkg/registry/apis/provisioning/repository/repository_with_urls_mock.go b/pkg/registry/apis/provisioning/repository/repository_with_urls_mock.go new file mode 100644 index 00000000000..c277cb32026 --- /dev/null +++ b/pkg/registry/apis/provisioning/repository/repository_with_urls_mock.go @@ -0,0 +1,309 @@ +// Code generated by mockery v2.52.4. DO NOT EDIT. + +package repository + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + field "k8s.io/apimachinery/pkg/util/validation/field" + + v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +// MockRepositoryWithURLs is an autogenerated mock type for the RepositoryWithURLs type +type MockRepositoryWithURLs struct { + mock.Mock +} + +type MockRepositoryWithURLs_Expecter struct { + mock *mock.Mock +} + +func (_m *MockRepositoryWithURLs) EXPECT() *MockRepositoryWithURLs_Expecter { + return &MockRepositoryWithURLs_Expecter{mock: &_m.Mock} +} + +// Config provides a mock function with no fields +func (_m *MockRepositoryWithURLs) 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 +} + +// MockRepositoryWithURLs_Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Config' +type MockRepositoryWithURLs_Config_Call struct { + *mock.Call +} + +// Config is a helper method to define mock.On call +func (_e *MockRepositoryWithURLs_Expecter) Config() *MockRepositoryWithURLs_Config_Call { + return &MockRepositoryWithURLs_Config_Call{Call: _e.mock.On("Config")} +} + +func (_c *MockRepositoryWithURLs_Config_Call) Run(run func()) *MockRepositoryWithURLs_Config_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockRepositoryWithURLs_Config_Call) Return(_a0 *v0alpha1.Repository) *MockRepositoryWithURLs_Config_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockRepositoryWithURLs_Config_Call) RunAndReturn(run func() *v0alpha1.Repository) *MockRepositoryWithURLs_Config_Call { + _c.Call.Return(run) + return _c +} + +// RefURLs provides a mock function with given fields: ctx, ref +func (_m *MockRepositoryWithURLs) RefURLs(ctx context.Context, ref string) (*v0alpha1.RepositoryURLs, error) { + ret := _m.Called(ctx, ref) + + if len(ret) == 0 { + panic("no return value specified for RefURLs") + } + + var r0 *v0alpha1.RepositoryURLs + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (*v0alpha1.RepositoryURLs, error)); ok { + return rf(ctx, ref) + } + if rf, ok := ret.Get(0).(func(context.Context, string) *v0alpha1.RepositoryURLs); ok { + r0 = rf(ctx, ref) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v0alpha1.RepositoryURLs) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, ref) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockRepositoryWithURLs_RefURLs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RefURLs' +type MockRepositoryWithURLs_RefURLs_Call struct { + *mock.Call +} + +// RefURLs is a helper method to define mock.On call +// - ctx context.Context +// - ref string +func (_e *MockRepositoryWithURLs_Expecter) RefURLs(ctx interface{}, ref interface{}) *MockRepositoryWithURLs_RefURLs_Call { + return &MockRepositoryWithURLs_RefURLs_Call{Call: _e.mock.On("RefURLs", ctx, ref)} +} + +func (_c *MockRepositoryWithURLs_RefURLs_Call) Run(run func(ctx context.Context, ref string)) *MockRepositoryWithURLs_RefURLs_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *MockRepositoryWithURLs_RefURLs_Call) Return(_a0 *v0alpha1.RepositoryURLs, _a1 error) *MockRepositoryWithURLs_RefURLs_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockRepositoryWithURLs_RefURLs_Call) RunAndReturn(run func(context.Context, string) (*v0alpha1.RepositoryURLs, error)) *MockRepositoryWithURLs_RefURLs_Call { + _c.Call.Return(run) + return _c +} + +// ResourceURLs provides a mock function with given fields: ctx, file +func (_m *MockRepositoryWithURLs) ResourceURLs(ctx context.Context, file *FileInfo) (*v0alpha1.RepositoryURLs, error) { + ret := _m.Called(ctx, file) + + if len(ret) == 0 { + panic("no return value specified for ResourceURLs") + } + + var r0 *v0alpha1.RepositoryURLs + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *FileInfo) (*v0alpha1.RepositoryURLs, error)); ok { + return rf(ctx, file) + } + if rf, ok := ret.Get(0).(func(context.Context, *FileInfo) *v0alpha1.RepositoryURLs); ok { + r0 = rf(ctx, file) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v0alpha1.RepositoryURLs) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *FileInfo) error); ok { + r1 = rf(ctx, file) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockRepositoryWithURLs_ResourceURLs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResourceURLs' +type MockRepositoryWithURLs_ResourceURLs_Call struct { + *mock.Call +} + +// ResourceURLs is a helper method to define mock.On call +// - ctx context.Context +// - file *FileInfo +func (_e *MockRepositoryWithURLs_Expecter) ResourceURLs(ctx interface{}, file interface{}) *MockRepositoryWithURLs_ResourceURLs_Call { + return &MockRepositoryWithURLs_ResourceURLs_Call{Call: _e.mock.On("ResourceURLs", ctx, file)} +} + +func (_c *MockRepositoryWithURLs_ResourceURLs_Call) Run(run func(ctx context.Context, file *FileInfo)) *MockRepositoryWithURLs_ResourceURLs_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*FileInfo)) + }) + return _c +} + +func (_c *MockRepositoryWithURLs_ResourceURLs_Call) Return(_a0 *v0alpha1.RepositoryURLs, _a1 error) *MockRepositoryWithURLs_ResourceURLs_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockRepositoryWithURLs_ResourceURLs_Call) RunAndReturn(run func(context.Context, *FileInfo) (*v0alpha1.RepositoryURLs, error)) *MockRepositoryWithURLs_ResourceURLs_Call { + _c.Call.Return(run) + return _c +} + +// Test provides a mock function with given fields: ctx +func (_m *MockRepositoryWithURLs) 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 +} + +// MockRepositoryWithURLs_Test_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Test' +type MockRepositoryWithURLs_Test_Call struct { + *mock.Call +} + +// Test is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockRepositoryWithURLs_Expecter) Test(ctx interface{}) *MockRepositoryWithURLs_Test_Call { + return &MockRepositoryWithURLs_Test_Call{Call: _e.mock.On("Test", ctx)} +} + +func (_c *MockRepositoryWithURLs_Test_Call) Run(run func(ctx context.Context)) *MockRepositoryWithURLs_Test_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockRepositoryWithURLs_Test_Call) Return(_a0 *v0alpha1.TestResults, _a1 error) *MockRepositoryWithURLs_Test_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockRepositoryWithURLs_Test_Call) RunAndReturn(run func(context.Context) (*v0alpha1.TestResults, error)) *MockRepositoryWithURLs_Test_Call { + _c.Call.Return(run) + return _c +} + +// Validate provides a mock function with no fields +func (_m *MockRepositoryWithURLs) 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 +} + +// MockRepositoryWithURLs_Validate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Validate' +type MockRepositoryWithURLs_Validate_Call struct { + *mock.Call +} + +// Validate is a helper method to define mock.On call +func (_e *MockRepositoryWithURLs_Expecter) Validate() *MockRepositoryWithURLs_Validate_Call { + return &MockRepositoryWithURLs_Validate_Call{Call: _e.mock.On("Validate")} +} + +func (_c *MockRepositoryWithURLs_Validate_Call) Run(run func()) *MockRepositoryWithURLs_Validate_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockRepositoryWithURLs_Validate_Call) Return(_a0 field.ErrorList) *MockRepositoryWithURLs_Validate_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockRepositoryWithURLs_Validate_Call) RunAndReturn(run func() field.ErrorList) *MockRepositoryWithURLs_Validate_Call { + _c.Call.Return(run) + return _c +} + +// NewMockRepositoryWithURLs creates a new instance of MockRepositoryWithURLs. 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 NewMockRepositoryWithURLs(t interface { + mock.TestingT + Cleanup(func()) +}) *MockRepositoryWithURLs { + mock := &MockRepositoryWithURLs{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/registry/apis/provisioning/resources/dualwriter.go b/pkg/registry/apis/provisioning/resources/dualwriter.go index 78042c35a12..24e605847e2 100644 --- a/pkg/registry/apis/provisioning/resources/dualwriter.go +++ b/pkg/registry/apis/provisioning/resources/dualwriter.go @@ -62,7 +62,7 @@ func (r *DualReadWriter) Read(ctx context.Context, path string, ref string) (*Pa // Fail as we use the dry run for this response and it's not about updating the resource if err := parsed.DryRun(ctx); err != nil { - return nil, apierrors.NewBadRequest(fmt.Sprintf("Dry run failed: %v", err)) + return nil, fmt.Errorf("error running dryRun: %w", err) } // Authorize based on the existing resource @@ -105,25 +105,22 @@ func (r *DualReadWriter) Delete(ctx context.Context, opts DualWriteOptions) (*Pa } parsed.Action = provisioning.ResourceActionDelete + + // Use the parser's DryRun method like create/update operations + if !opts.SkipDryRun { + if err := parsed.DryRun(ctx); err != nil { + return nil, fmt.Errorf("error running dryRun for delete: %w", err) + } + } + err = r.repo.Delete(ctx, opts.Path, opts.Ref, opts.Message) if err != nil { return nil, fmt.Errorf("delete file from repository: %w", err) } - // Delete the file in the grafana database + // Delete the file in the grafana database using the parser's Run method if opts.Ref == "" { - ctx, _, err := identity.WithProvisioningIdentity(ctx, parsed.Obj.GetNamespace()) - if err != nil { - return parsed, err - } - - // FIXME: empty folders with no repository files will remain in the system - // until the next reconciliation. - err = parsed.Client.Delete(ctx, parsed.Obj.GetName(), metav1.DeleteOptions{}) - if apierrors.IsNotFound(err) { - err = nil // ignorable - } - + err = parsed.Run(ctx) if err != nil { return nil, fmt.Errorf("delete resource from storage: %w", err) } @@ -224,13 +221,12 @@ func (r *DualReadWriter) createOrUpdate(ctx context.Context, create bool, opts D logger := logging.FromContext(ctx).With("path", opts.Path, "name", parsed.Obj.GetName(), "ref", opts.Ref) logger.Warn("failed to dry run resource on create", "error", err) - // TODO: return this as a 400 rather than 500 - return nil, fmt.Errorf("error running dryRun %w", err) + return nil, fmt.Errorf("error running dryRun: %w", err) } } if len(parsed.Errors) > 0 { - // TODO: return this as a 400 rather than 500 + // Now returns BadRequest (400) for validation errors return nil, fmt.Errorf("errors while parsing file [%v]", parsed.Errors) } @@ -449,6 +445,7 @@ func (r *DualReadWriter) moveFile(ctx context.Context, opts DualWriteOptions) (* // Perform the move operation in the repository // If we have new content, we need to update the file content as part of the move if len(opts.Data) > 0 { + // FIXME: I think we should MOVE + UPDATE instead of Delete / Create // For moves with content updates, we need to delete the old file and create the new one if err = r.repo.Delete(ctx, opts.OriginalPath, opts.Ref, opts.Message); err != nil { return nil, fmt.Errorf("delete original file in repository: %w", err) @@ -576,7 +573,7 @@ func (r *DualReadWriter) deleteFolder(ctx context.Context, opts DualWriteOptions return folderDeleteResponse(ctx, opts.Path, opts.Ref, r.repo) } -func getFolderURLs(ctx context.Context, path, ref string, repo repository.Repository) (*provisioning.ResourceURLs, error) { +func getFolderURLs(ctx context.Context, path, ref string, repo repository.Repository) (*provisioning.RepositoryURLs, error) { if urlRepo, ok := repo.(repository.RepositoryWithURLs); ok && ref != "" { urls, err := urlRepo.ResourceURLs(ctx, &repository.FileInfo{Path: path, Ref: ref}) if err != nil { diff --git a/pkg/registry/apis/provisioning/resources/object.go b/pkg/registry/apis/provisioning/resources/object.go index a30694417bb..f97d6199c76 100644 --- a/pkg/registry/apis/provisioning/resources/object.go +++ b/pkg/registry/apis/provisioning/resources/object.go @@ -146,6 +146,12 @@ func (o *ResourceListerFromSearch) Stats(ctx context.Context, namespace, reposit Resource: v.Resource, Count: v.Count, }) + // Everything is unmanaged in legacy storage + stats.Unmanaged = append(stats.Unmanaged, provisioning.ResourceCount{ + Group: v.Group, + Resource: v.Resource, + Count: v.Count, + }) } return stats, nil } @@ -157,12 +163,35 @@ func (o *ResourceListerFromSearch) Stats(ctx context.Context, namespace, reposit if err != nil { return nil, err } + + // Create a map to track managed counts by group/resource + managedCounts := make(map[string]int64) + for _, manager := range stats.Managed { + for _, managedStat := range manager.Stats { + key := managedStat.Group + ":" + managedStat.Resource + managedCounts[key] += managedStat.Count + } + } + for _, v := range info.Stats { stats.Instance = append(stats.Instance, provisioning.ResourceCount{ Group: v.Group, Resource: v.Resource, Count: v.Count, }) + + // Calculate unmanaged count: total - managed + key := v.Group + ":" + v.Resource + managedCount := managedCounts[key] + unmanagedCount := v.Count - managedCount + + if unmanagedCount > 0 { + stats.Unmanaged = append(stats.Unmanaged, provisioning.ResourceCount{ + Group: v.Group, + Resource: v.Resource, + Count: unmanagedCount, + }) + } } return stats, nil } diff --git a/pkg/registry/apis/provisioning/resources/parser.go b/pkg/registry/apis/provisioning/resources/parser.go index 2b3f0c2deac..35b94007390 100644 --- a/pkg/registry/apis/provisioning/resources/parser.go +++ b/pkg/registry/apis/provisioning/resources/parser.go @@ -87,7 +87,7 @@ type ParsedResource struct { Repo provisioning.ResourceRepositoryInfo // Resource URLs - URLs *provisioning.ResourceURLs + URLs *provisioning.RepositoryURLs // Check for classic file types (dashboard.json, etc) Classic provisioning.ClassicFileType @@ -168,8 +168,8 @@ func (r *parser) Parse(ctx context.Context, info *repository.FileInfo) (parsed * if obj.GetNamespace() != "" && obj.GetNamespace() != r.repo.Namespace { return nil, apierrors.NewBadRequest("the file namespace does not match target namespace") } - obj.SetNamespace(r.repo.Namespace) + parsed.Meta.SetManagerProperties(utils.ManagerProperties{ Kind: utils.ManagerKindRepo, Identity: r.repo.Name, @@ -232,9 +232,48 @@ func (f *ParsedResource) DryRun(ctx context.Context) error { fieldValidation = "Ignore" // FIXME: temporary while we improve validation } + // Handle deletion action separately + if f.Action == provisioning.ResourceActionDelete { + // For delete, we need the existing resource to validate deletion + f.Existing, err = f.Client.Get(ctx, f.Obj.GetName(), metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + // Resource doesn't exist, nothing to delete - this is fine for dry run + return nil + } + return fmt.Errorf("failed to get existing resource for delete dry run: %w", err) + } + + // Check for ownership conflicts + requestingManager := utils.ManagerProperties{ + Kind: utils.ManagerKindRepo, + Identity: f.Repo.Name, + } + if err := CheckResourceOwnership(f.Existing, f.Obj.GetName(), requestingManager); err != nil { + return err + } + + // For delete dry run, we simulate the delete operation + // The dry run response will be the existing resource that would be deleted + f.DryRunResponse = f.Existing.DeepCopy() + return nil + } + // FIXME: shouldn't we check for the specific error? // Dry run CREATE or UPDATE f.Existing, _ = f.Client.Get(ctx, f.Obj.GetName(), metav1.GetOptions{}) + + // Check for ownership conflicts after fetching existing resource + requestingManager := utils.ManagerProperties{ + Kind: utils.ManagerKindRepo, + Identity: f.Repo.Name, + } + + // Check for ownership conflicts after fetching existing resource + if err := CheckResourceOwnership(f.Existing, f.Obj.GetName(), requestingManager); err != nil { + return err + } + if f.Existing == nil { f.Action = provisioning.ResourceActionCreate f.DryRunResponse, err = f.Client.Create(ctx, f.Obj, metav1.CreateOptions{ @@ -268,6 +307,55 @@ func (f *ParsedResource) Run(ctx context.Context) error { fieldValidation = "Ignore" // FIXME: temporary while we improve validation } + // Check for ownership conflicts + requestingManager := utils.ManagerProperties{ + Kind: utils.ManagerKindRepo, + Identity: f.Repo.Name, + } + + // Handle deletion action + if f.Action == provisioning.ResourceActionDelete { + // If we don't have existing resource from DryRun, fetch it now + if f.DryRunResponse == nil { + f.Existing, err = f.Client.Get(ctx, f.Obj.GetName(), metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + // Resource doesn't exist, nothing to delete - this is fine + return nil + } + return fmt.Errorf("failed to get existing resource for delete: %w", err) + } + } + + // Check ownership with the existing resource + if err := CheckResourceOwnership(f.Existing, f.Obj.GetName(), requestingManager); err != nil { + return err + } + + // Perform the actual delete + err = f.Client.Delete(ctx, f.Obj.GetName(), metav1.DeleteOptions{}) + if apierrors.IsNotFound(err) { + err = nil // ignorable - resource was already deleted + } + + // Set the deleted resource as the result + if err == nil && f.Existing != nil { + f.Upsert = f.Existing.DeepCopy() + } + + return err + } + + // If we don't have existing resource from DryRun, fetch it now + if f.DryRunResponse == nil { + f.Existing, _ = f.Client.Get(ctx, f.Obj.GetName(), metav1.GetOptions{}) + } + + // Check ownership with the existing resource (if any) + if err := CheckResourceOwnership(f.Existing, f.Obj.GetName(), requestingManager); err != nil { + return err + } + // If we have already tried loading existing, start with create if f.DryRunResponse != nil && f.Existing == nil { f.Action = provisioning.ResourceActionCreate diff --git a/pkg/registry/apis/provisioning/resources/repository_test.go b/pkg/registry/apis/provisioning/resources/repository_test.go index c53512c3c0a..0c1c97f564f 100644 --- a/pkg/registry/apis/provisioning/resources/repository_test.go +++ b/pkg/registry/apis/provisioning/resources/repository_test.go @@ -5,6 +5,7 @@ import ( "errors" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -435,3 +436,132 @@ func (m *MockDynamicResourceInterface) ApplyStatus(ctx context.Context, name str // Ensure MockDynamicResourceInterface implements dynamic.ResourceInterface var _ dynamic.ResourceInterface = (*MockDynamicResourceInterface)(nil) + +func TestCheckResourceOwnership(t *testing.T) { + tests := []struct { + name string + existingResource *unstructured.Unstructured + requestingManager utils.ManagerProperties + expectError bool + expectedMessage string + }{ + { + name: "no existing resource - allow operation", + existingResource: nil, // Explicitly nil to represent non-existing resource + requestingManager: utils.ManagerProperties{ + Kind: utils.ManagerKindRepo, + Identity: "repo-1", + }, + expectError: false, + }, + { + name: "existing resource with no manager - allow operation", + existingResource: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test-resource", + }, + }, + }, + requestingManager: utils.ManagerProperties{ + Kind: utils.ManagerKindRepo, + Identity: "repo-1", + }, + expectError: false, + }, + { + name: "same manager - allow operation", + existingResource: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test-resource", + "annotations": map[string]interface{}{ + utils.AnnoKeyManagerKind: "repo", + utils.AnnoKeyManagerIdentity: "repo-1", + }, + }, + }, + }, + requestingManager: utils.ManagerProperties{ + Kind: utils.ManagerKindRepo, + Identity: "repo-1", + }, + expectError: false, + }, + { + name: "different manager but allows edits - allow operation", + existingResource: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test-resource", + "annotations": map[string]interface{}{ + utils.AnnoKeyManagerKind: "repo", + utils.AnnoKeyManagerIdentity: "repo-1", + utils.AnnoKeyManagerAllowsEdits: "true", + }, + }, + }, + }, + requestingManager: utils.ManagerProperties{ + Kind: utils.ManagerKindRepo, + Identity: "repo-2", + }, + expectError: false, + }, + { + name: "different manager and doesn't allow edits - deny operation", + existingResource: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test-resource", + "annotations": map[string]interface{}{ + utils.AnnoKeyManagerKind: "repo", + utils.AnnoKeyManagerIdentity: "repo-1", + }, + }, + }, + }, + requestingManager: utils.ManagerProperties{ + Kind: utils.ManagerKindRepo, + Identity: "repo-2", + }, + expectError: true, + expectedMessage: "resource 'test-resource' is managed by repo 'repo-1' and cannot be modified by repo 'repo-2'", + }, + { + name: "different manager types - deny operation", + existingResource: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "test-resource", + "annotations": map[string]interface{}{ + utils.AnnoKeyManagerKind: "terraform", + utils.AnnoKeyManagerIdentity: "tf-stack-1", + }, + }, + }, + }, + requestingManager: utils.ManagerProperties{ + Kind: utils.ManagerKindRepo, + Identity: "repo-1", + }, + expectError: true, + expectedMessage: "resource 'test-resource' is managed by terraform 'tf-stack-1' and cannot be modified by repo 'repo-1'", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test the package-level ownership check function directly + err := CheckResourceOwnership(tt.existingResource, "test-resource", tt.requestingManager) + + if tt.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedMessage) + assert.True(t, apierrors.IsBadRequest(err)) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/registry/apis/provisioning/resources/resources.go b/pkg/registry/apis/provisioning/resources/resources.go index ec5942c21ec..cbd5cb120c6 100644 --- a/pkg/registry/apis/provisioning/resources/resources.go +++ b/pkg/registry/apis/provisioning/resources/resources.go @@ -25,6 +25,19 @@ var ( ErrMissingName = field.Required(field.NewPath("name", "metadata", "name"), "missing name in resource") ) +// NewResourceOwnershipConflictError creates a BadRequest error for when a resource +// is owned by a different repository or manager and cannot be modified +func NewResourceOwnershipConflictError(resourceName string, currentManager utils.ManagerProperties, requestingManager utils.ManagerProperties) error { + message := fmt.Sprintf("resource '%s' is managed by %s '%s' and cannot be modified by %s '%s'", + resourceName, + currentManager.Kind, + currentManager.Identity, + requestingManager.Kind, + requestingManager.Identity) + + return apierrors.NewBadRequest(message) +} + type WriteOptions struct { Path string Ref string @@ -54,6 +67,45 @@ func NewResourcesManager(repo repository.ReaderWriter, folders *FolderManager, p } } +// CheckResourceOwnership validates that the requesting manager can modify the existing resource +// Returns an error if the existing resource is owned by a different manager that doesn't allow edits +// If existingResource is nil, no ownership conflict exists (new resource) +// This is a package-level function that can be used without a ResourcesManager instance +func CheckResourceOwnership(existingResource *unstructured.Unstructured, resourceName string, requestingManager utils.ManagerProperties) error { + if existingResource == nil { + // Resource doesn't exist, so no ownership conflict + return nil + } + + // Check if the existing resource has manager properties + existingMeta, err := utils.MetaAccessor(existingResource) + if err != nil { + // If we can't get metadata, allow the operation + return nil + } + + currentManager, hasManager := existingMeta.GetManagerProperties() + if !hasManager { + // No manager information, so no ownership conflict + return nil + } + + // Check if this is the same manager + if currentManager.Kind == requestingManager.Kind && currentManager.Identity == requestingManager.Identity { + // Same manager, no conflict + return nil + } + + // Check if the current manager allows edits + if currentManager.AllowsEdits { + // Manager allows edits from others, no conflict + return nil + } + + // Different manager and edits not allowed - return ownership conflict error + return NewResourceOwnershipConflictError(resourceName, currentManager, requestingManager) +} + // CreateResource writes an object to the repository func (r *ResourcesManager) WriteResourceFileFromObject(ctx context.Context, obj *unstructured.Unstructured, options WriteOptions) (string, error) { if err := ctx.Err(); err != nil { diff --git a/pkg/registry/apis/provisioning/response.go b/pkg/registry/apis/provisioning/response.go new file mode 100644 index 00000000000..20fab310c68 --- /dev/null +++ b/pkg/registry/apis/provisioning/response.go @@ -0,0 +1,18 @@ +package provisioning + +import ( + "errors" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apiserver/pkg/registry/rest" +) + +// respondWithError checks if the provided error contains an API error and unwraps it before passing it to the responder. +func respondWithError(responder rest.Responder, err error) { + var statusErr *apierrors.StatusError + if errors.As(err, &statusErr) { + responder.Error(statusErr) + } else { + responder.Error(err) + } +} diff --git a/pkg/registry/apis/provisioning/routes.go b/pkg/registry/apis/provisioning/routes.go index 749c4a8d2ed..5136555bf41 100644 --- a/pkg/registry/apis/provisioning/routes.go +++ b/pkg/registry/apis/provisioning/routes.go @@ -53,7 +53,7 @@ func (b *APIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes { MediaTypeProps: spec3.MediaTypeProps{ Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ - Ref: spec.MustCreateRef("#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ResourceStats"), + Ref: spec.MustCreateRef("#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ResourceStats"), }, }, }, @@ -101,7 +101,7 @@ func (b *APIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes { MediaTypeProps: spec3.MediaTypeProps{ Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ - Ref: spec.MustCreateRef("#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.RepositoryViewList"), + Ref: spec.MustCreateRef("#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.RepositoryViewList"), }, }, }, diff --git a/pkg/registry/apis/provisioning/secrets/secret.go b/pkg/registry/apis/provisioning/secrets/secret.go index 7b1de7fee32..9c58a1f39ae 100644 --- a/pkg/registry/apis/provisioning/secrets/secret.go +++ b/pkg/registry/apis/provisioning/secrets/secret.go @@ -4,14 +4,14 @@ import ( "context" "errors" - "github.com/grafana/grafana/pkg/registry/apis/secret" - "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" - "github.com/grafana/grafana/pkg/setting" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/pkg/registry/apis/secret" + "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + "github.com/grafana/grafana/pkg/setting" ) const svcName = provisioning.GROUP @@ -109,7 +109,7 @@ func (s *secretsService) Encrypt(ctx context.Context, namespace, name string, da } func (s *secretsService) Decrypt(ctx context.Context, namespace string, name string) ([]byte, error) { - results, err := s.decryptSvc.Decrypt(ctx, svcName, namespace, []string{name}) + results, err := s.decryptSvc.Decrypt(ctx, svcName, namespace, name) if err != nil { return nil, err } diff --git a/pkg/registry/apis/provisioning/secrets/secret_mock.go b/pkg/registry/apis/provisioning/secrets/secret_mock.go index 29c9c04f364..3366e8e1255 100644 --- a/pkg/registry/apis/provisioning/secrets/secret_mock.go +++ b/pkg/registry/apis/provisioning/secrets/secret_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.52.4. DO NOT EDIT. +// Code generated by mockery v2.53.4. DO NOT EDIT. package secrets diff --git a/pkg/registry/apis/provisioning/secrets/secret_test.go b/pkg/registry/apis/provisioning/secrets/secret_test.go index d7097975f43..744b9ccbd41 100644 --- a/pkg/registry/apis/provisioning/secrets/secret_test.go +++ b/pkg/registry/apis/provisioning/secrets/secret_test.go @@ -5,11 +5,6 @@ import ( "errors" "testing" - secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" - "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets/mocks" - "github.com/grafana/grafana/pkg/registry/apis/secret" - "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" - "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -17,6 +12,11 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" + + secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" + "github.com/grafana/grafana/pkg/registry/apis/secret" + "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + "github.com/grafana/grafana/pkg/setting" ) // mockDynamicInterface implements a simplified version of the dynamic.ResourceInterface @@ -49,7 +49,7 @@ func (m *mockDynamicInterface) Delete(ctx context.Context, name string, options func TestNewSecretsService(t *testing.T) { mockSecretsSvc := NewMockSecureValueClient(t) - mockDecryptSvc := &mocks.MockDecryptService{} + mockDecryptSvc := &secret.MockDecryptService{} svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, &setting.Cfg{}) @@ -64,7 +64,7 @@ func TestSecretsService_Encrypt(t *testing.T) { namespace string secretName string data string - setupMocks func(*MockSecureValueClient, *mocks.MockDecryptService, *mockDynamicInterface) + setupMocks func(*MockSecureValueClient, *secret.MockDecryptService, *mockDynamicInterface) expectedName string expectedError string }{ @@ -73,7 +73,7 @@ func TestSecretsService_Encrypt(t *testing.T) { namespace: "test-namespace", secretName: "test-secret", data: "secret-data", - setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *mocks.MockDecryptService, mockResourceInterface *mockDynamicInterface) { + setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *secret.MockDecryptService, mockResourceInterface *mockDynamicInterface) { // Setup client to return the mock resource interface mockSecretsSvc.EXPECT().Client(mock.Anything, "test-namespace").Return(mockResourceInterface, nil) @@ -99,7 +99,7 @@ func TestSecretsService_Encrypt(t *testing.T) { namespace: "test-namespace", secretName: "existing-secret", data: "new-secret-data", - setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *mocks.MockDecryptService, mockResourceInterface *mockDynamicInterface) { + setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *secret.MockDecryptService, mockResourceInterface *mockDynamicInterface) { existingSecret := &unstructured.Unstructured{ Object: map[string]interface{}{ "metadata": map[string]interface{}{ @@ -138,7 +138,7 @@ func TestSecretsService_Encrypt(t *testing.T) { namespace: "test-namespace", secretName: "test-secret", data: "secret-data", - setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *mocks.MockDecryptService, mockResourceInterface *mockDynamicInterface) { + setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *secret.MockDecryptService, mockResourceInterface *mockDynamicInterface) { // Setup client to return the mock resource interface mockSecretsSvc.EXPECT().Client(mock.Anything, "test-namespace").Return(mockResourceInterface, nil) @@ -153,7 +153,7 @@ func TestSecretsService_Encrypt(t *testing.T) { namespace: "test-namespace", secretName: "test-secret", data: "secret-data", - setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *mocks.MockDecryptService, mockResourceInterface *mockDynamicInterface) { + setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *secret.MockDecryptService, mockResourceInterface *mockDynamicInterface) { // Setup client to return the mock resource interface mockSecretsSvc.EXPECT().Client(mock.Anything, "test-namespace").Return(mockResourceInterface, nil) @@ -172,7 +172,7 @@ func TestSecretsService_Encrypt(t *testing.T) { namespace: "test-namespace", secretName: "existing-secret", data: "new-secret-data", - setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *mocks.MockDecryptService, mockResourceInterface *mockDynamicInterface) { + setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *secret.MockDecryptService, mockResourceInterface *mockDynamicInterface) { existingSecret := &unstructured.Unstructured{ Object: map[string]interface{}{ "metadata": map[string]interface{}{ @@ -204,7 +204,7 @@ func TestSecretsService_Encrypt(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockSecretsSvc := NewMockSecureValueClient(t) - mockDecryptSvc := &mocks.MockDecryptService{} + mockDecryptSvc := &secret.MockDecryptService{} mockResourceInterface := &mockDynamicInterface{} tt.setupMocks(mockSecretsSvc, mockDecryptSvc, mockResourceInterface) @@ -228,7 +228,7 @@ func TestSecretsService_Encrypt(t *testing.T) { func TestSecretsService_Encrypt_ClientError(t *testing.T) { mockSecretsSvc := NewMockSecureValueClient(t) - mockDecryptSvc := &mocks.MockDecryptService{} + mockDecryptSvc := &secret.MockDecryptService{} // Setup client to return error mockSecretsSvc.EXPECT().Client(mock.Anything, "test-namespace").Return(nil, errors.New("client error")) @@ -249,7 +249,7 @@ func TestSecretsService_Decrypt(t *testing.T) { name string namespace string secretName string - setupMocks func(*MockSecureValueClient, *mocks.MockDecryptService) + setupMocks func(*MockSecureValueClient, *secret.MockDecryptService) expectedResult []byte expectedError string }{ @@ -257,7 +257,7 @@ func TestSecretsService_Decrypt(t *testing.T) { name: "successfully decrypt secret", namespace: "test-namespace", secretName: "test-secret", - setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *mocks.MockDecryptService) { + setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *secret.MockDecryptService) { exposedValue := secretv1beta1.NewExposedSecureValue("decrypted-data") mockResult := secret.NewDecryptResultValue(&exposedValue) @@ -268,7 +268,7 @@ func TestSecretsService_Decrypt(t *testing.T) { }), svcName, "test-namespace", - []string{"test-secret"}, + "test-secret", ).Return(map[string]secret.DecryptResult{ "test-secret": mockResult, }, nil) @@ -279,14 +279,14 @@ func TestSecretsService_Decrypt(t *testing.T) { name: "decrypt service error", namespace: "test-namespace", secretName: "test-secret", - setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *mocks.MockDecryptService) { + setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *secret.MockDecryptService) { mockDecryptSvc.EXPECT().Decrypt( mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }), svcName, "test-namespace", - []string{"test-secret"}, + "test-secret", ).Return(nil, errors.New("decrypt service error")) }, expectedError: "decrypt service error", @@ -295,14 +295,14 @@ func TestSecretsService_Decrypt(t *testing.T) { name: "secret not found in results", namespace: "test-namespace", secretName: "test-secret", - setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *mocks.MockDecryptService) { + setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *secret.MockDecryptService) { mockDecryptSvc.EXPECT().Decrypt( mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }), svcName, "test-namespace", - []string{"test-secret"}, + "test-secret", ).Return(map[string]secret.DecryptResult{}, nil) }, expectedError: secret.ErrDecryptNotFound.Error(), @@ -311,7 +311,7 @@ func TestSecretsService_Decrypt(t *testing.T) { name: "decrypt result has error", namespace: "test-namespace", secretName: "test-secret", - setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *mocks.MockDecryptService) { + setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *secret.MockDecryptService) { mockResult := secret.NewDecryptResultErr(errors.New("decryption failed")) mockDecryptSvc.EXPECT().Decrypt( @@ -320,7 +320,7 @@ func TestSecretsService_Decrypt(t *testing.T) { }), svcName, "test-namespace", - []string{"test-secret"}, + "test-secret", ).Return(map[string]secret.DecryptResult{ "test-secret": mockResult, }, nil) @@ -332,7 +332,7 @@ func TestSecretsService_Decrypt(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockSecretsSvc := NewMockSecureValueClient(t) - mockDecryptSvc := &mocks.MockDecryptService{} + mockDecryptSvc := &secret.MockDecryptService{} tt.setupMocks(mockSecretsSvc, mockDecryptSvc) @@ -356,7 +356,7 @@ func TestSecretsService_Decrypt(t *testing.T) { // Test to verify that the Decrypt method creates the correct service identity context func TestSecretsService_Decrypt_ServiceIdentityContext(t *testing.T) { mockSecretsSvc := NewMockSecureValueClient(t) - mockDecryptSvc := &mocks.MockDecryptService{} + mockDecryptSvc := &secret.MockDecryptService{} exposedValue := secretv1beta1.NewExposedSecureValue("test-data") mockResult := secret.NewDecryptResultValue(&exposedValue) @@ -369,7 +369,7 @@ func TestSecretsService_Decrypt_ServiceIdentityContext(t *testing.T) { }), svcName, "test-namespace", - []string{"test-secret"}, + "test-secret", ).Return(map[string]secret.DecryptResult{ "test-secret": mockResult, }, nil) @@ -388,14 +388,14 @@ func TestSecretsService_Delete(t *testing.T) { name string namespace string secretName string - setupMocks func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *mocks.MockDecryptService, mockResourceInterface *mockDynamicInterface) + setupMocks func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *secret.MockDecryptService, mockResourceInterface *mockDynamicInterface) expectedError string }{ { name: "delete success", namespace: "test-namespace", secretName: "test-secret", - setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *mocks.MockDecryptService, mockResourceInterface *mockDynamicInterface) { + setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *secret.MockDecryptService, mockResourceInterface *mockDynamicInterface) { // Setup client to return the mock resource interface mockSecretsSvc.EXPECT().Client(mock.Anything, "test-namespace").Return(mockResourceInterface, nil) @@ -407,7 +407,7 @@ func TestSecretsService_Delete(t *testing.T) { name: "delete returns error", namespace: "test-namespace", secretName: "test-secret", - setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *mocks.MockDecryptService, mockResourceInterface *mockDynamicInterface) { + setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *secret.MockDecryptService, mockResourceInterface *mockDynamicInterface) { // Setup client to return the mock resource interface mockSecretsSvc.EXPECT().Client(mock.Anything, "test-namespace").Return(mockResourceInterface, nil) @@ -420,7 +420,7 @@ func TestSecretsService_Delete(t *testing.T) { name: "client error", namespace: "test-namespace", secretName: "test-secret", - setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *mocks.MockDecryptService, mockResourceInterface *mockDynamicInterface) { + setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *secret.MockDecryptService, mockResourceInterface *mockDynamicInterface) { // Setup client to return error mockSecretsSvc.EXPECT().Client(mock.Anything, "test-namespace").Return(nil, errors.New("client error")) }, @@ -431,7 +431,7 @@ func TestSecretsService_Delete(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockSecretsSvc := NewMockSecureValueClient(t) - mockDecryptSvc := &mocks.MockDecryptService{} + mockDecryptSvc := &secret.MockDecryptService{} mockResourceInterface := &mockDynamicInterface{} tt.setupMocks(mockSecretsSvc, mockDecryptSvc, mockResourceInterface) @@ -499,7 +499,7 @@ func TestIsNotFoundError(t *testing.T) { func TestSecretsService_Encrypt_WithK8sNotFoundError(t *testing.T) { mockSecretsSvc := NewMockSecureValueClient(t) - mockDecryptSvc := &mocks.MockDecryptService{} + mockDecryptSvc := &secret.MockDecryptService{} mockResourceInterface := &mockDynamicInterface{} // Setup client to return the mock resource interface @@ -532,7 +532,7 @@ func TestSecretsService_Encrypt_WithK8sNotFoundError(t *testing.T) { func TestSecretsService_Delete_WithK8sNotFoundError(t *testing.T) { mockSecretsSvc := NewMockSecureValueClient(t) - mockDecryptSvc := &mocks.MockDecryptService{} + mockDecryptSvc := &secret.MockDecryptService{} mockResourceInterface := &mockDynamicInterface{} // Setup client to return the mock resource interface @@ -554,7 +554,7 @@ func TestSecretsService_Delete_WithK8sNotFoundError(t *testing.T) { func TestSecretsService_Delete_WithGrafanaNotFoundError(t *testing.T) { mockSecretsSvc := NewMockSecureValueClient(t) - mockDecryptSvc := &mocks.MockDecryptService{} + mockDecryptSvc := &secret.MockDecryptService{} mockResourceInterface := &mockDynamicInterface{} // Setup client to return the mock resource interface diff --git a/pkg/registry/apis/provisioning/secrets/secure_value_client_mock.go b/pkg/registry/apis/provisioning/secrets/secure_value_client_mock.go index e00d66395ef..25b9a4ca3b8 100644 --- a/pkg/registry/apis/provisioning/secrets/secure_value_client_mock.go +++ b/pkg/registry/apis/provisioning/secrets/secure_value_client_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.52.4. DO NOT EDIT. +// Code generated by mockery v2.53.4. DO NOT EDIT. package secrets diff --git a/pkg/registry/apis/query/client/instance_provider.go b/pkg/registry/apis/query/client/instance_provider.go index 8716ae5a41c..96769229daf 100644 --- a/pkg/registry/apis/query/client/instance_provider.go +++ b/pkg/registry/apis/query/client/instance_provider.go @@ -25,7 +25,7 @@ type singleTenantInstance struct { cfg *setting.Cfg } -func (t *singleTenantInstance) GetDataSourceClient(_ context.Context, _ data.DataSourceRef, _ map[string]string) (clientapi.QueryDataClient, error) { +func (t *singleTenantInstance) GetDataSourceClient(_ context.Context, _ data.DataSourceRef) (clientapi.QueryDataClient, error) { return t.client, nil } @@ -37,7 +37,7 @@ func NewSingleTenantInstanceProvider(cfg *setting.Cfg, features featuremgmt.Feat } } -func (s *singleTenantInstanceProvider) GetInstance(_ context.Context) (clientapi.Instance, error) { +func (s *singleTenantInstanceProvider) GetInstance(_ context.Context, _ map[string]string) (clientapi.Instance, error) { return &singleTenantInstance{ client: s.client, features: s.features, @@ -59,3 +59,7 @@ func (s *singleTenantInstance) GetLogger(parent log.Logger) log.Logger { // currently we do not add any extra info return parent.New() } + +func (s *singleTenantInstance) ReportMetrics() { + // we do not report any metrics currently +} diff --git a/pkg/registry/apis/query/clientapi/clientapi.go b/pkg/registry/apis/query/clientapi/clientapi.go index 46f9167199b..c35e28a4b9e 100644 --- a/pkg/registry/apis/query/clientapi/clientapi.go +++ b/pkg/registry/apis/query/clientapi/clientapi.go @@ -29,12 +29,13 @@ type InstanceConfigurationSettings struct { } type Instance interface { - GetDataSourceClient(ctx context.Context, ref data.DataSourceRef, headers map[string]string) (QueryDataClient, error) + GetDataSourceClient(ctx context.Context, ref data.DataSourceRef) (QueryDataClient, error) // fetch information on the grafana instance (e.g. feature toggles) GetSettings() InstanceConfigurationSettings GetLogger(parent log.Logger) log.Logger + ReportMetrics() // some metrics are only reported at the end } type InstanceProvider interface { - GetInstance(ctx context.Context) (Instance, error) + GetInstance(ctx context.Context, headers map[string]string) (Instance, error) } diff --git a/pkg/registry/apis/query/query.go b/pkg/registry/apis/query/query.go index ae202728a0d..f2b574de933 100644 --- a/pkg/registry/apis/query/query.go +++ b/pkg/registry/apis/query/query.go @@ -246,7 +246,7 @@ func handleQuery(ctx context.Context, raw query.QueryDataRequest, b QueryAPIBuil headers := ExtractKnownHeaders(httpreq.Header) - instance, err := b.instanceProvider.GetInstance(ctx) + instance, err := b.instanceProvider.GetInstance(ctx, headers) if err != nil { connectLogger.Error("failed to get instance configuration settings", "err", err) responder.Error(err) @@ -260,7 +260,6 @@ func handleQuery(ctx context.Context, raw query.QueryDataRequest, b QueryAPIBuil mtDsClientBuilder := mtdsclient.NewMtDatasourceClientBuilderWithInstance( instance, ctx, - headers, dsQuerierLoggerWithSlug, ) @@ -281,6 +280,10 @@ func handleQuery(ctx context.Context, raw query.QueryDataRequest, b QueryAPIBuil qdr, err := service.QueryData(ctx, dsQuerierLoggerWithSlug, cache, exprService, mReq, mtDsClientBuilder, headers) + // tell the `instance` structure that it can now report + // metrics that are only reported once during a request + instance.ReportMetrics() + if err != nil { return qdr, err } diff --git a/pkg/registry/apis/query/query_test.go b/pkg/registry/apis/query/query_test.go index cd74c0dca0f..a1861361b11 100644 --- a/pkg/registry/apis/query/query_test.go +++ b/pkg/registry/apis/query/query_test.go @@ -250,18 +250,21 @@ type mockClient struct { stubbedFrame *data.Frame } -func (m mockClient) GetInstance(ctx context.Context) (clientapi.Instance, error) { +func (m mockClient) GetInstance(ctx context.Context, headers map[string]string) (clientapi.Instance, error) { mclient := mockClient{ stubbedFrame: m.stubbedFrame, } return mclient, nil } +func (m mockClient) ReportMetrics() { +} + func (m mockClient) GetLogger(parent log.Logger) log.Logger { return parent.New() } -func (m mockClient) GetDataSourceClient(ctx context.Context, ref dataapi.DataSourceRef, headers map[string]string) (clientapi.QueryDataClient, error) { +func (m mockClient) GetDataSourceClient(ctx context.Context, ref dataapi.DataSourceRef) (clientapi.QueryDataClient, error) { mclient := mockClient{ stubbedFrame: m.stubbedFrame, } diff --git a/pkg/registry/apis/secret/contracts/decrypt.go b/pkg/registry/apis/secret/contracts/decrypt.go index 2a630420315..d42b1c7c958 100644 --- a/pkg/registry/apis/secret/contracts/decrypt.go +++ b/pkg/registry/apis/secret/contracts/decrypt.go @@ -24,12 +24,12 @@ type DecryptStorage interface { // DecryptAuthorizer is the interface for authorizing decryption requests. type DecryptAuthorizer interface { - Authorize(ctx context.Context, secureValueName string, secureValueDecrypters []string) (identity string, allowed bool) + Authorize(ctx context.Context, namespace xkube.Namespace, secureValueName string, secureValueDecrypters []string) (identity string, allowed bool) } // DecryptService is the interface for the decrypt service. type DecryptService interface { - Decrypt(ctx context.Context, serviceName string, namespace string, names []string) (map[string]DecryptResult, error) + Decrypt(ctx context.Context, serviceName string, namespace string, names ...string) (map[string]DecryptResult, error) } // DecryptResult is the (union) result of a decryption operation. diff --git a/pkg/registry/apis/secret/contracts/keeper.go b/pkg/registry/apis/secret/contracts/keeper.go index 48db9a13dc0..5558a4db3b5 100644 --- a/pkg/registry/apis/secret/contracts/keeper.go +++ b/pkg/registry/apis/secret/contracts/keeper.go @@ -10,7 +10,8 @@ import ( ) var ( - ErrKeeperNotFound = errors.New("keeper not found") + ErrKeeperNotFound = errors.New("keeper not found") + ErrKeeperAlreadyExists = errors.New("keeper already exists") ) // KeeperMetadataStorage is the interface for wiring and dependency injection. diff --git a/pkg/registry/apis/secret/contracts/mutator.go b/pkg/registry/apis/secret/contracts/mutator.go new file mode 100644 index 00000000000..110b0d90ba9 --- /dev/null +++ b/pkg/registry/apis/secret/contracts/mutator.go @@ -0,0 +1,14 @@ +package contracts + +import ( + secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" + "k8s.io/apiserver/pkg/admission" +) + +type SecureValueMutator interface { + Mutate(sv *secretv1beta1.SecureValue, operation admission.Operation) error +} + +type KeeperMutator interface { + Mutate(kp *secretv1beta1.Keeper, operation admission.Operation) error +} diff --git a/pkg/registry/apis/secret/contracts/validator.go b/pkg/registry/apis/secret/contracts/validator.go index a0f326ab268..0e68cc874eb 100644 --- a/pkg/registry/apis/secret/contracts/validator.go +++ b/pkg/registry/apis/secret/contracts/validator.go @@ -5,6 +5,7 @@ import ( "k8s.io/apiserver/pkg/admission" secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" + "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" ) type SecureValueValidator interface { @@ -14,3 +15,21 @@ type SecureValueValidator interface { type KeeperValidator interface { Validate(keeper *secretv1beta1.Keeper, oldKeeper *secretv1beta1.Keeper, operation admission.Operation) field.ErrorList } + +type ErrValidateSecureValue struct { + list field.ErrorList +} + +var _ xkube.ErrorLister = (*ErrValidateSecureValue)(nil) + +func NewErrValidateSecureValue(list field.ErrorList) *ErrValidateSecureValue { + return &ErrValidateSecureValue{list: list} +} + +func (e *ErrValidateSecureValue) Error() string { + return e.list.ToAggregate().Error() +} + +func (e *ErrValidateSecureValue) ErrorList() field.ErrorList { + return e.list +} diff --git a/pkg/registry/apis/secret/decrypt/authorizer.go b/pkg/registry/apis/secret/decrypt/authorizer.go index 042ec12e0ab..ca112302e63 100644 --- a/pkg/registry/apis/secret/decrypt/authorizer.go +++ b/pkg/registry/apis/secret/decrypt/authorizer.go @@ -11,6 +11,7 @@ import ( secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" ) // decryptAuthorizer is the authorizer implementation for decrypt operations. @@ -25,7 +26,7 @@ func ProvideDecryptAuthorizer(tracer trace.Tracer) contracts.DecryptAuthorizer { } // authorize checks whether the auth info token has the right permissions to decrypt the secure value. -func (a *decryptAuthorizer) Authorize(ctx context.Context, secureValueName string, secureValueDecrypters []string) (id string, isAllowed bool) { +func (a *decryptAuthorizer) Authorize(ctx context.Context, ns xkube.Namespace, secureValueName string, secureValueDecrypters []string) (id string, isAllowed bool) { ctx, span := a.tracer.Start(ctx, "DecryptAuthorizer.Authorize", trace.WithAttributes( attribute.String("name", secureValueName), attribute.StringSlice("decrypters", secureValueDecrypters), @@ -44,6 +45,10 @@ func (a *decryptAuthorizer) Authorize(ctx context.Context, secureValueName strin return "", false } + if !claims.NamespaceMatches(authInfo.GetNamespace(), ns.String()) { + return "", false + } + serviceIdentityList, ok := authInfo.GetExtra()[authn.ServiceIdentityKey] if !ok { return "", false diff --git a/pkg/registry/apis/secret/decrypt/authorizer_test.go b/pkg/registry/apis/secret/decrypt/authorizer_test.go index 131ec33fb8b..61008b9faf8 100644 --- a/pkg/registry/apis/secret/decrypt/authorizer_test.go +++ b/pkg/registry/apis/secret/decrypt/authorizer_test.go @@ -10,43 +10,45 @@ import ( "go.opentelemetry.io/otel/trace/noop" "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" ) func TestDecryptAuthorizer(t *testing.T) { tracer := noop.NewTracerProvider().Tracer("test") + defaultNs := xkube.Namespace("default") t.Run("when no auth info is present, it returns false", func(t *testing.T) { ctx := context.Background() authorizer := ProvideDecryptAuthorizer(tracer) - identity, allowed := authorizer.Authorize(ctx, "", nil) + identity, allowed := authorizer.Authorize(ctx, defaultNs, "", nil) require.Empty(t, identity) require.False(t, allowed) }) t.Run("when token permissions are empty, it returns false", func(t *testing.T) { - ctx := createAuthContext(context.Background(), "identity", []string{}) + ctx := createAuthContext(context.Background(), defaultNs.String(), "identity", []string{}) authorizer := ProvideDecryptAuthorizer(tracer) - identity, allowed := authorizer.Authorize(ctx, "", nil) + identity, allowed := authorizer.Authorize(ctx, defaultNs, "", nil) require.NotEmpty(t, identity) require.False(t, allowed) }) t.Run("when service identity is empty, it returns false", func(t *testing.T) { - ctx := createAuthContext(context.Background(), "", []string{}) + ctx := createAuthContext(context.Background(), defaultNs.String(), "", []string{}) authorizer := ProvideDecryptAuthorizer(tracer) - identity, allowed := authorizer.Authorize(ctx, "", nil) + identity, allowed := authorizer.Authorize(ctx, defaultNs, "", nil) require.Empty(t, identity) require.False(t, allowed) }) t.Run("when service identity is empty string, it returns false", func(t *testing.T) { - ctx := createAuthContext(context.Background(), " ", []string{}) + ctx := createAuthContext(context.Background(), defaultNs.String(), " ", []string{}) authorizer := ProvideDecryptAuthorizer(tracer) - identity, allowed := authorizer.Authorize(ctx, "", nil) + identity, allowed := authorizer.Authorize(ctx, defaultNs, "", nil) require.Empty(t, identity) require.False(t, allowed) }) @@ -55,14 +57,14 @@ func TestDecryptAuthorizer(t *testing.T) { authorizer := ProvideDecryptAuthorizer(tracer) // nameless - ctx := createAuthContext(context.Background(), "identity", []string{"secret.grafana.app/securevalues"}) - identity, allowed := authorizer.Authorize(ctx, "", nil) + ctx := createAuthContext(context.Background(), defaultNs.String(), "identity", []string{"secret.grafana.app/securevalues"}) + identity, allowed := authorizer.Authorize(ctx, defaultNs, "", nil) require.NotEmpty(t, identity) require.False(t, allowed) // named - ctx = createAuthContext(context.Background(), "identity", []string{"secret.grafana.app/securevalues/name"}) - identity, allowed = authorizer.Authorize(ctx, "", nil) + ctx = createAuthContext(context.Background(), defaultNs.String(), "identity", []string{"secret.grafana.app/securevalues/name"}) + identity, allowed = authorizer.Authorize(ctx, defaultNs, "", nil) require.NotEmpty(t, identity) require.False(t, allowed) }) @@ -71,32 +73,32 @@ func TestDecryptAuthorizer(t *testing.T) { authorizer := ProvideDecryptAuthorizer(tracer) // nameless - ctx := createAuthContext(context.Background(), "identity", []string{"secret.grafana.app/securevalues:*"}) - identity, allowed := authorizer.Authorize(ctx, "", nil) + ctx := createAuthContext(context.Background(), defaultNs.String(), "identity", []string{"secret.grafana.app/securevalues:*"}) + identity, allowed := authorizer.Authorize(ctx, defaultNs, "", nil) require.NotEmpty(t, identity) require.False(t, allowed) // named - ctx = createAuthContext(context.Background(), "identity", []string{"secret.grafana.app/securevalues/name:something"}) - identity, allowed = authorizer.Authorize(ctx, "", nil) + ctx = createAuthContext(context.Background(), defaultNs.String(), "identity", []string{"secret.grafana.app/securevalues/name:something"}) + identity, allowed = authorizer.Authorize(ctx, defaultNs, "", nil) require.NotEmpty(t, identity) require.False(t, allowed) }) t.Run("when permission does not have 2 or 3 parts, it returns false", func(t *testing.T) { - ctx := createAuthContext(context.Background(), "identity", []string{"secret.grafana.app:decrypt"}) + ctx := createAuthContext(context.Background(), defaultNs.String(), "identity", []string{"secret.grafana.app:decrypt"}) authorizer := ProvideDecryptAuthorizer(tracer) - identity, allowed := authorizer.Authorize(ctx, "", nil) + identity, allowed := authorizer.Authorize(ctx, defaultNs, "", nil) require.NotEmpty(t, identity) require.False(t, allowed) }) t.Run("when permission has group that is not `secret.grafana.app`, it returns false", func(t *testing.T) { - ctx := createAuthContext(context.Background(), "identity", []string{"wrong.group/securevalues/invalid:decrypt"}) + ctx := createAuthContext(context.Background(), defaultNs.String(), "identity", []string{"wrong.group/securevalues/invalid:decrypt"}) authorizer := ProvideDecryptAuthorizer(tracer) - identity, allowed := authorizer.Authorize(ctx, "", nil) + identity, allowed := authorizer.Authorize(ctx, defaultNs, "", nil) require.NotEmpty(t, identity) require.False(t, allowed) }) @@ -105,23 +107,23 @@ func TestDecryptAuthorizer(t *testing.T) { authorizer := ProvideDecryptAuthorizer(tracer) // nameless - ctx := createAuthContext(context.Background(), "identity", []string{"secret.grafana.app/invalid-resource:decrypt"}) - identity, allowed := authorizer.Authorize(ctx, "", nil) + ctx := createAuthContext(context.Background(), defaultNs.String(), "identity", []string{"secret.grafana.app/invalid-resource:decrypt"}) + identity, allowed := authorizer.Authorize(ctx, defaultNs, "", nil) require.NotEmpty(t, identity) require.False(t, allowed) // named - ctx = createAuthContext(context.Background(), "identity", []string{"secret.grafana.app/invalid-resource/name:decrypt"}) - identity, allowed = authorizer.Authorize(ctx, "", nil) + ctx = createAuthContext(context.Background(), defaultNs.String(), "identity", []string{"secret.grafana.app/invalid-resource/name:decrypt"}) + identity, allowed = authorizer.Authorize(ctx, defaultNs, "", nil) require.NotEmpty(t, identity) require.False(t, allowed) }) t.Run("when the allow list is empty, it allows all identities", func(t *testing.T) { - ctx := createAuthContext(context.Background(), "identity", []string{"secret.grafana.app/securevalues:decrypt"}) + ctx := createAuthContext(context.Background(), defaultNs.String(), "identity", []string{"secret.grafana.app/securevalues:decrypt"}) authorizer := ProvideDecryptAuthorizer(tracer) - identity, allowed := authorizer.Authorize(ctx, "", []string{"identity"}) + identity, allowed := authorizer.Authorize(ctx, defaultNs, "", []string{"identity"}) require.NotEmpty(t, identity) require.True(t, allowed) }) @@ -130,14 +132,14 @@ func TestDecryptAuthorizer(t *testing.T) { authorizer := ProvideDecryptAuthorizer(tracer) // nameless - ctx := createAuthContext(context.Background(), "identity", []string{"secret.grafana.app/securevalues:decrypt"}) - identity, allowed := authorizer.Authorize(ctx, "", []string{"group2"}) + ctx := createAuthContext(context.Background(), defaultNs.String(), "identity", []string{"secret.grafana.app/securevalues:decrypt"}) + identity, allowed := authorizer.Authorize(ctx, defaultNs, "", []string{"group2"}) require.NotEmpty(t, identity) require.False(t, allowed) // named - ctx = createAuthContext(context.Background(), "identity", []string{"secret.grafana.app/securevalues/name:decrypt"}) - identity, allowed = authorizer.Authorize(ctx, "", []string{"group2"}) + ctx = createAuthContext(context.Background(), defaultNs.String(), "identity", []string{"secret.grafana.app/securevalues/name:decrypt"}) + identity, allowed = authorizer.Authorize(ctx, defaultNs, "", []string{"group2"}) require.NotEmpty(t, identity) require.False(t, allowed) }) @@ -146,20 +148,20 @@ func TestDecryptAuthorizer(t *testing.T) { authorizer := ProvideDecryptAuthorizer(tracer) // nameless - ctx := createAuthContext(context.Background(), "identity", []string{"secret.grafana.app/securevalues:decrypt"}) - identity, allowed := authorizer.Authorize(ctx, "", []string{"identity"}) + ctx := createAuthContext(context.Background(), defaultNs.String(), "identity", []string{"secret.grafana.app/securevalues:decrypt"}) + identity, allowed := authorizer.Authorize(ctx, defaultNs, "", []string{"identity"}) require.True(t, allowed) require.Equal(t, "identity", identity) // named - ctx = createAuthContext(context.Background(), "identity", []string{"secret.grafana.app/securevalues/name:decrypt"}) - identity, allowed = authorizer.Authorize(ctx, "name", []string{"identity"}) + ctx = createAuthContext(context.Background(), defaultNs.String(), "identity", []string{"secret.grafana.app/securevalues/name:decrypt"}) + identity, allowed = authorizer.Authorize(ctx, defaultNs, "name", []string{"identity"}) require.True(t, allowed) require.Equal(t, "identity", identity) }) t.Run("when there are multiple permissions, some invalid, only valid ones are considered", func(t *testing.T) { - ctx := createAuthContext(context.Background(), "identity", []string{ + ctx := createAuthContext(context.Background(), defaultNs.String(), "identity", []string{ "secret.grafana.app/securevalues/name1:decrypt", "secret.grafana.app/securevalues/name2:decrypt", "secret.grafana.app/securevalues/invalid:read", @@ -168,47 +170,47 @@ func TestDecryptAuthorizer(t *testing.T) { }) authorizer := ProvideDecryptAuthorizer(tracer) - identity, allowed := authorizer.Authorize(ctx, "name1", []string{"identity"}) + identity, allowed := authorizer.Authorize(ctx, defaultNs, "name1", []string{"identity"}) require.True(t, allowed) require.Equal(t, "identity", identity) - identity, allowed = authorizer.Authorize(ctx, "name2", []string{"identity"}) + identity, allowed = authorizer.Authorize(ctx, defaultNs, "name2", []string{"identity"}) require.True(t, allowed) require.Equal(t, "identity", identity) }) t.Run("when empty secure value name with specific permission, it returns false", func(t *testing.T) { - ctx := createAuthContext(context.Background(), "identity", []string{"secret.grafana.app/securevalues/name:decrypt"}) + ctx := createAuthContext(context.Background(), defaultNs.String(), "identity", []string{"secret.grafana.app/securevalues/name:decrypt"}) authorizer := ProvideDecryptAuthorizer(tracer) - identity, allowed := authorizer.Authorize(ctx, "", []string{"identity"}) + identity, allowed := authorizer.Authorize(ctx, defaultNs, "", []string{"identity"}) require.Equal(t, "identity", identity) require.False(t, allowed) }) t.Run("when permission has an extra / but no name, it returns false", func(t *testing.T) { - ctx := createAuthContext(context.Background(), "identity", []string{"secret.grafana.app/securevalues/:decrypt"}) + ctx := createAuthContext(context.Background(), defaultNs.String(), "identity", []string{"secret.grafana.app/securevalues/:decrypt"}) authorizer := ProvideDecryptAuthorizer(tracer) - identity, allowed := authorizer.Authorize(ctx, "", []string{"identity"}) + identity, allowed := authorizer.Authorize(ctx, defaultNs, "", []string{"identity"}) require.Equal(t, "identity", identity) require.False(t, allowed) }) t.Run("when the decrypters list is empty, meaning nothing can decrypt the secure value, it returns false", func(t *testing.T) { - ctx := createAuthContext(context.Background(), "identity", []string{"secret.grafana.app/securevalues:decrypt"}) + ctx := createAuthContext(context.Background(), defaultNs.String(), "identity", []string{"secret.grafana.app/securevalues:decrypt"}) authorizer := ProvideDecryptAuthorizer(tracer) - identity, allowed := authorizer.Authorize(ctx, "name", []string{}) + identity, allowed := authorizer.Authorize(ctx, defaultNs, "name", []string{}) require.Equal(t, "identity", identity) require.False(t, allowed) }) t.Run("when one of decrypters matches the identity, it returns true", func(t *testing.T) { - ctx := createAuthContext(context.Background(), "identity1", []string{"secret.grafana.app/securevalues:decrypt"}) + ctx := createAuthContext(context.Background(), defaultNs.String(), "identity1", []string{"secret.grafana.app/securevalues:decrypt"}) authorizer := ProvideDecryptAuthorizer(tracer) - identity, allowed := authorizer.Authorize(ctx, "", []string{"identity1", "identity2", "identity3"}) + identity, allowed := authorizer.Authorize(ctx, defaultNs, "", []string{"identity1", "identity2", "identity3"}) require.Equal(t, "identity1", identity) require.True(t, allowed) }) @@ -216,25 +218,35 @@ func TestDecryptAuthorizer(t *testing.T) { t.Run("permissions must be case-sensitive and return false", func(t *testing.T) { authorizer := ProvideDecryptAuthorizer(tracer) - ctx := createAuthContext(context.Background(), "identity", []string{"SECRET.grafana.app/securevalues:decrypt"}) - identity, allowed := authorizer.Authorize(ctx, "", []string{"identity"}) + ctx := createAuthContext(context.Background(), defaultNs.String(), "identity", []string{"SECRET.grafana.app/securevalues:decrypt"}) + identity, allowed := authorizer.Authorize(ctx, defaultNs, "", []string{"identity"}) require.Equal(t, "identity", identity) require.False(t, allowed) - ctx = createAuthContext(context.Background(), "identity", []string{"secret.grafana.app/SECUREVALUES:decrypt"}) - identity, allowed = authorizer.Authorize(ctx, "", []string{"identity"}) + ctx = createAuthContext(context.Background(), defaultNs.String(), "identity", []string{"secret.grafana.app/SECUREVALUES:decrypt"}) + identity, allowed = authorizer.Authorize(ctx, defaultNs, "", []string{"identity"}) require.Equal(t, "identity", identity) require.False(t, allowed) - ctx = createAuthContext(context.Background(), "identity", []string{"secret.grafana.app/securevalues:DECRYPT"}) - identity, allowed = authorizer.Authorize(ctx, "", []string{"identity"}) + ctx = createAuthContext(context.Background(), defaultNs.String(), "identity", []string{"secret.grafana.app/securevalues:DECRYPT"}) + identity, allowed = authorizer.Authorize(ctx, defaultNs, "", []string{"identity"}) require.Equal(t, "identity", identity) require.False(t, allowed) }) + + t.Run("when namespace doesn't match the token's, it returns false", func(t *testing.T) { + authorizer := ProvideDecryptAuthorizer(tracer) + + ctx := createAuthContext(context.Background(), "namespace1", "identity", []string{"secret.grafana.app/securevalues:decrypt"}) + identity, allowed := authorizer.Authorize(ctx, "namespace2", "", []string{"identity"}) + require.Empty(t, identity) + require.False(t, allowed) + }) } -func createAuthContext(ctx context.Context, serviceIdentity string, permissions []string) context.Context { +func createAuthContext(ctx context.Context, namespace string, serviceIdentity string, permissions []string) context.Context { requester := &identity.StaticRequester{ + Namespace: namespace, AccessTokenClaims: &authn.Claims[authn.AccessTokenClaims]{ Rest: authn.AccessTokenClaims{ Permissions: permissions, diff --git a/pkg/registry/apis/secret/decrypt/grpc_client.go b/pkg/registry/apis/secret/decrypt/grpc_client.go index 356a04f9ccf..9919ec67f9b 100644 --- a/pkg/registry/apis/secret/decrypt/grpc_client.go +++ b/pkg/registry/apis/secret/decrypt/grpc_client.go @@ -6,17 +6,19 @@ import ( "crypto/x509" "errors" "fmt" + "maps" "os" + "slices" "github.com/fullstorydev/grpchan" - authnlib "github.com/grafana/authlib/authn" - "github.com/grafana/authlib/types" "go.opentelemetry.io/otel/trace" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" + authnlib "github.com/grafana/authlib/authn" + "github.com/grafana/authlib/types" decryptv1beta1 "github.com/grafana/grafana/apps/secret/decrypt/v1beta1" secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" @@ -109,12 +111,22 @@ func createTLSCredentials(config TLSConfig) (credentials.TransportCredentials, e } // Decrypt a set of secure value names in a given namespace for a specific service name. -func (g *GRPCDecryptClient) Decrypt(ctx context.Context, serviceName string, namespace string, names []string) (map[string]contracts.DecryptResult, error) { +func (g *GRPCDecryptClient) Decrypt(ctx context.Context, serviceName string, namespace string, names ...string) (map[string]contracts.DecryptResult, error) { _, err := types.ParseNamespace(namespace) if err != nil { return nil, err } + unique := make(map[string]bool, len(names)) + for _, v := range names { + if v != "" { + unique[v] = true + } + } + if len(unique) < 1 { + return map[string]contracts.DecryptResult{}, nil + } + tokenExchangerInterceptor := authnlib.NewGrpcClientInterceptor( g.tokenExchanger, authnlib.WithClientInterceptorTracer(g.tracer), @@ -132,7 +144,7 @@ func (g *GRPCDecryptClient) Decrypt(ctx context.Context, serviceName string, nam req := &decryptv1beta1.SecureValueDecryptRequest{ Namespace: namespace, - Names: names, + Names: slices.Collect(maps.Keys(unique)), } // Decryption will still use the service identity from the auth token, diff --git a/pkg/registry/apis/secret/decrypt/local_client.go b/pkg/registry/apis/secret/decrypt/local_client.go index e9fc5daa618..a565fcc06f9 100644 --- a/pkg/registry/apis/secret/decrypt/local_client.go +++ b/pkg/registry/apis/secret/decrypt/local_client.go @@ -21,7 +21,7 @@ func NewLocalDecryptClient(decryptStorage contracts.DecryptStorage) (*LocalDecry }, nil } -func (c *LocalDecryptClient) Decrypt(ctx context.Context, serviceName, namespace string, names []string) (map[string]contracts.DecryptResult, error) { +func (c *LocalDecryptClient) Decrypt(ctx context.Context, serviceName, namespace string, names ...string) (map[string]contracts.DecryptResult, error) { ns, err := types.ParseNamespace(namespace) if err != nil { return nil, err @@ -32,6 +32,10 @@ func (c *LocalDecryptClient) Decrypt(ctx context.Context, serviceName, namespace results := make(map[string]contracts.DecryptResult, len(names)) for _, name := range names { + _, found := results[name] + if found || name == "" { + continue // no need to decrypt + } exposedSecureValue, err := c.decryptStorage.Decrypt(ctx, xkube.Namespace(namespace), name) if err != nil { results[name] = contracts.NewDecryptResultErr(err) diff --git a/pkg/registry/apis/secret/decrypt/noop_authorizer.go b/pkg/registry/apis/secret/decrypt/noop_authorizer.go index 079d49b67ab..f2fe29cbc48 100644 --- a/pkg/registry/apis/secret/decrypt/noop_authorizer.go +++ b/pkg/registry/apis/secret/decrypt/noop_authorizer.go @@ -4,6 +4,7 @@ import ( "context" "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" ) // NoopAlwaysAllowedAuthorizer is a no-op implementation of the DecryptAuthorizer which always returns `allowed=true`. @@ -11,6 +12,6 @@ type NoopAlwaysAllowedAuthorizer struct{} var _ contracts.DecryptAuthorizer = &NoopAlwaysAllowedAuthorizer{} -func (a *NoopAlwaysAllowedAuthorizer) Authorize(ctx context.Context, secureValueName string, secureValueDecrypters []string) (identity string, allowed bool) { +func (a *NoopAlwaysAllowedAuthorizer) Authorize(context.Context, xkube.Namespace, string, []string) (string, bool) { return "", true } diff --git a/pkg/registry/apis/secret/decrypt/service_test.go b/pkg/registry/apis/secret/decrypt/service_test.go index 66013f8408d..e1fd85a6fe3 100644 --- a/pkg/registry/apis/secret/decrypt/service_test.go +++ b/pkg/registry/apis/secret/decrypt/service_test.go @@ -9,8 +9,6 @@ import ( "strings" "testing" - decryptv1beta1 "github.com/grafana/grafana/apps/secret/decrypt/v1beta1" - secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel/trace/noop" @@ -19,6 +17,8 @@ import ( "google.golang.org/grpc/credentials" "google.golang.org/grpc/metadata" + decryptv1beta1 "github.com/grafana/grafana/apps/secret/decrypt/v1beta1" + secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" "github.com/grafana/grafana/pkg/registry/apis/secret/decrypt" "github.com/grafana/grafana/pkg/registry/apis/secret/testutils" @@ -48,7 +48,7 @@ func TestDecryptService(t *testing.T) { decryptService, err := decrypt.ProvideDecryptService(cfg, tracer, mockStorage) require.NoError(t, err) - resp, err := decryptService.Decrypt(ctx, "svc-name", "default", []string{"secure-value-1"}) + resp, err := decryptService.Decrypt(ctx, "svc-name", "default", "secure-value-1") require.NotNil(t, resp) require.NoError(t, err) require.EqualValues(t, decryptedValuesResp, resp) @@ -76,7 +76,7 @@ func TestDecryptService(t *testing.T) { decryptService, err := decrypt.ProvideDecryptService(cfg, tracer, mockStorage) require.NoError(t, err) - resp, err := decryptService.Decrypt(ctx, "svc-name", "default", []string{"secure-value-1", "secure-value-2"}) + resp, err := decryptService.Decrypt(ctx, "svc-name", "default", "secure-value-1", "secure-value-2") require.NotNil(t, resp) require.NoError(t, err) require.EqualValues(t, decryptedValuesResp, resp) @@ -104,7 +104,7 @@ func TestDecryptService(t *testing.T) { decryptService, err := decrypt.ProvideDecryptService(cfg, tracer, mockStorage) require.NoError(t, err) - resp, err := decryptService.Decrypt(ctx, "svc-name", "default", []string{"secure-value-1", "secure-value-2"}) + resp, err := decryptService.Decrypt(ctx, "svc-name", "default", "secure-value-1", "secure-value-2") require.NotNil(t, resp) require.NoError(t, err) require.EqualValues(t, decryptedValuesResp, resp) @@ -221,7 +221,7 @@ func TestDecryptService(t *testing.T) { svcIdentity := "provsysoning-test" - result, err := decryptService.Decrypt(t.Context(), svcIdentity, namespace, []string{"secure-value-1"}) + result, err := decryptService.Decrypt(t.Context(), svcIdentity, namespace, "secure-value-1") require.NoError(t, err) require.NotNil(t, result) require.Len(t, result, 1) diff --git a/pkg/registry/apis/secret/decrypt_client.go b/pkg/registry/apis/secret/decrypt_client.go index 0ed0d3b31a7..41d9ae9aa2d 100644 --- a/pkg/registry/apis/secret/decrypt_client.go +++ b/pkg/registry/apis/secret/decrypt_client.go @@ -6,6 +6,8 @@ import ( ) // DecryptService is a decrypt client for secure value secrets. +// +//go:generate mockery --name DecryptService --structname MockDecryptService --inpackage --filename decrypt_client_mock.go --with-expecter type DecryptService = contracts.DecryptService var ( diff --git a/pkg/registry/apis/provisioning/secrets/mocks/decrypt_service_mock.go b/pkg/registry/apis/secret/decrypt_client_mock.go similarity index 66% rename from pkg/registry/apis/provisioning/secrets/mocks/decrypt_service_mock.go rename to pkg/registry/apis/secret/decrypt_client_mock.go index dd3c37804ef..445941a5b39 100644 --- a/pkg/registry/apis/provisioning/secrets/mocks/decrypt_service_mock.go +++ b/pkg/registry/apis/secret/decrypt_client_mock.go @@ -1,12 +1,13 @@ -// Code generated by mockery v2.52.4. DO NOT EDIT. +// Code generated by mockery v2.53.4. DO NOT EDIT. -package mocks +package secret import ( context "context" - contracts "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" mock "github.com/stretchr/testify/mock" + + contracts "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" ) // MockDecryptService is an autogenerated mock type for the DecryptService type @@ -23,8 +24,15 @@ func (_m *MockDecryptService) EXPECT() *MockDecryptService_Expecter { } // Decrypt provides a mock function with given fields: ctx, serviceName, namespace, names -func (_m *MockDecryptService) Decrypt(ctx context.Context, serviceName string, namespace string, names []string) (map[string]contracts.DecryptResult, error) { - ret := _m.Called(ctx, serviceName, namespace, names) +func (_m *MockDecryptService) Decrypt(ctx context.Context, serviceName string, namespace string, names ...string) (map[string]contracts.DecryptResult, error) { + _va := make([]interface{}, len(names)) + for _i := range names { + _va[_i] = names[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, serviceName, namespace) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) if len(ret) == 0 { panic("no return value specified for Decrypt") @@ -32,19 +40,19 @@ func (_m *MockDecryptService) Decrypt(ctx context.Context, serviceName string, n var r0 map[string]contracts.DecryptResult var r1 error - if rf, ok := ret.Get(0).(func(context.Context, string, string, []string) (map[string]contracts.DecryptResult, error)); ok { - return rf(ctx, serviceName, namespace, names) + if rf, ok := ret.Get(0).(func(context.Context, string, string, ...string) (map[string]contracts.DecryptResult, error)); ok { + return rf(ctx, serviceName, namespace, names...) } - if rf, ok := ret.Get(0).(func(context.Context, string, string, []string) map[string]contracts.DecryptResult); ok { - r0 = rf(ctx, serviceName, namespace, names) + if rf, ok := ret.Get(0).(func(context.Context, string, string, ...string) map[string]contracts.DecryptResult); ok { + r0 = rf(ctx, serviceName, namespace, names...) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(map[string]contracts.DecryptResult) } } - if rf, ok := ret.Get(1).(func(context.Context, string, string, []string) error); ok { - r1 = rf(ctx, serviceName, namespace, names) + if rf, ok := ret.Get(1).(func(context.Context, string, string, ...string) error); ok { + r1 = rf(ctx, serviceName, namespace, names...) } else { r1 = ret.Error(1) } @@ -61,14 +69,21 @@ type MockDecryptService_Decrypt_Call struct { // - ctx context.Context // - serviceName string // - namespace string -// - names []string -func (_e *MockDecryptService_Expecter) Decrypt(ctx interface{}, serviceName interface{}, namespace interface{}, names interface{}) *MockDecryptService_Decrypt_Call { - return &MockDecryptService_Decrypt_Call{Call: _e.mock.On("Decrypt", ctx, serviceName, namespace, names)} +// - names ...string +func (_e *MockDecryptService_Expecter) Decrypt(ctx interface{}, serviceName interface{}, namespace interface{}, names ...interface{}) *MockDecryptService_Decrypt_Call { + return &MockDecryptService_Decrypt_Call{Call: _e.mock.On("Decrypt", + append([]interface{}{ctx, serviceName, namespace}, names...)...)} } -func (_c *MockDecryptService_Decrypt_Call) Run(run func(ctx context.Context, serviceName string, namespace string, names []string)) *MockDecryptService_Decrypt_Call { +func (_c *MockDecryptService_Decrypt_Call) Run(run func(ctx context.Context, serviceName string, namespace string, names ...string)) *MockDecryptService_Decrypt_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].([]string)) + variadicArgs := make([]string, len(args)-3) + for i, a := range args[3:] { + if a != nil { + variadicArgs[i] = a.(string) + } + } + run(args[0].(context.Context), args[1].(string), args[2].(string), variadicArgs...) }) return _c } @@ -78,7 +93,7 @@ func (_c *MockDecryptService_Decrypt_Call) Return(_a0 map[string]contracts.Decry return _c } -func (_c *MockDecryptService_Decrypt_Call) RunAndReturn(run func(context.Context, string, string, []string) (map[string]contracts.DecryptResult, error)) *MockDecryptService_Decrypt_Call { +func (_c *MockDecryptService_Decrypt_Call) RunAndReturn(run func(context.Context, string, string, ...string) (map[string]contracts.DecryptResult, error)) *MockDecryptService_Decrypt_Call { _c.Call.Return(run) return _c } diff --git a/pkg/registry/apis/secret/inline/inline_secure_value.go b/pkg/registry/apis/secret/inline/inline_secure_value.go index 9a24f6ace52..08ce9f3068b 100644 --- a/pkg/registry/apis/secret/inline/inline_secure_value.go +++ b/pkg/registry/apis/secret/inline/inline_secure_value.go @@ -5,19 +5,18 @@ import ( "errors" "fmt" - "github.com/grafana/authlib/authn" - authlib "github.com/grafana/authlib/types" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" + "github.com/grafana/authlib/authn" + authlib "github.com/grafana/authlib/types" secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" - "github.com/grafana/grafana/pkg/util" ) type LocalInlineSecureValueService struct { @@ -158,6 +157,25 @@ func (s *LocalInlineSecureValueService) canIdentityReadSecureValue(ctx context.C return nil } +func (s *LocalInlineSecureValueService) verifyOwnerAndAuth(ctx context.Context, owner common.ObjectReference) (authlib.AuthInfo, error) { + // Any valid identity can create inline secure values + authInfo, ok := authlib.AuthInfoFrom(ctx) + if !ok { + return nil, fmt.Errorf("missing auth info in context") + } + + // Make sure the owner matches the identity when it is not global + if owner.Namespace == "" || !authlib.NamespaceMatches(authInfo.GetNamespace(), owner.Namespace) { + return nil, fmt.Errorf("owner namespace %s does not match auth info namespace %s", owner.Namespace, authInfo.GetNamespace()) + } + + if owner.Namespace == "" || owner.APIGroup == "" || owner.APIVersion == "" || owner.Kind == "" || owner.Name == "" { + return nil, fmt.Errorf("owner reference must have a valid API group, API version, kind, namespace and name") + } + + return authInfo, nil +} + func (s *LocalInlineSecureValueService) CreateInline(ctx context.Context, owner common.ObjectReference, value common.RawSecureValue) (string, error) { ctx, span := s.tracer.Start(ctx, "InlineSecureValueService.CreateInline", trace.WithAttributes( attribute.String("owner.namespace", owner.Namespace), @@ -168,27 +186,9 @@ func (s *LocalInlineSecureValueService) CreateInline(ctx context.Context, owner )) defer span.End() - authInfo, ok := authlib.AuthInfoFrom(ctx) - if !ok { - return "", fmt.Errorf("missing auth info in context") - } - - if authInfo.GetIdentityType() != authlib.TypeUser && authInfo.GetIdentityType() != authlib.TypeServiceAccount { - return "", fmt.Errorf("identity type %s not allowed, expected either %s or %s", authInfo.GetIdentityType(), authlib.TypeUser, authlib.TypeServiceAccount) - } - - serviceIdentityList, ok := authInfo.GetExtra()[authn.ServiceIdentityKey] - if !ok || len(serviceIdentityList) != 1 { - return "", fmt.Errorf("expected exactly one service identity, found %d", len(serviceIdentityList)) - } - serviceIdentity := serviceIdentityList[0] - - if owner.Namespace == "" || !authlib.NamespaceMatches(authInfo.GetNamespace(), owner.Namespace) { - return "", fmt.Errorf("owner namespace %s does not match auth info namespace %s", owner.Namespace, authInfo.GetNamespace()) - } - - if owner.APIGroup == "" || owner.APIVersion == "" || owner.Kind == "" || owner.Name == "" { - return "", fmt.Errorf("owner reference must have a valid API group, API version, kind and name") + authInfo, err := s.verifyOwnerAndAuth(ctx, owner) + if err != nil { + return "", err } if value.IsZero() { @@ -198,14 +198,17 @@ func (s *LocalInlineSecureValueService) CreateInline(ctx context.Context, owner // TODO(2025-07-31): when we migrate to using the common type, we don't need this conversion. secret := secretv1beta1.ExposedSecureValue(value) - decrypters := []string{serviceIdentity} - if owner.APIGroup != serviceIdentity { - decrypters = append(decrypters, owner.APIGroup) + // The owner group can always decrypt + decrypters := []string{owner.APIGroup} + + serviceIdentity, ok := authInfo.GetExtra()[authn.ServiceIdentityKey] + if ok && len(serviceIdentity) > 0 && serviceIdentity[0] != owner.APIGroup { + decrypters = append(decrypters, serviceIdentity[0]) } - spec := &secretv1beta1.SecureValue{ + obj := &secretv1beta1.SecureValue{ ObjectMeta: metav1.ObjectMeta{ - Name: "sv-" + util.GenerateShortUID(), + GenerateName: "inline-", Namespace: owner.Namespace, OwnerReferences: []metav1.OwnerReference{owner.ToOwnerReference()}, }, @@ -216,9 +219,9 @@ func (s *LocalInlineSecureValueService) CreateInline(ctx context.Context, owner }, } - createdSv, err := s.secureValueService.Create(ctx, spec, authInfo.GetUID()) + createdSv, err := s.secureValueService.Create(ctx, obj, authInfo.GetUID()) if err != nil { - return "", fmt.Errorf("error creating secure value %s for owner %v: %w", spec.Name, owner, err) + return "", fmt.Errorf("error creating secure value for owner %v: %w", owner, err) } return createdSv.GetName(), nil @@ -235,17 +238,8 @@ func (s *LocalInlineSecureValueService) DeleteWhenOwnedByResource(ctx context.Co )) defer span.End() - authInfo, ok := authlib.AuthInfoFrom(ctx) - if !ok { - return fmt.Errorf("missing auth info in context") - } - - if owner.Namespace == "" || !authlib.NamespaceMatches(authInfo.GetNamespace(), owner.Namespace) { - return fmt.Errorf("owner namespace %s does not match auth info namespace %s", owner.Namespace, authInfo.GetNamespace()) - } - - if owner.APIGroup == "" || owner.APIVersion == "" || owner.Kind == "" || owner.Name == "" { - return fmt.Errorf("owner reference must have a valid API group, API version, kind and name") + if _, err := s.verifyOwnerAndAuth(ctx, owner); err != nil { + return err } for _, name := range names { diff --git a/pkg/registry/apis/secret/inline/inline_secure_value_test.go b/pkg/registry/apis/secret/inline/inline_secure_value_test.go index 707d4f16711..2de9d0e570b 100644 --- a/pkg/registry/apis/secret/inline/inline_secure_value_test.go +++ b/pkg/registry/apis/secret/inline/inline_secure_value_test.go @@ -3,15 +3,16 @@ package inline_test import ( "testing" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace/noop" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" "github.com/grafana/grafana/pkg/registry/apis/secret/inline" "github.com/grafana/grafana/pkg/registry/apis/secret/testutils" "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" - "github.com/stretchr/testify/require" - "go.opentelemetry.io/otel/trace/noop" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func TestIntegration_InlineSecureValue_CanReference(t *testing.T) { @@ -255,7 +256,7 @@ func TestIntegration_InlineSecureValue_CreateInline(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, createdName) - decryptedValues, err := tu.DecryptService.Decrypt(t.Context(), serviceIdentity, owner.Namespace, []string{createdName}) + decryptedValues, err := tu.DecryptService.Decrypt(t.Context(), serviceIdentity, owner.Namespace, createdName) require.NoError(t, err) decryptedResult, ok := decryptedValues[createdName] @@ -263,12 +264,25 @@ func TestIntegration_InlineSecureValue_CreateInline(t *testing.T) { require.Equal(t, decryptedResult.Value().DangerouslyExposeAndConsumeValue(), rawSecret) // can also decrypt with the owner.APIGroup as a decrypter - decryptedValues, err = tu.DecryptService.Decrypt(t.Context(), owner.APIGroup, owner.Namespace, []string{createdName}) + decryptedValues, err = tu.DecryptService.Decrypt(t.Context(), owner.APIGroup, owner.Namespace, createdName) require.NoError(t, err) decryptedResult, ok = decryptedValues[createdName] require.True(t, ok) require.Equal(t, decryptedResult.Value().DangerouslyExposeAndConsumeValue(), rawSecret) + + // Ignores empty values and duplicates + decryptedValues, err = tu.DecryptService.Decrypt(t.Context(), owner.APIGroup, owner.Namespace, + "", createdName, "", createdName, "") // Empty and duplicate requested names + require.NoError(t, err) + require.Len(t, decryptedValues, 1) + _, ok = decryptedValues[createdName] + require.True(t, ok) + + // empty request + decryptedValues, err = tu.DecryptService.Decrypt(t.Context(), owner.APIGroup, owner.Namespace, "") + require.NoError(t, err) + require.Len(t, decryptedValues, 0) // << empty }) t.Run("when the auth info is missing it returns an error", func(t *testing.T) { diff --git a/pkg/registry/apis/secret/mutator/keeper.go b/pkg/registry/apis/secret/mutator/keeper.go new file mode 100644 index 00000000000..87ff7e9ea6a --- /dev/null +++ b/pkg/registry/apis/secret/mutator/keeper.go @@ -0,0 +1,31 @@ +package mutator + +import ( + "cmp" + "fmt" + + secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" + "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + "github.com/grafana/grafana/pkg/util" + "k8s.io/apiserver/pkg/admission" +) + +type keeperMutator struct{} + +var _ contracts.KeeperMutator = &keeperMutator{} + +func ProvideKeeperMutator() contracts.KeeperMutator { + return &keeperMutator{} +} + +func (*keeperMutator) Mutate(kp *secretv1beta1.Keeper, operation admission.Operation) error { + if kp == nil { + return fmt.Errorf("expected Keeper to be non-nil") + } + + if operation == admission.Create && kp.Name == "" { + kp.SetName(cmp.Or(kp.GetGenerateName(), "kp-") + util.GenerateShortUID()) + } + + return nil +} diff --git a/pkg/registry/apis/secret/mutator/keeper_test.go b/pkg/registry/apis/secret/mutator/keeper_test.go new file mode 100644 index 00000000000..643dfc93795 --- /dev/null +++ b/pkg/registry/apis/secret/mutator/keeper_test.go @@ -0,0 +1,71 @@ +package mutator + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apiserver/pkg/admission" + + secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" +) + +func TestKeeperMutator(t *testing.T) { + mutator := ProvideKeeperMutator() + + t.Run("when keeper is nil, it returns an error", func(t *testing.T) { + err := mutator.Mutate(nil, admission.Create) + require.Error(t, err) + require.Equal(t, "expected Keeper to be non-nil", err.Error()) + }) + + t.Run("when operation is Create and name is empty without GenerateName", func(t *testing.T) { + keeper := &secretv1beta1.Keeper{} + + err := mutator.Mutate(keeper, admission.Create) + require.NoError(t, err) + require.NotEmpty(t, keeper.Name) + require.True(t, strings.HasPrefix(keeper.Name, "kp-")) + }) + + t.Run("when operation is Create and name is already set", func(t *testing.T) { + keeper := &secretv1beta1.Keeper{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-name", + }, + } + originalName := keeper.Name + + err := mutator.Mutate(keeper, admission.Create) + require.NoError(t, err) + require.Equal(t, originalName, keeper.Name) + }) + + t.Run("when operation is Create and GenerateName is set", func(t *testing.T) { + keeper := &secretv1beta1.Keeper{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "custom-prefix-", + }, + } + + err := mutator.Mutate(keeper, admission.Create) + require.NoError(t, err) + require.NotEmpty(t, keeper.Name) + require.True(t, strings.HasPrefix(keeper.Name, "custom-prefix-")) + }) + + t.Run("when operation is Create and both name and GenerateName are set", func(t *testing.T) { + keeper := &secretv1beta1.Keeper{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-name", + GenerateName: "custom-prefix-", + }, + } + originalName := keeper.Name + + err := mutator.Mutate(keeper, admission.Create) + require.NoError(t, err) + require.Equal(t, originalName, keeper.Name) + }) +} diff --git a/pkg/registry/apis/secret/mutator/secure_value.go b/pkg/registry/apis/secret/mutator/secure_value.go new file mode 100644 index 00000000000..a8033127be8 --- /dev/null +++ b/pkg/registry/apis/secret/mutator/secure_value.go @@ -0,0 +1,37 @@ +package mutator + +import ( + "cmp" + "fmt" + + secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" + "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + "github.com/grafana/grafana/pkg/util" + "k8s.io/apiserver/pkg/admission" +) + +type secureValueMutator struct{} + +var _ contracts.SecureValueMutator = &secureValueMutator{} + +func ProvideSecureValueMutator() contracts.SecureValueMutator { + return &secureValueMutator{} +} + +func (*secureValueMutator) Mutate(sv *secretv1beta1.SecureValue, operation admission.Operation) error { + if sv == nil { + return fmt.Errorf("expected SecureValue to be non-nil") + } + + if operation == admission.Create && sv.Name == "" { + sv.SetName(cmp.Or(sv.GetGenerateName(), "sv-") + util.GenerateShortUID()) + } + + // On any mutation to a `SecureValue`, clear the status. + if operation == admission.Create || operation == admission.Update { + sv.Status.ExternalID = "" + sv.Status.Version = 0 + } + + return nil +} diff --git a/pkg/registry/apis/secret/mutator/secure_value_test.go b/pkg/registry/apis/secret/mutator/secure_value_test.go new file mode 100644 index 00000000000..55a94ddf68a --- /dev/null +++ b/pkg/registry/apis/secret/mutator/secure_value_test.go @@ -0,0 +1,179 @@ +package mutator + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apiserver/pkg/admission" + + secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" +) + +func TestSecureValueMutator(t *testing.T) { + mutator := ProvideSecureValueMutator() + + populatedStatus := secretv1beta1.SecureValueStatus{ + ExternalID: "existing-external-id", + Version: 1, + } + + t.Run("when secure value is nil, it returns an error", func(t *testing.T) { + err := mutator.Mutate(nil, admission.Create) + require.Error(t, err) + require.Equal(t, "expected SecureValue to be non-nil", err.Error()) + }) + + t.Run("when operation is Create and name is empty", func(t *testing.T) { + secureValue := &secretv1beta1.SecureValue{ + Status: populatedStatus, + } + + err := mutator.Mutate(secureValue, admission.Create) + require.NoError(t, err) + require.NotEmpty(t, secureValue.Name) + require.True(t, strings.HasPrefix(secureValue.Name, "sv-")) + require.Empty(t, secureValue.Status.ExternalID) + require.Equal(t, int64(0), secureValue.Status.Version) + }) + + t.Run("when operation is Create and name is already set", func(t *testing.T) { + secureValue := &secretv1beta1.SecureValue{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-name", + }, + Status: populatedStatus, + } + originalName := secureValue.Name + + err := mutator.Mutate(secureValue, admission.Create) + require.NoError(t, err) + require.Equal(t, originalName, secureValue.Name) + require.Empty(t, secureValue.Status.ExternalID) + require.Equal(t, int64(0), secureValue.Status.Version) + }) + + t.Run("when operation is Create and GenerateName is set", func(t *testing.T) { + secureValue := &secretv1beta1.SecureValue{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "custom-prefix-", + }, + Status: populatedStatus, + } + + err := mutator.Mutate(secureValue, admission.Create) + require.NoError(t, err) + require.NotEmpty(t, secureValue.Name) + require.True(t, strings.HasPrefix(secureValue.Name, "custom-prefix-")) + require.Empty(t, secureValue.Status.ExternalID) + require.Equal(t, int64(0), secureValue.Status.Version) + }) + + t.Run("when operation is Create and both name and GenerateName are set", func(t *testing.T) { + secureValue := &secretv1beta1.SecureValue{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-name", + GenerateName: "custom-prefix-", + }, + Status: populatedStatus, + } + originalName := secureValue.Name + + err := mutator.Mutate(secureValue, admission.Create) + require.NoError(t, err) + require.Equal(t, originalName, secureValue.Name) + require.Empty(t, secureValue.Status.ExternalID) + require.Equal(t, int64(0), secureValue.Status.Version) + }) + + t.Run("when operation is Update", func(t *testing.T) { + secureValue := &secretv1beta1.SecureValue{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-name", + }, + Status: populatedStatus, + } + originalName := secureValue.Name + + err := mutator.Mutate(secureValue, admission.Update) + require.NoError(t, err) + require.Equal(t, originalName, secureValue.Name) + require.Empty(t, secureValue.Status.ExternalID) + require.Equal(t, int64(0), secureValue.Status.Version) + }) + + t.Run("when operation is Update and name is empty", func(t *testing.T) { + secureValue := &secretv1beta1.SecureValue{ + Status: populatedStatus, + } + + err := mutator.Mutate(secureValue, admission.Update) + require.NoError(t, err) + require.Empty(t, secureValue.Name) + require.Empty(t, secureValue.Status.ExternalID) + require.Equal(t, int64(0), secureValue.Status.Version) + }) + + t.Run("when operation is Delete", func(t *testing.T) { + secureValue := &secretv1beta1.SecureValue{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-name", + }, + Status: populatedStatus, + } + originalName := secureValue.Name + originalExternalID := secureValue.Status.ExternalID + originalVersion := secureValue.Status.Version + + err := mutator.Mutate(secureValue, admission.Delete) + require.NoError(t, err) + require.Equal(t, originalName, secureValue.Name) + require.Equal(t, originalExternalID, secureValue.Status.ExternalID) + require.Equal(t, originalVersion, secureValue.Status.Version) + }) + + t.Run("when operation is Connect", func(t *testing.T) { + secureValue := &secretv1beta1.SecureValue{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-name", + }, + Status: populatedStatus, + } + originalName := secureValue.Name + originalExternalID := secureValue.Status.ExternalID + originalVersion := secureValue.Status.Version + + err := mutator.Mutate(secureValue, admission.Connect) + require.NoError(t, err) + require.Equal(t, originalName, secureValue.Name) + require.Equal(t, originalExternalID, secureValue.Status.ExternalID) + require.Equal(t, originalVersion, secureValue.Status.Version) + }) + + t.Run("when operation is Create with empty status", func(t *testing.T) { + secureValue := &secretv1beta1.SecureValue{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-name", + }, + } + + err := mutator.Mutate(secureValue, admission.Create) + require.NoError(t, err) + require.Empty(t, secureValue.Status.ExternalID) + require.Equal(t, int64(0), secureValue.Status.Version) + }) + + t.Run("when operation is Update with empty status", func(t *testing.T) { + secureValue := &secretv1beta1.SecureValue{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-name", + }, + } + + err := mutator.Mutate(secureValue, admission.Update) + require.NoError(t, err) + require.Empty(t, secureValue.Status.ExternalID) + require.Equal(t, int64(0), secureValue.Status.Version) + }) +} diff --git a/pkg/registry/apis/secret/secure_value_client.go b/pkg/registry/apis/secret/secure_value_client.go index e34f31abd42..faf1eea4ef1 100644 --- a/pkg/registry/apis/secret/secure_value_client.go +++ b/pkg/registry/apis/secret/secure_value_client.go @@ -265,8 +265,17 @@ func (c *secureValueClient) mapError(err error, name string) error { } func (c *secureValueClient) checkAccess(ctx context.Context, name, verb string) error { + authInfo, ok := claims.AuthInfoFrom(ctx) + if !ok { + return apierrors.NewUnauthorized("missing auth info in context") + } + gr := secretv1beta1.SecureValuesResourceInfo.GroupResource() + if !claims.NamespaceMatches(authInfo.GetNamespace(), c.namespace) { + return apierrors.NewForbidden(gr, name, fmt.Errorf("namespace mismatch: %s != %s", authInfo.GetNamespace(), c.namespace)) + } + decision, reason, err := c.access.Authorize(ctx, authorizer.AttributesRecord{ Verb: verb, Namespace: c.namespace, diff --git a/pkg/registry/apis/secret/secure_value_client_test.go b/pkg/registry/apis/secret/secure_value_client_test.go index 44e8349c101..480b8c5062f 100644 --- a/pkg/registry/apis/secret/secure_value_client_test.go +++ b/pkg/registry/apis/secret/secure_value_client_test.go @@ -1,6 +1,7 @@ package secret import ( + "context" "testing" "github.com/stretchr/testify/require" @@ -118,61 +119,75 @@ func TestIntegration_SecureValueClient_CRUD(t *testing.T) { } func Test_SecureValueClient_CRUD_NoPermissions(t *testing.T) { - setup := testutils.Setup(t) - - validator := validator.ProvideSecureValueValidator() - - client := ProvideSecureValueClient( - setup.SecureValueService, - validator, - setup.AccessClient, - ) - ns := "stacks-1234" - ctx := testutils.CreateUserAuthContext(t.Context(), ns, nil) - nsClient, err := client.Client(ctx, ns) - require.NoError(t, err) - require.NotNil(t, nsClient) - - sv := &secretv1beta1.SecureValue{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-sv", - Namespace: ns, - }, + testcases := []struct { + name string + ctx context.Context + errorReason metav1.StatusReason + }{ + {"no auth context", context.Background(), metav1.StatusReasonUnauthorized}, + {"no permissions", testutils.CreateUserAuthContext(t.Context(), ns, nil), metav1.StatusReasonForbidden}, + {"mismatching namespace", testutils.CreateUserAuthContext(t.Context(), "other-ns", nil), metav1.StatusReasonForbidden}, } - unstructured, err := toUnstructured(sv) - require.NoError(t, err) + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + setup := testutils.Setup(t) - // Create - created, err := nsClient.Create(ctx, unstructured, metav1.CreateOptions{}) - var apiErr *apierrors.StatusError - require.ErrorAs(t, err, &apiErr) - require.Equal(t, apiErr.ErrStatus.Reason, metav1.StatusReasonForbidden) - require.Nil(t, created) + validator := validator.ProvideSecureValueValidator() - // Read - read, err := nsClient.Get(ctx, sv.Name, metav1.GetOptions{}) - require.ErrorAs(t, err, &apiErr) - require.Equal(t, apiErr.ErrStatus.Reason, metav1.StatusReasonForbidden) - require.Nil(t, created) + client := ProvideSecureValueClient( + setup.SecureValueService, + validator, + setup.AccessClient, + ) - // Update - updated, err := nsClient.Update(ctx, unstructured, metav1.UpdateOptions{}) - require.ErrorAs(t, err, &apiErr) - require.Equal(t, apiErr.ErrStatus.Reason, metav1.StatusReasonForbidden) - require.Nil(t, updated) + ctx := tc.ctx - // List - list, err := nsClient.List(ctx, metav1.ListOptions{}) - require.ErrorAs(t, err, &apiErr) - require.Equal(t, apiErr.ErrStatus.Reason, metav1.StatusReasonForbidden) - require.Nil(t, list) + nsClient, err := client.Client(ctx, ns) + require.NoError(t, err) + require.NotNil(t, nsClient) - // Delete - err = nsClient.Delete(ctx, sv.Name, metav1.DeleteOptions{}) - require.ErrorAs(t, err, &apiErr) - require.Equal(t, apiErr.ErrStatus.Reason, metav1.StatusReasonForbidden) - require.Nil(t, read) + sv := &secretv1beta1.SecureValue{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sv", + Namespace: ns, + }, + } + + unstructured, err := toUnstructured(sv) + require.NoError(t, err) + + // Create + created, err := nsClient.Create(ctx, unstructured, metav1.CreateOptions{}) + var apiErr *apierrors.StatusError + require.ErrorAs(t, err, &apiErr) + require.Equal(t, apiErr.ErrStatus.Reason, tc.errorReason) + require.Nil(t, created) + + // Read + read, err := nsClient.Get(ctx, sv.Name, metav1.GetOptions{}) + require.ErrorAs(t, err, &apiErr) + require.Equal(t, apiErr.ErrStatus.Reason, tc.errorReason) + require.Nil(t, read) + + // Update + updated, err := nsClient.Update(ctx, unstructured, metav1.UpdateOptions{}) + require.ErrorAs(t, err, &apiErr) + require.Equal(t, apiErr.ErrStatus.Reason, tc.errorReason) + require.Nil(t, updated) + + // List + list, err := nsClient.List(ctx, metav1.ListOptions{}) + require.ErrorAs(t, err, &apiErr) + require.Equal(t, apiErr.ErrStatus.Reason, tc.errorReason) + require.Nil(t, list) + + // Delete + err = nsClient.Delete(ctx, sv.Name, metav1.DeleteOptions{}) + require.ErrorAs(t, err, &apiErr) + require.Equal(t, apiErr.ErrStatus.Reason, tc.errorReason) + }) + } } diff --git a/pkg/registry/apis/secret/service/consolidation_test.go b/pkg/registry/apis/secret/service/consolidation_test.go index 57ac94253c9..638ced19a81 100644 --- a/pkg/registry/apis/secret/service/consolidation_test.go +++ b/pkg/registry/apis/secret/service/consolidation_test.go @@ -81,8 +81,9 @@ func TestConsolidation(t *testing.T) { Namespace: tc.namespace, }, Spec: secretv1beta1.SecureValueSpec{ - Value: ptr.To(secretv1beta1.NewExposedSecureValue(tc.value)), - Decrypters: []string{"decrypter1"}, + Description: "test description", + Value: ptr.To(secretv1beta1.NewExposedSecureValue(tc.value)), + Decrypters: []string{"decrypter1"}, }, } @@ -158,8 +159,9 @@ func TestConsolidation(t *testing.T) { Namespace: tc.namespace, }, Spec: secretv1beta1.SecureValueSpec{ - Value: ptr.To(secretv1beta1.NewExposedSecureValue(tc.value)), - Decrypters: []string{"decrypter1"}, + Description: "test description", + Value: ptr.To(secretv1beta1.NewExposedSecureValue(tc.value)), + Decrypters: []string{"decrypter1"}, }, } diff --git a/pkg/registry/apis/secret/service/metrics/metrics.go b/pkg/registry/apis/secret/service/metrics/metrics.go index b179ead20b7..a1167b1e6ce 100644 --- a/pkg/registry/apis/secret/service/metrics/metrics.go +++ b/pkg/registry/apis/secret/service/metrics/metrics.go @@ -9,20 +9,17 @@ import ( const ( namespace = "grafana_secrets_manager" subsystem = "service" + // labels + successLabel = "success" ) // SecureValueServiceMetrics is a struct that contains all the metrics for SecureValue. type SecureValueServiceMetrics struct { SecureValueCreateDuration *prometheus.HistogramVec - SecureValueCreateCount *prometheus.CounterVec SecureValueUpdateDuration *prometheus.HistogramVec - SecureValueUpdateCount *prometheus.CounterVec SecureValueReadDuration *prometheus.HistogramVec - SecureValueReadCount *prometheus.CounterVec SecureValueListDuration *prometheus.HistogramVec - SecureValueListCount *prometheus.CounterVec SecureValueDeleteDuration *prometheus.HistogramVec - SecureValueDeleteCount *prometheus.CounterVec } func newSecureValueServiceMetrics() *SecureValueServiceMetrics { @@ -33,65 +30,35 @@ func newSecureValueServiceMetrics() *SecureValueServiceMetrics { Name: "secure_value_create_duration_seconds", Help: "Duration of Secure Value create operations", Buckets: prometheus.DefBuckets, - }, []string{"success"}), - SecureValueCreateCount: prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, - Name: "secure_value_create_count", - Help: "Count of Secure Value create operations", - }, []string{"success"}), + }, []string{successLabel}), SecureValueReadDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ Namespace: namespace, Subsystem: subsystem, Name: "secure_value_read_duration_seconds", Help: "Duration of Secure Value read operations", Buckets: prometheus.DefBuckets, - }, []string{"success"}), - SecureValueReadCount: prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, - Name: "secure_value_read_count", - Help: "Count of Secure Value read operations", - }, []string{"success"}), + }, []string{successLabel}), SecureValueUpdateDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ Namespace: namespace, Subsystem: subsystem, Name: "secure_value_update_duration_seconds", Help: "Duration of Secure Value update operations", Buckets: prometheus.DefBuckets, - }, []string{"success"}), - SecureValueUpdateCount: prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, - Name: "secure_value_update_count", - Help: "Count of Secure Value update operations", - }, []string{"success"}), + }, []string{successLabel}), SecureValueListDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ Namespace: namespace, Subsystem: subsystem, Name: "secure_value_list_duration_seconds", Help: "Duration of Secure Value list operations", Buckets: prometheus.DefBuckets, - }, []string{"success"}), - SecureValueListCount: prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, - Name: "secure_value_list_count", - Help: "Count of Secure Value list operations", - }, []string{"success"}), + }, []string{successLabel}), SecureValueDeleteDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ Namespace: namespace, Subsystem: subsystem, Name: "secure_value_delete_duration_seconds", Help: "Duration of Secure Value delete operations", Buckets: prometheus.DefBuckets, - }, []string{"success"}), - SecureValueDeleteCount: prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, - Name: "secure_value_delete_count", - Help: "Count of Secure Value delete operations", - }, []string{"success"}), + }, []string{successLabel}), } } @@ -107,15 +74,10 @@ func NewSecureValueServiceMetrics(reg prometheus.Registerer) *SecureValueService if reg != nil { reg.MustRegister( m.SecureValueCreateDuration, - m.SecureValueCreateCount, m.SecureValueReadDuration, - m.SecureValueReadCount, m.SecureValueUpdateDuration, - m.SecureValueUpdateCount, m.SecureValueListDuration, - m.SecureValueListCount, m.SecureValueDeleteDuration, - m.SecureValueDeleteCount, ) } metricsInstance = m diff --git a/pkg/registry/apis/secret/service/secure_value.go b/pkg/registry/apis/secret/service/secure_value.go index fe32208562d..ae3422acc7c 100644 --- a/pkg/registry/apis/secret/service/secure_value.go +++ b/pkg/registry/apis/secret/service/secure_value.go @@ -9,6 +9,7 @@ import ( claims "github.com/grafana/authlib/types" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" + "k8s.io/apiserver/pkg/admission" "github.com/grafana/grafana-app-sdk/logging" secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" @@ -27,6 +28,8 @@ type SecureValueService struct { accessClient claims.AccessClient database contracts.Database secureValueMetadataStorage contracts.SecureValueMetadataStorage + secureValueValidator contracts.SecureValueValidator + secureValueMutator contracts.SecureValueMutator keeperMetadataStorage contracts.KeeperMetadataStorage keeperService contracts.KeeperService metrics *metrics.SecureValueServiceMetrics @@ -37,6 +40,8 @@ func ProvideSecureValueService( accessClient claims.AccessClient, database contracts.Database, secureValueMetadataStorage contracts.SecureValueMetadataStorage, + secureValueValidator contracts.SecureValueValidator, + secureValueMutator contracts.SecureValueMutator, keeperMetadataStorage contracts.KeeperMetadataStorage, keeperService contracts.KeeperService, reg prometheus.Registerer, @@ -46,29 +51,34 @@ func ProvideSecureValueService( accessClient: accessClient, database: database, secureValueMetadataStorage: secureValueMetadataStorage, + secureValueValidator: secureValueValidator, + secureValueMutator: secureValueMutator, keeperMetadataStorage: keeperMetadataStorage, keeperService: keeperService, metrics: metrics.NewSecureValueServiceMetrics(reg), } } -func (s *SecureValueService) Create(ctx context.Context, sv *secretv1beta1.SecureValue, actorUID string) (_ *secretv1beta1.SecureValue, createErr error) { +func (s *SecureValueService) Create(ctx context.Context, sv *secretv1beta1.SecureValue, actorUID string) (createdSv *secretv1beta1.SecureValue, createErr error) { start := time.Now() - name, namespace := sv.GetName(), sv.GetNamespace() + ctx, span := s.tracer.Start(ctx, "SecureValueService.Create", trace.WithAttributes( - attribute.String("name", name), - attribute.String("namespace", namespace), + attribute.String("namespace", sv.GetNamespace()), attribute.String("actor", actorUID), )) defer span.End() defer func() { args := []any{ - "name", name, - "namespace", namespace, + "namespace", sv.GetNamespace(), "actorUID", actorUID, } + if createdSv != nil { + args = append(args, "name", createdSv.GetName()) + span.SetAttributes(attribute.String("name", createdSv.GetName())) + } + success := createErr == nil args = append(args, "success", success) if !success { @@ -80,7 +90,6 @@ func (s *SecureValueService) Create(ctx context.Context, sv *secretv1beta1.Secur logging.FromContext(ctx).Info("SecureValueService.Create finished", args...) s.metrics.SecureValueCreateDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds()) - s.metrics.SecureValueCreateCount.WithLabelValues(strconv.FormatBool(success)).Inc() }() return s.createNewVersion(ctx, sv, actorUID) @@ -116,7 +125,6 @@ func (s *SecureValueService) Update(ctx context.Context, newSecureValue *secretv logging.FromContext(ctx).Info("SecureValueService.Update finished", args...) s.metrics.SecureValueUpdateDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds()) - s.metrics.SecureValueUpdateCount.WithLabelValues(strconv.FormatBool(success)).Inc() }() if newSecureValue.Spec.Value == nil { @@ -151,6 +159,14 @@ func (s *SecureValueService) Update(ctx context.Context, newSecureValue *secretv } func (s *SecureValueService) createNewVersion(ctx context.Context, sv *secretv1beta1.SecureValue, actorUID string) (*secretv1beta1.SecureValue, error) { + if err := s.secureValueMutator.Mutate(sv, admission.Create); err != nil { + return nil, err + } + + if errorList := s.secureValueValidator.Validate(sv, nil, admission.Create); len(errorList) > 0 { + return nil, contracts.NewErrValidateSecureValue(errorList) + } + createdSv, err := s.secureValueMetadataStorage.Create(ctx, sv, actorUID) if err != nil { return nil, fmt.Errorf("creating secure value: %w", err) @@ -196,6 +212,13 @@ func (s *SecureValueService) createNewVersion(ctx context.Context, sv *secretv1b } func (s *SecureValueService) Read(ctx context.Context, namespace xkube.Namespace, name string) (_ *secretv1beta1.SecureValue, readErr error) { + if namespace == "" { + return nil, fmt.Errorf("namespace cannot be empty") + } + if name == "" { + return nil, fmt.Errorf("name cannot be empty") + } + start := time.Now() ctx, span := s.tracer.Start(ctx, "SecureValueService.Read", trace.WithAttributes( @@ -206,7 +229,7 @@ func (s *SecureValueService) Read(ctx context.Context, namespace xkube.Namespace defer func() { args := []any{ "name", name, - "namespace", namespace, + "namespace", namespace.String(), } success := readErr == nil @@ -220,7 +243,6 @@ func (s *SecureValueService) Read(ctx context.Context, namespace xkube.Namespace logging.FromContext(ctx).Info("SecureValueService.Read finished", args...) s.metrics.SecureValueReadDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds()) - s.metrics.SecureValueReadCount.WithLabelValues(strconv.FormatBool(success)).Inc() }() defer span.End() @@ -229,6 +251,10 @@ func (s *SecureValueService) Read(ctx context.Context, namespace xkube.Namespace } func (s *SecureValueService) List(ctx context.Context, namespace xkube.Namespace) (_ *secretv1beta1.SecureValueList, listErr error) { + if namespace == "" { + return nil, fmt.Errorf("namespace cannot be empty") + } + start := time.Now() ctx, span := s.tracer.Start(ctx, "SecureValueService.List", trace.WithAttributes( @@ -252,7 +278,6 @@ func (s *SecureValueService) List(ctx context.Context, namespace xkube.Namespace logging.FromContext(ctx).Info("SecureValueService.List finished", args...) s.metrics.SecureValueListDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds()) - s.metrics.SecureValueListCount.WithLabelValues(strconv.FormatBool(success)).Inc() }() user, ok := claims.AuthInfoFrom(ctx) @@ -292,6 +317,13 @@ func (s *SecureValueService) List(ctx context.Context, namespace xkube.Namespace } func (s *SecureValueService) Delete(ctx context.Context, namespace xkube.Namespace, name string) (_ *secretv1beta1.SecureValue, deleteErr error) { + if namespace == "" { + return nil, fmt.Errorf("namespace cannot be empty") + } + if name == "" { + return nil, fmt.Errorf("name cannot be empty") + } + start := time.Now() ctx, span := s.tracer.Start(ctx, "SecureValueService.Delete", trace.WithAttributes( @@ -317,7 +349,6 @@ func (s *SecureValueService) Delete(ctx context.Context, namespace xkube.Namespa logging.FromContext(ctx).Info("SecureValueService.Delete finished", args...) s.metrics.SecureValueDeleteDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds()) - s.metrics.SecureValueDeleteCount.WithLabelValues(strconv.FormatBool(success)).Inc() }() // TODO: does this need to be for update? diff --git a/pkg/registry/apis/secret/testutils/testutils.go b/pkg/registry/apis/secret/testutils/testutils.go index 6ade8366c89..66b37a91f40 100644 --- a/pkg/registry/apis/secret/testutils/testutils.go +++ b/pkg/registry/apis/secret/testutils/testutils.go @@ -20,8 +20,10 @@ import ( cipher "github.com/grafana/grafana/pkg/registry/apis/secret/encryption/cipher/service" osskmsproviders "github.com/grafana/grafana/pkg/registry/apis/secret/encryption/kmsproviders" "github.com/grafana/grafana/pkg/registry/apis/secret/encryption/manager" + "github.com/grafana/grafana/pkg/registry/apis/secret/mutator" "github.com/grafana/grafana/pkg/registry/apis/secret/secretkeeper/sqlkeeper" "github.com/grafana/grafana/pkg/registry/apis/secret/service" + "github.com/grafana/grafana/pkg/registry/apis/secret/validator" "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" @@ -123,7 +125,10 @@ func Setup(t *testing.T, opts ...func(*SetupConfig)) Sut { keeperService = setupCfg.KeeperService } - secureValueService := service.ProvideSecureValueService(tracer, accessClient, database, secureValueMetadataStorage, keeperMetadataStorage, keeperService, nil) + secureValueValidator := validator.ProvideSecureValueValidator() + secureValueMutator := mutator.ProvideSecureValueMutator() + + secureValueService := service.ProvideSecureValueService(tracer, accessClient, database, secureValueMetadataStorage, secureValueValidator, secureValueMutator, keeperMetadataStorage, keeperService, nil) decryptAuthorizer := decrypt.ProvideDecryptAuthorizer(tracer) diff --git a/pkg/registry/apps/alerting/notifications/templategroup/legacy_storage.go b/pkg/registry/apps/alerting/notifications/templategroup/legacy_storage.go index c03d9275b24..f6734bbd356 100644 --- a/pkg/registry/apps/alerting/notifications/templategroup/legacy_storage.go +++ b/pkg/registry/apps/alerting/notifications/templategroup/legacy_storage.go @@ -200,7 +200,8 @@ func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidatio } func (s *legacyStorage) defaultTemplate() (definitions.NotificationTemplate, error) { - defaultTemplate, err := templates.DefaultTemplate() + // Omit some templates that we do not want to use to see. + defaultTemplate, err := templates.DefaultTemplate(templates.DefaultTemplatesToOmit) if err != nil { return definitions.NotificationTemplate{}, err } diff --git a/apps/investigations/pkg/app/authorizer.go b/pkg/registry/apps/investigations/authorizer.go similarity index 97% rename from apps/investigations/pkg/app/authorizer.go rename to pkg/registry/apps/investigations/authorizer.go index 0747b06da5e..3178f3d2f9c 100644 --- a/apps/investigations/pkg/app/authorizer.go +++ b/pkg/registry/apps/investigations/authorizer.go @@ -1,4 +1,4 @@ -package app +package investigations import ( "context" diff --git a/apps/investigations/pkg/app/authorizer_test.go b/pkg/registry/apps/investigations/authorizer_test.go similarity index 99% rename from apps/investigations/pkg/app/authorizer_test.go rename to pkg/registry/apps/investigations/authorizer_test.go index 9829fb84793..c6d870bc7e7 100644 --- a/apps/investigations/pkg/app/authorizer_test.go +++ b/pkg/registry/apps/investigations/authorizer_test.go @@ -1,4 +1,4 @@ -package app +package investigations import ( "context" diff --git a/pkg/registry/apps/investigations/register.go b/pkg/registry/apps/investigations/register.go index 41ef093d5c2..6fbf2603491 100644 --- a/pkg/registry/apps/investigations/register.go +++ b/pkg/registry/apps/investigations/register.go @@ -25,7 +25,7 @@ func RegisterApp( appCfg := &runner.AppBuilderConfig{ OpenAPIDefGetter: investigationv0alpha1.GetOpenAPIDefinitions, ManagedKinds: investigationapp.GetKinds(), - Authorizer: investigationapp.GetAuthorizer(), + Authorizer: GetAuthorizer(), AllowedV0Alpha1Resources: []string{builder.AllResourcesAllowed}, } provider.Provider = simple.NewAppProvider(apis.LocalManifest(), appCfg, investigationapp.New) diff --git a/pkg/registry/apps/shorturl/conversions.go b/pkg/registry/apps/shorturl/conversions.go index 621f3618000..76025dfd966 100644 --- a/pkg/registry/apps/shorturl/conversions.go +++ b/pkg/registry/apps/shorturl/conversions.go @@ -2,12 +2,15 @@ package shorturl import ( "fmt" + "strings" "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/types" shorturl "github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1alpha1" + "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/shorturls" ) @@ -32,3 +35,37 @@ func convertToK8sResource(v *shorturls.ShortUrl, namespacer request.NamespaceMap } return p } + +func LegacyCreateCommandToUnstructured(cmd dtos.CreateShortURLCmd) unstructured.Unstructured { + obj := unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": cmd.UID, + }, + "spec": map[string]interface{}{ + "path": cmd.Path, + }, + }, + } + return obj +} + +func UnstructuredToLegacyShortURLDTO(item unstructured.Unstructured, appURL string) *dtos.ShortURL { + url := fmt.Sprintf("%s/goto/%s?orgId=%s", strings.TrimSuffix(appURL, "/"), item.GetName(), item.GetNamespace()) + + return &dtos.ShortURL{ + UID: item.GetName(), + URL: url, + } +} + +func UnstructuredToLegacyShortURL(item unstructured.Unstructured) *shorturls.ShortUrl { + spec := item.Object["spec"].(map[string]interface{}) + status := item.Object["status"].(map[string]interface{}) + + return &shorturls.ShortUrl{ + Uid: item.GetName(), + Path: spec["path"].(string), + LastSeenAt: status["lastSeenAt"].(int64), + } +} diff --git a/pkg/registry/apps/shorturl/legacy_storage.go b/pkg/registry/apps/shorturl/legacy_storage.go index 0b7948d62a5..35f538b5806 100644 --- a/pkg/registry/apps/shorturl/legacy_storage.go +++ b/pkg/registry/apps/shorturl/legacy_storage.go @@ -14,6 +14,7 @@ import ( "k8s.io/apiserver/pkg/registry/rest" shorturl "github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1alpha1" + "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/authn" @@ -112,11 +113,20 @@ func (s *legacyStorage) Create(ctx context.Context, return nil, fmt.Errorf("unsupported identity type") } + if createValidation != nil { + if err := createValidation(ctx, obj.DeepCopyObject()); err != nil { + return nil, err + } + } p, ok := obj.(*shorturl.ShortURL) if !ok { return nil, fmt.Errorf("expected shorturl?") } - out, err := s.service.CreateShortURL(ctx, signedInUser, p.Spec.Path) + cmd := &dtos.CreateShortURLCmd{ + Path: p.Spec.Path, + UID: p.Name, + } + out, err := s.service.CreateShortURL(ctx, signedInUser, cmd) if err != nil { return nil, err } @@ -131,12 +141,58 @@ func (s *legacyStorage) Update(ctx context.Context, forceAllowCreate bool, options *metav1.UpdateOptions, ) (runtime.Object, bool, error) { - return nil, false, fmt.Errorf("Update for shorturl not implemented") + // For other updates, use the original logic + requester, err := identity.GetRequester(ctx) + if err != nil { + return nil, false, err + } + + // Convert identity.Requester to *user.SignedInUser + var signedInUser *user.SignedInUser + if authnIdentity, ok := requester.(*authn.Identity); ok { + signedInUser = authnIdentity.SignedInUser() + } else if userIdentity, ok := requester.(*user.SignedInUser); ok { + signedInUser = userIdentity + } else { + return nil, false, fmt.Errorf("unsupported identity type") + } + + shortURL, err := s.service.GetShortURLByUID(ctx, signedInUser, name) + if err != nil || shortURL == nil { + if errors.Is(err, shorturls.ErrShortURLNotFound) || err == nil { + err = k8serrors.NewNotFound(schema.GroupResource{ + Group: shorturl.ShortURLKind().Group(), + Resource: shorturl.ShortURLKind().Plural(), + }, name) + } + return nil, false, err + } + + err = s.service.UpdateLastSeenAt(ctx, shortURL) + if err != nil { + return nil, false, err + } + // Fetch the updated short URL to return + updatedLegacyShortURL, err := s.service.GetShortURLByUID(ctx, signedInUser, name) + if err != nil { + return nil, false, err + } + + return convertToK8sResource(updatedLegacyShortURL, s.namespacer), true, nil } // GracefulDeleter func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { - return nil, false, fmt.Errorf("Delete for shorturl not implemented") + v, err := s.Get(ctx, name, &metav1.GetOptions{}) + if err != nil { + return v, false, err // includes the not-found error + } + p, ok := v.(*shorturl.ShortURL) + if !ok { + return v, false, fmt.Errorf("expected a shorturl response from Get") + } + err = s.service.DeleteStaleShortURLs(ctx, &shorturls.DeleteShortUrlCommand{Uid: name}) + return p, true, err // true is instant delete } // CollectionDeleter diff --git a/pkg/semconv/go.mod b/pkg/semconv/go.mod index 4e7a041137c..782ae759d51 100644 --- a/pkg/semconv/go.mod +++ b/pkg/semconv/go.mod @@ -1,6 +1,6 @@ module github.com/grafana/grafana/pkg/semconv -go 1.24.5 +go 1.24.6 require go.opentelemetry.io/otel v1.37.0 diff --git a/pkg/server/service.go b/pkg/server/service.go index f10bf072494..ba8c0d5a1f9 100644 --- a/pkg/server/service.go +++ b/pkg/server/service.go @@ -27,8 +27,8 @@ func NewService(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*cor return s, nil } -func (s *coreService) start(_ context.Context) error { - serv, err := Initialize(s.cfg, s.opts, s.apiOpts) +func (s *coreService) start(ctx context.Context) error { + serv, err := Initialize(ctx, s.cfg, s.opts, s.apiOpts) if err != nil { return err } diff --git a/pkg/server/wire.go b/pkg/server/wire.go index e3198908997..392a4fec45e 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -7,6 +7,8 @@ package server import ( + "context" + "github.com/google/wire" "github.com/stretchr/testify/mock" "go.opentelemetry.io/otel" @@ -45,6 +47,7 @@ import ( cipher "github.com/grafana/grafana/pkg/registry/apis/secret/encryption/cipher/service" encryptionManager "github.com/grafana/grafana/pkg/registry/apis/secret/encryption/manager" secretinline "github.com/grafana/grafana/pkg/registry/apis/secret/inline" + secretmutator "github.com/grafana/grafana/pkg/registry/apis/secret/mutator" secretsecurevalueservice "github.com/grafana/grafana/pkg/registry/apis/secret/service" secretvalidator "github.com/grafana/grafana/pkg/registry/apis/secret/validator" appregistry "github.com/grafana/grafana/pkg/registry/apps" @@ -437,6 +440,8 @@ var wireBasicSet = wire.NewSet( secretsecurevalueservice.ProvideSecureValueService, secretvalidator.ProvideKeeperValidator, secretvalidator.ProvideSecureValueValidator, + secretmutator.ProvideKeeperMutator, + secretmutator.ProvideSecureValueMutator, secretmigrator.NewWithEngine, secretdatabase.ProvideDatabase, wire.Bind(new(secretcontracts.Database), new(*secretdatabase.Database)), @@ -499,12 +504,12 @@ var wireTestSet = wire.NewSet( wire.Bind(new(cleanup.AlertRuleService), new(*ngstore.DBstore)), ) -func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Server, error) { +func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Server, error) { wire.Build(wireExtsSet) return &Server{}, nil } -func InitializeForTest(t sqlutil.ITestDB, testingT interface { +func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interface { mock.TestingT Cleanup(func()) }, cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions, @@ -513,14 +518,14 @@ func InitializeForTest(t sqlutil.ITestDB, testingT interface { return &TestEnv{Server: &Server{}, TestingT: testingT, SQLStore: &sqlstore.SQLStore{}, Cfg: &setting.Cfg{}}, nil } -func InitializeForCLI(cfg *setting.Cfg) (Runner, error) { +func InitializeForCLI(ctx context.Context, cfg *setting.Cfg) (Runner, error) { wire.Build(wireExtsCLISet) return Runner{}, nil } // InitializeForCLITarget is a simplified set of dependencies for the CLI, used // by the server target subcommand to launch specific dskit modules. -func InitializeForCLITarget(cfg *setting.Cfg) (ModuleRunner, error) { +func InitializeForCLITarget(ctx context.Context, cfg *setting.Cfg) (ModuleRunner, error) { wire.Build(wireExtsBaseCLISet) return ModuleRunner{}, nil } diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 6ef87327da4..11a06b02dbb 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -6,6 +6,7 @@ package server import ( + "context" "github.com/google/wire" httpclient2 "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana/apps/advisor/pkg/app/checkregistry" @@ -13,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/api/avatar" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/configprovider" "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/httpclient" @@ -27,7 +29,7 @@ import ( "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/infra/usagestats/service" "github.com/grafana/grafana/pkg/infra/usagestats/statscollector" - validator2 "github.com/grafana/grafana/pkg/infra/usagestats/validator" + validator3 "github.com/grafana/grafana/pkg/infra/usagestats/validator" "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/login/social/connectors" "github.com/grafana/grafana/pkg/login/social/socialimpl" @@ -35,7 +37,7 @@ import ( "github.com/grafana/grafana/pkg/middleware/loggermw" "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" provider2 "github.com/grafana/grafana/pkg/plugins/backendplugin/provider" - manager3 "github.com/grafana/grafana/pkg/plugins/manager" + manager4 "github.com/grafana/grafana/pkg/plugins/manager" "github.com/grafana/grafana/pkg/plugins/manager/filestore" "github.com/grafana/grafana/pkg/plugins/manager/loader/assetpath" "github.com/grafana/grafana/pkg/plugins/manager/process" @@ -64,13 +66,14 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/secret" "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" "github.com/grafana/grafana/pkg/registry/apis/secret/decrypt" - service11 "github.com/grafana/grafana/pkg/registry/apis/secret/encryption/cipher/service" + service4 "github.com/grafana/grafana/pkg/registry/apis/secret/encryption/cipher/service" "github.com/grafana/grafana/pkg/registry/apis/secret/encryption/kmsproviders" - manager4 "github.com/grafana/grafana/pkg/registry/apis/secret/encryption/manager" + manager2 "github.com/grafana/grafana/pkg/registry/apis/secret/encryption/manager" "github.com/grafana/grafana/pkg/registry/apis/secret/inline" + "github.com/grafana/grafana/pkg/registry/apis/secret/mutator" "github.com/grafana/grafana/pkg/registry/apis/secret/secretkeeper" - service12 "github.com/grafana/grafana/pkg/registry/apis/secret/service" - validator3 "github.com/grafana/grafana/pkg/registry/apis/secret/validator" + service5 "github.com/grafana/grafana/pkg/registry/apis/secret/service" + "github.com/grafana/grafana/pkg/registry/apis/secret/validator" "github.com/grafana/grafana/pkg/registry/apis/userstorage" "github.com/grafana/grafana/pkg/registry/apps" advisor2 "github.com/grafana/grafana/pkg/registry/apps/advisor" @@ -91,7 +94,7 @@ import ( "github.com/grafana/grafana/pkg/services/annotations/annotationsimpl" "github.com/grafana/grafana/pkg/services/anonymous/anonimpl" "github.com/grafana/grafana/pkg/services/anonymous/anonimpl/anonstore" - "github.com/grafana/grafana/pkg/services/anonymous/validator" + validator2 "github.com/grafana/grafana/pkg/services/anonymous/validator" "github.com/grafana/grafana/pkg/services/apikey/apikeyimpl" "github.com/grafana/grafana/pkg/services/apiserver" "github.com/grafana/grafana/pkg/services/apiserver/aggregatorrunner" @@ -109,18 +112,18 @@ import ( "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/correlations" "github.com/grafana/grafana/pkg/services/dashboardimport" - service9 "github.com/grafana/grafana/pkg/services/dashboardimport/service" + service11 "github.com/grafana/grafana/pkg/services/dashboardimport/service" dashboards2 "github.com/grafana/grafana/pkg/services/dashboards" database2 "github.com/grafana/grafana/pkg/services/dashboards/database" - service5 "github.com/grafana/grafana/pkg/services/dashboards/service" + service7 "github.com/grafana/grafana/pkg/services/dashboards/service" "github.com/grafana/grafana/pkg/services/dashboardsnapshots" - database4 "github.com/grafana/grafana/pkg/services/dashboardsnapshots/database" - service8 "github.com/grafana/grafana/pkg/services/dashboardsnapshots/service" + database5 "github.com/grafana/grafana/pkg/services/dashboardsnapshots/database" + service10 "github.com/grafana/grafana/pkg/services/dashboardsnapshots/service" "github.com/grafana/grafana/pkg/services/dashboardversion/dashverimpl" "github.com/grafana/grafana/pkg/services/datasourceproxy" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/datasources/guardian" - service7 "github.com/grafana/grafana/pkg/services/datasources/service" + service9 "github.com/grafana/grafana/pkg/services/datasources/service" encryption2 "github.com/grafana/grafana/pkg/services/encryption" "github.com/grafana/grafana/pkg/services/encryption/provider" service2 "github.com/grafana/grafana/pkg/services/encryption/service" @@ -136,7 +139,7 @@ import ( "github.com/grafana/grafana/pkg/services/kmsproviders/osskmsproviders" "github.com/grafana/grafana/pkg/services/ldap" api4 "github.com/grafana/grafana/pkg/services/ldap/api" - service10 "github.com/grafana/grafana/pkg/services/ldap/service" + service12 "github.com/grafana/grafana/pkg/services/ldap/service" "github.com/grafana/grafana/pkg/services/libraryelements" "github.com/grafana/grafana/pkg/services/librarypanels" "github.com/grafana/grafana/pkg/services/licensing" @@ -158,7 +161,7 @@ import ( "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/playlist/playlistimpl" "github.com/grafana/grafana/pkg/services/plugindashboards" - service6 "github.com/grafana/grafana/pkg/services/plugindashboards/service" + service8 "github.com/grafana/grafana/pkg/services/plugindashboards/service" "github.com/grafana/grafana/pkg/services/pluginsintegration" "github.com/grafana/grafana/pkg/services/pluginsintegration/advisor" "github.com/grafana/grafana/pkg/services/pluginsintegration/angulardetectorsprovider" @@ -180,7 +183,7 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginexternal" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugininstaller" - service4 "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings/service" + service6 "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings/service" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/pluginsintegration/provisionedplugins" "github.com/grafana/grafana/pkg/services/pluginsintegration/renderer" @@ -210,7 +213,7 @@ import ( "github.com/grafana/grafana/pkg/services/secrets/migrator" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts/extsvcaccounts" - manager2 "github.com/grafana/grafana/pkg/services/serviceaccounts/manager" + manager3 "github.com/grafana/grafana/pkg/services/serviceaccounts/manager" "github.com/grafana/grafana/pkg/services/serviceaccounts/proxy" "github.com/grafana/grafana/pkg/services/serviceaccounts/retriever" "github.com/grafana/grafana/pkg/services/shorturls" @@ -243,7 +246,7 @@ import ( "github.com/grafana/grafana/pkg/services/validations" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" - database5 "github.com/grafana/grafana/pkg/storage/secret/database" + database4 "github.com/grafana/grafana/pkg/storage/secret/database" "github.com/grafana/grafana/pkg/storage/secret/encryption" "github.com/grafana/grafana/pkg/storage/secret/metadata" migrator2 "github.com/grafana/grafana/pkg/storage/secret/migrator" @@ -280,7 +283,7 @@ import ( // Injectors from wire.go: -func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Server, error) { +func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Server, error) { routeRegisterImpl := routing.ProvideRegister() tracingConfig, err := tracing.ProvideTracingConfig(cfg) if err != nil { @@ -387,7 +390,11 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser mysqlService := mysql.ProvideService() mssqlService := mssql.ProvideService(cfg) entityEventsService := store.ProvideEntityEventsService(cfg, sqlStore, featureToggles) - quotaService := quotaimpl.ProvideService(sqlStore, cfg) + configProvider, err := configprovider.ProvideService(cfg) + if err != nil { + return nil, err + } + quotaService := quotaimpl.ProvideService(ctx, sqlStore, configProvider) orgService, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) if err != nil { return nil, err @@ -438,14 +445,55 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser } ossDashboardStats := search.ProvideDashboardStats() documentBuilderSupplier := search.ProvideDocumentBuilders(sqlStore, ossDashboardStats) + databaseDatabase := database4.ProvideDatabase(sqlStore, tracer) + secureValueMetadataStorage, err := metadata.ProvideSecureValueMetadataStorage(databaseDatabase, tracer, registerer) + if err != nil { + return nil, err + } + secureValueValidator := validator.ProvideSecureValueValidator() + secureValueMutator := mutator.ProvideSecureValueMutator() + keeperMetadataStorage, err := metadata.ProvideKeeperMetadataStorage(databaseDatabase, tracer, registerer) + if err != nil { + return nil, err + } + encryptedValueStorage, err := encryption.ProvideEncryptedValueStorage(databaseDatabase, tracer) + if err != nil { + return nil, err + } + dataKeyStorage, err := encryption.ProvideDataKeyStorage(databaseDatabase, tracer, registerer) + if err != nil { + return nil, err + } + cipher, err := service4.ProvideAESGCMCipherService(tracer, usageStats) + if err != nil { + return nil, err + } + providerConfig, err := kmsproviders.ProvideOSSKMSProviders(cfg, cipher) + if err != nil { + return nil, err + } + encryptionManager, err := manager2.ProvideEncryptionManager(tracer, dataKeyStorage, usageStats, cipher, providerConfig) + if err != nil { + return nil, err + } + ossKeeperService, err := secretkeeper.ProvideService(tracer, encryptedValueStorage, encryptionManager, registerer) + if err != nil { + return nil, err + } + secureValueService := service5.ProvideSecureValueService(tracer, accessClient, databaseDatabase, secureValueMetadataStorage, secureValueValidator, secureValueMutator, keeperMetadataStorage, ossKeeperService, registerer) + inlineSecureValueSupport, err := inline.ProvideInlineSecureValueService(cfg, tracer, secureValueService, accessClient) + if err != nil { + return nil, err + } options := &unified.Options{ - Cfg: cfg, - Features: featureToggles, - DB: sqlStore, - Tracer: tracingService, - Reg: registerer, - Authzc: accessClient, - Docs: documentBuilderSupplier, + Cfg: cfg, + Features: featureToggles, + DB: sqlStore, + Tracer: tracingService, + Reg: registerer, + Authzc: accessClient, + Docs: documentBuilderSupplier, + SecureValues: inlineSecureValueSupport, } storageMetrics := resource.ProvideStorageMetrics(registerer) bleveIndexMetrics := resource.ProvideIndexMetrics(registerer) @@ -475,13 +523,13 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser if err != nil { return nil, err } - serviceAccountsService, err := manager2.ProvideServiceAccountsService(cfg, usageStats, sqlStore, apikeyService, kvStore, userService, orgService, acimplService, serviceAccountPermissionsService, serverLockService) + serviceAccountsService, err := manager3.ProvideServiceAccountsService(cfg, usageStats, sqlStore, apikeyService, kvStore, userService, orgService, acimplService, serviceAccountPermissionsService, serverLockService) if err != nil { return nil, err } extSvcAccountsService := extsvcaccounts.ProvideExtSvcAccountsService(acimplService, cfg, inProcBus, sqlStore, featureToggles, registerer, serviceAccountsService, secretsService, tracingService) registryRegistry := registry2.ProvideExtSvcRegistry(cfg, extSvcAccountsService, serverLockService, featureToggles) - service13 := service4.ProvideService(sqlStore, secretsService) + service13 := service6.ProvideService(sqlStore, secretsService) serviceregistrationService := serviceregistration.ProvideService(cfg, featureToggles, registryRegistry, service13) initialize := pipeline.ProvideInitializationStage(pluginManagementCfg, inMemory, providerService, processService, serviceregistrationService, acimplService, actionSetService, envVarsProvider, tracingService) terminate, err := pipeline.ProvideTerminationStage(pluginManagementCfg, inMemory, processService) @@ -500,12 +548,12 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser if err != nil { return nil, err } - dashboardServiceImpl, err := service5.ProvideDashboardServiceImpl(cfg, dashboardsStore, dashboardFolderStoreImpl, featureToggles, folderPermissionsService, accessControl, acimplService, folderimplService, registerer, eventualRestConfigProvider, userService, quotaService, orgService, publicDashboardServiceWrapperImpl, resourceClient, dualwriteService, sortService, serverLockService, kvStore) + dashboardServiceImpl, err := service7.ProvideDashboardServiceImpl(cfg, dashboardsStore, dashboardFolderStoreImpl, featureToggles, folderPermissionsService, accessControl, acimplService, folderimplService, registerer, eventualRestConfigProvider, userService, quotaService, orgService, publicDashboardServiceWrapperImpl, resourceClient, dualwriteService, sortService, serverLockService, kvStore) if err != nil { return nil, err } - pluginService := service5.ProvideDashboardPluginService(featureToggles, dashboardServiceImpl) - service14 := service6.ProvideService(fileStoreManager, pluginService) + pluginService := service7.ProvideDashboardPluginService(featureToggles, dashboardServiceImpl) + service14 := service8.ProvideService(fileStoreManager, pluginService) orgRoleMapper := connectors.ProvideOrgRoleMapper(cfg, orgService) ssosettingsimplService := ssosettingsimpl.ProvideService(cfg, sqlStore, accessControl, routeRegisterImpl, featureToggles, secretsService, usageStats, registerer, ossImpl, ossLicensingService) socialService := socialimpl.ProvideService(cfg, featureToggles, usageStats, bundleregistryService, remoteCache, orgRoleMapper, ssosettingsimplService) @@ -526,15 +574,15 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser if err != nil { return nil, err } - pluginInstaller := manager3.ProvideInstaller(pluginManagementCfg, inMemory, loaderLoader, repoManager, serviceregistrationService) + pluginInstaller := manager4.ProvideInstaller(pluginManagementCfg, inMemory, loaderLoader, repoManager, serviceregistrationService) ossProvider := guardian.ProvideGuardian() - cacheServiceImpl := service7.ProvideCacheService(cacheService, sqlStore, ossProvider) + cacheServiceImpl := service9.ProvideCacheService(cacheService, sqlStore, ossProvider) shortURLService := shorturlimpl.ProvideService(sqlStore) queryHistoryService := queryhistory.ProvideService(cfg, sqlStore, routeRegisterImpl, accessControl) - dashboardService := service5.ProvideDashboardService(featureToggles, dashboardServiceImpl) + dashboardService := service7.ProvideDashboardService(featureToggles, dashboardServiceImpl) dashverService := dashverimpl.ProvideService(cfg, sqlStore, dashboardService, dashboardsStore, featureToggles, eventualRestConfigProvider, userService, resourceClient, dualwriteService, sortService) - dashboardSnapshotStore := database4.ProvideStore(sqlStore, cfg) - serviceImpl := service8.ProvideService(dashboardSnapshotStore, secretsService, dashboardService) + dashboardSnapshotStore := database5.ProvideStore(sqlStore, cfg) + serviceImpl := service10.ProvideService(dashboardSnapshotStore, secretsService, dashboardService) dBstore, err := store2.ProvideDBStore(cfg, featureToggles, sqlStore, folderimplService, dashboardService, accessControl, inProcBus) if err != nil { return nil, err @@ -550,7 +598,7 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser datasourcePermissionsService := ossaccesscontrol.ProvideDatasourcePermissionsService(cfg, featureToggles, sqlStore) requestConfigProvider := pluginconfig.NewRequestConfigProvider(pluginInstanceCfg) baseProvider := plugincontext.ProvideBaseService(cfg, requestConfigProvider) - service15, err := service7.ProvideService(sqlStore, secretsService, secretsKVStore, cfg, featureToggles, accessControl, datasourcePermissionsService, quotaService, pluginstoreService, middlewareHandler, baseProvider) + service15, err := service9.ProvideService(sqlStore, secretsService, secretsKVStore, cfg, featureToggles, accessControl, datasourcePermissionsService, quotaService, pluginstoreService, middlewareHandler, baseProvider) if err != nil { return nil, err } @@ -566,7 +614,7 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser if err != nil { return nil, err } - dashboardProvisioningService := service5.ProvideDashboardProvisioningService(featureToggles, dashboardServiceImpl) + dashboardProvisioningService := service7.ProvideDashboardProvisioningService(featureToggles, dashboardServiceImpl) receiverPermissionsService, err := ossaccesscontrol.ProvideReceiverPermissionsService(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, acimplService, teamService, userService, actionSetService) if err != nil { return nil, err @@ -646,7 +694,7 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser statsService := statsimpl.ProvideService(cfg, sqlStore, dashboardService, folderimplService, orgService, featureToggles) gatherer := metrics.ProvideGatherer() apiAPI := api3.ProvideApi(starService, dashboardService) - anonUserLimitValidatorImpl := validator.ProvideAnonUserLimitValidator() + anonUserLimitValidatorImpl := validator2.ProvideAnonUserLimitValidator() anonDeviceService := anonimpl.ProvideAnonymousDeviceService(usageStats, authnService, sqlStore, cfg, orgService, serverLockService, accessControl, routeRegisterImpl, anonUserLimitValidatorImpl) signingkeysimplService, err := signingkeysimpl.ProvideEmbeddedSigningKeysService(sqlStore, secretsService, remoteCache, routeRegisterImpl) if err != nil { @@ -662,7 +710,7 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser if err != nil { return nil, err } - validatorService, err := validator2.ProvideService(pluginstoreService) + validatorService, err := validator3.ProvideService(pluginstoreService) if err != nil { return nil, err } @@ -701,7 +749,7 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser } v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, pluginsAppInstaller, shortURLAppInstaller) builderMetrics := builder.ProvideBuilderMetrics(registerer) - apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics) + apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics) if err != nil { return nil, err } @@ -722,8 +770,8 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser if err != nil { return nil, err } - importDashboardService := service9.ProvideService(routeRegisterImpl, quotaService, service14, pluginstoreService, libraryPanelService, dashboardService, accessControl, folderimplService, featureToggles) - dashboardUpdater := service6.ProvideDashboardUpdater(inProcBus, pluginstoreService, service14, importDashboardService, service13, pluginService, dashboardService) + importDashboardService := service11.ProvideService(routeRegisterImpl, quotaService, service14, pluginstoreService, libraryPanelService, dashboardService, accessControl, folderimplService, featureToggles) + dashboardUpdater := service8.ProvideDashboardUpdater(inProcBus, pluginstoreService, service14, importDashboardService, service13, pluginService, dashboardService) sanitizerProvider := sanitizer.ProvideService(renderingService) healthService, err := grpcserver.ProvideHealthService(cfg, grpcserverProvider) if err != nil { @@ -735,9 +783,9 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser } ossGroups := ldap.ProvideGroupsService() identitySynchronizer := authnimpl.ProvideIdentitySynchronizer(authnimplService) - ldapImpl := service10.ProvideService(cfg, featureToggles, ssosettingsimplService) + ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService) apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService) - dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, pluginstoreService, service15, dashboardServiceImpl, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, dashboardFolderStoreImpl, libraryPanelService, eventualRestConfigProvider, userService) + dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, pluginstoreService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, dashboardFolderStoreImpl, libraryPanelService, eventualRestConfigProvider, userService) snapshotsAPIBuilder := dashboardsnapshot.RegisterAPIService(serviceImpl, apiserverService, cfg, featureToggles, sqlStore, registerer) featureFlagAPIBuilder := featuretoggle.RegisterAPIService(featureManager, accessControl, apiserverService, cfg, registerer) dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, accessControl, registerer) @@ -750,50 +798,14 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser if err != nil { return nil, err } - legacyDataSourceLookup := service7.ProvideLegacyDataSourceLookup(service15) + legacyDataSourceLookup := service9.ProvideLegacyDataSourceLookup(service15) queryAPIBuilder, err := query2.RegisterAPIService(cfg, featureToggles, apiserverService, service15, pluginstoreService, accessControl, middlewareHandler, plugincontextProvider, registerer, tracingService, legacyDataSourceLookup, exprService) if err != nil { return nil, err } userStorageAPIBuilder := userstorage.RegisterAPIService(featureToggles, apiserverService, registerer) factory := github.ProvideFactory() - legacyMigrator := legacy.ProvideLegacyMigrator(sqlStore, provisioningServiceImpl, libraryPanelService, accessControl, featureToggles) - databaseDatabase := database5.ProvideDatabase(sqlStore, tracer) - secureValueMetadataStorage, err := metadata.ProvideSecureValueMetadataStorage(databaseDatabase, tracer, registerer) - if err != nil { - return nil, err - } - keeperMetadataStorage, err := metadata.ProvideKeeperMetadataStorage(databaseDatabase, tracer, registerer) - if err != nil { - return nil, err - } - encryptedValueStorage, err := encryption.ProvideEncryptedValueStorage(databaseDatabase, tracer) - if err != nil { - return nil, err - } - dataKeyStorage, err := encryption.ProvideDataKeyStorage(databaseDatabase, tracer, registerer) - if err != nil { - return nil, err - } - cipher, err := service11.ProvideAESGCMCipherService(tracer, usageStats) - if err != nil { - return nil, err - } - providerConfig, err := kmsproviders.ProvideOSSKMSProviders(cfg, cipher) - if err != nil { - return nil, err - } - encryptionManager, err := manager4.ProvideEncryptionManager(tracer, dataKeyStorage, usageStats, cipher, providerConfig) - if err != nil { - return nil, err - } - ossKeeperService, err := secretkeeper.ProvideService(tracer, encryptedValueStorage, encryptionManager, registerer) - if err != nil { - return nil, err - } - secureValueService := service12.ProvideSecureValueService(tracer, accessClient, databaseDatabase, secureValueMetadataStorage, keeperMetadataStorage, ossKeeperService, registerer) - secureValueValidator := validator3.ProvideSecureValueValidator() - secureValueClient := secret.ProvideSecureValueClient(secureValueService, secureValueValidator, accessClient) + legacyMigrator := legacy.ProvideLegacyMigrator(sqlStore, provisioningServiceImpl, libraryPanelService, dashboardPermissionsService, accessControl, featureToggles) decryptAuthorizer := decrypt.ProvideDecryptAuthorizer(tracer) decryptStorage, err := metadata.ProvideDecryptStorage(tracer, ossKeeperService, keeperMetadataStorage, secureValueMetadataStorage, decryptAuthorizer, registerer) if err != nil { @@ -803,10 +815,11 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser if err != nil { return nil, err } + secureValueClient := secret.ProvideSecureValueClient(secureValueService, secureValueValidator, accessClient) repositorySecrets := secrets.ProvideRepositorySecrets(featureToggles, secretsService, secureValueClient, v3, cfg) webhookExtraBuilder := webhooks.ProvideWebhooks(cfg, featureToggles, repositorySecrets, factory, renderingService, resourceClient, eventualRestConfigProvider) v4 := extras.ProvideProvisioningOSSExtras(webhookExtraBuilder) - apiBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, factory, accessClient, legacyMigrator, dualwriteService, usageStats, repositorySecrets, tracingService, v4) + apiBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, factory, accessClient, legacyMigrator, dualwriteService, usageStats, v3, repositorySecrets, tracingService, v4) if err != nil { return nil, err } @@ -844,7 +857,7 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser return server, nil } -func InitializeForTest(t sqlutil.ITestDB, testingT interface { +func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interface { Cleanup(func()) mock.TestingT }, cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*TestEnv, error) { @@ -954,7 +967,11 @@ func InitializeForTest(t sqlutil.ITestDB, testingT interface { mysqlService := mysql.ProvideService() mssqlService := mssql.ProvideService(cfg) entityEventsService := store.ProvideEntityEventsService(cfg, sqlStore, featureToggles) - quotaService := quotaimpl.ProvideService(sqlStore, cfg) + configProvider, err := configprovider.ProvideService(cfg) + if err != nil { + return nil, err + } + quotaService := quotaimpl.ProvideService(ctx, sqlStore, configProvider) orgService, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) if err != nil { return nil, err @@ -1005,14 +1022,55 @@ func InitializeForTest(t sqlutil.ITestDB, testingT interface { } ossDashboardStats := search.ProvideDashboardStats() documentBuilderSupplier := search.ProvideDocumentBuilders(sqlStore, ossDashboardStats) + databaseDatabase := database4.ProvideDatabase(sqlStore, tracer) + secureValueMetadataStorage, err := metadata.ProvideSecureValueMetadataStorage(databaseDatabase, tracer, registerer) + if err != nil { + return nil, err + } + secureValueValidator := validator.ProvideSecureValueValidator() + secureValueMutator := mutator.ProvideSecureValueMutator() + keeperMetadataStorage, err := metadata.ProvideKeeperMetadataStorage(databaseDatabase, tracer, registerer) + if err != nil { + return nil, err + } + encryptedValueStorage, err := encryption.ProvideEncryptedValueStorage(databaseDatabase, tracer) + if err != nil { + return nil, err + } + dataKeyStorage, err := encryption.ProvideDataKeyStorage(databaseDatabase, tracer, registerer) + if err != nil { + return nil, err + } + cipher, err := service4.ProvideAESGCMCipherService(tracer, usageStats) + if err != nil { + return nil, err + } + providerConfig, err := kmsproviders.ProvideOSSKMSProviders(cfg, cipher) + if err != nil { + return nil, err + } + encryptionManager, err := manager2.ProvideEncryptionManager(tracer, dataKeyStorage, usageStats, cipher, providerConfig) + if err != nil { + return nil, err + } + ossKeeperService, err := secretkeeper.ProvideService(tracer, encryptedValueStorage, encryptionManager, registerer) + if err != nil { + return nil, err + } + secureValueService := service5.ProvideSecureValueService(tracer, accessClient, databaseDatabase, secureValueMetadataStorage, secureValueValidator, secureValueMutator, keeperMetadataStorage, ossKeeperService, registerer) + inlineSecureValueSupport, err := inline.ProvideInlineSecureValueService(cfg, tracer, secureValueService, accessClient) + if err != nil { + return nil, err + } options := &unified.Options{ - Cfg: cfg, - Features: featureToggles, - DB: sqlStore, - Tracer: tracingService, - Reg: registerer, - Authzc: accessClient, - Docs: documentBuilderSupplier, + Cfg: cfg, + Features: featureToggles, + DB: sqlStore, + Tracer: tracingService, + Reg: registerer, + Authzc: accessClient, + Docs: documentBuilderSupplier, + SecureValues: inlineSecureValueSupport, } storageMetrics := resource.ProvideStorageMetrics(registerer) bleveIndexMetrics := resource.ProvideIndexMetrics(registerer) @@ -1042,13 +1100,13 @@ func InitializeForTest(t sqlutil.ITestDB, testingT interface { if err != nil { return nil, err } - serviceAccountsService, err := manager2.ProvideServiceAccountsService(cfg, usageStats, sqlStore, apikeyService, kvStore, userService, orgService, acimplService, serviceAccountPermissionsService, serverLockService) + serviceAccountsService, err := manager3.ProvideServiceAccountsService(cfg, usageStats, sqlStore, apikeyService, kvStore, userService, orgService, acimplService, serviceAccountPermissionsService, serverLockService) if err != nil { return nil, err } extSvcAccountsService := extsvcaccounts.ProvideExtSvcAccountsService(acimplService, cfg, inProcBus, sqlStore, featureToggles, registerer, serviceAccountsService, secretsService, tracingService) registryRegistry := registry2.ProvideExtSvcRegistry(cfg, extSvcAccountsService, serverLockService, featureToggles) - service13 := service4.ProvideService(sqlStore, secretsService) + service13 := service6.ProvideService(sqlStore, secretsService) serviceregistrationService := serviceregistration.ProvideService(cfg, featureToggles, registryRegistry, service13) initialize := pipeline.ProvideInitializationStage(pluginManagementCfg, inMemory, providerService, processService, serviceregistrationService, acimplService, actionSetService, envVarsProvider, tracingService) terminate, err := pipeline.ProvideTerminationStage(pluginManagementCfg, inMemory, processService) @@ -1067,12 +1125,12 @@ func InitializeForTest(t sqlutil.ITestDB, testingT interface { if err != nil { return nil, err } - dashboardServiceImpl, err := service5.ProvideDashboardServiceImpl(cfg, dashboardsStore, dashboardFolderStoreImpl, featureToggles, folderPermissionsService, accessControl, acimplService, folderimplService, registerer, eventualRestConfigProvider, userService, quotaService, orgService, publicDashboardServiceWrapperImpl, resourceClient, dualwriteService, sortService, serverLockService, kvStore) + dashboardServiceImpl, err := service7.ProvideDashboardServiceImpl(cfg, dashboardsStore, dashboardFolderStoreImpl, featureToggles, folderPermissionsService, accessControl, acimplService, folderimplService, registerer, eventualRestConfigProvider, userService, quotaService, orgService, publicDashboardServiceWrapperImpl, resourceClient, dualwriteService, sortService, serverLockService, kvStore) if err != nil { return nil, err } - pluginService := service5.ProvideDashboardPluginService(featureToggles, dashboardServiceImpl) - service14 := service6.ProvideService(fileStoreManager, pluginService) + pluginService := service7.ProvideDashboardPluginService(featureToggles, dashboardServiceImpl) + service14 := service8.ProvideService(fileStoreManager, pluginService) oauthtokentestService := oauthtokentest.ProvideService() ossCachingService := caching.ProvideCachingService() middlewareHandler, err := pluginsintegration.ProvideClientWithMiddlewares(cfg, inMemory, oauthtokentestService, tracingService, ossCachingService, featureToggles, registerer) @@ -1084,19 +1142,19 @@ func InitializeForTest(t sqlutil.ITestDB, testingT interface { if err != nil { return nil, err } - pluginInstaller := manager3.ProvideInstaller(pluginManagementCfg, inMemory, loaderLoader, repoManager, serviceregistrationService) + pluginInstaller := manager4.ProvideInstaller(pluginManagementCfg, inMemory, loaderLoader, repoManager, serviceregistrationService) ossProvider := guardian.ProvideGuardian() - cacheServiceImpl := service7.ProvideCacheService(cacheService, sqlStore, ossProvider) + cacheServiceImpl := service9.ProvideCacheService(cacheService, sqlStore, ossProvider) userAuthTokenService, err := authimpl.ProvideUserAuthTokenService(sqlStore, serverLockService, quotaService, secretsService, cfg, tracingService, featureToggles) if err != nil { return nil, err } shortURLService := shorturlimpl.ProvideService(sqlStore) queryHistoryService := queryhistory.ProvideService(cfg, sqlStore, routeRegisterImpl, accessControl) - dashboardService := service5.ProvideDashboardService(featureToggles, dashboardServiceImpl) + dashboardService := service7.ProvideDashboardService(featureToggles, dashboardServiceImpl) dashverService := dashverimpl.ProvideService(cfg, sqlStore, dashboardService, dashboardsStore, featureToggles, eventualRestConfigProvider, userService, resourceClient, dualwriteService, sortService) - dashboardSnapshotStore := database4.ProvideStore(sqlStore, cfg) - serviceImpl := service8.ProvideService(dashboardSnapshotStore, secretsService, dashboardService) + dashboardSnapshotStore := database5.ProvideStore(sqlStore, cfg) + serviceImpl := service10.ProvideService(dashboardSnapshotStore, secretsService, dashboardService) dBstore, err := store2.ProvideDBStore(cfg, featureToggles, sqlStore, folderimplService, dashboardService, accessControl, inProcBus) if err != nil { return nil, err @@ -1112,7 +1170,7 @@ func InitializeForTest(t sqlutil.ITestDB, testingT interface { datasourcePermissionsService := ossaccesscontrol.ProvideDatasourcePermissionsService(cfg, featureToggles, sqlStore) requestConfigProvider := pluginconfig.NewRequestConfigProvider(pluginInstanceCfg) baseProvider := plugincontext.ProvideBaseService(cfg, requestConfigProvider) - service15, err := service7.ProvideService(sqlStore, secretsService, secretsKVStore, cfg, featureToggles, accessControl, datasourcePermissionsService, quotaService, pluginstoreService, middlewareHandler, baseProvider) + service15, err := service9.ProvideService(sqlStore, secretsService, secretsKVStore, cfg, featureToggles, accessControl, datasourcePermissionsService, quotaService, pluginstoreService, middlewareHandler, baseProvider) if err != nil { return nil, err } @@ -1128,7 +1186,7 @@ func InitializeForTest(t sqlutil.ITestDB, testingT interface { if err != nil { return nil, err } - dashboardProvisioningService := service5.ProvideDashboardProvisioningService(featureToggles, dashboardServiceImpl) + dashboardProvisioningService := service7.ProvideDashboardProvisioningService(featureToggles, dashboardServiceImpl) receiverPermissionsService, err := ossaccesscontrol.ProvideReceiverPermissionsService(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, acimplService, teamService, userService, actionSetService) if err != nil { return nil, err @@ -1215,7 +1273,7 @@ func InitializeForTest(t sqlutil.ITestDB, testingT interface { statsService := statsimpl.ProvideService(cfg, sqlStore, dashboardService, folderimplService, orgService, featureToggles) gatherer := metrics.ProvideGathererForTest(registerer) apiAPI := api3.ProvideApi(starService, dashboardService) - anonUserLimitValidatorImpl := validator.ProvideAnonUserLimitValidator() + anonUserLimitValidatorImpl := validator2.ProvideAnonUserLimitValidator() anonDeviceService := anonimpl.ProvideAnonymousDeviceService(usageStats, authnService, sqlStore, cfg, orgService, serverLockService, accessControl, routeRegisterImpl, anonUserLimitValidatorImpl) signingkeysimplService, err := signingkeysimpl.ProvideEmbeddedSigningKeysService(sqlStore, secretsService, remoteCache, routeRegisterImpl) if err != nil { @@ -1231,7 +1289,7 @@ func InitializeForTest(t sqlutil.ITestDB, testingT interface { if err != nil { return nil, err } - validatorService, err := validator2.ProvideService(pluginstoreService) + validatorService, err := validator3.ProvideService(pluginstoreService) if err != nil { return nil, err } @@ -1270,7 +1328,7 @@ func InitializeForTest(t sqlutil.ITestDB, testingT interface { } v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, pluginsAppInstaller, shortURLAppInstaller) builderMetrics := builder.ProvideBuilderMetrics(registerer) - apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics) + apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics) if err != nil { return nil, err } @@ -1291,8 +1349,8 @@ func InitializeForTest(t sqlutil.ITestDB, testingT interface { if err != nil { return nil, err } - importDashboardService := service9.ProvideService(routeRegisterImpl, quotaService, service14, pluginstoreService, libraryPanelService, dashboardService, accessControl, folderimplService, featureToggles) - dashboardUpdater := service6.ProvideDashboardUpdater(inProcBus, pluginstoreService, service14, importDashboardService, service13, pluginService, dashboardService) + importDashboardService := service11.ProvideService(routeRegisterImpl, quotaService, service14, pluginstoreService, libraryPanelService, dashboardService, accessControl, folderimplService, featureToggles) + dashboardUpdater := service8.ProvideDashboardUpdater(inProcBus, pluginstoreService, service14, importDashboardService, service13, pluginService, dashboardService) sanitizerProvider := sanitizer.ProvideService(renderingService) healthService, err := grpcserver.ProvideHealthService(cfg, grpcserverProvider) if err != nil { @@ -1304,9 +1362,9 @@ func InitializeForTest(t sqlutil.ITestDB, testingT interface { } ossGroups := ldap.ProvideGroupsService() identitySynchronizer := authnimpl.ProvideIdentitySynchronizer(authnimplService) - ldapImpl := service10.ProvideService(cfg, featureToggles, ssosettingsimplService) + ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService) apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService) - dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, pluginstoreService, service15, dashboardServiceImpl, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, dashboardFolderStoreImpl, libraryPanelService, eventualRestConfigProvider, userService) + dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, pluginstoreService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, dashboardFolderStoreImpl, libraryPanelService, eventualRestConfigProvider, userService) snapshotsAPIBuilder := dashboardsnapshot.RegisterAPIService(serviceImpl, apiserverService, cfg, featureToggles, sqlStore, registerer) featureFlagAPIBuilder := featuretoggle.RegisterAPIService(featureManager, accessControl, apiserverService, cfg, registerer) dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, accessControl, registerer) @@ -1319,50 +1377,14 @@ func InitializeForTest(t sqlutil.ITestDB, testingT interface { if err != nil { return nil, err } - legacyDataSourceLookup := service7.ProvideLegacyDataSourceLookup(service15) + legacyDataSourceLookup := service9.ProvideLegacyDataSourceLookup(service15) queryAPIBuilder, err := query2.RegisterAPIService(cfg, featureToggles, apiserverService, service15, pluginstoreService, accessControl, middlewareHandler, plugincontextProvider, registerer, tracingService, legacyDataSourceLookup, exprService) if err != nil { return nil, err } userStorageAPIBuilder := userstorage.RegisterAPIService(featureToggles, apiserverService, registerer) factory := github.ProvideFactory() - legacyMigrator := legacy.ProvideLegacyMigrator(sqlStore, provisioningServiceImpl, libraryPanelService, accessControl, featureToggles) - databaseDatabase := database5.ProvideDatabase(sqlStore, tracer) - secureValueMetadataStorage, err := metadata.ProvideSecureValueMetadataStorage(databaseDatabase, tracer, registerer) - if err != nil { - return nil, err - } - keeperMetadataStorage, err := metadata.ProvideKeeperMetadataStorage(databaseDatabase, tracer, registerer) - if err != nil { - return nil, err - } - encryptedValueStorage, err := encryption.ProvideEncryptedValueStorage(databaseDatabase, tracer) - if err != nil { - return nil, err - } - dataKeyStorage, err := encryption.ProvideDataKeyStorage(databaseDatabase, tracer, registerer) - if err != nil { - return nil, err - } - cipher, err := service11.ProvideAESGCMCipherService(tracer, usageStats) - if err != nil { - return nil, err - } - providerConfig, err := kmsproviders.ProvideOSSKMSProviders(cfg, cipher) - if err != nil { - return nil, err - } - encryptionManager, err := manager4.ProvideEncryptionManager(tracer, dataKeyStorage, usageStats, cipher, providerConfig) - if err != nil { - return nil, err - } - ossKeeperService, err := secretkeeper.ProvideService(tracer, encryptedValueStorage, encryptionManager, registerer) - if err != nil { - return nil, err - } - secureValueService := service12.ProvideSecureValueService(tracer, accessClient, databaseDatabase, secureValueMetadataStorage, keeperMetadataStorage, ossKeeperService, registerer) - secureValueValidator := validator3.ProvideSecureValueValidator() - secureValueClient := secret.ProvideSecureValueClient(secureValueService, secureValueValidator, accessClient) + legacyMigrator := legacy.ProvideLegacyMigrator(sqlStore, provisioningServiceImpl, libraryPanelService, dashboardPermissionsService, accessControl, featureToggles) decryptAuthorizer := decrypt.ProvideDecryptAuthorizer(tracer) decryptStorage, err := metadata.ProvideDecryptStorage(tracer, ossKeeperService, keeperMetadataStorage, secureValueMetadataStorage, decryptAuthorizer, registerer) if err != nil { @@ -1372,10 +1394,11 @@ func InitializeForTest(t sqlutil.ITestDB, testingT interface { if err != nil { return nil, err } + secureValueClient := secret.ProvideSecureValueClient(secureValueService, secureValueValidator, accessClient) repositorySecrets := secrets.ProvideRepositorySecrets(featureToggles, secretsService, secureValueClient, v3, cfg) webhookExtraBuilder := webhooks.ProvideWebhooks(cfg, featureToggles, repositorySecrets, factory, renderingService, resourceClient, eventualRestConfigProvider) v4 := extras.ProvideProvisioningOSSExtras(webhookExtraBuilder) - apiBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, factory, accessClient, legacyMigrator, dualwriteService, usageStats, repositorySecrets, tracingService, v4) + apiBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, factory, accessClient, legacyMigrator, dualwriteService, usageStats, v3, repositorySecrets, tracingService, v4) if err != nil { return nil, err } @@ -1417,7 +1440,7 @@ func InitializeForTest(t sqlutil.ITestDB, testingT interface { return testEnv, nil } -func InitializeForCLI(cfg *setting.Cfg) (Runner, error) { +func InitializeForCLI(ctx context.Context, cfg *setting.Cfg) (Runner, error) { featureManager, err := featuremgmt.ProvideManagerService(cfg) if err != nil { return Runner{}, err @@ -1458,7 +1481,11 @@ func InitializeForCLI(cfg *setting.Cfg) (Runner, error) { return Runner{}, err } secretsMigrator := migrator.ProvideSecretsMigrator(serviceService, secretsService, sqlStore, ossImpl, featureToggles) - quotaService := quotaimpl.ProvideService(sqlStore, cfg) + configProvider, err := configprovider.ProvideService(cfg) + if err != nil { + return Runner{}, err + } + quotaService := quotaimpl.ProvideService(ctx, sqlStore, configProvider) orgService, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) if err != nil { return Runner{}, err @@ -1473,7 +1500,7 @@ func InitializeForCLI(cfg *setting.Cfg) (Runner, error) { return Runner{}, err } tracer := otelTracer() - databaseDatabase := database5.ProvideDatabase(sqlStore, tracer) + databaseDatabase := database4.ProvideDatabase(sqlStore, tracer) registerer := metrics.ProvideRegisterer() globalDataKeyStorage, err := encryption.ProvideGlobalDataKeyStorage(databaseDatabase, tracer, registerer) if err != nil { @@ -1491,7 +1518,7 @@ func InitializeForCLI(cfg *setting.Cfg) (Runner, error) { if err != nil { return Runner{}, err } - cipher, err := service11.ProvideAESGCMCipherService(tracer, usageStats) + cipher, err := service4.ProvideAESGCMCipherService(tracer, usageStats) if err != nil { return Runner{}, err } @@ -1499,18 +1526,18 @@ func InitializeForCLI(cfg *setting.Cfg) (Runner, error) { if err != nil { return Runner{}, err } - encryptionManager, err := manager4.ProvideEncryptionManager(tracer, dataKeyStorage, usageStats, cipher, providerConfig) + encryptionManager, err := manager2.ProvideEncryptionManager(tracer, dataKeyStorage, usageStats, cipher, providerConfig) if err != nil { return Runner{}, err } - consolidationService := service12.ProvideConsolidationService(tracer, globalDataKeyStorage, encryptedValueStorage, globalEncryptedValueStorage, encryptionManager) + consolidationService := service5.ProvideConsolidationService(tracer, globalDataKeyStorage, encryptedValueStorage, globalEncryptedValueStorage, encryptionManager) runner := NewRunner(cfg, sqlStore, ossImpl, serviceService, featureToggles, secretsService, secretsMigrator, userService, consolidationService) return runner, nil } // InitializeForCLITarget is a simplified set of dependencies for the CLI, used // by the server target subcommand to launch specific dskit modules. -func InitializeForCLITarget(cfg *setting.Cfg) (ModuleRunner, error) { +func InitializeForCLITarget(ctx context.Context, cfg *setting.Cfg) (ModuleRunner, error) { ossImpl := setting.ProvideProvider(cfg) featureManager, err := featuremgmt.ProvideManagerService(cfg) if err != nil { @@ -1591,7 +1618,7 @@ var withOTelSet = wire.NewSet( otelTracer, grpcserver.ProvideService, interceptors.ProvideAuthenticator, ) -var wireBasicSet = wire.NewSet(annotationsimpl.ProvideService, wire.Bind(new(annotations.Repository), new(*annotationsimpl.RepositoryImpl)), New, api.ProvideHTTPServer, query.ProvideService, wire.Bind(new(query.Service), new(*query.ServiceImpl)), bus.ProvideBus, wire.Bind(new(bus.Bus), new(*bus.InProcBus)), rendering.ProvideService, wire.Bind(new(rendering.Service), new(*rendering.RenderingService)), routing.ProvideRegister, wire.Bind(new(routing.RouteRegister), new(*routing.RouteRegisterImpl)), hooks.ProvideService, kvstore.ProvideService, localcache.ProvideService, bundleregistry.ProvideService, wire.Bind(new(supportbundles.Service), new(*bundleregistry.Service)), updatemanager.ProvideGrafanaService, updatemanager.ProvidePluginsService, service.ProvideService, wire.Bind(new(usagestats.Service), new(*service.UsageStats)), validator2.ProvideService, legacy.ProvideLegacyMigrator, pluginsintegration.WireSet, dashboards.ProvideFileStoreManager, wire.Bind(new(dashboards.FileStore), new(*dashboards.FileStoreManager)), cloudwatch.ProvideService, cloudmonitoring.ProvideService, azuremonitor.ProvideService, postgres.ProvideService, mysql.ProvideService, mssql.ProvideService, store.ProvideEntityEventsService, dualwrite.ProvideService, httpclientprovider.New, wire.Bind(new(httpclient.Provider), new(*httpclient2.Provider)), serverlock.ProvideService, annotationsimpl.ProvideCleanupService, wire.Bind(new(annotations.Cleaner), new(*annotationsimpl.CleanupServiceImpl)), cleanup.ProvideService, shorturlimpl.ProvideService, wire.Bind(new(shorturls.Service), new(*shorturlimpl.ShortURLService)), queryhistory.ProvideService, wire.Bind(new(queryhistory.Service), new(*queryhistory.QueryHistoryService)), correlations.ProvideService, wire.Bind(new(correlations.Service), new(*correlations.CorrelationsService)), quotaimpl.ProvideService, remotecache.ProvideService, wire.Bind(new(remotecache.CacheStorage), new(*remotecache.RemoteCache)), authinfoimpl.ProvideService, wire.Bind(new(login.AuthInfoService), new(*authinfoimpl.Service)), authinfoimpl.ProvideStore, datasourceproxy.ProvideService, sort.ProvideService, search2.ProvideService, searchV2.ProvideService, searchV2.ProvideSearchHTTPService, store.ProvideService, store.ProvideSystemUsersService, live.ProvideService, pushhttp.ProvideService, contexthandler.ProvideService, service10.ProvideService, wire.Bind(new(service10.LDAP), new(*service10.LDAPImpl)), jwt.ProvideService, wire.Bind(new(jwt.JWTService), new(*jwt.AuthService)), store2.ProvideDBStore, image.ProvideDeleteExpiredService, ngalert.ProvideService, librarypanels.ProvideService, wire.Bind(new(librarypanels.Service), new(*librarypanels.LibraryPanelService)), libraryelements.ProvideService, 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)), withOTelSet, testdatasource.ProvideService, api4.ProvideService, opentsdb.ProvideService, socialimpl.ProvideService, influxdb.ProvideService, wire.Bind(new(social.Service), new(*socialimpl.SocialService)), tempo.ProvideService, loki.ProvideService, graphite.ProvideService, prometheus.ProvideService, elasticsearch.ProvideService, pyroscope.ProvideService, parca.ProvideService, zipkin.ProvideService, jaeger.ProvideService, service7.ProvideCacheService, wire.Bind(new(datasources.CacheService), new(*service7.CacheServiceImpl)), service2.ProvideEncryptionService, wire.Bind(new(encryption2.Internal), new(*service2.Service)), manager.ProvideSecretsService, wire.Bind(new(secrets2.Service), new(*manager.SecretsService)), database.ProvideSecretsStore, wire.Bind(new(secrets2.Store), new(*database.SecretsStoreImpl)), grafanads.ProvideService, wire.Bind(new(dashboardsnapshots.Store), new(*database4.DashboardSnapshotStore)), database4.ProvideStore, wire.Bind(new(dashboardsnapshots.Service), new(*service8.ServiceImpl)), service8.ProvideService, service7.ProvideService, wire.Bind(new(datasources.DataSourceService), new(*service7.Service)), service7.ProvideLegacyDataSourceLookup, retriever.ProvideService, wire.Bind(new(serviceaccounts.ServiceAccountRetriever), new(*retriever.Service)), ossaccesscontrol.ProvideServiceAccountPermissions, wire.Bind(new(accesscontrol.ServiceAccountPermissionsService), new(*ossaccesscontrol.ServiceAccountPermissionsService)), manager2.ProvideServiceAccountsService, proxy.ProvideServiceAccountsProxy, wire.Bind(new(serviceaccounts.Service), new(*proxy.ServiceAccountsProxy)), mtdsclient.NewNullMTDatasourceClientBuilder, expr.ProvideService, featuremgmt.ProvideManagerService, featuremgmt.ProvideToggles, service5.ProvideDashboardServiceImpl, wire.Bind(new(dashboards2.PermissionsRegistrationService), new(*service5.DashboardServiceImpl)), service5.ProvideDashboardService, service5.ProvideDashboardProvisioningService, service5.ProvideDashboardPluginService, database2.ProvideDashboardStore, folderimpl.ProvideService, wire.Bind(new(folder.Service), new(*folderimpl.Service)), folderimpl.ProvideStore, wire.Bind(new(folder.Store), new(*folderimpl.FolderStoreImpl)), folderimpl.ProvideDashboardFolderStore, wire.Bind(new(folder.FolderStore), new(*folderimpl.DashboardFolderStoreImpl)), service9.ProvideService, wire.Bind(new(dashboardimport.Service), new(*service9.ImportDashboardService)), service6.ProvideService, wire.Bind(new(plugindashboards.Service), new(*service6.Service)), service6.ProvideDashboardUpdater, sanitizer.ProvideService, kvstore2.ProvideService, avatar.ProvideAvatarCacheServer, statscollector.ProvideService, csrf.ProvideCSRFFilter, wire.Bind(new(csrf.Service), new(*csrf.CSRF)), ossaccesscontrol.ProvideTeamPermissions, wire.Bind(new(accesscontrol.TeamPermissionsService), new(*ossaccesscontrol.TeamPermissionsService)), ossaccesscontrol.ProvideFolderPermissions, wire.Bind(new(accesscontrol.FolderPermissionsService), new(*ossaccesscontrol.FolderPermissionsService)), ossaccesscontrol.ProvideDashboardPermissions, wire.Bind(new(accesscontrol.DashboardPermissionsService), new(*ossaccesscontrol.DashboardPermissionsService)), ossaccesscontrol.ProvideReceiverPermissionsService, wire.Bind(new(accesscontrol.ReceiverPermissionsService), new(*ossaccesscontrol.ReceiverPermissionsService)), starimpl.ProvideService, playlistimpl.ProvideService, apikeyimpl.ProvideService, dashverimpl.ProvideService, service3.ProvideService, wire.Bind(new(publicdashboards.Service), new(*service3.PublicDashboardServiceImpl)), database3.ProvideStore, wire.Bind(new(publicdashboards.Store), new(*database3.PublicDashboardStoreImpl)), metric.ProvideService, api2.ProvideApi, api3.ProvideApi, userimpl.ProvideService, orgimpl.ProvideService, orgimpl.ProvideDeletionService, statsimpl.ProvideService, grpccontext.ProvideContextHandler, grpcserver.ProvideHealthService, grpcserver.ProvideReflectionService, resolver.ProvideEntityReferenceResolver, teamimpl.ProvideService, teamapi.ProvideTeamAPI, tempuserimpl.ProvideService, loginattemptimpl.ProvideService, wire.Bind(new(loginattempt.Service), new(*loginattemptimpl.Service)), migrations2.ProvideDataSourceMigrationService, migrations2.ProvideSecretMigrationProvider, wire.Bind(new(migrations2.SecretMigrationProvider), new(*migrations2.SecretMigrationProviderImpl)), resourcepermissions.NewActionSetService, wire.Bind(new(accesscontrol.ActionResolver), new(resourcepermissions.ActionSetService)), wire.Bind(new(pluginaccesscontrol.ActionSetRegistry), new(resourcepermissions.ActionSetService)), permreg.ProvidePermissionRegistry, acimpl.ProvideAccessControl, dualwrite2.ProvideZanzanaReconciler, navtreeimpl.ProvideService, wire.Bind(new(accesscontrol.AccessControl), new(*acimpl.AccessControl)), wire.Bind(new(notifications.TempUserStore), new(tempuser.Service)), tagimpl.ProvideService, wire.Bind(new(tag.Service), new(*tagimpl.Service)), authnimpl.ProvideService, authnimpl.ProvideIdentitySynchronizer, authnimpl.ProvideAuthnService, authnimpl.ProvideAuthnServiceAuthenticateOnly, authnimpl.ProvideRegistration, supportbundlesimpl.ProvideService, extsvcaccounts.ProvideExtSvcAccountsService, wire.Bind(new(serviceaccounts.ExtSvcAccountsService), new(*extsvcaccounts.ExtSvcAccountsService)), registry2.ProvideExtSvcRegistry, wire.Bind(new(extsvcauth.ExternalServiceRegistry), new(*registry2.Registry)), anonstore.ProvideAnonDBStore, wire.Bind(new(anonstore.AnonStore), new(*anonstore.AnonDBStore)), loggermw.Provide, slogadapter.Provide, signingkeysimpl.ProvideEmbeddedSigningKeysService, wire.Bind(new(signingkeys.Service), new(*signingkeysimpl.Service)), ssosettingsimpl.ProvideService, wire.Bind(new(ssosettings.Service), new(*ssosettingsimpl.Service)), idimpl.ProvideService, wire.Bind(new(auth.IDService), new(*idimpl.Service)), cloudmigrationimpl.ProvideService, userimpl.ProvideVerifier, connectors.ProvideOrgRoleMapper, wire.Bind(new(user.Verifier), new(*userimpl.Verifier)), authz.WireSet, metadata.ProvideSecureValueMetadataStorage, metadata.ProvideKeeperMetadataStorage, metadata.ProvideDecryptStorage, decrypt.ProvideDecryptAuthorizer, decrypt.ProvideDecryptService, inline.ProvideInlineSecureValueService, encryption.ProvideDataKeyStorage, encryption.ProvideGlobalDataKeyStorage, encryption.ProvideEncryptedValueStorage, encryption.ProvideGlobalEncryptedValueStorage, service12.ProvideSecureValueService, validator3.ProvideKeeperValidator, validator3.ProvideSecureValueValidator, migrator2.NewWithEngine, database5.ProvideDatabase, wire.Bind(new(contracts.Database), new(*database5.Database)), manager4.ProvideEncryptionManager, service11.ProvideAESGCMCipherService, resource.ProvideStorageMetrics, resource.ProvideIndexMetrics, apiserver.WireSet, apiregistry.WireSet, appregistry.WireSet) +var wireBasicSet = wire.NewSet(annotationsimpl.ProvideService, wire.Bind(new(annotations.Repository), new(*annotationsimpl.RepositoryImpl)), New, api.ProvideHTTPServer, query.ProvideService, wire.Bind(new(query.Service), new(*query.ServiceImpl)), bus.ProvideBus, wire.Bind(new(bus.Bus), new(*bus.InProcBus)), rendering.ProvideService, wire.Bind(new(rendering.Service), new(*rendering.RenderingService)), routing.ProvideRegister, wire.Bind(new(routing.RouteRegister), new(*routing.RouteRegisterImpl)), hooks.ProvideService, kvstore.ProvideService, localcache.ProvideService, bundleregistry.ProvideService, wire.Bind(new(supportbundles.Service), new(*bundleregistry.Service)), updatemanager.ProvideGrafanaService, updatemanager.ProvidePluginsService, service.ProvideService, wire.Bind(new(usagestats.Service), new(*service.UsageStats)), validator3.ProvideService, legacy.ProvideLegacyMigrator, pluginsintegration.WireSet, dashboards.ProvideFileStoreManager, wire.Bind(new(dashboards.FileStore), new(*dashboards.FileStoreManager)), cloudwatch.ProvideService, cloudmonitoring.ProvideService, azuremonitor.ProvideService, postgres.ProvideService, mysql.ProvideService, mssql.ProvideService, store.ProvideEntityEventsService, dualwrite.ProvideService, httpclientprovider.New, wire.Bind(new(httpclient.Provider), new(*httpclient2.Provider)), serverlock.ProvideService, annotationsimpl.ProvideCleanupService, wire.Bind(new(annotations.Cleaner), new(*annotationsimpl.CleanupServiceImpl)), cleanup.ProvideService, shorturlimpl.ProvideService, wire.Bind(new(shorturls.Service), new(*shorturlimpl.ShortURLService)), queryhistory.ProvideService, wire.Bind(new(queryhistory.Service), new(*queryhistory.QueryHistoryService)), correlations.ProvideService, wire.Bind(new(correlations.Service), new(*correlations.CorrelationsService)), quotaimpl.ProvideService, remotecache.ProvideService, wire.Bind(new(remotecache.CacheStorage), new(*remotecache.RemoteCache)), authinfoimpl.ProvideService, wire.Bind(new(login.AuthInfoService), new(*authinfoimpl.Service)), authinfoimpl.ProvideStore, datasourceproxy.ProvideService, sort.ProvideService, search2.ProvideService, searchV2.ProvideService, searchV2.ProvideSearchHTTPService, store.ProvideService, store.ProvideSystemUsersService, live.ProvideService, pushhttp.ProvideService, contexthandler.ProvideService, service12.ProvideService, wire.Bind(new(service12.LDAP), new(*service12.LDAPImpl)), jwt.ProvideService, wire.Bind(new(jwt.JWTService), new(*jwt.AuthService)), store2.ProvideDBStore, image.ProvideDeleteExpiredService, ngalert.ProvideService, librarypanels.ProvideService, wire.Bind(new(librarypanels.Service), new(*librarypanels.LibraryPanelService)), libraryelements.ProvideService, 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)), withOTelSet, testdatasource.ProvideService, api4.ProvideService, opentsdb.ProvideService, socialimpl.ProvideService, influxdb.ProvideService, wire.Bind(new(social.Service), new(*socialimpl.SocialService)), tempo.ProvideService, loki.ProvideService, graphite.ProvideService, prometheus.ProvideService, elasticsearch.ProvideService, pyroscope.ProvideService, parca.ProvideService, zipkin.ProvideService, jaeger.ProvideService, service9.ProvideCacheService, wire.Bind(new(datasources.CacheService), new(*service9.CacheServiceImpl)), service2.ProvideEncryptionService, wire.Bind(new(encryption2.Internal), new(*service2.Service)), manager.ProvideSecretsService, wire.Bind(new(secrets2.Service), new(*manager.SecretsService)), database.ProvideSecretsStore, wire.Bind(new(secrets2.Store), new(*database.SecretsStoreImpl)), grafanads.ProvideService, wire.Bind(new(dashboardsnapshots.Store), new(*database5.DashboardSnapshotStore)), database5.ProvideStore, wire.Bind(new(dashboardsnapshots.Service), new(*service10.ServiceImpl)), service10.ProvideService, service9.ProvideService, wire.Bind(new(datasources.DataSourceService), new(*service9.Service)), service9.ProvideLegacyDataSourceLookup, retriever.ProvideService, wire.Bind(new(serviceaccounts.ServiceAccountRetriever), new(*retriever.Service)), ossaccesscontrol.ProvideServiceAccountPermissions, wire.Bind(new(accesscontrol.ServiceAccountPermissionsService), new(*ossaccesscontrol.ServiceAccountPermissionsService)), manager3.ProvideServiceAccountsService, proxy.ProvideServiceAccountsProxy, wire.Bind(new(serviceaccounts.Service), new(*proxy.ServiceAccountsProxy)), mtdsclient.NewNullMTDatasourceClientBuilder, expr.ProvideService, featuremgmt.ProvideManagerService, featuremgmt.ProvideToggles, service7.ProvideDashboardServiceImpl, wire.Bind(new(dashboards2.PermissionsRegistrationService), new(*service7.DashboardServiceImpl)), service7.ProvideDashboardService, service7.ProvideDashboardProvisioningService, service7.ProvideDashboardPluginService, database2.ProvideDashboardStore, folderimpl.ProvideService, wire.Bind(new(folder.Service), new(*folderimpl.Service)), folderimpl.ProvideStore, wire.Bind(new(folder.Store), new(*folderimpl.FolderStoreImpl)), folderimpl.ProvideDashboardFolderStore, wire.Bind(new(folder.FolderStore), new(*folderimpl.DashboardFolderStoreImpl)), service11.ProvideService, wire.Bind(new(dashboardimport.Service), new(*service11.ImportDashboardService)), service8.ProvideService, wire.Bind(new(plugindashboards.Service), new(*service8.Service)), service8.ProvideDashboardUpdater, sanitizer.ProvideService, kvstore2.ProvideService, avatar.ProvideAvatarCacheServer, statscollector.ProvideService, csrf.ProvideCSRFFilter, wire.Bind(new(csrf.Service), new(*csrf.CSRF)), ossaccesscontrol.ProvideTeamPermissions, wire.Bind(new(accesscontrol.TeamPermissionsService), new(*ossaccesscontrol.TeamPermissionsService)), ossaccesscontrol.ProvideFolderPermissions, wire.Bind(new(accesscontrol.FolderPermissionsService), new(*ossaccesscontrol.FolderPermissionsService)), ossaccesscontrol.ProvideDashboardPermissions, wire.Bind(new(accesscontrol.DashboardPermissionsService), new(*ossaccesscontrol.DashboardPermissionsService)), ossaccesscontrol.ProvideReceiverPermissionsService, wire.Bind(new(accesscontrol.ReceiverPermissionsService), new(*ossaccesscontrol.ReceiverPermissionsService)), starimpl.ProvideService, playlistimpl.ProvideService, apikeyimpl.ProvideService, dashverimpl.ProvideService, service3.ProvideService, wire.Bind(new(publicdashboards.Service), new(*service3.PublicDashboardServiceImpl)), database3.ProvideStore, wire.Bind(new(publicdashboards.Store), new(*database3.PublicDashboardStoreImpl)), metric.ProvideService, api2.ProvideApi, api3.ProvideApi, userimpl.ProvideService, orgimpl.ProvideService, orgimpl.ProvideDeletionService, statsimpl.ProvideService, grpccontext.ProvideContextHandler, grpcserver.ProvideHealthService, grpcserver.ProvideReflectionService, resolver.ProvideEntityReferenceResolver, teamimpl.ProvideService, teamapi.ProvideTeamAPI, tempuserimpl.ProvideService, loginattemptimpl.ProvideService, wire.Bind(new(loginattempt.Service), new(*loginattemptimpl.Service)), migrations2.ProvideDataSourceMigrationService, migrations2.ProvideSecretMigrationProvider, wire.Bind(new(migrations2.SecretMigrationProvider), new(*migrations2.SecretMigrationProviderImpl)), resourcepermissions.NewActionSetService, wire.Bind(new(accesscontrol.ActionResolver), new(resourcepermissions.ActionSetService)), wire.Bind(new(pluginaccesscontrol.ActionSetRegistry), new(resourcepermissions.ActionSetService)), permreg.ProvidePermissionRegistry, acimpl.ProvideAccessControl, dualwrite2.ProvideZanzanaReconciler, navtreeimpl.ProvideService, wire.Bind(new(accesscontrol.AccessControl), new(*acimpl.AccessControl)), wire.Bind(new(notifications.TempUserStore), new(tempuser.Service)), tagimpl.ProvideService, wire.Bind(new(tag.Service), new(*tagimpl.Service)), authnimpl.ProvideService, authnimpl.ProvideIdentitySynchronizer, authnimpl.ProvideAuthnService, authnimpl.ProvideAuthnServiceAuthenticateOnly, authnimpl.ProvideRegistration, supportbundlesimpl.ProvideService, extsvcaccounts.ProvideExtSvcAccountsService, wire.Bind(new(serviceaccounts.ExtSvcAccountsService), new(*extsvcaccounts.ExtSvcAccountsService)), registry2.ProvideExtSvcRegistry, wire.Bind(new(extsvcauth.ExternalServiceRegistry), new(*registry2.Registry)), anonstore.ProvideAnonDBStore, wire.Bind(new(anonstore.AnonStore), new(*anonstore.AnonDBStore)), loggermw.Provide, slogadapter.Provide, signingkeysimpl.ProvideEmbeddedSigningKeysService, wire.Bind(new(signingkeys.Service), new(*signingkeysimpl.Service)), ssosettingsimpl.ProvideService, wire.Bind(new(ssosettings.Service), new(*ssosettingsimpl.Service)), idimpl.ProvideService, wire.Bind(new(auth.IDService), new(*idimpl.Service)), cloudmigrationimpl.ProvideService, userimpl.ProvideVerifier, connectors.ProvideOrgRoleMapper, wire.Bind(new(user.Verifier), new(*userimpl.Verifier)), authz.WireSet, metadata.ProvideSecureValueMetadataStorage, metadata.ProvideKeeperMetadataStorage, metadata.ProvideDecryptStorage, decrypt.ProvideDecryptAuthorizer, decrypt.ProvideDecryptService, inline.ProvideInlineSecureValueService, encryption.ProvideDataKeyStorage, encryption.ProvideGlobalDataKeyStorage, encryption.ProvideEncryptedValueStorage, encryption.ProvideGlobalEncryptedValueStorage, service5.ProvideSecureValueService, validator.ProvideKeeperValidator, validator.ProvideSecureValueValidator, mutator.ProvideKeeperMutator, mutator.ProvideSecureValueMutator, migrator2.NewWithEngine, database4.ProvideDatabase, wire.Bind(new(contracts.Database), new(*database4.Database)), manager2.ProvideEncryptionManager, service4.ProvideAESGCMCipherService, resource.ProvideStorageMetrics, resource.ProvideIndexMetrics, apiserver.WireSet, apiregistry.WireSet, appregistry.WireSet) var wireSet = wire.NewSet( wireBasicSet, metrics.WireSet, sqlstore.ProvideService, metrics2.ProvideService, wire.Bind(new(notifications.Service), new(*notifications.NotificationService)), wire.Bind(new(notifications.WebhookSender), new(*notifications.NotificationService)), wire.Bind(new(notifications.EmailSender), new(*notifications.NotificationService)), wire.Bind(new(db.DB), new(*sqlstore.SQLStore)), prefimpl.ProvideService, oauthtoken.ProvideService, wire.Bind(new(oauthtoken.OAuthTokenService), new(*oauthtoken.Service)), wire.Bind(new(cleanup.AlertRuleService), new(*store2.DBstore)), diff --git a/pkg/server/wireexts_oss.go b/pkg/server/wireexts_oss.go index 61a05716b47..57c18d86cc4 100644 --- a/pkg/server/wireexts_oss.go +++ b/pkg/server/wireexts_oss.go @@ -7,6 +7,7 @@ package server import ( "github.com/google/wire" + "github.com/grafana/grafana/pkg/configprovider" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" @@ -71,6 +72,10 @@ var provisioningExtras = wire.NewSet( extras.ProvideProvisioningOSSExtras, ) +var configProviderExtras = wire.NewSet( + configprovider.ProvideService, +) + var wireExtsBasicSet = wire.NewSet( authimpl.ProvideUserAuthTokenService, wire.Bind(new(auth.UserTokenService), new(*authimpl.UserAuthTokenService)), @@ -144,6 +149,7 @@ var wireExtsBasicSet = wire.NewSet( gsmKMSProviders.ProvideOSSKMSProviders, secret.ProvideSecureValueClient, provisioningExtras, + configProviderExtras, ) var wireExtsSet = wire.NewSet( @@ -173,6 +179,7 @@ var wireExtsBaseCLISet = wire.NewSet( hooks.ProvideService, setting.ProvideProvider, wire.Bind(new(setting.Provider), new(*setting.OSSImpl)), licensing.ProvideService, wire.Bind(new(licensing.Licensing), new(*licensing.OSSLicensingService)), + configProviderExtras, ) // wireModuleServerSet is a wire set for the ModuleServer. diff --git a/pkg/services/apiserver/options/grafana-aggregator.go b/pkg/services/apiserver/options/grafana-aggregator.go index 367b4940cc7..bc8b6f66fa6 100644 --- a/pkg/services/apiserver/options/grafana-aggregator.go +++ b/pkg/services/apiserver/options/grafana-aggregator.go @@ -75,7 +75,7 @@ func (o *GrafanaAggregatorOptions) ApplyTo(aggregatorConfig *aggregatorapiserver return err } // override the RESTOptionsGetter to use the in memory storage options - restOptionsGetter, err := apistore.NewRESTOptionsGetterMemory(etcdOptions.StorageConfig) + restOptionsGetter, err := apistore.NewRESTOptionsGetterMemory(etcdOptions.StorageConfig, nil) if err != nil { return err } diff --git a/pkg/services/apiserver/options/storage.go b/pkg/services/apiserver/options/storage.go index 2ff900037fd..5d7661a99e1 100644 --- a/pkg/services/apiserver/options/storage.go +++ b/pkg/services/apiserver/options/storage.go @@ -14,6 +14,7 @@ import ( "k8s.io/client-go/rest" "github.com/grafana/grafana/pkg/infra/tracing" + secret "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/apistore" "github.com/grafana/grafana/pkg/storage/unified/resource" @@ -63,6 +64,9 @@ type StorageOptions struct { // value, it is considered large and gets partially stored in blob storage. BlobThresholdBytes int + // Support writing secrets inline + InlineSecrets secret.InlineSecureValueSupport + // {resource}.{group} = 1|2|3|4 UnifiedStorageConfig map[string]setting.UnifiedStorageConfig @@ -164,7 +168,7 @@ func (o *StorageOptions) ApplyTo(serverConfig *genericapiserver.RecommendedConfi if err != nil { return err } - getter := apistore.NewRESTOptionsGetterForClient(unified, etcdOptions.StorageConfig, o.ConfigProvider) + getter := apistore.NewRESTOptionsGetterForClient(unified, o.InlineSecrets, etcdOptions.StorageConfig, o.ConfigProvider) serverConfig.RESTOptionsGetter = getter return nil } diff --git a/pkg/services/apiserver/service.go b/pkg/services/apiserver/service.go index be580025823..31777f82a65 100644 --- a/pkg/services/apiserver/service.go +++ b/pkg/services/apiserver/service.go @@ -39,6 +39,7 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/registry/apis/datasource" + secret "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" "github.com/grafana/grafana/pkg/services/apiserver/aggregatorrunner" "github.com/grafana/grafana/pkg/services/apiserver/appinstaller" "github.com/grafana/grafana/pkg/services/apiserver/auth/authenticator" @@ -105,6 +106,7 @@ type service struct { contextProvider datasource.PluginContextWrapper pluginStore pluginstore.Store unified resource.ResourceClient + secrets secret.InlineSecureValueSupport restConfigProvider RestConfigProvider buildHandlerChainFuncFromBuilders builder.BuildHandlerChainFuncFromBuilders @@ -128,6 +130,7 @@ func ProvideService( pluginStore pluginstore.Store, storageStatus dualwrite.Service, unified resource.ResourceClient, + secrets secret.InlineSecureValueSupport, restConfigProvider RestConfigProvider, buildHandlerChainFuncFromBuilders builder.BuildHandlerChainFuncFromBuilders, eventualRestConfigProvider *eventualRestConfigProvider, @@ -159,6 +162,7 @@ func ProvideService( serverLockService: serverLockService, storageStatus: storageStatus, unified: unified, + secrets: secrets, restConfigProvider: restConfigProvider, buildHandlerChainFuncFromBuilders: buildHandlerChainFuncFromBuilders, aggregatorRunner: aggregatorRunner, @@ -337,7 +341,7 @@ func (s *service) start(ctx context.Context) error { return err } } else { - getter := apistore.NewRESTOptionsGetterForClient(s.unified, o.RecommendedOptions.Etcd.StorageConfig, s.restConfigProvider) + getter := apistore.NewRESTOptionsGetterForClient(s.unified, s.secrets, o.RecommendedOptions.Etcd.StorageConfig, s.restConfigProvider) optsregister = getter.RegisterOptions serverConfig.RESTOptionsGetter = getter } diff --git a/pkg/services/authz/rbac/mapper.go b/pkg/services/authz/rbac/mapper.go index 28537ad638d..53b6bb4ef0a 100644 --- a/pkg/services/authz/rbac/mapper.go +++ b/pkg/services/authz/rbac/mapper.go @@ -125,6 +125,9 @@ func NewMapperRegistry() MapperRegistry { "securevalues": newResourceTranslation("secret.securevalues", "uid", false), "keepers": newResourceTranslation("secret.keepers", "uid", false), }, + "settings.grafana.app": { + "settings": newResourceTranslation("settings", "uid", false), + }, "query.grafana.app": { "query": translation{ resource: "datasources", diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go index 163fcfbcb1b..1dadac3bcb9 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go @@ -81,6 +81,8 @@ type Service struct { api *api.CloudMigrationAPI tracer tracing.Tracer metrics *Metrics + + grafanaVersion string } var LogPrefix = "cloudmigration.service" @@ -191,6 +193,8 @@ func ProvideService( } } + s.grafanaVersion = cfg.BuildVersion + return s, nil } @@ -856,9 +860,10 @@ func (s *Service) report( } e := gmsclient.EventRequestDTO{ - Event: t, - LocalID: id, - UserUID: userUID, + Event: t, + LocalID: id, + UserUID: userUID, + GrafanaVersion: s.grafanaVersion, } if d != 0 { diff --git a/pkg/services/cloudmigration/gmsclient/dtos.go b/pkg/services/cloudmigration/gmsclient/dtos.go index 299e6a29edf..54332422fcd 100644 --- a/pkg/services/cloudmigration/gmsclient/dtos.go +++ b/pkg/services/cloudmigration/gmsclient/dtos.go @@ -51,6 +51,7 @@ type EventRequestDTO struct { Error string `json:"error"` DurationIfFinished time.Duration `json:"duration"` UserUID string `json:"userUid"` + GrafanaVersion string `json:"grafanaVersion"` } type LocalEventType string diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index a8c7419921d..b6bfc346f51 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -589,14 +589,20 @@ func (d *dashboardStore) deleteDashboard(cmd *dashboards.DeleteDashboardCommand, return err } - // remove all access control permission with folder scope - err := d.deleteResourcePermissions(sess, dashboard.OrgID, dashboards.ScopeFoldersProvider.GetResourceScopeUID(dashboard.UID)) - if err != nil { - return err + // While migrating to unified storage, we might execute commands in both stores, so we delete the permissions + // only when the command is executed on both stores, thus we can skip it here. + if cmd.RemovePermissions { + if err := d.deleteResourcePermissions(sess, dashboard.OrgID, dashboards.ScopeFoldersProvider.GetResourceScopeUID(dashboard.UID)); err != nil { + return err + } } } else { - if err := d.deleteResourcePermissions(sess, dashboard.OrgID, ac.GetResourceScopeUID("dashboards", dashboard.UID)); err != nil { - return err + // While migrating to unified storage, we might execute commands in both stores, so we delete the permissions + // only when the command is executed on both stores, thus we can skip it here. + if cmd.RemovePermissions { + if err := d.deleteResourcePermissions(sess, dashboard.OrgID, ac.GetResourceScopeUID("dashboards", dashboard.UID)); err != nil { + return err + } } } diff --git a/pkg/services/dashboards/models.go b/pkg/services/dashboards/models.go index 9dbe4696d1b..f48fafc6a81 100644 --- a/pkg/services/dashboards/models.go +++ b/pkg/services/dashboards/models.go @@ -233,6 +233,7 @@ type DeleteDashboardCommand struct { UID string OrgID int64 ForceDeleteFolderRules bool + RemovePermissions bool } type DeleteOrphanedProvisionedDashboardsCommand struct { diff --git a/pkg/services/dashboardversion/dashverimpl/dashver.go b/pkg/services/dashboardversion/dashverimpl/dashver.go index b2063f44091..3341d8a1339 100644 --- a/pkg/services/dashboardversion/dashverimpl/dashver.go +++ b/pkg/services/dashboardversion/dashverimpl/dashver.go @@ -69,7 +69,6 @@ func ProvideService(cfg *setting.Cfg, db db.DB, dashboardService dashboards.Dash } func (s *Service) Get(ctx context.Context, query *dashver.GetDashboardVersionQuery) (*dashver.DashboardVersionDTO, error) { - // Get the DashboardUID if not populated if query.DashboardUID == "" { u, err := s.getDashUIDMaybeEmpty(ctx, query.DashboardID) if err != nil { @@ -78,17 +77,6 @@ func (s *Service) Get(ctx context.Context, query *dashver.GetDashboardVersionQue query.DashboardUID = u } - // The store methods require the dashboard ID (uid is not in the dashboard - // versions table, at time of this writing), so get the DashboardID if it - // was not populated. - if query.DashboardID == 0 { - id, err := s.getDashIDMaybeEmpty(ctx, query.DashboardUID, query.OrgID) - if err != nil { - return nil, err - } - query.DashboardID = id - } - version, err := s.getHistoryThroughK8s(ctx, query.OrgID, query.DashboardUID, query.Version) if err != nil { return nil, err @@ -128,7 +116,6 @@ func (s *Service) DeleteExpired(ctx context.Context, cmd *dashver.DeleteExpiredV // List all dashboard versions for the given dashboard ID. func (s *Service) List(ctx context.Context, query *dashver.ListDashboardVersionsQuery) (*dashver.DashboardVersionResponse, error) { - // Get the DashboardUID if not populated if query.DashboardUID == "" { u, err := s.getDashUIDMaybeEmpty(ctx, query.DashboardID) if err != nil { @@ -137,16 +124,6 @@ func (s *Service) List(ctx context.Context, query *dashver.ListDashboardVersions query.DashboardUID = u } - // The store methods require the dashboard ID (uid is not in the dashboard - // versions table, at time of this writing), so get the DashboardID if it - // was not populated. - if query.DashboardID == 0 { - id, err := s.getDashIDMaybeEmpty(ctx, query.DashboardUID, query.OrgID) - if err != nil { - return nil, err - } - query.DashboardID = id - } if query.Limit == 0 { query.Limit = 1000 } @@ -182,23 +159,6 @@ func (s *Service) getDashUIDMaybeEmpty(ctx context.Context, id int64) (string, e return result.UID, nil } -// getDashIDMaybeEmpty is a helper function which takes a dashboardUID and -// returns the ID. If the dashboard is not found, it will return -1. -func (s *Service) getDashIDMaybeEmpty(ctx context.Context, uid string, orgID int64) (int64, error) { - q := dashboards.GetDashboardQuery{UID: uid, OrgID: orgID} - result, err := s.dashSvc.GetDashboard(ctx, &q) - if err != nil { - if errors.Is(err, dashboards.ErrDashboardNotFound) { - s.log.Debug("dashboard not found") - return -1, nil - } else { - s.log.Error("error getting dashboard", err) - return -1, err - } - } - return result.ID, nil -} - 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. @@ -259,13 +219,29 @@ func (s *Service) listHistoryThroughK8s(ctx context.Context, orgID int64, dashbo return nil, dashboards.ErrDashboardNotFound } + // if k8s returns a continue token, we need to fetch the next page(s) until we either reach the limit or there are no more pages + continueToken = out.GetContinue() + for (len(out.Items) < int(limit)) && (continueToken != "") { + tempOut, err := s.k8sclient.List(ctx, orgID, v1.ListOptions{ + LabelSelector: labelSelector, + FieldSelector: fieldSelector, + Continue: continueToken, + Limit: limit - int64(len(out.Items)), + }) + if err != nil { + return nil, err + } + out.Items = append(out.Items, tempOut.Items...) + continueToken = tempOut.GetContinue() + } + dashboards, err := s.UnstructuredToLegacyDashboardVersionList(ctx, out.Items, orgID) if err != nil { return nil, err } return &dashver.DashboardVersionResponse{ - ContinueToken: out.GetContinue(), + ContinueToken: continueToken, Versions: dashboards, }, nil } diff --git a/pkg/services/dashboardversion/dashverimpl/dashver_test.go b/pkg/services/dashboardversion/dashverimpl/dashver_test.go index 1b32379e1a5..0bca02e8e93 100644 --- a/pkg/services/dashboardversion/dashverimpl/dashver_test.go +++ b/pkg/services/dashboardversion/dashverimpl/dashver_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" @@ -197,6 +198,72 @@ func TestListDashboardVersions(t *testing.T) { }}}, res) }) + t.Run("List returns correct continue token across multiple pages", func(t *testing.T) { + dashboardService := dashboards.NewFakeDashboardService(t) + dashboardVersionService := Service{dashSvc: dashboardService, features: featuremgmt.WithFeatures()} + mockCli := new(client.MockK8sHandler) + dashboardVersionService.k8sclient = mockCli + dashboardVersionService.features = featuremgmt.WithFeatures() + + dashboardService.On("GetDashboardUIDByID", mock.Anything, + mock.AnythingOfType("*dashboards.GetDashboardRefByIDQuery")). + Return(&dashboards.DashboardRef{UID: "uid"}, nil) + query := dashver.ListDashboardVersionsQuery{DashboardID: 42, Limit: 3} + mockCli.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil) + firstPage := &unstructured.UnstructuredList{ + Items: []unstructured.Unstructured{ + {Object: map[string]any{ + "metadata": map[string]any{ + "name": "uid", + "resourceVersion": "11", + "generation": int64(4), + "labels": map[string]any{ + utils.LabelKeyDeprecatedInternalID: "42", // nolint:staticcheck + }, + }, + "spec": map[string]any{}, + }}, + {Object: map[string]any{ + "metadata": map[string]any{ + "name": "uid", + "resourceVersion": "12", + "generation": int64(5), + "labels": map[string]any{ + utils.LabelKeyDeprecatedInternalID: "42", // nolint:staticcheck + }, + }, + "spec": map[string]any{}, + }}, + }, + } + firstMeta, err := meta.ListAccessor(firstPage) + require.NoError(t, err) + firstMeta.SetContinue("t1") + secondPage := &unstructured.UnstructuredList{ + Items: []unstructured.Unstructured{ + {Object: map[string]any{ + "metadata": map[string]any{ + "name": "uid", + "resourceVersion": "13", + "generation": int64(6), + "labels": map[string]any{ + utils.LabelKeyDeprecatedInternalID: "42", // nolint:staticcheck + }, + }, + "spec": map[string]any{}, + }}, + }, + } + mockCli.On("List", mock.Anything, mock.Anything, mock.Anything).Return(firstPage, nil).Once() + mockCli.On("List", mock.Anything, mock.Anything, mock.Anything).Return(secondPage, nil).Once() + + res, err := dashboardVersionService.List(context.Background(), &query) + require.Nil(t, err) + require.Equal(t, 3, len(res.Versions)) + require.Equal(t, "", res.ContinueToken) + mockCli.AssertNumberOfCalls(t, "List", 2) + }) + t.Run("should return dashboard not found error when k8s client says not found", func(t *testing.T) { dashboardService := dashboards.NewFakeDashboardService(t) dashboardVersionService := Service{dashSvc: dashboardService, features: featuremgmt.WithFeatures()} diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index ed5d527799e..009f7052d18 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -254,14 +254,6 @@ var ( Stage: FeatureStagePrivatePreview, Owner: grafanaPluginsPlatformSquad, }, - { - Name: "sqlDatasourceDatabaseSelection", - Description: "Enables previous SQL data source dataset dropdown behavior", - FrontendOnly: true, - Stage: FeatureStagePublicPreview, - Owner: grafanaOSSBigTent, - HideFromAdminPage: true, - }, { Name: "recordedQueriesMulti", Description: "Enables writing multiple items from a single query within Recorded Queries", @@ -759,7 +751,7 @@ var ( Stage: FeatureStagePublicPreview, FrontendOnly: false, Owner: grafanaAlertingSquad, - Expression: "false", + Expression: "true", }, { Name: "scopeApi", @@ -1395,15 +1387,6 @@ var ( Owner: identityAccessTeam, AllowSelfServe: true, }, - { - Name: "teamHttpHeadersMimir", - Description: "Enables LBAC for datasources for Mimir to apply LBAC filtering of metrics to the client requests for users in teams", - Stage: FeatureStageGeneralAvailability, - FrontendOnly: false, - AllowSelfServe: true, - Owner: identityAccessTeam, - Expression: "true", - }, { Name: "teamHttpHeadersTempo", Description: "Enables LBAC for datasources for Tempo to apply LBAC filtering of traces to the client requests for users in teams", @@ -1655,6 +1638,14 @@ var ( Owner: grafanaObservabilityTracesAndProfilingSquad, FrontendOnly: true, }, + { + Name: "grafanaAssistantInProfilesDrilldown", + Description: "Enables integration with Grafana Assistant in Profiles Drilldown", + Stage: FeatureStageGeneralAvailability, + Owner: grafanaObservabilityTracesAndProfilingSquad, + FrontendOnly: true, + Expression: "true", + }, { Name: "postgresDSUsePGX", Description: "Enables using PGX instead of libpq for PostgreSQL datasource", @@ -1714,6 +1705,14 @@ var ( HideFromAdminPage: true, HideFromDocs: true, }, + { + Name: "kubernetesAuthzResourcePermissionApis", + Description: "Registers AuthZ resource permission /apis endpoints", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromAdminPage: true, + HideFromDocs: true, + }, { Name: "kubernetesAuthnMutation", Description: "Enables create, delete, and update mutations for resources owned by IAM identity", @@ -1757,6 +1756,15 @@ var ( HideFromDocs: true, Expression: "false", }, + { + Name: "alertingImportAlertmanagerUI", + Description: "Enables the UI to see imported Alertmanager configuration", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromAdminPage: true, + HideFromDocs: true, + Expression: "false", + }, { Name: "sharingDashboardImage", Description: "Enables image sharing functionality for dashboards", @@ -1881,6 +1889,13 @@ var ( Owner: grafanaDataProSquad, FrontendOnly: true, }, + { + Name: "favoriteDatasources", + Description: "Enable favorite datasources", + Stage: FeatureStageExperimental, + Owner: grafanaPluginsPlatformSquad, + FrontendOnly: true, + }, { Name: "newLogContext", Description: "New Log Context component", @@ -1896,6 +1911,24 @@ var ( Owner: grafanaPartnerPluginsSquad, Expression: "false", }, + { + Name: "unifiedStorageSearchAfterWriteExperimentalAPI", + Description: "Enable experimental search-after-write guarantees to unified-storage search endpoints", + Stage: FeatureStageExperimental, + Owner: grafanaSearchAndStorageSquad, + HideFromAdminPage: true, + HideFromDocs: true, + RequiresRestart: true, + Expression: "false", + }, + { + Name: "teamFolders", + Description: "Enables team folders functionality", + Stage: FeatureStageExperimental, + FrontendOnly: false, + Owner: grafanaFrontendSearchNavOrganise, + Expression: "false", + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 8af78162673..88ad21535ac 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -32,7 +32,6 @@ faroDatasourceSelector,preview,@grafana/app-o11y,false,false,true enableDatagridEditing,preview,@grafana/dataviz-squad,false,false,true extraThemes,experimental,@grafana/grafana-frontend-platform,false,false,true pluginsFrontendSandbox,privatePreview,@grafana/plugins-platform-backend,false,false,false -sqlDatasourceDatabaseSelection,preview,@grafana/oss-big-tent,false,false,true recordedQueriesMulti,GA,@grafana/observability-metrics,false,false,false logsExploreTableVisualisation,GA,@grafana/observability-logs,false,false,true awsDatasourcesTempCredentials,GA,@grafana/aws-datasources,false,false,false @@ -182,7 +181,6 @@ investigationsBackend,experimental,@grafana/grafana-app-platform-squad,false,fal k8SFolderCounts,experimental,@grafana/search-and-storage,false,false,false k8SFolderMove,experimental,@grafana/search-and-storage,false,false,false improvedExternalSessionHandlingSAML,GA,@grafana/identity-access-team,false,false,false -teamHttpHeadersMimir,GA,@grafana/identity-access-team,false,false,false teamHttpHeadersTempo,experimental,@grafana/identity-access-team,false,false,false templateVariablesUsesCombobox,experimental,@grafana/grafana-frontend-platform,false,false,true grafanaAdvisor,privatePreview,@grafana/plugins-platform-backend,false,false,false @@ -215,6 +213,7 @@ localizationForPlugins,experimental,@grafana/plugins-platform-backend,false,fals unifiedNavbars,GA,@grafana/plugins-platform-backend,false,false,true logsPanelControls,preview,@grafana/observability-logs,false,false,true metricsFromProfiles,experimental,@grafana/observability-traces-and-profiling,false,false,true +grafanaAssistantInProfilesDrilldown,GA,@grafana/observability-traces-and-profiling,false,false,true postgresDSUsePGX,experimental,@grafana/oss-big-tent,false,false,false tempoAlerting,experimental,@grafana/observability-traces-and-profiling,false,false,true pluginsAutoUpdate,experimental,@grafana/plugins-platform-backend,false,false,false @@ -223,11 +222,13 @@ alertingListViewV2PreviewToggle,privatePreview,@grafana/alerting-squad,false,fal alertRuleUseFiredAtForStartsAt,experimental,@grafana/alerting-squad,false,false,false alertingBulkActionsInUI,GA,@grafana/alerting-squad,false,false,true kubernetesAuthzApis,experimental,@grafana/identity-access-team,false,false,false +kubernetesAuthzResourcePermissionApis,experimental,@grafana/identity-access-team,false,false,false kubernetesAuthnMutation,experimental,@grafana/identity-access-team,false,false,false restoreDashboards,experimental,@grafana/grafana-frontend-platform,false,false,false skipTokenRotationIfRecent,GA,@grafana/identity-access-team,false,false,false alertEnrichment,experimental,@grafana/alerting-squad,false,false,false alertingImportAlertmanagerAPI,experimental,@grafana/alerting-squad,false,false,false +alertingImportAlertmanagerUI,experimental,@grafana/alerting-squad,false,false,false sharingDashboardImage,experimental,@grafana/sharing-squad,false,false,true preferLibraryPanelTitle,privatePreview,@grafana/dashboards-squad,false,false,false tabularNumbers,GA,@grafana/grafana-frontend-platform,false,false,false @@ -243,5 +244,8 @@ dashboardDsAdHocFiltering,experimental,@grafana/datapro,false,false,true dashboardLevelTimeMacros,experimental,@grafana/dashboards-squad,false,false,true alertmanagerRemoteSecondaryWithRemoteState,experimental,@grafana/alerting-squad,false,false,false adhocFiltersInTooltips,experimental,@grafana/datapro,false,false,true +favoriteDatasources,experimental,@grafana/plugins-platform-backend,false,false,true newLogContext,experimental,@grafana/observability-logs,false,false,true newClickhouseConfigPageDesign,privatePreview,@grafana/partner-datasources,false,false,false +unifiedStorageSearchAfterWriteExperimentalAPI,experimental,@grafana/search-and-storage,false,true,false +teamFolders,experimental,@grafana/grafana-search-navigate-organise,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 3a745490f8d..8f8db49668d 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -139,10 +139,6 @@ const ( // Enables the plugins frontend sandbox FlagPluginsFrontendSandbox = "pluginsFrontendSandbox" - // FlagSqlDatasourceDatabaseSelection - // Enables previous SQL data source dataset dropdown behavior - FlagSqlDatasourceDatabaseSelection = "sqlDatasourceDatabaseSelection" - // FlagRecordedQueriesMulti // Enables writing multiple items from a single query within Recorded Queries FlagRecordedQueriesMulti = "recordedQueriesMulti" @@ -739,10 +735,6 @@ const ( // Enables improved support for SAML external sessions. Ensure the NameID format is correctly configured in Grafana for SAML Single Logout to function properly. FlagImprovedExternalSessionHandlingSAML = "improvedExternalSessionHandlingSAML" - // FlagTeamHttpHeadersMimir - // Enables LBAC for datasources for Mimir to apply LBAC filtering of metrics to the client requests for users in teams - FlagTeamHttpHeadersMimir = "teamHttpHeadersMimir" - // FlagTeamHttpHeadersTempo // Enables LBAC for datasources for Tempo to apply LBAC filtering of traces to the client requests for users in teams FlagTeamHttpHeadersTempo = "teamHttpHeadersTempo" @@ -871,6 +863,10 @@ const ( // Enables creating metrics from profiles and storing them as recording rules FlagMetricsFromProfiles = "metricsFromProfiles" + // FlagGrafanaAssistantInProfilesDrilldown + // Enables integration with Grafana Assistant in Profiles Drilldown + FlagGrafanaAssistantInProfilesDrilldown = "grafanaAssistantInProfilesDrilldown" + // FlagPostgresDSUsePGX // Enables using PGX instead of libpq for PostgreSQL datasource FlagPostgresDSUsePGX = "postgresDSUsePGX" @@ -903,6 +899,10 @@ const ( // Registers AuthZ /apis endpoint FlagKubernetesAuthzApis = "kubernetesAuthzApis" + // FlagKubernetesAuthzResourcePermissionApis + // Registers AuthZ resource permission /apis endpoints + FlagKubernetesAuthzResourcePermissionApis = "kubernetesAuthzResourcePermissionApis" + // FlagKubernetesAuthnMutation // Enables create, delete, and update mutations for resources owned by IAM identity FlagKubernetesAuthnMutation = "kubernetesAuthnMutation" @@ -923,6 +923,10 @@ const ( // Enables the API to import Alertmanager configuration FlagAlertingImportAlertmanagerAPI = "alertingImportAlertmanagerAPI" + // FlagAlertingImportAlertmanagerUI + // Enables the UI to see imported Alertmanager configuration + FlagAlertingImportAlertmanagerUI = "alertingImportAlertmanagerUI" + // FlagSharingDashboardImage // Enables image sharing functionality for dashboards FlagSharingDashboardImage = "sharingDashboardImage" @@ -983,6 +987,10 @@ const ( // Enable adhoc filter buttons in visualization tooltips FlagAdhocFiltersInTooltips = "adhocFiltersInTooltips" + // FlagFavoriteDatasources + // Enable favorite datasources + FlagFavoriteDatasources = "favoriteDatasources" + // FlagNewLogContext // New Log Context component FlagNewLogContext = "newLogContext" @@ -990,4 +998,12 @@ const ( // FlagNewClickhouseConfigPageDesign // Enables new design for the Clickhouse data source configuration page FlagNewClickhouseConfigPageDesign = "newClickhouseConfigPageDesign" + + // FlagUnifiedStorageSearchAfterWriteExperimentalAPI + // Enable experimental search-after-write guarantees to unified-storage search endpoints + FlagUnifiedStorageSearchAfterWriteExperimentalAPI = "unifiedStorageSearchAfterWriteExperimentalAPI" + + // FlagTeamFolders + // Enables team folders functionality + FlagTeamFolders = "teamFolders" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 68fc984f930..d117e50380a 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -242,6 +242,21 @@ "expression": "false" } }, + { + "metadata": { + "name": "alertingImportAlertmanagerUI", + "resourceVersion": "1754585847887", + "creationTimestamp": "2025-08-07T16:57:27Z" + }, + "spec": { + "description": "Enables the UI to see imported Alertmanager configuration", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "hideFromAdminPage": true, + "hideFromDocs": true, + "expression": "false" + } + }, { "metadata": { "name": "alertingImportYAMLUI", @@ -444,14 +459,17 @@ { "metadata": { "name": "alertingSaveStateCompressed", - "resourceVersion": "1753448760331", - "creationTimestamp": "2025-01-27T17:47:33Z" + "resourceVersion": "1754657532777", + "creationTimestamp": "2025-01-27T17:47:33Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-08-08 12:52:12.777935 +0000 UTC" + } }, "spec": { "description": "Enables the compressed protobuf-based alert state storage", "stage": "preview", "codeowner": "@grafana/alerting-squad", - "expression": "false" + "expression": "true" } }, { @@ -1316,6 +1334,19 @@ "frontend": true } }, + { + "metadata": { + "name": "favoriteDatasources", + "resourceVersion": "1754648387873", + "creationTimestamp": "2025-08-08T10:19:47Z" + }, + "spec": { + "description": "Enable favorite datasources", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend", + "frontend": true + } + }, { "metadata": { "name": "featureHighlights", @@ -1441,6 +1472,37 @@ "codeowner": "@grafana/plugins-platform-backend" } }, + { + "metadata": { + "name": "grafanaAssistantInProfilesDrilldown", + "resourceVersion": "1754572610001", + "creationTimestamp": "2025-08-01T07:43:17Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-08-07 13:16:50.001205 +0000 UTC" + } + }, + "spec": { + "description": "Enables integration with Grafana Assistant in Profiles Drilldown", + "stage": "GA", + "codeowner": "@grafana/observability-traces-and-profiling", + "frontend": true, + "expression": "true" + } + }, + { + "metadata": { + "name": "grafanaAssistantInProfilesDrillfown", + "resourceVersion": "1754034112469", + "creationTimestamp": "2025-08-01T07:41:52Z", + "deletionTimestamp": "2025-08-01T07:43:17Z" + }, + "spec": { + "description": "Enables interation with Grafana Assitant in Profiles Drilldown", + "stage": "experimental", + "codeowner": "@grafana/observability-traces-and-profiling", + "frontend": true + } + }, { "metadata": { "name": "grafanaManagedRecordingRules", @@ -1742,6 +1804,20 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "kubernetesAuthzResourcePermissionApis", + "resourceVersion": "1754668670559", + "creationTimestamp": "2025-08-08T15:57:50Z" + }, + "spec": { + "description": "Registers AuthZ resource permission /apis endpoints", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team", + "hideFromAdminPage": true, + "hideFromDocs": true + } + }, { "metadata": { "name": "kubernetesDashboards", @@ -2897,7 +2973,8 @@ "metadata": { "name": "sqlDatasourceDatabaseSelection", "resourceVersion": "1753448760331", - "creationTimestamp": "2023-06-06T16:28:52Z" + "creationTimestamp": "2023-06-06T16:28:52Z", + "deletionTimestamp": "2025-08-08T12:18:29Z" }, "spec": { "description": "Enables previous SQL data source dataset dropdown behavior", @@ -3010,11 +3087,25 @@ "expression": "false" } }, + { + "metadata": { + "name": "teamFolders", + "resourceVersion": "1755099058649", + "creationTimestamp": "2025-08-13T15:30:58Z" + }, + "spec": { + "description": "Enables team folders functionality", + "stage": "experimental", + "codeowner": "@grafana/grafana-search-navigate-organise", + "expression": "false" + } + }, { "metadata": { "name": "teamHttpHeadersMimir", "resourceVersion": "1753448760331", - "creationTimestamp": "2025-01-13T10:42:47Z" + "creationTimestamp": "2025-01-13T10:42:47Z", + "deletionTimestamp": "2025-08-06T08:19:47Z" }, "spec": { "description": "Enables LBAC for datasources for Mimir to apply LBAC filtering of metrics to the client requests for users in teams", @@ -3211,6 +3302,25 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "unifiedStorageSearchAfterWriteExperimentalAPI", + "resourceVersion": "1755089543487", + "creationTimestamp": "2025-08-13T12:35:14Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-08-13 12:52:23.487521 +0000 UTC" + } + }, + "spec": { + "description": "Enable experimental search-after-write guarantees to unified-storage search endpoints", + "stage": "experimental", + "codeowner": "@grafana/search-and-storage", + "requiresRestart": true, + "hideFromAdminPage": true, + "hideFromDocs": true, + "expression": "false" + } + }, { "metadata": { "name": "unifiedStorageSearchDualReaderEnabled", diff --git a/pkg/services/folder/folderimpl/folder.go b/pkg/services/folder/folderimpl/folder.go index 62fc24bbd86..3cf952ebd64 100644 --- a/pkg/services/folder/folderimpl/folder.go +++ b/pkg/services/folder/folderimpl/folder.go @@ -996,7 +996,7 @@ func (s *Service) legacyDelete(ctx context.Context, cmd *folder.DeleteFolderComm // Delete all dashboards in the folders for _, folderUID := range folderUIDs { // nolint:staticcheck - deleteCmd := dashboards.DeleteDashboardCommand{OrgID: cmd.OrgID, UID: folderUID, ForceDeleteFolderRules: cmd.ForceDeleteRules} + deleteCmd := dashboards.DeleteDashboardCommand{OrgID: cmd.OrgID, UID: folderUID, ForceDeleteFolderRules: cmd.ForceDeleteRules, RemovePermissions: cmd.RemovePermissions} if err := s.dashboardStore.DeleteDashboard(ctx, &deleteCmd); err != nil { return toFolderError(err) } diff --git a/pkg/services/folder/model.go b/pkg/services/folder/model.go index b19cf1ddae0..3d09d372896 100644 --- a/pkg/services/folder/model.go +++ b/pkg/services/folder/model.go @@ -170,7 +170,8 @@ type DeleteFolderCommand struct { OrgID int64 `json:"orgId" xorm:"org_id"` ForceDeleteRules bool `json:"forceDeleteRules"` - SignedInUser identity.Requester `json:"-"` + SignedInUser identity.Requester `json:"-"` + RemovePermissions bool `json:"-"` } // GetFolderQuery is used for all folder Get requests. Only one of UID, ID, or diff --git a/pkg/services/frontend/index.html b/pkg/services/frontend/index.html index f582df4604e..97dacad25ae 100644 --- a/pkg/services/frontend/index.html +++ b/pkg/services/frontend/index.html @@ -262,18 +262,14 @@ const theme = window.grafanaBootData.user.theme; if (theme === "system") { const darkQuery = window.matchMedia("(prefers-color-scheme: dark)"); - - if (darkQuery.matches) { - document.body.classList.add("theme-dark"); - window.grafanaBootData.user.lightTheme = false; - } else { - document.body.classList.add("theme-light"); - window.grafanaBootData.user.lightTheme = true; - } + window.grafanaBootData.user.lightTheme = !darkQuery.matches; } - const isLightTheme = window.grafanaBootData.user.lightTheme; - cssLink.href = window.grafanaBootData.assets[isLightTheme ? 'light' : 'dark']; + const isLightTheme = window.grafanaBootData.user.lightTheme; + + document.body.classList.add(isLightTheme ? "theme-light" : "theme-dark"); + + cssLink.href = window.grafanaBootData.assets[isLightTheme ? 'light' : 'dark']; document.head.appendChild(cssLink); } diff --git a/pkg/services/libraryelements/libraryelements_permissions_test.go b/pkg/services/libraryelements/libraryelements_permissions_test.go index 4f040b13fce..48a533e92a3 100644 --- a/pkg/services/libraryelements/libraryelements_permissions_test.go +++ b/pkg/services/libraryelements/libraryelements_permissions_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana-openapi-client-go/models" + "github.com/grafana/grafana/pkg/configprovider" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/libraryelements/model" @@ -32,7 +33,9 @@ func TestIntegrationLibraryElementPermissions(t *testing.T) { dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{}) grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) - quotaService := quotaimpl.ProvideService(env.SQLStore, env.Cfg) + cfgProvider, err := configprovider.ProvideService(env.Cfg) + require.NoError(t, err) + quotaService := quotaimpl.ProvideService(context.Background(), env.SQLStore, cfgProvider) orgService, err := orgimpl.ProvideService(env.SQLStore, env.Cfg, quotaService) require.NoError(t, err) @@ -138,7 +141,9 @@ func TestIntegrationLibraryElementGranularPermissions(t *testing.T) { } dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{}) grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) - quotaService := quotaimpl.ProvideService(env.SQLStore, env.Cfg) + cfgProvider, err := configprovider.ProvideService(env.Cfg) + require.NoError(t, err) + quotaService := quotaimpl.ProvideService(context.Background(), env.SQLStore, cfgProvider) orgService, err := orgimpl.ProvideService(env.SQLStore, env.Cfg, quotaService) require.NoError(t, err) @@ -357,7 +362,9 @@ func createUserInOrg(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUs cfg.AutoAssignOrg = true cfg.AutoAssignOrgId = 1 - quotaService := quotaimpl.ProvideService(db, cfg) + cfgProvider, err := configprovider.ProvideService(cfg) + require.NoError(t, err) + quotaService := quotaimpl.ProvideService(context.Background(), db, cfgProvider) orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( diff --git a/pkg/services/mtdsclient/mt_datasource_client_builder.go b/pkg/services/mtdsclient/mt_datasource_client_builder.go index 3c964aeefd1..b530a277142 100644 --- a/pkg/services/mtdsclient/mt_datasource_client_builder.go +++ b/pkg/services/mtdsclient/mt_datasource_client_builder.go @@ -9,13 +9,13 @@ import ( ) type MTDatasourceClientBuilder interface { - BuildClient(pluginId string, uid string) (clientapi.QueryDataClient, bool) + BuildClient(pluginId string, uid string) (clientapi.QueryDataClient, bool, error) } type nullBuilder struct{} -func (m *nullBuilder) BuildClient(pluginId string, uid string) (clientapi.QueryDataClient, bool) { - return nil, false +func (m *nullBuilder) BuildClient(pluginId string, uid string) (clientapi.QueryDataClient, bool, error) { + return nil, false, nil } // we use this noop for st flows @@ -26,37 +26,32 @@ func NewNullMTDatasourceClientBuilder() MTDatasourceClientBuilder { type MtDatasourceClientBuilderWithInstance struct { instance clientapi.Instance ctx context.Context - headers map[string]string logger log.Logger } -func (b *MtDatasourceClientBuilderWithInstance) BuildClient(pluginId string, uid string) (clientapi.QueryDataClient, bool) { +func (b *MtDatasourceClientBuilderWithInstance) BuildClient(pluginId string, uid string) (clientapi.QueryDataClient, bool, error) { dsClient, err := b.instance.GetDataSourceClient( b.ctx, v0alpha1.DataSourceRef{ Type: pluginId, UID: uid, }, - b.headers, ) if err != nil { - b.logger.Debug("failed to get mt ds client", "error", err) - return nil, false + return nil, true, err } - return dsClient, true + return dsClient, true, nil } // TODO: I think we might be able to refactor this to just use the instance func NewMtDatasourceClientBuilderWithInstance( instance clientapi.Instance, ctx context.Context, - headers map[string]string, logger log.Logger, ) MTDatasourceClientBuilder { return &MtDatasourceClientBuilderWithInstance{ instance: instance, ctx: ctx, - headers: headers, logger: logger, } } @@ -73,10 +68,10 @@ type testBuilder struct { isMultitenant bool } -func (b *testBuilder) BuildClient(pluginId string, uid string) (clientapi.QueryDataClient, bool) { +func (b *testBuilder) BuildClient(pluginId string, uid string) (clientapi.QueryDataClient, bool, error) { if !b.isMultitenant { - return nil, false + return nil, false, nil } - return b.mockClient, true + return b.mockClient, true, nil } diff --git a/pkg/services/navtree/navtreeimpl/applinks.go b/pkg/services/navtree/navtreeimpl/applinks.go index b4571c88a69..8226304388a 100644 --- a/pkg/services/navtree/navtreeimpl/applinks.go +++ b/pkg/services/navtree/navtreeimpl/applinks.go @@ -104,6 +104,10 @@ func (s *ServiceImpl) processAppPlugin(plugin pluginstore.Plugin, c *contextmode link.Id = "standalone-plugin-page-" + include.Path link.SortWeight = pathConfig.SortWeight + if len(pathConfig.SubTitle) > 0 { + link.SubTitle = pathConfig.SubTitle + } + // Check if the section already has a page with the same URL, and in that case override it // (This only happens if it is explicitly set by `navigation.app_standalone_pages` in the INI config) isOverridingCorePage := false @@ -348,7 +352,11 @@ func (s *ServiceImpl) readNavigationSettings() { } s.navigationAppPathConfig = map[string]NavigationAppConfig{ - "/a/grafana-auth-app": {SectionID: navtree.NavIDCfgAccess, SortWeight: 2}, + "/a/grafana-auth-app": { + SectionID: navtree.NavIDCfgAccess, + SortWeight: 2, + SubTitle: "Use policies to control automated access to metrics, logs, traces, and other Grafana Cloud services", + }, } appSections := s.cfg.Raw.Section("navigation.app_sections") diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index a6e3ba62cd5..eefbb6dea30 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -95,6 +95,16 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { } ruleAuthzService := accesscontrol.NewRuleService(api.AccessControl) + convertSrv := NewConvertPrometheusSrv( + &api.Cfg.UnifiedAlerting, + logger, + api.RuleStore, + api.DatasourceCache, + api.AlertRules, + api.FeatureManager, + api.MultiOrgAlertmanager, + ) + // Register endpoints for proxying to Alertmanager-compatible backends. api.RegisterAlertmanagerApiEndpoints(NewForkingAM( api.DatasourceCache, @@ -115,6 +125,8 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { ), receiverAuthz: accesscontrol.NewReceiverAccess[ReceiverStatus](api.AccessControl, false), }, + convertSrv, + api.FeatureManager, ), m) // Register endpoints for proxying to Prometheus-compatible backends. api.RegisterPrometheusApiEndpoints(NewForkingProm( @@ -181,15 +193,5 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { hist: api.Historian, }), m) - api.RegisterConvertPrometheusApiEndpoints(NewConvertPrometheusApi( - NewConvertPrometheusSrv( - &api.Cfg.UnifiedAlerting, - logger, - api.RuleStore, - api.DatasourceCache, - api.AlertRules, - api.FeatureManager, - api.MultiOrgAlertmanager, - ), - ), m) + api.RegisterConvertPrometheusApiEndpoints(NewConvertPrometheusApi(convertSrv), m) } diff --git a/pkg/services/ngalert/api/api_alertmanager.go b/pkg/services/ngalert/api/api_alertmanager.go index cdba800aab8..90dcb26fc74 100644 --- a/pkg/services/ngalert/api/api_alertmanager.go +++ b/pkg/services/ngalert/api/api_alertmanager.go @@ -83,7 +83,7 @@ func (srv AlertmanagerSrv) RouteDeleteAlertingConfig(c *contextmodel.ReqContext) func (srv AlertmanagerSrv) RouteGetAlertingConfig(c *contextmodel.ReqContext) response.Response { canSeeAutogen := c.HasRole(org.RoleAdmin) - config, err := srv.mam.GetAlertmanagerConfiguration(c.Req.Context(), c.GetOrgID(), canSeeAutogen) + config, err := srv.mam.GetAlertmanagerConfiguration(c.Req.Context(), c.GetOrgID(), canSeeAutogen, false) if err != nil { if errors.Is(err, store.ErrNoAlertmanagerConfiguration) { return ErrResp(http.StatusNotFound, err, "") diff --git a/pkg/services/ngalert/api/api_convert_prometheus.go b/pkg/services/ngalert/api/api_convert_prometheus.go index 1d4fd9ac814..d6fb86d31e2 100644 --- a/pkg/services/ngalert/api/api_convert_prometheus.go +++ b/pkg/services/ngalert/api/api_convert_prometheus.go @@ -135,7 +135,7 @@ type ConvertPrometheusSrv struct { type Alertmanager interface { DeleteExtraConfiguration(ctx context.Context, org int64, identifier string) error SaveAndApplyExtraConfiguration(ctx context.Context, org int64, extraConfig apimodels.ExtraConfiguration) error - GetAlertmanagerConfiguration(ctx context.Context, org int64, withAutogen bool) (apimodels.GettableUserConfig, error) + GetAlertmanagerConfiguration(ctx context.Context, org int64, withAutogen bool, withMergedExtraConfig bool) (apimodels.GettableUserConfig, error) } func NewConvertPrometheusSrv( @@ -594,7 +594,7 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetAlertmanagerConfig(c * identifier := parseConfigIdentifierHeader(c) - cfg, err := srv.am.GetAlertmanagerConfiguration(ctx, c.GetOrgID(), false) + cfg, err := srv.am.GetAlertmanagerConfiguration(ctx, c.GetOrgID(), false, false) if err != nil { logger.Error("failed to get alertmanager configuration", "err", err) return errorToResponse(err) diff --git a/pkg/services/ngalert/api/api_convert_prometheus_test.go b/pkg/services/ngalert/api/api_convert_prometheus_test.go index 44753418cce..c5f72c93439 100644 --- a/pkg/services/ngalert/api/api_convert_prometheus_test.go +++ b/pkg/services/ngalert/api/api_convert_prometheus_test.go @@ -292,7 +292,7 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) { } }) - t.Run("with extra labels header should apply labels to all rules", func(t *testing.T) { + t.Run("with extra labels header should apply labels only to alert rules", func(t *testing.T) { srv, _, ruleStore := createConvertPrometheusSrv(t) rc := createRequestCtx() rc.Req.Header.Set(extraLabelsHeader, "environment=production,team=alerting") @@ -306,17 +306,28 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) { require.NoError(t, err) require.Len(t, rules, 2) - for _, rule := range rules { - require.Equal(t, "production", rule.Labels["environment"]) - require.Equal(t, "alerting", rule.Labels["team"]) + var alertRule, recordingRule *models.AlertRule + for i := range rules { + switch rules[i].Title { + case "TestAlert": + alertRule = rules[i] + case "recorded-metric": + recordingRule = rules[i] + } } - // Original labels must be preserved - alertRule := rules[0] - if alertRule.Title == "recorded-metric" { - alertRule = rules[1] - } + require.NotNil(t, alertRule, "Alert rule should be present") + require.NotNil(t, recordingRule, "Recording rule should be present") + + // Alert rule should have extra labels + require.Equal(t, "production", alertRule.Labels["environment"]) + require.Equal(t, "alerting", alertRule.Labels["team"]) require.Equal(t, "critical", alertRule.Labels["severity"]) + + // Recording rule should not have extra labels + require.NotContains(t, recordingRule.Labels, "environment") + require.NotContains(t, recordingRule.Labels, "team") + require.Equal(t, "warning", recordingRule.Labels["severity"]) }) t.Run("with extra labels that conflict with rule labels", func(t *testing.T) { @@ -335,10 +346,28 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) { require.NoError(t, err) require.Len(t, rules, 2) - for _, rule := range rules { - require.Equal(t, "production", rule.Labels["environment"]) - require.NotEqual(t, "low", rule.Labels["severity"]) + var alertRule, recordingRule *models.AlertRule + for i := range rules { + switch rules[i].Title { + case "TestAlert": + alertRule = rules[i] + case "recorded-metric": + recordingRule = rules[i] + } } + + require.NotNil(t, alertRule, "Alert rule should be present") + require.NotNil(t, recordingRule, "Recording rule should be present") + + // Alert rule should have extra labels where they don't conflict + require.Equal(t, "production", alertRule.Labels["environment"]) + // Rule label should take precedence over extra label + require.Equal(t, "critical", alertRule.Labels["severity"]) + require.NotEqual(t, "low", alertRule.Labels["severity"]) + + // Recording rule should not have extra labels + require.NotContains(t, recordingRule.Labels, "environment") + require.Equal(t, "warning", recordingRule.Labels["severity"]) }) t.Run("with invalid extra labels header should return 400", func(t *testing.T) { @@ -1753,8 +1782,8 @@ func (m *mockAlertmanager) SaveAndApplyExtraConfiguration(ctx context.Context, o return args.Error(0) } -func (m *mockAlertmanager) GetAlertmanagerConfiguration(ctx context.Context, org int64, withAutogen bool) (apimodels.GettableUserConfig, error) { - args := m.Called(ctx, org, withAutogen) +func (m *mockAlertmanager) GetAlertmanagerConfiguration(ctx context.Context, org int64, withAutogen bool, withMergedExtraConfig bool) (apimodels.GettableUserConfig, error) { + args := m.Called(ctx, org, withAutogen, withMergedExtraConfig) return args.Get(0).(apimodels.GettableUserConfig), args.Error(1) } @@ -1883,7 +1912,7 @@ func TestRouteConvertPrometheusGetAlertmanagerConfig(t *testing.T) { t.Run("without config identifier header should use default identifier", func(t *testing.T) { mockAM := &mockAlertmanager{} - mockAM.On("GetAlertmanagerConfiguration", mock.Anything, orgID, false).Return(apimodels.GettableUserConfig{ + mockAM.On("GetAlertmanagerConfiguration", mock.Anything, orgID, false, false).Return(apimodels.GettableUserConfig{ ExtraConfigs: []apimodels.ExtraConfiguration{ { Identifier: defaultConfigIdentifier, @@ -1906,7 +1935,7 @@ receivers: t.Run("with empty config identifier header should use default identifier", func(t *testing.T) { mockAM := &mockAlertmanager{} - mockAM.On("GetAlertmanagerConfiguration", mock.Anything, orgID, false).Return(apimodels.GettableUserConfig{ + mockAM.On("GetAlertmanagerConfiguration", mock.Anything, orgID, false, false).Return(apimodels.GettableUserConfig{ ExtraConfigs: []apimodels.ExtraConfiguration{ { Identifier: defaultConfigIdentifier, @@ -1954,7 +1983,7 @@ receivers: }, } - mockAM.On("GetAlertmanagerConfiguration", mock.Anything, int64(1), false).Return(expectedConfig, nil).Once() + mockAM.On("GetAlertmanagerConfiguration", mock.Anything, int64(1), false, false).Return(expectedConfig, nil).Once() rc := createRequestCtx() rc.Req.Header.Set(configIdentifierHeader, identifier) @@ -2008,7 +2037,7 @@ receivers: }, } - mockAM.On("GetAlertmanagerConfiguration", mock.Anything, orgID, false).Return(expectedConfig, nil).Once() + mockAM.On("GetAlertmanagerConfiguration", mock.Anything, orgID, false, false).Return(expectedConfig, nil).Once() rc := createRequestCtx() rc.Req.Header.Set(configIdentifierHeader, identifier) @@ -2023,7 +2052,7 @@ receivers: ft := featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerAPI) srv, _, _ := createConvertPrometheusSrv(t, withAlertmanager(mockAM), withFeatureToggles(ft)) - mockAM.On("GetAlertmanagerConfiguration", mock.Anything, orgID, false).Return(apimodels.GettableUserConfig{}, errors.New("config error")).Once() + mockAM.On("GetAlertmanagerConfiguration", mock.Anything, orgID, false, false).Return(apimodels.GettableUserConfig{}, errors.New("config error")).Once() rc := createRequestCtx() rc.Req.Header.Set(configIdentifierHeader, identifier) diff --git a/pkg/services/ngalert/api/api_provisioning_test.go b/pkg/services/ngalert/api/api_provisioning_test.go index e21f949e4f7..926f3ebcc5b 100644 --- a/pkg/services/ngalert/api/api_provisioning_test.go +++ b/pkg/services/ngalert/api/api_provisioning_test.go @@ -403,7 +403,7 @@ func TestIntegrationProvisioningApi(t *testing.T) { }) t.Run("PUT without MissingSeriesEvalsToResolve clears the field", func(t *testing.T) { - oldValue := util.Pointer(5) + oldValue := util.Pointer[int64](5) sut := createProvisioningSrvSut(t) rc := createTestRequestCtx() rule := createTestAlertRule("rule", 1) @@ -420,8 +420,8 @@ func TestIntegrationProvisioningApi(t *testing.T) { }) t.Run("PUT with MissingSeriesEvalsToResolve updates the value", func(t *testing.T) { - oldValue := util.Pointer(5) - newValue := util.Pointer(10) + oldValue := util.Pointer[int64](5) + newValue := util.Pointer[int64](10) sut := createProvisioningSrvSut(t) rc := createTestRequestCtx() rule := createTestAlertRule("rule", 1) @@ -657,12 +657,12 @@ func TestIntegrationProvisioningApi(t *testing.T) { require.Nil(t, updated.Rules[0].MissingSeriesEvalsToResolve) // Put the same group with a new value - group.Rules[0].MissingSeriesEvalsToResolve = util.Pointer(5) + group.Rules[0].MissingSeriesEvalsToResolve = util.Pointer[int64](5) response = sut.RoutePutAlertRuleGroup(&rc, group, "folder-uid", group.Title) require.Equal(t, 200, response.Status()) updated = deserializeRuleGroup(t, response.Body()) require.NotNil(t, updated.Rules[0].MissingSeriesEvalsToResolve) - require.Equal(t, 5, *updated.Rules[0].MissingSeriesEvalsToResolve) + require.Equal(t, int64(5), *updated.Rules[0].MissingSeriesEvalsToResolve) // Reset the value again group.Rules[0].MissingSeriesEvalsToResolve = nil diff --git a/pkg/services/ngalert/api/forking_alertmanager.go b/pkg/services/ngalert/api/forking_alertmanager.go index df5059b296c..96e295fc68f 100644 --- a/pkg/services/ngalert/api/forking_alertmanager.go +++ b/pkg/services/ngalert/api/forking_alertmanager.go @@ -1,36 +1,81 @@ package api import ( + "errors" + "net/http" + + amv2 "github.com/prometheus/alertmanager/api/v2/models" + "github.com/grafana/grafana/pkg/api/response" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/featuremgmt" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/store" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/web" ) +const withExtraConfigUID = "~grafana-with-extra-config" + +type ConvertService interface { + RouteConvertPrometheusGetAlertmanagerConfig(ctx *contextmodel.ReqContext) response.Response +} + type AlertmanagerApiHandler struct { AMSvc *LotexAM GrafanaSvc *AlertmanagerSrv + ConvertSvc ConvertService DatasourceCache datasources.CacheService + FeatureManager featuremgmt.FeatureToggles } // NewForkingAM implements a set of routes that proxy to various Alertmanager-compatible backends. -func NewForkingAM(datasourceCache datasources.CacheService, proxy *LotexAM, grafana *AlertmanagerSrv) *AlertmanagerApiHandler { +func NewForkingAM(datasourceCache datasources.CacheService, proxy *LotexAM, grafana *AlertmanagerSrv, convertSvc ConvertService, featureManager featuremgmt.FeatureToggles) *AlertmanagerApiHandler { return &AlertmanagerApiHandler{ AMSvc: proxy, GrafanaSvc: grafana, + ConvertSvc: convertSvc, DatasourceCache: datasourceCache, + FeatureManager: featureManager, } } func (f *AlertmanagerApiHandler) getService(ctx *contextmodel.ReqContext) (*LotexAM, error) { - _, err := getDatasourceByUID(ctx, f.DatasourceCache, apimodels.AlertmanagerBackend) - if err != nil { - return nil, err + // If this is not an extra config request, we should check that the datasource exists and is of the correct type. + if isExtra := f.isExtraConfig(ctx); !isExtra { + _, err := getDatasourceByUID(ctx, f.DatasourceCache, apimodels.AlertmanagerBackend) + if err != nil { + return nil, err + } } + return f.AMSvc, nil } +// isExtraConfig checks if the datasourceUID represents an extra config. +// Extra configs are the alertmanager configurations that were saved using the Prometheus conversion API. +func (f *AlertmanagerApiHandler) isExtraConfig(ctx *contextmodel.ReqContext) bool { + // Only enabled if feature flag is on + if !f.FeatureManager.IsEnabledGlobally(featuremgmt.FlagAlertingImportAlertmanagerUI) { + return false + } + + datasourceUID := web.Params(ctx.Req)[":DatasourceUID"] + return datasourceUID == withExtraConfigUID +} + func (f *AlertmanagerApiHandler) handleRouteGetAMStatus(ctx *contextmodel.ReqContext, dsUID string) response.Response { + if f.isExtraConfig(ctx) { + status := apimodels.GettableStatus{ + Cluster: &amv2.ClusterStatus{ + Status: util.Pointer("ready"), + }, + } + return response.JSON(http.StatusOK, status) + } + s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -40,6 +85,10 @@ func (f *AlertmanagerApiHandler) handleRouteGetAMStatus(ctx *contextmodel.ReqCon } func (f *AlertmanagerApiHandler) handleRouteCreateSilence(ctx *contextmodel.ReqContext, body apimodels.PostableSilence, dsUID string) response.Response { + if f.isExtraConfig(ctx) { + return response.Error(http.StatusForbidden, "Read-only configuration", nil) + } + s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -49,6 +98,10 @@ func (f *AlertmanagerApiHandler) handleRouteCreateSilence(ctx *contextmodel.ReqC } func (f *AlertmanagerApiHandler) handleRouteDeleteAlertingConfig(ctx *contextmodel.ReqContext, dsUID string) response.Response { + if f.isExtraConfig(ctx) { + return response.Error(http.StatusForbidden, "Read-only configuration", nil) + } + s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -58,6 +111,10 @@ func (f *AlertmanagerApiHandler) handleRouteDeleteAlertingConfig(ctx *contextmod } func (f *AlertmanagerApiHandler) handleRouteDeleteSilence(ctx *contextmodel.ReqContext, silenceID string, dsUID string) response.Response { + if f.isExtraConfig(ctx) { + return response.Error(http.StatusForbidden, "Read-only configuration", nil) + } + s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -67,6 +124,19 @@ func (f *AlertmanagerApiHandler) handleRouteDeleteSilence(ctx *contextmodel.ReqC } func (f *AlertmanagerApiHandler) handleRouteGetAlertingConfig(ctx *contextmodel.ReqContext, dsUID string) response.Response { + if f.isExtraConfig(ctx) { + canSeeAutogen := ctx.HasRole(org.RoleAdmin) + config, err := f.GrafanaSvc.mam.GetAlertmanagerConfiguration(ctx.Req.Context(), ctx.GetOrgID(), canSeeAutogen, true) + if err != nil { + if errors.Is(err, store.ErrNoAlertmanagerConfiguration) { + return response.Error(http.StatusNotFound, "No configuration found", err) + } + return response.Error(http.StatusInternalServerError, "Failed to get merged configuration", err) + } + + return response.JSON(http.StatusOK, config) + } + s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -76,6 +146,10 @@ func (f *AlertmanagerApiHandler) handleRouteGetAlertingConfig(ctx *contextmodel. } func (f *AlertmanagerApiHandler) handleRouteGetAMAlertGroups(ctx *contextmodel.ReqContext, dsUID string) response.Response { + if f.isExtraConfig(ctx) { + return f.GrafanaSvc.RouteGetAMAlertGroups(ctx) + } + s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -85,6 +159,10 @@ func (f *AlertmanagerApiHandler) handleRouteGetAMAlertGroups(ctx *contextmodel.R } func (f *AlertmanagerApiHandler) handleRouteGetAMAlerts(ctx *contextmodel.ReqContext, dsUID string) response.Response { + if f.isExtraConfig(ctx) { + return f.GrafanaSvc.RouteGetAMAlerts(ctx) + } + s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -94,6 +172,10 @@ func (f *AlertmanagerApiHandler) handleRouteGetAMAlerts(ctx *contextmodel.ReqCon } func (f *AlertmanagerApiHandler) handleRouteGetSilence(ctx *contextmodel.ReqContext, silenceID string, dsUID string) response.Response { + if f.isExtraConfig(ctx) { + return f.GrafanaSvc.RouteGetSilence(ctx, silenceID) + } + s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -103,6 +185,10 @@ func (f *AlertmanagerApiHandler) handleRouteGetSilence(ctx *contextmodel.ReqCont } func (f *AlertmanagerApiHandler) handleRouteGetSilences(ctx *contextmodel.ReqContext, dsUID string) response.Response { + if f.isExtraConfig(ctx) { + return f.GrafanaSvc.RouteGetSilences(ctx) + } + s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -112,6 +198,10 @@ func (f *AlertmanagerApiHandler) handleRouteGetSilences(ctx *contextmodel.ReqCon } func (f *AlertmanagerApiHandler) handleRoutePostAlertingConfig(ctx *contextmodel.ReqContext, body apimodels.PostableUserConfig, dsUID string) response.Response { + if f.isExtraConfig(ctx) { + return response.Error(http.StatusForbidden, "Read-only configuration", nil) + } + s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -123,6 +213,10 @@ func (f *AlertmanagerApiHandler) handleRoutePostAlertingConfig(ctx *contextmodel } func (f *AlertmanagerApiHandler) handleRoutePostAMAlerts(ctx *contextmodel.ReqContext, body apimodels.PostableAlerts, dsUID string) response.Response { + if f.isExtraConfig(ctx) { + return response.Error(http.StatusForbidden, "Read-only configuration", nil) + } + s, err := f.getService(ctx) if err != nil { return errorToResponse(err) diff --git a/pkg/services/ngalert/api/forking_alertmanager_test.go b/pkg/services/ngalert/api/forking_alertmanager_test.go new file mode 100644 index 00000000000..96e4c8a654a --- /dev/null +++ b/pkg/services/ngalert/api/forking_alertmanager_test.go @@ -0,0 +1,138 @@ +package api + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/featuremgmt" + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/web" +) + +func TestAlertmanagerApiHandler_isExtraConfig(t *testing.T) { + tests := []struct { + name string + datasourceUID string + flagEnabled bool + expectedIsExtra bool + }{ + { + name: "normal datasource when feature enabled", + datasourceUID: "normal-datasource", + flagEnabled: true, + expectedIsExtra: false, + }, + { + name: "extra config when feature enabled", + datasourceUID: "~grafana-with-extra-config", + flagEnabled: true, + expectedIsExtra: true, + }, + { + name: "extra config when feature disabled", + datasourceUID: "~grafana-with-extra-config", + flagEnabled: false, + expectedIsExtra: false, + }, + { + name: "empty datasource UID", + datasourceUID: "", + flagEnabled: true, + expectedIsExtra: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &http.Request{} + ctx := &contextmodel.ReqContext{ + Context: &web.Context{ + Req: req, + }, + SignedInUser: &user.SignedInUser{ + OrgID: 1, + }, + } + + ctx.Req = web.SetURLParams(req, map[string]string{ + ":DatasourceUID": tt.datasourceUID, + }) + + var features featuremgmt.FeatureToggles + if tt.flagEnabled { + features = featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerUI) + } else { + features = featuremgmt.WithFeatures() + } + + handler := &AlertmanagerApiHandler{ + FeatureManager: features, + } + + isExtra := handler.isExtraConfig(ctx) + assert.Equal(t, tt.expectedIsExtra, isExtra) + }) + } +} + +func TestAlertmanagerApiHandler_ExtraConfigRouting(t *testing.T) { + t.Run("GET status returns ready for extra config", func(t *testing.T) { + req := &http.Request{} + ctx := &contextmodel.ReqContext{ + Context: &web.Context{ + Req: req, + }, + SignedInUser: &user.SignedInUser{ + OrgID: 1, + }, + } + + ctx.Req = web.SetURLParams(req, map[string]string{ + ":DatasourceUID": "~grafana-with-extra-config", + }) + + handler := &AlertmanagerApiHandler{ + FeatureManager: featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerUI), + } + + resp := handler.handleRouteGetAMStatus(ctx, "~grafana-with-extra-config") + require.Equal(t, http.StatusOK, resp.Status()) + }) + + t.Run("POST operations return 403 for extra config", func(t *testing.T) { + req := &http.Request{} + ctx := &contextmodel.ReqContext{ + Context: &web.Context{ + Req: req, + }, + SignedInUser: &user.SignedInUser{ + OrgID: 1, + }, + } + + ctx.Req = web.SetURLParams(req, map[string]string{ + ":DatasourceUID": "~grafana-with-extra-config", + }) + + handler := &AlertmanagerApiHandler{ + FeatureManager: featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerUI), + } + + resp := handler.handleRouteCreateSilence(ctx, apimodels.PostableSilence{}, "~grafana-with-extra-config") + assert.Equal(t, http.StatusForbidden, resp.Status()) + + resp = handler.handleRouteDeleteAlertingConfig(ctx, "~grafana-with-extra-config") + assert.Equal(t, http.StatusForbidden, resp.Status()) + + resp = handler.handleRoutePostAlertingConfig(ctx, apimodels.PostableUserConfig{}, "~grafana-with-extra-config") + assert.Equal(t, http.StatusForbidden, resp.Status()) + + resp = handler.handleRoutePostAMAlerts(ctx, apimodels.PostableAlerts{}, "~grafana-with-extra-config") + assert.Equal(t, http.StatusForbidden, resp.Status()) + }) +} diff --git a/pkg/services/ngalert/api/prometheus/api_prometheus.go b/pkg/services/ngalert/api/prometheus/api_prometheus.go index c223bce9aac..2d0b441c9ef 100644 --- a/pkg/services/ngalert/api/prometheus/api_prometheus.go +++ b/pkg/services/ngalert/api/prometheus/api_prometheus.go @@ -2,6 +2,7 @@ package api import ( "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -29,7 +30,7 @@ import ( type RuleStoreReader interface { GetUserVisibleNamespaces(context.Context, int64, identity.Requester) (map[string]*folder.Folder, error) - ListAlertRulesStore + ListAlertRulesStoreV2 } type RuleGroupAccessControlService interface { @@ -240,6 +241,10 @@ type RuleGroupStatusesOptions struct { } type ListAlertRulesStore interface { + ListAlertRules(ctx context.Context, query *ngmodels.ListAlertRulesQuery) (ngmodels.RulesGroup, error) +} + +type ListAlertRulesStoreV2 interface { ListAlertRulesByGroup(ctx context.Context, query *ngmodels.ListAlertRulesByGroupQuery) (ngmodels.RulesGroup, string, error) } @@ -287,7 +292,7 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *contextmodel.ReqContext) respon return response.JSON(ruleResponse.HTTPStatusCode(), ruleResponse) } - ruleResponse = PrepareRuleGroupStatuses( + ruleResponse = PrepareRuleGroupStatusesV2( srv.log, srv.store, RuleGroupStatusesOptions{ @@ -399,7 +404,7 @@ func RuleAlertStateMutatorGenerator(manager state.AlertInstanceManager) RuleAler } } -func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts RuleGroupStatusesOptions, ruleStatusMutator RuleStatusMutator, alertStateMutator RuleAlertStateMutator, provenanceRecords map[string]ngmodels.Provenance) apimodels.RuleResponse { +func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opts RuleGroupStatusesOptions, ruleStatusMutator RuleStatusMutator, alertStateMutator RuleAlertStateMutator, provenanceRecords map[string]ngmodels.Provenance) apimodels.RuleResponse { ruleResponse := apimodels.RuleResponse{ DiscoveryBase: apimodels.DiscoveryBase{ Status: "success", @@ -483,14 +488,16 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru } byGroupQuery := ngmodels.ListAlertRulesByGroupQuery{ - OrgID: opts.OrgID, + ListAlertRulesQuery: ngmodels.ListAlertRulesQuery{ + OrgID: opts.OrgID, + NamespaceUIDs: namespaceUIDs, + DashboardUID: dashboardUID, + PanelID: panelID, + RuleGroups: ruleGroups, + ReceiverName: receiverName, + }, GroupLimit: maxGroups, GroupContinueToken: nextToken, - NamespaceUIDs: namespaceUIDs, - DashboardUID: dashboardUID, - PanelID: panelID, - RuleGroups: ruleGroups, - ReceiverName: receiverName, } ruleList, continueToken, err := store.ListAlertRulesByGroup(opts.Ctx, &byGroupQuery) if err != nil { @@ -542,6 +549,174 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru return ruleResponse } +func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts RuleGroupStatusesOptions, ruleStatusMutator RuleStatusMutator, alertStateMutator RuleAlertStateMutator, provenanceRecords map[string]ngmodels.Provenance) apimodels.RuleResponse { + ruleResponse := apimodels.RuleResponse{ + DiscoveryBase: apimodels.DiscoveryBase{ + Status: "success", + }, + Data: apimodels.RuleDiscovery{ + RuleGroups: []apimodels.RuleGroup{}, + }, + } + + dashboardUID := opts.Query.Get("dashboard_uid") + panelID, err := getPanelIDFromQuery(opts.Query) + if err != nil { + ruleResponse.Status = "error" + ruleResponse.Error = fmt.Sprintf("invalid panel_id: %s", err.Error()) + ruleResponse.ErrorType = apiv1.ErrBadData + return ruleResponse + } + if dashboardUID == "" && panelID != 0 { + ruleResponse.Status = "error" + ruleResponse.Error = "panel_id must be set with dashboard_uid" + ruleResponse.ErrorType = apiv1.ErrBadData + return ruleResponse + } + + limitRulesPerGroup := getInt64WithDefault(opts.Query, "limit_rules", -1) + limitAlertsPerRule := getInt64WithDefault(opts.Query, "limit_alerts", -1) + matchers, err := getMatchersFromQuery(opts.Query) + if err != nil { + ruleResponse.Status = "error" + ruleResponse.Error = err.Error() + ruleResponse.ErrorType = apiv1.ErrBadData + return ruleResponse + } + stateFilterSet, err := getStatesFromQuery(opts.Query) + if err != nil { + ruleResponse.Status = "error" + ruleResponse.Error = err.Error() + ruleResponse.ErrorType = apiv1.ErrBadData + return ruleResponse + } + + healthFilterSet, err := getHealthFromQuery(opts.Query) + if err != nil { + ruleResponse.Status = "error" + ruleResponse.Error = err.Error() + ruleResponse.ErrorType = apiv1.ErrBadData + return ruleResponse + } + + var labelOptions []ngmodels.LabelOption + if !getBoolWithDefault(opts.Query, queryIncludeInternalLabels, false) { + labelOptions = append(labelOptions, ngmodels.WithoutInternalLabels()) + } + + if len(opts.AllowedNamespaces) == 0 { + log.Debug("User does not have access to any namespaces") + return ruleResponse + } + + namespaceUIDs := make([]string, 0, len(opts.AllowedNamespaces)) + + folderUID := opts.Query.Get("folder_uid") + _, exists := opts.AllowedNamespaces[folderUID] + if folderUID != "" && exists { + namespaceUIDs = append(namespaceUIDs, folderUID) + } else { + for k := range opts.AllowedNamespaces { + namespaceUIDs = append(namespaceUIDs, k) + } + } + + ruleGroups := opts.Query["rule_group"] + + receiverName := opts.Query.Get("receiver_name") + + alertRuleQuery := ngmodels.ListAlertRulesQuery{ + OrgID: opts.OrgID, + NamespaceUIDs: namespaceUIDs, + DashboardUID: dashboardUID, + PanelID: panelID, + RuleGroups: ruleGroups, + ReceiverName: receiverName, + } + ruleList, err := store.ListAlertRules(opts.Ctx, &alertRuleQuery) + if err != nil { + ruleResponse.Status = "error" + ruleResponse.Error = fmt.Sprintf("failure getting rules: %s", err.Error()) + ruleResponse.ErrorType = apiv1.ErrServer + return ruleResponse + } + + ruleNames := opts.Query["rule_name"] + ruleNamesSet := make(map[string]struct{}, len(ruleNames)) + for _, rn := range ruleNames { + ruleNamesSet[rn] = struct{}{} + } + + maxGroups := getInt64WithDefault(opts.Query, "group_limit", -1) + nextToken := opts.Query.Get("group_next_token") + if nextToken != "" { + if _, err := base64.URLEncoding.DecodeString(nextToken); err != nil { + nextToken = "" + } + } + + groupedRules := getGroupedRules(log, ruleList, ruleNamesSet, opts.AllowedNamespaces) + rulesTotals := make(map[string]int64, len(groupedRules)) + var newToken string + foundToken := false + for _, rg := range groupedRules { + if nextToken != "" && !foundToken { + if !tokenGreaterThanOrEqual(getRuleGroupNextToken(rg.Folder, rg.GroupKey.RuleGroup), nextToken) { + continue + } + foundToken = true + } + + if maxGroups > -1 && len(ruleResponse.Data.RuleGroups) == int(maxGroups) { + newToken = getRuleGroupNextToken(rg.Folder, rg.GroupKey.RuleGroup) + break + } + + ruleGroup, totals := toRuleGroup(log, rg.GroupKey, rg.Folder, rg.Rules, provenanceRecords, limitAlertsPerRule, stateFilterSet, matchers, labelOptions, ruleStatusMutator, alertStateMutator) + ruleGroup.Totals = totals + for k, v := range totals { + rulesTotals[k] += v + } + + if len(stateFilterSet) > 0 { + filterRulesByState(ruleGroup, stateFilterSet) + } + + if len(healthFilterSet) > 0 { + filterRulesByHealth(ruleGroup, healthFilterSet) + } + + if limitRulesPerGroup > -1 && int64(len(ruleGroup.Rules)) > limitRulesPerGroup { + ruleGroup.Rules = ruleGroup.Rules[0:limitRulesPerGroup] + } + + if len(ruleGroup.Rules) > 0 { + ruleResponse.Data.RuleGroups = append(ruleResponse.Data.RuleGroups, *ruleGroup) + } + } + + ruleResponse.Data.NextToken = newToken + + // Only return Totals if there is no pagination + if maxGroups == -1 { + ruleResponse.Data.Totals = rulesTotals + } + + return ruleResponse +} + +func getRuleGroupNextToken(namespace, group string) string { + return base64.URLEncoding.EncodeToString([]byte(namespace + "/" + group)) +} + +// Returns true if tokenA >= tokenB +func tokenGreaterThanOrEqual(tokenA string, tokenB string) bool { + decodedTokenA, _ := base64.URLEncoding.DecodeString(tokenA) + decodedTokenB, _ := base64.URLEncoding.DecodeString(tokenB) + + return string(decodedTokenA) >= string(decodedTokenB) +} + type ruleGroup struct { Folder string GroupKey ngmodels.AlertRuleGroupKey diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go index 7cf30d3b702..580338a4584 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go @@ -760,10 +760,12 @@ func (c ExtraConfiguration) Validate() error { func fromPrometheusConfig(prometheusConfig config.Config) PostableApiAlertingConfig { config := PostableApiAlertingConfig{ Config: Config{ - Global: prometheusConfig.Global, - Route: AsGrafanaRoute(prometheusConfig.Route), - InhibitRules: prometheusConfig.InhibitRules, - Templates: prometheusConfig.Templates, + Global: prometheusConfig.Global, + Route: AsGrafanaRoute(prometheusConfig.Route), + InhibitRules: prometheusConfig.InhibitRules, + TimeIntervals: prometheusConfig.TimeIntervals, + MuteTimeIntervals: prometheusConfig.MuteTimeIntervals, + Templates: prometheusConfig.Templates, }, } diff --git a/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go b/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go index 0bb57a9de82..21e6b438ac2 100644 --- a/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go +++ b/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go @@ -593,7 +593,7 @@ type PostableGrafanaRule struct { // If set to 0, the value is reset to the default. // required: false // example: 3 - MissingSeriesEvalsToResolve *int `json:"missing_series_evals_to_resolve,omitempty" yaml:"missing_series_evals_to_resolve,omitempty"` + MissingSeriesEvalsToResolve *int64 `json:"missing_series_evals_to_resolve,omitempty" yaml:"missing_series_evals_to_resolve,omitempty"` } // swagger:model @@ -616,7 +616,7 @@ type GettableGrafanaRule struct { Record *Record `json:"record,omitempty" yaml:"record,omitempty"` Metadata *AlertRuleMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty"` GUID string `json:"guid" yaml:"guid"` - MissingSeriesEvalsToResolve *int `json:"missing_series_evals_to_resolve,omitempty" yaml:"missing_series_evals_to_resolve,omitempty"` + MissingSeriesEvalsToResolve *int64 `json:"missing_series_evals_to_resolve,omitempty" yaml:"missing_series_evals_to_resolve,omitempty"` } // UserInfo represents user-related information, including a unique identifier and a name. diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go index 5aeb919b4ad..0d91080064b 100644 --- a/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go @@ -174,7 +174,7 @@ type ProvisionedAlertRule struct { // example: {"metric":"grafana_alerts_ratio", "from":"A"} Record *Record `json:"record"` // example: 2 - MissingSeriesEvalsToResolve *int `json:"missingSeriesEvalsToResolve,omitempty"` + MissingSeriesEvalsToResolve *int64 `json:"missingSeriesEvalsToResolve,omitempty"` } // swagger:route GET /v1/provisioning/folder/{FolderUID}/rule-groups/{Group} provisioning stable RouteGetAlertRuleGroup @@ -273,7 +273,7 @@ type AlertRuleExport struct { NoDataState *NoDataState `json:"noDataState,omitempty" yaml:"noDataState,omitempty" hcl:"no_data_state"` ExecErrState *ExecutionErrorState `json:"execErrState,omitempty" yaml:"execErrState,omitempty" hcl:"exec_err_state"` For model.Duration `json:"for,omitempty" yaml:"for,omitempty"` - KeepFiringFor model.Duration `json:"keepFiringFor,omitempty" yaml:"keepFiringFor,omitempty" hcl:"keep_firing_for"` + KeepFiringFor model.Duration `json:"keepFiringFor,omitempty" yaml:"keepFiringFor,omitempty"` // ForString and KeepFiringForString are used to: // - Only export the for field for HCL if it is non-zero. // - Format the Prometheus model.Duration type properly for HCL. @@ -284,7 +284,7 @@ type AlertRuleExport struct { IsPaused bool `json:"isPaused" yaml:"isPaused" hcl:"is_paused"` NotificationSettings *AlertRuleNotificationSettingsExport `json:"notification_settings,omitempty" yaml:"notification_settings,omitempty" hcl:"notification_settings,block"` Record *AlertRuleRecordExport `json:"record,omitempty" yaml:"record,omitempty" hcl:"record,block"` - MissingSeriesEvalsToResolve *int `json:"missing_series_evals_to_resolve,omitempty" yaml:"missing_series_evals_to_resolve,omitempty" hcl:"missing_series_evals_to_resolve"` + MissingSeriesEvalsToResolve *int64 `json:"missing_series_evals_to_resolve,omitempty" yaml:"missing_series_evals_to_resolve,omitempty" hcl:"missing_series_evals_to_resolve"` } // AlertQueryExport is the provisioned export of models.AlertQuery. diff --git a/pkg/services/ngalert/api/validation/api_ruler_validation.go b/pkg/services/ngalert/api/validation/api_ruler_validation.go index 01d0b430cef..1dffe249bdd 100644 --- a/pkg/services/ngalert/api/validation/api_ruler_validation.go +++ b/pkg/services/ngalert/api/validation/api_ruler_validation.go @@ -299,10 +299,10 @@ func validateKeepFiringForInterval(ruleNode *apimodels.PostableExtendedRuleNode) // - == 0, returns nil (reset to default) // - == nil && UID == "", returns nil (new rule) // - == nil && UID != "", returns -1 (existing rule) -func validateMissingSeriesEvalsToResolve(ruleNode *apimodels.PostableExtendedRuleNode) (*int, error) { +func validateMissingSeriesEvalsToResolve(ruleNode *apimodels.PostableExtendedRuleNode) (*int64, error) { if ruleNode.GrafanaManagedAlert.MissingSeriesEvalsToResolve == nil { if ruleNode.GrafanaManagedAlert.UID != "" { - return util.Pointer(-1), nil // will be patched later with the real value of the current version of the rule + return util.Pointer[int64](-1), nil // will be patched later with the real value of the current version of the rule } return nil, nil // if it's a new rule, use nil as the default } diff --git a/pkg/services/ngalert/eval/eval.go b/pkg/services/ngalert/eval/eval.go index fec321ddd11..67aa263b24a 100644 --- a/pkg/services/ngalert/eval/eval.go +++ b/pkg/services/ngalert/eval/eval.go @@ -45,7 +45,7 @@ type expressionExecutor interface { type expressionBuilder interface { expressionExecutor - BuildPipeline(req *expr.Request) (expr.DataPipeline, error) + BuildPipeline(ctx context.Context, req *expr.Request) (expr.DataPipeline, error) } type conditionEvaluator struct { @@ -849,11 +849,11 @@ func (e *evaluatorImpl) Create(ctx EvaluationContext, condition models.Condition if err != nil { return nil, err } - return e.create(condition, req) + return e.create(ctx.Ctx, condition, req) } -func (e *evaluatorImpl) create(condition models.Condition, req *expr.Request) (ConditionEvaluator, error) { - pipeline, err := e.expressionService.BuildPipeline(req) +func (e *evaluatorImpl) create(ctx context.Context, condition models.Condition, req *expr.Request) (ConditionEvaluator, error) { + pipeline, err := e.expressionService.BuildPipeline(ctx, req) if err != nil { return nil, err } diff --git a/pkg/services/ngalert/eval/eval_test.go b/pkg/services/ngalert/eval/eval_test.go index 15a9d464b15..a93ecacd64d 100644 --- a/pkg/services/ngalert/eval/eval_test.go +++ b/pkg/services/ngalert/eval/eval_test.go @@ -1539,7 +1539,7 @@ func TestCreate(t *testing.T) { factory := evaluatorImpl{ expressionService: fakeExpressionService{ - buildHook: func(req *expr.Request) (expr.DataPipeline, error) { + buildHook: func(ctx context.Context, req *expr.Request) (expr.DataPipeline, error) { if request != nil { assert.Fail(t, "BuildPipeline was called twice but should be only once") } @@ -1562,15 +1562,15 @@ func TestCreate(t *testing.T) { type fakeExpressionService struct { hook func(ctx context.Context, now time.Time, pipeline expr.DataPipeline) (*backend.QueryDataResponse, error) - buildHook func(req *expr.Request) (expr.DataPipeline, error) + buildHook func(ctx context.Context, req *expr.Request) (expr.DataPipeline, error) } func (f fakeExpressionService) ExecutePipeline(ctx context.Context, now time.Time, pipeline expr.DataPipeline) (*backend.QueryDataResponse, error) { return f.hook(ctx, now, pipeline) } -func (f fakeExpressionService) BuildPipeline(req *expr.Request) (expr.DataPipeline, error) { - return f.buildHook(req) +func (f fakeExpressionService) BuildPipeline(ctx context.Context, req *expr.Request) (expr.DataPipeline, error) { + return f.buildHook(ctx, req) } type fakeNode struct { diff --git a/pkg/services/ngalert/eval/validate.go b/pkg/services/ngalert/eval/validate.go index 37c58385102..573426f06c1 100644 --- a/pkg/services/ngalert/eval/validate.go +++ b/pkg/services/ngalert/eval/validate.go @@ -50,7 +50,7 @@ func (e *ConditionValidator) Validate(ctx EvaluationContext, condition models.Co case expr.TypeCMDNode: } } - pipeline, err := e.expressionService.BuildPipeline(req) + pipeline, err := e.expressionService.BuildPipeline(ctx.Ctx, req) if err != nil { return err } diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index b1f415f929d..f888a2f8467 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -124,6 +124,9 @@ func (r RuleType) String() string { } const ( + // When adding new constants with __*__ pattern that are labels, + // you need to add them to PrivateLabelsToFilter below if they should be filtered out from recording rules. + // Annotations are actually a set of labels, so technically this is the label name of an annotation. DashboardUIDAnnotation = "__dashboardUid__" PanelIDAnnotation = "__panelId__" @@ -199,6 +202,15 @@ var ( AutogeneratedRouteReceiverNameLabel: {}, AutogeneratedRouteSettingsHashLabel: {}, } + + // PrivateLabelsToFilter are internal labels that should be filtered out from recording rules. + // These labels are used internally by Grafana and should not appear in the final metrics. + PrivateLabelsToFilter = map[string]struct{}{ + ConvertedPrometheusRuleLabel: {}, + AutogeneratedRouteLabel: {}, + AutogeneratedRouteReceiverNameLabel: {}, + AutogeneratedRouteSettingsHashLabel: {}, + } ) // AlertRuleGroup is the base model for a rule group in unified alerting. @@ -304,7 +316,7 @@ type AlertRule struct { // required before resolving an alert state (a dimension) when data is missing. // If nil, alerts resolve after 2 missing evaluation intervals // (i.e., resolution occurs during the second evaluation where data is absent). - MissingSeriesEvalsToResolve *int + MissingSeriesEvalsToResolve *int64 } type AlertRuleMetadata struct { @@ -407,6 +419,25 @@ func WithoutInternalLabels() LabelOption { } } +// WithoutPrivateLabels returns a new map without private labels. +// It filters out labels that are in PrivateLabelsToFilter set. +func WithoutPrivateLabels(labels map[string]string) map[string]string { + if labels == nil { + return nil + } + + result := make(map[string]string, len(labels)) + for k, v := range labels { + // Check if it's in the explicit filter list + if _, shouldFilter := PrivateLabelsToFilter[k]; shouldFilter { + continue + } + + result[k] = v + } + return result +} + func (alertRule *AlertRule) ImportedPrometheusRule() bool { _, hasConvertedPrometheusRuleLabel := alertRule.GetLabels()[ConvertedPrometheusRuleLabel] return hasConvertedPrometheusRuleLabel || alertRule.HasPrometheusRuleDefinition() @@ -598,7 +629,7 @@ func (alertRule *AlertRule) GetGroupKey() AlertRuleGroupKey { // to wait before resolving an alert rule instance when its data is missing. // If not configured, it returns the default value (2), which means the alert // resolves after missing for two evaluation intervals. -func (alertRule *AlertRule) GetMissingSeriesEvalsToResolve() int { +func (alertRule *AlertRule) GetMissingSeriesEvalsToResolve() int64 { if alertRule.MissingSeriesEvalsToResolve == nil { return 2 // default value } @@ -858,22 +889,7 @@ const ( ) type ListAlertRulesByGroupQuery struct { - OrgID int64 - RuleUIDs []string - NamespaceUIDs []string - ExcludeOrgs []int64 - RuleGroups []string - - // DashboardUID and PanelID are optional and allow filtering rules - // to return just those for a dashboard and panel. - DashboardUID string - PanelID int64 - - ReceiverName string - TimeIntervalName string - - HasPrometheusRuleDefinition *bool - + ListAlertRulesQuery RuleType RuleTypeFilter GroupLimit int64 // Number of groups to fetch diff --git a/pkg/services/ngalert/models/alert_rule_test.go b/pkg/services/ngalert/models/alert_rule_test.go index 329e979ea03..f6935ececbb 100644 --- a/pkg/services/ngalert/models/alert_rule_test.go +++ b/pkg/services/ngalert/models/alert_rule_test.go @@ -554,8 +554,8 @@ func TestDiff(t *testing.T) { if rule1.MissingSeriesEvalsToResolve != rule2.MissingSeriesEvalsToResolve { diff := diffs.GetDiffsForField("MissingSeriesEvalsToResolve") assert.Len(t, diff, 1) - assert.Equal(t, *rule1.MissingSeriesEvalsToResolve, int(diff[0].Left.Int())) - assert.Equal(t, *rule2.MissingSeriesEvalsToResolve, int(diff[0].Right.Int())) + assert.Equal(t, *rule1.MissingSeriesEvalsToResolve, diff[0].Left.Int()) + assert.Equal(t, *rule2.MissingSeriesEvalsToResolve, diff[0].Right.Int()) difCnt++ } @@ -1001,14 +1001,14 @@ func TestAlertRuleGetMissingSeriesEvalsToResolve(t *testing.T) { t.Run("should return the default 2 if MissingSeriesEvalsToResolve is nil", func(t *testing.T) { rule := RuleGen.GenerateRef() rule.MissingSeriesEvalsToResolve = nil - require.Equal(t, 2, rule.GetMissingSeriesEvalsToResolve()) + require.Equal(t, int64(2), rule.GetMissingSeriesEvalsToResolve()) }) t.Run("should return the correct value", func(t *testing.T) { rule := RuleGen.With( RuleMuts.WithMissingSeriesEvalsToResolve(3), ).GenerateRef() - require.Equal(t, 3, rule.GetMissingSeriesEvalsToResolve()) + require.Equal(t, int64(3), rule.GetMissingSeriesEvalsToResolve()) }) } @@ -1113,7 +1113,7 @@ func TestValidateAlertRule(t *testing.T) { t.Run("missingSeriesEvalsToResolve", func(t *testing.T) { testCases := []struct { name string - missingSeriesEvalsToResolve *int + missingSeriesEvalsToResolve *int64 expectedErrorContains string }{ { @@ -1122,17 +1122,17 @@ func TestValidateAlertRule(t *testing.T) { }, { name: "should reject negative value", - missingSeriesEvalsToResolve: util.Pointer(-1), + missingSeriesEvalsToResolve: util.Pointer[int64](-1), expectedErrorContains: "field `missing_series_evals_to_resolve` must be greater than 0", }, { name: "should reject 0", - missingSeriesEvalsToResolve: util.Pointer(0), + missingSeriesEvalsToResolve: util.Pointer[int64](0), expectedErrorContains: "field `missing_series_evals_to_resolve` must be greater than 0", }, { name: "should accept positive value", - missingSeriesEvalsToResolve: util.Pointer(2), + missingSeriesEvalsToResolve: util.Pointer[int64](2), }, } @@ -1348,3 +1348,54 @@ func TestAlertRule_ImportedPrometheusRule(t *testing.T) { }) } } + +func TestWithoutPrivateLabels(t *testing.T) { + tests := []struct { + name string + input map[string]string + expected map[string]string + }{ + { + name: "nil map", + input: nil, + expected: nil, + }, + { + name: "empty map", + input: map[string]string{}, + expected: map[string]string{}, + }, + { + name: "removes only specific private labels", + input: map[string]string{ + ConvertedPrometheusRuleLabel: "removed", + AutogeneratedRouteLabel: "removed", + AutogeneratedRouteReceiverNameLabel: "removed", + AutogeneratedRouteSettingsHashLabel: "removed", + DashboardUIDAnnotation: "kept", + PanelIDAnnotation: "kept", + "__custom_label__": "kept", // User-defined labels with __ are kept + "normal_label": "kept", + "another_label": "kept", + }, + expected: map[string]string{ + "__custom_label__": "kept", + "normal_label": "kept", + "another_label": "kept", + DashboardUIDAnnotation: "kept", + PanelIDAnnotation: "kept", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inputCopy := maps.Clone(tt.input) + + result := WithoutPrivateLabels(tt.input) + + require.Equal(t, tt.expected, result) + require.Equal(t, inputCopy, tt.input, "input map should not be modified") + }) + } +} diff --git a/pkg/services/ngalert/models/testing.go b/pkg/services/ngalert/models/testing.go index aa8e7c8f32d..c9ba4b91348 100644 --- a/pkg/services/ngalert/models/testing.go +++ b/pkg/services/ngalert/models/testing.go @@ -128,7 +128,7 @@ func (g *AlertRuleGenerator) Generate() AlertRule { Labels: labels, NotificationSettings: ns, Metadata: GenerateMetadata(), - MissingSeriesEvalsToResolve: util.Pointer(2), + MissingSeriesEvalsToResolve: util.Pointer[int64](2), } for _, mutator := range g.mutators { @@ -514,12 +514,12 @@ func (a *AlertRuleMutators) WithSameGroup() AlertRuleMutator { } } -func (a *AlertRuleMutators) WithMissingSeriesEvalsToResolve(timesOfInterval int) AlertRuleMutator { +func (a *AlertRuleMutators) WithMissingSeriesEvalsToResolve(timesOfInterval int64) AlertRuleMutator { return func(rule *AlertRule) { if timesOfInterval <= 0 { panic("timesOfInterval must be greater than 0") } - rule.MissingSeriesEvalsToResolve = util.Pointer(timesOfInterval) + rule.MissingSeriesEvalsToResolve = util.Pointer[int64](timesOfInterval) } } diff --git a/pkg/services/ngalert/notifier/alertmanager_config.go b/pkg/services/ngalert/notifier/alertmanager_config.go index 510f5c25c08..3d4ebba01f9 100644 --- a/pkg/services/ngalert/notifier/alertmanager_config.go +++ b/pkg/services/ngalert/notifier/alertmanager_config.go @@ -116,13 +116,14 @@ func (moa *MultiOrgAlertmanager) ApplyConfig(ctx context.Context, orgId int64, d // GetAlertmanagerConfiguration returns the latest alertmanager configuration for a given org. // If withAutogen is true, the configuration will be augmented with autogenerated routes. -func (moa *MultiOrgAlertmanager) GetAlertmanagerConfiguration(ctx context.Context, org int64, withAutogen bool) (definitions.GettableUserConfig, error) { +// If withMergedExtraConfig is true, any extra configurations will be merged into the main configuration. +func (moa *MultiOrgAlertmanager) GetAlertmanagerConfiguration(ctx context.Context, org int64, withAutogen bool, withMergedExtraConfig bool) (definitions.GettableUserConfig, error) { amConfig, err := moa.configStore.GetLatestAlertmanagerConfiguration(ctx, org) if err != nil { return definitions.GettableUserConfig{}, fmt.Errorf("failed to get latest configuration: %w", err) } - cfg, err := moa.gettableUserConfigFromAMConfigString(ctx, org, amConfig.AlertmanagerConfiguration) + cfg, err := moa.gettableUserConfigFromAMConfigString(ctx, org, amConfig.AlertmanagerConfiguration, withMergedExtraConfig) if err != nil { return definitions.GettableUserConfig{}, err } @@ -197,7 +198,7 @@ func (moa *MultiOrgAlertmanager) GetAppliedAlertmanagerConfigurations(ctx contex gettableHistoricConfigs := make([]*definitions.GettableHistoricUserConfig, 0, len(configs)) for _, config := range configs { appliedAt := strfmt.DateTime(time.Unix(config.LastApplied, 0).UTC()) - gettableConfig, err := moa.gettableUserConfigFromAMConfigString(ctx, org, config.AlertmanagerConfiguration) + gettableConfig, err := moa.gettableUserConfigFromAMConfigString(ctx, org, config.AlertmanagerConfiguration, false) if err != nil { // If there are invalid records, skip them and return the valid ones. moa.logger.Warn("Invalid configuration found in alert configuration history table", "id", config.ID, "orgID", org) @@ -217,7 +218,7 @@ func (moa *MultiOrgAlertmanager) GetAppliedAlertmanagerConfigurations(ctx contex return gettableHistoricConfigs, nil } -func (moa *MultiOrgAlertmanager) gettableUserConfigFromAMConfigString(ctx context.Context, orgID int64, config string) (definitions.GettableUserConfig, error) { +func (moa *MultiOrgAlertmanager) gettableUserConfigFromAMConfigString(ctx context.Context, orgID int64, config string, withMergedExtraConfig bool) (definitions.GettableUserConfig, error) { cfg, err := Load([]byte(config)) if err != nil { return definitions.GettableUserConfig{}, fmt.Errorf("failed to unmarshal alertmanager configuration: %w", err) @@ -228,10 +229,29 @@ func (moa *MultiOrgAlertmanager) gettableUserConfigFromAMConfigString(ctx contex return definitions.GettableUserConfig{}, fmt.Errorf("failed to decrypt external configurations: %w", err) } + var alertmanagerConfig definitions.PostableApiAlertingConfig + var templateFiles map[string]string + if withMergedExtraConfig && len(cfg.ExtraConfigs) > 0 { + mergeResult, err := cfg.GetMergedAlertmanagerConfig() + if err != nil { + return definitions.GettableUserConfig{}, fmt.Errorf("failed to merge configuration: %w", err) + } + alertmanagerConfig = mergeResult.Config + + mergedTemplates := cfg.GetMergedTemplateDefinitions() + templateFiles = make(map[string]string, len(mergedTemplates)) + for _, t := range mergedTemplates { + templateFiles[t.Name] = t.Content + } + } else { + alertmanagerConfig = cfg.AlertmanagerConfig + templateFiles = cfg.TemplateFiles + } + result := definitions.GettableUserConfig{ - TemplateFiles: cfg.TemplateFiles, + TemplateFiles: templateFiles, AlertmanagerConfig: definitions.GettableApiAlertingConfig{ - Config: cfg.AlertmanagerConfig.Config, + Config: alertmanagerConfig.Config, }, ExtraConfigs: cfg.ExtraConfigs, } @@ -239,13 +259,13 @@ func (moa *MultiOrgAlertmanager) gettableUserConfigFromAMConfigString(ctx contex // First we encrypt the secure settings. // This is done to ensure that any secure settings incorrectly stored in Settings are encrypted and moved to // SecureSettings. This can happen if an integration definition is updated to make a field secure. - if err := EncryptReceiverConfigSettings(cfg.AlertmanagerConfig.Receivers, func(ctx context.Context, payload []byte) ([]byte, error) { + if err := EncryptReceiverConfigSettings(alertmanagerConfig.Receivers, func(ctx context.Context, payload []byte) ([]byte, error) { return moa.Crypto.Encrypt(ctx, payload, secrets.WithoutScope()) }); err != nil { return definitions.GettableUserConfig{}, fmt.Errorf("failed to encrypt receivers: %w", err) } - for _, recv := range cfg.AlertmanagerConfig.Receivers { + for _, recv := range alertmanagerConfig.Receivers { receivers := make([]*definitions.GettableGrafanaReceiver, 0, len(recv.GrafanaManagedReceivers)) for _, pr := range recv.GrafanaManagedReceivers { secureFields := make(map[string]bool, len(pr.SecureSettings)) diff --git a/pkg/services/ngalert/notifier/alertmanager_config_test.go b/pkg/services/ngalert/notifier/alertmanager_config_test.go index 642ca5e3c04..3b12153da98 100644 --- a/pkg/services/ngalert/notifier/alertmanager_config_test.go +++ b/pkg/services/ngalert/notifier/alertmanager_config_test.go @@ -48,7 +48,7 @@ receivers: err := mam.SaveAndApplyExtraConfiguration(ctx, orgID, extraConfig) require.NoError(t, err) - gettableConfig, err := mam.GetAlertmanagerConfiguration(ctx, orgID, false) + gettableConfig, err := mam.GetAlertmanagerConfiguration(ctx, orgID, false, false) require.NoError(t, err) require.Len(t, gettableConfig.ExtraConfigs, 1) require.Equal(t, extraConfig.Identifier, gettableConfig.ExtraConfigs[0].Identifier) @@ -98,7 +98,7 @@ receivers: require.NoError(t, err) // Verify only one config exists with updated content - gettableConfig, err := mam.GetAlertmanagerConfiguration(ctx, orgID, false) + gettableConfig, err := mam.GetAlertmanagerConfiguration(ctx, orgID, false, false) require.NoError(t, err) require.Len(t, gettableConfig.ExtraConfigs, 1) require.Equal(t, identifier, gettableConfig.ExtraConfigs[0].Identifier) @@ -172,14 +172,14 @@ receivers: err := mam.SaveAndApplyExtraConfiguration(ctx, orgID, extraConfig) require.NoError(t, err) - gettableConfig, err := mam.GetAlertmanagerConfiguration(ctx, orgID, false) + gettableConfig, err := mam.GetAlertmanagerConfiguration(ctx, orgID, false, false) require.NoError(t, err) require.Len(t, gettableConfig.ExtraConfigs, 1) err = mam.DeleteExtraConfiguration(ctx, orgID, identifier) require.NoError(t, err) - gettableConfig, err = mam.GetAlertmanagerConfiguration(ctx, orgID, false) + gettableConfig, err = mam.GetAlertmanagerConfiguration(ctx, orgID, false, false) require.NoError(t, err) require.Len(t, gettableConfig.ExtraConfigs, 0) }) diff --git a/pkg/services/ngalert/prom/convert.go b/pkg/services/ngalert/prom/convert.go index 4b8137f347b..82318bba1e6 100644 --- a/pkg/services/ngalert/prom/convert.go +++ b/pkg/services/ngalert/prom/convert.go @@ -236,7 +236,9 @@ func (p *Converter) convertRule(orgID int64, namespaceUID string, promGroup Prom } labels := make(map[string]string, len(rule.Labels)+len(promGroup.Labels)+len(p.cfg.ExtraLabels)+1) - maps.Copy(labels, p.cfg.ExtraLabels) + if !isRecordingRule { + maps.Copy(labels, p.cfg.ExtraLabels) + } maps.Copy(labels, promGroup.Labels) maps.Copy(labels, rule.Labels) @@ -275,7 +277,7 @@ func (p *Converter) convertRule(orgID int64, namespaceUID string, promGroup Prom // 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), + MissingSeriesEvalsToResolve: util.Pointer[int64](1), } if !isRecordingRule { diff --git a/pkg/services/ngalert/prom/convert_test.go b/pkg/services/ngalert/prom/convert_test.go index 5d7b9470d00..ae4cf4a73e8 100644 --- a/pkg/services/ngalert/prom/convert_test.go +++ b/pkg/services/ngalert/prom/convert_test.go @@ -358,7 +358,7 @@ func TestPrometheusRulesToGrafana(t *testing.T) { 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) + require.Equal(t, util.Pointer(int64(1)), grafanaRule.MissingSeriesEvalsToResolve) require.Equal(t, models.OkErrState, grafanaRule.ExecErrState) require.Equal(t, models.OK, grafanaRule.NoDataState) @@ -705,7 +705,7 @@ func TestPrometheusRulesToGrafana_ExtraLabels(t *testing.T) { require.Equal(t, expectedLabels, grafanaGroup.Rules[0].Labels) }) - t.Run("extra labels are applied to recording rules", func(t *testing.T) { + t.Run("extra labels are not applied to recording rules", func(t *testing.T) { promGroup := PrometheusRuleGroup{ Name: "test-group-2", Interval: prommodel.Duration(10 * time.Second), @@ -721,8 +721,12 @@ func TestPrometheusRulesToGrafana_ExtraLabels(t *testing.T) { require.NoError(t, err) require.Len(t, grafanaGroup.Rules, 1) - expectedLabels := withInternalLabel(cfg.ExtraLabels) + expectedLabels := withInternalLabel(make(map[string]string)) require.Equal(t, expectedLabels, grafanaGroup.Rules[0].Labels) + + for key := range cfg.ExtraLabels { + require.NotContains(t, grafanaGroup.Rules[0].Labels, key) + } }) } diff --git a/pkg/services/ngalert/schedule/recording_rule.go b/pkg/services/ngalert/schedule/recording_rule.go index f0992873981..d931c502327 100644 --- a/pkg/services/ngalert/schedule/recording_rule.go +++ b/pkg/services/ngalert/schedule/recording_rule.go @@ -272,8 +272,9 @@ func (r *recordingRule) tryEvaluation(ctx context.Context, ev *Evaluation, logge return nil } + filteredLabels := ngmodels.WithoutPrivateLabels(ev.rule.Labels) writeStart := r.clock.Now() - err = r.writer.WriteDatasource(ctx, ev.rule.Record.TargetDatasourceUID, ev.rule.Record.Metric, ev.scheduledAt, frames, ev.rule.OrgID, ev.rule.Labels) + err = r.writer.WriteDatasource(ctx, ev.rule.Record.TargetDatasourceUID, ev.rule.Record.Metric, ev.scheduledAt, frames, ev.rule.OrgID, filteredLabels) writeDur := r.clock.Now().Sub(writeStart) if err != nil { diff --git a/pkg/services/ngalert/schedule/recording_rule_test.go b/pkg/services/ngalert/schedule/recording_rule_test.go index 56cdf939dca..b5da12947d5 100644 --- a/pkg/services/ngalert/schedule/recording_rule_test.go +++ b/pkg/services/ngalert/schedule/recording_rule_test.go @@ -12,8 +12,11 @@ import ( "time" "github.com/benbjohnson/clock" + "github.com/gogo/protobuf/proto" + "github.com/golang/snappy" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/prometheus/prometheus/prompb" "github.com/stretchr/testify/require" "go.uber.org/atomic" @@ -750,6 +753,69 @@ func testRecordingRule_Integration(t *testing.T, writeTarget *writer.TestRemoteW require.Equal(t, "error", status.Health) }) }) + + t.Run("rule with private labels filtered", func(t *testing.T) { + writeTarget.Reset() + rule := gen.With(withQueryForHealth("ok")).GenerateRef() + rule.Record.TargetDatasourceUID = dsUID + rule.Labels = map[string]string{ + "normal_label": "not_filtered_1", + "another_label": "not_filtered_2", + models.AutogeneratedRouteLabel: "filtered", + models.AutogeneratedRouteReceiverNameLabel: "filtered", + "__user_custom__": "not_filtered_3", + "only_end__": "not_filtered_4", + } + + ruleStore.PutRule(context.Background(), rule) + folderTitle := ruleStore.getNamespaceTitle(rule.NamespaceUID) + ruleFactory := ruleFactoryFromScheduler(sch) + + process := ruleFactory.new(context.Background(), rule) + evalDoneChan := make(chan time.Time) + process.(*recordingRule).evalAppliedHook = func(_ models.AlertRuleKey, t time.Time) { + evalDoneChan <- t + } + now := time.Now() + + go func() { + _ = process.Run() + }() + + process.Eval(&Evaluation{ + scheduledAt: now, + rule: rule, + folderTitle: folderTitle, + }) + _ = waitForTimeChannel(t, evalDoneChan) + + t.Run("write was performed with filtered labels", func(t *testing.T) { + require.Equal(t, 1, writeTarget.RequestsCount) + require.NotEmpty(t, writeTarget.LastRequestBody) + + writeReq := decodePrometheusWriteRequest(t, writeTarget.LastRequestBody) + + // Check that the private labels are filtered out + for _, label := range []string{ + models.AutogeneratedRouteLabel, + models.AutogeneratedRouteReceiverNameLabel, + } { + _, found := getLabel(writeReq, label) + require.False(t, found, "Label %s should not be present in the write request", label) + } + + // Check that user-defined labels are preserved + for _, label := range []string{ + "normal_label", + "another_label", + "__user_custom__", + "only_end__", + } { + value, _ := getLabel(writeReq, label) + require.Equal(t, rule.Labels[label], value, "Label %s=%s should be present in the write request", label, rule.Labels[label]) + } + }) + }) } func withQueryForHealth(health string) models.AlertRuleMutator { @@ -826,3 +892,28 @@ type mockPluginContextProvider struct{} func (m *mockPluginContextProvider) GetWithDataSource(ctx context.Context, pluginID string, user identity.Requester, ds *datasources.DataSource) (backend.PluginContext, error) { return backend.PluginContext{}, nil } + +func decodePrometheusWriteRequest(t *testing.T, data string) *prompb.WriteRequest { + t.Helper() + + decompressed, err := snappy.Decode(nil, []byte(data)) + require.NoError(t, err) + + var writeReq prompb.WriteRequest + err = proto.Unmarshal(decompressed, &writeReq) + require.NoError(t, err) + + return &writeReq +} + +func getLabel(req *prompb.WriteRequest, labelName string) (string, bool) { + for _, ts := range req.Timeseries { + for _, label := range ts.Labels { + if label.Name == labelName { + return label.Value, true + } + } + } + + return "", false +} diff --git a/pkg/services/ngalert/schedule/registry_test.go b/pkg/services/ngalert/schedule/registry_test.go index 9d838556ed7..b747830a18c 100644 --- a/pkg/services/ngalert/schedule/registry_test.go +++ b/pkg/services/ngalert/schedule/registry_test.go @@ -214,7 +214,7 @@ func TestRuleWithFolderFingerprint(t *testing.T) { SimplifiedNotificationsSection: false, }, }, - MissingSeriesEvalsToResolve: util.Pointer(2), + MissingSeriesEvalsToResolve: util.Pointer[int64](2), } r2 := &models.AlertRule{ ID: 2, @@ -260,7 +260,7 @@ func TestRuleWithFolderFingerprint(t *testing.T) { SimplifiedQueryAndExpressionsSection: true, }, }, - MissingSeriesEvalsToResolve: util.Pointer(1), + MissingSeriesEvalsToResolve: util.Pointer[int64](1), } excludedFields := map[string]struct{}{ diff --git a/pkg/services/ngalert/sender/notifier.go b/pkg/services/ngalert/sender/notifier.go index ef1022921e2..2ba819a8c41 100644 --- a/pkg/services/ngalert/sender/notifier.go +++ b/pkg/services/ngalert/sender/notifier.go @@ -1,6 +1,6 @@ // THIS FILE IS COPIED FROM UPSTREAM // -// https://github.com/prometheus/prometheus/blob/293f0c9185260165fd7dabbf8a9e8758b32abeae/notifier/notifier.go +// https://github.com/prometheus/prometheus/blob/bd5b2ea95ce14fba11db871b4068313408465207/notifier/notifier.go // // Copyright 2013 The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); @@ -31,7 +31,6 @@ import ( "time" "github.com/go-openapi/strfmt" - "github.com/grafana/grafana/pkg/util/httpclient" "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/client_golang/prometheus" config_util "github.com/prometheus/common/config" @@ -48,6 +47,9 @@ import ( ) const ( + // DefaultMaxBatchSize is the default maximum number of alerts to send in a single request to the alertmanager. + DefaultMaxBatchSize = 256 + contentTypeJSON = "application/json" ) @@ -135,6 +137,9 @@ type Options struct { Do func(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) Registerer prometheus.Registerer + + // MaxBatchSize determines the maximum number of alerts to send in a single request to the alertmanager. + MaxBatchSize int } type alertMetrics struct { @@ -215,18 +220,15 @@ func newAlertMetrics(r prometheus.Registerer, queueCap int, queueLen, alertmanag return m } -func do(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) { - if client == nil { - client = httpclient.New() - } - return client.Do(req.WithContext(ctx)) -} - // NewManager is the manager constructor. func NewManager(o *Options, logger *slog.Logger) *Manager { if o.Do == nil { o.Do = do } + // Set default MaxBatchSize if not provided. + if o.MaxBatchSize <= 0 { + o.MaxBatchSize = DefaultMaxBatchSize + } if logger == nil { logger = promslog.NewNopLogger() } @@ -253,8 +255,6 @@ func NewManager(o *Options, logger *slog.Logger) *Manager { return n } -const maxBatchSize = 64 - func (n *Manager) queueLen() int { n.mtx.RLock() defer n.mtx.RUnlock() @@ -268,7 +268,7 @@ func (n *Manager) nextBatch() []*Alert { var alerts []*Alert - if len(n.queue) > maxBatchSize { + if maxBatchSize := n.opts.MaxBatchSize; len(n.queue) > maxBatchSize { alerts = append(make([]*Alert, 0, maxBatchSize), n.queue[:maxBatchSize]...) n.queue = n.queue[maxBatchSize:] } else { diff --git a/pkg/services/ngalert/sender/notifier_ext.go b/pkg/services/ngalert/sender/notifier_ext.go index 348189eaf34..e57a85f5a4c 100644 --- a/pkg/services/ngalert/sender/notifier_ext.go +++ b/pkg/services/ngalert/sender/notifier_ext.go @@ -19,8 +19,17 @@ import ( "github.com/prometheus/prometheus/config" "github.com/prometheus/prometheus/model/labels" + + "github.com/grafana/grafana/pkg/util/httpclient" ) +func do(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) { + if client == nil { + client = httpclient.New() + } + return client.Do(req.WithContext(ctx)) +} + // ApplyConfig updates the status state as the new config requires. // Extension: add new parameter headers. func (n *Manager) ApplyConfig(conf *config.Config, headers map[string]http.Header) error { diff --git a/pkg/services/ngalert/sender/notifier_test.go b/pkg/services/ngalert/sender/notifier_test.go index d1aec6068ab..20caf7e9aea 100644 --- a/pkg/services/ngalert/sender/notifier_test.go +++ b/pkg/services/ngalert/sender/notifier_test.go @@ -1,6 +1,6 @@ // THIS FILE IS COPIED FROM UPSTREAM // -// https://github.com/prometheus/prometheus/blob/293f0c9185260165fd7dabbf8a9e8758b32abeae/notifier/notifier_test.go +// https://github.com/prometheus/prometheus/blob/bd5b2ea95ce14fba11db871b4068313408465207/notifier/notifier_test.go // // Copyright 2013 The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); @@ -40,15 +40,16 @@ import ( "go.uber.org/atomic" "gopkg.in/yaml.v2" - "github.com/prometheus/prometheus/discovery" - "github.com/prometheus/prometheus/config" + "github.com/prometheus/prometheus/discovery" _ "github.com/prometheus/prometheus/discovery/file" "github.com/prometheus/prometheus/discovery/targetgroup" "github.com/prometheus/prometheus/model/labels" "github.com/prometheus/prometheus/model/relabel" ) +const maxBatchSize = 256 + func TestPostPath(t *testing.T) { cases := []struct { in, out string @@ -411,7 +412,7 @@ func TestCustomDo(t *testing.T) { }, }, nil) - h.sendOne(context.Background(), nil, testURL, []byte(testBody), http.Header{}) + h.sendOne(context.Background(), nil, testURL, []byte(testBody), nil) require.True(t, received, "Expected to receive an alert, but didn't") } @@ -419,6 +420,7 @@ func TestCustomDo(t *testing.T) { func TestExternalLabels(t *testing.T) { h := NewManager(&Options{ QueueCapacity: 3 * maxBatchSize, + MaxBatchSize: maxBatchSize, ExternalLabels: labels.FromStrings("a", "b"), RelabelConfigs: []*relabel.Config{ { @@ -453,6 +455,7 @@ func TestExternalLabels(t *testing.T) { func TestHandlerRelabel(t *testing.T) { h := NewManager(&Options{ QueueCapacity: 3 * maxBatchSize, + MaxBatchSize: maxBatchSize, RelabelConfigs: []*relabel.Config{ { SourceLabels: model.LabelNames{"alertname"}, @@ -531,6 +534,7 @@ func TestHandlerQueuing(t *testing.T) { h := NewManager( &Options{ QueueCapacity: 3 * maxBatchSize, + MaxBatchSize: maxBatchSize, }, nil, ) @@ -660,7 +664,7 @@ alerting: require.NoError(t, err, "Unable to load YAML config.") require.Len(t, cfg.AlertingConfig.AlertmanagerConfigs, 1) - err = n.ApplyConfig(cfg, map[string]http.Header{}) + err = n.ApplyConfig(cfg, nil) require.NoError(t, err, "Error applying the config.") tgs := make(map[string][]*targetgroup.Group) @@ -711,7 +715,7 @@ alerting: require.NoError(t, err, "Unable to load YAML config.") require.Len(t, cfg.AlertingConfig.AlertmanagerConfigs, 1) - err = n.ApplyConfig(cfg, map[string]http.Header{}) + err = n.ApplyConfig(cfg, nil) require.NoError(t, err, "Error applying the config.") tgs := make(map[string][]*targetgroup.Group) @@ -1066,10 +1070,7 @@ func TestStop_DrainingEnabled(t *testing.T) { require.Equal(t, int64(2), alertsReceived.Load()) } -func TestIntegrationApplyConfig(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test in short mode") - } +func TestApplyConfig(t *testing.T) { targetURL := "alertmanager:9093" targetGroup := &targetgroup.Group{ Targets: []model.LabelSet{ @@ -1094,14 +1095,14 @@ alerting: require.Len(t, cfg.AlertingConfig.AlertmanagerConfigs, 1) // First, apply the config and reload. - require.NoError(t, n.ApplyConfig(cfg, map[string]http.Header{})) + require.NoError(t, n.ApplyConfig(cfg, nil)) tgs := map[string][]*targetgroup.Group{"config-0": {targetGroup}} n.reload(tgs) require.Len(t, n.Alertmanagers(), 1) require.Equal(t, alertmanagerURL, n.Alertmanagers()[0].String()) // Reapply the config. - require.NoError(t, n.ApplyConfig(cfg, map[string]http.Header{})) + require.NoError(t, n.ApplyConfig(cfg, nil)) // Ensure the known alertmanagers are not dropped. require.Len(t, n.Alertmanagers(), 1) require.Equal(t, alertmanagerURL, n.Alertmanagers()[0].String()) @@ -1119,7 +1120,7 @@ alerting: require.NoError(t, yaml.UnmarshalStrict([]byte(s), cfg)) require.Len(t, cfg.AlertingConfig.AlertmanagerConfigs, 2) - require.NoError(t, n.ApplyConfig(cfg, map[string]http.Header{})) + require.NoError(t, n.ApplyConfig(cfg, nil)) require.Len(t, n.Alertmanagers(), 1) // Ensure no unnecessary alertmanagers are injected. require.Empty(t, n.alertmanagers["config-0"].ams) @@ -1142,7 +1143,7 @@ alerting: require.NoError(t, yaml.UnmarshalStrict([]byte(s), cfg)) require.Len(t, cfg.AlertingConfig.AlertmanagerConfigs, 2) - require.NoError(t, n.ApplyConfig(cfg, map[string]http.Header{})) + require.NoError(t, n.ApplyConfig(cfg, nil)) require.Len(t, n.Alertmanagers(), 2) for cfgIdx := range 2 { ams := n.alertmanagers[fmt.Sprintf("config-%d", cfgIdx)].ams @@ -1169,6 +1170,6 @@ alerting: require.NoError(t, yaml.UnmarshalStrict([]byte(s), cfg)) require.Len(t, cfg.AlertingConfig.AlertmanagerConfigs, 2) - require.NoError(t, n.ApplyConfig(cfg, map[string]http.Header{})) + require.NoError(t, n.ApplyConfig(cfg, nil)) require.Empty(t, n.Alertmanagers()) } diff --git a/pkg/services/ngalert/sender/sender.go b/pkg/services/ngalert/sender/sender.go index e4bb96146ff..eb708a5d810 100644 --- a/pkg/services/ngalert/sender/sender.go +++ b/pkg/services/ngalert/sender/sender.go @@ -38,9 +38,9 @@ type ExternalAlertmanager struct { manager *Manager - sanitizeLabelSetFn func(lbls models.LabelSet) labels.Labels - sdCancel context.CancelFunc - sdManager *discovery.Manager + sdCancel context.CancelFunc + sdManager *discovery.Manager + options *ExternalAMOptions } type ExternalAMcfg struct { @@ -49,22 +49,27 @@ type ExternalAMcfg struct { Timeout time.Duration } -type Option func(*ExternalAlertmanager) +type ExternalAMOptions struct { + Options + sanitizeLabelSetFn func(lbls models.LabelSet) labels.Labels +} + +type Option func(*ExternalAMOptions) type doFunc func(context.Context, *http.Client, *http.Request) (*http.Response, error) // WithDoFunc receives a function to use when making HTTP requests from the Manager. func WithDoFunc(doFunc doFunc) Option { - return func(s *ExternalAlertmanager) { - s.manager.opts.Do = doFunc + return func(opts *ExternalAMOptions) { + opts.Do = doFunc } } // WithUTF8Labels skips sanitizing labels and annotations before sending alerts to the external Alertmanager(s). // It assumes UTF-8 label names are supported by the Alertmanager(s). func WithUTF8Labels() Option { - return func(s *ExternalAlertmanager) { - s.sanitizeLabelSetFn = func(lbls models.LabelSet) labels.Labels { + return func(opts *ExternalAMOptions) { + opts.sanitizeLabelSetFn = func(lbls models.LabelSet) labels.Labels { ls := make(labels.Labels, 0, len(lbls)) for k, v := range lbls { ls = append(ls, labels.Label{Name: k, Value: v}) @@ -74,6 +79,20 @@ func WithUTF8Labels() Option { } } +// WithMaxQueueCapacity sets the maximum capacity of the queue used by the sender. +func WithMaxQueueCapacity(capacity int) Option { + return func(opts *ExternalAMOptions) { + opts.QueueCapacity = capacity + } +} + +// WithMaxBatchSize sets the maximum batch size for sending alerts to the external Alertmanager(s). +func WithMaxBatchSize(size int) Option { + return func(opts *ExternalAMOptions) { + opts.MaxBatchSize = size + } +} + func (cfg *ExternalAMcfg) SHA256() string { return asSHA256([]string{cfg.headerString(), cfg.URL}) } @@ -99,16 +118,34 @@ func (cfg *ExternalAMcfg) headerString() string { func NewExternalAlertmanagerSender(l log.Logger, reg prometheus.Registerer, opts ...Option) (*ExternalAlertmanager, error) { sdCtx, sdCancel := context.WithCancel(context.Background()) + + options := &ExternalAMOptions{ + Options: Options{ + QueueCapacity: defaultMaxQueueCapacity, + MaxBatchSize: DefaultMaxBatchSize, + Registerer: reg, + DrainOnShutdown: defaultDrainOnShutdown, + }, + } + + for _, opt := range opts { + opt(options) + } + s := &ExternalAlertmanager{ logger: l, sdCancel: sdCancel, + options: options, + } + + if options.sanitizeLabelSetFn == nil { + options.sanitizeLabelSetFn = s.sanitizeLabelSet } - s.sanitizeLabelSetFn = s.sanitizeLabelSet s.manager = NewManager( // Injecting a new registry here means these metrics are not exported. // Once we fix the individual Alertmanager metrics we should fix this scenario too. - &Options{QueueCapacity: defaultMaxQueueCapacity, Registerer: reg, DrainOnShutdown: defaultDrainOnShutdown}, + &options.Options, toSlogLogger(s.logger), ) sdMetrics, err := discovery.CreateAndRegisterSDMetrics(prometheus.NewRegistry()) @@ -124,10 +161,6 @@ func NewExternalAlertmanagerSender(l log.Logger, reg prometheus.Registerer, opts return nil, errors.New("failed to create new discovery manager") } - for _, opt := range opts { - opt(s) - } - return s, nil } @@ -274,8 +307,8 @@ func buildNotifierConfig(alertmanagers []ExternalAMcfg) (*config.Config, map[str func (s *ExternalAlertmanager) alertToNotifierAlert(alert models.PostableAlert) *Alert { // Prometheus alertmanager has stricter rules for annotations/labels than grafana's internal alertmanager, so we sanitize invalid keys. return &Alert{ - Labels: s.sanitizeLabelSetFn(alert.Labels), - Annotations: s.sanitizeLabelSetFn(alert.Annotations), + Labels: s.options.sanitizeLabelSetFn(alert.Labels), + Annotations: s.options.sanitizeLabelSetFn(alert.Annotations), StartsAt: time.Time(alert.StartsAt), EndsAt: time.Time(alert.EndsAt), GeneratorURL: alert.GeneratorURL.String(), diff --git a/pkg/services/ngalert/sender/sender_test.go b/pkg/services/ngalert/sender/sender_test.go index b3a22bf51fc..1f51aeaf857 100644 --- a/pkg/services/ngalert/sender/sender_test.go +++ b/pkg/services/ngalert/sender/sender_test.go @@ -1,6 +1,7 @@ package sender import ( + "fmt" "testing" "github.com/prometheus/alertmanager/api/v2/models" @@ -108,3 +109,121 @@ func TestSanitizeLabelSet(t *testing.T) { }) } } + +func TestWithMaxQueueCapacity(t *testing.T) { + logger := log.NewNopLogger() + + t.Run("WithMaxQueueCapacity sets custom capacity", func(t *testing.T) { + customCapacity := 123 + am, err := NewExternalAlertmanagerSender(logger, prometheus.NewRegistry(), WithMaxQueueCapacity(customCapacity)) + require.NoError(t, err) + require.Equal(t, customCapacity, am.options.QueueCapacity) + }) + + t.Run("default capacity when option is not used", func(t *testing.T) { + am, err := NewExternalAlertmanagerSender(logger, prometheus.NewRegistry()) + require.NoError(t, err) + require.Equal(t, defaultMaxQueueCapacity, am.options.QueueCapacity) + }) + + t.Run("custom queue capacity is enforced", func(t *testing.T) { + customCapacity := 5 + am, err := NewExternalAlertmanagerSender(logger, prometheus.NewRegistry(), WithMaxQueueCapacity(customCapacity)) + require.NoError(t, err) + + totalAlerts := customCapacity + 3 + alerts := make([]*Alert, totalAlerts) + for i := range alerts { + alerts[i] = &Alert{ + Labels: labels.FromStrings("alertname", fmt.Sprintf("alert_%d", i)), + } + } + + am.manager.Send(alerts...) + + require.Equal(t, customCapacity, len(am.manager.queue)) + + for i, alert := range am.manager.queue { + expectedLabel := fmt.Sprintf("alert_%d", i+3) + require.Equal(t, expectedLabel, alert.Labels.Get("alertname")) + } + }) +} + +func TestWithMaxBatchSize(t *testing.T) { + logger := log.NewNopLogger() + + t.Run("WithMaxBatchSize sets custom batch size", func(t *testing.T) { + customBatchSize := 5 + am, err := NewExternalAlertmanagerSender(logger, prometheus.NewRegistry(), WithMaxBatchSize(customBatchSize)) + require.NoError(t, err) + require.Equal(t, customBatchSize, am.options.MaxBatchSize) + require.Equal(t, customBatchSize, am.manager.opts.MaxBatchSize) + }) + + t.Run("default batch size when option is not used", func(t *testing.T) { + am, err := NewExternalAlertmanagerSender(logger, prometheus.NewRegistry()) + require.NoError(t, err) + require.Equal(t, DefaultMaxBatchSize, am.options.MaxBatchSize) + require.Equal(t, DefaultMaxBatchSize, am.manager.opts.MaxBatchSize) + }) + + t.Run("custom batch size is enforced", func(t *testing.T) { + customBatchSize := 3 + am, err := NewExternalAlertmanagerSender(logger, prometheus.NewRegistry(), WithMaxBatchSize(customBatchSize)) + require.NoError(t, err) + + totalAlerts := customBatchSize * 2 + alerts := make([]*Alert, totalAlerts) + for i := range alerts { + alerts[i] = &Alert{ + Labels: labels.FromStrings("alertname", fmt.Sprintf("alert_%d", i)), + } + } + + am.manager.Send(alerts...) + require.Equal(t, totalAlerts, len(am.manager.queue)) + + firstBatch := am.manager.nextBatch() + require.Equal(t, customBatchSize, len(firstBatch)) + + secondBatch := am.manager.nextBatch() + require.Equal(t, customBatchSize, len(secondBatch)) + + emptyBatch := am.manager.nextBatch() + require.Equal(t, 0, len(emptyBatch), "No more alerts should remain") + }) +} + +func TestWithUTF8Labels(t *testing.T) { + logger := log.NewNopLogger() + + alert := models.PostableAlert{ + Annotations: models.LabelSet{ + "some-name": "test", + }, + Alert: models.Alert{ + Labels: models.LabelSet{ + "🔥": "fire", + }, + }, + } + + t.Run("WithUTF8Labels preserves UTF-8 characters", func(t *testing.T) { + am, err := NewExternalAlertmanagerSender(logger, prometheus.NewRegistry(), WithUTF8Labels()) + require.NoError(t, err) + + result := am.alertToNotifierAlert(alert) + require.Equal(t, "test", result.Annotations.Get("some-name")) + require.Equal(t, "fire", result.Labels.Get("🔥")) + }) + + t.Run("default sanitizes UTF-8 characters", func(t *testing.T) { + am, err := NewExternalAlertmanagerSender(logger, prometheus.NewRegistry()) + require.NoError(t, err) + + result := am.alertToNotifierAlert(alert) + require.Equal(t, "test", result.Annotations.Get("some_name")) + require.Equal(t, "fire", result.Labels.Get("_0x1f525")) + }) +} diff --git a/pkg/services/ngalert/state/manager.go b/pkg/services/ngalert/state/manager.go index a228f2bc151..571a5cd1aa7 100644 --- a/pkg/services/ngalert/state/manager.go +++ b/pkg/services/ngalert/state/manager.go @@ -587,13 +587,13 @@ func (st *Manager) processMissingSeriesStates(logger log.Logger, evaluatedAt tim // stateIsStale determines whether the evaluation state is considered stale. // A state is considered stale if the data has been missing for at least missingSeriesEvalsToResolve evaluation intervals. -func stateIsStale(evaluatedAt time.Time, lastEval time.Time, intervalSeconds int64, missingSeriesEvalsToResolve int) bool { +func stateIsStale(evaluatedAt time.Time, lastEval time.Time, intervalSeconds int64, missingSeriesEvalsToResolve int64) bool { // If the last evaluation time equals the current evaluation time, the state is not stale. if evaluatedAt.Equal(lastEval) { return false } - resolveIfMissingDuration := time.Duration(int64(missingSeriesEvalsToResolve)*intervalSeconds) * time.Second + resolveIfMissingDuration := time.Duration(missingSeriesEvalsToResolve*intervalSeconds) * time.Second // timeSinceLastEval >= resolveIfMissingDuration return evaluatedAt.Sub(lastEval) >= resolveIfMissingDuration diff --git a/pkg/services/ngalert/state/manager_private_test.go b/pkg/services/ngalert/state/manager_private_test.go index 471e747d88e..081f4843538 100644 --- a/pkg/services/ngalert/state/manager_private_test.go +++ b/pkg/services/ngalert/state/manager_private_test.go @@ -39,7 +39,7 @@ func TestStateIsStale(t *testing.T) { name string lastEvaluation time.Time expectedResult bool - missingSeriesEvalsToResolve int + missingSeriesEvalsToResolve int64 }{ { name: "false if last evaluation is now", diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index a512b5949b4..c3031528013 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -565,7 +565,7 @@ func TestIntegration_DeleteAlertRulesByUID(t *testing.T) { }) t.Run("should remove all version and insert one with empty rule_uid when DeletedRuleRetention is set", func(t *testing.T) { - orgID := int64(rand.IntN(1000)) + orgID := int64(rand.IntN(1000)) + 1 gen = gen.With(gen.WithOrgID(orgID)) // Create a new store to pass the custom bus to check the signal b := &fakeBus{} @@ -632,7 +632,7 @@ func TestIntegration_DeleteAlertRulesByUID(t *testing.T) { }) t.Run("should remove all versions and not keep history if DeletedRuleRetention = 0", func(t *testing.T) { - orgID := int64(rand.IntN(1000)) + orgID := int64(rand.IntN(1000)) + 1 gen = gen.With(gen.WithOrgID(orgID)) // Create a new store to pass the custom bus to check the signal b := &fakeBus{} @@ -685,7 +685,7 @@ func TestIntegration_DeleteAlertRulesByUID(t *testing.T) { }) t.Run("should remove all versions and not keep history if permanently is true", func(t *testing.T) { - orgID := int64(rand.IntN(1000)) + orgID := int64(rand.IntN(1000)) + 1 gen = gen.With(gen.WithOrgID(orgID)) // Create a new store to pass the custom bus to check the signal b := &fakeBus{} @@ -1781,7 +1781,7 @@ func TestIntegration_ListAlertRulesByGroup(t *testing.T) { t.Run("should return all rules when no limit passed", func(t *testing.T) { result, continueToken, err := store.ListAlertRulesByGroup(context.Background(), &models.ListAlertRulesByGroupQuery{ - OrgID: orgID, + ListAlertRulesQuery: models.ListAlertRulesQuery{OrgID: orgID}, }) require.NoError(t, err) require.Len(t, result, 50, "should return all rules when no limit is set") @@ -1792,8 +1792,8 @@ func TestIntegration_ListAlertRulesByGroup(t *testing.T) { // random number from 1 to totalGroups - 1 (to ensure we always receive less than totalGroups) groupLimit := rand.Int64N(int64(totalGroups)-1) + 1 result, continueToken, err := store.ListAlertRulesByGroup(context.Background(), &models.ListAlertRulesByGroupQuery{ - OrgID: orgID, - GroupLimit: groupLimit, + ListAlertRulesQuery: models.ListAlertRulesQuery{OrgID: orgID}, + GroupLimit: groupLimit, }) require.NoError(t, err) expectedRuleCount := groupLimit * int64(rulesPerGroup) @@ -1804,8 +1804,8 @@ func TestIntegration_ListAlertRulesByGroup(t *testing.T) { t.Run("pagination should all for continuation", func(t *testing.T) { groupLimit := int64(2) // fixed group limit for this test result, continueToken, err := store.ListAlertRulesByGroup(context.Background(), &models.ListAlertRulesByGroupQuery{ - OrgID: orgID, - GroupLimit: groupLimit, + ListAlertRulesQuery: models.ListAlertRulesQuery{OrgID: orgID}, + GroupLimit: groupLimit, }) require.NoError(t, err) require.Len(t, result, int(groupLimit*int64(rulesPerGroup)), "should return rules for the first two groups") @@ -1822,8 +1822,8 @@ func TestIntegration_ListAlertRulesByGroup(t *testing.T) { // Continue from previous, fetching the rest of the rules result, continueToken, err = store.ListAlertRulesByGroup(context.Background(), &models.ListAlertRulesByGroupQuery{ - OrgID: orgID, - GroupContinueToken: continueToken, + ListAlertRulesQuery: models.ListAlertRulesQuery{OrgID: orgID}, + GroupContinueToken: continueToken, }) require.NoError(t, err) resultRules = append(resultRules, result...) @@ -1887,8 +1887,8 @@ func Benchmark_ListAlertRules(b *testing.B) { b.Run(fmt.Sprintf("list %d groups paginated", groupLimit), func(b *testing.B) { for b.Loop() { _, _, err := store.ListAlertRulesByGroup(context.Background(), &models.ListAlertRulesByGroupQuery{ - OrgID: orgID, - GroupLimit: int64(groupLimit), + ListAlertRulesQuery: models.ListAlertRulesQuery{OrgID: orgID}, + GroupLimit: int64(groupLimit), }) if err != nil { b.Fatal(err) @@ -2071,7 +2071,7 @@ func TestIntegration_CleanUpDeletedAlertRules(t *testing.T) { store.FeatureToggles = featuremgmt.WithFeatures(featuremgmt.FlagAlertRuleRestore) gen := models.RuleGen - orgID := int64(rand.IntN(1000)) + orgID := int64(rand.IntN(1000)) + 1 gen = gen.With(gen.WithOrgID(orgID)) diff --git a/pkg/services/ngalert/store/models.go b/pkg/services/ngalert/store/models.go index 7cbea0c0f86..f7b6858a86e 100644 --- a/pkg/services/ngalert/store/models.go +++ b/pkg/services/ngalert/store/models.go @@ -30,7 +30,7 @@ type alertRule struct { IsPaused bool NotificationSettings string `xorm:"notification_settings"` Metadata string `xorm:"metadata"` - MissingSeriesEvalsToResolve *int `xorm:"missing_series_evals_to_resolve"` + MissingSeriesEvalsToResolve *int64 `xorm:"missing_series_evals_to_resolve"` } func (a alertRule) TableName() string { @@ -68,18 +68,17 @@ type alertRuleVersion struct { IsPaused bool NotificationSettings string `xorm:"notification_settings"` Metadata string `xorm:"metadata"` - MissingSeriesEvalsToResolve *int `xorm:"missing_series_evals_to_resolve"` + MissingSeriesEvalsToResolve *int64 `xorm:"missing_series_evals_to_resolve"` } // EqualSpec compares two alertRuleVersion objects for equality based on their specifications and returns true if they match. -// The comparison is very basic and can produce false-negative. Fields excluded: ID, ParentVersion, RestoredFrom, Version, Created and CreatedBy +// The comparison is very basic and can produce false-negative. Fields excluded: ID, ParentVersion, RestoredFrom, Version, Created, RuleGroupIndex and CreatedBy func (a alertRuleVersion) EqualSpec(b alertRuleVersion) bool { return a.RuleOrgID == b.RuleOrgID && a.RuleGUID == b.RuleGUID && a.RuleUID == b.RuleUID && a.RuleNamespaceUID == b.RuleNamespaceUID && a.RuleGroup == b.RuleGroup && - a.RuleGroupIndex == b.RuleGroupIndex && a.Title == b.Title && a.Condition == b.Condition && a.Data == b.Data && diff --git a/pkg/services/ngalert/store/models_test.go b/pkg/services/ngalert/store/models_test.go new file mode 100644 index 00000000000..e37598c3714 --- /dev/null +++ b/pkg/services/ngalert/store/models_test.go @@ -0,0 +1,223 @@ +package store + +import ( + "testing" + "time" + + "github.com/grafana/grafana/pkg/util" +) + +func TestAlertRuleVersion_EqualSpec(t *testing.T) { + baseVersion := alertRuleVersion{ + RuleOrgID: 1, + RuleGUID: "guid1", + RuleUID: "uid1", + RuleGroup: "group1", + Title: "Sample", + Condition: "cond", + Data: "data", + IntervalSeconds: 60, + Record: "record", + NoDataState: "state1", + ExecErrState: "state2", + For: time.Minute, + Annotations: `{ "test": "annotation" }`, + Labels: `{ "test": "label" }`, + IsPaused: true, + NotificationSettings: `{ some json object }`, + Metadata: `{ some json object }`, + MissingSeriesEvalsToResolve: util.Pointer(int64(10)), + } + + tests := []struct { + name string + a alertRuleVersion + b alertRuleVersion + expect bool + }{ + { + name: "equal versions", + a: baseVersion, + b: baseVersion, + expect: true, + }, + { + name: "different RuleOrgID", + a: baseVersion, + b: func() alertRuleVersion { v := baseVersion; v.RuleOrgID = 2; return v }(), + expect: false, + }, + { + name: "different RuleGUID", + a: baseVersion, + b: func() alertRuleVersion { v := baseVersion; v.RuleGUID = "guid2"; return v }(), + expect: false, + }, + { + name: "different Title", + a: baseVersion, + b: func() alertRuleVersion { v := baseVersion; v.Title = "Title2"; return v }(), + expect: false, + }, + { + name: "nil MissingSeriesEvalsToResolve in one version", + a: func() alertRuleVersion { v := baseVersion; v.MissingSeriesEvalsToResolve = nil; return v }(), + b: baseVersion, + expect: false, + }, + { + name: "both MissingSeriesEvalsToResolve nil", + a: func() alertRuleVersion { v := baseVersion; v.MissingSeriesEvalsToResolve = nil; return v }(), + b: func() alertRuleVersion { v := baseVersion; v.MissingSeriesEvalsToResolve = nil; return v }(), + expect: true, + }, + { + name: "different NotificationSettings", + a: baseVersion, + b: func() alertRuleVersion { v := baseVersion; v.NotificationSettings = "notify2"; return v }(), + expect: false, + }, + { + name: "complex mismatch", + a: baseVersion, + b: func() alertRuleVersion { + v := baseVersion + v.RuleOrgID = 2 + v.RuleGUID = "guid2" + v.Title = "Title2" + return v + }(), + expect: false, + }, + { + name: "different For durations", + a: baseVersion, + b: func() alertRuleVersion { v := baseVersion; v.For = 2 * time.Minute; return v }(), + expect: false, + }, + { + name: "exact match including bools and other types", + a: func() alertRuleVersion { + v := baseVersion + v.IsPaused = true + v.Metadata = "meta1" + v.Labels = "label1" + return v + }(), + b: func() alertRuleVersion { + v := baseVersion + v.IsPaused = true + v.Metadata = "meta1" + v.Labels = "label1" + return v + }(), + expect: true, + }, + { + name: "different ID but otherwise equal", + a: func() alertRuleVersion { + v := baseVersion + v.ID = 1 + return v + }(), + b: func() alertRuleVersion { + v := baseVersion + v.ID = 2 + return v + }(), + expect: true, + }, + { + name: "different ParentVersion but otherwise equal", + a: func() alertRuleVersion { + v := baseVersion + v.ParentVersion = 1 + return v + }(), + b: func() alertRuleVersion { + v := baseVersion + v.ParentVersion = 2 + return v + }(), + expect: true, + }, + { + name: "different RestoredFrom but otherwise equal", + a: func() alertRuleVersion { + v := baseVersion + v.RestoredFrom = 1 + return v + }(), + b: func() alertRuleVersion { + v := baseVersion + v.RestoredFrom = 2 + return v + }(), + expect: true, + }, + { + name: "different Version but otherwise equal", + a: func() alertRuleVersion { + v := baseVersion + v.Version = 1 + return v + }(), + b: func() alertRuleVersion { + v := baseVersion + v.Version = 2 + return v + }(), + expect: true, + }, + { + name: "different Created but otherwise equal", + a: func() alertRuleVersion { + v := baseVersion + v.Created = time.Now() + return v + }(), + b: func() alertRuleVersion { + v := baseVersion + v.Created = time.Now().Add(time.Hour) + return v + }(), + expect: true, + }, + { + name: "different RuleGroupIndex but otherwise equal", + a: func() alertRuleVersion { + v := baseVersion + v.RuleGroupIndex = 1 + return v + }(), + b: func() alertRuleVersion { + v := baseVersion + v.RuleGroupIndex = 2 + return v + }(), + expect: true, + }, + { + name: "different CreatedBy but otherwise equal", + a: func() alertRuleVersion { + v := baseVersion + v.CreatedBy = util.Pointer("user1") + return v + }(), + b: func() alertRuleVersion { + v := baseVersion + v.CreatedBy = util.Pointer("user2") + return v + }(), + expect: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.a.EqualSpec(tt.b); got != tt.expect { + t.Errorf("EqualSpec() = %v, expect %v", got, tt.expect) + } + }) + } +} diff --git a/pkg/services/org/orgimpl/store_test.go b/pkg/services/org/orgimpl/store_test.go index c2fe4c77bf8..3357282ab31 100644 --- a/pkg/services/org/orgimpl/store_test.go +++ b/pkg/services/org/orgimpl/store_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/configprovider" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" @@ -108,7 +109,8 @@ func TestIntegrationOrgDataAccess(t *testing.T) { City: "city", ZipCode: "zip", State: "state", - Country: "country"}, + Country: "country", + }, }) require.NoError(t, err) orga, err := orgStore.Get(context.Background(), ac2.ID) @@ -1039,7 +1041,9 @@ func TestIntegration_SQLStore_RemoveOrgUser(t *testing.T) { func createOrgAndUserSvc(t *testing.T, store db.DB, cfg *setting.Cfg) (org.Service, user.Service) { t.Helper() - quotaService := quotaimpl.ProvideService(store, cfg) + cfgProvider, err := configprovider.ProvideService(cfg) + require.NoError(t, err) + quotaService := quotaimpl.ProvideService(context.Background(), store, cfgProvider) orgService, err := ProvideService(store, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( diff --git a/pkg/services/pluginsintegration/loader/loader_test.go b/pkg/services/pluginsintegration/loader/loader_test.go index 4bf6f9df4c2..d9c102c48b4 100644 --- a/pkg/services/pluginsintegration/loader/loader_test.go +++ b/pkg/services/pluginsintegration/loader/loader_test.go @@ -121,11 +121,12 @@ func TestLoader_Load(t *testing.T) { Backend: true, QueryOptions: map[string]bool{"minInterval": true}, }, - Module: "core:plugin/cloudwatch", - BaseURL: "public/plugins/cloudwatch", - FS: mustNewStaticFSForTests(t, filepath.Join(corePluginDir(t), "app/plugins/datasource/cloudwatch")), - Signature: plugins.SignatureStatusInternal, - Class: plugins.ClassCore, + Module: "core:plugin/cloudwatch", + BaseURL: "public/plugins/cloudwatch", + FS: mustNewStaticFSForTests(t, filepath.Join(corePluginDir(t), "app/plugins/datasource/cloudwatch")), + Signature: plugins.SignatureStatusInternal, + Class: plugins.ClassCore, + Translations: map[string]string{}, }, }, }, @@ -220,6 +221,7 @@ func TestLoader_Load(t *testing.T) { Signature: "valid", SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", + Translations: map[string]string{}, }, }, }, @@ -265,11 +267,12 @@ func TestLoader_Load(t *testing.T) { Backend: true, State: plugins.ReleaseStateAlpha, }, - Class: plugins.ClassExternal, - Module: "public/plugins/test-datasource/module.js", - BaseURL: "public/plugins/test-datasource", - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "unsigned-datasource/plugin")), - Signature: "unsigned", + Class: plugins.ClassExternal, + Module: "public/plugins/test-datasource/module.js", + BaseURL: "public/plugins/test-datasource", + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "unsigned-datasource/plugin")), + Signature: "unsigned", + Translations: map[string]string{}, }, }, }, @@ -328,11 +331,12 @@ func TestLoader_Load(t *testing.T) { Backend: true, State: plugins.ReleaseStateAlpha, }, - Class: plugins.ClassExternal, - Module: "public/plugins/test-datasource/module.js", - BaseURL: "public/plugins/test-datasource", - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "unsigned-datasource/plugin")), - Signature: plugins.SignatureStatusUnsigned, + Class: plugins.ClassExternal, + Module: "public/plugins/test-datasource/module.js", + BaseURL: "public/plugins/test-datasource", + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "unsigned-datasource/plugin")), + Signature: plugins.SignatureStatusUnsigned, + Translations: map[string]string{}, }, }, }, @@ -453,6 +457,7 @@ func TestLoader_Load(t *testing.T) { Signature: plugins.SignatureStatusUnsigned, Module: "public/plugins/test-app/module.js", BaseURL: "public/plugins/test-app", + Translations: map[string]string{}, }, }, }, @@ -542,6 +547,7 @@ func TestLoader_Load_ExternalRegistration(t *testing.T) { ClientID: "client-id", ClientSecret: "secretz", }, + Translations: map[string]string{}, }, } @@ -636,11 +642,12 @@ func TestLoader_Load_CustomSource(t *testing.T) { ExtensionPoints: []plugins.ExtensionPoint{}, }, }, - FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "cdn/plugin")), - Class: plugins.ClassExternal, - Signature: plugins.SignatureStatusValid, - BaseURL: "https://cdn.example.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel", - Module: "https://cdn.example.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/module.js", + FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "cdn/plugin")), + Class: plugins.ClassExternal, + Signature: plugins.SignatureStatusValid, + BaseURL: "https://cdn.example.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel", + Module: "https://cdn.example.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/module.js", + Translations: map[string]string{}, }} l := newLoader(t, cfg, fakes.NewFakePluginRegistry(), fakes.NewFakeProcessManager(), fakes.NewFakeBackendProcessProvider(), newFakeErrorTracker()) @@ -727,6 +734,7 @@ func TestLoader_Load_MultiplePlugins(t *testing.T) { Signature: "valid", SignatureType: plugins.SignatureTypePrivate, SignatureOrg: "Will Browne", + Translations: map[string]string{}, }, }, pluginErrors: map[string]*plugins.Error{ @@ -845,6 +853,7 @@ func TestLoader_Load_RBACReady(t *testing.T) { SignatureOrg: "gabrielmabille", Module: "public/plugins/test-app/module.js", BaseURL: "public/plugins/test-app", + Translations: map[string]string{}, }, }, }, @@ -913,6 +922,7 @@ func TestLoader_Load_Signature_RootURL(t *testing.T) { SignatureOrg: "Will Browne", Module: "public/plugins/test-datasource/module.js", BaseURL: "public/plugins/test-datasource", + Translations: map[string]string{}, }, } @@ -999,6 +1009,7 @@ func TestLoader_Load_DuplicatePlugins(t *testing.T) { SignatureOrg: "Grafana Labs", Module: "public/plugins/test-app/module.js", BaseURL: "public/plugins/test-app", + Translations: map[string]string{}, }, } @@ -1089,6 +1100,7 @@ func TestLoader_Load_SkipUninitializedPlugins(t *testing.T) { SignatureOrg: "Grafana Labs", Module: "public/plugins/test-app/module.js", BaseURL: "public/plugins/test-app", + Translations: map[string]string{}, }, } @@ -1290,6 +1302,7 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", Class: plugins.ClassExternal, + Translations: map[string]string{}, } child := &plugins.Plugin{ @@ -1333,6 +1346,7 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", Class: plugins.ClassExternal, + Translations: map[string]string{}, } parent.Children = []*plugins.Plugin{child} @@ -1482,6 +1496,7 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", Class: plugins.ClassExternal, + Translations: map[string]string{}, } child := &plugins.Plugin{ @@ -1532,6 +1547,7 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { SignatureType: plugins.SignatureTypeGrafana, SignatureOrg: "Grafana Labs", Class: plugins.ClassExternal, + Translations: map[string]string{}, } parent.Children = []*plugins.Plugin{child} diff --git a/pkg/services/pluginsintegration/pluginconfig/config.go b/pkg/services/pluginsintegration/pluginconfig/config.go index ba57d743cfe..08e0fc460c3 100644 --- a/pkg/services/pluginsintegration/pluginconfig/config.go +++ b/pkg/services/pluginsintegration/pluginconfig/config.go @@ -32,7 +32,6 @@ func ProvidePluginManagementConfig(cfg *setting.Cfg, settingProvider setting.Pro config.Features{ SkipHostEnvVarsEnabled: features.IsEnabledGlobally(featuremgmt.FlagPluginsSkipHostEnvVars), SriChecksEnabled: features.IsEnabledGlobally(featuremgmt.FlagPluginsSriChecks), - LocalizationForPlugins: features.IsEnabledGlobally(featuremgmt.FlagLocalizationForPlugins), TempoAlertingEnabled: features.IsEnabledGlobally(featuremgmt.FlagTempoAlerting), PluginAssetProvider: features.IsEnabledGlobally(featuremgmt.FlagPluginAssetProvider), }, diff --git a/pkg/services/provisioning/alerting/rules_types.go b/pkg/services/provisioning/alerting/rules_types.go index 9c92b7255ac..b5b28072beb 100644 --- a/pkg/services/provisioning/alerting/rules_types.go +++ b/pkg/services/provisioning/alerting/rules_types.go @@ -62,21 +62,23 @@ func (ruleGroupV1 *AlertRuleGroupV1) MapToModel() (models.AlertRuleGroupWithFold } type AlertRuleV1 struct { - UID values.StringValue `json:"uid" yaml:"uid"` - Title values.StringValue `json:"title" yaml:"title"` - Condition values.StringValue `json:"condition" yaml:"condition"` - Data []QueryV1 `json:"data" yaml:"data"` - DasboardUID values.StringValue `json:"dasboardUid" yaml:"dasboardUid"` // TODO: Grandfathered typo support. TODO: This should be removed in V2. - DashboardUID values.StringValue `json:"dashboardUid" yaml:"dashboardUid"` - PanelID values.Int64Value `json:"panelId" yaml:"panelId"` - NoDataState values.StringValue `json:"noDataState" yaml:"noDataState"` - ExecErrState values.StringValue `json:"execErrState" yaml:"execErrState"` - For values.StringValue `json:"for" yaml:"for"` - Annotations values.StringMapValue `json:"annotations" yaml:"annotations"` - Labels values.StringMapValue `json:"labels" yaml:"labels"` - IsPaused values.BoolValue `json:"isPaused" yaml:"isPaused"` - NotificationSettings *NotificationSettingsV1 `json:"notification_settings" yaml:"notification_settings"` - Record *RecordV1 `json:"record" yaml:"record"` + UID values.StringValue `json:"uid" yaml:"uid"` + Title values.StringValue `json:"title" yaml:"title"` + Condition values.StringValue `json:"condition" yaml:"condition"` + Data []QueryV1 `json:"data" yaml:"data"` + DasboardUID values.StringValue `json:"dasboardUid" yaml:"dasboardUid"` // TODO: Grandfathered typo support. TODO: This should be removed in V2. + DashboardUID values.StringValue `json:"dashboardUid" yaml:"dashboardUid"` + PanelID values.Int64Value `json:"panelId" yaml:"panelId"` + NoDataState values.StringValue `json:"noDataState" yaml:"noDataState"` + ExecErrState values.StringValue `json:"execErrState" yaml:"execErrState"` + For values.StringValue `json:"for" yaml:"for"` + KeepFiringFor values.StringValue `json:"keepFiringFor" yaml:"keepFiringFor"` + MissingSeriesEvalsToResolve values.Int64Value `json:"missing_series_evals_to_resolve" yaml:"missing_series_evals_to_resolve"` + Annotations values.StringMapValue `json:"annotations" yaml:"annotations"` + Labels values.StringMapValue `json:"labels" yaml:"labels"` + IsPaused values.BoolValue `json:"isPaused" yaml:"isPaused"` + NotificationSettings *NotificationSettingsV1 `json:"notification_settings" yaml:"notification_settings"` + Record *RecordV1 `json:"record" yaml:"record"` } func withFallback(value, fallback string) *string { @@ -108,6 +110,24 @@ func (rule *AlertRuleV1) mapToModel(orgID int64) (models.AlertRule, error) { } alertRule.For = time.Duration(duration) + keepFiringForDuration := model.Duration(0) + if rule.KeepFiringFor.Value() != "" { + var err error + keepFiringForDuration, err = model.ParseDuration(rule.KeepFiringFor.Value()) + if err != nil { + return models.AlertRule{}, fmt.Errorf("rule '%s' failed to parse 'keepFiringFor' field: %w", alertRule.Title, err) + } + } + alertRule.KeepFiringFor = time.Duration(keepFiringForDuration) + + if rule.MissingSeriesEvalsToResolve.Raw != "" { + missingSeriesEvalsToResolve := rule.MissingSeriesEvalsToResolve.Value() + if missingSeriesEvalsToResolve < 0 { + return models.AlertRule{}, fmt.Errorf("rule '%s' failed to parse 'missing_series_evals_to_resolve' field: cannot be negative", alertRule.Title) + } + alertRule.MissingSeriesEvalsToResolve = &missingSeriesEvalsToResolve + } + dasboardUID := rule.DasboardUID.Value() dashboardUID := rule.DashboardUID.Value() alertRule.DashboardUID = withFallback(dashboardUID, dasboardUID) // Use correct spelling over supported typo. diff --git a/pkg/services/query/query.go b/pkg/services/query/query.go index 7aaafdea105..0c4dda928b5 100644 --- a/pkg/services/query/query.go +++ b/pkg/services/query/query.go @@ -70,6 +70,9 @@ func ProvideService( type Service interface { Run(ctx context.Context) error QueryData(ctx context.Context, user identity.Requester, skipDSCache bool, reqDTO dtos.MetricRequest) (*backend.QueryDataResponse, error) + + // this is more "forward compatible", for example supports per-query time ranges + QueryDataNew(ctx context.Context, user identity.Requester, skipDSCache bool, reqDTO dtos.MetricRequest) (*backend.QueryDataResponse, error) } // Gives us compile time error if the service does not adhere to the contract of the interface @@ -86,7 +89,6 @@ type ServiceImpl struct { concurrentQueryLimit int mtDatasourceClientBuilder mtdsclient.MTDatasourceClientBuilder headers map[string]string - supportLocalTimeRange bool } // Run ServiceImpl. @@ -96,7 +98,7 @@ func (s *ServiceImpl) Run(ctx context.Context) error { } // QueryData processes queries and returns query responses. It handles queries to single or mixed datasources, as well as expressions. -func (s *ServiceImpl) QueryData(ctx context.Context, user identity.Requester, skipDSCache bool, reqDTO dtos.MetricRequest) (*backend.QueryDataResponse, error) { +func (s *ServiceImpl) queryData(ctx context.Context, user identity.Requester, skipDSCache bool, reqDTO dtos.MetricRequest, supportLocaltimeRange bool) (*backend.QueryDataResponse, error) { fromAlert := false for header, val := range s.headers { if header == models.FromAlertHeaderName && val == "true" { @@ -104,7 +106,7 @@ func (s *ServiceImpl) QueryData(ctx context.Context, user identity.Requester, sk } } // Parse the request into parsed queries grouped by datasource uid - parsedReq, err := s.parseMetricRequest(ctx, user, skipDSCache, reqDTO, s.supportLocalTimeRange) + parsedReq, err := s.parseMetricRequest(ctx, user, skipDSCache, reqDTO, supportLocaltimeRange) if err != nil { return nil, err } @@ -121,6 +123,14 @@ func (s *ServiceImpl) QueryData(ctx context.Context, user identity.Requester, sk return s.executeConcurrentQueries(ctx, user, skipDSCache, reqDTO, parsedReq.parsedQueries) } +func (s *ServiceImpl) QueryData(ctx context.Context, user identity.Requester, skipDSCache bool, reqDTO dtos.MetricRequest) (*backend.QueryDataResponse, error) { + return s.queryData(ctx, user, skipDSCache, reqDTO, false) +} + +func (s *ServiceImpl) QueryDataNew(ctx context.Context, user identity.Requester, skipDSCache bool, reqDTO dtos.MetricRequest) (*backend.QueryDataResponse, error) { + return s.queryData(ctx, user, skipDSCache, reqDTO, true) +} + // splitResponse contains the results of a concurrent data source query - the response and any headers type splitResponse struct { responses backend.Responses @@ -223,9 +233,8 @@ func QueryData(ctx context.Context, log log.Logger, dscache datasources.CacheSer mtDatasourceClientBuilder: mtDatasourceClientBuilder, headers: headers, concurrentQueryLimit: 16, // TODO: make it configurable - supportLocalTimeRange: true, } - return s.QueryData(ctx, nil, false, reqDTO) + return s.QueryDataNew(ctx, nil, false, reqDTO) } // handleExpressions handles queries when there is an expression. @@ -293,7 +302,11 @@ func (s *ServiceImpl) handleQuerySingleDatasource(ctx context.Context, user iden req.Queries = append(req.Queries, q.query) } - mtDsClient, ok := s.mtDatasourceClientBuilder.BuildClient(ds.Type, ds.UID) + mtDsClient, ok, err := s.mtDatasourceClientBuilder.BuildClient(ds.Type, ds.UID) + if err != nil { + return nil, err + } + if !ok { // single tenant flow pCtx, err := s.pCtxProvider.GetWithDataSource(ctx, ds.Type, user, ds) if err != nil { @@ -311,16 +324,21 @@ func (s *ServiceImpl) handleQuerySingleDatasource(ctx context.Context, user iden } } -func getTimeRange(query *simplejson.Json, globalFrom string, globalTo string) gtime.TimeRange { - from := query.Get("timeRange").Get("from").MustString("") - to := query.Get("timeRange").Get("to").MustString("") - - if (from == "") && (to == "") { - from = globalFrom - to = globalTo +func getTimeRange(query *simplejson.Json, globalFrom string, globalTo string) (string, string, error) { + tr, ok := query.CheckGet("timeRange") + if !ok { // timeRange json node does not exist, use global from/to + return globalFrom, globalTo, nil + } + from, err := tr.Get("from").String() + if err != nil { + return "", "", errors.New("time range: field 'from' is missing or invalid") + } + to, err := tr.Get("to").String() + if err != nil { + return "", "", errors.New("time range: field 'to' is missing or invalid") } - return gtime.NewTimeRange(from, to) + return from, to, nil } // parseRequest parses a request into parsed queries grouped by datasource uid @@ -359,7 +377,11 @@ func (s *ServiceImpl) parseMetricRequest(ctx context.Context, user identity.Requ var timeRange gtime.TimeRange if supportLocalTimeRange { - timeRange = getTimeRange(query, reqDTO.From, reqDTO.To) + from, to, err := getTimeRange(query, reqDTO.From, reqDTO.To) + if err != nil { + return nil, err + } + timeRange = gtime.NewTimeRange(from, to) } else { timeRange = gtime.NewTimeRange(reqDTO.From, reqDTO.To) } diff --git a/pkg/services/query/query_service_mock.go b/pkg/services/query/query_service_mock.go index 800c822290d..22c08118941 100644 --- a/pkg/services/query/query_service_mock.go +++ b/pkg/services/query/query_service_mock.go @@ -49,6 +49,36 @@ func (_m *FakeQueryService) QueryData(ctx context.Context, user identity.Request return r0, r1 } +// QueryDataNew provides a mock function with given fields: ctx, user, skipDSCache, reqDTO +func (_m *FakeQueryService) QueryDataNew(ctx context.Context, user identity.Requester, skipDSCache bool, reqDTO dtos.MetricRequest) (*backend.QueryDataResponse, error) { + ret := _m.Called(ctx, user, skipDSCache, reqDTO) + + if len(ret) == 0 { + panic("no return value specified for QueryDataNew") + } + + var r0 *backend.QueryDataResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, identity.Requester, bool, dtos.MetricRequest) (*backend.QueryDataResponse, error)); ok { + return rf(ctx, user, skipDSCache, reqDTO) + } + if rf, ok := ret.Get(0).(func(context.Context, identity.Requester, bool, dtos.MetricRequest) *backend.QueryDataResponse); ok { + r0 = rf(ctx, user, skipDSCache, reqDTO) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*backend.QueryDataResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, identity.Requester, bool, dtos.MetricRequest) error); ok { + r1 = rf(ctx, user, skipDSCache, reqDTO) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Run provides a mock function with given fields: ctx func (_m *FakeQueryService) Run(ctx context.Context) error { ret := _m.Called(ctx) diff --git a/pkg/services/query/query_test.go b/pkg/services/query/query_test.go index 7187312da50..bf2737eab39 100644 --- a/pkg/services/query/query_test.go +++ b/pkg/services/query/query_test.go @@ -391,7 +391,7 @@ func TestIntegrationParseMetricRequest(t *testing.T) { verifyTimestamps(parsedReq2, int64(0), int64(0), int64(0), int64(0)) }) - t.Run("Test a datasource query with malformed local time range", func(t *testing.T) { + t.Run("Test a datasource query with local time range, malformed to-value", func(t *testing.T) { tc := setup(t, false, nil) mr := metricRequestWithQueries(t, `{ "refId": "A", @@ -401,31 +401,69 @@ func TestIntegrationParseMetricRequest(t *testing.T) { }, "timeRange": { "from": "1753944618000", - "not_to": "1753944619000" + "to": 1753944619000 } - }`, `{ - "refId": "B", + }`) + mr.From = "" + mr.To = "" + _, err := tc.queryService.parseMetricRequest(context.Background(), tc.signedInUser, true, mr, true) + require.Error(t, err) + require.ErrorContains(t, err, "'to'") + }) + t.Run("Test a datasource query with local time range, missing to-value", func(t *testing.T) { + tc := setup(t, false, nil) + mr := metricRequestWithQueries(t, `{ + "refId": "A", "datasource": { "uid": "gIEkMvIVz", "type": "postgres" }, - "timeRange": 42 + "timeRange": { + "from": "1753944618000" + } }`) mr.From = "" mr.To = "" - parsedReq, err := tc.queryService.parseMetricRequest(context.Background(), tc.signedInUser, true, mr, true) - require.NoError(t, err) - require.NotNil(t, parsedReq) - assert.Len(t, parsedReq.parsedQueries, 1) - assert.Contains(t, parsedReq.parsedQueries, "gIEkMvIVz") - queries := parsedReq.getFlattenedQueries() - assert.Len(t, queries, 2) - - assert.Equal(t, int64(1753944618000), queries[0].query.TimeRange.From.UnixMilli()) - assert.Equal(t, int64(0), queries[0].query.TimeRange.To.UnixMilli()) - - assert.Equal(t, int64(0), queries[1].query.TimeRange.From.UnixMilli()) - assert.Equal(t, int64(0), queries[1].query.TimeRange.To.UnixMilli()) + _, err := tc.queryService.parseMetricRequest(context.Background(), tc.signedInUser, true, mr, true) + require.Error(t, err) + require.ErrorContains(t, err, "'to'") + }) + t.Run("Test a datasource query with local time range, malformed from-value", func(t *testing.T) { + tc := setup(t, false, nil) + mr := metricRequestWithQueries(t, `{ + "refId": "A", + "datasource": { + "uid": "gIEkMvIVz", + "type": "postgres" + }, + "timeRange": { + "from": 1753944618000, + "to": "1753944619000" + } + }`) + mr.From = "" + mr.To = "" + _, err := tc.queryService.parseMetricRequest(context.Background(), tc.signedInUser, true, mr, true) + require.Error(t, err) + require.ErrorContains(t, err, "'from'") + }) + t.Run("Test a datasource query with local time range, missing from-value", func(t *testing.T) { + tc := setup(t, false, nil) + mr := metricRequestWithQueries(t, `{ + "refId": "A", + "datasource": { + "uid": "gIEkMvIVz", + "type": "postgres" + }, + "timeRange": { + "to": "1753944619000" + } + }`) + mr.From = "" + mr.To = "" + _, err := tc.queryService.parseMetricRequest(context.Background(), tc.signedInUser, true, mr, true) + require.Error(t, err) + require.ErrorContains(t, err, "'from'") }) } diff --git a/pkg/services/quota/quotaimpl/quota.go b/pkg/services/quota/quotaimpl/quota.go index 0d720653630..8b0da1f71bf 100644 --- a/pkg/services/quota/quotaimpl/quota.go +++ b/pkg/services/quota/quotaimpl/quota.go @@ -7,11 +7,11 @@ import ( "go.opentelemetry.io/otel" "golang.org/x/sync/errgroup" + "github.com/grafana/grafana/pkg/configprovider" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/quota" - "github.com/grafana/grafana/pkg/setting" ) // tracer is the global tracer for the quota service. Tracer pulls the globally @@ -46,8 +46,8 @@ func (s *serviceDisabled) RegisterQuotaReporter(e *quota.NewUsageReporter) error type service struct { store store - Cfg *setting.Cfg - Logger log.Logger + cfg configprovider.ConfigProvider + logger log.Logger mutex sync.RWMutex reporters map[quota.TargetSrv]quota.UsageReporterFunc @@ -57,26 +57,26 @@ type service struct { targetToSrv *quota.TargetToSrv } -func ProvideService(db db.DB, cfg *setting.Cfg) quota.Service { +func ProvideService(ctx context.Context, db db.DB, configProvider configprovider.ConfigProvider) quota.Service { logger := log.New("quota_service") s := service{ store: &sqlStore{db: db, logger: logger}, - Cfg: cfg, - Logger: logger, + cfg: configProvider, + logger: logger, reporters: make(map[quota.TargetSrv]quota.UsageReporterFunc), defaultLimits: "a.Map{}, targetToSrv: quota.NewTargetToSrv(), } - if s.IsDisabled() { + if s.IsDisabled(ctx) { return &serviceDisabled{} } return &s } -func (s *service) IsDisabled() bool { - return !s.Cfg.Quota.Enabled +func (s *service) IsDisabled(ctx context.Context) bool { + return !s.cfg.Get(ctx).Quota.Enabled } // QuotaReached checks that quota is reached for a target. Runs CheckQuotaReached and take context and scope parameters from the request context diff --git a/pkg/services/quota/quotaimpl/quota_test.go b/pkg/services/quota/quotaimpl/quota_test.go index bf89d4aec5d..c7f4e6d0c60 100644 --- a/pkg/services/quota/quotaimpl/quota_test.go +++ b/pkg/services/quota/quotaimpl/quota_test.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/configprovider" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/kvstore" @@ -105,7 +106,9 @@ func TestIntegrationQuotaCommandsAndQueries(t *testing.T) { } b := bus.ProvideBus(tracing.InitializeTracerForTest()) - quotaService := ProvideService(sqlStore, cfg) + cfgProvider, err := configprovider.ProvideService(cfg) + require.NoError(t, err) + quotaService := ProvideService(context.Background(), sqlStore, cfgProvider) orgService, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) require.NoError(t, err) userService, err := userimpl.ProvideService( @@ -247,7 +250,9 @@ func TestIntegrationQuotaCommandsAndQueries(t *testing.T) { }() cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{Enabled: util.Pointer(false)} - quotaSrv := ProvideService(sqlStore, cfg) + cfgProvider, err := configprovider.ProvideService(cfg) + require.NoError(t, err) + quotaSrv := ProvideService(context.Background(), sqlStore, cfgProvider) q, err := getQuotaBySrvTargetScope(t, quotaSrv, ngalertmodels.QuotaTargetSrv, ngalertmodels.QuotaTarget, quota.OrgScope, "a.ScopeParameters{OrgID: o.ID}) require.NoError(t, err) diff --git a/pkg/services/serviceaccounts/tests/common.go b/pkg/services/serviceaccounts/tests/common.go index c88155232ee..f2a50e732c4 100644 --- a/pkg/services/serviceaccounts/tests/common.go +++ b/pkg/services/serviceaccounts/tests/common.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/configprovider" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/apikey" @@ -43,7 +44,9 @@ func SetupUserServiceAccount(t *testing.T, db db.DB, cfg *setting.Cfg, testUser role = testUser.Role } - quotaService := quotaimpl.ProvideService(db, cfg) + cfgProvider, err := configprovider.ProvideService(cfg) + require.NoError(t, err) + quotaService := quotaimpl.ProvideService(context.Background(), db, cfgProvider) orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( @@ -122,7 +125,9 @@ func SetupApiKeys(t *testing.T, store db.DB, cfg *setting.Cfg, testKeys []TestAp func SetupUsersServiceAccounts(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, testUsers []TestUser) (users []user.User, orgID int64) { role := string(org.RoleNone) - quotaService := quotaimpl.ProvideService(sqlStore, cfg) + cfgProvider, err := configprovider.ProvideService(cfg) + require.NoError(t, err) + quotaService := quotaimpl.ProvideService(context.Background(), sqlStore, cfgProvider) orgService, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( diff --git a/pkg/services/shorturls/models.go b/pkg/services/shorturls/models.go index ffa38d91390..8b284fa13a7 100644 --- a/pkg/services/shorturls/models.go +++ b/pkg/services/shorturls/models.go @@ -12,19 +12,21 @@ var ( ErrShortURLAbsolutePath = errutil.ValidationFailed("shorturl.absolute-path", errutil.WithPublicMessage("Path should be relative")) ErrShortURLInvalidPath = errutil.ValidationFailed("shorturl.invalid-path", errutil.WithPublicMessage("Invalid short URL path")) ErrShortURLInternal = errutil.Internal("shorturl.internal") + ErrShortURLConflict = errutil.Conflict("shorturl.conflict") ) type ShortUrl struct { - Id int64 - OrgId int64 - Uid string - Path string - CreatedBy int64 - CreatedAt int64 - LastSeenAt int64 + Id int64 `json:"-"` + OrgId int64 `json:"-"` + Uid string `json:"uid"` + Path string `json:"path"` + CreatedBy int64 `json:"-"` + CreatedAt int64 `json:"-"` + LastSeenAt int64 `json:"lastSeenAt"` } type DeleteShortUrlCommand struct { + Uid string OlderThan time.Time NumDeleted int64 diff --git a/pkg/services/shorturls/shorturl.go b/pkg/services/shorturls/shorturl.go index 5472b8e6e57..14852e9432a 100644 --- a/pkg/services/shorturls/shorturl.go +++ b/pkg/services/shorturls/shorturl.go @@ -3,12 +3,14 @@ package shorturls import ( "context" + "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/services/user" ) type Service interface { GetShortURLByUID(ctx context.Context, user *user.SignedInUser, uid string) (*ShortUrl, error) - CreateShortURL(ctx context.Context, user *user.SignedInUser, path string) (*ShortUrl, error) + CreateShortURL(ctx context.Context, user *user.SignedInUser, cmd *dtos.CreateShortURLCmd) (*ShortUrl, error) UpdateLastSeenAt(ctx context.Context, shortURL *ShortUrl) error DeleteStaleShortURLs(ctx context.Context, cmd *DeleteShortUrlCommand) error + ConvertShortURLToDTO(shortURL *ShortUrl, appURL string) *dtos.ShortURL } diff --git a/pkg/services/shorturls/shorturlimpl/shorturl.go b/pkg/services/shorturls/shorturlimpl/shorturl.go index 5269b9683e5..9f83ef70d66 100644 --- a/pkg/services/shorturls/shorturlimpl/shorturl.go +++ b/pkg/services/shorturls/shorturlimpl/shorturl.go @@ -2,13 +2,16 @@ package shorturlimpl import ( "context" + "fmt" "path" "strings" "time" + "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/shorturls" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/util" "github.com/teris-io/shortid" ) @@ -34,8 +37,8 @@ func (s ShortURLService) UpdateLastSeenAt(ctx context.Context, shortURL *shortur return s.SQLStore.Update(ctx, shortURL) } -func (s ShortURLService) CreateShortURL(ctx context.Context, user *user.SignedInUser, relPath string) (*shorturls.ShortUrl, error) { - relPath = strings.TrimSpace(relPath) +func (s ShortURLService) CreateShortURL(ctx context.Context, user *user.SignedInUser, cmd *dtos.CreateShortURLCmd) (*shorturls.ShortUrl, error) { + relPath := strings.TrimSpace(cmd.Path) if path.IsAbs(relPath) { return nil, shorturls.ErrShortURLAbsolutePath.Errorf("expected relative path: %s", relPath) @@ -44,9 +47,30 @@ func (s ShortURLService) CreateShortURL(ctx context.Context, user *user.SignedIn return nil, shorturls.ErrShortURLInvalidPath.Errorf("path cannot contain '../': %s", relPath) } - uid, err := shortid.Generate() - if err != nil { - return nil, shorturls.ErrShortURLInternal.Errorf("failed to generate uid: %w", err) + uid := cmd.UID + if uid == "" { + var err error + uid, err = shortid.Generate() + if err != nil { + return nil, shorturls.ErrShortURLInternal.Errorf("failed to generate uid: %w", err) + } + } else { + // Ensure the UID is valid + if !util.IsValidShortUID(uid) { + return nil, shorturls.ErrShortURLBadRequest.Errorf("invalid UID: %s", uid) + } + + // Check if the UID already exists + existingShortURL, err := s.SQLStore.Get(ctx, user, uid) + if err != nil { + if !shorturls.ErrShortURLNotFound.Is(err) { + return nil, shorturls.ErrShortURLInternal.Errorf("failed to check existing short URL: %w", err) + } + } + if existingShortURL != nil { + // If the UID already exists, we return an error + return nil, shorturls.ErrShortURLConflict.Errorf("short URL with UID '%s' already exists", uid) + } } now := time.Now().Unix() @@ -68,3 +92,12 @@ func (s ShortURLService) CreateShortURL(ctx context.Context, user *user.SignedIn func (s ShortURLService) DeleteStaleShortURLs(ctx context.Context, cmd *shorturls.DeleteShortUrlCommand) error { return s.SQLStore.Delete(ctx, cmd) } + +func (s ShortURLService) ConvertShortURLToDTO(shortURL *shorturls.ShortUrl, appURL string) *dtos.ShortURL { + url := fmt.Sprintf("%s/goto/%s?orgId=%d", strings.TrimSuffix(appURL, "/"), shortURL.Uid, shortURL.OrgId) + + return &dtos.ShortURL{ + UID: shortURL.Uid, + URL: url, + } +} diff --git a/pkg/services/shorturls/shorturlimpl/shorturl_test.go b/pkg/services/shorturls/shorturlimpl/shorturl_test.go index 71a26545aa3..f733f65173c 100644 --- a/pkg/services/shorturls/shorturlimpl/shorturl_test.go +++ b/pkg/services/shorturls/shorturlimpl/shorturl_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/shorturls" "github.com/grafana/grafana/pkg/services/user" @@ -25,11 +26,13 @@ func TestIntegrationShortURLService(t *testing.T) { store := db.InitTestDB(t) t.Run("User can create and read short URLs", func(t *testing.T) { - const refPath = "mock/path?test=true" + cmd := &dtos.CreateShortURLCmd{ + Path: "mock/path?test=true", + } service := ShortURLService{SQLStore: &sqlStore{db: store}} - newShortURL, err := service.CreateShortURL(context.Background(), user, refPath) + newShortURL, err := service.CreateShortURL(context.Background(), user, cmd) require.NoError(t, err) require.NotNil(t, newShortURL) require.NotEmpty(t, newShortURL.Uid) @@ -37,7 +40,7 @@ func TestIntegrationShortURLService(t *testing.T) { existingShortURL, err := service.GetShortURLByUID(context.Background(), user, newShortURL.Uid) require.NoError(t, err) require.NotNil(t, existingShortURL) - require.Equal(t, refPath, existingShortURL.Path) + require.Equal(t, cmd.Path, existingShortURL.Path) t.Run("and update last seen at", func(t *testing.T) { origGetTime := getTime @@ -59,7 +62,7 @@ func TestIntegrationShortURLService(t *testing.T) { }) t.Run("and stale short urls can be deleted", func(t *testing.T) { - staleShortURL, err := service.CreateShortURL(context.Background(), user, refPath) + staleShortURL, err := service.CreateShortURL(context.Background(), user, cmd) require.NoError(t, err) require.NotNil(t, staleShortURL) require.NotEmpty(t, staleShortURL.Uid) @@ -100,18 +103,25 @@ func TestIntegrationShortURLService(t *testing.T) { ctx := context.Background() - absolutePath := "/path?test=true" - newShortURL, err := service.CreateShortURL(ctx, user, absolutePath) + cmd := &dtos.CreateShortURLCmd{ + Path: "/path?test=true", + } + + newShortURL, err := service.CreateShortURL(ctx, user, cmd) require.ErrorIs(t, err, shorturls.ErrShortURLAbsolutePath) require.Nil(t, newShortURL) - relativePath := "path/../test?test=true" - newShortURL, err = service.CreateShortURL(ctx, user, relativePath) + cmd2 := &dtos.CreateShortURLCmd{ + Path: "path/../test?test=true", + } + newShortURL, err = service.CreateShortURL(ctx, user, cmd2) require.ErrorIs(t, err, shorturls.ErrShortURLInvalidPath) require.Nil(t, newShortURL) - relativePath = "../path/test?test=true" - newShortURL, err = service.CreateShortURL(ctx, user, relativePath) + cmd3 := &dtos.CreateShortURLCmd{ + Path: "../path/test?test=true", + } + newShortURL, err = service.CreateShortURL(ctx, user, cmd3) require.ErrorIs(t, err, shorturls.ErrShortURLInvalidPath) require.Nil(t, newShortURL) }) @@ -121,14 +131,15 @@ func TestIntegrationShortURLService(t *testing.T) { ctx := context.Background() - const refPath = "mock/path?test=true" - - newShortURL1, err := service.CreateShortURL(ctx, user, refPath) + cmd := &dtos.CreateShortURLCmd{ + Path: "mock/path?test=true", + } + newShortURL1, err := service.CreateShortURL(ctx, user, cmd) require.NoError(t, err) require.NotNil(t, newShortURL1) require.NotEmpty(t, newShortURL1.Uid) - newShortURL2, err := service.CreateShortURL(ctx, user, refPath) + newShortURL2, err := service.CreateShortURL(ctx, user, cmd) require.NoError(t, err) require.NotNil(t, newShortURL2) require.NotEmpty(t, newShortURL2.Uid) @@ -136,4 +147,38 @@ func TestIntegrationShortURLService(t *testing.T) { require.NotEqual(t, newShortURL1.Uid, newShortURL2.Uid) require.Equal(t, newShortURL1.Path, newShortURL2.Path) }) + + t.Run("Create URL providing the UID", func(t *testing.T) { + service := ShortURLService{SQLStore: &sqlStore{db: store}} + + ctx := context.Background() + + cmd := &dtos.CreateShortURLCmd{ + Path: "mock/path?test=true", + UID: "custom-uid", + } + newShortURL1, err := service.CreateShortURL(ctx, user, cmd) + require.NoError(t, err) + require.NotNil(t, newShortURL1) + require.Equal(t, cmd.UID, newShortURL1.Uid) + }) + + t.Run("Create URL providing an existing UID should fail", func(t *testing.T) { + service := ShortURLService{SQLStore: &sqlStore{db: store}} + + ctx := context.Background() + + cmd := &dtos.CreateShortURLCmd{ + Path: "mock/path?test=true", + UID: "custom-uid-2", + } + newShortURL1, err := service.CreateShortURL(ctx, user, cmd) + require.NoError(t, err) + require.NotNil(t, newShortURL1) + require.Equal(t, cmd.UID, newShortURL1.Uid) + + newShortURL2, err := service.CreateShortURL(ctx, user, cmd) + require.ErrorIs(t, err, shorturls.ErrShortURLConflict) + require.Nil(t, newShortURL2) + }) } diff --git a/pkg/services/shorturls/shorturlimpl/store.go b/pkg/services/shorturls/shorturlimpl/store.go index 31600394512..0fe54fc677d 100644 --- a/pkg/services/shorturls/shorturlimpl/store.go +++ b/pkg/services/shorturls/shorturlimpl/store.go @@ -58,6 +58,21 @@ func (s sqlStore) Insert(ctx context.Context, shortURL *shorturls.ShortUrl) erro } func (s sqlStore) Delete(ctx context.Context, cmd *shorturls.DeleteShortUrlCommand) error { + // If a UID is provided, delete that specific short URL + if cmd.Uid != "" { + return s.db.WithTransactionalDbSession(ctx, func(session *db.Session) error { + var rawSql = "DELETE FROM short_url WHERE uid = ?" + + if result, err := session.Exec(rawSql, cmd.Uid); err != nil { + return err + } else if cmd.NumDeleted, err = result.RowsAffected(); err != nil { + return err + } + return nil + }) + } + + // Otherwise, delete all stale short URLs older than the specified time return s.db.WithTransactionalDbSession(ctx, func(session *db.Session) error { var rawSql = "DELETE FROM short_url WHERE created_at <= ? AND (last_seen_at IS NULL OR last_seen_at = 0)" diff --git a/pkg/services/sqlstore/database_config.go b/pkg/services/sqlstore/database_config.go index 47e77640aa8..c1d80ceb32d 100644 --- a/pkg/services/sqlstore/database_config.go +++ b/pkg/services/sqlstore/database_config.go @@ -94,6 +94,9 @@ func (dbCfg *DatabaseConfig) readConfig(cfg *setting.Cfg) error { } else { dbCfg.Type = sec.Key("type").String() dbCfg.Host = sec.Key("host").String() + if port := sec.Key("port").String(); port != "" { + dbCfg.Host = dbCfg.Host + ":" + port + } dbCfg.Name = sec.Key("name").String() dbCfg.User = sec.Key("user").String() dbCfg.ConnectionString = sec.Key("connection_string").String() diff --git a/pkg/services/sqlstore/sqlutil/sqlutil.go b/pkg/services/sqlstore/sqlutil/sqlutil.go index 8506acdab79..7d3d09a5ff3 100644 --- a/pkg/services/sqlstore/sqlutil/sqlutil.go +++ b/pkg/services/sqlstore/sqlutil/sqlutil.go @@ -22,6 +22,11 @@ type TestDB struct { DriverName string ConnStr string Path string + Host string + Port string + User string + Password string + Database string Cleanup func() } @@ -132,6 +137,11 @@ func mySQLTestDB() (*TestDB, error) { return &TestDB{ DriverName: "mysql", ConnStr: conn_str, + Host: host, + Port: port, + User: "grafana", + Password: "password", + Database: "grafana_tests", Cleanup: func() {}, }, nil } @@ -149,6 +159,11 @@ func postgresTestDB() (*TestDB, error) { return &TestDB{ DriverName: "postgres", ConnStr: connStr, + Host: host, + Port: port, + User: "grafanatest", + Password: "grafanatest", + Database: "grafanatest", Cleanup: func() {}, }, nil } diff --git a/pkg/services/team/teamimpl/store_test.go b/pkg/services/team/teamimpl/store_test.go index ba569d1ef40..2a455bc2a7e 100644 --- a/pkg/services/team/teamimpl/store_test.go +++ b/pkg/services/team/teamimpl/store_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/configprovider" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" ac "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -47,7 +48,9 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { }, }, } - quotaService := quotaimpl.ProvideService(sqlStore, cfg) + cfgProvider, err := configprovider.ProvideService(cfg) + require.NoError(t, err) + quotaService := quotaimpl.ProvideService(context.Background(), sqlStore, cfgProvider) orgSvc, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) require.NoError(t, err) userSvc, err := userimpl.ProvideService( @@ -453,7 +456,9 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { t.Run("Should be able to exclude service accounts from teamembers", func(t *testing.T) { sqlStore = db.InitTestDB(t) - quotaService := quotaimpl.ProvideService(sqlStore, cfg) + cfgProvider, err := configprovider.ProvideService(cfg) + require.NoError(t, err) + quotaService := quotaimpl.ProvideService(context.Background(), sqlStore, cfgProvider) orgSvc, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) require.NoError(t, err) userSvc, err := userimpl.ProvideService( @@ -609,7 +614,9 @@ func TestIntegrationSQLStore_GetTeamMembers_ACFilter(t *testing.T) { team2, errCreateTeam := teamSvc.CreateTeam(context.Background(), &team2Cmd) require.NoError(t, errCreateTeam) - quotaService := quotaimpl.ProvideService(store, cfg) + cfgProvider, err := configprovider.ProvideService(cfg) + require.NoError(t, err) + quotaService := quotaimpl.ProvideService(context.Background(), store, cfgProvider) orgSvc, err := orgimpl.ProvideService(store, cfg, quotaService) require.NoError(t, err) userSvc, err := userimpl.ProvideService( @@ -682,7 +689,6 @@ func TestIntegrationSQLStore_GetTeamMembers_ACFilter(t *testing.T) { expectedNumUsers: 0, }, { - desc: "should return some team members", query: &team.GetTeamMembersQuery{ OrgID: testOrgID, diff --git a/pkg/services/user/userimpl/store_test.go b/pkg/services/user/userimpl/store_test.go index bfa19888346..be42acc2716 100644 --- a/pkg/services/user/userimpl/store_test.go +++ b/pkg/services/user/userimpl/store_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/configprovider" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -37,7 +38,9 @@ func TestIntegrationUserDataAccess(t *testing.T) { } ss, cfg := db.InitTestDBWithCfg(t) - quotaService := quotaimpl.ProvideService(ss, cfg) + cfgProvider, err := configprovider.ProvideService(cfg) + require.NoError(t, err) + quotaService := quotaimpl.ProvideService(context.Background(), ss, cfgProvider) orgService, err := orgimpl.ProvideService(ss, cfg, quotaService) require.NoError(t, err) userStore := ProvideStore(ss, setting.NewCfg()) @@ -396,7 +399,8 @@ func TestIntegrationUserDataAccess(t *testing.T) { _, err = userStore.GetSignedInUser(context.Background(), &user.GetSignedInUserQuery{ OrgID: users[1].OrgID, - UserID: userID}) // zero + UserID: userID, + }) // zero require.Error(t, err) } }) @@ -1068,7 +1072,9 @@ func TestIntegrationMetricsUsage(t *testing.T) { } ss, cfg := db.InitTestDBWithCfg(t) userStore := ProvideStore(ss, setting.NewCfg()) - quotaService := quotaimpl.ProvideService(ss, cfg) + cfgProvider, err := configprovider.ProvideService(cfg) + require.NoError(t, err) + quotaService := quotaimpl.ProvideService(context.Background(), ss, cfgProvider) orgService, err := orgimpl.ProvideService(ss, cfg, quotaService) require.NoError(t, err) @@ -1127,7 +1133,9 @@ func assertEqualUser(t *testing.T, expected, got *user.User) { func createOrgAndUserSvc(t *testing.T, store db.DB, cfg *setting.Cfg) (org.Service, user.Service) { t.Helper() - quotaService := quotaimpl.ProvideService(store, cfg) + cfgProvider, err := configprovider.ProvideService(cfg) + require.NoError(t, err) + quotaService := quotaimpl.ProvideService(context.Background(), store, cfgProvider) orgService, err := orgimpl.ProvideService(store, cfg, quotaService) require.NoError(t, err) usrSvc, err := ProvideService( diff --git a/pkg/storage/legacysql/dualwrite/dualwriter.go b/pkg/storage/legacysql/dualwrite/dualwriter.go index 0cb1091b13b..7907db3af65 100644 --- a/pkg/storage/legacysql/dualwrite/dualwriter.go +++ b/pkg/storage/legacysql/dualwrite/dualwriter.go @@ -39,16 +39,7 @@ type dualWriter struct { func (d *dualWriter) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { // If we read from unified, we can just do that and return. if d.readUnified { - unifiedGet, unifiedErr := d.unified.Get(ctx, name, options) - if apierrors.IsNotFound(unifiedErr) { - // If resource is not found in unified storage, fallback to legacy. - // This fixes cases in where records (stored in multiple tables, including permissions) - // are inserted first in legacy and then on Unified. - log := logging.FromContext(ctx).With("method", "Get") - log.Error("resource not found in Unified Storage, trying to GET from legacy", "err", unifiedErr) - return d.legacy.Get(ctx, name, options) - } - 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) @@ -196,6 +187,22 @@ func (d *dualWriter) Create(ctx context.Context, in runtime.Object, createValida return nil, fmt.Errorf("name or generatename have to be set") } + readFromUnifiedWriteToBothStorages := d.readUnified && d.legacy != nil && d.unified != nil + + permissions := "" + if readFromUnifiedWriteToBothStorages { + objIn, err := utils.MetaAccessor(in) + if err != nil { + return nil, err + } + + // keep permissions, we will set it back after the object is created + permissions = objIn.GetAnnotation(utils.AnnoKeyGrantPermissions) + if permissions != "" { + objIn.SetAnnotation(utils.AnnoKeyGrantPermissions, "") // remove the annotation for now + } + } + // create in legacy first, and then unistore. if unistore fails, but legacy succeeds, // will try to cleanup the object in legacy. createdFromLegacy, err := d.legacy.Create(ctx, in, createValidation, options) @@ -212,6 +219,17 @@ func (d *dualWriter) Create(ctx context.Context, in runtime.Object, createValida accCreated.SetResourceVersion("") accCreated.SetUID("") + if readFromUnifiedWriteToBothStorages { + objCopy, err := utils.MetaAccessor(createdCopy) + if err != nil { + return nil, err + } + // restore the permissions annotation, as we removed it before creating in legacy + if permissions != "" { + objCopy.SetAnnotation(utils.AnnoKeyGrantPermissions, permissions) + } + } + // 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) @@ -259,10 +277,20 @@ func (d *dualWriter) Delete(ctx context.Context, name string, deleteValidation r // we want to delete from legacy first, otherwise if the delete from unistore was successful, // but legacy failed, the user would get a failure, but not be able to retry the delete // as they would not be able to see the object in unistore anymore. + + // By setting RemovePermissions to false in the context, we will skip the deletion of permissions + // in the legacy store. This is needed as otherwise the permissions would be missing when executing + // the delete operation in the unified storage store. + ctx = utils.SetFolderRemovePermissions(ctx, false) + objFromLegacy, asyncLegacy, err := d.legacy.Delete(ctx, name, deleteValidation, options) if err != nil && (!d.readUnified || !d.errorIsOK && !apierrors.IsNotFound(err)) { return nil, false, err } + + // We can now flip it again. + ctx = utils.SetFolderRemovePermissions(ctx, true) + // 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) diff --git a/pkg/storage/legacysql/dualwrite/dualwriter_mode3_test.go b/pkg/storage/legacysql/dualwrite/dualwriter_mode3_test.go index 00753d1d656..3076d8a9409 100644 --- a/pkg/storage/legacysql/dualwrite/dualwriter_mode3_test.go +++ b/pkg/storage/legacysql/dualwrite/dualwriter_mode3_test.go @@ -109,40 +109,12 @@ func TestMode3_Get(t *testing.T) { }, }, { - name: "should return an error when getting an object in the unified store fails", + name: "should return an error when getting an object in the unified store fails, and should not go to legacy", setupStorageFn: func(m *mock.Mock, name string) { m.On("Get", mock.Anything, name, mock.Anything).Return(nil, errors.New("error")) }, wantErr: true, }, - { - name: "should return an error when getting an object in the unified store fails with not found error, and legacy fails as well", - setupLegacyFn: func(m *mock.Mock, name string) { - m.On("Get", mock.Anything, name, mock.Anything).Return(nil, errors.New("error")) - }, - setupStorageFn: func(m *mock.Mock, name string) { - m.On("Get", mock.Anything, name, mock.Anything).Return(nil, apierrors.NewNotFound(schema.GroupResource{Group: "dashboards.dashboard.grafana.app", Resource: "dashboard"}, "uid")) - }, - wantErr: true, - }, - { - name: "should succeed when getting an object in the UnifiedStorage fails with NotFound, but Legacy succeeds", - 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).Return(nil, apierrors.NewNotFound(schema.GroupResource{Group: "dashboards.dashboard.grafana.app", Resource: "dashboard"}, "uid")) - }, - }, - { - name: "should succeed when getting an object in the LegacyStorage fails", - setupLegacyFn: func(m *mock.Mock, name string) { - m.On("Get", mock.Anything, name, mock.Anything).Return(nil, errors.New("error")) - }, - setupStorageFn: func(m *mock.Mock, name string) { - m.On("Get", mock.Anything, name, mock.Anything).Return(exampleObj, nil) - }, - }, } name := "foo" diff --git a/pkg/storage/secret/metadata/decrypt_store.go b/pkg/storage/secret/metadata/decrypt_store.go index de98a817a86..1a827b32d00 100644 --- a/pkg/storage/secret/metadata/decrypt_store.go +++ b/pkg/storage/secret/metadata/decrypt_store.go @@ -95,7 +95,6 @@ func (s *decryptStorage) Decrypt(ctx context.Context, namespace xkube.Namespace, success := decryptErr == nil s.metrics.DecryptDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds()) - s.metrics.DecryptRequestCount.WithLabelValues(strconv.FormatBool(success)).Inc() }() // Basic authn check before reading a secure value metadata, it is here on purpose. @@ -111,7 +110,7 @@ func (s *decryptStorage) Decrypt(ctx context.Context, namespace xkube.Namespace, return "", contracts.ErrDecryptNotFound } - decrypterIdentity, authorized := s.decryptAuthorizer.Authorize(ctx, name, sv.Spec.Decrypters) + decrypterIdentity, authorized := s.decryptAuthorizer.Authorize(ctx, namespace, name, sv.Spec.Decrypters) if !authorized { return "", contracts.ErrDecryptNotAuthorized } diff --git a/pkg/storage/secret/metadata/decrypt_store_test.go b/pkg/storage/secret/metadata/decrypt_store_test.go index 3c35a443794..bc50b29c4a6 100644 --- a/pkg/storage/secret/metadata/decrypt_store_test.go +++ b/pkg/storage/secret/metadata/decrypt_store_test.go @@ -292,13 +292,14 @@ func TestIntegrationDecrypt(t *testing.T) { require.NotEmpty(t, exposed) require.Equal(t, "value", exposed.DangerouslyExposeAndConsumeValue()) - require.Len(t, fakeLogger.InfoMsgs, 2) + require.Len(t, fakeLogger.InfoMsgs, 3) require.Equal(t, fakeLogger.InfoMsgs[0], "SecureValueMetadataStorage.Read") - require.Equal(t, fakeLogger.InfoMsgs[1], "Secrets Audit Log") + require.Equal(t, fakeLogger.InfoMsgs[1], "KeeperMetadataStorage.GetKeeperConfig") + require.Equal(t, fakeLogger.InfoMsgs[2], "Secrets Audit Log") - require.Len(t, fakeLogger.InfoArgs, 2) + require.Len(t, fakeLogger.InfoArgs, 3) // we only want to check the audit log args - args := fakeLogger.InfoArgs[1] + args := fakeLogger.InfoArgs[2] require.Contains(t, args, "grafana_decrypter_identity") require.Contains(t, args, "decrypter_identity") for i, arg := range args { diff --git a/pkg/storage/secret/metadata/keeper_store.go b/pkg/storage/secret/metadata/keeper_store.go index 92707351ba7..38c27cd1873 100644 --- a/pkg/storage/secret/metadata/keeper_store.go +++ b/pkg/storage/secret/metadata/keeper_store.go @@ -3,6 +3,7 @@ package metadata import ( "context" "fmt" + "strconv" "time" "github.com/prometheus/client_golang/prometheus" @@ -10,10 +11,12 @@ import ( "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" + "github.com/grafana/grafana-app-sdk/logging" secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" "github.com/grafana/grafana/pkg/storage/secret/metadata/metrics" + "github.com/grafana/grafana/pkg/storage/unified/sql" "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" ) @@ -40,7 +43,7 @@ func ProvideKeeperMetadataStorage( }, nil } -func (s *keeperMetadataStorage) Create(ctx context.Context, keeper *secretv1beta1.Keeper, actorUID string) (*secretv1beta1.Keeper, error) { +func (s *keeperMetadataStorage) Create(ctx context.Context, keeper *secretv1beta1.Keeper, actorUID string) (_ *secretv1beta1.Keeper, createErr error) { start := time.Now() ctx, span := s.tracer.Start(ctx, "KeeperMetadataStorage.Create", trace.WithAttributes( attribute.String("name", keeper.GetName()), @@ -49,6 +52,26 @@ func (s *keeperMetadataStorage) Create(ctx context.Context, keeper *secretv1beta )) defer span.End() + defer func() { + success := createErr == nil + + args := []any{ + "name", keeper.GetName(), + "namespace", keeper.GetNamespace(), + "actorUID", actorUID, + } + + if !success { + span.SetStatus(codes.Error, "KeeperMetadataStorage.Create failed") + span.RecordError(createErr) + args = append(args, "error", createErr) + } + + logging.FromContext(ctx).Info("KeeperMetadataStorage.Create", args...) + + s.metrics.KeeperMetadataCreateDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds()) + }() + row, err := toKeeperCreateRow(keeper, actorUID) if err != nil { return nil, fmt.Errorf("failed to create row: %w", err) @@ -69,13 +92,12 @@ func (s *keeperMetadataStorage) Create(ctx context.Context, keeper *secretv1beta return err } - // Validate before inserting that any `secureValues` referenced exist and do not reference other third-party keepers. - if err := s.validateSecureValueReferences(ctx, keeper); err != nil { - return err - } - result, err := s.db.ExecContext(ctx, query, req.GetArgs()...) if err != nil { + if sql.IsRowAlreadyExistsError(err) { + return fmt.Errorf("namespace=%s name=%s: %w", keeper.Namespace, keeper.Name, contracts.ErrKeeperAlreadyExists) + } + return fmt.Errorf("inserting row: %w", err) } @@ -99,13 +121,10 @@ func (s *keeperMetadataStorage) Create(ctx context.Context, keeper *secretv1beta return nil, fmt.Errorf("failed to convert to kubernetes object: %w", err) } - s.metrics.KeeperMetadataCreateDuration.WithLabelValues(string(createdKeeper.Spec.GetType())).Observe(time.Since(start).Seconds()) - s.metrics.KeeperMetadataCreateCount.WithLabelValues(string(createdKeeper.Spec.GetType())).Inc() - return createdKeeper, nil } -func (s *keeperMetadataStorage) Read(ctx context.Context, namespace xkube.Namespace, name string, opts contracts.ReadOpts) (*secretv1beta1.Keeper, error) { +func (s *keeperMetadataStorage) Read(ctx context.Context, namespace xkube.Namespace, name string, opts contracts.ReadOpts) (_ *secretv1beta1.Keeper, readErr error) { start := time.Now() ctx, span := s.tracer.Start(ctx, "KeeperMetadataStorage.Read", trace.WithAttributes( attribute.String("name", name), @@ -114,6 +133,26 @@ func (s *keeperMetadataStorage) Read(ctx context.Context, namespace xkube.Namesp )) defer span.End() + defer func() { + success := readErr == nil + + args := []any{ + "name", name, + "namespace", namespace.String(), + } + + args = append(args, "success", success) + if !success { + span.SetStatus(codes.Error, "KeeperMetadataStorage.Read failed") + span.RecordError(readErr) + args = append(args, "error", readErr) + } + + logging.FromContext(ctx).Info("KeeperMetadataStorage.Read", args...) + + s.metrics.KeeperMetadataGetDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds()) + }() + keeperDB, err := s.read(ctx, namespace.String(), name, opts) if err != nil { return nil, err @@ -124,9 +163,6 @@ func (s *keeperMetadataStorage) Read(ctx context.Context, namespace xkube.Namesp return nil, fmt.Errorf("failed to convert to kubernetes object: %w", err) } - s.metrics.KeeperMetadataGetDuration.WithLabelValues(string(keeper.Spec.GetType())).Observe(time.Since(start).Seconds()) - s.metrics.KeeperMetadataGetCount.WithLabelValues(string(keeper.Spec.GetType())).Inc() - return keeper, nil } @@ -168,7 +204,7 @@ func (s *keeperMetadataStorage) read(ctx context.Context, namespace, name string return &keeper, nil } -func (s *keeperMetadataStorage) Update(ctx context.Context, newKeeper *secretv1beta1.Keeper, actorUID string) (*secretv1beta1.Keeper, error) { +func (s *keeperMetadataStorage) Update(ctx context.Context, newKeeper *secretv1beta1.Keeper, actorUID string) (_ *secretv1beta1.Keeper, updateErr error) { start := time.Now() ctx, span := s.tracer.Start(ctx, "KeeperMetadataStorage.Update", trace.WithAttributes( attribute.String("name", newKeeper.GetName()), @@ -177,6 +213,25 @@ func (s *keeperMetadataStorage) Update(ctx context.Context, newKeeper *secretv1b )) defer span.End() + defer func() { + success := updateErr == nil + args := []any{ + "name", newKeeper.GetName(), + "namespace", newKeeper.GetNamespace(), + "actorUID", actorUID, + } + + args = append(args, "success", success) + if !success { + span.SetStatus(codes.Error, "KeeperMetadataStorage.Update failed") + span.RecordError(updateErr) + args = append(args, "error", updateErr) + } + + logging.FromContext(ctx).Info("KeeperMetadataStorage.Update", args...) + s.metrics.KeeperMetadataUpdateDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds()) + }() + var newRow *keeperDB err := s.db.Transaction(ctx, func(ctx context.Context) error { @@ -185,11 +240,6 @@ func (s *keeperMetadataStorage) Update(ctx context.Context, newKeeper *secretv1b return err } - // Validate before updating that any `secureValues` referenced exists and does not reference other third-party keepers. - if err := s.validateSecureValueReferences(ctx, newKeeper); err != nil { - return err - } - // Read old value first. oldKeeperRow, err := s.read(ctx, newKeeper.Namespace, newKeeper.Name, contracts.ReadOpts{ForUpdate: true}) if err != nil { @@ -239,13 +289,10 @@ func (s *keeperMetadataStorage) Update(ctx context.Context, newKeeper *secretv1b return nil, fmt.Errorf("failed to convert to kubernetes object: %w", err) } - s.metrics.KeeperMetadataUpdateDuration.WithLabelValues(string(keeper.Spec.GetType())).Observe(time.Since(start).Seconds()) - s.metrics.KeeperMetadataUpdateCount.WithLabelValues(string(keeper.Spec.GetType())).Inc() - return keeper, nil } -func (s *keeperMetadataStorage) Delete(ctx context.Context, namespace xkube.Namespace, name string) error { +func (s *keeperMetadataStorage) Delete(ctx context.Context, namespace xkube.Namespace, name string) (delErr error) { start := time.Now() ctx, span := s.tracer.Start(ctx, "KeeperMetadataStorage.Delete", trace.WithAttributes( attribute.String("name", name), @@ -253,6 +300,26 @@ func (s *keeperMetadataStorage) Delete(ctx context.Context, namespace xkube.Name )) defer span.End() + defer func() { + success := delErr == nil + + args := []any{ + "name", name, + "namespace", namespace.String(), + } + + args = append(args, "success", success) + if !success { + span.SetStatus(codes.Error, "KeeperMetadataStorage.Delete failed") + span.RecordError(delErr) + args = append(args, "error", delErr) + } + + logging.FromContext(ctx).Info("KeeperMetadataStorage.Delete", args...) + + s.metrics.KeeperMetadataDeleteDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds()) + }() + req := deleteKeeper{ SQLTemplate: sqltemplate.New(s.dialect), Namespace: namespace.String(), @@ -279,9 +346,6 @@ func (s *keeperMetadataStorage) Delete(ctx context.Context, namespace xkube.Name return fmt.Errorf("expected 1 row affected, got %d for %s on %s", rowsAffected, name, namespace) } - s.metrics.KeeperMetadataDeleteDuration.Observe(time.Since(start).Seconds()) - s.metrics.KeeperMetadataDeleteCount.Inc() - return nil } @@ -294,6 +358,22 @@ func (s *keeperMetadataStorage) List(ctx context.Context, namespace xkube.Namesp defer func() { span.SetAttributes(attribute.Int("returnedList.count", len(keeperList))) + success := err == nil + + args := []any{ + "namespace", namespace.String(), + "success", success, + } + + if !success { + span.SetStatus(codes.Error, "KeeperMetadataStorage.List failed") + span.RecordError(err) + args = append(args, "error", err) + } + + logging.FromContext(ctx).Info("KeeperMetadataStorage.List", args...) + + s.metrics.KeeperMetadataListDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds()) }() req := listKeeper{ @@ -336,9 +416,6 @@ func (s *keeperMetadataStorage) List(ctx context.Context, namespace xkube.Namesp return nil, fmt.Errorf("read rows error: %w", err) } - s.metrics.KeeperMetadataListDuration.Observe(time.Since(start).Seconds()) - s.metrics.KeeperMetadataListCount.Inc() - return keepers, nil } @@ -491,19 +568,39 @@ func (s *keeperMetadataStorage) validateSecureValueReferences(ctx context.Contex return nil } -func (s *keeperMetadataStorage) GetKeeperConfig(ctx context.Context, namespace string, name *string, opts contracts.ReadOpts) (secretv1beta1.KeeperConfig, error) { +func (s *keeperMetadataStorage) GetKeeperConfig(ctx context.Context, namespace string, name *string, opts contracts.ReadOpts) (_ secretv1beta1.KeeperConfig, getErr error) { ctx, span := s.tracer.Start(ctx, "KeeperMetadataStorage.GetKeeperConfig", trace.WithAttributes( attribute.String("namespace", namespace), attribute.Bool("isForUpdate", opts.ForUpdate), )) + start := time.Now() defer span.End() + defer func() { + success := getErr == nil + + args := []any{ + "namespace", namespace, + "isForUpdate", strconv.FormatBool(opts.ForUpdate), + } + + args = append(args, "success", success) + if !success { + span.SetStatus(codes.Error, "KeeperMetadataStorage.GetKeeperConfig failed") + span.RecordError(getErr) + args = append(args, "error", getErr) + } + + logging.FromContext(ctx).Info("KeeperMetadataStorage.GetKeeperConfig", args...) + + s.metrics.KeeperMetadataGetKeeperConfigDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds()) + }() + // Check if keeper is the systemwide one. if name == nil { return &secretv1beta1.SystemKeeperConfig{}, nil } - start := time.Now() span.SetAttributes(attribute.String("name", *name)) // Load keeper config from metadata store, or TODO: keeper cache. @@ -514,8 +611,6 @@ func (s *keeperMetadataStorage) GetKeeperConfig(ctx context.Context, namespace s keeperConfig := toProvider(secretv1beta1.KeeperType(kp.Type), kp.Payload) - s.metrics.KeeperMetadataGetKeeperConfigDuration.Observe(time.Since(start).Seconds()) - // TODO: this would be a good place to check if credentials are secure values and load them. return keeperConfig, nil } diff --git a/pkg/storage/secret/metadata/metrics/metrics.go b/pkg/storage/secret/metadata/metrics/metrics.go index f9baba1de64..5d904253dde 100644 --- a/pkg/storage/secret/metadata/metrics/metrics.go +++ b/pkg/storage/secret/metadata/metrics/metrics.go @@ -9,33 +9,26 @@ import ( const ( namespace = "grafana_secrets_manager" subsystem = "storage" + // labels + successLabel = "success" ) // StorageMetrics is a struct that contains all the metrics for all operations of secrets storage. type StorageMetrics struct { KeeperMetadataCreateDuration *prometheus.HistogramVec - KeeperMetadataCreateCount *prometheus.CounterVec KeeperMetadataUpdateDuration *prometheus.HistogramVec - KeeperMetadataUpdateCount *prometheus.CounterVec - KeeperMetadataDeleteDuration prometheus.Histogram - KeeperMetadataDeleteCount prometheus.Counter + KeeperMetadataDeleteDuration *prometheus.HistogramVec KeeperMetadataGetDuration *prometheus.HistogramVec - KeeperMetadataGetCount *prometheus.CounterVec - KeeperMetadataListDuration prometheus.Histogram - KeeperMetadataListCount prometheus.Counter - KeeperMetadataGetKeeperConfigDuration prometheus.Histogram + KeeperMetadataListDuration *prometheus.HistogramVec + KeeperMetadataGetKeeperConfigDuration *prometheus.HistogramVec SecureValueMetadataCreateDuration *prometheus.HistogramVec - SecureValueMetadataCreateCount *prometheus.CounterVec - SecureValueMetadataGetDuration prometheus.Histogram - SecureValueMetadataGetCount prometheus.Counter - SecureValueMetadataListDuration prometheus.Histogram - SecureValueMetadataListCount prometheus.Counter - SecureValueSetExternalIDDuration prometheus.Histogram - SecureValueSetStatusDuration prometheus.Histogram + SecureValueMetadataGetDuration *prometheus.HistogramVec + SecureValueMetadataListDuration *prometheus.HistogramVec + SecureValueSetExternalIDDuration *prometheus.HistogramVec + SecureValueSetStatusDuration *prometheus.HistogramVec - DecryptDuration *prometheus.HistogramVec - DecryptRequestCount *prometheus.CounterVec + DecryptDuration *prometheus.HistogramVec } func newStorageMetrics() *StorageMetrics { @@ -47,72 +40,42 @@ func newStorageMetrics() *StorageMetrics { Name: "keeper_metadata_create_duration_seconds", Help: "Duration of keeper metadata create operations", Buckets: prometheus.DefBuckets, - }, []string{"keeper_type"}), - KeeperMetadataCreateCount: prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, - Name: "keeper_metadata_create_count", - Help: "Count of keeper metadata create operations", - }, []string{"keeper_type"}), + }, []string{successLabel}), KeeperMetadataUpdateDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ Namespace: namespace, Subsystem: subsystem, Name: "keeper_metadata_update_duration_seconds", Help: "Duration of keeper metadata update operations", Buckets: prometheus.DefBuckets, - }, []string{"keeper_type"}), - KeeperMetadataUpdateCount: prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, - Name: "keeper_metadata_update_count", - Help: "Count of keeper metadata update operations", - }, []string{"keeper_type"}), - KeeperMetadataDeleteDuration: prometheus.NewHistogram(prometheus.HistogramOpts{ + }, []string{successLabel}), + KeeperMetadataDeleteDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ Namespace: namespace, Subsystem: subsystem, Name: "keeper_metadata_delete_duration_seconds", Help: "Duration of keeper metadata delete operations", Buckets: prometheus.DefBuckets, - }), - KeeperMetadataDeleteCount: prometheus.NewCounter(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, - Name: "keeper_metadata_delete_count", - Help: "Count of keeper metadata delete operations", - }), + }, []string{successLabel}), KeeperMetadataGetDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ Namespace: namespace, Subsystem: subsystem, Name: "keeper_metadata_get_duration_seconds", Help: "Duration of keeper metadata get operations", Buckets: prometheus.DefBuckets, - }, []string{"keeper_type"}), - KeeperMetadataGetCount: prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, - Name: "keeper_metadata_get_count", - Help: "Count of keeper metadata get operations", - }, []string{"keeper_type"}), - KeeperMetadataListDuration: prometheus.NewHistogram(prometheus.HistogramOpts{ + }, []string{successLabel}), + KeeperMetadataListDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ Namespace: namespace, Subsystem: subsystem, Name: "keeper_metadata_list_duration_seconds", Help: "Duration of keeper metadata list operations", Buckets: prometheus.DefBuckets, - }), - KeeperMetadataListCount: prometheus.NewCounter(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, - Name: "keeper_metadata_list_count", - Help: "Count of keeper metadata list operations", - }), - KeeperMetadataGetKeeperConfigDuration: prometheus.NewHistogram(prometheus.HistogramOpts{ + }, []string{successLabel}), + KeeperMetadataGetKeeperConfigDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ Namespace: namespace, Subsystem: subsystem, Name: "keeper_metadata_get_keeper_config_duration_seconds", Help: "Duration of keeper metadata get keeper config operations", Buckets: prometheus.DefBuckets, - }), + }, []string{successLabel}), // Secure value metrics SecureValueMetadataCreateDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ @@ -121,53 +84,35 @@ func newStorageMetrics() *StorageMetrics { Name: "secure_value_metadata_create_duration_seconds", Help: "Duration of secure value metadata create operations", Buckets: prometheus.DefBuckets, - }, []string{"successful"}), - SecureValueMetadataCreateCount: prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, - Name: "secure_value_metadata_create_count", - Help: "Count of secure value metadata create operations", - }, []string{"successful"}), - SecureValueMetadataGetDuration: prometheus.NewHistogram(prometheus.HistogramOpts{ + }, []string{successLabel}), + SecureValueMetadataGetDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ Namespace: namespace, Subsystem: subsystem, Name: "secure_value_metadata_get_duration_seconds", Help: "Duration of secure value metadata get operations", Buckets: prometheus.DefBuckets, - }), - SecureValueMetadataGetCount: prometheus.NewCounter(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, - Name: "secure_value_metadata_get_count", - Help: "Count of secure value metadata get operations", - }), - SecureValueMetadataListDuration: prometheus.NewHistogram(prometheus.HistogramOpts{ + }, []string{successLabel}), + SecureValueMetadataListDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ Namespace: namespace, Subsystem: subsystem, Name: "secure_value_metadata_list_duration_seconds", Help: "Duration of secure value metadata list operations", Buckets: prometheus.DefBuckets, - }), - SecureValueMetadataListCount: prometheus.NewCounter(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, - Name: "secure_value_metadata_list_count", - Help: "Count of secure value metadata list operations", - }), - SecureValueSetExternalIDDuration: prometheus.NewHistogram(prometheus.HistogramOpts{ + }, []string{successLabel}), + SecureValueSetExternalIDDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ Namespace: namespace, Subsystem: subsystem, Name: "secure_value_set_external_id_duration_seconds", Help: "Duration of secure value set external id operations", Buckets: prometheus.DefBuckets, - }), - SecureValueSetStatusDuration: prometheus.NewHistogram(prometheus.HistogramOpts{ + }, []string{successLabel}), + SecureValueSetStatusDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ Namespace: namespace, Subsystem: subsystem, Name: "secure_value_set_status_duration_seconds", Help: "Duration of secure value set status operations", Buckets: prometheus.DefBuckets, - }), + }, []string{successLabel}), // Decrypt metrics DecryptDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ @@ -176,13 +121,7 @@ func newStorageMetrics() *StorageMetrics { Name: "decrypt_duration_seconds", Help: "Duration of decrypt operations", Buckets: prometheus.DefBuckets, - }, []string{"successful"}), - DecryptRequestCount: prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, - Name: "decrypt_request_count", - Help: "Count of decrypt operations", - }, []string{"successful"}), + }, []string{successLabel}), } } @@ -199,26 +138,17 @@ func NewStorageMetrics(reg prometheus.Registerer) *StorageMetrics { if reg != nil { reg.MustRegister( m.KeeperMetadataCreateDuration, - m.KeeperMetadataCreateCount, m.KeeperMetadataUpdateDuration, - m.KeeperMetadataUpdateCount, m.KeeperMetadataDeleteDuration, - m.KeeperMetadataDeleteCount, m.KeeperMetadataGetDuration, - m.KeeperMetadataGetCount, m.KeeperMetadataListDuration, - m.KeeperMetadataListCount, m.KeeperMetadataGetKeeperConfigDuration, m.SecureValueMetadataCreateDuration, - m.SecureValueMetadataCreateCount, m.SecureValueMetadataGetDuration, - m.SecureValueMetadataGetCount, m.SecureValueMetadataListDuration, - m.SecureValueMetadataListCount, m.SecureValueSetExternalIDDuration, m.SecureValueSetStatusDuration, m.DecryptDuration, - m.DecryptRequestCount, ) } diff --git a/pkg/storage/secret/metadata/secure_value_store.go b/pkg/storage/secret/metadata/secure_value_store.go index 5782ee9a3e4..448d8ce2698 100644 --- a/pkg/storage/secret/metadata/secure_value_store.go +++ b/pkg/storage/secret/metadata/secure_value_store.go @@ -55,13 +55,14 @@ func (s *secureValueMetadataStorage) Create(ctx context.Context, sv *secretv1bet defer span.End() defer func() { + success := svmCreateErr == nil + args := []any{ "name", name, "namespace", namespace, "actorUID", actorUID, } - success := svmCreateErr == nil args = append(args, "success", success) if !success { span.SetStatus(codes.Error, "SecureValueMetadataStorage.Create failed") @@ -72,7 +73,6 @@ func (s *secureValueMetadataStorage) Create(ctx context.Context, sv *secretv1bet logging.FromContext(ctx).Info("SecureValueMetadataStorage.Create", args...) s.metrics.SecureValueMetadataCreateDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds()) - s.metrics.SecureValueMetadataCreateCount.WithLabelValues(strconv.FormatBool(success)).Inc() }() // Set inside the transaction callback @@ -265,10 +265,22 @@ func (s *secureValueMetadataStorage) Read(ctx context.Context, namespace xkube.N defer span.End() defer func() { - logging.FromContext(ctx).Info("SecureValueMetadataStorage.Read", "namespace", namespace, "name", name, "success", readErr == nil, "error", readErr) + success := readErr == nil - s.metrics.SecureValueMetadataGetDuration.Observe(time.Since(start).Seconds()) - s.metrics.SecureValueMetadataGetCount.Inc() + args := []any{ + "name", name, + "namespace", namespace.String(), + "success", success, + } + + if !success { + span.SetStatus(codes.Error, "SecureValueMetadataStorage.Read failed") + span.RecordError(readErr) + args = append(args, "error", readErr) + } + + logging.FromContext(ctx).Info("SecureValueMetadataStorage.Read", args...) + s.metrics.SecureValueMetadataGetDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds()) }() secureValue, err := s.readActiveVersion(ctx, namespace, name, opts) @@ -284,7 +296,7 @@ func (s *secureValueMetadataStorage) Read(ctx context.Context, namespace xkube.N return secureValueKub, nil } -func (s *secureValueMetadataStorage) List(ctx context.Context, namespace xkube.Namespace) (svList []secretv1beta1.SecureValue, error error) { +func (s *secureValueMetadataStorage) List(ctx context.Context, namespace xkube.Namespace) (svList []secretv1beta1.SecureValue, listErr error) { start := time.Now() ctx, span := s.tracer.Start(ctx, "SecureValueMetadataStorage.List", trace.WithAttributes( attribute.String("namespace", namespace.String()), @@ -292,7 +304,23 @@ func (s *secureValueMetadataStorage) List(ctx context.Context, namespace xkube.N defer span.End() defer func() { + success := listErr == nil span.SetAttributes(attribute.Int("returnedList.count", len(svList))) + + args := []any{ + "namespace", namespace.String(), + "success", success, + } + + if !success { + span.SetStatus(codes.Error, "SecureValueMetadataStorage.List failed") + span.RecordError(listErr) + args = append(args, "error", listErr) + } + + logging.FromContext(ctx).Info("SecureValueMetadataStorage.List", args...) + + s.metrics.SecureValueMetadataListDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds()) }() req := listSecureValue{ @@ -344,9 +372,6 @@ func (s *secureValueMetadataStorage) List(ctx context.Context, namespace xkube.N return nil, fmt.Errorf("read rows error: %w", err) } - s.metrics.SecureValueMetadataListDuration.Observe(time.Since(start).Seconds()) - s.metrics.SecureValueMetadataListCount.Inc() - return secureValues, nil } @@ -423,7 +448,7 @@ func (s *secureValueMetadataStorage) SetVersionToInactive(ctx context.Context, n return nil } -func (s *secureValueMetadataStorage) SetExternalID(ctx context.Context, namespace xkube.Namespace, name string, version int64, externalID contracts.ExternalID) error { +func (s *secureValueMetadataStorage) SetExternalID(ctx context.Context, namespace xkube.Namespace, name string, version int64, externalID contracts.ExternalID) (setExtIDErr error) { start := time.Now() ctx, span := s.tracer.Start(ctx, "SecureValueMetadataStorage.SetExternalID", trace.WithAttributes( attribute.String("name", name), @@ -431,8 +456,29 @@ func (s *secureValueMetadataStorage) SetExternalID(ctx context.Context, namespac attribute.String("externalID", externalID.String()), attribute.Int64("version", version), )) + defer span.End() + defer func() { + success := setExtIDErr == nil + args := []any{ + "name", name, + "namespace", namespace.String(), + "success", success, + "version", strconv.FormatInt(version, 10), + "externalID", externalID.String(), + } + + if !success { + span.SetStatus(codes.Error, "SecureValueMetadataStorage.SetExternalID failed") + span.RecordError(setExtIDErr) + args = append(args, "error", setExtIDErr) + } + + logging.FromContext(ctx).Info("SecureValueMetadataStorage.SetExternalID", args...) + s.metrics.SecureValueSetExternalIDDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds()) + }() + req := updateExternalIdSecureValue{ SQLTemplate: sqltemplate.New(s.dialect), Namespace: namespace.String(), @@ -459,7 +505,6 @@ func (s *secureValueMetadataStorage) SetExternalID(ctx context.Context, namespac if modifiedCount > 1 { return fmt.Errorf("secureValueMetadataStorage.SetExternalID: modified more than one secret, this is a bug, check the where condition: modifiedCount=%d", modifiedCount) } - s.metrics.SecureValueSetExternalIDDuration.Observe(time.Since(start).Seconds()) return nil } diff --git a/pkg/storage/secret/metadata/secure_value_test.go b/pkg/storage/secret/metadata/secure_value_test.go index 7f387847848..7747751753d 100644 --- a/pkg/storage/secret/metadata/secure_value_test.go +++ b/pkg/storage/secret/metadata/secure_value_test.go @@ -5,15 +5,16 @@ import ( "slices" "testing" - secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" - "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" - "github.com/grafana/grafana/pkg/registry/apis/secret/testutils" - "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" "github.com/mitchellh/copystructure" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" "pgregory.net/rapid" + + secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" + "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + "github.com/grafana/grafana/pkg/registry/apis/secret/testutils" + "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" ) type modelSecureValue struct { @@ -404,7 +405,7 @@ func TestStateMachine(t *testing.T) { "decrypt": func(t *rapid.T) { input := decryptGen.Draw(t, "decryptInput") modelResult, modelErr := model.decrypt(input.decrypter, input.namespace, input.name) - result, err := sut.DecryptService.Decrypt(t.Context(), input.decrypter, input.namespace, []string{input.name}) + result, err := sut.DecryptService.Decrypt(t.Context(), input.decrypter, input.namespace, input.name) if err != nil || modelErr != nil { require.ErrorIs(t, err, modelErr) return @@ -439,7 +440,7 @@ func TestSecureValueServiceExampleBased(t *testing.T) { require.NoError(t, err) require.Equal(t, sv.Status.Version, deletedSv.Status.Version) - result, err := sut.DecryptService.Decrypt(t.Context(), sv.Spec.Decrypters[0], sv.Namespace, []string{sv.Name}) + result, err := sut.DecryptService.Decrypt(t.Context(), sv.Spec.Decrypters[0], sv.Namespace, sv.Name) require.NoError(t, err) require.Equal(t, 1, len(result)) require.ErrorIs(t, result[sv.Name].Error(), contracts.ErrDecryptNotFound) diff --git a/pkg/storage/unified/README.md b/pkg/storage/unified/README.md index f60b6c937db..b33d2aa3660 100644 --- a/pkg/storage/unified/README.md +++ b/pkg/storage/unified/README.md @@ -296,6 +296,11 @@ docker in case you don't have one: docker run -d --name db -e "MYSQL_DATABASE=grafana" -e "MYSQL_USER=grafana" -e "MYSQL_PASSWORD=grafana" -e "MYSQL_ROOT_PASSWORD=root" -p 3306:3306 docker.io/bitnami/mysql:8.0.31 ``` +or use our mysql docker block: +```sh +make devenv sources=mysql +``` + ### 2. Create dedicated ini files for every service Example distributor ini file: @@ -304,7 +309,7 @@ Example distributor ini file: * Bind and join `memberlist` on `127.0.0.1:7946` (default memberlist port) ```ini -target = distributor +target = search-server-distributor [server] http_port = 3000 @@ -404,6 +409,7 @@ path = grafana1.db [grafana-apiserver] address = 127.0.0.1:10000 storage_type = unified-grpc +search_server_address = 127.0.0.1:10000 [server] protocol = http @@ -443,9 +449,9 @@ Repeat for the other services. ./bin/grafana server target --config conf/storage-api-2.ini ./bin/grafana server target --config conf/storage-api-3.ini -./bin/grafana server target --config conf/grafana1.ini -./bin/grafana server target --config conf/grafana2.ini -./bin/grafana server target --config conf/grafana3.ini +./bin/grafana server --config conf/grafana1.ini +./bin/grafana server --config conf/grafana2.ini +./bin/grafana server --config conf/grafana3.ini ``` etc diff --git a/pkg/storage/unified/apistore/prepare.go b/pkg/storage/unified/apistore/prepare.go index 19ca5aeca9c..a7f94a69706 100644 --- a/pkg/storage/unified/apistore/prepare.go +++ b/pkg/storage/unified/apistore/prepare.go @@ -5,9 +5,9 @@ import ( "context" "errors" "fmt" - "math" "time" + "github.com/dustin/go-humanize" "github.com/google/uuid" apiequality "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -16,59 +16,97 @@ import ( "k8s.io/apiserver/pkg/storage" "k8s.io/klog/v2" - authtypes "github.com/grafana/authlib/types" - + authlib "github.com/grafana/authlib/types" + "github.com/grafana/grafana-app-sdk/logging" + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/utils" + secrets "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" ) -func logN(n, b float64) float64 { - return math.Log(n) / math.Log(b) +type objectForStorage struct { + // The value to save in unistore + raw bytes.Buffer + + // Reference to the owner object + ref common.ObjectReference + + // apply permissions after create (defined in the resource body) + grantPermissions string + + // Synchronous AfterCreate permissions -- allows users to become "admin" of the thing they made + permissionCreator permissionCreatorFunc + + // These secrets where created, should be cleaned up if storage fails + createdSecureValues []string + + // These should be deleted if storage succeeds + deleteSecureValues []string + + // We know something changed + // This will ensure that the generation increments + hasChanged bool } -// Slightly modified function from https://github.com/dustin/go-humanize (MIT). -func formatBytes(numBytes int) string { - base := 1024.0 - sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"} - if numBytes < 10 { - return fmt.Sprintf("%d B", numBytes) +func (v *objectForStorage) finish(ctx context.Context, err error, secrets secrets.InlineSecureValueSupport) error { + if err != nil { + // Remove the secure values that were created + for _, s := range v.createdSecureValues { + if e := secrets.DeleteWhenOwnedByResource(ctx, v.ref, s); e != nil { + logging.FromContext(ctx).Warn("unable to clean up new secure value", "name", s, "err", e) + } + } + return err } - e := math.Floor(logN(float64(numBytes), base)) - suffix := sizes[int(e)] - val := math.Floor(float64(numBytes)/math.Pow(base, e)*10+0.5) / 10 - return fmt.Sprintf("%.1f %s", val, suffix) + + // Delete secure values after successfully saving the object + if len(v.deleteSecureValues) > 0 { + for _, s := range v.deleteSecureValues { + if e := secrets.DeleteWhenOwnedByResource(ctx, v.ref, s); e != nil { + logging.FromContext(ctx).Warn("unable to clean up new secure value", "name", s, "err", e) + } + } + } + + // Create permissions + if v.permissionCreator != nil { + return v.permissionCreator(ctx) + } + + return nil } // Called on create -func (s *Storage) prepareObjectForStorage(ctx context.Context, newObject runtime.Object) ([]byte, string, error) { - info, ok := authtypes.AuthInfoFrom(ctx) +func (s *Storage) prepareObjectForStorage(ctx context.Context, newObject runtime.Object) (objectForStorage, error) { + v := objectForStorage{} + info, ok := authlib.AuthInfoFrom(ctx) if !ok { - return nil, "", errors.New("missing auth info") + return v, errors.New("missing auth info") } obj, err := utils.MetaAccessor(newObject) if err != nil { - return nil, "", err + return v, err } if obj.GetName() == "" { - return nil, "", storage.NewInvalidObjError("", "missing name") + return v, storage.NewInvalidObjError("", "missing name") } if obj.GetResourceVersion() != "" { - return nil, "", storage.ErrResourceVersionSetOnCreate + return v, storage.ErrResourceVersionSetOnCreate } if obj.GetUID() == "" { obj.SetUID(types.UID(uuid.NewString())) } if obj.GetFolder() != "" && !s.opts.EnableFolderSupport { - return nil, "", apierrors.NewBadRequest(fmt.Sprintf("folders are not supported for: %s", s.gr.String())) + return v, apierrors.NewBadRequest(fmt.Sprintf("folders are not supported for: %s", s.gr.String())) } - grantPermisions := obj.GetAnnotation(utils.AnnoKeyGrantPermissions) - if grantPermisions != "" { + v.grantPermissions = obj.GetAnnotation(utils.AnnoKeyGrantPermissions) + if v.grantPermissions != "" { obj.SetAnnotation(utils.AnnoKeyGrantPermissions, "") // remove the annotation } if err := checkManagerPropertiesOnCreate(info, obj); err != nil { - return nil, "", err + return v, err } if s.opts.RequireDeprecatedInternalID { @@ -92,33 +130,37 @@ func (s *Storage) prepareObjectForStorage(ctx context.Context, newObject runtime obj.SetCreatedBy(info.GetUID()) obj.SetGeneration(1) // the first time we write - var buf bytes.Buffer - if err = s.codec.Encode(newObject, &buf); err != nil { - return nil, "", err + err = prepareSecureValues(ctx, s.opts.SecureValues, obj, nil, &v) + if err != nil { + return v, err } - val, err := s.handleLargeResources(ctx, obj, buf) - return val, grantPermisions, err + err = s.codec.Encode(newObject, &v.raw) + if err == nil { + err = s.handleLargeResources(ctx, obj, &v.raw) + } + return v, err } // Called on update -func (s *Storage) prepareObjectForUpdate(ctx context.Context, updateObject runtime.Object, previousObject runtime.Object) ([]byte, error) { - info, ok := authtypes.AuthInfoFrom(ctx) +func (s *Storage) prepareObjectForUpdate(ctx context.Context, updateObject runtime.Object, previousObject runtime.Object) (objectForStorage, error) { + v := objectForStorage{} + info, ok := authlib.AuthInfoFrom(ctx) if !ok { - return nil, errors.New("missing auth info") + return v, errors.New("missing auth info") } obj, err := utils.MetaAccessor(updateObject) if err != nil { - return nil, err + return v, err } if obj.GetName() == "" { - return nil, fmt.Errorf("updated object must have a name") + return v, fmt.Errorf("updated object must have a name") } previous, err := utils.MetaAccessor(previousObject) if err != nil { - return nil, err + return v, err } if previous.GetUID() == "" { @@ -133,7 +175,7 @@ func (s *Storage) prepareObjectForUpdate(ctx context.Context, updateObject runti } if obj.GetName() != previous.GetName() { - return nil, fmt.Errorf("name mismatch between existing and updated object") + return v, fmt.Errorf("name mismatch between existing and updated object") } obj.SetCreatedBy(previous.GetCreatedBy()) @@ -148,34 +190,39 @@ func (s *Storage) prepareObjectForUpdate(ctx context.Context, updateObject runti obj.SetDeprecatedInternalID(previousInternalID) // nolint:staticcheck } + err = prepareSecureValues(ctx, s.opts.SecureValues, obj, previous, &v) + if err != nil { + return v, err + } + // Check if we should bump the generation - changed := obj.GetFolder() != previous.GetFolder() - if changed { + if obj.GetFolder() != previous.GetFolder() { if !s.opts.EnableFolderSupport { - return nil, apierrors.NewBadRequest(fmt.Sprintf("folders are not supported for: %s", s.gr.String())) + return v, apierrors.NewBadRequest(fmt.Sprintf("folders are not supported for: %s", s.gr.String())) } // TODO: check that we can move the folder? + v.hasChanged = true } else if obj.GetDeletionTimestamp() != nil && previous.GetDeletionTimestamp() == nil { - changed = true // bump generation when deleted - } else { + v.hasChanged = true // bump generation when deleted + } else if !v.hasChanged { spec, e1 := obj.GetSpec() oldSpec, e2 := previous.GetSpec() if e1 == nil && e2 == nil { if !apiequality.Semantic.DeepEqual(spec, oldSpec) { - changed = true + v.hasChanged = true } } } // Mark the resource as changed - if changed { + if v.hasChanged { obj.SetGeneration(previous.GetGeneration() + 1) obj.SetUpdatedBy(info.GetUID()) obj.SetUpdatedTimestampMillis(time.Now().UnixMilli()) // Only validate when the generation has changed if err := checkManagerPropertiesOnUpdateSpec(info, obj, previous); err != nil { - return nil, err + return v, err } } else { obj.SetGeneration(previous.GetGeneration()) @@ -183,19 +230,20 @@ func (s *Storage) prepareObjectForUpdate(ctx context.Context, updateObject runti obj.SetAnnotation(utils.AnnoKeyUpdatedTimestamp, previous.GetAnnotation(utils.AnnoKeyUpdatedTimestamp)) } - var buf bytes.Buffer - if err = s.codec.Encode(updateObject, &buf); err != nil { - return nil, err + err = s.codec.Encode(updateObject, &v.raw) + if err == nil { + err = s.handleLargeResources(ctx, obj, &v.raw) } - return s.handleLargeResources(ctx, obj, buf) + return v, err } -func (s *Storage) handleLargeResources(ctx context.Context, obj utils.GrafanaMetaAccessor, buf bytes.Buffer) ([]byte, error) { +// The bytes buffer will be reset with the proper value +func (s *Storage) handleLargeResources(ctx context.Context, obj utils.GrafanaMetaAccessor, buf *bytes.Buffer) error { support := s.opts.LargeObjectSupport size := buf.Len() if support != nil && size > support.Threshold() { if support.MaxSize() > 0 && size > support.MaxSize() { - return nil, fmt.Errorf("request object is too big (%s > %s)", formatBytes(size), formatBytes(support.MaxSize())) + return fmt.Errorf("request object is too big (%s > %s)", humanize.Bytes(uint64(size)), humanize.Bytes(uint64(support.MaxSize()))) } key := &resourcepb.ResourceKey{ @@ -207,19 +255,17 @@ func (s *Storage) handleLargeResources(ctx context.Context, obj utils.GrafanaMet err := support.Deconstruct(ctx, key, s.store, obj, buf.Bytes()) if err != nil { - return nil, err + return err } buf.Reset() orig, ok := obj.GetRuntimeObject() if !ok { - return nil, fmt.Errorf("error using object as runtime object") + return fmt.Errorf("error using object as runtime object") } // Now encode the smaller version - if err = s.codec.Encode(orig, &buf); err != nil { - return nil, err - } + return s.codec.Encode(orig, buf) } - return buf.Bytes(), nil + return nil } diff --git a/pkg/storage/unified/apistore/prepare_test.go b/pkg/storage/unified/apistore/prepare_test.go index b11081dea9b..1501b63da40 100644 --- a/pkg/storage/unified/apistore/prepare_test.go +++ b/pkg/storage/unified/apistore/prepare_test.go @@ -15,7 +15,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apiserver/pkg/storage" - authtypes "github.com/grafana/authlib/types" + authlib "github.com/grafana/authlib/types" dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" @@ -37,19 +37,19 @@ func TestPrepareObjectForStorage(t *testing.T) { }, } - ctx := authtypes.WithAuthInfo(context.Background(), - &identity.StaticRequester{UserID: 1, UserUID: "user-uid", Type: authtypes.TypeUser}, + ctx := authlib.WithAuthInfo(context.Background(), + &identity.StaticRequester{UserID: 1, UserUID: "user-uid", Type: authlib.TypeUser}, ) t.Run("Error getting auth info from context", func(t *testing.T) { - _, _, err := s.prepareObjectForStorage(context.Background(), nil) + _, err := s.prepareObjectForStorage(context.Background(), nil) require.Error(t, err) require.Contains(t, err.Error(), "missing auth info") }) t.Run("Error on missing name", func(t *testing.T) { dashboard := dashv1.Dashboard{} - _, _, err := s.prepareObjectForStorage(ctx, dashboard.DeepCopyObject()) + _, err := s.prepareObjectForStorage(ctx, dashboard.DeepCopyObject()) require.Error(t, err) require.Contains(t, err.Error(), "missing name") }) @@ -58,7 +58,7 @@ func TestPrepareObjectForStorage(t *testing.T) { dashboard := dashv1.Dashboard{} dashboard.Name = "test-name" dashboard.ResourceVersion = "123" - _, _, err := s.prepareObjectForStorage(ctx, dashboard.DeepCopyObject()) + _, err := s.prepareObjectForStorage(ctx, dashboard.DeepCopyObject()) require.Error(t, err) require.Equal(t, storage.ErrResourceVersionSetOnCreate, err) }) @@ -67,10 +67,10 @@ func TestPrepareObjectForStorage(t *testing.T) { dashboard := dashv1.Dashboard{} dashboard.Name = "test-name" - encodedData, _, err := s.prepareObjectForStorage(ctx, dashboard.DeepCopyObject()) + v, err := s.prepareObjectForStorage(ctx, dashboard.DeepCopyObject()) require.NoError(t, err) - newObject, _, err := s.codec.Decode(encodedData, nil, &dashv1.Dashboard{}) + newObject, _, err := s.codec.Decode(v.raw.Bytes(), nil, &dashv1.Dashboard{}) require.NoError(t, err) obj, err := utils.MetaAccessor(newObject) require.NoError(t, err) @@ -106,10 +106,10 @@ func TestPrepareObjectForStorage(t *testing.T) { TimestampMillis: now.UnixMilli(), }) - encodedData, _, err := s.prepareObjectForStorage(ctx, obj) + v, err := s.prepareObjectForStorage(ctx, obj) require.NoError(t, err) - newObject, _, err := s.codec.Decode(encodedData, nil, &dashv1.Dashboard{}) + newObject, _, err := s.codec.Decode(v.raw.Bytes(), nil, &dashv1.Dashboard{}) require.NoError(t, err) meta, err = utils.MetaAccessor(newObject) require.NoError(t, err) @@ -133,10 +133,10 @@ func TestPrepareObjectForStorage(t *testing.T) { meta.SetFolder("aaa") require.NoError(t, err) - encodedData, _, err := s.prepareObjectForStorage(ctx, obj) + v, err := s.prepareObjectForStorage(ctx, obj) require.NoError(t, err) - insertedObject, _, err := s.codec.Decode(encodedData, nil, &dashv1.Dashboard{}) + insertedObject, _, err := s.codec.Decode(v.raw.Bytes(), nil, &dashv1.Dashboard{}) require.NoError(t, err) meta, err = utils.MetaAccessor(insertedObject) require.NoError(t, err) @@ -148,8 +148,8 @@ func TestPrepareObjectForStorage(t *testing.T) { require.Nil(t, ts) // Change the user... and only update metadata - ctx = authtypes.WithAuthInfo(context.Background(), - &identity.StaticRequester{UserID: 1, UserUID: "user2", Type: authtypes.TypeUser}, + ctx = authlib.WithAuthInfo(context.Background(), + &identity.StaticRequester{UserID: 1, UserUID: "user2", Type: authlib.TypeUser}, ) // Change the status... but generation is the same @@ -189,9 +189,9 @@ func TestPrepareObjectForStorage(t *testing.T) { dashboard := dashv1.Dashboard{} dashboard.Name = "test-name" - encodedData, _, err := s.prepareObjectForStorage(ctx, dashboard.DeepCopyObject()) + v, err := s.prepareObjectForStorage(ctx, dashboard.DeepCopyObject()) require.NoError(t, err) - newObject, _, err := s.codec.Decode(encodedData, nil, &dashv1.Dashboard{}) + newObject, _, err := s.codec.Decode(v.raw.Bytes(), nil, &dashv1.Dashboard{}) require.NoError(t, err) obj, err := utils.MetaAccessor(newObject) require.NoError(t, err) @@ -208,9 +208,9 @@ func TestPrepareObjectForStorage(t *testing.T) { require.NoError(t, err) meta.SetDeprecatedInternalID(1) // nolint:staticcheck - encodedData, _, err := s.prepareObjectForStorage(ctx, obj) + v, err := s.prepareObjectForStorage(ctx, obj) require.NoError(t, err) - newObject, _, err := s.codec.Decode(encodedData, nil, &dashv1.Dashboard{}) + newObject, _, err := s.codec.Decode(v.raw.Bytes(), nil, &dashv1.Dashboard{}) require.NoError(t, err) meta, err = utils.MetaAccessor(newObject) require.NoError(t, err) @@ -225,14 +225,14 @@ func TestPrepareObjectForStorage(t *testing.T) { require.NoError(t, err) meta.SetAnnotation(utils.AnnoKeyGrantPermissions, "default") - encodedData, p, err := s.prepareObjectForStorage(ctx, obj) + v, err := s.prepareObjectForStorage(ctx, obj) require.NoError(t, err) - newObject, _, err := s.codec.Decode(encodedData, nil, &dashv1.Dashboard{}) + newObject, _, err := s.codec.Decode(v.raw.Bytes(), nil, &dashv1.Dashboard{}) require.NoError(t, err) meta, err = utils.MetaAccessor(newObject) require.NoError(t, err) require.Empty(t, meta.GetAnnotation(utils.AnnoKeyGrantPermissions)) - require.Equal(t, p, "default") + require.Equal(t, v.grantPermissions, "default") }) t.Run("calculate generation", func(t *testing.T) { @@ -295,23 +295,56 @@ func TestPrepareObjectForStorage(t *testing.T) { require.Equal(t, int64(1), out.GetGeneration()) // still 1 }) }) + + t.Run("should fail invalid input", func(t *testing.T) { + _, err := s.prepareObjectForStorage(context.Background(), &dashv1.Dashboard{}) + require.Error(t, err) + require.Contains(t, err.Error(), "missing auth info") + + _, err = s.prepareObjectForUpdate(context.Background(), &dashv1.Dashboard{}, &dashv1.Dashboard{}) + require.Error(t, err) + require.Contains(t, err.Error(), "missing auth info") + + _, err = s.prepareObjectForStorage(ctx, &dashv1.Dashboard{}) + require.Error(t, err) + require.Contains(t, err.Error(), "missing name") + + _, err = s.prepareObjectForUpdate(ctx, &dashv1.Dashboard{}, &dashv1.Dashboard{}) + require.Error(t, err) + require.Contains(t, err.Error(), "updated object must have a name") + + _, err = s.prepareObjectForUpdate(ctx, &dashv1.Dashboard{ObjectMeta: v1.ObjectMeta{ + Name: "test-name", + }}, &dashv1.Dashboard{ObjectMeta: v1.ObjectMeta{ + Name: "not-the-same-name", + }}) + require.Error(t, err) + require.Contains(t, err.Error(), "name mismatch between") + + _, err = s.prepareObjectForStorage(ctx, &dashv1.Dashboard{ObjectMeta: v1.ObjectMeta{ + Name: "test-name", + ResourceVersion: "123", // RV must not be set + }}) + require.Error(t, err) + require.Equal(t, storage.ErrResourceVersionSetOnCreate, err) + }) } func getPreparedObject(t *testing.T, ctx context.Context, s *Storage, obj runtime.Object, old runtime.Object) utils.GrafanaMetaAccessor { t.Helper() - var raw []byte + var v objectForStorage var err error if old == nil { - raw, _, err = s.prepareObjectForStorage(ctx, obj) + v, err = s.prepareObjectForStorage(ctx, obj) } else { - raw, err = s.prepareObjectForUpdate(ctx, obj, old) + v, err = s.prepareObjectForUpdate(ctx, obj, old) } require.NoError(t, err) out := &unstructured.Unstructured{} - err = out.UnmarshalJSON(raw) + err = out.UnmarshalJSON(v.raw.Bytes()) require.NoError(t, err) meta, err := utils.MetaAccessor(out) @@ -324,7 +357,7 @@ func TestPrepareLargeObjectForStorage(t *testing.T) { node, err := snowflake.NewNode(rand.Int64N(1024)) require.NoError(t, err) - ctx := authtypes.WithAuthInfo(context.Background(), &identity.StaticRequester{UserID: 1, UserUID: "user-uid", Type: authtypes.TypeUser}) + ctx := authlib.WithAuthInfo(context.Background(), &identity.StaticRequester{UserID: 1, UserUID: "user-uid", Type: authlib.TypeUser}) dashboard := dashv1.Dashboard{} dashboard.Name = "test-name" @@ -341,7 +374,7 @@ func TestPrepareLargeObjectForStorage(t *testing.T) { }, } - _, _, err := f.prepareObjectForStorage(ctx, dashboard.DeepCopyObject()) + _, err := f.prepareObjectForStorage(ctx, dashboard.DeepCopyObject()) require.Nil(t, err) require.True(t, los.deconstructed) }) @@ -359,7 +392,7 @@ func TestPrepareLargeObjectForStorage(t *testing.T) { }, } - _, _, err := f.prepareObjectForStorage(ctx, dashboard.DeepCopyObject()) + _, err := f.prepareObjectForStorage(ctx, dashboard.DeepCopyObject()) require.Nil(t, err) require.False(t, los.deconstructed) }) diff --git a/pkg/storage/unified/apistore/restoptions.go b/pkg/storage/unified/apistore/restoptions.go index 42c91e9219c..53ce1d2bb2a 100644 --- a/pkg/storage/unified/apistore/restoptions.go +++ b/pkg/storage/unified/apistore/restoptions.go @@ -19,6 +19,7 @@ import ( flowcontrolrequest "k8s.io/apiserver/pkg/util/flowcontrol/request" "k8s.io/client-go/tools/cache" + secret "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" "github.com/grafana/grafana/pkg/storage/unified/resource" ) @@ -28,6 +29,7 @@ type StorageOptionsRegister func(gr schema.GroupResource, opts StorageOptions) type RESTOptionsGetter struct { client resource.ResourceClient + secrets secret.InlineSecureValueSupport original storagebackend.Config configProvider RestConfigProvider @@ -35,16 +37,22 @@ type RESTOptionsGetter struct { options map[string]StorageOptions } -func NewRESTOptionsGetterForClient(client resource.ResourceClient, original storagebackend.Config, configProvider RestConfigProvider) *RESTOptionsGetter { +func NewRESTOptionsGetterForClient( + client resource.ResourceClient, + secrets secret.InlineSecureValueSupport, + original storagebackend.Config, + configProvider RestConfigProvider, +) *RESTOptionsGetter { return &RESTOptionsGetter{ client: client, + secrets: secrets, original: original, options: make(map[string]StorageOptions), configProvider: configProvider, } } -func NewRESTOptionsGetterMemory(originalStorageConfig storagebackend.Config) (*RESTOptionsGetter, error) { +func NewRESTOptionsGetterMemory(originalStorageConfig storagebackend.Config, secrets secret.InlineSecureValueSupport) (*RESTOptionsGetter, error) { backend, err := resource.NewCDKBackend(context.Background(), resource.CDKBackendOptions{ Bucket: memblob.OpenBucket(&memblob.Options{}), }) @@ -59,6 +67,7 @@ func NewRESTOptionsGetterMemory(originalStorageConfig storagebackend.Config) (*R } return NewRESTOptionsGetterForClient( resource.NewLocalResourceClient(server), + secrets, originalStorageConfig, nil, ), nil @@ -67,7 +76,7 @@ func NewRESTOptionsGetterMemory(originalStorageConfig storagebackend.Config) (*R // Optionally, this constructor allows specifying directories // for resources that are required to be read/watched on startup and there // won't be any write operations that initially bootstrap their directories -func NewRESTOptionsGetterForFile(path string, +func NewRESTOptionsGetterForFileXX(path string, originalStorageConfig storagebackend.Config, features map[string]any) (*RESTOptionsGetter, error) { if path == "" { @@ -95,6 +104,7 @@ func NewRESTOptionsGetterForFile(path string, } return NewRESTOptionsGetterForClient( resource.NewLocalResourceClient(server), + nil, // secrets originalStorageConfig, nil, ), nil @@ -137,8 +147,10 @@ func (r *RESTOptionsGetter) GetRESTOptions(resource schema.GroupResource, _ runt trigger storage.IndexerFuncs, indexers *cache.Indexers, ) (storage.Interface, factory.DestroyFunc, error) { + opts := r.options[resource.String()] + opts.SecureValues = r.secrets return NewStorage(config, r.client, keyFunc, nil, newFunc, newListFunc, getAttrsFunc, - trigger, indexers, r.configProvider, r.options[resource.String()]) + trigger, indexers, r.configProvider, opts) }, DeleteCollectionWorkers: 0, EnableGarbageCollection: false, diff --git a/pkg/storage/unified/apistore/secure.go b/pkg/storage/unified/apistore/secure.go new file mode 100644 index 00000000000..3f88ba19a63 --- /dev/null +++ b/pkg/storage/unified/apistore/secure.go @@ -0,0 +1,149 @@ +package apistore + +import ( + "context" + "fmt" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" + secret "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" +) + +// prepareSecureValues will create any new secure values and register changes inside the provided objectForStorage +// any call to this function MUST be followed by a call to info.finish(ctx, nil, store) to ensure that the secure values are cleaned up +func prepareSecureValues(ctx context.Context, store secret.InlineSecureValueSupport, obj utils.GrafanaMetaAccessor, previousObject utils.GrafanaMetaAccessor, v *objectForStorage) (err error) { + secure, err := obj.GetSecureValues() + if err != nil { + return err + } + + // Owner reference for inline values + v.ref = utils.ToObjectReference(obj) + + var previous common.InlineSecureValues + if previousObject == nil { + if len(secure) == 0 { + return nil // nothing needs to change + } + if store == nil { + return fmt.Errorf("secure value support is not configured (create)") + } + previous = make(common.InlineSecureValues, 0) + } else { + // Merge in any values from the previous object and handle remove + previous, err = previousObject.GetSecureValues() + if err != nil { + return err + } + for _, p := range previous { + if p.Name == "" || p.Remove || !p.Create.IsZero() { + return fmt.Errorf("invalid state, saved values must only have a name") + } + } + + // Keep exactly what we had before + if len(secure) == 0 { + if len(previous) > 0 { + return obj.SetSecureValues(previous) + } + return nil + } + + if store == nil { + return fmt.Errorf("secure value support is not configured (update)") + } + } + + for k, val := range secure { + before := previous[k] + if val.Name == "" { + if before.Name != "" { // implicitly delete previous secure value if the same field no longer references it + v.deleteSecureValues = append(v.deleteSecureValues, before.Name) + delete(previous, k) + } + if val.Remove { + if before.Name == "" { + return fmt.Errorf("cannot remove secure value '%s', it did not exist in the previous value", k) + } + delete(secure, k) + v.hasChanged = true + continue + } + if !val.Create.IsZero() { + n, err := store.CreateInline(ctx, v.ref, val.Create) + if err != nil { + return err + } + v.createdSecureValues = append(v.createdSecureValues, n) + v.hasChanged = true + secure[k] = common.InlineSecureValue{Name: n} + continue + } + return fmt.Errorf("invalid secure value state: %s", k) + } + + // The name changed from the previously stored value + if before.Name != "" && before.Name != val.Name { + // This can happen when explicitly shifting from an inline value to a shared secret + v.deleteSecureValues = append(v.deleteSecureValues, before.Name) + v.hasChanged = true + } + + delete(previous, k) + } + + // Keep all previous values that were not referenced in the update + for k, v := range previous { + _, found := secure[k] + if !found { + secure[k] = v // the previous value + } + } + + return cleanupSecureValues(v, obj, secure) +} + +// make sure the registered changes are unique and valid +func cleanupSecureValues(v *objectForStorage, obj utils.GrafanaMetaAccessor, secure common.InlineSecureValues) error { + // Make sure the deleted list is unique and does not contain any referenced values + if len(v.deleteSecureValues) > 0 && len(secure) > 0 { + confirm := v.deleteSecureValues + v.deleteSecureValues = make([]string, 0, len(v.deleteSecureValues)) + used := make(map[string]bool, len(secure)) + for _, v := range secure { + used[v.Name] = true + } + for _, name := range confirm { + if _, ok := used[name]; ok { + continue + } + used[name] = true + v.deleteSecureValues = append(v.deleteSecureValues, name) + } + } + + if len(v.deleteSecureValues) > 0 || len(v.createdSecureValues) > 0 { + v.hasChanged = true + } + return obj.SetSecureValues(secure) +} + +// Mutation hook that will delete secure values +func handleSecureValuesDelete(ctx context.Context, store secret.InlineSecureValueSupport, obj utils.GrafanaMetaAccessor) error { + secure, err := obj.GetSecureValues() + if err != nil || len(secure) == 0 { + return err + } + + if store == nil { + return fmt.Errorf("secure value support is not configured (delete)") + } + + owner := utils.ToObjectReference(obj) + for _, v := range secure { + if err = store.DeleteWhenOwnedByResource(ctx, owner, v.Name); err != nil { + return err + } + } + return obj.SetSecureValues(nil) // remove them from the object +} diff --git a/pkg/storage/unified/apistore/secure_test.go b/pkg/storage/unified/apistore/secure_test.go new file mode 100644 index 00000000000..fb36d2910bb --- /dev/null +++ b/pkg/storage/unified/apistore/secure_test.go @@ -0,0 +1,286 @@ +package apistore + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/registry/apis/secret" +) + +func TestSecureLifecycle(t *testing.T) { + resourceWithSecureValues := func(sv common.InlineSecureValues) utils.GrafanaMetaAccessor { + obj, err := utils.MetaAccessor(&unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "something.grafana.app/v1beta1", + "kind": "CustomKind", + "metadata": map[string]any{ + "namespace": "default", + "name": "test", + }, + "secure": sv, + }, + }) + require.NoError(t, err) + return obj + } + + t.Run("create secure values", func(t *testing.T) { + secureStore := secret.NewMockInlineSecureValueSupport(t) + secureStore.On("CreateInline", mock.Anything, mock.Anything, common.RawSecureValue("SecretAAA")). + Return("NameForA", nil).Once() + secureStore.On("CreateInline", mock.Anything, mock.Anything, common.RawSecureValue("SecretBBB")). + Return("NameForB", nil).Once() + + info := &objectForStorage{} + obj := resourceWithSecureValues(common.InlineSecureValues{ + "a": common.InlineSecureValue{Create: "SecretAAA"}, + "b": common.InlineSecureValue{Create: "SecretBBB"}, + }) + + err := prepareSecureValues(context.Background(), secureStore, obj, nil, info) + require.NoError(t, err) + require.True(t, info.hasChanged) + slices.Sort(info.createdSecureValues) // keep a predictable order + require.Equal(t, []string{"NameForA", "NameForB"}, info.createdSecureValues) + secure, err := obj.GetSecureValues() + require.NoError(t, err) + require.JSONEq(t, `{ + "a": {"name": "NameForA"}, + "b": {"name": "NameForB"} + }`, asJSON(secure, true)) + secureStore.AssertExpectations(t) + }) + + t.Run("create secure values with errors", func(t *testing.T) { + obj := resourceWithSecureValues(common.InlineSecureValues{ + "a": common.InlineSecureValue{Create: "SecretAAA"}, + "b": common.InlineSecureValue{Create: "SecretBBB"}, + }) + + info := &objectForStorage{} + expectError := fmt.Errorf("expected error") + secureStore := secret.NewMockInlineSecureValueSupport(t) + secureStore.On("CreateInline", mock.Anything, mock.Anything, common.RawSecureValue("SecretAAA")). + Return("", expectError).Maybe() + secureStore.On("CreateInline", mock.Anything, mock.Anything, common.RawSecureValue("SecretBBB")). + Return("", expectError).Maybe() + + err := prepareSecureValues(context.Background(), secureStore, obj, nil, info) + require.Error(t, err, "should error when secure value creation fails") + require.Equal(t, expectError, err, "error should be propagated") + secureStore.AssertExpectations(t) + }) + + t.Run("change name manually", func(t *testing.T) { + secureStore := secret.NewMockInlineSecureValueSupport(t) + + info := &objectForStorage{} + previous := resourceWithSecureValues(common.InlineSecureValues{ + "a": common.InlineSecureValue{Name: "111"}, + "b": common.InlineSecureValue{Name: "222"}, + "c": common.InlineSecureValue{Name: "333"}, + }) + obj := resourceWithSecureValues(common.InlineSecureValues{ + "a": common.InlineSecureValue{Name: "222"}, + "b": common.InlineSecureValue{Name: "333"}, // no change + // "c" will be loaded from the previous object without changes + }) + + err := prepareSecureValues(context.Background(), secureStore, obj, previous, info) + require.NoError(t, err) + require.True(t, info.hasChanged) + require.Empty(t, info.createdSecureValues) + require.Equal(t, info.deleteSecureValues, []string{"111"}) // will be removed if storage succeeds + secure, err := obj.GetSecureValues() + require.NoError(t, err) + require.JSONEq(t, `{ + "a": {"name": "222"}, + "b": {"name": "333"}, + "c": {"name": "333"} + }`, asJSON(secure, true)) + }) + + t.Run("update without secrets", func(t *testing.T) { + secureStore := secret.NewMockInlineSecureValueSupport(t) + + info := &objectForStorage{} + previousObject := resourceWithSecureValues(common.InlineSecureValues{ + "a": common.InlineSecureValue{Name: "NameForA"}, + "b": common.InlineSecureValue{Name: "NameForB"}, + }) + objWithoutSecrets := resourceWithSecureValues(nil) + + // Note that the secure values from the previous object are copied over + err := prepareSecureValues(context.Background(), secureStore, objWithoutSecrets, previousObject, info) + require.NoError(t, err) + require.False(t, info.hasChanged) + secure, err := objWithoutSecrets.GetSecureValues() + require.NoError(t, err) + require.JSONEq(t, `{ + "a": {"name": "NameForA"}, + "b": {"name": "NameForB"} + }`, asJSON(secure, true)) + }) + + t.Run("remove secure values", func(t *testing.T) { + secureStore := secret.NewMockInlineSecureValueSupport(t) + previous := resourceWithSecureValues(common.InlineSecureValues{ + "a": common.InlineSecureValue{Name: "NameForA"}, + "b": common.InlineSecureValue{Name: "NameForB"}, + "c": common.InlineSecureValue{Name: "NameForC"}, + }) + + // Remove "b" with an explicit command + obj := resourceWithSecureValues(common.InlineSecureValues{ + "a": common.InlineSecureValue{Name: "NameForA"}, // no change + "b": common.InlineSecureValue{Remove: true}, + // "c" will be loaded from the previous object + }) + + // Prepare secure values does not change anything when removing + info := &objectForStorage{} + err := prepareSecureValues(context.Background(), secureStore, obj, previous, info) + require.NoError(t, err) + require.True(t, info.hasChanged) // value was removed + secureStore.AssertExpectations(t) // nothing called + + secure, err := obj.GetSecureValues() + require.NoError(t, err) + require.JSONEq(t, `{ + "a": {"name": "NameForA"}, + "c": {"name": "NameForC"} + }`, asJSON(secure, true)) + + // When there is not an error, the finish command will do a real delete + owner := utils.ToObjectReference(obj) + secureStore.On("DeleteWhenOwnedByResource", mock.Anything, owner, "NameForB"). + Return(nil).Once() + err = info.finish(context.Background(), nil, secureStore) + require.NoError(t, err) + require.True(t, info.hasChanged) // value was removed + secureStore.AssertExpectations(t) // nothing called + + // When an error exists, no values will be deleted + err = fmt.Errorf("expected error") + outErr := info.finish(context.Background(), err, secureStore) + require.Equal(t, err, outErr, "error should be passed through") + }) + + t.Run("remove invalid secure values", func(t *testing.T) { + secureStore := secret.NewMockInlineSecureValueSupport(t) + obj := resourceWithSecureValues(common.InlineSecureValues{ + "b": common.InlineSecureValue{Remove: true}, + }) + + // Previous values must exist for remove to execute + info := &objectForStorage{} + err := prepareSecureValues(context.Background(), secureStore, obj, resourceWithSecureValues(nil), info) + require.Error(t, err, "should error when previous secure values does not exist") + require.Equal(t, "cannot remove secure value 'b', it did not exist in the previous value", err.Error()) + secureStore.AssertExpectations(t) + }) + + t.Run("delete resource", func(t *testing.T) { + secureStore := secret.NewMockInlineSecureValueSupport(t) + obj := resourceWithSecureValues(common.InlineSecureValues{ + "a": common.InlineSecureValue{Name: "NameForA"}, + }) + sv, err := obj.GetSecureValues() + require.NoError(t, err) + require.Len(t, sv, 1) + + owner := utils.ToObjectReference(obj) + secureStore.On("DeleteWhenOwnedByResource", mock.Anything, owner, "NameForA"). + Return(nil).Once() + + err = handleSecureValuesDelete(context.Background(), secureStore, obj) + require.NoError(t, err) + secureStore.AssertExpectations(t) + sv, err = obj.GetSecureValues() + require.NoError(t, err) + require.Empty(t, sv, "secure values should be empty after delete") + + // Delete should propagate deletion errors + obj = resourceWithSecureValues(common.InlineSecureValues{ + "a": common.InlineSecureValue{Name: "NameForA"}, + }) + expectError := fmt.Errorf("expected error") + secureStore = secret.NewMockInlineSecureValueSupport(t) + secureStore.On("DeleteWhenOwnedByResource", mock.Anything, owner, "NameForA"). + Return(expectError).Once() + err = handleSecureValuesDelete(context.Background(), secureStore, obj) + require.Equal(t, expectError, err, "error should be passed through") + secureStore.AssertExpectations(t) + }) + + t.Run("invalid states", func(t *testing.T) { + secureStore := secret.NewMockInlineSecureValueSupport(t) + + info := &objectForStorage{} + err := prepareSecureValues(context.Background(), secureStore, resourceWithSecureValues(common.InlineSecureValues{ + "a": common.InlineSecureValue{}, // MUST have Create, Remove or Name + }), nil, info) + require.Error(t, err) + }) + + t.Run("setup errors", func(t *testing.T) { + objWithoutSecrets := resourceWithSecureValues(nil) + objWithCreateSecret := resourceWithSecureValues(common.InlineSecureValues{ + "a": common.InlineSecureValue{Create: "SecretAAA"}, + }) + invalid, _ := utils.MetaAccessor(&unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "something.grafana.app/v1beta1", + "kind": "CustomKind", + "metadata": map[string]any{ + "namespace": "default", + "name": "test", + }, + "secure": t, // something NOT a secure value + }, + }) + info := &objectForStorage{} + err := prepareSecureValues(context.Background(), nil, invalid, nil, info) + require.Error(t, err, "should error when secure values are not a map") + + err = prepareSecureValues(context.Background(), nil, objWithCreateSecret, invalid, info) + require.Error(t, err, "should error when previous secure values are not a map") + + err = prepareSecureValues(context.Background(), nil, objWithCreateSecret, nil, info) + require.Error(t, err, "should error when secure value storage is not configured") + + err = prepareSecureValues(context.Background(), nil, objWithCreateSecret, objWithoutSecrets, info) + require.Error(t, err, "should error when secure value storage is not configured") + + err = prepareSecureValues(context.Background(), nil, objWithoutSecrets, objWithCreateSecret, info) + require.Error(t, err, "should error when previous value does not have a name") + + // DELETE Setup errors + err = handleSecureValuesDelete(context.Background(), nil, invalid) + require.Error(t, err, "should error when secure values are not a map") + + err = handleSecureValuesDelete(context.Background(), nil, objWithCreateSecret) + require.Error(t, err, "should error when secure value storage is not configured") + }) +} + +func asJSON(v any, pretty bool) string { + if v == nil { + return "" + } + if pretty { + bytes, _ := json.MarshalIndent(v, "", " ") + return string(bytes) + } + bytes, _ := json.Marshal(v) + return string(bytes) +} diff --git a/pkg/storage/unified/apistore/store.go b/pkg/storage/unified/apistore/store.go index c8580ca1fcc..52f36fecdcc 100644 --- a/pkg/storage/unified/apistore/store.go +++ b/pkg/storage/unified/apistore/store.go @@ -32,9 +32,10 @@ import ( "k8s.io/client-go/tools/cache" authtypes "github.com/grafana/authlib/types" - + "github.com/grafana/grafana-app-sdk/logging" "github.com/grafana/grafana/pkg/apimachinery/utils" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" + secrets "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" ) @@ -61,6 +62,9 @@ type StorageOptions struct { // Add internalID label when missing RequireDeprecatedInternalID bool + // Process inline secure values + SecureValues secrets.InlineSecureValueSupport + // Temporary fix to support adding default permissions AfterCreate Permissions DefaultPermissionSetter } @@ -183,33 +187,33 @@ func (s *Storage) convertToObject(data []byte, obj runtime.Object) (runtime.Obje // in seconds (0 means forever). If no error is returned and out is not nil, out will be // set to the read value from database. func (s *Storage) Create(ctx context.Context, key string, obj runtime.Object, out runtime.Object, ttl uint64) error { - var err error - var permissions string - req := &resourcepb.CreateRequest{} - req.Value, permissions, err = s.prepareObjectForStorage(ctx, obj) + v, err := s.prepareObjectForStorage(ctx, obj) if err != nil { return s.handleManagedResourceRouting(ctx, err, resourcepb.WatchEvent_ADDED, key, obj, out) } - + req := &resourcepb.CreateRequest{ + Value: v.raw.Bytes(), + } req.Key, err = s.getKey(key) if err != nil { return err } - grantPermissions, err := afterCreatePermissionCreator(ctx, req.Key, permissions, obj, s.opts.Permissions) + v.permissionCreator, err = afterCreatePermissionCreator(ctx, req.Key, v.grantPermissions, obj, s.opts.Permissions) if err != nil { return err } rsp, err := s.store.Create(ctx, req) if err != nil { - return resource.GetError(resource.AsErrorResult(err)) + return v.finish(ctx, resource.GetError(resource.AsErrorResult(err)), s.opts.SecureValues) } if rsp.Error != nil { + err = resource.GetError(rsp.Error) if rsp.Error.Code == http.StatusConflict { - return storage.NewKeyExistsError(key, 0) + err = storage.NewKeyExistsError(key, 0) } - return resource.GetError(rsp.Error) + return v.finish(ctx, err, s.opts.SecureValues) } if _, err := s.convertToObject(req.Value, out); err != nil { @@ -231,12 +235,7 @@ func (s *Storage) Create(ctx context.Context, key string, obj runtime.Object, ou }) } - // Synchronous AfterCreate permissions -- allows users to become "admin" of the thing they made - if grantPermissions != nil { - return grantPermissions(ctx) - } - - return nil + return v.finish(ctx, nil, s.opts.SecureValues) } // Delete removes the specified key and returns the value that existed at that spot. @@ -305,6 +304,11 @@ func (s *Storage) Delete( if rsp.Error != nil { return resource.GetError(rsp.Error) } + + if err = handleSecureValuesDelete(ctx, s.opts.SecureValues, meta); err != nil { + logging.FromContext(ctx).Warn("failed to delete inline secure values", "err", err) + } + if err := s.versioner.UpdateObject(out, uint64(rsp.ResourceVersion)); err != nil { return err } @@ -588,21 +592,27 @@ func (s *Storage) GuaranteedUpdate( break } - req.Value, err = s.prepareObjectForUpdate(ctx, updatedObj, existingObj) + v, err := s.prepareObjectForUpdate(ctx, updatedObj, existingObj) if err != nil { return s.handleManagedResourceRouting(ctx, err, resourcepb.WatchEvent_MODIFIED, key, updatedObj, destination) } - var rv uint64 // Only update (for real) if the bytes have changed + var rv uint64 + req.Value = v.raw.Bytes() if !bytes.Equal(req.Value, existingBytes) { updateResponse, err := s.store.Update(ctx, req) if err != nil { - return resource.GetError(resource.AsErrorResult(err)) + err = resource.GetError(resource.AsErrorResult(err)) + } else if updateResponse.Error != nil { + err = resource.GetError(updateResponse.Error) } - if updateResponse.Error != nil { - return resource.GetError(updateResponse.Error) + + // Cleanup secure values + if err = v.finish(ctx, err, s.opts.SecureValues); err != nil { + return err } + rv = uint64(updateResponse.ResourceVersion) } diff --git a/pkg/storage/unified/client.go b/pkg/storage/unified/client.go index 635f134825c..60a96e77855 100644 --- a/pkg/storage/unified/client.go +++ b/pkg/storage/unified/client.go @@ -6,24 +6,23 @@ import ( "path/filepath" "time" - "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" - "gocloud.dev/blob/fileblob" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - otgrpc "github.com/opentracing-contrib/go-grpc" "github.com/opentracing/opentracing-go" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" + "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" + "gocloud.dev/blob/fileblob" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" "github.com/grafana/authlib/types" "github.com/grafana/dskit/flagext" "github.com/grafana/dskit/grpcclient" "github.com/grafana/dskit/middleware" "github.com/grafana/dskit/services" - infraDB "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" + secrets "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" "github.com/grafana/grafana/pkg/services/apiserver/options" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" @@ -36,13 +35,14 @@ import ( ) type Options struct { - Cfg *setting.Cfg - Features featuremgmt.FeatureToggles - DB infraDB.DB - Tracer tracing.Tracer - Reg prometheus.Registerer - Authzc types.AccessClient - Docs resource.DocumentBuilderSupplier + Cfg *setting.Cfg + Features featuremgmt.FeatureToggles + DB infraDB.DB + Tracer tracing.Tracer + Reg prometheus.Registerer + Authzc types.AccessClient + Docs resource.DocumentBuilderSupplier + SecureValues secrets.InlineSecureValueSupport } type clientMetrics struct { @@ -64,7 +64,7 @@ func ProvideUnifiedStorageClient(opts *Options, SearchServerAddress: apiserverCfg.Key("search_server_address").MustString(""), BlobStoreURL: apiserverCfg.Key("blob_url").MustString(""), BlobThresholdBytes: apiserverCfg.Key("blob_threshold_bytes").MustInt(options.BlobThresholdDefault), - }, opts.Cfg, opts.Features, opts.DB, opts.Tracer, opts.Reg, opts.Authzc, opts.Docs, storageMetrics, indexMetrics) + }, opts.Cfg, opts.Features, opts.DB, opts.Tracer, opts.Reg, opts.Authzc, opts.Docs, storageMetrics, indexMetrics, opts.SecureValues) if err == nil { // Used to get the folder stats client = federated.NewFederatedClient( @@ -86,6 +86,7 @@ func newClient(opts options.StorageOptions, docs resource.DocumentBuilderSupplier, storageMetrics *resource.StorageMetrics, indexMetrics *resource.BleveIndexMetrics, + secure secrets.InlineSecureValueSupport, ) (resource.ResourceClient, error) { ctx := context.Background() @@ -168,6 +169,7 @@ func newClient(opts options.StorageOptions, StorageMetrics: storageMetrics, IndexMetrics: indexMetrics, Features: features, + SecureValues: secure, } if cfg.QOSEnabled { diff --git a/pkg/storage/unified/client_test.go b/pkg/storage/unified/client_test.go index 3169786ba64..48d57032501 100644 --- a/pkg/storage/unified/client_test.go +++ b/pkg/storage/unified/client_test.go @@ -6,6 +6,9 @@ import ( "strings" "testing" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + authlib "github.com/grafana/authlib/types" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/services/apiserver/options" @@ -13,9 +16,6 @@ import ( "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" - "github.com/stretchr/testify/require" - - "google.golang.org/grpc" ) func TestUnifiedStorageClient(t *testing.T) { @@ -45,6 +45,7 @@ func TestUnifiedStorageClient(t *testing.T) { nil, nil, nil, + nil, ) require.NoError(t, err) @@ -78,6 +79,7 @@ func TestUnifiedStorageClient(t *testing.T) { nil, nil, nil, + nil, ) require.NoError(t, err) diff --git a/pkg/storage/unified/resource/errors.go b/pkg/storage/unified/resource/errors.go index 903f6e4cff9..79d3911e470 100644 --- a/pkg/storage/unified/resource/errors.go +++ b/pkg/storage/unified/resource/errors.go @@ -4,13 +4,14 @@ import ( "errors" "net/http" + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + grpcstatus "google.golang.org/grpc/status" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation/field" - "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" - grpcstatus "google.golang.org/grpc/status" - + "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" "github.com/grafana/grafana/pkg/util/scheduler" ) @@ -59,6 +60,58 @@ func NewTooManyRequestsError(msg string) *resourcepb.ErrorResult { } } +func newInvalidFieldError( + obj utils.GrafanaMetaAccessor, + detail string, + path string, + morePath ...string, +) *resourcepb.ErrorResult { + gvk := obj.GetGroupVersionKind() + return &resourcepb.ErrorResult{ + Message: detail, + Code: http.StatusUnprocessableEntity, + Reason: string(metav1.StatusReasonInvalid), + Details: &resourcepb.ErrorDetails{ + Name: obj.GetName(), + Group: gvk.Group, + Kind: gvk.Kind, + Uid: string(obj.GetUID()), + Causes: []*resourcepb.ErrorCause{ + { + Reason: string(field.ErrorTypeForbidden), + Field: field.NewPath(path, morePath...).String(), + }, + }, + }, + } +} + +func newRequiredFieldError( + obj utils.GrafanaMetaAccessor, + detail string, + path string, + morePath ...string, +) *resourcepb.ErrorResult { + gvk := obj.GetGroupVersionKind() + return &resourcepb.ErrorResult{ + Message: detail, + Code: http.StatusUnprocessableEntity, + Reason: string(metav1.StatusReasonInvalid), + Details: &resourcepb.ErrorDetails{ + Name: obj.GetName(), + Group: gvk.Group, + Kind: gvk.Kind, + Uid: string(obj.GetUID()), + Causes: []*resourcepb.ErrorCause{ + { + Reason: string(field.ErrorTypeRequired), + Field: field.NewPath(path, morePath...).String(), + }, + }, + }, + } +} + // Convert golang errors to status result errors that can be returned to a client func AsErrorResult(err error) *resourcepb.ErrorResult { if err == nil { diff --git a/pkg/storage/unified/resource/search.go b/pkg/storage/unified/resource/search.go index 4752fa35cec..614d0dafb50 100644 --- a/pkg/storage/unified/resource/search.go +++ b/pkg/storage/unified/resource/search.go @@ -100,16 +100,17 @@ const tracingPrexfixSearch = "unified_search." // This supports indexing+search regardless of implementation type searchSupport struct { - tracer trace.Tracer - log *slog.Logger - storage StorageBackend - search SearchBackend - indexMetrics *BleveIndexMetrics - access types.AccessClient - builders *builderCache - initWorkers int - initMinSize int - initMaxSize int + tracer trace.Tracer + log *slog.Logger + storage StorageBackend + search SearchBackend + indexMetrics *BleveIndexMetrics + access types.AccessClient + builders *builderCache + initWorkers int + initMinSize int + initMaxSize int + searchAfterWrite bool ring *ring.Ring ringLifecycler *ring.BasicLifecycler @@ -133,7 +134,7 @@ var ( _ resourcepb.ManagedObjectIndexServer = (*searchSupport)(nil) ) -func newSearchSupport(opts SearchOptions, storage StorageBackend, access types.AccessClient, blob BlobSupport, tracer trace.Tracer, indexMetrics *BleveIndexMetrics, ring *ring.Ring, ringLifecycler *ring.BasicLifecycler) (support *searchSupport, err error) { +func newSearchSupport(opts SearchOptions, storage StorageBackend, access types.AccessClient, blob BlobSupport, tracer trace.Tracer, indexMetrics *BleveIndexMetrics, ring *ring.Ring, ringLifecycler *ring.BasicLifecycler, searchAfterWrite bool) (support *searchSupport, err error) { // No backend search support if opts.Backend == nil { return nil, nil @@ -155,6 +156,7 @@ func newSearchSupport(opts SearchOptions, storage StorageBackend, access types.A initWorkers: opts.WorkerThreads, initMinSize: opts.InitMinCount, initMaxSize: opts.InitMaxCount, + searchAfterWrite: searchAfterWrite, indexMetrics: indexMetrics, clientIndexEventsChan: opts.IndexEventsChan, indexEventsChan: make(chan *IndexEvent), @@ -479,26 +481,29 @@ func (s *searchSupport) init(ctx context.Context) error { span.AddEvent("namespaces indexed", trace.WithAttributes(attribute.Int("namespaced_indexed", totalBatchesIndexed))) - // Now start listening for new events watchctx := context.Background() // new context? - events, err := s.storage.WatchWriteEvents(watchctx) - if err != nil { - return err - } - go func() { - for { - v := <-events - - // Skip events during batch updates - if v.PreviousRV < 0 { - continue - } - - s.dispatchEvent(watchctx, v) + // don't start watcher when SearchAfterWrite changes are enabled + if !s.searchAfterWrite { + // Now start listening for new events + events, err := s.storage.WatchWriteEvents(watchctx) + if err != nil { + return err } - }() + go func() { + for { + v := <-events - go s.monitorIndexEvents(ctx) + // Skip events during batch updates + if v.PreviousRV < 0 { + continue + } + + s.dispatchEvent(watchctx, v) + } + }() + + go s.monitorIndexEvents(ctx) + } // since usage insights is not in unified storage, we need to periodically rebuild the index // to make sure these data points are up to date. @@ -943,6 +948,8 @@ func (s *searchSupport) getOrCreateIndexQueueProcessor(index ResourceIndex, nsr key := fmt.Sprintf("%s/%s/%s", nsr.Namespace, nsr.Group, nsr.Resource) if indexQueueProcessor, ok := s.indexQueueProcessors[key]; ok { + // index stored on existing processor may have been closed and rebuilt, so we need to update it + indexQueueProcessor.updateIndex(index) return indexQueueProcessor, nil } diff --git a/pkg/storage/unified/resource/search_queue.go b/pkg/storage/unified/resource/search_queue.go index 08f3e5eeac4..23310caf9df 100644 --- a/pkg/storage/unified/resource/search_queue.go +++ b/pkg/storage/unified/resource/search_queue.go @@ -12,7 +12,6 @@ import ( // It is responsible for ingesting events for a single index // It will batch events and send them to the index in a single bulk request type indexQueueProcessor struct { - index ResourceIndex nsr NamespacedResource queue chan *WrittenEvent batchSize int @@ -20,8 +19,11 @@ type indexQueueProcessor struct { resChan chan *IndexEvent // Channel to send results to the caller - mu sync.Mutex - running bool + indexMu sync.Mutex + index ResourceIndex + + runningMu sync.Mutex + running bool } type IndexEvent struct { @@ -33,6 +35,12 @@ type IndexEvent struct { Err error } +func (b *indexQueueProcessor) updateIndex(newIndex ResourceIndex) { + b.indexMu.Lock() + defer b.indexMu.Unlock() + b.index = newIndex +} + // newIndexQueueProcessor creates a new IndexQueueProcessor for the given index func newIndexQueueProcessor(index ResourceIndex, nsr NamespacedResource, batchSize int, builder DocumentBuilder, resChan chan *IndexEvent) *indexQueueProcessor { return &indexQueueProcessor{ @@ -51,8 +59,8 @@ func (b *indexQueueProcessor) Add(evt *WrittenEvent) { b.queue <- evt // Start the processor if it's not already running - b.mu.Lock() - defer b.mu.Unlock() + b.runningMu.Lock() + defer b.runningMu.Unlock() if !b.running { b.running = true go b.runProcessor() @@ -62,9 +70,9 @@ func (b *indexQueueProcessor) Add(evt *WrittenEvent) { // runProcessor is the task processing the queue of written events func (b *indexQueueProcessor) runProcessor() { defer func() { - b.mu.Lock() + b.runningMu.Lock() b.running = false - b.mu.Unlock() + b.runningMu.Unlock() }() for { @@ -116,8 +124,9 @@ func (b *indexQueueProcessor) process(batch []*WrittenEvent) { } else { item.Action = ActionIndex ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() doc, err := b.builder.BuildDocument(ctx, evt.Key, evt.ResourceVersion, evt.Value) + cancel() + if err != nil { result.Err = err } else { @@ -128,7 +137,11 @@ func (b *indexQueueProcessor) process(batch []*WrittenEvent) { req.Items = append(req.Items, item) } - err := b.index.BulkIndex(req) + b.indexMu.Lock() + idx := b.index + b.indexMu.Unlock() + + err := idx.BulkIndex(req) if err != nil { for _, r := range resp { r.Err = err diff --git a/pkg/storage/unified/resource/search_test.go b/pkg/storage/unified/resource/search_test.go index 677e24b8b8b..07a95639ed6 100644 --- a/pkg/storage/unified/resource/search_test.go +++ b/pkg/storage/unified/resource/search_test.go @@ -2,6 +2,8 @@ package resource import ( "context" + "fmt" + "log/slog" "sync" "testing" "time" @@ -246,7 +248,7 @@ func TestBuildIndexes_MaxCountThreshold(t *testing.T) { InitMaxCount: tt.initMaxSize, } - support, err := newSearchSupport(opts, storage, nil, nil, noop.NewTracerProvider().Tracer("test"), nil, nil, nil) + support, err := newSearchSupport(opts, storage, nil, nil, noop.NewTracerProvider().Tracer("test"), nil, nil, nil, false) require.NoError(t, err) require.NotNil(t, support) @@ -304,7 +306,7 @@ func TestSearchGetOrCreateIndex(t *testing.T) { InitMaxCount: 0, } - support, err := newSearchSupport(opts, storage, nil, nil, noop.NewTracerProvider().Tracer("test"), nil, nil, nil) + support, err := newSearchSupport(opts, storage, nil, nil, noop.NewTracerProvider().Tracer("test"), nil, nil, nil, false) require.NoError(t, err) require.NotNil(t, support) @@ -358,7 +360,7 @@ func TestSearchGetOrCreateIndexWithCancellation(t *testing.T) { InitMaxCount: 0, } - support, err := newSearchSupport(opts, storage, nil, nil, noop.NewTracerProvider().Tracer("test"), nil, nil, nil) + support, err := newSearchSupport(opts, storage, nil, nil, noop.NewTracerProvider().Tracer("test"), nil, nil, nil, false) require.NoError(t, err) require.NotNil(t, support) @@ -387,6 +389,49 @@ func TestSearchGetOrCreateIndexWithCancellation(t *testing.T) { require.NoError(t, err) } +func TestSearchWillUpdateIndexOnQueueProcessor(t *testing.T) { + // Regression test: Indexes were being closed when being rebuilt, but not updated on the queue processor. This was causing new events to + // be added to a closed index, resulting in an error and missing docs in the index. + + // Create mock components + mockIndex1 := &MockResourceIndex{} + mockIndex2 := &MockResourceIndex{} // Different index to test replacement + mockBuilder := &MockDocumentBuilder{} + + // Create searchSupport instance + s := &searchSupport{ + log: slog.Default(), + indexQueueProcessors: make(map[string]*indexQueueProcessor), + indexQueueProcessorsMutex: sync.Mutex{}, + indexEventsChan: make(chan *IndexEvent, 10), + } + + nsr := NamespacedResource{ + Namespace: "test-namespace", + Group: "test-group", + Resource: "test-resource", + } + + // Pre-populate the processor to avoid the builders.get() call + key := fmt.Sprintf("%s/%s/%s", nsr.Namespace, nsr.Group, nsr.Resource) + processor1 := newIndexQueueProcessor(mockIndex1, nsr, 10, mockBuilder, s.indexEventsChan) + s.indexQueueProcessors[key] = processor1 + + // Verify initial state + require.Same(t, mockIndex1, processor1.index) + + // Call getOrCreateIndexQueueProcessor with a different index + // This should return the existing processor but update its index + processor2, err := s.getOrCreateIndexQueueProcessor(mockIndex2, nsr) + require.NoError(t, err) + require.NotNil(t, processor2) + + // Same processor instance, but index was replaced + require.Same(t, processor1, processor2, "Should return the same processor instance") + require.Same(t, mockIndex2, processor2.index, "Index should be replaced with mockIndex2") + require.Same(t, mockIndex2, processor1.index, "Original processor should have updated index") +} + type slowSearchBackendWithCache struct { mockSearchBackend wg sync.WaitGroup diff --git a/pkg/storage/unified/resource/secure.go b/pkg/storage/unified/resource/secure.go new file mode 100644 index 00000000000..18ff83af7e7 --- /dev/null +++ b/pkg/storage/unified/resource/secure.go @@ -0,0 +1,81 @@ +package resource + +import ( + "context" + "net/http" + "slices" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/grafana/grafana/pkg/apimachinery/utils" + secrets "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + "github.com/grafana/grafana/pkg/storage/unified/resourcepb" +) + +// The "CanReference" check exists to avoid writing references to secrets +// the user should not allow granting access. We only check it when the value changes +func canReferenceSecureValues(ctx context.Context, + obj utils.GrafanaMetaAccessor, + old utils.GrafanaMetaAccessor, + secrets secrets.InlineSecureValueSupport, +) *resourcepb.ErrorResult { + secure, err := obj.GetSecureValues() + if err != nil || len(secure) == 0 { + return AsErrorResult(err) + } + + if secrets == nil { + return &resourcepb.ErrorResult{ + Message: "secure storage not configured", + Code: http.StatusServiceUnavailable, + Reason: string(metav1.StatusReasonServiceUnavailable), + } + } + + // All references should only set a name + names := make([]string, 0, len(secure)) + for k, v := range secure { + if !v.Create.IsZero() { + return newInvalidFieldError(obj, + "unable to create values in unified storage", + "secure", k, "create") + } + if v.Remove { + return newInvalidFieldError(obj, + "unable to save the remove command", + "secure", k, "remove") + } + if v.Name == "" { + return newRequiredFieldError(obj, + "missing name", + "secure", k, "name") + } + names = append(names, v.Name) + } + + // This will call the real service to check access, converting any errors to protobuf + canReference := func() *resourcepb.ErrorResult { + slices.Sort(names) + names = slices.Compact(names) // + if err := secrets.CanReference(ctx, utils.ToObjectReference(obj), names...); err != nil { + return AsErrorResult(err) + } + return nil + } + + // Always check for create + if old == nil { + return canReference() + } + + oldSecureValues, err := old.GetSecureValues() + if err != nil || len(secure) != len(oldSecureValues) { + return canReference() + } + for k, v := range secure { + if oldSecureValues[k].Name != v.Name { + return canReference() + } + } + return nil // no need to check if the values are the same +} diff --git a/pkg/storage/unified/resource/secure_test.go b/pkg/storage/unified/resource/secure_test.go new file mode 100644 index 00000000000..67388c5ae4c --- /dev/null +++ b/pkg/storage/unified/resource/secure_test.go @@ -0,0 +1,188 @@ +package resource + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/registry/apis/secret" +) + +func TestSecureValues(t *testing.T) { + raw := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "playlist.grafana.app/v0alpha1", + "kind": "Playlist", + "metadata": map[string]any{ + "name": "nn", + "namespace": "ns", + }, + "spec": map[string]any{ + "title": "hello", + }, + "secure": map[string]any{}, // empty + }, + } + obj, err := utils.MetaAccessor(raw) + require.NoError(t, err) + owner := utils.ToObjectReference(obj) + + t.Run("Invalid input", func(t *testing.T) { + raw.Object["secure"] = map[string]any{ + "A": common.InlineSecureValue{ + Create: common.NewSecretValue("XXX"), + }, + } + secureMock := secret.NewMockInlineSecureValueSupport(t) + pberr := canReferenceSecureValues(context.Background(), obj, nil, nil) + require.Equal(t, http.StatusServiceUnavailable, int(pberr.Code), "missing store") + + t.Run("create", func(t *testing.T) { + raw.Object["secure"] = map[string]any{ + "A": common.InlineSecureValue{ + Create: common.NewSecretValue("XXX"), + }, + } + pberr = canReferenceSecureValues(context.Background(), obj, nil, secureMock) + require.Equal(t, http.StatusUnprocessableEntity, int(pberr.Code)) + require.Equal(t, "secure.A.create", pberr.Details.Causes[0].Field) + }) + + t.Run("remove", func(t *testing.T) { + raw.Object["secure"] = map[string]any{ + "A": common.InlineSecureValue{ + Remove: true, + }, + } + pberr = canReferenceSecureValues(context.Background(), obj, nil, secureMock) + require.Equal(t, http.StatusUnprocessableEntity, int(pberr.Code)) + require.Equal(t, "secure.A.remove", pberr.Details.Causes[0].Field) + }) + + t.Run("missing name", func(t *testing.T) { + raw.Object["secure"] = map[string]any{ + "A": common.InlineSecureValue{ + Name: "", // EMPTY + }, + } + pberr = canReferenceSecureValues(context.Background(), obj, nil, secureMock) + require.Equal(t, http.StatusUnprocessableEntity, int(pberr.Code)) + require.Equal(t, "secure.A.name", pberr.Details.Causes[0].Field) + }) + + secureMock.AssertExpectations(t) + }) + + t.Run("OnCreate", func(t *testing.T) { + raw.Object["secure"] = map[string]any{ + "A": common.InlineSecureValue{ + Name: "111", + }, + "B": common.InlineSecureValue{ + Name: "111", // duplicate reference, but only checked once + }, + } + secureMock := secret.NewMockInlineSecureValueSupport(t) + secureMock.On("CanReference", mock.Anything, owner, "111"). + Return(nil).Once() + + pberr := canReferenceSecureValues(context.Background(), obj, nil, secureMock) + require.Nil(t, pberr) + secureMock.AssertExpectations(t) + }) + + t.Run("OnUpdate", func(t *testing.T) { + raw.Object["secure"] = map[string]any{ + "A": common.InlineSecureValue{ + Name: "111", + }, + "B": common.InlineSecureValue{ + Name: "222", + }, + } + + old, _ := utils.MetaAccessor(&unstructured.Unstructured{}) + secureMock := secret.NewMockInlineSecureValueSupport(t) + secureMock.On("CanReference", mock.Anything, owner, "111", "222"). + Return(nil).Once() + + pberr := canReferenceSecureValues(context.Background(), obj, old, secureMock) + require.Nil(t, pberr) + secureMock.AssertExpectations(t) + }) + + t.Run("OnUpdate with same keys", func(t *testing.T) { + raw.Object["secure"] = map[string]any{ + "A": common.InlineSecureValue{ + Name: "111", + }, + "B": common.InlineSecureValue{ + Name: "222", + }, + } + + old, _ := utils.MetaAccessor(&unstructured.Unstructured{ + Object: map[string]any{ + "secure": map[string]any{ + "A": common.InlineSecureValue{ + Name: "111", // same + }, + "B": common.InlineSecureValue{ + Name: "Not222", + }, + }}}) + secureMock := secret.NewMockInlineSecureValueSupport(t) + secureMock.On("CanReference", mock.Anything, owner, "111", "222"). + Return(nil).Once() + + pberr := canReferenceSecureValues(context.Background(), obj, old, secureMock) + require.Nil(t, pberr) + secureMock.AssertExpectations(t) + }) + + t.Run("Update without changes should skip CanReference", func(t *testing.T) { + raw.Object["secure"] = map[string]any{ + "A": common.InlineSecureValue{ + Name: "111", + }, + } + secureMock := secret.NewMockInlineSecureValueSupport(t) + + pberr := canReferenceSecureValues(context.Background(), obj, obj, secureMock) + require.Nil(t, pberr) + secureMock.AssertExpectations(t) // CanReference should not be called + }) + + t.Run("upstream errors", func(t *testing.T) { + raw.Object["secure"] = map[string]any{ + "A": common.InlineSecureValue{ + Name: "111", + }, + } + secureMock := secret.NewMockInlineSecureValueSupport(t) + secureMock.On("CanReference", mock.Anything, owner, "111"). + Return(fmt.Errorf("nope")).Once() // <<< error in CanReference + + pberr := canReferenceSecureValues(context.Background(), obj, nil, secureMock) + require.NotNil(t, pberr) + secureMock.AssertExpectations(t) + + // Check CanReference when the old value is invalid + old, _ := utils.MetaAccessor(&unstructured.Unstructured{ + Object: map[string]any{"secure": t}}) + + secureMock = secret.NewMockInlineSecureValueSupport(t) + secureMock.On("CanReference", mock.Anything, owner, "111"). + Return(nil).Once() + pberr = canReferenceSecureValues(context.Background(), obj, old, secureMock) + require.Nil(t, pberr) + secureMock.AssertExpectations(t) + }) +} diff --git a/pkg/storage/unified/resource/server.go b/pkg/storage/unified/resource/server.go index 78bf79627da..1b52245157d 100644 --- a/pkg/storage/unified/resource/server.go +++ b/pkg/storage/unified/resource/server.go @@ -21,8 +21,8 @@ import ( claims "github.com/grafana/authlib/types" "github.com/grafana/dskit/backoff" "github.com/grafana/dskit/ring" - "github.com/grafana/grafana/pkg/apimachinery/utils" + secrets "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" "github.com/grafana/grafana/pkg/util/scheduler" ) @@ -208,6 +208,9 @@ type ResourceServerOptions struct { // Link RBAC AccessClient claims.AccessClient + // Manage secure values + SecureValues secrets.InlineSecureValueSupport + // Callbacks for startup and shutdown Lifecycle LifecycleHooks @@ -229,9 +232,12 @@ type ResourceServerOptions struct { Ring *ring.Ring RingLifecycler *ring.BasicLifecycler + + // Enable strong consistency for searches. When enabled, index is always updated with latest changes before search. + SearchAfterWrite bool } -func NewResourceServer(opts ResourceServerOptions) (ResourceServer, error) { +func NewResourceServer(opts ResourceServerOptions) (*server, error) { if opts.Tracer == nil { opts.Tracer = noop.NewTracerProvider().Tracer("resource-server") } @@ -297,6 +303,7 @@ func NewResourceServer(opts ResourceServerOptions) (ResourceServer, error) { blob: blobstore, diagnostics: opts.Diagnostics, access: opts.AccessClient, + secure: opts.SecureValues, writeHooks: opts.WriteHooks, lifecycle: opts.Lifecycle, now: opts.Now, @@ -311,7 +318,7 @@ func NewResourceServer(opts ResourceServerOptions) (ResourceServer, error) { if opts.Search.Resources != nil { var err error - s.search, err = newSearchSupport(opts.Search, s.backend, s.access, s.blob, opts.Tracer, opts.IndexMetrics, opts.Ring, opts.RingLifecycler) + s.search, err = newSearchSupport(opts.Search, s.backend, s.access, s.blob, opts.Tracer, opts.IndexMetrics, opts.Ring, opts.RingLifecycler, opts.SearchAfterWrite) if err != nil { return nil, err } @@ -333,6 +340,7 @@ type server struct { log *slog.Logger backend StorageBackend blob BlobSupport + secure secrets.InlineSecureValueSupport search *searchSupport diagnostics resourcepb.DiagnosticsServer access claims.AccessClient @@ -444,16 +452,6 @@ func (s *server) newEvent(ctx context.Context, user claims.AuthInfo, key *resour return nil, NewBadRequestError("can not save annotation: " + utils.AnnoKeyGrantPermissions) } - // Verify that this resource can reference secure values - secure, err := obj.GetSecureValues() - if err != nil { - return nil, AsErrorResult(err) - } - if len(secure) > 0 { - // See: https://github.com/grafana/grafana/pull/107803 - return nil, NewBadRequestError("Saving secure values is not yet supported") - } - event := &WriteEvent{ Value: value, Key: key, @@ -477,6 +475,11 @@ func (s *server) newEvent(ctx context.Context, user claims.AuthInfo, key *resour } } + // Verify that this resource can reference secure values + if err := canReferenceSecureValues(ctx, obj, event.ObjectOld, s.secure); err != nil { + return nil, err + } + if key.Namespace != obj.GetNamespace() { return nil, NewBadRequestError("key/namespace do not match") } diff --git a/pkg/storage/unified/resource/server_test.go b/pkg/storage/unified/resource/server_test.go index da92e67d7c0..b0dff754a18 100644 --- a/pkg/storage/unified/resource/server_test.go +++ b/pkg/storage/unified/resource/server_test.go @@ -14,8 +14,7 @@ import ( "gocloud.dev/blob/memblob" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - claims "github.com/grafana/authlib/types" - + authlib "github.com/grafana/authlib/types" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" @@ -23,14 +22,14 @@ import ( func TestSimpleServer(t *testing.T) { testUserA := &identity.StaticRequester{ - Type: claims.TypeUser, + Type: authlib.TypeUser, Login: "testuser", UserID: 123, UserUID: "u123", OrgRole: identity.RoleAdmin, IsGrafanaAdmin: true, // can do anything } - ctx := claims.WithAuthInfo(context.Background(), testUserA) + ctx := authlib.WithAuthInfo(context.Background(), testUserA) bucket := memblob.OpenBucket(nil) if false { diff --git a/pkg/storage/unified/resource/storage_backend.go b/pkg/storage/unified/resource/storage_backend.go index 1fb0ad2e66a..dff33f63b36 100644 --- a/pkg/storage/unified/resource/storage_backend.go +++ b/pkg/storage/unified/resource/storage_backend.go @@ -215,6 +215,7 @@ func (k *kvStorageBackend) ListIterator(ctx context.Context, req *resourcepb.Lis // Fetch the latest objects keys := make([]MetaDataKey, 0, min(defaultListBufferSize, req.Limit+1)) + idx := 0 for metaKey, err := range k.metaStore.ListResourceKeysAtRevision(ctx, MetaListRequestKey{ Namespace: req.Options.Key.Namespace, Group: req.Options.Key.Group, @@ -224,15 +225,17 @@ func (k *kvStorageBackend) ListIterator(ctx context.Context, req *resourcepb.Lis if err != nil { return 0, err } + // Skip the first offset items. This is not efficient, but it's a simple way to implement it for now. + if idx < int(offset) { + idx++ + continue + } keys = append(keys, metaKey) + // Only fetch the first limit items + 1 to get the next token. + if len(keys) >= int(req.Limit+1) { + break + } } - - sortMetaKeysByResourceVersion(keys, true) // sort ascending for sql parity - - if offset > 0 && int64(len(keys)) > offset { - keys = keys[offset:] - } - iter := kvListIterator{ keys: keys, currentIndex: -1, @@ -430,19 +433,6 @@ func sortByResourceVersion(filteredKeys []DataKey, sortAscending bool) { } } -// sortMetaKeysByResourceVersion sorts the metadata keys based on the sortAscending flag -func sortMetaKeysByResourceVersion(keys []MetaDataKey, sortAscending bool) { - if sortAscending { - sort.Slice(keys, func(i, j int) bool { - return keys[i].ResourceVersion < keys[j].ResourceVersion - }) - } else { - sort.Slice(keys, func(i, j int) bool { - return keys[i].ResourceVersion > keys[j].ResourceVersion - }) - } -} - // applyPagination filters keys based on pagination parameters func applyPagination(keys []DataKey, lastSeenRV int64, sortAscending bool) []DataKey { if lastSeenRV == 0 { diff --git a/pkg/storage/unified/search/bleve.go b/pkg/storage/unified/search/bleve.go index 1e0d19e2e4f..6c0ca8a14ff 100644 --- a/pkg/storage/unified/search/bleve.go +++ b/pkg/storage/unified/search/bleve.go @@ -246,7 +246,7 @@ func (b *bleveBackend) BuildIndex( } } - resourceDir := filepath.Join(b.opts.Root, cleanFileSegment(key.Namespace), cleanFileSegment(fmt.Sprintf("%s.%s", key.Resource, key.Group))) + resourceDir := b.getResourceDir(key) var index bleve.Index cachedIndex := b.getCachedIndex(key) @@ -377,13 +377,20 @@ func (b *bleveBackend) BuildIndex( b.indexMetrics.OpenIndexes.WithLabelValues(idx.indexStorage).Inc() } - // Start a background task to cleanup the old index directories. If we have built a new file-based index, - // the new name is ignored. If we have created in-memory index and fileIndexName is empty, all old directories can be removed. - go b.cleanOldIndexes(resourceDir, fileIndexName) + // Clean up the old index directories. If we have built a new file-based index, the new name is ignored. + // If we have created in-memory index and fileIndexName is empty, all old directories can be removed. + // + // We do the cleanup on the same goroutine as the index building. Using background goroutine could + // cleanup new index directory that is being built by new call to BuildIndex. + b.cleanOldIndexes(resourceDir, fileIndexName) return idx, nil } +func (b *bleveBackend) getResourceDir(key resource.NamespacedResource) string { + return filepath.Join(b.opts.Root, cleanFileSegment(key.Namespace), cleanFileSegment(fmt.Sprintf("%s.%s", key.Resource, key.Group))) +} + func cleanFileSegment(input string) string { input = strings.ReplaceAll(input, string(filepath.Separator), "_") input = strings.ReplaceAll(input, "..", "_") @@ -525,7 +532,7 @@ func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string, resourceVe return idx, indexName } -func (b *bleveBackend) closeAllIndexes() { +func (b *bleveBackend) CloseAllIndexes() { b.cacheMx.Lock() defer b.cacheMx.Unlock() diff --git a/pkg/storage/unified/search/bleve_integration_test.go b/pkg/storage/unified/search/bleve_integration_test.go index 043b7642dac..7c3ca33f095 100644 --- a/pkg/storage/unified/search/bleve_integration_test.go +++ b/pkg/storage/unified/search/bleve_integration_test.go @@ -25,6 +25,8 @@ func TestBleveSearchBackend(t *testing.T) { require.NoError(t, err) require.NotNil(t, backend) + t.Cleanup(backend.CloseAllIndexes) + return backend }, &unitest.TestOptions{ NSPrefix: "bleve-test", @@ -48,5 +50,7 @@ func TestSearchBackendBenchmark(t *testing.T) { require.NoError(t, err) require.NotNil(t, backend) + t.Cleanup(backend.CloseAllIndexes) + unitest.BenchmarkSearchBackend(t, backend, opts) } diff --git a/pkg/storage/unified/search/bleve_performance_test.go b/pkg/storage/unified/search/bleve_performance_test.go index e1ae015efd2..d6cb840647e 100644 --- a/pkg/storage/unified/search/bleve_performance_test.go +++ b/pkg/storage/unified/search/bleve_performance_test.go @@ -3,7 +3,6 @@ package search_test import ( "context" "fmt" - "os" "runtime" "testing" "time" @@ -14,14 +13,14 @@ import ( "github.com/stretchr/testify/require" ) -func setupIndex() (resource.ResourceIndex, string) { +func setupIndex(b testing.TB) resource.ResourceIndex { // size := 1000000 // TODO: 200k documents standard size? size := 200000 // batchSize := 1000 slower 8s (for 200k documents) - 34s (for 1M documents) // batchSize := 10000 // faster 5s (for 200k documents) - 27s (for 1M documents) batchSize := 100000 // fasterer 3.5s (for 200k documents) - 27s (for 1M documents) writer := newTestWriter(size, batchSize) - return newTestDashboardsIndex(nil, 1, int64(size), int64(batchSize), writer) + return newTestDashboardsIndex(b, 1, int64(size), int64(batchSize), writer) } const maxAllowedTime = 20 * time.Millisecond // Reasonable (can vary per env) performance threshold per query (e.g., 20ms) @@ -37,13 +36,7 @@ func BenchmarkBleveQuery(b *testing.B) { var memStatsAfterIndex runtime.MemStats runtime.ReadMemStats(&memStatsStart) - testIndex, testIndexDir := setupIndex() - defer func() { - err := os.RemoveAll(testIndexDir) - if err != nil { - fmt.Printf("Error removing index directory: %v\n", err) - } - }() + testIndex := setupIndex(b) runtime.ReadMemStats(&memStatsAfterIndex) diff --git a/pkg/storage/unified/search/bleve_search_test.go b/pkg/storage/unified/search/bleve_search_test.go index e5bfc81a338..865f570389a 100644 --- a/pkg/storage/unified/search/bleve_search_test.go +++ b/pkg/storage/unified/search/bleve_search_test.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "log" - "os" "testing" "github.com/blevesearch/bleve/v2" @@ -30,7 +29,7 @@ func TestCanSearchByTitle(t *testing.T) { } t.Run("when query is empty, sort documents by title instead of search score", func(t *testing.T) { - index, _ := newTestDashboardsIndex(t, threshold, 2, 2, noop) + index := newTestDashboardsIndex(t, threshold, 2, 2, noop) err := index.BulkIndex(&resource.BulkIndexRequest{ Items: []*resource.BulkIndexItem{ { @@ -74,7 +73,7 @@ func TestCanSearchByTitle(t *testing.T) { }) t.Run("will boost phrase match query over match query results", func(t *testing.T) { - index, _ := newTestDashboardsIndex(t, threshold, 2, 2, noop) + index := newTestDashboardsIndex(t, threshold, 2, 2, noop) err := index.BulkIndex(&resource.BulkIndexRequest{ Items: []*resource.BulkIndexItem{ { @@ -118,7 +117,7 @@ func TestCanSearchByTitle(t *testing.T) { }) t.Run("will prioritize matches", func(t *testing.T) { - index, _ := newTestDashboardsIndex(t, threshold, 2, 2, noop) + index := newTestDashboardsIndex(t, threshold, 2, 2, noop) err := index.BulkIndex(&resource.BulkIndexRequest{ Items: []*resource.BulkIndexItem{ { @@ -161,7 +160,7 @@ func TestCanSearchByTitle(t *testing.T) { }) t.Run("will boost exact match query over match phrase query results", func(t *testing.T) { - index, _ := newTestDashboardsIndex(t, threshold, 2, 2, noop) + index := newTestDashboardsIndex(t, threshold, 2, 2, noop) err := index.BulkIndex(&resource.BulkIndexRequest{ Items: []*resource.BulkIndexItem{ { @@ -205,7 +204,7 @@ func TestCanSearchByTitle(t *testing.T) { }) t.Run("title with numbers will match document", func(t *testing.T) { - index, _ := newTestDashboardsIndex(t, threshold, 2, 2, noop) + index := newTestDashboardsIndex(t, threshold, 2, 2, noop) err := index.BulkIndex(&resource.BulkIndexRequest{ Items: []*resource.BulkIndexItem{ { @@ -246,7 +245,7 @@ func TestCanSearchByTitle(t *testing.T) { }) t.Run("title will match escaped characters", func(t *testing.T) { - index, _ := newTestDashboardsIndex(t, threshold, 2, 2, noop) + index := newTestDashboardsIndex(t, threshold, 2, 2, noop) err := index.BulkIndex(&resource.BulkIndexRequest{ Items: []*resource.BulkIndexItem{ { @@ -293,7 +292,7 @@ func TestCanSearchByTitle(t *testing.T) { }) t.Run("title search will match document", func(t *testing.T) { - index, _ := newTestDashboardsIndex(t, threshold, 2, 2, noop) + index := newTestDashboardsIndex(t, threshold, 2, 2, noop) err := index.BulkIndex(&resource.BulkIndexRequest{ Items: []*resource.BulkIndexItem{ { @@ -358,7 +357,7 @@ func TestCanSearchByTitle(t *testing.T) { }) t.Run("title search will NOT match documents", func(t *testing.T) { - index, _ := newTestDashboardsIndex(t, threshold, 2, 2, noop) + index := newTestDashboardsIndex(t, threshold, 2, 2, noop) err := index.BulkIndex(&resource.BulkIndexRequest{ Items: []*resource.BulkIndexItem{ { @@ -427,7 +426,7 @@ func TestCanSearchByTitle(t *testing.T) { }) t.Run("title search with character will match one document", func(t *testing.T) { - index, _ := newTestDashboardsIndex(t, threshold, 2, 2, noop) + index := newTestDashboardsIndex(t, threshold, 2, 2, noop) err := index.BulkIndex(&resource.BulkIndexRequest{ Items: []*resource.BulkIndexItem{ { @@ -520,22 +519,21 @@ func newQueryByTitle(query string) *resourcepb.ResourceSearchRequest { } } -func newTestDashboardsIndex(t TB, threshold int64, size int64, batchSize int64, writer IndexWriter) (resource.ResourceIndex, string) { +func newTestDashboardsIndex(t testing.TB, threshold int64, size int64, batchSize int64, writer IndexWriter) resource.ResourceIndex { key := &resourcepb.ResourceKey{ Namespace: "default", Group: "dashboard.grafana.app", Resource: "dashboards", } - tmpdir, err := os.MkdirTemp("", "grafana-bleve-test") - require.NoError(t, err) - backend, err := search.NewBleveBackend(search.BleveOptions{ - Root: tmpdir, + Root: t.TempDir(), FileThreshold: threshold, // use in-memory for tests BatchSize: int(batchSize), }, tracing.NewNoopTracerService(), featuremgmt.WithFeatures(), nil) require.NoError(t, err) + t.Cleanup(backend.CloseAllIndexes) + rv := int64(10) ctx := identity.WithRequester(context.Background(), &user.SignedInUser{Namespace: "ns"}) @@ -556,7 +554,7 @@ func newTestDashboardsIndex(t TB, threshold int64, size int64, batchSize int64, }, size, rv, info.Fields, "test", writer) require.NoError(t, err) - return index, tmpdir + return index } type IndexWriter func(index resource.ResourceIndex) (int64, error) @@ -603,15 +601,3 @@ func debugIndexedTerms(index bleve.Index, field string) { } } } - -// TB is an interface that works for both *testing.T and *testing.B -type TB interface { - Log(args ...interface{}) - Logf(format string, args ...interface{}) - Error(args ...interface{}) - Errorf(format string, args ...interface{}) - Fatal(args ...interface{}) - Fatalf(format string, args ...interface{}) - Helper() - FailNow() -} diff --git a/pkg/storage/unified/search/bleve_test.go b/pkg/storage/unified/search/bleve_test.go index a391efde3a1..e28d8a80cb8 100644 --- a/pkg/storage/unified/search/bleve_test.go +++ b/pkg/storage/unified/search/bleve_test.go @@ -50,6 +50,8 @@ func TestBleveBackend(t *testing.T) { }, tracing.NewNoopTracerService(), featuremgmt.WithFeatures(), nil) require.NoError(t, err) + t.Cleanup(backend.CloseAllIndexes) + rv := int64(10) ctx := identity.WithRequester(context.Background(), &user.SignedInUser{Namespace: "ns"}) var dashboardsIndex resource.ResourceIndex @@ -753,7 +755,7 @@ func setupBleveBackend(t *testing.T, fileThreshold int, cacheTTL time.Duration, }, tracing.NewNoopTracerService(), featuremgmt.WithFeatures(), metrics) require.NoError(t, err) require.NotNil(t, backend) - t.Cleanup(backend.closeAllIndexes) + t.Cleanup(backend.CloseAllIndexes) return backend, reg } @@ -841,9 +843,9 @@ func TestFileIndexIsReusedOnSameSizeAndRV(t *testing.T) { index_server_open_indexes{index_storage="file"} 1 `), "index_server_open_indexes")) - backend1.closeAllIndexes() + backend1.CloseAllIndexes() - // Verify that there are no open indexes after closeAllIndexes call. + // Verify that there are no open indexes after CloseAllIndexes call. require.NoError(t, testutil.GatherAndCompare(reg1, bytes.NewBufferString(` # HELP index_server_open_indexes Number of open indexes per storage type. An open index corresponds to single resource group. # TYPE index_server_open_indexes gauge @@ -881,7 +883,7 @@ func TestFileIndexIsNotReusedOnDifferentSize(t *testing.T) { backend1, _ := setupBleveBackend(t, 5, time.Nanosecond, tmpDir) _, err := backend1.BuildIndex(context.Background(), ns, 10, 100, nil, "test", indexTestDocs(ns, 10)) require.NoError(t, err) - backend1.closeAllIndexes() + backend1.CloseAllIndexes() // We open new backend using same directory, but with different size. Index should be rebuilt. backend2, _ := setupBleveBackend(t, 5, time.Nanosecond, tmpDir) @@ -906,7 +908,7 @@ func TestFileIndexIsNotReusedOnDifferentRV(t *testing.T) { backend1, _ := setupBleveBackend(t, 5, time.Nanosecond, tmpDir) _, err := backend1.BuildIndex(context.Background(), ns, 10, 100, nil, "test", indexTestDocs(ns, 10)) require.NoError(t, err) - backend1.closeAllIndexes() + backend1.CloseAllIndexes() // We open new backend using same directory, but with different RV. Index should be rebuilt. backend2, _ := setupBleveBackend(t, 5, time.Nanosecond, tmpDir) @@ -945,6 +947,12 @@ func TestRebuildingIndexClosesPreviousCachedIndex(t *testing.T) { firstIndex, err := backend.BuildIndex(context.Background(), ns, int64(firstSize), 100, nil, "test", indexTestDocs(ns, firstSize)) require.NoError(t, err) + if testCase.firstInMemory { + verifyDirEntriesCount(t, backend.getResourceDir(ns), 0) + } else { + verifyDirEntriesCount(t, backend.getResourceDir(ns), 1) + } + openInMemoryIndexes := 0 secondSize := 100 @@ -955,6 +963,12 @@ func TestRebuildingIndexClosesPreviousCachedIndex(t *testing.T) { secondIndex, err := backend.BuildIndex(context.Background(), ns, int64(secondSize), 100, nil, "test", indexTestDocs(ns, secondSize)) require.NoError(t, err) + if testCase.secondInMemory { + verifyDirEntriesCount(t, backend.getResourceDir(ns), 0) + } else { + verifyDirEntriesCount(t, backend.getResourceDir(ns), 1) + } + // Verify that first and second index are different, and first one is now closed. require.NotEqual(t, firstIndex, secondIndex) @@ -975,6 +989,19 @@ func TestRebuildingIndexClosesPreviousCachedIndex(t *testing.T) { } } +func verifyDirEntriesCount(t *testing.T, dir string, count int) { + ents, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + ents = nil + // This is fine, if dir doesn't exist. + } else { + require.NoError(t, err) + } + } + require.Len(t, ents, count) +} + func indexTestDocs(ns resource.NamespacedResource, docs int) func(index resource.ResourceIndex) (int64, error) { return func(index resource.ResourceIndex) (int64, error) { var items []*resource.BulkIndexItem diff --git a/pkg/storage/unified/sql/backend.go b/pkg/storage/unified/sql/backend.go index 7733de8ff91..ca3e941eb92 100644 --- a/pkg/storage/unified/sql/backend.go +++ b/pkg/storage/unified/sql/backend.go @@ -599,7 +599,7 @@ func (b *backend) listLatest(ctx context.Context, req *resourcepb.ListRequest, c return 0, fmt.Errorf("only works for the 'latest' resource version") } - iter := &listIter{} + iter := &listIter{sortAsc: false} err := b.db.WithTx(ctx, ReadCommittedRO, func(ctx context.Context, tx db.Tx) error { var err error iter.listRV, err = b.fetchLatestRV(ctx, tx, b.dialect, req.Options.Key.Group, req.Options.Key.Resource) @@ -637,7 +637,7 @@ func (b *backend) listAtRevision(ctx context.Context, req *resourcepb.ListReques defer span.End() // Get the RV - iter := &listIter{listRV: req.ResourceVersion} + iter := &listIter{listRV: req.ResourceVersion, sortAsc: false} if req.NextPageToken != "" { continueToken, err := resource.GetContinueToken(req.NextPageToken) if err != nil { diff --git a/pkg/storage/unified/sql/data/resource_history_get.sql b/pkg/storage/unified/sql/data/resource_history_get.sql index fdb90d908b7..434ae8ffa01 100644 --- a/pkg/storage/unified/sql/data/resource_history_get.sql +++ b/pkg/storage/unified/sql/data/resource_history_get.sql @@ -29,7 +29,7 @@ WHERE 1 = 1 AND {{ .Ident "resource_version" }} = {{ .Arg .ExactRV }} {{ end }} {{ if .SortAscending }} -ORDER BY {{ .Ident "resource_version" }} ASC +ORDER BY resource_version ASC {{ else }} -ORDER BY {{ .Ident "resource_version" }} DESC +ORDER BY resource_version DESC {{ end }} diff --git a/pkg/storage/unified/sql/data/resource_history_list.sql b/pkg/storage/unified/sql/data/resource_history_list.sql index 58838c331cf..9a0bbe1fa78 100644 --- a/pkg/storage/unified/sql/data/resource_history_list.sql +++ b/pkg/storage/unified/sql/data/resource_history_list.sql @@ -50,7 +50,7 @@ SELECT AND kv.{{ .Ident "name" }} = {{ .Arg .Request.Options.Key.Name }} {{ end }} {{ end }} - ORDER BY kv.{{ .Ident "resource_version" }} ASC + ORDER BY kv.{{ .Ident "namespace" }} ASC, kv.{{ .Ident "name" }} ASC {{ if (gt .Request.Limit 0) }} LIMIT {{ .Arg .Request.Limit }} OFFSET {{ .Arg .Request.Offset }} {{ end }} diff --git a/pkg/storage/unified/sql/data/resource_list.sql b/pkg/storage/unified/sql/data/resource_list.sql index a2ee2f47435..2041618a370 100644 --- a/pkg/storage/unified/sql/data/resource_list.sql +++ b/pkg/storage/unified/sql/data/resource_list.sql @@ -23,5 +23,5 @@ SELECT AND {{ .Ident "name" }} = {{ .Arg .Request.Options.Key.Name }} {{ end }} {{ end }} - ORDER BY {{ .Ident "resource_version" }} ASC + ORDER BY {{ .Ident "namespace" }} ASC, {{ .Ident "name" }} ASC ; diff --git a/pkg/storage/unified/sql/queries.go b/pkg/storage/unified/sql/queries.go index 1e0e50ae4af..43ae7f3a45a 100644 --- a/pkg/storage/unified/sql/queries.go +++ b/pkg/storage/unified/sql/queries.go @@ -242,13 +242,10 @@ func (r *resourceHistoryReadLatestRVResponse) Results() (*resourceHistoryReadLat } type historyListRequest struct { - ResourceVersion int64 - Limit int64 - Offset int64 - Folder string - Options *resourcepb.ListOptions + ResourceVersion, Limit, Offset int64 + Folder string + Options *resourcepb.ListOptions } - type sqlResourceHistoryListRequest struct { sqltemplate.SQLTemplate Request *historyListRequest diff --git a/pkg/storage/unified/sql/server.go b/pkg/storage/unified/sql/server.go index e89b20a0780..6d2639007db 100644 --- a/pkg/storage/unified/sql/server.go +++ b/pkg/storage/unified/sql/server.go @@ -11,8 +11,8 @@ import ( "github.com/grafana/authlib/types" "github.com/grafana/dskit/ring" "github.com/grafana/dskit/services" - infraDB "github.com/grafana/grafana/pkg/infra/db" + secrets "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/setting" @@ -38,6 +38,7 @@ type ServerOptions struct { IndexMetrics *resource.BleveIndexMetrics Features featuremgmt.FeatureToggles QOSQueue QOSEnqueueDequeuer + SecureValues secrets.InlineSecureValueSupport Ring *ring.Ring RingLifecycler *ring.BasicLifecycler } @@ -52,7 +53,8 @@ func NewResourceServer( Blob: resource.BlobConfig{ URL: apiserverCfg.Key("blob_url").MustString(""), }, - Reg: opts.Reg, + Reg: opts.Reg, + SecureValues: opts.SecureValues, } if opts.AccessClient != nil { serverOptions.AccessClient = resource.NewAuthzLimitedClient(opts.AccessClient, resource.AuthzOptions{Tracer: opts.Tracer, Registry: opts.Reg}) @@ -101,6 +103,7 @@ func NewResourceServer( serverOptions.QOSQueue = opts.QOSQueue serverOptions.Ring = opts.Ring serverOptions.RingLifecycler = opts.RingLifecycler + serverOptions.SearchAfterWrite = opts.Features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageSearchAfterWriteExperimentalAPI) return resource.NewResourceServer(serverOptions) } diff --git a/pkg/storage/unified/sql/test/benchmark_test.go b/pkg/storage/unified/sql/test/benchmark_test.go index 585776fd7bb..1fe29d3b594 100644 --- a/pkg/storage/unified/sql/test/benchmark_test.go +++ b/pkg/storage/unified/sql/test/benchmark_test.go @@ -51,6 +51,8 @@ func TestIntegrationBenchmarkSQLStorageBackend(t *testing.T) { } func TestIntegrationBenchmarkResourceServer(t *testing.T) { + t.Skip("skipping slow test, causing CI to fail due to timeout") + if testing.Short() { t.Skip("skipping integration test in short mode") } @@ -75,6 +77,8 @@ func TestIntegrationBenchmarkResourceServer(t *testing.T) { require.NoError(t, err) require.NotNil(t, search) + t.Cleanup(search.CloseAllIndexes) + // Create a new resource backend dbstore := db.InitTestDB(t) eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil) diff --git a/pkg/storage/unified/sql/test/integration_test.go b/pkg/storage/unified/sql/test/integration_test.go index c03915164f8..159f7b97a83 100644 --- a/pkg/storage/unified/sql/test/integration_test.go +++ b/pkg/storage/unified/sql/test/integration_test.go @@ -121,6 +121,8 @@ func TestIntegrationSearchAndStorage(t *testing.T) { require.NoError(t, err) require.NotNil(t, search) + t.Cleanup(search.CloseAllIndexes) + // Create a new resource backend dbstore := db.InitTestDB(t) eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil) diff --git a/pkg/storage/unified/sql/testdata/mysql--resource_history_get-read object history.sql b/pkg/storage/unified/sql/testdata/mysql--resource_history_get-read object history.sql index 66103fecdeb..4cde1c5f536 100755 --- a/pkg/storage/unified/sql/testdata/mysql--resource_history_get-read object history.sql +++ b/pkg/storage/unified/sql/testdata/mysql--resource_history_get-read object history.sql @@ -13,4 +13,4 @@ WHERE 1 = 1 AND `group` = 'gg' AND `resource` = 'rr' AND `name` = 'name' -ORDER BY `resource_version` DESC +ORDER BY resource_version DESC diff --git a/pkg/storage/unified/sql/testdata/mysql--resource_history_list-single path.sql b/pkg/storage/unified/sql/testdata/mysql--resource_history_list-single path.sql index 4a49051687d..0599ac2e10f 100755 --- a/pkg/storage/unified/sql/testdata/mysql--resource_history_list-single path.sql +++ b/pkg/storage/unified/sql/testdata/mysql--resource_history_list-single path.sql @@ -24,6 +24,6 @@ SELECT AND maxkv.`name` = kv.`name` WHERE kv.`action` != 3 AND kv.`namespace` = 'ns' - ORDER BY kv.`resource_version` ASC + ORDER BY kv.`namespace` ASC, kv.`name` ASC LIMIT 10 OFFSET 0 ; diff --git a/pkg/storage/unified/sql/testdata/mysql--resource_list-filter_on_namespace.sql b/pkg/storage/unified/sql/testdata/mysql--resource_list-filter_on_namespace.sql index ffb9ed43f85..d72fc1ddd1e 100755 --- a/pkg/storage/unified/sql/testdata/mysql--resource_list-filter_on_namespace.sql +++ b/pkg/storage/unified/sql/testdata/mysql--resource_list-filter_on_namespace.sql @@ -10,5 +10,5 @@ SELECT FROM `resource` WHERE 1 = 1 AND `namespace` = 'ns' - ORDER BY `resource_version` ASC + ORDER BY `namespace` ASC, `name` ASC ; diff --git a/pkg/storage/unified/sql/testdata/postgres--resource_history_get-read object history.sql b/pkg/storage/unified/sql/testdata/postgres--resource_history_get-read object history.sql index 680fa3ca0a0..b0b6a310419 100755 --- a/pkg/storage/unified/sql/testdata/postgres--resource_history_get-read object history.sql +++ b/pkg/storage/unified/sql/testdata/postgres--resource_history_get-read object history.sql @@ -13,4 +13,4 @@ WHERE 1 = 1 AND "group" = 'gg' AND "resource" = 'rr' AND "name" = 'name' -ORDER BY "resource_version" DESC +ORDER BY resource_version DESC diff --git a/pkg/storage/unified/sql/testdata/postgres--resource_history_list-single path.sql b/pkg/storage/unified/sql/testdata/postgres--resource_history_list-single path.sql index 86aa57ad2f8..400b2a502cf 100755 --- a/pkg/storage/unified/sql/testdata/postgres--resource_history_list-single path.sql +++ b/pkg/storage/unified/sql/testdata/postgres--resource_history_list-single path.sql @@ -24,6 +24,6 @@ SELECT AND maxkv."name" = kv."name" WHERE kv."action" != 3 AND kv."namespace" = 'ns' - ORDER BY kv."resource_version" ASC + ORDER BY kv."namespace" ASC, kv."name" ASC LIMIT 10 OFFSET 0 ; diff --git a/pkg/storage/unified/sql/testdata/postgres--resource_list-filter_on_namespace.sql b/pkg/storage/unified/sql/testdata/postgres--resource_list-filter_on_namespace.sql index 9268c75bb2b..41131a1520f 100755 --- a/pkg/storage/unified/sql/testdata/postgres--resource_list-filter_on_namespace.sql +++ b/pkg/storage/unified/sql/testdata/postgres--resource_list-filter_on_namespace.sql @@ -10,5 +10,5 @@ SELECT FROM "resource" WHERE 1 = 1 AND "namespace" = 'ns' - ORDER BY "resource_version" ASC + ORDER BY "namespace" ASC, "name" ASC ; diff --git a/pkg/storage/unified/sql/testdata/sqlite--resource_history_get-read object history.sql b/pkg/storage/unified/sql/testdata/sqlite--resource_history_get-read object history.sql index 680fa3ca0a0..b0b6a310419 100755 --- a/pkg/storage/unified/sql/testdata/sqlite--resource_history_get-read object history.sql +++ b/pkg/storage/unified/sql/testdata/sqlite--resource_history_get-read object history.sql @@ -13,4 +13,4 @@ WHERE 1 = 1 AND "group" = 'gg' AND "resource" = 'rr' AND "name" = 'name' -ORDER BY "resource_version" DESC +ORDER BY resource_version DESC diff --git a/pkg/storage/unified/sql/testdata/sqlite--resource_history_list-single path.sql b/pkg/storage/unified/sql/testdata/sqlite--resource_history_list-single path.sql index 86aa57ad2f8..400b2a502cf 100755 --- a/pkg/storage/unified/sql/testdata/sqlite--resource_history_list-single path.sql +++ b/pkg/storage/unified/sql/testdata/sqlite--resource_history_list-single path.sql @@ -24,6 +24,6 @@ SELECT AND maxkv."name" = kv."name" WHERE kv."action" != 3 AND kv."namespace" = 'ns' - ORDER BY kv."resource_version" ASC + ORDER BY kv."namespace" ASC, kv."name" ASC LIMIT 10 OFFSET 0 ; diff --git a/pkg/storage/unified/sql/testdata/sqlite--resource_list-filter_on_namespace.sql b/pkg/storage/unified/sql/testdata/sqlite--resource_list-filter_on_namespace.sql index 9268c75bb2b..41131a1520f 100755 --- a/pkg/storage/unified/sql/testdata/sqlite--resource_list-filter_on_namespace.sql +++ b/pkg/storage/unified/sql/testdata/sqlite--resource_list-filter_on_namespace.sql @@ -10,5 +10,5 @@ SELECT FROM "resource" WHERE 1 = 1 AND "namespace" = 'ns' - ORDER BY "resource_version" ASC + ORDER BY "namespace" ASC, "name" ASC ; diff --git a/pkg/storage/unified/testing/storage_backend.go b/pkg/storage/unified/testing/storage_backend.go index 2390a6e68fb..e3314376715 100644 --- a/pkg/storage/unified/testing/storage_backend.go +++ b/pkg/storage/unified/testing/storage_backend.go @@ -175,8 +175,8 @@ func runTestIntegrationBackendHappyPath(t *testing.T, backend resource.StorageBa require.NoError(t, err) require.Nil(t, resp.Error) require.Len(t, resp.Items, 2) - require.Contains(t, string(resp.Items[0].Value), "item3 ADDED") - require.Contains(t, string(resp.Items[1].Value), "item2 MODIFIED") + require.Contains(t, string(resp.Items[0].Value), "item2 MODIFIED") + require.Contains(t, string(resp.Items[1].Value), "item3 ADDED") require.GreaterOrEqual(t, resp.ResourceVersion, rv5) // rv5 is the latest resource version }) @@ -372,11 +372,11 @@ func runTestIntegrationBackendList(t *testing.T, backend resource.StorageBackend require.NoError(t, err) require.Nil(t, res.Error) require.Len(t, res.Items, 5) - // should be sorted by resource_version ASC + // should be sorted by key ASC require.Contains(t, string(res.Items[0].Value), "item1 ADDED") - require.Contains(t, string(res.Items[1].Value), "item4 ADDED") - require.Contains(t, string(res.Items[2].Value), "item5 ADDED") - require.Contains(t, string(res.Items[3].Value), "item2 MODIFIED") + require.Contains(t, string(res.Items[1].Value), "item2 MODIFIED") + require.Contains(t, string(res.Items[2].Value), "item4 ADDED") + require.Contains(t, string(res.Items[3].Value), "item5 ADDED") require.Contains(t, string(res.Items[4].Value), "item6 ADDED") require.Empty(t, res.NextPageToken) @@ -399,8 +399,8 @@ func runTestIntegrationBackendList(t *testing.T, backend resource.StorageBackend continueToken, err := resource.GetContinueToken(res.NextPageToken) require.NoError(t, err) require.Contains(t, string(res.Items[0].Value), "item1 ADDED") - require.Contains(t, string(res.Items[1].Value), "item4 ADDED") - require.Contains(t, string(res.Items[2].Value), "item5 ADDED") + require.Contains(t, string(res.Items[1].Value), "item2 MODIFIED") + require.Contains(t, string(res.Items[2].Value), "item4 ADDED") require.GreaterOrEqual(t, continueToken.ResourceVersion, rv8) }) @@ -438,12 +438,13 @@ func runTestIntegrationBackendList(t *testing.T, backend resource.StorageBackend }, }) require.NoError(t, err) + require.NoError(t, err) require.Nil(t, res.Error) require.Len(t, res.Items, 3) t.Log(res.Items) require.Contains(t, string(res.Items[0].Value), "item1 ADDED") - require.Contains(t, string(res.Items[1].Value), "item4 ADDED") - require.Contains(t, string(res.Items[2].Value), "item5 ADDED") + require.Contains(t, string(res.Items[1].Value), "item2 MODIFIED") + require.Contains(t, string(res.Items[2].Value), "item4 ADDED") continueToken, err := resource.GetContinueToken(res.NextPageToken) require.NoError(t, err) @@ -470,84 +471,15 @@ func runTestIntegrationBackendList(t *testing.T, backend resource.StorageBackend require.NoError(t, err) require.Nil(t, res.Error) require.Len(t, res.Items, 2) - require.Contains(t, string(res.Items[0].Value), "item5 ADDED") - require.Contains(t, string(res.Items[1].Value), "item2 MODIFIED") + t.Log(res.Items) + require.Contains(t, string(res.Items[0].Value), "item4 ADDED") + require.Contains(t, string(res.Items[1].Value), "item5 ADDED") continueToken, err = resource.GetContinueToken(res.NextPageToken) require.NoError(t, err) require.Equal(t, rv8, continueToken.ResourceVersion) require.Equal(t, int64(4), continueToken.StartOffset) }) - - t.Run("Paginate through latest items one by one", func(t *testing.T) { - baseKey := &resourcepb.ResourceKey{ - Namespace: ns, - Group: "group", - Resource: "resource", - } - expectedItems := []string{"item1 ADDED", "item4 ADDED", "item5 ADDED", "item2 MODIFIED", "item6 ADDED"} - var allItems []*resourcepb.ResourceWrapper - var nextPageToken string - - for i, expectedValue := range expectedItems { - req := &resourcepb.ListRequest{ - Limit: 1, - NextPageToken: nextPageToken, - Options: &resourcepb.ListOptions{ - Key: baseKey, - }, - } - - res, err := server.List(ctx, req) - require.NoError(t, err) - require.Nil(t, res.Error) - require.Len(t, res.Items, 1) - require.Contains(t, string(res.Items[0].Value), expectedValue) - allItems = append(allItems, res.Items[0]) - - if i < len(expectedItems)-1 { - require.NotEmpty(t, res.NextPageToken, "should have a continue token for page %d", i+1) - nextPageToken = res.NextPageToken - } else { - require.Empty(t, res.NextPageToken, "should not have a continue token on the last page") - } - } - require.Len(t, allItems, len(expectedItems)) - }) - - t.Run("Paginate latest with a limit larger than remaining items", func(t *testing.T) { - baseKey := &resourcepb.ResourceKey{ - Namespace: ns, - Group: "group", - Resource: "resource", - } - // Request first 3 items (out of 5 total) - req := &resourcepb.ListRequest{ - Limit: 3, - Options: &resourcepb.ListOptions{ - Key: baseKey, - }, - } - res1, err := server.List(ctx, req) - require.NoError(t, err) - require.Nil(t, res1.Error) - require.Len(t, res1.Items, 3) - require.NotEmpty(t, res1.NextPageToken) - require.Contains(t, string(res1.Items[0].Value), "item1 ADDED") - require.Contains(t, string(res1.Items[1].Value), "item4 ADDED") - require.Contains(t, string(res1.Items[2].Value), "item5 ADDED") - - // Request next page with a large limit - req.Limit = 10 // Larger than the 2 remaining items - req.NextPageToken = res1.NextPageToken - res2, err := server.List(ctx, req) - require.NoError(t, err) - require.Nil(t, res2.Error) - require.Len(t, res2.Items, 2) // Should only get the 2 remaining items - require.Contains(t, string(res2.Items[0].Value), "item2 MODIFIED") - require.Contains(t, string(res2.Items[1].Value), "item6 ADDED") - require.Empty(t, res2.NextPageToken, "should be no continue token on the last page") - }) } func runTestIntegrationBackendListHistory(t *testing.T, backend resource.StorageBackend, nsPrefix string) { @@ -727,6 +659,7 @@ func runTestIntegrationBackendListHistory(t *testing.T, backend resource.Storage require.NoError(t, err) require.Nil(t, res.Error) require.Len(t, res.Items, 2) + t.Log(res.Items) require.Contains(t, string(res.Items[0].Value), "item1 MODIFIED") require.Equal(t, rvHistory2, res.Items[0].ResourceVersion) require.Contains(t, string(res.Items[1].Value), "item1 MODIFIED") diff --git a/pkg/tests/api/alerting/api_alertmanager_extra_config_test.go b/pkg/tests/api/alerting/api_alertmanager_extra_config_test.go new file mode 100644 index 00000000000..77f5ce83b85 --- /dev/null +++ b/pkg/tests/api/alerting/api_alertmanager_extra_config_test.go @@ -0,0 +1,109 @@ +package alerting + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/tests/testinfra" +) + +func TestIntegrationAlertmanagerExtraConfigMerging(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + testinfra.SQLiteIntegrationTest(t) + + dir, gpath := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + DisableAnonymous: true, + AppModeProduction: true, + EnableFeatureToggles: []string{ + "alertingImportAlertmanagerAPI", + "alertingImportAlertmanagerUI", + }, + }) + + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, gpath) + apiClient := newAlertingApiClient(grafanaListedAddr, "admin", "admin") + + t.Run("retrieve merged configuration via extra config datasource", func(t *testing.T) { + // first upload standard alertmanager configuration + baseConfig := apimodels.PostableUserConfig{} + baseConfigJSON := `{ + "alertmanager_config": { + "route": { + "receiver": "base-default" + }, + "receivers": [{ + "name": "base-default", + "grafana_managed_receiver_configs": [{ + "name": "base-email", + "type": "email", + "settings": { + "addresses": "example@grafana.com" + } + }] + }] + }, + "template_files": { + "base.tmpl": "{{ define \"base.template\" }}Base Template{{ end }}" + } + }` + err := json.Unmarshal([]byte(baseConfigJSON), &baseConfig) + require.NoError(t, err) + + err = env.Server.HTTPServer.AlertNG.MultiOrgAlertmanager.SaveAndApplyAlertmanagerConfiguration(context.Background(), 1, baseConfig) + require.NoError(t, err) + + //now add extra configuration + extraHeaders := map[string]string{ + "Content-Type": "application/yaml", + "X-Grafana-Alerting-Merge-Matchers": "imported=true", + } + + extraConfigYAML := ` +route: + receiver: extra-webhook + +receivers: +- name: extra-webhook + webhook_configs: + - url: 'http://external.example.com/webhook' +` + + extraConfig := apimodels.AlertmanagerUserConfig{ + AlertmanagerConfig: extraConfigYAML, + TemplateFiles: map[string]string{ + "extra.tmpl": `{{ define "extra.template" }}Extra Template{{ end }}`, + }, + } + + response := apiClient.ConvertPrometheusPostAlertmanagerConfig(t, extraConfig, extraHeaders) + require.Equal(t, "success", response.Status) + + // get the merged configuration and check it + datasourceUID := "~grafana-with-extra-config" + mergedConfig := apiClient.GetAlertmanagerConfigForDatasource(t, datasourceUID) + + receiverNames := make(map[string]bool) + for _, recv := range mergedConfig.AlertmanagerConfig.Receivers { + receiverNames[recv.Name] = true + } + + require.True(t, receiverNames["base-default"]) + require.True(t, receiverNames["extra-webhook"]) + + require.Contains(t, mergedConfig.TemplateFiles, "base.tmpl", "Base template should be present") + require.Equal(t, `{{ define "base.template" }}Base Template{{ end }}`, mergedConfig.TemplateFiles["base.tmpl"]) + require.Contains(t, mergedConfig.TemplateFiles, "extra.tmpl", "Extra template should be present") + require.Equal(t, `{{ define "extra.template" }}Extra Template{{ end }}`, mergedConfig.TemplateFiles["extra.tmpl"]) + + require.NotNil(t, mergedConfig.AlertmanagerConfig.Route) + }) +} diff --git a/pkg/tests/api/alerting/api_namespace_test.go b/pkg/tests/api/alerting/api_namespace_test.go index 5ef2508a36b..6a9c0a6fa11 100644 --- a/pkg/tests/api/alerting/api_namespace_test.go +++ b/pkg/tests/api/alerting/api_namespace_test.go @@ -1,6 +1,7 @@ package alerting import ( + "bytes" "context" "encoding/json" "fmt" @@ -10,8 +11,7 @@ import ( "github.com/prometheus/common/model" "github.com/stretchr/testify/require" - "bytes" - + "github.com/grafana/grafana/pkg/configprovider" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" @@ -180,7 +180,9 @@ func TestIntegration_NamespacingForRules(t *testing.T) { }) t.Run("org separation", func(t *testing.T) { - orgService, err := orgimpl.ProvideService(store, cfg, quotaimpl.ProvideService(store, cfg)) + cfgProvider, err := configprovider.ProvideService(cfg) + require.NoError(t, err) + orgService, err := orgimpl.ProvideService(store, cfg, quotaimpl.ProvideService(context.Background(), store, cfgProvider)) require.NoError(t, err) newOrg, err := orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: "Test Org 2"}) require.NoError(t, err) @@ -386,7 +388,9 @@ func TestIntegration_NamespacingForPrometheusRules(t *testing.T) { }) t.Run("should maintain org separation for Prometheus rules", func(t *testing.T) { - orgService, err := orgimpl.ProvideService(store, cfg, quotaimpl.ProvideService(store, cfg)) + cfgProvider, err := configprovider.ProvideService(cfg) + require.NoError(t, err) + orgService, err := orgimpl.ProvideService(store, cfg, quotaimpl.ProvideService(context.Background(), store, cfgProvider)) require.NoError(t, err) newOrg, err := orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: "Prometheus Test Org 2"}) require.NoError(t, err) diff --git a/pkg/tests/api/alerting/api_provisioning_test.go b/pkg/tests/api/alerting/api_provisioning_test.go index 54b3e71b61e..57cf2ea0de3 100644 --- a/pkg/tests/api/alerting/api_provisioning_test.go +++ b/pkg/tests/api/alerting/api_provisioning_test.go @@ -661,7 +661,7 @@ func TestIntegrationProvisioningRules(t *testing.T) { Model: json.RawMessage([]byte(`{"type":"math","expression":"2 + 3 \u003e 1"}`)), }, }, - MissingSeriesEvalsToResolve: util.Pointer(3), + MissingSeriesEvalsToResolve: util.Pointer[int64](3), }, }, } @@ -676,7 +676,7 @@ func TestIntegrationProvisioningRules(t *testing.T) { for _, rule := range result.Rules { require.NotEmpty(t, rule.UID) if rule.UID == "rule3" { - require.Equal(t, 3, *rule.MissingSeriesEvalsToResolve) + require.Equal(t, int64(3), *rule.MissingSeriesEvalsToResolve) } } }) @@ -1028,6 +1028,15 @@ func TestIntegrationExportFileProvision(t *testing.T) { require.Equal(t, http.StatusOK, status) require.Greater(t, len(data), 0) + t.Run("provisioned alert rules should have proper data", func(t *testing.T) { + provisionedRule, status, _ := apiClient.GetProvisioningAlertRule(t, "my_id_1") + require.Equal(t, http.StatusOK, status) + + require.Equal(t, model.Duration(time.Second*120), provisionedRule.KeepFiringFor) + require.NotNil(t, provisionedRule.MissingSeriesEvalsToResolve) + require.Equal(t, int64(3), *provisionedRule.MissingSeriesEvalsToResolve) + }) + t.Run("exported alert rules should escape $ characters", func(t *testing.T) { // call export endpoint status, exportRaw := apiClient.ExportRulesWithStatus(t, &definitions.AlertRulesExportParameters{ diff --git a/pkg/tests/api/alerting/api_ruler_test.go b/pkg/tests/api/alerting/api_ruler_test.go index da9f0c3441b..e6d022edf6f 100644 --- a/pkg/tests/api/alerting/api_ruler_test.go +++ b/pkg/tests/api/alerting/api_ruler_test.go @@ -1761,42 +1761,42 @@ func TestIntegrationRuleUpdate(t *testing.T) { t.Run("missing_series_evals_to_resolve", func(t *testing.T) { testCases := []struct { name string - initialValue *int - updatedValue *int - expectedValue *int + initialValue *int64 + updatedValue *int64 + expectedValue *int64 expectedStatus int }{ { name: "should be able to set missing_series_evals_to_resolve to 5", initialValue: nil, - updatedValue: util.Pointer(5), - expectedValue: util.Pointer(5), + updatedValue: util.Pointer[int64](5), + expectedValue: util.Pointer[int64](5), expectedStatus: http.StatusAccepted, }, { name: "should be able to update missing_series_evals_to_resolve", - initialValue: util.Pointer(1), - updatedValue: util.Pointer(2), - expectedValue: util.Pointer(2), + initialValue: util.Pointer[int64](1), + updatedValue: util.Pointer[int64](2), + expectedValue: util.Pointer[int64](2), expectedStatus: http.StatusAccepted, }, { name: "should preserve missing_series_evals_to_resolve when it's set nil", - initialValue: util.Pointer(5), + initialValue: util.Pointer[int64](5), updatedValue: nil, - expectedValue: util.Pointer(5), + expectedValue: util.Pointer[int64](5), expectedStatus: http.StatusAccepted, }, { name: "should reject missing_series_evals_to_resolve < 0", - initialValue: util.Pointer(1), - updatedValue: util.Pointer(-1), + initialValue: util.Pointer[int64](1), + updatedValue: util.Pointer[int64](-1), expectedStatus: http.StatusBadRequest, }, { name: "should be able to reset missing_series_evals_to_resolve by setting it to 0", - initialValue: util.Pointer(1), - updatedValue: util.Pointer(0), + initialValue: util.Pointer[int64](1), + updatedValue: util.Pointer[int64](0), expectedValue: nil, expectedStatus: http.StatusAccepted, }, @@ -3474,7 +3474,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) { }, NoDataState: apimodels.NoDataState(ngmodels.Alerting), ExecErrState: apimodels.ExecutionErrorState(ngmodels.AlertingErrState), - MissingSeriesEvalsToResolve: util.Pointer(2), // If UID is specified, this field is required + MissingSeriesEvalsToResolve: util.Pointer[int64](2), // If UID is specified, this field is required }, }, }, diff --git a/pkg/tests/api/alerting/test-data/provisioning-rules.yaml b/pkg/tests/api/alerting/test-data/provisioning-rules.yaml index 3d916fcb7d4..52000630a54 100644 --- a/pkg/tests/api/alerting/test-data/provisioning-rules.yaml +++ b/pkg/tests/api/alerting/test-data/provisioning-rules.yaml @@ -62,6 +62,10 @@ groups: execErrState: Alerting # for how long should the alert fire before alerting for: 60s + # for how long the alert should keep firing after condition stops being true + keepFiringFor: 120s + # number of evaluation intervals required to resolve alert when data is missing + missing_series_evals_to_resolve: 3 # > a map of strings to pass around any data annotations: some_key: some_value diff --git a/pkg/tests/api/alerting/testing.go b/pkg/tests/api/alerting/testing.go index 056f813cf1d..48a516ed87c 100644 --- a/pkg/tests/api/alerting/testing.go +++ b/pkg/tests/api/alerting/testing.go @@ -20,6 +20,7 @@ import ( "gopkg.in/yaml.v3" "github.com/grafana/grafana/pkg/api" + "github.com/grafana/grafana/pkg/configprovider" "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" @@ -1052,6 +1053,16 @@ func (a apiClient) GetAlertmanagerConfigWithStatus(t *testing.T) (apimodels.Gett return sendRequestJSON[apimodels.GettableUserConfig](t, req, http.StatusOK) } +func (a apiClient) GetAlertmanagerConfigForDatasource(t *testing.T, datasourceUID string) apimodels.GettableUserConfig { + t.Helper() + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/alertmanager/%s/config/api/v1/alerts", a.url, datasourceUID), nil) + require.NoError(t, err) + + config, status, body := sendRequestJSON[apimodels.GettableUserConfig](t, req, http.StatusOK) + requireStatusCode(t, http.StatusOK, status, body) + return config +} + func (a apiClient) GetActiveAlertsWithStatus(t *testing.T) (apimodels.AlertGroups, int, string) { t.Helper() req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/alertmanager/grafana/api/v2/alerts/groups", a.url), nil) @@ -1460,7 +1471,9 @@ func createUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCom cfg.AutoAssignOrg = true cfg.AutoAssignOrgId = 1 - quotaService := quotaimpl.ProvideService(db, cfg) + cfgProvider, err := configprovider.ProvideService(cfg) + require.NoError(t, err) + quotaService := quotaimpl.ProvideService(context.Background(), db, cfgProvider) orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( @@ -1474,6 +1487,14 @@ func createUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCom return u.ID } +func (a apiClient) GetProvisioningAlertRule(t *testing.T, ruleUID string) (apimodels.ProvisionedAlertRule, int, string) { + t.Helper() + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/provisioning/alert-rules/%s", a.url, ruleUID), nil) + require.NoError(t, err) + + return sendRequestJSON[apimodels.ProvisionedAlertRule](t, req, http.StatusOK) +} + func (a apiClient) GetProvisioningAlertRuleExport(t *testing.T, ruleUID string, params *apimodels.ExportQueryParams) (int, string) { t.Helper() u, err := url.Parse(fmt.Sprintf("%s/api/v1/provisioning/alert-rules/%s/export", a.url, ruleUID)) diff --git a/pkg/tests/api/correlations/common_test.go b/pkg/tests/api/correlations/common_test.go index 2bd291db9e8..f7039417a67 100644 --- a/pkg/tests/api/correlations/common_test.go +++ b/pkg/tests/api/correlations/common_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/configprovider" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/server" "github.com/grafana/grafana/pkg/services/correlations" @@ -144,7 +145,9 @@ func (c TestContext) createOrg(name string) int64 { c.t.Helper() store := c.env.SQLStore c.env.Cfg.AutoAssignOrg = false - quotaService := quotaimpl.ProvideService(store, c.env.Cfg) + cfgProvider, err := configprovider.ProvideService(c.env.Cfg) + require.NoError(c.t, err) + quotaService := quotaimpl.ProvideService(context.Background(), store, cfgProvider) orgService, err := orgimpl.ProvideService(store, c.env.Cfg, quotaService) require.NoError(c.t, err) orgId, err := orgService.GetOrCreate(context.Background(), name) @@ -158,7 +161,9 @@ func (c TestContext) createUser(cmd user.CreateUserCommand) User { c.env.Cfg.AutoAssignOrg = true c.env.Cfg.AutoAssignOrgId = 1 - quotaService := quotaimpl.ProvideService(store, c.env.Cfg) + cfgProvider, err := configprovider.ProvideService(c.env.Cfg) + require.NoError(c.t, err) + quotaService := quotaimpl.ProvideService(context.Background(), store, cfgProvider) orgService, err := orgimpl.ProvideService(store, c.env.Cfg, quotaService) require.NoError(c.t, err) usrSvc, err := userimpl.ProvideService( diff --git a/pkg/tests/api/plugins/api_plugins_test.go b/pkg/tests/api/plugins/api_plugins_test.go index 56a10ed6a7f..ff3878476a0 100644 --- a/pkg/tests/api/plugins/api_plugins_test.go +++ b/pkg/tests/api/plugins/api_plugins_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/configprovider" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" @@ -199,7 +200,9 @@ func createUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCom cfg.AutoAssignOrg = true cfg.AutoAssignOrgId = 1 - quotaService := quotaimpl.ProvideService(db, cfg) + cfgProvider, err := configprovider.ProvideService(cfg) + require.NoError(t, err) + quotaService := quotaimpl.ProvideService(context.Background(), db, cfgProvider) orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( @@ -224,7 +227,7 @@ func makePostRequest(t *testing.T, URL string) (int, map[string]interface{}) { b, err := io.ReadAll(resp.Body) require.NoError(t, err) - var body = make(map[string]interface{}) + body := make(map[string]interface{}) err = json.Unmarshal(b, &body) require.NoError(t, err) @@ -251,7 +254,7 @@ func expectedResp(t *testing.T, filename string) dtos.PluginList { } func updateRespSnapshot(t *testing.T, filename string, body string) { - err := os.WriteFile(filepath.Join("data", filename), []byte(body), 0600) + err := os.WriteFile(filepath.Join("data", filename), []byte(body), 0o600) if err != nil { t.Errorf("error writing snapshot %s: %v", filename, err) } diff --git a/pkg/tests/api/shorturl/short_url_test.go b/pkg/tests/api/shorturl/short_url_test.go index a938b861281..cc014662457 100644 --- a/pkg/tests/api/shorturl/short_url_test.go +++ b/pkg/tests/api/shorturl/short_url_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/configprovider" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/org" @@ -110,7 +111,9 @@ func createUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCom cfg.AutoAssignOrg = true cfg.AutoAssignOrgId = 1 - quotaService := quotaimpl.ProvideService(db, cfg) + cfgProvider, err := configprovider.ProvideService(cfg) + require.NoError(t, err) + quotaService := quotaimpl.ProvideService(context.Background(), db, cfgProvider) orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( diff --git a/pkg/tests/api/stats/admin_test.go b/pkg/tests/api/stats/admin_test.go index b1b18a7f2b7..7e6356e555d 100644 --- a/pkg/tests/api/stats/admin_test.go +++ b/pkg/tests/api/stats/admin_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/configprovider" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/org" @@ -90,7 +91,9 @@ func createUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCom cfg.AutoAssignOrg = true cfg.AutoAssignOrgId = 1 - quotaService := quotaimpl.ProvideService(db, cfg) + cfgProvider, err := configprovider.ProvideService(cfg) + require.NoError(t, err) + quotaService := quotaimpl.ProvideService(context.Background(), db, cfgProvider) orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( diff --git a/pkg/tests/apis/alerting/notifications/templategroup/templates_group_test.go b/pkg/tests/apis/alerting/notifications/templategroup/templates_group_test.go index 3d1e83cbd4a..ba76f3f2d1a 100644 --- a/pkg/tests/apis/alerting/notifications/templategroup/templates_group_test.go +++ b/pkg/tests/apis/alerting/notifications/templategroup/templates_group_test.go @@ -108,7 +108,7 @@ func TestIntegrationResourceIdentifier(t *testing.T) { require.NoError(t, err) require.NotEmptyf(t, actual.Name, "Resource name should not be empty") - defaultDefn, err := templates.DefaultTemplate() + defaultDefn, err := templates.DefaultTemplate(templates.DefaultTemplatesToOmit) require.NoError(t, err) require.Equal(t, v0alpha1.TemplateGroupSpec{ Title: v0alpha1.DefaultTemplateTitle, diff --git a/pkg/tests/apis/helper.go b/pkg/tests/apis/helper.go index 994e9f96a13..1865c42b49f 100644 --- a/pkg/tests/apis/helper.go +++ b/pkg/tests/apis/helper.go @@ -29,6 +29,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/configprovider" "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/server" @@ -95,7 +96,9 @@ func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper { Namespacer: request.GetNamespaceMapper(nil), } - quotaService := quotaimpl.ProvideService(c.env.SQLStore, c.env.Cfg) + cfgProvider, err := configprovider.ProvideService(c.env.Cfg) + require.NoError(c.t, err) + quotaService := quotaimpl.ProvideService(context.Background(), c.env.SQLStore, cfgProvider) orgSvc, err := orgimpl.ProvideService(c.env.SQLStore, c.env.Cfg, quotaService) require.NoError(c.t, err) c.orgSvc = orgSvc @@ -375,8 +378,10 @@ type K8sResponse[T any] struct { Status *metav1.Status } -type AnyResourceResponse = K8sResponse[AnyResource] -type AnyResourceListResponse = K8sResponse[AnyResourceList] +type ( + AnyResourceResponse = K8sResponse[AnyResource] + AnyResourceListResponse = K8sResponse[AnyResourceList] +) func (c *K8sTestHelper) PostResource(user User, resource string, payload AnyResource) AnyResourceResponse { c.t.Helper() @@ -799,7 +804,7 @@ func VerifyOpenAPISnapshots(t *testing.T, dir string, gv schema.GroupVersion, h } if write { - e2 := os.WriteFile(fpath, []byte(pretty), 0644) + e2 := os.WriteFile(fpath, []byte(pretty), 0o644) if e2 != nil { t.Errorf("error writing file: %s", e2.Error()) } diff --git a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json index ab1e77d03b9..2352079084e 100644 --- a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json @@ -1734,7 +1734,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.JobList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.JobList" } } } @@ -1757,7 +1757,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.JobSpec" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.JobSpec" }, "examples": { "incremental": { @@ -1788,7 +1788,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.Job" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" } } } @@ -1837,7 +1837,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.Job" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job" } } } @@ -2527,7 +2527,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.RepositoryViewList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.RepositoryViewList" } } } @@ -2560,7 +2560,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ResourceStats" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ResourceStats" } } } @@ -2571,6 +2571,26 @@ }, "components": { "schemas": { + "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Author": { + "type": "object", + "required": [ + "name", + "username" + ], + "properties": { + "avatarURL": { + "type": "string" + }, + "name": { + "type": "string", + "default": "" + }, + "username": { + "type": "string", + "default": "" + } + } + }, "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.BitbucketRepositoryConfig": { "type": "object", "required": [ @@ -2674,6 +2694,59 @@ } } }, + "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.FileItem": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "author": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "modified": { + "type": "integer", + "format": "int64" + }, + "path": { + "type": "string", + "default": "" + }, + "size": { + "type": "integer", + "format": "int64" + } + } + }, + "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.FileList": { + "description": "Information we can get just from the file listing", + "type": "object", + "required": [ + "items" + ], + "properties": { + "apiVersion": { + "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" + }, + "items": { + "type": "array", + "items": { + "default": {} + }, + "x-kubernetes-list-type": "atomic" + }, + "kind": { + "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" + }, + "metadata": { + "default": {} + } + } + }, "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.GitHubRepositoryConfig": { "type": "object", "required": [ @@ -2802,6 +2875,148 @@ } } }, + "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.HistoricJob": { + "description": "HistoricJob is an append only log, saving all jobs that have been processed.\n\nNOTE: This should not be used directly by any external consumer. When there is a more stable integration with loki (an appropriate append only store) this may be removed without notice.\n\nThe repository name and type are stored as labels.", + "type": "object", + "properties": { + "apiVersion": { + "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" + }, + "kind": { + "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" + }, + "metadata": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta" + } + ] + }, + "spec": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.JobSpec" + } + ] + }, + "status": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.JobStatus" + } + ] + } + }, + "x-kubernetes-group-version-kind": [ + { + "group": "provisioning.grafana.app", + "kind": "HistoricJob", + "version": "v0alpha1" + } + ] + }, + "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.HistoricJobList": { + "type": "object", + "properties": { + "apiVersion": { + "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" + }, + "items": { + "type": "array", + "items": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.HistoricJob" + } + ] + } + }, + "kind": { + "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" + }, + "metadata": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta" + } + ] + } + }, + "x-kubernetes-group-version-kind": [ + { + "group": "provisioning.grafana.app", + "kind": "HistoricJobList", + "version": "v0alpha1" + } + ] + }, + "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.HistoryItem": { + "type": "object", + "required": [ + "ref", + "message", + "authors", + "createdAt" + ], + "properties": { + "authors": { + "type": "array", + "items": { + "default": {} + }, + "x-kubernetes-list-type": "atomic" + }, + "createdAt": { + "type": "integer", + "format": "int64", + "default": 0 + }, + "message": { + "type": "string", + "default": "" + }, + "ref": { + "type": "string", + "default": "" + } + } + }, + "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.HistoryList": { + "description": "HistoryList is a list of versions of a resource", + "type": "object", + "required": [ + "items" + ], + "properties": { + "apiVersion": { + "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" + }, + "items": { + "type": "array", + "items": { + "default": {} + }, + "x-kubernetes-list-type": "atomic" + }, + "kind": { + "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" + }, + "metadata": { + "default": {} + } + } + }, "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.Job": { "description": "The repository name and type are stored as labels", "type": "object", @@ -3051,6 +3266,14 @@ "items": { "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.JobResourceSummary" } + }, + "url": { + "description": "URLs contains URLs for the reference branch or commit if applicable.", + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.RepositoryURLs" + } + ] } } }, @@ -3062,6 +3285,33 @@ } } }, + "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ManagerStats": { + "type": "object", + "required": [ + "stats" + ], + "properties": { + "id": { + "description": "Manager identity", + "type": "string" + }, + "kind": { + "description": "Manager kind", + "type": "string" + }, + "stats": { + "description": "stats", + "type": "array", + "items": { + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ResourceCount" + } + ] + } + } + } + }, "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.MigrateJobOptions": { "type": "object", "properties": { @@ -3432,6 +3682,133 @@ } } }, + "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.RepositoryURLs": { + "type": "object", + "properties": { + "compareURL": { + "description": "Compare this version to the target branch", + "type": "string" + }, + "newPullRequestURL": { + "description": "A URL that will create a new pull request for this branch", + "type": "string" + }, + "repositoryURL": { + "description": "A URL pointing to the repository this lives in", + "type": "string" + }, + "sourceURL": { + "description": "A URL pointing to the file or ref in the repository", + "type": "string" + } + } + }, + "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.RepositoryView": { + "type": "object", + "required": [ + "name", + "title", + "type", + "target", + "workflows" + ], + "properties": { + "branch": { + "description": "For git, this is the target branch", + "type": "string" + }, + "name": { + "description": "The k8s name for this repository", + "type": "string", + "default": "" + }, + "target": { + "description": "When syncing, where values are saved\n\nPossible enum values:\n - `\"folder\"` Resources will be saved into a folder managed by this repository It will contain a copy of everything from the remote The folder k8s name will be the same as the repository k8s name\n - `\"instance\"` Resources are saved in the global context Only one repository may specify the `instance` target When this exists, the UI will promote writing to the instance repo rather than the grafana database (where possible)", + "type": "string", + "default": "", + "enum": [ + "folder", + "instance" + ] + }, + "title": { + "description": "Repository display", + "type": "string", + "default": "" + }, + "type": { + "description": "The repository type\n\nPossible enum values:\n - `\"bitbucket\"`\n - `\"git\"`\n - `\"github\"`\n - `\"gitlab\"`\n - `\"local\"`", + "type": "string", + "default": "", + "enum": [ + "bitbucket", + "git", + "github", + "gitlab", + "local" + ] + }, + "workflows": { + "description": "The supported workflows", + "type": "array", + "items": { + "type": "string", + "default": "", + "enum": [ + "branch", + "write" + ] + } + } + } + }, + "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.RepositoryViewList": { + "description": "Summary shows a view of the configuration that is sanitized and is OK for logged in users to see", + "type": "object", + "required": [ + "items" + ], + "properties": { + "apiVersion": { + "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" + }, + "availableRepositoryTypes": { + "description": "AvailableRepositoryTypes is the list of repository types supported in this instance (e.g. git, bitbucket, github, etc)", + "type": "array", + "items": { + "type": "string", + "default": "", + "enum": [ + "bitbucket", + "git", + "github", + "gitlab", + "local" + ] + } + }, + "items": { + "type": "array", + "items": { + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.RepositoryView" + } + ] + }, + "x-kubernetes-map-type": "atomic" + }, + "kind": { + "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" + }, + "legacyStorage": { + "description": "The backend is using legacy storage FIXME: Not sure where this should be exposed... but we need it somewhere The UI should force the onboarding workflow when this is true", + "type": "boolean" + } + } + }, "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ResourceCount": { "type": "object", "required": [ @@ -3656,6 +4033,59 @@ } } }, + "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ResourceStats": { + "description": "Information we can get just from the file listing", + "type": "object", + "properties": { + "apiVersion": { + "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" + }, + "instance": { + "description": "Stats across all unified storage When legacy storage is still used, this will offer a shim", + "type": "array", + "items": { + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ResourceCount" + } + ] + }, + "x-kubernetes-list-type": "atomic" + }, + "kind": { + "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" + }, + "managed": { + "description": "Stats for each manager", + "type": "array", + "items": { + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ManagerStats" + } + ] + }, + "x-kubernetes-list-type": "atomic" + }, + "metadata": { + "default": {} + }, + "unmanaged": { + "description": "Stats across all unified storage When legacy storage is still used, this will offer a shim", + "type": "array", + "items": { + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ResourceCount" + } + ] + }, + "x-kubernetes-list-type": "atomic" + } + } + }, "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ResourceType": { "type": "object", "properties": { @@ -3683,27 +4113,6 @@ } } }, - "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ResourceURLs": { - "type": "object", - "properties": { - "compareURL": { - "description": "Compare this version to the target branch", - "type": "string" - }, - "newPullRequestURL": { - "description": "A URL that will create a new pull requeset for this branch", - "type": "string" - }, - "repositoryURL": { - "description": "A URL pointing to the repository this lives in", - "type": "string" - }, - "sourceURL": { - "description": "A URL pointing to the this file in the repository", - "type": "string" - } - } - }, "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ResourceWrapper": { "description": "This is a container type for any resource type", "type": "object", @@ -3771,7 +4180,7 @@ "description": "Typed links for this file (only supported by external systems, github etc)", "allOf": [ { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ResourceURLs" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.RepositoryURLs" } ] } @@ -4002,1457 +4411,6 @@ "additionalProperties": true, "x-kubernetes-preserve-unknown-fields": true }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.Author": { - "type": "object", - "required": [ - "name", - "username" - ], - "properties": { - "avatarURL": { - "type": "string" - }, - "name": { - "type": "string", - "default": "" - }, - "username": { - "type": "string", - "default": "" - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.BitbucketRepositoryConfig": { - "type": "object", - "required": [ - "branch" - ], - "properties": { - "branch": { - "description": "The branch to use in the repository.", - "type": "string", - "default": "" - }, - "encryptedToken": { - "description": "Token for accessing the repository, but encrypted. This is not possible to read back to a user decrypted.", - "type": "string", - "format": "byte", - "x-kubernetes-list-type": "atomic" - }, - "path": { - "description": "Path is the subdirectory for the Grafana data. If specified, Grafana will ignore anything that is outside this directory in the repository. This is usually something like `grafana/`. Trailing and leading slash are not required. They are always added when needed. The path is relative to the root of the repository, regardless of the leading slash.\n\nWhen specifying something like `grafana-`, we will not look for `grafana-*`; we will only look for files under the directory `/grafana-/`. That means `/grafana-example.json` would not be found.", - "type": "string" - }, - "token": { - "description": "Token for accessing the repository. If set, it will be encrypted into encryptedToken, then set to an empty string again.", - "type": "string" - }, - "tokenUser": { - "description": "TokenUser is the user that will be used to access the repository if it's a personal access token.", - "type": "string" - }, - "url": { - "description": "The repository URL (e.g. `https://bitbucket.org/example/test`).", - "type": "string" - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.DeleteJobOptions": { - "type": "object", - "properties": { - "paths": { - "description": "Paths to be deleted. Examples: - dashboard.json (for a file) - a/b/c/other-dashboard.json (for a file) - nested/deep/ (for a directory) FIXME: we should validate this in admission hooks", - "type": "array", - "items": { - "type": "string", - "default": "" - } - }, - "ref": { - "description": "Ref to the branch or commit hash to delete from", - "type": "string" - }, - "resources": { - "description": "Resources to delete This option has been created because currently the frontend does not use standarized app platform APIs. For performance and API consistency reasons, the preferred option is it to use the paths.", - "type": "array", - "items": { - "default": {} - } - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ErrorDetails": { - "type": "object", - "required": [ - "type" - ], - "properties": { - "detail": { - "type": "string" - }, - "field": { - "type": "string" - }, - "type": { - "type": "string", - "default": "" - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ExportJobOptions": { - "type": "object", - "properties": { - "branch": { - "description": "FIXME: we should validate this in admission hooks Target branch for export (only git)", - "type": "string" - }, - "folder": { - "description": "The source folder (or empty) to export", - "type": "string" - }, - "message": { - "description": "Message to use when committing the changes in a single commit", - "type": "string" - }, - "path": { - "description": "FIXME: we should validate this in admission hooks Prefix in target file system", - "type": "string" - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.FileItem": { - "type": "object", - "required": [ - "path" - ], - "properties": { - "author": { - "type": "string" - }, - "hash": { - "type": "string" - }, - "modified": { - "type": "integer", - "format": "int64" - }, - "path": { - "type": "string", - "default": "" - }, - "size": { - "type": "integer", - "format": "int64" - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.FileList": { - "description": "Information we can get just from the file listing", - "type": "object", - "required": [ - "items" - ], - "properties": { - "apiVersion": { - "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" - }, - "items": { - "type": "array", - "items": { - "default": {} - }, - "x-kubernetes-list-type": "atomic" - }, - "kind": { - "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" - }, - "metadata": { - "default": {} - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.GitHubRepositoryConfig": { - "type": "object", - "required": [ - "branch" - ], - "properties": { - "branch": { - "description": "The branch to use in the repository.", - "type": "string", - "default": "" - }, - "encryptedToken": { - "description": "Token for accessing the repository, but encrypted. This is not possible to read back to a user decrypted.", - "type": "string", - "format": "byte", - "x-kubernetes-list-type": "atomic" - }, - "generateDashboardPreviews": { - "description": "Whether we should show dashboard previews for pull requests. By default, this is false (i.e. we will not create previews).", - "type": "boolean" - }, - "path": { - "description": "Path is the subdirectory for the Grafana data. If specified, Grafana will ignore anything that is outside this directory in the repository. This is usually something like `grafana/`. Trailing and leading slash are not required. They are always added when needed. The path is relative to the root of the repository, regardless of the leading slash.\n\nWhen specifying something like `grafana-`, we will not look for `grafana-*`; we will only look for files under the directory `/grafana-/`. That means `/grafana-example.json` would not be found.", - "type": "string" - }, - "token": { - "description": "Token for accessing the repository. If set, it will be encrypted into encryptedToken, then set to an empty string again.", - "type": "string" - }, - "url": { - "description": "The repository URL (e.g. `https://github.com/example/test`).", - "type": "string" - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.GitLabRepositoryConfig": { - "type": "object", - "required": [ - "branch" - ], - "properties": { - "branch": { - "description": "The branch to use in the repository.", - "type": "string", - "default": "" - }, - "encryptedToken": { - "description": "Token for accessing the repository, but encrypted. This is not possible to read back to a user decrypted.", - "type": "string", - "format": "byte", - "x-kubernetes-list-type": "atomic" - }, - "path": { - "description": "Path is the subdirectory for the Grafana data. If specified, Grafana will ignore anything that is outside this directory in the repository. This is usually something like `grafana/`. Trailing and leading slash are not required. They are always added when needed. The path is relative to the root of the repository, regardless of the leading slash.\n\nWhen specifying something like `grafana-`, we will not look for `grafana-*`; we will only look for files under the directory `/grafana-/`. That means `/grafana-example.json` would not be found.", - "type": "string" - }, - "token": { - "description": "Token for accessing the repository. If set, it will be encrypted into encryptedToken, then set to an empty string again.", - "type": "string" - }, - "url": { - "description": "The repository URL (e.g. `https://gitlab.com/example/test`).", - "type": "string" - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.GitRepositoryConfig": { - "type": "object", - "required": [ - "branch" - ], - "properties": { - "branch": { - "description": "The branch to use in the repository.", - "type": "string", - "default": "" - }, - "encryptedToken": { - "description": "Token for accessing the repository, but encrypted. This is not possible to read back to a user decrypted.", - "type": "string", - "format": "byte", - "x-kubernetes-list-type": "atomic" - }, - "path": { - "description": "Path is the subdirectory for the Grafana data. If specified, Grafana will ignore anything that is outside this directory in the repository. This is usually something like `grafana/`. Trailing and leading slash are not required. They are always added when needed. The path is relative to the root of the repository, regardless of the leading slash.\n\nWhen specifying something like `grafana-`, we will not look for `grafana-*`; we will only look for files under the directory `/grafana-/`. That means `/grafana-example.json` would not be found.", - "type": "string" - }, - "token": { - "description": "Token for accessing the repository. If set, it will be encrypted into encryptedToken, then set to an empty string again.", - "type": "string" - }, - "tokenUser": { - "description": "TokenUser is the user that will be used to access the repository if it's a personal access token.", - "type": "string" - }, - "url": { - "description": "The repository URL (e.g. `https://github.com/example/test.git`).", - "type": "string" - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.HealthStatus": { - "type": "object", - "required": [ - "healthy" - ], - "properties": { - "checked": { - "description": "When the health was checked last time", - "type": "integer", - "format": "int64" - }, - "healthy": { - "description": "When not healthy, requests will not be executed", - "type": "boolean", - "default": false - }, - "message": { - "description": "Summary messages (can be shown to users) Will only be populated when not healthy", - "type": "array", - "items": { - "type": "string", - "default": "" - }, - "x-kubernetes-list-type": "atomic" - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.HistoryItem": { - "type": "object", - "required": [ - "ref", - "message", - "authors", - "createdAt" - ], - "properties": { - "authors": { - "type": "array", - "items": { - "default": {} - }, - "x-kubernetes-list-type": "atomic" - }, - "createdAt": { - "type": "integer", - "format": "int64", - "default": 0 - }, - "message": { - "type": "string", - "default": "" - }, - "ref": { - "type": "string", - "default": "" - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.HistoryList": { - "description": "HistoryList is a list of versions of a resource", - "type": "object", - "required": [ - "items" - ], - "properties": { - "apiVersion": { - "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" - }, - "items": { - "type": "array", - "items": { - "default": {} - }, - "x-kubernetes-list-type": "atomic" - }, - "kind": { - "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" - }, - "metadata": { - "default": {} - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.Job": { - "description": "The repository name and type are stored as labels", - "type": "object", - "properties": { - "apiVersion": { - "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" - }, - "kind": { - "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" - }, - "metadata": { - "default": {} - }, - "spec": { - "default": {} - }, - "status": { - "default": {} - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.JobList": { - "type": "object", - "required": [ - "items" - ], - "properties": { - "apiVersion": { - "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" - }, - "items": { - "type": "array", - "items": { - "default": {} - } - }, - "kind": { - "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" - }, - "metadata": { - "default": {} - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.JobResourceSummary": { - "type": "object", - "properties": { - "create": { - "type": "integer", - "format": "int64" - }, - "delete": { - "type": "integer", - "format": "int64" - }, - "error": { - "description": "Create or update (export)", - "type": "integer", - "format": "int64" - }, - "errors": { - "description": "Report errors for this resource type This may not be an exhaustive list and recommend looking at the logs for more info", - "type": "array", - "items": { - "type": "string", - "default": "" - } - }, - "group": { - "type": "string" - }, - "noop": { - "description": "No action required (useful for sync)", - "type": "integer", - "format": "int64" - }, - "resource": { - "type": "string" - }, - "total": { - "type": "integer", - "format": "int64" - }, - "update": { - "type": "integer", - "format": "int64" - }, - "write": { - "type": "integer", - "format": "int64" - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.JobSpec": { - "type": "object", - "properties": { - "action": { - "description": "Possible enum values:\n - `\"delete\"` deletes files in the remote repository\n - `\"migrate\"` acts like JobActionExport, then JobActionPull. It also tries to preserve the history.\n - `\"move\"` moves files in the remote repository\n - `\"pr\"` adds additional useful information to a PR, such as comments with preview links and rendered images.\n - `\"pull\"` replicates the remote branch in the local copy of the repository.\n - `\"push\"` replicates the local copy of the repository in the remote branch.", - "type": "string", - "enum": [ - "delete", - "migrate", - "move", - "pr", - "pull", - "push" - ] - }, - "delete": { - "description": "Delete when the action is `delete`" - }, - "migrate": { - "description": "Required when the action is `migrate`" - }, - "move": { - "description": "Move when the action is `move`" - }, - "pr": { - "description": "Pull request options" - }, - "pull": { - "description": "Required when the action is `pull`" - }, - "push": { - "description": "Required when the action is `push`" - }, - "repository": { - "description": "The the repository reference (for now also in labels) This value is required, but will be popuplated from the job making the request", - "type": "string" - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.JobStatus": { - "description": "The job status", - "type": "object", - "properties": { - "errors": { - "type": "array", - "items": { - "type": "string", - "default": "" - } - }, - "finished": { - "type": "integer", - "format": "int64" - }, - "message": { - "type": "string" - }, - "progress": { - "description": "Optional value 0-100 that can be set while running", - "type": "number", - "format": "double" - }, - "started": { - "type": "integer", - "format": "int64" - }, - "state": { - "description": "Possible enum values:\n - `\"error\"` Finished with errors\n - `\"pending\"` Job has been submitted, but not processed yet\n - `\"success\"` Finished with success\n - `\"warning\"` Finished with some non-critical errors\n - `\"working\"` The job is running", - "type": "string", - "enum": [ - "error", - "pending", - "success", - "warning", - "working" - ] - }, - "summary": { - "description": "Summary of processed actions", - "type": "array", - "items": {} - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.LocalRepositoryConfig": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ManagerStats": { - "type": "object", - "required": [ - "stats" - ], - "properties": { - "id": { - "description": "Manager identity", - "type": "string" - }, - "kind": { - "description": "Manager kind", - "type": "string" - }, - "stats": { - "description": "stats", - "type": "array", - "items": { - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ResourceCount" - } - ] - } - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.MigrateJobOptions": { - "type": "object", - "properties": { - "history": { - "description": "Preserve history (if possible)", - "type": "boolean" - }, - "message": { - "description": "Message to use when committing the changes in a single commit", - "type": "string" - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.MoveJobOptions": { - "type": "object", - "properties": { - "paths": { - "description": "Paths to be deleted. Examples: - dashboard.json (for a file) - a/b/c/other-dashboard.json (for a file) - nested/deep/ (for a directory) FIXME: we should validate this in admission hooks", - "type": "array", - "items": { - "type": "string", - "default": "" - } - }, - "ref": { - "description": "Ref to the branch or commit hash that should move", - "type": "string" - }, - "resources": { - "description": "Resources to move This option has been created because currently the frontend does not use standarized app platform APIs. For performance and API consistency reasons, the preferred option is it to use the paths.", - "type": "array", - "items": { - "default": {} - } - }, - "targetPath": { - "description": "Destination path for the move (e.g. \"new-location/\")", - "type": "string" - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.PullRequestJobOptions": { - "type": "object", - "properties": { - "hash": { - "description": "The specific commit hash that triggered this notice", - "type": "string" - }, - "pr": { - "description": "Pull request number (when appropriate)", - "type": "integer", - "format": "int32" - }, - "ref": { - "description": "The branch of commit hash", - "type": "string" - }, - "url": { - "description": "URL to the originator (eg, PR URL)", - "type": "string" - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.RefItem": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "hash": { - "description": "The SHA hash of the commit this ref points to", - "type": "string" - }, - "name": { - "description": "The name of the reference (branch or tag)", - "type": "string", - "default": "" - }, - "refURL": { - "description": "The URL to the reference (branch or tag)", - "type": "string" - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.RefList": { - "type": "object", - "required": [ - "items" - ], - "properties": { - "apiVersion": { - "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" - }, - "items": { - "type": "array", - "items": { - "default": {} - }, - "x-kubernetes-list-type": "atomic" - }, - "kind": { - "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" - }, - "metadata": { - "default": {} - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.Repository": { - "description": "When this code is changed, make sure to update the code generation. As of writing, this can be done via the hack dir in the root of the repo: ./hack/update-codegen.sh provisioning If you've opened the generated files in this dir at some point in VSCode, you may also have to re-open them to clear errors.", - "type": "object", - "properties": { - "apiVersion": { - "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" - }, - "kind": { - "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" - }, - "metadata": { - "default": {} - }, - "spec": { - "default": {} - }, - "status": { - "default": {} - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.RepositoryList": { - "type": "object", - "required": [ - "items" - ], - "properties": { - "apiVersion": { - "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" - }, - "items": { - "type": "array", - "items": { - "default": {} - }, - "x-kubernetes-list-type": "atomic" - }, - "kind": { - "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" - }, - "metadata": { - "default": {} - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.RepositorySpec": { - "type": "object", - "required": [ - "title", - "workflows", - "sync", - "type" - ], - "properties": { - "bitbucket": { - "description": "The repository on Bitbucket. Mutually exclusive with local | github | git." - }, - "description": { - "description": "Repository description", - "type": "string" - }, - "git": { - "description": "The repository on Git. Mutually exclusive with local | github | git." - }, - "github": { - "description": "The repository on GitHub. Mutually exclusive with local | github | git." - }, - "gitlab": { - "description": "The repository on GitLab. Mutually exclusive with local | github | git." - }, - "local": { - "description": "The repository on the local file system. Mutually exclusive with local | github." - }, - "sync": { - "description": "Sync settings -- how values are pulled from the repository into grafana", - "default": {} - }, - "title": { - "description": "The repository display name (shown in the UI)", - "type": "string", - "default": "" - }, - "type": { - "description": "The repository type. When selected oneOf the values below should be non-nil\n\nPossible enum values:\n - `\"bitbucket\"`\n - `\"git\"`\n - `\"github\"`\n - `\"gitlab\"`\n - `\"local\"`", - "type": "string", - "default": "", - "enum": [ - "bitbucket", - "git", - "github", - "gitlab", - "local" - ] - }, - "workflows": { - "description": "UI driven Workflow that allow changes to the contends of the repository. The order is relevant for defining the precedence of the workflows. When empty, the repository does not support any edits (eg, readonly)", - "type": "array", - "items": { - "type": "string", - "default": "", - "enum": [ - "branch", - "write" - ] - } - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.RepositoryStatus": { - "description": "The status of a Repository. This is expected never to be created by a kubectl call or similar, and is expected to rarely (if ever) be edited manually. As such, it is also a little less well structured than the spec, such as conditional-but-ever-present fields.", - "type": "object", - "required": [ - "observedGeneration", - "health", - "sync", - "webhook" - ], - "properties": { - "health": { - "description": "This will get updated with the current health status (and updated periodically)", - "default": {} - }, - "observedGeneration": { - "description": "The generation of the spec last time reconciliation ran", - "type": "integer", - "format": "int64", - "default": 0 - }, - "stats": { - "description": "The object count when sync last ran", - "type": "array", - "items": { - "default": {} - }, - "x-kubernetes-list-type": "atomic" - }, - "sync": { - "description": "Sync information with the last sync information", - "default": {} - }, - "webhook": { - "description": "Webhook Information (if applicable)" - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.RepositoryView": { - "type": "object", - "required": [ - "name", - "title", - "type", - "target", - "workflows" - ], - "properties": { - "branch": { - "description": "For git, this is the target branch", - "type": "string" - }, - "name": { - "description": "The k8s name for this repository", - "type": "string", - "default": "" - }, - "target": { - "description": "When syncing, where values are saved\n\nPossible enum values:\n - `\"folder\"` Resources will be saved into a folder managed by this repository It will contain a copy of everything from the remote The folder k8s name will be the same as the repository k8s name\n - `\"instance\"` Resources are saved in the global context Only one repository may specify the `instance` target When this exists, the UI will promote writing to the instance repo rather than the grafana database (where possible)", - "type": "string", - "default": "", - "enum": [ - "folder", - "instance" - ] - }, - "title": { - "description": "Repository display", - "type": "string", - "default": "" - }, - "type": { - "description": "The repository type\n\nPossible enum values:\n - `\"bitbucket\"`\n - `\"git\"`\n - `\"github\"`\n - `\"gitlab\"`\n - `\"local\"`", - "type": "string", - "default": "", - "enum": [ - "bitbucket", - "git", - "github", - "gitlab", - "local" - ] - }, - "workflows": { - "description": "The supported workflows", - "type": "array", - "items": { - "type": "string", - "default": "", - "enum": [ - "branch", - "write" - ] - } - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.RepositoryViewList": { - "description": "Summary shows a view of the configuration that is sanitized and is OK for logged in users to see", - "type": "object", - "required": [ - "items" - ], - "properties": { - "apiVersion": { - "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" - }, - "availableRepositoryTypes": { - "description": "AvailableRepositoryTypes is the list of repository types supported in this instance (e.g. git, bitbucket, github, etc)", - "type": "array", - "items": { - "type": "string", - "default": "", - "enum": [ - "bitbucket", - "git", - "github", - "gitlab", - "local" - ] - } - }, - "items": { - "type": "array", - "items": { - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.RepositoryView" - } - ] - }, - "x-kubernetes-map-type": "atomic" - }, - "kind": { - "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" - }, - "legacyStorage": { - "description": "The backend is using legacy storage FIXME: Not sure where this should be exposed... but we need it somewhere The UI should force the onboarding workflow when this is true", - "type": "boolean" - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ResourceCount": { - "type": "object", - "required": [ - "group", - "resource", - "count" - ], - "properties": { - "count": { - "type": "integer", - "format": "int64", - "default": 0 - }, - "group": { - "type": "string", - "default": "" - }, - "resource": { - "type": "string", - "default": "" - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ResourceList": { - "description": "Information we can get just from the file listing", - "type": "object", - "required": [ - "items" - ], - "properties": { - "apiVersion": { - "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" - }, - "items": { - "type": "array", - "items": { - "default": {} - }, - "x-kubernetes-list-type": "atomic" - }, - "kind": { - "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" - }, - "metadata": { - "default": {} - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ResourceListItem": { - "type": "object", - "required": [ - "path", - "group", - "resource", - "name", - "hash" - ], - "properties": { - "folder": { - "type": "string" - }, - "group": { - "type": "string", - "default": "" - }, - "hash": { - "description": "the k8s identifier", - "type": "string", - "default": "" - }, - "name": { - "type": "string", - "default": "" - }, - "path": { - "type": "string", - "default": "" - }, - "resource": { - "type": "string", - "default": "" - }, - "time": { - "type": "integer", - "format": "int64" - }, - "title": { - "type": "string" - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ResourceObjects": { - "type": "object", - "required": [ - "type" - ], - "properties": { - "action": { - "description": "The action required/used for dryRun\n\nPossible enum values:\n - `\"create\"`\n - `\"delete\"`\n - `\"move\"`\n - `\"update\"`", - "type": "string", - "enum": [ - "create", - "delete", - "move", - "update" - ] - }, - "dryRun": { - "description": "The value returned from a dryRun request" - }, - "existing": { - "description": "The same value, currently saved in the grafana database" - }, - "file": { - "description": "The resource from the repository with all modifications applied eg, the name, folder etc will all be applied to this object" - }, - "type": { - "description": "The identified type for this object", - "default": {} - }, - "upsert": { - "description": "For write events, this will return the value that was added or updated" - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ResourceRef": { - "type": "object", - "properties": { - "group": { - "description": "Group is the group of the resource, such as \"dashboard.grafana.app\".", - "type": "string" - }, - "kind": { - "description": "Kind is the type of resource, for example, \"Dashboard\".", - "type": "string" - }, - "name": { - "description": "Name is the name of the resource, such as a dashboard UID.", - "type": "string" - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ResourceRepositoryInfo": { - "type": "object", - "required": [ - "type", - "title", - "namespace", - "name" - ], - "properties": { - "name": { - "description": "The name (identifier)", - "type": "string", - "default": "" - }, - "namespace": { - "description": "The namespace this belongs to", - "type": "string", - "default": "" - }, - "title": { - "description": "The display name for this repository", - "type": "string", - "default": "" - }, - "type": { - "description": "The repository type\n\nPossible enum values:\n - `\"bitbucket\"`\n - `\"git\"`\n - `\"github\"`\n - `\"gitlab\"`\n - `\"local\"`", - "type": "string", - "default": "", - "enum": [ - "bitbucket", - "git", - "github", - "gitlab", - "local" - ] - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ResourceStats": { - "description": "Information we can get just from the file listing", - "type": "object", - "properties": { - "apiVersion": { - "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" - }, - "instance": { - "description": "Stats across all unified storage When legacy storage is still used, this will offer a shim", - "type": "array", - "items": { - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ResourceCount" - } - ] - }, - "x-kubernetes-list-type": "atomic" - }, - "kind": { - "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" - }, - "managed": { - "description": "Stats for each manager", - "type": "array", - "items": { - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ManagerStats" - } - ] - }, - "x-kubernetes-list-type": "atomic" - }, - "metadata": { - "default": {} - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ResourceType": { - "type": "object", - "properties": { - "classic": { - "description": "For non-k8s native formats, what did this start as\n\nPossible enum values:\n - `\"access-control\"` Access control https://github.com/grafana/grafana/blob/v11.3.1/conf/provisioning/access-control/sample.yaml\n - `\"alerting\"` Alert configuration https://github.com/grafana/grafana/blob/v11.3.1/conf/provisioning/alerting/sample.yaml\n - `\"dashboard\"` Dashboard JSON\n - `\"datasources\"` Datasource definitions eg: https://github.com/grafana/grafana/blob/v11.3.1/conf/provisioning/datasources/sample.yaml", - "type": "string", - "enum": [ - "access-control", - "alerting", - "dashboard", - "datasources" - ] - }, - "group": { - "type": "string" - }, - "kind": { - "type": "string" - }, - "resource": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ResourceURLs": { - "type": "object", - "properties": { - "compareURL": { - "description": "Compare this version to the target branch", - "type": "string" - }, - "newPullRequestURL": { - "description": "A URL that will create a new pull requeset for this branch", - "type": "string" - }, - "repositoryURL": { - "description": "A URL pointing to the repository this lives in", - "type": "string" - }, - "sourceURL": { - "description": "A URL pointing to the this file in the repository", - "type": "string" - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ResourceWrapper": { - "description": "This is a container type for any resource type", - "type": "object", - "required": [ - "repository", - "resource" - ], - "properties": { - "apiVersion": { - "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" - }, - "errors": { - "description": "If errors exist, show them here", - "type": "array", - "items": { - "type": "string", - "default": "" - }, - "x-kubernetes-list-type": "atomic" - }, - "hash": { - "description": "The repo hash value", - "type": "string" - }, - "kind": { - "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" - }, - "path": { - "description": "Path to the remote file", - "type": "string" - }, - "ref": { - "description": "The request ref (or branch if exists)", - "type": "string" - }, - "repository": { - "description": "Basic repository info", - "default": {} - }, - "resource": { - "description": "Different flavors of the same object", - "default": {} - }, - "timestamp": { - "description": "The modified time in the remote file system" - }, - "urls": { - "description": "Typed links for this file (only supported by external systems, github etc)" - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.SyncJobOptions": { - "type": "object", - "required": [ - "incremental" - ], - "properties": { - "incremental": { - "description": "Incremental synchronization for versioned repositories", - "type": "boolean", - "default": false - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.SyncOptions": { - "type": "object", - "required": [ - "enabled", - "target" - ], - "properties": { - "enabled": { - "description": "Enabled must be saved as true before any sync job will run", - "type": "boolean", - "default": false - }, - "intervalSeconds": { - "description": "When non-zero, the sync will run periodically", - "type": "integer", - "format": "int64" - }, - "target": { - "description": "Where values should be saved\n\nPossible enum values:\n - `\"folder\"` Resources will be saved into a folder managed by this repository It will contain a copy of everything from the remote The folder k8s name will be the same as the repository k8s name\n - `\"instance\"` Resources are saved in the global context Only one repository may specify the `instance` target When this exists, the UI will promote writing to the instance repo rather than the grafana database (where possible)", - "type": "string", - "default": "", - "enum": [ - "folder", - "instance" - ] - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.SyncStatus": { - "type": "object", - "required": [ - "state", - "message" - ], - "properties": { - "finished": { - "description": "When the sync job finished", - "type": "integer", - "format": "int64" - }, - "incremental": { - "description": "Incremental synchronization for versioned repositories", - "type": "boolean" - }, - "job": { - "description": "The ID for the job that ran this sync", - "type": "string" - }, - "lastRef": { - "description": "The repository ref when the last successful sync ran", - "type": "string" - }, - "message": { - "description": "Summary messages (will be shown to users)", - "type": "array", - "items": { - "type": "string", - "default": "" - }, - "x-kubernetes-list-type": "atomic" - }, - "scheduled": { - "description": "When the next sync check is scheduled", - "type": "integer", - "format": "int64" - }, - "started": { - "description": "When the sync job started", - "type": "integer", - "format": "int64" - }, - "state": { - "description": "pending, running, success, error\n\nPossible enum values:\n - `\"error\"` Finished with errors\n - `\"pending\"` Job has been submitted, but not processed yet\n - `\"success\"` Finished with success\n - `\"warning\"` Finished with some non-critical errors\n - `\"working\"` The job is running", - "type": "string", - "default": "", - "enum": [ - "error", - "pending", - "success", - "warning", - "working" - ] - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.TestResults": { - "description": "HistoryList is a list of versions of a resource", - "type": "object", - "required": [ - "code", - "success" - ], - "properties": { - "apiVersion": { - "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" - }, - "code": { - "description": "HTTP status code", - "type": "integer", - "format": "int32", - "default": 0 - }, - "errors": { - "description": "Field related errors", - "type": "array", - "items": { - "default": {} - } - }, - "kind": { - "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" - }, - "success": { - "description": "Is the connection healthy", - "type": "boolean", - "default": false - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.WebhookResponse": { - "type": "object", - "properties": { - "added": { - "description": "Optional message", - "type": "string" - }, - "apiVersion": { - "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" - }, - "code": { - "description": "HTTP Status code 200 implies that the payload was understood but nothing is required 202 implies that an async job has been scheduled to handle the request", - "type": "integer", - "format": "int32" - }, - "job": { - "description": "Jobs to be processed When the response is 202 (Accepted) the queued jobs will be returned" - }, - "kind": { - "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" - } - } - }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.WebhookStatus": { - "type": "object", - "properties": { - "encryptedSecret": { - "type": "string", - "format": "byte" - }, - "id": { - "type": "integer", - "format": "int64" - }, - "lastEvent": { - "type": "integer", - "format": "int64" - }, - "secret": { - "type": "string" - }, - "subscribedEvents": { - "type": "array", - "items": { - "type": "string", - "default": "" - } - }, - "url": { - "type": "string" - } - } - }, "io.k8s.apimachinery.pkg.apis.meta.v1.APIResource": { "description": "APIResource specifies the name of a resource and whether it is namespaced.", "type": "object", diff --git a/pkg/tests/apis/provisioning/deletejob_test.go b/pkg/tests/apis/provisioning/deletejob_test.go new file mode 100644 index 00000000000..accef534fc6 --- /dev/null +++ b/pkg/tests/apis/provisioning/deletejob_test.go @@ -0,0 +1,377 @@ +package provisioning + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +func TestIntegrationProvisioning_DeleteJob(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + helper := runGrafana(t) + ctx := context.Background() + + const repo = "delete-job-test-repo" + testRepo := TestRepo{ + Name: repo, + Copies: map[string]string{ + "testdata/all-panels.json": "dashboard1.json", + "testdata/text-options.json": "dashboard2.json", + "testdata/timeline-demo.json": "folder/dashboard3.json", + }, + ExpectedDashboards: 3, + ExpectedFolders: 1, + } + + helper.CreateRepo(t, testRepo) + + t.Run("delete single file", func(t *testing.T) { + // FIXME: make the tests in a way that we can simply have a spec and some expectations per scenario. + + // Debug state before delete + helper.DebugState(t, repo, "BEFORE DELETE") + + // Verify file exists in repository before attempting delete + _, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "dashboard1.json") + require.NoError(t, err, "dashboard1.json should exist in repository before delete") + + spec := provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Delete: &provisioning.DeleteJobOptions{ + Paths: []string{"dashboard1.json"}, + }, + } + // Create delete job for single file + helper.TriggerJobAndWaitForSuccess(t, repo, spec) + + // Debug state after successful delete + helper.DebugState(t, repo, "AFTER DELETE") + + // FIXME: create a helper to verify repository files + + // Verify file is deleted from repository + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "dashboard1.json") + require.Error(t, err, "file should be deleted from repository") + require.True(t, apierrors.IsNotFound(err), "should be not found error") + + // FIXME: create a helper to verify grafana resources + // Verify dashboard is removed from Grafana after sync + dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.Equal(t, 2, len(dashboards.Items), "should have 2 dashboards after delete") + + // Verify other files still exist + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "dashboard2.json") + require.NoError(t, err, "other files should still exist") + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "dashboard3.json") + require.NoError(t, err, "nested files should still exist") + }) + + t.Run("delete multiple files", func(t *testing.T) { + spec := provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Delete: &provisioning.DeleteJobOptions{ + Paths: []string{"dashboard2.json", "folder/dashboard3.json"}, + }, + } + helper.TriggerJobAndWaitForSuccess(t, repo, spec) + + // FIXME: use helper + // Verify files are deleted from repository + _, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "dashboard2.json") + require.Error(t, err, "dashboard2.json should be deleted") + require.True(t, apierrors.IsNotFound(err)) + + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "dashboard3.json") + require.Error(t, err, "folder/dashboard3.json should be deleted") + require.True(t, apierrors.IsNotFound(err)) + + // Verify all dashboards are removed from Grafana after sync + dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.Equal(t, 0, len(dashboards.Items), "should have 0 dashboards after deleting all") + }) + + t.Run("delete by resource reference", func(t *testing.T) { + // FIXME: do not create this on top of the other one. Isolate the cases + // Create modified test files with unique UIDs for ResourceRef testing + // Read and modify the testdata files to have unique UIDs that don't conflict with existing resources + allPanelsContent := helper.LoadFile("testdata/all-panels.json") + textOptionsContent := helper.LoadFile("testdata/text-options.json") + timelineDemoContent := helper.LoadFile("testdata/timeline-demo.json") + + // FIXME: use generic objects + // Modify UIDs to be unique for ResourceRef tests + allPanelsModified := strings.Replace(string(allPanelsContent), `"uid": "n1jR8vnnz"`, `"uid": "resourceref1"`, 1) + textOptionsModified := strings.Replace(string(textOptionsContent), `"uid": "WZ7AhQiVz"`, `"uid": "resourceref2"`, 1) + timelineDemoModified := strings.Replace(string(timelineDemoContent), `"uid": "mIJjFy8Kz"`, `"uid": "resourceref3"`, 1) + + // Create temporary files and copy them to the provisioning path + tmpDir := t.TempDir() + tmpFile1 := filepath.Join(tmpDir, "resource-test-1.json") + tmpFile2 := filepath.Join(tmpDir, "resource-test-2.json") + tmpFile3 := filepath.Join(tmpDir, "resource-test-3.json") + + require.NoError(t, os.WriteFile(tmpFile1, []byte(allPanelsModified), 0644)) + require.NoError(t, os.WriteFile(tmpFile2, []byte(textOptionsModified), 0644)) + require.NoError(t, os.WriteFile(tmpFile3, []byte(timelineDemoModified), 0644)) + + // Copy the temporary files to the provisioning path + helper.CopyToProvisioningPath(t, tmpFile1, "resource-test-1.json") // UID: resourceref1 + helper.CopyToProvisioningPath(t, tmpFile2, "resource-test-2.json") // UID: resourceref2 + helper.CopyToProvisioningPath(t, tmpFile3, "nested/resource-test-3.json") // UID: resourceref3 + + // Trigger sync to populate the new resources + helper.SyncAndWait(t, repo, nil) + + // Verify the new resources are created + dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.GreaterOrEqual(t, len(dashboards.Items), 3, "should have at least 3 dashboards after adding test resources") + + // Debug: print the actual dashboard names/UIDs to verify they match our expectations + for i, dashboard := range dashboards.Items { + t.Logf("Dashboard %d: name=%s, UID=%s", i+1, dashboard.GetName(), dashboard.GetUID()) + } + + t.Run("delete single dashboard by resource reference", func(t *testing.T) { + spec := provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Delete: &provisioning.DeleteJobOptions{ + Resources: []provisioning.ResourceRef{ + { + Name: "resourceref1", // UID from modified all-panels.json + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + } + + helper.TriggerJobAndWaitForSuccess(t, repo, spec) + + // FIXME: use helpers + // Verify corresponding file is deleted from repository + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "resource-test-1.json") + require.Error(t, err, "file should be deleted from repository") + require.True(t, apierrors.IsNotFound(err), "should be not found error") + + // Verify dashboard is removed from Grafana (check count like other successful tests) + dashboards, err = helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.Equal(t, 2, len(dashboards.Items), "should have 2 dashboards after deleting 1 from 3") + + // Verify other resources still exist + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "resource-test-2.json") + require.NoError(t, err, "other files should still exist") + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "nested", "resource-test-3.json") + require.NoError(t, err, "nested files should still exist") + }) + + t.Run("delete multiple resources by reference", func(t *testing.T) { + spec := provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Delete: &provisioning.DeleteJobOptions{ + Resources: []provisioning.ResourceRef{ + { + Name: "resourceref2", // UID from modified text-options.json + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + { + Name: "resourceref3", // UID from modified timeline-demo.json + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + } + helper.TriggerJobAndWaitForSuccess(t, repo, spec) + + // FIXME: use helpers + // Verify both dashboards are removed from Grafana + _, err = helper.DashboardsV1.Resource.Get(ctx, "resourceref2", metav1.GetOptions{}) + require.Error(t, err, "text-options dashboard should be deleted") + require.True(t, apierrors.IsNotFound(err)) + + _, err = helper.DashboardsV1.Resource.Get(ctx, "resourceref3", metav1.GetOptions{}) + require.Error(t, err, "timeline-demo dashboard should be deleted") + require.True(t, apierrors.IsNotFound(err)) + + // Verify corresponding files are deleted from repository + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "resource-test-2.json") + require.Error(t, err, "resource-test-2.json should be deleted") + + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "nested", "resource-test-3.json") + require.Error(t, err, "nested/resource-test-3.json should be deleted") + + // Verify specific dashboards are removed from Grafana + dashboards, err = helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.Equal(t, 0, len(dashboards.Items), "should have 0 dashboards after deleting 2 more (2 -> 0)") + }) + + t.Run("mixed deletion - paths and resources", func(t *testing.T) { + // FIXME: do not build this case on top of the other one. Isolate the cases + // Setup fresh resources for mixed test - reuse the modified content with unique UIDs + tmpMixed1 := filepath.Join(tmpDir, "mixed-test-1.json") + tmpMixed2 := filepath.Join(tmpDir, "mixed-test-2.json") + tmpMixed3 := filepath.Join(tmpDir, "mixed-test-3.json") + + require.NoError(t, os.WriteFile(tmpMixed1, []byte(allPanelsModified), 0644)) + require.NoError(t, os.WriteFile(tmpMixed2, []byte(textOptionsModified), 0644)) + require.NoError(t, os.WriteFile(tmpMixed3, []byte(timelineDemoModified), 0644)) + + helper.CopyToProvisioningPath(t, tmpMixed1, "mixed-test-1.json") // UID: resourceref1 + helper.CopyToProvisioningPath(t, tmpMixed2, "mixed-test-2.json") // UID: resourceref2 + helper.CopyToProvisioningPath(t, tmpMixed3, "mixed-test-3.json") // UID: resourceref3 + + helper.SyncAndWait(t, repo, nil) + + spec := provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Delete: &provisioning.DeleteJobOptions{ + Paths: []string{"mixed-test-1.json"}, // Delete by path + Resources: []provisioning.ResourceRef{ + { + Name: "resourceref2", // Delete by resource reference + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + } + + helper.TriggerJobAndWaitForSuccess(t, repo, spec) + + // FIXME: use the helpers + // Verify both targeted resources are deleted from Grafana + _, err = helper.DashboardsV1.Resource.Get(ctx, "resourceref1", metav1.GetOptions{}) + require.Error(t, err, "dashboard deleted by path should be removed") + + _, err = helper.DashboardsV1.Resource.Get(ctx, "resourceref2", metav1.GetOptions{}) + require.Error(t, err, "dashboard deleted by resource ref should be removed") + + // Verify the untargeted resource still exists + _, err = helper.DashboardsV1.Resource.Get(ctx, "resourceref3", metav1.GetOptions{}) + require.NoError(t, err, "untargeted dashboard should still exist") + + // Verify files are properly deleted/preserved in repository + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "mixed-test-1.json") + require.Error(t, err, "file deleted by path should be removed") + + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "mixed-test-2.json") + require.Error(t, err, "file for resource deleted by ref should be removed") + + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "mixed-test-3.json") + require.NoError(t, err, "untargeted file should still exist") + }) + + t.Run("delete folder by resource reference", func(t *testing.T) { + // FIXME: do not build this case on top of the previous one. Isolate them + // Create a dashboard inside a folder to automatically create the folder structure + // This follows the same pattern as other tests in this file + testDashboard := strings.Replace(string(allPanelsContent), `"uid": "n1jR8vnnz"`, `"uid": "folder-dash"`, 1) + + // Write the modified dashboard to a temporary file first + tmpFolderDash := filepath.Join(tmpDir, "folder-dashboard.json") + require.NoError(t, os.WriteFile(tmpFolderDash, []byte(testDashboard), 0644)) + + // Copy it to the folder structure using the helper + helper.CopyToProvisioningPath(t, tmpFolderDash, "test-folder/dashboard-in-folder.json") + + // Sync to create the folder and its contents + helper.SyncAndWait(t, repo, nil) + + // Verify folder was created in Grafana as a Folder resource + folders, err := helper.Folders.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + + var testFolder *unstructured.Unstructured + for _, folder := range folders.Items { + // Folder names are generated with suffixes, so check if it starts with "test-folder" + if strings.HasPrefix(folder.GetName(), "test-folder") { + testFolder = &folder + break + } + } + require.NotNil(t, testFolder, "test-folder should exist as a Folder resource") + testFolderName := testFolder.GetName() + + // Verify dashboard inside the folder exists + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "test-folder", "dashboard-in-folder.json") + require.NoError(t, err, "dashboard inside folder should exist") + + spec := provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Delete: &provisioning.DeleteJobOptions{ + Resources: []provisioning.ResourceRef{ + { + Name: testFolderName, // Use the actual generated folder name + Kind: "Folder", + Group: "folder.grafana.app", + }, + }, + }, + } + helper.TriggerJobAndWaitForSuccess(t, repo, spec) + + // FIXME: use helpers + // Verify folder is deleted from Grafana + _, err = helper.Folders.Resource.Get(ctx, testFolderName, metav1.GetOptions{}) + require.Error(t, err, "folder should be deleted from Grafana") + require.True(t, apierrors.IsNotFound(err), "should be not found error") + + // Verify folder contents are also deleted from repository + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "test-folder", "dashboard-in-folder.json") + require.Error(t, err, "dashboard inside deleted folder should also be deleted") + require.True(t, apierrors.IsNotFound(err), "should be not found error") + }) + + t.Run("delete non-existent resource by reference", func(t *testing.T) { + // Create delete job for non-existent resource + spec := provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Delete: &provisioning.DeleteJobOptions{ + Resources: []provisioning.ResourceRef{ + { + Name: "non-existent-uid", + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + } + + job := helper.TriggerJobAndWaitForComplete(t, repo, spec) + state := mustNestedString(job.Object, "status", "state") + assert.Equal(t, "error", state, "delete job should have failed due to non-existent file") + }) + }) + + t.Run("delete non-existent file", func(t *testing.T) { + spec := provisioning.JobSpec{ + Action: provisioning.JobActionDelete, + Delete: &provisioning.DeleteJobOptions{ + Paths: []string{"non-existent.json"}, + }, + } + + job := helper.TriggerJobAndWaitForComplete(t, repo, spec) + state := mustNestedString(job.Object, "status", "state") + assert.Equal(t, "error", state, "delete job should have failed due to non-existent file") + }) +} diff --git a/pkg/tests/apis/provisioning/exportjob_test.go b/pkg/tests/apis/provisioning/exportjob_test.go new file mode 100644 index 00000000000..79b2203ab47 --- /dev/null +++ b/pkg/tests/apis/provisioning/exportjob_test.go @@ -0,0 +1,272 @@ +package provisioning + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" +) + +func TestProvisioning_ExportUnifiedToRepository(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + helper := runGrafana(t) + ctx := context.Background() + + // Write dashboards at + dashboard := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v0.yaml") + _, err := helper.DashboardsV0.Resource.Create(ctx, dashboard, metav1.CreateOptions{}) + require.NoError(t, err, "should be able to create v0 dashboard") + + // FIXME: add helper and template for dashboards in different versions + dashboard = helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v1.yaml") + _, err = helper.DashboardsV1.Resource.Create(ctx, dashboard, metav1.CreateOptions{}) + require.NoError(t, err, "should be able to create v1 dashboard") + + dashboard = helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v2alpha1.yaml") + _, err = helper.DashboardsV2alpha1.Resource.Create(ctx, dashboard, metav1.CreateOptions{}) + require.NoError(t, err, "should be able to create v2alpha1 dashboard") + + dashboard = helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v2beta1.yaml") + _, err = helper.DashboardsV2beta1.Resource.Create(ctx, dashboard, metav1.CreateOptions{}) + require.NoError(t, err, "should be able to create v2beta1 dashboard") + + // Now for the repository. + const repo = "local-repository" + testRepo := TestRepo{ + Name: repo, + Copies: map[string]string{}, // No initial files needed for export test + ExpectedDashboards: 4, // 4 dashboards created above (v0, v1, v2alpha1, v2beta1) + ExpectedFolders: 0, // No folders expected after sync + } + helper.CreateRepo(t, testRepo) + + // Now export + helper.DebugState(t, repo, "BEFORE EXPORT TO REPOSITORY") + + spec := provisioning.JobSpec{ + Action: provisioning.JobActionPush, + Push: &provisioning.ExportJobOptions{ + Folder: "", // export entire instance + Path: "", // no prefix necessary for testing + }, + } + helper.TriggerJobAndWaitForSuccess(t, repo, spec) + + helper.DebugState(t, repo, "AFTER EXPORT TO REPOSITORY") + + type props struct { + title string + apiVersion string + name string + fileName string + } + + printFileTree(t, helper.ProvisioningPath) + + // Check that each file was exported with its stored version + for _, test := range []props{ + {title: "Test dashboard. Created at v0", apiVersion: "dashboard.grafana.app/v0alpha1", name: "test-v0", fileName: "test-dashboard-created-at-v0.json"}, + {title: "Test dashboard. Created at v1", apiVersion: "dashboard.grafana.app/v1beta1", name: "test-v1", fileName: "test-dashboard-created-at-v1.json"}, + {title: "Test dashboard. Created at v2alpha1", apiVersion: "dashboard.grafana.app/v2alpha1", name: "test-v2alpha1", fileName: "test-dashboard-created-at-v2alpha1.json"}, + {title: "Test dashboard. Created at v2beta1", apiVersion: "dashboard.grafana.app/v2beta1", name: "test-v2beta1", fileName: "test-dashboard-created-at-v2beta1.json"}, + } { + fpath := filepath.Join(helper.ProvisioningPath, test.fileName) + //nolint:gosec // we are ok with reading files in testdata + body, err := os.ReadFile(fpath) + require.NoError(t, err, "exported file was not created at path %s", fpath) + obj := map[string]any{} + err = json.Unmarshal(body, &obj) + require.NoError(t, err, "exported file not json %s", fpath) + + val, _, err := unstructured.NestedString(obj, "apiVersion") + require.NoError(t, err) + require.Equal(t, test.apiVersion, val) + + val, _, err = unstructured.NestedString(obj, "spec", "title") + require.NoError(t, err) + require.Equal(t, test.title, val) + + val, _, err = unstructured.NestedString(obj, "metadata", "name") + require.NoError(t, err) + require.Equal(t, test.name, val) + + require.Nil(t, obj["status"], "should not have a status element") + } +} + +func TestIntegrationProvisioning_SecondRepositoryOnlyExportsNewDashboards(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + helper := runGrafana(t) + ctx := context.Background() + + // FIXME: helper to create dashboards. + // Create some unmanaged dashboards directly in Grafana first + dashboard1 := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v1.yaml") + dashboard1Obj, err := helper.DashboardsV1.Resource.Create(ctx, dashboard1, metav1.CreateOptions{}) + require.NoError(t, err, "should be able to create first dashboard") + dashboard1Name := dashboard1Obj.GetName() + + dashboard2 := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v2beta1.yaml") + dashboard2Obj, err := helper.DashboardsV2beta1.Resource.Create(ctx, dashboard2, metav1.CreateOptions{}) + require.NoError(t, err, "should be able to create second dashboard") + dashboard2Name := dashboard2Obj.GetName() + + // Create the first repository with sync enabled and separate filesystem path + const repo1 = "first-repository" + repo1Path := filepath.Join(helper.ProvisioningPath, repo1) + testRepo1 := TestRepo{ + Name: repo1, + Target: "folder", + Path: repo1Path, + Copies: map[string]string{}, // No initial files needed for export test + ExpectedDashboards: 2, // 2 dashboards created above (v1, v2beta1) + ExpectedFolders: 1, // One folder expected after sync + } + helper.CreateRepo(t, testRepo1) + + // Print file tree before export + printFileTree(t, helper.ProvisioningPath) + + // Initial export + helper.DebugState(t, repo1, "BEFORE INITIAL EXPORT") + + spec := provisioning.JobSpec{ + Action: provisioning.JobActionPush, + Push: &provisioning.ExportJobOptions{ + Folder: "", // export entire instance + Path: "", // no prefix necessary for testing + }, + } + + helper.TriggerJobAndWaitForSuccess(t, repo1, spec) + + helper.DebugState(t, repo1, "AFTER INITIAL EXPORT") + helper.SyncAndWait(t, repo1, nil) + + printFileTree(t, helper.ProvisioningPath) + // Verify that the first repository has claimed ownership of the dashboards + managedDash1, err := helper.DashboardsV1.Resource.Get(ctx, dashboard1Name, metav1.GetOptions{}) + require.NoError(t, err) + require.Equal(t, repo1, managedDash1.GetAnnotations()[utils.AnnoKeyManagerIdentity], "dashboard1 should be managed by first repo") + + managedDash2, err := helper.DashboardsV2beta1.Resource.Get(ctx, dashboard2Name, metav1.GetOptions{}) + require.NoError(t, err) + require.Equal(t, repo1, managedDash2.GetAnnotations()[utils.AnnoKeyManagerIdentity], "dashboard2 should be managed by first repo") + + // Create second repository - enable sync and set different target with separate filesystem path + const repo2 = "second-repository" + repo2Path := filepath.Join(helper.ProvisioningPath, repo2) + testRepo2 := TestRepo{ + Name: repo2, + Target: "folder", + Path: repo2Path, + Copies: map[string]string{}, // No initial files needed for export test + ExpectedDashboards: 2, // 2 dashboards exist when second repo syncs + ExpectedFolders: 2, // Two folders expected after sync (repo1 + repo2) + } + helper.CreateRepo(t, testRepo2) + + // Wait for second repository to sync + helper.SyncAndWait(t, repo2, nil) + + printFileTree(t, helper.ProvisioningPath) + + // FIXME: use helpers to check status + // Validate that folders for both repositories exist + folders, err := helper.Folders.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err, "should be able to list folders") + + var repo1FolderFound, repo2FolderFound bool + for _, folder := range folders.Items { + if folder.GetName() == repo1 { + repo1FolderFound = true + } + if folder.GetName() == repo2 { + repo2FolderFound = true + } + } + require.True(t, repo1FolderFound, "folder for first repository %s should exist after sync", repo1) + require.True(t, repo2FolderFound, "folder for second repository %s should exist after sync", repo2) + + // Create a third dashboard that won't be claimed by the first repo + dashboard3 := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v0.yaml") + dashboard3Obj, err := helper.DashboardsV0.Resource.Create(ctx, dashboard3, metav1.CreateOptions{}) + require.NoError(t, err, "should be able to create third dashboard") + dashboard3Name := dashboard3Obj.GetName() + + // Verify dashboard3 is not managed by anyone initially + unmanagedDash3, err := helper.DashboardsV0.Resource.Get(ctx, dashboard3Name, metav1.GetOptions{}) + require.NoError(t, err) + manager, found := unmanagedDash3.GetAnnotations()[utils.AnnoKeyManagerIdentity] + require.True(t, !found || manager == "", "dashboard3 should not be managed initially") + + printFileTree(t, helper.ProvisioningPath) + // Count files in first repo before second export + files1Before, err := countFilesInDir(repo1Path) + require.NoError(t, err) + + // Export from second repository - this should only export the unmanaged dashboard3 + helper.DebugState(t, repo2, "BEFORE SECOND EXPORT") + + spec = provisioning.JobSpec{ + Action: provisioning.JobActionPush, + Push: &provisioning.ExportJobOptions{ + Folder: "", // export entire instance + Path: "", // no prefix necessary for testing + }, + } + helper.TriggerJobAndWaitForSuccess(t, repo2, spec) + + helper.DebugState(t, repo2, "AFTER SECOND EXPORT") + + // Wait for both repositories to sync + helper.SyncAndWait(t, repo1, nil) + helper.SyncAndWait(t, repo2, nil) + + printFileTree(t, helper.ProvisioningPath) + files1After, err := countFilesInDir(repo1Path) + require.NoError(t, err) + + actualNewFiles := files1After - files1Before + require.Equal(t, 0, actualNewFiles, + "second repository should skip managed dashboards and had folder issues with unmanaged dashboard (expected %d new files, got %d)", + 0, actualNewFiles) + + // Verify files in the second repository + files2After, err := countFilesInDir(repo2Path) + require.NoError(t, err) + require.Equal(t, 1, files2After, + "second repository should only export the unmanaged dashboard (expected %d new files, got %d)", + 1, files2After) + + // Verify dashboard1 and dashboard2 are still managed by repo1 (unchanged) + stillManagedDash1, err := helper.DashboardsV1.Resource.Get(ctx, dashboard1Name, metav1.GetOptions{}) + require.NoError(t, err) + require.Equal(t, repo1, stillManagedDash1.GetAnnotations()[utils.AnnoKeyManagerIdentity], + "dashboard1 should still be managed by first repo") + + stillManagedDash2, err := helper.DashboardsV2beta1.Resource.Get(ctx, dashboard2Name, metav1.GetOptions{}) + require.NoError(t, err) + require.Equal(t, repo1, stillManagedDash2.GetAnnotations()[utils.AnnoKeyManagerIdentity], + "dashboard2 should still be managed by first repo") + + // Verify dashboard3 is now managed by repo2 + stillManagedDash3, err := helper.DashboardsV0.Resource.Get(ctx, dashboard3Name, metav1.GetOptions{}) + require.NoError(t, err) + require.Equal(t, repo2, stillManagedDash3.GetAnnotations()[utils.AnnoKeyManagerIdentity], + "dashboard3 should now be managed by second repo") +} diff --git a/pkg/tests/apis/provisioning/files_test.go b/pkg/tests/apis/provisioning/files_test.go new file mode 100644 index 00000000000..afe7bfd62b9 --- /dev/null +++ b/pkg/tests/apis/provisioning/files_test.go @@ -0,0 +1,580 @@ +package provisioning + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "path" + "testing" + + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestIntegrationProvisioning_DeleteResources(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + helper := runGrafana(t) + ctx := context.Background() + + const repo = "delete-test-repo" + localTmp := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{ + "Name": repo, + "SyncEnabled": true, + "SyncTarget": "instance", + }) + _, err := helper.Repositories.Resource.Create(ctx, localTmp, metav1.CreateOptions{}) + require.NoError(t, err) + + // Copy the dashboards to the repository path + helper.CopyToProvisioningPath(t, "testdata/all-panels.json", "dashboard1.json") + helper.CopyToProvisioningPath(t, "testdata/text-options.json", "folder/dashboard2.json") + helper.CopyToProvisioningPath(t, "testdata/timeline-demo.json", "folder/nested/dashboard3.json") + // make sure we don't fail when there is a .keep file in a folder + helper.CopyToProvisioningPath(t, "testdata/.keep", "folder/nested/.keep") + + // Trigger and wait for a sync job to finish + helper.SyncAndWait(t, repo, nil) + + dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.Equal(t, 3, len(dashboards.Items)) + + folders, err := helper.Folders.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.Equal(t, 2, len(folders.Items)) + + t.Run("delete individual dashboard file, should delete from repo and grafana", func(t *testing.T) { + result := helper.AdminREST.Delete(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("files", "dashboard1.json"). + Do(ctx) + require.NoError(t, result.Error()) + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "dashboard1.json") + require.Error(t, err) + dashboards, err = helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.Equal(t, 2, len(dashboards.Items)) + }) + + t.Run("delete folder, should delete from repo and grafana all nested resources too", func(t *testing.T) { + // need to delete directly through the url, because the k8s client doesn't support `/` in a subresource + // but that is needed by gitsync to know that it is a folder + addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String() + url := fmt.Sprintf("http://admin:admin@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/folder/", addr, repo) + req, err := http.NewRequest(http.MethodDelete, url, nil) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + // nolint:errcheck + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + // should be deleted from the repo + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder") + require.Error(t, err) + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "dashboard2.json") + require.Error(t, err) + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "nested") + require.Error(t, err) + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "nested", "dashboard3.json") + require.Error(t, err) + + // all should be deleted from grafana + for _, d := range dashboards.Items { + _, err = helper.DashboardsV1.Resource.Get(ctx, d.GetName(), metav1.GetOptions{}) + require.Error(t, err) + } + for _, f := range folders.Items { + _, err = helper.Folders.Resource.Get(ctx, f.GetName(), metav1.GetOptions{}) + require.Error(t, err) + } + }) + + t.Run("deleting a non-existent file should fail", func(t *testing.T) { + result := helper.AdminREST.Delete(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("files", "non-existent.json"). + Do(ctx) + require.Error(t, result.Error()) + }) +} + +func TestIntegrationProvisioning_MoveResources(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + helper := runGrafana(t) + ctx := context.Background() + const repo = "move-test-repo" + localTmp := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{ + "Name": repo, + "SyncEnabled": true, + "SyncTarget": "instance", + }) + _, err := helper.Repositories.Resource.Create(ctx, localTmp, metav1.CreateOptions{}) + require.NoError(t, err) + + // Copy test dashboards to the repository path for initial setup + const originalDashboard = "all-panels.json" + helper.CopyToProvisioningPath(t, "testdata/all-panels.json", originalDashboard) + + // Wait for sync to ensure the dashboard is created in Grafana + helper.SyncAndWait(t, repo, nil) + + // Verify the original dashboard exists in Grafana (using the UID from all-panels.json) + const allPanelsUID = "n1jR8vnnz" // This is the UID from the all-panels.json file + obj, err := helper.DashboardsV1.Resource.Get(ctx, allPanelsUID, metav1.GetOptions{}) + require.NoError(t, err, "original dashboard should exist in Grafana") + require.Equal(t, repo, obj.GetAnnotations()[utils.AnnoKeyManagerIdentity]) + + t.Run("move file without content change", func(t *testing.T) { + const targetPath = "moved/simple-move.json" + + // Perform the move operation using helper function + resp := helper.postFilesRequest(t, repo, filesPostOptions{ + targetPath: targetPath, + originalPath: originalDashboard, + message: "move file without content change", + }) + // nolint:errcheck + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode, "move operation should succeed") + + // Verify the file moved in the repository + movedObj, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "moved", "simple-move.json") + require.NoError(t, err, "moved file should exist in repository") + + // Check the content is preserved (verify it's still the all-panels dashboard) + resource, _, err := unstructured.NestedMap(movedObj.Object, "resource") + require.NoError(t, err) + dryRun, _, err := unstructured.NestedMap(resource, "dryRun") + require.NoError(t, err) + title, _, err := unstructured.NestedString(dryRun, "spec", "title") + require.NoError(t, err) + require.Equal(t, "Panel tests - All panels", title, "content should be preserved") + + // Verify original file no longer exists + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", originalDashboard) + require.Error(t, err, "original file should no longer exist") + + // Verify dashboard still exists in Grafana with same content but may have updated path references + helper.SyncAndWait(t, repo, nil) + _, err = helper.DashboardsV1.Resource.Get(ctx, allPanelsUID, metav1.GetOptions{}) + require.NoError(t, err, "dashboard should still exist in Grafana after move") + }) + + t.Run("move file to nested path without ref", func(t *testing.T) { + // Test a different scenario: Move a file that was never synced to Grafana + // This might reveal the issue if dashboard creation fails during move + const sourceFile = "never-synced.json" + helper.CopyToProvisioningPath(t, "testdata/timeline-demo.json", sourceFile) + + // DO NOT sync - move the file immediately without it ever being in Grafana + const targetPath = "deep/nested/timeline.json" + + // Perform the move operation without the file ever being synced to Grafana + resp := helper.postFilesRequest(t, repo, filesPostOptions{ + targetPath: targetPath, + originalPath: sourceFile, + message: "move never-synced file to nested path", + }) + // nolint:errcheck + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode, "move operation should succeed") + + // Check folders were created and validate hierarchy + folderList, err := helper.Folders.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err, "should be able to list folders") + + // Build a map of folder names to their objects for easier lookup + folders := make(map[string]*unstructured.Unstructured) + for _, folder := range folderList.Items { + title, _, _ := unstructured.NestedString(folder.Object, "spec", "title") + folders[title] = &folder + parent, _, _ := unstructured.NestedString(folder.Object, "metadata", "annotations", "grafana.app/folder") + t.Logf(" - %s: %s (parent: %s)", folder.GetName(), title, parent) + } + + // Validate expected folders exist with proper hierarchy + // Expected structure: deep -> deep/nested + deepFolderTitle := "deep" + nestedFolderTitle := "nested" + + // Validate "deep" folder exists and has no parent (is top-level) + require.Contains(t, folders, deepFolderTitle, "deep folder should exist") + f := folders[deepFolderTitle] + deepFolderName := f.GetName() + title, _, _ := unstructured.NestedString(f.Object, "spec", "title") + require.Equal(t, deepFolderTitle, title, "deep folder should have correct title") + parent, found, _ := unstructured.NestedString(f.Object, "metadata", "annotations", "grafana.app/folder") + require.True(t, !found || parent == "", "deep folder should be top-level (no parent)") + + // Validate "deep/nested" folder exists and has "deep" as parent + require.Contains(t, folders, nestedFolderTitle, "nested folder should exist") + f = folders[nestedFolderTitle] + nestedFolderName := f.GetName() + title, _, _ = unstructured.NestedString(f.Object, "spec", "title") + require.Equal(t, nestedFolderTitle, title, "nested folder should have correct title") + parent, _, _ = unstructured.NestedString(f.Object, "metadata", "annotations", "grafana.app/folder") + require.Equal(t, deepFolderName, parent, "nested folder should have deep folder as parent") + + // The key test: Check if dashboard was created in Grafana during move + const timelineUID = "mIJjFy8Kz" + dashboard, err := helper.DashboardsV1.Resource.Get(ctx, timelineUID, metav1.GetOptions{}) + require.NoError(t, err, "dashboard should exist in Grafana after moving never-synced file") + dashboardFolder, _, _ := unstructured.NestedString(dashboard.Object, "metadata", "annotations", "grafana.app/folder") + + // Validate dashboard is in the correct nested folder + require.Equal(t, nestedFolderName, dashboardFolder, "dashboard should be in the nested folder") + + // Verify the file moved in the repository + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "deep", "nested", "timeline.json") + require.NoError(t, err, "moved file should exist in nested repository path") + + // Verify the original file no longer exists in the repository + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", sourceFile) + require.Error(t, err, "original file should no longer exist in repository") + }) + + t.Run("move file with content update", func(t *testing.T) { + const sourcePath = "moved/simple-move.json" // Use the file from previous test + const targetPath = "updated/content-updated.json" + + // Use text-options.json content for the update + updatedContent := helper.LoadFile("testdata/text-options.json") + + // Perform move with content update using helper function + resp := helper.postFilesRequest(t, repo, filesPostOptions{ + targetPath: targetPath, + originalPath: sourcePath, + message: "move file with content update", + body: string(updatedContent), + }) + // nolint:errcheck + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode, "move with content update should succeed") + + // Verify the moved file has updated content (should now be text-options dashboard) + movedObj, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "updated", "content-updated.json") + require.NoError(t, err, "moved file should exist in repository") + + resource, _, err := unstructured.NestedMap(movedObj.Object, "resource") + require.NoError(t, err) + dryRun, _, err := unstructured.NestedMap(resource, "dryRun") + require.NoError(t, err) + title, _, err := unstructured.NestedString(dryRun, "spec", "title") + require.NoError(t, err) + require.Equal(t, "Text options", title, "content should be updated to text-options dashboard") + + // Check it has the expected UID from text-options.json + name, _, err := unstructured.NestedString(dryRun, "metadata", "name") + require.NoError(t, err) + require.Equal(t, "WZ7AhQiVz", name, "should have the UID from text-options.json") + + // Verify source file no longer exists + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "moved", "simple-move.json") + require.Error(t, err, "source file should no longer exist") + + // Sync and verify the updated dashboard exists in Grafana + helper.SyncAndWait(t, repo, nil) + const textOptionsUID = "WZ7AhQiVz" // UID from text-options.json + updatedDashboard, err := helper.DashboardsV1.Resource.Get(ctx, textOptionsUID, metav1.GetOptions{}) + require.NoError(t, err, "updated dashboard should exist in Grafana") + + // Verify the original dashboard was deleted from Grafana + _, err = helper.DashboardsV1.Resource.Get(ctx, allPanelsUID, metav1.GetOptions{}) + require.Error(t, err, "original dashboard should be deleted from Grafana") + require.True(t, apierrors.IsNotFound(err)) + + // Verify the new dashboard has the updated content + updatedTitle, _, err := unstructured.NestedString(updatedDashboard.Object, "spec", "title") + require.NoError(t, err) + require.Equal(t, "Text options", updatedTitle) + }) + + t.Run("move directory", func(t *testing.T) { + // Create some files in a directory first using existing testdata files + helper.CopyToProvisioningPath(t, "testdata/timeline-demo.json", "source-dir/timeline-demo.json") + helper.CopyToProvisioningPath(t, "testdata/text-options.json", "source-dir/text-options.json") + + // Sync to ensure files are recognized + helper.SyncAndWait(t, repo, nil) + + const sourceDir = "source-dir/" + const targetDir = "moved-dir/" + + // Move directory using helper function + resp := helper.postFilesRequest(t, repo, filesPostOptions{ + targetPath: targetDir, + originalPath: sourceDir, + message: "move directory", + }) + // nolint:errcheck + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode, "directory move should succeed") + + // Verify source directory no longer exists + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "source-dir") + require.Error(t, err, "source directory should no longer exist") + + // Verify target directory and files exist + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "moved-dir", "timeline-demo.json") + require.NoError(t, err, "moved timeline-demo.json should exist") + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "moved-dir", "text-options.json") + require.NoError(t, err, "moved text-options.json should exist") + }) + + t.Run("error cases", func(t *testing.T) { + t.Run("missing originalPath parameter", func(t *testing.T) { + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("files", "target.json"). + Body([]byte(`{"test": "content"}`)). + SetHeader("Content-Type", "application/json"). + Do(ctx) + require.Error(t, result.Error(), "should fail without originalPath") + }) + + t.Run("file to directory type mismatch", func(t *testing.T) { + // First create a simple test file without slashes in the path + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("files", "simple-test.json"). + Body(helper.LoadFile("testdata/all-panels.json")). + SetHeader("Content-Type", "application/json"). + Do(ctx) + require.NoError(t, result.Error(), "should create test file") + + // Now try to move this file to a directory path using helper function + resp := helper.postFilesRequest(t, repo, filesPostOptions{ + targetPath: "target-dir/", + originalPath: "simple-test.json", + message: "test move", + }) + // nolint:errcheck + defer resp.Body.Close() + // Read response body to check error message + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + require.NotEqual(t, http.StatusOK, resp.StatusCode, "should fail when moving file to directory") + require.Contains(t, string(body), "cannot move between file and directory types") + }) + + t.Run("non-existent source file", func(t *testing.T) { + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("files", "target.json"). + Param("originalPath", "non-existent.json"). + Body([]byte("")). + SetHeader("Content-Type", "application/json"). + Do(ctx) + require.Error(t, result.Error(), "should fail when source file doesn't exist") + }) + }) +} + +func TestIntegrationProvisioning_FilesOwnershipProtection(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + helper := runGrafana(t) + ctx := context.Background() + + // Create first repository targeting "folder-1" with its own subdirectory + const repo1 = "ownership-repo-1" + helper.CreateRepo(t, TestRepo{ + Name: repo1, + Path: path.Join(helper.ProvisioningPath, "repo1"), + Target: "folder", + Copies: map[string]string{ + "testdata/all-panels.json": "dashboard1.json", + }, + ExpectedDashboards: 1, + ExpectedFolders: 1, + }) + + // Create second repository targeting "folder-2" with its own subdirectory + const repo2 = "ownership-repo-2" + path2 := path.Join(helper.ProvisioningPath, "repo2") + helper.CreateRepo(t, TestRepo{ + Name: repo2, + Path: path2, + Target: "folder", + Copies: map[string]string{ + "testdata/timeline-demo.json": "dashboard2.json", + }, + ExpectedDashboards: 2, // Total across both repos + ExpectedFolders: 2, // Total across both repos + }) + + t.Run("CREATE file with UID already owned by different repository - should fail", func(t *testing.T) { + // Try to create a dashboard in repo2 that has the same UID as the one in repo1 + // The all-panels.json has UID "n1jR8vnnz" which is already owned by repo1 + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo2). // Using repo2 to try to create resource with same UID as repo1 + SubResource("files", "conflicting-dashboard.json"). + Body(helper.LoadFile("testdata/all-panels.json")). // Same file = same UID + SetHeader("Content-Type", "application/json"). + Do(ctx) + + // This should fail with ownership conflict + require.Error(t, result.Error(), "creating resource with UID already owned by different repository should fail") + + // Get detailed error information + err := result.Error() + t.Logf("CREATE operation error: %T - %v", err, err) + if statusErr := apierrors.APIStatus(nil); errors.As(err, &statusErr) { + t.Logf("Status error details: code=%d, reason=%s, message=%s", + statusErr.Status().Code, statusErr.Status().Reason, statusErr.Status().Message) + } + + // Verify it returns BadRequest (400) for ownership conflicts + if !apierrors.IsBadRequest(err) { + t.Errorf("Expected BadRequest error but got: %T - %v", err, err) + return + } + + // Check error message contains ownership conflict information + errorMsg := err.Error() + t.Logf("Error message: %s", errorMsg) + require.Contains(t, errorMsg, fmt.Sprintf("managed by repo '%s'", repo1)) + require.Contains(t, errorMsg, fmt.Sprintf("cannot be modified by repo '%s'", repo2)) + }) + + t.Run("UPDATE with UID already owned by different repository - should fail", func(t *testing.T) { + // Try to update the dashboard owned by repo1 using repo2 + result := helper.AdminREST.Put(). + Namespace("default"). + Resource("repositories"). + Name(repo2). // Using repo2 to try to update repo1's resource + SubResource("files", "conflicting-update.json"). + Body(helper.LoadFile("testdata/all-panels.json")). // Same UID as repo1's dashboard + SetHeader("Content-Type", "application/json"). + Do(ctx) + + // This should fail with ownership conflict + require.Error(t, result.Error(), "updating resource owned by different repository should fail") + + // Get detailed error information + err := result.Error() + t.Logf("UPDATE operation error: %T - %v", err, err) + if statusErr := apierrors.APIStatus(nil); errors.As(err, &statusErr) { + t.Logf("Status error details: code=%d, reason=%s, message=%s", + statusErr.Status().Code, statusErr.Status().Reason, statusErr.Status().Message) + } + + // Verify it returns BadRequest (400) for ownership conflicts + if !apierrors.IsBadRequest(err) { + t.Errorf("Expected BadRequest error but got: %T - %v", err, err) + return + } + + // Check error message contains ownership conflict information + errorMsg := err.Error() + t.Logf("Error message: %s", errorMsg) + require.Contains(t, errorMsg, fmt.Sprintf("managed by repo '%s'", repo1)) + require.Contains(t, errorMsg, fmt.Sprintf("cannot be modified by repo '%s'", repo2)) + }) + + t.Run("DELETE resource owned by different repository - should fail", func(t *testing.T) { + // Create a file manually in the second repo which is already in first one + helper.CopyToProvisioningPath(t, "testdata/all-panels.json", "repo2/conflicting-delete.json") + printFileTree(t, helper.ProvisioningPath) + + result := helper.AdminREST.Delete(). + Namespace("default"). + Resource("repositories"). + Name(repo2). + SubResource("files", "conflicting-delete.json"). + SetHeader("Content-Type", "application/json"). + Do(ctx) + + // This should fail with ownership conflict + require.Error(t, result.Error(), "deleting resource owned by different repository should fail") + + // Get detailed error information + err := result.Error() + t.Logf("DELETE operation error: %T - %v", err, err) + if statusErr := apierrors.APIStatus(nil); errors.As(err, &statusErr) { + t.Logf("Status error details: code=%d, reason=%s, message=%s", + statusErr.Status().Code, statusErr.Status().Reason, statusErr.Status().Message) + } + + // Verify it returns BadRequest (400) for ownership conflicts + if !apierrors.IsBadRequest(err) { + t.Errorf("Expected BadRequest error but got: %T - %v", err, err) + return + } + + // Check error message contains ownership conflict information + errorMsg := err.Error() + t.Logf("Error message: %s", errorMsg) + require.Contains(t, errorMsg, fmt.Sprintf("managed by repo '%s'", repo1)) + require.Contains(t, errorMsg, fmt.Sprintf("cannot be modified by repo '%s'", repo2)) + }) + + t.Run("MOVE and UPDATE file with UID already owned by different repository - should fail", func(t *testing.T) { + resp := helper.postFilesRequest(t, repo2, filesPostOptions{ + targetPath: "moved-dashboard.json", + originalPath: path.Join("dashboard2.json"), + message: "attempt to move file from different repository", + body: string(helper.LoadFile("testdata/all-panels.json")), // Content to move with the conflicting UID + }) + // nolint:errcheck + defer resp.Body.Close() + + // This should fail with ownership conflict + require.NotEqual(t, http.StatusOK, resp.StatusCode, "moving resource owned by different repository should fail") + // Read response body to check error message + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + errorMsg := string(body) + + // Log detailed error information + t.Logf("MOVE operation HTTP status: %d", resp.StatusCode) + t.Logf("MOVE operation error response: %s", errorMsg) + + require.Equal(t, http.StatusBadRequest, resp.StatusCode, "should return BadRequest (400) for ownership conflict") + // Check error message contains ownership conflict information + require.Contains(t, errorMsg, fmt.Sprintf("managed by repo '%s'", repo1)) + require.Contains(t, errorMsg, fmt.Sprintf("cannot be modified by repo '%s'", repo2)) + }) + + t.Run("verify original resources remain intact", func(t *testing.T) { + const allPanelsUID = "n1jR8vnnz" // UID from all-panels.json (repo1) + const timelineUID = "mIJjFy8Kz" // UID from timeline-demo.json (repo2) + + // Verify repo1's dashboard is still owned by repo1 + dashboard1, err := helper.DashboardsV1.Resource.Get(ctx, allPanelsUID, metav1.GetOptions{}) + require.NoError(t, err, "repo1's dashboard should still exist") + require.Equal(t, repo1, dashboard1.GetAnnotations()[utils.AnnoKeyManagerIdentity], "repo1's dashboard should still be owned by repo1") + + // Verify repo2's dashboard is still owned by repo2 + dashboard2, err := helper.DashboardsV1.Resource.Get(ctx, timelineUID, metav1.GetOptions{}) + require.NoError(t, err, "repo2's dashboard should still exist") + require.Equal(t, repo2, dashboard2.GetAnnotations()[utils.AnnoKeyManagerIdentity], "repo2's dashboard should still be owned by repo2") + }) +} diff --git a/pkg/tests/apis/provisioning/helper_test.go b/pkg/tests/apis/provisioning/helper_test.go index f1252af3662..554ad36bff8 100644 --- a/pkg/tests/apis/provisioning/helper_test.go +++ b/pkg/tests/apis/provisioning/helper_test.go @@ -4,10 +4,12 @@ import ( "context" "encoding/json" "fmt" + "io/fs" "net/http" "net/url" "os" "path" + "path/filepath" "strings" "testing" "text/template" @@ -94,39 +96,114 @@ func (h *provisioningTestHelper) SyncAndWait(t *testing.T, repo string, options h.AwaitJobSuccess(t, t.Context(), unstruct) } +func (h *provisioningTestHelper) TriggerJobAndWaitForSuccess(t *testing.T, repo string, spec provisioning.JobSpec) { + t.Helper() + + body := asJSON(spec) + result := h.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("jobs"). + Body(body). + SetHeader("Content-Type", "application/json"). + Do(t.Context()) + + if apierrors.IsAlreadyExists(result.Error()) { + // Wait for all jobs to finish as we don't have the name. + h.AwaitJobs(t, repo) + return + } + + obj, err := result.Get() + require.NoError(t, err, "expecting to be able to sync repository") + + unstruct, ok := obj.(*unstructured.Unstructured) + require.True(t, ok, "expecting unstructured object, but got %T", obj) + + name := unstruct.GetName() + require.NotEmpty(t, name, "expecting name to be set") + h.AwaitJobSuccess(t, t.Context(), unstruct) +} + +func (h *provisioningTestHelper) TriggerJobAndWaitForComplete(t *testing.T, repo string, spec provisioning.JobSpec) *unstructured.Unstructured { + t.Helper() + + body := asJSON(spec) + result := h.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("jobs"). + Body(body). + SetHeader("Content-Type", "application/json"). + Do(t.Context()) + + if apierrors.IsAlreadyExists(result.Error()) { + // Wait for all jobs to finish as we don't have the name. + h.AwaitJobs(t, repo) + t.Errorf("repository %s already has a job running, but we expected a new one to be created", repo) + t.FailNow() + + return nil + } + + obj, err := result.Get() + require.NoError(t, err, "expecting to be able to sync repository") + + unstruct, ok := obj.(*unstructured.Unstructured) + require.True(t, ok, "expecting unstructured object, but got %T", obj) + + name := unstruct.GetName() + require.NotEmpty(t, name, "expecting name to be set") + + return h.AwaitJob(t, t.Context(), unstruct) +} + func (h *provisioningTestHelper) AwaitJobSuccess(t *testing.T, ctx context.Context, job *unstructured.Unstructured) { t.Helper() + job = h.AwaitJob(t, ctx, job) + lastErrors := mustNestedStringSlice(job.Object, "status", "errors") + lastState := mustNestedString(job.Object, "status", "state") + + repo := job.GetLabels()[jobs.LabelRepository] + + // Debug state if job failed + if len(lastErrors) > 0 || lastState != string(provisioning.JobStateSuccess) { + h.DebugState(t, repo, fmt.Sprintf("JOB FAILED: %s", job.GetName())) + } + + require.Empty(t, lastErrors, "historic job '%s' has errors: %v", job.GetName(), lastErrors) + require.Equal(t, string(provisioning.JobStateSuccess), lastState, + "historic job '%s' was not successful", job.GetName()) +} + +func (h *provisioningTestHelper) AwaitJob(t *testing.T, ctx context.Context, job *unstructured.Unstructured) *unstructured.Unstructured { + t.Helper() repo := job.GetLabels()[jobs.LabelRepository] require.NotEmpty(t, repo) - if !assert.EventuallyWithT(t, func(collect *assert.CollectT) { + + var lastResult *unstructured.Unstructured + require.EventuallyWithT(t, func(collect *assert.CollectT) { result, err := h.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "jobs", string(job.GetUID())) - if apierrors.IsNotFound(err) { - assert.Fail(collect, "job '%s' not found yet yet", job.GetName()) - return // continue trying + if !assert.False(collect, apierrors.IsNotFound(err)) { + collect.Errorf("job '%s' not found, still waiting for it to complete", job.GetName()) + return } - // Can fail fast here -- the jobs are immutable - require.NoError(t, err) - require.NotNil(t, result) - - errors := mustNestedStringSlice(result.Object, "status", "errors") - require.Empty(t, errors, "historic job '%s' has errors: %v", job.GetName(), errors) - state := mustNestedString(result.Object, "status", "state") - require.Equal(t, string(provisioning.JobStateSuccess), state, - "historic job '%s' was not successful", job.GetName()) - }, time.Second*10, time.Millisecond*25) { - // We also want to add the job details to the error when it fails. - job, err := h.Jobs.Resource.Get(ctx, job.GetName(), metav1.GetOptions{}) + assert.NoError(collect, err, "failed to get job '%s' to be found", job.GetName()) if err != nil { - t.Logf("failed to get job details for further help: %v", err) - } else { - t.Logf("job details: %+v", job.Object) + return } - t.FailNow() - } + + lastResult = result + }, time.Second*10, time.Millisecond*25) + require.NotNil(t, lastResult, "expected job result to be non-nil") + + return lastResult } func (h *provisioningTestHelper) AwaitJobs(t *testing.T, repoName string) { @@ -138,9 +215,11 @@ func (h *provisioningTestHelper) AwaitJobs(t *testing.T, repoName string) { if assert.NoError(collect, err, "failed to list active jobs") { for _, elem := range list.Items { repo, _, err := unstructured.NestedString(elem.Object, "spec", "repository") - require.NoError(t, err) - if repo == repoName { - collect.Errorf("there are still remaining jobs for %s: %+v", repoName, elem) + if !assert.NoError(collect, err, "failed to get repository from job spec") { + return + } + + if !assert.NotEqual(collect, repoName, repo, "there are still remaining jobs for %s: %+v", repoName, elem) { return } } @@ -173,9 +252,11 @@ func (h *provisioningTestHelper) AwaitJobsWithStates(t *testing.T, repoName stri if assert.NoError(collect, err, "failed to list active jobs") { for _, elem := range list.Items { repo, _, err := unstructured.NestedString(elem.Object, "spec", "repository") - require.NoError(t, err) - if repo == repoName { - collect.Errorf("there are still remaining jobs for %s: %+v", repoName, elem) + if !assert.NoError(collect, err, "failed to get repository from job spec") { + return + } + + if !assert.NotEqual(collect, repoName, repo, "there are still remaining jobs for %s: %+v", repoName, elem) { return } } @@ -234,6 +315,7 @@ func (h *provisioningTestHelper) RenderObject(t *testing.T, filePath string, val // The from path is relative to test file's directory. func (h *provisioningTestHelper) CopyToProvisioningPath(t *testing.T, from, to string) { fullPath := path.Join(h.ProvisioningPath, to) + t.Logf("Copying file from '%s' to provisioning path '%s'", from, fullPath) err := os.MkdirAll(path.Dir(fullPath), 0750) require.NoError(t, err, "failed to create directories for provisioning path") @@ -242,6 +324,210 @@ func (h *provisioningTestHelper) CopyToProvisioningPath(t *testing.T, from, to s require.NoError(t, err, "failed to write file to provisioning path") } +// DebugState logs the current state of filesystem, repository, and Grafana resources for debugging +func (h *provisioningTestHelper) DebugState(t *testing.T, repo string, label string) { + t.Helper() + t.Logf("=== DEBUG STATE: %s ===", label) + + ctx := context.Background() + + // Log filesystem contents using existing tree function + printFileTree(t, h.ProvisioningPath) + + // Log all repositories first + t.Logf("All repositories:") + repos, err := h.Repositories.Resource.List(ctx, metav1.ListOptions{}) + if err != nil { + t.Logf(" ERROR listing repositories: %v", err) + } else { + t.Logf(" Total repositories: %d", len(repos.Items)) + for i, repository := range repos.Items { + t.Logf(" Repository %d: name=%s", i+1, repository.GetName()) + } + } + + // Log repository files for the specific repo + t.Logf("Repository '%s' files:", repo) + h.logRepositoryFiles(t, ctx, repo, " ") + + // Log files for all other repositories too + if repos != nil && len(repos.Items) > 1 { + t.Logf("Files in other repositories:") + for _, repository := range repos.Items { + if repository.GetName() != repo { + t.Logf(" Repository '%s' files:", repository.GetName()) + h.logRepositoryFiles(t, ctx, repository.GetName(), " ") + } + } + } + + // Log Grafana dashboards + t.Logf("Grafana dashboards:") + dashboards, err := h.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) + if err != nil { + t.Logf(" ERROR listing dashboards: %v", err) + } else { + t.Logf(" Total dashboards: %d", len(dashboards.Items)) + for i, dashboard := range dashboards.Items { + t.Logf(" Dashboard %d: name=%s, UID=%s", i+1, dashboard.GetName(), dashboard.GetUID()) + } + } + + // Log Grafana folders + t.Logf("Grafana folders:") + folders, err := h.Folders.Resource.List(ctx, metav1.ListOptions{}) + if err != nil { + t.Logf(" ERROR listing folders: %v", err) + } else { + t.Logf(" Total folders: %d", len(folders.Items)) + for i, folder := range folders.Items { + t.Logf(" Folder %d: name=%s", i+1, folder.GetName()) + } + } + + t.Logf("=== END DEBUG STATE ===") +} + +// logRepositoryFiles logs repository file structure using the files API +func (h *provisioningTestHelper) logRepositoryFiles(t *testing.T, ctx context.Context, repoName string, prefix string) { + t.Helper() + + // Try to list files at root level + files, err := h.Repositories.Resource.Get(ctx, repoName, metav1.GetOptions{}, "files") + if err != nil { + t.Logf("%sERROR getting repository files: %v", prefix, err) + return + } + + // The API returns a structured response, we need to extract the actual file data + if files.Object != nil { + h.logRepositoryObject(t, files.Object, prefix, "") + } else { + t.Logf("%s(empty repository)", prefix) + } +} + +// logRepositoryObject recursively logs repository file structure from API response +func (h *provisioningTestHelper) logRepositoryObject(t *testing.T, obj map[string]interface{}, prefix string, path string) { + t.Helper() + + if obj == nil { + return + } + + // Skip metadata fields and focus on actual content + for key, value := range obj { + // Skip Kubernetes metadata fields + if key == "kind" || key == "apiVersion" || key == "metadata" { + continue + } + + // Calculate new path for nested objects + var newPath string + if path != "" { + newPath = path + "/" + key + } else { + newPath = key + } + + switch v := value.(type) { + case map[string]interface{}: + t.Logf("%s├── %s/", prefix, key) + h.logRepositoryObject(t, v, prefix+" ", newPath) + case []interface{}: + // Handle lists (like items array) + if key == "items" && len(v) > 0 { + t.Logf("%s%d items:", prefix, len(v)) + for i, item := range v { + if itemMap, ok := item.(map[string]interface{}); ok { + // Try to get the actual file path from the item + if pathVal, exists := itemMap["path"]; exists { + t.Logf("%s├── %v", prefix, pathVal) + } else { + t.Logf("%s├── item %d:", prefix, i+1) + } + h.logRepositoryObject(t, itemMap, prefix+" ", newPath) + } + } + } + default: + // This could be file content or metadata + // Skip common metadata fields that are not useful for debugging + if key != "kind" && key != "apiVersion" && key != "path" && key != "size" && key != "hash" { + t.Logf("%s├── %s: %v", prefix, key, value) + } + } + } +} + +type TestRepo struct { + Name string + Target string + Path string + Values map[string]any + Copies map[string]string + ExpectedDashboards int + ExpectedFolders int +} + +func (h *provisioningTestHelper) CreateRepo(t *testing.T, repo TestRepo) { + if repo.Target == "" { + repo.Target = "instance" + } + + // Use custom path if provided, otherwise use default provisioning path + repoPath := h.ProvisioningPath + if repo.Path != "" { + repoPath = repo.Path + // Ensure the directory exists + err := os.MkdirAll(repoPath, 0750) + require.NoError(t, err, "should be able to create repository path") + } + + templateVars := map[string]any{ + "Name": repo.Name, + "SyncEnabled": true, + "SyncTarget": repo.Target, + } + if repo.Path != "" { + templateVars["Path"] = repoPath + } + + localTmp := h.RenderObject(t, "testdata/local-write.json.tmpl", templateVars) + + _, err := h.Repositories.Resource.Create(t.Context(), localTmp, metav1.CreateOptions{}) + require.NoError(t, err) + + for from, to := range repo.Copies { + if repo.Path != "" { + // Copy to custom path + fullPath := path.Join(repoPath, to) + err := os.MkdirAll(path.Dir(fullPath), 0750) + require.NoError(t, err, "failed to create directories for custom path") + file := h.LoadFile(from) + err = os.WriteFile(fullPath, file, 0600) + require.NoError(t, err, "failed to write file to custom path") + } else { + h.CopyToProvisioningPath(t, from, to) + } + } + + // Trigger and wait for initial sync to populate resources + h.SyncAndWait(t, repo.Name, nil) + + // Debug state after initial sync + h.DebugState(t, repo.Name, "AFTER INITIAL SYNC") + + // Verify initial state + dashboards, err := h.DashboardsV1.Resource.List(t.Context(), metav1.ListOptions{}) + require.NoError(t, err) + require.Equal(t, repo.ExpectedDashboards, len(dashboards.Items), "should the expected dashboards after sync") + + folders, err := h.Folders.Resource.List(t.Context(), metav1.ListOptions{}) + require.NoError(t, err) + require.Equal(t, repo.ExpectedFolders, len(folders.Items), "should have the expected folders after sync") +} + type grafanaOption func(opts *testinfra.GrafanaOpts) // Useful for debugging a test in development. @@ -433,3 +719,104 @@ func (h *provisioningTestHelper) postFilesRequest(t *testing.T, repo string, opt return resp } + +// printFileTree prints the directory structure as a tree for debugging purposes +func printFileTree(t *testing.T, rootPath string) { + t.Helper() + t.Logf("File tree for %s:", rootPath) + + err := filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(rootPath, path) + if err != nil { + return err + } + + if relPath == "." { + return nil + } + + depth := strings.Count(relPath, string(filepath.Separator)) + indent := strings.Repeat(" ", depth) + + if d.IsDir() { + t.Logf("%s├── %s/", indent, d.Name()) + } else { + info, err := d.Info() + if err != nil { + t.Logf("%s├── %s (error reading info)", indent, d.Name()) + } else { + t.Logf("%s├── %s (%d bytes)", indent, d.Name(), info.Size()) + } + } + + return nil + }) + if err != nil { + t.Logf("Error walking directory: %v", err) + } +} + +// Helper function to count files in a directory recursively +func countFilesInDir(rootPath string) (int, error) { + count := 0 + err := filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + count++ + } + return nil + }) + return count, err +} + +// CleanupAllRepos deletes all repositories and waits for them to be fully removed +func (h *provisioningTestHelper) CleanupAllRepos(t *testing.T) { + t.Helper() + ctx := context.Background() + + // First, get all repositories that exist + list, err := h.Repositories.Resource.List(ctx, metav1.ListOptions{}) + if err != nil || len(list.Items) == 0 { + return // Nothing to clean up + } + + // Wait for any active jobs to complete before deleting repositories + require.EventuallyWithT(t, func(collect *assert.CollectT) { + activeJobs, err := h.Jobs.Resource.List(ctx, metav1.ListOptions{}) + if !assert.NoError(collect, err, "failed to list active jobs") { + return + } + assert.Equal(collect, 0, len(activeJobs.Items), "all active jobs should complete before cleanup") + }, time.Second*20, time.Millisecond*100, "active jobs should complete before cleanup") + + // Now delete all repositories with retries + require.EventuallyWithT(t, func(collect *assert.CollectT) { + list, err := h.Repositories.Resource.List(ctx, metav1.ListOptions{}) + if !assert.NoError(collect, err) { + return + } + + for _, repo := range list.Items { + err := h.Repositories.Resource.Delete(ctx, repo.GetName(), metav1.DeleteOptions{}) + // Don't fail if already deleted (404 is OK) + if err != nil { + assert.True(collect, apierrors.IsNotFound(err), "Should be able to delete repository %s (or it should already be deleted)", repo.GetName()) + } + } + }, time.Second*10, time.Millisecond*100, "should be able to delete all repositories") + + // Then wait for repositories to be fully deleted to ensure clean state + require.EventuallyWithT(t, func(collect *assert.CollectT) { + list, err := h.Repositories.Resource.List(ctx, metav1.ListOptions{}) + if !assert.NoError(collect, err) { + return + } + assert.Equal(collect, 0, len(list.Items), "repositories should be cleaned up") + }, time.Second*15, time.Millisecond*100, "repositories should be cleaned up between subtests") +} diff --git a/pkg/tests/apis/provisioning/movejob_test.go b/pkg/tests/apis/provisioning/movejob_test.go new file mode 100644 index 00000000000..770f0f9bce9 --- /dev/null +++ b/pkg/tests/apis/provisioning/movejob_test.go @@ -0,0 +1,360 @@ +package provisioning + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +func TestIntegrationProvisioning_MoveJob(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + helper := runGrafana(t) + ctx := context.Background() + const repo = "move-test-repo" + testRepo := TestRepo{ + Name: repo, + Copies: map[string]string{ + "testdata/all-panels.json": "dashboard1.json", + "testdata/text-options.json": "dashboard2.json", + "testdata/timeline-demo.json": "folder/dashboard3.json", + }, + ExpectedDashboards: 3, + ExpectedFolders: 1, + } + helper.CreateRepo(t, testRepo) + + t.Run("move single file", func(t *testing.T) { + helper.DebugState(t, repo, "BEFORE MOVE SINGLE FILE") + + spec := provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + Paths: []string{"dashboard1.json"}, + TargetPath: "moved/", + }, + } + helper.TriggerJobAndWaitForSuccess(t, repo, spec) + + helper.DebugState(t, repo, "AFTER MOVE SINGLE FILE") + // TODO: This additional sync should not be necessary - the move job should handle sync properly + helper.SyncAndWait(t, repo, nil) + + // FIXME: use the helpers for assertions + // Verify file is moved in repository + _, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "moved", "dashboard1.json") + require.NoError(t, err, "file should exist at new location in repository") + + // Verify original file is gone from repository + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "dashboard1.json") + require.Error(t, err, "original file should be gone from repository") + require.True(t, apierrors.IsNotFound(err), "should be not found error") + + // Verify other files still exist at original locations + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "dashboard2.json") + require.NoError(t, err, "other files should still exist") + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "dashboard3.json") + require.NoError(t, err, "nested files should still exist") + + // Verify dashboard still exists in Grafana after sync + dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.Len(t, dashboards.Items, 3, "should still have 3 dashboards after move") + // Verify that dashboards have the correct source paths + foundPaths := make(map[string]bool) + for _, dashboard := range dashboards.Items { + sourcePath := dashboard.GetAnnotations()["grafana.app/sourcePath"] + foundPaths[sourcePath] = true + } + + require.True(t, foundPaths["moved/dashboard1.json"], "should have dashboard with moved source path") + require.True(t, foundPaths["dashboard2.json"], "should have dashboard2 in original location") + require.True(t, foundPaths["folder/dashboard3.json"], "should have dashboard3 in original nested location") + }) + + t.Run("move multiple files and folder", func(t *testing.T) { + helper.DebugState(t, repo, "BEFORE MOVE MULTIPLE FILES") + + // Create move job for multiple files including a folder + spec := provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + Paths: []string{"dashboard2.json", "folder/"}, + TargetPath: "archived/", + }, + } + helper.TriggerJobAndWaitForSuccess(t, repo, spec) + + helper.DebugState(t, repo, "AFTER MOVE MULTIPLE FILES") + // TODO: This additional sync should not be necessary - the move job should handle sync properly + helper.SyncAndWait(t, repo, nil) + + // Verify files are moved in repository + _, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "archived", "dashboard2.json") + require.NoError(t, err, "dashboard2.json should exist at new location") + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "archived", "folder", "dashboard3.json") + require.NoError(t, err, "folder/dashboard3.json should exist at new nested location") + + // Verify original files are gone from repository + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "dashboard2.json") + require.Error(t, err, "dashboard2.json should be gone from original location") + require.True(t, apierrors.IsNotFound(err)) + + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "dashboard3.json") + require.Error(t, err, "folder should be gone from original location") + require.True(t, apierrors.IsNotFound(err), err.Error()) + + // Verify dashboards still exist in Grafana after sync + // Note: Since dashboard1.json was moved in the previous test, we now expect all 3 dashboards + // to be accessible from their moved locations (dashboard1 from moved/, dashboard2 and dashboard3 from archived/) + dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.Len(t, dashboards.Items, 3, "should still have 3 dashboards after move") + + // Verify that dashboards have the correct source paths after cumulative moves + foundPaths := make(map[string]bool) + for _, dashboard := range dashboards.Items { + sourcePath := dashboard.GetAnnotations()["grafana.app/sourcePath"] + foundPaths[sourcePath] = true + } + + require.True(t, foundPaths["moved/dashboard1.json"], "should have dashboard1 from first move") + require.True(t, foundPaths["archived/dashboard2.json"], "should have dashboard2 in archived location") + require.True(t, foundPaths["archived/folder/dashboard3.json"], "should have dashboard3 in archived nested location") + }) + + t.Run("move non-existent file", func(t *testing.T) { + helper.DebugState(t, repo, "BEFORE MOVE NON-EXISTENT FILE") + + spec := provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + Paths: []string{"non-existent.json"}, + TargetPath: "moved/", + }, + } + + job := helper.TriggerJobAndWaitForComplete(t, repo, spec) + state := mustNestedString(job.Object, "status", "state") + require.Equal(t, "error", state, "move job should have failed due to non-existent file") + }) + + t.Run("move non-existent uid", func(t *testing.T) { + spec := provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + TargetPath: "moved-nonexistent/", + Resources: []provisioning.ResourceRef{ + { + Name: "non-existent-move-uid", + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + } + + job := helper.TriggerJobAndWaitForComplete(t, repo, spec) + state := mustNestedString(job.Object, "status", "state") + require.Equal(t, "error", state, "move job should have failed due to non-existent uid") + }) + + t.Run("move without target path", func(t *testing.T) { + // Create move job without target path (should fail) + spec := provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + Paths: []string{"moved/dashboard1.json"}, + // TargetPath intentionally omitted + }, + } + + job := helper.TriggerJobAndWaitForComplete(t, repo, spec) + state := mustNestedString(job.Object, "status", "state") + assert.Equal(t, "error", state, "move job should have failed due to missing target path") + }) + + t.Run("move by resource reference", func(t *testing.T) { + // Delete the existing repository to avoid conflicts with validation rules + err := helper.Repositories.Resource.Delete(ctx, repo, metav1.DeleteOptions{}) + require.NoError(t, err) + + // Wait for repository to be fully deleted + require.EventuallyWithT(t, func(collect *assert.CollectT) { + _, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}) + assert.True(collect, apierrors.IsNotFound(err), "repository should be deleted") + }, time.Second*5, time.Millisecond*50, "repository should be deleted before creating new one") + + // Create a unique repository for resource reference testing to avoid contamination + const refRepo = "move-ref-test-repo" + localRefTmp := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{ + "Name": refRepo, + "SyncEnabled": true, + "SyncTarget": "instance", + }) + _, err = helper.Repositories.Resource.Create(ctx, localRefTmp, metav1.CreateOptions{}) + require.NoError(t, err) + + // Create modified test files with unique UIDs for ResourceRef testing + allPanelsContent := helper.LoadFile("testdata/all-panels.json") + textOptionsContent := helper.LoadFile("testdata/text-options.json") + timelineDemoContent := helper.LoadFile("testdata/timeline-demo.json") + + // Modify UIDs to be unique for ResourceRef tests + allPanelsModified := strings.Replace(string(allPanelsContent), `"uid": "n1jR8vnnz"`, `"uid": "moveref1"`, 1) + textOptionsModified := strings.Replace(string(textOptionsContent), `"uid": "WZ7AhQiVz"`, `"uid": "moveref2"`, 1) + timelineDemoModified := strings.Replace(string(timelineDemoContent), `"uid": "mIJjFy8Kz"`, `"uid": "moveref3"`, 1) + + // Create temporary files and copy them to the provisioning path + tmpDir := t.TempDir() + tmpFile1 := filepath.Join(tmpDir, "move-ref-test-1.json") + tmpFile2 := filepath.Join(tmpDir, "move-ref-test-2.json") + tmpFile3 := filepath.Join(tmpDir, "move-ref-test-3.json") + + require.NoError(t, os.WriteFile(tmpFile1, []byte(allPanelsModified), 0644)) + require.NoError(t, os.WriteFile(tmpFile2, []byte(textOptionsModified), 0644)) + require.NoError(t, os.WriteFile(tmpFile3, []byte(timelineDemoModified), 0644)) + + // Copy files to provisioning path to set up test - use refRepo's path + helper.CopyToProvisioningPath(t, tmpFile1, "move-source-1.json") + helper.CopyToProvisioningPath(t, tmpFile2, "move-source-2.json") + helper.CopyToProvisioningPath(t, tmpFile3, "move-source-3.json") + + // Sync to populate resources in Grafana + helper.SyncAndWait(t, refRepo, nil) + + t.Run("move single dashboard by resource reference", func(t *testing.T) { + spec := provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + TargetPath: "moved-by-ref/", + Resources: []provisioning.ResourceRef{ + { + Name: "moveref1", // UID from modified all-panels.json + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + } + + helper.TriggerJobAndWaitForSuccess(t, refRepo, spec) + // Verify corresponding file is moved in repository + _, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "moved-by-ref", "move-source-1.json") + require.NoError(t, err, "file should be moved to new location in repository") + + // Verify original file is deleted from repository + _, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "move-source-1.json") + require.Error(t, err, "original file should be deleted from repository") + require.True(t, apierrors.IsNotFound(err), "should be not found error") + + // Verify dashboard still exists in Grafana (should be updated after sync) + helper.SyncAndWait(t, refRepo, nil) + _, err = helper.DashboardsV1.Resource.Get(ctx, "moveref1", metav1.GetOptions{}) + require.NoError(t, err, "dashboard should still exist in Grafana after move") + + // Verify other resource still exists in original location + _, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "move-source-2.json") + require.NoError(t, err, "other files should still exist in original location") + }) + + t.Run("move multiple resources by reference", func(t *testing.T) { + spec := provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + TargetPath: "archived-by-ref/", + Resources: []provisioning.ResourceRef{ + { + Name: "moveref2", // UID from modified text-options.json + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + } + helper.TriggerJobAndWaitForSuccess(t, refRepo, spec) + + // Verify file is moved in repository + _, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "archived-by-ref", "move-source-2.json") + require.NoError(t, err, "file should be moved to new location") + + // Verify original file is deleted from repository + _, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "move-source-2.json") + require.Error(t, err, "original file should be deleted from repository") + require.True(t, apierrors.IsNotFound(err), "should be not found error") + + // Verify dashboard still exists in Grafana after sync + helper.SyncAndWait(t, refRepo, nil) + _, err = helper.DashboardsV1.Resource.Get(ctx, "moveref2", metav1.GetOptions{}) + require.NoError(t, err, "dashboard should still exist in Grafana after move") + }) + + t.Run("mixed move - paths and resources", func(t *testing.T) { + // Setup fresh resources for mixed test + tmpMixed1 := filepath.Join(tmpDir, "mixed-move-1.json") + tmpMixed2 := filepath.Join(tmpDir, "mixed-move-2.json") + + allPanelsMixed := strings.Replace(string(allPanelsContent), `"uid": "n1jR8vnnz"`, `"uid": "mixedmove1"`, 1) + textOptionsMixed := strings.Replace(string(textOptionsContent), `"uid": "WZ7AhQiVz"`, `"uid": "mixedmove2"`, 1) + + require.NoError(t, os.WriteFile(tmpMixed1, []byte(allPanelsMixed), 0644)) + require.NoError(t, os.WriteFile(tmpMixed2, []byte(textOptionsMixed), 0644)) + + helper.CopyToProvisioningPath(t, tmpMixed1, "mixed-move-1.json") // UID: mixedmove1 + helper.CopyToProvisioningPath(t, tmpMixed2, "mixed-move-2.json") // UID: mixedmove2 + + helper.SyncAndWait(t, refRepo, nil) + + // Create move job that combines both paths and resource references + spec := provisioning.JobSpec{ + Action: provisioning.JobActionMove, + Move: &provisioning.MoveJobOptions{ + TargetPath: "mixed-target/", + Paths: []string{"mixed-move-1.json"}, // Move by path + Resources: []provisioning.ResourceRef{ + { + Name: "mixedmove2", // Move by resource reference + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + } + helper.TriggerJobAndWaitForSuccess(t, refRepo, spec) + + // Verify both targeted resources are moved in repository + _, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "mixed-target", "mixed-move-1.json") + require.NoError(t, err, "file moved by path should exist at new location") + + _, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "mixed-target", "mixed-move-2.json") + require.NoError(t, err, "file moved by resource ref should exist at new location") + + // Verify files are deleted from original locations + _, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "mixed-move-1.json") + require.Error(t, err, "file moved by path should be deleted from original location") + + _, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "mixed-move-2.json") + require.Error(t, err, "file moved by resource ref should be deleted from original location") + + // Verify dashboards still exist in Grafana after sync + helper.SyncAndWait(t, refRepo, nil) + _, err = helper.DashboardsV1.Resource.Get(ctx, "mixedmove1", metav1.GetOptions{}) + require.NoError(t, err, "dashboard moved by path should still exist in Grafana") + + _, err = helper.DashboardsV1.Resource.Get(ctx, "mixedmove2", metav1.GetOptions{}) + require.NoError(t, err, "dashboard moved by resource ref should still exist in Grafana") + }) + }) +} diff --git a/pkg/tests/apis/provisioning/provisioning_test.go b/pkg/tests/apis/provisioning/provisioning_test.go deleted file mode 100644 index b7b845e233f..00000000000 --- a/pkg/tests/apis/provisioning/provisioning_test.go +++ /dev/null @@ -1,2304 +0,0 @@ -package provisioning - -import ( - "context" - "encoding/json" - "fmt" - "io" - "io/fs" - "net/http" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - 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/runtime" - - provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" - "github.com/grafana/grafana/pkg/apimachinery/utils" - "github.com/grafana/grafana/pkg/extensions" - "github.com/grafana/grafana/pkg/infra/usagestats" - "github.com/grafana/grafana/pkg/tests/apis" -) - -// printFileTree prints the directory structure as a tree for debugging purposes -func printFileTree(t *testing.T, rootPath string) { - t.Helper() - t.Logf("File tree for %s:", rootPath) - - err := filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - - relPath, err := filepath.Rel(rootPath, path) - if err != nil { - return err - } - - if relPath == "." { - return nil - } - - depth := strings.Count(relPath, string(filepath.Separator)) - indent := strings.Repeat(" ", depth) - - if d.IsDir() { - t.Logf("%s├── %s/", indent, d.Name()) - } else { - info, err := d.Info() - if err != nil { - t.Logf("%s├── %s (error reading info)", indent, d.Name()) - } else { - t.Logf("%s├── %s (%d bytes)", indent, d.Name(), info.Size()) - } - } - - return nil - }) - if err != nil { - t.Logf("Error walking directory: %v", err) - } -} - -func TestIntegrationProvisioning_CreatingAndGetting(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - helper := runGrafana(t) - createOptions := metav1.CreateOptions{FieldValidation: "Strict"} - ctx := context.Background() - - inputFiles := []string{ - "testdata/github-readonly.json.tmpl", - "testdata/local-readonly.json.tmpl", - } - - for _, inputFilePath := range inputFiles { - t.Run(inputFilePath, func(t *testing.T) { - input := helper.RenderObject(t, inputFilePath, nil) - - _, err := helper.Repositories.Resource.Create(ctx, input, createOptions) - require.NoError(t, err, "failed to create resource") - - name := mustNestedString(input.Object, "metadata", "name") - output, err := helper.Repositories.Resource.Get(ctx, name, metav1.GetOptions{}) - require.NoError(t, err, "failed to read back resource") - - // Move encrypted token mutation - token, found, err := unstructured.NestedString(output.Object, "spec", "github", "encryptedToken") - require.NoError(t, err, "encryptedToken is not a string") - if found { - unstructured.RemoveNestedField(input.Object, "spec", "github", "token") - err = unstructured.SetNestedField(input.Object, token, "spec", "github", "encryptedToken") - require.NoError(t, err, "unable to copy encrypted token") - } - - // Marshal as real objects to ",omitempty" values are tested properly - expectedRepo := unstructuredToRepository(t, input) - returnedRepo := unstructuredToRepository(t, output) - require.Equal(t, expectedRepo.Spec, returnedRepo.Spec) - - // A viewer should not be able to see the same thing - var statusCode int - rsp := helper.ViewerREST.Get(). - Namespace("default"). - Resource("repositories"). - Name(name). - Do(context.Background()) - require.Error(t, rsp.Error()) - rsp.StatusCode(&statusCode) - require.Equal(t, http.StatusForbidden, statusCode) - - // Viewer can see file listing - rsp = helper.AdminREST.Get(). - Namespace("default"). - Resource("repositories"). - Name(name). - Suffix("files/"). - Do(context.Background()) - require.NoError(t, rsp.Error()) - - // Verify that we can list refs - rsp = helper.AdminREST.Get(). - Namespace("default"). - Resource("repositories"). - Name(name). - Suffix("refs"). - Do(context.Background()) - - if expectedRepo.Spec.Type == provisioning.LocalRepositoryType { - require.ErrorContains(t, rsp.Error(), "does not support versioned operations") - } else { - require.NoError(t, rsp.Error()) - refs := &provisioning.RefList{} - err = rsp.Into(refs) - require.NoError(t, err) - require.True(t, len(refs.Items) >= 1, "should have at least one ref") - - var foundBranch bool - for _, ref := range refs.Items { - // FIXME: this assertion should be improved for all git types and take things from config - if ref.Name == "integration-test" { - require.Equal(t, "0f3370c212b04b9704e00f6926ef339bf91c7a1b", ref.Hash) - require.Equal(t, "https://github.com/grafana/grafana-git-sync-demo/tree/integration-test", ref.RefURL) - foundBranch = true - } - } - - require.True(t, foundBranch, "branch should be found") - } - }) - } - - // Viewer can see settings listing - t.Run("viewer has access to list", func(t *testing.T) { - settings := &provisioning.RepositoryViewList{} - rsp := helper.ViewerREST.Get(). - Namespace("default"). - Suffix("settings"). - Do(context.Background()) - require.NoError(t, rsp.Error()) - err := rsp.Into(settings) - require.NoError(t, err) - require.Len(t, settings.Items, len(inputFiles)) - - // FIXME: this should be an enterprise integration test - if extensions.IsEnterprise { - require.ElementsMatch(t, []provisioning.RepositoryType{ - provisioning.LocalRepositoryType, - provisioning.GitHubRepositoryType, - provisioning.GitRepositoryType, - provisioning.BitbucketRepositoryType, - provisioning.GitLabRepositoryType, - }, settings.AvailableRepositoryTypes) - } else { - require.ElementsMatch(t, []provisioning.RepositoryType{ - provisioning.LocalRepositoryType, - provisioning.GitHubRepositoryType, - }, settings.AvailableRepositoryTypes) - } - }) - - t.Run("Repositories are reported in stats", func(t *testing.T) { - report := apis.DoRequest(helper.K8sTestHelper, apis.RequestParams{ - Method: http.MethodGet, - Path: "/api/admin/usage-report-preview", - User: helper.Org1.Admin, - }, &usagestats.Report{}) - - stats := map[string]any{} - for k, v := range report.Result.Metrics { - if strings.HasPrefix(k, "stats.repository.") { - stats[k] = v - } - } - require.Equal(t, map[string]any{ - "stats.repository.github.count": 1.0, - "stats.repository.local.count": 1.0, - }, stats) - }) -} - -func TestIntegrationProvisioning_FailInvalidSchema(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - t.Skip("Reenable this test once we enforce schema validation for provisioning") - - helper := runGrafana(t) - ctx := context.Background() - - const repo = "invalid-schema-tmp" - // Set up the repository and the file to import. - helper.CopyToProvisioningPath(t, "testdata/invalid-dashboard-schema.json", "invalid-dashboard-schema.json") - - localTmp := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{ - "Name": repo, - "SyncEnabled": true, - }) - _, err := helper.Repositories.Resource.Create(ctx, localTmp, metav1.CreateOptions{}) - require.NoError(t, err) - - // Make sure the repo can read and validate the file - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "invalid-dashboard-schema.json") - status := helper.RequireApiErrorStatus(err, metav1.StatusReasonBadRequest, http.StatusBadRequest) - require.Equal(t, status.Message, "Dry run failed: Dashboard.dashboard.grafana.app \"invalid-schema-uid\" is invalid: [spec.panels.0.repeatDirection: Invalid value: conflicting values \"h\" and \"this is not an allowed value\", spec.panels.0.repeatDirection: Invalid value: conflicting values \"v\" and \"this is not an allowed value\"]") - - const invalidSchemaUid = "invalid-schema-uid" - _, err = helper.DashboardsV1.Resource.Get(ctx, invalidSchemaUid, metav1.GetOptions{}) - require.Error(t, err, "invalid dashboard shouldn't exist") - require.True(t, apierrors.IsNotFound(err)) - - var jobObj *unstructured.Unstructured - require.EventuallyWithT(t, func(collect *assert.CollectT) { - result := helper.AdminREST.Post(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("jobs"). - Body(asJSON(&provisioning.JobSpec{ - Action: provisioning.JobActionPull, - Pull: &provisioning.SyncJobOptions{}, - })). - SetHeader("Content-Type", "application/json"). - Do(t.Context()) - require.NoError(collect, result.Error()) - job, err := result.Get() - require.NoError(collect, err) - var ok bool - jobObj, ok = job.(*unstructured.Unstructured) - assert.True(collect, ok, "expecting unstructured object, but got %T", job) - }, time.Second*10, time.Millisecond*10, "Expected to be able to start a sync job") - - require.EventuallyWithT(t, func(collect *assert.CollectT) { - // helper.TriggerJobProcessing(t) - result, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, - "jobs", string(jobObj.GetUID())) - - if apierrors.IsNotFound(err) { - assert.Fail(collect, "job '%s' not found yet yet", jobObj.GetName()) - return // continue trying - } - - // Can fail fast here -- the jobs are immutable - require.NoError(t, err) - require.NotNil(t, result) - - job := &provisioning.Job{} - err = runtime.DefaultUnstructuredConverter.FromUnstructured(result.Object, job) - require.NoError(t, err, "should convert to Job object") - - assert.Equal(t, provisioning.JobStateError, job.Status.State) - assert.Equal(t, job.Status.Message, "completed with errors") - assert.Equal(t, job.Status.Errors[0], "Dashboard.dashboard.grafana.app \"invalid-schema-uid\" is invalid: [spec.panels.0.repeatDirection: Invalid value: conflicting values \"h\" and \"this is not an allowed value\", spec.panels.0.repeatDirection: Invalid value: conflicting values \"v\" and \"this is not an allowed value\"]") - }, time.Second*10, time.Millisecond*10, "Expected provisioning job to conclude with the status failed") - - _, err = helper.DashboardsV1.Resource.Get(ctx, invalidSchemaUid, metav1.GetOptions{}) - require.Error(t, err, "invalid dashboard shouldn't have been created") - require.True(t, apierrors.IsNotFound(err)) - - err = helper.Repositories.Resource.Delete(ctx, repo, metav1.DeleteOptions{}, "files", "invalid-dashboard-schema.json") - require.NoError(t, err, "should delete the resource file") -} - -func TestIntegrationProvisioning_CreatingGitHubRepository(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - helper := runGrafana(t) - ctx := context.Background() - - // FIXME: instead of using an existing GitHub repository, we should create a new one for the tests and a branch - // This was the previous structure - // ghmock.WithRequestMatchHandler(ghmock.GetReposGitTreesByOwnerByRepoByTreeSha, - // ghHandleTree(t, map[string][]*gh.TreeEntry{ - // "deadbeef": { - // treeEntryDir("grafana", "subtree"), - // }, - // "subtree": { - // treeEntry("dashboard.json", helper.LoadFile("testdata/all-panels.json")), - // treeEntryDir("subdir", "subtree2"), - // treeEntry("subdir/dashboard2.yaml", helper.LoadFile("testdata/text-options.json")), - // }, - // })), - - // FIXME: uncomment these to implement webhook integration tests. - // helper.GetEnv().GitHubFactory.Client = ghmock.NewMockedHTTPClient( - // ghmock.WithRequestMatchHandler(ghmock.GetReposHooksByOwnerByRepo, ghAlwaysWrite(t, []*gh.Hook{})), - // ghmock.WithRequestMatchHandler(ghmock.PostReposHooksByOwnerByRepo, ghAlwaysWrite(t, &gh.Hook{ID: gh.Ptr(int64(123))})), - // ) - - const repo = "github-create-test" - _, err := helper.Repositories.Resource.Create(ctx, - helper.RenderObject(t, "testdata/github-readonly.json.tmpl", map[string]any{ - "Name": repo, - "SyncEnabled": true, - "SyncTarget": "instance", - "Path": "grafana/", - }), - metav1.CreateOptions{}, - ) - require.NoError(t, err) - - helper.SyncAndWait(t, repo, nil) - - // By now, we should have synced, meaning we have data to read in the local Grafana instance! - - found, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) - require.NoError(t, err, "can list values") - - names := []string{} - for _, v := range found.Items { - names = append(names, v.GetName()) - } - require.Len(t, names, 3, "should have three dashboards") - assert.Contains(t, names, "adg5vbj", "should contain dashboard.json's contents") - assert.Contains(t, names, "admfz74", "should contain dashboard2.yaml's contents") - assert.Contains(t, names, "adn5mxb", "should contain dashboard2.yaml's contents") - - err = helper.Repositories.Resource.Delete(ctx, repo, metav1.DeleteOptions{}) - require.NoError(t, err, "should delete values") - - require.EventuallyWithT(t, func(collect *assert.CollectT) { - found, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) - assert.NoError(t, err, "can list values") - assert.Equal(collect, 0, len(found.Items), "expected dashboards to be deleted") - }, time.Second*20, time.Millisecond*10, "Expected dashboards to be deleted") - - t.Run("github url cleanup", func(t *testing.T) { - tests := []struct { - name string - input string - output string - }{ - { - name: "simple-url", - input: "https://github.com/dprokop/grafana-git-sync-test", - output: "https://github.com/dprokop/grafana-git-sync-test", - }, - { - name: "trim-dot-git", - input: "https://github.com/dprokop/grafana-git-sync-test.git", - output: "https://github.com/dprokop/grafana-git-sync-test", - }, - { - name: "trim-slash", - input: "https://github.com/dprokop/grafana-git-sync-test/", - output: "https://github.com/dprokop/grafana-git-sync-test", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - input := helper.RenderObject(t, "testdata/github-readonly.json.tmpl", map[string]any{ - "Name": test.name, - "URL": test.input, - }) - - _, err := helper.Repositories.Resource.Create(ctx, input, metav1.CreateOptions{}) - require.NoError(t, err, "failed to create resource") - - obj, err := helper.Repositories.Resource.Get(ctx, test.name, metav1.GetOptions{}) - require.NoError(t, err, "failed to read back resource") - - url, _, err := unstructured.NestedString(obj.Object, "spec", "github", "url") - require.NoError(t, err, "failed to read URL") - require.Equal(t, test.output, url) - - err = helper.Repositories.Resource.Delete(ctx, test.name, metav1.DeleteOptions{}) - require.NoError(t, err, "failed to delete") - }) - } - }) -} - -func TestIntegrationProvisioning_RunLocalRepository(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - helper := runGrafana(t) - ctx := context.Background() - - const allPanels = "n1jR8vnnz" - const repo = "local-local-examples" - const targetPath = "all-panels.json" - - // Set up the repository. - localTmp := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{"Name": repo}) - obj, err := helper.Repositories.Resource.Create(ctx, localTmp, metav1.CreateOptions{}) - require.NoError(t, err) - name, _, _ := unstructured.NestedString(obj.Object, "metadata", "name") - require.Equal(t, repo, name, "wrote the expected name") - - // Write a file -- this will create it *both* in the local file system, and in grafana - t.Run("write all panels", func(t *testing.T) { - code := 0 - - // Check that we can not (yet) UPDATE the target path - result := helper.AdminREST.Put(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("files", targetPath). - Body(helper.LoadFile("testdata/all-panels.json")). - SetHeader("Content-Type", "application/json"). - Do(ctx).StatusCode(&code) - require.Equal(t, http.StatusNotFound, code) - require.True(t, apierrors.IsNotFound(result.Error())) - - // Now try again with POST (as an editor) - result = helper.EditorREST.Post(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("files", targetPath). - Body(helper.LoadFile("testdata/all-panels.json")). - SetHeader("Content-Type", "application/json"). - Do(ctx).StatusCode(&code) - require.NoError(t, result.Error(), "expecting to be able to create file") - wrapper := &provisioning.ResourceWrapper{} - raw, err := result.Raw() - require.NoError(t, err) - err = json.Unmarshal(raw, wrapper) - require.NoError(t, err) - require.Equal(t, 200, code, "expected 200 response") - require.Equal(t, provisioning.ClassicDashboard, wrapper.Resource.Type.Classic) - name, _, _ := unstructured.NestedString(wrapper.Resource.File.Object, "metadata", "name") - require.Equal(t, allPanels, name, "name from classic UID") - name, _, _ = unstructured.NestedString(wrapper.Resource.Upsert.Object, "metadata", "name") - require.Equal(t, allPanels, name, "save the name from the request") - - // Get the file from the grafana database - obj, err := helper.DashboardsV1.Resource.Get(ctx, allPanels, metav1.GetOptions{}) - require.NoError(t, err, "the value should be saved in grafana") - val, _, _ := unstructured.NestedString(obj.Object, "metadata", "annotations", utils.AnnoKeyManagerKind) - require.Equal(t, string(utils.ManagerKindRepo), val, "should have repo annotations") - val, _, _ = unstructured.NestedString(obj.Object, "metadata", "annotations", utils.AnnoKeyManagerIdentity) - require.Equal(t, repo, val, "should have repo annotations") - - // Read the file we wrote - wrapObj, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", targetPath) - require.NoError(t, err, "read value") - - wrap := &unstructured.Unstructured{} - wrap.Object, _, err = unstructured.NestedMap(wrapObj.Object, "resource", "dryRun") - require.NoError(t, err) - meta, err := utils.MetaAccessor(wrap) - require.NoError(t, err) - require.Equal(t, allPanels, meta.GetName(), "read the name out of the saved file") - - // Check that an admin can update - meta.SetAnnotation("test", "from-provisioning") - body, err := json.Marshal(wrap.Object) - require.NoError(t, err) - result = helper.AdminREST.Put(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("files", targetPath). - Body(body). - SetHeader("Content-Type", "application/json"). - Do(ctx).StatusCode(&code) - require.Equal(t, 200, code) - require.NoError(t, result.Error(), "update as admin value") - raw, err = result.Raw() - require.NoError(t, err) - err = json.Unmarshal(raw, wrapper) - require.NoError(t, err) - anno, _, _ := unstructured.NestedString(wrapper.Resource.File.Object, "metadata", "annotations", "test") - require.Equal(t, "from-provisioning", anno, "should set the annotation") - - // But a viewer can not - result = helper.ViewerREST.Put(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("files", targetPath). - Body(body). - SetHeader("Content-Type", "application/json"). - Do(ctx).StatusCode(&code) - require.Equal(t, 403, code) - require.True(t, apierrors.IsForbidden(result.Error()), code) - }) - - t.Run("fail using invalid paths", func(t *testing.T) { - result := helper.AdminREST.Post(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("files", "test", "..", "..", "all-panels.json"). // UNSAFE PATH - Body(helper.LoadFile("testdata/all-panels.json")). - SetHeader("Content-Type", "application/json"). - Do(ctx) - require.Error(t, result.Error(), "invalid path should return error") - - // Read a file with a bad path - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "../../all-panels.json") - require.Error(t, err, "invalid path should error") - }) - - t.Run("require name or generateName", func(t *testing.T) { - code := 0 - result := helper.AdminREST.Post(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("files", "example.json"). - Body([]byte(`apiVersion: dashboard.grafana.app/v0alpha1 -kind: Dashboard -spec: - title: Test dashboard -`)).Do(ctx).StatusCode(&code) - require.Error(t, result.Error(), "missing name") - - result = helper.AdminREST.Post(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("files", "example.json"). - Body([]byte(`apiVersion: dashboard.grafana.app/v0alpha1 -kind: Dashboard -metadata: - generateName: prefix- -spec: - title: Test dashboard -`)).Do(ctx).StatusCode(&code) - require.NoError(t, result.Error(), "should create name") - require.Equal(t, 200, code, "expect OK result") - - raw, err := result.Raw() - require.NoError(t, err) - - obj := &unstructured.Unstructured{} - err = json.Unmarshal(raw, obj) - require.NoError(t, err) - - name, _, _ = unstructured.NestedString(obj.Object, "resource", "upsert", "metadata", "name") - require.True(t, strings.HasPrefix(name, "prefix-"), "should generate name") - }) -} - -func TestIntegrationProvisioning_ImportAllPanelsFromLocalRepository(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - helper := runGrafana(t) - ctx := context.Background() - - // The dashboard shouldn't exist yet - const allPanels = "n1jR8vnnz" - _, err := helper.DashboardsV1.Resource.Get(ctx, allPanels, metav1.GetOptions{}) - require.Error(t, err, "no all-panels dashboard should exist") - require.True(t, apierrors.IsNotFound(err)) - - const repo = "local-tmp" - // Set up the repository and the file to import. - helper.CopyToProvisioningPath(t, "testdata/all-panels.json", "all-panels.json") - localTmp := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{ - "Name": repo, - "SyncEnabled": true, - }) - - // We create the repository - _, err = helper.Repositories.Resource.Create(ctx, localTmp, metav1.CreateOptions{}) - require.NoError(t, err) - - // Now, we import it, such that it may exist - // The sync may not be necessary as the sync may have happened automatically at this point - helper.SyncAndWait(t, repo, nil) - - // Make sure the repo can read and validate the file - obj, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "all-panels.json") - require.NoError(t, err, "valid path should be fine") - - resource, _, err := unstructured.NestedMap(obj.Object, "resource") - require.NoError(t, err, "missing resource") - require.NoError(t, err, "invalid action") - require.NotNil(t, resource["file"], "the raw file") - require.NotNil(t, resource["dryRun"], "dryRun result") - - action, _, err := unstructured.NestedString(resource, "action") - require.NoError(t, err, "invalid action") - // FIXME: there is no point in in returning action for a read / get request. - require.Equal(t, "update", action) - - _, err = helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) - require.NoError(t, err, "can list values") - - obj, err = helper.DashboardsV1.Resource.Get(ctx, allPanels, metav1.GetOptions{}) - require.NoError(t, err, "all-panels dashboard should exist") - require.Equal(t, repo, obj.GetAnnotations()[utils.AnnoKeyManagerIdentity]) - - // Try writing the value directly - err = unstructured.SetNestedField(obj.Object, []any{"aaa", "bbb"}, "spec", "tags") - require.NoError(t, err, "set tags") - obj, err = helper.DashboardsV1.Resource.Update(ctx, obj, metav1.UpdateOptions{}) - require.NoError(t, err) - v, _, _ := unstructured.NestedString(obj.Object, "metadata", "annotations", utils.AnnoKeyUpdatedBy) - require.Equal(t, "access-policy:provisioning", v) - - // Should not be able to directly delete the managed resource - err = helper.DashboardsV1.Resource.Delete(ctx, allPanels, metav1.DeleteOptions{}) - require.NoError(t, err, "user can delete") - - _, err = helper.DashboardsV1.Resource.Get(ctx, allPanels, metav1.GetOptions{}) - require.Error(t, err, "should delete the internal resource") - require.True(t, apierrors.IsNotFound(err)) -} - -func TestProvisioning_ExportUnifiedToRepository(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - helper := runGrafana(t) - ctx := context.Background() - - // Write dashboards at - dashboard := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v0.yaml") - _, err := helper.DashboardsV0.Resource.Create(ctx, dashboard, metav1.CreateOptions{}) - require.NoError(t, err, "should be able to create v0 dashboard") - - dashboard = helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v1.yaml") - _, err = helper.DashboardsV1.Resource.Create(ctx, dashboard, metav1.CreateOptions{}) - require.NoError(t, err, "should be able to create v1 dashboard") - - dashboard = helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v2alpha1.yaml") - _, err = helper.DashboardsV2alpha1.Resource.Create(ctx, dashboard, metav1.CreateOptions{}) - require.NoError(t, err, "should be able to create v2alpha1 dashboard") - - dashboard = helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v2beta1.yaml") - _, err = helper.DashboardsV2beta1.Resource.Create(ctx, dashboard, metav1.CreateOptions{}) - require.NoError(t, err, "should be able to create v2beta1 dashboard") - - // Now for the repository. - const repo = "local-repository" - createBody := helper.RenderObject(t, "exportunifiedtorepository/repository.json.tmpl", map[string]any{"Name": repo}) - _, err = helper.Repositories.Resource.Create(ctx, createBody, metav1.CreateOptions{}) - require.NoError(t, err, "should be able to create repository") - - // Now export... - result := helper.AdminREST.Post(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("jobs"). - SetHeader("Content-Type", "application/json"). - Body(asJSON(&provisioning.JobSpec{ - Push: &provisioning.ExportJobOptions{ - Folder: "", // export entire instance - Path: "", // no prefix necessary for testing - }, - })). - Do(ctx) - require.NoError(t, result.Error()) - - // And time to assert. - helper.AwaitJobs(t, repo) - - type props struct { - title string - apiVersion string - name string - fileName string - } - - printFileTree(t, helper.ProvisioningPath) - - // Check that each file was exported with its stored version - for _, test := range []props{ - {title: "Test dashboard. Created at v0", apiVersion: "dashboard.grafana.app/v0alpha1", name: "test-v0", fileName: "test-dashboard-created-at-v0.json"}, - {title: "Test dashboard. Created at v1", apiVersion: "dashboard.grafana.app/v1beta1", name: "test-v1", fileName: "test-dashboard-created-at-v1.json"}, - {title: "Test dashboard. Created at v2alpha1", apiVersion: "dashboard.grafana.app/v2alpha1", name: "test-v2alpha1", fileName: "test-dashboard-created-at-v2alpha1.json"}, - {title: "Test dashboard. Created at v2beta1", apiVersion: "dashboard.grafana.app/v2beta1", name: "test-v2beta1", fileName: "test-dashboard-created-at-v2beta1.json"}, - } { - fpath := filepath.Join(helper.ProvisioningPath, test.fileName) - //nolint:gosec // we are ok with reading files in testdata - body, err := os.ReadFile(fpath) - require.NoError(t, err, "exported file was not created at path %s", fpath) - obj := map[string]any{} - err = json.Unmarshal(body, &obj) - require.NoError(t, err, "exported file not json %s", fpath) - - val, _, err := unstructured.NestedString(obj, "apiVersion") - require.NoError(t, err) - require.Equal(t, test.apiVersion, val) - - val, _, err = unstructured.NestedString(obj, "spec", "title") - require.NoError(t, err) - require.Equal(t, test.title, val) - - val, _, err = unstructured.NestedString(obj, "metadata", "name") - require.NoError(t, err) - require.Equal(t, test.name, val) - - require.Nil(t, obj["status"], "should not have a status element") - } -} - -func TestIntegrationProvisioning_DeleteResources(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - helper := runGrafana(t) - ctx := context.Background() - - const repo = "delete-test-repo" - localTmp := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{ - "Name": repo, - "SyncEnabled": true, - "SyncTarget": "instance", - }) - _, err := helper.Repositories.Resource.Create(ctx, localTmp, metav1.CreateOptions{}) - require.NoError(t, err) - - // Copy the dashboards to the repository path - helper.CopyToProvisioningPath(t, "testdata/all-panels.json", "dashboard1.json") - helper.CopyToProvisioningPath(t, "testdata/text-options.json", "folder/dashboard2.json") - helper.CopyToProvisioningPath(t, "testdata/timeline-demo.json", "folder/nested/dashboard3.json") - // make sure we don't fail when there is a .keep file in a folder - helper.CopyToProvisioningPath(t, "testdata/.keep", "folder/nested/.keep") - - // Trigger and wait for a sync job to finish - helper.SyncAndWait(t, repo, nil) - - dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) - require.NoError(t, err) - require.Equal(t, 3, len(dashboards.Items)) - - folders, err := helper.Folders.Resource.List(ctx, metav1.ListOptions{}) - require.NoError(t, err) - require.Equal(t, 2, len(folders.Items)) - - t.Run("delete individual dashboard file, should delete from repo and grafana", func(t *testing.T) { - result := helper.AdminREST.Delete(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("files", "dashboard1.json"). - Do(ctx) - require.NoError(t, result.Error()) - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "dashboard1.json") - require.Error(t, err) - dashboards, err = helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) - require.NoError(t, err) - require.Equal(t, 2, len(dashboards.Items)) - }) - - t.Run("delete folder, should delete from repo and grafana all nested resources too", func(t *testing.T) { - // need to delete directly through the url, because the k8s client doesn't support `/` in a subresource - // but that is needed by gitsync to know that it is a folder - addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String() - url := fmt.Sprintf("http://admin:admin@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/folder/", addr, repo) - req, err := http.NewRequest(http.MethodDelete, url, nil) - require.NoError(t, err) - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - // nolint:errcheck - defer resp.Body.Close() - require.Equal(t, http.StatusOK, resp.StatusCode) - - // should be deleted from the repo - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder") - require.Error(t, err) - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "dashboard2.json") - require.Error(t, err) - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "nested") - require.Error(t, err) - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "nested", "dashboard3.json") - require.Error(t, err) - - // all should be deleted from grafana - for _, d := range dashboards.Items { - _, err = helper.DashboardsV1.Resource.Get(ctx, d.GetName(), metav1.GetOptions{}) - require.Error(t, err) - } - for _, f := range folders.Items { - _, err = helper.Folders.Resource.Get(ctx, f.GetName(), metav1.GetOptions{}) - require.Error(t, err) - } - }) - - t.Run("deleting a non-existent file should fail", func(t *testing.T) { - result := helper.AdminREST.Delete(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("files", "non-existent.json"). - Do(ctx) - require.Error(t, result.Error()) - }) -} - -func TestIntegrationProvisioning_DeleteJob(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - helper := runGrafana(t) - ctx := context.Background() - - const repo = "delete-job-test-repo" - localTmp := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{ - "Name": repo, - "SyncEnabled": true, - "SyncTarget": "instance", - }) - _, err := helper.Repositories.Resource.Create(ctx, localTmp, metav1.CreateOptions{}) - require.NoError(t, err) - // Copy multiple test files to the repository - helper.CopyToProvisioningPath(t, "testdata/all-panels.json", "dashboard1.json") - helper.CopyToProvisioningPath(t, "testdata/text-options.json", "dashboard2.json") - helper.CopyToProvisioningPath(t, "testdata/timeline-demo.json", "folder/dashboard3.json") - - // Trigger and wait for initial sync to populate resources - helper.SyncAndWait(t, repo, nil) - - // Verify initial state - should have 3 dashboards and 1 folder - dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) - require.NoError(t, err) - require.Equal(t, 3, len(dashboards.Items), "should have 3 dashboards after sync") - - folders, err := helper.Folders.Resource.List(ctx, metav1.ListOptions{}) - require.NoError(t, err) - require.Equal(t, 1, len(folders.Items), "should have 1 folder after sync") - - t.Run("delete single file", func(t *testing.T) { - // Create delete job for single file - result := helper.AdminREST.Post(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("jobs"). - Body(asJSON(&provisioning.JobSpec{ - Action: provisioning.JobActionDelete, - Delete: &provisioning.DeleteJobOptions{ - Paths: []string{"dashboard1.json"}, - }, - })). - SetHeader("Content-Type", "application/json"). - Do(ctx) - require.NoError(t, result.Error(), "should be able to create delete job") - - // Wait for job to complete - helper.AwaitJobs(t, repo) - - // Verify file is deleted from repository - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "dashboard1.json") - require.Error(t, err, "file should be deleted from repository") - require.True(t, apierrors.IsNotFound(err), "should be not found error") - - // Verify dashboard is removed from Grafana after sync - dashboards, err = helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) - require.NoError(t, err) - require.Equal(t, 2, len(dashboards.Items), "should have 2 dashboards after delete") - - // Verify other files still exist - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "dashboard2.json") - require.NoError(t, err, "other files should still exist") - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "dashboard3.json") - require.NoError(t, err, "nested files should still exist") - }) - - t.Run("delete multiple files", func(t *testing.T) { - // Create delete job for multiple files - result := helper.AdminREST.Post(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("jobs"). - Body(asJSON(&provisioning.JobSpec{ - Action: provisioning.JobActionDelete, - Delete: &provisioning.DeleteJobOptions{ - Paths: []string{"dashboard2.json", "folder/dashboard3.json"}, - }, - })). - SetHeader("Content-Type", "application/json"). - Do(ctx) - require.NoError(t, result.Error(), "should be able to create delete job") - - // Wait for job to complete - helper.AwaitJobs(t, repo) - - // Verify files are deleted from repository - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "dashboard2.json") - require.Error(t, err, "dashboard2.json should be deleted") - require.True(t, apierrors.IsNotFound(err)) - - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "dashboard3.json") - require.Error(t, err, "folder/dashboard3.json should be deleted") - require.True(t, apierrors.IsNotFound(err)) - - // Verify all dashboards are removed from Grafana after sync - dashboards, err = helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) - require.NoError(t, err) - require.Equal(t, 0, len(dashboards.Items), "should have 0 dashboards after deleting all") - }) - - t.Run("delete by resource reference", func(t *testing.T) { - // Create modified test files with unique UIDs for ResourceRef testing - // Read and modify the testdata files to have unique UIDs that don't conflict with existing resources - allPanelsContent := helper.LoadFile("testdata/all-panels.json") - textOptionsContent := helper.LoadFile("testdata/text-options.json") - timelineDemoContent := helper.LoadFile("testdata/timeline-demo.json") - - // Modify UIDs to be unique for ResourceRef tests - allPanelsModified := strings.Replace(string(allPanelsContent), `"uid": "n1jR8vnnz"`, `"uid": "resourceref1"`, 1) - textOptionsModified := strings.Replace(string(textOptionsContent), `"uid": "WZ7AhQiVz"`, `"uid": "resourceref2"`, 1) - timelineDemoModified := strings.Replace(string(timelineDemoContent), `"uid": "mIJjFy8Kz"`, `"uid": "resourceref3"`, 1) - - // Create temporary files and copy them to the provisioning path - tmpDir := t.TempDir() - tmpFile1 := filepath.Join(tmpDir, "resource-test-1.json") - tmpFile2 := filepath.Join(tmpDir, "resource-test-2.json") - tmpFile3 := filepath.Join(tmpDir, "resource-test-3.json") - - require.NoError(t, os.WriteFile(tmpFile1, []byte(allPanelsModified), 0644)) - require.NoError(t, os.WriteFile(tmpFile2, []byte(textOptionsModified), 0644)) - require.NoError(t, os.WriteFile(tmpFile3, []byte(timelineDemoModified), 0644)) - - // Copy the temporary files to the provisioning path - helper.CopyToProvisioningPath(t, tmpFile1, "resource-test-1.json") // UID: resourceref1 - helper.CopyToProvisioningPath(t, tmpFile2, "resource-test-2.json") // UID: resourceref2 - helper.CopyToProvisioningPath(t, tmpFile3, "nested/resource-test-3.json") // UID: resourceref3 - - // Trigger sync to populate the new resources - helper.SyncAndWait(t, repo, nil) - - // Verify the new resources are created - dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) - require.NoError(t, err) - require.GreaterOrEqual(t, len(dashboards.Items), 3, "should have at least 3 dashboards after adding test resources") - - // Debug: print the actual dashboard names/UIDs to verify they match our expectations - for i, dashboard := range dashboards.Items { - t.Logf("Dashboard %d: name=%s, UID=%s", i+1, dashboard.GetName(), dashboard.GetUID()) - } - - t.Run("delete single dashboard by resource reference", func(t *testing.T) { - // Create delete job for single dashboard using ResourceRef - result := helper.AdminREST.Post(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("jobs"). - Body(asJSON(&provisioning.JobSpec{ - Action: provisioning.JobActionDelete, - Delete: &provisioning.DeleteJobOptions{ - Resources: []provisioning.ResourceRef{ - { - Name: "resourceref1", // UID from modified all-panels.json - Kind: "Dashboard", - Group: "dashboard.grafana.app", - }, - }, - }, - })). - SetHeader("Content-Type", "application/json"). - Do(ctx) - require.NoError(t, result.Error(), "should be able to create delete job with ResourceRef") - - // Wait for job to complete - helper.AwaitJobs(t, repo) - - // Verify corresponding file is deleted from repository - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "resource-test-1.json") - require.Error(t, err, "file should be deleted from repository") - require.True(t, apierrors.IsNotFound(err), "should be not found error") - - // Verify dashboard is removed from Grafana (check count like other successful tests) - dashboards, err = helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) - require.NoError(t, err) - require.Equal(t, 2, len(dashboards.Items), "should have 2 dashboards after deleting 1 from 3") - - // Verify other resources still exist - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "resource-test-2.json") - require.NoError(t, err, "other files should still exist") - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "nested", "resource-test-3.json") - require.NoError(t, err, "nested files should still exist") - }) - - t.Run("delete multiple resources by reference", func(t *testing.T) { - // Create delete job for multiple resources using ResourceRef - result := helper.AdminREST.Post(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("jobs"). - Body(asJSON(&provisioning.JobSpec{ - Action: provisioning.JobActionDelete, - Delete: &provisioning.DeleteJobOptions{ - Resources: []provisioning.ResourceRef{ - { - Name: "resourceref2", // UID from modified text-options.json - Kind: "Dashboard", - Group: "dashboard.grafana.app", - }, - { - Name: "resourceref3", // UID from modified timeline-demo.json - Kind: "Dashboard", - Group: "dashboard.grafana.app", - }, - }, - }, - })). - SetHeader("Content-Type", "application/json"). - Do(ctx) - require.NoError(t, result.Error(), "should be able to create delete job with multiple ResourceRefs") - - // Wait for job to complete - helper.AwaitJobs(t, repo) - - // Verify both dashboards are removed from Grafana - _, err = helper.DashboardsV1.Resource.Get(ctx, "resourceref2", metav1.GetOptions{}) - require.Error(t, err, "text-options dashboard should be deleted") - require.True(t, apierrors.IsNotFound(err)) - - _, err = helper.DashboardsV1.Resource.Get(ctx, "resourceref3", metav1.GetOptions{}) - require.Error(t, err, "timeline-demo dashboard should be deleted") - require.True(t, apierrors.IsNotFound(err)) - - // Verify corresponding files are deleted from repository - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "resource-test-2.json") - require.Error(t, err, "resource-test-2.json should be deleted") - - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "nested", "resource-test-3.json") - require.Error(t, err, "nested/resource-test-3.json should be deleted") - - // Verify specific dashboards are removed from Grafana - dashboards, err = helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) - require.NoError(t, err) - require.Equal(t, 0, len(dashboards.Items), "should have 0 dashboards after deleting 2 more (2 -> 0)") - }) - - t.Run("mixed deletion - paths and resources", func(t *testing.T) { - // Setup fresh resources for mixed test - reuse the modified content with unique UIDs - tmpMixed1 := filepath.Join(tmpDir, "mixed-test-1.json") - tmpMixed2 := filepath.Join(tmpDir, "mixed-test-2.json") - tmpMixed3 := filepath.Join(tmpDir, "mixed-test-3.json") - - require.NoError(t, os.WriteFile(tmpMixed1, []byte(allPanelsModified), 0644)) - require.NoError(t, os.WriteFile(tmpMixed2, []byte(textOptionsModified), 0644)) - require.NoError(t, os.WriteFile(tmpMixed3, []byte(timelineDemoModified), 0644)) - - helper.CopyToProvisioningPath(t, tmpMixed1, "mixed-test-1.json") // UID: resourceref1 - helper.CopyToProvisioningPath(t, tmpMixed2, "mixed-test-2.json") // UID: resourceref2 - helper.CopyToProvisioningPath(t, tmpMixed3, "mixed-test-3.json") // UID: resourceref3 - - helper.SyncAndWait(t, repo, nil) - - // Create delete job that combines both paths and resource references - result := helper.AdminREST.Post(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("jobs"). - Body(asJSON(&provisioning.JobSpec{ - Action: provisioning.JobActionDelete, - Delete: &provisioning.DeleteJobOptions{ - Paths: []string{"mixed-test-1.json"}, // Delete by path - Resources: []provisioning.ResourceRef{ - { - Name: "resourceref2", // Delete by resource reference - Kind: "Dashboard", - Group: "dashboard.grafana.app", - }, - }, - }, - })). - SetHeader("Content-Type", "application/json"). - Do(ctx) - require.NoError(t, result.Error(), "should be able to create mixed delete job") - - // Wait for job to complete - helper.AwaitJobs(t, repo) - - // Verify both targeted resources are deleted from Grafana - _, err = helper.DashboardsV1.Resource.Get(ctx, "resourceref1", metav1.GetOptions{}) - require.Error(t, err, "dashboard deleted by path should be removed") - - _, err = helper.DashboardsV1.Resource.Get(ctx, "resourceref2", metav1.GetOptions{}) - require.Error(t, err, "dashboard deleted by resource ref should be removed") - - // Verify the untargeted resource still exists - _, err = helper.DashboardsV1.Resource.Get(ctx, "resourceref3", metav1.GetOptions{}) - require.NoError(t, err, "untargeted dashboard should still exist") - - // Verify files are properly deleted/preserved in repository - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "mixed-test-1.json") - require.Error(t, err, "file deleted by path should be removed") - - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "mixed-test-2.json") - require.Error(t, err, "file for resource deleted by ref should be removed") - - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "mixed-test-3.json") - require.NoError(t, err, "untargeted file should still exist") - }) - - t.Run("delete folder by resource reference", func(t *testing.T) { - // Create a dashboard inside a folder to automatically create the folder structure - // This follows the same pattern as other tests in this file - testDashboard := strings.Replace(string(allPanelsContent), `"uid": "n1jR8vnnz"`, `"uid": "folder-dash"`, 1) - - // Write the modified dashboard to a temporary file first - tmpFolderDash := filepath.Join(tmpDir, "folder-dashboard.json") - require.NoError(t, os.WriteFile(tmpFolderDash, []byte(testDashboard), 0644)) - - // Copy it to the folder structure using the helper - helper.CopyToProvisioningPath(t, tmpFolderDash, "test-folder/dashboard-in-folder.json") - - // Sync to create the folder and its contents - helper.SyncAndWait(t, repo, nil) - - // Verify folder was created in Grafana as a Folder resource - folders, err := helper.Folders.Resource.List(ctx, metav1.ListOptions{}) - require.NoError(t, err) - - var testFolder *unstructured.Unstructured - for _, folder := range folders.Items { - // Folder names are generated with suffixes, so check if it starts with "test-folder" - if strings.HasPrefix(folder.GetName(), "test-folder") { - testFolder = &folder - break - } - } - require.NotNil(t, testFolder, "test-folder should exist as a Folder resource") - testFolderName := testFolder.GetName() - - // Verify dashboard inside the folder exists - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "test-folder", "dashboard-in-folder.json") - require.NoError(t, err, "dashboard inside folder should exist") - - // Create delete job for the folder using ResourceRef (use the actual generated name) - result := helper.AdminREST.Post(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("jobs"). - Body(asJSON(&provisioning.JobSpec{ - Action: provisioning.JobActionDelete, - Delete: &provisioning.DeleteJobOptions{ - Resources: []provisioning.ResourceRef{ - { - Name: testFolderName, // Use the actual generated folder name - Kind: "Folder", - Group: "folder.grafana.app", - }, - }, - }, - })). - SetHeader("Content-Type", "application/json"). - Do(ctx) - require.NoError(t, result.Error(), "should be able to create delete job for folder") - - // Wait for job to complete - helper.AwaitJobs(t, repo) - - // Verify folder is deleted from Grafana - _, err = helper.Folders.Resource.Get(ctx, testFolderName, metav1.GetOptions{}) - require.Error(t, err, "folder should be deleted from Grafana") - require.True(t, apierrors.IsNotFound(err), "should be not found error") - - // Verify folder contents are also deleted from repository - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "test-folder", "dashboard-in-folder.json") - require.Error(t, err, "dashboard inside deleted folder should also be deleted") - require.True(t, apierrors.IsNotFound(err), "should be not found error") - }) - - t.Run("delete non-existent resource by reference", func(t *testing.T) { - // Create delete job for non-existent resource - result := helper.AdminREST.Post(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("jobs"). - Body(asJSON(&provisioning.JobSpec{ - Action: provisioning.JobActionDelete, - Delete: &provisioning.DeleteJobOptions{ - Resources: []provisioning.ResourceRef{ - { - Name: "non-existent-uid", - Kind: "Dashboard", - Group: "dashboard.grafana.app", - }, - }, - }, - })). - SetHeader("Content-Type", "application/json"). - Do(ctx) - require.NoError(t, result.Error(), "should be able to create delete job") - - // Wait for job to complete - should record error but continue - require.EventuallyWithT(t, func(collect *assert.CollectT) { - list := &unstructured.UnstructuredList{} - err := helper.AdminREST.Get(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("jobs"). - Do(ctx).Into(list) - assert.NoError(collect, err, "should be able to list jobs") - assert.NotEmpty(collect, list.Items, "expect at least one job") - - // Find the most recent delete job - var deleteJob *unstructured.Unstructured - for _, elem := range list.Items { - assert.Equal(collect, repo, elem.GetLabels()["provisioning.grafana.app/repository"], "should have repo label") - - action := mustNestedString(elem.Object, "spec", "action") - if action == "delete" { - // Get the most recent one (they should be ordered by creation time) - deleteJob = &elem - } - } - if !assert.NotNil(collect, deleteJob, "should find a delete job") { - return - } - - state := mustNestedString(deleteJob.Object, "status", "state") - // The job should complete but record errors for individual resource resolution failures - if state == "error" || state == "completed" || state == "success" { - // Any of these states is acceptable - the key is that resource resolution errors are recorded - // and don't fail the entire job due to error-tolerant implementation - return - } - assert.Fail(collect, "job should complete or error, but got state: %s", state) - }, time.Second*10, time.Millisecond*100, "Expected delete job to handle non-existent resource") - }) - - // Repository cleanup is handled by the main test function - }) - - t.Run("delete non-existent file", func(t *testing.T) { - // Create delete job for non-existent file - result := helper.AdminREST.Post(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("jobs"). - Body(asJSON(&provisioning.JobSpec{ - Action: provisioning.JobActionDelete, - Delete: &provisioning.DeleteJobOptions{ - Paths: []string{"non-existent.json"}, - }, - })). - SetHeader("Content-Type", "application/json"). - Do(ctx) - require.NoError(t, result.Error(), "should be able to create delete job") - - // Wait for job to complete - should fail due to strict error handling - require.EventuallyWithT(t, func(collect *assert.CollectT) { - list := &unstructured.UnstructuredList{} - err := helper.AdminREST.Get(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("jobs"). - Do(ctx).Into(list) - assert.NoError(collect, err, "should be able to list jobs") - assert.NotEmpty(collect, list.Items, "expect at least one job") - - // Find the delete job specifically - var deleteJob *unstructured.Unstructured - for _, elem := range list.Items { - assert.Equal(collect, repo, elem.GetLabels()["provisioning.grafana.app/repository"], "should have repo label") - - action := mustNestedString(elem.Object, "spec", "action") - if action == "delete" { - deleteJob = &elem - break - } - } - assert.NotNil(collect, deleteJob, "should find a delete job") - - state := mustNestedString(deleteJob.Object, "status", "state") - assert.Equal(collect, "error", state, "delete job should have failed due to non-existent file") - }, time.Second*10, time.Millisecond*100, "Expected delete job to fail with error state") - }) -} - -func TestIntegrationProvisioning_MoveJob(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - helper := runGrafana(t) - ctx := context.Background() - const repo = "move-test-repo" - localTmp := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{ - "Name": repo, - "SyncEnabled": true, - "SyncTarget": "instance", - }) - _, err := helper.Repositories.Resource.Create(ctx, localTmp, metav1.CreateOptions{}) - require.NoError(t, err) - // Copy multiple test files to the repository - helper.CopyToProvisioningPath(t, "testdata/all-panels.json", "dashboard1.json") - helper.CopyToProvisioningPath(t, "testdata/text-options.json", "dashboard2.json") - helper.CopyToProvisioningPath(t, "testdata/timeline-demo.json", "folder/dashboard3.json") - // Trigger and wait for initial sync to populate resources - helper.SyncAndWait(t, repo, nil) - // Verify initial state - should have 3 dashboards and 1 folder - dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) - require.NoError(t, err) - require.Equal(t, 3, len(dashboards.Items), "should have 3 dashboards after sync") - folders, err := helper.Folders.Resource.List(ctx, metav1.ListOptions{}) - require.NoError(t, err) - require.Equal(t, 1, len(folders.Items), "should have 1 folder after sync") - - t.Run("move single file", func(t *testing.T) { - // Create move job for single file - result := helper.AdminREST.Post(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("jobs"). - Body(asJSON(&provisioning.JobSpec{ - Action: provisioning.JobActionMove, - Move: &provisioning.MoveJobOptions{ - Paths: []string{"dashboard1.json"}, - TargetPath: "moved/", - }, - })). - SetHeader("Content-Type", "application/json"). - Do(ctx) - require.NoError(t, result.Error(), "should be able to create move job") - - raw, err := result.Raw() - require.NoError(t, err) - - obj := &unstructured.Unstructured{} - err = json.Unmarshal(raw, obj) - require.NoError(t, err) - - // Wait for job to complete - helper.AwaitJobSuccess(t, ctx, obj) - - // TODO: This additional sync should not be necessary - the move job should handle sync properly - helper.SyncAndWait(t, repo, nil) - - // Verify file is moved in repository - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "moved", "dashboard1.json") - require.NoError(t, err, "file should exist at new location in repository") - - // Verify original file is gone from repository - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "dashboard1.json") - require.Error(t, err, "original file should be gone from repository") - require.True(t, apierrors.IsNotFound(err), "should be not found error") - - // Verify other files still exist at original locations - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "dashboard2.json") - require.NoError(t, err, "other files should still exist") - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "dashboard3.json") - require.NoError(t, err, "nested files should still exist") - - // Verify dashboard still exists in Grafana after sync - // Use eventually to let unified storage reflect the changes in dashboards. - require.EventuallyWithT(t, func(collect *assert.CollectT) { - dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) - assert.NoError(collect, err) - assert.Len(collect, dashboards.Items, 3, "should still have 3 dashboards after move") - // Verify that dashboards have the correct source paths - foundPaths := make(map[string]bool) - for _, dashboard := range dashboards.Items { - sourcePath := dashboard.GetAnnotations()["grafana.app/sourcePath"] - foundPaths[sourcePath] = true - } - - assert.True(t, foundPaths["moved/dashboard1.json"], "should have dashboard with moved source path") - assert.True(t, foundPaths["dashboard2.json"], "should have dashboard2 in original location") - assert.True(t, foundPaths["folder/dashboard3.json"], "should have dashboard3 in original nested location") - }, time.Second*10, time.Millisecond*100, "Expected to eventually have 3 dashboards after move") - }) - - t.Run("move multiple files and folder", func(t *testing.T) { - // Create move job for multiple files including a folder - result := helper.AdminREST.Post(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("jobs"). - Body(asJSON(&provisioning.JobSpec{ - Action: provisioning.JobActionMove, - Move: &provisioning.MoveJobOptions{ - Paths: []string{"dashboard2.json", "folder/"}, - TargetPath: "archived/", - }, - })). - SetHeader("Content-Type", "application/json"). - Do(ctx) - require.NoError(t, result.Error(), "should be able to create move job") - - raw, err := result.Raw() - require.NoError(t, err) - obj := &unstructured.Unstructured{} - err = json.Unmarshal(raw, obj) - require.NoError(t, err) - - // Wait for job to complete - helper.AwaitJobSuccess(t, ctx, obj) - - // TODO: This additional sync should not be necessary - the move job should handle sync properly - helper.SyncAndWait(t, repo, nil) - - // Verify files are moved in repository - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "archived", "dashboard2.json") - require.NoError(t, err, "dashboard2.json should exist at new location") - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "archived", "folder", "dashboard3.json") - require.NoError(t, err, "folder/dashboard3.json should exist at new nested location") - - // Verify original files are gone from repository - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "dashboard2.json") - require.Error(t, err, "dashboard2.json should be gone from original location") - require.True(t, apierrors.IsNotFound(err)) - - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "dashboard3.json") - require.Error(t, err, "folder should be gone from original location") - require.True(t, apierrors.IsNotFound(err), err.Error()) - - // Verify dashboards still exist in Grafana after sync - // Note: Since dashboard1.json was moved in the previous test, we now expect all 3 dashboards - // to be accessible from their moved locations (dashboard1 from moved/, dashboard2 and dashboard3 from archived/) - dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) - require.NoError(t, err) - require.Len(t, dashboards.Items, 3, "should still have 3 dashboards after move") - - // Verify that dashboards have the correct source paths after cumulative moves - foundPaths := make(map[string]bool) - for _, dashboard := range dashboards.Items { - sourcePath := dashboard.GetAnnotations()["grafana.app/sourcePath"] - foundPaths[sourcePath] = true - } - - require.True(t, foundPaths["moved/dashboard1.json"], "should have dashboard1 from first move") - require.True(t, foundPaths["archived/dashboard2.json"], "should have dashboard2 in archived location") - require.True(t, foundPaths["archived/folder/dashboard3.json"], "should have dashboard3 in archived nested location") - }) - - t.Run("move non-existent file", func(t *testing.T) { - // Create move job for non-existent file - result := helper.AdminREST.Post(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("jobs"). - Body(asJSON(&provisioning.JobSpec{ - Action: provisioning.JobActionMove, - Move: &provisioning.MoveJobOptions{ - Paths: []string{"non-existent.json"}, - TargetPath: "moved/", - }, - })). - SetHeader("Content-Type", "application/json"). - Do(ctx) - require.NoError(t, result.Error(), "should be able to create move job") - - // Wait for job to complete - should fail due to strict error handling - require.EventuallyWithT(t, func(collect *assert.CollectT) { - list := &unstructured.UnstructuredList{} - err := helper.AdminREST.Get(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("jobs"). - Do(ctx).Into(list) - assert.NoError(collect, err, "should be able to list jobs") - assert.NotEmpty(collect, list.Items, "expect at least one job") - - // Find the move job specifically - var moveJob *unstructured.Unstructured - for _, elem := range list.Items { - assert.Equal(collect, repo, elem.GetLabels()["provisioning.grafana.app/repository"], "should have repo label") - - action := mustNestedString(elem.Object, "spec", "action") - if action == "move" { - // Check if this is the specific job we're looking for - paths, found, err := unstructured.NestedStringSlice(elem.Object, "spec", "move", "paths") - if err == nil && found && len(paths) > 0 && paths[0] == "non-existent.json" { - moveJob = &elem - break - } - } - } - assert.NotNil(collect, moveJob, "should find a move job for non-existent file") - - state := mustNestedString(moveJob.Object, "status", "state") - assert.Equal(collect, "error", state, "move job should have failed due to non-existent file") - }, time.Second*10, time.Millisecond*100, "Expected move job to fail with error state") - }) - - t.Run("move without target path", func(t *testing.T) { - // Create move job without target path (should fail) - result := helper.AdminREST.Post(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("jobs"). - Body(asJSON(&provisioning.JobSpec{ - Action: provisioning.JobActionMove, - Move: &provisioning.MoveJobOptions{ - Paths: []string{"moved/dashboard1.json"}, - // TargetPath intentionally omitted - }, - })). - SetHeader("Content-Type", "application/json"). - Do(ctx) - require.NoError(t, result.Error(), "should be able to create move job") - - // Wait for job to complete - should fail due to missing target path - require.EventuallyWithT(t, func(collect *assert.CollectT) { - list := &unstructured.UnstructuredList{} - err := helper.AdminREST.Get(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("jobs"). - Do(ctx).Into(list) - assert.NoError(collect, err, "should be able to list jobs") - assert.NotEmpty(collect, list.Items, "expect at least one job") - - // Find the move job specifically - var moveJob *unstructured.Unstructured - for _, elem := range list.Items { - assert.Equal(collect, repo, elem.GetLabels()["provisioning.grafana.app/repository"], "should have repo label") - - action := mustNestedString(elem.Object, "spec", "action") - if action == "move" { - // Check if this is the job without target path - targetPath, found, _ := unstructured.NestedString(elem.Object, "spec", "move", "targetPath") - if !found || targetPath == "" { - moveJob = &elem - break - } - } - } - assert.NotNil(collect, moveJob, "should find a move job without target path") - - state := mustNestedString(moveJob.Object, "status", "state") - assert.Equal(collect, "error", state, "move job should have failed due to missing target path") - }, time.Second*10, time.Millisecond*100, "Expected move job to fail with error state") - }) - - t.Run("move by resource reference", func(t *testing.T) { - // Create a unique repository for resource reference testing to avoid contamination - const refRepo = "move-ref-test-repo" - localRefTmp := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{ - "Name": refRepo, - "SyncEnabled": true, - "SyncTarget": "folder", - }) - _, err := helper.Repositories.Resource.Create(ctx, localRefTmp, metav1.CreateOptions{}) - require.NoError(t, err) - - // Create modified test files with unique UIDs for ResourceRef testing - allPanelsContent := helper.LoadFile("testdata/all-panels.json") - textOptionsContent := helper.LoadFile("testdata/text-options.json") - timelineDemoContent := helper.LoadFile("testdata/timeline-demo.json") - - // Modify UIDs to be unique for ResourceRef tests - allPanelsModified := strings.Replace(string(allPanelsContent), `"uid": "n1jR8vnnz"`, `"uid": "moveref1"`, 1) - textOptionsModified := strings.Replace(string(textOptionsContent), `"uid": "WZ7AhQiVz"`, `"uid": "moveref2"`, 1) - timelineDemoModified := strings.Replace(string(timelineDemoContent), `"uid": "mIJjFy8Kz"`, `"uid": "moveref3"`, 1) - - // Create temporary files and copy them to the provisioning path - tmpDir := t.TempDir() - tmpFile1 := filepath.Join(tmpDir, "move-ref-test-1.json") - tmpFile2 := filepath.Join(tmpDir, "move-ref-test-2.json") - tmpFile3 := filepath.Join(tmpDir, "move-ref-test-3.json") - - require.NoError(t, os.WriteFile(tmpFile1, []byte(allPanelsModified), 0644)) - require.NoError(t, os.WriteFile(tmpFile2, []byte(textOptionsModified), 0644)) - require.NoError(t, os.WriteFile(tmpFile3, []byte(timelineDemoModified), 0644)) - - // Copy files to provisioning path to set up test - use refRepo's path - helper.CopyToProvisioningPath(t, tmpFile1, "move-source-1.json") - helper.CopyToProvisioningPath(t, tmpFile2, "move-source-2.json") - helper.CopyToProvisioningPath(t, tmpFile3, "move-source-3.json") - - // Sync to populate resources in Grafana - helper.SyncAndWait(t, refRepo, nil) - - t.Run("move single dashboard by resource reference", func(t *testing.T) { - // Create move job for single dashboard using ResourceRef - result := helper.AdminREST.Post(). - Namespace("default"). - Resource("repositories"). - Name(refRepo). - SubResource("jobs"). - Body(asJSON(&provisioning.JobSpec{ - Action: provisioning.JobActionMove, - Move: &provisioning.MoveJobOptions{ - TargetPath: "moved-by-ref/", - Resources: []provisioning.ResourceRef{ - { - Name: "moveref1", // UID from modified all-panels.json - Kind: "Dashboard", - Group: "dashboard.grafana.app", - }, - }, - }, - })). - SetHeader("Content-Type", "application/json"). - Do(ctx) - require.NoError(t, result.Error(), "should be able to create move job with ResourceRef") - - // Wait for job to complete - helper.AwaitJobs(t, refRepo) - - // Verify corresponding file is moved in repository - _, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "moved-by-ref", "move-source-1.json") - require.NoError(t, err, "file should be moved to new location in repository") - - // Verify original file is deleted from repository - _, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "move-source-1.json") - require.Error(t, err, "original file should be deleted from repository") - require.True(t, apierrors.IsNotFound(err), "should be not found error") - - // Verify dashboard still exists in Grafana (should be updated after sync) - helper.SyncAndWait(t, refRepo, nil) - _, err = helper.DashboardsV1.Resource.Get(ctx, "moveref1", metav1.GetOptions{}) - require.NoError(t, err, "dashboard should still exist in Grafana after move") - - // Verify other resource still exists in original location - _, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "move-source-2.json") - require.NoError(t, err, "other files should still exist in original location") - }) - - t.Run("move multiple resources by reference", func(t *testing.T) { - // Create move job for remaining resource using ResourceRef - result := helper.AdminREST.Post(). - Namespace("default"). - Resource("repositories"). - Name(refRepo). - SubResource("jobs"). - Body(asJSON(&provisioning.JobSpec{ - Action: provisioning.JobActionMove, - Move: &provisioning.MoveJobOptions{ - TargetPath: "archived-by-ref/", - Resources: []provisioning.ResourceRef{ - { - Name: "moveref2", // UID from modified text-options.json - Kind: "Dashboard", - Group: "dashboard.grafana.app", - }, - }, - }, - })). - SetHeader("Content-Type", "application/json"). - Do(ctx) - require.NoError(t, result.Error(), "should be able to create move job with ResourceRef") - - // Wait for job to complete - helper.AwaitJobs(t, refRepo) - - // Verify file is moved in repository - _, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "archived-by-ref", "move-source-2.json") - require.NoError(t, err, "file should be moved to new location") - - // Verify original file is deleted from repository - _, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "move-source-2.json") - require.Error(t, err, "original file should be deleted from repository") - require.True(t, apierrors.IsNotFound(err), "should be not found error") - - // Verify dashboard still exists in Grafana after sync - helper.SyncAndWait(t, refRepo, nil) - _, err = helper.DashboardsV1.Resource.Get(ctx, "moveref2", metav1.GetOptions{}) - require.NoError(t, err, "dashboard should still exist in Grafana after move") - }) - - t.Run("mixed move - paths and resources", func(t *testing.T) { - // Setup fresh resources for mixed test - tmpMixed1 := filepath.Join(tmpDir, "mixed-move-1.json") - tmpMixed2 := filepath.Join(tmpDir, "mixed-move-2.json") - - allPanelsMixed := strings.Replace(string(allPanelsContent), `"uid": "n1jR8vnnz"`, `"uid": "mixedmove1"`, 1) - textOptionsMixed := strings.Replace(string(textOptionsContent), `"uid": "WZ7AhQiVz"`, `"uid": "mixedmove2"`, 1) - - require.NoError(t, os.WriteFile(tmpMixed1, []byte(allPanelsMixed), 0644)) - require.NoError(t, os.WriteFile(tmpMixed2, []byte(textOptionsMixed), 0644)) - - helper.CopyToProvisioningPath(t, tmpMixed1, "mixed-move-1.json") // UID: mixedmove1 - helper.CopyToProvisioningPath(t, tmpMixed2, "mixed-move-2.json") // UID: mixedmove2 - - helper.SyncAndWait(t, refRepo, nil) - - // Create move job that combines both paths and resource references - result := helper.AdminREST.Post(). - Namespace("default"). - Resource("repositories"). - Name(refRepo). - SubResource("jobs"). - Body(asJSON(&provisioning.JobSpec{ - Action: provisioning.JobActionMove, - Move: &provisioning.MoveJobOptions{ - TargetPath: "mixed-target/", - Paths: []string{"mixed-move-1.json"}, // Move by path - Resources: []provisioning.ResourceRef{ - { - Name: "mixedmove2", // Move by resource reference - Kind: "Dashboard", - Group: "dashboard.grafana.app", - }, - }, - }, - })). - SetHeader("Content-Type", "application/json"). - Do(ctx) - require.NoError(t, result.Error(), "should be able to create mixed move job") - - // Wait for job to complete - helper.AwaitJobs(t, refRepo) - - // Verify both targeted resources are moved in repository - _, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "mixed-target", "mixed-move-1.json") - require.NoError(t, err, "file moved by path should exist at new location") - - _, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "mixed-target", "mixed-move-2.json") - require.NoError(t, err, "file moved by resource ref should exist at new location") - - // Verify files are deleted from original locations - _, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "mixed-move-1.json") - require.Error(t, err, "file moved by path should be deleted from original location") - - _, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "mixed-move-2.json") - require.Error(t, err, "file moved by resource ref should be deleted from original location") - - // Verify dashboards still exist in Grafana after sync - helper.SyncAndWait(t, refRepo, nil) - _, err = helper.DashboardsV1.Resource.Get(ctx, "mixedmove1", metav1.GetOptions{}) - require.NoError(t, err, "dashboard moved by path should still exist in Grafana") - - _, err = helper.DashboardsV1.Resource.Get(ctx, "mixedmove2", metav1.GetOptions{}) - require.NoError(t, err, "dashboard moved by resource ref should still exist in Grafana") - }) - }) -} - -func TestIntegrationProvisioning_MoveResources(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - helper := runGrafana(t) - ctx := context.Background() - const repo = "move-test-repo" - localTmp := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{ - "Name": repo, - "SyncEnabled": true, - "SyncTarget": "instance", - }) - _, err := helper.Repositories.Resource.Create(ctx, localTmp, metav1.CreateOptions{}) - require.NoError(t, err) - - // Copy test dashboards to the repository path for initial setup - const originalDashboard = "all-panels.json" - helper.CopyToProvisioningPath(t, "testdata/all-panels.json", originalDashboard) - - // Wait for sync to ensure the dashboard is created in Grafana - helper.SyncAndWait(t, repo, nil) - - // Verify the original dashboard exists in Grafana (using the UID from all-panels.json) - const allPanelsUID = "n1jR8vnnz" // This is the UID from the all-panels.json file - obj, err := helper.DashboardsV1.Resource.Get(ctx, allPanelsUID, metav1.GetOptions{}) - require.NoError(t, err, "original dashboard should exist in Grafana") - require.Equal(t, repo, obj.GetAnnotations()[utils.AnnoKeyManagerIdentity]) - - t.Run("move file without content change", func(t *testing.T) { - const targetPath = "moved/simple-move.json" - - // Perform the move operation using helper function - resp := helper.postFilesRequest(t, repo, filesPostOptions{ - targetPath: targetPath, - originalPath: originalDashboard, - message: "move file without content change", - }) - // nolint:errcheck - defer resp.Body.Close() - require.Equal(t, http.StatusOK, resp.StatusCode, "move operation should succeed") - - // Verify the file moved in the repository - movedObj, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "moved", "simple-move.json") - require.NoError(t, err, "moved file should exist in repository") - - // Check the content is preserved (verify it's still the all-panels dashboard) - resource, _, err := unstructured.NestedMap(movedObj.Object, "resource") - require.NoError(t, err) - dryRun, _, err := unstructured.NestedMap(resource, "dryRun") - require.NoError(t, err) - title, _, err := unstructured.NestedString(dryRun, "spec", "title") - require.NoError(t, err) - require.Equal(t, "Panel tests - All panels", title, "content should be preserved") - - // Verify original file no longer exists - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", originalDashboard) - require.Error(t, err, "original file should no longer exist") - - // Verify dashboard still exists in Grafana with same content but may have updated path references - helper.SyncAndWait(t, repo, nil) - _, err = helper.DashboardsV1.Resource.Get(ctx, allPanelsUID, metav1.GetOptions{}) - require.NoError(t, err, "dashboard should still exist in Grafana after move") - }) - - t.Run("move file to nested path without ref", func(t *testing.T) { - // Test a different scenario: Move a file that was never synced to Grafana - // This might reveal the issue if dashboard creation fails during move - const sourceFile = "never-synced.json" - helper.CopyToProvisioningPath(t, "testdata/timeline-demo.json", sourceFile) - - // DO NOT sync - move the file immediately without it ever being in Grafana - const targetPath = "deep/nested/timeline.json" - - // Perform the move operation without the file ever being synced to Grafana - resp := helper.postFilesRequest(t, repo, filesPostOptions{ - targetPath: targetPath, - originalPath: sourceFile, - message: "move never-synced file to nested path", - }) - // nolint:errcheck - defer resp.Body.Close() - require.Equal(t, http.StatusOK, resp.StatusCode, "move operation should succeed") - - // Check folders were created and validate hierarchy - folderList, err := helper.Folders.Resource.List(ctx, metav1.ListOptions{}) - require.NoError(t, err, "should be able to list folders") - - // Build a map of folder names to their objects for easier lookup - folders := make(map[string]*unstructured.Unstructured) - for _, folder := range folderList.Items { - title, _, _ := unstructured.NestedString(folder.Object, "spec", "title") - folders[title] = &folder - parent, _, _ := unstructured.NestedString(folder.Object, "metadata", "annotations", "grafana.app/folder") - t.Logf(" - %s: %s (parent: %s)", folder.GetName(), title, parent) - } - - // Validate expected folders exist with proper hierarchy - // Expected structure: deep -> deep/nested - deepFolderTitle := "deep" - nestedFolderTitle := "nested" - - // Validate "deep" folder exists and has no parent (is top-level) - require.Contains(t, folders, deepFolderTitle, "deep folder should exist") - f := folders[deepFolderTitle] - deepFolderName := f.GetName() - title, _, _ := unstructured.NestedString(f.Object, "spec", "title") - require.Equal(t, deepFolderTitle, title, "deep folder should have correct title") - parent, found, _ := unstructured.NestedString(f.Object, "metadata", "annotations", "grafana.app/folder") - require.True(t, !found || parent == "", "deep folder should be top-level (no parent)") - - // Validate "deep/nested" folder exists and has "deep" as parent - require.Contains(t, folders, nestedFolderTitle, "nested folder should exist") - f = folders[nestedFolderTitle] - nestedFolderName := f.GetName() - title, _, _ = unstructured.NestedString(f.Object, "spec", "title") - require.Equal(t, nestedFolderTitle, title, "nested folder should have correct title") - parent, _, _ = unstructured.NestedString(f.Object, "metadata", "annotations", "grafana.app/folder") - require.Equal(t, deepFolderName, parent, "nested folder should have deep folder as parent") - - // The key test: Check if dashboard was created in Grafana during move - const timelineUID = "mIJjFy8Kz" - dashboard, err := helper.DashboardsV1.Resource.Get(ctx, timelineUID, metav1.GetOptions{}) - require.NoError(t, err, "dashboard should exist in Grafana after moving never-synced file") - dashboardFolder, _, _ := unstructured.NestedString(dashboard.Object, "metadata", "annotations", "grafana.app/folder") - - // Validate dashboard is in the correct nested folder - require.Equal(t, nestedFolderName, dashboardFolder, "dashboard should be in the nested folder") - - // Verify the file moved in the repository - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "deep", "nested", "timeline.json") - require.NoError(t, err, "moved file should exist in nested repository path") - - // Verify the original file no longer exists in the repository - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", sourceFile) - require.Error(t, err, "original file should no longer exist in repository") - }) - - t.Run("move file with content update", func(t *testing.T) { - const sourcePath = "moved/simple-move.json" // Use the file from previous test - const targetPath = "updated/content-updated.json" - - // Use text-options.json content for the update - updatedContent := helper.LoadFile("testdata/text-options.json") - - // Perform move with content update using helper function - resp := helper.postFilesRequest(t, repo, filesPostOptions{ - targetPath: targetPath, - originalPath: sourcePath, - message: "move file with content update", - body: string(updatedContent), - }) - // nolint:errcheck - defer resp.Body.Close() - require.Equal(t, http.StatusOK, resp.StatusCode, "move with content update should succeed") - - // Verify the moved file has updated content (should now be text-options dashboard) - movedObj, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "updated", "content-updated.json") - require.NoError(t, err, "moved file should exist in repository") - - resource, _, err := unstructured.NestedMap(movedObj.Object, "resource") - require.NoError(t, err) - dryRun, _, err := unstructured.NestedMap(resource, "dryRun") - require.NoError(t, err) - title, _, err := unstructured.NestedString(dryRun, "spec", "title") - require.NoError(t, err) - require.Equal(t, "Text options", title, "content should be updated to text-options dashboard") - - // Check it has the expected UID from text-options.json - name, _, err := unstructured.NestedString(dryRun, "metadata", "name") - require.NoError(t, err) - require.Equal(t, "WZ7AhQiVz", name, "should have the UID from text-options.json") - - // Verify source file no longer exists - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "moved", "simple-move.json") - require.Error(t, err, "source file should no longer exist") - - // Sync and verify the updated dashboard exists in Grafana - helper.SyncAndWait(t, repo, nil) - const textOptionsUID = "WZ7AhQiVz" // UID from text-options.json - updatedDashboard, err := helper.DashboardsV1.Resource.Get(ctx, textOptionsUID, metav1.GetOptions{}) - require.NoError(t, err, "updated dashboard should exist in Grafana") - - // Verify the original dashboard was deleted from Grafana - _, err = helper.DashboardsV1.Resource.Get(ctx, allPanelsUID, metav1.GetOptions{}) - require.Error(t, err, "original dashboard should be deleted from Grafana") - require.True(t, apierrors.IsNotFound(err)) - - // Verify the new dashboard has the updated content - updatedTitle, _, err := unstructured.NestedString(updatedDashboard.Object, "spec", "title") - require.NoError(t, err) - require.Equal(t, "Text options", updatedTitle) - }) - - t.Run("move directory", func(t *testing.T) { - // Create some files in a directory first using existing testdata files - helper.CopyToProvisioningPath(t, "testdata/timeline-demo.json", "source-dir/timeline-demo.json") - helper.CopyToProvisioningPath(t, "testdata/text-options.json", "source-dir/text-options.json") - - // Sync to ensure files are recognized - helper.SyncAndWait(t, repo, nil) - - const sourceDir = "source-dir/" - const targetDir = "moved-dir/" - - // Move directory using helper function - resp := helper.postFilesRequest(t, repo, filesPostOptions{ - targetPath: targetDir, - originalPath: sourceDir, - message: "move directory", - }) - // nolint:errcheck - defer resp.Body.Close() - require.Equal(t, http.StatusOK, resp.StatusCode, "directory move should succeed") - - // Verify source directory no longer exists - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "source-dir") - require.Error(t, err, "source directory should no longer exist") - - // Verify target directory and files exist - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "moved-dir", "timeline-demo.json") - require.NoError(t, err, "moved timeline-demo.json should exist") - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "moved-dir", "text-options.json") - require.NoError(t, err, "moved text-options.json should exist") - }) - - t.Run("error cases", func(t *testing.T) { - t.Run("missing originalPath parameter", func(t *testing.T) { - result := helper.AdminREST.Post(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("files", "target.json"). - Body([]byte(`{"test": "content"}`)). - SetHeader("Content-Type", "application/json"). - Do(ctx) - require.Error(t, result.Error(), "should fail without originalPath") - }) - - t.Run("file to directory type mismatch", func(t *testing.T) { - // First create a simple test file without slashes in the path - result := helper.AdminREST.Post(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("files", "simple-test.json"). - Body(helper.LoadFile("testdata/all-panels.json")). - SetHeader("Content-Type", "application/json"). - Do(ctx) - require.NoError(t, result.Error(), "should create test file") - - // Now try to move this file to a directory path using helper function - resp := helper.postFilesRequest(t, repo, filesPostOptions{ - targetPath: "target-dir/", - originalPath: "simple-test.json", - message: "test move", - }) - // nolint:errcheck - defer resp.Body.Close() - // Read response body to check error message - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - require.NotEqual(t, http.StatusOK, resp.StatusCode, "should fail when moving file to directory") - require.Contains(t, string(body), "cannot move between file and directory types") - }) - - t.Run("non-existent source file", func(t *testing.T) { - result := helper.AdminREST.Post(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("files", "target.json"). - Param("originalPath", "non-existent.json"). - Body([]byte("")). - SetHeader("Content-Type", "application/json"). - Do(ctx) - require.Error(t, result.Error(), "should fail when source file doesn't exist") - }) - }) - - t.Run("move non-existent resource by reference", func(t *testing.T) { - // Create move job for non-existent resource - result := helper.AdminREST.Post(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("jobs"). - Body(asJSON(&provisioning.JobSpec{ - Action: provisioning.JobActionMove, - Move: &provisioning.MoveJobOptions{ - TargetPath: "moved-nonexistent/", - Resources: []provisioning.ResourceRef{ - { - Name: "non-existent-move-uid", - Kind: "Dashboard", - Group: "dashboard.grafana.app", - }, - }, - }, - })). - SetHeader("Content-Type", "application/json"). - Do(ctx) - require.NoError(t, result.Error(), "should be able to create move job") - - // Wait for job to complete - should record error but continue - require.EventuallyWithT(t, func(collect *assert.CollectT) { - list := &unstructured.UnstructuredList{} - err := helper.AdminREST.Get(). - Namespace("default"). - Resource("repositories"). - Name(repo). - SubResource("jobs"). - Do(ctx).Into(list) - assert.NoError(collect, err, "should be able to list jobs") - assert.NotEmpty(collect, list.Items, "expect at least one job") - - // Find the most recent move job - var moveJob *unstructured.Unstructured - for _, elem := range list.Items { - assert.Equal(collect, repo, elem.GetLabels()["provisioning.grafana.app/repository"], "should have repo label") - - action := mustNestedString(elem.Object, "spec", "action") - if action == "move" { - // Get the most recent one (they should be ordered by creation time) - moveJob = &elem - } - } - if !assert.NotNil(collect, moveJob, "should find a move job") { - return - } - - state := mustNestedString(moveJob.Object, "status", "state") - // The job should complete but record errors for individual resource resolution failures - if state == "error" || state == "completed" || state == "success" { - // Any of these states is acceptable - the key is that resource resolution errors are recorded - // and don't fail the entire job due to error-tolerant implementation - return - } - assert.Fail(collect, "job should complete or error, but got state: %s", state) - }, time.Second*10, time.Millisecond*100, "Expected move job to handle non-existent resource") - }) -} - -func TestIntegrationProvisioning_SecondRepositoryOnlyExportsNewDashboards(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - helper := runGrafana(t) - ctx := context.Background() - - // Create some unmanaged dashboards directly in Grafana first - dashboard1 := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v1.yaml") - dashboard1Obj, err := helper.DashboardsV1.Resource.Create(ctx, dashboard1, metav1.CreateOptions{}) - require.NoError(t, err, "should be able to create first dashboard") - dashboard1Name := dashboard1Obj.GetName() - - dashboard2 := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v2beta1.yaml") - dashboard2Obj, err := helper.DashboardsV2beta1.Resource.Create(ctx, dashboard2, metav1.CreateOptions{}) - require.NoError(t, err, "should be able to create second dashboard") - dashboard2Name := dashboard2Obj.GetName() - - // Create the first repository with sync enabled - const repo1 = "first-repository" - repo1Path := filepath.Join(helper.ProvisioningPath, repo1) - err = os.MkdirAll(repo1Path, 0750) - require.NoError(t, err, "should be able to create repository path") - - createBody1 := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{ - "Name": repo1, - "SyncEnabled": true, - "SyncTarget": "folder", - "Path": repo1Path, - }) - _, err = helper.Repositories.Resource.Create(ctx, createBody1, metav1.CreateOptions{}) - require.NoError(t, err, "should be able to create first repository") - - // Print file tree before export - printFileTree(t, helper.ProvisioningPath) - - // Initial export - result := helper.AdminREST.Post(). - Namespace("default"). - Resource("repositories"). - Name(repo1). - SubResource("jobs"). - SetHeader("Content-Type", "application/json"). - Body(asJSON(&provisioning.JobSpec{ - Push: &provisioning.ExportJobOptions{ - Folder: "", // export entire instance - Path: "", // no prefix necessary for testing - }, - })). - Do(ctx) - require.NoError(t, result.Error(), "should be able to create export job for first repo") - helper.AwaitJobsWithStates(t, repo1, []string{"success"}) - // Wait for first repository to sync - helper.SyncAndWait(t, repo1, nil) - - printFileTree(t, helper.ProvisioningPath) - // Verify that the first repository has claimed ownership of the dashboards - managedDash1, err := helper.DashboardsV1.Resource.Get(ctx, dashboard1Name, metav1.GetOptions{}) - require.NoError(t, err) - require.Equal(t, repo1, managedDash1.GetAnnotations()[utils.AnnoKeyManagerIdentity], "dashboard1 should be managed by first repo") - - managedDash2, err := helper.DashboardsV2beta1.Resource.Get(ctx, dashboard2Name, metav1.GetOptions{}) - require.NoError(t, err) - require.Equal(t, repo1, managedDash2.GetAnnotations()[utils.AnnoKeyManagerIdentity], "dashboard2 should be managed by first repo") - - // Create second repository - enable sync and set different target - - const repo2 = "second-repository" - repo2Path := filepath.Join(helper.ProvisioningPath, repo2) - err = os.MkdirAll(repo2Path, 0750) - require.NoError(t, err, "should be able to create seconrd repository path") - - printFileTree(t, helper.ProvisioningPath) - - createBody2 := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{ - "Name": repo2, - "SyncEnabled": true, - "SyncTarget": "folder", - "Path": repo2Path, - }) - - _, err = helper.Repositories.Resource.Create(ctx, createBody2, metav1.CreateOptions{}) - require.NoError(t, err, "should be able to create second repository") - - // Wait for second repository to sync - helper.SyncAndWait(t, repo2, nil) - - // Validate that folders for both repositories exist - folders, err := helper.Folders.Resource.List(ctx, metav1.ListOptions{}) - require.NoError(t, err, "should be able to list folders") - - var repo1FolderFound, repo2FolderFound bool - for _, folder := range folders.Items { - if folder.GetName() == repo1 { - repo1FolderFound = true - } - if folder.GetName() == repo2 { - repo2FolderFound = true - } - } - require.True(t, repo1FolderFound, "folder for first repository %s should exist after sync", repo1) - require.True(t, repo2FolderFound, "folder for second repository %s should exist after sync", repo2) - - // Create a third dashboard that won't be claimed by the first repo - dashboard3 := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v0.yaml") - dashboard3Obj, err := helper.DashboardsV0.Resource.Create(ctx, dashboard3, metav1.CreateOptions{}) - require.NoError(t, err, "should be able to create third dashboard") - dashboard3Name := dashboard3Obj.GetName() - - // Verify dashboard3 is not managed by anyone initially - unmanagedDash3, err := helper.DashboardsV0.Resource.Get(ctx, dashboard3Name, metav1.GetOptions{}) - require.NoError(t, err) - manager, found := unmanagedDash3.GetAnnotations()[utils.AnnoKeyManagerIdentity] - require.True(t, !found || manager == "", "dashboard3 should not be managed initially") - - printFileTree(t, helper.ProvisioningPath) - // Count files in first repo before second export - files1Before, err := countFilesInDir(repo1Path) - require.NoError(t, err) - - // Export from second repository - this should only export the unmanaged dashboard3 - result = helper.AdminREST.Post(). - Namespace("default"). - Resource("repositories"). - Name(repo2). - SubResource("jobs"). - SetHeader("Content-Type", "application/json"). - Body(asJSON(&provisioning.JobSpec{ - Push: &provisioning.ExportJobOptions{ - Folder: "", // export entire instance - Path: "", // no prefix necessary for testing - }, - })). - Do(ctx) - require.NoError(t, result.Error(), "should be able to create export job for second repo") - - // Wait for second repository export to complete - helper.AwaitJobsWithStates(t, repo2, []string{"success"}) - - // Wait for second repository to sync - helper.SyncAndWait(t, repo1, nil) - helper.SyncAndWait(t, repo2, nil) - - printFileTree(t, helper.ProvisioningPath) - files1After, err := countFilesInDir(repo1Path) - require.NoError(t, err) - - actualNewFiles := files1After - files1Before - require.Equal(t, 0, actualNewFiles, - "second repository should skip managed dashboards and had folder issues with unmanaged dashboard (expected %d new files, got %d)", - 0, actualNewFiles) - - // Verify files in the second repository - files2After, err := countFilesInDir(repo2Path) - require.NoError(t, err) - require.Equal(t, 1, files2After, - "second repository should only export the unmanaged dashboard (expected %d new files, got %d)", - 1, files2After) - - // Verify dashboard1 and dashboard2 are still managed by repo1 (unchanged) - stillManagedDash1, err := helper.DashboardsV1.Resource.Get(ctx, dashboard1Name, metav1.GetOptions{}) - require.NoError(t, err) - require.Equal(t, repo1, stillManagedDash1.GetAnnotations()[utils.AnnoKeyManagerIdentity], - "dashboard1 should still be managed by first repo") - - stillManagedDash2, err := helper.DashboardsV2beta1.Resource.Get(ctx, dashboard2Name, metav1.GetOptions{}) - require.NoError(t, err) - require.Equal(t, repo1, stillManagedDash2.GetAnnotations()[utils.AnnoKeyManagerIdentity], - "dashboard2 should still be managed by first repo") - - // Verify dashboard3 is now managed by repo2 - stillManagedDash3, err := helper.DashboardsV0.Resource.Get(ctx, dashboard3Name, metav1.GetOptions{}) - require.NoError(t, err) - require.Equal(t, repo2, stillManagedDash3.GetAnnotations()[utils.AnnoKeyManagerIdentity], - "dashboard3 should now be managed by second repo") -} - -// Helper function to count files in a directory recursively -func countFilesInDir(rootPath string) (int, error) { - count := 0 - err := filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if !d.IsDir() { - count++ - } - return nil - }) - return count, err -} diff --git a/pkg/tests/apis/provisioning/pulljob_test.go b/pkg/tests/apis/provisioning/pulljob_test.go new file mode 100644 index 00000000000..2af5ca64654 --- /dev/null +++ b/pkg/tests/apis/provisioning/pulljob_test.go @@ -0,0 +1,141 @@ +package provisioning + +import ( + "context" + "fmt" + "os" + "path" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" +) + +func TestIntegrationProvisioning_PullJobOwnershipProtection(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + helper := runGrafana(t) + ctx := context.Background() + + // Create two repositories with folder targets and separate paths to avoid file conflicts + const repo1 = "pulljob-repo-1" + const repo2 = "pulljob-repo-2" + + // Create first repository targeting "folder" with its own subdirectory + helper.CreateRepo(t, TestRepo{ + Name: repo1, + Path: path.Join(helper.ProvisioningPath, "repo1"), + Target: "folder", + Copies: map[string]string{ + "testdata/all-panels.json": "dashboard1.json", + }, + ExpectedDashboards: 1, + ExpectedFolders: 1, + }) + + // Create second repository targeting "folder" with its own subdirectory + helper.CreateRepo(t, TestRepo{ + Name: repo2, + Path: path.Join(helper.ProvisioningPath, "repo2"), + Target: "folder", + Copies: map[string]string{ + "testdata/timeline-demo.json": "dashboard2.json", + }, + ExpectedDashboards: 2, // Total across both repos + ExpectedFolders: 2, // Total across both repos + }) + + // Test: Pull job should fail when trying to manage resources owned by another repository + t.Run("pull job should fail when trying to manage resources owned by another repository", func(t *testing.T) { + // Step 1: Try to add a file with the same UID as repo1's dashboard to repo2's directory + // This simulates a scenario where repo2 tries to manage a resource that repo1 already owns + const allPanelsUID = "n1jR8vnnz" // UID from all-panels.json (owned by repo1) + + // Copy the same file (same UID) to repo2's directory to create ownership conflict + conflictingFilePath := "repo2/conflicting-dashboard.json" + helper.CopyToProvisioningPath(t, "testdata/all-panels.json", conflictingFilePath) + printFileTree(t, helper.ProvisioningPath) + + // Step 2: Try to pull repo2 - should fail due to ownership conflict + job := helper.TriggerJobAndWaitForComplete(t, repo2, provisioning.JobSpec{ + Action: provisioning.JobActionPull, + Pull: &provisioning.SyncJobOptions{}, + }) + + // Step 3: Verify the job failed with ownership conflict error + jobObj := &provisioning.Job{} + err := runtime.DefaultUnstructuredConverter.FromUnstructured(job.Object, jobObj) + require.NoError(t, err) + + // The job completes with "warning" state instead of "error" state when it doesn't have too many errors + t.Logf("Job state: %s", jobObj.Status.State) + t.Logf("Job errors: %v", jobObj.Status.Errors) + + require.Equal(t, provisioning.JobStateWarning, jobObj.Status.State, "job should complete with warnings due to ownership conflicts") + require.NotEmpty(t, jobObj.Status.Errors, "should have error details") + + // Check that error mentions ownership conflict + found := false + for _, errMsg := range jobObj.Status.Errors { + t.Logf("Error message: %s", errMsg) + if assert.Contains(t, errMsg, fmt.Sprintf("managed by repo '%s'", repo1)) && + assert.Contains(t, errMsg, fmt.Sprintf("cannot be modified by repo '%s'", repo2)) { + found = true + break + } + } + require.True(t, found, "should have ownership conflict error") + + // Step 4: Verify original resource is still owned by repo1 and unchanged + originalDashboard, err := helper.DashboardsV1.Resource.Get(ctx, allPanelsUID, metav1.GetOptions{}) + require.NoError(t, err, "original dashboard should still exist") + require.Equal(t, repo1, originalDashboard.GetAnnotations()[utils.AnnoKeyManagerIdentity], "ownership should remain with repo1") + + // Clean up the conflicting file for subsequent tests + err = os.Remove(filepath.Join(helper.ProvisioningPath, conflictingFilePath)) + require.NoError(t, err, "should clean up conflicting file") + }) + + // Test: Repositories should not delete resources owned by other repositories during pull + t.Run("repositories should not delete resources owned by other repositories during pull", func(t *testing.T) { + // Both repositories were created with their own resources (repo1 has all-panels.json, repo2 has timeline-demo.json) + // Verify that pulling one repository doesn't affect the other's resources + + // Step 1: Verify both repositories have their own resources + const allPanelsUID = "n1jR8vnnz" // UID from all-panels.json (repo1) + const timelineUID = "mIJjFy8Kz" // UID from timeline-demo.json (repo2) + + repo1Dashboard, err := helper.DashboardsV1.Resource.Get(ctx, allPanelsUID, metav1.GetOptions{}) + require.NoError(t, err, "repo1's dashboard should exist") + require.Equal(t, repo1, repo1Dashboard.GetAnnotations()[utils.AnnoKeyManagerIdentity], "should be owned by repo1") + + repo2Dashboard, err := helper.DashboardsV1.Resource.Get(ctx, timelineUID, metav1.GetOptions{}) + require.NoError(t, err, "repo2's dashboard should exist") + require.Equal(t, repo2, repo2Dashboard.GetAnnotations()[utils.AnnoKeyManagerIdentity], "should be owned by repo2") + + // Step 2: Pull repo1 (which doesn't manage repo2's resource) - should complete successfully + helper.SyncAndWait(t, repo1, nil) + + // Step 3: Verify that repo2's resource is still intact after repo1's pull + persistentRepo2Dashboard, err := helper.DashboardsV1.Resource.Get(ctx, timelineUID, metav1.GetOptions{}) + require.NoError(t, err, "repo2's dashboard should still exist after repo1 pull") + require.Equal(t, repo2, persistentRepo2Dashboard.GetAnnotations()[utils.AnnoKeyManagerIdentity], "ownership should remain with repo2") + require.Equal(t, repo2Dashboard.GetResourceVersion(), persistentRepo2Dashboard.GetResourceVersion(), "repo2's resource should not be modified by repo1 pull") + + // Step 4: Pull repo2 and verify repo1's resource is still intact + helper.SyncAndWait(t, repo2, nil) + + persistentRepo1Dashboard, err := helper.DashboardsV1.Resource.Get(ctx, allPanelsUID, metav1.GetOptions{}) + require.NoError(t, err, "repo1's dashboard should still exist after repo2 pull") + require.Equal(t, repo1, persistentRepo1Dashboard.GetAnnotations()[utils.AnnoKeyManagerIdentity], "ownership should remain with repo1") + require.Equal(t, repo1Dashboard.GetResourceVersion(), persistentRepo1Dashboard.GetResourceVersion(), "repo1's resource should not be modified by repo2 pull") + }) +} diff --git a/pkg/tests/apis/provisioning/repository_test.go b/pkg/tests/apis/provisioning/repository_test.go new file mode 100644 index 00000000000..e3213517869 --- /dev/null +++ b/pkg/tests/apis/provisioning/repository_test.go @@ -0,0 +1,793 @@ +package provisioning + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + 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/runtime" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/extensions" + "github.com/grafana/grafana/pkg/infra/usagestats" + "github.com/grafana/grafana/pkg/tests/apis" +) + +func TestIntegrationProvisioning_CreatingAndGetting(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + helper := runGrafana(t) + createOptions := metav1.CreateOptions{FieldValidation: "Strict"} + ctx := context.Background() + + inputFiles := []string{ + "testdata/github-readonly.json.tmpl", + "testdata/local-readonly.json.tmpl", + } + + for _, inputFilePath := range inputFiles { + t.Run(inputFilePath, func(t *testing.T) { + input := helper.RenderObject(t, inputFilePath, nil) + + _, err := helper.Repositories.Resource.Create(ctx, input, createOptions) + require.NoError(t, err, "failed to create resource") + + name := mustNestedString(input.Object, "metadata", "name") + output, err := helper.Repositories.Resource.Get(ctx, name, metav1.GetOptions{}) + require.NoError(t, err, "failed to read back resource") + + // Move encrypted token mutation + token, found, err := unstructured.NestedString(output.Object, "spec", "github", "encryptedToken") + require.NoError(t, err, "encryptedToken is not a string") + if found { + unstructured.RemoveNestedField(input.Object, "spec", "github", "token") + err = unstructured.SetNestedField(input.Object, token, "spec", "github", "encryptedToken") + require.NoError(t, err, "unable to copy encrypted token") + } + + // Marshal as real objects to ",omitempty" values are tested properly + expectedRepo := unstructuredToRepository(t, input) + returnedRepo := unstructuredToRepository(t, output) + require.Equal(t, expectedRepo.Spec, returnedRepo.Spec) + + // A viewer should not be able to see the same thing + var statusCode int + rsp := helper.ViewerREST.Get(). + Namespace("default"). + Resource("repositories"). + Name(name). + Do(context.Background()) + require.Error(t, rsp.Error()) + rsp.StatusCode(&statusCode) + require.Equal(t, http.StatusForbidden, statusCode) + + // Viewer can see file listing + rsp = helper.AdminREST.Get(). + Namespace("default"). + Resource("repositories"). + Name(name). + Suffix("files/"). + Do(context.Background()) + require.NoError(t, rsp.Error()) + + // Verify that we can list refs + rsp = helper.AdminREST.Get(). + Namespace("default"). + Resource("repositories"). + Name(name). + Suffix("refs"). + Do(context.Background()) + + if expectedRepo.Spec.Type == provisioning.LocalRepositoryType { + require.ErrorContains(t, rsp.Error(), "does not support versioned operations") + } else { + require.NoError(t, rsp.Error()) + refs := &provisioning.RefList{} + err = rsp.Into(refs) + require.NoError(t, err) + require.True(t, len(refs.Items) >= 1, "should have at least one ref") + + var foundBranch bool + for _, ref := range refs.Items { + // FIXME: this assertion should be improved for all git types and take things from config + if ref.Name == "integration-test" { + require.Equal(t, "0f3370c212b04b9704e00f6926ef339bf91c7a1b", ref.Hash) + require.Equal(t, "https://github.com/grafana/grafana-git-sync-demo/tree/integration-test", ref.RefURL) + foundBranch = true + } + } + + require.True(t, foundBranch, "branch should be found") + } + }) + } + + // Viewer can see settings listing + t.Run("viewer has access to list", func(t *testing.T) { + require.EventuallyWithT(t, func(collect *assert.CollectT) { + settings := &provisioning.RepositoryViewList{} + rsp := helper.ViewerREST.Get(). + Namespace("default"). + Suffix("settings"). + Do(context.Background()) + if !assert.NoError(collect, rsp.Error()) { + return + } + + err := rsp.Into(settings) + if !assert.NoError(collect, err) { + return + } + if !assert.Len(collect, settings.Items, len(inputFiles)) { + return + } + + // FIXME: this should be an enterprise integration test + if extensions.IsEnterprise { + assert.ElementsMatch(collect, []provisioning.RepositoryType{ + provisioning.LocalRepositoryType, + provisioning.GitHubRepositoryType, + provisioning.GitRepositoryType, + provisioning.BitbucketRepositoryType, + provisioning.GitLabRepositoryType, + }, settings.AvailableRepositoryTypes) + } else { + assert.ElementsMatch(collect, []provisioning.RepositoryType{ + provisioning.LocalRepositoryType, + provisioning.GitHubRepositoryType, + }, settings.AvailableRepositoryTypes) + } + }, time.Second*10, time.Millisecond*100, "Expected settings to match") + }) + + t.Run("Repositories are reported in stats", func(t *testing.T) { + require.EventuallyWithT(t, func(collect *assert.CollectT) { + report := apis.DoRequest(helper.K8sTestHelper, apis.RequestParams{ + Method: http.MethodGet, + Path: "/api/admin/usage-report-preview", + User: helper.Org1.Admin, + }, &usagestats.Report{}) + + stats := map[string]any{} + for k, v := range report.Result.Metrics { + if strings.HasPrefix(k, "stats.repository.") { + stats[k] = v + } + } + assert.Equal(collect, map[string]any{ + "stats.repository.github.count": 1.0, + "stats.repository.local.count": 1.0, + }, stats) + }, time.Second*10, time.Millisecond*100, "Expected stats to match") + }) +} + +func TestIntegrationProvisioning_FailInvalidSchema(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + t.Skip("Reenable this test once we enforce schema validation for provisioning") + + helper := runGrafana(t) + ctx := context.Background() + + const repo = "invalid-schema-tmp" + // Set up the repository and the file to import. + helper.CopyToProvisioningPath(t, "testdata/invalid-dashboard-schema.json", "invalid-dashboard-schema.json") + + localTmp := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{ + "Name": repo, + "SyncEnabled": true, + }) + _, err := helper.Repositories.Resource.Create(ctx, localTmp, metav1.CreateOptions{}) + require.NoError(t, err) + + // Make sure the repo can read and validate the file + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "invalid-dashboard-schema.json") + status := helper.RequireApiErrorStatus(err, metav1.StatusReasonBadRequest, http.StatusBadRequest) + require.Equal(t, status.Message, "Dry run failed: Dashboard.dashboard.grafana.app \"invalid-schema-uid\" is invalid: [spec.panels.0.repeatDirection: Invalid value: conflicting values \"h\" and \"this is not an allowed value\", spec.panels.0.repeatDirection: Invalid value: conflicting values \"v\" and \"this is not an allowed value\"]") + + const invalidSchemaUid = "invalid-schema-uid" + _, err = helper.DashboardsV1.Resource.Get(ctx, invalidSchemaUid, metav1.GetOptions{}) + require.Error(t, err, "invalid dashboard shouldn't exist") + require.True(t, apierrors.IsNotFound(err)) + + helper.DebugState(t, repo, "BEFORE PULL JOB WITH INVALID SCHEMA") + + spec := provisioning.JobSpec{ + Action: provisioning.JobActionPull, + Pull: &provisioning.SyncJobOptions{}, + } + + result := helper.TriggerJobAndWaitForComplete(t, repo, spec) + job := &provisioning.Job{} + err = runtime.DefaultUnstructuredConverter.FromUnstructured(result.Object, job) + require.NoError(t, err, "should convert to Job object") + + assert.Equal(t, provisioning.JobStateError, job.Status.State) + assert.Equal(t, job.Status.Message, "completed with errors") + assert.Equal(t, job.Status.Errors[0], "Dashboard.dashboard.grafana.app \"invalid-schema-uid\" is invalid: [spec.panels.0.repeatDirection: Invalid value: conflicting values \"h\" and \"this is not an allowed value\", spec.panels.0.repeatDirection: Invalid value: conflicting values \"v\" and \"this is not an allowed value\"]") + + _, err = helper.DashboardsV1.Resource.Get(ctx, invalidSchemaUid, metav1.GetOptions{}) + require.Error(t, err, "invalid dashboard shouldn't have been created") + require.True(t, apierrors.IsNotFound(err)) + + err = helper.Repositories.Resource.Delete(ctx, repo, metav1.DeleteOptions{}, "files", "invalid-dashboard-schema.json") + require.NoError(t, err, "should delete the resource file") +} + +func TestIntegrationProvisioning_CreatingGitHubRepository(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + helper := runGrafana(t) + ctx := context.Background() + + // FIXME: instead of using an existing GitHub repository, we should create a new one for the tests and a branch + // This was the previous structure + // ghmock.WithRequestMatchHandler(ghmock.GetReposGitTreesByOwnerByRepoByTreeSha, + // ghHandleTree(t, map[string][]*gh.TreeEntry{ + // "deadbeef": { + // treeEntryDir("grafana", "subtree"), + // }, + // "subtree": { + // treeEntry("dashboard.json", helper.LoadFile("testdata/all-panels.json")), + // treeEntryDir("subdir", "subtree2"), + // treeEntry("subdir/dashboard2.yaml", helper.LoadFile("testdata/text-options.json")), + // }, + // })), + + // FIXME: uncomment these to implement webhook integration tests. + // helper.GetEnv().GitHubFactory.Client = ghmock.NewMockedHTTPClient( + // ghmock.WithRequestMatchHandler(ghmock.GetReposHooksByOwnerByRepo, ghAlwaysWrite(t, []*gh.Hook{})), + // ghmock.WithRequestMatchHandler(ghmock.PostReposHooksByOwnerByRepo, ghAlwaysWrite(t, &gh.Hook{ID: gh.Ptr(int64(123))})), + // ) + + const repo = "github-create-test" + _, err := helper.Repositories.Resource.Create(ctx, + helper.RenderObject(t, "testdata/github-readonly.json.tmpl", map[string]any{ + "Name": repo, + "SyncEnabled": true, + "SyncTarget": "instance", + "Path": "grafana/", + }), + metav1.CreateOptions{}, + ) + require.NoError(t, err) + + helper.SyncAndWait(t, repo, nil) + + // By now, we should have synced, meaning we have data to read in the local Grafana instance! + + found, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err, "can list values") + + names := []string{} + for _, v := range found.Items { + names = append(names, v.GetName()) + } + require.Len(t, names, 3, "should have three dashboards") + assert.Contains(t, names, "adg5vbj", "should contain dashboard.json's contents") + assert.Contains(t, names, "admfz74", "should contain dashboard2.yaml's contents") + assert.Contains(t, names, "adn5mxb", "should contain dashboard2.yaml's contents") + + err = helper.Repositories.Resource.Delete(ctx, repo, metav1.DeleteOptions{}) + require.NoError(t, err, "should delete values") + + require.EventuallyWithT(t, func(collect *assert.CollectT) { + found, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) + assert.NoError(t, err, "can list values") + assert.Equal(collect, 0, len(found.Items), "expected dashboards to be deleted") + }, time.Second*20, time.Millisecond*10, "Expected dashboards to be deleted") + + // Wait for repository to be fully deleted before subtests run + require.EventuallyWithT(t, func(collect *assert.CollectT) { + _, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}) + assert.True(collect, apierrors.IsNotFound(err), "repository should be deleted") + }, time.Second*10, time.Millisecond*50, "repository should be deleted before subtests") + + t.Run("github url cleanup", func(t *testing.T) { + tests := []struct { + name string + input string + output string + }{ + { + name: "simple-url", + input: "https://github.com/dprokop/grafana-git-sync-test", + output: "https://github.com/dprokop/grafana-git-sync-test", + }, + { + name: "trim-dot-git", + input: "https://github.com/dprokop/grafana-git-sync-test.git", + output: "https://github.com/dprokop/grafana-git-sync-test", + }, + { + name: "trim-slash", + input: "https://github.com/dprokop/grafana-git-sync-test/", + output: "https://github.com/dprokop/grafana-git-sync-test", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + input := helper.RenderObject(t, "testdata/github-readonly.json.tmpl", map[string]any{ + "Name": test.name, + "URL": test.input, + "SyncTarget": "instance", + }) + + _, err := helper.Repositories.Resource.Create(ctx, input, metav1.CreateOptions{}) + require.NoError(t, err, "failed to create resource") + + obj, err := helper.Repositories.Resource.Get(ctx, test.name, metav1.GetOptions{}) + require.NoError(t, err, "failed to read back resource") + + url, _, err := unstructured.NestedString(obj.Object, "spec", "github", "url") + require.NoError(t, err, "failed to read URL") + require.Equal(t, test.output, url) + + err = helper.Repositories.Resource.Delete(ctx, test.name, metav1.DeleteOptions{}) + require.NoError(t, err, "failed to delete") + + // Wait for repository to be fully deleted before next test + require.EventuallyWithT(t, func(collect *assert.CollectT) { + _, err := helper.Repositories.Resource.Get(ctx, test.name, metav1.GetOptions{}) + assert.True(collect, apierrors.IsNotFound(err), "repository should be deleted") + }, time.Second*5, time.Millisecond*50, "repository should be deleted") + }) + } + }) +} + +func TestIntegrationProvisioning_InstanceSyncValidation(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + helper := runGrafana(t) + ctx := context.Background() + + t.Run("single instance sync is allowed", func(t *testing.T) { + // Ensure clean state + helper.CleanupAllRepos(t) + + repoName := "instance-repo-single" + testRepo := TestRepo{ + Name: repoName, + Target: "instance", + Copies: map[string]string{}, // No files needed for this test + ExpectedDashboards: 0, + ExpectedFolders: 0, + } + + // Create instance sync repository - should succeed + helper.CreateRepo(t, testRepo) + + // Clean up at end of test + helper.CleanupAllRepos(t) + }) + + t.Run("instance sync rejected when any other repository exists", func(t *testing.T) { + // Ensure clean state + helper.CleanupAllRepos(t) + + existingFolderName := "existing-folder-repo" + instanceRepoName := "instance-repo-blocked" + + // Create a folder sync repository first + folderTestRepo := TestRepo{ + Name: existingFolderName, + Target: "folder", + Copies: map[string]string{}, // No files needed for this test + ExpectedDashboards: 0, + ExpectedFolders: 1, // One folder expected after sync + } + helper.CreateRepo(t, folderTestRepo) + + // Try to create an instance sync repository - should fail because any other repository exists + instanceRepo := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{ + "Name": instanceRepoName, + "SyncEnabled": true, + "SyncTarget": "instance", + }) + + _, err := helper.Repositories.Resource.Create(ctx, instanceRepo, metav1.CreateOptions{FieldValidation: "Strict"}) + require.Error(t, err, "instance sync repository should be rejected when any other repository exists") + + // Verify the error message mentions that instance can only be created when no other repositories exist + statusError := helper.RequireApiErrorStatus(err, metav1.StatusReasonInvalid, http.StatusUnprocessableEntity) + require.Contains(t, statusError.Message, "Instance repository can only be created when no other repositories exist. Found: "+existingFolderName) + + // Clean up at end of test + helper.CleanupAllRepos(t) + }) + + t.Run("multiple folder syncs are allowed", func(t *testing.T) { + // Ensure clean state + helper.CleanupAllRepos(t) + + firstFolderName := "folder-repo-multi-1" + secondFolderName := "folder-repo-multi-2" + + // Create first folder sync repository + folderTestRepo1 := TestRepo{ + Name: firstFolderName, + Target: "folder", + Copies: map[string]string{}, // No files needed for this test + ExpectedDashboards: 0, + ExpectedFolders: 1, // One folder expected after sync + } + helper.CreateRepo(t, folderTestRepo1) + + // Create second folder sync repository - should succeed + folderTestRepo2 := TestRepo{ + Name: secondFolderName, + Target: "folder", + Copies: map[string]string{}, // No files needed for this test + ExpectedDashboards: 0, + ExpectedFolders: 2, // Two folders expected after sync (1 + 1) + } + helper.CreateRepo(t, folderTestRepo2) + + // Clean up at end of test + helper.CleanupAllRepos(t) + }) + + t.Run("folder sync is rejected when instance sync exists", func(t *testing.T) { + // Ensure clean state + helper.CleanupAllRepos(t) + + instanceRepoName := "instance-blocking-folder" + folderRepoName := "folder-blocked-by-instance" + + // Create instance sync repository first + instanceTestRepo := TestRepo{ + Name: instanceRepoName, + Target: "instance", + Copies: map[string]string{}, // No files needed for this test + ExpectedDashboards: 0, + ExpectedFolders: 0, + } + helper.CreateRepo(t, instanceTestRepo) + + // Try to create folder sync repository - should fail + folderRepo := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{ + "Name": folderRepoName, + "SyncEnabled": true, + "SyncTarget": "folder", + }) + + _, err := helper.Repositories.Resource.Create(ctx, folderRepo, metav1.CreateOptions{FieldValidation: "Strict"}) + require.Error(t, err, "folder sync repository should be rejected when instance sync exists") + + // Verify the error message mentions the existing instance repository + statusError := helper.RequireApiErrorStatus(err, metav1.StatusReasonInvalid, http.StatusUnprocessableEntity) + require.Contains(t, statusError.Message, "Cannot create folder repository when instance repository exists: "+instanceRepoName) + + // Clean up at end of test + helper.CleanupAllRepos(t) + }) + + t.Run("instance sync can only be created when no repositories exist", func(t *testing.T) { + // Ensure clean state + helper.CleanupAllRepos(t) + + // This test verifies that instance sync can ONLY be created when there are no other repositories + instanceRepoName := "instance-only-when-empty" + + // First, create instance sync repository when no other repositories exist - should succeed + instanceTestRepo := TestRepo{ + Name: instanceRepoName, + Target: "instance", + Copies: map[string]string{}, // No files needed for this test + ExpectedDashboards: 0, + ExpectedFolders: 0, + } + helper.CreateRepo(t, instanceTestRepo) + + // Now try to create any other repository - should fail + otherRepoName := "other-repo-blocked" + otherRepo := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{ + "Name": otherRepoName, + "SyncEnabled": true, + "SyncTarget": "folder", + }) + + _, err := helper.Repositories.Resource.Create(ctx, otherRepo, metav1.CreateOptions{FieldValidation: "Strict"}) + require.Error(t, err, "folder sync repository should be rejected when instance sync exists") + + statusError := helper.RequireApiErrorStatus(err, metav1.StatusReasonInvalid, http.StatusUnprocessableEntity) + require.Contains(t, statusError.Message, "Cannot create folder repository when instance repository exists: "+instanceRepoName) + + // Clean up at end of test + helper.CleanupAllRepos(t) + }) + + t.Run("repository limit validation", func(t *testing.T) { + // Ensure clean state + helper.CleanupAllRepos(t) + + // This test verifies the 10 repository limit validation by actually creating 10 repositories + + // Create 10 repositories - should all succeed + for i := 1; i <= 10; i++ { + repoName := fmt.Sprintf("limit-test-repo-%d", i) + limitTestRepo := TestRepo{ + Name: repoName, + Target: "folder", + Copies: map[string]string{}, // No files needed for this test + ExpectedDashboards: 0, + ExpectedFolders: i, // Each repository creates a folder, so total = i + } + helper.CreateRepo(t, limitTestRepo) + } + + // Try to create the 11th repository - should fail due to limit + eleventhRepoName := "limit-test-repo-11" + eleventhRepo := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{ + "Name": eleventhRepoName, + "SyncEnabled": true, + "SyncTarget": "folder", + }) + + _, err := helper.Repositories.Resource.Create(ctx, eleventhRepo, metav1.CreateOptions{FieldValidation: "Strict"}) + require.Error(t, err, "11th repository should be rejected due to limit") + + // Verify the error message mentions the repository limit + statusError := helper.RequireApiErrorStatus(err, metav1.StatusReasonInvalid, http.StatusUnprocessableEntity) + require.Contains(t, statusError.Message, "Maximum number of 10 repositories reached") + + // Clean up at end of test + helper.CleanupAllRepos(t) + }) +} + +func TestIntegrationProvisioning_RunLocalRepository(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + helper := runGrafana(t) + ctx := context.Background() + + const allPanels = "n1jR8vnnz" + const repo = "local-local-examples" + const targetPath = "all-panels.json" + + // Set up the repository. + localTmp := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{"Name": repo}) + obj, err := helper.Repositories.Resource.Create(ctx, localTmp, metav1.CreateOptions{}) + require.NoError(t, err) + name, _, _ := unstructured.NestedString(obj.Object, "metadata", "name") + require.Equal(t, repo, name, "wrote the expected name") + + // Write a file -- this will create it *both* in the local file system, and in grafana + t.Run("write all panels", func(t *testing.T) { + code := 0 + + // Check that we can not (yet) UPDATE the target path + result := helper.AdminREST.Put(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("files", targetPath). + Body(helper.LoadFile("testdata/all-panels.json")). + SetHeader("Content-Type", "application/json"). + Do(ctx).StatusCode(&code) + require.Equal(t, http.StatusNotFound, code) + require.True(t, apierrors.IsNotFound(result.Error())) + + // Now try again with POST (as an editor) + result = helper.EditorREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("files", targetPath). + Body(helper.LoadFile("testdata/all-panels.json")). + SetHeader("Content-Type", "application/json"). + Do(ctx).StatusCode(&code) + require.NoError(t, result.Error(), "expecting to be able to create file") + wrapper := &provisioning.ResourceWrapper{} + raw, err := result.Raw() + require.NoError(t, err) + err = json.Unmarshal(raw, wrapper) + require.NoError(t, err) + require.Equal(t, 200, code, "expected 200 response") + require.Equal(t, provisioning.ClassicDashboard, wrapper.Resource.Type.Classic) + name, _, _ := unstructured.NestedString(wrapper.Resource.File.Object, "metadata", "name") + require.Equal(t, allPanels, name, "name from classic UID") + name, _, _ = unstructured.NestedString(wrapper.Resource.Upsert.Object, "metadata", "name") + require.Equal(t, allPanels, name, "save the name from the request") + + // Get the file from the grafana database + obj, err := helper.DashboardsV1.Resource.Get(ctx, allPanels, metav1.GetOptions{}) + require.NoError(t, err, "the value should be saved in grafana") + val, _, _ := unstructured.NestedString(obj.Object, "metadata", "annotations", utils.AnnoKeyManagerKind) + require.Equal(t, string(utils.ManagerKindRepo), val, "should have repo annotations") + val, _, _ = unstructured.NestedString(obj.Object, "metadata", "annotations", utils.AnnoKeyManagerIdentity) + require.Equal(t, repo, val, "should have repo annotations") + + // Read the file we wrote + wrapObj, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", targetPath) + require.NoError(t, err, "read value") + + wrap := &unstructured.Unstructured{} + wrap.Object, _, err = unstructured.NestedMap(wrapObj.Object, "resource", "dryRun") + require.NoError(t, err) + meta, err := utils.MetaAccessor(wrap) + require.NoError(t, err) + require.Equal(t, allPanels, meta.GetName(), "read the name out of the saved file") + + // Check that an admin can update + meta.SetAnnotation("test", "from-provisioning") + body, err := json.Marshal(wrap.Object) + require.NoError(t, err) + result = helper.AdminREST.Put(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("files", targetPath). + Body(body). + SetHeader("Content-Type", "application/json"). + Do(ctx).StatusCode(&code) + require.Equal(t, 200, code) + require.NoError(t, result.Error(), "update as admin value") + raw, err = result.Raw() + require.NoError(t, err) + err = json.Unmarshal(raw, wrapper) + require.NoError(t, err) + anno, _, _ := unstructured.NestedString(wrapper.Resource.File.Object, "metadata", "annotations", "test") + require.Equal(t, "from-provisioning", anno, "should set the annotation") + + // But a viewer can not + result = helper.ViewerREST.Put(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("files", targetPath). + Body(body). + SetHeader("Content-Type", "application/json"). + Do(ctx).StatusCode(&code) + require.Equal(t, 403, code) + require.True(t, apierrors.IsForbidden(result.Error()), code) + }) + + t.Run("fail using invalid paths", func(t *testing.T) { + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("files", "test", "..", "..", "all-panels.json"). // UNSAFE PATH + Body(helper.LoadFile("testdata/all-panels.json")). + SetHeader("Content-Type", "application/json"). + Do(ctx) + require.Error(t, result.Error(), "invalid path should return error") + + // Read a file with a bad path + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "../../all-panels.json") + require.Error(t, err, "invalid path should error") + }) + + t.Run("require name or generateName", func(t *testing.T) { + code := 0 + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("files", "example.json"). + Body([]byte(`apiVersion: dashboard.grafana.app/v0alpha1 +kind: Dashboard +spec: + title: Test dashboard +`)).Do(ctx).StatusCode(&code) + require.Error(t, result.Error(), "missing name") + + result = helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("files", "example.json"). + Body([]byte(`apiVersion: dashboard.grafana.app/v0alpha1 +kind: Dashboard +metadata: + generateName: prefix- +spec: + title: Test dashboard +`)).Do(ctx).StatusCode(&code) + require.NoError(t, result.Error(), "should create name") + require.Equal(t, 200, code, "expect OK result") + + raw, err := result.Raw() + require.NoError(t, err) + + obj := &unstructured.Unstructured{} + err = json.Unmarshal(raw, obj) + require.NoError(t, err) + + name, _, _ = unstructured.NestedString(obj.Object, "resource", "upsert", "metadata", "name") + require.True(t, strings.HasPrefix(name, "prefix-"), "should generate name") + }) +} + +func TestIntegrationProvisioning_ImportAllPanelsFromLocalRepository(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + helper := runGrafana(t) + ctx := context.Background() + + // The dashboard shouldn't exist yet + const allPanels = "n1jR8vnnz" + _, err := helper.DashboardsV1.Resource.Get(ctx, allPanels, metav1.GetOptions{}) + require.Error(t, err, "no all-panels dashboard should exist") + require.True(t, apierrors.IsNotFound(err)) + + const repo = "local-tmp" + // Set up the repository and the file to import. + helper.CopyToProvisioningPath(t, "testdata/all-panels.json", "all-panels.json") + localTmp := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{ + "Name": repo, + "SyncEnabled": true, + }) + + // We create the repository + _, err = helper.Repositories.Resource.Create(ctx, localTmp, metav1.CreateOptions{}) + require.NoError(t, err) + + // Now, we import it, such that it may exist + // The sync may not be necessary as the sync may have happened automatically at this point + helper.SyncAndWait(t, repo, nil) + + // Make sure the repo can read and validate the file + obj, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "all-panels.json") + require.NoError(t, err, "valid path should be fine") + + resource, _, err := unstructured.NestedMap(obj.Object, "resource") + require.NoError(t, err, "missing resource") + require.NoError(t, err, "invalid action") + require.NotNil(t, resource["file"], "the raw file") + require.NotNil(t, resource["dryRun"], "dryRun result") + + action, _, err := unstructured.NestedString(resource, "action") + require.NoError(t, err, "invalid action") + // FIXME: there is no point in in returning action for a read / get request. + require.Equal(t, "update", action) + + _, err = helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err, "can list values") + + obj, err = helper.DashboardsV1.Resource.Get(ctx, allPanels, metav1.GetOptions{}) + require.NoError(t, err, "all-panels dashboard should exist") + require.Equal(t, repo, obj.GetAnnotations()[utils.AnnoKeyManagerIdentity]) + + // Try writing the value directly + err = unstructured.SetNestedField(obj.Object, []any{"aaa", "bbb"}, "spec", "tags") + require.NoError(t, err, "set tags") + obj, err = helper.DashboardsV1.Resource.Update(ctx, obj, metav1.UpdateOptions{}) + require.NoError(t, err) + v, _, _ := unstructured.NestedString(obj.Object, "metadata", "annotations", utils.AnnoKeyUpdatedBy) + require.Equal(t, "access-policy:provisioning", v) + + // Should not be able to directly delete the managed resource + err = helper.DashboardsV1.Resource.Delete(ctx, allPanels, metav1.DeleteOptions{}) + require.NoError(t, err, "user can delete") + + _, err = helper.DashboardsV1.Resource.Get(ctx, allPanels, metav1.GetOptions{}) + require.Error(t, err, "should delete the internal resource") + require.True(t, apierrors.IsNotFound(err)) +} diff --git a/pkg/tests/apis/provisioning/stats_test.go b/pkg/tests/apis/provisioning/stats_test.go new file mode 100644 index 00000000000..22b4c8372fb --- /dev/null +++ b/pkg/tests/apis/provisioning/stats_test.go @@ -0,0 +1,125 @@ +package provisioning + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/grafana/grafana/pkg/apimachinery/utils" +) + +func TestIntegrationProvisioning_Stats(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + helper := runGrafana(t) + ctx := context.Background() + + const repo = "stats-test-repo1" + + testRepo := TestRepo{ + Name: repo, + Copies: map[string]string{ + "testdata/all-panels.json": "dashboard1.json", + "testdata/text-options.json": "folder/dashboard2.json", + }, + ExpectedDashboards: 2, + ExpectedFolders: 1, + } + helper.CreateRepo(t, testRepo) + + // Create some unmanaged dashboards directly in Grafana + unmanagedDash1 := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v1.yaml") + dashboard1Obj, err := helper.DashboardsV1.Resource.Create(ctx, unmanagedDash1, metav1.CreateOptions{}) + require.NoError(t, err, "should be able to create unmanaged dashboard") + dashboard1Name := dashboard1Obj.GetName() + + // Verify that the unmanaged dashboard is indeed unmanaged + dashboard1, err := helper.DashboardsV1.Resource.Get(ctx, dashboard1Name, metav1.GetOptions{}) + require.NoError(t, err) + manager1, found1 := dashboard1.GetAnnotations()[utils.AnnoKeyManagerIdentity] + require.True(t, !found1 || manager1 == "", "dashboard1 should be unmanaged") + + // Get global stats + result := helper.AdminREST.Get(). + Namespace("default"). + Resource("stats"). + Do(ctx) + require.NoError(t, result.Error(), "should be able to get global stats") + + statsObj, err := result.Get() + require.NoError(t, err, "should be able to get global stats object") + unstructuredStats := statsObj.(*unstructured.Unstructured) + + // Parse instance stats + instanceStats, _, err := unstructured.NestedSlice(unstructuredStats.Object, "instance") + require.NoError(t, err, "should be able to get instance stats") + + var dashboardInstanceCount int64 + for _, instanceStat := range instanceStats { + stat := instanceStat.(map[string]interface{}) + group, _, _ := unstructured.NestedString(stat, "group") + resource, _, _ := unstructured.NestedString(stat, "resource") + if group == "dashboard.grafana.app" && resource == "dashboards" { + count, _, _ := unstructured.NestedInt64(stat, "count") + dashboardInstanceCount = count + break + } + } + require.GreaterOrEqual(t, dashboardInstanceCount, int64(3), "should have at least 3 dashboards total (2 managed + 1 unmanaged)") + + // Parse managed stats + managedStats, _, err := unstructured.NestedSlice(unstructuredStats.Object, "managed") + require.NoError(t, err, "should be able to get managed stats") + + var totalManagedDashboards int64 + foundRepo := false + for _, manager := range managedStats { + managerObj := manager.(map[string]interface{}) + identity, _, _ := unstructured.NestedString(managerObj, "id") + + if identity == repo { + foundRepo = true + stats, _, _ := unstructured.NestedSlice(managerObj, "stats") + for _, statObj := range stats { + stat := statObj.(map[string]interface{}) + group, _, _ := unstructured.NestedString(stat, "group") + resource, _, _ := unstructured.NestedString(stat, "resource") + if group == "dashboard.grafana.app" && resource == "dashboards" { + count, _, _ := unstructured.NestedInt64(stat, "count") + totalManagedDashboards += count + require.Equal(t, int64(2), count, "repo should manage 2 dashboards") + } else if group == "folder.grafana.app" && resource == "folders" { + count, _, _ := unstructured.NestedInt64(stat, "count") + require.Equal(t, int64(1), count, "repo should manage 1 folder") + } + } + } + } + require.True(t, foundRepo, "should find stats for repo1") + + // Parse unmanaged stats + unmanagedStats, _, err := unstructured.NestedSlice(unstructuredStats.Object, "unmanaged") + require.NoError(t, err, "should be able to get unmanaged stats") + + var unmanagedDashboardCount int64 + for _, unmanagedStat := range unmanagedStats { + stat := unmanagedStat.(map[string]interface{}) + group, _, _ := unstructured.NestedString(stat, "group") + resource, _, _ := unstructured.NestedString(stat, "resource") + if group == "dashboard.grafana.app" && resource == "dashboards" { + count, _, _ := unstructured.NestedInt64(stat, "count") + unmanagedDashboardCount = count + break + } + } + require.GreaterOrEqual(t, unmanagedDashboardCount, int64(1), "should have at least 1 unmanaged dashboard") + + // Verify the math: instance = managed + unmanaged + require.Equal(t, dashboardInstanceCount, totalManagedDashboards+unmanagedDashboardCount, + "instance count should equal managed + unmanaged counts") +} diff --git a/pkg/tests/apis/shorturl/shorturl_test.go b/pkg/tests/apis/shorturl/shorturl_test.go new file mode 100644 index 00000000000..ba80e5cf1b6 --- /dev/null +++ b/pkg/tests/apis/shorturl/shorturl_test.go @@ -0,0 +1,541 @@ +package shorturl + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/grafana/grafana/pkg/services/shorturls" + "github.com/stretchr/testify/assert" + + "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" + + "github.com/grafana/grafana/pkg/api/dtos" + grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/services/apiserver/options" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/apis" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" +) + +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +var gvr = schema.GroupVersionResource{ + Group: "shorturl.grafana.app", + Version: "v1alpha1", + Resource: "shorturls", +} + +var RESOURCEGROUP = gvr.GroupResource().String() + +func TestIntegrationShortURL(t *testing.T) { + t.Skip("Skipping due to issue with https://github.com/grafana/grafana/pull/109267") + + if testing.Short() { + t.Skip("skipping integration test") + } + + t.Run("default setup with k8s flag turned off (legacy APIs)", func(t *testing.T) { + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: true, // do not start extra port 6443 + DisableAnonymous: true, + EnableFeatureToggles: []string{}, // legacy APIs only + }) + // In this setup, K8s APIs are not available - legacy APIs only + doLegacyOnlyTests(t, helper) + + // When no feature toggles are enabled, shortURL K8s APIs should not be available + disco := helper.NewDiscoveryClient() + groups, err := disco.ServerGroups() + require.NoError(t, err) + + hasShortURLGroup := false + for _, group := range groups.Groups { + if group.Name == "shorturl.grafana.app" { + hasShortURLGroup = true + break + } + } + require.False(t, hasShortURLGroup, "shortURL K8s APIs should not be available when kubernetesShortURLs feature toggle is disabled") + }) + + t.Run("with dual write (unified storage, mode 0)", func(t *testing.T) { + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: false, // required for unified storage + DisableAnonymous: true, + APIServerStorageType: options.StorageTypeUnified, + EnableFeatureToggles: []string{"kubernetesShortURLs"}, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + RESOURCEGROUP: { + DualWriterMode: grafanarest.Mode0, + }, + }, + }) + doLegacyOnlyTests(t, helper) + }) + + t.Run("with dual write (unified storage, mode 1)", func(t *testing.T) { + mode := grafanarest.Mode1 + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: false, + DisableAnonymous: true, + APIServerStorageType: options.StorageTypeUnified, + EnableFeatureToggles: []string{"kubernetesShortURLs"}, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + RESOURCEGROUP: { + DualWriterMode: mode, + }, + }, + }) + doDualWriteTests(t, helper, mode) + }) + + t.Run("with dual write (unified storage, mode 2)", func(t *testing.T) { + mode := grafanarest.Mode2 + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: false, + DisableAnonymous: true, + APIServerStorageType: options.StorageTypeUnified, + EnableFeatureToggles: []string{"kubernetesShortURLs"}, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + RESOURCEGROUP: { + DualWriterMode: mode, + }, + }, + }) + doDualWriteTests(t, helper, mode) + }) + + t.Run("with dual write (unified storage, mode 3)", func(t *testing.T) { + mode := grafanarest.Mode3 + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: false, + DisableAnonymous: true, + APIServerStorageType: options.StorageTypeUnified, + EnableFeatureToggles: []string{"kubernetesShortURLs"}, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + RESOURCEGROUP: { + DualWriterMode: mode, + }, + }, + }) + doDualWriteTests(t, helper, mode) + }) + + t.Run("with dual write (unified storage, mode 5)", func(t *testing.T) { + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: false, + DisableAnonymous: true, + APIServerStorageType: options.StorageTypeUnified, + EnableFeatureToggles: []string{"kubernetesShortURLs"}, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + RESOURCEGROUP: { + DualWriterMode: grafanarest.Mode5, + }, + }, + }) + doUnifiedOnlyTests(t, helper) + }) +} + +// doLegacyOnlyTests tests functionality for Mode 0 (legacy only) +// Only legacy API should be used, no K8s API interaction +func doLegacyOnlyTests(t *testing.T, helper *apis.K8sTestHelper) { + client := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Editor, + GVR: gvr, + }) + + t.Run("Legacy API CRUD", func(t *testing.T) { + // Create via legacy API + legacyPayload := `{ + "path": "d/xCmMwXdVz/legacy-only-test" + }` + legacyCreate := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodPost, + Path: "/api/short-urls", + Body: []byte(legacyPayload), + }, &dtos.ShortURL{}) + require.NotNil(t, legacyCreate.Result) + uid := legacyCreate.Result.UID + require.NotEmpty(t, uid) + + // Read via legacy API + legacyGet := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodGet, + Path: "/api/short-urls/" + uid, + }, &shorturls.ShortUrl{}) + require.NotNil(t, legacyGet.Result) + assert.Equal(t, uid, legacyGet.Result.Uid) + assert.Equal(t, "d/xCmMwXdVz/legacy-only-test", legacyGet.Result.Path) + }) + + t.Run("Legacy API redirect functionality", func(t *testing.T) { + // Create via legacy API + legacyPayload := `{ + "path": "d/test/legacy-redirect" + }` + legacyCreate := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodPost, + Path: "/api/short-urls", + Body: []byte(legacyPayload), + }, &dtos.ShortURL{}) + require.NotNil(t, legacyCreate.Result) + uid := legacyCreate.Result.UID + + // Test redirect functionality + redirectResponse := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodGet, + Path: "/goto/" + uid + "?orgId=default", + }, (*interface{})(nil)) + assert.Equal(t, 302, redirectResponse.Response.StatusCode) + }) +} + +// doDualWriteTests tests functionality for Modes 1-3 (dual write modes) +// Both APIs available with cross-API visibility +func doDualWriteTests(t *testing.T, helper *apis.K8sTestHelper, mode grafanarest.DualWriterMode) { + // Check if shortURL K8s APIs are available + hasShortURLAPI := checkShortURLAPIAvailable(t, helper) + if !hasShortURLAPI { + t.Log("ShortURL Kubernetes APIs not available - skipping K8s API tests") + return + } + + t.Run("Legacy API -> K8s API visibility", func(t *testing.T) { + client := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Editor, + GVR: gvr, + }) + + // Create via legacy API + legacyPayload := `{ + "path": "d/xCmMwXdVz/dual-write-test" + }` + legacyCreate := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodPost, + Path: "/api/short-urls", + Body: []byte(legacyPayload), + }, &dtos.ShortURL{}) + require.NotNil(t, legacyCreate.Result) + uid := legacyCreate.Result.UID + require.NotEmpty(t, uid) + + // Should be visible via K8s API + found, err := client.Resource.Get(context.Background(), uid, metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, uid, found.GetName()) + + // Verify cross-API consistency + getFromBothAPIs(t, helper, client, uid) + + // Clean up + err = client.Resource.Delete(context.Background(), uid, metav1.DeleteOptions{}) + require.NoError(t, err) + }) + + t.Run("K8s API -> Legacy API visibility", func(t *testing.T) { + client := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Editor, + GVR: gvr, + }) + + // Create via K8s API + obj := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodPost, + Path: "/apis/shorturl.grafana.app/v1alpha1/namespaces/default/shorturls", + Body: []byte(`{ "metadata": { "generateName": "test-" }, "spec": { "path": "d/xCmMwXdVz/k8s-dual-write" } }`), + }, &unstructured.Unstructured{}) + require.NotNil(t, obj.Result) + + uid := obj.Result.GetName() + assert.NotEmpty(t, uid) + + // Should be visible via legacy API + legacyShortURL := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodGet, + Path: "/api/short-urls/" + uid, + }, &shorturls.ShortUrl{}).Result + require.NotNil(t, legacyShortURL) + assert.Equal(t, uid, legacyShortURL.Uid) + + // Verify cross-API consistency + getFromBothAPIs(t, helper, client, uid) + + // Clean up + err := client.Resource.Delete(context.Background(), uid, metav1.DeleteOptions{}) + require.NoError(t, err) + }) + + t.Run("Redirect functionality", func(t *testing.T) { + client := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Editor, + GVR: gvr, + }) + + // Create via K8s API + obj := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodPost, + Path: "/apis/shorturl.grafana.app/v1alpha1/namespaces/default/shorturls", + Body: []byte(`{ "metadata": { "generateName": "redirect-" }, "spec": { "path": "d/test/redirect" } }`), + }, &unstructured.Unstructured{}) + require.NotNil(t, obj.Result) + + uid := obj.Result.GetName() + + // Test redirect functionality and lastSeenAt update + redirectResponse := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodGet, + Path: "/goto/" + uid + "?orgId=default", + }, (*interface{})(nil)) + assert.Equal(t, 302, redirectResponse.Response.StatusCode) + + // Verify lastSeenAt was updated (should be > 0 now) + found, err := client.Resource.Get(context.Background(), uid, metav1.GetOptions{}) + require.NoError(t, err) + status, exists := found.Object["status"].(map[string]interface{}) + assert.True(t, exists) + lastSeenAt, exists := status["lastSeenAt"].(int64) + assert.True(t, exists) + + assert.Greater(t, lastSeenAt, int64(0)) + + // Clean up + err = client.Resource.Delete(context.Background(), uid, metav1.DeleteOptions{}) + require.NoError(t, err) + }) +} + +// doUnifiedOnlyTests tests functionality for Modes 4-5 (unified only) +// Only K8s API, no legacy API interaction +func doUnifiedOnlyTests(t *testing.T, helper *apis.K8sTestHelper) { + // Check if shortURL K8s APIs are available + hasShortURLAPI := checkShortURLAPIAvailable(t, helper) + if !hasShortURLAPI { + t.Log("ShortURL Kubernetes APIs not available - skipping K8s API tests") + return + } + + t.Run("K8s API CRUD (unified storage only)", func(t *testing.T) { + client := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Editor, + GVR: gvr, + }) + + // Create via K8s API + obj := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodPost, + Path: "/apis/shorturl.grafana.app/v1alpha1/namespaces/default/shorturls", + Body: []byte(`{ "metadata": { "generateName": "unified-" }, "spec": { "path": "d/xCmMwXdVz/unified-only" } }`), + }, &unstructured.Unstructured{}) + require.NotNil(t, obj.Result) + + uid := obj.Result.GetName() + assert.NotEmpty(t, uid) + + // Read via K8s API + found, err := client.Resource.Get(context.Background(), uid, metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, uid, found.GetName()) + + // Should NOT be visible via legacy API in unified-only mode + legacyResponse := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodGet, + Path: "/api/short-urls/" + uid, + }, (*shorturls.ShortUrl)(nil)) + // In unified-only mode, legacy API should not see the resource + assert.Nil(t, legacyResponse.Result) + + // Clean up + err = client.Resource.Delete(context.Background(), uid, metav1.DeleteOptions{}) + require.NoError(t, err) + }) + + t.Run("K8s API validation - invalid paths", func(t *testing.T) { + client := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Editor, + GVR: gvr, + }) + + testCases := []struct { + name string + path string + expectedError string + }{ + { + name: "absolute path should be rejected", + path: "/dashboard/absolute-path", + expectedError: "path should be relative", + }, + { + name: "path with directory traversal should be rejected", + path: "d/../../../etc/passwd", + expectedError: "invalid short URL path", + }, + { + name: "path with multiple directory traversals should be rejected", + path: "d/some/../path/../../../secret", + expectedError: "invalid short URL path", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Attempt to create ShortURL with invalid path + invalidBody := fmt.Sprintf(`{ "metadata": { "generateName": "invalid-" }, "spec": { "path": "%s" } }`, tc.path) + response := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodPost, + Path: "/apis/shorturl.grafana.app/v1alpha1/namespaces/default/shorturls", + Body: []byte(invalidBody), + }, (*unstructured.Unstructured)(nil)) + + // Should get a validation error, it should be 400 Bad Request but the validation hook returns 403 Forbidden + assert.Equal(t, http.StatusForbidden, response.Response.StatusCode, + "Expected 403 for invalid path: %s", tc.path) + + // Check that the error message contains expected validation error + assert.Contains(t, string(response.Body), tc.expectedError, + "Response should contain validation error message") + + // Should not have created a resource + assert.Nil(t, response.Result, "No resource should be created for invalid path") + }) + } + }) + + t.Run("K8s API validation - valid edge cases", func(t *testing.T) { + client := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Editor, + GVR: gvr, + }) + + validPaths := []string{ + "d/dashboard/valid-path", + "dashboard/some-id", + "explore?from=123&to=456", + "d/abc123/dashboard-with-params?var-test=value", + } + + for _, validPath := range validPaths { + t.Run(fmt.Sprintf("valid path: %s", validPath), func(t *testing.T) { + validBody := fmt.Sprintf(`{ "metadata": { "generateName": "valid-" }, "spec": { "path": "%s" } }`, validPath) + response := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodPost, + Path: "/apis/shorturl.grafana.app/v1alpha1/namespaces/default/shorturls", + Body: []byte(validBody), + }, &unstructured.Unstructured{}) + + // Should succeed + assert.Equal(t, http.StatusCreated, response.Response.StatusCode, + "Expected 201 Created for valid path: %s", validPath) + assert.NotNil(t, response.Result, "Resource should be created for valid path") + + if response.Result != nil { + uid := response.Result.GetName() + + // Clean up + err := client.Resource.Delete(context.Background(), uid, metav1.DeleteOptions{}) + assert.NoError(t, err, "Cleanup should succeed") + } + }) + } + }) + + t.Run("Redirect functionality (unified only)", func(t *testing.T) { + client := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Editor, + GVR: gvr, + }) + + // Create via K8s API + obj := apis.DoRequest[unstructured.Unstructured](helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodPost, + Path: "/apis/shorturl.grafana.app/v1alpha1/namespaces/default/shorturls", + Body: []byte(`{ "metadata": { "generateName": "redirect-unified-" }, "spec": { "path": "d/test/unified-redirect" } }`), + }, &unstructured.Unstructured{}) + require.NotNil(t, obj.Result) + + uid := obj.Result.GetName() + + // Test redirect functionality + redirectResponse := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodGet, + Path: "/goto/" + uid + "?orgId=default", + }, (*interface{})(nil)) + assert.Equal(t, 302, redirectResponse.Response.StatusCode) + + // Clean up + err := client.Resource.Delete(context.Background(), uid, metav1.DeleteOptions{}) + require.NoError(t, err) + }) +} + +// Helper function to check if shortURL K8s APIs are available +func checkShortURLAPIAvailable(t *testing.T, helper *apis.K8sTestHelper) bool { + disco := helper.NewDiscoveryClient() + groups, err := disco.ServerGroups() + if err != nil { + t.Logf("Failed to get server groups: %v", err) + return false + } + + for _, group := range groups.Groups { + if group.Name == "shorturl.grafana.app" { + return true + } + } + return false +} + +// This does a get with both k8s and legacy API, and verifies the results are the same +func getFromBothAPIs(t *testing.T, + helper *apis.K8sTestHelper, + client *apis.K8sResourceClient, + uid string, +) { + t.Helper() + + k8sResource, err := client.Resource.Get(context.Background(), uid, metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, uid, k8sResource.GetName()) + + // Legacy API: Try to get the shortURL (might not be implemented) + legacyShortURL := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodGet, + Path: "/api/short-urls/" + uid, + }, &shorturls.ShortUrl{}).Result + + if legacyShortURL != nil { + // If legacy API returns data, verify consistency + spec, ok := k8sResource.Object["spec"].(map[string]interface{}) + require.True(t, ok) + status, ok := k8sResource.Object["status"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, legacyShortURL.Uid, k8sResource.GetName()) + assert.Equal(t, legacyShortURL.Path, spec["path"].(string)) + assert.Equal(t, legacyShortURL.LastSeenAt, status["lastSeenAt"].(int64)) + } +} diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index 1aa4380467a..bb1073c9d8f 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -12,7 +12,9 @@ import ( "testing" "time" + "github.com/grafana/grafana/pkg/configprovider" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -85,7 +87,23 @@ func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.Tes err = featuremgmt.InitOpenFeatureWithCfg(cfg) require.NoError(t, err) - env, err := server.InitializeForTest(t, t, cfg, serverOpts, apiServerOpts) + + // Use proper database type based on the environment variable GRAFANA_TEST_DB in tests + testDB, err := sqlutil.GetTestDB(sqlutil.GetTestDBType()) + require.NoError(t, err) + t.Cleanup(testDB.Cleanup) + + dbCfg := cfg.Raw.Section("database") + dbCfg.Key("type").SetValue(testDB.DriverName) + dbCfg.Key("host").SetValue(testDB.Host) + dbCfg.Key("port").SetValue(testDB.Port) + dbCfg.Key("user").SetValue(testDB.User) + dbCfg.Key("password").SetValue(testDB.Password) + dbCfg.Key("name").SetValue(testDB.Database) + + t.Log("Using test database", "type", testDB.DriverName, "host", testDB.Host, "port", testDB.Port, "user", testDB.User, "name", testDB.Database) + + env, err := server.InitializeForTest(ctx, t, t, cfg, serverOpts, apiServerOpts) require.NoError(t, err) require.NotNil(t, env.Cfg) @@ -190,16 +208,16 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { require.True(t, found, "Couldn't detect project root directory") cfgDir := filepath.Join(tmpDir, "conf") - err := os.MkdirAll(cfgDir, 0750) + err := os.MkdirAll(cfgDir, 0o750) require.NoError(t, err) dataDir := filepath.Join(tmpDir, "data") // nolint:gosec - err = os.MkdirAll(dataDir, 0750) + err = os.MkdirAll(dataDir, 0o750) require.NoError(t, err) logsDir := filepath.Join(tmpDir, "logs") pluginsDir := filepath.Join(tmpDir, "plugins") publicDir := filepath.Join(tmpDir, "public") - err = os.MkdirAll(publicDir, 0750) + err = os.MkdirAll(publicDir, 0o750) require.NoError(t, err) viewsDir := filepath.Join(publicDir, "views") @@ -208,7 +226,7 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { // add a stub manifest to the build directory buildDir := filepath.Join(publicDir, "build") - err = os.MkdirAll(buildDir, 0750) + err = os.MkdirAll(buildDir, 0o750) require.NoError(t, err) err = os.WriteFile(filepath.Join(buildDir, "assets-manifest.json"), []byte(`{ "entrypoints": { @@ -238,7 +256,7 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { "integrity": "sha256-k1g7TksMHFQhhQGE" } } - `), 0750) + `), 0o750) require.NoError(t, err) emailsDir := filepath.Join(publicDir, "emails") @@ -246,16 +264,16 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { require.NoError(t, err) provDir := filepath.Join(cfgDir, "provisioning") provDSDir := filepath.Join(provDir, "datasources") - err = os.MkdirAll(provDSDir, 0750) + err = os.MkdirAll(provDSDir, 0o750) require.NoError(t, err) provNotifiersDir := filepath.Join(provDir, "notifiers") - err = os.MkdirAll(provNotifiersDir, 0750) + err = os.MkdirAll(provNotifiersDir, 0o750) require.NoError(t, err) provPluginsDir := filepath.Join(provDir, "plugins") - err = os.MkdirAll(provPluginsDir, 0750) + err = os.MkdirAll(provPluginsDir, 0o750) require.NoError(t, err) provDashboardsDir := filepath.Join(provDir, "dashboards") - err = os.MkdirAll(provDashboardsDir, 0750) + err = os.MkdirAll(provDashboardsDir, 0o750) require.NoError(t, err) corePluginsDir := filepath.Join(publicDir, "app/plugins") err = fs.CopyRecursive(filepath.Join(rootDir, "public", "app/plugins"), corePluginsDir) @@ -604,7 +622,9 @@ func CreateUser(t *testing.T, store db.DB, cfg *setting.Cfg, cmd user.CreateUser cfg.AutoAssignOrgId = 1 cmd.OrgID = 1 - quotaService := quotaimpl.ProvideService(store, cfg) + cfgProvider, err := configprovider.ProvideService(cfg) + require.NoError(t, err) + quotaService := quotaimpl.ProvideService(context.Background(), store, cfgProvider) orgService, err := orgimpl.ProvideService(store, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( diff --git a/pkg/tests/utils.go b/pkg/tests/utils.go index 4ea428df9f7..b8eba7b5f39 100644 --- a/pkg/tests/utils.go +++ b/pkg/tests/utils.go @@ -12,6 +12,7 @@ import ( goapi "github.com/grafana/grafana-openapi-client-go/client" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/configprovider" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" @@ -40,7 +41,9 @@ func CreateUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCom cfg.AutoAssignOrg = true cfg.AutoAssignOrgId = 1 - quotaService := quotaimpl.ProvideService(db, cfg) + cfgProvider, err := configprovider.ProvideService(cfg) + require.NoError(t, err) + quotaService := quotaimpl.ProvideService(context.Background(), db, cfgProvider) orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( diff --git a/pkg/tsdb/grafana-pyroscope-datasource/instance.go b/pkg/tsdb/grafana-pyroscope-datasource/instance.go index 978b7148bda..7943707b39a 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/instance.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/instance.go @@ -79,6 +79,9 @@ func (d *PyroscopeDatasource) CallResource(ctx context.Context, req *backend.Cal if req.Path == "labelValues" { return d.labelValues(ctx, req, sender) } + if req.Path == "profileMetadata" { + return d.profileMetadata(ctx, req, sender) + } return sender.Send(&backend.CallResourceResponse{ Status: 404, }) @@ -216,6 +219,32 @@ func (d *PyroscopeDatasource) labelValues(ctx context.Context, req *backend.Call return nil } +// profileMetadata returns the embedded profile-metrics.json data containing metadata +// for all known profile types, including their aggregation type (cumulative/instant), +// units, descriptions, and grouping information. +func (d *PyroscopeDatasource) profileMetadata(ctx context.Context, _ *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { + ctxLogger := logger.FromContext(ctx) + + registry := GetProfileMetadataRegistry() + + jsonData, err := json.Marshal(registry.profiles) + if err != nil { + ctxLogger.Error("Failed to marshal profile metadata", "error", err, "function", logEntrypoint()) + return sender.Send(&backend.CallResourceResponse{ + Status: 500, + Body: []byte(`{"error": "Failed to marshal profile metadata"}`), + }) + } + + return sender.Send(&backend.CallResourceResponse{ + Status: 200, + Body: jsonData, + Headers: map[string][]string{ + "Content-Type": {"application/json"}, + }, + }) +} + // QueryData handles multiple queries and returns multiple responses. // req contains the queries []DataQuery (where each query contains RefID as a unique identifier). // The QueryDataResponse contains a map of RefID to the response for each query, and each response diff --git a/pkg/tsdb/grafana-pyroscope-datasource/profile-metrics.json b/pkg/tsdb/grafana-pyroscope-datasource/profile-metrics.json new file mode 100644 index 00000000000..5912e37175e --- /dev/null +++ b/pkg/tsdb/grafana-pyroscope-datasource/profile-metrics.json @@ -0,0 +1,162 @@ +{ + "block:contentions:count:contentions:count": { + "id": "block:contentions:count:contentions:count", + "description": "Number of blocking contentions", + "type": "contentions", + "group": "block", + "unit": "short", + "aggregationType": "cumulative" + }, + "block:delay:nanoseconds:contentions:count": { + "id": "block:delay:nanoseconds:contentions:count", + "description": "Time spent in blocking delays", + "type": "delay", + "group": "block", + "unit": "ns", + "aggregationType": "cumulative" + }, + "goroutine:goroutine:count:goroutine:count": { + "id": "goroutine:goroutine:count:goroutine:count", + "description": "Number of goroutines", + "type": "goroutine", + "group": "goroutine", + "unit": "short", + "aggregationType": "instant" + }, + "goroutines:goroutine:count:goroutine:count": { + "id": "goroutines:goroutine:count:goroutine:count", + "description": "Number of goroutines", + "type": "goroutine", + "group": "goroutine", + "unit": "short", + "aggregationType": "instant" + }, + "memory:alloc_in_new_tlab_bytes:bytes::": { + "id": "memory:alloc_in_new_tlab_bytes:bytes::", + "description": "Size of memory allocated inside Thread-Local Allocation Buffers (TLAB)", + "type": "alloc_in_new_tlab_bytes", + "group": "memory", + "unit": "bytes", + "aggregationType": "cumulative" + }, + "memory:alloc_in_new_tlab_objects:count::": { + "id": "memory:alloc_in_new_tlab_objects:count::", + "description": "Number of objects allocated inside Thread-Local Allocation Buffers (TLAB)", + "type": "alloc_in_new_tlab_objects", + "group": "memory", + "unit": "short", + "aggregationType": "cumulative" + }, + "memory:alloc_objects:count:space:bytes": { + "id": "memory:alloc_objects:count:space:bytes", + "description": "Number of objects allocated", + "type": "alloc_objects", + "group": "memory", + "unit": "short", + "aggregationType": "cumulative" + }, + "memory:alloc_space:bytes:space:bytes": { + "id": "memory:alloc_space:bytes:space:bytes", + "description": "Size of memory allocated in the heap", + "type": "alloc_space", + "group": "memory", + "unit": "bytes", + "aggregationType": "cumulative" + }, + "memory:inuse_objects:count:space:bytes": { + "id": "memory:inuse_objects:count:space:bytes", + "description": "Number of objects currently in use", + "type": "inuse_objects", + "group": "memory", + "unit": "short", + "aggregationType": "instant" + }, + "memory:inuse_space:bytes:space:bytes": { + "id": "memory:inuse_space:bytes:space:bytes", + "description": "Size of memory currently in use", + "type": "inuse_space", + "group": "memory", + "unit": "bytes", + "aggregationType": "instant" + }, + "mutex:contentions:count:contentions:count": { + "id": "mutex:contentions:count:contentions:count", + "description": "Number of observed mutex contentions", + "type": "contentions", + "group": "mutex", + "unit": "short", + "aggregationType": "cumulative" + }, + "mutex:delay:nanoseconds:contentions:count": { + "id": "mutex:delay:nanoseconds:contentions:count", + "description": "Time spent waiting due to mutex contentions", + "type": "delay", + "group": "mutex", + "unit": "ns", + "aggregationType": "cumulative" + }, + "process_cpu:alloc_samples:count:cpu:nanoseconds": { + "id": "process_cpu:alloc_samples:count:cpu:nanoseconds", + "description": "Number of memory allocation samples during CPU time", + "type": "alloc_samples", + "group": "memory", + "unit": "short", + "aggregationType": "cumulative" + }, + "process_cpu:alloc_size:bytes:cpu:nanoseconds": { + "id": "process_cpu:alloc_size:bytes:cpu:nanoseconds", + "description": "Size of memory allocated during CPU time", + "type": "alloc_size", + "group": "alloc_size", + "unit": "bytes", + "aggregationType": "cumulative" + }, + "process_cpu:cpu:nanoseconds:cpu:nanoseconds": { + "id": "process_cpu:cpu:nanoseconds:cpu:nanoseconds", + "description": "CPU time consumed", + "type": "cpu", + "group": "process_cpu", + "unit": "ns", + "aggregationType": "cumulative" + }, + "process_cpu:exception:count:cpu:nanoseconds": { + "id": "process_cpu:exception:count:cpu:nanoseconds", + "description": "Number of exceptions within the sampled CPU time", + "type": "exceptions", + "group": "exceptions", + "unit": "short", + "aggregationType": "cumulative" + }, + "process_cpu:lock_count:count:cpu:nanoseconds": { + "id": "process_cpu:lock_count:count:cpu:nanoseconds", + "description": "Number of lock acquisitions attempted during CPU time", + "type": "lock_count", + "group": "locks", + "unit": "short", + "aggregationType": "instant" + }, + "process_cpu:lock_time:nanoseconds:cpu:nanoseconds": { + "id": "process_cpu:lock_time:nanoseconds:cpu:nanoseconds", + "description": "Cumulative time spent acquiring locks", + "type": "lock_time", + "group": "locks", + "unit": "ns", + "aggregationType": "cumulative" + }, + "process_cpu:samples:count::milliseconds": { + "id": "process_cpu:samples:count::milliseconds", + "description": "Number of process samples collected", + "type": "samples", + "group": "process_cpu", + "unit": "short", + "aggregationType": "cumulative" + }, + "process_cpu:samples:count:cpu:nanoseconds": { + "id": "process_cpu:samples:count:cpu:nanoseconds", + "description": "Number of samples collected over CPU time", + "type": "samples", + "group": "process_cpu", + "unit": "short", + "aggregationType": "instant" + } +} diff --git a/pkg/tsdb/grafana-pyroscope-datasource/profile_metadata.go b/pkg/tsdb/grafana-pyroscope-datasource/profile_metadata.go new file mode 100644 index 00000000000..6f778acef49 --- /dev/null +++ b/pkg/tsdb/grafana-pyroscope-datasource/profile_metadata.go @@ -0,0 +1,89 @@ +package pyroscope + +import ( + _ "embed" + "encoding/json" + "sync" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" +) + +//go:embed profile-metrics.json +var profileMetricsJSON []byte + +type ProfileMetadata struct { + ID string `json:"id"` + Description string `json:"description"` + Type string `json:"type"` + Group string `json:"group"` + Unit string `json:"unit"` + AggregationType string `json:"aggregationType"` +} + +type ProfileMetadataRegistry struct { + profiles map[string]*ProfileMetadata + mu sync.RWMutex + logger log.Logger +} + +var ( + registry *ProfileMetadataRegistry + registryOnce sync.Once +) + +// GetProfileMetadataRegistry returns the singleton instance of the profile metadata registry +func GetProfileMetadataRegistry() *ProfileMetadataRegistry { + registryOnce.Do(func() { + registry = &ProfileMetadataRegistry{ + profiles: make(map[string]*ProfileMetadata), + logger: backend.NewLoggerWith("logger", "tsdb.pyroscope.profile-metadata"), + } + registry.loadProfileMetadata() + }) + return registry +} + +// loadProfileMetadata loads the profile metadata from the embedded JSON file +func (r *ProfileMetadataRegistry) loadProfileMetadata() { + var profilesMap map[string]*ProfileMetadata + err := json.Unmarshal(profileMetricsJSON, &profilesMap) + if err != nil { + r.logger.Error("Failed to parse embedded profile-metrics.json", "error", err) + return + } + + r.mu.Lock() + defer r.mu.Unlock() + r.profiles = profilesMap + r.logger.Info("Loaded profile metadata", "count", len(profilesMap)) +} + +// GetProfileMetadata returns the metadata for a given profile type ID +func (r *ProfileMetadataRegistry) GetProfileMetadata(profileTypeID string) *ProfileMetadata { + r.mu.RLock() + defer r.mu.RUnlock() + return r.profiles[profileTypeID] +} + +// IsCumulativeProfile returns true if the profile type requires rate calculation +func (r *ProfileMetadataRegistry) IsCumulativeProfile(profileTypeID string) bool { + metadata := r.GetProfileMetadata(profileTypeID) + if metadata == nil { + r.logger.Debug("Profile metadata not found, using fallback logic", "profileTypeID", profileTypeID) + return isCumulativeProfileUnitFallback(getUnits(profileTypeID)) + } + return metadata.AggregationType == "cumulative" +} + +// isCumulativeProfileUnitFallback is the (old) fallback logic for unknown profile types +func isCumulativeProfileUnitFallback(unit string) bool { + switch unit { + case "ns": + return true + case "bytes": + return true + default: + return false + } +} diff --git a/pkg/tsdb/grafana-pyroscope-datasource/profile_metadata_test.go b/pkg/tsdb/grafana-pyroscope-datasource/profile_metadata_test.go new file mode 100644 index 00000000000..8c43884969f --- /dev/null +++ b/pkg/tsdb/grafana-pyroscope-datasource/profile_metadata_test.go @@ -0,0 +1,49 @@ +package pyroscope + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestProfileMetadataRegistry(t *testing.T) { + registry := GetProfileMetadataRegistry() + + t.Run("CPU profile is cumulative", func(t *testing.T) { + result := registry.IsCumulativeProfile("process_cpu:cpu:nanoseconds:cpu:nanoseconds") + require.True(t, result) + }) + + t.Run("Memory allocation is cumulative", func(t *testing.T) { + result := registry.IsCumulativeProfile("memory:alloc_space:bytes:space:bytes") + require.True(t, result) + }) + + t.Run("Goroutines are instant", func(t *testing.T) { + result := registry.IsCumulativeProfile("goroutine:goroutine:count:goroutine:count") + require.False(t, result) + }) + + t.Run("Memory in-use is instant", func(t *testing.T) { + result := registry.IsCumulativeProfile("memory:inuse_space:bytes:space:bytes") + require.False(t, result) + }) + + t.Run("Edge case: mutex contentions count is cumulative", func(t *testing.T) { + result := registry.IsCumulativeProfile("mutex:contentions:count:contentions:count") + require.True(t, result) + }) + + t.Run("Edge case: memory alloc objects count is cumulative", func(t *testing.T) { + result := registry.IsCumulativeProfile("memory:alloc_objects:count:space:bytes") + require.True(t, result) + }) + + t.Run("Unknown profile falls back to unit-based logic", func(t *testing.T) { + result := registry.IsCumulativeProfile("unknown:profile:nanoseconds:test:test") + require.True(t, result) // ns should be treated as cumulative by fallback + + result = registry.IsCumulativeProfile("unknown:profile:count:test:test") + require.False(t, result) // count/short should be treated as instant by fallback + }) +} diff --git a/pkg/tsdb/grafana-pyroscope-datasource/query.go b/pkg/tsdb/grafana-pyroscope-datasource/query.go index c2243c7af05..506f9de33ee 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/query.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/query.go @@ -76,7 +76,6 @@ func (d *PyroscopeDatasource) query(ctx context.Context, pCtx backend.PluginCont logger.Error("Failed to parse the MinStep using default", "MinStep", dsJson.MinStep, "function", logEntrypoint()) } } - logger.Debug("Sending SelectSeriesRequest", "queryModel", qm, "function", logEntrypoint()) seriesResp, err := d.client.GetSeries( gCtx, profileTypeId, @@ -96,7 +95,8 @@ func (d *PyroscopeDatasource) query(ctx context.Context, pCtx backend.PluginCont // add the frames to the response. responseMutex.Lock() withAnnotations := qm.Annotations != nil && *qm.Annotations - frames, err := seriesToDataFrames(seriesResp, withAnnotations) + stepDuration := math.Max(query.Interval.Seconds(), parsedInterval.Seconds()) + frames, err := seriesToDataFrames(seriesResp, withAnnotations, stepDuration, profileTypeId) if err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) @@ -136,7 +136,24 @@ func (d *PyroscopeDatasource) query(ctx context.Context, pCtx backend.PluginCont var frame *data.Frame if profileResp != nil { - frame = responseToDataFrames(profileResp) + var dsJson dsJsonModel + err := json.Unmarshal(pCtx.DataSourceInstanceSettings.JSONData, &dsJson) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return fmt.Errorf("error unmarshaling datasource json model: %v", err) + } + + parsedInterval := time.Second * 15 + if dsJson.MinStep != "" { + parsedInterval, err = gtime.ParseDuration(dsJson.MinStep) + if err != nil { + parsedInterval = time.Second * 15 + logger.Error("Failed to parse the MinStep using default", "MinStep", dsJson.MinStep, "function", logEntrypoint()) + } + } + stepDuration := math.Max(query.Interval.Seconds(), parsedInterval.Seconds()) + frame = responseToDataFrames(profileResp, stepDuration, profileTypeId) // If query called with streaming on then return a channel // to subscribe on a client-side and consume updates from a plugin. @@ -173,9 +190,9 @@ func (d *PyroscopeDatasource) query(ctx context.Context, pCtx backend.PluginCont // responseToDataFrames turns Pyroscope response to data.Frame. We encode the data into a nested set format where we have // [level, value, label] columns and by ordering the items in a depth first traversal order we can recreate the whole // tree back. -func responseToDataFrames(resp *ProfileResponse) *data.Frame { +func responseToDataFrames(resp *ProfileResponse, stepDurationSec float64, profileTypeID string) *data.Frame { tree := levelsToTree(resp.Flamebearer.Levels, resp.Flamebearer.Names) - return treeToNestedSetDataFrame(tree, resp.Units) + return treeToNestedSetDataFrame(tree, resp.Units, stepDurationSec, profileTypeID) } // START_OFFSET is offset of the bar relative to previous sibling @@ -337,9 +354,17 @@ type CustomMeta struct { // where ordering the items in depth first order and knowing the level/depth of each item we can recreate the // parent - child relationship without explicitly needing parent/child column, and we can later just iterate over the // dataFrame to again basically walking depth first over the tree/profile. -func treeToNestedSetDataFrame(tree *ProfileTree, unit string) *data.Frame { +func treeToNestedSetDataFrame(tree *ProfileTree, unit string, stepDurationSec float64, profileTypeID string) *data.Frame { frame := data.NewFrame("response") - frame.Meta = &data.FrameMeta{PreferredVisualization: "flamegraph"} + frameMeta := &data.FrameMeta{PreferredVisualization: "flamegraph"} + + // Add metadata when rate calculation is applied + if isCumulativeProfile(profileTypeID) && stepDurationSec > 0 { + frameMeta.Custom = map[string]interface{}{ + "rateCalculated": true, + } + } + frame.Meta = frameMeta levelField := data.NewField("level", nil, []int64{}) valueField := data.NewField("value", nil, []int64{}) @@ -356,8 +381,17 @@ func treeToNestedSetDataFrame(tree *ProfileTree, unit string) *data.Frame { if tree != nil { walkTree(tree, func(tree *ProfileTree) { levelField.Append(int64(tree.Level)) - valueField.Append(tree.Value) - selfField.Append(tree.Self) + + // Apply rate calculation for cumulative profiles + value := tree.Value + self := tree.Self + if isCumulativeProfile(profileTypeID) && stepDurationSec > 0 { + value = int64(float64(value) / stepDurationSec) + self = int64(float64(self) / stepDurationSec) + } + + valueField.Append(value) + selfField.Append(self) labelField.Append(tree.Name) }) } @@ -433,14 +467,53 @@ func (ta *TimedAnnotation) getValue() string { return ta.Annotation.Value } -func seriesToDataFrames(resp *SeriesResponse, withAnnotations bool) ([]*data.Frame, error) { +// isCumulativeProfile determines if a profile type requires rate calculation using the metadata registry +func isCumulativeProfile(profileTypeID string) bool { + registry := GetProfileMetadataRegistry() + return registry.IsCumulativeProfile(profileTypeID) +} + +// isCPUTimeProfile determines if a profile type represents CPU time in nanoseconds +func isCPUTimeProfile(profileTypeID string) bool { + registry := GetProfileMetadataRegistry() + metadata := registry.GetProfileMetadata(profileTypeID) + if metadata != nil { + // Check if it's CPU time (unit is nanoseconds and type contains cpu) + return metadata.Unit == "ns" && (metadata.Type == "cpu" || metadata.Group == "process_cpu") + } + return false +} + +// convertToRateUnit converts profile units to appropriate rate units when rate calculation is applied +func convertToRateUnit(originalUnit string) string { + switch originalUnit { + case "bytes": + return "binBps" + case "short": + return "ops" + case "ns": + return "ns" + default: + return originalUnit + } +} + +func seriesToDataFrames(resp *SeriesResponse, withAnnotations bool, stepDurationSec float64, profileTypeID string) ([]*data.Frame, error) { frames := make([]*data.Frame, 0, len(resp.Series)) annotations := make([]*TimedAnnotation, 0) for _, series := range resp.Series { // We create separate data frames as the series may not have the same length frame := data.NewFrame("series") - frame.Meta = &data.FrameMeta{PreferredVisualization: "graph"} + frameMeta := &data.FrameMeta{PreferredVisualization: "graph"} + + // Add metadata when rate calculation is applied + if isCumulativeProfile(profileTypeID) && stepDurationSec > 0 { + frameMeta.Custom = map[string]interface{}{ + "rateCalculated": true, + } + } + frame.Meta = frameMeta fields := make(data.Fields, 0, 2) timeField := data.NewField("time", nil, []time.Time{}) @@ -451,13 +524,35 @@ func seriesToDataFrames(resp *SeriesResponse, withAnnotations bool) ([]*data.Fra labels[label.Name] = label.Value } + // Determine display unit - convert units for rate-calculated cumulative profiles + displayUnit := resp.Units + if isCumulativeProfile(profileTypeID) && stepDurationSec > 0 { + if isCPUTimeProfile(profileTypeID) { + displayUnit = "cores" + } else { + // Convert other cumulative profile units to rate units + displayUnit = convertToRateUnit(resp.Units) + } + } + valueField := data.NewField(resp.Label, labels, []float64{}) - valueField.Config = &data.FieldConfig{Unit: resp.Units} + valueField.Config = &data.FieldConfig{Unit: displayUnit} fields = append(fields, valueField) for _, point := range series.Points { timeField.Append(time.UnixMilli(point.Timestamp)) - valueField.Append(point.Value) + + // Apply rate calculation for cumulative profiles + value := point.Value + if isCumulativeProfile(profileTypeID) && stepDurationSec > 0 { + value = value / stepDurationSec + + // Convert CPU nanoseconds to cores + if isCPUTimeProfile(profileTypeID) { + value = value / 1e9 + } + } + valueField.Append(value) if withAnnotations { for _, a := range point.Annotations { annotations = append(annotations, &TimedAnnotation{ diff --git a/pkg/tsdb/grafana-pyroscope-datasource/query_test.go b/pkg/tsdb/grafana-pyroscope-datasource/query_test.go index def783b917f..67addaaedd4 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/query_test.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/query_test.go @@ -5,10 +5,11 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1" - "github.com/stretchr/testify/require" ) // This is where the tests for the datasource backend live. @@ -130,7 +131,7 @@ func Test_profileToDataFrame(t *testing.T) { }, Units: "short", } - frame := responseToDataFrames(profile) + frame := responseToDataFrames(profile, 15.0, "goroutine:goroutine:count:goroutine:count") require.Equal(t, 4, len(frame.Fields)) require.Equal(t, data.NewField("level", nil, []int64{0, 1, 1}), frame.Fields[0]) require.Equal(t, data.NewField("value", nil, []int64{20, 10, 5}).SetConfig(&data.FieldConfig{Unit: "short"}), frame.Fields[1]) @@ -202,7 +203,7 @@ func Test_treeToNestedDataFrame(t *testing.T) { }, } - frame := treeToNestedSetDataFrame(tree, "short") + frame := treeToNestedSetDataFrame(tree, "short", 15.0, "goroutine:goroutine:count:goroutine:count") labelConfig := &data.FieldConfig{ TypeConfig: &data.FieldTypeConfig{ @@ -221,10 +222,52 @@ func Test_treeToNestedDataFrame(t *testing.T) { }) t.Run("nil profile tree", func(t *testing.T) { - frame := treeToNestedSetDataFrame(nil, "short") + frame := treeToNestedSetDataFrame(nil, "short", 15.0, "goroutine:goroutine:count:goroutine:count") require.Equal(t, 4, len(frame.Fields)) require.Equal(t, 0, frame.Fields[0].Len()) }) + + t.Run("rateCalculated metadata for cumulative profile", func(t *testing.T) { + tree := &ProfileTree{ + Value: 100, Level: 0, Self: 1, Name: "root", + } + frame := treeToNestedSetDataFrame(tree, "short", 15.0, "process_cpu:cpu:nanoseconds:cpu:nanoseconds") + require.NotNil(t, frame.Meta) + require.NotNil(t, frame.Meta.Custom) + custom := frame.Meta.Custom.(map[string]interface{}) + require.Equal(t, true, custom["rateCalculated"]) + }) + + t.Run("no rateCalculated metadata for instant profile", func(t *testing.T) { + tree := &ProfileTree{ + Value: 100, Level: 0, Self: 1, Name: "root", + } + frame := treeToNestedSetDataFrame(tree, "short", 15.0, "goroutine:goroutine:count:goroutine:count") + require.NotNil(t, frame.Meta) + require.Nil(t, frame.Meta.Custom) + }) + + t.Run("CPU time keeps original units for tree data", func(t *testing.T) { + tree := &ProfileTree{ + Value: 3000000000, Level: 0, Self: 1500000000, Name: "root", // 3s total, 1.5s self in nanoseconds + } + // Test CPU profile (should keep nanoseconds for flamegraph, no unit conversion) + frame := treeToNestedSetDataFrame(tree, "ns", 15.0, "process_cpu:cpu:nanoseconds:cpu:nanoseconds") + + // Check unit remains as nanoseconds (no conversion for flamegraphs) + require.Equal(t, "ns", frame.Fields[1].Config.Unit) + require.Equal(t, "ns", frame.Fields[2].Config.Unit) + + // Check values were rate calculated but not unit converted: 3000000000/15 = 200000000, 1500000000/15 = 100000000 + require.Equal(t, int64(200000000), frame.Fields[1].At(0)) + require.Equal(t, int64(100000000), frame.Fields[2].At(0)) + + // Check metadata shows rate was calculated + require.NotNil(t, frame.Meta) + require.NotNil(t, frame.Meta.Custom) + custom := frame.Meta.Custom.(map[string]interface{}) + require.Equal(t, true, custom["rateCalculated"]) + }) } func Test_seriesToDataFrameAnnotations(t *testing.T) { @@ -253,7 +296,7 @@ func Test_seriesToDataFrameAnnotations(t *testing.T) { Label: "samples", } - frames, err := seriesToDataFrames(series, true) + frames, err := seriesToDataFrames(series, true, 15.0, "goroutine:goroutine:count:goroutine:count") require.NoError(t, err) require.Equal(t, 1, len(frames)) require.Equal(t, 2, len(frames[0].Fields)) @@ -278,7 +321,7 @@ func Test_seriesToDataFrameAnnotations(t *testing.T) { }, } - frames, err := seriesToDataFrames(series, false) + frames, err := seriesToDataFrames(series, false, 15.0, "goroutine:goroutine:count:goroutine:count") require.NoError(t, err) require.Equal(t, 1, len(frames)) }) @@ -302,7 +345,7 @@ func Test_seriesToDataFrameAnnotations(t *testing.T) { }, } - frames, err := seriesToDataFrames(series, true) + frames, err := seriesToDataFrames(series, true, 15.0, "goroutine:goroutine:count:goroutine:count") require.NoError(t, err) require.Equal(t, 2, len(frames)) @@ -348,7 +391,7 @@ func Test_seriesToDataFrameAnnotations(t *testing.T) { }, } - frames, err := seriesToDataFrames(series, true) + frames, err := seriesToDataFrames(series, true, 15.0, "goroutine:goroutine:count:goroutine:count") require.NoError(t, err) require.Equal(t, 2, len(frames)) @@ -375,7 +418,7 @@ func Test_seriesToDataFrame(t *testing.T) { Units: "short", Label: "samples", } - frames, err := seriesToDataFrames(series, true) + frames, err := seriesToDataFrames(series, true, 15.0, "goroutine:goroutine:count:goroutine:count") require.NoError(t, err) require.Equal(t, 2, len(frames[0].Fields)) require.Equal(t, data.NewField("time", nil, []time.Time{time.UnixMilli(1000), time.UnixMilli(2000)}), frames[0].Fields[0]) @@ -390,7 +433,7 @@ func Test_seriesToDataFrame(t *testing.T) { Label: "samples", } - frames, err = seriesToDataFrames(series, true) + frames, err = seriesToDataFrames(series, true, 15.0, "goroutine:goroutine:count:goroutine:count") require.NoError(t, err) require.Equal(t, data.NewField("samples", map[string]string{"app": "bar"}, []float64{30, 10}).SetConfig(&data.FieldConfig{Unit: "short"}), frames[0].Fields[1]) }) @@ -404,7 +447,7 @@ func Test_seriesToDataFrame(t *testing.T) { Units: "short", Label: "samples", } - frames, err := seriesToDataFrames(resp, true) + frames, err := seriesToDataFrames(resp, true, 15.0, "goroutine:goroutine:count:goroutine:count") require.NoError(t, err) require.Equal(t, 2, len(frames)) require.Equal(t, 2, len(frames[0].Fields)) @@ -412,6 +455,99 @@ func Test_seriesToDataFrame(t *testing.T) { require.Equal(t, data.NewField("samples", map[string]string{"foo": "bar"}, []float64{30, 10}).SetConfig(&data.FieldConfig{Unit: "short"}), frames[0].Fields[1]) require.Equal(t, data.NewField("samples", map[string]string{"foo": "baz"}, []float64{30, 10}).SetConfig(&data.FieldConfig{Unit: "short"}), frames[1].Fields[1]) }) + + t.Run("rateCalculated metadata for cumulative profile", func(t *testing.T) { + series := &SeriesResponse{ + Series: []*Series{ + {Labels: []*LabelPair{}, Points: []*Point{{Timestamp: int64(1000), Value: 30}, {Timestamp: int64(2000), Value: 10}}}, + }, + Units: "ns", + Label: "cpu", + } + frames, err := seriesToDataFrames(series, false, 15.0, "process_cpu:cpu:nanoseconds:cpu:nanoseconds") + require.NoError(t, err) + require.Equal(t, 1, len(frames)) + require.NotNil(t, frames[0].Meta) + require.NotNil(t, frames[0].Meta.Custom) + custom := frames[0].Meta.Custom.(map[string]interface{}) + require.Equal(t, true, custom["rateCalculated"]) + }) + + t.Run("no rateCalculated metadata for instant profile", func(t *testing.T) { + series := &SeriesResponse{ + Series: []*Series{ + {Labels: []*LabelPair{}, Points: []*Point{{Timestamp: int64(1000), Value: 30}, {Timestamp: int64(2000), Value: 10}}}, + }, + Units: "short", + Label: "goroutines", + } + // Test instant profile (should not have rateCalculated metadata) + frames, err := seriesToDataFrames(series, false, 15.0, "goroutine:goroutine:count:goroutine:count") + require.NoError(t, err) + require.Equal(t, 1, len(frames)) + require.NotNil(t, frames[0].Meta) + require.Nil(t, frames[0].Meta.Custom) + }) + + t.Run("CPU time conversion to cores", func(t *testing.T) { + series := &SeriesResponse{ + Series: []*Series{ + {Labels: []*LabelPair{}, Points: []*Point{{Timestamp: int64(1000), Value: 3000000000}, {Timestamp: int64(2000), Value: 1500000000}}}, // 3s and 1.5s in nanoseconds + }, + Units: "ns", + Label: "cpu", + } + // should convert nanoseconds to cores and set unit to "cores" + frames, err := seriesToDataFrames(series, false, 15.0, "process_cpu:cpu:nanoseconds:cpu:nanoseconds") + require.NoError(t, err) + require.Equal(t, 1, len(frames)) + + require.Equal(t, "cores", frames[0].Fields[1].Config.Unit) + + // Check values were converted: 3000000000/15/1e9 = 0.2 cores/sec, 1500000000/15/1e9 = 0.1 cores/sec + values := fieldValues[float64](frames[0].Fields[1]) + require.Equal(t, []float64{0.2, 0.1}, values) + }) + + t.Run("Memory allocation unit conversion to bytes/sec", func(t *testing.T) { + series := &SeriesResponse{ + Series: []*Series{ + {Labels: []*LabelPair{}, Points: []*Point{{Timestamp: int64(1000), Value: 150000000}, {Timestamp: int64(2000), Value: 300000000}}}, // 150 MB, 300 MB + }, + Units: "bytes", + Label: "memory_alloc", + } + // should convert bytes to binBps and apply rate calculation + frames, err := seriesToDataFrames(series, false, 15.0, "memory:alloc_space:bytes:space:bytes") + require.NoError(t, err) + require.Equal(t, 1, len(frames)) + + require.Equal(t, "binBps", frames[0].Fields[1].Config.Unit) + + // Check values were rate calculated: 150000000/15 = 10000000, 300000000/15 = 20000000 + values := fieldValues[float64](frames[0].Fields[1]) + require.Equal(t, []float64{10000000, 20000000}, values) + }) + + t.Run("Count-based profile unit conversion to ops/sec", func(t *testing.T) { + series := &SeriesResponse{ + Series: []*Series{ + {Labels: []*LabelPair{}, Points: []*Point{{Timestamp: int64(1000), Value: 1500}, {Timestamp: int64(2000), Value: 3000}}}, // 1500, 3000 contentions + }, + Units: "short", + Label: "contentions", + } + // should convert short to ops and apply rate calculation + frames, err := seriesToDataFrames(series, false, 15.0, "mutex:contentions:count:contentions:count") + require.NoError(t, err) + require.Equal(t, 1, len(frames)) + + require.Equal(t, "ops", frames[0].Fields[1].Config.Unit) + + // Check values were rate calculated: 1500/15 = 100, 3000/15 = 200 + values := fieldValues[float64](frames[0].Fields[1]) + require.Equal(t, []float64{100, 200}, values) + }) } type FakeClient struct { diff --git a/pkg/tsdb/influxdb/fsql/client.go b/pkg/tsdb/influxdb/fsql/client.go index 270599d0351..0935fa8815a 100644 --- a/pkg/tsdb/influxdb/fsql/client.go +++ b/pkg/tsdb/influxdb/fsql/client.go @@ -11,6 +11,7 @@ import ( "github.com/apache/arrow-go/v18/arrow/flight/flightsql" "github.com/apache/arrow-go/v18/arrow/ipc" "github.com/apache/arrow-go/v18/arrow/memory" + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/backend/proxy" "google.golang.org/grpc" "google.golang.org/grpc/credentials" @@ -28,8 +29,8 @@ func (c *client) FlightClient() flight.Client { return c.Client.Client } -func newFlightSQLClient(addr string, metadata metadata.MD, secure bool, proxyClient proxy.Client) (*client, error) { - dialOptions, err := grpcDialOptions(secure, proxyClient) +func newFlightSQLClient(addr string, metadata metadata.MD, secure bool, tlsConfig *httpclient.TLSOptions, proxyClient proxy.Client) (*client, error) { + dialOptions, err := grpcDialOptions(secure, tlsConfig, proxyClient) if err != nil { return nil, fmt.Errorf("grpc dial options: %s", err) } @@ -46,7 +47,7 @@ func newFlightSQLClient(addr string, metadata metadata.MD, secure bool, proxyCli return &client{Client: fsqlClient, md: metadata}, nil } -func grpcDialOptions(secure bool, proxyClient proxy.Client) ([]grpc.DialOption, error) { +func grpcDialOptions(secure bool, tlsConfig *httpclient.TLSOptions, proxyClient proxy.Client) ([]grpc.DialOption, error) { dialOptions := []grpc.DialOption{} secureDialOpt := grpc.WithTransportCredentials(insecure.NewCredentials()) @@ -55,6 +56,9 @@ func grpcDialOptions(secure bool, proxyClient proxy.Client) ([]grpc.DialOption, if err != nil { return nil, fmt.Errorf("x509: %s", err) } + if tlsConfig != nil && len(tlsConfig.CACertificate) > 0 { + pool.AppendCertsFromPEM([]byte(tlsConfig.CACertificate)) + } secureDialOpt = grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(pool, "")) } dialOptions = append(dialOptions, secureDialOpt) diff --git a/pkg/tsdb/influxdb/fsql/fsql.go b/pkg/tsdb/influxdb/fsql/fsql.go index 6869696fab3..b81bfc55b06 100644 --- a/pkg/tsdb/influxdb/fsql/fsql.go +++ b/pkg/tsdb/influxdb/fsql/fsql.go @@ -143,7 +143,7 @@ func runnerFromDataSource(dsInfo *models.DatasourceInfo) (*runner, error) { md.Set("Authorization", fmt.Sprintf("Bearer %s", dsInfo.Token)) } - fsqlClient, err := newFlightSQLClient(u, md, !dsInfo.InsecureGrpc, dsInfo.ProxyClient) + fsqlClient, err := newFlightSQLClient(u, md, !dsInfo.InsecureGrpc, dsInfo.TLSConfig, dsInfo.ProxyClient) if err != nil { return nil, err } diff --git a/pkg/tsdb/influxdb/influxdb.go b/pkg/tsdb/influxdb/influxdb.go index 6523ea0158f..c4a04e19008 100644 --- a/pkg/tsdb/influxdb/influxdb.go +++ b/pkg/tsdb/influxdb/influxdb.go @@ -92,6 +92,7 @@ func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.Inst Token: settings.DecryptedSecureJSONData["token"], Timeout: opts.Timeouts.Timeout, ProxyClient: proxyClient, + TLSConfig: opts.TLS, } return model, nil } diff --git a/pkg/tsdb/influxdb/models/datasource_info.go b/pkg/tsdb/influxdb/models/datasource_info.go index 9683a28d2c8..8bf26c68bbc 100644 --- a/pkg/tsdb/influxdb/models/datasource_info.go +++ b/pkg/tsdb/influxdb/models/datasource_info.go @@ -4,6 +4,7 @@ import ( "net/http" "time" + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/backend/proxy" ) @@ -25,5 +26,7 @@ type DatasourceInfo struct { // FlightSQL grpc connection InsecureGrpc bool `json:"insecureGrpc"` + TLSConfig *httpclient.TLSOptions + ProxyClient proxy.Client } diff --git a/pkg/tsdb/tempo/metrics_stream.go b/pkg/tsdb/tempo/metrics_stream.go index 85fac2df83b..cd711ac80f9 100644 --- a/pkg/tsdb/tempo/metrics_stream.go +++ b/pkg/tsdb/tempo/metrics_stream.go @@ -24,7 +24,7 @@ type PartialTempoQuery struct { MetricsQueryType *dataquery.MetricsQueryType } -func (s *Service) runMetricsStream(ctx context.Context, req *backend.RunStreamRequest, sender *backend.StreamSender, datasource *Datasource) error { +func (s *Service) runMetricsStream(ctx context.Context, req *backend.RunStreamRequest, sender *backend.StreamSender, datasource *DatasourceInfo) error { ctx, span := tracing.DefaultTracer().Start(ctx, "datasource.tempo.runMetricsStream") defer span.End() diff --git a/pkg/tsdb/tempo/search_stream.go b/pkg/tsdb/tempo/search_stream.go index 527a1789f32..c78e138e4a7 100644 --- a/pkg/tsdb/tempo/search_stream.go +++ b/pkg/tsdb/tempo/search_stream.go @@ -31,7 +31,7 @@ type StreamSender interface { SendBytes(data []byte) error } -func (s *Service) runSearchStream(ctx context.Context, req *backend.RunStreamRequest, sender *backend.StreamSender, datasource *Datasource) error { +func (s *Service) runSearchStream(ctx context.Context, req *backend.RunStreamRequest, sender *backend.StreamSender, datasource *DatasourceInfo) error { ctx, span := tracing.DefaultTracer().Start(ctx, "datasource.tempo.runSearchStream") defer span.End() diff --git a/pkg/tsdb/tempo/standalone/datasource.go b/pkg/tsdb/tempo/standalone/datasource.go index 087cb4dff71..8d371c07917 100644 --- a/pkg/tsdb/tempo/standalone/datasource.go +++ b/pkg/tsdb/tempo/standalone/datasource.go @@ -6,18 +6,19 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + tempo "github.com/grafana/grafana/pkg/tsdb/tempo" ) -type Datasource struct { - Service *tempo.Service -} - var ( _ backend.QueryDataHandler = (*Datasource)(nil) _ backend.StreamHandler = (*Datasource)(nil) ) +type Datasource struct { + Service *tempo.Service +} + func NewDatasource(c context.Context, b backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { return &Datasource{ Service: tempo.ProvideService(httpclient.NewProvider()), diff --git a/pkg/tsdb/tempo/standalone/main.go b/pkg/tsdb/tempo/standalone/main.go index 6961ead4c20..748c9459e68 100644 --- a/pkg/tsdb/tempo/standalone/main.go +++ b/pkg/tsdb/tempo/standalone/main.go @@ -8,7 +8,6 @@ import ( ) func main() { - // Created as described at https://grafana.com/developers/plugin-tools/introduction/backend-plugins if err := datasource.Manage("tempo", NewDatasource, datasource.ManageOpts{}); err != nil { log.DefaultLogger.Error(err.Error()) os.Exit(1) diff --git a/pkg/tsdb/tempo/tempo.go b/pkg/tsdb/tempo/tempo.go index 96a295b7fac..cbe4893f8e2 100644 --- a/pkg/tsdb/tempo/tempo.go +++ b/pkg/tsdb/tempo/tempo.go @@ -21,36 +21,19 @@ type Service struct { logger log.Logger } -// Return the file, line, and (full-path) function name of the caller -func getRunContext() (string, int, string) { - pc := make([]uintptr, 10) - runtime.Callers(2, pc) - f := runtime.FuncForPC(pc[0]) - file, line := f.FileLine(pc[0]) - return file, line, f.Name() -} - -// Return a formatted string representing the execution context for the logger -func logEntrypoint() string { - file, line, pathToFunction := getRunContext() - parts := strings.Split(pathToFunction, "/") - functionName := parts[len(parts)-1] - return fmt.Sprintf("%s:%d[%s]", file, line, functionName) +type DatasourceInfo struct { + HTTPClient *http.Client + StreamingClient tempopb.StreamingQuerierClient + URL string } func ProvideService(httpClientProvider *httpclient.Provider) *Service { return &Service{ - logger: backend.NewLoggerWith("logger", "tsdb.tempo"), im: datasource.NewInstanceManager(newInstanceSettings(httpClientProvider)), + logger: backend.NewLoggerWith("logger", "tsdb.tempo"), } } -type Datasource struct { - HTTPClient *http.Client - StreamingClient tempopb.StreamingQuerierClient - URL string -} - func newInstanceSettings(httpClientProvider *httpclient.Provider) datasource.InstanceFactoryFunc { return func(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { ctxLogger := backend.NewLoggerWith("logger", "tsdb.tempo").FromContext(ctx) @@ -72,7 +55,7 @@ func newInstanceSettings(httpClientProvider *httpclient.Provider) datasource.Ins return nil, err } - model := &Datasource{ + model := &DatasourceInfo{ HTTPClient: client, StreamingClient: streamingClient, URL: settings.URL, @@ -91,16 +74,34 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) // loop over queries and execute them individually. for i, q := range req.Queries { ctxLogger.Debug("Processing query", "counter", i, "function", logEntrypoint()) - if res, err := s.query(ctx, req.PluginContext, q); err != nil { - ctxLogger.Error("Error processing query", "error", err) - return response, err - } else { - if res != nil { - ctxLogger.Debug("Query processed", "counter", i, "function", logEntrypoint()) - response.Responses[q.RefID] = *res - } else { - ctxLogger.Debug("Query resulted in empty response", "counter", i, "function", logEntrypoint()) + + var res *backend.DataResponse + var err error + + switch q.QueryType { + case string(dataquery.TempoQueryTypeTraceId): + res, err = s.getTrace(ctx, req.PluginContext, q) + if err != nil { + ctxLogger.Error("Error processing TraceId query", "error", err) + return response, err } + + case string(dataquery.TempoQueryTypeTraceql): + res, err = s.runTraceQlQuery(ctx, req.PluginContext, q) + if err != nil { + ctxLogger.Error("Error processing TraceQL query", "error", err) + return response, err + } + + default: + return nil, fmt.Errorf("unsupported query type: '%s' for query with refID '%s'", q.QueryType, q.RefID) + } + + if res != nil { + ctxLogger.Debug("Query processed", "counter", i, "function", logEntrypoint()) + response.Responses[q.RefID] = *res + } else { + ctxLogger.Debug("Query resulted in empty response", "counter", i, "function", logEntrypoint()) } } @@ -108,26 +109,33 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) return response, nil } -func (s *Service) query(ctx context.Context, pCtx backend.PluginContext, query backend.DataQuery) (*backend.DataResponse, error) { - switch query.QueryType { - case string(dataquery.TempoQueryTypeTraceId): - return s.getTrace(ctx, pCtx, query) - case string(dataquery.TempoQueryTypeTraceql): - return s.runTraceQlQuery(ctx, pCtx, query) - } - return nil, fmt.Errorf("unsupported query type: '%s' for query with refID '%s'", query.QueryType, query.RefID) -} - -func (s *Service) getDSInfo(ctx context.Context, pluginCtx backend.PluginContext) (*Datasource, error) { +func (s *Service) getDSInfo(ctx context.Context, pluginCtx backend.PluginContext) (*DatasourceInfo, error) { i, err := s.im.Get(ctx, pluginCtx) if err != nil { return nil, err } - instance, ok := i.(*Datasource) + instance, ok := i.(*DatasourceInfo) if !ok { return nil, fmt.Errorf("failed to cast datsource info") } return instance, nil } + +// Return the file, line, and (full-path) function name of the caller +func getRunContext() (string, int, string) { + pc := make([]uintptr, 10) + runtime.Callers(2, pc) + f := runtime.FuncForPC(pc[0]) + file, line := f.FileLine(pc[0]) + return file, line, f.Name() +} + +// Return a formatted string representing the execution context for the logger +func logEntrypoint() string { + file, line, pathToFunction := getRunContext() + parts := strings.Split(pathToFunction, "/") + functionName := parts[len(parts)-1] + return fmt.Sprintf("%s:%d[%s]", file, line, functionName) +} diff --git a/pkg/tsdb/tempo/trace.go b/pkg/tsdb/tempo/trace.go index 2536f3988ef..6cc705de502 100644 --- a/pkg/tsdb/tempo/trace.go +++ b/pkg/tsdb/tempo/trace.go @@ -135,7 +135,7 @@ func (s *Service) getTrace(ctx context.Context, pCtx backend.PluginContext, quer return result, nil } -func (s *Service) performTraceRequest(ctx context.Context, dsInfo *Datasource, apiVersion TraceRequestApiVersion, model *dataquery.TempoQuery, query backend.DataQuery, span trace.Span) (*http.Response, []byte, error) { +func (s *Service) performTraceRequest(ctx context.Context, dsInfo *DatasourceInfo, apiVersion TraceRequestApiVersion, model *dataquery.TempoQuery, query backend.DataQuery, span trace.Span) (*http.Response, []byte, error) { ctxLogger := s.logger.FromContext(ctx) request, err := s.createRequest(ctx, dsInfo, apiVersion, *model.Query, query.TimeRange.From.Unix(), query.TimeRange.To.Unix()) @@ -177,7 +177,7 @@ const ( TraceRequestApiVersionV2 ) -func (s *Service) createRequest(ctx context.Context, dsInfo *Datasource, apiVersion TraceRequestApiVersion, traceID string, start int64, end int64) (*http.Request, error) { +func (s *Service) createRequest(ctx context.Context, dsInfo *DatasourceInfo, apiVersion TraceRequestApiVersion, traceID string, start int64, end int64) (*http.Request, error) { ctxLogger := s.logger.FromContext(ctx) var baseUrl string var tempoQuery string diff --git a/pkg/tsdb/tempo/trace_test.go b/pkg/tsdb/tempo/trace_test.go index 78983040050..63932ee66bc 100644 --- a/pkg/tsdb/tempo/trace_test.go +++ b/pkg/tsdb/tempo/trace_test.go @@ -12,7 +12,7 @@ import ( func TestTempo(t *testing.T) { t.Run("createRequest v1 without time range - success", func(t *testing.T) { service := &Service{logger: backend.NewLoggerWith("logger", "tempo-test")} - req, err := service.createRequest(context.Background(), &Datasource{}, TraceRequestApiVersionV1, "traceID", 0, 0) + req, err := service.createRequest(context.Background(), &DatasourceInfo{}, TraceRequestApiVersionV1, "traceID", 0, 0) require.NoError(t, err) assert.Equal(t, 1, len(req.Header)) assert.Equal(t, "/api/traces/traceID", req.URL.String()) @@ -20,7 +20,7 @@ func TestTempo(t *testing.T) { t.Run("createRequest v1 with time range - success", func(t *testing.T) { service := &Service{logger: backend.NewLoggerWith("logger", "tempo-test")} - req, err := service.createRequest(context.Background(), &Datasource{}, TraceRequestApiVersionV1, "traceID", 1, 2) + req, err := service.createRequest(context.Background(), &DatasourceInfo{}, TraceRequestApiVersionV1, "traceID", 1, 2) require.NoError(t, err) assert.Equal(t, 1, len(req.Header)) assert.Equal(t, "/api/traces/traceID?start=1&end=2", req.URL.String()) @@ -28,7 +28,7 @@ func TestTempo(t *testing.T) { t.Run("createRequest v2 without time range - success", func(t *testing.T) { service := &Service{logger: backend.NewLoggerWith("logger", "tempo-test")} - req, err := service.createRequest(context.Background(), &Datasource{}, TraceRequestApiVersionV2, "traceID", 0, 0) + req, err := service.createRequest(context.Background(), &DatasourceInfo{}, TraceRequestApiVersionV2, "traceID", 0, 0) require.NoError(t, err) assert.Equal(t, 1, len(req.Header)) assert.Equal(t, "/api/v2/traces/traceID", req.URL.String()) @@ -36,7 +36,7 @@ func TestTempo(t *testing.T) { t.Run("createRequest v2 with time range - success", func(t *testing.T) { service := &Service{logger: backend.NewLoggerWith("logger", "tempo-test")} - req, err := service.createRequest(context.Background(), &Datasource{}, TraceRequestApiVersionV2, "traceID", 1, 2) + req, err := service.createRequest(context.Background(), &DatasourceInfo{}, TraceRequestApiVersionV2, "traceID", 1, 2) require.NoError(t, err) assert.Equal(t, 1, len(req.Header)) assert.Equal(t, "/api/v2/traces/traceID?start=1&end=2", req.URL.String()) diff --git a/pkg/tsdb/tempo/traceql_query.go b/pkg/tsdb/tempo/traceql_query.go index 1b5d31edad5..058a7944aa0 100644 --- a/pkg/tsdb/tempo/traceql_query.go +++ b/pkg/tsdb/tempo/traceql_query.go @@ -130,7 +130,7 @@ func handleConversionError(ctxLogger log.Logger, span trace.Span, err error) (*b return nil, nil } -func (s *Service) performMetricsQuery(ctx context.Context, dsInfo *Datasource, model *dataquery.TempoQuery, query backend.DataQuery, span trace.Span) (*http.Response, []byte, error) { +func (s *Service) performMetricsQuery(ctx context.Context, dsInfo *DatasourceInfo, model *dataquery.TempoQuery, query backend.DataQuery, span trace.Span) (*http.Response, []byte, error) { ctxLogger := s.logger.FromContext(ctx) request, err := s.createMetricsQuery(ctx, dsInfo, model, query.TimeRange.From.Unix(), query.TimeRange.To.Unix()) if err != nil { @@ -156,7 +156,7 @@ func (s *Service) performMetricsQuery(ctx context.Context, dsInfo *Datasource, m return resp, body, nil } -func (s *Service) createMetricsQuery(ctx context.Context, dsInfo *Datasource, query *dataquery.TempoQuery, start int64, end int64) (*http.Request, error) { +func (s *Service) createMetricsQuery(ctx context.Context, dsInfo *DatasourceInfo, query *dataquery.TempoQuery, start int64, end int64) (*http.Request, error) { ctxLogger := s.logger.FromContext(ctx) queryType := "query_range" diff --git a/pkg/tsdb/tempo/traceql_query_test.go b/pkg/tsdb/tempo/traceql_query_test.go index cffe2c2273f..2dddec9696e 100644 --- a/pkg/tsdb/tempo/traceql_query_test.go +++ b/pkg/tsdb/tempo/traceql_query_test.go @@ -14,7 +14,7 @@ func TestCreateMetricsQuery_Success(t *testing.T) { service := &Service{ logger: logger, } - dsInfo := &Datasource{ + dsInfo := &DatasourceInfo{ URL: "http://tempo:3100", } queryVal := "{attribute=\"value\"}" @@ -40,7 +40,7 @@ func TestCreateMetricsQuery_OnlyQuery(t *testing.T) { service := &Service{ logger: logger, } - dsInfo := &Datasource{ + dsInfo := &DatasourceInfo{ URL: "http://tempo:3100", } queryVal := "{attribute=\"value\"}" @@ -60,7 +60,7 @@ func TestCreateMetricsQuery_URLParseError(t *testing.T) { service := &Service{ logger: logger, } - dsInfo := &Datasource{ + dsInfo := &DatasourceInfo{ URL: "http://[::1]:namedport", } queryVal := "{attribute=\"value\"}" diff --git a/pkg/util/scheduler/queue.go b/pkg/util/scheduler/queue.go index f2c1458987f..0fdb9523334 100644 --- a/pkg/util/scheduler/queue.go +++ b/pkg/util/scheduler/queue.go @@ -23,9 +23,17 @@ var ErrTenantQueueFull = errors.New("tenant queue full") var ErrNilRunnable = errors.New("cannot enqueue nil runnable") var ErrMissingTenantID = errors.New("item requires TenantID") +// queuedItem represents a runnable task annotated with its enqueue timestamp. +// The timestamp allows us to compute how long the task spent waiting in the queue +// before being picked up by a worker. +type queuedItem struct { + runnable func() + enqueuedAt time.Time +} + type tenantQueue struct { id string - items []func() + items []queuedItem isActive bool } @@ -43,7 +51,7 @@ func (tq *tenantQueue) isFull(maxSize int) bool { return maxSize > 0 && len(tq.items) >= maxSize } func (tq *tenantQueue) addItem(runnable func()) { - tq.items = append(tq.items, runnable) + tq.items = append(tq.items, queuedItem{runnable: runnable, enqueuedAt: time.Now()}) } type enqueueRequest struct { @@ -106,7 +114,7 @@ type Queue struct { // Metrics queueLength *prometheus.GaugeVec discardedRequests *prometheus.CounterVec - enqueueDuration prometheus.Histogram + queueWaitDuration *prometheus.HistogramVec } type QueueOptions struct { @@ -148,11 +156,13 @@ func NewQueue(opts *QueueOptions) *Queue { Name: "discarded_requests_total", Help: "Total number of discarded requests", }, []string{"tenant", "reason"}) - q.enqueueDuration = promauto.With(opts.Registerer).NewHistogram(prometheus.HistogramOpts{ - Name: "enqueue_duration_seconds", - Help: "Duration of enqueue operation in seconds", - Buckets: prometheus.DefBuckets, - }) + q.queueWaitDuration = promauto.With(opts.Registerer).NewHistogramVec(prometheus.HistogramOpts{ + Name: "queue_wait_duration_seconds", + Help: "Time items spend waiting in the queue before being dequeued, in seconds", + NativeHistogramBucketFactor: 1.1, + NativeHistogramMaxBucketNumber: 160, + NativeHistogramMinResetDuration: time.Hour, + }, []string{"tenant"}) q.Service = services.NewBasicService(nil, q.dispatcherLoop, q.stopping) @@ -175,8 +185,11 @@ func (q *Queue) scheduleRoundRobin() { tq := tenantElem.Value.(*tenantQueue) // Get and deliver the runnable item - item := tq.items[0] - req.respChan <- dequeueResponse{runnable: item, err: nil} + qi := tq.items[0] + req.respChan <- dequeueResponse{runnable: qi.runnable, err: nil} + + // Observe how long the item spent waiting in the queue. + q.queueWaitDuration.WithLabelValues(tq.id).Observe(time.Since(qi.enqueuedAt).Seconds()) // Update bookkeeping q.pendingDequeueRequests.Remove(reqElem) @@ -200,7 +213,7 @@ func (q *Queue) handleEnqueueRequest(req enqueueRequest) { if !exists { tq = &tenantQueue{ id: req.tenantID, - items: make([]func(), 0, 8), + items: make([]queuedItem, 0, 8), } q.tenantQueues[req.tenantID] = tq } @@ -275,8 +288,6 @@ func (q *Queue) Enqueue(ctx context.Context, tenantID string, runnable func()) e return ErrQueueClosed } - start := time.Now() - respChan := make(chan error, 1) req := enqueueRequest{ tenantID: tenantID, @@ -295,7 +306,6 @@ func (q *Queue) Enqueue(ctx context.Context, tenantID string, runnable func()) e q.discardedRequests.WithLabelValues(tenantID, "context_canceled").Inc() err = ctx.Err() } - q.enqueueDuration.Observe(time.Since(start).Seconds()) return err } diff --git a/pkg/util/sqlite/sqlite_nocgo.go b/pkg/util/sqlite/sqlite_nocgo.go index e3b9a0429d3..28afe0f985f 100644 --- a/pkg/util/sqlite/sqlite_nocgo.go +++ b/pkg/util/sqlite/sqlite_nocgo.go @@ -2,7 +2,28 @@ package sqlite -import "modernc.org/sqlite" +import ( + "database/sql" + "errors" + + "modernc.org/sqlite" +) + +const DriverName = "sqlite" + +// The errors below are used in tests to simulate specific SQLite errors. It's a temporary solution +// until we rewrite the tests not to depend on the sqlite3 package internals directly. +// Note: Since modernc.org/sqlite driver does not expose error codes like sqlite3, we cannot use the same approach. +var ( + TestErrUniqueConstraintViolation = errors.New("unique constraint violation (simulated)") + TestErrBusy = errors.New("database is busy (simulated)") + TestErrLocked = errors.New("database is locked (simulated)") +) + +func init() { + // alias the driver name to match the CGo driver + sql.Register("sqlite3", &Driver{}) +} // // FIXME (@zserge) diff --git a/public/app/AppWrapper.tsx b/public/app/AppWrapper.tsx index 74a71d9711b..1d5a84f954b 100644 --- a/public/app/AppWrapper.tsx +++ b/public/app/AppWrapper.tsx @@ -1,3 +1,4 @@ +import { UNSAFE_PortalProvider } from '@react-aria/overlays'; import { Action, KBarProvider } from 'kbar'; import { Component, ComponentType, Fragment, ReactNode } from 'react'; import CacheProvider from 'react-inlinesvg/provider'; @@ -5,7 +6,7 @@ import { Provider } from 'react-redux'; import { Route, Routes } from 'react-router-dom-v5-compat'; import { config, navigationLogger, reportInteraction } from '@grafana/runtime'; -import { ErrorBoundaryAlert, PortalContainer, TimeRangeProvider } from '@grafana/ui'; +import { ErrorBoundaryAlert, getPortalContainer, PortalContainer, TimeRangeProvider } from '@grafana/ui'; import { getAppRoutes } from 'app/routes/routes'; import { store } from 'app/store/store'; @@ -122,12 +123,14 @@ export class AppWrapper extends Component { - -
- - - -
+ + +
+ + + +
+
diff --git a/public/app/api/clients/provisioning/v0alpha1/endpoints.gen.ts b/public/app/api/clients/provisioning/v0alpha1/endpoints.gen.ts index 63f4e3af895..bc262a42bae 100644 --- a/public/app/api/clients/provisioning/v0alpha1/endpoints.gen.ts +++ b/public/app/api/clients/provisioning/v0alpha1/endpoints.gen.ts @@ -853,6 +853,16 @@ export type JobResourceSummary = { update?: number; write?: number; }; +export type RepositoryUrLs = { + /** Compare this version to the target branch */ + compareURL?: string; + /** A URL that will create a new pull request for this branch */ + newPullRequestURL?: string; + /** A URL pointing to the repository this lives in */ + repositoryURL?: string; + /** A URL pointing to the file or ref in the repository */ + sourceURL?: string; +}; export type JobStatus = { errors?: string[]; finished?: number; @@ -869,6 +879,8 @@ export type JobStatus = { state?: 'error' | 'pending' | 'success' | 'warning' | 'working'; /** Summary of processed actions */ summary?: JobResourceSummary[]; + /** URLs contains URLs for the reference branch or commit if applicable. */ + url?: RepositoryUrLs; }; export type Job = { /** 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 */ @@ -1001,7 +1013,7 @@ export type RepositorySpec = { - `"local"` */ type: 'bitbucket' | 'git' | 'github' | 'gitlab' | 'local'; /** UI driven Workflow that allow changes to the contends of the repository. The order is relevant for defining the precedence of the workflows. When empty, the repository does not support any edits (eg, readonly) */ - workflows: ('branch' | 'write')[]; + workflows: RepoWorkflows; }; export type HealthStatus = { /** When the health was checked last time */ @@ -1176,16 +1188,6 @@ export type ResourceObjects = { /** For write events, this will return the value that was added or updated */ upsert?: Unstructured; }; -export type ResourceUrLs = { - /** Compare this version to the target branch */ - compareURL?: string; - /** A URL that will create a new pull requeset for this branch */ - newPullRequestURL?: string; - /** A URL pointing to the repository this lives in */ - repositoryURL?: string; - /** A URL pointing to the this file in the repository */ - sourceURL?: string; -}; export type ResourceWrapper = { /** 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 */ apiVersion?: string; @@ -1206,7 +1208,7 @@ export type ResourceWrapper = { /** The modified time in the remote file system */ timestamp?: Time; /** Typed links for this file (only supported by external systems, github etc) */ - urls?: ResourceUrLs; + urls?: RepositoryUrLs; }; export type ResourceListItem = { folder?: string; @@ -1256,6 +1258,7 @@ export type WebhookResponse = { /** 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 */ kind?: string; }; +export type RepoWorkflows = ('branch' | 'write')[] export type RepositoryView = { /** For git, this is the target branch */ branch?: string; @@ -1279,7 +1282,7 @@ export type RepositoryView = { - `"local"` */ type: 'bitbucket' | 'git' | 'github' | 'gitlab' | 'local'; /** The supported workflows */ - workflows: ('branch' | 'write')[]; + workflows: RepoWorkflows; }; export type RepositoryViewList = { /** 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 */ @@ -1310,6 +1313,8 @@ export type ResourceStats = { /** Stats for each manager */ managed?: ManagerStats[]; metadata?: any; + /** Stats across all unified storage When legacy storage is still used, this will offer a shim */ + unmanaged?: ResourceCount[]; }; export const { useListJobQuery, diff --git a/public/app/api/clients/provisioning/v0alpha1/index.ts b/public/app/api/clients/provisioning/v0alpha1/index.ts index eb200d83d01..d99727c062c 100644 --- a/public/app/api/clients/provisioning/v0alpha1/index.ts +++ b/public/app/api/clients/provisioning/v0alpha1/index.ts @@ -132,12 +132,17 @@ export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({ }, }, createRepositoryJobs: { - onQueryStarted: async (_, { queryFulfilled, dispatch }) => { + onQueryStarted: async ({ jobSpec }, { queryFulfilled, dispatch }) => { try { + const showMsg = jobSpec.action === 'pull' || jobSpec.action === 'migrate'; await queryFulfilled; - dispatch( - notifyApp(createSuccessNotification(t('provisioning.sync-repository.success-pull-started', 'Pull started'))) - ); + if (showMsg) { + dispatch( + notifyApp( + createSuccessNotification(t('provisioning.sync-repository.success-pull-started', 'Pull started')) + ) + ); + } } catch (e) { if (e instanceof Error) { dispatch( diff --git a/public/app/app.ts b/public/app/app.ts index 5c48558c30c..1de53e89c3d 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -216,7 +216,7 @@ export class GrafanaApp { // Login redirect requires locationUtil to be initialized locationUtil.initialize({ - config, + config: window.grafanaBootData.settings, getTimeRangeForUrl: getTimeSrv().timeRangeForUrl, getVariablesUrlParams: getVariablesUrlParams, }); diff --git a/public/app/core/components/AppChrome/AppChromeExtensionPoint.tsx b/public/app/core/components/AppChrome/AppChromeExtensionPoint.tsx index 16d651e72bd..96d7d68d8ad 100644 --- a/public/app/core/components/AppChrome/AppChromeExtensionPoint.tsx +++ b/public/app/core/components/AppChrome/AppChromeExtensionPoint.tsx @@ -1,25 +1,16 @@ -import { useLocation } from 'react-router-dom'; - import { PluginExtensionPoints } from '@grafana/data'; import { config, renderLimitedComponents, usePluginComponents } from '@grafana/runtime'; - -const excludedRoutes: Record = { - '/login': true, - '/signup': true, - '/verify': true, - '/user/password/send-reset-email': true, - '/user/password/reset': true, - '/sandbox/benchmarks': true, -}; +import { useGrafana } from 'app/core/context/GrafanaContext'; export function AppChromeExtensionPoint(): JSX.Element | null { - const location = useLocation(); + const { chrome } = useGrafana(); + const state = chrome.useState(); if (config.featureToggles.enableAppChromeExtensions !== true) { return null; } - if (excludedRoutes[location.pathname]) { + if (state.chromeless) { return null; } diff --git a/public/app/core/components/AppChrome/MegaMenu/MegaMenu.tsx b/public/app/core/components/AppChrome/MegaMenu/MegaMenu.tsx index 1c8b3959230..36955e8d405 100644 --- a/public/app/core/components/AppChrome/MegaMenu/MegaMenu.tsx +++ b/public/app/core/components/AppChrome/MegaMenu/MegaMenu.tsx @@ -13,9 +13,6 @@ import { setBookmark } from 'app/core/reducers/navBarTree'; import { usePatchUserPreferencesMutation } from 'app/features/preferences/api/index'; import { useDispatch, useSelector } from 'app/types/store'; -import { InviteUserButton } from '../../InviteUserButton/InviteUserButton'; -import { shouldRenderInviteUserButton } from '../../InviteUserButton/utils'; - import { MegaMenuHeader } from './MegaMenuHeader'; import { MegaMenuItem } from './MegaMenuItem'; import { usePinnedItems } from './hooks'; @@ -128,11 +125,6 @@ export const MegaMenu = memo( ))} - {shouldRenderInviteUserButton && ( -
- -
- )}
); @@ -170,11 +162,6 @@ const getStyles = (theme: GrafanaTheme2) => { width: MENU_WIDTH, }, }), - inviteNewMemberButton: css({ - display: 'flex', - padding: theme.spacing(1.5, 1, 1.5, 1), - borderTop: `1px solid ${theme.colors.border.weak}`, - }), dockMenuButton: css({ display: 'none', position: 'relative', diff --git a/public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx b/public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx index 0a9a48ac6ed..3d38c543105 100644 --- a/public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx +++ b/public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx @@ -3,10 +3,8 @@ import { useMemo, useState } from 'react'; import { t } from '@grafana/i18n'; import { reportInteraction } from '@grafana/runtime'; import { Menu, Dropdown, ToolbarButton } from '@grafana/ui'; -import { getExternalUserMngLinkUrl } from 'app/features/users/utils'; import { useSelector } from 'app/types/store'; -import { performInviteUserClick, shouldRenderInviteUserButton } from '../../InviteUserButton/utils'; import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator'; import { findCreateActions } from './utils'; @@ -17,23 +15,7 @@ export const QuickAdd = ({}: Props) => { const navBarTree = useSelector((state) => state.navBarTree); const [isOpen, setIsOpen] = useState(false); - const createActions = useMemo(() => { - const actions = findCreateActions(navBarTree); - - if (shouldRenderInviteUserButton) { - actions.push({ - text: t('navigation.invite-user.invite-new-member-button', 'Invite new member'), - url: getExternalUserMngLinkUrl('invite-user-top-bar'), - target: '_blank', - isCreateAction: true, - onClick: () => { - performInviteUserClick('quick_add_button', 'invite-user-quick-add-button'); - }, - }); - } - - return actions; - }, [navBarTree]); + const createActions = useMemo(() => findCreateActions(navBarTree), [navBarTree]); const showQuickAdd = createActions.length > 0; if (!showQuickAdd) { @@ -44,18 +26,12 @@ export const QuickAdd = ({}: Props) => { return ( {createActions.map((createAction, index) => ( -
- {shouldRenderInviteUserButton && index === createActions.length - 1 && } - { - reportInteraction('grafana_menu_item_clicked', { url: createAction.url, from: 'quickadd' }); - createAction.onClick?.(); - }} - /> -
+ reportInteraction('grafana_menu_item_clicked', { url: createAction.url, from: 'quickadd' })} + /> ))}
); diff --git a/public/app/core/components/AppChrome/TopBar/InviteUserButton.test.tsx b/public/app/core/components/AppChrome/TopBar/InviteUserButton.test.tsx index d867c17488b..de2921b3915 100644 --- a/public/app/core/components/AppChrome/TopBar/InviteUserButton.test.tsx +++ b/public/app/core/components/AppChrome/TopBar/InviteUserButton.test.tsx @@ -11,6 +11,9 @@ import { InviteUserButton } from './InviteUserButton'; jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), config: { + featureToggles: { + inviteUserExperimental: true, + }, externalUserMngLinkUrl: 'https://example.com/invite', }, reportInteraction: jest.fn(), diff --git a/public/app/core/components/AppChrome/TopBar/InviteUserButton.tsx b/public/app/core/components/AppChrome/TopBar/InviteUserButton.tsx index deec044c42e..9c86223b026 100644 --- a/public/app/core/components/AppChrome/TopBar/InviteUserButton.tsx +++ b/public/app/core/components/AppChrome/TopBar/InviteUserButton.tsx @@ -1,43 +1,36 @@ import { t } from '@grafana/i18n'; -import { config, reportInteraction } from '@grafana/runtime'; import { ToolbarButton } from '@grafana/ui'; -import { contextSrv } from 'app/core/core'; import { useMediaQueryMinWidth } from 'app/core/hooks/useMediaQueryMinWidth'; -import { getExternalUserMngLinkUrl } from 'app/features/users/utils'; -import { AccessControlAction } from 'app/types/accessControl'; import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator'; +import { performInviteUserClick, shouldRenderInviteUserButton } from './InviteUserButtonUtils'; + export function InviteUserButton() { const isLargeScreen = useMediaQueryMinWidth('lg'); const handleClick = () => { try { - reportInteraction('invite_user_button_clicked', { - placement: 'top_bar_right', - }); - - const url = getExternalUserMngLinkUrl('invite-user-top-bar'); - window.open(url.toString(), '_blank'); + performInviteUserClick('top_bar_right', 'invite-user-top-bar'); } catch (error) { console.error('Failed to handle invite user click:', error); } }; - const shouldRender = config.externalUserMngLinkUrl && contextSrv.hasPermission(AccessControlAction.OrgUsersAdd); - - return shouldRender ? ( - <> - - {isLargeScreen ? t('navigation.invite-user.invite-button', 'Invite') : undefined} - - - - ) : null; + return ( + shouldRenderInviteUserButton() && ( + <> + + {isLargeScreen ? t('navigation.invite-user.invite-button', 'Invite') : undefined} + + + + ) + ); } diff --git a/public/app/core/components/AppChrome/TopBar/InviteUserButtonUtils.test.tsx b/public/app/core/components/AppChrome/TopBar/InviteUserButtonUtils.test.tsx new file mode 100644 index 00000000000..b5ac54be8c9 --- /dev/null +++ b/public/app/core/components/AppChrome/TopBar/InviteUserButtonUtils.test.tsx @@ -0,0 +1,122 @@ +import type { FeatureToggles } from '@grafana/data'; +import { reportInteraction, config } from '@grafana/runtime'; +import { contextSrv } from 'app/core/core'; +import { getExternalUserMngLinkUrl } from 'app/features/users/utils'; +import { AccessControlAction } from 'app/types/accessControl'; + +import { performInviteUserClick, shouldRenderInviteUserButton } from './InviteUserButtonUtils'; + +// Mock dependencies +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + reportInteraction: jest.fn(), + config: { + featureToggles: {} as Partial, + externalUserMngLinkUrl: '', + }, +})); + +jest.mock('app/core/core', () => ({ + contextSrv: { + hasPermission: jest.fn(), + }, +})); + +jest.mock('app/features/users/utils', () => ({ + getExternalUserMngLinkUrl: jest.fn(), +})); + +const mockReportInteraction = jest.mocked(reportInteraction); +const mockConfig = jest.mocked(config); +const mockContextSrv = jest.mocked(contextSrv); +const mockGetExternalUserMngLinkUrl = jest.mocked(getExternalUserMngLinkUrl); + +// Type assertion to make mockConfig.featureToggles assignable +const mockFeatureToggles = mockConfig.featureToggles as Partial; + +// Mock window.open +const mockWindowOpen = jest.fn(); +Object.defineProperty(window, 'open', { + value: mockWindowOpen, + writable: true, +}); + +describe('InviteUserButtonUtils', () => { + beforeEach(() => { + jest.clearAllMocks(); + + // Set up default mocks + mockFeatureToggles.inviteUserExperimental = true; + mockConfig.externalUserMngLinkUrl = 'https://example.com/invite'; + mockContextSrv.hasPermission.mockReturnValue(true); + + // Dynamic mock that returns URL with the actual cnt parameter + mockGetExternalUserMngLinkUrl.mockImplementation((cnt: string) => `https://example.com/invite?cnt=${cnt}`); + }); + + describe('shouldRenderInviteUserButton', () => { + it('should return true when all conditions are met', () => { + expect(shouldRenderInviteUserButton()).toBe(true); + expect(mockContextSrv.hasPermission).toHaveBeenCalledWith(AccessControlAction.OrgUsersAdd); + }); + + it('should return false when feature toggle is disabled', () => { + mockFeatureToggles.inviteUserExperimental = false; + + expect(shouldRenderInviteUserButton()).toBe(false); + }); + + it('should return false when URL is not configured', () => { + mockConfig.externalUserMngLinkUrl = ''; + + expect(shouldRenderInviteUserButton()).toBeFalsy(); + }); + + it('should return false when user lacks permission', () => { + mockContextSrv.hasPermission.mockReturnValue(false); + + expect(shouldRenderInviteUserButton()).toBe(false); + }); + }); + + describe('performInviteUserClick', () => { + it('should report interaction, get URL, and open window with correct cnt parameter', () => { + const placement = 'top_bar_right'; + const cnt = 'invite-user-test'; + + performInviteUserClick(placement, cnt); + + expect(mockReportInteraction).toHaveBeenCalledWith('invite_user_button_clicked', { + placement, + }); + expect(mockGetExternalUserMngLinkUrl).toHaveBeenCalledWith(cnt); + expect(mockWindowOpen).toHaveBeenCalledWith(`https://example.com/invite?cnt=${cnt}`, '_blank'); + }); + + it('should work with different cnt values', () => { + const placement = 'top_bar_right'; + const cnt = 'dashboard-header'; + + performInviteUserClick(placement, cnt); + + expect(mockReportInteraction).toHaveBeenCalledWith('invite_user_button_clicked', { + placement, + }); + expect(mockGetExternalUserMngLinkUrl).toHaveBeenCalledWith(cnt); + expect(mockWindowOpen).toHaveBeenCalledWith(`https://example.com/invite?cnt=${cnt}`, '_blank'); + }); + + it('should handle special characters in cnt parameter', () => { + const placement = 'menu-item'; + const cnt = 'admin-panel_users-section'; + + performInviteUserClick(placement, cnt); + + expect(mockReportInteraction).toHaveBeenCalledWith('invite_user_button_clicked', { + placement, + }); + expect(mockGetExternalUserMngLinkUrl).toHaveBeenCalledWith(cnt); + expect(mockWindowOpen).toHaveBeenCalledWith(`https://example.com/invite?cnt=${cnt}`, '_blank'); + }); + }); +}); diff --git a/public/app/core/components/InviteUserButton/utils.ts b/public/app/core/components/AppChrome/TopBar/InviteUserButtonUtils.tsx similarity index 72% rename from public/app/core/components/InviteUserButton/utils.ts rename to public/app/core/components/AppChrome/TopBar/InviteUserButtonUtils.tsx index 6fb774a059b..1e931c90e54 100644 --- a/public/app/core/components/InviteUserButton/utils.ts +++ b/public/app/core/components/AppChrome/TopBar/InviteUserButtonUtils.tsx @@ -1,10 +1,9 @@ -import { reportInteraction } from '@grafana/runtime'; -import { config } from 'app/core/config'; -import { contextSrv } from 'app/core/services/context_srv'; +import { reportInteraction, config } from '@grafana/runtime'; +import { contextSrv } from 'app/core/core'; import { getExternalUserMngLinkUrl } from 'app/features/users/utils'; import { AccessControlAction } from 'app/types/accessControl'; -export const shouldRenderInviteUserButton = +export const shouldRenderInviteUserButton = () => config.featureToggles.inviteUserExperimental && config.externalUserMngLinkUrl && contextSrv.hasPermission(AccessControlAction.OrgUsersAdd); diff --git a/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx index e5033c75c1c..f2b02f35bd9 100644 --- a/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx +++ b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx @@ -107,7 +107,7 @@ export const SingleTopBar = memo(function SingleTopBar({ {config.featureToggles.extensionSidebar && !isSmallScreen && } {!showToolbarLevel && actions} {!contextSrv.user.isSignedIn && } - {config.featureToggles.inviteUserExperimental && } + {profileNode && }
diff --git a/public/app/core/components/InviteUserButton/InviteUserButton.tsx b/public/app/core/components/InviteUserButton/InviteUserButton.tsx deleted file mode 100644 index 6d6b5b620fd..00000000000 --- a/public/app/core/components/InviteUserButton/InviteUserButton.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { t } from '@grafana/i18n'; -import { Button } from '@grafana/ui'; - -import { performInviteUserClick } from './utils'; - -export function InviteUserButton() { - return ( - - ); -} diff --git a/public/app/core/components/Layers/LayerDragDropList.test.tsx b/public/app/core/components/Layers/LayerDragDropList.test.tsx index ae581c0f579..b9fb1a07ec1 100644 --- a/public/app/core/components/Layers/LayerDragDropList.test.tsx +++ b/public/app/core/components/Layers/LayerDragDropList.test.tsx @@ -1,4 +1,5 @@ -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { DATA_TEST_ID, LayerDragDropList, LayerDragDropListProps } from './LayerDragDropList'; @@ -47,6 +48,69 @@ describe('LayerDragDropList', () => { expect(screen.queryAllByLabelText('Drag and drop to reorder').length).toEqual(0); }); + it('calls onDelete when delete button is clicked', async () => { + const user = userEvent.setup(); + const { props } = renderScenario({ showActions: () => true }); + + const deleteButtons = screen.getAllByLabelText('Remove'); + await user.click(deleteButtons[0]); + + expect(props.onDelete).toHaveBeenCalledTimes(1); + expect(props.onDelete).toHaveBeenCalledWith({ name: layerTwoName, getName: expect.any(Function) }); + }); + + it('calls onDuplicate when duplicate button is clicked', async () => { + const user = userEvent.setup(); + const { props } = renderScenario({ showActions: () => true }); + + const duplicateButtons = screen.getAllByLabelText('Duplicate'); + await user.click(duplicateButtons[0]); + + expect(props.onDuplicate).toHaveBeenCalledTimes(1); + expect(props.onDuplicate).toHaveBeenCalledWith({ name: layerTwoName, getName: expect.any(Function) }); + }); + + it('calls onSelect when layer row is clicked', async () => { + const user = userEvent.setup(); + const { props } = renderScenario({}); + + const layerRows = screen.getAllByRole('button'); + await user.click(layerRows[0]); + + expect(props.onSelect).toHaveBeenCalledTimes(1); + expect(props.onSelect).toHaveBeenCalledWith({ name: layerTwoName, getName: expect.any(Function) }); + }); + + it('calls onSelect when Enter key is pressed on layer row', () => { + const { props } = renderScenario({}); + + const layerRows = screen.getAllByRole('button'); + fireEvent.keyDown(layerRows[0], { key: 'Enter' }); + + expect(props.onSelect).toHaveBeenCalledTimes(1); + expect(props.onSelect).toHaveBeenCalledWith({ name: layerTwoName, getName: expect.any(Function) }); + }); + + it('calls onSelect when Space key is pressed on layer row', () => { + const { props } = renderScenario({}); + + const layerRows = screen.getAllByRole('button'); + fireEvent.keyDown(layerRows[0], { key: ' ' }); + + expect(props.onSelect).toHaveBeenCalledTimes(1); + expect(props.onSelect).toHaveBeenCalledWith({ name: layerTwoName, getName: expect.any(Function) }); + }); + + it('does not call onSelect when other keys are pressed on layer row', () => { + const { props } = renderScenario({}); + + const layerRows = screen.getAllByRole('button'); + fireEvent.keyDown(layerRows[0], { key: 'Tab' }); + fireEvent.keyDown(layerRows[0], { key: 'Escape' }); + + expect(props.onSelect).not.toHaveBeenCalled(); + }); + function renderScenario(overrides: Partial>) { const testLayers = [ { name: layerOneName, getName: () => layerOneName }, diff --git a/public/app/core/components/Layers/LayerDragDropList.tsx b/public/app/core/components/Layers/LayerDragDropList.tsx index 9966f7cb1ff..d90dfdf04af 100644 --- a/public/app/core/components/Layers/LayerDragDropList.tsx +++ b/public/app/core/components/Layers/LayerDragDropList.tsx @@ -66,7 +66,13 @@ export const LayerDragDropList = ({ ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps} - onMouseDown={() => onSelect(element)} + onClick={() => onSelect(element)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect(element); + } + }} role="button" tabIndex={0} > diff --git a/public/app/core/components/NestedFolderPicker/FolderRepo.tsx b/public/app/core/components/NestedFolderPicker/FolderRepo.tsx index b4d8c2bcf26..be50e30b5be 100644 --- a/public/app/core/components/NestedFolderPicker/FolderRepo.tsx +++ b/public/app/core/components/NestedFolderPicker/FolderRepo.tsx @@ -1,6 +1,7 @@ import { t } from '@grafana/i18n'; -import { Badge } from '@grafana/ui'; -import { useIsProvisionedInstance } from 'app/features/provisioning/hooks/useIsProvisionedInstance'; +import { Badge, Stack } from '@grafana/ui'; +import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView'; +import { getReadOnlyTooltipText } from 'app/features/provisioning/utils/repository'; import { NestedFolderDTO } from 'app/features/search/service/types'; import { FolderDTO, FolderListItemDTO } from 'app/types/folders'; @@ -9,11 +10,31 @@ export interface Props { } export function FolderRepo({ folder }: Props) { - const isProvisionedInstance = useIsProvisionedInstance(); + // skip rendering if: + // folder is not present + // folder have parentUID + // folder is not managed + const skipRender = !folder || ('parentUID' in folder && folder.parentUID) || !folder.managedBy; - if (!folder || ('parentUID' in folder && folder.parentUID) || !folder.managedBy || isProvisionedInstance) { + const { isReadOnlyRepo, repoType } = useGetResourceRepositoryView({ + folderName: skipRender ? undefined : folder?.uid, + }); + + if (skipRender) { return null; } - return ; + return ( + // badge with text and icon only has different height, we will need to adjust the layout using stretch + + {isReadOnlyRepo && ( + + )} + + + ); } diff --git a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx index 042ec10f9ce..27b34e2f41e 100644 --- a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx +++ b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx @@ -44,6 +44,9 @@ export interface NestedFolderPickerProps { /* Whether the picker should be clearable */ clearable?: boolean; + + /* HTML ID for the button element for form labels */ + id?: string; } const debouncedSearch = debounce(getSearchResults, 300); @@ -68,6 +71,7 @@ export function NestedFolderPicker({ excludeUIDs, permission = 'edit', onChange, + id, }: NestedFolderPickerProps) { const styles = useStyles2(getStyles); const selectedFolder = useGetFolderQueryFacade(value); @@ -283,6 +287,7 @@ export function NestedFolderPicker({ if (!overlayOpen) { return ( { }; describe('useFoldersQuery', () => { - let configBackup: GrafanaConfig; + let configBackup: runtime.GrafanaBootConfig; beforeAll(() => { configBackup = { ...runtime.config }; diff --git a/public/app/core/components/OptionsUI/NumberInput.tsx b/public/app/core/components/OptionsUI/NumberInput.tsx index f5ba98492b7..b96893096d8 100644 --- a/public/app/core/components/OptionsUI/NumberInput.tsx +++ b/public/app/core/components/OptionsUI/NumberInput.tsx @@ -5,6 +5,7 @@ import * as React from 'react'; import { Field, Input } from '@grafana/ui'; interface Props { + id?: string; value?: number; placeholder?: string; autoFocus?: boolean; @@ -101,6 +102,7 @@ export class NumberInput extends PureComponent { return ( void; settings?: ColorValueEditorSettings; @@ -25,7 +26,7 @@ interface Props { /** * @alpha * */ -export const ColorValueEditor = ({ value, settings, onChange, details }: Props) => { +export const ColorValueEditor = ({ value, settings, onChange, details, id }: Props) => { const theme = useTheme2(); const styles = useStyles2(getStyles); @@ -37,6 +38,7 @@ export const ColorValueEditor = ({ value, settings, onChange, details }: Props)
extends PureComponent, State> render() { const { options, isLoading } = this.state; - const { value, onChange, item } = this.props; + const { value, onChange, item, id } = this.props; const { settings } = item; return ( + inputId={id} isLoading={isLoading} value={value} defaultValue={value} diff --git a/public/app/core/components/OptionsUI/number.tsx b/public/app/core/components/OptionsUI/number.tsx index e7b0db58bbe..cd69f2b0370 100644 --- a/public/app/core/components/OptionsUI/number.tsx +++ b/public/app/core/components/OptionsUI/number.tsx @@ -6,7 +6,7 @@ import { NumberInput } from './NumberInput'; type Props = StandardEditorProps; -export const NumberValueEditor = ({ value, onChange, item }: Props) => { +export const NumberValueEditor = ({ value, onChange, item, id }: Props) => { const { settings } = item; const onValueChange = useCallback( @@ -18,6 +18,7 @@ export const NumberValueEditor = ({ value, onChange, item }: Props) => { return ( extends PureComponent, State> { render() { const { options, isLoading } = this.state; - const { value, onChange, item } = this.props; + const { value, onChange, item, id } = this.props; const { settings } = item; let current = options.find((v) => v.value === value); @@ -63,6 +63,7 @@ export class SelectValueEditor extends PureComponent, State> { } return ( + inputId={id} isLoading={isLoading} value={current} defaultValue={value} diff --git a/public/app/core/components/OptionsUI/slider.tsx b/public/app/core/components/OptionsUI/slider.tsx index b9bb41bbd90..01835ea9a38 100644 --- a/public/app/core/components/OptionsUI/slider.tsx +++ b/public/app/core/components/OptionsUI/slider.tsx @@ -11,7 +11,7 @@ import { NumberInput } from './NumberInput'; type Props = StandardEditorProps; -export const SliderValueEditor = ({ value, onChange, item }: Props) => { +export const SliderValueEditor = ({ value, onChange, item, id }: Props) => { // Input reference const inputRef = useRef(null); @@ -109,7 +109,7 @@ export const SliderValueEditor = ({ value, onChange, item }: Props) => { included={included} /> - +
diff --git a/public/app/core/components/OptionsUI/string.tsx b/public/app/core/components/OptionsUI/string.tsx index 8001bf096af..7ec4635f7c8 100644 --- a/public/app/core/components/OptionsUI/string.tsx +++ b/public/app/core/components/OptionsUI/string.tsx @@ -8,7 +8,7 @@ interface Props extends StandardEditorProps { suffix?: ReactNode; } -export const StringValueEditor = ({ value, onChange, item, suffix }: Props) => { +export const StringValueEditor = ({ value, onChange, item, suffix, id }: Props) => { const Component = item.settings?.useTextarea ? TextArea : Input; const onValueChange = useCallback( ( @@ -36,6 +36,7 @@ export const StringValueEditor = ({ value, onChange, item, suffix }: Props) => { return ( ; -export function UnitValueEditor({ value, onChange, item }: Props) { +export function UnitValueEditor({ value, onChange, item, id }: Props) { const styles = useStyles2(getStyles); if (item?.settings?.isClearable && value != null) { return (
- + ); } - return ; + return ; } const getStyles = (theme: GrafanaTheme2) => ({ diff --git a/public/app/core/components/PageActionBar/PageActionBar.tsx b/public/app/core/components/PageActionBar/PageActionBar.tsx index a9a9686fddf..7a1ce445f69 100644 --- a/public/app/core/components/PageActionBar/PageActionBar.tsx +++ b/public/app/core/components/PageActionBar/PageActionBar.tsx @@ -1,10 +1,16 @@ -import { PureComponent } from 'react'; +import { css } from '@emotion/css'; -import { SelectableValue } from '@grafana/data'; -import { LinkButton, FilterInput, InlineField } from '@grafana/ui'; +import { SelectableValue, GrafanaTheme2 } from '@grafana/data'; +import { LinkButton, FilterInput, InlineField, Checkbox, useStyles2 } from '@grafana/ui'; import { SortPicker } from '../Select/SortPicker'; +export type FilterCheckbox = { + onChange: (value: boolean) => void; + value: boolean; + label?: string; +}; + export interface Props { searchQuery: string; setSearchQuery: (value: string) => void; @@ -16,41 +22,59 @@ export interface Props { value?: string; getSortOptions?: () => Promise; }; + filterCheckbox?: FilterCheckbox; } -export default class PageActionBar extends PureComponent { - render() { - const { - searchQuery, - linkButton, - setSearchQuery, - target, - placeholder = 'Search by name or type', - sortPicker, - } = this.props; - const linkProps: Omit[0], 'children'> = { - href: linkButton?.href, - disabled: linkButton?.disabled, - }; +export default function PageActionBar({ + searchQuery, + linkButton, + setSearchQuery, + target, + placeholder = 'Search by name or type', + sortPicker, + filterCheckbox, +}: Props) { + const styles = useStyles2(getStyles); + const linkProps: Omit[0], 'children'> = { + href: linkButton?.href, + disabled: linkButton?.disabled, + }; - if (target) { - linkProps.target = target; - } - - return ( -
- - - - {sortPicker && ( - - )} - {linkButton && {linkButton.title}} -
- ); + if (target) { + linkProps.target = target; } + + return ( +
+ + + + {filterCheckbox && ( + filterCheckbox.onChange(event.currentTarget.checked)} + /> + )} + {sortPicker && ( + + )} + {linkButton && {linkButton.title}} +
+ ); } + +const getStyles = (theme: GrafanaTheme2) => { + return { + container: css({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(2), + marginBottom: theme.spacing(2), + }), + }; +}; diff --git a/public/app/core/components/QueryOperationRow/QueryOperationAction.tsx b/public/app/core/components/QueryOperationRow/QueryOperationAction.tsx index f61344fd947..a747d9dbcdf 100644 --- a/public/app/core/components/QueryOperationRow/QueryOperationAction.tsx +++ b/public/app/core/components/QueryOperationRow/QueryOperationAction.tsx @@ -11,17 +11,25 @@ interface BaseQueryOperationActionProps { onClick: (e: React.MouseEvent) => void; disabled?: boolean; dataTestId?: string; + isGroupEnd?: boolean; + isHighlighted?: boolean; } function BaseQueryOperationAction(props: QueryOperationActionProps | QueryOperationToggleActionProps) { const styles = useStyles2(getStyles); return ( -
+
{ position: 'relative', color: theme.colors.text.secondary, }), + highlighted: css({ + color: theme.colors.primary.main, + }), + + iconGroupEnd: css({ + borderRight: `1px solid ${theme.colors.border.medium}`, + paddingRight: theme.spacing(0.5), + }), active: css({ '&:before': { display: 'block', diff --git a/public/app/core/components/Select/OldFolderPicker.test.tsx b/public/app/core/components/Select/OldFolderPicker.test.tsx deleted file mode 100644 index 69fb67c5c9a..00000000000 --- a/public/app/core/components/Select/OldFolderPicker.test.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import selectEvent from 'react-select-event'; - -import { selectors } from '@grafana/e2e-selectors'; -import { contextSrv } from 'app/core/core'; -import * as api from 'app/features/manage-dashboards/state/actions'; - -import { DashboardSearchHit } from '../../../features/search/types'; - -import { OldFolderPicker, getInitialValues } from './OldFolderPicker'; - -describe('OldFolderPicker', () => { - it('should render', async () => { - jest - .spyOn(api, 'searchFolders') - .mockResolvedValue([ - { title: 'Dash 1', uid: 'xMsQdBfWz' } as DashboardSearchHit, - { title: 'Dash 2', uid: 'wfTJJL5Wz' } as DashboardSearchHit, - ]); - - render(); - expect(await screen.findByTestId(selectors.components.FolderPicker.containerV2)).toBeInTheDocument(); - }); - - it('Should apply filter to the folders search results', async () => { - jest - .spyOn(api, 'searchFolders') - .mockResolvedValue([ - { title: 'Dash 1', uid: 'xMsQdBfWz' } as DashboardSearchHit, - { title: 'Dash 2', uid: 'wfTJJL5Wz' } as DashboardSearchHit, - { title: 'Dash 3', uid: '7MeksYbmk' } as DashboardSearchHit, - ]); - - render( hits.filter((h) => h.uid !== 'wfTJJL5Wz')} />); - - const pickerContainer = screen.getByTestId(selectors.components.FolderPicker.input); - selectEvent.openMenu(pickerContainer); - - const pickerOptions = await screen.findAllByTestId(selectors.components.Select.option); - - expect(pickerOptions).toHaveLength(2); - expect(pickerOptions[0]).toHaveTextContent('Dash 1'); - expect(pickerOptions[1]).toHaveTextContent('Dash 3'); - }); - - it('should allow creating a new option', async () => { - const newFolder = { title: 'New Folder', uid: '7MeksYbmk' } as DashboardSearchHit; - - jest - .spyOn(api, 'searchFolders') - .mockResolvedValue([ - { title: 'Dash 1', uid: 'xMsQdBfWz' } as DashboardSearchHit, - { title: 'Dash 2', uid: 'wfTJJL5Wz' } as DashboardSearchHit, - ]); - - const onChangeFn = jest.fn(); - - const create = jest.spyOn(api, 'createFolder').mockResolvedValue(newFolder); - - render(); - expect(await screen.findByTestId(selectors.components.FolderPicker.containerV2)).toBeInTheDocument(); - - await userEvent.type(screen.getByTestId(selectors.components.FolderPicker.input), newFolder.title); - const enter = await screen.findByText('Hit enter to add'); - - await userEvent.click(enter); - await waitFor(() => { - expect(create).toHaveBeenCalledWith({ title: newFolder.title }); - }); - - expect(onChangeFn).toHaveBeenCalledWith({ title: newFolder.title, uid: newFolder.uid }); - await waitFor(() => { - expect(screen.getByText(newFolder.title)).toBeInTheDocument(); - }); - }); - - it('should show the Dashboards root by default for editors', async () => { - jest - .spyOn(api, 'searchFolders') - .mockResolvedValue([ - { title: 'Dash 1', uid: 'xMsQdBfWz' } as DashboardSearchHit, - { title: 'Dash 2', uid: 'wfTJJL5Wz' } as DashboardSearchHit, - ]); - - jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true); - - const onChangeFn = jest.fn(); - render(); - expect(await screen.findByTestId(selectors.components.FolderPicker.containerV2)).toBeInTheDocument(); - const pickerContainer = screen.getByTestId(selectors.components.FolderPicker.input); - selectEvent.openMenu(pickerContainer); - - const pickerOptions = await screen.findAllByTestId(selectors.components.Select.option); - - expect(pickerOptions[0]).toHaveTextContent('Dashboards'); - }); - - it('should not show the Dashboards root by default if showRoot is false', async () => { - jest - .spyOn(api, 'searchFolders') - .mockResolvedValue([ - { title: 'Dash 1', uid: 'xMsQdBfWz' } as DashboardSearchHit, - { title: 'Dash 2', uid: 'wfTJJL5Wz' } as DashboardSearchHit, - ]); - - jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true); - - const onChangeFn = jest.fn(); - render(); - expect(await screen.findByTestId(selectors.components.FolderPicker.containerV2)).toBeInTheDocument(); - const pickerContainer = screen.getByTestId(selectors.components.FolderPicker.input); - selectEvent.openMenu(pickerContainer); - - const pickerOptions = await screen.findAllByTestId(selectors.components.Select.option); - - expect(pickerOptions[0]).not.toHaveTextContent('Dashboards'); - }); - - it('should not show the Dashboards root by default for not editors', async () => { - jest - .spyOn(api, 'searchFolders') - .mockResolvedValue([ - { title: 'Dash 1', uid: 'xMsQdBfWz' } as DashboardSearchHit, - { title: 'Dash 2', uid: 'wfTJJL5Wz' } as DashboardSearchHit, - ]); - - jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false); - - const onChangeFn = jest.fn(); - render(); - expect(await screen.findByTestId(selectors.components.FolderPicker.containerV2)).toBeInTheDocument(); - const pickerContainer = screen.getByTestId(selectors.components.FolderPicker.input); - selectEvent.openMenu(pickerContainer); - - const pickerOptions = await screen.findAllByTestId(selectors.components.Select.option); - - expect(pickerOptions[0]).not.toHaveTextContent('Dashboards'); - }); - - it('should return the correct search results when typing in the select', async () => { - jest.spyOn(api, 'searchFolders').mockImplementation((query: string) => { - return Promise.resolve( - [ - { title: 'Dash Test', uid: 'xMsQdBfWz' } as DashboardSearchHit, - { title: 'Dash Two', uid: 'wfTJJL5Wz' } as DashboardSearchHit, - ].filter((dash) => dash.title.indexOf(query) > -1) - ); - }); - jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false); - const onChangeFn = jest.fn(); - render(); - - const pickerContainer = screen.getByTestId(selectors.components.FolderPicker.input); - await userEvent.type(pickerContainer, 'Test'); - - expect(await screen.findByText('Dash Test')).toBeInTheDocument(); - expect(screen.queryByText('Dash Two')).not.toBeInTheDocument(); - }); -}); - -describe('getInitialValues', () => { - describe('when called with folderUid and title', () => { - it('then it should return folderUid and title', async () => { - const getFolder = jest.fn().mockResolvedValue({}); - const folder = await getInitialValues({ folderUid: '', folderName: 'Some title', getFolder }); - - expect(folder).toEqual({ label: 'Some title', value: '' }); - expect(getFolder).not.toHaveBeenCalled(); - }); - }); - - describe('when called with just a folderUid', () => { - it('then it should call api to retrieve title', async () => { - const getFolder = jest.fn().mockResolvedValue({ uid: '', title: 'Title from api' }); - const folder = await getInitialValues({ folderUid: '', getFolder }); - - expect(folder).toEqual({ label: 'Title from api', value: '' }); - expect(getFolder).toHaveBeenCalledTimes(1); - expect(getFolder).toHaveBeenCalledWith(''); - }); - }); - - describe('when called without folderUid', () => { - it('then it should throw an error', async () => { - const getFolder = jest.fn().mockResolvedValue({}); - await expect(getInitialValues({ getFolder })).rejects.toThrow(); - }); - }); -}); diff --git a/public/app/core/components/Select/OldFolderPicker.tsx b/public/app/core/components/Select/OldFolderPicker.tsx deleted file mode 100644 index 13ddca85986..00000000000 --- a/public/app/core/components/Select/OldFolderPicker.tsx +++ /dev/null @@ -1,395 +0,0 @@ -import { css } from '@emotion/css'; -import debounce from 'debounce-promise'; -import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; -import * as React from 'react'; -import { useAsync } from 'react-use'; - -import { AppEvents, GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; -import { Trans, t } from '@grafana/i18n'; -import { reportInteraction } from '@grafana/runtime'; -import { ActionMeta, AsyncVirtualizedSelect, Input, InputActionMeta, useStyles2 } from '@grafana/ui'; -import appEvents from 'app/core/app_events'; -import { contextSrv } from 'app/core/services/context_srv'; -import { createFolder, getFolderByUid, searchFolders } from 'app/features/manage-dashboards/state/actions'; -import { DashboardSearchHit } from 'app/features/search/types'; -import { AccessControlAction } from 'app/types/accessControl'; -import { PermissionLevelString, SearchQueryType } from 'app/types/acl'; - -export type FolderPickerFilter = (hits: DashboardSearchHit[]) => DashboardSearchHit[]; - -export const ADD_NEW_FOLER_OPTION = '+ Add new'; - -export interface FolderWarning { - warningCondition: (value: string) => boolean; - warningComponent: () => JSX.Element; -} - -export interface CustomAdd { - disallowValues: boolean; - isAllowedValue?: (value: string) => boolean; -} - -export interface Props { - onChange: ($folder: { title: string; uid: string }) => void; - enableCreateNew?: boolean; - rootName?: string; - enableReset?: boolean; - dashboardId?: number | string; - initialTitle?: string; - initialFolderUid?: string; - permissionLevel?: Exclude; - filter?: FolderPickerFilter; - allowEmpty?: boolean; - showRoot?: boolean; - onClear?: () => void; - searchQueryType?: SearchQueryType; - customAdd?: CustomAdd; - folderWarning?: FolderWarning; - - /** - * Skips loading all folders in order to find the folder matching - * the folder where the dashboard is stored. - * Instead initialFolderUid and initialTitle will be used to display the correct folder. - * initialFolderUid needs to be a string or an error will be thrown. - */ - skipInitialLoad?: boolean; - /** The id of the search input. Use this to set a matching label with htmlFor */ - inputId?: string; - invalid?: boolean; -} - -export type SelectedFolder = SelectableValue; -const VALUE_FOR_ADD = '-10'; - -export function OldFolderPicker(props: Props) { - const { - dashboardId, - allowEmpty, - onChange, - filter, - enableCreateNew, - inputId, - onClear, - enableReset, - initialFolderUid, - initialTitle = '', - permissionLevel = PermissionLevelString.Edit, - rootName: rootNameProp, - showRoot = true, - skipInitialLoad, - searchQueryType, - customAdd, - folderWarning, - invalid, - } = props; - - const rootName = rootNameProp ?? 'Dashboards'; - - const [folder, setFolder] = useState(null); - const [isCreatingNew, setIsCreatingNew] = useState(false); - const [inputValue, setInputValue] = useState(''); - const [newFolderValue, setNewFolderValue] = useState(folder?.title ?? ''); - - const styles = useStyles2(getStyles); - - const isClearable = typeof onClear === 'function'; - - const getOptions = useCallback( - async (query: string) => { - const searchHits = await searchFolders(query, permissionLevel, searchQueryType); - const resultsAfterMapAndFilter = mapSearchHitsToOptions(searchHits, filter); - const options: Array> = resultsAfterMapAndFilter; - - reportInteraction('grafana_folder_picker_results_loaded', { - results: options.length, - searchTermLength: query.length, - enableCreateNew: Boolean(enableCreateNew), - }); - - const hasAccess = - contextSrv.hasPermission(AccessControlAction.DashboardsWrite) || - contextSrv.hasPermission(AccessControlAction.DashboardsCreate); - - if (hasAccess && rootName?.toLowerCase().startsWith(query.toLowerCase()) && showRoot) { - options.unshift({ label: rootName, value: '' }); - } - - if ( - enableReset && - query === '' && - initialTitle !== '' && - !options.find((option) => option.label === initialTitle) - ) { - options.unshift({ label: initialTitle, value: initialFolderUid }); - } - if (enableCreateNew && Boolean(customAdd)) { - return [...options, { value: VALUE_FOR_ADD, label: ADD_NEW_FOLER_OPTION, title: query }]; - } else { - return options; - } - }, - [ - enableReset, - initialFolderUid, - initialTitle, - permissionLevel, - rootName, - showRoot, - searchQueryType, - filter, - enableCreateNew, - customAdd, - ] - ); - - const debouncedSearch = useMemo(() => { - return debounce(getOptions, 300, { leading: true }); - }, [getOptions]); - - const loadInitialValue = async () => { - const resetFolder: SelectableValue = { label: initialTitle, value: undefined }; - const rootFolder: SelectableValue = { label: rootName, value: '' }; - - const options = await getOptions(''); - - let folder: SelectableValue | null = null; - - if (initialFolderUid !== undefined && initialFolderUid !== null) { - folder = options.find((option) => option.value === initialFolderUid) || null; - } else if (enableReset && initialTitle) { - folder = resetFolder; - } else if (initialFolderUid) { - folder = options.find((option) => option.id === initialFolderUid) || null; - } - - if (!folder && !allowEmpty) { - if (contextSrv.isEditor) { - folder = rootFolder; - } else { - // We shouldn't assign a random folder without the user actively choosing it on a persisted dashboard - const isPersistedDashBoard = !!dashboardId; - if (isPersistedDashBoard) { - folder = resetFolder; - } else { - folder = options.length > 0 ? options[0] : resetFolder; - } - } - } - !isCreatingNew && setFolder(folder); - }; - - useEffect(() => { - // if this is not the same as our initial value notify parent - if (folder && folder.value !== initialFolderUid) { - !isCreatingNew && folder.value && folder.label && onChange({ uid: folder.value, title: folder.label }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [folder, initialFolderUid]); - - // initial values for dropdown - useAsync(async () => { - if (skipInitialLoad) { - const folder = await getInitialValues({ - getFolder: getFolderByUid, - folderUid: initialFolderUid, - folderName: initialTitle, - }); - setFolder(folder); - } - - await loadInitialValue(); - }, [skipInitialLoad, initialFolderUid, initialTitle]); - - useEffect(() => { - if (folder && folder.id === VALUE_FOR_ADD) { - setIsCreatingNew(true); - } - }, [folder]); - - const onFolderChange = useCallback( - (newFolder: SelectableValue | null | undefined, actionMeta: ActionMeta) => { - if (newFolder?.value === VALUE_FOR_ADD) { - setFolder({ - id: VALUE_FOR_ADD, - title: inputValue, - }); - setNewFolderValue(inputValue); - } else { - if (!newFolder) { - newFolder = { value: '', label: rootName }; - } - - if (actionMeta.action === 'clear' && onClear) { - onClear(); - return; - } - - setFolder(newFolder); - onChange({ uid: newFolder.value!, title: newFolder.label! }); - } - }, - [onChange, onClear, rootName, inputValue] - ); - - const createNewFolder = useCallback( - async (folderName: string) => { - if (folderWarning?.warningCondition(folderName)) { - reportInteraction('grafana_folder_picker_folder_created', { status: 'failed_condition' }); - return false; - } - - const newFolder = await createFolder({ title: folderName }); - let folder: SelectableValue = { value: '', label: 'Not created' }; - - if (newFolder.uid) { - reportInteraction('grafana_folder_picker_folder_created', { status: 'success' }); - appEvents.emit(AppEvents.alertSuccess, ['Folder Created', 'OK']); - folder = { value: newFolder.uid, label: newFolder.title }; - - setFolder(newFolder); - onFolderChange(folder, { action: 'create-option', option: folder }); - } else { - reportInteraction('grafana_folder_picker_folder_created', { status: 'failed' }); - appEvents.emit(AppEvents.alertError, ['Folder could not be created']); - } - - return folder; - }, - [folderWarning, onFolderChange] - ); - - const onKeyDown = useCallback( - (event: React.KeyboardEvent) => { - const dissalowValues = Boolean(customAdd?.disallowValues); - if (event.key === 'Enter' && dissalowValues && !customAdd?.isAllowedValue!(newFolderValue)) { - event.preventDefault(); - return; - } - - switch (event.key) { - case 'Enter': { - createNewFolder(folder?.title!); - setIsCreatingNew(false); - break; - } - case 'Escape': { - setFolder({ value: '', label: rootName }); - setIsCreatingNew(false); - } - } - }, - [customAdd?.disallowValues, customAdd?.isAllowedValue, newFolderValue, createNewFolder, folder?.title, rootName] - ); - - const onNewFolderChange = (e: FormEvent) => { - const value = e.currentTarget.value; - setNewFolderValue(value); - setFolder({ id: undefined, title: value }); - }; - - const onBlur = () => { - setFolder({ value: '', label: rootName }); - setIsCreatingNew(false); - }; - - const onInputChange = (value: string, { action }: InputActionMeta) => { - if (action === 'input-change') { - setInputValue((ant) => value); - } - - if (action === 'menu-close') { - setInputValue((_) => value); - } - return; - }; - - const FolderWarningWhenCreating = () => { - if (folderWarning?.warningCondition(newFolderValue)) { - return ; - } else { - return null; - } - }; - - const FolderWarningWhenSearching = () => { - if (folderWarning?.warningCondition(inputValue)) { - return ; - } else { - return null; - } - }; - - if (isCreatingNew) { - return ( - <> - -
- Press enter to create the new folder. -
- - - ); - } else { - return ( -
- - -
- ); - } -} - -function mapSearchHitsToOptions(hits: DashboardSearchHit[], filter?: FolderPickerFilter) { - const filteredHits = filter ? filter(hits) : hits; - return filteredHits.map((hit) => ({ label: hit.title, value: hit.uid })); -} -interface Args { - getFolder: typeof getFolderByUid; - folderUid?: string; - folderName?: string; -} - -export async function getInitialValues({ folderName, folderUid, getFolder }: Args): Promise> { - if (folderUid === null || folderUid === undefined) { - throw new Error('folderUid is not found.'); - } - - if (folderName) { - return { label: folderName, value: folderUid }; - } - - const folderDto = await getFolder(folderUid); - return { label: folderDto.title, value: folderUid }; -} - -const getStyles = (theme: GrafanaTheme2) => ({ - newFolder: css({ - color: theme.colors.warning.main, - fontSize: theme.typography.bodySmall.fontSize, - paddingBottom: theme.spacing(1), - }), -}); diff --git a/public/app/core/config.ts b/public/app/core/config.ts index ac3c63f01c6..6c757c9436a 100644 --- a/public/app/core/config.ts +++ b/public/app/core/config.ts @@ -1,6 +1,6 @@ import { PluginState } from '@grafana/data'; import { config, GrafanaBootConfig } from '@grafana/runtime'; -export { config, GrafanaBootConfig as Settings }; +export { config, type GrafanaBootConfig as Settings }; let grafanaConfig: GrafanaBootConfig = config; diff --git a/public/app/core/context/GrafanaContext.ts b/public/app/core/context/GrafanaContext.ts index 1f3bf86372b..edda20034cc 100644 --- a/public/app/core/context/GrafanaContext.ts +++ b/public/app/core/context/GrafanaContext.ts @@ -1,7 +1,6 @@ import { createContext, useCallback, useContext } from 'react'; -import { GrafanaConfig } from '@grafana/data'; -import { LocationService, locationService, BackendSrv } from '@grafana/runtime'; +import { LocationService, locationService, BackendSrv, GrafanaBootConfig } from '@grafana/runtime'; import { AppChromeService } from '../components/AppChrome/AppChromeService'; import { NewFrontendAssetsChecker } from '../services/NewFrontendAssetsChecker'; @@ -10,7 +9,7 @@ import { KeybindingSrv } from '../services/keybindingSrv'; export interface GrafanaContextType { backend: BackendSrv; location: LocationService; - config: GrafanaConfig; + config: GrafanaBootConfig; chrome: AppChromeService; keybindings: KeybindingSrv; newAssetsChecker: NewFrontendAssetsChecker; diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index f518dc83df7..193a03a6847 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -1,5 +1,16 @@ import FingerprintJS from '@fingerprintjs/fingerprintjs'; -import { from, lastValueFrom, MonoTypeOperatorFunction, Observable, Subject, Subscription, throwError } from 'rxjs'; +import { + from, + lastValueFrom, + MonoTypeOperatorFunction, + Observable, + Observer, + OperatorFunction, + Subject, + Subscriber, + Subscription, + throwError, +} from 'rxjs'; import { fromFetch } from 'rxjs/fetch'; import { catchError, @@ -149,13 +160,6 @@ export class BackendSrv implements BackendService { const requestId = options.requestId ?? `chunked-${this.chunkRequestId++}`; const controller = new AbortController(); - let url: string; - try { - url = parseUrlFromOptions(options); - } catch (error) { - return throwError(() => error); - } - const init = parseInitFromOptions({ ...options, requestId, @@ -163,62 +167,10 @@ export class BackendSrv implements BackendService { }); return new Observable((observer) => { - let done = false; // Calling fromFetch explicitly avoids the request queue - const sub = this.dependencies.fromFetch(url, init).subscribe({ - next: (response) => { - const rsp = { - status: response.status, - statusText: response.statusText, - ok: response.ok, - headers: response.headers, - url: response.url, - type: response.type, - redirected: response.redirected, - config: options, - traceId: response.headers.get(GRAFANA_TRACEID_HEADER) ?? undefined, - data: undefined, - }; - - if (!response.body) { - observer.next(rsp); - observer.complete(); - return; - } - - const reader = response.body.getReader(); - async function process() { - while (reader && !done) { - if (controller.signal.aborted) { - reader.cancel(controller.signal.reason); - console.log(requestId, 'signal.aborted'); - return; - } - const chunk = await reader.read(); - observer.next({ - ...rsp, - data: chunk.value, - }); - if (chunk.done) { - done = true; - console.log(requestId, 'done'); - } - } - } - process() - .then(() => { - console.log(requestId, 'complete'); - observer.complete(); - }) // runs in background - .catch((e) => { - console.log(requestId, 'catch', e); - observer.error(e); - }); // from abort - }, - error: (e) => { - observer.error(e); - }, - }); + const sub = parseUrlFromOptions(options) + .pipe(mergeMap((url) => this.dependencies.fromFetch(url, init))) + .subscribe(this.getChunkedResponseObserver({ controller, observer, options, requestId })); return function unsubscribe() { console.log(requestId, 'unsubscribe'); @@ -228,6 +180,76 @@ export class BackendSrv implements BackendService { }); } + private getChunkedResponseObserver({ + controller, + observer, + options, + requestId, + }: { + controller: AbortController; + observer: Subscriber | undefined>>; + options: BackendSrvRequest; + requestId: string; + }): Partial> { + let done = false; + return { + next: (response) => { + const rsp = { + status: response.status, + statusText: response.statusText, + ok: response.ok, + headers: response.headers, + url: response.url, + type: response.type, + redirected: response.redirected, + config: options, + traceId: response.headers.get(GRAFANA_TRACEID_HEADER) ?? undefined, + data: undefined, + }; + + if (!response.body) { + observer.next(rsp); + observer.complete(); + return; + } + + const reader = response.body.getReader(); + + // Setup onabort callback so that we can cancel the reader properly + controller.signal.onabort = () => { + reader.cancel(controller.signal.reason); + console.log(requestId, 'signal.aborted'); + }; + + async function process() { + while (reader && !done) { + const chunk = await reader.read(); + observer.next({ + ...rsp, + data: chunk.value, + }); + if (chunk.done) { + done = true; + console.log(requestId, 'done'); + } + } + } + process() + .then(() => { + console.log(requestId, 'complete'); + observer.complete(); + }) // runs in background + .catch((e) => { + console.log(requestId, 'catch', e); + observer.error(e); + }); // from abort + }, + error: (e) => { + observer.error(e); + }, + }; + } + private internalFetch(options: BackendSrvRequest): Observable> { if (options.requestId) { this.inFlightRequests.next(options.requestId); @@ -248,7 +270,8 @@ export class BackendSrv implements BackendService { options.headers['X-Grafana-Device-Id'] = `${this.deviceID}`; } - return this.getFromFetchStream(options).pipe( + return parseUrlFromOptions(options).pipe( + this.getFromFetchStream(options), this.handleStreamResponse(options), this.handleStreamError(options), this.handleStreamCancellation(options) @@ -302,38 +325,36 @@ export class BackendSrv implements BackendService { return options; } - private getFromFetchStream(options: BackendSrvRequest): Observable> { - const init = parseInitFromOptions(options); + private getFromFetchStream(options: BackendSrvRequest): OperatorFunction> { + return (inputStream) => + inputStream.pipe( + mergeMap((url) => { + const init = parseInitFromOptions(options); - let url: string; - try { - url = parseUrlFromOptions(options); - } catch (error) { - return throwError(() => error); - } + return this.dependencies.fromFetch(url, init).pipe( + mergeMap(async (response) => { + const { status, statusText, ok, headers, url, type, redirected } = response; - return this.dependencies.fromFetch(url, init).pipe( - mergeMap(async (response) => { - const { status, statusText, ok, headers, url, type, redirected } = response; + const responseType = options.responseType ?? (isContentTypeJson(headers) ? 'json' : undefined); - const responseType = options.responseType ?? (isContentTypeJson(headers) ? 'json' : undefined); - - const data = await parseResponseBody(response, responseType); - const fetchResponse: FetchResponse = { - status, - statusText, - ok, - data, - headers, - url, - type, - redirected, - config: options, - traceId: response.headers.get(GRAFANA_TRACEID_HEADER) ?? undefined, - }; - return fetchResponse; - }) - ); + const data = await parseResponseBody(response, responseType); + const fetchResponse: FetchResponse = { + status, + statusText, + ok, + data, + headers, + url, + type, + redirected, + config: options, + traceId: response.headers.get(GRAFANA_TRACEID_HEADER) ?? undefined, + }; + return fetchResponse; + }) + ); + }) + ); } showApplicationErrorAlert(err: FetchError) {} diff --git a/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.test.ts b/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.test.ts index 6e6188454d8..13d18fb8977 100644 --- a/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.test.ts +++ b/public/app/core/services/echo/backends/grafana-javascript-agent/GrafanaJavascriptAgentBackend.test.ts @@ -55,8 +55,10 @@ describe('GrafanaJavascriptAgentEchoBackend', () => { }); const buildInfo: BuildInfo = { + buildstamp: 12345, version: '1.0', commit: 'abcd123', + commitShort: 'abc', env: 'production', versionString: 'Grafana v1.0 (abcd123)', edition: GrafanaEdition.OpenSource, diff --git a/public/app/core/specs/backend_srv.test.ts b/public/app/core/specs/backend_srv.test.ts index 097788913d8..9ad572dbac3 100644 --- a/public/app/core/specs/backend_srv.test.ts +++ b/public/app/core/specs/backend_srv.test.ts @@ -1,4 +1,4 @@ -import { Observable, of, lastValueFrom } from 'rxjs'; +import { Observable, of, lastValueFrom, throwError } from 'rxjs'; import { fromFetch } from 'rxjs/fetch'; import { delay } from 'rxjs/operators'; @@ -782,22 +782,19 @@ describe('backendSrv', () => { await expect(promise).rejects.toThrow(PathValidationError); }); - it('should sanitise paths when calling .fetch', (done) => { + it('should sanitise paths when calling .fetch', async () => { const { backendSrv } = getTestContext(); const maliciousUrl = '/api/users/%2e%2e/admin'; - const observable = backendSrv.fetch({ url: maliciousUrl, method: 'GET', validatePath: true }); + await expect(backendSrv.fetch({ url: maliciousUrl, method: 'GET', validatePath: true })).toEmitValuesWith( + (received) => { + expect(received.length).toEqual(1); - observable.subscribe({ - next: () => { - throw new Error('Should not succeed'); - }, - error: (err) => { - expect(err).toBeInstanceOf(PathValidationError); - expect(err.message).toBe('Invalid request path'); - done(); - }, - }); + const processed: FetchError = received[0]; + expect(processed).toBeInstanceOf(PathValidationError); + expect(processed.message).toBe('Invalid request path'); + } + ); }); }); @@ -883,4 +880,255 @@ describe('backendSrv', () => { }); }); }); + + describe('chunked', () => { + beforeEach(() => { + // we do a bunch of console.log in the chunked function + jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('when making a successful chunked request', () => { + it('then it should return chunks of data', async () => { + const url = '/api/chunked-data'; + const { backendSrv, fromFetchMock } = getTestContext({ url }); + + // Mock a ReadableStream with chunks + const chunks = [new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6]), new Uint8Array([7, 8, 9])]; + + let chunkIndex = 0; + const mockReader = { + read: jest.fn().mockImplementation(() => { + if (chunkIndex < chunks.length) { + return Promise.resolve({ + done: false, + value: chunks[chunkIndex++], + }); + } + return Promise.resolve({ done: true, value: undefined }); + }), + cancel: jest.fn(), + }; + + const mockBody = { + getReader: jest.fn().mockReturnValue(mockReader), + }; + + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + headers: [], + body: mockBody, + url, + type: 'basic', + redirected: false, + }; + + fromFetchMock.mockReturnValue(of(mockResponse)); + + const options = { url, method: 'GET' }; + + await expect(backendSrv.chunked(options)).toEmitValuesWith((received) => { + const { body, ...rest } = mockResponse; + const config = { ...options }; + const expected = { ...rest, config }; + expect(received).toHaveLength(4); // 3 chunks + 1 final response + expect(received[0]).toEqual({ ...expected, data: new Uint8Array([1, 2, 3]) }); + expect(received[1]).toEqual({ ...expected, data: new Uint8Array([4, 5, 6]) }); + expect(received[2]).toEqual({ ...expected, data: new Uint8Array([7, 8, 9]) }); + expect(received[3]).toEqual({ ...expected, data: undefined }); + expect(mockReader.read).toHaveBeenCalledTimes(4); + }); + }); + }); + + describe('when request is cancelled', () => { + it('then it should abort the request and cancel the reader', async () => { + jest.useFakeTimers(); + const url = '/api/chunked-data'; + const { backendSrv, fromFetchMock } = getTestContext({ url }); + + const mockReader = { + read: jest.fn().mockImplementation(() => { + return new Promise(() => {}); // Never resolves + }), + cancel: jest.fn(), + }; + + const mockBody = { + getReader: jest.fn().mockReturnValue(mockReader), + }; + + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + headers: [], + body: mockBody, + url, + type: 'basic', + redirected: false, + }; + + fromFetchMock.mockReturnValue(of(mockResponse)); + + const options = { url, method: 'GET' }; + + const subscription = backendSrv.chunked(options).subscribe(); + + // Cancel the request + subscription.unsubscribe(); + + // Fast-forward until all timers have been executed + jest.advanceTimersByTime(100); + + expect(mockReader.cancel).toHaveBeenCalled(); + }); + }); + + describe('when request throws an error', () => { + it('then it should complete immediately', async () => { + const url = '/api/chunked-data'; + const { backendSrv, fromFetchMock } = getTestContext({ url }); + + fromFetchMock.mockReturnValue(throwError(() => new Error('Server error'))); + + const options = { url, method: 'GET' }; + + await expect(backendSrv.chunked(options)).toEmitValuesWith((received) => { + expect(received).toHaveLength(1); + + const error: FetchError = received[0]; + + expect(error).toBeInstanceOf(Error); + expect(error.message).toEqual('Server error'); + }); + }); + }); + + describe('when response has no body', () => { + it('then it should complete immediately', async () => { + const url = '/api/chunked-data'; + const { backendSrv, fromFetchMock } = getTestContext({ url }); + + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + headers: [], + body: null, + url, + type: 'basic', + redirected: false, + }; + + fromFetchMock.mockReturnValue(of(mockResponse)); + + const options = { url, method: 'GET' }; + + await expect(backendSrv.chunked(options)).toEmitValuesWith((received) => { + const { body, ...rest } = mockResponse; + const config = { ...options }; + const expected = { ...rest, config }; + expect(received).toHaveLength(1); + expect(received[0]).toEqual({ ...expected, data: undefined }); + }); + }); + }); + + describe('when validatePath is true and url is malicious', () => { + it('then it should throw an PathValidationError', async () => { + const url = '/api/users/%2e%2e/admin'; + const { backendSrv, fromFetchMock } = getTestContext({ url }); + + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + headers: [], + body: null, + url, + type: 'basic', + redirected: false, + }; + + fromFetchMock.mockReturnValue(of(mockResponse)); + + const options = { url, method: 'GET', validatePath: true }; + + await expect(backendSrv.chunked(options)).toEmitValuesWith((received) => { + expect(received).toHaveLength(1); + + const error: FetchError = received[0]; + + expect(error).toBeInstanceOf(PathValidationError); + expect(error.message).toEqual('Invalid request path'); + }); + }); + }); + + describe('when validatePath is true and url is not malicious', () => { + it('then it should return correct chunks', async () => { + const url = '/api/chunked-data'; + const { backendSrv, fromFetchMock } = getTestContext({ url }); + + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + headers: [], + body: null, + url, + type: 'basic', + redirected: false, + }; + + fromFetchMock.mockReturnValue(of(mockResponse)); + + const options = { url, method: 'GET', validatePath: true }; + + await expect(backendSrv.chunked(options)).toEmitValuesWith((received) => { + const { body, ...rest } = mockResponse; + const config = { ...options }; + const expected = { ...rest, config }; + expect(received).toHaveLength(1); + expect(received[0]).toEqual({ ...expected, data: undefined }); + }); + }); + }); + + describe('when validatePath is false and url is malicious', () => { + it('then it should return correct chunks', async () => { + const url = '/api/users/%2e%2e/admin'; + const { backendSrv, fromFetchMock } = getTestContext({ url }); + + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + headers: [], + body: null, + url, + type: 'basic', + redirected: false, + }; + + fromFetchMock.mockReturnValue(of(mockResponse)); + + const options = { url, method: 'GET', validatePath: false }; + + await expect(backendSrv.chunked(options)).toEmitValuesWith((received) => { + const { body, ...rest } = mockResponse; + const config = { ...options }; + const expected = { ...rest, config }; + expect(received).toHaveLength(1); + expect(received[0]).toEqual({ ...expected, data: undefined }); + }); + }); + }); + }); }); diff --git a/public/app/core/trustedTypePolicies.ts b/public/app/core/trustedTypePolicies.ts index 6dedb7f68e8..fa4489847d3 100644 --- a/public/app/core/trustedTypePolicies.ts +++ b/public/app/core/trustedTypePolicies.ts @@ -1,7 +1,7 @@ import { textUtil } from '@grafana/data'; import { config } from '@grafana/runtime'; -const CSP_REPORT_ONLY_ENABLED = config.bootData.settings.cspReportOnlyEnabled; +const CSP_REPORT_ONLY_ENABLED = config.cspReportOnlyEnabled; export const defaultTrustedTypesPolicy = { createHTML: (string: string, source: string, sink: string) => { @@ -21,11 +21,7 @@ export const defaultTrustedTypesPolicy = { }, }; -if ( - config.bootData.settings.trustedTypesDefaultPolicyEnabled && - window.trustedTypes && - window.trustedTypes.createPolicy -) { +if (config.trustedTypesDefaultPolicyEnabled && window.trustedTypes && window.trustedTypes.createPolicy) { // check if browser supports Trusted Types window.trustedTypes.createPolicy('default', defaultTrustedTypesPolicy); } diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts index d8d2b5343e9..4701cf7e019 100644 --- a/public/app/core/utils/explore.test.ts +++ b/public/app/core/utils/explore.test.ts @@ -7,7 +7,6 @@ import { LogsSortOrder, serializeStateToUrlParam, } from '@grafana/data'; -import { config } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; import { RefreshPicker } from '@grafana/ui'; import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; @@ -174,7 +173,7 @@ describe('getExploreUrl', () => { afterAll(() => { // Reset locationUtil locationUtil.initialize({ - config, + config: window.grafanaBootData.settings, getTimeRangeForUrl: getTimeSrv().timeRangeForUrl, getVariablesUrlParams: getVariablesUrlParams, }); diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index cb22e191a77..f2d7fcedfc1 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -93,7 +93,11 @@ export async function getExploreUrl(args: GetExploreUrlArguments): Promise q.value); const exploreState = JSON.stringify({ - [generateExploreId()]: { range: toURLRange(timeRange.raw), queries: interpolatedQueries, datasource: dsRef?.uid }, + [generateExploreId()]: { + range: toURLRange(timeRange.raw), + queries: interpolatedQueries, + datasource: dsRef?.uid, + }, }); return locationUtil.assureBaseUrl(urlUtil.renderUrl('/explore', { panes: exploreState, schemaVersion: 1 })); } diff --git a/public/app/core/utils/fetch.test.ts b/public/app/core/utils/fetch.test.ts index cd613132413..b764f61bb49 100644 --- a/public/app/core/utils/fetch.test.ts +++ b/public/app/core/utils/fetch.test.ts @@ -27,20 +27,29 @@ describe('parseUrlFromOptions', () => { ${{ id: [] }} | ${'api/dashboard'} | ${'api/dashboard'} `( "when called with params: '$params' and url: '$url' then result should be '$expected'", - ({ params, url, expected }) => { - expect(parseUrlFromOptions({ params, url })).toEqual(expected); + async ({ params, url, expected }) => { + await expect(parseUrlFromOptions({ params, url })).toEmitValuesWith((received) => { + expect(received).toHaveLength(1); + expect(received[0]).toEqual(expected); + }); } ); - it('should validate the path if validatePath is true', () => { - expect(() => parseUrlFromOptions({ url: '/api/users/%2e%2e/admin', validatePath: true })).toThrow( - PathValidationError + it('should validate the path if validatePath is true', async () => { + await expect(parseUrlFromOptions({ url: '/api/users/%2e%2e/admin', validatePath: true })).toEmitValuesWith( + (received) => { + expect(received).toHaveLength(1); + expect(received[0]).toBeInstanceOf(PathValidationError); + } ); }); - it('should not validate the path if validatePath is false', () => { - expect(parseUrlFromOptions({ url: '/api/users/%2e%2e/admin', validatePath: false })).toEqual( - '/api/users/%2e%2e/admin' + it('should not validate the path if validatePath is false', async () => { + await expect(parseUrlFromOptions({ url: '/api/users/%2e%2e/admin', validatePath: false })).toEmitValuesWith( + (received) => { + expect(received).toHaveLength(1); + expect(received[0]).toEqual('/api/users/%2e%2e/admin'); + } ); }); }); diff --git a/public/app/core/utils/fetch.ts b/public/app/core/utils/fetch.ts index a456f35e066..e714e944dc9 100644 --- a/public/app/core/utils/fetch.ts +++ b/public/app/core/utils/fetch.ts @@ -1,4 +1,5 @@ import { omitBy } from 'lodash'; +import { Observable, of, throwError } from 'rxjs'; import { deprecationWarning, validatePath } from '@grafana/data'; import { BackendSrvRequest } from '@grafana/runtime'; @@ -152,7 +153,7 @@ export async function parseResponseBody( return textData as T; } -function serializeParams(data: Record): string { +function serializeParams(data: Record>): string { return Object.keys(data) .map((key) => { const value = data[key]; @@ -167,16 +168,22 @@ function serializeParams(data: Record): string { /** * Formats and validates the URL. * If options.validatePath is true, this will throw an exception if the URL fails validation. + * @param options - The options to parse. + * @returns An observable that emits the parsed URL or an error if the URL fails validation. */ -export const parseUrlFromOptions = (options: BackendSrvRequest): string => { - const cleanParams = omitBy(options.params, (v) => v === undefined || (v && v.length === 0)); - const serializedParams = serializeParams(cleanParams); +export const parseUrlFromOptions = (options: BackendSrvRequest): Observable => { + try { + const cleanParams = omitBy(options.params, (v) => v === undefined || (v && v.length === 0)); + const serializedParams = serializeParams(cleanParams); - const url = options.validatePath // - ? validatePath(options.url) - : options.url; + const url = options.validatePath // + ? validatePath(options.url) + : options.url; - return options.params && serializedParams.length ? `${url}?${serializedParams}` : url; + return options.params && serializedParams.length ? of(`${url}?${serializedParams}`) : of(url); + } catch (error) { + return throwError(() => error); + } }; export const parseCredentials = (options: BackendSrvRequest): RequestCredentials => { diff --git a/public/app/features/alerting/unified/Analytics.ts b/public/app/features/alerting/unified/Analytics.ts index ebe07e43f9f..2f139066a5b 100644 --- a/public/app/features/alerting/unified/Analytics.ts +++ b/public/app/features/alerting/unified/Analytics.ts @@ -1,4 +1,4 @@ -import { isEmpty } from 'lodash'; +import { isEmpty, pickBy } from 'lodash'; import { config, createMonitoringLogger, reportInteraction } from '@grafana/runtime'; import { contextSrv } from 'app/core/core'; @@ -7,6 +7,7 @@ import { RuleNamespace } from '../../../types/unified-alerting'; import { RulerRulesConfigDTO } from '../../../types/unified-alerting-dto'; import { Origin } from './components/rule-viewer/tabs/version-history/ConfirmVersionRestoreModal'; +import { AdvancedFilters } from './components/rules/Filter/RulesFilter.v2'; import { FilterType } from './components/rules/central-state-history/EventListSceneObject'; import { RulesFilter, getSearchFilterFromQuery } from './search/rulesSearchParser'; import { RuleFormType } from './types/rule-form'; @@ -330,6 +331,32 @@ export function trackFolderBulkActionsUnpauseFail() { reportInteraction('grafana_alerting_folder_bulk_actions_unpause_fail'); } +export function trackFilterButtonClick() { + reportInteraction('grafana_alerting_filter_button_click'); +} + +export function trackFilterButtonApplyClick(payload: AdvancedFilters, pluginsFilterEnabled: boolean) { + // Filter out empty/default values before tracking + const meaningfulValues = pickBy(payload, (value, key) => { + if (value === null || value === undefined || value === '') { + return false; + } + if (Array.isArray(value) && value.length === 0) { + return false; + } + if (key === 'plugins' && !pluginsFilterEnabled) { + return false; + } + return true; + }); + + reportInteraction('grafana_alerting_filter_button_apply_click', meaningfulValues); +} + +export function trackFilterButtonClearClick() { + reportInteraction('grafana_alerting_filter_button_clear_click'); +} + export type AlertRuleTrackingProps = { user_id: number; grafana_version?: string; diff --git a/public/app/features/alerting/unified/NewSilencePage.tsx b/public/app/features/alerting/unified/NewSilencePage.tsx index 81d2793eb6a..7961a6c6a13 100644 --- a/public/app/features/alerting/unified/NewSilencePage.tsx +++ b/public/app/features/alerting/unified/NewSilencePage.tsx @@ -9,7 +9,7 @@ import { MATCHER_ALERT_RULE_UID } from 'app/features/alerting/unified/utils/cons import { parseQueryParamMatchers } from 'app/features/alerting/unified/utils/matchers'; import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper'; -import { GrafanaAlertmanagerDeliveryWarning } from './components/GrafanaAlertmanagerDeliveryWarning'; +import { GrafanaAlertmanagerWarning } from './components/GrafanaAlertmanagerWarning'; import { SilencesEditor } from './components/silences/SilencesEditor'; import { useAlertmanager } from './state/AlertmanagerContext'; import { withPageErrorBoundary } from './withPageErrorBoundary'; @@ -27,7 +27,7 @@ const SilencesEditorComponent = () => { return ( <> - + { return ( <> - + {policiesSupported && canSeePoliciesTab && ( [{ type: 'RuleNamespace', id: namespace }], + providesTags: (_result, _error, { namespace, rulerConfig }) => [ + { type: 'RuleNamespace', id: `${rulerConfig.dataSourceUid}/${namespace}` }, + ], }), // TODO This should be probably a separate ruler API file diff --git a/public/app/features/alerting/unified/api/alertmanager.ts b/public/app/features/alerting/unified/api/alertmanager.ts index ac178ba22ac..e0ad02ce32b 100644 --- a/public/app/features/alerting/unified/api/alertmanager.ts +++ b/public/app/features/alerting/unified/api/alertmanager.ts @@ -30,6 +30,7 @@ export async function fetchAlertManagerConfig(alertManagerSourceName: string): P alertmanager_config: result.data.alertmanager_config ?? {}, last_applied: result.data.last_applied, id: result.data.id, + extra_config: result.data.extra_config, }; } catch (e) { // if no config has been uploaded to grafana, it returns error instead of latest config diff --git a/public/app/features/alerting/unified/api/alertmanagerApi.ts b/public/app/features/alerting/unified/api/alertmanagerApi.ts index 1fefcce17ea..0c8940725fa 100644 --- a/public/app/features/alerting/unified/api/alertmanagerApi.ts +++ b/public/app/features/alerting/unified/api/alertmanagerApi.ts @@ -191,6 +191,7 @@ export const alertmanagerApi = alertingApi.injectEndpoints({ data: { alertmanager_config: status.config, template_files: {}, + extra_config: undefined, }, })) ); @@ -207,6 +208,7 @@ export const alertmanagerApi = alertingApi.injectEndpoints({ alertmanager_config: {}, template_files: {}, template_file_provenances: {}, + extra_config: undefined, }; const lazyConfigInitSupported = alertmanagerFeatures?.lazyConfigInit ?? false; @@ -244,6 +246,7 @@ export const alertmanagerApi = alertingApi.injectEndpoints({ template_file_provenances: result.template_file_provenances, last_applied: result.last_applied, id: result.id, + extra_config: result.extra_config, })); } diff --git a/public/app/features/alerting/unified/api/stateHistoryApi.ts b/public/app/features/alerting/unified/api/stateHistoryApi.ts index 2f82fe6ef2d..bb4c85c58a9 100644 --- a/public/app/features/alerting/unified/api/stateHistoryApi.ts +++ b/public/app/features/alerting/unified/api/stateHistoryApi.ts @@ -4,11 +4,29 @@ import { alertingApi } from './alertingApi'; export const stateHistoryApi = alertingApi.injectEndpoints({ endpoints: (build) => ({ - getRuleHistory: build.query({ - query: ({ ruleUid, from, to, limit = 100 }) => ({ - url: '/api/v1/rules/history', - params: { ruleUID: ruleUid, from, to, limit }, - }), + getRuleHistory: build.query< + DataFrameJSON, + { ruleUid?: string; from?: number; to?: number; limit?: number; labels?: Record } + >({ + query: ({ ruleUid, from, to, limit = 100, labels }) => { + const params: Record = { + ruleUID: ruleUid, + from, + to, + limit, + }; + + if (labels) { + Object.entries(labels).forEach(([key, value]) => { + params[`labels_${key}`] = value; + }); + } + + return { + url: '/api/v1/rules/history', + params, + }; + }, }), }), }); diff --git a/public/app/features/alerting/unified/components/AlertManagerPicker.tsx b/public/app/features/alerting/unified/components/AlertManagerPicker.tsx index 5a2a7ddc83e..6f2b830b9d8 100644 --- a/public/app/features/alerting/unified/components/AlertManagerPicker.tsx +++ b/public/app/features/alerting/unified/components/AlertManagerPicker.tsx @@ -6,6 +6,7 @@ import { t } from '@grafana/i18n'; import { InlineField, Select, SelectMenuOptions, useStyles2 } from '@grafana/ui'; import { useAlertmanager } from '../state/AlertmanagerContext'; +import { isExtraConfig } from '../utils/alertmanager/extraConfigs'; import { AlertManagerDataSource, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; interface Props { @@ -13,7 +14,11 @@ interface Props { } function getAlertManagerLabel(alertManager: AlertManagerDataSource) { - return alertManager.name === GRAFANA_RULES_SOURCE_NAME ? 'Grafana' : alertManager.name; + if (alertManager.name === GRAFANA_RULES_SOURCE_NAME) { + return 'Grafana'; + } + + return alertManager.displayName || alertManager.name; } export const AlertManagerPicker = ({ disabled = false }: Props) => { @@ -21,12 +26,50 @@ export const AlertManagerPicker = ({ disabled = false }: Props) => { const { selectedAlertmanager, availableAlertManagers, setSelectedAlertmanager } = useAlertmanager(); const options = useMemo(() => { - return availableAlertManagers.map>((ds) => ({ - label: getAlertManagerLabel(ds), - value: ds.name, - imgUrl: ds.imgUrl, - meta: ds.meta, - })); + // Group alertmanagers + const grafanaAM = availableAlertManagers.find((am) => am.name === GRAFANA_RULES_SOURCE_NAME); + const extraConfig = availableAlertManagers.find((am) => isExtraConfig(am.name)); + const datasourceAMs = availableAlertManagers.filter( + (am) => am.name !== GRAFANA_RULES_SOURCE_NAME && !isExtraConfig(am.name) + ); + + const groupedOptions: Array | { label: string; options: Array> }> = + []; + + // Add Grafana alertmanager first + if (grafanaAM) { + groupedOptions.push({ + label: getAlertManagerLabel(grafanaAM), + value: grafanaAM.name, + imgUrl: grafanaAM.imgUrl, + meta: grafanaAM.meta, + }); + } + + // Add extra config (single merged configuration) + if (extraConfig) { + groupedOptions.push({ + label: getAlertManagerLabel(extraConfig), + value: extraConfig.name, + imgUrl: extraConfig.imgUrl, + meta: extraConfig.meta, + }); + } + + // Add external alertmanagers + if (datasourceAMs.length > 0) { + groupedOptions.push({ + label: t('alerting.alert-manager-picker.external-alertmanagers-group', 'External Alertmanagers'), + options: datasourceAMs.map((ds) => ({ + label: getAlertManagerLabel(ds), + value: ds.name, + imgUrl: ds.imgUrl, + meta: ds.meta, + })), + }); + } + + return groupedOptions; }, [availableAlertManagers]); const isDisabled = disabled || options.length === 1; @@ -61,12 +104,22 @@ const getStyles = (theme: GrafanaTheme2) => ({ field: css({ margin: 0, }), + optionContent: css({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + whiteSpace: 'pre-line', + }), }); // custom option that overwrites the default "white-space: nowrap" for Alertmanager names that are really long -const CustomOption = (props: ComponentProps) => ( -
{label}
} - /> -); +const CustomOption = (props: ComponentProps) => { + const styles = useStyles2(getStyles); + + return ( +
{label}
} + /> + ); +}; diff --git a/public/app/features/alerting/unified/components/GrafanaAlertmanagerDeliveryWarning.test.tsx b/public/app/features/alerting/unified/components/GrafanaAlertmanagerWarning.test.tsx similarity index 75% rename from public/app/features/alerting/unified/components/GrafanaAlertmanagerDeliveryWarning.test.tsx rename to public/app/features/alerting/unified/components/GrafanaAlertmanagerWarning.test.tsx index 6ed7a2a7aab..52c87f5f41c 100644 --- a/public/app/features/alerting/unified/components/GrafanaAlertmanagerDeliveryWarning.test.tsx +++ b/public/app/features/alerting/unified/components/GrafanaAlertmanagerWarning.test.tsx @@ -10,10 +10,10 @@ import { AlertmanagerChoice } from '../../../../plugins/datasource/alertmanager/ import { grantUserPermissions } from '../mocks'; import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; -import { GrafanaAlertmanagerDeliveryWarning } from './GrafanaAlertmanagerDeliveryWarning'; +import { GrafanaAlertmanagerWarning } from './GrafanaAlertmanagerWarning'; setupMswServer(); -describe('GrafanaAlertmanagerDeliveryWarning', () => { +describe('GrafanaAlertmanagerWarning', () => { beforeEach(() => { grantUserPermissions([AccessControlAction.AlertingNotificationsRead]); }); @@ -21,9 +21,7 @@ describe('GrafanaAlertmanagerDeliveryWarning', () => { it('Should not render when the datasource is not Grafana', () => { setAlertmanagerChoices(AlertmanagerChoice.External, 0); - const { container } = renderWithStore( - - ); + const { container } = renderWithStore(); expect(container).toBeEmptyDOMElement(); }); @@ -31,7 +29,7 @@ describe('GrafanaAlertmanagerDeliveryWarning', () => { it('Should render warning when the datasource is Grafana and using external AM', async () => { setAlertmanagerChoices(AlertmanagerChoice.External, 1); - renderWithStore(); + renderWithStore(); expect(await screen.findByText('Grafana alerts are not delivered to Grafana Alertmanager')).toBeVisible(); }); @@ -39,7 +37,7 @@ describe('GrafanaAlertmanagerDeliveryWarning', () => { it('Should render warning when the datasource is Grafana and using All AM', async () => { setAlertmanagerChoices(AlertmanagerChoice.All, 1); - renderWithStore(); + renderWithStore(); expect(await screen.findByText('You have additional Alertmanagers to configure')).toBeVisible(); }); @@ -48,7 +46,7 @@ describe('GrafanaAlertmanagerDeliveryWarning', () => { setAlertmanagerChoices(AlertmanagerChoice.Internal, 1); const { container } = renderWithStore( - + ); await waitFor(() => { @@ -60,7 +58,7 @@ describe('GrafanaAlertmanagerDeliveryWarning', () => { setAlertmanagerChoices(AlertmanagerChoice.All, 0); const { container } = renderWithStore( - + ); await waitFor(() => { diff --git a/public/app/features/alerting/unified/components/GrafanaAlertmanagerDeliveryWarning.tsx b/public/app/features/alerting/unified/components/GrafanaAlertmanagerWarning.tsx similarity index 75% rename from public/app/features/alerting/unified/components/GrafanaAlertmanagerDeliveryWarning.tsx rename to public/app/features/alerting/unified/components/GrafanaAlertmanagerWarning.tsx index c147cb7ada2..cf2afa30e6b 100644 --- a/public/app/features/alerting/unified/components/GrafanaAlertmanagerDeliveryWarning.tsx +++ b/public/app/features/alerting/unified/components/GrafanaAlertmanagerWarning.tsx @@ -7,13 +7,23 @@ import { Alert, useStyles2 } from '@grafana/ui'; import { AlertmanagerChoice } from '../../../../plugins/datasource/alertmanager/types'; import { alertmanagerApi } from '../api/alertmanagerApi'; import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities'; +import { isExtraConfig } from '../utils/alertmanager/extraConfigs'; import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; -interface GrafanaAlertmanagerDeliveryWarningProps { +interface GrafanaAlertmanagerWarningProps { currentAlertmanager: string; } -export function GrafanaAlertmanagerDeliveryWarning({ currentAlertmanager }: GrafanaAlertmanagerDeliveryWarningProps) { +export function GrafanaAlertmanagerWarning({ currentAlertmanager }: GrafanaAlertmanagerWarningProps) { + return ( + <> + + + + ); +} + +function GrafanaExternalAlertmanagerConfigWarning({ currentAlertmanager }: GrafanaAlertmanagerWarningProps) { const styles = useStyles2(getStyles); const externalAlertmanager = currentAlertmanager !== GRAFANA_RULES_SOURCE_NAME; @@ -87,6 +97,23 @@ export function GrafanaAlertmanagerDeliveryWarning({ currentAlertmanager }: Graf return null; } +function GrafanaExtraConfigWarning({ currentAlertmanager }: GrafanaAlertmanagerWarningProps) { + const isSelectedExtraConfig = currentAlertmanager && isExtraConfig(currentAlertmanager); + + if (!isSelectedExtraConfig) { + return null; + } + + return ( + + + This shows the merged configuration of Grafana alertmanager with imported configurations. This merged view is + read-only in the UI. + + + ); +} + const getStyles = (theme: GrafanaTheme2) => ({ adminHint: css({ fontSize: theme.typography.bodySmall.fontSize, diff --git a/public/app/features/alerting/unified/components/HoverCard.tsx b/public/app/features/alerting/unified/components/HoverCard.tsx index 9e010873aaf..6c5577df37b 100644 --- a/public/app/features/alerting/unified/components/HoverCard.tsx +++ b/public/app/features/alerting/unified/components/HoverCard.tsx @@ -17,6 +17,10 @@ export interface PopupCardProps { showAfter?: number; arrow?: boolean; showOn?: 'click' | 'hover'; + disableBlur?: boolean; + isOpen?: boolean; + onClose?: () => void; + onToggle?: () => void; } export const PopupCard = ({ @@ -29,6 +33,10 @@ export const PopupCard = ({ wrapperClassName, disabled = false, showOn = 'hover', + disableBlur = false, + isOpen, + onClose, + onToggle, ...rest }: PopupCardProps) => { const popoverRef = useRef(null); @@ -52,19 +60,37 @@ export const PopupCard = ({ return ( {(showPopper, hidePopper, popperProps) => { + // Use manual control if provided, otherwise use internal state + const isManuallyControlled = isOpen !== undefined; + const shouldShow = isManuallyControlled ? isOpen : popperProps.show; + + const handleClose = () => { + if (onClose) { + onClose(); + } else { + hidePopper(); + } + }; + + const handleShow = () => { + if (!isManuallyControlled) { + showPopper(); + } + }; + // support hover and click interaction const onClickProps = { - onClick: showPopper, + onClick: onToggle || (isManuallyControlled ? handleClose : showPopper), }; const onHoverProps = { - onMouseLeave: hidePopper, - onMouseEnter: showPopper, + onMouseLeave: handleClose, + onMouseEnter: handleShow, }; const blurFocusProps = { - onBlur: hidePopper, - onFocus: showPopper, + onBlur: handleClose, + onFocus: handleShow, }; return ( @@ -72,26 +98,29 @@ export const PopupCard = ({ {popoverRef.current && ( )} {cloneElement(children, { ref: popoverRef, - onFocus: showPopper, - onBlur: hidePopper, + onFocus: handleShow, + onBlur: disableBlur ? undefined : handleClose, tabIndex: 0, // make sure we pass the correct interaction handlers here to the element we want to interact with ...(showOnHover ? onHoverProps : {}), - ...(showOnClick ? onClickProps : {}), + // Only add click handling if we have onToggle or not manually controlled + ...(showOnClick && (onToggle || !isManuallyControlled) ? onClickProps : {}), })} ); diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx index 6f2423e424d..729644b8209 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx @@ -23,10 +23,11 @@ import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbili import { usePagination } from '../../hooks/usePagination'; import { useURLSearchParams } from '../../hooks/useURLSearchParams'; import { useAlertmanager } from '../../state/AlertmanagerContext'; +import { isExtraConfig } from '../../utils/alertmanager/extraConfigs'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { withPageErrorBoundary } from '../../withPageErrorBoundary'; import { AlertmanagerPageWrapper } from '../AlertingPageWrapper'; -import { GrafanaAlertmanagerDeliveryWarning } from '../GrafanaAlertmanagerDeliveryWarning'; +import { GrafanaAlertmanagerWarning } from '../GrafanaAlertmanagerWarning'; import { ContactPoint } from './ContactPoint'; import { NotificationTemplates } from './NotificationTemplates'; @@ -140,8 +141,12 @@ const ContactPointsTab = () => { ) : ( )} + {/* Grafana manager Alertmanager does not support global config, Mimir and Cortex do */} - {!isGrafanaManagedAlertmanager && } + {/* Extra configs also don't support global config */} + {!isGrafanaManagedAlertmanager && !isExtraConfig(selectedAlertmanager!) && ( + + )} {ExportDrawer} ); @@ -220,7 +225,7 @@ export const ContactPointsPageContents = () => { return ( <> - + {showContactPointsTab && ( diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts index b3499b8c520..372e028bbcc 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts @@ -247,8 +247,9 @@ export const queriesAndExpressionsReducer = createReducer(initialState, (builder const dataQuery = updatedQueries.at(0); const isInstantDataQuery = dataQuery ? getInstantFromDataQuery(dataQuery) : false; + const hasReducer = expressionQueries.some((q) => isReducerExpression(q.model)); + const shouldRemoveReducer = isInstantDataQuery && expressionQueries.length === 2 && hasReducer; - const shouldRemoveReducer = isInstantDataQuery && expressionQueries.length === 2; if (shouldRemoveReducer) { const reduceExpressionIndex = state.queries.findIndex( (query) => diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx index 7c3dd139d8e..1d8f0e2ea5d 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx @@ -48,6 +48,7 @@ import { RulePluginOrigin, getRulePluginOrigin, isFederatedRuleGroup, + isGrafanaRuleIdentifier, isPausedRule, prometheusRuleType, rulerRuleType, @@ -83,12 +84,15 @@ export enum ActiveTab { const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary(); const alertingListViewV2 = shouldUseAlertingListViewV2(); -const shouldUseConsistencyCheck = prometheusRulesPrimary || alertingListViewV2; - const RuleViewer = () => { const { rule, identifier } = useAlertRule(); const { pageNav, activeTab } = usePageNav(rule); + // GMA /api/v1/rules endpoint is strongly consistent, so we don't need to check for consistency + const shouldUseConsistencyCheck = isGrafanaRuleIdentifier(identifier) + ? false + : prometheusRulesPrimary || alertingListViewV2; + // this will be used to track if we are in the process of cloning a rule // we want to be able to show a modal if the rule has been provisioned explain the limitations // of duplicating provisioned alert rules diff --git a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.tsx b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.tsx index b904a5c1427..3eea628410f 100644 --- a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.tsx +++ b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.tsx @@ -7,13 +7,13 @@ import { SupportedView } from './RulesViewModeSelector'; const RulesFilterV2 = lazy(() => import('./RulesFilter.v2')); -interface RulesFilerProps { +export interface RulesFilterProps { onClear?: () => void; viewMode?: SupportedView; onViewModeChange?: (viewMode: SupportedView) => void; } -const RulesFilter = (props: RulesFilerProps) => { +const RulesFilter = (props: RulesFilterProps) => { const newView = config.featureToggles.alertingFilterV2; return {newView ? : }; }; diff --git a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx index ca70ddaa984..d58b0a5abdf 100644 --- a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx +++ b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx @@ -24,7 +24,8 @@ import { alertStateToReadable } from '../../../utils/rules'; import { PopupCard } from '../../HoverCard'; import { MultipleDataSourcePicker } from '../MultipleDataSourcePicker'; -import { RulesViewModeSelector, SupportedView } from './RulesViewModeSelector'; +import { RulesFilterProps } from './RulesFilter'; +import { RulesViewModeSelector } from './RulesViewModeSelector'; const RuleTypeOptions: SelectableValue[] = [ { label: 'Alert ', value: PromRuleType.Alerting }, @@ -37,15 +38,8 @@ const RuleHealthOptions: SelectableValue[] = [ { label: 'Error', value: RuleHealth.Error }, ]; -// Contact point selector is not supported in Alerting ListView V2 yet const canRenderContactPointSelector = contextSrv.hasPermission(AccessControlAction.AlertingReceiversRead); -interface RulesFilerProps { - onClear?: () => void; - viewMode?: SupportedView; - onViewModeChange?: (viewMode: SupportedView) => void; -} - const RuleStateOptions = Object.entries(PromAlertingRuleState) .filter(([key, value]) => value !== PromAlertingRuleState.Unknown) // Exclude Unknown state from filter options .map(([key, value]) => ({ @@ -53,7 +47,7 @@ const RuleStateOptions = Object.entries(PromAlertingRuleState) value, })); -const RulesFilter = ({ onClear = () => undefined, viewMode, onViewModeChange }: RulesFilerProps) => { +const RulesFilter = ({ onClear = () => undefined, viewMode, onViewModeChange }: RulesFilterProps) => { const styles = useStyles2(getStyles); const { pluginsFilterEnabled } = usePluginsFilterStatus(); const { filterState, hasActiveFilters, searchQuery, setSearchQuery, updateFilters } = useRulesFilter(); diff --git a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v2.tsx b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v2.tsx index 91d76ce107f..8411da2e8d3 100644 --- a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v2.tsx +++ b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v2.tsx @@ -1,226 +1,623 @@ import { css } from '@emotion/css'; -import { useCallback, useMemo, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { Controller, SubmitHandler, useForm } from 'react-hook-form'; +import { ContactPointSelector } from '@grafana/alerting/unstable'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { - Badge, + Box, Button, - Grid, - IconButton, + Combobox, + FilterInput, + Icon, Input, - InteractiveTable, Label, + MultiCombobox, RadioButtonGroup, - Select, Stack, - Tab, - TabsBar, + Tooltip, useStyles2, } from '@grafana/ui'; +import { contextSrv } from 'app/core/core'; +import { AccessControlAction } from 'app/types/accessControl'; +import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; +import { trackFilterButtonApplyClick, trackFilterButtonClearClick, trackFilterButtonClick } from '../../../Analytics'; +import { useRulesFilter } from '../../../hooks/useFilteredRules'; +import { RuleHealth, applySearchFilterToQuery, getSearchFilterFromQuery } from '../../../search/rulesSearchParser'; import { PopupCard } from '../../HoverCard'; -import MoreButton from '../../MoreButton'; -type RulesFilterProps = { - onClear?: () => void; +import { RulesFilterProps } from './RulesFilter'; +import { RulesViewModeSelector } from './RulesViewModeSelector'; +import { + useAlertingDataSourceOptions, + useLabelOptions, + useNamespaceAndGroupOptions, +} from './useRuleFilterAutocomplete'; +import { + emptyAdvancedFilters, + formAdvancedFiltersToRuleFilter, + searchQueryToDefaultValues, + usePluginsFilterStatus, + usePortalContainer, +} from './utils'; + +const canRenderContactPointSelector = contextSrv.hasPermission(AccessControlAction.AlertingReceiversRead); + +/** + * Custom hook that creates a DOM container for rendering dropdowns outside of popup stacking contexts. + * This prevents dropdowns from appearing behind modals/popups due to CSS stacking context limitations. + * + * @param zIndex - The z-index value for the portal container + * @returns HTMLDivElement container appended to document.body, or undefined during initial render + */ + +export type AdvancedFilters = { + namespace?: string | null; + groupName?: string | null; + ruleName?: string; + ruleType?: PromRuleType | '*'; + ruleState: PromAlertingRuleState | '*'; + dataSourceNames: string[]; + labels: string[]; + ruleHealth?: RuleHealth | '*'; + dashboardUid?: string; + plugins?: 'show' | 'hide'; + contactPoint?: string | null; }; -type ActiveTab = 'custom' | 'saved'; +type SearchQueryForm = { + query: string; +}; -export default function RulesFilter({ onClear = () => {} }: RulesFilterProps) { +export default function RulesFilter({ viewMode, onViewModeChange }: RulesFilterProps) { const styles = useStyles2(getStyles); - const [activeTab, setActiveTab] = useState('custom'); - const filterOptions = useMemo(() => { - return ( - - {activeTab === 'custom' && } - {activeTab === 'saved' && } -
- } - header={ - - setActiveTab('custom')} - /> - setActiveTab('saved')} - /> - - } - > - - - ); - }, [activeTab, styles.content, styles.fixTabsMargin]); + const [isPopupOpen, setIsPopupOpen] = useState(false); + const { searchQuery, updateFilters, setSearchQuery } = useRulesFilter(); + const popupRef = useRef(null); + const { pluginsFilterEnabled } = usePluginsFilterStatus(); + // this form will managed the search query string, which is updated either by the user typing in the input or by the advanced filters + const { setValue, watch, getValues, handleSubmit } = useForm({ + defaultValues: { + query: searchQuery, + }, + }); + + useEffect(() => { + setValue('query', searchQuery); + }, [searchQuery, setValue]); + + const submitHandler: SubmitHandler = (values: SearchQueryForm) => { + const parsedFilter = getSearchFilterFromQuery(values.query); + updateFilters(parsedFilter); + }; + + const handleAdvancedFilters: SubmitHandler = (values) => { + const newFilter = formAdvancedFiltersToRuleFilter(values); + updateFilters(newFilter); + + const newSearchQuery = applySearchFilterToQuery('', newFilter); + setSearchQuery(newSearchQuery); + + trackFilterButtonApplyClick(values, pluginsFilterEnabled); + setIsPopupOpen(false); // Should close popup after applying filters? + }; + + const handleClearFilters = () => { + updateFilters(formAdvancedFiltersToRuleFilter(emptyAdvancedFilters)); + setSearchQuery(undefined); + }; + + const handleOnToggle = () => { + trackFilterButtonClick(); + setIsPopupOpen(!isPopupOpen); + }; + + // Handle outside clicks to close the popup + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (isPopupOpen && popupRef.current && event.target instanceof Node && !popupRef.current.contains(event.target)) { + // Check if click is on a portal element (combobox dropdown) + if (event.target instanceof Element) { + const isPortalClick = + event.target.closest('[data-popper-placement]') || event.target.closest('[role="listbox"]'); + + if (!isPortalClick) { + setIsPopupOpen(false); + } + } else { + setIsPopupOpen(false); + } + } + }; + + if (isPopupOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isPopupOpen]); + + const filterButtonLabel = t('alerting.rules-filter.filter-options.aria-label-show-filters', 'Filter'); return ( - - - - +
{}}> + + + + + setValue('query', string)} + onBlur={() => { + const currentQuery = getValues('query'); + const parsedFilter = getSearchFilterFromQuery(currentQuery); + updateFilters(parsedFilter); + }} + value={watch('query')} + /> + + {/* the popup card is mounted inside of a portal, so we can't rely on the usual form handling mechanisms of button[type=submit] */} + setIsPopupOpen(false)} + onToggle={handleOnToggle} + content={ + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions +
e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation(); + } + }} + role="dialog" + aria-label={t('alerting.rules-filter.filter-options.aria-label', 'Filter options')} + tabIndex={-1} + > + +
+ } + > + +
+ +
- +
); } -const FilterOptions = () => { +interface FilterOptionsProps { + onSubmit: SubmitHandler; + onClear: () => void; + pluginsFilterEnabled: boolean; +} + +const FilterOptions = ({ onSubmit, onClear, pluginsFilterEnabled }: FilterOptionsProps) => { + const styles = useStyles2(getStyles); + const theme = useStyles2((theme) => theme); + const { filterState } = useRulesFilter(); + const isManualResetRef = useRef(false); + + // Create portal container to render dropdowns above the popup modal + const portalContainer = usePortalContainer(theme.zIndex.portal + 100); + + const defaultValues = searchQueryToDefaultValues(filterState); + + // Fetch namespace and group data from all sources (optimized for filter UI) + const { namespaceOptions, allGroupNames, isLoadingNamespaces, namespacePlaceholder, groupPlaceholder } = + useNamespaceAndGroupOptions(); + + const { labelOptions, isLoadingGrafanaLabels } = useLabelOptions(); + + // Create label options for the multi-select dropdown + const dataSourceOptions = useAlertingDataSourceOptions(); + + // turn the filterState into form default values + const { handleSubmit, reset, register, control } = useForm({ + defaultValues, + }); + + // Update form values when filterState changes (e.g., when popup reopens) + useEffect(() => { + // Skip if we're in the middle of a manual reset + if (isManualResetRef.current) { + isManualResetRef.current = false; + return; + } + + const newDefaultValues = searchQueryToDefaultValues(filterState); + reset(newDefaultValues); + }, [filterState, reset]); + + const submitAdvancedFilters = handleSubmit(onSubmit); + return ( - - - - - - - - - - + + ( + field.onChange(selections.map((s) => s.value))} + placeholder={ + isLoadingGrafanaLabels + ? t('common.loading', 'Loading...') + : t('alerting.rules-filter.placeholder-labels', 'Select labels') + } + loading={isLoadingGrafanaLabels} + disabled={isLoadingGrafanaLabels || labelOptions.filter((option) => !option.infoOption).length === 0} + portalContainer={portalContainer} + width="auto" + minWidth={40} + maxWidth={80} + /> + )} + /> + + { + return ( + + placeholder={namespacePlaceholder} + options={namespaceOptions} + onChange={(option) => field.onChange(option?.value || null)} + value={field.value} + loading={isLoadingNamespaces} + disabled={isLoadingNamespaces || namespaceOptions.length === 0} + isClearable + portalContainer={portalContainer} + /> + ); + }} + /> + + { + return ( + + placeholder={groupPlaceholder} + options={allGroupNames.map((name) => ({ label: name, value: name }))} + onChange={(option) => field.onChange(option?.value || null)} + value={field.value} + loading={isLoadingNamespaces} + disabled={isLoadingNamespaces || allGroupNames.length === 0} + isClearable + portalContainer={portalContainer} + /> + ); + }} + /> +
+ } + > + + + + + ( + field.onChange(selections.map((s) => s.value))} + placeholder={t('alerting.rules-filter.placeholder-data-sources', 'Select data sources')} + portalContainer={portalContainer} + width="auto" + minWidth={40} + maxWidth={80} + /> + )} + /> + {canRenderContactPointSelector && ( + <> + + { + return ( + { + field.onChange(contactPoint?.spec.title || null); + }} + portalContainer={portalContainer} + /> + ); + }} + /> + + )} + + ( + + options={[ + { label: t('common.all', 'All'), value: '*' }, + { label: t('alerting.rules.state.firing', 'Firing'), value: PromAlertingRuleState.Firing }, + { label: t('alerting.rules.state.normal', 'Normal'), value: PromAlertingRuleState.Inactive }, + { label: t('alerting.rules.state.pending', 'Pending'), value: PromAlertingRuleState.Pending }, + { + label: t('alerting.rules.state.recovering', 'Recovering'), + value: PromAlertingRuleState.Recovering, + }, + { label: t('alerting.rules.state.unknown', 'Unknown'), value: PromAlertingRuleState.Unknown }, + ]} + value={field.value} + onChange={field.onChange} + /> + )} + /> + + ( + + options={[ + { label: t('common.all', 'All'), value: '*' }, + { label: t('alerting.rules.type.alert', 'Alert rule'), value: PromRuleType.Alerting }, + { label: t('alerting.rules.type.recording', 'Recording rule'), value: PromRuleType.Recording }, + ]} + value={field.value} + onChange={field.onChange} + /> + )} + /> + + ( + + options={[ + { label: t('common.all', 'All'), value: '*' }, + { label: t('alerting.rules.health.ok', 'OK'), value: RuleHealth.Ok }, + { label: t('alerting.rules.health.no-data', 'No data'), value: RuleHealth.NoData }, + { label: t('alerting.rules.health.error', 'Error'), value: RuleHealth.Error }, + ]} + value={field.value} + onChange={field.onChange} + /> + )} + /> + {pluginsFilterEnabled && ( + <> + + ( + + options={[ + { label: t('alerting.rules-filter.label.show', 'Show'), value: 'show' }, + { label: t('alerting.rules-filter.label.hide', 'Hide'), value: 'hide' }, + ]} + value={field.value} + onChange={field.onChange} + /> + )} + /> + + )} +
+ + + + - + ); }; -type TableColumns = { - name: string; - default?: boolean; -}; - -const SavedSearches = () => { - const applySearch = useCallback((name: string) => {}, []); +function SearchQueryHelp() { + const styles = useStyles2(helpStyles); return ( - - - - columns={[ - { - id: 'name', - header: 'Saved search name', - cell: ({ row }) => ( - - {row.original.name} - {row.original.default ? ( - - ) : null} - - ), - }, - { - id: 'actions', - cell: ({ row }) => ( - - - - - ), - }, - ]} - data={[ - { - name: 'My saved search', - default: true, - }, - { - name: 'Another saved search', - }, - { - name: 'This one has a really long name and some emojis too 🥒', - }, - ]} - getRowId={(row) => row.name} - /> - - +
+
+ + Search syntax allows to query alert rules by the parameters defined below. + +
+
+
+
+ Filter type +
+
+ Expression +
+ + + + + + + + + + +
+
); -}; +} + +function HelpRow({ title, expr }: { title: string; expr: string }) { + const styles = useStyles2(helpStyles); + + return ( + <> +
{title}
+ {expr} + + ); +} + +const helpStyles = (theme: GrafanaTheme2) => ({ + grid: css({ + display: 'grid', + gridTemplateColumns: 'max-content auto', + gap: theme.spacing(1), + alignItems: 'center', + }), + code: css({ + display: 'block', + textAlign: 'center', + }), +}); function getStyles(theme: GrafanaTheme2) { return { content: css({ padding: theme.spacing(1), - maxWidth: 500, }), - fixTabsMargin: css({ - marginTop: theme.spacing(-1), + grid: css({ + display: 'grid', + gridTemplateColumns: 'auto 1fr', + alignItems: 'center', + gap: theme.spacing(2), }), }; } diff --git a/public/app/features/alerting/unified/components/rules/Filter/RulesViewModeSelector.tsx b/public/app/features/alerting/unified/components/rules/Filter/RulesViewModeSelector.tsx index 661507d6ac8..f467a1395fa 100644 --- a/public/app/features/alerting/unified/components/rules/Filter/RulesViewModeSelector.tsx +++ b/public/app/features/alerting/unified/components/rules/Filter/RulesViewModeSelector.tsx @@ -13,8 +13,8 @@ export type SupportedView = 'list' | 'grouped'; type LegacySupportedView = 'list' | 'grouped' | 'state'; const ViewOptions: Array> = [ - { icon: 'folder', label: 'Grouped', value: 'grouped' }, - { icon: 'list-ul', label: 'List', value: 'list' }, + { icon: 'folder', value: 'grouped', label: 'Grouped' }, + { icon: 'list-ul', value: 'list', label: 'List' }, ]; interface RulesViewModeSelectorV2Props { diff --git a/public/app/features/alerting/unified/components/rules/Filter/useRuleFilterAutocomplete.ts b/public/app/features/alerting/unified/components/rules/Filter/useRuleFilterAutocomplete.ts new file mode 100644 index 00000000000..8210080a1d8 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/Filter/useRuleFilterAutocomplete.ts @@ -0,0 +1,186 @@ +import { useMemo } from 'react'; + +import { DataSourceInstanceSettings } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { ComboboxOption } from '@grafana/ui'; +import { GrafanaPromRuleGroupDTO } from 'app/types/unified-alerting-dto'; + +import { alertRuleApi } from '../../../api/alertRuleApi'; +import { GRAFANA_RULER_CONFIG } from '../../../api/featureDiscoveryApi'; +import { prometheusApi } from '../../../api/prometheusApi'; +import { useGetLabelsFromDataSourceName } from '../../../components/rule-editor/useAlertRuleSuggestions'; +import { GRAFANA_RULES_SOURCE_NAME, getRulesDataSources } from '../../../utils/datasource'; + +export function useNamespaceAndGroupOptions(): { + namespaceOptions: Array>; + allGroupNames: string[]; + isLoadingNamespaces: boolean; + namespacePlaceholder: string; + groupPlaceholder: string; +} { + const { currentData: grafanaPromRulesResponse, isLoading: isLoadingGrafanaPromRules } = + prometheusApi.endpoints.getGrafanaGroups.useQuery({ + limitAlerts: 0, + groupLimit: 1000, + }); + + // Transform Grafana groups to namespace structure + const grafanaPromRules = useMemo(() => { + const groups = grafanaPromRulesResponse?.data?.groups ?? []; + + const namespaceMap = new Map(); + groups.forEach((group) => { + const namespaceName = group.file || 'default'; + const existing = namespaceMap.get(namespaceName); + if (existing) { + existing.groups.push(group); + } else { + namespaceMap.set(namespaceName, { name: namespaceName, groups: [group] }); + } + }); + + return Array.from(namespaceMap.values()); + }, [grafanaPromRulesResponse]); + + const { isLoading: isLoadingGrafanaRulerRules } = alertRuleApi.endpoints.rulerRules.useQuery({ + rulerConfig: GRAFANA_RULER_CONFIG, + }); + + const externalDataSources = useMemo(getRulesDataSources, []); + + const externalPromRulesQueries = externalDataSources.map((ds) => + prometheusApi.endpoints.getGroups.useQuery({ + ruleSource: { uid: ds.uid }, + excludeAlerts: true, + groupLimit: 500, + }) + ); + + const isLoadingNamespaces = useMemo(() => { + return ( + isLoadingGrafanaPromRules || + isLoadingGrafanaRulerRules || + externalPromRulesQueries.some((query) => query.isLoading) + ); + }, [isLoadingGrafanaPromRules, isLoadingGrafanaRulerRules, externalPromRulesQueries]); + + const namespaceOptions = useMemo((): Array> => { + const grafanaFolders: Array> = []; + const externalNamespaces: Array> = []; + + // Grafana folders + grafanaPromRules.forEach((namespace) => { + grafanaFolders.push({ + label: namespace.name, + value: namespace.name, + description: t('alerting.rules-filter.grafana-folder', 'Grafana folder'), + }); + }); + + // External namespaces (dedupe by file) + externalPromRulesQueries.forEach((query) => { + const namespaces = new Set(); + query.currentData?.data?.groups?.forEach((group) => { + namespaces.add(group.file || 'default'); + }); + + namespaces.forEach((namespaceName) => { + if (namespaceName.includes('/') && (namespaceName.endsWith('.yml') || namespaceName.endsWith('.yaml'))) { + const filename = namespaceName.split('/').pop() || namespaceName; + const maxDescriptionLength = 100; + const truncatedDescription = + namespaceName.length > maxDescriptionLength + ? `${namespaceName.substring(0, maxDescriptionLength)}...` + : namespaceName; + externalNamespaces.push({ label: filename, value: namespaceName, description: truncatedDescription }); + } else { + const maxLength = 50; + const maxDescriptionLength = 100; + const truncatedName = + namespaceName.length > maxLength ? `${namespaceName.substring(0, maxLength)}...` : namespaceName; + const truncatedDescription = + namespaceName.length > maxDescriptionLength + ? `${namespaceName.substring(0, maxDescriptionLength)}...` + : namespaceName; + externalNamespaces.push({ label: truncatedName, value: namespaceName, description: truncatedDescription }); + } + }); + }); + + const collator = new Intl.Collator(); + grafanaFolders.sort((a, b) => collator.compare(a.label ?? '', b.label ?? '')); + externalNamespaces.sort((a, b) => collator.compare(a.label ?? '', b.label ?? '')); + + return [...grafanaFolders, ...externalNamespaces]; + }, [grafanaPromRules, externalPromRulesQueries]); + + const allGroupNames = useMemo(() => { + const groupSet = new Set(); + grafanaPromRules.forEach((namespace) => { + namespace.groups.forEach((group) => groupSet.add(group.name)); + }); + externalPromRulesQueries.forEach((query) => { + query.currentData?.data?.groups?.forEach((group) => { + groupSet.add(group.name); + }); + }); + return Array.from(groupSet).sort(); + }, [grafanaPromRules, externalPromRulesQueries]); + + const namespacePlaceholder = useMemo(() => { + if (isLoadingNamespaces) { + return t('common.loading', 'Loading...'); + } + if (namespaceOptions.length === 0) { + return t('alerting.rules-filter.no-namespaces', 'No folders available'); + } + return t('alerting.rules-filter.filter-options.placeholder-namespace', 'Select namespace'); + }, [isLoadingNamespaces, namespaceOptions.length]); + + const groupPlaceholder = useMemo(() => { + if (isLoadingNamespaces) { + return t('common.loading', 'Loading...'); + } + if (allGroupNames.length === 0) { + return t('alerting.rules-filter.no-groups', 'No groups available'); + } + return t('grafana.select-group', 'Select group'); + }, [isLoadingNamespaces, allGroupNames.length]); + + return { namespaceOptions, allGroupNames, isLoadingNamespaces, namespacePlaceholder, groupPlaceholder }; +} + +export function useLabelOptions(): { + labelOptions: Array>; + isLoadingGrafanaLabels: boolean; +} { + const { labels: grafanaLabels, isLoading: isLoadingGrafanaLabels } = + useGetLabelsFromDataSourceName(GRAFANA_RULES_SOURCE_NAME); + + const labelOptions = useMemo((): Array> => { + const infoOption: ComboboxOption = { + label: t('label-dropdown-info', "Can't find your label? Enter it manually"), + value: '__GRAFANA_LABEL_DROPDOWN_INFO__', + infoOption: true, + }; + + const selectableOptions = Array.from(grafanaLabels.entries()) + .flatMap(([key, values]) => + Array.from(values).map((value: string) => ({ label: `${key}=${value}`, value: `${key}=${value}` })) + ) + .sort((a, b) => new Intl.Collator().compare(a.label, b.label)); + + return [...selectableOptions, infoOption]; + }, [grafanaLabels]); + + return { labelOptions, isLoadingGrafanaLabels }; +} + +export function useAlertingDataSourceOptions(): Array> { + return useMemo(() => { + return getDataSourceSrv() + .getList({ alerting: true }) + .map((ds: DataSourceInstanceSettings) => ({ label: ds.name, value: ds.name })); + }, []); +} diff --git a/public/app/features/alerting/unified/components/rules/Filter/utils.ts b/public/app/features/alerting/unified/components/rules/Filter/utils.ts new file mode 100644 index 00000000000..049634634af --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/Filter/utils.ts @@ -0,0 +1,81 @@ +import { useEffect, useRef } from 'react'; + +import { useAlertingHomePageExtensions } from '../../../plugins/useAlertingHomePageExtensions'; +import { RulesFilter } from '../../../search/rulesSearchParser'; + +import { AdvancedFilters } from './RulesFilter.v2'; + +export function formAdvancedFiltersToRuleFilter(values: AdvancedFilters): RulesFilter { + return { + freeFormWords: [], + ...values, + namespace: values.namespace || undefined, + groupName: values.groupName || undefined, + contactPoint: values.contactPoint || undefined, + ruleHealth: values.ruleHealth === '*' ? undefined : values.ruleHealth, + ruleState: values.ruleState === '*' ? undefined : values.ruleState, + ruleType: values.ruleType === '*' ? undefined : values.ruleType, + plugins: values.plugins === 'show' ? undefined : 'hide', + }; +} + +export const emptyAdvancedFilters: AdvancedFilters = { + namespace: null, + groupName: null, + ruleName: undefined, + ruleType: '*', + ruleState: '*', + dataSourceNames: [], + labels: [], + ruleHealth: '*', + dashboardUid: undefined, + plugins: 'show', + contactPoint: null, +}; + +export function searchQueryToDefaultValues(filterState: RulesFilter): AdvancedFilters { + return { + namespace: filterState.namespace ?? null, + groupName: filterState.groupName ?? null, + ruleName: filterState.ruleName, + ruleType: filterState.ruleType ?? '*', + ruleState: filterState.ruleState ?? '*', + dataSourceNames: filterState.dataSourceNames, + labels: filterState.labels, + ruleHealth: filterState.ruleHealth ?? '*', + dashboardUid: filterState.dashboardUid, + plugins: filterState.plugins ?? 'show', + contactPoint: filterState.contactPoint ?? null, + }; +} + +export function usePortalContainer(zIndex: number): HTMLElement | undefined { + const containerRef = useRef(null); + + useEffect(() => { + const container = document.createElement('div'); + Object.assign(container.style, { + position: 'fixed', + top: '0', + left: '0', + width: '100%', + height: '100%', + pointerEvents: 'none', + zIndex: String(zIndex), + }); + + document.body.appendChild(container); + containerRef.current = container; + + return () => { + container.remove(); + }; + }, [zIndex]); + + return containerRef.current || undefined; +} + +export function usePluginsFilterStatus() { + const { components } = useAlertingHomePageExtensions(); + return { pluginsFilterEnabled: components.length > 0 }; +} diff --git a/public/app/features/alerting/unified/components/rules/RulesFilterV2.test.tsx b/public/app/features/alerting/unified/components/rules/RulesFilterV2.test.tsx new file mode 100644 index 00000000000..ecff8085f42 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/RulesFilterV2.test.tsx @@ -0,0 +1,366 @@ +import { render, screen } from 'test/test-utils'; +import { byRole, byTestId } from 'testing-library-selector'; + +import { ComponentTypeWithExtensionMeta, PluginExtensionComponentMeta, PluginExtensionTypes } from '@grafana/data'; +import { config, locationService, setPluginComponentsHook } from '@grafana/runtime'; +import { setupMswServer } from 'app/features/alerting/unified/mockApi'; +import { grantUserPermissions } from 'app/features/alerting/unified/mocks'; +import { AccessControlAction } from 'app/types/accessControl'; +import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; + +import * as analytics from '../../Analytics'; +import { useRulesFilter } from '../../hooks/useFilteredRules'; +import { RulesFilter as RulesFilterType } from '../../search/rulesSearchParser'; +import { setupPluginsExtensionsHook } from '../../testSetup/plugins'; + +// Grant permission before importing the component since permission check happens at module level +grantUserPermissions([AccessControlAction.AlertingReceiversRead]); + +let mockFilterState: RulesFilterType = { + ruleName: '', + ruleState: undefined, + dataSourceNames: [], + freeFormWords: [], + labels: [], +}; +let mockSearchQuery = ''; +const mockUpdateFilters = jest.fn(); +const mockSetSearchQuery = jest.fn(); +const mockClearAll = jest.fn(); + +jest.mock('../../hooks/useFilteredRules', () => ({ + useRulesFilter: jest.fn(() => ({ + searchQuery: mockSearchQuery, + filterState: mockFilterState, + updateFilters: mockUpdateFilters, + setSearchQuery: mockSetSearchQuery, + clearAll: mockClearAll, + hasActiveFilters: false, + activeFilters: [], + })), +})); + +import RulesFilter from './Filter/RulesFilter'; +import RulesFilterV2 from './Filter/RulesFilter.v2'; + +const useRulesFilterMock = useRulesFilter as jest.MockedFunction; + +setupMswServer(); + +jest.spyOn(analytics, 'trackFilterButtonClick'); +jest.spyOn(analytics, 'trackFilterButtonApplyClick'); +jest.spyOn(analytics, 'trackFilterButtonClearClick'); + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => ({ + getList: jest.fn().mockReturnValue([ + { name: 'Prometheus', uid: 'prometheus-uid' }, + { name: 'Loki', uid: 'loki-uid' }, + ]), + }), +})); + +jest.mock('./MultipleDataSourcePicker', () => { + const original = jest.requireActual('./MultipleDataSourcePicker'); + return { + ...original, + MultipleDataSourcePicker: () => null, + }; +}); + +jest.mock('../../plugins/useAlertingHomePageExtensions', () => ({ + useAlertingHomePageExtensions: jest.fn(() => { + const { usePluginComponents } = jest.requireActual('@grafana/runtime'); + const { PluginExtensionPoints } = jest.requireActual('@grafana/data'); + return usePluginComponents({ + extensionPointId: PluginExtensionPoints.AlertingHomePage, + limitPerPlugin: 1, + }); + }), +})); + +setupPluginsExtensionsHook(); + +// Helper function to create mock plugin components +function createMockComponent(pluginId: string): ComponentTypeWithExtensionMeta<{}> { + function MockComponent() { + return
Test Plugin Component
; + } + + MockComponent.meta = { + id: `test-component-${pluginId}`, + pluginId, + title: 'Test Component', + description: 'Test plugin component', + type: PluginExtensionTypes.component, + } satisfies PluginExtensionComponentMeta; + + return MockComponent as ComponentTypeWithExtensionMeta<{}>; +} + +const ui = { + searchInput: byTestId('search-query-input'), + filterButton: byRole('button', { name: 'Filter' }), + applyButton: byTestId('filter-apply-button'), + clearButton: byTestId('filter-clear-button'), + ruleNameInput: byTestId('rule-name-input'), +}; + +beforeEach(() => { + locationService.replace({ search: '' }); + jest.clearAllMocks(); + + mockFilterState = { + ruleName: '', + ruleState: undefined, + dataSourceNames: [], + freeFormWords: [], + labels: [], + }; + mockSearchQuery = ''; + mockUpdateFilters.mockClear(); + mockSetSearchQuery.mockClear(); + mockClearAll.mockClear(); + mockSetSearchQuery.mockImplementation(() => {}); + + // Reset plugin components hook to default (no plugins) + setPluginComponentsHook(() => ({ + components: [], + isLoading: false, + })); +}); + +describe('RulesFilter Feature Flag', () => { + const originalFeatureToggle = config.featureToggles.alertingFilterV2; + + afterEach(() => { + config.featureToggles.alertingFilterV2 = originalFeatureToggle; + }); + + it('Should render RulesFilterV2 when alertingFilterV2 feature flag is enabled', async () => { + config.featureToggles.alertingFilterV2 = true; + + render(); + + // Wait for suspense to resolve and check that the V2 filter button is present + await screen.findByRole('button', { name: 'Filter' }); + expect(ui.filterButton.get()).toBeInTheDocument(); + expect(ui.searchInput.get()).toBeInTheDocument(); + }); + + it('Should render RulesFilterV1 when alertingFilterV2 feature flag is disabled', async () => { + config.featureToggles.alertingFilterV2 = false; + + render(); + + // Wait for suspense to resolve and check V1 structure + await screen.findByText('Search'); + + // V1 has search input but no V2-style filter button + expect(ui.searchInput.get()).toBeInTheDocument(); + expect(ui.filterButton.query()).not.toBeInTheDocument(); + + // V1 has a help icon next to the search input + expect(screen.getByText('Search')).toBeInTheDocument(); + }); +}); + +describe('RulesFilterV2', () => { + it('Should render component without crashing', () => { + render(); + + expect(ui.searchInput.get()).toBeInTheDocument(); + expect(ui.filterButton.get()).toBeInTheDocument(); + }); + + it('Should allow typing in search input', async () => { + const { user } = render(); + + await user.type(ui.searchInput.get(), 'test search'); + expect(ui.searchInput.get()).toHaveValue('test search'); + }); + + it('Should open filter popup when filter button is clicked', async () => { + const { user } = render(); + await user.click(ui.filterButton.get()); + expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument(); + }); + + it('Should close popup when clicking outside', async () => { + const { user } = render(); + await user.click(ui.filterButton.get()); + expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument(); + await user.click(document.body); + expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument(); + }); + + it('Should clear filters and search field when clicking clear button', async () => { + const { user } = render(); + await user.type(ui.searchInput.get(), 'test'); + expect(ui.searchInput.get()).toHaveValue('test'); + await user.click(ui.filterButton.get()); + + await user.click(ui.clearButton.get()); + expect(ui.ruleNameInput.get()).toHaveValue(''); + + // Check that setSearchQuery was called with undefined to clear the search + expect(mockSetSearchQuery).toHaveBeenCalledWith(undefined); + }); + + it('Should populate search field with query string when filters are applied via rule name', async () => { + const { user } = render(); + + await user.click(ui.filterButton.get()); + + await user.type(ui.ruleNameInput.get(), 'test'); + + // Mock the setSearchQuery to update mockSearchQuery + mockSetSearchQuery.mockImplementation((newQuery: string | undefined) => { + mockSearchQuery = newQuery ?? ''; + }); + + await user.click(ui.applyButton.get()); + + // Check that setSearchQuery was called with the expected query + expect(mockSetSearchQuery).toHaveBeenCalledWith('rule:test'); + }); + + it('Should parse search query and call updateFilters when user types directly in search field', async () => { + const { user, rerender } = render(); + + // Type a search query directly into the search input + await user.type(ui.searchInput.get(), 'rule:test state:firing'); + + // Trigger the onBlur handler by clicking elsewhere + await user.click(document.body); + + // Verify updateFilters was called with the parsed filter + expect(mockUpdateFilters).toHaveBeenCalledWith({ + dataSourceNames: [], + freeFormWords: [], + labels: [], + ruleName: 'test', + ruleState: 'firing', + }); + + // Simulate the filter state update by updating our mock + mockFilterState = { + dataSourceNames: [], + freeFormWords: [], + labels: [], + ruleName: 'test', + ruleState: PromAlertingRuleState.Firing, + }; + + // Update the mock to return the new state + useRulesFilterMock.mockReturnValue({ + searchQuery: mockSearchQuery, + filterState: mockFilterState, + updateFilters: mockUpdateFilters, + setSearchQuery: mockSetSearchQuery, + clearAll: mockClearAll, + hasActiveFilters: false, + activeFilters: [], + }); + + // Force a re-render to pick up the new filter state + rerender(); + + // Open the filter popup + await user.click(ui.filterButton.get()); + + expect(ui.ruleNameInput.get()).toHaveValue('test'); + + const firingRadio = screen.getByRole('radio', { name: 'Firing' }); + expect(firingRadio).toBeChecked(); + }); + + it('Should handle free-form rule name search in query string', async () => { + const { user } = render(); + + // Type a free-form search (no filter prefix) + await user.type(ui.searchInput.get(), 'test'); + + // Trigger the search by pressing Enter + await user.keyboard('{Enter}'); + + // The search input should retain the value + expect(ui.searchInput.get()).toHaveValue('test'); + }); + + describe('Conditional Fields', () => { + it('Should show contact point field when user has proper permissions', async () => { + // Permission is already mocked to true at module level + const { user } = render(); + await user.click(ui.filterButton.get()); + expect(screen.getByText('Contact point')).toBeInTheDocument(); + }); + + it('Should show plugin filter when plugins are enabled', async () => { + // Mock plugin components to simulate plugins available + setPluginComponentsHook(() => ({ + components: [createMockComponent('test-plugin')], + isLoading: false, + })); + + const { user } = render(); + await user.click(ui.filterButton.get()); + expect(screen.getByText('Plugin rules')).toBeInTheDocument(); + }); + + it('Should hide plugin filter when no plugins are available', async () => { + // Mock plugin components to return no components + setPluginComponentsHook(() => ({ + components: [], + isLoading: false, + })); + + const { user } = render(); + await user.click(ui.filterButton.get()); + expect(screen.queryByText('Plugin rules')).not.toBeInTheDocument(); + }); + }); + + describe('Analytics Tracking', () => { + it('Should track filter button clicks when opening popup', async () => { + const { user } = render(); + + await user.click(ui.filterButton.get()); + + expect(analytics.trackFilterButtonClick).toHaveBeenCalledTimes(1); + }); + + it('Should track clear button clicks', async () => { + const { user } = render(); + + await user.click(ui.filterButton.get()); + await user.click(ui.clearButton.get()); + + expect(analytics.trackFilterButtonClearClick).toHaveBeenCalledTimes(1); + }); + + it('Should track apply button clicks with filter values', async () => { + const { user } = render(); + + await user.click(ui.filterButton.get()); + await user.click(ui.applyButton.get()); + + expect(analytics.trackFilterButtonApplyClick).toHaveBeenCalledTimes(1); + }); + + it('Should not track filter button click when filter button is clicked to close popup', async () => { + const { user } = render(); + + await user.click(ui.filterButton.get()); + expect(analytics.trackFilterButtonClick).toHaveBeenCalledTimes(1); + + await user.click(document.body); + + jest.clearAllMocks(); + + await user.click(ui.filterButton.get()); + expect(analytics.trackFilterButtonClick).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/CentralHistoryRuntimeDataSource.ts b/public/app/features/alerting/unified/components/rules/central-state-history/CentralHistoryRuntimeDataSource.ts index bab7a40e855..f6b76f5e00a 100644 --- a/public/app/features/alerting/unified/components/rules/central-state-history/CentralHistoryRuntimeDataSource.ts +++ b/public/app/features/alerting/unified/components/rules/central-state-history/CentralHistoryRuntimeDataSource.ts @@ -12,7 +12,7 @@ import { stateHistoryApi } from '../../../api/stateHistoryApi'; import { DataSourceInformation } from '../../../home/Insights'; import { LIMIT_EVENTS } from './EventListSceneObject'; -import { historyResultToDataFrame } from './utils'; +import { historyResultToDataFrame, parseBackendLabelFilters } from './utils'; const historyDataSourceUid = '__history_api_ds_uid__'; const historyDataSourcePluginId = '__history_api_ds_pluginId__'; @@ -65,7 +65,9 @@ class HistoryAPIDatasource extends RuntimeDataSource { const stateTo = templateSrv.replace(query.stateTo ?? '', request.scopedVars); const stateFrom = templateSrv.replace(query.stateFrom ?? '', request.scopedVars); - const historyResult = await getHistory(from, to); + const labelFilters = parseBackendLabelFilters(labels); + + const historyResult = await getHistory(from, to, labelFilters); return { data: historyResultToDataFrame(historyResult, { stateTo, stateFrom, labels }), @@ -85,15 +87,17 @@ class HistoryAPIDatasource extends RuntimeDataSource { * Fetch the history events from the history api. * @param from the start time * @param to the end time - * @returns the history events only filtered by time + * @param labels optional label filters for backend filtering + * @returns the history events filtered by time and labels */ -export const getHistory = (from: number, to: number) => { +export const getHistory = (from: number, to: number, labels?: Record) => { return dispatch( stateHistoryApi.endpoints.getRuleHistory.initiate( { from: from, to: to, limit: LIMIT_EVENTS, + labels: labels, }, { forceRefetch: Boolean(getTimeSrv().getAutoRefreshInteval().interval), // force refetch in case we are using the refresh option diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx b/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx index 24fe014c246..4f83a7aec0f 100644 --- a/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx +++ b/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx @@ -38,6 +38,7 @@ import { LABELS_FILTER, STATE_FILTER_FROM, STATE_FILTER_TO } from './CentralAler import { EventDetails } from './EventDetails'; import { HistoryErrorMessage } from './HistoryErrorMessage'; import { useRuleHistoryRecords } from './useRuleHistoryRecords'; +import { parseBackendLabelFilters } from './utils'; export const LIMIT_EVENTS = 5000; // limit is hard-capped at 5000 at the BE level. const PAGE_SIZE = 100; @@ -65,6 +66,8 @@ export const HistoryEventsList = ({ const from = timeRange?.from.unix(); const to = timeRange?.to.unix(); + const labelFilters = parseBackendLabelFilters(valueInLabelFilter.toString()); + const { data: stateHistory, isLoading, @@ -74,6 +77,7 @@ export const HistoryEventsList = ({ from: from, to: to, limit: LIMIT_EVENTS, + labels: labelFilters, }); const { historyRecords: historyRecordsNotSorted } = useRuleHistoryRecords(stateHistory, { @@ -102,7 +106,7 @@ export const HistoryEventsList = ({ > {t( 'alerting.central-alert-history.too-many-events.text', - 'The selected time period has too many events to display. Diplaying the latest 5000 events. Try using a shorter time period.' + 'The selected time period has too many events to display. Displaying the latest 5000 events. Try using a shorter time period.' )} )} diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/HistoryEventsList.test.tsx b/public/app/features/alerting/unified/components/rules/central-state-history/HistoryEventsList.test.tsx index e2196595e57..d538fc8b0c2 100644 --- a/public/app/features/alerting/unified/components/rules/central-state-history/HistoryEventsList.test.tsx +++ b/public/app/features/alerting/unified/components/rules/central-state-history/HistoryEventsList.test.tsx @@ -4,8 +4,10 @@ import { byLabelText, byTestId } from 'testing-library-selector'; import { getDefaultTimeRange } from '@grafana/data'; import { setupMswServer } from '../../../mockApi'; +import { captureRequests } from '../../../mocks/server/events'; import { StateFilterValues } from './CentralAlertHistoryScene'; +import { getHistory } from './CentralHistoryRuntimeDataSource'; import { HistoryEventsList } from './EventListSceneObject'; setupMswServer(); @@ -148,4 +150,54 @@ describe('HistoryEventsList', () => { }); expect(ui.rowHeader.query()).not.toBeInTheDocument(); }); + + describe('backend filtering', () => { + it('should send only exact match filters to the backend', async () => { + const capture = captureRequests((req) => req.url.includes('/api/v1/rules/history')); + + render( + + ); + + await waitFor(() => { + expect(ui.loadingBar.query()).not.toBeInTheDocument(); + }); + + const requests = await capture; + expect(requests).toHaveLength(1); + + const url = new URL(requests[0].url); + + expect(url.searchParams.get('labels_alertname')).toBe('alert1'); + expect(url.searchParams.get('labels_team')).toBe('alerting'); + expect(url.searchParams.get('labels_grafana_folder')).toBeNull(); + expect(url.searchParams.get('labels_severity')).toBeNull(); + }); + + it('should apply same backend filtering to chart data via getHistory function', async () => { + const capture = captureRequests((req) => req.url.includes('/api/v1/rules/history')); + + const from = 123; + const to = 456; + const labels = { alertname: 'alert_1', team: 'alerting' }; + + await getHistory(from, to, labels); + + const requests = await capture; + expect(requests).toHaveLength(1); + + const url = new URL(requests[0].url); + + expect(url.searchParams.get('labels_alertname')).toBe(labels.alertname); + expect(url.searchParams.get('labels_team')).toBe(labels.team); + expect(url.searchParams.get('from')).toBe(from.toString()); + expect(url.searchParams.get('to')).toBe(to.toString()); + }); + }); }); diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/utils.ts b/public/app/features/alerting/unified/components/rules/central-state-history/utils.ts index a629b03dfb2..aaa7ea4830e 100644 --- a/public/app/features/alerting/unified/components/rules/central-state-history/utils.ts +++ b/public/app/features/alerting/unified/components/rules/central-state-history/utils.ts @@ -24,6 +24,23 @@ import { LABELS_FILTER, STATE_FILTER_FROM, STATE_FILTER_TO, StateFilterValues } const GROUPING_INTERVAL = 10 * 1000; // 10 seconds const QUERY_PARAM_PREFIX = 'var-'; // Prefix used by Grafana to sync variables in the URL +/** + * Parse label filters and prepare backend filters. + * Backend supports only exact matchers. + */ +export function parseBackendLabelFilters(labelFilter: string): Record { + const labelMatchers = parsePromQLStyleMatcherLooseSafe(labelFilter); + const labelFilters: Record = {}; + + labelMatchers.forEach((matcher) => { + if (!matcher.isRegex && matcher.isEqual) { + labelFilters[matcher.name] = matcher.value; + } + }); + + return labelFilters; +} + interface HistoryFilters { stateTo: string; stateFrom: string; diff --git a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx index 94ccf10db46..30154485057 100644 --- a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx @@ -39,7 +39,7 @@ import { matcherFieldToMatcher } from '../../utils/alertmanager'; import { makeAMLink } from '../../utils/misc'; import { withPageErrorBoundary } from '../../withPageErrorBoundary'; import { AlertmanagerPageWrapper } from '../AlertingPageWrapper'; -import { GrafanaAlertmanagerDeliveryWarning } from '../GrafanaAlertmanagerDeliveryWarning'; +import { GrafanaAlertmanagerWarning } from '../GrafanaAlertmanagerWarning'; import MatchersField from './MatchersField'; import { SilencePeriod } from './SilencePeriod'; @@ -118,7 +118,7 @@ const ExistingSilenceEditor = () => { return ( <> - + ); diff --git a/public/app/features/alerting/unified/components/silences/SilencesTable.tsx b/public/app/features/alerting/unified/components/silences/SilencesTable.tsx index 314dcf26586..8d3c80acad1 100644 --- a/public/app/features/alerting/unified/components/silences/SilencesTable.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencesTable.tsx @@ -30,7 +30,7 @@ import { withPageErrorBoundary } from '../../withPageErrorBoundary'; import { AlertmanagerPageWrapper } from '../AlertingPageWrapper'; import { Authorize } from '../Authorize'; import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; -import { GrafanaAlertmanagerDeliveryWarning } from '../GrafanaAlertmanagerDeliveryWarning'; +import { GrafanaAlertmanagerWarning } from '../GrafanaAlertmanagerWarning'; import { Matchers } from './Matchers'; import { NoSilencesSplash } from './NoSilencesCTA'; @@ -146,7 +146,7 @@ const SilencesTable = () => { return (
- + {!!silences.length && ( diff --git a/public/app/features/alerting/unified/hooks/useFilteredRules.ts b/public/app/features/alerting/unified/hooks/useFilteredRules.ts index 544b0db9975..fb78380b180 100644 --- a/public/app/features/alerting/unified/hooks/useFilteredRules.ts +++ b/public/app/features/alerting/unified/hooks/useFilteredRules.ts @@ -80,7 +80,11 @@ export function useRulesFilter() { } }, [queryParams, updateFilters, filterState, updateQueryParams]); - return { filterState, hasActiveFilters, activeFilters, searchQuery, setSearchQuery, updateFilters }; + const clearAll = useCallback(() => { + updateQueryParams({ search: undefined }); + }, [updateQueryParams]); + + return { filterState, hasActiveFilters, searchQuery, setSearchQuery, updateFilters, clearAll, activeFilters }; } export const useFilteredRules = (namespaces: CombinedRuleNamespace[], filterState: RulesFilter) => { diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index 0202406bf8e..6378aa33ddc 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -645,7 +645,7 @@ export const grantUserPermissions = (permissions: AccessControlAction[]) => { }; export const grantUserRole = (role: string) => { - jest.spyOn(contextSrv, 'hasRole').mockReturnValue(true); + jest.spyOn(contextSrv, 'hasRole').mockImplementation((checkRole) => checkRole === role); }; export function mockUnifiedAlertingStore(unifiedAlerting?: Partial) { diff --git a/public/app/features/alerting/unified/rule-editor/formDefaults.test.ts b/public/app/features/alerting/unified/rule-editor/formDefaults.test.ts index 88b3fbed717..8cdff983960 100644 --- a/public/app/features/alerting/unified/rule-editor/formDefaults.test.ts +++ b/public/app/features/alerting/unified/rule-editor/formDefaults.test.ts @@ -1,4 +1,3 @@ -import { GrafanaConfig } from '@grafana/data'; import { config, getDataSourceSrv } from '@grafana/runtime'; import { mockAlertQuery, mockDataSource, mockReduceExpression, mockThresholdExpression } from '../mocks'; @@ -201,8 +200,7 @@ describe('getDefaultManualRouting', () => { }); describe('getDefaultFormValues', () => { - // This is for Typescript. GrafanaBootConfig returns narrower types than GrafanaConfig - const grafanaConfig: GrafanaConfig = config; + const grafanaConfig = config; const uaConfig = grafanaConfig.unifiedAlerting; const mockGetInstanceSettings = jest.fn(); diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx index 9417ffa2230..3b150a698df 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx @@ -1,11 +1,14 @@ +import { HttpResponse } from 'msw'; import { render } from 'test/test-utils'; import { byRole, byTestId } from 'testing-library-selector'; +import { OrgRole } from '@grafana/data'; import { setPluginComponentsHook, setPluginLinksHook } from '@grafana/runtime'; import { AccessControlAction } from 'app/types/accessControl'; import { setupMswServer } from '../mockApi'; -import { grantUserPermissions } from '../mocks'; +import { grantUserPermissions, grantUserRole } from '../mocks'; +import { setGrafanaRuleGroupExportResolver } from '../mocks/server/configure'; import { alertingFactory } from '../mocks/server/db'; import { RulesFilter } from '../search/rulesSearchParser'; import { testWithFeatureToggles } from '../test/test-utils'; @@ -162,11 +165,16 @@ describe('RuleListActions', () => { newAlertRuleForExport: byRole('link', { name: /new alert rule for export/i }), newGrafanaRecordingRule: byRole('link', { name: /new grafana recording rule/i }), newDataSourceRecordingRule: byRole('link', { name: /new data source recording rule/i }), + importAlertRules: byRole('link', { name: /import alert rules/i }), + exportAllGrafanaRules: byRole('menuitem', { name: /export all grafana rules/i }), }, + exportDrawer: byRole('dialog', { name: /export/i }), }; beforeEach(() => { jest.clearAllMocks(); + // Default to Viewer role (non-admin) + grantUserRole(OrgRole.Viewer); }); it.each([ @@ -243,6 +251,90 @@ describe('RuleListActions', () => { expect(ui.menuOptions.newGrafanaRecordingRule.query(menu)).toBeInTheDocument(); expect(ui.menuOptions.newDataSourceRecordingRule.query(menu)).toBeInTheDocument(); }); + + describe('Import Alert Rules', () => { + testWithFeatureToggles(['alertingMigrationUI']); + + it('should show "Import alert rules" option when user is admin and feature toggle is enabled', async () => { + grantUserRole(OrgRole.Admin); + grantUserPermissions([AccessControlAction.AlertingRuleRead]); + + const { user } = render(); + + await user.click(ui.moreButton.get()); + const menu = await ui.moreMenu.find(); + + expect(ui.menuOptions.importAlertRules.query(menu)).toBeInTheDocument(); + }); + + it('should not show "Import alert rules" option when user is not admin', async () => { + // Keep default Viewer role + grantUserPermissions([AccessControlAction.AlertingRuleRead]); + + const { user } = render(); + + await user.click(ui.moreButton.get()); + const menu = await ui.moreMenu.find(); + + expect(ui.menuOptions.importAlertRules.query(menu)).not.toBeInTheDocument(); + }); + + it('should have correct URL for "Import alert rules" menu item', async () => { + grantUserRole(OrgRole.Admin); + grantUserPermissions([AccessControlAction.AlertingRuleRead]); + + const { user } = render(); + + await user.click(ui.moreButton.get()); + const menu = await ui.moreMenu.find(); + const importMenuItem = ui.menuOptions.importAlertRules.get(menu); + + expect(importMenuItem).toHaveAttribute('href', '/alerting/import-datasource-managed-rules'); + }); + }); + + describe('Export All Grafana Rules', () => { + it('should show "Export all Grafana rules" option when user has export permissions', async () => { + grantUserPermissions([AccessControlAction.AlertingRuleRead]); + + const { user } = render(); + + await user.click(ui.moreButton.get()); + const menu = await ui.moreMenu.find(); + + expect(ui.menuOptions.exportAllGrafanaRules.query(menu)).toBeInTheDocument(); + }); + + it('should not show "Export all Grafana rules" option when user lacks export permissions', async () => { + grantUserPermissions([]); // No permissions + + const { user } = render(); + + await user.click(ui.moreButton.get()); + const menu = await ui.moreMenu.find(); + + expect(ui.menuOptions.exportAllGrafanaRules.query(menu)).not.toBeInTheDocument(); + }); + + it('should open export drawer when "Export all Grafana rules" is clicked', async () => { + // Set up MSW mock for export endpoint + setGrafanaRuleGroupExportResolver(() => { + return HttpResponse.text('# Mock YAML export content\ngroups: []'); + }); + + grantUserPermissions([AccessControlAction.AlertingRuleRead]); + + const { user } = render(); + + await user.click(ui.moreButton.get()); + const menu = await ui.moreMenu.find(); + const exportMenuItem = ui.menuOptions.exportAllGrafanaRules.get(menu); + + await user.click(exportMenuItem); + + expect(ui.exportDrawer.query()).toBeInTheDocument(); + }); + }); }); describe('RuleList v2 - View switching', () => { diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx index a677cb1d248..7f0ae393755 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx @@ -1,10 +1,12 @@ import { useMemo } from 'react'; +import { useToggle } from 'react-use'; import { Trans, t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; import { Button, Dropdown, Icon, LinkButton, Menu, Stack } from '@grafana/ui'; import { AlertingPageWrapper } from '../components/AlertingPageWrapper'; +import { GrafanaRulesExporter } from '../components/export/GrafanaRulesExporter'; import RulesFilter from '../components/rules/Filter/RulesFilter'; import { useListViewMode } from '../components/rules/Filter/RulesViewModeSelector'; import { AIAlertRuleButtonComponent } from '../enterprise-components/AI/AIGenAlertRuleButton/addAIAlertRuleButton'; @@ -35,13 +37,17 @@ function RuleList() { export function RuleListActions() { const [createGrafanaRuleSupported, createGrafanaRuleAllowed] = useAlertingAbility(AlertingAction.CreateAlertRule); const [createCloudRuleSupported, createCloudRuleAllowed] = useAlertingAbility(AlertingAction.CreateExternalAlertRule); + const [exportRulesSupported, exportRulesAllowed] = useAlertingAbility(AlertingAction.ExportGrafanaManagedRules); const canCreateGrafanaRules = createGrafanaRuleSupported && createGrafanaRuleAllowed; const canCreateCloudRules = createCloudRuleSupported && createCloudRuleAllowed; + const canExportRules = exportRulesSupported && exportRulesAllowed; const canCreateRules = canCreateGrafanaRules || canCreateCloudRules; const canImportRulesToGMA = isAdmin() && config.featureToggles.alertingMigrationUI; + const [showExportDrawer, toggleShowExportDrawer] = useToggle(false); + const moreActionsMenu = useMemo( () => ( @@ -51,10 +57,17 @@ export function RuleListActions() { icon="file-export" url="/alerting/export-new-rule" /> + {canExportRules && ( + + )} {canImportRulesToGMA && ( )} @@ -77,7 +90,7 @@ export function RuleListActions() { ), - [canCreateGrafanaRules, canCreateCloudRules, canImportRulesToGMA] + [canCreateGrafanaRules, canCreateCloudRules, canImportRulesToGMA, canExportRules, toggleShowExportDrawer] ); return ( @@ -93,6 +106,7 @@ export function RuleListActions() { More + {canExportRules && showExportDrawer && } ); } diff --git a/public/app/features/alerting/unified/state/AlertmanagerContext.test.tsx b/public/app/features/alerting/unified/state/AlertmanagerContext.test.tsx index 49d0b8c5d79..26bd108bdd4 100644 --- a/public/app/features/alerting/unified/state/AlertmanagerContext.test.tsx +++ b/public/app/features/alerting/unified/state/AlertmanagerContext.test.tsx @@ -1,9 +1,11 @@ import { renderHook } from '@testing-library/react'; import * as React from 'react'; +import { Provider } from 'react-redux'; import { locationService } from '@grafana/runtime'; import store from 'app/core/store'; import { AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types'; +import { configureStore } from 'app/store/configureStore'; import * as useAlertManagerSources from '../hooks/useAlertManagerSources'; import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY } from '../utils/constants'; @@ -21,14 +23,21 @@ const externalAmMimir: AlertManagerDataSource = { imgUrl: '', }; +function getProviderWrapper() { + const reduxStore = configureStore(); + return ({ children }: React.PropsWithChildren) => ( + + {children} + + ); +} + describe('useAlertmanager', () => { it('Should return undefined alert manager name when there are no available alert managers', () => { jest .spyOn(useAlertManagerSources, 'useAlertManagersByPermission') .mockReturnValueOnce({ availableExternalDataSources: [], availableInternalDataSources: [] }); - const wrapper = ({ children }: React.PropsWithChildren) => ( - {children} - ); + const wrapper = getProviderWrapper(); const { result } = renderHook(() => useAlertmanager(), { wrapper }); expect(result.current.selectedAlertmanager).toBe(undefined); @@ -40,9 +49,7 @@ describe('useAlertmanager', () => { availableInternalDataSources: [{ name: GRAFANA_RULES_SOURCE_NAME, imgUrl: '', hasConfigurationAPI: true }], }); - const wrapper = ({ children }: React.PropsWithChildren) => ( - {children} - ); + const wrapper = getProviderWrapper(); const { result } = renderHook(() => useAlertmanager(), { wrapper }); expect(result.current.selectedAlertmanager).toBe(GRAFANA_RULES_SOURCE_NAME); @@ -55,9 +62,7 @@ describe('useAlertmanager', () => { locationService.push({ search: `alertmanager=${externalAmProm.name}` }); - const wrapper = ({ children }: React.PropsWithChildren) => ( - {children} - ); + const wrapper = getProviderWrapper(); const { result } = renderHook(() => useAlertmanager(), { wrapper }); expect(result.current.selectedAlertmanager).toBe(externalAmProm.name); @@ -70,9 +75,7 @@ describe('useAlertmanager', () => { locationService.push({ search: `alertmanager=Not available external AM` }); - const wrapper = ({ children }: React.PropsWithChildren) => ( - {children} - ); + const wrapper = getProviderWrapper(); const { result } = renderHook(() => useAlertmanager(), { wrapper }); expect(result.current.selectedAlertmanager).toBe(undefined); @@ -83,9 +86,7 @@ describe('useAlertmanager', () => { .spyOn(useAlertManagerSources, 'useAlertManagersByPermission') .mockReturnValueOnce({ availableExternalDataSources: [externalAmProm], availableInternalDataSources: [] }); - const wrapper = ({ children }: React.PropsWithChildren) => ( - {children} - ); + const wrapper = getProviderWrapper(); store.set(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, externalAmProm.name); locationService.push({ search: '' }); @@ -101,9 +102,7 @@ describe('useAlertmanager', () => { locationService.push({ search: `alertmanager=${externalAmProm.name}` }); - const wrapper = ({ children }: React.PropsWithChildren) => ( - {children} - ); + const wrapper = getProviderWrapper(); store.set(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, externalAmMimir.name); diff --git a/public/app/features/alerting/unified/state/AlertmanagerContext.tsx b/public/app/features/alerting/unified/state/AlertmanagerContext.tsx index d196dd7b565..22846f18352 100644 --- a/public/app/features/alerting/unified/state/AlertmanagerContext.tsx +++ b/public/app/features/alerting/unified/state/AlertmanagerContext.tsx @@ -1,10 +1,13 @@ import * as React from 'react'; -import { locationService } from '@grafana/runtime'; +import { config, locationService } from '@grafana/runtime'; import store from 'app/core/store'; import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types'; +import grafanaIconSvg from 'img/grafana_icon.svg'; import { useAlertManagersByPermission } from '../hooks/useAlertManagerSources'; +import { useAlertmanagerConfig } from '../hooks/useAlertmanagerConfig'; +import { EXTRA_CONFIG_UID, isExtraConfig } from '../utils/alertmanager/extraConfigs'; import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, ALERTMANAGER_NAME_QUERY_KEY } from '../utils/constants'; import { AlertManagerDataSource, @@ -33,9 +36,46 @@ const AlertmanagerProvider = ({ children, accessType, alertmanagerSourceName }: const queryParams = locationService.getSearch(); const updateQueryParams = locationService.partial; const allAvailableAlertManagers = useAlertManagersByPermission(accessType); - const availableAlertManagers = allAvailableAlertManagers.availableInternalDataSources.concat( - allAvailableAlertManagers.availableExternalDataSources - ); + const isExtraConfigEnabled = config.featureToggles.alertingImportAlertmanagerUI ?? false; + + const { data: grafanaConfig } = useAlertmanagerConfig(isExtraConfigEnabled ? GRAFANA_RULES_SOURCE_NAME : undefined, { + refetchOnFocus: false, + refetchOnReconnect: false, + }); + const hasExtraConfigs = Boolean(grafanaConfig?.extra_config && grafanaConfig.extra_config.length > 0); + + const availableAlertManagers = React.useMemo(() => { + const regularAlertManagers = allAvailableAlertManagers.availableInternalDataSources.concat( + allAvailableAlertManagers.availableExternalDataSources + ); + + const extraConfigDataSource: AlertManagerDataSource[] = + isExtraConfigEnabled && hasExtraConfigs + ? [ + { + name: EXTRA_CONFIG_UID, + displayName: 'Grafana (imported)', + imgUrl: grafanaIconSvg, + hasConfigurationAPI: false, + handleGrafanaManagedAlerts: true, + }, + ] + : []; + + // list in order: Grafana -> Extra Config -> Extra Alertmanagers + const grafanaAlertmanager = regularAlertManagers.find((am) => am.name === GRAFANA_RULES_SOURCE_NAME); + const datasourceAlertmanagers = regularAlertManagers.filter((am) => am.name !== GRAFANA_RULES_SOURCE_NAME); + const orderedAlertManagers: AlertManagerDataSource[] = []; + + if (grafanaAlertmanager) { + orderedAlertManagers.push(grafanaAlertmanager); + } + + orderedAlertManagers.push(...extraConfigDataSource); + orderedAlertManagers.push(...datasourceAlertmanagers); + + return orderedAlertManagers; + }, [allAvailableAlertManagers, isExtraConfigEnabled, hasExtraConfigs]); const updateSelectedAlertmanager = React.useCallback( (selectedAlertManager: string) => { @@ -73,7 +113,16 @@ const AlertmanagerProvider = ({ children, accessType, alertmanagerSourceName }: ? desiredAlertmanager : undefined; - const selectedAlertmanagerConfig = getAlertmanagerDataSourceByName(selectedAlertmanager)?.jsonData; + const selectedAlertmanagerConfig = React.useMemo(() => { + if (selectedAlertmanager && isExtraConfig(selectedAlertmanager)) { + const config: AlertManagerDataSourceJsonData = { + implementation: AlertManagerImplementation.prometheus, + handleGrafanaManagedAlerts: true, + }; + return config; + } + return getAlertmanagerDataSourceByName(selectedAlertmanager)?.jsonData; + }, [selectedAlertmanager]); // determine if we're dealing with an Alertmanager data source that supports the ruler API const isGrafanaAlertmanager = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME; diff --git a/public/app/features/alerting/unified/utils/alertmanager/extraConfigs.test.ts b/public/app/features/alerting/unified/utils/alertmanager/extraConfigs.test.ts new file mode 100644 index 00000000000..ca16e7a4baf --- /dev/null +++ b/public/app/features/alerting/unified/utils/alertmanager/extraConfigs.test.ts @@ -0,0 +1,24 @@ +import { EXTRA_CONFIG_UID, isExtraConfig } from './extraConfigs'; + +describe('extraConfigs utilities', () => { + describe('isExtraConfig', () => { + it('should return true for the exact extra config UID', () => { + expect(isExtraConfig(EXTRA_CONFIG_UID)).toBe(true); + }); + + it('should return false for non-extra config names', () => { + expect(isExtraConfig('grafana')).toBe(false); + expect(isExtraConfig('prometheus-am')).toBe(false); + expect(isExtraConfig('regular-alertmanager')).toBe(false); + expect(isExtraConfig('')).toBe(false); + expect(isExtraConfig('~grafana-converted-extra-config-test')).toBe(false); // old pattern + expect(isExtraConfig('~grafana-with-extra-config-suffix')).toBe(false); // with suffix + }); + }); + + describe('EXTRA_CONFIG_UID constant', () => { + it('should have the correct UID value', () => { + expect(EXTRA_CONFIG_UID).toBe('~grafana-with-extra-config'); + }); + }); +}); diff --git a/public/app/features/alerting/unified/utils/alertmanager/extraConfigs.ts b/public/app/features/alerting/unified/utils/alertmanager/extraConfigs.ts new file mode 100644 index 00000000000..6fe8ac88ee6 --- /dev/null +++ b/public/app/features/alerting/unified/utils/alertmanager/extraConfigs.ts @@ -0,0 +1,15 @@ +export const EXTRA_CONFIG_UID = '~grafana-with-extra-config'; + +export interface ExtraConfiguration { + identifier: string; + source?: string; + createdAt?: string; +} + +export interface AlertingConfigResponse { + extra_config?: ExtraConfiguration[]; +} + +export function isExtraConfig(name: string): boolean { + return name === EXTRA_CONFIG_UID; +} diff --git a/public/app/features/alerting/unified/utils/datasource.ts b/public/app/features/alerting/unified/utils/datasource.ts index 4bcf3757d2c..545e2b1ab6d 100644 --- a/public/app/features/alerting/unified/utils/datasource.ts +++ b/public/app/features/alerting/unified/utils/datasource.ts @@ -27,6 +27,7 @@ import { useAlertManagersByPermission } from '../hooks/useAlertManagerSources'; import { isAlertManagerWithConfigAPI } from '../state/AlertmanagerContext'; import { instancesPermissions, notificationsPermissions, silencesPermissions } from './access-control'; +import { isExtraConfig } from './alertmanager/extraConfigs'; import { getAllDataSources } from './config'; import { isGrafanaRuleIdentifier } from './rules'; @@ -52,6 +53,7 @@ export enum DataSourceType { export interface AlertManagerDataSource { name: string; + displayName?: string; imgUrl: string; meta?: DataSourceInstanceSettings['meta']; hasConfigurationAPI?: boolean; @@ -300,6 +302,11 @@ export function getDatasourceAPIUid(dataSourceName: string) { if (dataSourceName === GRAFANA_RULES_SOURCE_NAME) { return GRAFANA_RULES_SOURCE_NAME; } + + if (isExtraConfig(dataSourceName)) { + return dataSourceName; + } + const ds = getDataSourceByName(dataSourceName); if (!ds) { throw new Error(`Datasource "${dataSourceName}" not found`); diff --git a/public/app/features/apiserver/types.ts b/public/app/features/apiserver/types.ts index 58df8592274..4ab780003ab 100644 --- a/public/app/features/apiserver/types.ts +++ b/public/app/features/apiserver/types.ts @@ -67,12 +67,15 @@ export const AnnoKeyDashboardIsSnapshot = 'grafana.app/dashboard-is-snapshot'; export const AnnoKeyDashboardSnapshotOriginalUrl = 'grafana.app/dashboard-snapshot-original-url'; /** @deprecated NOT A REAL annotation -- this is just a shim */ export const AnnoKeyDashboardGnetId = 'grafana.app/dashboard-gnet-id'; - /** @deprecated NOT A REAL annotation -- this is just a shim */ export const AnnoKeyFolderTitle = 'grafana.app/folderTitle'; /** @deprecated NOT A REAL annotation -- this is just a shim */ export const AnnoKeyFolderUrl = 'grafana.app/folderUrl'; +/** @experimental only provided by proxies for setup with reloadDashboardsOnParamsChange toggle on */ +/** Not intended to be used in production, we will be removing this in short-term future */ +export const AnnoReloadOnParamsChange = 'grafana.app/reloadOnParamsChange'; + // labels export const DeprecatedInternalId = 'grafana.app/deprecatedInternalID'; @@ -89,6 +92,10 @@ type GrafanaAnnotations = { [AnnoKeySourcePath]?: string; [AnnoKeySourceChecksum]?: string; [AnnoKeySourceTimestamp]?: string; + + /** @experimental only provided by proxies for setup with reloadDashboardsOnParamsChange toggle on */ + /** Not intended to be used in production, we will be removing this in short-term future */ + [AnnoReloadOnParamsChange]?: boolean; }; // Annotations provided by the front-end client diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx index 1574433972d..038a32d1af6 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx @@ -16,6 +16,7 @@ import { FolderRepo } from '../../core/components/NestedFolderPicker/FolderRepo' import { contextSrv } from '../../core/services/context_srv'; import { ManagerKind } from '../apiserver/types'; import { buildNavModel, getDashboardsTabID } from '../folders/state/navModel'; +import { useGetResourceRepositoryView } from '../provisioning/hooks/useGetResourceRepositoryView'; import { useSearchStateManager } from '../search/state/SearchStateManager'; import { getSearchPlaceholder } from '../search/tempI18nPhrases'; @@ -41,6 +42,7 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record new URLSearchParams(location.search), [location.search]); + const { isReadOnlyRepo, repoType } = useGetResourceRepositoryView({ folderName: folderUID }); useEffect(() => { stateManager.initStateFromUrl(folderUID); @@ -109,6 +111,7 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record { if (folderDTO) { @@ -160,12 +163,14 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: RecordRecently deleted )} - {folderDTO && } + {folderDTO && } {(canCreateDashboards || canCreateFolders) && ( )} diff --git a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx index ca3952427e6..5e538d6e91b 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx @@ -148,7 +148,6 @@ export function BrowseActions({ folderDTO }: Props) { folderUid={folderDTO?.uid || ''} onDismiss={() => { setShowBulkDeleteProvisionedResource(false); - onActionComplete(); }} /> @@ -166,7 +165,6 @@ export function BrowseActions({ folderDTO }: Props) { folderUid={folderDTO?.uid} onDismiss={() => { setShowBulkMoveProvisionedResource(false); - onActionComplete(); }} /> diff --git a/public/app/features/browse-dashboards/components/BrowseActions/useSelectionProvisioningStatus.ts b/public/app/features/browse-dashboards/components/BrowseActions/useSelectionProvisioningStatus.ts index cc4724b9ded..891dd1cecd1 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/useSelectionProvisioningStatus.ts +++ b/public/app/features/browse-dashboards/components/BrowseActions/useSelectionProvisioningStatus.ts @@ -12,6 +12,10 @@ import { useSelector } from 'app/types/store'; import { findItem } from '../../state/utils'; import { DashboardTreeSelection } from '../../types'; +// TODO: This will soon be remove after bulk action is merged in + +// This hook can be remove once searching endpoint returns provisioning status +// It is used to determine if the selected items are provisioned or not, which is currently missing from the search API export function useSelectionProvisioningStatus( selectedItems: Omit, isParentProvisioned: boolean @@ -23,7 +27,6 @@ export function useSelectionProvisioningStatus( const provisioningEnabled = config.featureToggles.provisioning; const [status, setStatus] = useState({ hasProvisioned: false, hasNonProvisioned: false }); - const [folderCache, setFolderCache] = useState>({}); const [dashboardCache, setDashboardCache] = useState>({}); @@ -38,6 +41,8 @@ export function useSelectionProvisioningStatus( [] ); + // Simplified: removed complex root folder tracking logic + const findItemInState = useCallback( (uid: string) => { const item = findItem(browseState.rootItems?.items || [], browseState.childrenByParentUID, uid); @@ -84,7 +89,6 @@ export function useSelectionProvisioningStatus( const checkItemProvisioning = useCallback( async (uid: string, isFolder: boolean): Promise => { if (isSearching) { - // If searching, we need provisioning status with fetching metadata return isFolder ? await getFolderMeta(uid) : await getDashboardMeta(uid); } @@ -93,7 +97,7 @@ export function useSelectionProvisioningStatus( return item?.managedBy === ManagerKind.Repo; } - // Check parent folder first + // Check parent folder first for dashboards const parent = item?.parentUID ? findItemInState(item.parentUID) : undefined; if (parent?.managedBy === ManagerKind.Repo) { return true; @@ -106,7 +110,7 @@ export function useSelectionProvisioningStatus( useEffect(() => { const checkProvisioningStatus = async () => { - // If the instance is provisioned or the parent folder is provisioned, we can skip checking individual items + // Early returns for simple cases if (isProvisionedInstance || isParentProvisioned) { setStatus({ hasProvisioned: true, hasNonProvisioned: false }); return; @@ -120,6 +124,12 @@ export function useSelectionProvisioningStatus( const folders = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]); const dashboards = Object.keys(selectedItems.dashboard).filter((uid) => selectedItems.dashboard[uid]); + // If no items selected + if (folders.length === 0 && dashboards.length === 0) { + setStatus({ hasProvisioned: false, hasNonProvisioned: false }); + return; + } + let hasProvisioned = false; let hasNonProvisioned = false; @@ -127,11 +137,18 @@ export function useSelectionProvisioningStatus( ...folders.map((uid) => ({ uid, isFolder: true })), ...dashboards.map((uid) => ({ uid, isFolder: false })), ]; + for (const { uid, isFolder } of allItems) { const isProvisioned = await checkItemProvisioning(uid, isFolder); - isProvisioned ? (hasProvisioned = true) : (hasNonProvisioned = true); + + if (isProvisioned) { + hasProvisioned = true; + } else { + hasNonProvisioned = true; + } + if (hasProvisioned && hasNonProvisioned) { - // If we have both provisioned and non-provisioned items, we can stop checking + // If we have both, we can stop checking break; } } @@ -140,15 +157,10 @@ export function useSelectionProvisioningStatus( }; checkProvisioningStatus(); - }, [ - selectedItems, - isProvisionedInstance, - isParentProvisioned, - isSearching, - findItemInState, - checkItemProvisioning, - provisioningEnabled, - ]); + }, [selectedItems, isProvisionedInstance, isParentProvisioned, checkItemProvisioning, provisioningEnabled]); - return status; + return { + hasProvisioned: status.hasProvisioned, + hasNonProvisioned: status.hasNonProvisioned, + }; } diff --git a/public/app/features/browse-dashboards/components/BrowseActions/useSelectionRepoValidation.ts b/public/app/features/browse-dashboards/components/BrowseActions/useSelectionRepoValidation.ts new file mode 100644 index 00000000000..cde7e9d56e5 --- /dev/null +++ b/public/app/features/browse-dashboards/components/BrowseActions/useSelectionRepoValidation.ts @@ -0,0 +1,55 @@ +import { skipToken } from '@reduxjs/toolkit/query'; + +import { config } from '@grafana/runtime'; +import { useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1'; +import { getIsReadOnlyRepo } from 'app/features/provisioning/utils/repository'; +import { useSelector } from 'app/types/store'; + +import { useChildrenByParentUIDState, rootItemsSelector } from '../../state/hooks'; +import { findItem } from '../../state/utils'; +import { DashboardTreeSelection } from '../../types'; +import { getItemRepositoryUid } from '../utils'; + +// This hook is responsible for validating if all selected resources (dashboard folders and dashboards) are in the same repository +export function useSelectionRepoValidation(selectedItems: Omit) { + const provisioningEnabled = config.featureToggles.provisioning; + const childrenByParentUID = useChildrenByParentUIDState(); + const rootItems = useSelector(rootItemsSelector)?.items ?? []; + + const { data: settingsData } = useGetFrontendSettingsQuery(!provisioningEnabled ? skipToken : undefined); + // Function to grab repository configuration by UID + const getRepositoryByUid = (repoUid: string) => { + if (!settingsData?.items || repoUid === 'non_provisioned') { + return undefined; + } + return settingsData.items.find((repo) => repo.name === repoUid); + }; + + const getRepoUid = (uid: string) => { + const item = findItem(rootItems, childrenByParentUID, uid); + return item ? getItemRepositoryUid(item, rootItems, childrenByParentUID) : 'non_provisioned'; + }; + + const selectedUIDs = [ + ...Object.keys(selectedItems.folder || {}).filter((id) => selectedItems.folder[id]), + ...Object.keys(selectedItems.dashboard || {}).filter((id) => selectedItems.dashboard[id]), + ]; + + const repoUIDs = selectedUIDs.map(getRepoUid).filter((repoId): repoId is string => !!repoId); + + const selectedItemsRepoUID = repoUIDs.length > 0 ? repoUIDs[0] : undefined; + const isCrossRepo = new Set(repoUIDs).size > 1; + + const isInLockedRepo = (uid: string) => !selectedItemsRepoUID || getRepoUid(uid) === selectedItemsRepoUID; + const isUidInReadOnlyRepo = (uid: string) => { + const repo = getRepositoryByUid(getRepoUid(uid)); + return repo ? getIsReadOnlyRepo(repo) : false; + }; + + return { + selectedItemsRepoUID, + isInLockedRepo, + isCrossRepo, // true if items are from different repositories + isUidInReadOnlyRepo, + }; +} diff --git a/public/app/features/browse-dashboards/components/BrowseActions/utils.ts b/public/app/features/browse-dashboards/components/BrowseActions/utils.ts index 2d6b4328c53..59a3979cf2f 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/utils.ts +++ b/public/app/features/browse-dashboards/components/BrowseActions/utils.ts @@ -1,4 +1,8 @@ import { t } from '@grafana/i18n'; +import { DashboardViewItem } from 'app/features/search/types'; + +import { findItem } from '../../state/utils'; +import { DashboardViewItemCollection } from '../../types'; export function buildBreakdownString( folderCount: number, @@ -26,3 +30,40 @@ export function buildBreakdownString( } return breakdownString; } + +// Utility: Get root folder for any item (reusing existing pattern from reducers.ts) +export function getItemRootFolder( + item: { uid: string; parentUID?: string; kind?: string }, + browseState: { + rootItems?: { items: DashboardViewItem[] }; + childrenByParentUID: Record; + } +): string | undefined { + const rootItems = browseState.rootItems?.items || []; + + // If it's already a root-level item, return its UID (only for folders) + if (!item.parentUID) { + return item.kind === 'folder' ? item.uid : undefined; + } + + // For nested items, traverse up to find root folder (same pattern as reducers.ts) + let nextParentUID = item.parentUID; + + while (nextParentUID) { + const parent = findItem(rootItems, browseState.childrenByParentUID, nextParentUID); + + // Safety check to prevent infinite loops (same as reducers.ts) + if (!parent) { + break; + } + + // Found the root folder (no parent) + if (!parent.parentUID) { + return parent.uid; + } + + nextParentUID = parent.parentUID; + } + + return undefined; +} diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkActionFailureBanner.test.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkActionFailureBanner.test.tsx deleted file mode 100644 index 9d91c74fbae..00000000000 --- a/public/app/features/browse-dashboards/components/BulkActions/BulkActionFailureBanner.test.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import { BulkActionFailureBanner, MoveResultFailed } from './BulkActionFailureBanner'; - -const setup = (resultOverrides?: Array>, onDismissOverride?: () => void) => { - const defaultFailedItems: MoveResultFailed[] = [ - { - status: 'failed', - title: 'Dashboard 1', - errorMessage: 'Permission denied', - }, - { - status: 'failed', - title: 'Dashboard 2', - errorMessage: 'Network error', - }, - ]; - - const result = - resultOverrides !== undefined - ? resultOverrides.map((override) => ({ status: 'failed' as const, title: 'Default Title', ...override })) - : defaultFailedItems; - - const onDismiss = onDismissOverride || jest.fn(); - - const props = { - result, - onDismiss, - }; - - return { - user: userEvent.setup(), - ...render(), - props, - }; -}; - -describe('BulkActionFailureBanner', () => { - it('should display error alert with correct item count', () => { - const testData = [{ title: 'Single Item', errorMessage: 'Single error' }]; - setup(testData); - - // Test that an alert is rendered - const alert = screen.getByRole('alert'); - expect(alert).toBeInTheDocument(); - - // Test structure: should have same number of list items as input data - const listItems = screen.getAllByRole('listitem'); - expect(listItems).toHaveLength(testData.length); - }); - - it('should render correct number of failed items with proper structure', () => { - const testData = [ - { title: 'Failed Dashboard A', errorMessage: 'Access denied' }, - { title: 'Failed Dashboard B', errorMessage: 'Validation failed' }, - ]; - setup(testData); - - const alert = screen.getByRole('alert'); - expect(alert).toBeInTheDocument(); - - // Test structure: number of list items matches input - const listItems = screen.getAllByRole('listitem'); - expect(listItems).toHaveLength(testData.length); - - // Test that each item has the expected structure (title + error message) - listItems.forEach((item, index) => { - const title = testData[index].title; - const errorMessage = testData[index].errorMessage; - - if (title) { - expect(item).toHaveTextContent(title); - } - if (errorMessage) { - expect(item).toHaveTextContent(errorMessage); - } - }); - }); - - it('should handle mixed scenarios', () => { - const testData = [ - { title: 'Item without error' }, - { title: 'Item with empty error', errorMessage: '' }, - { title: 'Another item with error', errorMessage: 'Another error' }, - ]; - setup(testData); - - const alert = screen.getByRole('alert'); - expect(alert).toBeInTheDocument(); - - const listItems = screen.getAllByRole('listitem'); - expect(listItems).toHaveLength(testData.length); - - // Test that items with error messages contain both title and error - // Items without errors should only contain title - testData.forEach((data, index) => { - const listItem = listItems[index]; - - // All items should have their title - if (data.title) { - expect(listItem).toHaveTextContent(data.title); - } - - // Only items with non-empty error messages should show the error - if (data.errorMessage && data.errorMessage.trim() !== '') { - expect(listItem).toHaveTextContent(data.errorMessage); - } - }); - }); - - it('should maintain list structure', () => { - const testData = [ - { title: 'Item 1', errorMessage: 'Error 1' }, - { title: 'Item 2', errorMessage: 'Error 2' }, - ]; - setup(testData); - - // Test semantic structure - const list = screen.getByRole('list'); - expect(list).toBeInTheDocument(); - - const listItems = screen.getAllByRole('listitem'); - expect(listItems).toHaveLength(testData.length); - }); - - it('should handle items without error messages', () => { - const testData = [{ title: 'Just Title Item' }]; - setup(testData); - - const listItems = screen.getAllByRole('listitem'); - expect(listItems).toHaveLength(1); - - const item = listItems[0]; - expect(item).toHaveTextContent(testData[0].title!); - // Should not contain colon separator when no error message - expect(item.textContent).not.toMatch(/:\s*.+$/); - }); - - it('should render dismissible alert', () => { - const onDismiss = jest.fn(); - setup([], onDismiss); - - const alert = screen.getByRole('alert'); - expect(alert).toBeInTheDocument(); - - // Alert should have close button (dismissible) - const closeButton = screen.getByRole('button', { name: /close/i }); - expect(closeButton).toBeInTheDocument(); - }); -}); diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkActionFailureBanner.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkActionFailureBanner.tsx deleted file mode 100644 index 332e0695144..00000000000 --- a/public/app/features/browse-dashboards/components/BulkActions/BulkActionFailureBanner.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { t } from '@grafana/i18n'; -import { Alert } from '@grafana/ui'; - -export type MoveResultFailed = { - status?: 'failed'; - title?: string; - errorMessage?: string; -}; - -export function BulkActionFailureBanner({ result, onDismiss }: { result: MoveResultFailed[]; onDismiss: () => void }) { - return ( - -
    - {result.map((item) => ( -
  • - {item.title} - {item.errorMessage && `: ${item.errorMessage}`} -
  • - ))} -
-
- ); -} diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkActionPostSubmitStep.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkActionPostSubmitStep.tsx deleted file mode 100644 index c3a03aac1c9..00000000000 --- a/public/app/features/browse-dashboards/components/BulkActions/BulkActionPostSubmitStep.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { t, Trans } from '@grafana/i18n'; -import { Alert, Button, Stack } from '@grafana/ui'; - -import { BulkActionFailureBanner, MoveResultFailed } from './BulkActionFailureBanner'; -import { BulkActionProgress, ProgressState } from './BulkActionProgress'; -import { MoveResultSuccessState } from './utils'; - -interface Props { - action: 'move' | 'delete'; - progress: ProgressState | null; - successState: MoveResultSuccessState; - failureResults: MoveResultFailed[] | undefined; - handleSuccess: () => void; - setFailureResults: (results: MoveResultFailed[] | undefined) => void; -} - -export function BulkActionPostSubmitStep({ - action, - progress, - successState, - failureResults, - handleSuccess, - setFailureResults, -}: Props) { - if (progress) { - return ; - } - - if (successState.allSuccess) { - return ( - <> - - {action === 'move' - ? t('browse-dashboards.bulk-action-resources-form.all-moved', 'All resources have been moved successfully') - : t( - 'browse-dashboards.bulk-action-resources-form.all-deleted', - 'All resources have been deleted successfully' - )} - - - - - - ); - } - - if (failureResults) { - return setFailureResults(undefined)} />; - } - - return null; -} diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.test.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.test.tsx deleted file mode 100644 index 47a6fabfb6a..00000000000 --- a/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { render, screen } from '@testing-library/react'; - -import { BulkActionProgress, ProgressState } from './BulkActionProgress'; - -const setup = (progressOverrides: Partial = {}, action: 'delete' | 'move' = 'delete') => { - const defaultProgress: ProgressState = { - current: 5, - total: 10, - item: 'Test Dashboard', - ...progressOverrides, - }; - - const props = { - progress: defaultProgress, - }; - - return { - ...render(), - props, - }; -}; - -describe('BulkActionProgress', () => { - it('should render progress text with current and total values', () => { - setup({ current: 3, total: 8 }); - - expect(screen.getByText(/Progress: 3 of 8/)).toBeInTheDocument(); - }); - - it('should render current item being deleted', () => { - setup({ item: 'My Test Dashboard' }); - - expect(screen.getByText(/Deleting:/)).toBeInTheDocument(); - expect(screen.getByText(/My Test Dashboard/)).toBeInTheDocument(); - }); - - it('should handle edge case with total of 1', () => { - setup({ current: 1, total: 1 }); - - expect(screen.getByText(/Progress: 1 of 1/)).toBeInTheDocument(); - }); - - it('should handle edge case with zero current progress', () => { - setup({ current: 0, total: 5 }); - - expect(screen.getByText(/Progress: 0 of 5/)).toBeInTheDocument(); - }); - - it('should render all required elements together', () => { - setup({ current: 7, total: 15, item: 'Complex Dashboard Name' }); - - // Progress text - expect(screen.getByText(/Progress: 7 of 15/)).toBeInTheDocument(); - - // Spinner icon - expect(screen.getByTestId('Spinner')).toBeInTheDocument(); - - // Current item text - expect(screen.getByText(/Deleting:/)).toBeInTheDocument(); - expect(screen.getByText(/Complex Dashboard Name/)).toBeInTheDocument(); - }); - - it('should render moving action text when action is move', () => { - setup({ current: 2, total: 4, item: 'Moving Dashboard' }, 'move'); - - expect(screen.getByText(/Moving:/)).toBeInTheDocument(); - expect(screen.getByText(/Moving Dashboard/)).toBeInTheDocument(); - }); -}); diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.tsx deleted file mode 100644 index f022554d52a..00000000000 --- a/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Trans } from '@grafana/i18n'; -import { Box, Text, Stack, Spinner } from '@grafana/ui'; -import ProgressBar from 'app/features/provisioning/Shared/ProgressBar'; - -export type ProgressState = { - current: number; - total: number; - item: string; -}; - -interface Props { - progress: ProgressState; - action: 'move' | 'delete'; -} - -export function BulkActionProgress({ progress, action }: Props) { - const progressPercentage = Math.round((progress.current / progress.total) * 100); - - return ( - - - - - Progress: {{ current: progress.current }} of {{ total: progress.total }} - - - - - - - {action === 'move' ? ( - Moving - ) : ( - Deleting - )} - : {progress.item} - - - ); -} diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.test.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.test.tsx index 045efed7e54..2a5be2aa309 100644 --- a/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.test.tsx +++ b/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.test.tsx @@ -1,45 +1,12 @@ import { screen, waitFor } from '@testing-library/react'; -import { HttpResponse, http } from 'msw'; import { render } from 'test/test-utils'; -import { setBackendSrv } from '@grafana/runtime'; -import server, { setupMockServer } from '@grafana/test-utils/server'; -import { RepositoryView } from 'app/api/clients/provisioning/v0alpha1'; -import { backendSrv } from 'app/core/services/backend_srv'; +import { Job, RepositoryView } from 'app/api/clients/provisioning/v0alpha1'; + +import { useSelectionRepoValidation } from '../BrowseActions/useSelectionRepoValidation'; import { BulkDeleteProvisionedResource } from './BulkDeleteProvisionedResource'; - -// Set up backendSrv as recommended in the PR comment -setBackendSrv(backendSrv); -setupMockServer(); - -jest.mock('../utils', () => ({ - collectSelectedItems: jest.fn().mockReturnValue([ - { uid: 'folder-1', isFolder: true, displayName: 'Test Folder' }, - { uid: 'dashboard-1', isFolder: false, displayName: 'Test Dashboard' }, - ]), - fetchProvisionedDashboardPath: jest.fn().mockResolvedValue('/test/dashboard.json'), -})); - -jest.mock('../../state/hooks', () => ({ - useChildrenByParentUIDState: jest.fn().mockReturnValue({}), - rootItemsSelector: jest.fn().mockReturnValue({ - items: [ - { uid: 'folder-1', title: 'Test Folder', kind: 'folder' }, - { uid: 'dashboard-1', title: 'Test Dashboard', kind: 'dashboard' }, - ], - }), -})); - -jest.mock('../../state/utils', () => ({ - findItem: jest.fn().mockImplementation((rootItems: unknown[], childrenByUID: unknown, uid: string) => { - const mockRootItems = [ - { uid: 'folder-1', title: 'Test Folder', kind: 'folder' }, - { uid: 'dashboard-1', title: 'Test Dashboard', kind: 'dashboard' }, - ]; - return mockRootItems.find((item) => item.uid === uid); - }), -})); +import { ResponseType } from './useBulkActionJob'; jest.mock('../BrowseActions/DescendantCount', () => ({ DescendantCount: jest.fn(({ selectedItems }) => ( @@ -54,43 +21,104 @@ jest.mock('app/features/provisioning/hooks/useGetResourceRepositoryView', () => useGetResourceRepositoryView: jest.fn(), })); -describe('BulkDeleteProvisionedResource', () => { +jest.mock('../BrowseActions/useSelectionRepoValidation', () => ({ + useSelectionRepoValidation: jest.fn(), +})); + +const mockUseSelectionRepoValidation = useSelectionRepoValidation as jest.MockedFunction< + typeof useSelectionRepoValidation +>; + +jest.mock('./useBulkActionJob', () => ({ + useBulkActionJob: jest.fn(), +})); + +jest.mock('app/features/provisioning/Job/JobStatus', () => ({ + JobStatus: jest.fn(({ watch, jobType }) => ( +
+ Job Status - {jobType} - {watch?.status?.state || 'pending'} +
+ )), +})); + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getAppEvents: jest.fn(() => ({ + publish: jest.fn(), + })), +})); + +const mockUseGetResourceRepositoryView = jest.mocked( + require('app/features/provisioning/hooks/useGetResourceRepositoryView').useGetResourceRepositoryView +); +const mockUseBulkActionJob = jest.mocked(require('./useBulkActionJob').useBulkActionJob); +const mockGetAppEvents = jest.mocked(require('@grafana/runtime').getAppEvents); + +function setup( + repository: RepositoryView | null, + mockJobResult: ResponseType = { + success: true, + job: { metadata: { name: 'test-job' }, status: { state: 'success' } }, + }, + isLoading = false +) { + const selectedItems = { + folder: { 'folder-1': true }, + dashboard: { 'dashboard-1': true }, + }; + const defaultRepository: RepositoryView = { - name: 'test-folder', // This must match the folderUid passed to the component + name: 'test-folder', type: 'github', title: 'Test Repository', target: 'folder', workflows: ['branch', 'write'], }; - const selectedItems = { - folder: { 'folder-1': true }, - dashboard: { 'dashboard-1': true }, - }; + const onDismiss = jest.fn(); + const mockCreateBulkJob = jest.fn().mockResolvedValue(mockJobResult); + mockUseGetResourceRepositoryView.mockReturnValue({ + repository: repository ?? defaultRepository, + folder: repository + ? { + metadata: { + annotations: { + 'grafana.app/file-path': '/test/folder', + }, + }, + } + : null, + isInstanceManaged: false, + }); + + mockUseBulkActionJob.mockReturnValue({ + createBulkJob: mockCreateBulkJob, + isLoading, + }); + + const renderResult = render( + + ); + + return { + onDismiss, + mockCreateBulkJob, + selectedItems, + defaultRepository, + ...renderResult, + }; +} + +describe('BulkDeleteProvisionedResource', () => { beforeEach(() => { - server.use( - http.delete('/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/:name/files/*', () => { - return HttpResponse.json({ - urls: { repositoryURL: 'https://github.com/test/repo' }, - }); - }) - ); jest.clearAllMocks(); - const { useGetResourceRepositoryView } = jest.requireMock( - 'app/features/provisioning/hooks/useGetResourceRepositoryView' - ); - useGetResourceRepositoryView.mockReturnValue({ - repository: defaultRepository, - folder: { - metadata: { - annotations: { - 'grafana.app/file-path': '/test/folder', - }, - }, - }, - isInstanceManaged: false, + mockUseSelectionRepoValidation.mockReturnValue({ + selectedItemsRepoUID: 'test-folder', + isInLockedRepo: jest.fn().mockReturnValue(false), + isCrossRepo: false, + isUidInReadOnlyRepo: jest.fn().mockReturnValue(false), }); }); @@ -98,38 +126,8 @@ describe('BulkDeleteProvisionedResource', () => { jest.restoreAllMocks(); }); - function setup(repository: RepositoryView | null = defaultRepository) { - const onDismiss = jest.fn(); - - const { useGetResourceRepositoryView } = jest.requireMock( - 'app/features/provisioning/hooks/useGetResourceRepositoryView' - ); - useGetResourceRepositoryView.mockReturnValue({ - repository, - folder: repository - ? { - metadata: { - annotations: { - 'grafana.app/file-path': '/test/folder', - }, - }, - } - : null, - isInstanceManaged: false, - }); - - const renderResult = render( - - ); - - return { - onDismiss, - ...renderResult, - }; - } - it('renders the delete warning and form', async () => { - setup(); + setup(null); expect(await screen.findByText(/This will delete selected folders and their descendants/)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Delete/i })).toBeInTheDocument(); @@ -137,7 +135,7 @@ describe('BulkDeleteProvisionedResource', () => { }); it('calls onDismiss when Cancel is clicked', async () => { - const { onDismiss, user } = setup(); + const { onDismiss, user } = setup(null); await user.click(screen.getByRole('button', { name: /Cancel/i })); @@ -145,57 +143,112 @@ describe('BulkDeleteProvisionedResource', () => { }); it('handles successful deletion', async () => { - const { user } = setup(); + const { user, mockCreateBulkJob, defaultRepository } = setup(null); await user.click(screen.getByRole('button', { name: /Delete/i })); - expect(await screen.findByText(/All resources have been deleted successfully/)).toBeInTheDocument(); - }); - - it('handles deletion errors', async () => { - const { user } = setup(); - - // Mock API to return error for this test - server.use( - http.delete('/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/:name/files/*', () => { - return HttpResponse.json({ message: 'Network error' }, { status: 500 }); + expect(mockCreateBulkJob).toHaveBeenCalledWith( + defaultRepository, + expect.objectContaining({ + action: 'delete', + delete: expect.objectContaining({ + resources: expect.arrayContaining([ + expect.objectContaining({ name: 'folder-1', kind: 'Folder' }), + expect.objectContaining({ name: 'dashboard-1', kind: 'Dashboard' }), + ]), + }), }) ); + // Should show JobStatus component + expect(await screen.findByTestId('job-status')).toBeInTheDocument(); + expect(screen.getByText(/Job Status - delete - success/)).toBeInTheDocument(); + }); + + it('handles deletion errors', async () => { + const mockPublish = jest.fn(); + const { user } = setup(null, { success: false, error: 'Network error' }); + + mockGetAppEvents.mockReturnValue({ + publish: mockPublish, + }); + await user.click(screen.getByRole('button', { name: /Delete/i })); - await waitFor(() => { - // Should show error alert with failed items - expect(screen.getByRole('alert')).toBeInTheDocument(); - expect(screen.getByLabelText(/items failed/)).toBeInTheDocument(); + // Should remain on form (not show JobStatus) when there's an error + expect(screen.queryByTestId('job-status')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Delete/i })).toBeInTheDocument(); - // Should have error list items for both folder and dashboard - const errorItems = screen.getAllByRole('listitem'); - expect(errorItems).toHaveLength(2); // One for folder, one for dashboard + expect(mockPublish).toHaveBeenCalledWith({ + type: 'alert-error', + payload: ['Error deleting resources', 'Network error'], }); }); it('shows loading state during deletion', async () => { - const { user } = setup(); - - // Mock slow API response - server.use( - http.delete('/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/:name/files/*', async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - return HttpResponse.json({ - urls: { repositoryURL: 'https://github.com/test/repo' }, - }); - }) - ); + const workingJob: Job = { metadata: { name: 'test-job' }, status: { state: 'working' } }; + const { user } = setup(null, { success: true, job: workingJob }); await user.click(screen.getByRole('button', { name: /Delete/i })); - expect(screen.getByText(/Deleting.../)).toBeInTheDocument(); + // After click, should show JobStatus + await waitFor(() => { + expect(screen.getByTestId('job-status')).toBeInTheDocument(); + }); + + expect(screen.getByText(/Job Status - delete - working/)).toBeInTheDocument(); }); - it('returns null when repository is not available', () => { - const { container } = setup(null); + it('Should not show buttons when job is in working state', async () => { + const workingJob: Job = { metadata: { name: 'test-job' }, status: { state: 'working' } }; + const { user } = setup(null, { success: true, job: workingJob }); - expect(container.firstChild).toBeNull(); + await user.click(screen.getByRole('button', { name: /Delete/i })); + + // Should show JobStatus after submission + expect(await screen.findByTestId('job-status')).toBeInTheDocument(); + + // Done button should not be disabled + const deleteButton = screen.queryByRole('button', { name: /Delete/i }); + expect(deleteButton).not.toBeInTheDocument(); + }); + + it('calls createBulkJob with branch workflow parameters when branch is selected', async () => { + const { user, mockCreateBulkJob, defaultRepository } = setup(null); + + await user.click(screen.getByRole('button', { name: /Delete/i })); + + expect(mockCreateBulkJob).toHaveBeenCalledWith( + defaultRepository, + expect.objectContaining({ + action: 'delete', + delete: expect.objectContaining({ + ref: expect.stringContaining('bulk-delete/'), + }), + }) + ); + }); + + it('calls createBulkJob with write workflow parameters when write is selected', async () => { + const { user, mockCreateBulkJob, defaultRepository } = setup(null); + + // Switch to write workflow + const writeRadio = screen.getByRole('radio', { name: /Save/i }); + await user.click(writeRadio); + + await user.click(screen.getByRole('button', { name: /Delete/i })); + + expect(mockCreateBulkJob).toHaveBeenCalledWith( + defaultRepository, + expect.objectContaining({ + action: 'delete', + delete: expect.objectContaining({ + resources: expect.arrayContaining([ + expect.objectContaining({ name: 'folder-1', kind: 'Folder' }), + expect.objectContaining({ name: 'dashboard-1', kind: 'Dashboard' }), + ]), + }), + }) + ); }); }); diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.tsx index 14ea787fc81..16970ee282d 100644 --- a/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.tsx +++ b/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.tsx @@ -1,153 +1,77 @@ import { useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; +import { AppEvents } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; +import { getAppEvents } from '@grafana/runtime'; import { Box, Button, Stack } from '@grafana/ui'; -import { - DeleteRepositoryFilesWithPathApiArg, - DeleteRepositoryFilesWithPathApiResponse, - RepositoryView, - useDeleteRepositoryFilesWithPathMutation, -} from 'app/api/clients/provisioning/v0alpha1'; -import { extractErrorMessage } from 'app/api/utils'; -import { AnnoKeySourcePath } from 'app/features/apiserver/types'; +import { Job, RepositoryView } from 'app/api/clients/provisioning/v0alpha1'; import { ResourceEditFormSharedFields } from 'app/features/dashboard-scene/components/Provisioned/ResourceEditFormSharedFields'; import { getDefaultWorkflow, getWorkflowOptions } from 'app/features/dashboard-scene/saving/provisioned/defaults'; import { generateTimestamp } from 'app/features/dashboard-scene/saving/provisioned/utils/timestamp'; +import { JobStatus } from 'app/features/provisioning/Job/JobStatus'; import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView'; -import { useSelector } from 'app/types/store'; +import { GENERAL_FOLDER_UID } from 'app/features/search/constants'; -import { useChildrenByParentUIDState, rootItemsSelector } from '../../state/hooks'; -import { findItem } from '../../state/utils'; import { DescendantCount } from '../BrowseActions/DescendantCount'; -import { collectSelectedItems, fetchProvisionedDashboardPath } from '../utils'; +import { useSelectionRepoValidation } from '../BrowseActions/useSelectionRepoValidation'; +import { collectSelectedItems } from '../utils'; -import { MoveResultFailed } from './BulkActionFailureBanner'; -import { BulkActionPostSubmitStep } from './BulkActionPostSubmitStep'; -import { ProgressState } from './BulkActionProgress'; -import { useBulkActionRequest } from './useBulkActionRequest'; -import { - BulkActionFormData, - BulkActionProvisionResourceProps, - BulkSuccessResponse, - MoveResultSuccessState, -} from './utils'; +import { RepoInvalidStateBanner } from './RepoInvalidStateBanner'; +import { DeleteJobSpec, useBulkActionJob } from './useBulkActionJob'; +import { BulkActionFormData, BulkActionProvisionResourceProps } from './utils'; interface FormProps extends BulkActionProvisionResourceProps { initialValues: BulkActionFormData; repository: RepositoryView; workflowOptions: Array<{ label: string; value: string }>; - folderPath?: string; } -function FormContent({ initialValues, selectedItems, repository, workflowOptions, folderPath, onDismiss }: FormProps) { +function FormContent({ initialValues, selectedItems, repository, workflowOptions, onDismiss }: FormProps) { // States - const [progress, setProgress] = useState(null); - const [failureResults, setFailureResults] = useState(); - const [successState, setSuccessState] = useState({ - allSuccess: false, - repoUrl: '', - }); + const [job, setJob] = useState(); const [hasSubmitted, setHasSubmitted] = useState(false); // Hooks - const [deleteRepoFile, request] = useDeleteRepositoryFilesWithPathMutation(); + const { createBulkJob, isLoading: isCreatingJob } = useBulkActionJob(); const methods = useForm({ defaultValues: initialValues }); - const childrenByParentUID = useChildrenByParentUIDState(); - const rootItems = useSelector(rootItemsSelector); const { handleSubmit, watch } = methods; const workflow = watch('workflow'); - const { handleSuccess } = useBulkActionRequest({ workflow, repository, successState, onDismiss }); - - const getResourcePath = async (uid: string, isFolder: boolean): Promise => { - const item = findItem(rootItems?.items || [], childrenByParentUID, uid); - if (!item) { - return undefined; - } - return isFolder ? `${folderPath}/${item.title}/` : fetchProvisionedDashboardPath(uid); - }; const handleSubmitForm = async (data: BulkActionFormData) => { - setFailureResults(undefined); setHasSubmitted(true); - const targets = collectSelectedItems(selectedItems, childrenByParentUID, rootItems?.items || []); + const resources = collectSelectedItems(selectedItems); - if (targets.length > 0) { - setProgress({ - current: 0, - total: targets.length, - item: targets[0].displayName || 'Unknown', + // Create the delete job spec + const jobSpec: DeleteJobSpec = { + action: 'delete', + delete: { + ref: data.workflow === 'write' ? undefined : data.ref, + resources, + }, + }; + + const result = await createBulkJob(repository, jobSpec); + + if (result.success && result.job) { + setJob(result.job); // Store the job for tracking + } else if (!result.success && result.error) { + // Handle error case - show error alert + getAppEvents().publish({ + type: AppEvents.alertError.name, + payload: [ + t('browse-dashboards.bulk-delete-resources-form.error-deleting-resources', 'Error deleting resources'), + result.error, + ], }); - } - - const successes: BulkSuccessResponse< - DeleteRepositoryFilesWithPathApiArg, - DeleteRepositoryFilesWithPathApiResponse - > = []; - const failures: MoveResultFailed[] = []; - - // Iterate through each selected item and delete it - // We want sequential processing to avoid overwhelming the API - for (let i = 0; i < targets.length; i++) { - const { uid, isFolder, displayName } = targets[i]; - setProgress({ - current: i, - total: targets.length, - item: displayName, - }); - - try { - // get path in repository - const path = await getResourcePath(uid, isFolder); - if (!path) { - failures.push({ - status: 'failed', - title: `${isFolder ? 'Folder' : 'Dashboard'}: ${displayName}`, - errorMessage: t('browse-dashboards.bulk-delete-resources-form.error-path-not-found', 'Path not found'), - }); - continue; - } - - // build params - const deleteParams: DeleteRepositoryFilesWithPathApiArg = { - name: repository.name, - path, - ref: workflow === 'write' ? undefined : data.ref, - message: data.comment || `Delete resource ${path}`, - }; - - // perform delete operation - const response = await deleteRepoFile(deleteParams).unwrap(); - successes.push({ index: i, item: deleteParams, data: response }); - } catch (error: unknown) { - failures.push({ - status: 'failed', - title: `${isFolder ? 'Folder' : 'Dashboard'}: ${displayName}`, - errorMessage: extractErrorMessage(error), - }); - } - - setProgress({ - current: i + 1, - total: targets.length, - item: targets[i + 1]?.displayName, - }); - } - - setProgress(null); - - if (successes.length > 0 && failures.length === 0) { - // handleSuccess(successes); - setSuccessState({ - allSuccess: true, - repoUrl: successes[0].data.urls?.newPullRequestURL, - }); - } else if (failures.length > 0) { - setFailureResults(failures); + setHasSubmitted(false); // Reset submit state so user can try again } }; + const disableBtn = + isCreatingJob || job?.status?.state === 'working' || job?.status?.state === 'pending' || hasSubmitted; + return (
@@ -159,15 +83,8 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions - {hasSubmitted ? ( - + {hasSubmitted && job ? ( + ) : ( <> - - - @@ -202,10 +118,15 @@ export function BulkDeleteProvisionedResource({ selectedItems, onDismiss, }: BulkActionProvisionResourceProps) { - const { repository, folder } = useGetResourceRepositoryView({ folderName: folderUid }); + // Check if we're on the root browser dashboards page + const isRootPage = !folderUid || folderUid === GENERAL_FOLDER_UID; + const { selectedItemsRepoUID } = useSelectionRepoValidation(selectedItems); + // For root provisioned folders, the folder UID is the repository name + const { repository, isReadOnlyRepo } = useGetResourceRepositoryView({ + folderName: isRootPage ? selectedItemsRepoUID : folderUid, + }); const workflowOptions = getWorkflowOptions(repository); - const folderPath = folder?.metadata?.annotations?.[AnnoKeySourcePath] || ''; const timestamp = generateTimestamp(); const initialValues = { @@ -214,8 +135,8 @@ export function BulkDeleteProvisionedResource({ workflow: getDefaultWorkflow(repository), }; - if (!repository) { - return null; + if (!repository || isReadOnlyRepo) { + return ; } return ( @@ -225,7 +146,6 @@ export function BulkDeleteProvisionedResource({ initialValues={initialValues} repository={repository} workflowOptions={workflowOptions} - folderPath={folderPath} /> ); } diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkMoveProvisionedResource.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkMoveProvisionedResource.tsx index a45ad80eabe..f20d41d650d 100644 --- a/public/app/features/browse-dashboards/components/BulkActions/BulkMoveProvisionedResource.tsx +++ b/public/app/features/browse-dashboards/components/BulkActions/BulkMoveProvisionedResource.tsx @@ -2,43 +2,28 @@ import { skipToken } from '@reduxjs/toolkit/query'; import { useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; +import { AppEvents } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { FolderPicker } from '@grafana/runtime'; +import { FolderPicker, getAppEvents } from '@grafana/runtime'; import { Box, Button, Field, Stack } from '@grafana/ui'; import { useGetFolderQuery } from 'app/api/clients/folder/v1beta1'; -import { - CreateRepositoryFilesWithPathApiArg, - CreateRepositoryFilesWithPathApiResponse, - RepositoryView, - useCreateRepositoryFilesWithPathMutation, - ResourceWrapper, -} from 'app/api/clients/provisioning/v0alpha1'; -import { extractErrorMessage } from 'app/api/utils'; -import { ScopedResourceClient } from 'app/features/apiserver/client'; +import { RepositoryView, Job } from 'app/api/clients/provisioning/v0alpha1'; import { AnnoKeySourcePath } from 'app/features/apiserver/types'; import { ResourceEditFormSharedFields } from 'app/features/dashboard-scene/components/Provisioned/ResourceEditFormSharedFields'; import { getDefaultWorkflow, getWorkflowOptions } from 'app/features/dashboard-scene/saving/provisioned/defaults'; import { generateTimestamp } from 'app/features/dashboard-scene/saving/provisioned/utils/timestamp'; +import { JobStatus } from 'app/features/provisioning/Job/JobStatus'; import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView'; -import { useSelector } from 'app/types/store'; +import { GENERAL_FOLDER_UID } from 'app/features/search/constants'; -import { useChildrenByParentUIDState, rootItemsSelector } from '../../state/hooks'; -import { findItem } from '../../state/utils'; import { DescendantCount } from '../BrowseActions/DescendantCount'; -import { collectSelectedItems, fetchProvisionedDashboardPath } from '../utils'; +import { useSelectionRepoValidation } from '../BrowseActions/useSelectionRepoValidation'; +import { collectSelectedItems } from '../utils'; + +import { RepoInvalidStateBanner } from './RepoInvalidStateBanner'; +import { MoveJobSpec, useBulkActionJob } from './useBulkActionJob'; +import { BulkActionFormData, BulkActionProvisionResourceProps, getTargetFolderPathInRepo } from './utils'; -import { MoveResultFailed } from './BulkActionFailureBanner'; -import { BulkActionPostSubmitStep } from './BulkActionPostSubmitStep'; -import { ProgressState } from './BulkActionProgress'; -import { useBulkActionRequest } from './useBulkActionRequest'; -import { - BulkActionFormData, - BulkActionProvisionResourceProps, - BulkSuccessResponse, - getTargetFolderPathInRepo, - getResourceTargetPath, - MoveResultSuccessState, -} from './utils'; interface FormProps extends BulkActionProvisionResourceProps { initialValues: BulkActionFormData; repository: RepositoryView; @@ -48,178 +33,64 @@ interface FormProps extends BulkActionProvisionResourceProps { function FormContent({ initialValues, selectedItems, repository, workflowOptions, folderPath, onDismiss }: FormProps) { // States + const [job, setJob] = useState(); const [targetFolderUID, setTargetFolderUID] = useState(undefined); - const [progress, setProgress] = useState(null); - const [failureResults, setFailureResults] = useState(); - const [successState, setSuccessState] = useState({ - allSuccess: false, - repoUrl: '', - }); const [hasSubmitted, setHasSubmitted] = useState(false); // Hooks - const [moveFile, moveRequest] = useCreateRepositoryFilesWithPathMutation(); + const { createBulkJob, isLoading: isCreatingJob } = useBulkActionJob(); const methods = useForm({ defaultValues: initialValues }); - const childrenByParentUID = useChildrenByParentUIDState(); - const rootItems = useSelector(rootItemsSelector); const { handleSubmit, watch } = methods; const workflow = watch('workflow'); - const { handleSuccess } = useBulkActionRequest({ workflow, repository, successState, onDismiss }); // Get target folder data const { data: targetFolder } = useGetFolderQuery(targetFolderUID ? { name: targetFolderUID } : skipToken); - const getResourceCurrentPath = async (uid: string, isFolder: boolean): Promise => { - const item = findItem(rootItems?.items || [], childrenByParentUID, uid); - if (!item) { - return undefined; - } - return isFolder ? `${folderPath}/${item.title}/` : fetchProvisionedDashboardPath(uid); - }; - - const getDashboardBody = async (currentPath: string) => { - const repositoryClient = new ScopedResourceClient({ - group: 'provisioning.grafana.app', - version: 'v0alpha1', - resource: 'repositories', - }); - const fileResponse = await repositoryClient.subresource(repository.name, `files/${currentPath}`); - return fileResponse.resource?.file; - }; - const setupMoveOperation = () => { const targetFolderPathInRepo = getTargetFolderPathInRepo({ targetFolder }); - const targets = collectSelectedItems(selectedItems, childrenByParentUID, rootItems?.items || []); + const resources = collectSelectedItems(selectedItems); - if (targets.length > 0) { - setProgress({ - current: 0, - total: targets.length, - item: targets[0].displayName || 'Unknown', - }); - } - - return { targetFolderPathInRepo, targets }; - }; - - const createFileBody = async (isFolder: boolean, displayName: string, currentPath: string) => { - if (isFolder) { - return { - title: displayName, - type: 'folder', - }; - } - - const fileBody = await getDashboardBody(currentPath); - if (!fileBody) { - throw new Error( - t('browse-dashboards.bulk-move-resources-form.error-file-content-not-found', 'File content not found') - ); - } - - return fileBody; + return { targetFolderPathInRepo, resources }; }; const handleSubmitForm = async (data: BulkActionFormData) => { - setFailureResults(undefined); setHasSubmitted(true); - // 1. Validate - if (!targetFolder) { - setFailureResults([ - { - status: 'failed', - title: t('browse-dashboards.bulk-move-resources-form.error-title', 'Target Folder Error'), - }, - ]); - return; + // 1. Setup + const { targetFolderPathInRepo, resources } = setupMoveOperation(); + + if (!targetFolderPathInRepo) { + throw new Error( + t( + 'browse-dashboards.bulk-move-resources-form.error-no-target-folder-path', + 'Target folder path in repository is invalid, please select another folder.' + ) + ); } - // 2. Setup - const { targetFolderPathInRepo, targets } = setupMoveOperation(); + // Create the move job spec + const jobSpec: MoveJobSpec = { + action: 'move', + move: { + ref: data.workflow === 'write' ? undefined : data.ref, + targetPath: `${targetFolderPathInRepo}/`, + resources, + }, + }; - // 3. Process items - const successes: BulkSuccessResponse< - CreateRepositoryFilesWithPathApiArg, - CreateRepositoryFilesWithPathApiResponse - > = []; - const failures: MoveResultFailed[] = []; + const result = await createBulkJob(repository, jobSpec); - // Iterate through each selected item and move it - // We want sequential processing to avoid overwhelming the API - for (let i = 0; i < targets.length; i++) { - const { uid, isFolder, displayName } = targets[i]; - setProgress({ - current: i, - total: targets.length, - item: displayName, + if (result.success && result.job) { + setJob(result.job); // Store the job for tracking + } else if (!result.success && result.error) { + getAppEvents().publish({ + type: AppEvents.alertError.name, + payload: [ + t('browse-dashboards.bulk-move-resources-form.error-moving-resources', 'Error moving resources'), + result.error, + ], }); - - try { - // 1. Get source path in repository - const currentPath = await getResourceCurrentPath(uid, isFolder); - if (!currentPath) { - failures.push({ - status: 'failed', - title: `${isFolder ? 'Folder' : 'Dashboard'}: ${displayName}`, - errorMessage: t('browse-dashboards.bulk-move-resources-form.error-path-not-found', 'Path not found'), - }); - continue; - } - - if (!targetFolderPathInRepo) { - failures.push({ - status: 'failed', - title: `${isFolder ? 'Folder' : 'Dashboard'}: ${displayName}`, - errorMessage: t( - 'browse-dashboards.bulk-move-resources-form.error-target-folder-path-missing', - 'Target folder path is missing' - ), - }); - continue; - } - - const newPath = getResourceTargetPath(currentPath, targetFolderPathInRepo); - const fileBody = await createFileBody(isFolder, displayName, currentPath); - - // Build move parameters - const moveParams: CreateRepositoryFilesWithPathApiArg = { - name: repository.name, - path: newPath, // NEW target path - ref: workflow === 'write' ? undefined : data.ref, - message: data.comment || `Move resource ${displayName}`, - originalPath: currentPath, // CURRENT path (source) - body: fileBody, // File content - }; - - // Call endpoint to move resource - const response = await moveFile(moveParams).unwrap(); - successes.push({ index: i, item: moveParams, data: response }); - } catch (error: unknown) { - failures.push({ - status: 'failed', - title: `${isFolder ? 'Folder' : 'Dashboard'}: ${displayName}`, - errorMessage: extractErrorMessage(error), - }); - } - - setProgress({ - current: i + 1, - total: targets.length, - item: targets[i + 1]?.displayName, - }); - } - - setProgress(null); - - if (successes.length > 0 && failures.length === 0) { - // handleSuccess(successes); - setSuccessState({ - allSuccess: true, - repoUrl: successes[0].data.urls?.newPullRequestURL, - }); - } else if (failures.length > 0) { - setFailureResults(failures); + setHasSubmitted(false); // Reset submit state so user can try again } }; @@ -234,15 +105,8 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions - {hasSubmitted ? ( - + {hasSubmitted && job ? ( + ) : ( <> {/* Target folder selection */} @@ -259,12 +123,20 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions /> - - @@ -277,7 +149,12 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions } export function BulkMoveProvisionedResource({ folderUid, selectedItems, onDismiss }: BulkActionProvisionResourceProps) { - const { repository, folder } = useGetResourceRepositoryView({ folderName: folderUid }); + // Check if we're on the root browser dashboards page + const isRootPage = !folderUid || folderUid === GENERAL_FOLDER_UID; + const { selectedItemsRepoUID } = useSelectionRepoValidation(selectedItems); + const { repository, folder, isReadOnlyRepo } = useGetResourceRepositoryView({ + folderName: isRootPage ? selectedItemsRepoUID : folderUid, + }); const workflowOptions = getWorkflowOptions(repository); const folderPath = folder?.metadata?.annotations?.[AnnoKeySourcePath] || ''; @@ -289,8 +166,8 @@ export function BulkMoveProvisionedResource({ folderUid, selectedItems, onDismis workflow: getDefaultWorkflow(repository), }; - if (!repository) { - return null; + if (!repository || isReadOnlyRepo) { + return ; } return ( @@ -300,7 +177,7 @@ export function BulkMoveProvisionedResource({ folderUid, selectedItems, onDismis initialValues={initialValues} repository={repository} workflowOptions={workflowOptions} - folderPath={folderPath} + folderPath={isRootPage ? '/' : folderPath} /> ); } diff --git a/public/app/features/browse-dashboards/components/BulkActions/RepoInvalidStateBanner.tsx b/public/app/features/browse-dashboards/components/BulkActions/RepoInvalidStateBanner.tsx new file mode 100644 index 00000000000..10381234514 --- /dev/null +++ b/public/app/features/browse-dashboards/components/BulkActions/RepoInvalidStateBanner.tsx @@ -0,0 +1,44 @@ +import { Trans, t } from '@grafana/i18n'; +import { Alert } from '@grafana/ui'; + +interface Props { + noRepository: boolean; + isReadOnlyRepo: boolean; + readOnlyMessage?: string; +} + +export function RepoInvalidStateBanner({ noRepository, isReadOnlyRepo, readOnlyMessage }: Props) { + if (noRepository) { + return ( + + + The repository for the selected folder could not be found. Please ensure that the folder is provisioned + correctly. + + + ); + } + + if (isReadOnlyRepo) { + return ( + + {readOnlyMessage + ? t( + 'browse-dashboards.bulk-move-resources-form.error.read-only-saving-message', + 'Repository is read-only and provisioned in git. {{readOnlyMessage}}', + { readOnlyMessage } + ) + : t( + 'browse-dashboards.bulk-move-resources-form.error.read-only-message', + 'If you have direct access to the target, please make modifications directly in the target repository.' + )} + + ); + } + + return null; +} diff --git a/public/app/features/browse-dashboards/components/BulkActions/useBulkActionJob.ts b/public/app/features/browse-dashboards/components/BulkActions/useBulkActionJob.ts new file mode 100644 index 00000000000..fe47c0469e6 --- /dev/null +++ b/public/app/features/browse-dashboards/components/BulkActions/useBulkActionJob.ts @@ -0,0 +1,70 @@ +import { useCreateRepositoryJobsMutation, RepositoryView, Job } from 'app/api/clients/provisioning/v0alpha1'; +import { extractErrorMessage } from 'app/api/utils'; + +export interface ResourceRef { + name: string; + group: 'dashboard.grafana.app' | 'folder.grafana.app'; + kind: 'Dashboard' | 'Folder'; +} + +export interface DeleteJobSpec { + action: 'delete'; + delete: { + ref?: string; + resources: ResourceRef[]; + }; +} + +export interface MoveJobSpec { + action: 'move'; + move: { + ref?: string; + targetPath: string; // Must end with '/' slash + resources: ResourceRef[]; + }; +} + +export type BulkJobSpec = DeleteJobSpec | MoveJobSpec; + +interface UseBulkActionJobResult { + createBulkJob: ( + repository: RepositoryView, + jobSpec: BulkJobSpec + ) => Promise<{ + success: boolean; + jobId?: string; + job?: Job; // Return the full job object + error?: string; + }>; + isLoading: boolean; +} + +export type ResponseType = { success: boolean; jobId?: string; job?: Job; error?: string }; + +// This hook is used to create bulk action (delete, move) jobs for provisioning resources +export function useBulkActionJob(): UseBulkActionJobResult { + const [createJob, { isLoading }] = useCreateRepositoryJobsMutation(); + + const createBulkJob = async (repository: RepositoryView, jobSpec: BulkJobSpec): Promise => { + try { + const response = await createJob({ + name: repository.name, + jobSpec, + }).unwrap(); + + const jobId = response.metadata?.name; + return { + success: true, + jobId, + job: response, // Return the full job object + }; + } catch (error) { + return { success: false, error: extractErrorMessage(error) }; + } + }; + + return { + createBulkJob, + isLoading, + }; +} diff --git a/public/app/features/browse-dashboards/components/BulkActions/useBulkActionRequest.ts b/public/app/features/browse-dashboards/components/BulkActions/useBulkActionRequest.ts deleted file mode 100644 index 0e0ddfcaf2c..00000000000 --- a/public/app/features/browse-dashboards/components/BulkActions/useBulkActionRequest.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useNavigate } from 'react-router-dom-v5-compat'; - -import { RepositoryView } from 'app/api/clients/provisioning/v0alpha1'; -import { buildResourceBranchRedirectUrl } from 'app/features/dashboard-scene/settings/utils'; - -import { MoveResultSuccessState } from './utils'; - -interface Props { - workflow?: 'branch' | 'write'; - repository: RepositoryView; - successState: MoveResultSuccessState; - onDismiss?: () => void; -} -export function useBulkActionRequest({ workflow, repository, successState, onDismiss }: Props) { - const navigate = useNavigate(); - - const handleSuccess = () => { - if (workflow === 'branch') { - onDismiss?.(); - if (successState.repoUrl) { - const url = buildResourceBranchRedirectUrl({ - paramName: 'repo_url', - paramValue: successState.repoUrl, - repoType: repository.type, - }); - - navigate(url); - return; - } - window.location.reload(); - } else { - onDismiss?.(); - window.location.reload(); - } - }; - return { - handleSuccess, - }; -} diff --git a/public/app/features/browse-dashboards/components/BulkActions/useFolderNameFromSelection.ts b/public/app/features/browse-dashboards/components/BulkActions/useFolderNameFromSelection.ts new file mode 100644 index 00000000000..a21b74129fa --- /dev/null +++ b/public/app/features/browse-dashboards/components/BulkActions/useFolderNameFromSelection.ts @@ -0,0 +1,49 @@ +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; + +import { ManagerKind } from 'app/features/apiserver/types'; + +import { rootItemsSelector, useChildrenByParentUIDState } from '../../state/hooks'; +import { findItem } from '../../state/utils'; +import { DashboardTreeSelection } from '../../types'; + +// This hook retrieves the folder UID from the selection state. Because search endpoint currently does not return resource metadata +// NOTE: This is a temporary workaround until the search endpoint is updated +interface Props { + folderUid?: string; + selectedItems: Omit; +} +export function useFolderNameFromSelection({ folderUid, selectedItems }: Props) { + const rootItems = useSelector(rootItemsSelector); + const childrenByParentUID = useChildrenByParentUIDState(); + + return useMemo(() => { + // if we already have a folderUid, return it; + if (folderUid) { + return folderUid; + } + + // Helper to walk up tree and find provisioned folder + const findProvisionedParent = (itemUid: string): string | undefined => { + const item = findItem(rootItems?.items || [], childrenByParentUID, itemUid); + if (!item) { + return undefined; + } + + if (item.managedBy === ManagerKind.Repo) { + return item.uid; + } + if (item.parentUID) { + return findProvisionedParent(item.parentUID); + } + return undefined; + }; + + // Try folders, then dashboards + const firstSelectedUid = + Object.keys(selectedItems.folder).find((uid) => selectedItems.folder[uid]) || + Object.keys(selectedItems.dashboard).find((uid) => selectedItems.dashboard[uid]); + + return firstSelectedUid ? findProvisionedParent(firstSelectedUid) : undefined; + }, [folderUid, selectedItems, rootItems, childrenByParentUID]); +} diff --git a/public/app/features/browse-dashboards/components/BulkActions/utils.ts b/public/app/features/browse-dashboards/components/BulkActions/utils.ts index 48fc4892c73..d039fecab23 100644 --- a/public/app/features/browse-dashboards/components/BulkActions/utils.ts +++ b/public/app/features/browse-dashboards/components/BulkActions/utils.ts @@ -16,17 +16,6 @@ export interface BulkActionProvisionResourceProps { onDismiss?: () => void; } -export type BulkSuccessResponse = Array<{ - index: number; - item: T; - data: K; -}>; - -export type MoveResultSuccessState = { - allSuccess: boolean; - repoUrl?: string; -}; - export function getTargetFolderPathInRepo({ targetFolder }: { targetFolder?: Folder }): string | undefined { if (!targetFolder) { return undefined; diff --git a/public/app/features/browse-dashboards/components/CheckboxCell.tsx b/public/app/features/browse-dashboards/components/CheckboxCell.tsx index 60995129f90..736e98bbf44 100644 --- a/public/app/features/browse-dashboards/components/CheckboxCell.tsx +++ b/public/app/features/browse-dashboards/components/CheckboxCell.tsx @@ -3,10 +3,14 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { t } from '@grafana/i18n'; -import { Checkbox, useStyles2 } from '@grafana/ui'; +import { Checkbox, Tooltip, useStyles2 } from '@grafana/ui'; +import { ManagerKind } from 'app/features/apiserver/types'; +import { getReadOnlyTooltipText } from 'app/features/provisioning/utils/repository'; +import { useSelector } from 'app/types/store'; import { DashboardsTreeCellProps, SelectionState } from '../types'; +import { useSelectionRepoValidation } from './BrowseActions/useSelectionRepoValidation'; import { isSharedWithMe, canEditItemType } from './utils'; export default function CheckboxCell({ @@ -17,6 +21,11 @@ export default function CheckboxCell({ }: DashboardsTreeCellProps) { const item = row.item; + // Get current selection state for repository validation + const selectedItems = useSelector((state) => state.browseDashboards.selectedItems); + const { selectedItemsRepoUID, isInLockedRepo, isUidInReadOnlyRepo } = useSelectionRepoValidation(selectedItems); + + // Early returns for cases where we should show a spacer instead of checkbox if (!isSelected) { return ; } @@ -33,11 +42,43 @@ export default function CheckboxCell({ return ; } + // Disable checkbox for root provisioned folder itself + if (item.managedBy === ManagerKind.Repo && !item.parentUID) { + return ; + } + + if ((permissions && permissions.isReadOnlyRepo) || isUidInReadOnlyRepo(item.uid)) { + // When the folder is read-only (inherited from repository), disable checkbox with tooltip + return ( + + + + + + ); + } + // Check if user can edit this specific item type if (permissions && !canEditItemType(item.kind, permissions)) { return ; } + // check if current item uid has different repo uid than selected items + if (selectedItemsRepoUID && !isInLockedRepo(item.uid)) { + return ( + + + + + + ); + } + const state = isSelected(item); return ( diff --git a/public/app/features/browse-dashboards/components/CheckboxHeaderCell.tsx b/public/app/features/browse-dashboards/components/CheckboxHeaderCell.tsx index 269c9bfc638..54e17908012 100644 --- a/public/app/features/browse-dashboards/components/CheckboxHeaderCell.tsx +++ b/public/app/features/browse-dashboards/components/CheckboxHeaderCell.tsx @@ -3,11 +3,17 @@ import { Checkbox } from '@grafana/ui'; import { DashboardTreeHeaderProps, SelectionState } from '../types'; -export default function CheckboxHeaderCell({ isSelected, onAllSelectionChange }: DashboardTreeHeaderProps) { +export default function CheckboxHeaderCell({ + isSelected, + onAllSelectionChange, + permissions, +}: DashboardTreeHeaderProps) { const state = isSelected?.('$all') ?? SelectionState.Unselected; + const isReadOnlyRepo = permissions?.isReadOnlyRepo; return ( ) { } async function renderAndOpen(folder?: FolderDTO) { - render(); + render(); const newButton = screen.getByText('New'); await userEvent.click(newButton); } @@ -42,7 +42,9 @@ describe('NewActionsButton', () => { }); it('clicking the "New folder" button opens the drawer', async () => { - render(); + render( + + ); const newButton = screen.getByText('New'); await userEvent.click(newButton); @@ -55,7 +57,7 @@ describe('NewActionsButton', () => { }); it('should only render dashboard items when folder creation is disabled', async () => { - render(); + render(); const newButton = screen.getByText('New'); await userEvent.click(newButton); @@ -65,7 +67,7 @@ describe('NewActionsButton', () => { }); it('should only render folder item when dashboard creation is disabled', async () => { - render(); + render(); const newButton = screen.getByText('New'); await userEvent.click(newButton); diff --git a/public/app/features/browse-dashboards/components/CreateNewButton.tsx b/public/app/features/browse-dashboards/components/CreateNewButton.tsx index 027916de85e..bc8b896b8ae 100644 --- a/public/app/features/browse-dashboards/components/CreateNewButton.tsx +++ b/public/app/features/browse-dashboards/components/CreateNewButton.tsx @@ -5,7 +5,9 @@ import { locationUtil } from '@grafana/data'; import { config, locationService, reportInteraction } from '@grafana/runtime'; import { Button, Drawer, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui'; import { useAppNotification } from 'app/core/copy/appNotification'; +import { RepoType } from 'app/features/provisioning/Wizard/types'; import { useIsProvisionedInstance } from 'app/features/provisioning/hooks/useIsProvisionedInstance'; +import { getReadOnlyTooltipText } from 'app/features/provisioning/utils/repository'; import { getImportPhrase, getNewDashboardPhrase, @@ -24,9 +26,17 @@ interface Props { parentFolder?: FolderDTO; canCreateFolder: boolean; canCreateDashboard: boolean; + isReadOnlyRepo: boolean; + repoType?: RepoType; } -export default function CreateNewButton({ parentFolder, canCreateDashboard, canCreateFolder }: Props) { +export default function CreateNewButton({ + parentFolder, + canCreateDashboard, + canCreateFolder, + isReadOnlyRepo, + repoType, +}: Props) { const [isOpen, setIsOpen] = useState(false); const location = useLocation(); const [newFolder] = useNewFolderMutation(); @@ -94,7 +104,11 @@ export default function CreateNewButton({ parentFolder, canCreateDashboard, canC return ( <> - diff --git a/public/app/features/browse-dashboards/components/DeleteProvisionedFolderForm.test.tsx b/public/app/features/browse-dashboards/components/DeleteProvisionedFolderForm.test.tsx index a3ddd150785..23108ee102c 100644 --- a/public/app/features/browse-dashboards/components/DeleteProvisionedFolderForm.test.tsx +++ b/public/app/features/browse-dashboards/components/DeleteProvisionedFolderForm.test.tsx @@ -134,6 +134,7 @@ const defaultHookData: ProvisionedFolderFormDataResult = { repository: mockRepository, folder: mockFolder, initialValues: mockFormData, + isReadOnlyRepo: false, }; function setup( diff --git a/public/app/features/browse-dashboards/components/DeleteProvisionedFolderForm.tsx b/public/app/features/browse-dashboards/components/DeleteProvisionedFolderForm.tsx index f6fb26101f8..87f1d6bca1e 100644 --- a/public/app/features/browse-dashboards/components/DeleteProvisionedFolderForm.tsx +++ b/public/app/features/browse-dashboards/components/DeleteProvisionedFolderForm.tsx @@ -20,6 +20,7 @@ import { FolderDTO } from 'app/types/folders'; import { useProvisionedFolderFormData } from '../hooks/useProvisionedFolderFormData'; import { DescendantCount } from './BrowseActions/DescendantCount'; +import { RepoInvalidStateBanner } from './BulkActions/RepoInvalidStateBanner'; import { getFolderURL } from './utils'; interface FormProps extends DeleteProvisionedFolderFormProps { @@ -150,14 +151,23 @@ function FormContent({ initialValues, parentFolder, repository, workflowOptions, } export function DeleteProvisionedFolderForm({ parentFolder, onDismiss }: DeleteProvisionedFolderFormProps) { - const { workflowOptions, repository, folder, initialValues } = useProvisionedFolderFormData({ + const { workflowOptions, repository, folder, initialValues, isReadOnlyRepo } = useProvisionedFolderFormData({ folderUid: parentFolder?.uid, action: 'delete', title: parentFolder?.title, }); - if (!initialValues) { - return null; + if (isReadOnlyRepo || !initialValues) { + return ( + + ); } return ( diff --git a/public/app/features/browse-dashboards/components/FolderActionsButton.tsx b/public/app/features/browse-dashboards/components/FolderActionsButton.tsx index b986d541f7f..c58b310e0e3 100644 --- a/public/app/features/browse-dashboards/components/FolderActionsButton.tsx +++ b/public/app/features/browse-dashboards/components/FolderActionsButton.tsx @@ -6,6 +6,8 @@ import { locationService, reportInteraction } from '@grafana/runtime'; import { Button, Drawer, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui'; import { Permissions } from 'app/core/components/AccessControl'; import { appEvents } from 'app/core/core'; +import { RepoType } from 'app/features/provisioning/Wizard/types'; +import { getReadOnlyTooltipText } from 'app/features/provisioning/utils/repository'; import { ShowModalReactEvent } from 'app/types/events'; import { FolderDTO } from 'app/types/folders'; @@ -20,9 +22,11 @@ import { DeleteProvisionedFolderForm } from './DeleteProvisionedFolderForm'; interface Props { folder: FolderDTO; + isReadOnlyRepo?: boolean; + repoType?: RepoType; } -export function FolderActionsButton({ folder }: Props) { +export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props) { const [isOpen, setIsOpen] = useState(false); const [showPermissionsDrawer, setShowPermissionsDrawer] = useState(false); const [showDeleteProvisionedFolderDrawer, setShowDeleteProvisionedFolderDrawer] = useState(false); @@ -137,7 +141,11 @@ export function FolderActionsButton({ folder }: Props) { return ( <> - diff --git a/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.test.tsx b/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.test.tsx index 2242a8965ae..de712d32b7e 100644 --- a/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.test.tsx +++ b/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.test.tsx @@ -131,6 +131,7 @@ const mockHookData: ProvisionedFolderFormDataResult = { workflows: ['write', 'branch'], target: 'folder', }, + isReadOnlyRepo: false, folder: { metadata: { annotations: { @@ -188,18 +189,18 @@ describe('NewProvisionedFolderForm', () => { }); it('should return null when initialValues is not available', () => { - const { container } = setup( + setup( {}, { ...mockHookData, initialValues: undefined, } ); - expect(container.firstChild).toBeNull(); + expect(screen.getByLabelText('Repository not found')).toBeInTheDocument(); }); it('should show error when repository is not found', () => { - const { container } = setup( + setup( {}, { ...mockHookData, @@ -207,7 +208,7 @@ describe('NewProvisionedFolderForm', () => { initialValues: undefined, } ); - expect(container.firstChild).toBeNull(); + expect(screen.getByLabelText('Repository not found')).toBeInTheDocument(); }); it('should show branch field when branch workflow is selected', async () => { @@ -221,25 +222,6 @@ describe('NewProvisionedFolderForm', () => { expect(screen.getByRole('textbox', { name: /branch/i })).toBeInTheDocument(); }); - it('should validate folder name', async () => { - (validationSrv.validateNewFolderName as jest.Mock).mockRejectedValue(new Error('Folder name already exists')); - - const { user } = setup(); - - const folderNameInput = screen.getByRole('textbox', { name: /folder name/i }); - await user.clear(folderNameInput); - await user.type(folderNameInput, 'Existing Folder'); - - // Submit the form - const submitButton = screen.getByRole('button', { name: /^create$/i }); - await user.click(submitButton); - - // Wait for validation error to appear - await waitFor(() => { - expect(screen.getByText('Folder name already exists')).toBeInTheDocument(); - }); - }); - it('should validate branch name', async () => { const { user } = setup(); @@ -296,7 +278,7 @@ describe('NewProvisionedFolderForm', () => { expect.objectContaining({ ref: undefined, // write workflow uses undefined ref name: 'test-repo', - path: '/dashboards/new-test-folder/', + path: '/dashboards/New Test Folder/', message: 'Creating a new test folder', body: { title: 'New Test Folder', @@ -349,7 +331,7 @@ describe('NewProvisionedFolderForm', () => { expect.objectContaining({ ref: 'feature/new-folder', name: 'test-repo', - path: '/dashboards/branch-folder/', + path: '/dashboards/Branch Folder/', message: 'Create folder: Branch Folder', body: { title: 'Branch Folder', diff --git a/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx b/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx index 797f4bb98ba..510c506ce75 100644 --- a/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx +++ b/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx @@ -1,11 +1,10 @@ -import { css } from '@emotion/css'; import { FormProvider, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom-v5-compat'; -import { AppEvents, GrafanaTheme2 } from '@grafana/data'; +import { AppEvents } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { getAppEvents } from '@grafana/runtime'; -import { Alert, Text, Button, Field, Icon, Input, Stack, useStyles2 } from '@grafana/ui'; +import { Alert, Button, Field, Input, Stack } from '@grafana/ui'; import { Folder } from 'app/api/clients/folder/v1beta1'; import { RepositoryView, useCreateRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning/v0alpha1'; import { AnnoKeySourcePath, Resource } from 'app/features/apiserver/types'; @@ -22,8 +21,7 @@ import { FolderDTO } from 'app/types/folders'; import { useProvisionedFolderFormData } from '../hooks/useProvisionedFolderFormData'; -import { validateFolderName } from './NewFolderForm'; -import { formatFolderName, hasFolderNameCharactersToReplace } from './utils'; +import { RepoInvalidStateBanner } from './BulkActions/RepoInvalidStateBanner'; interface FormProps extends Props { initialValues: BaseProvisionedFormData; @@ -47,7 +45,7 @@ function FormContent({ initialValues, repository, workflowOptions, folder, onDis }); const { handleSubmit, watch, register, formState } = methods; - const [workflow, title] = watch(['workflow', 'title']); + const [workflow] = watch(['workflow']); const onBranchSuccess = ({ urls }: { urls?: Record }, info: ProvisionedOperationInfo) => { const prUrl = urls?.newPullRequestURL; @@ -108,12 +106,8 @@ function FormContent({ initialValues, repository, workflowOptions, folder, onDis return; } const basePath = folder?.metadata?.annotations?.[AnnoKeySourcePath] ?? ''; - - // Convert folder title to filename format (lowercase, replace spaces with hyphens) - const titleInFilenameFormat = formatFolderName(title); // TODO: this is currently not working, issue created https://github.com/grafana/git-ui-sync-project/issues/314 - const prefix = basePath ? `${basePath}/` : ''; - const path = `${prefix}${titleInFilenameFormat}/`; + const path = `${prefix}${title}/`; const folderModel = { title, @@ -153,13 +147,13 @@ function FormContent({ initialValues, repository, workflowOptions, folder, onDis - - - + @@ -213,14 +206,23 @@ function FormContent({ initialValues, repository, workflowOptions, folder, onDis } export function NewProvisionedFolderForm({ parentFolder, onDismiss }: Props) { - const { workflowOptions, repository, folder, initialValues } = useProvisionedFolderFormData({ + const { workflowOptions, repository, folder, initialValues, isReadOnlyRepo } = useProvisionedFolderFormData({ folderUid: parentFolder?.uid, action: 'create', title: '', // Empty title for new folders }); - if (!initialValues) { - return null; + if (isReadOnlyRepo || !initialValues) { + return ( + + ); } return ( @@ -235,38 +237,20 @@ export function NewProvisionedFolderForm({ parentFolder, onDismiss }: Props) { ); } -function FolderNamePreviewMessage({ folderName }: { folderName: string }) { - const styles = useStyles2(getStyles); - const isValidFolderName = - folderName.length && hasFolderNameCharactersToReplace(folderName) && validateFolderName(folderName); - - if (!isValidFolderName) { - return null; +function validateProvisionedFolderName(folderName: string): string | true { + if (!folderName || typeof folderName !== 'string') { + return t('browse-dashboards.new-provisioned-folder-form.error-required', 'Folder name is required'); } - return ( -
- - - {t( - 'browse-dashboards.new-provisioned-folder-form.text-your-folder-will-be-created-as', - 'Your folder will be created as {{folderName}}', - { - folderName: formatFolderName(folderName), - } - )} - -
- ); -} + // Backend allows: a-zA-Z0-9 _- (no dots, no forward slash for folder names) + const invalidCharRegex = /[^a-zA-Z0-9 _-]/; -const getStyles = (theme: GrafanaTheme2) => { - return { - folderNameMessage: css({ - display: 'flex', - alignItems: 'center', - fontSize: theme.typography.bodySmall.fontSize, - color: theme.colors.success.text, - }), - }; -}; + if (invalidCharRegex.test(folderName)) { + return t( + 'browse-dashboards.new-provisioned-folder-form.error-invalid-characters', + 'Folder name contains invalid characters. Only letters, numbers, spaces, underscores, and hyphens are allowed.' + ); + } + + return true; // Valid +} diff --git a/public/app/features/browse-dashboards/components/utils.test.ts b/public/app/features/browse-dashboards/components/utils.test.ts deleted file mode 100644 index 2bf48e3d329..00000000000 --- a/public/app/features/browse-dashboards/components/utils.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { formatFolderName, hasFolderNameCharactersToReplace } from './utils'; - -describe('formatFolderName', () => { - it('should handle empty string', () => { - expect(formatFolderName('')).toBe(''); - }); - - it('should convert uppercase to lowercase', () => { - expect(formatFolderName('MyFolder')).toBe('myfolder'); - expect(formatFolderName('UPPERCASE')).toBe('uppercase'); - expect(formatFolderName('MiXeD cAsE')).toBe('mixed-case'); - }); - - it('should replace whitespace with hyphens', () => { - expect(formatFolderName('folder name')).toBe('folder-name'); - expect(formatFolderName('folder name')).toBe('folder-name'); // multiple spaces - expect(formatFolderName('folder\tname')).toBe('folder-name'); // tab - expect(formatFolderName('folder\nname')).toBe('folder-name'); // newline - expect(formatFolderName(' folder name ')).toBe('folder-name'); // leading/trailing spaces - }); - - it('should remove special characters', () => { - expect(formatFolderName('folder@name')).toBe('foldername'); - expect(formatFolderName('folder!@#$%^&*()name')).toBe('foldername'); - expect(formatFolderName('folder_name')).toBe('foldername'); - expect(formatFolderName('folder.name')).toBe('foldername'); - expect(formatFolderName('folder/name')).toBe('foldername'); - }); - - it('should preserve numbers and hyphens', () => { - expect(formatFolderName('folder-123')).toBe('folder-123'); - expect(formatFolderName('folder123')).toBe('folder123'); - expect(formatFolderName('123-folder')).toBe('123-folder'); - expect(formatFolderName('folder-name-123')).toBe('folder-name-123'); - }); - - it('should handle complex mixed cases', () => { - expect(formatFolderName('My Folder @2023!')).toBe('my-folder-2023'); - expect(formatFolderName(' FOLDER_NAME with-123 ')).toBe('foldername-with-123'); - expect(formatFolderName('Test@Folder#Name$123')).toBe('testfoldername123'); - expect(formatFolderName('Multiple Spaces Between')).toBe('multiple-spaces-between'); - }); - - it('should handle strings with only special characters', () => { - expect(formatFolderName('!@#$%^&*()')).toBe(''); - expect(formatFolderName('___')).toBe(''); - expect(formatFolderName('...')).toBe(''); - }); - - it('should handle strings with only whitespace', () => { - expect(formatFolderName(' ')).toBe(''); - expect(formatFolderName('\t\n\r')).toBe(''); - }); - - it('should handle already formatted names', () => { - expect(formatFolderName('already-formatted')).toBe('already-formatted'); - expect(formatFolderName('folder123')).toBe('folder123'); - expect(formatFolderName('test-folder-name-123')).toBe('test-folder-name-123'); - }); -}); - -describe('hasFolderNameCharactersToReplace', () => { - it('should return false for non-string inputs', () => { - // @ts-expect-error - expect(hasFolderNameCharactersToReplace(null)).toBe(false); - // @ts-expect-error - expect(hasFolderNameCharactersToReplace(undefined)).toBe(false); - // @ts-expect-error - expect(hasFolderNameCharactersToReplace(123)).toBe(false); - // @ts-expect-error - expect(hasFolderNameCharactersToReplace({})).toBe(false); - // @ts-expect-error - expect(hasFolderNameCharactersToReplace([])).toBe(false); - }); - - it('should return false for empty string', () => { - expect(hasFolderNameCharactersToReplace('')).toBe(false); - }); - - it('should return false for valid folder names', () => { - expect(hasFolderNameCharactersToReplace('validname')).toBe(false); - expect(hasFolderNameCharactersToReplace('folder123')).toBe(false); - expect(hasFolderNameCharactersToReplace('test-folder-name')).toBe(false); - expect(hasFolderNameCharactersToReplace('folder-123')).toBe(false); - expect(hasFolderNameCharactersToReplace('123-folder')).toBe(false); - expect(hasFolderNameCharactersToReplace('a')).toBe(false); - expect(hasFolderNameCharactersToReplace('1')).toBe(false); - }); - - it('should return true for names with whitespace', () => { - expect(hasFolderNameCharactersToReplace('folder name')).toBe(true); - expect(hasFolderNameCharactersToReplace('folder name')).toBe(true); - expect(hasFolderNameCharactersToReplace('folder\tname')).toBe(true); - expect(hasFolderNameCharactersToReplace('folder\nname')).toBe(true); - expect(hasFolderNameCharactersToReplace(' folder')).toBe(true); - expect(hasFolderNameCharactersToReplace('folder ')).toBe(true); - expect(hasFolderNameCharactersToReplace(' ')).toBe(true); - }); - - it('should return true for names with uppercase letters', () => { - expect(hasFolderNameCharactersToReplace('FolderName')).toBe(true); - expect(hasFolderNameCharactersToReplace('UPPERCASE')).toBe(true); - expect(hasFolderNameCharactersToReplace('MiXeD')).toBe(true); - expect(hasFolderNameCharactersToReplace('folder-Name')).toBe(true); - }); - - it('should return true for names with special characters', () => { - expect(hasFolderNameCharactersToReplace('folder@name')).toBe(true); - expect(hasFolderNameCharactersToReplace('folder!name')).toBe(true); - expect(hasFolderNameCharactersToReplace('folder_name')).toBe(true); - expect(hasFolderNameCharactersToReplace('folder.name')).toBe(true); - expect(hasFolderNameCharactersToReplace('folder/name')).toBe(true); - expect(hasFolderNameCharactersToReplace('folder#name')).toBe(true); - }); - - it('should return true for mixed cases with multiple issues', () => { - expect(hasFolderNameCharactersToReplace('Test@Folder#Name$123')).toBe(true); - expect(hasFolderNameCharactersToReplace('Multiple Spaces Between')).toBe(true); - }); - - it('should return true for strings with only special characters', () => { - expect(hasFolderNameCharactersToReplace('!@#$%^&*()')).toBe(true); - }); -}); diff --git a/public/app/features/browse-dashboards/components/utils.ts b/public/app/features/browse-dashboards/components/utils.ts index 66a85a6d0ee..de03451146f 100644 --- a/public/app/features/browse-dashboards/components/utils.ts +++ b/public/app/features/browse-dashboards/components/utils.ts @@ -1,12 +1,17 @@ import { config } from '@grafana/runtime'; import { contextSrv } from 'app/core/core'; -import { AnnoKeySourcePath } from 'app/features/apiserver/types'; -import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; +import { ManagerKind } from 'app/features/apiserver/types'; import { DashboardViewItem } from 'app/features/search/types'; -import { useChildrenByParentUIDState } from '../state/hooks'; import { findItem } from '../state/utils'; -import { DashboardTreeSelection, DashboardViewItemWithUIItems, BrowseDashboardsPermissions } from '../types'; +import { + DashboardTreeSelection, + DashboardViewItemWithUIItems, + BrowseDashboardsPermissions, + BrowseDashboardsState, +} from '../types'; + +import { ResourceRef } from './BulkActions/useBulkActionJob'; export function makeRowID(baseId: string, item: DashboardViewItemWithUIItems) { return baseId + item.uid; @@ -28,82 +33,26 @@ export function getFolderURL(uid: string) { return url; } -export function hasFolderNameCharactersToReplace(folderName: string): boolean { - if (typeof folderName !== 'string') { - return false; - } - - // whitespace that needs to be replaced with hyphens - const hasWhitespace = /\s+/.test(folderName); - - // characters that are not lowercase letters, numbers, or hyphens - const hasInvalidCharacters = /[^a-z0-9-]/.test(folderName); - - return hasWhitespace || hasInvalidCharacters; -} - -export function formatFolderName(folderName?: string): string { - if (typeof folderName !== 'string') { - console.error('Invalid folder name type:', typeof folderName); - return ''; - } - - const result = folderName - .trim() // Remove leading/trailing whitespace first - .toLowerCase() - .replace(/\s+/g, '-') - .replace(/[^a-z0-9-]/g, '') - .replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens - - // If the result is empty, return empty string - if (result === '') { - return ''; - } - - return result; -} - -// Fetch provisioned dashboard path in repository -export async function fetchProvisionedDashboardPath(uid: string): Promise { - try { - const dto = await getDashboardAPI().getDashboardDTO(uid); - const sourcePath = - 'meta' in dto - ? dto.meta.k8s?.annotations?.[AnnoKeySourcePath] || dto.meta.provisionedExternalId - : dto.metadata?.annotations?.[AnnoKeySourcePath]; - return `${sourcePath}`; - } catch (error) { - console.error('Error fetching provisioned dashboard path:', error); - return undefined; - } -} - // Collect selected dashboard and folder from the DashboardTreeSelection // This is used to prepare the items for bulk delete operation. -export function collectSelectedItems( - selectedItems: Omit, - childrenByParentUID: ReturnType, - rootItems: DashboardViewItem[] = [] -) { - const targets: Array<{ uid: string; isFolder: boolean; displayName: string }> = []; +export function collectSelectedItems(selectedItems: Omit) { + const resources: ResourceRef[] = []; // folders for (const [uid, selected] of Object.entries(selectedItems.folder)) { if (selected) { - const item = findItem(rootItems, childrenByParentUID, uid); - targets.push({ uid, isFolder: true, displayName: item?.title || uid }); + resources.push({ name: uid, group: 'folder.grafana.app', kind: 'Folder' }); } } // dashboards for (const [uid, selected] of Object.entries(selectedItems.dashboard)) { if (selected) { - const item = findItem(rootItems, childrenByParentUID, uid); - targets.push({ uid, isFolder: false, displayName: item?.title || uid }); + resources.push({ name: uid, group: 'dashboard.grafana.app', kind: 'Dashboard' }); } } - return targets; + return resources; } export function canEditItemType(itemKind: string, permissions: BrowseDashboardsPermissions) { @@ -120,3 +69,35 @@ export function canSelectItems(permissions: BrowseDashboardsPermissions) { const canSelectDashboards = canEditDashboards || canDeleteDashboards; return Boolean(canSelectFolders || canSelectDashboards); } + +/** + * Finds the repository name for an item by traversing up the tree to find the root provisioned folder (managed by ManagerKind.Repo) + * This should be an edge case where user have multiple provisioned folders and try to managing resources on root folder + */ +export function getItemRepositoryUid( + item: DashboardViewItem, + rootItems: DashboardViewItem[], + childrenByParentUID: BrowseDashboardsState['childrenByParentUID'] +): string { + // For root provisioned folders, the UID is the repository name + if (item.managedBy === ManagerKind.Repo && !item.parentUID && item.kind === 'folder') { + return item.uid; + } + + // Traverse up the tree to find the root provisioned folder + let currentItem = item; + while (currentItem.parentUID) { + const parent = findItem(rootItems, childrenByParentUID, currentItem.parentUID); + if (!parent) { + break; + } + + if (parent.managedBy === ManagerKind.Repo && !parent.parentUID) { + return currentItem.parentUID; + } + + currentItem = parent; + } + + return 'non_provisioned'; +} diff --git a/public/app/features/browse-dashboards/hooks/useProvisionedFolderFormData.ts b/public/app/features/browse-dashboards/hooks/useProvisionedFolderFormData.ts index 8ea8c4835f0..342808e6520 100644 --- a/public/app/features/browse-dashboards/hooks/useProvisionedFolderFormData.ts +++ b/public/app/features/browse-dashboards/hooks/useProvisionedFolderFormData.ts @@ -20,6 +20,7 @@ export interface ProvisionedFolderFormDataResult { folder?: Folder; workflowOptions: Array<{ label: string; value: string }>; initialValues?: BaseProvisionedFormData; + isReadOnlyRepo: boolean; } /** @@ -30,7 +31,7 @@ export function useProvisionedFolderFormData({ action, title, }: UseProvisionedFolderFormDataProps): ProvisionedFolderFormDataResult { - const { repository, folder, isLoading } = useGetResourceRepositoryView({ folderName: folderUid }); + const { repository, folder, isLoading, isReadOnlyRepo } = useGetResourceRepositoryView({ folderName: folderUid }); const workflowOptions = getWorkflowOptions(repository); const timestamp = generateTimestamp(); @@ -56,5 +57,6 @@ export function useProvisionedFolderFormData({ folder, workflowOptions, initialValues, + isReadOnlyRepo, }; } diff --git a/public/app/features/browse-dashboards/state/reducers.ts b/public/app/features/browse-dashboards/state/reducers.ts index df1505f50d7..7f0004fd242 100644 --- a/public/app/features/browse-dashboards/state/reducers.ts +++ b/public/app/features/browse-dashboards/state/reducers.ts @@ -1,5 +1,6 @@ import { PayloadAction } from '@reduxjs/toolkit'; +import { ManagerKind } from 'app/features/apiserver/types'; import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types'; import { GENERAL_FOLDER_UID } from '../../search/constants'; @@ -96,6 +97,11 @@ export function setItemSelectionState( return; } + // Prevent selection of root provisioned folders + if (item.managedBy === ManagerKind.Repo && !item.parentUID) { + return; + } + // Selecting a folder selects all children, and unselecting a folder deselects all children // so propagate the new selection state to all descendants function markChildren(kind: DashboardViewItemKind, uid: string) { @@ -178,6 +184,11 @@ export function setAllSelection( continue; } + // Skip all provisioned resources during "select all" on root level + if (child.managedBy === ManagerKind.Repo && !child.parentUID) { + continue; + } + state.selectedItems[child.kind][child.uid] = isSelected; if (child.kind !== 'folder') { diff --git a/public/app/features/browse-dashboards/types.ts b/public/app/features/browse-dashboards/types.ts index 795d3bb794d..5aece134d5e 100644 --- a/public/app/features/browse-dashboards/types.ts +++ b/public/app/features/browse-dashboards/types.ts @@ -67,4 +67,5 @@ export interface BrowseDashboardsPermissions { canEditDashboards: boolean; canDeleteFolders?: boolean; canDeleteDashboards?: boolean; + isReadOnlyRepo?: boolean; } diff --git a/public/app/features/commandPalette/actions/dashboardActions.test.ts b/public/app/features/commandPalette/actions/dashboardActions.test.ts index 15778f6f6c2..a175df93452 100644 --- a/public/app/features/commandPalette/actions/dashboardActions.test.ts +++ b/public/app/features/commandPalette/actions/dashboardActions.test.ts @@ -113,7 +113,7 @@ describe('dashboardActions', () => { }); it('returns an empty array if anonymous access is not enabled', async () => { - config.bootData.settings.anonymousEnabled = false; + config.anonymousEnabled = false; const searchQuery = 'mySearchQuery'; const results = await getSearchResultActions(searchQuery); expect(grafanaSearcherSpy).not.toHaveBeenCalled(); @@ -121,7 +121,7 @@ describe('dashboardActions', () => { }); it('calls the search backend and returns an array of CommandPaletteActions if anonymous access is enabled', async () => { - config.bootData.settings.anonymousEnabled = true; + config.anonymousEnabled = true; const searchQuery = 'mySearchQuery'; const results = await getSearchResultActions(searchQuery); expect(grafanaSearcherSpy).toHaveBeenCalledWith({ diff --git a/public/app/features/commandPalette/actions/dashboardActions.ts b/public/app/features/commandPalette/actions/dashboardActions.ts index f2d3aea83fa..70a62e57295 100644 --- a/public/app/features/commandPalette/actions/dashboardActions.ts +++ b/public/app/features/commandPalette/actions/dashboardActions.ts @@ -51,7 +51,7 @@ export async function getRecentDashboardActions(): Promise { // Empty strings should not come through to here - if (searchQuery.length === 0 || (!contextSrv.user.isSignedIn && !config.bootData.settings.anonymousEnabled)) { + if (searchQuery.length === 0 || (!contextSrv.user.isSignedIn && !config.anonymousEnabled)) { return []; } diff --git a/public/app/features/commandPalette/actions/staticActions.ts b/public/app/features/commandPalette/actions/staticActions.ts index e7e8c8d6734..b70c5ee4f1c 100644 --- a/public/app/features/commandPalette/actions/staticActions.ts +++ b/public/app/features/commandPalette/actions/staticActions.ts @@ -3,7 +3,10 @@ import { useMemo } from 'react'; import { NavModelItem } from '@grafana/data'; import { t } from '@grafana/i18n'; import { enrichHelpItem } from 'app/core/components/AppChrome/MegaMenu/utils'; -import { performInviteUserClick, shouldRenderInviteUserButton } from 'app/core/components/InviteUserButton/utils'; +import { + shouldRenderInviteUserButton, + performInviteUserClick, +} from 'app/core/components/AppChrome/TopBar/InviteUserButtonUtils'; import { changeTheme } from 'app/core/services/theme'; import { currentMockApiState, toggleMockApiAndReload, togglePseudoLocale } from 'app/dev-utils'; import { useSelector } from 'app/types/store'; @@ -139,10 +142,10 @@ export function useStaticActions(): CommandPaletteAction[] { return useMemo(() => { const navBarActions = navTreeToActions(navBarTree); - if (shouldRenderInviteUserButton) { + if (shouldRenderInviteUserButton()) { navBarActions.push({ id: 'invite-user', - name: t('navigation.invite-user.invite-new-member-button', 'Invite new member'), + name: t('navigation.invite-user.invite-new-user-button', 'Invite new user'), section: t('command-palette.section.actions', 'Actions'), priority: ACTIONS_PRIORITY, perform: () => { diff --git a/public/app/features/commandPalette/useMatches.ts b/public/app/features/commandPalette/useMatches.ts index 50926b10b80..42a5716bd8d 100644 --- a/public/app/features/commandPalette/useMatches.ts +++ b/public/app/features/commandPalette/useMatches.ts @@ -1,10 +1,11 @@ -import uFuzzy from '@leeoniya/ufuzzy'; import { ActionImpl, Priority, useKBar } from 'kbar'; import { useThrottledValue } from 'kbar/lib/utils'; import * as React from 'react'; +import { fuzzySearch } from '@grafana/data'; + // From https://github.dev/timc1/kbar/blob/main/src/useMatches.tsx -// TODO: Go back to useMatches from kbar when https://github.com/timc1/kbar/issues/255 is fixed +// We are using fuzzySearch here instead of kbar's implementation as it's more performant export const NO_GROUP = { name: 'none', @@ -169,8 +170,6 @@ type Match = { }; function useInternalMatches(filtered: ActionImpl[], search: string): Match[] { - const ufuzzy = useUfuzzy(); - const value = React.useMemo( () => ({ filtered, @@ -188,58 +187,14 @@ function useInternalMatches(filtered: ActionImpl[], search: string): Match[] { const haystack = throttledFiltered.map(({ name, keywords }) => `${name} ${keywords ?? ''}`.toLowerCase()); - const results: Match[] = []; + const matchingIndices = fuzzySearch(haystack, throttledSearch); - // If the search term is too long, then just do a simple substring search. - // We don't expect users to actually hit this frequently, but want to prevent browser hangs - if (throttledSearch.length > FUZZY_SEARCH_LIMIT) { - const query = throttledSearch.toLowerCase(); - - for (let haystackIndex = 0; haystackIndex < haystack.length; haystackIndex++) { - const haystackItem = haystack[haystackIndex]; - - // Use the position of the match as a stand-in for score - const substringPosition = haystackItem.indexOf(query); - - if (substringPosition > -1) { - const score = substringPosition * -1; // lower position of the match should be a higher priority score - const action = throttledFiltered[haystackIndex]; - results.push({ score, action }); - } - } - } else { - const termCount = ufuzzy.split(throttledSearch).length; - const infoThresh = Infinity; - const oooLimit = termCount < 5 ? 4 : 0; - - const [, info, order] = ufuzzy.search(haystack, throttledSearch, oooLimit, infoThresh); - - if (info && order) { - for (let orderIndex = 0; orderIndex < order.length; orderIndex++) { - const actionIndex = order[orderIndex]; - const score = order.length - orderIndex; - const action = throttledFiltered[info.idx[actionIndex]]; - results.push({ score, action }); - } - } - } + // Convert indices back to Match objects with proper scoring + const results: Match[] = matchingIndices.map((index, order) => ({ + action: throttledFiltered[index], + score: matchingIndices.length - order, // Higher score for better ranked matches + })); return results; - }, [throttledFiltered, throttledSearch, ufuzzy]); -} - -const FUZZY_SEARCH_LIMIT = 25; - -function useUfuzzy(): uFuzzy { - const ref = React.useRef(); - - if (!ref.current) { - ref.current = new uFuzzy({ - // See https://github.com/leeoniya/uFuzzy#options - intraMode: 0, // don't allow for typos/extra letters - intraIns: 0, // require each term in the search to appear exactly - }); - } - - return ref.current; + }, [throttledFiltered, throttledSearch]); } diff --git a/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingEditor.tsx b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingEditor.tsx index bab710c70c2..8d0a0614778 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingEditor.tsx +++ b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingEditor.tsx @@ -1,35 +1,63 @@ +import { v4 as uuidv4 } from 'uuid'; + import { t } from '@grafana/i18n'; import { Icon, Stack, Tooltip } from '@grafana/ui'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; import { ConditionalRendering } from './ConditionalRendering'; +import { ConditionalRenderingGroup } from './ConditionalRenderingGroup'; + +let placeholderConditionalRendering: ConditionalRendering | undefined; +function getPlaceholderConditionalRendering(): ConditionalRendering { + if (!placeholderConditionalRendering) { + placeholderConditionalRendering = new ConditionalRendering({ + rootGroup: new ConditionalRenderingGroup({ + visibility: 'show', + condition: 'and', + value: [], + }), + }); + } + return placeholderConditionalRendering; +} export function useConditionalRenderingEditor( - conditionalRendering?: ConditionalRendering -): OptionsPaneCategoryDescriptor | null { - if (!conditionalRendering) { - return null; - } - + conditionalRendering?: ConditionalRendering, + disabledText?: string +): OptionsPaneCategoryDescriptor { const title = t('dashboard.conditional-rendering.root.title', 'Show / hide rules'); + const conditionalRenderingToRender = conditionalRendering ?? getPlaceholderConditionalRendering(); + return new OptionsPaneCategoryDescriptor({ title, + disabledText: conditionalRendering + ? undefined + : (disabledText ?? + t( + 'dashboard.conditional-rendering.editor.unsupported-item-type', + 'Conditional rendering not supported for this item type' + )), id: 'conditional-rendering-options', renderTitle: () => (
{title}
- - - + {conditionalRendering ? ( + + + + ) : ( + + )}
), isOpenDefault: true, }).addItem( new OptionsPaneItemDescriptor({ title, - render: () => , + id: uuidv4(), + render: () => , }) ); } diff --git a/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingGroup.tsx b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingGroup.tsx index 8ebba87cab3..59fdccbd190 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingGroup.tsx +++ b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingGroup.tsx @@ -70,15 +70,17 @@ export class ConditionalRenderingGroup extends ConditionalRenderingBase) { const { value } = model.useState(); + const [actualValue, setActualValue] = useState(value.value); + + useEffect(() => { + setActualValue(value.value); + }, [value.value]); + const variables = useMemo(() => sceneGraph.getVariables(model), [model]); - const variableNames: Array> = useMemo( + const variableNames: ComboboxOption[] = useMemo( () => variables.state.variables.map((v) => ({ value: v.state.name, label: v.state.label ?? v.state.name })), [variables.state.variables] ); @@ -109,10 +165,31 @@ function ConditionalRenderingVariableRenderer({ model }: SceneComponentProps { + if (value.operator === '=~' || value.operator === '!~') { + try { + new RegExp(actualValue); + return ''; + } catch (err) { + return t('dashboard.conditional-rendering.conditions.variable.error.invalid-regex', 'Invalid regex'); + } + } + + return ''; + }, [actualValue, value.operator]); + const undoText = t('dashboard.edit-actions.edit-template-variable-rule', 'Change template variable rule'); return ( @@ -124,12 +201,14 @@ function ConditionalRenderingVariableRenderer({ model }: SceneComponentProps { - dashboardEditActions.edit({ - description: undoText, - source: model, - perform: () => model.setStateAndNotify({ value: { ...value, name: option.value } }), - undo: () => model.setStateAndNotify({ value: { ...value, name: value.name } }), - }); + if (option.value !== value.name) { + dashboardEditActions.edit({ + description: undoText, + source: model, + perform: () => model.setStateAndNotify({ value: { ...value, name: option.value } }), + undo: () => model.setStateAndNotify({ value: { ...value, name: value.name } }), + }); + } }} /> @@ -140,28 +219,38 @@ function ConditionalRenderingVariableRenderer({ model }: SceneComponentProps { - dashboardEditActions.edit({ - description: undoText, - source: model, - perform: () => model.setStateAndNotify({ value: { ...value, operator: option.value } }), - undo: () => model.setStateAndNotify({ value: { ...value, operator: value.operator } }), - }); + if (option.value !== value.operator) { + dashboardEditActions.edit({ + description: undoText, + source: model, + perform: () => model.setStateAndNotify({ value: { ...value, operator: option.value } }), + undo: () => model.setStateAndNotify({ value: { ...value, operator: value.operator } }), + }); + } }} /> - { - const eventValue = e.currentTarget.value; - dashboardEditActions.edit({ - description: undoText, - source: model, - perform: () => model.setStateAndNotify({ value: { ...value, value: eventValue } }), - undo: () => model.setStateAndNotify({ value: { ...value, value: value.value } }), - }); - }} - /> + + { + if (evt.currentTarget.value !== value.value) { + setActualValue(evt.currentTarget.value); + } + }} + onBlur={() => { + if (actualValue !== value.value) { + dashboardEditActions.edit({ + description: undoText, + source: model, + perform: () => model.setStateAndNotify({ value: { ...value, value: actualValue } }), + undo: () => model.setStateAndNotify({ value: { ...value, value: value.value } }), + }); + } + }} + /> + ); } diff --git a/public/app/features/dashboard-scene/conditional-rendering/types.ts b/public/app/features/dashboard-scene/conditional-rendering/types.ts index 6a1f94876fd..f07330967de 100644 --- a/public/app/features/dashboard-scene/conditional-rendering/types.ts +++ b/public/app/features/dashboard-scene/conditional-rendering/types.ts @@ -22,7 +22,7 @@ export type GroupConditionValue = ConditionalRenderingConditions[]; export type TimeRangeSizeConditionValue = string; -export type VariableConditionValueOperator = '=' | '!='; +export type VariableConditionValueOperator = '=' | '!=' | '=~' | '!~'; export type VariableConditionValue = { name: string; diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx index 93e5ec503d8..730c16aa410 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx @@ -1,4 +1,5 @@ import { ReactNode, useMemo, useRef } from 'react'; +import { v4 as uuidv4 } from 'uuid'; import { Trans, t } from '@grafana/i18n'; import { SceneObject } from '@grafana/scenes'; @@ -38,8 +39,8 @@ export class DashboardEditableElement implements EditableDashboardElement { const { body } = dashboard.useState(); const dashboardOptions = useMemo(() => { - const dashboardTitleInputId = 'dashboard-title-input'; - const dashboardDescriptionInputId = 'dashboard-description-input'; + const dashboardTitleInputId = uuidv4(); + const dashboardDescriptionInputId = uuidv4(); const editPaneHeaderOptions = new OptionsPaneCategoryDescriptor({ title: '', id: 'dashboard-options' }) .addItem( new OptionsPaneItemDescriptor({ diff --git a/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx b/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx index 574028a75d8..c5c55bbaf61 100644 --- a/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx +++ b/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import { v4 as uuidv4 } from 'uuid'; import { Trans, t } from '@grafana/i18n'; import { locationService } from '@grafana/runtime'; @@ -48,13 +49,14 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc .addItem( new OptionsPaneItemDescriptor({ title: '', + id: uuidv4(), render: () => , }) ) .addItem( new OptionsPaneItemDescriptor({ title: t('dashboard.viz-panel.options.title-option', 'Title'), - id: 'PanelFrameTitle', + id: uuidv4(), value: panel.state.title, popularRank: 1, render: (descriptor) => ( @@ -65,7 +67,7 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc .addItem( new OptionsPaneItemDescriptor({ title: t('dashboard.viz-panel.options.description', 'Description'), - id: 'description-text-area', + id: uuidv4(), value: panel.state.description, render: (descriptor) => , }) @@ -73,7 +75,7 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc .addItem( new OptionsPaneItemDescriptor({ title: t('dashboard.viz-panel.options.transparent-background', 'Transparent background'), - id: 'transparent-background', + id: uuidv4(), render: (descriptor) => , }) ); @@ -133,9 +135,7 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc } } -type OpenPanelEditVizProps = { - panel: VizPanel; -}; +type OpenPanelEditVizProps = { panel: VizPanel }; const OpenPanelEditViz = ({ panel }: OpenPanelEditVizProps) => { return ( diff --git a/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx b/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx index 5605a573146..9b1d53a6cd9 100644 --- a/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx +++ b/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx @@ -73,18 +73,12 @@ jest.mock('@grafana/runtime', () => ({ }, config: { ...jest.requireActual('@grafana/runtime').config, - bootData: { - ...jest.requireActual('@grafana/runtime').config.bootData, - settings: { - ...jest.requireActual('@grafana/runtime').config.bootData.settings, - defaultDatasource: 'ds1', - datasources: { - ds1: { - name: 'ds-uid', - meta: { - id: 'grafana', - }, - }, + defaultDatasource: 'ds1', + datasources: { + ds1: { + name: 'ds-uid', + meta: { + id: 'grafana', }, }, }, diff --git a/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.test.tsx b/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.test.tsx deleted file mode 100644 index 49998fa30d9..00000000000 --- a/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.test.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { locationService } from '@grafana/runtime'; - -import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene'; -import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; - -import { onPanelInspectClose } from './PanelInspectDrawer'; - -describe('onPanelInspectClose', () => { - test('when on default home dashboard page', async () => { - locationService.push('/'); - - const { scene } = await buildTestScene({ - url: '', - slug: '', - }); - - onPanelInspectClose(scene); - expect(locationService.getLocation().pathname).toBe('/'); - }); - - test('when on custom home dashboard page with uid defined', async () => { - locationService.push('/'); - - const { scene } = await buildTestScene( - { - url: '', - slug: '', - }, - 'home-dash ' - ); - - onPanelInspectClose(scene); - expect(locationService.getLocation().pathname).toBe('/'); - }); - - test('when on new dashboard page', async () => { - locationService.push('/dashboard/new'); - const { scene } = await buildTestScene( - { - url: '', - slug: '', - }, - '' - ); - - onPanelInspectClose(scene); - expect(locationService.getLocation().pathname).toBe('/dashboard/new'); - }); - - test('when on a dashboard page', async () => { - const { scene } = await buildTestScene( - { - slug: 'dash-slug', - url: '/d/dash-uid/dash-slug', - }, - 'dash-uid' - ); - - onPanelInspectClose(scene); - expect(locationService.getLocation().pathname).toBe('/d/dash-uid/dash-slug'); - }); -}); - -async function buildTestScene(metaOverride?: DashboardSceneState['meta'], uid = 'dash-1') { - const scene = new DashboardScene({ - title: 'hello', - uid, - meta: { - canEdit: true, - ...metaOverride, - }, - body: DefaultGridLayoutManager.fromVizPanels([]), - }); - - return { scene }; -} diff --git a/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx b/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx index de6cb8ba6cd..bbcf72b0bcf 100644 --- a/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx +++ b/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx @@ -1,8 +1,4 @@ -import { useLocation } from 'react-router-dom-v5-compat'; - -import { locationUtil } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { locationService } from '@grafana/runtime'; import { SceneComponentProps, SceneObjectBase, @@ -16,8 +12,6 @@ import { getDataSourceWithInspector } from 'app/features/dashboard/components/In import { supportsDataQuery } from 'app/features/dashboard/components/PanelEditor/utils'; import { InspectTab } from 'app/features/inspector/types'; -import { DashboardScene } from '../scene/DashboardScene'; -import { getDashboardUrl } from '../utils/getDashboardUrl'; import { getDashboardSceneFor } from '../utils/utils'; import { HelpWizard } from './HelpWizard/HelpWizard'; @@ -30,6 +24,7 @@ import { SceneInspectTab } from './types'; interface PanelInspectDrawerState extends SceneObjectState { tabs?: SceneInspectTab[]; + currentTab: InspectTab; panelRef: SceneObjectRef; pluginNotLoaded?: boolean; canEdit?: boolean; @@ -93,26 +88,21 @@ export class PanelInspectDrawer extends SceneObjectBase } onClose = () => { - onPanelInspectClose(getDashboardSceneFor(this)); + getDashboardSceneFor(this).closeModal(); }; } function PanelInspectRenderer({ model }: SceneComponentProps) { - const { tabs, pluginNotLoaded, panelRef } = model.useState(); - const location = useLocation(); - - const queryParams = new URLSearchParams(location.search); + const { tabs, pluginNotLoaded, panelRef, currentTab } = model.useState(); if (!tabs) { return null; } - const urlTab = queryParams.get('inspectTab'); - const currentTab = tabs.find((tab) => tab.getTabValue() === urlTab) ?? tabs[0]; - const vizPanel = panelRef!.resolve(); + const activeTab = tabs.find((tab) => tab.getTabValue() === currentTab) ?? tabs[0]; - if (urlTab === InspectTab.Help) { + if (currentTab === InspectTab.Help) { return ; } @@ -128,8 +118,10 @@ function PanelInspectRenderer({ model }: SceneComponentProps { + model.setState({ currentTab: tab.getTabValue() }); + }} /> ); })} @@ -145,27 +137,7 @@ function PanelInspectRenderer({ model }: SceneComponentProps )} - {currentTab && currentTab.Component && } + {activeTab.Component && } ); } - -export function onPanelInspectClose(dashboard: DashboardScene) { - const meta = dashboard.state.meta; - // Checking for location here as well, otherwise down below isHomeDashboard will be set to true - // as it doesn't have uid neither slug nor url. - const isNew = !dashboard.state.uid && locationService.getLocation().pathname === '/dashboard/new'; - - locationService.push( - getDashboardUrl({ - uid: dashboard.state.uid, - slug: dashboard.state.meta.slug, - currentQueryParams: locationService.getLocation().search, - updateQuery: { - inspect: null, - inspectTab: null, - }, - isHomeDashboard: !meta.url && !meta.slug && !isNew, - }) - ); -} diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx index 016e72010bf..9a6b5194abb 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx +++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx @@ -212,7 +212,7 @@ describe('DashboardScenePage', () => { expect(await screen.findByText('Inspect: Panel B')).toBeInTheDocument(); - act(() => locationService.partial({ inspect: null })); + await userEvent.click(screen.getByTestId(selectors.components.Drawer.General.close)); expect(screen.queryByText('Inspect: Panel B')).not.toBeInTheDocument(); }); diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx index ef1ec38b653..d7eb249f1c7 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx +++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx @@ -11,6 +11,7 @@ import PageLoader from 'app/core/components/PageLoader/PageLoader'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { DashboardPageError } from 'app/features/dashboard/containers/DashboardPageError'; import { DashboardPageRouteParams, DashboardPageRouteSearchParams } from 'app/features/dashboard/containers/types'; +import { getDashboardSceneProfiler } from 'app/features/dashboard/services/DashboardProfiler'; import { DashboardRoutes } from 'app/types/dashboard'; import { DashboardPrompt } from '../saving/DashboardPrompt'; @@ -48,6 +49,7 @@ export function DashboardScenePage({ route, queryParams, location }: Props) { } return () => { + getDashboardSceneProfiler().cancelProfile(); preserveDashboardSceneStateInLocalStorage(locationService.getSearch(), uid); stateManager.clearState(); stateManager.resetActiveManager(); diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts index a6bab169cc6..0911ba9b6f2 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts @@ -36,40 +36,34 @@ jest.mock('@grafana/runtime', () => { featureToggles: { ...original.config.featureToggles, dashboardNewLayouts: false, // Default value + reloadDashboardsOnParamsChange: false, // Default value }, - - bootData: { - ...original.config.bootData, - settings: { - ...original.config.bootData.settings, - datasources: { - 'gdev-testdata': { - id: 7, - uid: 'abc', - type: 'grafana-testdata-datasource', - name: 'gdev-testdata', - meta: { - id: 'grafana-testdata-datasource', - type: 'datasource', - name: 'TestData', - aliasIDs: ['testdata'], - }, - }, - '-- Grafana --': { - id: -1, - uid: 'grafana', - type: 'datasource', - name: '-- Grafana --', - meta: { - id: 'grafana', - type: 'datasource', - name: '-- Grafana --', - }, - }, + datasources: { + 'gdev-testdata': { + id: 7, + uid: 'abc', + type: 'grafana-testdata-datasource', + name: 'gdev-testdata', + meta: { + id: 'grafana-testdata-datasource', + type: 'datasource', + name: 'TestData', + aliasIDs: ['testdata'], + }, + }, + '-- Grafana --': { + id: -1, + uid: 'grafana', + type: 'datasource', + name: '-- Grafana --', + meta: { + id: 'grafana', + type: 'datasource', + name: '-- Grafana --', }, - defaultDatasource: 'gdev-testdata', }, }, + defaultDatasource: 'gdev-testdata', }, }; }); @@ -122,11 +116,46 @@ const mockDashboardLoader = { // Set the mock loader setDashboardLoaderSrv(mockDashboardLoader as unknown as DashboardLoaderSrv); +// Test helpers for query parameter processing tests +const mockTimeAndLocation = (mockNow: Date, queryParams: Record) => { + const originalDateNow = Date.now; + Date.now = jest.fn(() => mockNow.getTime()); + + const mockGetSearch = jest.fn().mockReturnValue(new URLSearchParams()); + const mockGetSearchObject = jest.fn().mockReturnValue(queryParams); + + locationService.getSearch = mockGetSearch; + locationService.getSearchObject = mockGetSearchObject; + + return () => { + Date.now = originalDateNow; + }; +}; + +const withFeatureToggle = (enabled: boolean, testFn: () => Promise) => async () => { + const originalValue = config.featureToggles.reloadDashboardsOnParamsChange; + config.featureToggles.reloadDashboardsOnParamsChange = enabled; + + try { + await testFn(); + } finally { + config.featureToggles.reloadDashboardsOnParamsChange = originalValue; + } +}; + +// Test constants +const MOCK_DASHBOARD = { dashboard: { uid: 'fake-dash', editable: true }, meta: {} }; +const MOCK_NOW = new Date('2023-10-01T12:00:00.000Z'); + // Reset the mock between tests beforeEach(() => { jest.clearAllMocks(); mockDashboardLoader.loadDashboard.mockReset(); mockDashboardLoader.loadSnapshot.mockReset(); + + // Reset locationService mocks + locationService.getSearch = jest.fn().mockReturnValue(new URLSearchParams()); + locationService.getSearchObject = jest.fn().mockReturnValue({}); }); describe('DashboardScenePageStateManager v1', () => { @@ -152,6 +181,109 @@ describe('DashboardScenePageStateManager v1', () => { expect(loadDashboardMock.mock.calls.length).toBe(1); }); + describe('reloadDashboardsOnParamsChange feature toggle', () => { + it( + 'should process query params when enabled', + withFeatureToggle(true, async () => { + const cleanup = mockTimeAndLocation(MOCK_NOW, { + from: 'now-1h', + to: 'now', + timezone: 'UTC', + extraParam: 'shouldBeRemoved', + }); + + const expectedFromISO = '2023-10-01T06:00:00.000Z'; // now-1h (calculated by dateMath) + const expectedToISO = '2023-10-01T12:00:00.000Z'; // now + + const loadDashboardMock = setupLoadDashboardMock(MOCK_DASHBOARD); + + const loader = new DashboardScenePageStateManager({}); + await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); + + // Verify that loadDashboard was called with processed query params + expect(loadDashboardMock).toHaveBeenCalledWith( + 'db', + '', + 'fake-dash', + expect.objectContaining({ + from: expectedFromISO, + to: expectedToISO, + }) + ); + + // Verify that extra parameters were filtered out (including timezone) + const callArgs = loadDashboardMock.mock.calls[0][3]; + expect(callArgs).not.toHaveProperty('extraParam'); + expect(callArgs).not.toHaveProperty('timezone'); // timezone is filtered out by design + + cleanup(); + }) + ); + + it( + 'should pass undefined query params when disabled', + withFeatureToggle(false, async () => { + const loadDashboardMock = setupLoadDashboardMock(MOCK_DASHBOARD); + + const loader = new DashboardScenePageStateManager({}); + await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); + + // Verify that loadDashboard was called without query params (3 parameters only) + expect(loadDashboardMock).toHaveBeenCalledWith('db', '', 'fake-dash'); + }) + ); + + it( + 'should filter parameters correctly when enabled', + withFeatureToggle(true, async () => { + const cleanup = mockTimeAndLocation(MOCK_NOW, { + from: 'now-6h', + to: 'now', + 'var-server': 'web-01', + 'var-env': 'production', + scopes: 'scope1,scope2', + version: '2', + refresh: '5s', // should be filtered out + theme: 'dark', // should be filtered out + kiosk: 'true', // should be filtered out + }); + + const expectedFromISO = '2023-10-01T06:00:00.000Z'; // now-6h + const expectedToISO = '2023-10-01T12:00:00.000Z'; // now + + const loadDashboardMock = setupLoadDashboardMock(MOCK_DASHBOARD); + + const loader = new DashboardScenePageStateManager({}); + await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); + + const callArgs = loadDashboardMock.mock.calls[0][3]; + + // Should keep allowed parameters with specific expected values + expect(callArgs).toEqual( + expect.objectContaining({ + from: expectedFromISO, + to: expectedToISO, + 'var-server': 'web-01', + 'var-env': 'production', + scopes: 'scope1,scope2', + version: '2', + }) + ); + + // Should filter out disallowed parameters + expect(callArgs).toEqual( + expect.not.objectContaining({ + refresh: expect.anything(), + theme: expect.anything(), + kiosk: expect.anything(), + }) + ); + + cleanup(); + }) + ); + }); + it("should error when the dashboard doesn't exist", async () => { setupLoadDashboardMockReject({ status: 404, diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 19ddab65c0a..18b4098fa9c 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -36,7 +36,7 @@ import { transformSaveModelSchemaV2ToScene } from '../serialization/transformSav import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { restoreDashboardStateFromLocalStorage } from '../utils/dashboardSessionState'; -import { updateNavModel } from './utils'; +import { processQueryParamsForDashboardLoad, updateNavModel } from './utils'; export interface LoadError { status?: number; @@ -447,7 +447,14 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag return await dashboardLoaderSrv.loadDashboard('public', '', uid); } default: - rsp = await dashboardLoaderSrv.loadDashboard(type || 'db', slug || '', uid); + // If reloadDashboardsOnParamsChange is on, we need to process query params for dashboard load + // Since the scene is not yet there, we need to process whatever came through URL + if (config.featureToggles.reloadDashboardsOnParamsChange) { + const queryParamsObject = processQueryParamsForDashboardLoad(); + rsp = await dashboardLoaderSrv.loadDashboard(type || 'db', slug || '', uid, queryParamsObject); + } else { + rsp = await dashboardLoaderSrv.loadDashboard(type || 'db', slug || '', uid); + } if (route === DashboardRoutes.Embedded) { rsp.meta.isEmbedded = true; diff --git a/public/app/features/dashboard-scene/pages/utils.ts b/public/app/features/dashboard-scene/pages/utils.ts index 2e06429589f..d540e8fbdb5 100644 --- a/public/app/features/dashboard-scene/pages/utils.ts +++ b/public/app/features/dashboard-scene/pages/utils.ts @@ -1,3 +1,5 @@ +import { UrlQueryMap, getTimeZone, getDefaultTimeRange, dateMath } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; import { updateNavIndex } from 'app/core/actions'; import { backendSrv } from 'app/core/services/backend_srv'; import { buildNavModel } from 'app/features/folders/state/navModel'; @@ -11,3 +13,49 @@ export async function updateNavModel(folderUid: string) { console.warn('Error fetching parent folder', folderUid, 'for dashboard', err); } } + +/** + * Processes query parameters for dashboard loading, normalizing time range and filtering allowed parameters + */ +export function processQueryParamsForDashboardLoad(): UrlQueryMap { + const queryParams = locationService.getSearch(); + const queryParamsObject = locationService.getSearchObject(); + + queryParamsObject.timezone = queryParams.get('timezone') ?? undefined; + const now = Date.now(); + const timeZone = getTimeZone({ + timeZone: queryParams.get('timezone') ?? undefined, + }); + const defaultTimeRange = getDefaultTimeRange(); + + const fromQP = queryParams.get('from'); + const toQP = queryParams.get('to'); + + const to = toQP + ? dateMath.toDateTime(toQP, { + roundUp: true, + timezone: timeZone, + now: now, + }) + : undefined; + + const from = fromQP + ? dateMath.toDateTime(fromQP, { + roundUp: false, + timezone: timeZone, + now: now, + }) + : undefined; + + queryParamsObject.from = from?.toISOString() ?? defaultTimeRange.from.toISOString(); + queryParamsObject.to = to?.toISOString() ?? defaultTimeRange.to.toISOString(); + + // Remove all properties that are not from, to, scopes, version or start with var- + Object.keys(queryParamsObject).forEach((key) => { + if (key !== 'from' && key !== 'to' && key !== 'scopes' && key !== 'version' && !key.startsWith('var-')) { + delete queryParamsObject[key]; + } + }); + + return queryParamsObject; +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx index 241b37165b5..e3cb5a4f4b1 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx @@ -18,12 +18,12 @@ import { } from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test'; import { selectors } from '@grafana/e2e-selectors'; -import { config, locationService } from '@grafana/runtime'; +import { config } from '@grafana/runtime'; import { PANEL_EDIT_LAST_USED_DATASOURCE } from 'app/features/dashboard/utils/dashboard'; -import { InspectTab } from 'app/features/inspector/types'; import { SHARED_DASHBOARD_QUERY, DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/constants'; import { DashboardDataDTO } from 'app/types/dashboard'; +import { PanelInspectDrawer } from '../../inspect/PanelInspectDrawer'; import { PanelTimeRange, PanelTimeRangeState } from '../../scene/PanelTimeRange'; import { transformSaveModelToScene } from '../../serialization/transformSaveModelToScene'; import { findVizPanelByKey } from '../../utils/utils'; @@ -241,6 +241,7 @@ jest.mock('@grafana/runtime', () => ({ // if datasource is not found, return default instance settings return instance1SettingsMock; }, + getList: () => [], }), config: { ...jest.requireActual('@grafana/runtime').config, @@ -603,12 +604,10 @@ describe('PanelDataQueriesTab', () => { describe('query inspection', () => { it('allows query inspection from the tab', async () => { - const { queriesTab } = await setupScene('panel-1'); + const { queriesTab, scene } = await setupScene('panel-1'); queriesTab.onOpenInspector(); - const params = locationService.getSearchObject(); - expect(params.inspect).toBe('1'); - expect(params.inspectTab).toBe(InspectTab.Query); + expect(scene.state.overlay).toBeInstanceOf(PanelInspectDrawer); }); }); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx index 668b8612851..4295ddb38d4 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx @@ -1,7 +1,7 @@ import { CoreApp, DataSourceApi, DataSourceInstanceSettings, getDataSourceRef } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { t, Trans } from '@grafana/i18n'; -import { config, getDataSourceSrv, locationService } from '@grafana/runtime'; +import { config, getDataSourceSrv } from '@grafana/runtime'; import { SceneObjectBase, SceneComponentProps, @@ -21,6 +21,7 @@ import { dataSource as expressionDatasource } from 'app/features/expressions/Exp import { ExpressionTypeDropdown } from 'app/features/expressions/components/ExpressionTypeDropdown'; import { ExpressionQueryType } from 'app/features/expressions/types'; import { getDefaults } from 'app/features/expressions/utils/expressionTypes'; +import { InspectTab } from 'app/features/inspector/types'; import { GroupActionComponents } from 'app/features/query/components/QueryActionComponent'; import { QueryEditorRows } from 'app/features/query/components/QueryEditorRows'; import { QueryGroupTopSection } from 'app/features/query/components/QueryGroup'; @@ -32,8 +33,9 @@ import { MIXED_DATASOURCE_NAME } from '../../../../plugins/datasource/mixed/Mixe import { useQueryLibraryContext } from '../../../explore/QueryLibrary/QueryLibraryContext'; import { ExpressionDatasourceUID } from '../../../expressions/types'; import { getDatasourceSrv } from '../../../plugins/datasource_srv'; +import { PanelInspectDrawer } from '../../inspect/PanelInspectDrawer'; import { PanelTimeRange } from '../../scene/PanelTimeRange'; -import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor } from '../../utils/utils'; +import { getDashboardSceneFor, getQueryRunnerFor } from '../../utils/utils'; import { getUpdatedHoverHeader } from '../getPanelFrameOptions'; import { PanelDataPaneTab, TabId, PanelDataTabHeaderProps } from './types'; @@ -171,10 +173,8 @@ export class PanelDataQueriesTab extends SceneObjectBase { - const panel = this.state.panelRef.resolve(); - const panelId = getPanelIdForVizPanel(panel); - - locationService.partial({ inspect: panelId, inspectTab: 'query' }); + const dashboard = getDashboardSceneFor(this); + dashboard.showModal(new PanelInspectDrawer({ panelRef: this.state.panelRef, currentTab: InspectTab.Query })); }; public onChangeDataSource = async (newSettings: DataSourceInstanceSettings, defaultQueries?: SceneDataQuery[]) => { @@ -394,8 +394,12 @@ export function PanelDataQueriesTabRendered({ model }: SceneComponentProps - openQueryLibraryDrawer(getDatasourceNames(datasource, queries), onSelectQueryFromLibrary, { - context: CoreApp.PanelEditor, + openQueryLibraryDrawer({ + datasourceFilters: getDatasourceNames(datasource, queries), + onSelectQuery: onSelectQueryFromLibrary, + options: { + context: CoreApp.PanelEditor, + }, }) } variant="secondary" diff --git a/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx b/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx index 366fcdc81e4..a46c113c7c9 100644 --- a/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx +++ b/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { v4 as uuidv4 } from 'uuid'; import { CoreApp } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; @@ -37,7 +38,7 @@ export function getPanelFrameOptions(panel: VizPanel): OptionsPaneCategoryDescri .addItem( new OptionsPaneItemDescriptor({ title: t('dashboard-scene.get-panel-frame-options.title.title', 'Title'), - id: 'PanelFrameTitle', + id: uuidv4(), value: panel.state.title, popularRank: 1, render: function renderTitle(descriptor) { @@ -55,7 +56,7 @@ export function getPanelFrameOptions(panel: VizPanel): OptionsPaneCategoryDescri .addItem( new OptionsPaneItemDescriptor({ title: t('dashboard-scene.get-panel-frame-options.title.description', 'Description'), - id: 'description-text-area', + id: uuidv4(), value: panel.state.description, render: function renderDescription(descriptor) { return ; @@ -71,7 +72,7 @@ export function getPanelFrameOptions(panel: VizPanel): OptionsPaneCategoryDescri .addItem( new OptionsPaneItemDescriptor({ title: t('dashboard-scene.get-panel-frame-options.title.transparent-background', 'Transparent background'), - id: 'transparent-background', + id: uuidv4(), render: function renderTransparent(descriptor) { return ; }, @@ -86,6 +87,7 @@ export function getPanelFrameOptions(panel: VizPanel): OptionsPaneCategoryDescri }).addItem( new OptionsPaneItemDescriptor({ title: t('dashboard-scene.get-panel-frame-options.title.panel-links', 'Panel links'), + id: uuidv4(), render: () => , }) ) diff --git a/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.tsx b/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.tsx index 260c491bb57..d69c9b6984c 100644 --- a/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.tsx +++ b/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.tsx @@ -6,11 +6,12 @@ import { AppEvents, locationUtil } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { getAppEvents, locationService } from '@grafana/runtime'; import { Dashboard } from '@grafana/schema'; -import { Alert, Button, Field, Input, Stack, TextArea } from '@grafana/ui'; +import { Button, Field, Input, Stack, TextArea } from '@grafana/ui'; import { RepositoryView } from 'app/api/clients/provisioning/v0alpha1'; import { FolderPicker } from 'app/core/components/Select/FolderPicker'; import kbn from 'app/core/utils/kbn'; import { Resource } from 'app/features/apiserver/types'; +import { RepoInvalidStateBanner } from 'app/features/browse-dashboards/components/BulkActions/RepoInvalidStateBanner'; import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv'; import { PROVISIONING_URL } from 'app/features/provisioning/constants'; import { useCreateOrUpdateRepositoryFile } from 'app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile'; @@ -60,7 +61,7 @@ export function SaveProvisionedDashboardForm({ reset(defaultValues); }, [defaultValues, reset]); - const onRequestError = (error: unknown, info: ProvisionedOperationInfo) => { + const onRequestError = (error: unknown) => { appEvents.publish({ type: AppEvents.alertError.name, payload: [t('dashboard-scene.save-provisioned-dashboard-form.api-error', 'Error saving dashboard'), error], @@ -80,6 +81,7 @@ export function SaveProvisionedDashboardForm({ }; const onWriteSuccess = (_: ProvisionedOperationInfo, upsert: Resource) => { + handleDismiss(); if (isNew && upsert?.metadata.name) { handleNewDashboard(upsert); } else { @@ -91,6 +93,7 @@ export function SaveProvisionedDashboardForm({ }; const onBranchSuccess = (ref: string, path: string, info: ProvisionedOperationInfo, upsert: Resource) => { + handleDismiss(); if (isNew && upsert?.metadata?.name) { handleNewDashboard(upsert); } else { @@ -104,7 +107,7 @@ export function SaveProvisionedDashboardForm({ } }; - const onDismiss = () => { + const handleDismiss = () => { dashboard.setState({ isDirty: false }); panelEditor?.onDiscard(); drawer.onClose(); @@ -118,7 +121,6 @@ export function SaveProvisionedDashboardForm({ onBranchSuccess: ({ ref, path }, info, resource) => onBranchSuccess(ref, path, info, resource), onWriteSuccess, onError: onRequestError, - onDismiss, }, }); @@ -157,16 +159,11 @@ export function SaveProvisionedDashboardForm({
{readOnly && ( - - - If you have direct access to the target, copy the JSON and paste it there. - - + )} {isNew && ( diff --git a/public/app/features/dashboard-scene/saving/provisioned/hooks.ts b/public/app/features/dashboard-scene/saving/provisioned/hooks.ts index 8edec0ec7a2..1b87fc88be3 100644 --- a/public/app/features/dashboard-scene/saving/provisioned/hooks.ts +++ b/public/app/features/dashboard-scene/saving/provisioned/hooks.ts @@ -4,6 +4,7 @@ import { RepositoryView } from 'app/api/clients/provisioning/v0alpha1'; import { useUrlParams } from 'app/core/navigation/hooks'; import { AnnoKeyManagerIdentity, AnnoKeyManagerKind, AnnoKeySourcePath } from 'app/features/apiserver/types'; import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView'; +import { getIsReadOnlyRepo } from 'app/features/provisioning/utils/repository'; import { DashboardMeta } from 'app/types/dashboard'; import { DashboardScene } from '../../scene/DashboardScene'; @@ -110,8 +111,6 @@ export function useProvisionedDashboardData(dashboard: DashboardScene): Provisio const { values, isNew, repository } = defaultValuesResult; const workflowOptions = getWorkflowOptions(repository, loadedFromRef); - const readOnly = !repository?.workflows?.length; - return { isReady: true, defaultValues: values, @@ -119,7 +118,7 @@ export function useProvisionedDashboardData(dashboard: DashboardScene): Provisio loadedFromRef, workflowOptions, isNew, - readOnly, + readOnly: getIsReadOnlyRepo(repository), isLoading, setIsLoading, }; diff --git a/public/app/features/dashboard-scene/saving/useSaveDashboard.ts b/public/app/features/dashboard-scene/saving/useSaveDashboard.ts index c5e2be5151b..4d99b26648c 100644 --- a/public/app/features/dashboard-scene/saving/useSaveDashboard.ts +++ b/public/app/features/dashboard-scene/saving/useSaveDashboard.ts @@ -71,7 +71,7 @@ export function useSaveDashboard(isCopy = false) { url: resultData.url, }); } else { - reportInteraction(`grafana_dashboard_${resultData.uid ? 'saved' : 'created'}`, { + reportInteraction(`grafana_dashboard_${options.isNew ? 'created' : 'saved'}`, { name: saveModel.title, url: resultData.url, }); diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 48581e303ae..0dc7501cbc1 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -129,8 +129,6 @@ export interface DashboardSceneState extends SceneObjectState { meta: Omit; /** Version of the dashboard */ version?: number; - /** Panel to inspect */ - inspectPanelKey?: string; /** Panel to view in fullscreen */ viewPanelScene?: ViewPanelScene; /** Edit view */ diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts index 9dbd47cf919..4bad8552455 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts +++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts @@ -12,18 +12,6 @@ import { DashboardRepeatsProcessedEvent } from './types/DashboardRepeatsProcesse describe('DashboardSceneUrlSync', () => { describe('Given a standard scene', () => { - it('Should set inspectPanelKey when url has inspect key', () => { - const scene = buildTestScene(); - scene.urlSync?.updateFromUrl({ inspect: '2' }); - expect(scene.state.inspectPanelKey).toBe('2'); - }); - - it('Should handle inspect key that is not found', () => { - const scene = buildTestScene(); - scene.urlSync?.updateFromUrl({ inspect: '12321' }); - expect(scene.state.inspectPanelKey).toBe(undefined); - }); - it('Should set viewPanelKey when url has viewPanel', () => { const scene = buildTestScene(); scene.urlSync?.updateFromUrl({ viewPanel: '2' }); diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts index e19a7331d5c..b145635bb39 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts +++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts @@ -7,7 +7,6 @@ import appEvents from 'app/core/app_events'; import { contextSrv } from 'app/core/core'; import { KioskMode } from 'app/types/dashboard'; -import { PanelInspectDrawer } from '../inspect/PanelInspectDrawer'; import { buildPanelEditScene } from '../panel-edit/PanelEditor'; import { createDashboardEditViewFor } from '../settings/utils'; import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer'; @@ -23,7 +22,6 @@ import { DashboardRepeatsProcessedEvent } from './types/DashboardRepeatsProcesse export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { private _viewEventSub?: Unsubscribable; - private _inspectEventSub?: Unsubscribable; constructor(private _scene: DashboardScene) {} @@ -35,7 +33,6 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { const state = this._scene.state; return { - inspect: state.inspectPanelKey, autofitpanels: this.getAutoFitPanels(), viewPanel: state.viewPanelScene?.getUrlKey(), editview: state.editview?.getUrlKey(), @@ -55,7 +52,7 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { } updateFromUrl(values: SceneObjectUrlValues): void { - const { inspectPanelKey, viewPanelScene, isEditing, editPanel, shareView } = this._scene.state; + const { viewPanelScene, isEditing, editPanel, shareView } = this._scene.state; const update: Partial = {}; if (typeof values.editview === 'string' && this._scene.canEditDashboard()) { @@ -75,30 +72,6 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { update.editview = undefined; } - // Handle inspect object state - if (typeof values.inspect === 'string') { - let panel = findVizPanelByKey(this._scene, values.inspect); - if (!panel) { - // If we are trying to view a repeat clone that can't be found it might be that the repeats have not been processed yet - // Here we check if the key contains the clone key so we force the repeat processing - // It doesn't matter if the element or the ancestors are clones or not, just that the key contains the clone key - if (containsCloneKey(values.inspect)) { - this._handleInspectRepeatClone(values.inspect); - return; - } - - appEvents.emit(AppEvents.alertError, ['Panel not found']); - locationService.partial({ inspect: null }); - return; - } - - update.inspectPanelKey = values.inspect; - update.overlay = new PanelInspectDrawer({ panelRef: panel.getRef() }); - } else if (inspectPanelKey) { - update.inspectPanelKey = undefined; - update.overlay = undefined; - } - // Handle view panel state if (typeof values.viewPanel === 'string') { const panel = findVizPanelByKey(this._scene, values.viewPanel); @@ -186,21 +159,6 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { } } - private _handleInspectRepeatClone(inspect: string) { - if (!this._inspectEventSub) { - this._inspectEventSub = this._scene.subscribeToEvent(DashboardRepeatsProcessedEvent, () => { - const panel = findVizPanelByKey(this._scene, inspect); - if (panel) { - this._inspectEventSub?.unsubscribe(); - this._scene.setState({ - inspectPanelKey: inspect, - overlay: new PanelInspectDrawer({ panelRef: panel.getRef() }), - }); - } - }); - } - } - private _handleViewRepeatClone(viewPanel: string) { if (!this._viewEventSub) { this._viewEventSub = this._scene.subscribeToEvent(DashboardRepeatsProcessedEvent, () => { diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx index 00dbd0943a3..47fec9cf54a 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx @@ -5,7 +5,17 @@ import { GrafanaTheme2, store } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { config, locationService } from '@grafana/runtime'; -import { Button, ButtonGroup, Dropdown, Icon, Menu, ToolbarButton, ToolbarButtonRow, useStyles2 } from '@grafana/ui'; +import { + Badge, + Button, + ButtonGroup, + Dropdown, + Icon, + Menu, + ToolbarButton, + ToolbarButtonRow, + useStyles2, +} from '@grafana/ui'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator'; import grafanaConfig from 'app/core/config'; @@ -13,6 +23,8 @@ import { LS_PANEL_COPY_KEY } from 'app/core/constants'; import { contextSrv } from 'app/core/core'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; +import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView'; +import { getReadOnlyTooltipText } from 'app/features/provisioning/utils/repository'; import { useSelector } from 'app/types/store'; import { shareDashboardType } from '../../dashboard/components/ShareModal/utils'; @@ -75,6 +87,10 @@ export function ToolbarActions({ dashboard }: Props) { const isEditingAndShowingDashboard = isEditing && isShowingDashboard; const folderRepo = useSelector((state) => selectFolderRepository()(state, meta.folderUid)); const isManaged = Boolean(dashboard.isManagedRepository() || folderRepo); + // Get the repository for the dashboard's folder + const { isReadOnlyRepo, repoType } = useGetResourceRepositoryView({ + folderName: meta.folderUid, + }); // Internal only; // allows viewer editing without ability to save @@ -118,6 +134,22 @@ export function ToolbarActions({ dashboard }: Props) { }, }); + if (isReadOnlyRepo) { + toolbarActions.push({ + group: 'icon-actions', + condition: true, + render: () => { + return ( + + ); + }, + }); + } + if (dashboard.isManaged() && meta.canEdit) { toolbarActions.push({ group: 'icon-actions', @@ -325,12 +357,17 @@ export function ToolbarActions({ dashboard }: Props) { onClick={() => { dashboard.onEnterEditMode(); }} - tooltip={t('dashboard.toolbar.edit.tooltip', 'Enter edit mode')} + tooltip={ + isReadOnlyRepo + ? getReadOnlyTooltipText({ isLocal: repoType === 'local' }) + : t('dashboard.toolbar.edit.tooltip', 'Enter edit mode') + } key="edit" className={styles.buttonWithExtraMargin} variant={config.featureToggles.newDashboardSharingComponent ? 'secondary' : 'primary'} size="sm" data-testid={selectors.components.NavToolbar.editDashboard.editButton} + disabled={isReadOnlyRepo} > Edit diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx index 10191b32a58..8d841b7d1dc 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx @@ -90,8 +90,7 @@ describe('panelMenuBehavior', () => { expect(getExploreArgs.queries).toEqual([{ query: 'QueryA', refId: 'A' }]); expect(getExploreArgs.scopedVars?.__sceneObject?.value).toBe(panel); - // verify inspect url keeps url params and adds inspect= - expect(menu.state.items?.[4].href).toBe('/d/dash-1?from=now-5m&to=now&inspect=panel-12'); + expect(menu.state.items?.[4].text).toBe('Inspect'); expect(menu.state.items?.[4].subMenu).toBeDefined(); expect(menu.state.items?.[4].subMenu?.length).toBe(3); diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx index d10b2ab5b65..d02e94516dd 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx @@ -32,11 +32,12 @@ import { dispatch } from 'app/store/store'; import { AccessControlAction } from 'app/types/accessControl'; import { ShowConfirmModalEvent } from 'app/types/events'; +import { PanelInspectDrawer } from '../inspect/PanelInspectDrawer'; import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer'; import { ShareModal } from '../sharing/ShareModal'; import { isInCloneChain } from '../utils/clone'; import { DashboardInteractions } from '../utils/interactions'; -import { getEditPanelUrl, getInspectUrl, getViewPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders'; +import { getEditPanelUrl, getViewPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders'; import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor, isLibraryPanel } from '../utils/utils'; import { DashboardScene } from './DashboardScene'; @@ -290,7 +291,7 @@ export function panelMenuBehavior(menu: VizPanelMenu) { iconClassName: 'question-circle', onClick: (e: React.MouseEvent) => { e.preventDefault(); - onInspectPanel(panel, InspectTab.Help); + dashboard.showModal(new PanelInspectDrawer({ panelRef: panel.getRef(), currentTab: InspectTab.Help })); }, }); } @@ -404,20 +405,18 @@ function getInspectMenuItem( if (plugin && !plugin.meta.skipDataQuery) { inspectSubMenu.push({ text: t('panel.header-menu.inspect-data', `Data`), - href: getInspectUrl(panel, InspectTab.Data), onClick: (e) => { e.preventDefault(); - locationService.partial({ inspect: panel.state.key, inspectTab: InspectTab.Data }); + dashboard.showModal(new PanelInspectDrawer({ panelRef: panel.getRef(), currentTab: InspectTab.Data })); }, }); if (dashboard instanceof DashboardScene && dashboard.state.meta.canEdit) { inspectSubMenu.push({ text: t('panel.header-menu.query', `Query`), - href: getInspectUrl(panel, InspectTab.Query), onClick: (e) => { e.preventDefault(); - locationService.partial({ inspect: panel.state.key, inspectTab: InspectTab.Query }); + dashboard.showModal(new PanelInspectDrawer({ panelRef: panel.getRef(), currentTab: InspectTab.Query })); }, }); } @@ -425,10 +424,9 @@ function getInspectMenuItem( inspectSubMenu.push({ text: t('panel.header-menu.inspect-json', `Panel JSON`), - href: getInspectUrl(panel, InspectTab.JSON), onClick: (e) => { e.preventDefault(); - locationService.partial({ inspect: panel.state.key, inspectTab: InspectTab.JSON }); + dashboard.showModal(new PanelInspectDrawer({ panelRef: panel.getRef(), currentTab: InspectTab.JSON })); }, }); @@ -436,10 +434,9 @@ function getInspectMenuItem( text: t('panel.header-menu.inspect', `Inspect`), iconClassName: 'info-circle', shortcut: 'i', - href: getInspectUrl(panel), onClick: (e) => { if (!e.isDefaultPrevented()) { - locationService.partial({ inspect: panel.state.key, inspectTab: InspectTab.Data }); + dashboard.showModal(new PanelInspectDrawer({ panelRef: panel.getRef(), currentTab: InspectTab.Data })); } }, subMenu: inspectSubMenu.length > 0 ? inspectSubMenu : undefined, @@ -582,10 +579,3 @@ export function toggleVizPanelLegend(vizPanel: VizPanel): void { function hasLegendOptions(optionsWithLegend: unknown): optionsWithLegend is OptionsWithLegend { return optionsWithLegend != null && typeof optionsWithLegend === 'object' && 'legend' in optionsWithLegend; } - -const onInspectPanel = (vizPanel: VizPanel, tab?: InspectTab) => { - locationService.partial({ - inspect: vizPanel.state.key, - inspectTab: tab, - }); -}; diff --git a/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts b/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts index 0d7ad8f85a8..a4f2ef2e2fd 100644 --- a/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts +++ b/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts @@ -4,13 +4,15 @@ import { sceneGraph, VizPanel } from '@grafana/scenes'; import appEvents from 'app/core/app_events'; import { KeybindingSet } from 'app/core/services/KeybindingSet'; import { contextSrv } from 'app/core/services/context_srv'; +import { InspectTab } from 'app/features/inspector/types'; import { AccessControlAction } from 'app/types/accessControl'; import { shareDashboardType } from '../../dashboard/components/ShareModal/utils'; +import { PanelInspectDrawer } from '../inspect/PanelInspectDrawer'; import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer'; import { ShareModal } from '../sharing/ShareModal'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; -import { getEditPanelUrl, getInspectUrl, getViewPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders'; +import { getEditPanelUrl, getViewPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders'; import { getPanelIdForVizPanel } from '../utils/utils'; import { DashboardScene } from './DashboardScene'; @@ -111,15 +113,7 @@ export function setupKeyboardShortcuts(scene: DashboardScene) { keybindings.addBinding({ key: 'i', onTrigger: withFocusedPanel(scene, async (vizPanel: VizPanel) => { - if (scene.state.inspectPanelKey) { - locationService.push( - locationUtil.getUrlForPartial(locationService.getLocation(), { - inspect: undefined, - }) - ); - } else { - locationService.push(locationUtil.stripBaseFromUrl(getInspectUrl(vizPanel))); - } + scene.showModal(new PanelInspectDrawer({ panelRef: vizPanel.getRef(), currentTab: InspectTab.Data })); }), }); diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx index b08387e9b74..99bcff261e6 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx @@ -3,21 +3,18 @@ import React from 'react'; import { CustomVariable, - LocalValueVariable, MultiValueVariable, sceneGraph, SceneObjectBase, SceneObjectState, - SceneVariableSet, VariableDependencyConfig, VariableValueSingle, VizPanel, - VizPanelState, } from '@grafana/scenes'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { ConditionalRendering } from '../../conditional-rendering/ConditionalRendering'; -import { getCloneKey } from '../../utils/clone'; +import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone'; import { getMultiVariableValues } from '../../utils/utils'; import { scrollCanvasElementIntoView } from '../layouts-shared/scrollCanvasElementIntoView'; import { DashboardLayoutItem } from '../types/DashboardLayoutItem'; @@ -113,21 +110,19 @@ export class AutoGridItem extends SceneObjectBase implements const variableValues = values.length ? values : emptyVariablePlaceholderOption.values; const variableTexts = texts.length ? texts : emptyVariablePlaceholderOption.texts; + + // Loop through variable values and create repeats for (let index = 0; index < variableValues.length; index++) { - const cloneState: Partial = { - $variables: new SceneVariableSet({ - variables: [ - new LocalValueVariable({ - name: variable.state.name, - value: variableValues[index], - text: String(variableTexts[index]), - }), - ], - }), - key: getCloneKey(panelToRepeat.state.key!, index), - }; - const clone = panelToRepeat.clone(cloneState); - repeatedPanels.push(clone); + const isSource = index === 0; + const clone = isSource + ? panelToRepeat + : panelToRepeat.clone({ key: getCloneKey(panelToRepeat.state.key!, index) }); + + clone.setState({ $variables: getLocalVariableValueSet(variable, variableValues[index], variableTexts[index]) }); + + if (index > 0) { + repeatedPanels.push(clone); + } } this.setState({ repeatedPanels }); @@ -136,6 +131,10 @@ export class AutoGridItem extends SceneObjectBase implements this.publishEvent(new DashboardRepeatsProcessedEvent({ source: this }), true); } + public getPanelCount() { + return (this.state.repeatedPanels?.length ?? 0) + 1; + } + public setRepeatByVariable(variableName: string | undefined) { const stateUpdate: Partial = { variableName }; @@ -172,13 +171,6 @@ export class AutoGridItem extends SceneObjectBase implements if (!this.state.variableName) { return; } - - if ((this.state.repeatedPanels?.length ?? 0) > 1) { - this.state.body.setState({ - $variables: this.state.repeatedPanels![0].state.$variables?.clone(), - $data: this.state.repeatedPanels![0].state.$data?.clone(), - }); - } } public editingCompleted(withChanges: boolean) { diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemEditor.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemEditor.tsx index dc20ec7a8bd..93804685fcb 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemEditor.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemEditor.tsx @@ -1,3 +1,5 @@ +import { v4 as uuidv4 } from 'uuid'; + import { t } from '@grafana/i18n'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; @@ -16,7 +18,7 @@ export function getOptions(model: AutoGridItem): OptionsPaneCategoryDescriptor[] }).addItem( new OptionsPaneItemDescriptor({ title: t('dashboard.auto-grid.item-options.repeat.variable.title', 'Repeat by variable'), - id: 'repeat-by-variable-select', + id: uuidv4(), description: t( 'dashboard.auto-grid.item-options.repeat.variable.description', 'Repeat this panel for each value in the selected variable. This is not visible while in edit mode. You need to go back to dashboard and then update the variable or reload the dashboard.' diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx index aefaf3fabb8..91882937ee2 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx @@ -1,23 +1,81 @@ import { css, cx } from '@emotion/css'; +import { memo, useMemo } from 'react'; import { GrafanaTheme2 } from '@grafana/data/'; -import { SceneComponentProps } from '@grafana/scenes'; +import { LazyLoader, SceneComponentProps, VizPanel } from '@grafana/scenes'; import { useStyles2 } from '@grafana/ui'; import { useIsConditionallyHidden } from '../../conditional-rendering/useIsConditionallyHidden'; import { useDashboardState } from '../../utils/utils'; +import { getIsLazy } from '../layouts-shared/utils'; import { AutoGridItem } from './AutoGridItem'; import { DRAGGED_ITEM_HEIGHT, DRAGGED_ITEM_LEFT, DRAGGED_ITEM_TOP, DRAGGED_ITEM_WIDTH } from './const'; export function AutoGridItemRenderer({ model }: SceneComponentProps) { - const { body, repeatedPanels, key } = model.useState(); + const { body, repeatedPanels = [], key } = model.useState(); const { draggingKey } = model.getParentGrid().useState(); - const { isEditing } = useDashboardState(model); + const { isEditing, preload } = useDashboardState(model); const [isConditionallyHidden, conditionalRenderingClass, conditionalRenderingOverlay] = useIsConditionallyHidden(model); const styles = useStyles2(getStyles); + const isLazy = useMemo(() => getIsLazy(preload), [preload]); + + const Wrapper = useMemo( + () => + memo( + ({ + item, + addDndContainer, + isDragged, + isDragging, + isRepeat = false, + }: { + item: VizPanel; + addDndContainer: boolean; + isDragged: boolean; + isDragging: boolean; + isRepeat?: boolean; + }) => ( +
+ {isDragged &&
} + {isLazy ? ( + + + {conditionalRenderingOverlay} + + ) : ( +
+ + {conditionalRenderingOverlay} +
+ )} +
+ ) + ), + [conditionalRenderingClass, conditionalRenderingOverlay, isLazy, key, model.containerRef, styles] + ); + if (isConditionallyHidden && !isEditing) { return null; } @@ -25,37 +83,25 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps + {repeatedPanels.map((item) => ( -
- - {conditionalRenderingOverlay} -
+ ))} - ) : ( -
- {isDragged &&
} - -
- - {conditionalRenderingOverlay} -
-
); } const getStyles = (theme: GrafanaTheme2) => ({ - container: css({ - width: '100%', - height: '100%', - }), - wrapper: css({ - width: '100%', - height: '100%', - position: 'relative', - }), + wrapper: css({ width: '100%', height: '100%', position: 'relative' }), draggedWrapper: css({ position: 'absolute', zIndex: 1000, @@ -65,6 +111,9 @@ const getStyles = (theme: GrafanaTheme2) => ({ height: `var(${DRAGGED_ITEM_HEIGHT})`, opacity: 0.8, }), + draggedRepeatWrapper: css({ + visibility: 'hidden', + }), draggedPlaceholder: css({ width: '100%', height: '100%', diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutRenderer.tsx index 2a44564b536..c70a3ef5200 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutRenderer.tsx @@ -1,15 +1,13 @@ import { css, cx } from '@emotion/css'; -import { useMemo } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { LazyLoader, SceneComponentProps, sceneGraph } from '@grafana/scenes'; +import { SceneComponentProps, sceneGraph } from '@grafana/scenes'; import { useStyles2 } from '@grafana/ui'; import { useHasClonedParents } from '../../utils/clone'; import { useDashboardState } from '../../utils/utils'; import { CanvasGridAddActions } from '../layouts-shared/CanvasGridAddActions'; import { dashboardCanvasAddButtonHoverStyles } from '../layouts-shared/styles'; -import { getIsLazy } from '../layouts-shared/utils'; import { AutoGridLayout, AutoGridLayoutState } from './AutoGridLayout'; import { AutoGridLayoutManager } from './AutoGridLayoutManager'; @@ -18,12 +16,10 @@ export function AutoGridLayoutRenderer({ model }: SceneComponentProps getIsLazy(preload), [preload]); - if (isHidden || !layoutOrchestrator) { return null; } @@ -35,15 +31,9 @@ export function AutoGridLayoutRenderer({ model }: SceneComponentProps - {children.map((item) => - isLazy ? ( - - - - ) : ( - - ) - )} + {children.map((item) => ( + + ))} {showCanvasActions && }
); @@ -75,24 +65,6 @@ const getStyles = (theme: GrafanaTheme2, state: AutoGridLayoutState) => ({ // Show add action when hovering over the grid ...dashboardCanvasAddButtonHoverStyles, }), - containerFillScreen: css({ - flexGrow: 1, - }), - containerEditing: css({ - paddingBottom: theme.spacing(5), - position: 'relative', - }), - wrapper: css({ - display: 'grid', - position: 'relative', - width: '100%', - height: '100%', - }), - dragging: css({ - position: 'fixed', - top: 0, - left: 0, - zIndex: theme.zIndex.portal + 1, - pointerEvents: 'none', - }), + containerFillScreen: css({ flexGrow: 1 }), + containerEditing: css({ paddingBottom: theme.spacing(5), position: 'relative' }), }); diff --git a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx index 00458d505a9..2da0727000f 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx @@ -32,17 +32,17 @@ describe('PanelRepeaterGridItem', () => { activateFullSceneTree(scene); - expect(repeater.state.repeatedPanels?.length).toBe(5); + expect(repeater.state.repeatedPanels?.length).toBe(4); - const panel1 = repeater.state.repeatedPanels![0]; - const panel2 = repeater.state.repeatedPanels![1]; + const panel1 = repeater.state.body; + const panel2 = repeater.state.repeatedPanels![0]; // Panels should have scoped variables expect(panel1.state.$variables?.state.variables[0].getValue()).toBe('1'); expect(panel1.state.$variables?.state.variables[0].getValueText?.()).toBe('A'); expect(panel2.state.$variables?.state.variables[0].getValue()).toBe('2'); - expect(isInCloneChain(panel1.state.key!)).toBe(false); + expect(panel1.state.key).toBe('panel-1'); expect(isInCloneChain(panel2.state.key!)).toBe(true); }); @@ -55,7 +55,7 @@ describe('PanelRepeaterGridItem', () => { await new Promise((r) => setTimeout(r, 10)); - expect(repeater.state.repeatedPanels?.length).toBe(5); + expect(repeater.state.repeatedPanels?.length).toBe(4); }); it('Should pass isMulti/includeAll values if variable is multi variable and has them set', async () => { @@ -67,7 +67,7 @@ describe('PanelRepeaterGridItem', () => { await new Promise((r) => setTimeout(r, 10)); - expect(repeater.state.repeatedPanels?.length).toBe(5); + expect(repeater.state.repeatedPanels?.length).toBe(4); // LocalValueVariableState is not exposed, so we build this type casting const variableState = repeater.state.repeatedPanels![0].state.$variables?.state.variables[0].state as { @@ -79,18 +79,6 @@ describe('PanelRepeaterGridItem', () => { expect(variableState.includeAll).toBe(true); }); - it('Should display a panel when there are no options', async () => { - const { scene, repeater } = buildPanelRepeaterScene({ variableQueryTime: 1, numberOfOptions: 0 }); - - activateFullSceneTree(scene); - - expect(repeater.state.repeatedPanels?.length).toBe(0); - - await new Promise((r) => setTimeout(r, 100)); - - expect(repeater.state.repeatedPanels?.length).toBe(1); - }); - it('Should redo the repeat when editing panel and then returning to dashboard', async () => { const panel = new DashboardGridItem({ variableName: 'server', @@ -130,7 +118,7 @@ describe('PanelRepeaterGridItem', () => { await new Promise((r) => setTimeout(r, 10)); - expect(panel.state.repeatedPanels?.length).toBe(5); + expect(panel.state.repeatedPanels?.length).toBe(4); const vizPanel = panel.state.body as VizPanel; @@ -150,7 +138,7 @@ describe('PanelRepeaterGridItem', () => { await new Promise((r) => setTimeout(r, 10)); - expect(panel.state.repeatedPanels?.length).toBe(5); + expect(panel.state.repeatedPanels?.length).toBe(4); expect((panel.state.repeatedPanels![0] as VizPanel).state.title).toBe('Changed'); }); @@ -203,7 +191,7 @@ describe('PanelRepeaterGridItem', () => { await new Promise((r) => setTimeout(r, 10)); - expect(panel.state.repeatedPanels?.length).toBe(5); + expect(panel.state.repeatedPanels?.length).toBe(4); const vizPanel = panel.state.body as VizPanel; @@ -226,27 +214,10 @@ describe('PanelRepeaterGridItem', () => { await new Promise((r) => setTimeout(r, 10)); expect(performRepeatMock).toHaveBeenCalledTimes(1); // only for the edited panel - expect(panel.state.repeatedPanels?.length).toBe(5); + expect(panel.state.repeatedPanels?.length).toBe(4); expect((panel.state.repeatedPanels![0] as VizPanel).state.title).toBe('Changed'); }); - it('Should display a panel when there are variable errors', () => { - const { scene, repeater } = buildPanelRepeaterScene({ - variableQueryTime: 0, - numberOfOptions: 0, - throwError: 'Error', - }); - - // we expect console.error when variable encounters an error - const origError = console.error; - console.error = jest.fn(); - - activateFullSceneTree(scene); - - expect(repeater.state.repeatedPanels?.length).toBe(1); - console.error = origError; - }); - it('Should display a panel when there are variable errors async query', async () => { const { scene, repeater } = buildPanelRepeaterScene({ variableQueryTime: 1, @@ -262,7 +233,7 @@ describe('PanelRepeaterGridItem', () => { await new Promise((r) => setTimeout(r, 10)); - expect(repeater.state.repeatedPanels?.length).toBe(1); + expect(repeater.state.body.state.$variables?.state.variables[0].getValue()).toBe(''); console.error = origError; }); @@ -346,14 +317,14 @@ describe('PanelRepeaterGridItem', () => { variable.changeValueTo(['1', '3'], ['A', 'C']); - expect(repeater.state.repeatedPanels?.length).toBe(2); + expect(repeater.state.repeatedPanels?.length).toBe(1); }); it('Should fall back to default variable if specified variable cannot be found', () => { const { scene, repeater } = buildPanelRepeaterScene({ variableQueryTime: 0 }); scene.setState({ $variables: undefined }); activateFullSceneTree(scene); - expect(repeater.state.repeatedPanels?.[0].state.$variables?.state.variables[0].state.name).toBe( + expect(repeater.state.body.state.$variables?.state.variables[0].state.name).toBe( '_____default_sys_repeat_var_____' ); }); diff --git a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx index e19803ed5f9..f228575170b 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx @@ -6,20 +6,17 @@ import { VizPanel, SceneObjectBase, SceneGridLayout, - SceneVariableSet, SceneGridItemStateLike, SceneGridItemLike, sceneGraph, MultiValueVariable, - LocalValueVariable, CustomVariable, - VizPanelState, VariableValueSingle, } from '@grafana/scenes'; import { GRID_COLUMN_COUNT } from 'app/core/constants'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; -import { getCloneKey } from '../../utils/clone'; +import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone'; import { getMultiVariableValues } from '../../utils/utils'; import { scrollCanvasElementIntoView, scrollIntoView } from '../layouts-shared/scrollCanvasElementIntoView'; import { DashboardLayoutItem } from '../types/DashboardLayoutItem'; @@ -86,13 +83,12 @@ export class DashboardGridItem return; } - const itemCount = this.state.repeatedPanels?.length ?? 1; const stateChange: Partial = {}; if (this.getRepeatDirection() === 'v') { - stateChange.itemHeight = Math.ceil(newState.height! / itemCount); + stateChange.itemHeight = Math.ceil(newState.height! / this.getPanelCount()); } else { - const rowCount = Math.ceil(itemCount / this.getMaxPerRow()); + const rowCount = Math.ceil(this.getPanelCount() / this.getMaxPerRow()); stateChange.itemHeight = Math.ceil(newState.height! / rowCount); } @@ -101,6 +97,10 @@ export class DashboardGridItem } } + public getPanelCount() { + return (this.state.repeatedPanels?.length ?? 0) + 1; + } + public getClassName(): string { return this.state.variableName ? 'panel-repeater-grid-item' : ''; } @@ -117,13 +117,6 @@ export class DashboardGridItem if (!this.state.variableName) { return; } - - if (this.state.repeatedPanels?.length ?? 0 > 1) { - this.state.body.setState({ - $variables: this.state.repeatedPanels![0].state.$variables?.clone(), - $data: this.state.repeatedPanels![0].state.$data?.clone(), - }); - } } public editingCompleted(withChanges: boolean) { @@ -177,22 +170,16 @@ export class DashboardGridItem // Loop through variable values and create repeats for (let index = 0; index < variableValues.length; index++) { - const cloneState: Partial = { - $variables: new SceneVariableSet({ - variables: [ - new LocalValueVariable({ - name: variable.state.name, - value: variableValues[index], - text: String(variableTexts[index]), - isMulti: variable.state.isMulti, - includeAll: variable.state.includeAll, - }), - ], - }), - key: getCloneKey(panelToRepeat.state.key!, index), - }; - const clone = panelToRepeat.clone(cloneState); - repeatedPanels.push(clone); + const isSource = index === 0; + const clone = isSource + ? panelToRepeat + : panelToRepeat.clone({ key: getCloneKey(panelToRepeat.state.key!, index) }); + + clone.setState({ $variables: getLocalVariableValueSet(variable, variableValues[index], variableTexts[index]) }); + + if (index > 0) { + repeatedPanels.push(clone); + } } const direction = this.getRepeatDirection(); @@ -200,12 +187,13 @@ export class DashboardGridItem const itemHeight = this.state.itemHeight ?? 10; const prevHeight = this.state.height; const maxPerRow = this.getMaxPerRow(); + const panelCount = repeatedPanels.length + 1; // +1 for the source panel if (direction === 'h') { - const rowCount = Math.ceil(repeatedPanels.length / maxPerRow); + const rowCount = Math.ceil(panelCount / maxPerRow); stateChange.height = rowCount * itemHeight; } else { - stateChange.height = repeatedPanels.length * itemHeight; + stateChange.height = panelCount * itemHeight; } this.setState(stateChange); diff --git a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItemEditor.tsx b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItemEditor.tsx index 3b18200fadc..9f822fe01e7 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItemEditor.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItemEditor.tsx @@ -1,4 +1,5 @@ import { useCallback } from 'react'; +import { v4 as uuidv4 } from 'uuid'; import { SelectableValue } from '@grafana/data'; import { t } from '@grafana/i18n'; @@ -8,6 +9,7 @@ import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect'; +import { useConditionalRenderingEditor } from '../../conditional-rendering/ConditionalRenderingEditor'; import { dashboardEditActions } from '../../edit-pane/shared'; import { DashboardGridItem } from './DashboardGridItem'; @@ -21,7 +23,7 @@ export function getDashboardGridItemOptions(gridItem: DashboardGridItem): Option .addItem( new OptionsPaneItemDescriptor({ title: t('dashboard.default-layout.item-options.repeat.variable.title', 'Repeat by variable'), - id: 'repeat-by-variable-select', + id: uuidv4(), description: t( 'dashboard.default-layout.item-options.repeat.variable.description', 'Repeat this panel for each value in the selected variable. This is not visible while in edit mode. You need to go back to dashboard and then update the variable or reload the dashboard.' @@ -42,15 +44,24 @@ export function getDashboardGridItemOptions(gridItem: DashboardGridItem): Option .addItem( new OptionsPaneItemDescriptor({ title: t('dashboard.default-layout.item-options.repeat.max', 'Max per row'), + id: uuidv4(), useShowIf: () => { const { variableName, repeatDirection } = gridItem.useState(); return Boolean(variableName) && repeatDirection === 'h'; }, - render: () => , + render: (descriptor) => , }) ); - return [repeatCategory]; + const conditionalRenderingCategory = useConditionalRenderingEditor( + undefined, + t( + 'dashboard.conditional-rendering.editor.not-supported-for-custom-grid', + 'Conditional rendering is not supported for the custom grid layout. Switch to auto grid to use conditional rendering.' + ) + ); + + return [repeatCategory, conditionalRenderingCategory]; } interface OptionComponentProps { @@ -81,7 +92,7 @@ function RepeatDirectionOption({ gridItem }: OptionComponentProps) { ); } -function MaxPerRowOption({ gridItem }: OptionComponentProps) { +function MaxPerRowOption({ gridItem, id }: OptionComponentProps & { id?: string }) { const { maxPerRow } = gridItem.useState(); const maxPerRowOptions: Array> = [2, 3, 4, 6, 8, 12].map((value) => ({ label: value.toString(), @@ -90,6 +101,7 @@ function MaxPerRowOption({ gridItem }: OptionComponentProps) { return ( row.setState({ title: e.currentTarget.value })} />; + return row.setState({ title: e.currentTarget.value })} />; } -function RowRepeatSelect({ row, dashboard }: { row: SceneGridRow; dashboard: DashboardScene }) { +function RowRepeatSelect({ row, dashboard, id }: { row: SceneGridRow; dashboard: DashboardScene; id?: string }) { const { $behaviors, children } = row.useState(); let repeatBehavior = $behaviors?.find((b) => b instanceof RowRepeaterBehavior); const vizPanels = useMemo( @@ -104,6 +107,7 @@ function RowRepeatSelect({ row, dashboard }: { row: SceneGridRow; dashboard: Das return ( <> { diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx index 396e4075587..a05393bca37 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx @@ -1,4 +1,5 @@ -import { useMemo } from 'react'; +import { useId, useMemo } from 'react'; +import { v4 as uuidv4 } from 'uuid'; import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; @@ -32,13 +33,15 @@ export function useEditOptions(model: RowItem, isNewElement: boolean): OptionsPa .addItem( new OptionsPaneItemDescriptor({ title: t('dashboard.rows-layout.row-options.row.fill-screen', 'Fill screen'), - render: () => , + id: uuidv4(), + render: (descriptor) => , }) ) .addItem( new OptionsPaneItemDescriptor({ title: t('dashboard.rows-layout.row-options.row.hide-header', 'Hide row header'), - render: () => , + id: uuidv4(), + render: (descriptor) => , }) ), [model, isNewElement] @@ -53,11 +56,12 @@ export function useEditOptions(model: RowItem, isNewElement: boolean): OptionsPa }).addItem( new OptionsPaneItemDescriptor({ title: t('dashboard.rows-layout.row-options.repeat.variable.title', 'Repeat by variable'), + id: uuidv4(), description: t( 'dashboard.rows-layout.row-options.repeat.variable.description', 'Repeat this row for each value in the selected variable.' ), - render: () => , + render: (descriptor) => , }) ), [model] @@ -94,6 +98,7 @@ function RowTitleInput({ row, isNewElement }: { row: RowItem; isNewElement: bool } > row.onHeaderHiddenToggle()} />; + return row.onHeaderHiddenToggle()} />; } -function FillScreenSwitch({ row }: { row: RowItem }) { +function FillScreenSwitch({ row, id }: { row: RowItem; id?: string }) { const { fillScreen } = row.useState(); - return row.onChangeFillScreen(!fillScreen)} />; + return row.onChangeFillScreen(!fillScreen)} />; } -function RowRepeatSelect({ row }: { row: RowItem }) { +function RowRepeatSelect({ row, id }: { row: RowItem; id?: string }) { const { layout } = row.useState(); const dashboard = useDashboard(row); @@ -131,6 +136,7 @@ function RowRepeatSelect({ row }: { row: RowItem }) { return ( <> row.onChangeRepeat(repeat)} diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.tsx index 9d1a602fce4..ea606dcaadf 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeater.tsx @@ -1,17 +1,11 @@ import { isEqual } from 'lodash'; import { useEffect } from 'react'; -import { - MultiValueVariable, - SceneVariableSet, - LocalValueVariable, - sceneGraph, - VariableValueSingle, -} from '@grafana/scenes'; +import { MultiValueVariable, sceneGraph, VariableValueSingle } from '@grafana/scenes'; import { Spinner } from '@grafana/ui'; import { DashboardStateChangedEvent } from '../../edit-pane/shared'; -import { getCloneKey } from '../../utils/clone'; +import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone'; import { dashboardLog, getMultiVariableValues } from '../../utils/utils'; import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent'; @@ -109,17 +103,7 @@ export function performRowRepeats(variable: MultiValueVariable, row: RowItem, co rowClone.setState({ key: rowCloneKey, - $variables: new SceneVariableSet({ - variables: [ - new LocalValueVariable({ - name: variable.state.name, - value: variableValues[rowIndex], - text: String(variableTexts[rowIndex]), - isMulti: variable.state.isMulti, - includeAll: variable.state.includeAll, - }), - ], - }), + $variables: getLocalVariableValueSet(variable, variableValues[rowIndex], variableTexts[rowIndex]), layout, }); diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx index 1b04a9d6c23..1cdf72a480c 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx @@ -17,7 +17,6 @@ import { getDashboardSceneFor } from '../../utils/utils'; import { DashboardGridItem } from '../layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager'; import { RowRepeaterBehavior } from '../layout-default/RowRepeaterBehavior'; -import { TabItemRepeaterBehavior } from '../layout-tabs/TabItemRepeaterBehavior'; import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager'; import { getRowFromClipboard } from '../layouts-shared/paste'; import { generateUniqueTitle, ungroupLayout } from '../layouts-shared/utils'; @@ -196,14 +195,12 @@ export class RowsLayoutManager extends SceneObjectBase i const conditionalRendering = tab.state.conditionalRendering; conditionalRendering?.clearParent(); - const behavior = tab.state.$behaviors?.find((b) => b instanceof TabItemRepeaterBehavior); - rows.push( new RowItem({ layout: tab.state.layout.clone(), title: tab.state.title, conditionalRendering, - repeatByVariable: behavior?.state.variableName, + repeatByVariable: tab.state.repeatByVariable, }) ); } diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx index 17c19241f83..9c74a5b37a7 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx @@ -7,6 +7,7 @@ import { Trans } from '@grafana/i18n'; import { MultiValueVariable, SceneComponentProps, sceneGraph, useSceneObjectState } from '@grafana/scenes'; import { Button, useStyles2 } from '@grafana/ui'; +import { isInCloneChain } from '../../utils/clone'; import { useDashboardState } from '../../utils/utils'; import { useClipboardState } from '../layouts-shared/useClipboardState'; @@ -20,6 +21,8 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps model.forceSelectRow(start.draggableId)} @@ -42,7 +45,7 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps ))} {dropProvided.placeholder} - {isEditing && ( + {isEditing && !isClone && (