Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync
This commit is contained in:
commit
a8ee76264b
182 files changed
+3755
-977
No files matched your search
@@ -615,16 +615,6 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Pagination/Pagination.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/PanelChrome/PanelContext.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
||||
|
||||
@@ -757,6 +757,7 @@ embed.go @grafana/grafana-as-code
|
||||
/.github/pr-commands.json @tolzhabayev
|
||||
/.github/renovate.json5 @grafana/frontend-ops
|
||||
/.github/actions/test-coverage-processor/action.yml @grafana/grafana-backend-group
|
||||
/.github/actions/setup-grafana-bench/ @Proximyst
|
||||
/.github/workflows/add-to-whats-new.yml @grafana/docs-tooling
|
||||
/.github/workflows/auto-triager/ @grafana/plugins-platform-frontend
|
||||
/.github/workflows/alerting-swagger-gen.yml @grafana/alerting-backend
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
name: 'Setup Grafana Bench'
|
||||
description: 'Sets up and installs Grafana Bench'
|
||||
|
||||
inputs:
|
||||
github-app-name:
|
||||
description: 'Name of the GitHub App in Vault'
|
||||
required: false
|
||||
default: 'grafana-ci-bot'
|
||||
branch:
|
||||
description: 'The branch to install from'
|
||||
required: false
|
||||
default: 'main'
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Retrieve GitHub App secrets
|
||||
id: get-secrets
|
||||
uses: grafana/shared-workflows/actions/get-vault-secrets@get-vault-secrets-v1.0.1
|
||||
with:
|
||||
repo_secrets: |
|
||||
APP_ID=${{ inputs.github-app-name }}:app-id
|
||||
APP_INSTALLATION_ID=${{ inputs.github-app-name }}:app-installation-id
|
||||
PRIVATE_KEY=${{ inputs.github-app-name }}:private-key
|
||||
|
||||
- name: Generate GitHub App token
|
||||
id: generate_token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ env.APP_ID }}
|
||||
private-key: ${{ env.PRIVATE_KEY }}
|
||||
repositories: "grafana-bench"
|
||||
owner: "grafana"
|
||||
|
||||
- name: Setup Bench
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
run: |
|
||||
git clone https://x-access-token:${GH_TOKEN}@github.com/grafana/grafana-bench.git ../grafana-bench
|
||||
|
||||
cd ../grafana-bench
|
||||
git switch ${{ inputs.branch }}
|
||||
go install .
|
||||
@@ -21,8 +21,13 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23.7'
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Verify code generation
|
||||
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
|
||||
|
||||
@@ -39,17 +39,13 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential shared-mime-info
|
||||
|
||||
- name: Verify code generation
|
||||
run: |
|
||||
CODEGEN_VERIFY=1 make gen-cue
|
||||
CODEGEN_VERIFY=1 make gen-jsonnet
|
||||
go install github.com/mfridman/tparse@c1754a1f484ac5cd422697b0fec635177ddc8507 # v0.17.0
|
||||
|
||||
- name: Generate Go code
|
||||
run: make gen-go
|
||||
|
||||
- name: Run unit tests
|
||||
run: COVER_OPTS="-coverprofile=be-unit.cov -coverpkg=github.com/grafana/grafana/..." make test-go-unit
|
||||
run: COVER_OPTS="-coverprofile=be-unit.cov -coverpkg=github.com/grafana/grafana/..." GO_TEST_OUTPUT="/tmp/unit.log" make test-go-unit
|
||||
|
||||
- name: Process and upload coverage
|
||||
uses: ./.github/actions/test-coverage-processor
|
||||
@@ -61,6 +57,22 @@ jobs:
|
||||
codecov-flag: 'be-unit'
|
||||
codecov-name: 'be-unit'
|
||||
|
||||
- name: Install Grafana Bench
|
||||
# We can't allow forks here, as we need secret access.
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: ./.github/actions/setup-grafana-bench
|
||||
|
||||
- name: Process output for Bench
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
run: |
|
||||
grafana-bench report \
|
||||
--trigger pr-backend-unit-tests-oss \
|
||||
--report-input go \
|
||||
--report-output log \
|
||||
--grafana-version "$(git rev-parse HEAD)" \
|
||||
--suite-name grafana-oss-unit-tests \
|
||||
/tmp/unit.log
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -17,6 +17,7 @@ GO_RACE_FLAG := $(if $(GO_RACE),-race)
|
||||
GO_BUILD_FLAGS += $(if $(GO_BUILD_DEV),-dev)
|
||||
GO_BUILD_FLAGS += $(if $(GO_BUILD_TAGS),-build-tags=$(GO_BUILD_TAGS))
|
||||
GO_BUILD_FLAGS += $(GO_RACE_FLAG)
|
||||
GO_TEST_OUTPUT := $(shell [ -n "$(GO_TEST_OUTPUT)" ] && echo '-json | tee $(GO_TEST_OUTPUT) | tparse -all')
|
||||
GIT_BASE = remotes/origin/main
|
||||
|
||||
# GNU xargs has flag -r, and BSD xargs (e.g. MacOS) has that behaviour by default
|
||||
@@ -248,7 +249,7 @@ test-go: test-go-unit test-go-integration
|
||||
.PHONY: test-go-unit
|
||||
test-go-unit: ## Run unit tests for backend with flags.
|
||||
@echo "test backend unit tests"
|
||||
$(GO) test $(GO_RACE_FLAG) -short -covermode=atomic -coverprofile=unit.cov -timeout=30m $(GO_TEST_FILES)
|
||||
$(GO) test $(GO_RACE_FLAG) -short -covermode=atomic -coverprofile=unit.cov -timeout=30m $(GO_TEST_FILES) $(GO_TEST_OUTPUT)
|
||||
|
||||
.PHONY: test-go-unit-pretty
|
||||
test-go-unit-pretty: check-tparse
|
||||
@@ -261,7 +262,7 @@ test-go-unit-pretty: check-tparse
|
||||
.PHONY: test-go-integration
|
||||
test-go-integration: ## Run integration tests for backend with flags.
|
||||
@echo "test backend integration tests"
|
||||
$(GO) test $(GO_RACE_FLAG) -count=1 -run "^TestIntegration" -covermode=atomic -coverprofile=integration.cov -timeout=5m $(GO_INTEGRATION_TESTS)
|
||||
$(GO) test $(GO_RACE_FLAG) -count=1 -run "^TestIntegration" -covermode=atomic -coverprofile=integration.cov -timeout=5m $(GO_INTEGRATION_TESTS) $(GO_TEST_OUTPUT)
|
||||
|
||||
.PHONY: test-go-integration-alertmanager
|
||||
test-go-integration-alertmanager: ## Run integration tests for the remote alertmanager (config taken from the mimir_backend block).
|
||||
|
||||
@@ -1530,6 +1530,13 @@ basic_auth_password =
|
||||
# Request timeout for recording rule writes.
|
||||
timeout = 10s
|
||||
|
||||
# Default data source UID to write to if not specified in the rule definition.
|
||||
# Only has effect if the grafanaManagedRecordRulesDatasources feature toggle is enabled.
|
||||
default_datasource_uid =
|
||||
|
||||
# Suffix to apply to the data source URL for remote write requests.
|
||||
remote_write_path_suffix = /push
|
||||
|
||||
# Optional custom headers to include in recording rule write requests.
|
||||
[recording_rules.custom_headers]
|
||||
# exampleHeader = exampleValue
|
||||
|
||||
@@ -1512,6 +1512,13 @@ basic_auth_password =
|
||||
# Request timeout for recording rule writes.
|
||||
timeout = 30s
|
||||
|
||||
# Default data source UID to write to if not specified in the rule definition.
|
||||
# Only has effect if the grafanaManagedRecordRulesDatasources feature toggle is enabled.
|
||||
default_datasource_uid =
|
||||
|
||||
# Suffix to apply to the data source URL for remote write requests.
|
||||
remote_write_path_suffix = /push
|
||||
|
||||
# Optional custom headers to include in recording rule write requests.
|
||||
[recording_rules.custom_headers]
|
||||
# exampleHeader = exampleValue
|
||||
|
||||
+2
-2
@@ -9,6 +9,6 @@ include docs.mk
|
||||
|
||||
.PHONY: sources/panels-visualizations/query-transform-data/transform-data/index.md
|
||||
sources/panels-visualizations/query-transform-data/transform-data/index.md: ## Generate the Transform Data page source.
|
||||
cd $(CURDIR)/.. && npx tsc ./scripts/docs/generate-transformations.ts && \
|
||||
node -e "require('./scripts/docs/generate-transformations').buildMarkdownContent()" && \
|
||||
cd $(CURDIR)/.. && \
|
||||
npx tsx ./scripts/docs/generate-transformations.ts && \
|
||||
npx prettier -w $(CURDIR)/$@
|
||||
+1
-1
@@ -125,7 +125,7 @@ update: ## Fetch the latest version of this Makefile and the `make-docs` script
|
||||
TOPIC_TYPES := concept multiple-tasks reference section task tutorial visualization
|
||||
.PHONY: $(patsubst %,topic/%,$(TOPIC_TYPES))
|
||||
topic/%: ## Create a topic from the Writers' Toolkit template. Specify the topic type as the target, for example, `make topic/task TOPIC_PATH=sources/my-new-topic.md`.
|
||||
topic/%:
|
||||
$(patsubst %,topic/%,$(TOPIC_TYPES)):
|
||||
$(if $(TOPIC_PATH),,$(error "You must set the TOPIC_PATH variable to the path where the $(@F) topic will be created. For example: make $(@) TOPIC_PATH=sources/my-new-topic.md"))
|
||||
mkdir -p $(dir $(TOPIC_PATH))
|
||||
curl -s -o $(TOPIC_PATH) https://raw.githubusercontent.com/grafana/writers-toolkit/refs/heads/main/docs/static/templates/$(@F)-template.md
|
||||
@@ -8,6 +8,12 @@
|
||||
# [Semantic versioning](https://semver.org/) is used to help the reader identify the significance of changes.
|
||||
# Changes are relevant to this script and the support docs.mk GNU Make interface.
|
||||
#
|
||||
# ## 8.5.2 (2025-02-28)
|
||||
#
|
||||
# ### Fixed
|
||||
#
|
||||
# - topic/<KIND> targets are no longer no-ops as a result of 8.5.1.
|
||||
#
|
||||
# ## 8.5.1 (2025-02-18)
|
||||
#
|
||||
# ### Fixed
|
||||
|
||||
@@ -128,7 +128,7 @@ 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
|
||||
#### Install a plugin using Grafana configuration
|
||||
|
||||
{{% admonition type="note" %}}
|
||||
This feature requires Grafana 11.5.0 or later.
|
||||
|
||||
@@ -22,8 +22,7 @@ The Group Attribute Sync API allows you to configure [group attribute sync featu
|
||||
> **Note:** Available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise) and [Grafana Cloud](https://grafana.com/docs/grafana-cloud/)
|
||||
|
||||
{{% admonition type="note" %}}
|
||||
This feature is behind the `groupAttributeSync` feature toggle.
|
||||
You can enable feature toggles through configuration file or environment variables. See configuration [docs](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#feature_toggles) for details.
|
||||
This feature is currently in [private preview](https://grafana.com/docs/release-life-cycle/#private-preview) and behind the `groupAttributeSync` feature toggle. Please contact support to have this feature enabled.
|
||||
{{% /admonition %}}
|
||||
|
||||
## List group mappings
|
||||
|
||||
@@ -104,6 +104,16 @@ When there are multiple transformations, Grafana applies them in the order they
|
||||
|
||||
The order in which Grafana applies transformations directly impacts the results. For example, if you use a Reduce transformation to condense all the results of one column into a single value, then you can only apply transformations to that single value.
|
||||
|
||||
## Dashboard variables in transformations
|
||||
|
||||
All text input fields in transformations accept [variable syntax](ref:dashboard-variable):
|
||||
|
||||
{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-transformation-variables-v11.6.png" alt="Transformation with a mock variable in a text field" >}}
|
||||
|
||||
When you use dashboard variables in transformations, the variables are automatically interpolated before the transformations are applied to the data.
|
||||
|
||||
For an example, refer to [Use a dashboard variable](#use-a-dashboard-variable) in the **Filter fields by name** transformation.
|
||||
|
||||
## Add a transformation function to data
|
||||
|
||||
The following steps guide you in adding a transformation to data. This documentation does not include steps for each type of transformation. For a complete list of transformations, refer to [Transformation functions](#transformation-functions).
|
||||
|
||||
@@ -59,7 +59,7 @@ For more information about traces and how to use them, refer to the following do
|
||||
- [Tempo data source](ref:tempo-data-source)
|
||||
- [Getting started with Tempo](/docs/tempo/latest/getting-started)
|
||||
|
||||
{{< figure src="/static/img/docs/explore/trace-view-9-4.png" class="docs-image--no-shadow" max-width= "900px" caption="Screenshot of the trace view" >}}
|
||||
{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-traces-visualization-11.5.png" max-width= "700px" alt="Screenshot of the trace view" >}}
|
||||
|
||||
{{< docs/play title="Traces Panel" url="https://play.grafana.org/d/edodkfmj0tce8f/" >}}
|
||||
|
||||
@@ -77,7 +77,7 @@ To use this procedure, you need:
|
||||
- A Grafana instance
|
||||
- A [Tempo data source](ref:tempo-data-source) connected to your Grafana instance
|
||||
|
||||
### Add the traces visualization query {#add-the-traces-panel-query}
|
||||
### Steps {#add-the-traces-panel-query}
|
||||
|
||||
To view and analyze traces data in a dashboard, you need to add the traces visualization to your dashboard and define a query using the panel editor.
|
||||
The query determines the data that is displayed in the visualization.
|
||||
@@ -100,19 +100,19 @@ This procedure uses dashboard variables and templates to allow you to enter trac
|
||||
1. In the query editor, click the **TraceQL** query type tab.
|
||||
1. Enter `${traceId}` in the TraceQL query field to create a dashboard variable. This variable is used as the template query.
|
||||
|
||||
{{< figure src="/static/img/docs/panels/traces/screenshot-traces-template-query.png" caption="Add a template query" >}}
|
||||
{{< figure src="/static/img/docs/panels/traces/screenshot-traces-template-query.png" alt="Add a template query" >}}
|
||||
|
||||
1. Click **Back to dashboard**.
|
||||
1. Click **Settings** and go to the **Variables** tab.
|
||||
1. Add a new variable called `traceId`, of variable type **Custom**, giving it a label if required.
|
||||
|
||||
{{< figure src="/media/docs/grafana/dashboards/screenshot-traces-custom-variable-11.2.png" max-width="50%" caption="Add a Custom variable" >}}
|
||||
{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-traces-custom-variable-v11.5.png" max-width="400px" alt="Add a template query" >}}
|
||||
|
||||
1. Click **Save dashboard**.
|
||||
1. Click **Back to dashboard** and **Exit edit**.
|
||||
1. Verify that the panel works by using a valid trace ID for the data source used for the trace panel and editing the ID in the dashboard variable.
|
||||
|
||||
{{< figure src="/static/img/docs/panels/traces/screenshot-traces-traceid-panel.png" caption="Results of query in trace panel" >}}
|
||||
{{< figure src="/static/img/docs/panels/traces/screenshot-traces-traceid-panel.png" alt="Results of query in trace panel" >}}
|
||||
|
||||
## Add TraceQL with table visualizations
|
||||
|
||||
@@ -127,14 +127,14 @@ It’s more useful to instead be able to use TraceQL queries to search for speci
|
||||
1. Under the **Panel options**, enter a **Title** for your trace panel or have Grafana create one using [generative AI features](ref:generative-ai-features).
|
||||
1. Add an appropriate TraceQL query to search for traces that you would like to visualize in the dashboard. This example uses a simple, static query. You can write the TraceQL query as a template query to take advantage of other dashboard variables, if they exist. This lets you create dynamic queries based on these variables.
|
||||
|
||||
{{< figure src="/static/img/docs/panels/traces/screenshot-traces-dynamic-query.png" caption="Create a dynamic query" >}}
|
||||
{{< figure src="/static/img/docs/panels/traces/screenshot-traces-dynamic-query.png" alt="Create a dynamic query" >}}
|
||||
|
||||
1. Click **Save dashboard**.
|
||||
1. Click **Back to dashboard** and **Exit edit**.
|
||||
|
||||
When results are returned from a query, the results are rendered in the panel’s table.
|
||||
|
||||
{{< figure src="/static/img/docs/panels/traces/screenshot-traces-returned-query.png" caption="Results of a returned query in the panel table" >}}
|
||||
{{< figure src="/static/img/docs/panels/traces/screenshot-traces-returned-query.png" alt="Results of a returned query in the panel table" >}}
|
||||
|
||||
### Use a variable to add other links to traces
|
||||
|
||||
@@ -146,12 +146,12 @@ To create a set of data links in the panel, use the following steps:
|
||||
1. Add a **Title** for the data link.
|
||||
1. Find the UUID of the dashboard by looking in your browser’s address bar when the full dashboard is being rendered. Because this is a link to a dashboard in the same Grafana stack, only the path of the dashboard is required.
|
||||
|
||||
{{< figure src="/static/img/docs/panels/traces/screensnot-traces-uuid-url.png" caption="Unique identifier for the dashboard" >}}
|
||||
{{< figure src="/static/img/docs/panels/traces/screensnot-traces-uuid-url.png" alt="Unique identifier for the dashboard" >}}
|
||||
|
||||
1. In the **URL** field, make a self-reference to the dashboard that contains both of the panels. This self-reference uses the value of the selected trace in the table to fill in the dashboard variable. Use the path for the dashboard from the previous step and then fill in the value of `traceId` using the selected results from the TraceQL table.
|
||||
The trace ID is exposed using the `traceID` data field in the returned results, so use that as the value for the dashboard variable.
|
||||
|
||||
{{< figure src="/static/img/docs/panels/traces/screenshot-traces-edit-link.png" caption="Edit link and add the Trace link" >}}
|
||||
{{< figure src="/static/img/docs/panels/traces/screenshot-traces-edit-link.png" alt="Edit link and add the Trace link" >}}
|
||||
|
||||
1. Select **Save** to save the data link.
|
||||
1. Click **Save dashboard**.
|
||||
@@ -159,24 +159,35 @@ To create a set of data links in the panel, use the following steps:
|
||||
|
||||
You should now see a list of matching traces in the table visualization. While selecting the **TraceID** or **SpanID** fields will give you the option to either open the **Explore** page to visualize the trace or following the data link, selecting any other field (such as **Start time**, **Name** or **Duration**) automatically follows the data link, filling in the `traceId` dashboard variable, and then shows the relevant trace in the trace panel.
|
||||
|
||||
{{< figure src="/static/img/docs/panels/traces/screenshot-traces-trace-link.png" caption="Selecting the trace link" >}}
|
||||
{{< figure src="/static/img/docs/panels/traces/screenshot-traces-trace-link.png" alt="Selecting the trace link" >}}
|
||||
|
||||
{{< figure src="/static/img/docs/panels/traces/screenshot-traces-trace-link-follow.png" caption="Follow the trace link populates the trace ID and displays the traces view" >}}
|
||||
|
||||
## Panel options
|
||||
## Configuration options
|
||||
|
||||
{{< docs/shared lookup="visualizations/config-options-intro.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
### Panel options
|
||||
|
||||
{{< docs/shared lookup="visualizations/panel-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
|
||||
|
||||
## Span filters
|
||||
### Span filters options
|
||||
|
||||
The **Span filters** options control the initial state of the span filters when the visualization loads, allowing you to customize your trace analysis view.
|
||||
|
||||
- **Find in trace** - Set the initial value to focus on spans relevant to your query.
|
||||
- **Show matches only** - Toggle the switch on to display only spans that match the defined filter criteria. This helps simplify trace interpretation.
|
||||
- **Show critical path only** - Toggle the switch on to highlight only the spans in the critical path, which helps identify performance bottlenecks and their impact on overall latency.
|
||||
- **Service name** and **Service name operator** - Define a specific service or pattern to narrow analysis to spans related to particular services.
|
||||
- **Span name** and **Span name operator** - Filter spans by name or pattern to focus on specific span types or processes.
|
||||
- **Min duration** and **Max duration** - Set duration thresholds to exclude spans outside the desired time range.
|
||||
- **Tags** - Add one or more tags to further refine the filtering criteria so only relevant spans are displayed.
|
||||
The following options support variable interpolation, where you can set the service name to a variable `$var` and the visualization will replace it with the value for the variable named `$var` in the span filters: **Service name**, **Span name**, **Min duration**, **Max duration**, and **Tags**.
|
||||
|
||||
The options support variable interpolation, where you can set the service name to a variable `$var` and the visualization will replace it with the value for the variable named `$var` in the span filters. This applies to the service name, span name, duration fields, and tags.
|
||||
<!-- prettier-ignore-start -->
|
||||
|
||||
| Option | Description |
|
||||
| ------ | ----------- |
|
||||
| Find in trace | Set the initial value to focus on spans relevant to your query. |
|
||||
| Show matches only | Toggle the switch on to display only spans that match the defined filter criteria. This helps simplify trace interpretation. |
|
||||
| Show critical path only | Toggle the switch on to highlight only the spans in the critical path, which helps identify performance bottlenecks and their impact on overall latency. |
|
||||
| Service name | Along with the **Service name operator**, define a specific service or pattern to narrow analysis to spans related to particular services. |
|
||||
| Span name | Along with the **Span name operator**, filter spans by name or pattern to focus on specific span types or processes. |
|
||||
| Min duration | Set the minimum duration threshold to exclude spans outside the desired time range. |
|
||||
| Max duration | Set the maximum duration threshold to exclude spans outside the desired time range. |
|
||||
| Tags | Add one or more tags to further refine the filtering criteria so only relevant spans are displayed. |
|
||||
|
||||
<!-- prettier-ignore-end -->
|
||||
@@ -64,6 +64,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
|
||||
| `groupToNestedTableTransformation` | Enables the group to nested table transformation | Yes |
|
||||
| `newPDFRendering` | New implementation for the dashboard-to-PDF rendering | Yes |
|
||||
| `tlsMemcached` | Use TLS-enabled memcached in the enterprise caching feature | Yes |
|
||||
| `ssoSettingsSAML` | Use the new SSO Settings API to configure the SAML connector | Yes |
|
||||
| `cloudWatchNewLabelParsing` | Updates CloudWatch label parsing to be more accurate | Yes |
|
||||
| `accessActionSets` | Introduces action sets for resource permissions. Also ensures that all folder editors and admins can create subfolders without needing any additional permissions. | Yes |
|
||||
| `newDashboardSharingComponent` | Enables the new sharing drawer design | Yes |
|
||||
@@ -111,7 +112,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
|
||||
| `regressionTransformation` | Enables regression analysis transformation |
|
||||
| `onPremToCloudMigrations` | Enable the Grafana Migration Assistant, which helps you easily migrate on-prem resources, such as dashboards, folders, and data source configurations, to your Grafana Cloud stack. |
|
||||
| `alertingSaveStateCompressed` | Enables the compressed protobuf-based alert state storage |
|
||||
| `ssoSettingsSAML` | Use the new SSO Settings API to configure the SAML connector |
|
||||
| `azureMonitorPrometheusExemplars` | Allows configuration of Azure Monitor as a data source that can provide Prometheus exemplars |
|
||||
| `ssoSettingsLDAP` | Use the new SSO Settings API to configure LDAP |
|
||||
| `improvedExternalSessionHandling` | Enables improved support for OAuth external sessions. After enabling this feature, users might need to re-authenticate themselves. |
|
||||
|
||||
@@ -16,8 +16,7 @@ Groups are mapped to [fixed](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/
|
||||
> **Note:** Available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/).
|
||||
|
||||
{{% admonition type="note" %}}
|
||||
This feature is behind the `groupAttributeSync` feature toggle.
|
||||
You can enable feature toggles through configuration file or environment variables. See configuration [docs](/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#feature_toggles) for details.
|
||||
This feature is currently in [private preview](https://grafana.com/docs/release-life-cycle/#private-preview) and behind the `groupAttributeSync` feature toggle. Please contact support to have this feature enabled.
|
||||
{{% /admonition %}}
|
||||
|
||||
When a user logs in, Grafana checks the user's external group memberships and the configured group to role mappings to assign the corresponding roles to the user.
|
||||
|
||||
@@ -76,14 +76,14 @@ require (
|
||||
github.com/googleapis/go-sql-spanner v1.11.1 // @grafana/grafana-search-and-storage
|
||||
github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group
|
||||
github.com/gorilla/websocket v1.5.3 // @grafana/grafana-app-platform-squad
|
||||
github.com/grafana/alerting v0.0.0-20250305143710-aae4827ec061 // @grafana/alerting-backend
|
||||
github.com/grafana/alerting v0.0.0-20250307175047-1d263576d356 // @grafana/alerting-backend
|
||||
github.com/grafana/authlib v0.0.0-20250225105729-99e678595501 // @grafana/identity-access-team
|
||||
github.com/grafana/authlib/types v0.0.0-20250224151205-5ef97131cc82 // @grafana/identity-access-team
|
||||
github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics
|
||||
github.com/grafana/dataplane/sdata v0.0.9 // @grafana/observability-metrics
|
||||
github.com/grafana/dskit v0.0.0-20241105154643-a6b453a88040 // @grafana/grafana-backend-group
|
||||
github.com/grafana/e2e v0.1.1 // @grafana-app-platform-squad
|
||||
github.com/grafana/gofpdf v0.0.0-20231002120153-857cc45be447 // @grafana/sharing-squad
|
||||
github.com/grafana/gofpdf v0.0.0-20250307124105-3b9c5d35577f // @grafana/sharing-squad
|
||||
github.com/grafana/gomemcache v0.0.0-20240805133030-fdaf6a95408e // @grafana/grafana-operator-experience-squad
|
||||
github.com/grafana/grafana-app-sdk v0.31.0 // @grafana/grafana-app-platform-squad
|
||||
github.com/grafana/grafana-app-sdk/logging v0.30.0 // @grafana/grafana-app-platform-squad
|
||||
@@ -135,7 +135,7 @@ require (
|
||||
github.com/openfga/openfga v1.8.6 // @grafana/identity-access-team
|
||||
github.com/openzipkin/zipkin-go v0.4.3 // @grafana/oss-big-tent
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible // @grafana/alerting-backend
|
||||
github.com/phpdave11/gofpdi v1.0.13 // @grafana/sharing-squad
|
||||
github.com/phpdave11/gofpdi v1.0.14 // @grafana/sharing-squad
|
||||
github.com/prometheus/alertmanager v0.27.0 // @grafana/alerting-backend
|
||||
github.com/prometheus/client_golang v1.21.0 // @grafana/alerting-backend
|
||||
github.com/prometheus/client_model v0.6.1 // @grafana/grafana-backend-group
|
||||
|
||||
@@ -1559,8 +1559,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.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grafana/alerting v0.0.0-20250305143710-aae4827ec061 h1:2s0xCzcxw8MlzLbbrEkghmwQpXjwkcRJYSItBQ1ZD0U=
|
||||
github.com/grafana/alerting v0.0.0-20250305143710-aae4827ec061/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM=
|
||||
github.com/grafana/alerting v0.0.0-20250307175047-1d263576d356 h1:71o8Bxw/wg+aBRUASiGux67gDEUC2bqq3I+x2hw8eIc=
|
||||
github.com/grafana/alerting v0.0.0-20250307175047-1d263576d356/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM=
|
||||
github.com/grafana/authlib v0.0.0-20250225105729-99e678595501 h1:FTuDRy/Shw8yOdG+v1DnkeuaCAl8fvwgcfaG9Wccuhg=
|
||||
github.com/grafana/authlib v0.0.0-20250225105729-99e678595501/go.mod h1:XVpdLhaeYqz414FmGnW00/0vTe1x8c0GRH3KaeRtyg0=
|
||||
github.com/grafana/authlib/types v0.0.0-20250224151205-5ef97131cc82 h1:DnRUYiAotHXnrfYJCvhH1NkiyWVcPm5Pd+P7Ugqt/d8=
|
||||
@@ -1573,8 +1573,8 @@ github.com/grafana/dskit v0.0.0-20241105154643-a6b453a88040 h1:IR+UNYHqaU31t8/TA
|
||||
github.com/grafana/dskit v0.0.0-20241105154643-a6b453a88040/go.mod h1:SPLNCARd4xdjCkue0O6hvuoveuS1dGJjDnfxYe405YQ=
|
||||
github.com/grafana/e2e v0.1.1 h1:/b6xcv5BtoBnx8cZnCiey9DbjEc8z7gXHO5edoeRYxc=
|
||||
github.com/grafana/e2e v0.1.1/go.mod h1:RpNLgae5VT+BUHvPE+/zSypmOXKwEu4t+tnEMS1ATaE=
|
||||
github.com/grafana/gofpdf v0.0.0-20231002120153-857cc45be447 h1:jxJJ5z0GxqhWFbQUsys3BHG8jnmniJ2Q74tXAG1NaDo=
|
||||
github.com/grafana/gofpdf v0.0.0-20231002120153-857cc45be447/go.mod h1:IxsY6mns6Q5sAnWcrptrgUrSglTZJXH/kXr9nbpb/9I=
|
||||
github.com/grafana/gofpdf v0.0.0-20250307124105-3b9c5d35577f h1:5xkjl5Y/j2QefJKOtTfyD1wXlVsQ2yEXmd0u82h5obs=
|
||||
github.com/grafana/gofpdf v0.0.0-20250307124105-3b9c5d35577f/go.mod h1:+O5QxOwwgP10jedZHapzXY+IPKTnzHBtIs5UUb9G+kI=
|
||||
github.com/grafana/gomemcache v0.0.0-20240805133030-fdaf6a95408e h1:UlEET0InuoFautfaFp8lDrNF7rPHYXuBMrzwWx9XqFY=
|
||||
github.com/grafana/gomemcache v0.0.0-20240805133030-fdaf6a95408e/go.mod h1:IGRj8oOoxwJbHBYl1+OhS9UjQR0dv6SQOep7HqmtyFU=
|
||||
github.com/grafana/grafana-api-golang-client v0.27.0 h1:zIwMXcbCB4n588i3O2N6HfNcQogCNTd/vPkEXTr7zX8=
|
||||
@@ -2126,10 +2126,10 @@ github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xl
|
||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||
github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY=
|
||||
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||
github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||
github.com/phpdave11/gofpdi v1.0.13 h1:o61duiW8M9sMlkVXWlvP92sZJtGKENvW3VExs6dZukQ=
|
||||
github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||
github.com/phpdave11/gofpdi v1.0.14 h1:jlcDIJ6ObCh3X9nANGEK6RY5wbUKHJ5unBjrzG4i89A=
|
||||
github.com/phpdave11/gofpdi v1.0.14/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
|
||||
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
|
||||
@@ -1168,6 +1168,7 @@ github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h
|
||||
github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI=
|
||||
github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
|
||||
github.com/phpdave11/gofpdf v1.4.2 h1:KPKiIbfwbvC/wOncwhrpRdXVj2CZTCFlw4wnoyjtHfQ=
|
||||
github.com/phpdave11/gofpdi v1.0.14 h1:jlcDIJ6ObCh3X9nANGEK6RY5wbUKHJ5unBjrzG4i89A=
|
||||
github.com/pierrec/lz4 v2.0.5+incompatible h1:2xWsjqPFWcplujydGg4WmhC/6fZqK42wMM8aXeqhl0I=
|
||||
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
|
||||
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
|
||||
|
||||
+2
-2
@@ -278,8 +278,8 @@
|
||||
"@grafana/prometheus": "workspace:*",
|
||||
"@grafana/runtime": "workspace:*",
|
||||
"@grafana/saga-icons": "workspace:*",
|
||||
"@grafana/scenes": "6.1.4",
|
||||
"@grafana/scenes-react": "6.1.4",
|
||||
"@grafana/scenes": "6.2.1",
|
||||
"@grafana/scenes-react": "6.2.1",
|
||||
"@grafana/schema": "workspace:*",
|
||||
"@grafana/sql": "workspace:*",
|
||||
"@grafana/ui": "workspace:*",
|
||||
|
||||
@@ -131,6 +131,14 @@ export enum ReducerID {
|
||||
p99 = 'p99',
|
||||
}
|
||||
|
||||
export function getFieldTypeForReducer(id: ReducerID, fallback: FieldType): FieldType {
|
||||
return id === ReducerID.count
|
||||
? FieldType.number
|
||||
: id === ReducerID.allIsNull || id === ReducerID.allIsZero
|
||||
? FieldType.boolean
|
||||
: fallback;
|
||||
}
|
||||
|
||||
export function isReducerID(id: string): id is ReducerID {
|
||||
return Object.keys(ReducerID).includes(id);
|
||||
}
|
||||
|
||||
@@ -368,4 +368,97 @@ describe('GroupBy transformer', () => {
|
||||
expect(result[0].fields).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should retain "time" field type when used as aggregation (max, etc)', async () => {
|
||||
const testSeries = toDataFrame({
|
||||
name: 'A',
|
||||
fields: [
|
||||
{ name: 'user', type: FieldType.string, values: ['A', 'B', 'A', 'B'] },
|
||||
{ name: 'time', type: FieldType.time, values: [7, 2, 1, 5] },
|
||||
],
|
||||
});
|
||||
|
||||
const cfg: DataTransformerConfig<GroupByTransformerOptions> = {
|
||||
id: DataTransformerID.groupBy,
|
||||
options: {
|
||||
fields: {
|
||||
user: {
|
||||
operation: GroupByOperationID.groupBy,
|
||||
aggregations: [],
|
||||
},
|
||||
time: {
|
||||
operation: GroupByOperationID.aggregate,
|
||||
aggregations: [ReducerID.max],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(transformDataFrame([cfg], [testSeries])).toEmitValuesWith((received) => {
|
||||
const result = received[0];
|
||||
const expected: Field[] = [
|
||||
{
|
||||
name: 'user',
|
||||
type: FieldType.string,
|
||||
values: ['A', 'B'],
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
name: 'time (max)',
|
||||
type: FieldType.time,
|
||||
values: [7, 5],
|
||||
config: {},
|
||||
},
|
||||
];
|
||||
|
||||
expect(result[0].fields).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should retain "time" field type when used as aggregation (max, etc)', async () => {
|
||||
const testSeries = toDataFrame({
|
||||
refId: 'A',
|
||||
fields: [
|
||||
{ name: 'user', type: FieldType.string, values: ['A', 'B', 'A', 'B'] },
|
||||
{ name: 'time', type: FieldType.time, values: [7, 2, 1, 5] },
|
||||
],
|
||||
});
|
||||
|
||||
const cfg: DataTransformerConfig<GroupByTransformerOptions> = {
|
||||
id: DataTransformerID.groupBy,
|
||||
options: {
|
||||
fields: {
|
||||
user: {
|
||||
operation: GroupByOperationID.groupBy,
|
||||
aggregations: [],
|
||||
},
|
||||
time: {
|
||||
operation: GroupByOperationID.aggregate,
|
||||
aggregations: [ReducerID.max],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(transformDataFrame([cfg], [testSeries])).toEmitValuesWith((received) => {
|
||||
const result = received[0];
|
||||
const expected: Field[] = [
|
||||
{
|
||||
name: 'user',
|
||||
type: FieldType.string,
|
||||
values: ['A', 'B'],
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
name: 'time (max)',
|
||||
type: FieldType.time,
|
||||
values: [7, 5],
|
||||
config: {},
|
||||
},
|
||||
];
|
||||
|
||||
expect(result[0].refId).toEqual('A');
|
||||
expect(result[0].fields).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,9 @@
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
import { guessFieldTypeForField } from '../../dataframe/processDataFrame';
|
||||
import { getFieldDisplayName } from '../../field/fieldState';
|
||||
import { DataFrame, Field, FieldType } from '../../types/dataFrame';
|
||||
import { DataFrame, Field } from '../../types/dataFrame';
|
||||
import { DataTransformerInfo, TransformationApplicabilityLevels } from '../../types/transformations';
|
||||
import { reduceField, ReducerID } from '../fieldReducer';
|
||||
import { getFieldTypeForReducer, reduceField, ReducerID } from '../fieldReducer';
|
||||
|
||||
import { DataTransformerID } from './ids';
|
||||
import { findMaxFields } from './utils';
|
||||
@@ -112,16 +111,16 @@ export const groupByTransformer: DataTransformerInfo<GroupByTransformerOptions>
|
||||
const aggregationField: Field = {
|
||||
name: `${fieldName} (${aggregation})`,
|
||||
values: valuesByAggregation[aggregation] ?? [],
|
||||
type: FieldType.other,
|
||||
type: getFieldTypeForReducer(aggregation, field.type),
|
||||
config: {},
|
||||
};
|
||||
|
||||
aggregationField.type = detectFieldType(aggregation, field, aggregationField);
|
||||
fields.push(aggregationField);
|
||||
}
|
||||
}
|
||||
|
||||
processed.push({
|
||||
...frame,
|
||||
fields,
|
||||
length: valuesByGroupKey.size,
|
||||
});
|
||||
@@ -146,20 +145,6 @@ const shouldCalculateField = (field: Field, options: GroupByTransformerOptions):
|
||||
);
|
||||
};
|
||||
|
||||
function detectFieldType(aggregation: string, sourceField: Field, targetField: Field): FieldType {
|
||||
switch (aggregation) {
|
||||
case ReducerID.allIsNull:
|
||||
return FieldType.boolean;
|
||||
case ReducerID.last:
|
||||
case ReducerID.lastNotNull:
|
||||
case ReducerID.first:
|
||||
case ReducerID.firstNotNull:
|
||||
return sourceField.type;
|
||||
default:
|
||||
return guessFieldTypeForField(targetField) ?? FieldType.string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups values together by key. This will create a mapping of strings
|
||||
* to _FieldMaps_ that will then be used to group values on.
|
||||
|
||||
@@ -256,4 +256,6 @@ export interface FeatureToggles {
|
||||
rendererDisableAppPluginsPreload?: boolean;
|
||||
assetSriChecks?: boolean;
|
||||
alertRuleRestore?: boolean;
|
||||
grafanaManagedRecordingRulesDatasources?: boolean;
|
||||
inviteUserExperimental?: boolean;
|
||||
}
|
||||
@@ -206,9 +206,6 @@ export const versionedPages = {
|
||||
shareSnapshot: {
|
||||
'11.2.0': 'data-testid new share button share snapshot',
|
||||
},
|
||||
inviteUser: {
|
||||
'11.5.0': 'data-testid new share button invite user',
|
||||
},
|
||||
},
|
||||
},
|
||||
NewExportButton: {
|
||||
|
||||
File renamed without changes.
@@ -56,3 +56,4 @@ export {
|
||||
export { isPluginExtensionLink, isPluginExtensionComponent } from './pluginExtensions/utils';
|
||||
export { setCurrentUser } from './user';
|
||||
export { RuntimeDataSource } from './RuntimeDataSource';
|
||||
export { ScopesContext, type ScopesContextValueState, type ScopesContextValue, useScopes } from './ScopesContext';
|
||||
@@ -9,9 +9,5 @@
|
||||
* and be subject to the standard policies
|
||||
*/
|
||||
|
||||
export {
|
||||
ScopesContext,
|
||||
type ScopesContextValueState,
|
||||
type ScopesContextValue,
|
||||
useScopes,
|
||||
} from './unstable/ScopesContext';
|
||||
// Dummy export to make it a valid module. Remove when we have real exports.
|
||||
export const unstable = {};
|
||||
@@ -7,6 +7,7 @@ import { selectors } from '@grafana/e2e-selectors';
|
||||
import { useStyles2 } from '../../themes/ThemeContext';
|
||||
import { getFocusStyles } from '../../themes/mixins';
|
||||
import { IconName } from '../../types';
|
||||
import { t } from '../../utils/i18n';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { IconButton } from '../IconButton/IconButton';
|
||||
import { Link } from '../Link/Link';
|
||||
@@ -82,6 +83,15 @@ export const PageToolbar = memo(
|
||||
</>
|
||||
);
|
||||
|
||||
const goBackLabel = t('grafana-ui.page-toolbar.go-back', 'Go back (Esc)');
|
||||
const searchParentFolderLabel = t(
|
||||
'grafana-ui.page-toolbar.search-parent-folder',
|
||||
'Search dashboard in the {{parent}} folder',
|
||||
{ parent }
|
||||
);
|
||||
const searchDashboardNameLabel = t('grafana-ui.page-toolbar.search-dashboard-name', 'Search dashboard by name');
|
||||
const searchLinksLabel = t('grafana-ui.page-toolbar.search-links', 'Search links');
|
||||
|
||||
return (
|
||||
<nav className={mainStyle} aria-label={ariaLabel}>
|
||||
<div className={styles.leftWrapper}>
|
||||
@@ -94,7 +104,7 @@ export const PageToolbar = memo(
|
||||
<div className={styles.pageIcon}>
|
||||
<IconButton
|
||||
name="arrow-left"
|
||||
tooltip="Go back (Esc)"
|
||||
tooltip={goBackLabel}
|
||||
tooltipPlacement="bottom"
|
||||
size="xxl"
|
||||
data-testid={selectors.components.BackButton.backArrow}
|
||||
@@ -102,11 +112,11 @@ export const PageToolbar = memo(
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<nav aria-label="Search links" className={styles.navElement}>
|
||||
<nav aria-label={searchLinksLabel} className={styles.navElement}>
|
||||
{parent && parentHref && (
|
||||
<>
|
||||
<Link
|
||||
aria-label={`Search dashboard in the ${parent} folder`}
|
||||
aria-label={searchParentFolderLabel}
|
||||
className={cx(styles.titleText, styles.parentLink, styles.titleLink, styles.truncateText)}
|
||||
href={parentHref}
|
||||
>
|
||||
@@ -126,7 +136,7 @@ export const PageToolbar = memo(
|
||||
<h1 className={styles.h1Styles}>
|
||||
{titleHref ? (
|
||||
<Link
|
||||
aria-label="Search dashboard by name"
|
||||
aria-label={searchDashboardNameLabel}
|
||||
className={cx(styles.titleText, styles.titleLink)}
|
||||
href={titleHref}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { css, cx } from '@emotion/css';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { t } from '../../utils/i18n';
|
||||
import { Button, ButtonVariant } from '../Button';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
|
||||
@@ -97,12 +98,15 @@ export const Pagination = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const previousPageLabel = t('grafana-ui.pagination.previous-page', 'previous page');
|
||||
const nextPageLabel = t('grafana-ui.pagination.next-page', 'next page');
|
||||
|
||||
return (
|
||||
<div className={cx(styles.container, className)}>
|
||||
<ol>
|
||||
<li className={styles.item}>
|
||||
<Button
|
||||
aria-label={`previous page`}
|
||||
aria-label={previousPageLabel}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => onNavigate(currentPage - 1)}
|
||||
@@ -114,7 +118,7 @@ export const Pagination = ({
|
||||
{pageButtons}
|
||||
<li className={styles.item}>
|
||||
<Button
|
||||
aria-label={`next page`}
|
||||
aria-label={nextPageLabel}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => onNavigate(currentPage + 1)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { CSSProperties, PointerEvent, ReactElement, ReactNode, useId, useRef } from 'react';
|
||||
import { CSSProperties, PointerEvent, ReactElement, ReactNode, useId, useRef, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import { useMeasure, useToggle } from 'react-use';
|
||||
|
||||
@@ -150,13 +150,18 @@ export function PanelChrome({
|
||||
const styles = useStyles2(getStyles);
|
||||
const panelContentId = useId();
|
||||
const panelTitleId = useId().replace(/:/g, '_');
|
||||
const { isSelected, onSelect } = useElementSelection(selectionId);
|
||||
const { isSelected, onSelect, isSelectable } = useElementSelection(selectionId);
|
||||
const pointerDownEvt = useRef<PointerEvent | null>(null);
|
||||
|
||||
const hasHeader = !hoverHeader;
|
||||
|
||||
const [isOpen, toggleOpen] = useToggle(true);
|
||||
|
||||
// Highlight the full panel when hovering over header
|
||||
const [selectableHighlight, setSelectableHighlight] = useState(false);
|
||||
const onHeaderEnter = React.useCallback(() => setSelectableHighlight(true), []);
|
||||
const onHeaderLeave = React.useCallback(() => setSelectableHighlight(false), []);
|
||||
|
||||
// if collapsed is not defined, then component is uncontrolled and state is managed internally
|
||||
if (collapsed === undefined) {
|
||||
collapsed = !isOpen;
|
||||
@@ -273,7 +278,8 @@ export function PanelChrome({
|
||||
className={cx(
|
||||
styles.container,
|
||||
isPanelTransparent && styles.transparentContainer,
|
||||
isSelected && 'dashboard-selected-element'
|
||||
isSelected && 'dashboard-selected-element',
|
||||
!isSelected && isSelectable && selectableHighlight && 'dashboard-selectable-element'
|
||||
)}
|
||||
style={containerStyles}
|
||||
aria-labelledby={!!title ? panelTitleId : undefined}
|
||||
@@ -325,6 +331,8 @@ export function PanelChrome({
|
||||
pointerDownEvt.current = null;
|
||||
}
|
||||
}}
|
||||
onMouseEnter={isSelectable ? onHeaderEnter : undefined}
|
||||
onMouseLeave={isSelectable ? onHeaderLeave : undefined}
|
||||
onPointerUp={(evt) => {
|
||||
evt.stopPropagation();
|
||||
if (
|
||||
|
||||
@@ -69,9 +69,18 @@ export function getDashboardGridStyles(theme: GrafanaTheme2) {
|
||||
},
|
||||
|
||||
'.dashboard-selected-element': {
|
||||
outline: `2px dashed ${theme.colors.primary.border}`,
|
||||
outline: `1px dashed ${theme.colors.primary.border}`,
|
||||
outlineOffset: '0px',
|
||||
borderRadius: theme.shape.radius.default,
|
||||
},
|
||||
|
||||
'.dashboard-selectable-element': {
|
||||
'&:hover': {
|
||||
outline: `1px dashed ${theme.colors.border.strong}`,
|
||||
outlineOffset: '0px',
|
||||
borderRadius: theme.shape.radius.default,
|
||||
backgroundColor: theme.colors.emphasize(theme.colors.background.canvas, 0.08),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -85,6 +85,7 @@ func dashboardGuardianResponse(err error) response.Response {
|
||||
// 401: unauthorisedError
|
||||
// 403: forbiddenError
|
||||
// 404: notFoundError
|
||||
// 406: notAcceptableError
|
||||
// 500: internalServerError
|
||||
//
|
||||
//nolint:gocyclo
|
||||
@@ -99,14 +100,10 @@ func (hs *HTTPServer) GetDashboard(c *contextmodel.ReqContext) response.Response
|
||||
return rsp
|
||||
}
|
||||
|
||||
// V2 values should be read from the k8s API
|
||||
// v2 is not supported in /api
|
||||
if strings.HasPrefix(dash.APIVersion, "v2") {
|
||||
root := hs.Cfg.AppSubURL
|
||||
if !strings.HasSuffix(root, "/") {
|
||||
root += "/"
|
||||
}
|
||||
url := fmt.Sprintf("%sapis/dashboard.grafana.app/%s/namespaces/%s/dashboards/%s", root, dash.APIVersion, hs.namespacer(c.OrgID), dash.UID)
|
||||
return response.Redirect(url)
|
||||
url := fmt.Sprintf("/apis/dashboard.grafana.app/%s/namespaces/%s/dashboards/%s", dash.APIVersion, hs.namespacer(c.SignedInUser.GetOrgID()), dash.UID)
|
||||
return response.Error(http.StatusNotAcceptable, "dashboard api version not supported, use "+url+" instead", nil)
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -694,6 +695,46 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
assert.Equal(t, false, dash.Meta.Provisioned)
|
||||
}, mockSQLStore)
|
||||
})
|
||||
|
||||
t.Run("v2 dashboards should not be returned in api", func(t *testing.T) {
|
||||
mockSQLStore := dbtest.NewFakeDB()
|
||||
dashboardService := dashboards.NewFakeDashboardService(t)
|
||||
|
||||
dataValue, err := simplejson.NewJson([]byte(`{"id": 1, "apiVersion": "v2"}`))
|
||||
require.NoError(t, err)
|
||||
qResult := &dashboards.Dashboard{
|
||||
ID: 1,
|
||||
UID: "dash",
|
||||
OrgID: 1,
|
||||
APIVersion: "v2",
|
||||
Data: dataValue,
|
||||
}
|
||||
dashboardService.On("GetDashboard", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardQuery")).Return(qResult, nil)
|
||||
guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanViewValue: true})
|
||||
|
||||
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/dash", "/api/dashboards/uid/:uid", org.RoleEditor, func(sc *scenarioContext) {
|
||||
hs := &HTTPServer{
|
||||
Cfg: setting.NewCfg(),
|
||||
LibraryPanelService: &mockLibraryPanelService{},
|
||||
LibraryElementService: &libraryelementsfake.LibraryElementService{},
|
||||
SQLStore: mockSQLStore,
|
||||
AccessControl: accesscontrolmock.New(),
|
||||
DashboardService: dashboardService,
|
||||
Features: featuremgmt.WithFeatures(),
|
||||
starService: startest.NewStarServiceFake(),
|
||||
tracer: tracing.InitializeTracerForTest(),
|
||||
dashboardProvisioningService: mockDashboardProvisioningService{},
|
||||
folderService: foldertest.NewFakeService(),
|
||||
log: log.New("test"),
|
||||
namespacer: func(orgID int64) string { return strconv.FormatInt(orgID, 10) },
|
||||
}
|
||||
hs.callGetDashboard(sc)
|
||||
|
||||
assert.Equal(t, http.StatusNotAcceptable, sc.resp.Code)
|
||||
result := sc.ToJSON()
|
||||
assert.Equal(t, "dashboard api version not supported, use /apis/dashboard.grafana.app/v2/namespaces/1/dashboards/dash instead", result.Get("message").MustString())
|
||||
}, mockSQLStore)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDashboardVersionsAPIEndpoint(t *testing.T) {
|
||||
|
||||
@@ -52,6 +52,11 @@ type NotFoundError GenericError
|
||||
// swagger:response badRequestError
|
||||
type BadRequestError GenericError
|
||||
|
||||
// NotAcceptableError is returned when the server cannot produce a response matching the accepted formats.
|
||||
//
|
||||
// swagger:response notAcceptableError
|
||||
type NotAcceptableError GenericError
|
||||
|
||||
// ConflictError
|
||||
//
|
||||
// swagger:response conflictError
|
||||
|
||||
+12
-10
@@ -149,7 +149,7 @@ func (hs *HTTPServer) UpdateSignedInUser(c *contextmodel.ReqContext) response.Re
|
||||
cmd.Email = strings.TrimSpace(cmd.Email)
|
||||
cmd.Login = strings.TrimSpace(cmd.Login)
|
||||
|
||||
userID, errResponse := getUserID(c)
|
||||
userID, errResponse := hs.getUserID(c)
|
||||
if errResponse != nil {
|
||||
return errResponse
|
||||
}
|
||||
@@ -349,7 +349,7 @@ func (hs *HTTPServer) UpdateUserEmail(c *contextmodel.ReqContext) response.Respo
|
||||
// 403: forbiddenError
|
||||
// 500: internalServerError
|
||||
func (hs *HTTPServer) GetSignedInUserOrgList(c *contextmodel.ReqContext) response.Response {
|
||||
userID, errResponse := getUserID(c)
|
||||
userID, errResponse := hs.getUserID(c)
|
||||
if errResponse != nil {
|
||||
return errResponse
|
||||
}
|
||||
@@ -369,7 +369,7 @@ func (hs *HTTPServer) GetSignedInUserOrgList(c *contextmodel.ReqContext) respons
|
||||
// 403: forbiddenError
|
||||
// 500: internalServerError
|
||||
func (hs *HTTPServer) GetSignedInUserTeamList(c *contextmodel.ReqContext) response.Response {
|
||||
userID, errResponse := getUserID(c)
|
||||
userID, errResponse := hs.getUserID(c)
|
||||
if errResponse != nil {
|
||||
return errResponse
|
||||
}
|
||||
@@ -479,7 +479,7 @@ func (hs *HTTPServer) UserSetUsingOrg(c *contextmodel.ReqContext) response.Respo
|
||||
return response.Error(http.StatusBadRequest, "id is invalid", err)
|
||||
}
|
||||
|
||||
userID, errResponse := getUserID(c)
|
||||
userID, errResponse := hs.getUserID(c)
|
||||
if errResponse != nil {
|
||||
return errResponse
|
||||
}
|
||||
@@ -504,7 +504,8 @@ func (hs *HTTPServer) ChangeActiveOrgAndRedirectToHome(c *contextmodel.ReqContex
|
||||
}
|
||||
|
||||
if !c.SignedInUser.IsIdentityType(claims.TypeUser) {
|
||||
c.JsonApiErr(http.StatusForbidden, "Endpoint only available for users", nil)
|
||||
hs.log.Debug("Requested endpoint only available to users")
|
||||
c.JsonApiErr(http.StatusNotModified, "Endpoint only available for users", nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -548,7 +549,7 @@ func (hs *HTTPServer) ChangeUserPassword(c *contextmodel.ReqContext) response.Re
|
||||
return response.Error(http.StatusBadRequest, "bad request data", err)
|
||||
}
|
||||
|
||||
userID, errResponse := getUserID(c)
|
||||
userID, errResponse := hs.getUserID(c)
|
||||
if errResponse != nil {
|
||||
return errResponse
|
||||
}
|
||||
@@ -584,7 +585,7 @@ func (hs *HTTPServer) SetHelpFlag(c *contextmodel.ReqContext) response.Response
|
||||
return response.Error(http.StatusBadRequest, "id is invalid", err)
|
||||
}
|
||||
|
||||
userID, errResponse := getUserID(c)
|
||||
userID, errResponse := hs.getUserID(c)
|
||||
if errResponse != nil {
|
||||
return errResponse
|
||||
}
|
||||
@@ -614,7 +615,7 @@ func (hs *HTTPServer) SetHelpFlag(c *contextmodel.ReqContext) response.Response
|
||||
// 403: forbiddenError
|
||||
// 500: internalServerError
|
||||
func (hs *HTTPServer) ClearHelpFlags(c *contextmodel.ReqContext) response.Response {
|
||||
userID, errResponse := getUserID(c)
|
||||
userID, errResponse := hs.getUserID(c)
|
||||
if errResponse != nil {
|
||||
return errResponse
|
||||
}
|
||||
@@ -627,9 +628,10 @@ func (hs *HTTPServer) ClearHelpFlags(c *contextmodel.ReqContext) response.Respon
|
||||
return response.JSON(http.StatusOK, &util.DynMap{"message": "Help flag set", "helpFlags1": flags})
|
||||
}
|
||||
|
||||
func getUserID(c *contextmodel.ReqContext) (int64, *response.NormalResponse) {
|
||||
func (hs *HTTPServer) getUserID(c *contextmodel.ReqContext) (int64, *response.NormalResponse) {
|
||||
if !c.SignedInUser.IsIdentityType(claims.TypeUser) {
|
||||
return 0, response.Error(http.StatusForbidden, "Endpoint only available for users", nil)
|
||||
hs.log.Debug("Requested endpoint only available to users")
|
||||
return 0, response.Error(http.StatusNotModified, "Endpoint only available for users", nil)
|
||||
}
|
||||
|
||||
userID, err := c.SignedInUser.GetInternalID()
|
||||
|
||||
@@ -43,7 +43,7 @@ type SearchHandler struct {
|
||||
}
|
||||
|
||||
func NewSearchHandler(tracer trace.Tracer, dual dualwrite.Service, legacyDashboardSearcher resource.ResourceIndexClient, resourceClient resource.ResourceClient, features featuremgmt.FeatureToggles) *SearchHandler {
|
||||
searchClient := resource.NewSearchClient(dual, dashboardv0alpha1.DashboardResourceInfo.GroupResource(), resourceClient, legacyDashboardSearcher)
|
||||
searchClient := resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), dashboardv0alpha1.DashboardResourceInfo.GroupResource(), resourceClient, legacyDashboardSearcher)
|
||||
return &SearchHandler{
|
||||
client: searchClient,
|
||||
log: log.New("grafana-apiserver.dashboards.search"),
|
||||
@@ -369,6 +369,10 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
s.log.Debug("search result hits and cost", "total_hits", result.TotalHits, "query_cost", result.QueryCost)
|
||||
}
|
||||
|
||||
parsedResults, err := dashboardsearch.ParseResults(result, searchRequest.Offset)
|
||||
if err != nil {
|
||||
errhttp.Write(ctx, err, w)
|
||||
@@ -378,8 +382,8 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
|
||||
if len(searchRequest.SortBy) == 0 {
|
||||
// default sort by resource descending ( folders then dashboards ) then title
|
||||
sort.Slice(parsedResults.Hits, func(i, j int) bool {
|
||||
return parsedResults.Hits[i].Resource > parsedResults.Hits[j].Resource ||
|
||||
(parsedResults.Hits[i].Resource == parsedResults.Hits[j].Resource && strings.ToLower(parsedResults.Hits[i].Title) < strings.ToLower(parsedResults.Hits[j].Title))
|
||||
// Just sorting by resource for now. The rest should be sorted by search score already
|
||||
return parsedResults.Hits[i].Resource > parsedResults.Hits[j].Resource
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -276,7 +276,7 @@ func TestSearchHandler(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Sort - default sort by resource then title", func(t *testing.T) {
|
||||
t.Run("Sort - default sort by resource", func(t *testing.T) {
|
||||
rows := make([]*resource.ResourceTableRow, len(mockResults))
|
||||
for i, r := range mockResults {
|
||||
rows[i] = &resource.ResourceTableRow{
|
||||
@@ -334,7 +334,7 @@ func TestSearchHandler(t *testing.T) {
|
||||
err := json.NewDecoder(resp.Body).Decode(p)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, len(mockResults), len(p.Hits))
|
||||
assert.Equal(t, mockResults[3].Value, p.Hits[0].Title)
|
||||
assert.Equal(t, mockResults[2].Value, p.Hits[0].Title)
|
||||
assert.Equal(t, mockResults[1].Value, p.Hits[3].Title)
|
||||
})
|
||||
}
|
||||
@@ -646,16 +646,16 @@ var mockResults = []MockResult{
|
||||
Resource: "dashboard",
|
||||
Value: "Dashboard 2",
|
||||
},
|
||||
{
|
||||
Name: "f2",
|
||||
Resource: "folder",
|
||||
Value: "Folder 2",
|
||||
},
|
||||
{
|
||||
Name: "f1",
|
||||
Resource: "folder",
|
||||
Value: "Folder 1",
|
||||
},
|
||||
{
|
||||
Name: "f2",
|
||||
Resource: "folder",
|
||||
Value: "Folder 2",
|
||||
},
|
||||
}
|
||||
|
||||
func (m *MockClient) Search(ctx context.Context, in *resource.ResourceSearchRequest, opts ...grpc.CallOption) (*resource.ResourceSearchResponse, error) {
|
||||
|
||||
@@ -68,10 +68,11 @@ func ProvideBackgroundServiceRegistry(
|
||||
pluginInstaller *plugininstaller.Service,
|
||||
zanzanaReconciler *dualwrite.ZanzanaReconciler,
|
||||
appRegistry *appregistry.Service,
|
||||
pluginDashboardUpdater *plugindashboardsservice.DashboardUpdater,
|
||||
// Need to make sure these are initialized, is there a better place to put them?
|
||||
_ dashboardsnapshots.Service,
|
||||
_ serviceaccounts.Service, _ *guardian.Provider,
|
||||
_ *plugindashboardsservice.DashboardUpdater, _ *sanitizer.Provider,
|
||||
_ *sanitizer.Provider,
|
||||
_ *grpcserver.HealthService, _ *grpcserver.ReflectionService,
|
||||
_ *ldapapi.Service, _ *apiregistry.Service, _ auth.IDService, _ *teamapi.TeamAPI, _ ssosettings.Service,
|
||||
_ cloudmigration.Service, _ authnimpl.Registration,
|
||||
@@ -113,6 +114,7 @@ func ProvideBackgroundServiceRegistry(
|
||||
pluginInstaller,
|
||||
zanzanaReconciler,
|
||||
appRegistry,
|
||||
pluginDashboardUpdater,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ type k8sHandler struct {
|
||||
func NewK8sHandler(dual dualwrite.Service, namespacer request.NamespaceMapper, gvr schema.GroupVersionResource,
|
||||
restConfig func(context.Context) (*rest.Config, error), dashStore dashboards.Store, userSvc user.Service, resourceClient resource.ResourceClient, sorter sort.Service) K8sHandler {
|
||||
legacySearcher := legacysearcher.NewDashboardSearchClient(dashStore, sorter)
|
||||
searchClient := resource.NewSearchClient(dual, gvr.GroupResource(), resourceClient, legacySearcher)
|
||||
searchClient := resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), gvr.GroupResource(), resourceClient, legacySearcher)
|
||||
|
||||
return &k8sHandler{
|
||||
namespacer: namespacer,
|
||||
|
||||
@@ -897,6 +897,299 @@ func TestService_Check(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_List(t *testing.T) {
|
||||
callingService := authn.NewAccessTokenAuthInfo(authn.Claims[authn.AccessTokenClaims]{
|
||||
Claims: jwt.Claims{
|
||||
Subject: types.NewTypeID(types.TypeAccessPolicy, "some-service"),
|
||||
Audience: []string{"authzservice"},
|
||||
},
|
||||
Rest: authn.AccessTokenClaims{Namespace: "org-12"},
|
||||
})
|
||||
|
||||
type testCase struct {
|
||||
name string
|
||||
req *authzv1.ListRequest
|
||||
permissions []accesscontrol.Permission
|
||||
expected *authzv1.ListResponse
|
||||
expectErr bool
|
||||
}
|
||||
|
||||
t.Run("Require auth info", func(t *testing.T) {
|
||||
s := setupService()
|
||||
ctx := context.Background()
|
||||
_, err := s.List(ctx, &authzv1.ListRequest{
|
||||
Namespace: "org-12",
|
||||
Subject: "user:test-uid",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Verb: "get",
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "could not get auth info")
|
||||
})
|
||||
|
||||
testCases := []testCase{
|
||||
{
|
||||
name: "should error if no namespace is provided",
|
||||
req: &authzv1.ListRequest{
|
||||
Namespace: "",
|
||||
Subject: "user:test-uid",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Verb: "get",
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "should error if caller namespace does not match request namespace",
|
||||
req: &authzv1.ListRequest{
|
||||
Namespace: "org-13",
|
||||
Subject: "user:test-uid",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Verb: "get",
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "should error if no subject is provided",
|
||||
req: &authzv1.ListRequest{
|
||||
Namespace: "org-12",
|
||||
Subject: "",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Verb: "get",
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "should error if an unsupported subject type is provided",
|
||||
req: &authzv1.ListRequest{
|
||||
Namespace: "org-12",
|
||||
Subject: "api-key:12",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Verb: "get",
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "should error if an invalid subject is provided",
|
||||
req: &authzv1.ListRequest{
|
||||
Namespace: "org-12",
|
||||
Subject: "invalid:12",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Verb: "get",
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "should error if an unknown group is provided",
|
||||
req: &authzv1.ListRequest{
|
||||
Namespace: "org-12",
|
||||
Subject: "user:test-uid",
|
||||
Group: "unknown.grafana.app",
|
||||
Resource: "unknown",
|
||||
Verb: "get",
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "should error if an unknown verb is provided",
|
||||
req: &authzv1.ListRequest{
|
||||
Namespace: "org-12",
|
||||
Subject: "user:test-uid",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Verb: "unknown",
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
}
|
||||
t.Run("Request validation", func(t *testing.T) {
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := setupService()
|
||||
ctx := types.WithAuthInfo(context.Background(), callingService)
|
||||
userID := &store.UserIdentifiers{UID: "test-uid", ID: 1}
|
||||
store := &fakeStore{
|
||||
userID: userID,
|
||||
userPermissions: tc.permissions,
|
||||
}
|
||||
s.store = store
|
||||
s.permissionStore = store
|
||||
s.identityStore = &fakeIdentityStore{teams: []int64{1, 2}}
|
||||
|
||||
_, err := s.List(ctx, tc.req)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
testCases = []testCase{
|
||||
{
|
||||
name: "should list permissions for user with permission",
|
||||
req: &authzv1.ListRequest{
|
||||
Namespace: "org-12",
|
||||
Subject: "user:test-uid",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Verb: "get",
|
||||
},
|
||||
permissions: []accesscontrol.Permission{
|
||||
{Action: "dashboards:read", Scope: "dashboards:uid:dash1"},
|
||||
{Action: "dashboards:read", Scope: "dashboards:uid:dash2"},
|
||||
{Action: "dashboards:read", Scope: "folders:uid:fold1"},
|
||||
},
|
||||
expected: &authzv1.ListResponse{
|
||||
Items: []string{"dash1", "dash2"},
|
||||
Folders: []string{"fold1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should return empty list for user without permission",
|
||||
req: &authzv1.ListRequest{
|
||||
Namespace: "org-12",
|
||||
Subject: "user:test-uid",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Verb: "get",
|
||||
},
|
||||
permissions: []accesscontrol.Permission{},
|
||||
expected: &authzv1.ListResponse{},
|
||||
},
|
||||
}
|
||||
t.Run("User permission list", func(t *testing.T) {
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := setupService()
|
||||
ctx := types.WithAuthInfo(context.Background(), callingService)
|
||||
userID := &store.UserIdentifiers{UID: "test-uid", ID: 1}
|
||||
store := &fakeStore{
|
||||
userID: userID,
|
||||
userPermissions: tc.permissions,
|
||||
}
|
||||
s.store = store
|
||||
s.permissionStore = store
|
||||
s.identityStore = &fakeIdentityStore{teams: []int64{1, 2}}
|
||||
|
||||
resp, err := s.List(ctx, tc.req)
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, resp.Items, tc.expected.Items)
|
||||
require.ElementsMatch(t, resp.Folders, tc.expected.Folders)
|
||||
|
||||
// Check cache
|
||||
id, ok := s.idCache.Get(ctx, userIdentifierCacheKey("org-12", "test-uid"))
|
||||
require.True(t, ok)
|
||||
require.Equal(t, id.UID, "test-uid")
|
||||
perms, ok := s.permCache.Get(ctx, userPermCacheKey("org-12", "test-uid", "dashboards:read"))
|
||||
require.True(t, ok)
|
||||
require.Len(t, perms, len(tc.expected.Items)+len(tc.expected.Folders))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
testCases = []testCase{
|
||||
{
|
||||
name: "should list permissions for anonymous with permission",
|
||||
req: &authzv1.ListRequest{
|
||||
Namespace: "org-12",
|
||||
Subject: "anonymous:0",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Verb: "get",
|
||||
},
|
||||
permissions: []accesscontrol.Permission{
|
||||
{Action: "dashboards:read", Scope: "dashboards:uid:dash1"},
|
||||
{Action: "dashboards:read", Scope: "dashboards:uid:dash2"},
|
||||
{Action: "dashboards:read", Scope: "folders:uid:fold1"},
|
||||
},
|
||||
expected: &authzv1.ListResponse{
|
||||
Items: []string{"dash1", "dash2"},
|
||||
Folders: []string{"fold1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should return empty list for anonymous without permission",
|
||||
req: &authzv1.ListRequest{
|
||||
Namespace: "org-12",
|
||||
Subject: "anonymous:0",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Verb: "get",
|
||||
},
|
||||
permissions: []accesscontrol.Permission{},
|
||||
expected: &authzv1.ListResponse{},
|
||||
},
|
||||
}
|
||||
t.Run("Anonymous permission list", func(t *testing.T) {
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := setupService()
|
||||
ctx := types.WithAuthInfo(context.Background(), callingService)
|
||||
store := &fakeStore{userPermissions: tc.permissions}
|
||||
s.store = store
|
||||
s.permissionStore = store
|
||||
s.identityStore = &fakeIdentityStore{teams: []int64{1, 2}}
|
||||
|
||||
resp, err := s.List(ctx, tc.req)
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, resp.Items, tc.expected.Items)
|
||||
require.ElementsMatch(t, resp.Folders, tc.expected.Folders)
|
||||
|
||||
// Check cache
|
||||
perms, ok := s.permCache.Get(ctx, anonymousPermCacheKey("org-12", "dashboards:read"))
|
||||
require.True(t, ok)
|
||||
require.Len(t, perms, len(tc.expected.Items)+len(tc.expected.Folders))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
testCases = []testCase{
|
||||
{
|
||||
name: "should list permissions for rendering",
|
||||
req: &authzv1.ListRequest{
|
||||
Namespace: "org-12",
|
||||
Subject: "render:0",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Verb: "get",
|
||||
},
|
||||
expected: &authzv1.ListResponse{
|
||||
All: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should deny rendering access to another app resources",
|
||||
req: &authzv1.ListRequest{
|
||||
Namespace: "org-12",
|
||||
Subject: "render:0",
|
||||
Group: "another.grafana.app",
|
||||
Resource: "dashboards",
|
||||
Verb: "get",
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
}
|
||||
t.Run("Rendering permission list", func(t *testing.T) {
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := setupService()
|
||||
ctx := types.WithAuthInfo(context.Background(), callingService)
|
||||
|
||||
resp, err := s.List(ctx, tc.req)
|
||||
if tc.expectErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expected.All, resp.All)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func setupService() *Service {
|
||||
cache := cache.NewLocalCache(cache.Config{Expiry: 5 * time.Minute, CleanupInterval: 5 * time.Minute})
|
||||
logger := log.New("authz-rbac-service")
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/api/routing"
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/httpclient"
|
||||
"github.com/grafana/grafana/pkg/infra/kvstore"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
@@ -47,6 +46,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/quota/quotatest"
|
||||
secretsfakes "github.com/grafana/grafana/pkg/services/secrets/fakes"
|
||||
secretskv "github.com/grafana/grafana/pkg/services/secrets/kvstore"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/services/user/usertest"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
@@ -60,6 +60,8 @@ func Test_NoopServiceDoesNothing(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_CreateGetAndDeleteToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := setUpServiceTest(t, false)
|
||||
|
||||
createResp, err := s.CreateToken(context.Background())
|
||||
@@ -82,6 +84,8 @@ func Test_CreateGetAndDeleteToken(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_GetSnapshotStatusFromGMS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setupTest := func(ctx context.Context) (service *Service, snapshotUID string, sessionUID string) {
|
||||
s := setUpServiceTest(t, false).(*Service)
|
||||
|
||||
@@ -150,6 +154,8 @@ func Test_GetSnapshotStatusFromGMS(t *testing.T) {
|
||||
}
|
||||
|
||||
t.Run("test case: gms snapshot initialized", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -172,6 +178,8 @@ func Test_GetSnapshotStatusFromGMS(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("test case: gms snapshot processing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -194,6 +202,8 @@ func Test_GetSnapshotStatusFromGMS(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("test case: gms snapshot finished", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -216,6 +226,8 @@ func Test_GetSnapshotStatusFromGMS(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("test case: gms snapshot canceled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -238,6 +250,8 @@ func Test_GetSnapshotStatusFromGMS(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("test case: gms snapshot error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -260,6 +274,8 @@ func Test_GetSnapshotStatusFromGMS(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("test case: gms snapshot unknown", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -292,6 +308,8 @@ func Test_GetSnapshotStatusFromGMS(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("GMS results applied to local snapshot", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -335,6 +353,8 @@ func Test_GetSnapshotStatusFromGMS(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_OnlyQueriesStatusFromGMSWhenRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := setUpServiceTest(t, false).(*Service)
|
||||
|
||||
gmsClientMock := &gmsClientMock{
|
||||
@@ -407,6 +427,8 @@ func Test_OnlyQueriesStatusFromGMSWhenRequired(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_DeletedDashboardsNotMigrated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := setUpServiceTest(t, false).(*Service)
|
||||
|
||||
// modify what the mock returns for just this test case
|
||||
@@ -454,6 +476,8 @@ func Test_SortFolders(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDeleteSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := setUpServiceTest(t, false).(*Service)
|
||||
user := &user.SignedInUser{UserUID: "user123"}
|
||||
|
||||
@@ -496,7 +520,11 @@ func TestDeleteSession(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestReportEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("when the session is nil, it does not report the event", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -513,6 +541,8 @@ func TestReportEvent(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("when the session is not nil, it reports the event", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -530,6 +560,8 @@ func TestReportEvent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetFolderNamesForFolderUIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := setUpServiceTest(t, false).(*Service)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
@@ -594,6 +626,8 @@ func TestGetFolderNamesForFolderUIDs(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetParentNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := setUpServiceTest(t, false).(*Service)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
@@ -654,6 +688,8 @@ func TestGetParentNames(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetLibraryElementsCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := setUpServiceTest(t, false).(*Service)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -718,6 +754,8 @@ func TestIsPublicSignatureType(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetPlugins(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := setUpServiceTest(t, false).(*Service)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -817,7 +855,6 @@ func TestGetPlugins(t *testing.T) {
|
||||
type configOverrides func(c *setting.Cfg)
|
||||
|
||||
func setUpServiceTest(t *testing.T, withDashboardMock bool, cfgOverrides ...configOverrides) cloudmigration.Service {
|
||||
sqlStore := db.InitTestDB(t)
|
||||
secretsService := secretsfakes.NewFakeSecretsService()
|
||||
rr := routing.NewRouteRegister()
|
||||
tracer := tracing.InitializeTracerForTest()
|
||||
@@ -862,6 +899,14 @@ func setUpServiceTest(t *testing.T, withDashboardMock bool, cfgOverrides ...conf
|
||||
featuremgmt.FlagDashboardRestore, // needed for skipping creating soft-deleted dashboards in the snapshot.
|
||||
)
|
||||
|
||||
sqlStore := sqlstore.NewTestStore(t,
|
||||
sqlstore.WithCfg(cfg),
|
||||
sqlstore.WithFeatureFlags(
|
||||
featuremgmt.FlagOnPremToCloudMigrations,
|
||||
featuremgmt.FlagDashboardRestore, // needed for skipping creating soft-deleted dashboards in the snapshot.
|
||||
),
|
||||
)
|
||||
|
||||
kvStore := kvstore.ProvideService(sqlStore)
|
||||
|
||||
bus := bus.ProvideBus(tracer)
|
||||
|
||||
@@ -22,10 +22,14 @@ import (
|
||||
)
|
||||
|
||||
func TestGetAlertMuteTimings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
t.Run("it returns the mute timings", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := setUpServiceTest(t, false).(*Service)
|
||||
s.features = featuremgmt.WithFeatures(featuremgmt.FlagOnPremToCloudMigrations)
|
||||
|
||||
@@ -42,10 +46,14 @@ func TestGetAlertMuteTimings(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetNotificationTemplates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
t.Run("it returns the notification templates", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := setUpServiceTest(t, false).(*Service)
|
||||
|
||||
user := &user.SignedInUser{OrgID: 1}
|
||||
@@ -61,10 +69,14 @@ func TestGetNotificationTemplates(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetContactPoints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
t.Run("it returns the contact points", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := setUpServiceTest(t, false).(*Service)
|
||||
|
||||
user := &user.SignedInUser{
|
||||
@@ -89,10 +101,14 @@ func TestGetContactPoints(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetNotificationPolicies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
t.Run("it returns the contact points", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := setUpServiceTest(t, false).(*Service)
|
||||
|
||||
user := &user.SignedInUser{OrgID: 1}
|
||||
@@ -113,10 +129,14 @@ func TestGetNotificationPolicies(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetAlertRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
t.Run("it returns the alert rules", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := setUpServiceTest(t, false).(*Service)
|
||||
|
||||
user := &user.SignedInUser{OrgID: 1}
|
||||
@@ -130,6 +150,8 @@ func TestGetAlertRules(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("when the alert_rules_state config is `paused`, then the alert rules are all returned in `paused` state", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
alertRulesState := func(c *setting.Cfg) {
|
||||
c.CloudMigration.AlertRulesState = setting.GMSAlertRulesPaused
|
||||
}
|
||||
@@ -153,10 +175,14 @@ func TestGetAlertRules(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetAlertRuleGroups(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
t.Run("it returns the alert rule groups", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := setUpServiceTest(t, false).(*Service)
|
||||
|
||||
user := &user.SignedInUser{OrgID: 1}
|
||||
@@ -190,6 +216,8 @@ func TestGetAlertRuleGroups(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("with the alert rules state set to paused, it returns the alert rule groups with alert rules paused", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
alertRulesState := func(c *setting.Cfg) {
|
||||
c.CloudMigration.AlertRulesState = setting.GMSAlertRulesPaused
|
||||
}
|
||||
|
||||
@@ -6,21 +6,17 @@ import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/services/cloudmigration"
|
||||
fakeSecrets "github.com/grafana/grafana/pkg/services/secrets/fakes"
|
||||
secretskv "github.com/grafana/grafana/pkg/services/secrets/kvstore"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/tests/testsuite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
testsuite.Run(m)
|
||||
}
|
||||
|
||||
func Test_GetAllCloudMigrationSessions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, s := setUpTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -47,6 +43,8 @@ func Test_GetAllCloudMigrationSessions(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_CreateMigrationSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, s := setUpTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -77,8 +75,11 @@ func Test_CreateMigrationSession(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_GetMigrationSessionByUID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, s := setUpTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("find session by uid", func(t *testing.T) {
|
||||
uid := "qwerty"
|
||||
orgId := int64(1)
|
||||
@@ -114,6 +115,8 @@ func Test_DeleteMigrationSession(t *testing.T) {
|
||||
*/
|
||||
|
||||
func Test_SnapshotManagement(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, s := setUpTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -166,6 +169,8 @@ func Test_SnapshotManagement(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_SnapshotResources(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, s := setUpTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -242,6 +247,8 @@ func Test_SnapshotResources(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetSnapshotList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, s := setUpTest(t)
|
||||
// Taken from setUpTest
|
||||
sessionUID := "qwerty"
|
||||
@@ -367,7 +374,8 @@ func TestDecryptToken(t *testing.T) {
|
||||
}
|
||||
|
||||
func setUpTest(t *testing.T) (*sqlstore.SQLStore, *sqlStore) {
|
||||
testDB := db.InitTestDB(t)
|
||||
testDB := sqlstore.NewTestStore(t)
|
||||
|
||||
s := &sqlStore{
|
||||
db: testDB,
|
||||
secretsService: fakeSecrets.FakeSecretsService{},
|
||||
|
||||
@@ -279,6 +279,7 @@ func (dr *DashboardServiceImpl) GetProvisionedDashboardDataByDashboardID(ctx con
|
||||
|
||||
for _, org := range orgs {
|
||||
res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
|
||||
ManagedBy: utils.ManagerKindClassicFP, // nolint:staticcheck
|
||||
OrgId: org.ID,
|
||||
DashboardIds: []int64{dashboardID},
|
||||
})
|
||||
@@ -306,6 +307,7 @@ func (dr *DashboardServiceImpl) GetProvisionedDashboardDataByDashboardUID(ctx co
|
||||
}
|
||||
|
||||
res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
|
||||
ManagedBy: utils.ManagerKindClassicFP, // nolint:staticcheck
|
||||
OrgId: orgID,
|
||||
DashboardUIDs: []string{dashboardUID},
|
||||
})
|
||||
@@ -569,6 +571,7 @@ func (dr *DashboardServiceImpl) DeleteOrphanedProvisionedDashboards(ctx context.
|
||||
ctx, _ := identity.WithServiceIdentity(ctx, org.ID)
|
||||
// find all dashboards in the org that have a file repo set that is not in the given readers list
|
||||
foundDashs, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
|
||||
ManagedBy: utils.ManagerKindClassicFP, //nolint:staticcheck
|
||||
ManagerIdentityNotIn: cmd.ReaderNames,
|
||||
OrgId: org.ID,
|
||||
})
|
||||
|
||||
@@ -877,7 +877,8 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
|
||||
"spec": map[string]any{},
|
||||
}}, nil).Once()
|
||||
k8sCliMock.On("Search", mock.Anything, int64(1), mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool {
|
||||
return req.Options.Fields[0].Key == "manager.id" && req.Options.Fields[0].Values[0] == "test" && req.Options.Fields[0].Operator == "notin"
|
||||
// nolint:staticcheck
|
||||
return req.Options.Fields[0].Key == "manager.kind" && req.Options.Fields[0].Values[0] == string(utils.ManagerKindClassicFP) && req.Options.Fields[1].Key == "manager.id" && req.Options.Fields[1].Values[0] == "test" && req.Options.Fields[1].Operator == "notin"
|
||||
})).Return(&resource.ResourceSearchResponse{
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
@@ -907,7 +908,8 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
|
||||
}, nil).Once()
|
||||
|
||||
k8sCliMock.On("Search", mock.Anything, int64(2), mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool {
|
||||
return req.Options.Fields[0].Key == "manager.id" && req.Options.Fields[0].Values[0] == "test" && req.Options.Fields[0].Operator == "notin"
|
||||
// nolint:staticcheck
|
||||
return req.Options.Fields[0].Key == "manager.kind" && req.Options.Fields[0].Values[0] == string(utils.ManagerKindClassicFP) && req.Options.Fields[1].Key == "manager.id" && req.Options.Fields[1].Values[0] == "test" && req.Options.Fields[1].Operator == "notin"
|
||||
})).Return(&resource.ResourceSearchResponse{
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
|
||||
@@ -223,8 +224,13 @@ func (s *Service) getDashIDMaybeEmpty(ctx context.Context, uid string, orgID int
|
||||
func (s *Service) getHistoryThroughK8s(ctx context.Context, orgID int64, dashboardUID string, rv int64) (*dashver.DashboardVersionDTO, error) {
|
||||
out, err := s.k8sclient.Get(ctx, dashboardUID, orgID, v1.GetOptions{ResourceVersion: strconv.FormatInt(rv, 10)})
|
||||
if err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
return nil, dashboards.ErrDashboardNotFound
|
||||
}
|
||||
|
||||
return nil, err
|
||||
} else if out == nil {
|
||||
}
|
||||
if out == nil {
|
||||
return nil, dashboards.ErrDashboardNotFound
|
||||
}
|
||||
|
||||
@@ -243,8 +249,13 @@ func (s *Service) listHistoryThroughK8s(ctx context.Context, orgID int64, dashbo
|
||||
Continue: continueToken,
|
||||
})
|
||||
if err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
return nil, dashboards.ErrDashboardNotFound
|
||||
}
|
||||
|
||||
return nil, err
|
||||
} else if out == nil {
|
||||
}
|
||||
if out == nil {
|
||||
return nil, dashboards.ErrDashboardNotFound
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,10 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
@@ -114,6 +116,23 @@ func TestDashboardVersionService(t *testing.T) {
|
||||
Data: simplejson.NewFromAny(map[string]any{"uid": "uid", "version": int64(11)}),
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("should dashboard not found error when k8s returns not found", 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(featuremgmt.FlagKubernetesClientDashboardsFolders)
|
||||
dashboardService.On("GetDashboardUIDByID", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardRefByIDQuery")).Return(&dashboards.DashboardRef{UID: "uid"}, nil)
|
||||
mockCli.On("Get", mock.Anything, "uid", int64(1), v1.GetOptions{ResourceVersion: "10"}, mock.Anything).Return(nil, apierrors.NewNotFound(schema.GroupResource{Group: "dashboards.dashboard.grafana.app", Resource: "dashboard"}, "uid"))
|
||||
|
||||
_, err := dashboardVersionService.Get(context.Background(), &dashver.GetDashboardVersionQuery{
|
||||
DashboardID: 42,
|
||||
OrgID: 1,
|
||||
Version: 10,
|
||||
})
|
||||
require.ErrorIs(t, err, dashboards.ErrDashboardNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteExpiredVersions(t *testing.T) {
|
||||
@@ -266,6 +285,21 @@ func TestListDashboardVersions(t *testing.T) {
|
||||
Data: simplejson.NewFromAny(map[string]any{"uid": "uid", "version": int64(5)}),
|
||||
}}}, res)
|
||||
})
|
||||
|
||||
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()}
|
||||
mockCli := new(client.MockK8sHandler)
|
||||
dashboardVersionService.k8sclient = mockCli
|
||||
dashboardVersionService.features = featuremgmt.WithFeatures(featuremgmt.FlagKubernetesClientDashboardsFolders)
|
||||
dashboardService.On("GetDashboardUIDByID", mock.Anything,
|
||||
mock.AnythingOfType("*dashboards.GetDashboardRefByIDQuery")).
|
||||
Return(&dashboards.DashboardRef{UID: "uid"}, nil)
|
||||
mockCli.On("List", mock.Anything, mock.Anything, mock.Anything).Return(nil, apierrors.NewNotFound(schema.GroupResource{Group: "dashboards.dashboard.grafana.app", Resource: "dashboard"}, "uid"))
|
||||
query := dashver.ListDashboardVersionsQuery{DashboardID: 42}
|
||||
_, err := dashboardVersionService.List(context.Background(), &query)
|
||||
require.ErrorIs(t, dashboards.ErrDashboardNotFound, err)
|
||||
})
|
||||
}
|
||||
|
||||
type FakeDashboardVersionStore struct {
|
||||
|
||||
@@ -1070,8 +1070,9 @@ var (
|
||||
{
|
||||
Name: "ssoSettingsSAML",
|
||||
Description: "Use the new SSO Settings API to configure the SAML connector",
|
||||
Stage: FeatureStagePublicPreview,
|
||||
Stage: FeatureStageGeneralAvailability,
|
||||
Owner: identityAccessTeam,
|
||||
Expression: "true",
|
||||
AllowSelfServe: true,
|
||||
},
|
||||
{
|
||||
@@ -1789,6 +1790,24 @@ var (
|
||||
Owner: grafanaAlertingSquad,
|
||||
Expression: "true", // enabled by default
|
||||
},
|
||||
{
|
||||
Name: "grafanaManagedRecordingRulesDatasources",
|
||||
Description: "Enables writing to data sources for Grafana-managed recording rules.",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaAlertingSquad,
|
||||
AllowSelfServe: false,
|
||||
HideFromAdminPage: true,
|
||||
HideFromDocs: true,
|
||||
},
|
||||
{
|
||||
Name: "inviteUserExperimental",
|
||||
Description: "Renders invite user button along the app",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaSharingSquad,
|
||||
HideFromAdminPage: true,
|
||||
HideFromDocs: true,
|
||||
FrontendOnly: true,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ kubernetesAggregator,experimental,@grafana/grafana-app-platform-squad,false,true
|
||||
expressionParser,experimental,@grafana/grafana-app-platform-squad,false,true,false
|
||||
groupByVariable,experimental,@grafana/dashboards-squad,false,false,false
|
||||
scopeFilters,experimental,@grafana/dashboards-squad,false,false,false
|
||||
ssoSettingsSAML,preview,@grafana/identity-access-team,false,false,false
|
||||
ssoSettingsSAML,GA,@grafana/identity-access-team,false,false,false
|
||||
oauthRequireSubClaim,experimental,@grafana/identity-access-team,false,false,false
|
||||
newDashboardWithFiltersAndGroupBy,experimental,@grafana/dashboards-squad,false,false,false
|
||||
cloudWatchNewLabelParsing,GA,@grafana/aws-datasources,false,false,false
|
||||
@@ -237,3 +237,5 @@ newShareReportDrawer,experimental,@grafana/sharing-squad,false,false,false
|
||||
rendererDisableAppPluginsPreload,experimental,@grafana/sharing-squad,false,false,true
|
||||
assetSriChecks,experimental,@grafana/frontend-ops,false,false,true
|
||||
alertRuleRestore,preview,@grafana/alerting-squad,false,false,false
|
||||
grafanaManagedRecordingRulesDatasources,experimental,@grafana/alerting-squad,false,false,false
|
||||
inviteUserExperimental,experimental,@grafana/sharing-squad,false,false,true
|
||||
|
@@ -958,4 +958,12 @@ const (
|
||||
// FlagAlertRuleRestore
|
||||
// Enables the alert rule restore feature
|
||||
FlagAlertRuleRestore = "alertRuleRestore"
|
||||
|
||||
// FlagGrafanaManagedRecordingRulesDatasources
|
||||
// Enables writing to data sources for Grafana-managed recording rules.
|
||||
FlagGrafanaManagedRecordingRulesDatasources = "grafanaManagedRecordingRulesDatasources"
|
||||
|
||||
// FlagInviteUserExperimental
|
||||
// Renders invite user button along the app
|
||||
FlagInviteUserExperimental = "inviteUserExperimental"
|
||||
)
|
||||
@@ -1964,6 +1964,20 @@
|
||||
"hideFromDocs": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "grafanaManagedRecordingRulesDatasources",
|
||||
"resourceVersion": "1741291902441",
|
||||
"creationTimestamp": "2025-03-06T20:11:42Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables writing to data sources for Grafana-managed recording rules.",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/alerting-squad",
|
||||
"hideFromAdminPage": true,
|
||||
"hideFromDocs": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "grafanaconThemes",
|
||||
@@ -2190,6 +2204,21 @@
|
||||
"requiresRestart": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "inviteUserExperimental",
|
||||
"resourceVersion": "1741358664069",
|
||||
"creationTimestamp": "2025-03-07T14:44:24Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Renders invite user button along the app",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/sharing-squad",
|
||||
"frontend": true,
|
||||
"hideFromAdminPage": true,
|
||||
"hideFromDocs": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "jaegerBackendMigration",
|
||||
@@ -3848,14 +3877,18 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "ssoSettingsSAML",
|
||||
"resourceVersion": "1718727528075",
|
||||
"creationTimestamp": "2024-03-14T11:04:45Z"
|
||||
"resourceVersion": "1741342789814",
|
||||
"creationTimestamp": "2024-03-14T11:04:45Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-03-07 10:19:49.814084 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Use the new SSO Settings API to configure the SAML connector",
|
||||
"stage": "preview",
|
||||
"stage": "GA",
|
||||
"codeowner": "@grafana/identity-access-team",
|
||||
"allowSelfServe": true
|
||||
"allowSelfServe": true,
|
||||
"expression": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -188,7 +188,14 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) {
|
||||
|
||||
if api.FeatureManager.IsEnabledGlobally(featuremgmt.FlagAlertingConversionAPI) {
|
||||
api.RegisterConvertPrometheusApiEndpoints(NewConvertPrometheusApi(
|
||||
NewConvertPrometheusSrv(&api.Cfg.UnifiedAlerting, logger, api.RuleStore, api.DatasourceCache, api.AlertRules),
|
||||
NewConvertPrometheusSrv(
|
||||
&api.Cfg.UnifiedAlerting,
|
||||
logger,
|
||||
api.RuleStore,
|
||||
api.DatasourceCache,
|
||||
api.AlertRules,
|
||||
api.FeatureManager,
|
||||
),
|
||||
), m)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/prometheus/alertmanager/pkg/labels"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
|
||||
@@ -259,6 +261,118 @@ func TestAlertmanagerConfig(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetAlertmanagerConfiguration_NewSecretField(t *testing.T) {
|
||||
// This test has the following goals:
|
||||
// Given:
|
||||
// - A saved notifier config with an existing secret field stored unencrypted in Settings.
|
||||
// Ensure:
|
||||
// - The secret field is not returned in plaintext.
|
||||
// - The secret field is returned as a bool in SecureFields.
|
||||
// - The secret field is correctly saved and encrypted in SecureSettings when saving the notifier config without changes.
|
||||
// - The secret field is removed from Settings when saving the notifier config.
|
||||
|
||||
sut := createSut(t)
|
||||
orgId := int64(1)
|
||||
|
||||
// This config has the secret field "integrationKey" stored incorrectly and unencrypted in Settings.
|
||||
configs := map[int64]*ngmodels.AlertConfiguration{
|
||||
1: {
|
||||
OrgID: orgId,
|
||||
AlertmanagerConfiguration: `{
|
||||
"alertmanager_config": {
|
||||
"route": {
|
||||
"receiver": "configWithNewlySecretSetting"
|
||||
},
|
||||
"receivers": [{
|
||||
"name": "configWithNewlySecretSetting",
|
||||
"grafana_managed_receiver_configs": [{
|
||||
"uid": "configWithNewlySecretSetting-uid",
|
||||
"name": "configWithNewlySecretSetting",
|
||||
"type": "pagerduty",
|
||||
"settings": {"integrationKey": "unencrypted secure secret"},
|
||||
"secureSettings": {}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
}
|
||||
`,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
Default: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Store the config as-is in the database. Bypasses normal save route so it doesn't get pre-emptively fixed.
|
||||
mam := createMultiOrgAlertmanager(t, configs)
|
||||
sut.mam = mam
|
||||
|
||||
rc := createRequestCtxInOrg(orgId)
|
||||
res := sut.RouteGetAlertingConfig(rc)
|
||||
gettable := asGettableUserConfig(t, res)
|
||||
|
||||
integration := gettable.GetGrafanaReceiverMap()["configWithNewlySecretSetting-uid"]
|
||||
require.NotNil(t, integration)
|
||||
|
||||
var settings map[string]string
|
||||
err := json.Unmarshal(integration.Settings, &settings)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The secret field "integrationKey" should not be returned in plaintext.
|
||||
assert.NotEqual(t, "unencrypted secure secret", settings["integrationKey"])
|
||||
// Just in case let's look for the unencrypted value anywhere in the settings.
|
||||
assert.NotContains(t, string(integration.Settings), "unencrypted")
|
||||
|
||||
// The secret fields should be returned as a bool in SecureFields.
|
||||
assert.True(t, integration.SecureFields["integrationKey"])
|
||||
|
||||
// Now we save the config without changes. This should encrypt the field "integrationKey" into SecureSettings and
|
||||
// remove it from Settings.
|
||||
|
||||
// Simulates FE-API interaction, "integrationKey" is not sent in Settings as the caller.
|
||||
// Instead, it leaves it out of "SecureSettings" to indicate the API should keep the existing value.
|
||||
var postWithoutChanges = `{
|
||||
"alertmanager_config": {
|
||||
"route": {
|
||||
"receiver": "configWithNewlySecretSetting"
|
||||
},
|
||||
"receivers": [{
|
||||
"name": "configWithNewlySecretSetting",
|
||||
"grafana_managed_receiver_configs": [{
|
||||
"uid": "configWithNewlySecretSetting-uid",
|
||||
"name": "configWithNewlySecretSetting",
|
||||
"type": "pagerduty",
|
||||
"settings": {},
|
||||
"secureSettings": {}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
}
|
||||
`
|
||||
postable := createAmConfigRequest(t, postWithoutChanges)
|
||||
|
||||
res = sut.RoutePostAlertingConfig(rc, postable)
|
||||
require.Equal(t, 202, res.Status())
|
||||
|
||||
// Check that the secret field "integrationKey" is now encrypted in SecureSettings.
|
||||
savedConfig := &apimodels.PostableUserConfig{}
|
||||
err = json.Unmarshal([]byte(configs[orgId].AlertmanagerConfiguration), savedConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
savedIntegration := savedConfig.GetGrafanaReceiverMap()["configWithNewlySecretSetting-uid"]
|
||||
require.NotNil(t, savedIntegration)
|
||||
|
||||
// No longer in Settings.
|
||||
assert.Equal(t, "{}", string(savedIntegration.Settings))
|
||||
|
||||
// Encrypted in SecureSettings.
|
||||
secureSecret := savedIntegration.SecureSettings["integrationKey"]
|
||||
assert.NotEmpty(t, secureSecret)
|
||||
encryptedSecret, err := base64.StdEncoding.DecodeString(secureSecret)
|
||||
require.NoError(t, err)
|
||||
|
||||
// No access to .Decrypt, but we can check that it's not the same as the unencrypted value.
|
||||
assert.NotEqual(t, "unencrypted secure secret", string(encryptedSecret))
|
||||
}
|
||||
|
||||
func TestAlertmanagerAutogenConfig(t *testing.T) {
|
||||
createSutForAutogen := func(t *testing.T) (AlertmanagerSrv, map[int64]*ngmodels.AlertConfiguration) {
|
||||
sut := createSut(t)
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
@@ -47,7 +48,17 @@ var (
|
||||
).Errorf("missing datasource UID header")
|
||||
|
||||
errInvalidHeaderValueMsg = "Invalid value for header {{.Public.Header}}: must be 'true' or 'false'"
|
||||
errInvalidHeaderValueBase = errutil.ValidationFailed("aleting.invalidHeaderValue").MustTemplate(errInvalidHeaderValueMsg, errutil.WithPublic(errInvalidHeaderValueMsg))
|
||||
errInvalidHeaderValueBase = errutil.ValidationFailed("alerting.invalidHeaderValue").MustTemplate(errInvalidHeaderValueMsg, errutil.WithPublic(errInvalidHeaderValueMsg))
|
||||
|
||||
errRecordingRulesNotEnabled = errutil.ValidationFailed(
|
||||
"alerting.recordingRulesNotEnabled",
|
||||
errutil.WithPublicMessage("Cannot import recording rules: Feature not enabled."),
|
||||
).Errorf("recording rules not enabled")
|
||||
|
||||
errRecordingRulesDatasourcesNotEnabled = errutil.ValidationFailed(
|
||||
"alerting.recordingRulesDatasourcesNotEnabled",
|
||||
errutil.WithPublicMessage("Cannot import recording rules: Configuration of target datasources not enabled."),
|
||||
).Errorf("recording rules target datasources configuration not enabled")
|
||||
)
|
||||
|
||||
func errInvalidHeaderValue(header string) error {
|
||||
@@ -98,15 +109,24 @@ type ConvertPrometheusSrv struct {
|
||||
ruleStore RuleStore
|
||||
datasourceCache datasources.CacheService
|
||||
alertRuleService *provisioning.AlertRuleService
|
||||
featureToggles featuremgmt.FeatureToggles
|
||||
}
|
||||
|
||||
func NewConvertPrometheusSrv(cfg *setting.UnifiedAlertingSettings, logger log.Logger, ruleStore RuleStore, datasourceCache datasources.CacheService, alertRuleService *provisioning.AlertRuleService) *ConvertPrometheusSrv {
|
||||
func NewConvertPrometheusSrv(
|
||||
cfg *setting.UnifiedAlertingSettings,
|
||||
logger log.Logger,
|
||||
ruleStore RuleStore,
|
||||
datasourceCache datasources.CacheService,
|
||||
alertRuleService *provisioning.AlertRuleService,
|
||||
featureToggles featuremgmt.FeatureToggles,
|
||||
) *ConvertPrometheusSrv {
|
||||
return &ConvertPrometheusSrv{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
ruleStore: ruleStore,
|
||||
datasourceCache: datasourceCache,
|
||||
alertRuleService: alertRuleService,
|
||||
featureToggles: featureToggles,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,6 +321,20 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusPostRuleGroup(c *contextm
|
||||
workingFolderUID := getWorkingFolderUID(c)
|
||||
logger = logger.New("folder_title", namespaceTitle, "group", promGroup.Name, "working_folder_uid", workingFolderUID)
|
||||
|
||||
// If we're importing recording rules, we can only import them if the feature is enabled,
|
||||
// and the feature flag that enables configuring target datasources per-rule is also enabled.
|
||||
if promGroupHasRecordingRules(promGroup) {
|
||||
if !srv.cfg.RecordingRules.Enabled {
|
||||
logger.Error("Cannot import recording rules", "error", errRecordingRulesNotEnabled)
|
||||
return errorToResponse(errRecordingRulesNotEnabled)
|
||||
}
|
||||
|
||||
if !srv.featureToggles.IsEnabledGlobally(featuremgmt.FlagGrafanaManagedRecordingRulesDatasources) {
|
||||
logger.Error("Cannot import recording rules", "error", errRecordingRulesDatasourcesNotEnabled)
|
||||
return errorToResponse(errRecordingRulesDatasourcesNotEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Converting Prometheus rule group", "rules", len(promGroup.Rules))
|
||||
|
||||
ns, errResp := srv.getOrCreateNamespace(c, namespaceTitle, logger, workingFolderUID)
|
||||
@@ -494,3 +528,12 @@ func namespaceErrorResponse(err error) response.Response {
|
||||
|
||||
return toNamespaceErrorResponse(err)
|
||||
}
|
||||
|
||||
func promGroupHasRecordingRules(promGroup apimodels.PrometheusRuleGroup) bool {
|
||||
for _, rule := range promGroup.Rules {
|
||||
if rule.Record != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
dsfakes "github.com/grafana/grafana/pkg/services/datasources/fakes"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/services/folder/foldertest"
|
||||
acfakes "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol/fakes"
|
||||
@@ -48,6 +49,13 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
|
||||
"severity": "critical",
|
||||
},
|
||||
},
|
||||
{
|
||||
Record: "recorded-metric",
|
||||
Expr: "vector(1)",
|
||||
Labels: map[string]string{
|
||||
"severity": "warning",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -104,21 +112,39 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
|
||||
response := srv.RouteConvertPrometheusPostRuleGroup(rc, fldr.Title, simpleGroup)
|
||||
require.Equal(t, http.StatusAccepted, response.Status())
|
||||
|
||||
// Get the updated rule
|
||||
// Get the rules
|
||||
remaining, err := ruleStore.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{
|
||||
OrgID: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, remaining, 1)
|
||||
require.Len(t, remaining, 2)
|
||||
|
||||
require.Equal(t, simpleGroup.Name, remaining[0].RuleGroup)
|
||||
require.Equal(t, fmt.Sprintf("[%s] %s", simpleGroup.Name, simpleGroup.Rules[0].Alert), remaining[0].Title)
|
||||
promRuleYAML, err := yaml.Marshal(simpleGroup.Rules[0])
|
||||
require.NoError(t, err)
|
||||
// Create a map of rule titles to their expected definitions
|
||||
expectedRules := make(map[string]string)
|
||||
for _, rule := range simpleGroup.Rules {
|
||||
if rule.Alert != "" {
|
||||
title := fmt.Sprintf("[%s] %s", simpleGroup.Name, rule.Alert)
|
||||
promRuleYAML, err := yaml.Marshal(rule)
|
||||
require.NoError(t, err)
|
||||
expectedRules[title] = string(promRuleYAML)
|
||||
} else if rule.Record != "" {
|
||||
title := fmt.Sprintf("[%s] %s", simpleGroup.Name, rule.Record)
|
||||
promRuleYAML, err := yaml.Marshal(rule)
|
||||
require.NoError(t, err)
|
||||
expectedRules[title] = string(promRuleYAML)
|
||||
}
|
||||
}
|
||||
|
||||
promDefinition, err := remaining[0].PrometheusRuleDefinition()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(promRuleYAML), promDefinition)
|
||||
// Verify each rule matches its expected definition
|
||||
for _, r := range remaining {
|
||||
require.Equal(t, simpleGroup.Name, r.RuleGroup)
|
||||
expectedDef, exists := expectedRules[r.Title]
|
||||
require.True(t, exists, "unexpected rule title: %s", r.Title)
|
||||
|
||||
promDefinition, err := r.PrometheusRuleDefinition()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedDef, promDefinition)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("should fail to replace a provisioned rule group", func(t *testing.T) {
|
||||
@@ -263,6 +289,58 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
|
||||
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup)
|
||||
require.Equal(t, http.StatusAccepted, response.Status())
|
||||
})
|
||||
|
||||
t.Run("with disabled recording rules", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
recordingRules bool
|
||||
recordingRulesTargetDS bool
|
||||
expectedStatus int
|
||||
}{
|
||||
{
|
||||
name: "when recording rules are enabled",
|
||||
recordingRules: true,
|
||||
recordingRulesTargetDS: true,
|
||||
expectedStatus: http.StatusAccepted,
|
||||
},
|
||||
{
|
||||
name: "when recording rules are disabled",
|
||||
recordingRules: false,
|
||||
recordingRulesTargetDS: true,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "when target datasources for recording rules are disabled",
|
||||
recordingRules: true,
|
||||
recordingRulesTargetDS: false,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "when both recording rules and target datasources are disabled",
|
||||
recordingRules: false,
|
||||
recordingRulesTargetDS: false,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var features featuremgmt.FeatureToggles
|
||||
if tc.recordingRulesTargetDS {
|
||||
features = featuremgmt.WithFeatures(featuremgmt.FlagGrafanaManagedRecordingRulesDatasources)
|
||||
} else {
|
||||
features = featuremgmt.WithFeatures()
|
||||
}
|
||||
|
||||
srv, _, _, _ := createConvertPrometheusSrv(t, withFeatureToggles(features))
|
||||
srv.cfg.RecordingRules.Enabled = tc.recordingRules
|
||||
rc := createRequestCtx()
|
||||
|
||||
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup)
|
||||
require.Equal(t, tc.expectedStatus, response.Status())
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRouteConvertPrometheusGetRuleGroup(t *testing.T) {
|
||||
@@ -783,6 +861,7 @@ type convertPrometheusSrvOptions struct {
|
||||
provenanceStore provisioning.ProvisioningStore
|
||||
fakeAccessControlRuleService *acfakes.FakeRuleService
|
||||
quotaChecker *provisioning.MockQuotaChecker
|
||||
featureToggles featuremgmt.FeatureToggles
|
||||
}
|
||||
|
||||
type convertPrometheusSrvOptionsFunc func(*convertPrometheusSrvOptions)
|
||||
@@ -805,6 +884,12 @@ func withQuotaChecker(checker *provisioning.MockQuotaChecker) convertPrometheusS
|
||||
}
|
||||
}
|
||||
|
||||
func withFeatureToggles(toggles featuremgmt.FeatureToggles) convertPrometheusSrvOptionsFunc {
|
||||
return func(opts *convertPrometheusSrvOptions) {
|
||||
opts.featureToggles = toggles
|
||||
}
|
||||
}
|
||||
|
||||
func createConvertPrometheusSrv(t *testing.T, opts ...convertPrometheusSrvOptionsFunc) (*ConvertPrometheusSrv, datasources.CacheService, *fakes.RuleStore, *foldertest.FakeService) {
|
||||
t.Helper()
|
||||
|
||||
@@ -816,6 +901,7 @@ func createConvertPrometheusSrv(t *testing.T, opts ...convertPrometheusSrvOption
|
||||
provenanceStore: fakes.NewFakeProvisioningStore(),
|
||||
fakeAccessControlRuleService: &acfakes.FakeRuleService{},
|
||||
quotaChecker: quotas,
|
||||
featureToggles: featuremgmt.WithFeatures(featuremgmt.FlagGrafanaManagedRecordingRulesDatasources),
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
@@ -851,9 +937,12 @@ func createConvertPrometheusSrv(t *testing.T, opts ...convertPrometheusSrvOption
|
||||
|
||||
cfg := &setting.UnifiedAlertingSettings{
|
||||
DefaultRuleEvaluationInterval: 1 * time.Minute,
|
||||
RecordingRules: setting.RecordingRuleSettings{
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
srv := NewConvertPrometheusSrv(cfg, log.NewNopLogger(), ruleStore, dsCache, alertRuleService)
|
||||
srv := NewConvertPrometheusSrv(cfg, log.NewNopLogger(), ruleStore, dsCache, alertRuleService, options.featureToggles)
|
||||
|
||||
return srv, dsCache, ruleStore, folderService
|
||||
}
|
||||
|
||||
@@ -374,7 +374,7 @@ func (ng *AlertNG) init() error {
|
||||
// Force-disable the feature if the feature toggle is not on - sets us up for feature toggle removal.
|
||||
ng.Cfg.UnifiedAlerting.RecordingRules.Enabled = false
|
||||
}
|
||||
recordingWriter, err := createRecordingWriter(ng.FeatureToggles, ng.Cfg.UnifiedAlerting.RecordingRules, ng.httpClientProvider, clk, ng.Metrics.GetRemoteWriterMetrics())
|
||||
recordingWriter, err := createRecordingWriter(ng.FeatureToggles, ng.Cfg.UnifiedAlerting.RecordingRules, ng.httpClientProvider, ng.DataSourceService, clk, ng.Metrics.GetRemoteWriterMetrics())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize recording writer: %w", err)
|
||||
}
|
||||
@@ -754,11 +754,26 @@ func createRemoteAlertmanager(cfg remote.AlertmanagerConfig, kvstore kvstore.KVS
|
||||
return remote.NewAlertmanager(cfg, notifier.NewFileStore(cfg.OrgID, kvstore), decryptFn, autogenFn, m, tracer)
|
||||
}
|
||||
|
||||
func createRecordingWriter(featureToggles featuremgmt.FeatureToggles, settings setting.RecordingRuleSettings, httpClientProvider httpclient.Provider, clock clock.Clock, m *metrics.RemoteWriter) (schedule.RecordingWriter, error) {
|
||||
func createRecordingWriter(featureToggles featuremgmt.FeatureToggles, settings setting.RecordingRuleSettings, httpClientProvider httpclient.Provider, datasourceService datasources.DataSourceService, clock clock.Clock, m *metrics.RemoteWriter) (schedule.RecordingWriter, error) {
|
||||
logger := log.New("ngalert.writer")
|
||||
|
||||
if settings.Enabled {
|
||||
return writer.NewPrometheusWriterWithSettings(settings, httpClientProvider, clock, logger, m)
|
||||
if featureToggles.IsEnabledGlobally(featuremgmt.FlagGrafanaManagedRecordingRulesDatasources) {
|
||||
cfg := writer.DatasourceWriterConfig{
|
||||
Timeout: settings.Timeout,
|
||||
DefaultDatasourceUID: settings.DefaultDatasourceUID,
|
||||
RemoteWritePathSuffix: settings.RemoteWritePathSuffix,
|
||||
}
|
||||
|
||||
logger.Info("Setting up remote write using data sources",
|
||||
"timeout", cfg.Timeout, "default_datasource_uid", cfg.DefaultDatasourceUID,
|
||||
"remote_write_path_suffix", cfg.RemoteWritePathSuffix)
|
||||
|
||||
return writer.NewDatasourceWriter(cfg, datasourceService, httpClientProvider, clock, logger, m), nil
|
||||
} else {
|
||||
logger.Info("Setting up remote write using static configuration")
|
||||
return writer.NewPrometheusWriterWithSettings(settings, httpClientProvider, clock, logger, m)
|
||||
}
|
||||
}
|
||||
|
||||
return writer.NoopWriter{}, nil
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/services/secrets"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
@@ -223,6 +224,16 @@ func (moa *MultiOrgAlertmanager) gettableUserConfigFromAMConfigString(ctx contex
|
||||
Config: cfg.AlertmanagerConfig.Config,
|
||||
},
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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 {
|
||||
receivers := make([]*definitions.GettableGrafanaReceiver, 0, len(recv.PostableGrafanaReceivers.GrafanaManagedReceivers))
|
||||
for _, pr := range recv.PostableGrafanaReceivers.GrafanaManagedReceivers {
|
||||
|
||||
@@ -3,11 +3,13 @@ package notifier
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/services/secrets"
|
||||
)
|
||||
@@ -57,17 +59,90 @@ func (c *alertmanagerCrypto) ProcessSecureSettings(ctx context.Context, orgId in
|
||||
|
||||
// EncryptReceiverConfigs encrypts all SecureSettings in the given receivers.
|
||||
func EncryptReceiverConfigs(c []*definitions.PostableApiReceiver, encrypt definitions.EncryptFn) error {
|
||||
return encryptReceiverConfigs(c, encrypt, true)
|
||||
}
|
||||
|
||||
func EncryptReceiverConfigSettings(c []*definitions.PostableApiReceiver, encrypt definitions.EncryptFn) error {
|
||||
return encryptReceiverConfigs(c, encrypt, false)
|
||||
}
|
||||
|
||||
// encryptReceiverConfigs encrypts all SecureSettings in the given receivers.
|
||||
// encryptExisting determines whether to encrypt existing secure settings.
|
||||
func encryptReceiverConfigs(c []*definitions.PostableApiReceiver, encrypt definitions.EncryptFn, encryptExisting bool) error {
|
||||
// encrypt secure settings for storing them in DB
|
||||
for _, r := range c {
|
||||
switch r.Type() {
|
||||
case definitions.GrafanaReceiverType:
|
||||
for _, gr := range r.PostableGrafanaReceivers.GrafanaManagedReceivers {
|
||||
for k, v := range gr.SecureSettings {
|
||||
encryptedData, err := encrypt(context.Background(), []byte(v))
|
||||
if encryptExisting {
|
||||
for k, v := range gr.SecureSettings {
|
||||
encryptedData, err := encrypt(context.Background(), []byte(v))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt secure settings: %w", err)
|
||||
}
|
||||
gr.SecureSettings[k] = base64.StdEncoding.EncodeToString(encryptedData)
|
||||
}
|
||||
}
|
||||
|
||||
if len(gr.Settings) > 0 {
|
||||
// We need to parse the settings to check for secret keys. If we find any, we encrypt them and
|
||||
// store them in SecureSettings. This can happen from incorrect configuration or when an integration
|
||||
// definition is updated to make a field secure.
|
||||
settings := make(map[string]any)
|
||||
if err := json.Unmarshal(gr.Settings, &settings); err != nil {
|
||||
return fmt.Errorf("integration '%s' of receiver '%s' has settings that cannot be parsed as JSON: %w", gr.Type, gr.Name, err)
|
||||
}
|
||||
|
||||
secretKeys, err := channels_config.GetSecretKeysForContactPointType(gr.Type)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt secure settings: %w", err)
|
||||
return fmt.Errorf("failed to get secret keys for contact point type %s: %w", gr.Type, err)
|
||||
}
|
||||
|
||||
secureSettings := gr.SecureSettings
|
||||
if secureSettings == nil {
|
||||
secureSettings = make(map[string]string)
|
||||
}
|
||||
|
||||
settingsChanged := false
|
||||
secureSettingsChanged := false
|
||||
for _, secretKey := range secretKeys {
|
||||
settingsValue, ok := settings[secretKey]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Secrets should not be stored in settings regardless.
|
||||
delete(settings, secretKey)
|
||||
settingsChanged = true
|
||||
|
||||
// If the secret is already encrypted, we don't need to encrypt it again.
|
||||
if _, ok := secureSettings[secretKey]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if strVal, isString := settingsValue.(string); isString {
|
||||
encrypted, err := encrypt(context.Background(), []byte(strVal))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt secure settings: %w", err)
|
||||
}
|
||||
secureSettings[secretKey] = base64.StdEncoding.EncodeToString(encrypted)
|
||||
secureSettingsChanged = true
|
||||
}
|
||||
}
|
||||
|
||||
// Defensive checks to limit the risk of unintentional edge case changes in this legacy API.
|
||||
if settingsChanged {
|
||||
// If we removed any secret keys from settings, we need to save the updated settings.
|
||||
jsonBytes, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gr.Settings = jsonBytes
|
||||
}
|
||||
if secureSettingsChanged {
|
||||
// If we added any secure settings, we need to save the updated secure settings.
|
||||
gr.SecureSettings = secureSettings
|
||||
}
|
||||
gr.SecureSettings[k] = base64.StdEncoding.EncodeToString(encryptedData)
|
||||
}
|
||||
}
|
||||
default:
|
||||
@@ -94,6 +169,14 @@ func (c *alertmanagerCrypto) LoadSecureSettings(ctx context.Context, orgId int64
|
||||
if err != nil {
|
||||
c.log.Warn("Last known alertmanager configuration was invalid. Overwriting...")
|
||||
} else {
|
||||
// First we encrypt the secure settings in the existing configuration.
|
||||
// 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(currentConfig.AlertmanagerConfig.Receivers, func(ctx context.Context, payload []byte) ([]byte, error) {
|
||||
return c.Encrypt(ctx, payload, secrets.WithoutScope())
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to encrypt receivers: %w", err)
|
||||
}
|
||||
currentReceiverMap = currentConfig.GetGrafanaReceiverMap()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-openapi/strfmt"
|
||||
alertingModels "github.com/grafana/alerting/models"
|
||||
@@ -17,13 +18,42 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
timeNow = time.Now()
|
||||
simpleAlert = amv2.PostableAlert{
|
||||
Alert: amv2.Alert{
|
||||
Labels: amv2.LabelSet{"__alert_rule_uid__": "rule uid", "alertname": "alert1", "lbl1": "val1"},
|
||||
Labels: amv2.LabelSet{
|
||||
alertingModels.RuleUIDLabel: "rule uid",
|
||||
prometheusModel.AlertNameLabel: "alert1",
|
||||
"lbl1": "val1",
|
||||
},
|
||||
},
|
||||
Annotations: amv2.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertImageToken__": "test-image-1"},
|
||||
StartsAt: strfmt.DateTime{},
|
||||
EndsAt: strfmt.DateTime{},
|
||||
Annotations: amv2.LabelSet{
|
||||
"ann1": "annv1",
|
||||
alertingModels.DashboardUIDAnnotation: "abcd",
|
||||
alertingModels.PanelIDAnnotation: "42",
|
||||
alertingModels.ImageTokenAnnotation: "test-image-1",
|
||||
alertingModels.OrgIDAnnotation: "1",
|
||||
},
|
||||
StartsAt: strfmt.DateTime(timeNow),
|
||||
EndsAt: strfmt.DateTime(timeNow.Add(time.Hour)), // Firing.
|
||||
}
|
||||
resolvedAlert = amv2.PostableAlert{
|
||||
Alert: amv2.Alert{
|
||||
Labels: amv2.LabelSet{
|
||||
alertingModels.RuleUIDLabel: "rule uid",
|
||||
prometheusModel.AlertNameLabel: "alert1",
|
||||
"lbl1": "val1",
|
||||
},
|
||||
},
|
||||
Annotations: amv2.LabelSet{
|
||||
"ann1": "annv1",
|
||||
alertingModels.DashboardUIDAnnotation: "abcd",
|
||||
alertingModels.PanelIDAnnotation: "42",
|
||||
alertingModels.ImageTokenAnnotation: "test-image-1",
|
||||
alertingModels.OrgIDAnnotation: "1",
|
||||
},
|
||||
StartsAt: strfmt.DateTime(timeNow.Add(-30 * time.Minute)),
|
||||
EndsAt: strfmt.DateTime(timeNow), // Resolved.
|
||||
}
|
||||
)
|
||||
|
||||
@@ -207,6 +237,45 @@ CommonAnnotations: {{ range .CommonAnnotations.SortedPairs }}{{ .Name }}={{ .Val
|
||||
}},
|
||||
Errors: nil,
|
||||
},
|
||||
}, {
|
||||
name: "DashboardURL generation contains from and to time",
|
||||
input: apimodels.TestTemplatesConfigBodyParams{
|
||||
Alerts: []*amv2.PostableAlert{&resolvedAlert}, // We specifically use a resolved alert as otherwise the `to` time will be the current time and difficult to test.
|
||||
Name: "slack.title",
|
||||
Template: `{{ define "slack.title" }}{{ (index .Alerts 0 ).DashboardURL }}{{ end }}`,
|
||||
},
|
||||
expected: TestTemplatesResults{
|
||||
Results: []alertingNotify.TestTemplatesResult{{
|
||||
Name: "slack.title",
|
||||
Text: fmt.Sprintf("http://localhost:9093/d/%s?from=%d&orgId=%s&to=%d",
|
||||
resolvedAlert.Annotations[alertingModels.DashboardUIDAnnotation],
|
||||
timeNow.Add(-90*time.Minute).UnixMilli(), // StartsAt - 1hr.
|
||||
resolvedAlert.Annotations[alertingModels.OrgIDAnnotation],
|
||||
timeNow.UnixMilli()),
|
||||
Scope: alertingNotify.TemplateScope(apimodels.RootScope),
|
||||
}},
|
||||
Errors: nil,
|
||||
},
|
||||
}, {
|
||||
name: "PanelURL generation contains from and to time ",
|
||||
input: apimodels.TestTemplatesConfigBodyParams{
|
||||
Alerts: []*amv2.PostableAlert{&resolvedAlert}, // We specifically use a resolved alert as otherwise the `to` time will be the current time and difficult to test.
|
||||
Name: "slack.title",
|
||||
Template: `{{ define "slack.title" }}{{ (index .Alerts 0 ).PanelURL }}{{ end }}`,
|
||||
},
|
||||
expected: TestTemplatesResults{
|
||||
Results: []alertingNotify.TestTemplatesResult{{
|
||||
Name: "slack.title",
|
||||
Text: fmt.Sprintf("http://localhost:9093/d/%s?from=%d&orgId=%s&to=%d&viewPanel=%s",
|
||||
resolvedAlert.Annotations[alertingModels.DashboardUIDAnnotation],
|
||||
timeNow.Add(-90*time.Minute).UnixMilli(), // StartsAt - 1hr.
|
||||
resolvedAlert.Annotations[alertingModels.OrgIDAnnotation],
|
||||
timeNow.UnixMilli(),
|
||||
resolvedAlert.Annotations[alertingModels.PanelIDAnnotation]),
|
||||
Scope: alertingNotify.TemplateScope(apimodels.RootScope),
|
||||
}},
|
||||
Errors: nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -191,8 +191,9 @@ func (p *Converter) convertRule(orgID int64, namespaceUID string, promGroup Prom
|
||||
|
||||
if isRecordingRule {
|
||||
record = &models.Record{
|
||||
From: queryRefID,
|
||||
Metric: rule.Record,
|
||||
From: queryRefID,
|
||||
Metric: rule.Record,
|
||||
TargetDatasourceUID: p.cfg.DatasourceUID,
|
||||
}
|
||||
|
||||
isPaused = p.cfg.RecordingRules.IsPaused
|
||||
|
||||
@@ -198,6 +198,7 @@ func TestPrometheusRulesToGrafana(t *testing.T) {
|
||||
require.NotNil(t, grafanaRule.Record)
|
||||
require.Equal(t, grafanaRule.Record.From, queryRefID)
|
||||
require.Equal(t, promRule.Record, grafanaRule.Record.Metric)
|
||||
require.Equal(t, tc.config.DatasourceUID, grafanaRule.Record.TargetDatasourceUID)
|
||||
} else {
|
||||
require.Equal(t, fmt.Sprintf("[%s] %s", tc.promGroup.Name, promRule.Alert), grafanaRule.Title)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/expr"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
dsfakes "github.com/grafana/grafana/pkg/services/datasources/fakes"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
|
||||
models "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/writer"
|
||||
@@ -173,18 +175,33 @@ func blankRecordingRuleForTests(ctx context.Context) *recordingRule {
|
||||
}
|
||||
|
||||
func TestRecordingRule_Integration(t *testing.T) {
|
||||
t.Run("with prometheus writer", func(t *testing.T) {
|
||||
writeTarget := writer.NewTestRemoteWriteTarget(t)
|
||||
defer writeTarget.Close()
|
||||
writerReg := prometheus.NewPedanticRegistry()
|
||||
writer := setupPrometheusWriter(t, writeTarget, writerReg)
|
||||
testRecordingRule_Integration(t, writeTarget, writer, writerReg, "")
|
||||
})
|
||||
t.Run("with datasource writer", func(t *testing.T) {
|
||||
writeTarget := writer.NewTestRemoteWriteTarget(t)
|
||||
defer writeTarget.Close()
|
||||
writerReg := prometheus.NewPedanticRegistry()
|
||||
writer := setupDatasourceWriter(t, writeTarget, writerReg, "ds-uid")
|
||||
testRecordingRule_Integration(t, writeTarget, writer, writerReg, "ds-uid")
|
||||
})
|
||||
}
|
||||
|
||||
func testRecordingRule_Integration(t *testing.T, writeTarget *writer.TestRemoteWriteTarget, writer RecordingWriter, writerReg *prometheus.Registry, dsUID string) {
|
||||
gen := models.RuleGen.With(models.RuleGen.WithAllRecordingRules(), models.RuleGen.WithOrgID(123))
|
||||
ruleStore := newFakeRulesStore()
|
||||
reg := prometheus.NewPedanticRegistry()
|
||||
sch := setupScheduler(t, ruleStore, nil, reg, nil, nil, nil)
|
||||
writeTarget := writer.NewTestRemoteWriteTarget(t)
|
||||
defer writeTarget.Close()
|
||||
writerReg := prometheus.NewPedanticRegistry()
|
||||
sch.recordingWriter = setupWriter(t, writeTarget, writerReg)
|
||||
sch.recordingWriter = writer
|
||||
|
||||
t.Run("rule that succeeds", func(t *testing.T) {
|
||||
writeTarget.Reset()
|
||||
rule := gen.With(withQueryForHealth("ok")).GenerateRef()
|
||||
rule.Record.TargetDatasourceUID = dsUID
|
||||
ruleStore.PutRule(context.Background(), rule)
|
||||
folderTitle := ruleStore.getNamespaceTitle(rule.NamespaceUID)
|
||||
ruleFactory := ruleFactoryFromScheduler(sch)
|
||||
@@ -543,7 +560,7 @@ func withQueryForHealth(health string) models.AlertRuleMutator {
|
||||
}
|
||||
}
|
||||
|
||||
func setupWriter(t *testing.T, target *writer.TestRemoteWriteTarget, reg prometheus.Registerer) *writer.PrometheusWriter {
|
||||
func setupPrometheusWriter(t *testing.T, target *writer.TestRemoteWriteTarget, reg prometheus.Registerer) *writer.PrometheusWriter {
|
||||
provider := testClientProvider{}
|
||||
m := metrics.NewNGAlert(reg)
|
||||
wr, err := writer.NewPrometheusWriterWithSettings(target.ClientSettings(), provider, clock.NewMock(), log.NewNopLogger(), m.GetRemoteWriterMetrics())
|
||||
@@ -551,6 +568,27 @@ func setupWriter(t *testing.T, target *writer.TestRemoteWriteTarget, reg prometh
|
||||
return wr
|
||||
}
|
||||
|
||||
func setupDatasourceWriter(t *testing.T, target *writer.TestRemoteWriteTarget, reg prometheus.Registerer, dsUID string) *writer.DatasourceWriter {
|
||||
provider := testClientProvider{}
|
||||
m := metrics.NewNGAlert(reg)
|
||||
|
||||
dss := &dsfakes.FakeDataSourceService{}
|
||||
p1, _ := dss.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{
|
||||
UID: dsUID,
|
||||
Type: datasources.DS_PROMETHEUS,
|
||||
})
|
||||
p1.URL = target.DatasourceURL()
|
||||
|
||||
cfg := writer.DatasourceWriterConfig{
|
||||
Timeout: time.Second * 5,
|
||||
DefaultDatasourceUID: "",
|
||||
RemoteWritePathSuffix: writer.RemoteWriteSuffix,
|
||||
}
|
||||
|
||||
return writer.NewDatasourceWriter(cfg, dss, provider, clock.NewMock(),
|
||||
log.New("test"), m.GetRemoteWriterMetrics())
|
||||
}
|
||||
|
||||
type testClientProvider struct{}
|
||||
|
||||
func (t testClientProvider) New(options ...httpclient.Options) (*http.Client, error) {
|
||||
|
||||
@@ -49,7 +49,6 @@ type RulesStore interface {
|
||||
}
|
||||
|
||||
type RecordingWriter interface {
|
||||
Write(ctx context.Context, name string, t time.Time, frames data.Frames, orgID int64, extraLabels map[string]string) error
|
||||
WriteDatasource(ctx context.Context, dsUID string, name string, t time.Time, frames data.Frames, orgID int64, extraLabels map[string]string) error
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
@@ -89,7 +92,14 @@ func (st DBstore) GetNamespaceByTitle(ctx context.Context, title string, orgID i
|
||||
}
|
||||
|
||||
// GetOrCreateNamespaceByTitle gets or creates a namespace by title in the specified folder.
|
||||
//
|
||||
// To avoid race conditions when two concurrent requests try to create the same folder,
|
||||
// we create folders with a deterministic UID based on the parent UID, title, and organization ID.
|
||||
func (st DBstore) GetOrCreateNamespaceByTitle(ctx context.Context, title string, orgID int64, user identity.Requester, parentUID string) (*folder.Folder, error) {
|
||||
if len(title) == 0 {
|
||||
return nil, fmt.Errorf("title is empty")
|
||||
}
|
||||
|
||||
var f *folder.Folder
|
||||
var err error
|
||||
|
||||
@@ -99,7 +109,14 @@ func (st DBstore) GetOrCreateNamespaceByTitle(ctx context.Context, title string,
|
||||
}
|
||||
|
||||
if f == nil {
|
||||
// Generate a deterministic UID with an alerting prefix
|
||||
uid, err := generateAlertingFolderUID(title, parentUID, orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating a new folder: %w", err)
|
||||
}
|
||||
|
||||
cmd := &folder.CreateFolderCommand{
|
||||
UID: uid,
|
||||
OrgID: orgID,
|
||||
Title: title,
|
||||
SignedInUser: user,
|
||||
@@ -107,9 +124,49 @@ func (st DBstore) GetOrCreateNamespaceByTitle(ctx context.Context, title string,
|
||||
}
|
||||
f, err = st.FolderService.Create(ctx, cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Handle potential race condition where another request might have created
|
||||
// the folder between our check and creation attempt
|
||||
existingFolder, lookupErr := st.GetNamespaceByTitle(ctx, title, orgID, user, parentUID)
|
||||
if lookupErr == nil {
|
||||
return existingFolder, nil
|
||||
}
|
||||
|
||||
// If we couldn't find it, return errors
|
||||
return nil, fmt.Errorf("failed to get or create folder: %w", errors.Join(
|
||||
fmt.Errorf("create folder: %w", err),
|
||||
fmt.Errorf("lookup folder: %w", lookupErr),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// generateAlertingFolderUID creates a deterministic UID for folders
|
||||
// based on the title and parent UID to avoid race conditions when multiple
|
||||
// identical folders are created concurrently
|
||||
func generateAlertingFolderUID(title string, parentUID string, orgID int64) (string, error) {
|
||||
h := fnv.New64a()
|
||||
|
||||
hashData := [][]byte{
|
||||
[]byte(parentUID),
|
||||
{0}, // separator
|
||||
[]byte(title),
|
||||
{0},
|
||||
[]byte(strconv.FormatInt(orgID, 10)),
|
||||
}
|
||||
|
||||
// Add hashData strings to the hash with a separator between them
|
||||
for _, data := range hashData {
|
||||
_, err := h.Write(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
// Create a deterministic string with alerting prefix
|
||||
base36 := strconv.FormatUint(h.Sum64(), 36)
|
||||
uid := fmt.Sprintf("alerting-%s", base36)
|
||||
|
||||
return uid, nil
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -206,6 +207,13 @@ func TestIntegration_GetOrCreateNamespaceByTitle(t *testing.T) {
|
||||
return store
|
||||
}
|
||||
|
||||
t.Run("should return error when title is empty", func(t *testing.T) {
|
||||
store := setupStore(t)
|
||||
_, err := store.GetOrCreateNamespaceByTitle(context.Background(), "", 1, u, folder.RootFolderUID)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "title is empty")
|
||||
})
|
||||
|
||||
t.Run("should create folder when it does not exist", func(t *testing.T) {
|
||||
store := setupStore(t)
|
||||
|
||||
@@ -317,6 +325,65 @@ func TestIntegration_GetOrCreateNamespaceByTitle(t *testing.T) {
|
||||
require.Equal(t, folder3.UID, gotFolder3.UID)
|
||||
require.Equal(t, folder.RootFolderUID, gotFolder3.ParentUID)
|
||||
})
|
||||
|
||||
t.Run("should create folder with deterministic UID and handle race conditions", func(t *testing.T) {
|
||||
store := setupStore(t)
|
||||
|
||||
folderTitle := "race condition test folder"
|
||||
parentUID := folder.RootFolderUID
|
||||
|
||||
// Calculate the expected UID that would be generated
|
||||
expectedUID, err := generateAlertingFolderUID(folderTitle, parentUID, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a folder first, simulating another concurrent call that succeeded first
|
||||
createFolder(t, store, expectedUID, folderTitle, 1, parentUID)
|
||||
|
||||
// Now try to create a folder with the same title and parent
|
||||
// This should not create a duplicate folder but return the existing one
|
||||
f, err := store.GetOrCreateNamespaceByTitle(context.Background(), folderTitle, 1, u, parentUID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedUID, f.UID, "Should return folder with same UID as would be deterministically generated")
|
||||
require.Equal(t, folderTitle, f.Title)
|
||||
require.Equal(t, parentUID, f.ParentUID)
|
||||
|
||||
// Verify only one folder was created
|
||||
folders, err := store.FolderService.GetFolders(
|
||||
context.Background(),
|
||||
folder.GetFoldersQuery{
|
||||
OrgID: 1,
|
||||
WithFullpath: true,
|
||||
SignedInUser: u,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, folders, 1, "Only one folder should exist")
|
||||
})
|
||||
|
||||
t.Run("should handle special characters in folder titles", func(t *testing.T) {
|
||||
store := setupStore(t)
|
||||
specialTitles := []string{
|
||||
"folder/with/slashes",
|
||||
"folder with spaces",
|
||||
"folder-with-dashes",
|
||||
"folder_with_underscores",
|
||||
"folder.with.dots",
|
||||
"!@#$%^&*()",
|
||||
}
|
||||
|
||||
for _, title := range specialTitles {
|
||||
t.Run(title, func(t *testing.T) {
|
||||
f, err := store.GetOrCreateNamespaceByTitle(context.Background(), title, 1, u, folder.RootFolderUID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, title, f.Title)
|
||||
|
||||
// Verify retrieval works
|
||||
retrieved, err := store.GetNamespaceByTitle(context.Background(), title, 1, u, folder.RootFolderUID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, f.UID, retrieved.UID)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_GetNamespaceChildren(t *testing.T) {
|
||||
@@ -417,3 +484,59 @@ func TestIntegration_GetNamespaceChildren(t *testing.T) {
|
||||
require.ElementsMatch(t, []string{rootFolder1, rootFolder2}, []string{children[0].UID, children[1].UID})
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateAlertingFolderUID(t *testing.T) {
|
||||
const orgID int64 = 1
|
||||
|
||||
t.Run("should generate deterministic UIDs for same inputs", func(t *testing.T) {
|
||||
title := "Test Folder"
|
||||
parentUID := "parent123"
|
||||
|
||||
uid1, err := generateAlertingFolderUID(title, parentUID, orgID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, util.IsValidShortUID(uid1), "Generated UID should be valid")
|
||||
|
||||
uid2, err := generateAlertingFolderUID(title, parentUID, orgID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, util.IsValidShortUID(uid2), "Generated UID should be valid")
|
||||
|
||||
require.Equal(t, uid1, uid2, "UIDs should be identical for same inputs")
|
||||
require.True(t, strings.HasPrefix(uid1, "alerting-"), "UID should have alerting prefix")
|
||||
})
|
||||
|
||||
t.Run("should generate different UIDs for different inputs", func(t *testing.T) {
|
||||
uid1, err := generateAlertingFolderUID("Folder1", "parent1", orgID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, util.IsValidShortUID(uid1), "Generated UID should be valid")
|
||||
|
||||
uid2, err := generateAlertingFolderUID("Folder2", "parent1", orgID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, util.IsValidShortUID(uid2), "Generated UID should be valid")
|
||||
|
||||
uid3, err := generateAlertingFolderUID("Folder1", "parent2", orgID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, util.IsValidShortUID(uid3), "Generated UID should be valid")
|
||||
|
||||
require.NotEqual(t, uid1, uid2, "UIDs should differ for different titles")
|
||||
require.NotEqual(t, uid1, uid3, "UIDs should differ for different parent UIDs")
|
||||
})
|
||||
|
||||
t.Run("should handle special characters in folder titles", func(t *testing.T) {
|
||||
specialTitles := []string{
|
||||
"folder/with/slashes",
|
||||
"folder with spaces",
|
||||
"folder-with-dashes",
|
||||
"folder_with_underscores",
|
||||
"folder.with.dots",
|
||||
"!@#$%^&*()",
|
||||
}
|
||||
|
||||
for _, title := range specialTitles {
|
||||
t.Run(title, func(t *testing.T) {
|
||||
uid, err := generateAlertingFolderUID(title, "parent123", orgID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, util.IsValidShortUID(uid), "Generated UID should be valid")
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package writer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/benbjohnson/clock"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
gocache "github.com/patrickmn/go-cache"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/adapters"
|
||||
)
|
||||
|
||||
const (
|
||||
// We do not need to aggresively refresh data source settings, because a
|
||||
// typical recording rule only writes every 60 seconds.
|
||||
cacheExpiration = 30 * time.Second
|
||||
|
||||
// Time between cleaning expired data sources.
|
||||
cacheCleanupInterval = 10 * time.Minute
|
||||
)
|
||||
|
||||
type DatasourceWriterConfig struct {
|
||||
// Timeout is the maximum time to wait for a remote write to succeed.
|
||||
Timeout time.Duration
|
||||
|
||||
// DetaultDatasourceUID is the data source used if no data source is specified.
|
||||
// This exists to cater for upgrading from old versions of Grafana, where rule
|
||||
// definitions may not have a target data source specified.
|
||||
DefaultDatasourceUID string
|
||||
|
||||
// RemoteWritePathSuffix is the path suffix for remote write, normally /push.
|
||||
RemoteWritePathSuffix string
|
||||
}
|
||||
|
||||
type DatasourceWriter struct {
|
||||
cfg DatasourceWriterConfig
|
||||
datasources datasources.DataSourceService
|
||||
httpClientProvider HttpClientProvider
|
||||
clock clock.Clock
|
||||
l log.Logger
|
||||
metrics *metrics.RemoteWriter
|
||||
|
||||
writers *gocache.Cache
|
||||
}
|
||||
|
||||
func NewDatasourceWriter(
|
||||
cfg DatasourceWriterConfig,
|
||||
datasources datasources.DataSourceService,
|
||||
httpClientProvider HttpClientProvider,
|
||||
clock clock.Clock,
|
||||
l log.Logger,
|
||||
metrics *metrics.RemoteWriter,
|
||||
) *DatasourceWriter {
|
||||
return &DatasourceWriter{
|
||||
cfg: cfg,
|
||||
datasources: datasources,
|
||||
httpClientProvider: httpClientProvider,
|
||||
clock: clock,
|
||||
l: l,
|
||||
metrics: metrics,
|
||||
writers: gocache.New(cacheExpiration, cacheCleanupInterval),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *DatasourceWriter) decrypt(ds *datasources.DataSource) (map[string]string, error) {
|
||||
decryptedJsonData, err := w.datasources.DecryptedValues(context.Background(), ds)
|
||||
if err != nil {
|
||||
w.l.Error("Failed to decrypt secure json data", "error", err)
|
||||
}
|
||||
return decryptedJsonData, err
|
||||
}
|
||||
|
||||
func (w *DatasourceWriter) makeWriter(ctx context.Context, orgID int64, dsUID string) (*PrometheusWriter, error) {
|
||||
ds, err := w.datasources.GetDataSource(ctx, &datasources.GetDataSourceQuery{
|
||||
UID: dsUID,
|
||||
OrgID: orgID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if ds.Type != datasources.DS_PROMETHEUS {
|
||||
return nil, errors.New("can only write to data sources of type prometheus")
|
||||
}
|
||||
|
||||
is, err := adapters.ModelToInstanceSettings(ds, w.decrypt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ho, err := is.HTTPClientOptions(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u, err := url.Parse(is.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u = u.JoinPath(w.cfg.RemoteWritePathSuffix)
|
||||
|
||||
cfg := PrometheusWriterConfig{
|
||||
URL: u.String(),
|
||||
HTTPOptions: httpclient.Options{
|
||||
Timeouts: ho.Timeouts,
|
||||
TLS: ho.TLS,
|
||||
BasicAuth: ho.BasicAuth,
|
||||
},
|
||||
Timeout: w.cfg.Timeout,
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewPrometheusWriter(
|
||||
cfg,
|
||||
w.httpClientProvider,
|
||||
w.clock,
|
||||
w.l,
|
||||
w.metrics)
|
||||
}
|
||||
|
||||
func uidKey(orgID int64, uid string) string {
|
||||
return fmt.Sprintf("%d-%s", orgID, uid)
|
||||
}
|
||||
|
||||
func (w *DatasourceWriter) WriteDatasource(ctx context.Context, dsUID string, name string, t time.Time, frames data.Frames, orgID int64, extraLabels map[string]string) error {
|
||||
if dsUID == "" {
|
||||
if w.cfg.DefaultDatasourceUID == "" {
|
||||
return errors.New("data source uid not specified and no default set")
|
||||
}
|
||||
dsUID = w.cfg.DefaultDatasourceUID
|
||||
w.l.Debug("Using default data source for remote write",
|
||||
"org_id", orgID, "datasource_uid", dsUID)
|
||||
}
|
||||
|
||||
key := uidKey(orgID, dsUID)
|
||||
|
||||
var writer *PrometheusWriter
|
||||
|
||||
val, ok := w.writers.Get(key)
|
||||
if ok {
|
||||
var ok bool
|
||||
writer, ok = val.(*PrometheusWriter)
|
||||
if !ok {
|
||||
return errors.New("type in cache not a Writer")
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
writer, err = w.makeWriter(ctx, orgID, dsUID)
|
||||
if err != nil {
|
||||
w.l.Error("Failed to create writer for data source",
|
||||
"org_id", orgID, "datasource_uid", dsUID)
|
||||
return err
|
||||
}
|
||||
|
||||
w.writers.Set(key, writer, 0)
|
||||
}
|
||||
|
||||
return writer.Write(ctx, name, t, frames, orgID, extraLabels)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package writer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/benbjohnson/clock"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/httpclient"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
dsfakes "github.com/grafana/grafana/pkg/services/datasources/fakes"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
|
||||
)
|
||||
|
||||
type testDataSources struct {
|
||||
dsfakes.FakeDataSourceService
|
||||
|
||||
prom1, prom2 *TestRemoteWriteTarget
|
||||
}
|
||||
|
||||
func (t *testDataSources) Reset() {
|
||||
t.prom1.Reset()
|
||||
t.prom2.Reset()
|
||||
}
|
||||
|
||||
func setupDataSources(t *testing.T) *testDataSources {
|
||||
res := &testDataSources{
|
||||
prom1: NewTestRemoteWriteTarget(t),
|
||||
prom2: NewTestRemoteWriteTarget(t),
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
res.prom1.Close()
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
res.prom2.Close()
|
||||
})
|
||||
|
||||
p1, _ := res.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{
|
||||
UID: "prom-1",
|
||||
Type: datasources.DS_PROMETHEUS,
|
||||
})
|
||||
p1.URL = res.prom1.srv.URL + "/api/v1"
|
||||
|
||||
p2, _ := res.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{
|
||||
UID: "prom-2",
|
||||
Type: datasources.DS_PROMETHEUS,
|
||||
})
|
||||
p2.URL = res.prom2.srv.URL + "/api/v1"
|
||||
|
||||
// Add a non-Prometheus datasource.
|
||||
_, _ = res.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{
|
||||
UID: "loki-1",
|
||||
Type: datasources.DS_LOKI,
|
||||
})
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func TestDatasourceWriter(t *testing.T) {
|
||||
series := []map[string]string{{"foo": "1"}, {"foo": "2"}, {"foo": "3"}, {"foo": "4"}}
|
||||
frames := frameGenFromLabels(t, data.FrameTypeNumericWide, series)
|
||||
|
||||
datasources := setupDataSources(t)
|
||||
|
||||
cfg := DatasourceWriterConfig{
|
||||
Timeout: time.Second * 5,
|
||||
DefaultDatasourceUID: "prom-2",
|
||||
RemoteWritePathSuffix: "/write",
|
||||
}
|
||||
|
||||
met := metrics.NewRemoteWriterMetrics(prometheus.NewRegistry())
|
||||
writer := NewDatasourceWriter(cfg, datasources, httpclient.NewProvider(), clock.New(), log.New("test"), met)
|
||||
|
||||
t.Run("when writing a prometheus datasource then the request is made to the expected endpoint", func(t *testing.T) {
|
||||
datasources.Reset()
|
||||
|
||||
err := writer.WriteDatasource(context.Background(), "prom-1", "metric", time.Now(), frames, 1, map[string]string{})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 1, datasources.prom1.RequestsCount)
|
||||
assert.Equal(t, 0, datasources.prom2.RequestsCount)
|
||||
|
||||
err = writer.WriteDatasource(context.Background(), "prom-2", "metric", time.Now(), frames, 1, map[string]string{})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 1, datasources.prom1.RequestsCount)
|
||||
assert.Equal(t, 1, datasources.prom2.RequestsCount)
|
||||
})
|
||||
|
||||
t.Run("when writing an unknown datasource then an error is returned", func(t *testing.T) {
|
||||
datasources.Reset()
|
||||
|
||||
err := writer.WriteDatasource(context.Background(), "prom-3", "metric", time.Now(), frames, 1, map[string]string{})
|
||||
require.Error(t, err)
|
||||
require.EqualError(t, err, "data source not found")
|
||||
})
|
||||
|
||||
t.Run("when writing a non-prometheus datasource then an error is returned", func(t *testing.T) {
|
||||
datasources.Reset()
|
||||
|
||||
err := writer.WriteDatasource(context.Background(), "loki-1", "metric", time.Now(), frames, 1, map[string]string{})
|
||||
require.Error(t, err)
|
||||
require.EqualError(t, err, "can only write to data sources of type prometheus")
|
||||
})
|
||||
|
||||
t.Run("when writing with an empty datasource uid then the default is written", func(t *testing.T) {
|
||||
datasources.Reset()
|
||||
|
||||
err := writer.WriteDatasource(context.Background(), "", "metric", time.Now(), frames, 1, map[string]string{})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
@@ -12,14 +12,6 @@ type FakeWriter struct {
|
||||
WriteFunc func(ctx context.Context, name string, t time.Time, frames data.Frames, orgID int64, extraLabels map[string]string) error
|
||||
}
|
||||
|
||||
func (w FakeWriter) Write(ctx context.Context, name string, t time.Time, frames data.Frames, orgID int64, extraLabels map[string]string) error {
|
||||
if w.WriteFunc == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return w.WriteFunc(ctx, name, t, frames, orgID, extraLabels)
|
||||
}
|
||||
|
||||
func (w FakeWriter) WriteDatasource(ctx context.Context, dsUID string, name string, t time.Time, frames data.Frames, orgID int64, extraLabels map[string]string) error {
|
||||
if w.WriteFunc == nil {
|
||||
return nil
|
||||
|
||||
@@ -12,7 +12,10 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const RemoteWriteEndpoint = "/api/v1/write"
|
||||
const RemoteWritePrefix = "/api/v1"
|
||||
const RemoteWriteSuffix = "/write"
|
||||
|
||||
const RemoteWriteEndpoint = RemoteWritePrefix + RemoteWriteSuffix
|
||||
|
||||
type TestRemoteWriteTarget struct {
|
||||
srv *httptest.Server
|
||||
@@ -59,6 +62,10 @@ func (s *TestRemoteWriteTarget) Close() {
|
||||
s.srv.Close()
|
||||
}
|
||||
|
||||
func (s *TestRemoteWriteTarget) DatasourceURL() string {
|
||||
return s.srv.URL + RemoteWritePrefix
|
||||
}
|
||||
|
||||
func (s *TestRemoteWriteTarget) ClientSettings() setting.RecordingRuleSettings {
|
||||
return setting.RecordingRuleSettings{
|
||||
URL: s.srv.URL + RemoteWriteEndpoint,
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
@@ -20,7 +21,6 @@ func ProvideDashboardUpdater(bus bus.Bus, pluginStore pluginstore.Store, pluginD
|
||||
dashboardPluginService dashboards.PluginService, dashboardService dashboards.DashboardService) *DashboardUpdater {
|
||||
du := newDashboardUpdater(bus, pluginStore, pluginDashboardService, dashboardImportService,
|
||||
pluginSettingsService, dashboardPluginService, dashboardService)
|
||||
du.updateAppDashboards()
|
||||
return du
|
||||
}
|
||||
|
||||
@@ -52,7 +52,12 @@ type DashboardUpdater struct {
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
func (du *DashboardUpdater) updateAppDashboards() {
|
||||
func (du *DashboardUpdater) Run(ctx context.Context) error {
|
||||
du.updateAppDashboards(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (du *DashboardUpdater) updateAppDashboards(ctx context.Context) {
|
||||
du.logger.Debug("Looking for app dashboard updates")
|
||||
|
||||
pluginSettings, err := du.pluginSettingsService.GetPluginSettings(context.Background(), &pluginsettings.GetArgs{OrgID: 0})
|
||||
@@ -67,9 +72,10 @@ func (du *DashboardUpdater) updateAppDashboards() {
|
||||
continue
|
||||
}
|
||||
|
||||
if pluginDef, exists := du.pluginStore.Plugin(context.Background(), pluginSetting.PluginID); exists {
|
||||
serviceCtx, _ := identity.WithServiceIdentity(ctx, pluginSetting.OrgID)
|
||||
if pluginDef, exists := du.pluginStore.Plugin(serviceCtx, pluginSetting.PluginID); exists {
|
||||
if pluginDef.Info.Version != pluginSetting.PluginVersion {
|
||||
du.syncPluginDashboards(context.Background(), pluginDef, pluginSetting.OrgID)
|
||||
du.syncPluginDashboards(serviceCtx, pluginDef, pluginSetting.OrgID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestDashboardUpdater(t *testing.T) {
|
||||
t.Run("updateAppDashboards", func(t *testing.T) {
|
||||
scenario(t, "Without any stored plugin settings shouldn't delete/import any dashboards",
|
||||
scenarioInput{}, func(ctx *scenarioContext) {
|
||||
ctx.dashboardUpdater.updateAppDashboards()
|
||||
ctx.dashboardUpdater.updateAppDashboards(context.Background())
|
||||
|
||||
require.Len(t, ctx.pluginSettingsService.getPluginSettingsArgs, 1)
|
||||
require.Equal(t, int64(0), ctx.pluginSettingsService.getPluginSettingsArgs[0])
|
||||
@@ -46,7 +46,7 @@ func TestDashboardUpdater(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}, func(ctx *scenarioContext) {
|
||||
ctx.dashboardUpdater.updateAppDashboards()
|
||||
ctx.dashboardUpdater.updateAppDashboards(context.Background())
|
||||
|
||||
require.NotEmpty(t, ctx.pluginSettingsService.getPluginSettingsArgs)
|
||||
require.Empty(t, ctx.dashboardService.deleteDashboardArgs)
|
||||
@@ -68,7 +68,7 @@ func TestDashboardUpdater(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}, func(ctx *scenarioContext) {
|
||||
ctx.dashboardUpdater.updateAppDashboards()
|
||||
ctx.dashboardUpdater.updateAppDashboards(context.Background())
|
||||
|
||||
require.NotEmpty(t, ctx.pluginSettingsService.getPluginSettingsArgs)
|
||||
require.Empty(t, ctx.dashboardService.deleteDashboardArgs)
|
||||
@@ -100,7 +100,7 @@ func TestDashboardUpdater(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}, func(ctx *scenarioContext) {
|
||||
ctx.dashboardUpdater.updateAppDashboards()
|
||||
ctx.dashboardUpdater.updateAppDashboards(context.Background())
|
||||
|
||||
require.NotEmpty(t, ctx.pluginSettingsService.getPluginSettingsArgs)
|
||||
require.Empty(t, ctx.dashboardService.deleteDashboardArgs)
|
||||
@@ -135,7 +135,7 @@ func TestDashboardUpdater(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}, func(ctx *scenarioContext) {
|
||||
ctx.dashboardUpdater.updateAppDashboards()
|
||||
ctx.dashboardUpdater.updateAppDashboards(context.Background())
|
||||
|
||||
require.NotEmpty(t, ctx.pluginSettingsService.getPluginSettingsArgs)
|
||||
require.Empty(t, ctx.dashboardService.deleteDashboardArgs)
|
||||
@@ -183,7 +183,7 @@ func TestDashboardUpdater(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}, func(ctx *scenarioContext) {
|
||||
ctx.dashboardUpdater.updateAppDashboards()
|
||||
ctx.dashboardUpdater.updateAppDashboards(context.Background())
|
||||
|
||||
require.NotEmpty(t, ctx.pluginSettingsService.getPluginSettingsArgs)
|
||||
require.Len(t, ctx.dashboardService.deleteDashboardArgs, 1)
|
||||
|
||||
@@ -337,7 +337,7 @@ func TestDynamicAngularDetectorsProviderBackgroundService(t *testing.T) {
|
||||
|
||||
t.Run("background service", func(t *testing.T) {
|
||||
oldBackgroundJobInterval := backgroundJobIntervalOnPrem
|
||||
backgroundJobIntervalOnPrem = time.Millisecond * 500
|
||||
backgroundJobIntervalOnPrem = 10 * time.Millisecond
|
||||
t.Cleanup(func() {
|
||||
backgroundJobIntervalOnPrem = oldBackgroundJobInterval
|
||||
})
|
||||
@@ -387,7 +387,7 @@ func TestDynamicAngularDetectorsProviderBackgroundService(t *testing.T) {
|
||||
|
||||
lastJobTime := time.Now()
|
||||
var jobCalls counter
|
||||
const jobInterval = time.Millisecond * 500
|
||||
const jobInterval = time.Millisecond * 20
|
||||
done := make(chan struct{})
|
||||
gcom := newDefaultGCOMScenario(func(_ http.ResponseWriter, _ *http.Request) {
|
||||
now := time.Now()
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package ualert
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
@@ -58,26 +62,59 @@ func (c setRuleGuidMigration) SQL(migrator.Dialect) string {
|
||||
}
|
||||
|
||||
func (c setRuleGuidMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) error {
|
||||
var results []string
|
||||
if err := sess.SQL("SELECT uid FROM alert_rule").Find(&results); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(results) == 0 {
|
||||
mg.Logger.Debug("no rules found")
|
||||
return nil
|
||||
}
|
||||
for _, uid := range results {
|
||||
u := uuid.NewString()
|
||||
_, err := sess.Exec("UPDATE alert_rule_version SET rule_guid = ? WHERE rule_uid = ?", u, uid)
|
||||
if err != nil {
|
||||
mg.Logger.Error("Failed to update alert_rule_version table", "error", err)
|
||||
var lastId *int64
|
||||
for {
|
||||
var results []int64
|
||||
qq := sess.Table(`alert_rule`).Select("id").OrderBy("id").Limit(500)
|
||||
if lastId != nil {
|
||||
qq = qq.Where("id > ?", lastId)
|
||||
}
|
||||
if err := qq.Find(&results); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = sess.Exec("UPDATE alert_rule SET guid = ? WHERE uid = ?", u, uid)
|
||||
if len(results) == 0 {
|
||||
break
|
||||
}
|
||||
bd := strings.Builder{}
|
||||
for idx, id := range results {
|
||||
u := uuid.NewString()
|
||||
if idx == 0 {
|
||||
bd.WriteString(fmt.Sprintf("SELECT %d as id, '%s' as guid", id, u))
|
||||
continue
|
||||
}
|
||||
bd.WriteString(fmt.Sprintf(" UNION ALL SELECT %d, '%s' ", id, u))
|
||||
}
|
||||
var q string
|
||||
if mg.Dialect.DriverName() == migrator.MySQL {
|
||||
q = fmt.Sprintf(`UPDATE alert_rule AS ar
|
||||
INNER JOIN (%s) AS t ON ar.id = t.id
|
||||
SET ar.guid = t.guid;`, bd.String())
|
||||
} else {
|
||||
q = fmt.Sprintf(`UPDATE alert_rule SET guid = t.guid FROM (%s) AS t WHERE alert_rule.id = t.id`, bd.String())
|
||||
}
|
||||
_, err := sess.Exec(q)
|
||||
if err != nil {
|
||||
mg.Logger.Error("Failed to update alert_rule table", "error", err)
|
||||
return err
|
||||
}
|
||||
lastId = util.Pointer(results[len(results)-1])
|
||||
}
|
||||
|
||||
q := `UPDATE alert_rule_version
|
||||
SET rule_guid = alert_rule.guid
|
||||
FROM alert_rule
|
||||
WHERE alert_rule.uid = alert_rule_version.rule_uid
|
||||
AND alert_rule.org_id = alert_rule_version.rule_org_id;`
|
||||
|
||||
if mg.Dialect.DriverName() == migrator.MySQL {
|
||||
q = `UPDATE alert_rule_version AS arv
|
||||
INNER JOIN alert_rule AS ar ON arv.rule_uid = ar.uid AND arv.rule_org_id = ar.org_id
|
||||
SET arv.rule_guid = ar.guid;`
|
||||
}
|
||||
_, err := sess.Exec(q)
|
||||
if err != nil {
|
||||
mg.Logger.Error("Failed to update alert_rule_version table with GUIDs from alert_rule table", "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -132,12 +132,14 @@ type UnifiedAlertingSettings struct {
|
||||
}
|
||||
|
||||
type RecordingRuleSettings struct {
|
||||
Enabled bool
|
||||
URL string
|
||||
BasicAuthUsername string
|
||||
BasicAuthPassword string
|
||||
CustomHeaders map[string]string
|
||||
Timeout time.Duration
|
||||
Enabled bool
|
||||
URL string
|
||||
BasicAuthUsername string
|
||||
BasicAuthPassword string
|
||||
CustomHeaders map[string]string
|
||||
Timeout time.Duration
|
||||
DefaultDatasourceUID string
|
||||
RemoteWritePathSuffix string
|
||||
}
|
||||
|
||||
// RemoteAlertmanagerSettings contains the configuration needed
|
||||
@@ -435,11 +437,13 @@ func (cfg *Cfg) ReadUnifiedAlertingSettings(iniFile *ini.File) error {
|
||||
|
||||
rr := iniFile.Section("recording_rules")
|
||||
uaCfgRecordingRules := RecordingRuleSettings{
|
||||
Enabled: rr.Key("enabled").MustBool(false),
|
||||
URL: rr.Key("url").MustString(""),
|
||||
BasicAuthUsername: rr.Key("basic_auth_username").MustString(""),
|
||||
BasicAuthPassword: rr.Key("basic_auth_password").MustString(""),
|
||||
Timeout: rr.Key("timeout").MustDuration(defaultRecordingRequestTimeout),
|
||||
Enabled: rr.Key("enabled").MustBool(false),
|
||||
URL: rr.Key("url").MustString(""),
|
||||
BasicAuthUsername: rr.Key("basic_auth_username").MustString(""),
|
||||
BasicAuthPassword: rr.Key("basic_auth_password").MustString(""),
|
||||
Timeout: rr.Key("timeout").MustDuration(defaultRecordingRequestTimeout),
|
||||
DefaultDatasourceUID: rr.Key("default_datasource_uid").MustString(""),
|
||||
RemoteWritePathSuffix: rr.Key("remote_write_path_suffix").MustString("/push"),
|
||||
}
|
||||
|
||||
rrHeaders := iniFile.Section("recording_rules.custom_headers")
|
||||
|
||||
@@ -95,7 +95,7 @@ func (d *dualWriter) Create(ctx context.Context, in runtime.Object, createValida
|
||||
|
||||
storageObj, errObjectSt := d.unified.Create(ctx, createdCopy, createValidation, options)
|
||||
if errObjectSt != nil {
|
||||
log.Error("unable to create object in unified storage", "err", err)
|
||||
log.Error("unable to create object in unified storage", "err", errObjectSt)
|
||||
if d.errorIsOK {
|
||||
return createdFromLegacy, nil
|
||||
}
|
||||
@@ -105,12 +105,13 @@ func (d *dualWriter) Create(ctx context.Context, in runtime.Object, createValida
|
||||
if err != nil {
|
||||
log.Error("unable to cleanup object in legacy storage", "err", err)
|
||||
}
|
||||
return storageObj, errObjectSt
|
||||
}
|
||||
|
||||
if d.readUnified {
|
||||
return storageObj, errObjectSt
|
||||
}
|
||||
return createdFromLegacy, errObjectSt
|
||||
return createdFromLegacy, err
|
||||
}
|
||||
|
||||
func (d *dualWriter) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
|
||||
@@ -121,7 +122,7 @@ func (d *dualWriter) Delete(ctx context.Context, name string, deleteValidation r
|
||||
// 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.
|
||||
objFromLegacy, asyncLegacy, err := d.legacy.Delete(ctx, name, deleteValidation, options)
|
||||
if err != nil && !d.readUnified {
|
||||
if err != nil && (!d.readUnified || !d.errorIsOK && !apierrors.IsNotFound(err)) {
|
||||
return objFromLegacy, asyncLegacy, err
|
||||
}
|
||||
|
||||
|
||||
@@ -256,6 +256,16 @@ func TestMode3_Delete(t *testing.T) {
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "should return an error when deleting an object in the LegacyStorage fails",
|
||||
setupLegacyFn: func(m *mock.Mock, input string) {
|
||||
m.On("Delete", mock.Anything, input, mock.Anything, mock.Anything).Return(nil, false, apierrors.NewInternalError(errors.New("error")))
|
||||
},
|
||||
setupStorageFn: func(m *mock.Mock, input string) {
|
||||
m.On("Delete", mock.Anything, input, mock.Anything, mock.Anything).Panic("i should not be called")
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
name := "foo"
|
||||
|
||||
@@ -50,3 +50,17 @@ type Service interface {
|
||||
// change the status (finish migration etc)
|
||||
Update(ctx context.Context, status StorageStatus) (StorageStatus, error)
|
||||
}
|
||||
|
||||
type SearchAdapter struct {
|
||||
Service
|
||||
}
|
||||
|
||||
func NewSearchAdapter(s Service) *SearchAdapter {
|
||||
return &SearchAdapter{Service: s}
|
||||
}
|
||||
|
||||
func (d *SearchAdapter) IsEnabled(gr schema.GroupResource) bool {
|
||||
//nolint:errcheck
|
||||
status, _ := d.Status(context.Background(), gr)
|
||||
return status.Runtime && d.Service.ShouldManage(gr)
|
||||
}
|
||||
@@ -202,7 +202,7 @@ require (
|
||||
github.com/googleapis/go-sql-spanner v1.11.1 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20250305143710-aae4827ec061 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20250307175047-1d263576d356 // indirect
|
||||
github.com/grafana/authlib v0.0.0-20250225105729-99e678595501 // indirect
|
||||
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
||||
github.com/grafana/dskit v0.0.0-20241105154643-a6b453a88040 // indirect
|
||||
|
||||
@@ -1253,8 +1253,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grafana/alerting v0.0.0-20250305143710-aae4827ec061 h1:2s0xCzcxw8MlzLbbrEkghmwQpXjwkcRJYSItBQ1ZD0U=
|
||||
github.com/grafana/alerting v0.0.0-20250305143710-aae4827ec061/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM=
|
||||
github.com/grafana/alerting v0.0.0-20250307175047-1d263576d356 h1:71o8Bxw/wg+aBRUASiGux67gDEUC2bqq3I+x2hw8eIc=
|
||||
github.com/grafana/alerting v0.0.0-20250307175047-1d263576d356/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM=
|
||||
github.com/grafana/authlib v0.0.0-20250225105729-99e678595501 h1:FTuDRy/Shw8yOdG+v1DnkeuaCAl8fvwgcfaG9Wccuhg=
|
||||
github.com/grafana/authlib v0.0.0-20250225105729-99e678595501/go.mod h1:XVpdLhaeYqz414FmGnW00/0vTe1x8c0GRH3KaeRtyg0=
|
||||
github.com/grafana/authlib/types v0.0.0-20250224151205-5ef97131cc82 h1:DnRUYiAotHXnrfYJCvhH1NkiyWVcPm5Pd+P7Ugqt/d8=
|
||||
|
||||
@@ -62,6 +62,9 @@ type IndexableDocument struct {
|
||||
// The generic display name
|
||||
Title string `json:"title,omitempty"`
|
||||
|
||||
// internal field for searching title with ngrams
|
||||
TitleNgram string `json:"title_ngram,omitempty"`
|
||||
|
||||
// internal sort field for title ( don't set this directly )
|
||||
TitlePhrase string `json:"title_phrase,omitempty"`
|
||||
|
||||
@@ -169,7 +172,8 @@ func NewIndexableDocument(key *ResourceKey, rv int64, obj utils.GrafanaMetaAcces
|
||||
Key: key,
|
||||
RV: rv,
|
||||
Name: key.Name,
|
||||
Title: title, // We always want *something* to display
|
||||
Title: title, // We always want *something* to display
|
||||
TitleNgram: title,
|
||||
TitlePhrase: strings.ToLower(title), // Lowercase for case-insensitive sorting
|
||||
Labels: obj.GetLabels(),
|
||||
Folder: obj.GetFolder(),
|
||||
@@ -264,6 +268,7 @@ const SEARCH_FIELD_NAMESPACE = "namespace"
|
||||
const SEARCH_FIELD_NAME = "name"
|
||||
const SEARCH_FIELD_RV = "rv"
|
||||
const SEARCH_FIELD_TITLE = "title"
|
||||
const SEARCH_FIELD_TITLE_NGRAM = "title_ngram"
|
||||
const SEARCH_FIELD_TITLE_PHRASE = "title_phrase" // filtering/sorting on title by full phrase
|
||||
const SEARCH_FIELD_DESCRIPTION = "description"
|
||||
const SEARCH_FIELD_TAGS = "tags"
|
||||
|
||||
@@ -37,6 +37,7 @@ func TestStandardDocumentBuilder(t *testing.T) {
|
||||
"rv": 10,
|
||||
"title": "test playlist unified storage",
|
||||
"title_phrase": "test playlist unified storage",
|
||||
"title_ngram": "test playlist unified storage",
|
||||
"created": 1717236672000,
|
||||
"createdBy": "user:ABC",
|
||||
"updatedBy": "user:XYZ",
|
||||
|
||||
@@ -131,7 +131,7 @@ require (
|
||||
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
|
||||
github.com/googleapis/go-sql-spanner v1.11.1 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20250305143710-aae4827ec061 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20250307175047-1d263576d356 // indirect
|
||||
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
||||
github.com/grafana/grafana-app-sdk/logging v0.30.0 // indirect
|
||||
github.com/grafana/grafana-aws-sdk v0.31.5 // indirect
|
||||
|
||||
@@ -1150,8 +1150,8 @@ github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z
|
||||
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-20250305143710-aae4827ec061 h1:2s0xCzcxw8MlzLbbrEkghmwQpXjwkcRJYSItBQ1ZD0U=
|
||||
github.com/grafana/alerting v0.0.0-20250305143710-aae4827ec061/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM=
|
||||
github.com/grafana/alerting v0.0.0-20250307175047-1d263576d356 h1:71o8Bxw/wg+aBRUASiGux67gDEUC2bqq3I+x2hw8eIc=
|
||||
github.com/grafana/alerting v0.0.0-20250307175047-1d263576d356/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM=
|
||||
github.com/grafana/authlib v0.0.0-20250225105729-99e678595501 h1:FTuDRy/Shw8yOdG+v1DnkeuaCAl8fvwgcfaG9Wccuhg=
|
||||
github.com/grafana/authlib v0.0.0-20250225105729-99e678595501/go.mod h1:XVpdLhaeYqz414FmGnW00/0vTe1x8c0GRH3KaeRtyg0=
|
||||
github.com/grafana/authlib/types v0.0.0-20250224151205-5ef97131cc82 h1:DnRUYiAotHXnrfYJCvhH1NkiyWVcPm5Pd+P7Ugqt/d8=
|
||||
|
||||
@@ -2,6 +2,7 @@ package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
@@ -23,6 +24,8 @@ const (
|
||||
mdOrgRole = "grafana-org-role"
|
||||
)
|
||||
|
||||
var logger = slog.Default().With("logger", "legacy.grpc.Authenticator")
|
||||
|
||||
// This is in a package we can no import
|
||||
// var _ interceptors.Authenticator = (*Authenticator)(nil)
|
||||
|
||||
@@ -142,6 +145,8 @@ func wrapContext(ctx context.Context) (context.Context, error) {
|
||||
func encodeIdentityInMetadata(user identity.Requester) metadata.MD {
|
||||
id, _ := user.GetInternalID()
|
||||
|
||||
logger.Debug("encodeIdentityInMetadata", "user.id", user.GetID(), "user.Login", user.GetLogin(), "user.Name", user.GetName())
|
||||
|
||||
return metadata.Pairs(
|
||||
// This should be everything needed to recreate the user
|
||||
mdToken, user.GetIDToken(),
|
||||
|
||||
@@ -5,13 +5,15 @@ import (
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
|
||||
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
|
||||
)
|
||||
|
||||
func NewSearchClient(dual dualwrite.Service, gr schema.GroupResource, unifiedClient ResourceIndexClient, legacyClient ResourceIndexClient) ResourceIndexClient {
|
||||
status, _ := dual.Status(context.Background(), gr)
|
||||
if status.Runtime && dual.ShouldManage(gr) {
|
||||
type DualWriter interface {
|
||||
IsEnabled(schema.GroupResource) bool
|
||||
ReadFromUnified(context.Context, schema.GroupResource) (bool, error)
|
||||
}
|
||||
|
||||
func NewSearchClient(dual DualWriter, gr schema.GroupResource, unifiedClient ResourceIndexClient, legacyClient ResourceIndexClient) ResourceIndexClient {
|
||||
if dual.IsEnabled(gr) {
|
||||
return &searchWrapper{
|
||||
dual: dual,
|
||||
groupResource: gr,
|
||||
@@ -19,14 +21,15 @@ func NewSearchClient(dual dualwrite.Service, gr schema.GroupResource, unifiedCli
|
||||
legacyClient: legacyClient,
|
||||
}
|
||||
}
|
||||
if status.ReadUnified {
|
||||
//nolint:errcheck
|
||||
if ok, _ := dual.ReadFromUnified(context.Background(), gr); ok {
|
||||
return unifiedClient
|
||||
}
|
||||
return legacyClient
|
||||
}
|
||||
|
||||
type searchWrapper struct {
|
||||
dual dualwrite.Service
|
||||
dual DualWriter
|
||||
groupResource schema.GroupResource
|
||||
|
||||
unifiedClient ResourceIndexClient
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/blevesearch/bleve/v2"
|
||||
"github.com/blevesearch/bleve/v2/analysis/analyzer/keyword"
|
||||
"github.com/blevesearch/bleve/v2/analysis/analyzer/standard"
|
||||
"github.com/blevesearch/bleve/v2/mapping"
|
||||
"github.com/blevesearch/bleve/v2/search"
|
||||
"github.com/blevesearch/bleve/v2/search/query"
|
||||
@@ -118,7 +120,10 @@ func (b *bleveBackend) BuildIndex(ctx context.Context,
|
||||
var index bleve.Index
|
||||
|
||||
build := true
|
||||
mapper := getBleveMappings(fields)
|
||||
mapper, err := getBleveMappings(fields)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if size > b.opts.FileThreshold {
|
||||
resourceDir := filepath.Join(b.opts.Root, key.Namespace,
|
||||
@@ -596,12 +601,26 @@ func (b *bleveIndex) toBleveSearchRequest(ctx context.Context, req *resource.Res
|
||||
// Add a text query
|
||||
if req.Query != "" && req.Query != "*" {
|
||||
searchrequest.Fields = append(searchrequest.Fields, resource.SEARCH_FIELD_SCORE)
|
||||
// mimic the behavior of the sql search
|
||||
query := strings.ToLower(req.Query)
|
||||
if !strings.Contains(query, "*") {
|
||||
query = "*" + query + "*"
|
||||
}
|
||||
queries = append(queries, bleve.NewWildcardQuery(query))
|
||||
|
||||
// There are multiple ways to match the query string to documents. The following queries are ordered by priority:
|
||||
|
||||
// Query 1: Match the exact query string
|
||||
queryExact := bleve.NewMatchQuery(req.Query)
|
||||
queryExact.SetBoost(10.0)
|
||||
queryExact.Analyzer = keyword.Name // don't analyze the query input - treat it as a single token
|
||||
|
||||
// Query 2: Phrase query with standard analyzer
|
||||
queryPhrase := bleve.NewMatchPhraseQuery(req.Query)
|
||||
queryExact.SetBoost(5.0)
|
||||
queryPhrase.Analyzer = standard.Name
|
||||
|
||||
// Query 3: Match query with standard analyzer
|
||||
queryAnalyzed := bleve.NewMatchQuery(req.Query)
|
||||
queryAnalyzed.Analyzer = standard.Name
|
||||
|
||||
// At least one of the queries must match
|
||||
searchQuery := bleve.NewDisjunctionQuery(queryExact, queryAnalyzed, queryPhrase)
|
||||
queries = append(queries, searchQuery)
|
||||
}
|
||||
|
||||
switch len(queries) {
|
||||
@@ -664,11 +683,18 @@ func (b *bleveIndex) toBleveSearchRequest(ctx context.Context, req *resource.Res
|
||||
sorting := getSortFields(req)
|
||||
searchrequest.SortBy(sorting)
|
||||
|
||||
// Always sort by *something*, otherwise the order is unstable
|
||||
// When no sort fields are provided, sort by score if there is a query, otherwise sort by title
|
||||
if len(sorting) == 0 {
|
||||
searchrequest.Sort = append(searchrequest.Sort, &search.SortDocID{
|
||||
Desc: false,
|
||||
})
|
||||
if req.Query != "" && req.Query != "*" {
|
||||
searchrequest.Sort = append(searchrequest.Sort, &search.SortScore{
|
||||
Desc: true,
|
||||
})
|
||||
} else {
|
||||
searchrequest.Sort = append(searchrequest.Sort, &search.SortField{
|
||||
Field: resource.SEARCH_FIELD_TITLE_PHRASE,
|
||||
Desc: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return searchrequest, nil
|
||||
|
||||
@@ -3,15 +3,22 @@ package search
|
||||
import (
|
||||
"github.com/blevesearch/bleve/v2"
|
||||
"github.com/blevesearch/bleve/v2/analysis/analyzer/keyword"
|
||||
"github.com/blevesearch/bleve/v2/analysis/analyzer/standard"
|
||||
"github.com/blevesearch/bleve/v2/mapping"
|
||||
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
)
|
||||
|
||||
func getBleveMappings(fields resource.SearchableDocumentFields) mapping.IndexMapping {
|
||||
func getBleveMappings(fields resource.SearchableDocumentFields) (mapping.IndexMapping, error) {
|
||||
mapper := bleve.NewIndexMapping()
|
||||
|
||||
err := RegisterCustomAnalyzers(mapper)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mapper.DefaultMapping = getBleveDocMappings(fields)
|
||||
return mapper
|
||||
|
||||
return mapper, nil
|
||||
}
|
||||
|
||||
func getBleveDocMappings(_ resource.SearchableDocumentFields) *mapping.DocumentMapping {
|
||||
@@ -24,15 +31,22 @@ func getBleveDocMappings(_ resource.SearchableDocumentFields) *mapping.DocumentM
|
||||
}
|
||||
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_NAME, nameMapping)
|
||||
|
||||
// for searching by title - uses an edge ngram token filter
|
||||
titleSearchMapping := bleve.NewTextFieldMapping()
|
||||
titleSearchMapping.Analyzer = TITLE_ANALYZER
|
||||
titleSearchMapping.Store = true
|
||||
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TITLE_NGRAM, titleSearchMapping)
|
||||
|
||||
// mapping for title to search on words/tokens larger than the ngram size
|
||||
titleWordMapping := bleve.NewTextFieldMapping()
|
||||
titleWordMapping.Analyzer = standard.Name
|
||||
titleWordMapping.Store = true
|
||||
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TITLE, titleWordMapping)
|
||||
|
||||
// for filtering/sorting by title full phrase
|
||||
titlePhraseMapping := bleve.NewKeywordFieldMapping()
|
||||
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TITLE_PHRASE, titlePhraseMapping)
|
||||
|
||||
// for searching by title
|
||||
// TODO: do we still need this since we have SEARCH_FIELD_TITLE_PHRASE?
|
||||
titleSearchMapping := bleve.NewTextFieldMapping()
|
||||
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TITLE, titleSearchMapping)
|
||||
|
||||
descriptionMapping := &mapping.FieldMapping{
|
||||
Name: resource.SEARCH_FIELD_DESCRIPTION,
|
||||
Type: "text",
|
||||
|
||||
@@ -12,7 +12,8 @@ import (
|
||||
)
|
||||
|
||||
func TestDocumentMapping(t *testing.T) {
|
||||
mappings := getBleveMappings(nil)
|
||||
mappings, err := getBleveMappings(nil)
|
||||
require.NoError(t, err)
|
||||
data := resource.IndexableDocument{
|
||||
Title: "title",
|
||||
Description: "descr",
|
||||
@@ -37,7 +38,7 @@ func TestDocumentMapping(t *testing.T) {
|
||||
}
|
||||
|
||||
doc := document.NewDocument("id")
|
||||
err := mappings.MapDocument(doc, data)
|
||||
err = mappings.MapDocument(doc, data)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, f := range doc.Fields {
|
||||
@@ -46,5 +47,5 @@ func TestDocumentMapping(t *testing.T) {
|
||||
|
||||
fmt.Printf("DOC: fields %d\n", len(doc.Fields))
|
||||
fmt.Printf("DOC: size %d\n", doc.Size())
|
||||
require.Equal(t, 14, len(doc.Fields))
|
||||
require.Equal(t, 15, len(doc.Fields))
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/blevesearch/bleve/v2"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/store/kind/dashboard"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCanSearchByTitle(t *testing.T) {
|
||||
key := &resource.ResourceKey{
|
||||
Namespace: "default",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
}
|
||||
|
||||
t.Run("when query is empty, sort documents by title instead of search score", func(t *testing.T) {
|
||||
index := newTestDashboardsIndex(t)
|
||||
err := index.Write(&resource.IndexableDocument{
|
||||
RV: 1,
|
||||
Name: "name1",
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "name1",
|
||||
Namespace: key.Namespace,
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Title: "bbb",
|
||||
TitleNgram: "bbb",
|
||||
TitlePhrase: "bbb",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = index.Write(&resource.IndexableDocument{
|
||||
RV: 1,
|
||||
Name: "name2",
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "name2",
|
||||
Namespace: key.Namespace,
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Title: "aaa",
|
||||
TitleNgram: "aaa",
|
||||
TitlePhrase: "aaa",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// search for phrase
|
||||
query := newQuery("")
|
||||
res, err := index.Search(context.Background(), nil, query, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(2), res.TotalHits)
|
||||
require.Equal(t, "name2", res.Results.Rows[0].Key.Name)
|
||||
})
|
||||
|
||||
t.Run("will boost phrase match query over match query results", func(t *testing.T) {
|
||||
index := newTestDashboardsIndex(t)
|
||||
err := index.Write(&resource.IndexableDocument{
|
||||
RV: 1,
|
||||
Name: "name1",
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "name1",
|
||||
Namespace: key.Namespace,
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Title: "I want to say a hello",
|
||||
TitleNgram: "I want to say a hello",
|
||||
TitlePhrase: "I want to say a hello",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = index.Write(&resource.IndexableDocument{
|
||||
RV: 1,
|
||||
Name: "name2",
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "name2",
|
||||
Namespace: key.Namespace,
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Title: "we want hello",
|
||||
TitleNgram: "we want hello",
|
||||
TitlePhrase: "we want hello",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// search for phrase
|
||||
query := newQuery("want hello")
|
||||
res, err := index.Search(context.Background(), nil, query, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(2), res.TotalHits)
|
||||
require.Equal(t, "name2", res.Results.Rows[0].Key.Name)
|
||||
})
|
||||
|
||||
t.Run("will prioritize matches", func(t *testing.T) {
|
||||
index := newTestDashboardsIndex(t)
|
||||
err := index.Write(&resource.IndexableDocument{
|
||||
RV: 1,
|
||||
Name: "name1",
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "name1",
|
||||
Namespace: key.Namespace,
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Title: "Asserts Dashboards",
|
||||
TitleNgram: "Asserts Dashboards",
|
||||
TitlePhrase: "Asserts Dashboards",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = index.Write(&resource.IndexableDocument{
|
||||
RV: 1,
|
||||
Name: "name2",
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "name2",
|
||||
Namespace: key.Namespace,
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Title: "New dashboard 10",
|
||||
TitleNgram: "New dashboard 10",
|
||||
TitlePhrase: "New dashboard 10",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
query := newQuery("New dash")
|
||||
res, err := index.Search(context.Background(), nil, query, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(2), res.TotalHits)
|
||||
require.Equal(t, "name2", res.Results.Rows[0].Key.Name)
|
||||
})
|
||||
|
||||
t.Run("will boost exact match query over match phrase query results", func(t *testing.T) {
|
||||
index := newTestDashboardsIndex(t)
|
||||
err := index.Write(&resource.IndexableDocument{
|
||||
RV: 1,
|
||||
Name: "name1",
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "name1",
|
||||
Namespace: key.Namespace,
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Title: "we want hello pls",
|
||||
TitleNgram: "we want hello pls",
|
||||
TitlePhrase: "we want hello pls",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = index.Write(&resource.IndexableDocument{
|
||||
RV: 1,
|
||||
Name: "name2",
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "name2",
|
||||
Namespace: key.Namespace,
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Title: "we want hello",
|
||||
TitleNgram: "we want hello",
|
||||
TitlePhrase: "we want hello",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// search for exact match
|
||||
query := newQuery("we want hello")
|
||||
res, err := index.Search(context.Background(), nil, query, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(2), res.TotalHits)
|
||||
require.Equal(t, "name2", res.Results.Rows[0].Key.Name)
|
||||
})
|
||||
|
||||
t.Run("title with numbers will match document", func(t *testing.T) {
|
||||
index := newTestDashboardsIndex(t)
|
||||
err := index.Write(&resource.IndexableDocument{
|
||||
RV: 1,
|
||||
Name: "name1",
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "aaa",
|
||||
Namespace: key.Namespace,
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Title: "A123456",
|
||||
TitleNgram: "A123456",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// search for prefix of title with mix of chars and numbers
|
||||
query := newQuery("A12")
|
||||
res, err := index.Search(context.Background(), nil, query, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), res.TotalHits)
|
||||
|
||||
// search for whole title
|
||||
query = newQuery("A123456")
|
||||
res, err = index.Search(context.Background(), nil, query, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), res.TotalHits)
|
||||
})
|
||||
|
||||
t.Run("title search will match document", func(t *testing.T) {
|
||||
index := newTestDashboardsIndex(t)
|
||||
err := index.Write(&resource.IndexableDocument{
|
||||
RV: 1,
|
||||
Name: "name1",
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "aaa",
|
||||
Namespace: key.Namespace,
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Title: "I want to say a wonderfully Hello to the WORLD! Hello-world",
|
||||
TitleNgram: "I want to say a wonderfully Hello to the WORLD! Hello-world",
|
||||
TitlePhrase: "I want to say a wonderfully Hello to the WORLD! Hello-world",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// search by entire phrase
|
||||
query := newQuery("I want to say a wonderfully Hello to the WORLD! Hello-world")
|
||||
res, err := index.Search(context.Background(), nil, query, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), res.TotalHits)
|
||||
|
||||
// search for word at start
|
||||
query = newQuery("hello")
|
||||
res, err = index.Search(context.Background(), nil, query, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), res.TotalHits)
|
||||
|
||||
// search for word larger than ngram max size
|
||||
query = newQuery("wonderfully")
|
||||
res, err = index.Search(context.Background(), nil, query, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), res.TotalHits)
|
||||
|
||||
// search for word at end
|
||||
query = newQuery("world")
|
||||
res, err = index.Search(context.Background(), nil, query, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), res.TotalHits)
|
||||
|
||||
// can search for word substring anchored at start of word (edge ngram)
|
||||
query = newQuery("worl")
|
||||
res, err = index.Search(context.Background(), nil, query, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), res.TotalHits)
|
||||
|
||||
// can search for multiple, non-consecutive words in title
|
||||
query = newQuery("hello world")
|
||||
res, err = index.Search(context.Background(), nil, query, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), res.TotalHits)
|
||||
|
||||
// can search for a term with a hyphen
|
||||
query = newQuery("hello-world")
|
||||
res, err = index.Search(context.Background(), nil, query, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), res.TotalHits)
|
||||
})
|
||||
|
||||
t.Run("title search will NOT match documents", func(t *testing.T) {
|
||||
index := newTestDashboardsIndex(t)
|
||||
err := index.Write(&resource.IndexableDocument{
|
||||
RV: 1,
|
||||
Name: "name1",
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "name1",
|
||||
Namespace: key.Namespace,
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Title: "I want to say a wonderful Hello to the WORLD! Hello-world",
|
||||
TitleNgram: "I want to say a wonderful Hello to the WORLD! Hello-world",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = index.Write(&resource.IndexableDocument{
|
||||
RV: 1,
|
||||
Name: "name2",
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "name2",
|
||||
Namespace: key.Namespace,
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Title: "A0456",
|
||||
TitleNgram: "A0456",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = index.Write(&resource.IndexableDocument{
|
||||
RV: 1,
|
||||
Name: "name3",
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "name3",
|
||||
Namespace: key.Namespace,
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Title: "mash-A02382-10",
|
||||
TitleNgram: "mash-A02382-10",
|
||||
TitlePhrase: "mash-A02382-10",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// word that doesn't exist
|
||||
query := newQuery("cats")
|
||||
res, err := index.Search(context.Background(), nil, query, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(0), res.TotalHits)
|
||||
|
||||
// string shorter than 3 chars (ngam min)
|
||||
query = newQuery("ma")
|
||||
res, err = index.Search(context.Background(), nil, query, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(0), res.TotalHits)
|
||||
|
||||
// substring that doesn't exist
|
||||
query = newQuery("A01")
|
||||
res, err = index.Search(context.Background(), nil, query, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(0), res.TotalHits)
|
||||
})
|
||||
}
|
||||
|
||||
func newQuery(query string) *resource.ResourceSearchRequest {
|
||||
return &resource.ResourceSearchRequest{
|
||||
Options: &resource.ListOptions{
|
||||
Key: &resource.ResourceKey{
|
||||
Namespace: "default",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
},
|
||||
},
|
||||
Limit: 100000,
|
||||
Query: query,
|
||||
}
|
||||
}
|
||||
|
||||
func newTestDashboardsIndex(t *testing.T) resource.ResourceIndex {
|
||||
key := &resource.ResourceKey{
|
||||
Namespace: "default",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
}
|
||||
tmpdir, err := os.MkdirTemp("", "grafana-bleve-test")
|
||||
require.NoError(t, err)
|
||||
|
||||
backend, err := NewBleveBackend(BleveOptions{
|
||||
Root: tmpdir,
|
||||
FileThreshold: 9999, // use in-memory for tests
|
||||
}, tracing.NewNoopTracerService(), featuremgmt.WithFeatures(featuremgmt.FlagUnifiedStorageSearchPermissionFiltering))
|
||||
require.NoError(t, err)
|
||||
|
||||
// AVOID NPE in test
|
||||
resource.NewIndexMetrics(backend.opts.Root, backend)
|
||||
|
||||
rv := int64(10)
|
||||
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{Namespace: "ns"})
|
||||
|
||||
info, err := DashboardBuilder(func(ctx context.Context, namespace string, blob resource.BlobSupport) (resource.DocumentBuilder, error) {
|
||||
return &DashboardDocumentBuilder{
|
||||
Namespace: namespace,
|
||||
Blob: blob,
|
||||
Stats: make(map[string]map[string]int64), // empty stats
|
||||
DatasourceLookup: dashboard.CreateDatasourceLookup([]*dashboard.DatasourceQueryResult{{}}),
|
||||
}, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
index, err := backend.BuildIndex(ctx, resource.NamespacedResource{
|
||||
Namespace: key.Namespace,
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
}, 2, rv, info.Fields, func(index resource.ResourceIndex) (int64, error) { return 0, nil })
|
||||
require.NoError(t, err)
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
// helper to check which tokens are generated by an analyzer
|
||||
// nolint:unused
|
||||
func debugAnalyzer(index bleve.Index, analyzerName string, text string) {
|
||||
// Get the analyzer (default: "standard")
|
||||
analyzer := index.Mapping().AnalyzerNamed(analyzerName)
|
||||
if analyzer == nil {
|
||||
log.Fatal("Analyzer not found")
|
||||
}
|
||||
|
||||
// Analyze text to see generated tokens
|
||||
analysisResult := analyzer.Analyze([]byte(text))
|
||||
|
||||
// Print tokens
|
||||
fmt.Println("Generated tokens for analyzer:", analyzerName)
|
||||
for _, token := range analysisResult {
|
||||
fmt.Println(string(token.Term))
|
||||
}
|
||||
}
|
||||
|
||||
// helper to check which terms are indexed for a field
|
||||
// nolint:unused
|
||||
func debugIndexedTerms(index bleve.Index, field string) {
|
||||
// Check what terms exist for the title field
|
||||
fieldTerms, err := index.FieldDict(field)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
for {
|
||||
term, err := fieldTerms.Next()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if term != nil {
|
||||
fmt.Println(term.Term)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
|
||||
"github.com/blevesearch/bleve/v2/analysis/token/edgengram"
|
||||
"github.com/blevesearch/bleve/v2/analysis/token/lowercase"
|
||||
"github.com/blevesearch/bleve/v2/analysis/token/unique"
|
||||
"github.com/blevesearch/bleve/v2/analysis/tokenizer/whitespace"
|
||||
"github.com/blevesearch/bleve/v2/mapping"
|
||||
)
|
||||
|
||||
const TITLE_ANALYZER = "title_analyzer"
|
||||
|
||||
func RegisterCustomAnalyzers(mapper *mapping.IndexMappingImpl) error {
|
||||
return registerTitleAnalyzer(mapper)
|
||||
}
|
||||
|
||||
// The registerTitleAnalyzer function defines a custom analyzer for the title field.
|
||||
// The edgeNgramTokenFilter will create n-grams anchored to the front of each token.
|
||||
// For example, the token "hello" will be tokenized into "hel", "hell", "hello".
|
||||
func registerTitleAnalyzer(mapper *mapping.IndexMappingImpl) error {
|
||||
// Define an N-Gram tokenizer (for substring search)
|
||||
edgeNgramTokenFilter := map[string]interface{}{
|
||||
"type": edgengram.Name,
|
||||
"min": 3.0,
|
||||
"max": 10.0,
|
||||
"back": edgengram.FRONT,
|
||||
}
|
||||
err := mapper.AddCustomTokenFilter("edge_ngram_filter", edgeNgramTokenFilter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
//Create a custom analyzer using the N-Gram tokenizer
|
||||
ngramAnalyzer := map[string]interface{}{
|
||||
"type": custom.Name,
|
||||
"tokenizer": whitespace.Name,
|
||||
"token_filters": []string{"edge_ngram_filter", lowercase.Name, unique.Name},
|
||||
//"char_filters": //TODO IF NEEDED
|
||||
}
|
||||
|
||||
err = mapper.AddCustomAnalyzer(TITLE_ANALYZER, ngramAnalyzer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
"name": "aaa",
|
||||
"rv": 1234,
|
||||
"title": "Test title",
|
||||
"title_ngram": "Test title",
|
||||
"title_phrase": "test title",
|
||||
"description": "test description",
|
||||
"tags": [
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"name": "aaa",
|
||||
"rv": 1234,
|
||||
"title": "test-aaa",
|
||||
"title_ngram": "test-aaa",
|
||||
"title_phrase": "test-aaa",
|
||||
"created": 1730490142000,
|
||||
"createdBy": "user:1",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"name": "bbb",
|
||||
"rv": 1234,
|
||||
"title": "test-bbb",
|
||||
"title_ngram": "test-bbb",
|
||||
"title_phrase": "test-bbb",
|
||||
"created": 1730490142000,
|
||||
"createdBy": "user:1",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"name": "aaa",
|
||||
"rv": 1234,
|
||||
"title": "Test AAA",
|
||||
"title_ngram": "Test AAA",
|
||||
"title_phrase": "test aaa",
|
||||
"created": 1731336353000,
|
||||
"createdBy": "user:t000000001"
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"name": "aaa",
|
||||
"rv": 1234,
|
||||
"title": "Test AAA",
|
||||
"title_ngram": "Test AAA",
|
||||
"title_phrase": "test aaa",
|
||||
"labels": {
|
||||
"grafana.app/deprecatedInternalID": "123"
|
||||
|
||||
@@ -108,7 +108,8 @@ func TestIntegrationConvertPrometheusEndpoints(t *testing.T) {
|
||||
EnableUnifiedAlerting: true,
|
||||
DisableAnonymous: true,
|
||||
AppModeProduction: true,
|
||||
EnableFeatureToggles: []string{"alertingConversionAPI"},
|
||||
EnableFeatureToggles: []string{"alertingConversionAPI", "grafanaManagedRecordingRulesDatasources", "grafanaManagedRecordingRules"},
|
||||
EnableRecordingRules: true,
|
||||
})
|
||||
|
||||
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, gpath)
|
||||
@@ -219,7 +220,8 @@ func TestIntegrationConvertPrometheusEndpoints_UpdateRule(t *testing.T) {
|
||||
EnableUnifiedAlerting: true,
|
||||
DisableAnonymous: true,
|
||||
AppModeProduction: true,
|
||||
EnableFeatureToggles: []string{"alertingConversionAPI"},
|
||||
EnableFeatureToggles: []string{"alertingConversionAPI", "grafanaManagedRecordingRulesDatasources", "grafanaManagedRecordingRules"},
|
||||
EnableRecordingRules: true,
|
||||
})
|
||||
|
||||
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, gpath)
|
||||
@@ -305,7 +307,8 @@ func TestIntegrationConvertPrometheusEndpoints_Conflict(t *testing.T) {
|
||||
EnableUnifiedAlerting: true,
|
||||
DisableAnonymous: true,
|
||||
AppModeProduction: true,
|
||||
EnableFeatureToggles: []string{"alertingConversionAPI"},
|
||||
EnableFeatureToggles: []string{"alertingConversionAPI", "grafanaManagedRecordingRulesDatasources", "grafanaManagedRecordingRules"},
|
||||
EnableRecordingRules: true,
|
||||
})
|
||||
|
||||
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, gpath)
|
||||
@@ -392,7 +395,8 @@ func TestIntegrationConvertPrometheusEndpoints_CreatePausedRules(t *testing.T) {
|
||||
EnableUnifiedAlerting: true,
|
||||
DisableAnonymous: true,
|
||||
AppModeProduction: true,
|
||||
EnableFeatureToggles: []string{"alertingConversionAPI"},
|
||||
EnableFeatureToggles: []string{"alertingConversionAPI", "grafanaManagedRecordingRulesDatasources", "grafanaManagedRecordingRules"},
|
||||
EnableRecordingRules: true,
|
||||
})
|
||||
|
||||
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path)
|
||||
@@ -507,7 +511,8 @@ func TestIntegrationConvertPrometheusEndpoints_FolderUIDHeader(t *testing.T) {
|
||||
EnableUnifiedAlerting: true,
|
||||
DisableAnonymous: true,
|
||||
AppModeProduction: true,
|
||||
EnableFeatureToggles: []string{"alertingConversionAPI"},
|
||||
EnableFeatureToggles: []string{"alertingConversionAPI", "grafanaManagedRecordingRulesDatasources", "grafanaManagedRecordingRules"},
|
||||
EnableRecordingRules: true,
|
||||
})
|
||||
|
||||
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path)
|
||||
@@ -608,7 +613,8 @@ func TestIntegrationConvertPrometheusEndpoints_Delete(t *testing.T) {
|
||||
EnableUnifiedAlerting: true,
|
||||
DisableAnonymous: true,
|
||||
AppModeProduction: true,
|
||||
EnableFeatureToggles: []string{"alertingConversionAPI"},
|
||||
EnableFeatureToggles: []string{"alertingConversionAPI", "grafanaManagedRecordingRulesDatasources", "grafanaManagedRecordingRules"},
|
||||
EnableRecordingRules: true,
|
||||
})
|
||||
|
||||
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, gpath)
|
||||
|
||||
@@ -2161,10 +2161,8 @@ var expAlertmanagerConfigFromAPI = `
|
||||
"name": "discord_test",
|
||||
"type": "discord",
|
||||
"disableResolveMessage": false,
|
||||
"settings": {
|
||||
"url": "http://CHANNEL_ADDR/discord_recv/discord_test"
|
||||
},
|
||||
"secureFields": {}
|
||||
"settings": {},
|
||||
"secureFields": {"url": true}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2176,10 +2174,8 @@ var expAlertmanagerConfigFromAPI = `
|
||||
"name": "googlechat_test",
|
||||
"type": "googlechat",
|
||||
"disableResolveMessage": false,
|
||||
"settings": {
|
||||
"url": "http://CHANNEL_ADDR/googlechat_recv/googlechat_test"
|
||||
},
|
||||
"secureFields": {}
|
||||
"settings": {},
|
||||
"secureFields": {"url": true}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2207,10 +2203,8 @@ var expAlertmanagerConfigFromAPI = `
|
||||
"name": "victorops_test",
|
||||
"type": "victorops",
|
||||
"disableResolveMessage": false,
|
||||
"settings": {
|
||||
"url": "http://CHANNEL_ADDR/victorops_recv/victorops_test"
|
||||
},
|
||||
"secureFields": {}
|
||||
"settings": {},
|
||||
"secureFields": {"url": true}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -334,10 +334,10 @@ func TestIntegrationLegacySupport(t *testing.T) {
|
||||
require.Equal(t, 200, rsp.Response.StatusCode)
|
||||
require.Equal(t, "v1alpha1", rsp.Result.Meta.APIVersion)
|
||||
|
||||
// V2 should send a redirect
|
||||
// V2 should send a not acceptable
|
||||
rsp = apis.DoRequest(helper, apis.RequestParams{
|
||||
User: helper.Org1.Admin,
|
||||
Path: "/api/dashboards/uid/test-v2",
|
||||
}, &dtos.DashboardFullWithMeta{})
|
||||
require.Equal(t, 302, rsp.Response.StatusCode) // redirect
|
||||
require.Equal(t, 406, rsp.Response.StatusCode) // not acceptable
|
||||
}
|
||||
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user