Merge branch 'main' into eleijonmarck/teamlbac/warning-sign-on-lbac-rule

This commit is contained in:
Eric Leijonmarck
2024-02-15 12:16:01 +00:00
367 changed files with 9829 additions and 5831 deletions
-10
View File
@@ -1296,16 +1296,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
],
"public/app/core/components/help/HelpModal.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"],
[0, 0, 0, "Styles should be written using objects.", "7"]
],
"public/app/core/navigation/types.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
+3 -1
View File
@@ -113,7 +113,9 @@
"public/app/plugins/datasource/tempo/*.{ts,tsx}",
"public/app/plugins/datasource/tempo/**/*.{ts,tsx}",
"public/app/plugins/datasource/loki/*.{ts,tsx}",
"public/app/plugins/datasource/loki/**/*.{ts,tsx}"
"public/app/plugins/datasource/loki/**/*.{ts,tsx}",
"public/app/plugins/datasource/elasticsearch/*.{ts,tsx}",
"public/app/plugins/datasource/elasticsearch/**/*.{ts,tsx}"
],
"settings": {
"import/resolver": {
-1
View File
@@ -142,7 +142,6 @@
/pkg/tests/apis/ @grafana/grafana-app-platform-squad
/pkg/tests/api/correlations/ @grafana/explore-squad
/pkg/tsdb/grafanads/ @grafana/backend-platform
/pkg/tsdb/intervalv2/ @grafana/backend-platform
/pkg/tsdb/legacydata/ @grafana/backend-platform
/pkg/tsdb/opentsdb/ @grafana/backend-platform
/pkg/tsdb/sqleng/ @grafana/partner-datasources @grafana/oss-big-tent
+1
View File
@@ -6,6 +6,7 @@
"ignoreDeps": [
"history", // we should bump this together with react-router-dom (see https://github.com/grafana/grafana/issues/76744)
"react-router-dom", // we should bump this together with history (see https://github.com/grafana/grafana/issues/76744)
"loader-utils", // v3 requires upstream changes in ngtemplate-loader. ignore, and remove when we remove angular.
"monaco-editor", // due to us exposing this via @grafana/ui/CodeEditor's props bumping can break plugins
"@fingerprintjs/fingerprintjs", // we don't want to bump to v4 due to licensing changes
],
@@ -9,9 +9,9 @@ on:
type: choice
options:
- grafana-azure-monitor-datasource
- grafana-cloud-monitoring-datasource
- grafana-testdata-datasource
- parca
- stackdriver
- tempo
concurrency:
+3 -1
View File
@@ -12,6 +12,7 @@ node_modules
pkg
public/lib/monaco
public/sass/*.generated.scss
scripts/cli/bettererIssueTemplate.md
scripts/grafana-server/tmp
vendor
@@ -37,4 +38,5 @@ kinds/report.json
# Generated schema docs
docs/sources/developers/kinds/
scripts/cli/bettererIssueTemplate.md
# Crowdin files
public/locales/**/*.json
+5
View File
@@ -840,6 +840,7 @@ key_file =
key_id =
role_attribute_path =
role_attribute_strict = false
groups_attribute_path =
auto_sign_up = false
url_login = false
allow_assign_grafana_admin = false
@@ -1202,6 +1203,10 @@ max_state_save_concurrency = 1
# The interval string is a possibly signed sequence of decimal numbers, followed by a unit suffix (ms, s, m, h, d), e.g. 30s or 1m.
state_periodic_save_interval = 5m
# Disables the smoothing of alert evaluations across their evaluation window.
# Rules will evaluate in sync.
disable_jitter = false
[unified_alerting.screenshots]
# Enable screenshots in notifications. You must have either installed the Grafana image rendering
# plugin, or set up Grafana to use a remote rendering service.
+5 -1
View File
@@ -774,6 +774,7 @@
# Use in conjunction with key_file in case the JWT token's header specifies a key ID in "kid" field
;key_id = some-key-id
;role_attribute_path =
;groups_attribute_path =
;role_attribute_strict = false
;auto_sign_up = false
;url_login = false
@@ -1129,6 +1130,10 @@
# The interval string is a possibly signed sequence of decimal numbers, followed by a unit suffix (ms, s, m, h, d), e.g. 30s or 1m.
;state_periodic_save_interval = 5m
# Disables the smoothing of alert evaluations across their evaluation window.
# Rules will evaluate in sync.
;disable_jitter = false
[unified_alerting.reserved_labels]
# Comma-separated list of reserved labels added by the Grafana Alerting engine that should be disabled.
# For example: `disabled_labels=grafana_folder`
@@ -1639,4 +1644,3 @@
[public_dashboards]
# Set to false to disable public dashboards
;enabled = true
+28 -1
View File
@@ -29,13 +29,40 @@ We value clean and readable code, that is loosely coupled and covered by unit te
Tests must use the standard library, `testing`. For assertions, prefer using [testify](https://github.com/stretchr/testify).
### Test Suite and Database Tests
We have a [testsuite](https://github.com/grafana/grafana/tree/main/pkg/tests/testsuite) package which provides utilities for package-level setup and teardown.
Currently this is just used to ensure that test databases are correctly set up and torn down, but it also provides a place we can attach future tasks.
Each package SHOULD include a [TestMain](https://pkg.go.dev/testing#hdr-Main) function that calls `testsuite.Run(m)`:
```go
package mypkg
import (
"testing"
"github.com/grafana/grafana/pkg/tests/testsuite"
)
func TestMain(m *testing.M) {
testsuite.Run(m)
}
```
You only need to define `TestMain` in one `_test.go` file within each package.
> Warning
> For tests that use the database, you MUST define `TestMain` so that the test databases can be cleaned up properly.
### Integration Tests
We run unit and integration tests separately, to help keep our CI pipeline running smoothly and provide a better developer experience.
To properly mark a test as being an integration test, you must format your test function definition as follows, with the function name starting with `TestIntegration` and the check for `testing.Short()`:
```
```go
func TestIntegrationFoo(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
+1
View File
@@ -9,6 +9,7 @@ Make sure you have the following dependencies installed before setting up your d
- [Git](https://git-scm.com/)
- [Go](https://golang.org/dl/) (see [go.mod](../go.mod#L3) for minimum required version)
- [Node.js (Long Term Support)](https://nodejs.org), with [corepack enabled](https://nodejs.org/api/corepack.html#enabling-the-feature)
- GCC (required for Cgo dependencies)
### macOS
@@ -24,6 +24,7 @@ expect_claims = {"iss": "http://env.grafana.local:8087/realms/grafana", "azp": "
auto_sign_up = true
role_attribute_path = contains(roles[*], 'grafanaadmin') && 'GrafanaAdmin' || contains(roles[*], 'admin') && 'Admin' || contains(roles[*], 'editor') && 'Editor' || 'Viewer'
role_attribute_strict = false
groups_attribute_path = groups[]
allow_assign_grafana_admin = true
```
@@ -28,7 +28,7 @@ The following tables list permissions associated with basic and fixed roles.
| Grafana Admin | `fixed:roles:reader`<br>`fixed:roles:writer`<br>`fixed:users:reader`<br>`fixed:users:writer`<br>`fixed:org.users:reader`<br>`fixed:org.users:writer`<br>`fixed:ldap:reader`<br>`fixed:ldap:writer`<br>`fixed:stats:reader`<br>`fixed:settings:reader`<br>`fixed:settings:writer`<br>`fixed:provisioning:writer`<br>`fixed:organization:reader`<br>`fixed:organization:maintainer`<br>`fixed:licensing:reader`<br>`fixed:licensing:writer`<br>`fixed:datasources.caching:reader`<br>`fixed:datasources.caching:writer`<br>`fixed:dashboards.insights:reader`<br>`fixed:datasources.insights:reader`<br>`fixed:plugins:maintainer`<br>`fixed:authentication.config:writer` | Default [Grafana server administrator]({{< relref "../../#grafana-server-administrators" >}}) assignments. |
| Admin | `fixed:reports:reader`<br>`fixed:reports:writer`<br>`fixed:datasources:reader`<br>`fixed:datasources:writer`<br>`fixed:organization:writer`<br>`fixed:datasources.permissions:reader`<br>`fixed:datasources.permissions:writer`<br>`fixed:teams:writer`<br>`fixed:dashboards:reader`<br>`fixed:dashboards:writer`<br>`fixed:dashboards.permissions:reader`<br>`fixed:dashboards.permissions:writer`<br>`fixed:dashboards.public:writer`<br>`fixed:folders:reader`<br>`fixed:folders:writer`<br>`fixed:folders.permissions:reader`<br>`fixed:folders.permissions:writer`<br>`fixed:alerting:writer`<br>`fixed:apikeys:reader`<br>`fixed:apikeys:writer`<br>`fixed:alerting.provisioning.secrets:reader`<br>`fixed:alerting.provisioning:writer`<br>`fixed:datasources.caching:reader`<br>`fixed:datasources.caching:writer`<br>`fixed:dashboards.insights:reader`<br>`fixed:datasources.insights:reader`<br>`fixed:plugins:writer` | Default [Grafana organization administrator]({{< relref "../#basic-roles" >}}) assignments. |
| Editor | `fixed:datasources:explorer`<br>`fixed:dashboards:creator`<br>`fixed:folders:creator`<br>`fixed:annotations:writer`<br>`fixed:teams:creator` if the `editors_can_admin` configuration flag is enabled<br>`fixed:alerting:writer`<br>`fixed:dashboards.insights:reader`<br>`fixed:datasources.insights:reader` | Default [Editor]({{< relref "../#basic-roles" >}}) assignments. |
| Viewer | `fixed:datasources:id:reader`<br>`fixed:organization:reader`<br>`fixed:annotations:reader`<br>`fixed:annotations.dashboard:writer`<br>`fixed:alerting:reader`<br>`fixed:plugins.app:reader`<br>`fixed:dashboards.insights:reader`<br>`fixed:datasources.insights:reader` | Default [Viewer]({{< relref "../#basic-roles" >}}) assignments. |
| Viewer | `fixed:datasources.id:reader`<br>`fixed:organization:reader`<br>`fixed:annotations:reader`<br>`fixed:annotations.dashboard:writer`<br>`fixed:alerting:reader`<br>`fixed:plugins.app:reader`<br>`fixed:dashboards.insights:reader`<br>`fixed:datasources.insights:reader` | Default [Viewer]({{< relref "../#basic-roles" >}}) assignments. |
| No Basic Role | | Default [No Basic Role]({{< relref "../#basic-roles" >}}) |
## Fixed role definitions
@@ -61,7 +61,7 @@ The following tables list permissions associated with basic and fixed roles.
| `fixed:datasources.caching:reader` | `datasources.caching:read` | Read data source query caching settings. |
| `fixed:datasources.caching:writer` | `datasources.caching:read`<br>`datasources.caching:write` | Enable, disable, or update query caching settings. |
| `fixed:datasources:explorer` | `datasources:explore` | Enable the Explore feature. Data source permissions still apply, you can only query data sources for which you have query permissions. |
| `fixed:datasources:id:reader` | `datasources.id:read` | Read the ID of a data source based on its name. |
| `fixed:datasources.id:reader` | `datasources.id:read` | Read the ID of a data source based on its name. |
| `fixed:datasources.insights:reader` | `datasources.insights:read` | Read data source insights data. |
| `fixed:datasources.permissions:reader` | `datasources.permissions:read` | Read data source permissions. |
| `fixed:datasources.permissions:writer` | All permissions from `fixed:datasources.permissions:reader` and <br>`datasources.permissions:write` | Create, read, or delete permissions of a data source. |
+1 -4
View File
@@ -54,7 +54,7 @@ Grafana Alerting supports many additional configuration options, from configurin
The following topics provide you with advanced configuration options for Grafana Alerting.
- [Provision alert rules using file provisioning][file-provisioning]
- [Provision alert rules using file provisioning](/docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/file-provisioning)
- [Provision alert rules using Terraform][terraform-provisioning]
- [Add an external Alertmanager][configure-alertmanager]
- [Configure high availability][configure-high-availability]
@@ -72,9 +72,6 @@ The following topics provide you with advanced configuration options for Grafana
[data-source-management]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/administration/data-source-management"
[data-source-management]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/administration/data-source-management"
[file-provisioning]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/file-provisioning"
[file-provisioning]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/provision-alerting-resources/file-provisioning"
[terraform-provisioning]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/terraform-provisioning"
[terraform-provisioning]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/set-up/provision-alerting-resources/terraform-provisioning"
{{% /docs/reference %}}
@@ -2,7 +2,7 @@
aliases:
- ../provision-alerting-resources/
canonical: https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/
description: Import and export alerting resources
description: Provision alerting resources
keywords:
- grafana
- alerting
@@ -14,11 +14,11 @@ labels:
- cloud
- enterprise
- oss
title: Import and export Grafana Alerting resources
title: Provision Alerting resources
weight: 300
---
# Import and export Grafana Alerting resources
# Provision Alerting resources
Alerting infrastructure is often complex, with many pieces of the pipeline that often live in different places. Scaling this across multiple teams and organizations is an especially challenging task. Importing and exporting (or provisioning) your alerting resources in Grafana Alerting makes this process easier by enabling you to create, manage, and maintain your alerting data in a way that best suits your organization.
@@ -26,116 +26,55 @@ You can import alert rules, contact points, notification policies, mute timings,
You cannot edit imported alerting resources in the Grafana UI in the same way as alerting resources that were not imported. You can only edit imported contact points, notification policies, templates, and mute timings in the source where they were created. For example, if you manage your alerting resources using files from disk, you cannot edit the data in Terraform or from within Grafana.
## Import alerting resources
Choose from the options below to import (or provision) your Grafana Alerting resources.
1. [Use configuration files to provision your alerting resources](/docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/file-provisioning), such as alert rules and contact points, through files on disk.
{{< admonition type="note" >}}
File provisioning is not available in Grafana Cloud instances.
{{< /admonition >}}
1. Use [Terraform to provision alerting resources][alerting_tf_provisioning].
1. Use the [Alerting provisioning HTTP API][alerting_http_provisioning] to manage alerting resources.
{{< admonition type="note" >}}
The JSON output from the majority of Alerting HTTP endpoints isn't compatible for provisioning via configuration files.
Instead, use the [Export Alerting endpoints](/docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/export-alerting-resources#export-api-endpoints) to return or download the alerting resources in provisioning format.
{{< /admonition >}}
## Export alerting resources
You can export both manually created and provisioned alerting resources. For more information, refer to [Export alerting resources][alerting_export].
To modify imported alert rules, you can use the **Modify export** feature to edit and then export.
Choose from the options below to import your Grafana Alerting resources.
## View provisioned alerting resources
1. Use file provisioning to manage your Grafana Alerting resources, such as alert rules and contact points, through files on disk.
To view your provisioned resources in Grafana, complete the following steps.
{{% admonition type="note" %}}
File provisioning is not available in Grafana Cloud instances.
{{% /admonition %}}
1. Open your Grafana instance.
1. Navigate to Alerting.
1. Click an alerting resource folder, for example, Alert rules.
2. Use the Alerting Provisioning HTTP API.
For more information on the Alerting Provisioning HTTP API, refer to [Alerting provisioning HTTP API][alerting_provisioning].
Here is a ready-to-use template for alert rules:
#### Alert rules template
```
{
"title": "TEST-API_1",
"ruleGroup": "API",
"folderUID": "FOLDER",
"noDataState": "OK",
"execErrState": "OK",
"for": "5m",
"orgId": 1,
"uid": "",
"condition": "B",
"annotations": {
"summary": "test_api_1"
},
"labels": {
"API": "test1"
},
"data": [
{
"refId": "A",
"queryType": "",
"relativeTimeRange": {
"from": 600,
"to": 0
},
"datasourceUid": " XXXXXXXXX-XXXXXXXXX-XXXXXXXXXX",
"model": {
"expr": "up",
"hide": false,
"intervalMs": 1000,
"maxDataPoints": 43200,
"refId": "A"
}
},
{
"refId": "B",
"queryType": "",
"relativeTimeRange": {
"from": 0,
"to": 0
},
"datasourceUid": "-100",
"model": {
"conditions": [
{
"evaluator": {
"params": [
6
],
"type": "gt"
},
"operator": {
"type": "and"
},
"query": {
"params": [
"A"
]
},
"reducer": {
"params": [],
"type": "last"
},
"type": "query"
}
],
"datasource": {
"type": "__expr__",
"uid": "-100"
},
"hide": false,
"intervalMs": 1000,
"maxDataPoints": 43200,
"refId": "B",
"type": "classic_conditions"
}
}
]
}
```
3. Use [Terraform](https://www.terraform.io/).
Provisioned resources are labeled **Provisioned**, so that it is clear that they were not created manually.
**Useful Links:**
[Grafana provisioning][provisioning]
[Grafana Alerting provisioning API][alerting_provisioning]
{{% docs/reference %}}
[alerting_provisioning]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/developers/http_api/alerting_provisioning"
[alerting_provisioning]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/developers/http_api/alerting_provisioning"
[alerting_tf_provisioning]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/terraform-provisioning"
[alerting_tf_provisioning]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/terraform-provisioning"
[alerting_http_provisioning]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning"
[alerting_http_provisioning]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning"
[alerting_export]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/export-alerting-resources"
[alerting_export]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/export-alerting-resources"
[alerting_export_http]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/export-alerting-resources#export-api-endpoints"
[alerting_export_http]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/export-alerting-resources#export-api-endpoints"
[provisioning]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/administration/provisioning"
[provisioning]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/administration/provisioning"
@@ -0,0 +1,106 @@
---
aliases:
- ../../provision-alerting-resources/view-provisioned-resources/
- ./view-provisioned-resources/
canonical: https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/export-alerting-resources/
description: Export alerting resources in Grafana
keywords:
- grafana
- alerting
- alerting resources
- provisioning
labels:
products:
- cloud
- enterprise
- oss
title: Export alerting resources
weight: 300
---
# Export alerting resources
Export your alerting resources, such as alert rules, contact points, and notification policies for provisioning, automatically importing single folders and single groups.
The export options listed below enable you to download resources in YAML, JSON, or Terraform format, facilitating their provisioning through [configuration files](/docs/grafana/<GRAFANA_VERSION>/alerting/set-up/provision-alerting-resources/file-provisioning) or [Terraform][alerting_tf_provisioning].
## Export alert rules
To export alert rules from the Grafana UI, complete the following steps.
1. Click **Alerts & IRM** -> **Alert rules**.
1. To export all Grafana-managed rules, click **Export rules**.
1. To export a folder, change the **View as** to **List**.
1. Select the folder you want to export and click the **Export rules folder** icon.
1. To export a group, change the **View as** to **Grouped**.
1. Find the group you want to export and click the **Export rule group** icon.
1. Choose the format to export in.
The exported rule data appears in different formats - YAML, JSON, Terraform.
1. Click **Copy Code** or **Download**.
a. Choose **Copy Code** to go to an existing file and paste in the code.
b. Choose **Download** to download a file with the exported data.
## Modify and export alert rules without saving changes
Use the **Modify export** mode to edit and export an alert rule without updating it.
{{% admonition type="note" %}} This feature is for Grafana-managed alert rules only. It is available to Admin, Viewer, and Editor roles. {{% /admonition %}}
To export a modified alert rule without saving the modifications, complete the following steps from the Grafana UI.
1. Click **Alerts & IRM** -> **Alert rules**.
1. Locate the alert rule you want to edit and click **More** -> **Modify Export** to open the Alert Rule form.
1. From the Alert Rule form, edit the fields you want to change. Changes made are not applied to the alert rule.
1. Click **Export**.
1. Choose the format to export in.
The exported rule data appears in different formats - YAML, JSON, Terraform.
1. Click **Copy Code** or **Download**.
a. Choose **Copy Code** to go to an existing file and paste in the code.
b. Choose **Download** to download a file with the exported data.
## Export API endpoints
You can also use the **Alerting provisioning HTTP API** to export alerting resources in YAML or JSON formats for provisioning.
Note that most Alerting endpoints return a JSON format that is not compatible for provisioning via configuration files, except the ones listed below.
| Method | URI | Summary |
| ------ | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| GET | /api/v1/provisioning/alert-rules/:uid/export | [Export an alert rule in provisioning file format.][export_rule] |
| GET | /api/v1/provisioning/folder/:folderUid/rule-groups/:group/export | [Export an alert rule group in provisioning file format.][export_rule_group] |
| GET | /api/v1/provisioning/alert-rules/export | [Export all alert rules in provisioning file format.][export_rules] |
| GET | /api/v1/provisioning/contact-points/export | [Export all contact points in provisioning file format.][export_contacts] |
| GET | /api/v1/provisioning/policies/export | [Export the notification policy tree in provisioning file format.][export_notifications] |
These endpoints accept a `download` parameter to download a file containing the exported resources.
{{% docs/reference %}}
[alerting_tf_provisioning]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/terraform-provisioning"
[alerting_tf_provisioning]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/terraform-provisioning"
[alerting_http_provisioning]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning"
[alerting_http_provisioning]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning"
[export_rule]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning/#span-idroute-get-alert-rule-exportspan-export-an-alert-rule-in-provisioning-file-format-_routegetalertruleexport_"
[export_rule]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning/#span-idroute-get-alert-rule-exportspan-export-an-alert-rule-in-provisioning-file-format-_routegetalertruleexport_"
[export_rule_group]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning/#span-idroute-get-alert-rule-group-exportspan-export-an-alert-rule-group-in-provisioning-file-format-_routegetalertrulegroupexport_"
[export_rule_group]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning/#span-idroute-get-alert-rule-group-exportspan-export-an-alert-rule-group-in-provisioning-file-format-_routegetalertrulegroupexport_"
[export_rules]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning/#span-idroute-get-alert-rules-exportspan-export-all-alert-rules-in-provisioning-file-format-_routegetalertrulesexport_"
[export_rules]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning/#span-idroute-get-alert-rules-exportspan-export-all-alert-rules-in-provisioning-file-format-_routegetalertrulesexport_"
[export_contacts]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning/#span-idroute-get-contactpoints-exportspan-export-all-contact-points-in-provisioning-file-format-_routegetcontactpointsexport_"
[export_contacts]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning/#span-idroute-get-contactpoints-exportspan-export-all-contact-points-in-provisioning-file-format-_routegetcontactpointsexport_"
[export_notifications]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning/#span-idroute-get-policy-tree-exportspan-export-the-notification-policy-tree-in-provisioning-file-format-_routegetpolicytreeexport_"
[export_notifications]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning/#span-idroute-get-policy-tree-exportspan-export-the-notification-policy-tree-in-provisioning-file-format-_routegetpolicytreeexport_"
{{% /docs/reference %}}
@@ -11,14 +11,14 @@ keywords:
- provisioning
labels:
products:
- cloud
- enterprise
- oss
title: Use file provisioning to manage alerting resources
menuTitle: Use configuration files to provision
title: Use configuration files to provision alerting resources
weight: 100
---
## Use file provisioning to manage alerting resources
# Use configuration files to provision alerting resources
Manage your alerting resources using files from disk. When you start Grafana, the data from these files is created in your Grafana system. Grafana adds any new resources you created, updates any that you changed, and deletes old ones.
@@ -26,26 +26,28 @@ Arrange your files in a directory in a way that best suits your use case. For ex
Details on how to set up the files and which fields are required for each object are listed below depending on which resource you are provisioning.
**Note:**
For a complete guide about how Grafana provisions resources, refer to the [Provision Grafana][provisioning] documentation.
Importing takes place during the initial set up of your Grafana system, but you can re-run it at any time using the [Grafana Admin API][reload-provisioning-configurations].
{{< admonition type="note" >}}
### Import alert rules
- You cannot edit provisioned resources from files in Grafana. You can only change the resource properties by changing the provisioning file and restarting Grafana or carrying out a hot reload. This prevents changes being made to the resource that would be overwritten if a file is provisioned again or a hot reload is carried out.
- Importing takes place during the initial set up of your Grafana system, but you can re-run it at any time using the [Grafana Admin API](/docs/grafana/<GRAFANA_VERSION>/developers/http_api/admin#reload-provisioning-configurations).
- Importing an existing alerting resource results in a conflict. First, when present, remove the resources you plan to import.
{{< /admonition >}}
## Import alert rules
Create or delete alert rules in your Grafana instance(s).
1. Create alert rules in Grafana.
1. Use the [Alerting provisioning API][alerting_provisioning] export endpoints to download a provisioning file for your alert rules.
1. Copy the contents into a YAML or JSON configuration file in the default provisioning directory or in your configured directory.
1. [Export][alerting_export] and download a provisioning file for your alert rules.
1. Copy the contents into a YAML or JSON configuration file in the `provisioning/alerting` directory.
Example configuration files can be found below.
1. Ensure that your files are in the right directory on the node running the Grafana server, so that they deploy alongside your Grafana instance(s).
1. Delete the alert rules in Grafana that are going to be imported.
**Note:**
If you do not delete the alert rule, it will clash with the imported alert rule once uploaded.
1. Add the file(s) to your GitOps workflow, so that they deploy alongside your Grafana instance(s).
Here is an example of a configuration file for creating alert rules.
@@ -134,17 +136,17 @@ deleteRules:
uid: my_id_1
```
### Import contact points
## Import contact points
Create or delete contact points in your Grafana instance(s).
1. Create a contact point in Grafana.
1. Use the [Alerting provisioning API][alerting_provisioning] export endpoints to download a provisioning file for your contact point.
1. Copy the contents into a YAML or JSON configuration file in the default provisioning directory or in your configured directory.
1. [Export][alerting_export] and download a provisioning file for your contact point.
1. Copy the contents into a YAML or JSON configuration file in the `provisioning/alerting` directory.
Example configuration files can be found below.
1. Ensure that your files are in the right directory on the node running the Grafana server, so that they deploy alongside your Grafana instance(s).
1. Add the file(s) to your GitOps workflow, so that they deploy alongside your Grafana instance(s).
Here is an example of a configuration file for creating contact points.
@@ -184,12 +186,14 @@ deleteContactPoints:
uid: first_uid
```
#### Settings
### Settings
Here are some examples of settings you can use for the different
contact point integrations.
##### Alertmanager
{{< collapse title="Alertmanager" >}}
#### Alertmanager
```yaml
type: prometheus-alertmanager
@@ -202,7 +206,11 @@ settings:
basicAuthPassword: abc123
```
##### DingDing
{{< /collapse >}}
{{< collapse title="DingDing" >}}
#### DingDing
```yaml
type: dingding
@@ -216,7 +224,11 @@ settings:
{{ template "default.message" . }}
```
##### Discord
{{< /collapse >}}
{{< collapse title="Discord" >}}
#### Discord
```yaml
type: discord
@@ -232,7 +244,11 @@ settings:
{{ template "default.message" . }}
```
##### E-Mail
{{< /collapse >}}
{{< collapse title="E-Mail" >}}
#### E-Mail
```yaml
type: email
@@ -248,7 +264,11 @@ settings:
{{ template "default.title" . }}
```
##### Google Chat
{{< /collapse >}}
{{< collapse title="Google Chat" >}}
#### Google Chat
```yaml
type: googlechat
@@ -260,7 +280,11 @@ settings:
{{ template "default.message" . }}
```
##### Kafka
{{< /collapse >}}
{{< collapse title="Kafka" >}}
#### Kafka
```yaml
type: kafka
@@ -271,7 +295,11 @@ settings:
kafkaTopic: topic1
```
##### LINE
{{< /collapse >}}
{{< collapse title="LINE" >}}
#### LINE
```yaml
type: line
@@ -280,7 +308,11 @@ settings:
token: xxx
```
##### Microsoft Teams
{{< /collapse >}}
{{< collapse title="Microsoft Teams" >}}
#### Microsoft Teams
```yaml
type: teams
@@ -297,7 +329,11 @@ settings:
{{ template "default.message" . }}
```
##### OpsGenie
{{< /collapse >}}
{{< collapse title="OpsGenie" >}}
#### OpsGenie
```yaml
type: opsgenie
@@ -319,7 +355,11 @@ settings:
sendTagsAs: both
```
##### PagerDuty
{{< /collapse >}}
{{< collapse title="PagerDuty" >}}
#### PagerDuty
```yaml
type: pagerduty
@@ -339,7 +379,11 @@ settings:
{{ template "default.message" . }}
```
##### Pushover
{{< /collapse >}}
{{< collapse title="Pushover" >}}
#### Pushover
```yaml
type: pushover
@@ -367,7 +411,11 @@ settings:
{{ template "default.message" . }}
```
##### Slack
{{< /collapse >}}
{{< collapse title="Slack" >}}
#### Slack
```yaml
type: slack
@@ -399,7 +447,11 @@ settings:
{{ template "slack.default.text" . }}
```
##### Sensu Go
{{< /collapse >}}
{{< collapse title="Sensu Go" >}}
#### Sensu Go
```yaml
type: sensugo
@@ -421,7 +473,11 @@ settings:
{{ template "default.message" . }}
```
##### Telegram
{{< /collapse >}}
{{< collapse title="Telegram" >}}
#### Telegram
```yaml
type: telegram
@@ -435,7 +491,11 @@ settings:
{{ template "default.message" . }}
```
##### Threema Gateway
{{< /collapse >}}
{{< collapse title="Threema Gateway" >}}
#### Threema Gateway
```yaml
type: threema
@@ -448,7 +508,11 @@ settings:
recipient_id: A9R4KL4S
```
##### VictorOps
{{< /collapse >}}
{{< collapse title="VictorOps" >}}
#### VictorOps
```yaml
type: victorops
@@ -459,7 +523,11 @@ settings:
messageType: CRITICAL
```
##### Webhook
{{< /collapse >}}
{{< collapse title="Webhook" >}}
#### Webhook
```yaml
type: webhook
@@ -480,7 +548,11 @@ settings:
maxAlerts: '10'
```
##### WeCom
{{< /collapse >}}
{{< collapse title="WeCom" >}}
#### WeCom
```yaml
type: wecom
@@ -495,17 +567,27 @@ settings:
{{ template "default.title" . }}
```
### Import notification policies
{{< /collapse >}}
## Import notification policies
Create or reset the notification policy tree in your Grafana instance(s).
In Grafana, the entire notification policy tree is considered a single, large resource. Add new specific policies as sub-policies under the root policy. Since specific policies may depend on each other, you cannot provision subsets of the policy tree; the entire tree must be defined in a single place.
{{% admonition type="warning" %}}
Since the policy tree is a single resource, provisioning it will overwrite a policy tree created through any other means.
{{< /admonition >}}
1. Create a notification policy in Grafana.
1. Use the [Alerting provisioning API][alerting_provisioning] export endpoints to download a provisioning file for your notification policy.
1. Copy the contents into a YAML or JSON configuration file in the default provisioning directory or in your configured directory.
1. [Export][alerting_export] and download a provisioning file for your notification policy.
1. Copy the contents into a YAML or JSON configuration file in the `provisioning/alerting` directory.
Example configuration files can be found below.
1. Ensure that your files are in the right directory on the node running the Grafana server, so that they deploy alongside your Grafana instance(s).
1. Add the file(s) to your GitOps workflow, so that they deploy alongside your Grafana instance(s).
Here is an example of a configuration file for creating notification policies.
@@ -581,13 +663,7 @@ resetPolicies:
- 1
```
**Note:**
In Grafana, the entire notification policy tree is considered a single, large resource. Add new specific policies as sub-policies under the root policy. Since specific policies may depend on each other, you cannot provision subsets of the policy tree; the entire tree must be defined in a single place.
Since the policy tree is a single resource, applying it will overwrite a policy tree created through any other means.
### Import templates
## Import templates
Create or delete templates in your Grafana instance(s).
@@ -595,7 +671,7 @@ Create or delete templates in your Grafana instance(s).
Example configuration files can be found below.
2. Add the file(s) to your GitOps workflow, so that they deploy alongside your Grafana instance(s).
1. Add the file(s) to your GitOps workflow, so that they deploy alongside your Grafana instance(s).
Here is an example of a configuration file for creating templates.
@@ -627,7 +703,7 @@ deleteTemplates:
name: my_first_template
```
### Import mute timings
## Import mute timings
Create or delete mute timings in your Grafana instance(s).
@@ -676,65 +752,68 @@ deleteMuteTimes:
name: mti_1
```
### File provisioning using Kubernetes
## File provisioning using Kubernetes
If you are a Kubernetes user, you can leverage file provisioning using Kubernetes configuration maps.
1. Create one or more configuration maps as follows.
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: grafana-alerting
data:
provisioning.yaml: |
templates:
- name: my_first_template
template: the content for my template
```
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: grafana-alerting
data:
provisioning.yaml: |
templates:
- name: my_first_template
template: the content for my template
```
2. Add the file(s) to your GitOps workflow, so that they deploy alongside your Grafana instance(s).
1. Add the file(s) to your GitOps workflow, so that they deploy alongside your Grafana instance(s).
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: grafana
spec:
replicas: 1
selector:
matchLabels:
app: grafana
template:
metadata:
name: grafana
labels:
app: grafana
spec:
containers:
- name: grafana
image: grafana/grafana:latest
ports:
- name: grafana
containerPort: 3000
volumeMounts:
- mountPath: /etc/grafana/provisioning/alerting
name: grafana-alerting
readOnly: false
volumes:
- name: grafana-alerting
configMap:
defaultMode: 420
name: grafana-alerting
```
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: grafana
spec:
replicas: 1
selector:
matchLabels:
app: grafana
template:
metadata:
name: grafana
labels:
app: grafana
spec:
containers:
- name: grafana
image: grafana/grafana:latest
ports:
- name: grafana
containerPort: 3000
volumeMounts:
- mountPath: /etc/grafana/provisioning/alerting
name: grafana-alerting
readOnly: false
volumes:
- name: grafana-alerting
configMap:
defaultMode: 420
name: grafana-alerting
```
This eliminates the need for a persistent database to use Grafana Alerting in Kubernetes; all your provisioned resources appear after each restart or re-deployment. Grafana still requires a database for normal operation, you do not need to persist the contents of the database between restarts if all objects are provisioned using files.
{{% docs/reference %}}
[alerting_provisioning]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/developers/http_api/alerting_provisioning"
[alerting_provisioning]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/developers/http_api/alerting_provisioning"
**Useful Links:**
[reload-provisioning-configurations]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/developers/http_api/admin#reload-provisioning-configurations"
[reload-provisioning-configurations]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/developers/http_api/admin#reload-provisioning-configurations"
[Grafana provisioning][provisioning]
{{% docs/reference %}}
[alerting_export]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/export-alerting-resources"
[alerting_export]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/export-alerting-resources"
[provisioning]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/administration/provisioning"
[provisioning]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/administration/provisioning"
{{% /docs/reference %}}
@@ -0,0 +1,20 @@
---
canonical: https://grafana.com/docs/grafana/latest/developers/http_api/alerting_provisioning/
description: Create and manage alerting resources using the HTTP API
keywords:
- grafana
- alerting
- alerting resources
- provisioning
labels:
products:
- cloud
- enterprise
- oss
title: Use the HTTP API to manage alerting resources
weight: 400
---
# Use the HTTP API to manage alerting resources
{{< docs/shared lookup="alerts/alerting_provisioning.md" source="grafana" version="latest" >}}
@@ -11,41 +11,46 @@ keywords:
- Terraform
labels:
products:
- cloud
- enterprise
- oss
title: Use Terraform to manage alerting resources
menuTitle: Use Terraform to provision
title: Use Terraform to provision alerting resources
weight: 200
---
# Use Terraform to manage alerting resources
# Use Terraform to provision alerting resources
Use Terraform’s Grafana Provider to manage your alerting resources and provision them into your Grafana system. Terraform provider support for Grafana Alerting makes it easy to create, manage, and maintain your entire Grafana Alerting stack as code.
For more information on managing your alerting resources using Terraform, refer to the [Grafana Provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs) documentation.
Refer to [Grafana Provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs) documentation for more examples and information on Terraform Alerting schemas.
Complete the following tasks to create and manage your alerting resources using Terraform.
1. Create an API key for provisioning.
1. Configure the Terraform provider.
1. Define your alerting resources in Terraform.
1. Define your alerting resources in Terraform. [Export alerting resources][alerting_export] in Terraform format, or implement the [Terraform Alerting schemas](https://registry.terraform.io/providers/grafana/grafana/latest/docs).
1. Run `terraform apply` to provision your alerting resources.
## Before you begin
{{< admonition type="note" >}}
- Ensure you have the grafana/grafana [Terraform provider](https://registry.terraform.io/providers/grafana/grafana/1.28.0) 1.27.0 or higher.
- By default, you cannot edit resources provisioned from Terraform from the UI. This ensures that your alerting stack always stays in sync with your code. To change the default behaviour, refer to [Edit provisioned resources in the Grafana UI](#edit-provisioned-resources-in-the-grafana-ui).
- Ensure you are using Grafana 9.1 or higher.
- Before you begin, ensure you have the [Grafana Terraform Provider](https://registry.terraform.io/providers/grafana/grafana/) 1.27.0 or higher, and are using Grafana 9.1 or higher.
{{< /admonition >}}
## Create an API key for provisioning
You can [create a normal Grafana API key][api-keys] to authenticate Terraform with Grafana. Most existing tooling using API keys should automatically work with the new Grafana Alerting support.
You can create a [service account token][service-accounts] to authenticate Terraform with Grafana. Most existing tooling using API keys should automatically work with the new Grafana Alerting support.
There are also dedicated RBAC roles for alerting provisioning. This lets you easily authenticate as a [service account][service-accounts] with the minimum permissions needed to provision your Alerting infrastructure.
There are also dedicated RBAC roles for alerting provisioning. This lets you easily authenticate as a service account with the minimum permissions needed to provision your Alerting infrastructure.
To create an API key for provisioning, complete the following steps.
1. Create a new service account for your CI pipeline.
1. Assign the role “Access the alert rules Provisioning API.”
1. Create a new service account.
1. Assign the role or permission to access the [Alerting provisioning API][alerting_http_provisioning].
1. Create a new service account token.
1. Name and save the token for use in Terraform.
@@ -73,70 +78,68 @@ provider "grafana" {
}
```
## Provision contact points and templates
## Import contact points and templates
Contact points connect an alerting stack to the outside world. They tell Grafana how to connect to your external systems and where to deliver notifications. There are over fifteen different [integrations](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/contact_point#optional) to choose from.
Contact points connect an alerting stack to the outside world. They tell Grafana how to connect to your external systems and where to deliver notifications.
To provision contact points and templates, complete the following steps.
To provision contact points and templates, refer to the [grafana_contact_point schema](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/contact_point) and [grafana_message_template schema](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/message_template), and complete the following steps.
1. Copy this code block into a .tf file on your local machine.
1. Copy this code block into a `.tf` file on your local machine.
This example creates a contact point that sends alert notifications to Slack.
This example creates a contact point that sends alert notifications to Slack.
```HCL
resource "grafana_contact_point" "my_slack_contact_point" {
name = "Send to My Slack Channel"
```HCL
resource "grafana_contact_point" "my_slack_contact_point" {
name = "Send to My Slack Channel"
slack {
url = <YOUR_SLACK_WEBHOOK_URL>
text = <<EOT
{{ len .Alerts.Firing }} alerts are firing!
slack {
url = <YOUR_SLACK_WEBHOOK_URL>
text = <<EOT
{{ len .Alerts.Firing }} alerts are firing!
Alert summaries:
{{ range .Alerts.Firing }}
{{ template "Alert Instance Template" . }}
{{ end }}
EOT
}
}
```
Alert summaries:
{{ range .Alerts.Firing }}
{{ template "Alert Instance Template" . }}
{{ end }}
EOT
}
}
```
You can create multiple external integrations in a single contact point. Notifications routed to this contact point will be sent to all integrations. This example shows multiple integrations in the same Terraform resource.
You can create multiple external integrations in a single contact point. Notifications routed to this contact point will be sent to all integrations. This example shows multiple integrations in the same Terraform resource.
```
resource "grafana_contact_point" "my_multi_contact_point" {
name = "Send to Many Places"
```
resource "grafana_contact_point" "my_multi_contact_point" {
name = "Send to Many Places"
slack {
url = "webhook1"
...
}
slack {
url = "webhook2"
...
}
teams {
...
}
email {
...
}
}
```
slack {
url = "webhook1"
...
}
slack {
url = "webhook2"
...
}
teams {
...
}
email {
...
}
}
```
2. Enter text for your notification in the text field.
1. Enter text for your notification in the text field.
The `text` field supports [Go-style templating](https://pkg.go.dev/text/template). This enables you to manage your Grafana Alerting notification templates directly in Terraform.
The `text` field supports [Go-style templating](https://pkg.go.dev/text/template). This enables you to manage your Grafana Alerting notification templates directly in Terraform.
3. Run the command ‘terraform apply’.
1. Run the command `terraform apply`.
4. Go to the Grafana UI and check the details of your contact point.
1. Go to the Grafana UI and check the details of your contact point.
By default, you cannot edit resources provisioned via Terraform from the UI. This ensures that your alerting stack always stays in sync with your code.
1. Click **Test** to verify that the contact point works correctly.
5. Click **Test** to verify that the contact point works correctly.
**Note:**
### Reuse templates
You can reuse the same templates across many contact points. In the example above, a shared template ie embedded using the statement `{{ template “Alert Instance Template” . }}`
@@ -155,196 +158,193 @@ EOT
}
```
## Provision notification policies and routing
## Import notification policies and routing
Notification policies tell Grafana how to route alert instances to your contact points. They connect firing alerts to your previously defined contact points using a system of labels and matchers.
To provision notification policies and routing, complete the following steps.
To provision notification policies and routing, refer to the [grafana_notification_policy schema](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/notification_policy), and complete the following steps.
1. Copy this code block into a .tf file on your local machine.
{{% admonition type="warning" %}}
In this example, the alerts are grouped by `alertname`, which means that any notifications coming from alerts which share the same name, are grouped into the same Slack message. You can provide any set of label keys here, or you can use the special label `"..."` to route by all label keys, sending each alert in a separate notification.
Since the policy tree is a single resource, provisioning the `grafana_notification_policy` resource will overwrite a policy tree created through any other means.
If you want to route specific notifications differently, you can add sub-policies. Sub-policies allow you to apply routing to different alerts based on label matching. In this example, we apply a mute timing to all alerts with the label a=b.
{{< /admonition >}}
```HCL
resource "grafana_notification_policy" "my_policy" {
group_by = ["alertname"]
contact_point = grafana_contact_point.my_slack_contact_point.name
1. Copy this code block into a `.tf` file on your local machine.
group_wait = "45s"
group_interval = "6m"
repeat_interval = "3h"
In this example, the alerts are grouped by `alertname`, which means that any notifications coming from alerts which share the same name, are grouped into the same Slack message. You can provide any set of label keys here, or you can use the special label `"..."` to route by all label keys, sending each alert in a separate notification.
policy {
matcher {
label = "a"
match = "="
value = "b"
}
group_by = ["..."]
contact_point = grafana_contact_point.a_different_contact_point.name
mute_timings = [grafana_mute_timing.my_mute_timing.name]
If you want to route specific notifications differently, you can add sub-policies. Sub-policies allow you to apply routing to different alerts based on label matching. In this example, we apply a mute timing to all alerts with the label a=b.
policy {
matcher {
label = "sublabel"
match = "="
value = "subvalue"
}
contact_point = grafana_contact_point.a_third_contact_point.name
group_by = ["..."]
}
}
}
```
```HCL
resource "grafana_notification_policy" "my_policy" {
group_by = ["alertname"]
contact_point = grafana_contact_point.my_slack_contact_point.name
2. In the mute_timings field, link a mute timing to your notification policy.
group_wait = "45s"
group_interval = "6m"
repeat_interval = "3h"
3. Run the command ‘terraform apply’.
policy {
matcher {
label = "a"
match = "="
value = "b"
}
group_by = ["..."]
contact_point = grafana_contact_point.a_different_contact_point.name
mute_timings = [grafana_mute_timing.my_mute_timing.name]
4. Go to the Grafana UI and check the details of your notification policy.
policy {
matcher {
label = "sublabel"
match = "="
value = "subvalue"
}
contact_point = grafana_contact_point.a_third_contact_point.name
group_by = ["..."]
}
}
}
```
**Note:**
1. In the mute_timings field, link a mute timing to your notification policy.
Since the policy tree is a single resource, applying it will overwrite a policy tree created through any other means.
1. Run the command `terraform apply`.
By default, you cannot edit resources provisioned from Terraform from the UI. This ensures that your alerting stack always stays in sync with your code.
1. Go to the Grafana UI and check the details of your notification policy.
5. Click **Test** to verify that the notification point is working correctly.
1. Click **Test** to verify that the notification point is working correctly.
## Provision mute timings
## Import mute timings
Mute timings provide the ability to mute alert notifications for defined time periods.
To provision mute timings, complete the following steps.
To provision mute timings, refer to the [grafana_mute_timing schema](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/mute_timing), and complete the following steps.
1. Copy this code block into a .tf file on your local machine.
1. Copy this code block into a `.tf` file on your local machine.
In this example, alert notifications are muted on weekends.
In this example, alert notifications are muted on weekends.
```HCL
resource "grafana_mute_timing" "my_mute_timing" {
name = "My Mute Timing"
```HCL
resource "grafana_mute_timing" "my_mute_timing" {
name = "My Mute Timing"
intervals {
times {
start = "04:56"
end = "14:17"
}
weekdays = ["saturday", "sunday", "tuesday:thursday"]
months = ["january:march", "12"]
years = ["2025:2027"]
}
}
```
intervals {
times {
start = "04:56"
end = "14:17"
}
weekdays = ["saturday", "sunday", "tuesday:thursday"]
months = ["january:march", "12"]
years = ["2025:2027"]
}
}
```
2. Run the command ‘terraform apply’.
3. Go to the Grafana UI and check the details of your mute timing.
4. Reference your newly created mute timing in a notification policy using the `mute_timings` field.
1. Run the command `terraform apply`.
1. Go to the Grafana UI and check the details of your mute timing.
1. Reference your newly created mute timing in a notification policy using the `mute_timings` field.
This will apply your mute timing to some or all of your notifications.
**Note:**
1. Click **Test** to verify that the mute timing is working correctly.
By default, you cannot edit resources provisioned from Terraform from the UI. This ensures that your alerting stack always stays in sync with your code.
5. Click **Test** to verify that the mute timing is working correctly.
## Provision alert rules
## Import alert rules
[Alert rules][alerting-rules] enable you to alert against any Grafana data source. This can be a data source that you already have configured, or you can [define your data sources in Terraform](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/data_source) alongside your alert rules.
To provision alert rules, complete the following steps.
To provision alert rules, refer to the [grafana_rule_group schema](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/rule_group), and complete the following steps.
1. Create a data source to query and a folder to store your rules in.
In this example, the [TestData][testdata] data source is used.
In this example, the [TestData][testdata] data source is used.
Alerts can be defined against any backend datasource in Grafana.
Alerts can be defined against any backend datasource in Grafana.
```HCL
resource "grafana_data_source" "testdata_datasource" {
name = "TestData"
type = "testdata"
}
```HCL
resource "grafana_data_source" "testdata_datasource" {
name = "TestData"
type = "testdata"
}
resource "grafana_folder" "rule_folder" {
title = "My Rule Folder"
}
```
resource "grafana_folder" "rule_folder" {
title = "My Rule Folder"
}
```
2. Define an alert rule.
1. Define an alert rule.
For more information on alert rules, refer to [how to create Grafana-managed alerts](/blog/2022/08/01/grafana-alerting-video-how-to-create-alerts-in-grafana-9/).
For more information on alert rules, refer to [how to create Grafana-managed alerts](/blog/2022/08/01/grafana-alerting-video-how-to-create-alerts-in-grafana-9/).
3. Create a rule group containing one or more rules.
1. Create a rule group containing one or more rules.
In this example, the `grafana_rule_group` resource group is used.
In this example, the `grafana_rule_group` resource group is used.
```HCL
resource "grafana_rule_group" "my_rule_group" {
name = "My Alert Rules"
folder_uid = grafana_folder.rule_folder.uid
interval_seconds = 60
org_id = 1
```HCL
resource "grafana_rule_group" "my_rule_group" {
name = "My Alert Rules"
folder_uid = grafana_folder.rule_folder.uid
interval_seconds = 60
org_id = 1
rule {
name = "My Random Walk Alert"
condition = "C"
for = "0s"
rule {
name = "My Random Walk Alert"
condition = "C"
for = "0s"
// Query the datasource.
data {
ref_id = "A"
relative_time_range {
from = 600
to = 0
}
datasource_uid = grafana_data_source.testdata_datasource.uid
// `model` is a JSON blob that sends datasource-specific data.
// It's different for every datasource. The alert's query is defined here.
model = jsonencode({
intervalMs = 1000
maxDataPoints = 43200
refId = "A"
})
}
// Query the datasource.
data {
ref_id = "A"
relative_time_range {
from = 600
to = 0
}
datasource_uid = grafana_data_source.testdata_datasource.uid
// `model` is a JSON blob that sends datasource-specific data.
// It's different for every datasource. The alert's query is defined here.
model = jsonencode({
intervalMs = 1000
maxDataPoints = 43200
refId = "A"
})
}
// The query was configured to obtain data from the last 60 seconds. Let's alert on the average value of that series using a Reduce stage.
data {
datasource_uid = "__expr__"
// You can also create a rule in the UI, then GET that rule to obtain the JSON.
// This can be helpful when using more complex reduce expressions.
model = <<EOT
{"conditions":[{"evaluator":{"params":[0,0],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"params":[],"type":"last"},"type":"avg"}],"datasource":{"name":"Expression","type":"__expr__","uid":"__expr__"},"expression":"A","hide":false,"intervalMs":1000,"maxDataPoints":43200,"reducer":"last","refId":"B","type":"reduce"}
EOT
ref_id = "B"
relative_time_range {
from = 0
to = 0
}
}
// The query was configured to obtain data from the last 60 seconds. Let's alert on the average value of that series using a Reduce stage.
data {
datasource_uid = "__expr__"
// You can also create a rule in the UI, then GET that rule to obtain the JSON.
// This can be helpful when using more complex reduce expressions.
model = <<EOT
{"conditions":[{"evaluator":{"params":[0,0],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"params":[],"type":"last"},"type":"avg"}],"datasource":{"name":"Expression","type":"__expr__","uid":"__expr__"},"expression":"A","hide":false,"intervalMs":1000,"maxDataPoints":43200,"reducer":"last","refId":"B","type":"reduce"}
EOT
ref_id = "B"
relative_time_range {
from = 0
to = 0
}
}
// Now, let's use a math expression as our threshold.
// We want to alert when the value of stage "B" above exceeds 70.
data {
datasource_uid = "__expr__"
ref_id = "C"
relative_time_range {
from = 0
to = 0
}
model = jsonencode({
expression = "$B > 70"
type = "math"
refId = "C"
})
}
}
}
```
// Now, let's use a math expression as our threshold.
// We want to alert when the value of stage "B" above exceeds 70.
data {
datasource_uid = "__expr__"
ref_id = "C"
relative_time_range {
from = 0
to = 0
}
model = jsonencode({
expression = "$B > 70"
type = "math"
refId = "C"
})
}
}
}
```
4. Go to the Grafana UI and check your alert rule.
1. Run the command `terraform apply`.
1. Go to the Grafana UI and check your alert rule.
You can see whether or not the alert rule is firing. You can also see a visualization of each of the alert rule’s query stages
@@ -352,12 +352,36 @@ When the alert fires, Grafana routes a notification through the policy you defin
For example, if you chose Slack as a contact point, Grafana’s embedded [Alertmanager](https://github.com/prometheus/alertmanager) automatically posts a message to Slack.
## Edit provisioned resources in the Grafana UI
By default, you cannot edit resources provisioned via Terraform in Grafana. To enable editing these resources in the Grafana UI, use the `disable_provenance` attribute on alerting resources:
```HCL
provider "grafana" {
url = "http://grafana.example.com/"
auth = var.grafana_auth
}
resource "grafana_mute_timing" "mute_all" {
name = "mute all"
disable_provenance = true
intervals {}
}
```
**Useful Links:**
[Grafana Terraform Provider documentation](https://registry.terraform.io/providers/grafana/grafana/latest/docs)
{{% docs/reference %}}
[alerting-rules]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/alerting-rules"
[alerting-rules]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules"
[api-keys]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/administration/api-keys"
[api-keys]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/administration/api-keys"
[alerting_export]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/export-alerting-resources"
[alerting_export]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/export-alerting-resources"
[alerting_http_provisioning]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning"
[alerting_http_provisioning]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/alerting/set-up/provision-alerting-resources/http-api-provisioning"
[service-accounts]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/administration/service-accounts"
[service-accounts]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/administration/service-accounts"
@@ -1,108 +0,0 @@
---
aliases:
- ../../provision-alerting-resources/view-provisioned-resources/
canonical: https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/view-provisioned-resources/
description: Manage provisioned alerting resources in Grafana
keywords:
- grafana
- alerting
- alerting resources
- provisioning
labels:
products:
- cloud
- enterprise
- oss
menuTitle: Manage provisioned alerting resources
title: Manage provisioned alerting resources
weight: 300
---
# Manage provisioned alerting resources
Verify that your alerting resources were created in Grafana, as well as edit or export your provisioned alerting resources.
## View provisioned alerting resoureces
To view your provisioned resources in Grafana, complete the following steps.
1. Open your Grafana instance.
1. Navigate to Alerting.
1. Click an alerting resource folder, for example, Alert rules.
Provisioned resources are labeled **Provisioned**, so that it is clear that they were not created manually.
## Export provisioned alerting resources
Export your alerting resources, such as alert rules, contact points, and notification policies in JSON, YAML, or Terraform format. You can export all Grafana-managed alert rules, single folders, and single groups.
To export provisioned alerting resources from the Grafana UI, complete the following steps.
1. Click **Alerts & IRM** -> **Alert rules**.
1. To export all Grafana-managed rules, click **Export rules**.
1. To export a folder, change the **View as** to **List**.
1. Select the folder you want to export and click the **Export rules folder** icon.
1. To export a group, change the **View as** to **Grouped**.
1. Find the group you want to export and click the **Export rule group** icon.
1. Choose the format to export in.
Note that formats JSON and YAML are suitable only for file provisioning. To get rule definition in provisioning API format, use the provisioning GET API.
1. Click **Copy Code** or **Download**.
1. Choose **Copy Code** to go to an existing file and paste in the code.
1. Choose **Download** to download a file with the exported data.
## Edit provisioned alert rules
Use the **Modify export** mode for alert rules to edit provisioned alert rules and export a modified version.
{{% admonition type="note" %}} This feature is for Grafana-managed alert rules only. It is available to Admin, Viewer, and Editor roles. {{% /admonition %}}
To edit provisioned alerting alert rules from the Grafana UI, complete the following steps.
1. Click **Alerts & IRM** -> **Alert rules**.
1. Locate the alert rule you want to edit and click **More** -> **Modify Export** to open the Alert Rule form.
1. From the Alert Rule form, edit the fields you want to change.
1. Click **Export** to export all alert rules within the group.
You can only export groups of rules; not single rules.
The exported rule data appears in different formats - HTML, JSON, Terraform.
1. Choose the format to export in.
1. Click **Copy Code** or **Download**.
a. Choose **Copy Code** to go to an existing file and paste in the code.
b. Choose **Download** to download a file with the exported data.
## Edit API-provisioned alerting resources
To enable editing of API-provisioned resources in the Grafana UI, add the `X-Disable-Provenance` header to the following requests in the API:
- `POST /api/v1/provisioning/alert-rules`
- `PUT /api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}` (calling this endpoint will change provenance for all alert rules within the alert group)
- `POST /api/v1/provisioning/contact-points`
- `POST /api/v1/provisioning/mute-timings`
- `PUT /api/v1/provisioning/policies`
- `PUT /api/v1/provisioning/templates/{name}`
To reset the notification policy tree to the default and unlock it for editing in the Grafana UI, use the `DELETE /api/v1/provisioning/policies` endpoint.
In Terraform, you can use the `disable_provenance` attribute on alerting resources:
```
provider "grafana" {
url = "http://grafana.example.com/"
auth = var.grafana_auth
}
resource "grafana_mute_timing" "mute_all" {
name = "mute all"
disable_provenance = true
intervals {}
}
```
**Note:**
You cannot edit provisioned resources from files in Grafana. You can only change the resource properties by changing the provisioning file and restarting Grafana or carrying out a hot reload. This prevents changes being made to the resource that would be overwritten if a file is provisioned again or a hot reload is carried out.
@@ -0,0 +1,80 @@
---
aliases:
- ../features/datasources/phlare/ # /docs/grafana/<GRAFANA_VERSION>/features/datasources/phlare/
- ../features/datasources/grafana-pyroscope/ # /docs/grafana/<GRAFANA_VERSION>/features/datasources/grafana-pyroscope/
- ../datasources/grafana-pyroscope/ # /docs/grafana/<GRAFANA_VERSION>/datasources/grafana-pyroscope/
description: Horizontally-scalable, highly-available, multi-tenant continuous profiling
aggregation system. OSS profiling solution from Grafana Labs.
keywords:
- grafana
- phlare
- guide
- profiling
- pyroscope
labels:
products:
- cloud
- enterprise
- oss
title: Grafana Pyroscope
weight: 1150
---
# Grafana Pyroscope data source
Grafana Pyroscope is a horizontally scalable, highly available, multi-tenant, OSS, continuous profiling aggregation system. Add it as a data source, and you are ready to query your profiles in [Explore][explore].
To learn more about profiling and Pyroscope, refer to the [Introduction to Pyroscope](/docs/pyroscope/introduction/).
For information on configuring the Pyroscope data source, refer to [Configure the Grafana Pyroscope data source](./configure-pyroscope-data-source).
## Integrate profiles into dashboards
Using the Pyroscope data source, you can integrate profiles into your dashboards.
In this case, the screenshot shows memory profiles alongside panels for logs and metrics to be able to debug out of memory (OOM) errors alongside the associated logs and metrics.
![dashboard](https://grafana.com/static/img/pyroscope/grafana-pyroscope-dashboard-2023-11-30.png)
## Visualize traces and profiles data using Traces to profiles
You can link profile and tracing data using your Pyroscope data source with the Tempo data source.
Combined traces and profiles let you see granular line-level detail when available for a trace span. This allows you pinpoint the exact function that's causing a bottleneck in your application as well as a specific request.
![trace-profiler-view](https://grafana.com/static/img/pyroscope/pyroscope-trace-profiler-view-2023-11-30.png)
For more information, refer to the [Traces to profile section][configure-tempo-data-source] of the Tempo data source documentation.
{{< youtube id="AG8VzfFMLxo" >}}
## Provision the Grafana Pyroscope data source
You can modify the Grafana configuration files to provision the Grafana Pyroscope data source.
To learn more, and to view the available provisioning settings, refer to [provisioning documentation][provisioning-data-sources].
Here is an example configuration:
```yaml
apiVersion: 1
datasources:
- name: Grafana Pyroscope
type: grafana-pyroscope-datasource
url: http://localhost:4040
jsonData:
minStep: '15s'
```
{{% docs/reference %}}
[explore]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/explore"
[explore]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/explore"
[flame-graph]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/flame-graph"
[flame-graph]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/flame-graph"
[provisioning-data-sources]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/administration/provisioning#datasources"
[provisioning-data-sources]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/administration/provisioning#datasources"
[configure-tempo-data-source]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/datasources/tempo/configure-tempo-data-source"
[configure-tempo-data-source]: "/docs/grafana-cloud/ -> docs/grafana-cloud/connect-externally-hosted/data-sources/tempo/configure-tempo-data-source"
{{% /docs/reference %}}
@@ -1,13 +1,7 @@
---
aliases:
- ../features/datasources/phlare/
- ../features/datasources/grafana-pyroscope/
description: Horizontally-scalable, highly-available, multi-tenant continuous profiling
aggregation system. OSS profiling solution from Grafana Labs.
description: Configure your Pyroscope data source for Grafana.
keywords:
- grafana
- phlare
- guide
- configure
- profiling
- pyroscope
labels:
@@ -15,15 +9,12 @@ labels:
- cloud
- enterprise
- oss
title: Grafana Pyroscope
weight: 1150
title: Configure the Grafana Pyroscope data source
menuTitle: Configure Pyroscope
weight: 200
---
# Grafana Pyroscope data source
Grafana Pyroscope is a horizontally scalable, highly available, multi-tenant, OSS, continuous profiling aggregation system. Add it as a data source, and you are ready to query your profiles in [Explore][explore].
## Configure the Grafana Pyroscope data source
# Configure the Grafana Pyroscope data source
To configure basic settings for the data source, complete the following steps:
@@ -44,84 +35,6 @@ To configure basic settings for the data source, complete the following steps:
| `Password` | Password for basic authentication. |
| `Minimal step` | Used for queries returning timeseries data. The Pyroscope backend, similar to Prometheus, scrapes profiles at certain intervals. To prevent querying at smaller interval, use Minimal step same or higher than your Pyroscope scrape interval. This prevents returning too many data points to the frontend. |
### Traces to profiles
You can link profile and tracing data using your Pyroscope data source with the Tempo data source.
For more information, refer to the [Traces to profile section][configure-tempo-data-source] of the Tempo data source documentation.
{{< youtube id="AG8VzfFMLxo" >}}
## Querying
You can query your profiling data using the query editor.
### Query editor
The query editor gives you access to a profile type selector, a label selector, and collapsible options.
![Query editor](/media/docs/pyroscope/query-editor/query-editor.png 'Query editor')
To access the query editor:
1. Sign into Grafana or Grafana Cloud.
1. Select your Pyroscope data source.
1. From the menu, choose **Explore**.
1. Select a profile type from the drop-down menu.
{{< figure src="/media/docs/pyroscope/query-editor/select-profile.png" class="docs-image--no-shadow" max-width="450px" caption="Profile selector" >}}
1. Use the labels selector input to filter by labels. Pyroscope uses similar syntax to Prometheus to filter labels.
Refer to [Pyroscope documentation](https://grafana.com/docs/pyroscope/latest/) for available operators and syntax.
While the label selector can be left empty to query all profiles without filtering by labels, the profile type or app must be selected for the query to be valid.
Grafana doesn't show any data if the profile type or app isn’t selected when a query runs.
![Labels selector](/media/docs/pyroscope/query-editor/labels-selector.png 'Labels selector')
1. Expand the **Options** section to view **Query Type** and **Group by**.
![Options section](/media/docs/pyroscope/query-editor/options-section.png 'Options section')
1. Select a query type to return the profile data which can be shown in the [Flame Graph][flame-graph], metric data visualized in a graph, or both. You can only select both options in a dashboard, because panels allow only one visualization.
**Group by** allows you to group metric data by a specified label. Without any **Group by** label, metric data is aggregated over all the labels into single time series. You can use multiple labels to group by. Group by has only an effect on the metric data and doesn't change the profile data results.
### Profiles query results
Profiles can be visualized in a flame graph. See the [Flame Graph documentation][flame-graph] to learn about the visualization and its features.
![Flame graph](/media/docs/pyroscope/query-editor/flame-graph.png 'Flame graph')
Pyroscope returns profiles aggregated over a selected time range.
The absolute values in the flame graph grow as the time range gets bigger while keeping the relative values meaningful.
You can zoom in on the time range to get a higher granularity profile up to the point of a single scrape interval.
### Metrics query results
Metrics results represent the aggregated sum value over time of the selected profile type.
![Metrics graph](/media/docs/pyroscope/query-editor/metric-graph.png 'Metrics graph')
This allows you to quickly see any spikes in the value of the scraped profiles and zoom in to a particular time range.
## Provision the Grafana Pyroscope data source
You can modify the Grafana configuration files to provision the Grafana Pyroscope data source. To learn more, and to view the available provisioning settings, see [provisioning documentation][provisioning-data-sources].
Here is an example configuration:
```yaml
apiVersion: 1
datasources:
- name: Grafana Pyroscope
type: grafana-pyroscope-datasource
url: http://localhost:4040
jsonData:
minStep: '15s'
```
{{% docs/reference %}}
[explore]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/explore"
[explore]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/explore"
@@ -0,0 +1,82 @@
---
description: Use the query editor to explore your Pyroscope data.
keywords:
- query
- profiling
- pyroscope
labels:
products:
- cloud
- enterprise
- oss
title: Query profile data
menuTitle: Query profile data
weight: 300
---
# Query profile data
The Pyroscope data source query editor gives you access to a profile type selector, a label selector, and collapsible options.
![Query editor](/media/docs/pyroscope/query-editor/query-editor.png 'Query editor')
To access the query editor:
1. Sign into Grafana or Grafana Cloud.
1. Select your Pyroscope data source.
1. From the menu, choose **Explore**.
1. Select a profile type from the drop-down menu.
{{< figure src="/media/docs/pyroscope/query-editor/select-profile.png" class="docs-image--no-shadow" max-width="450px" caption="Profile selector" >}}
1. Use the labels selector input to filter by labels. Pyroscope uses similar syntax to Prometheus to filter labels.
Refer to [Pyroscope documentation](https://grafana.com/docs/pyroscope/latest/) for available operators and syntax.
While the label selector can be left empty to query all profiles without filtering by labels, the profile type or app must be selected for the query to be valid.
Grafana doesn't show any data if the profile type or app isn’t selected when a query runs.
![Labels selector](/media/docs/pyroscope/query-editor/labels-selector.png 'Labels selector')
1. Expand the **Options** section to view **Query Type** and **Group by**.
![Options section](/media/docs/pyroscope/query-editor/options-section.png 'Options section')
1. Select a query type to return the profile data. Data is shown in the [Flame Graph][flame-graph], metric data visualized in a graph, or both. You can only select both options in Explore. The panels used on dashboards allow only one visualization.
Using **Group by**, you can group metric data by a specified label.
Without any **Group by** label, metric data aggregates over all the labels into single time series.
You can use multiple labels to group by. Group by only effects the metric data and doesn't change the profile data results.
## Profiles query results
Profiles can be visualized in a flame graph.
Refer to the [Flame Graph documentation][flame-graph] to learn about the visualization and its features.
![Flame graph](/media/docs/pyroscope/query-editor/flame-graph.png 'Flame graph')
Pyroscope returns profiles aggregated over a selected time range.
The absolute values in the flame graph grow as the time range gets bigger while keeping the relative values meaningful.
You can zoom in on the time range to get a higher granularity profile up to the point of a single scrape interval.
## Metrics query results
Metrics results represent the aggregated sum value over time of the selected profile type.
![Metrics graph](/media/docs/pyroscope/query-editor/metric-graph.png 'Metrics graph')
This allows you to quickly see any spikes in the value of the scraped profiles and zoom in to a particular time range.
{{% docs/reference %}}
[explore]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/explore"
[explore]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/explore"
[flame-graph]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/flame-graph"
[flame-graph]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/panels-visualizations/visualizations/flame-graph"
[provisioning-data-sources]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/administration/provisioning#datasources"
[provisioning-data-sources]: "/docs/grafana-cloud/ -> /docs/grafana/<GRAFANA VERSION>/administration/provisioning#datasources"
[configure-tempo-data-source]: "/docs/grafana/ -> /docs/grafana/<GRAFANA VERSION>/datasources/tempo/configure-tempo-data-source"
[configure-tempo-data-source]: "/docs/grafana-cloud/ -> docs/grafana-cloud/connect-externally-hosted/data-sources/tempo/configure-tempo-data-source"
{{% /docs/reference %}}
File diff suppressed because it is too large Load Diff
@@ -55,7 +55,6 @@ Some features are enabled by default. You can disable these feature by setting t
| `lokiQueryHints` | Enables query hints for Loki | Yes |
| `alertingPreviewUpgrade` | Show Unified Alerting preview and upgrade page in legacy alerting | Yes |
| `alertingQueryOptimization` | Optimizes eligible queries in order to reduce load on datasources | |
| `jitterAlertRules` | Distributes alert rule evaluations more evenly over time, by rule group | |
## Preview feature toggles
@@ -160,6 +159,7 @@ Experimental features might be changed or removed without prior notice.
| `annotationPermissionUpdate` | Separate annotation permissions from dashboard permissions to allow for more granular control. |
| `extractFieldsNameDeduplication` | Make sure extracted field names are unique in the dataframe |
| `dashboardSceneForViewers` | Enables dashboard rendering using Scenes for viewer roles |
| `dashboardSceneSolo` | Enables rendering dashboards using scenes for solo panels |
| `dashboardScene` | Enables dashboard rendering using scenes for all roles |
| `ssoSettingsApi` | Enables the SSO settings API |
| `logsInfiniteScrolling` | Enables infinite scrolling for the Logs panel in Explore and Dashboards |
File diff suppressed because it is too large Load Diff
@@ -25,9 +25,13 @@ When configured, this connection lets you run queries from a trace span into the
There are two ways to configure the trace to profiles feature:
- Use a simplified configuration with default query, or
- Use a basic configuration with default query, or
- Configure a custom query where you can use a template language to interpolate variables from the trace or span.
{{< admonition type="note">}}
Traces to profile requires a Tempo data source with Traces to profiles configured and a Pyroscope data source. This integration supports profile data generated using Go, Ruby, and Java instrumentation SDKs.
{{< /admonition >}}
To use trace to profiles, navigate to **Explore** and query a trace. Each span now links to your queries. Clicking a link runs the query in a split panel. If tags are configured, Grafana dynamically inserts the span attribute values into the query. The query runs over the time range of the (span start time - 60) to (span end time + 60 seconds).
![Selecting a link in the span queries the profile data source](/media/docs/tempo/profiles/tempo-trace-to-profile.png)
@@ -40,7 +44,7 @@ Hover over a particular block in the flame graph to see more details about the r
## Use a basic configuration
To use a simple configuration, follow these steps:
To use a basic configuration, follow these steps:
1. Select a Pyroscope data source from the **Data source** drop-down.
1. Optional: Choose any tags to use in the query. If left blank, the default values of `service.name` and `service.namespace` are used.
+10 -7
View File
@@ -43,6 +43,10 @@ describe('Loki query builder', () => {
req.reply({ status: 'success', data: ['instance1', 'instance2'] });
}).as('valuesRequest');
cy.intercept(/index\/stats/, (req) => {
req.reply({ streams: 2, chunks: 2660, bytes: 2721792, entries: 14408 });
});
// Go to Explore and choose Loki data source
e2e.pages.Explore.visit();
e2e.components.DataSourcePicker.container().should('be.visible').click();
@@ -68,21 +72,20 @@ describe('Loki query builder', () => {
// Add labels to remove error
e2e.components.QueryBuilder.labelSelect().should('be.visible').click();
// wait until labels are loaded and set on the component before starting to type
e2e.components.QueryBuilder.labelSelect().children('div').children('input').type('i');
cy.wait('@labelsRequest');
e2e.components.QueryBuilder.labelSelect().children('div').children('input').type('instance{enter}');
e2e.components.QueryBuilder.labelSelect().children('div').children('input').type('nstance{enter}');
e2e.components.QueryBuilder.matchOperatorSelect()
.should('be.visible')
.click()
.click({ force: true })
.children('div')
.children('input')
.type('=~{enter}', { force: true });
e2e.components.QueryBuilder.valueSelect().should('be.visible').click();
e2e.components.QueryBuilder.valueSelect().children('div').children('input').type('instance1{enter}');
cy.wait('@valuesRequest');
e2e.components.QueryBuilder.valueSelect()
.children('div')
.children('input')
.type('instance1{enter}')
.type('instance2{enter}');
e2e.components.QueryBuilder.valueSelect().children('div').children('input').type('instance2{enter}');
cy.contains(MISSING_LABEL_FILTER_ERROR_MESSAGE).should('not.exist');
cy.contains(finalQuery).should('be.visible');
+30
View File
@@ -11,4 +11,34 @@ describe('Solo Route', () => {
cy.get('canvas').should('have.length', 6);
});
it('Can view solo panel in scenes', () => {
// open Panel Tests - Graph NG
e2e.pages.SoloPanel.visit(
'TkZXxlNG3/panel-tests-graph-ng?orgId=1&from=1699954597665&to=1699956397665&panelId=54&__feature.dashboardSceneSolo=true'
);
e2e.components.Panels.Panel.title('Interpolation: Step before').should('exist');
cy.contains('uplot-main-div').should('not.exist');
});
it('Can view solo repeated panel in scenes', () => {
// open Panel Tests - Graph NG
e2e.pages.SoloPanel.visit(
'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-1&__feature.dashboardSceneSolo=true'
);
e2e.components.Panels.Panel.title('server=B').should('exist');
cy.contains('uplot-main-div').should('not.exist');
});
it('Can view solo in repeaterd row and panel in scenes', () => {
// open Panel Tests - Graph NG
e2e.pages.SoloPanel.visit(
'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-2-row-2-clone-2&__feature.dashboardSceneSolo=true'
);
e2e.components.Panels.Panel.title('server = D, pod = Sod').should('exist');
cy.contains('uplot-main-div').should('not.exist');
});
});
+1 -1
View File
@@ -63,7 +63,7 @@ require (
github.com/grafana/cuetsy v0.1.11 // @grafana/grafana-as-code
github.com/grafana/grafana-aws-sdk v0.23.1 // @grafana/aws-datasources
github.com/grafana/grafana-azure-sdk-go v1.12.0 // @grafana/partner-datasources
github.com/grafana/grafana-plugin-sdk-go v0.208.0 // @grafana/plugins-platform-backend
github.com/grafana/grafana-plugin-sdk-go v0.209.0 // @grafana/plugins-platform-backend
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // @grafana/backend-platform
github.com/hashicorp/go-hclog v1.6.2 // @grafana/plugins-platform-backend
github.com/hashicorp/go-plugin v1.6.0 // @grafana/plugins-platform-backend
+2 -2
View File
@@ -1946,8 +1946,8 @@ github.com/grafana/grafana-google-sdk-go v0.1.0/go.mod h1:Vo2TKWfDVmNTELBUM+3lkr
github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79 h1:r+mU5bGMzcXCRVAuOrTn54S80qbfVkvTdUJZfSfTNbs=
github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79/go.mod h1:wc6Hbh3K2TgCUSfBC/BOzabItujtHMESZeFk5ZhdxhQ=
github.com/grafana/grafana-plugin-sdk-go v0.114.0/go.mod h1:D7x3ah+1d4phNXpbnOaxa/osSaZlwh9/ZUnGGzegRbk=
github.com/grafana/grafana-plugin-sdk-go v0.208.0 h1:+cHmkoayG+nkqwbyQ9uVoRZJFcyLuZafwkTaeRO/r8U=
github.com/grafana/grafana-plugin-sdk-go v0.208.0/go.mod h1:RpVdugdeeNmo/DUQdFRbsBrIwDg+igNHSNsaUqiXdEc=
github.com/grafana/grafana-plugin-sdk-go v0.209.0 h1:izPAnJePvzqrpJ3/X3eCbESnxR2bPqx32Q92glYHmkU=
github.com/grafana/grafana-plugin-sdk-go v0.209.0/go.mod h1:RpVdugdeeNmo/DUQdFRbsBrIwDg+igNHSNsaUqiXdEc=
github.com/grafana/kindsys v0.0.0-20230508162304-452481b63482 h1:1YNoeIhii4UIIQpCPU+EXidnqf449d0C3ZntAEt4KSo=
github.com/grafana/kindsys v0.0.0-20230508162304-452481b63482/go.mod h1:GNcfpy5+SY6RVbNGQW264gC0r336Dm+0zgQ5vt6+M8Y=
github.com/grafana/prometheus-alertmanager v0.25.1-0.20240208102907-e82436ce63e6 h1:CBm0rwLCPDyarg9/bHJ50rBLYmyMDoyCWpgRMITZhdA=
+7 -7
View File
@@ -31,9 +31,9 @@
"packages:prepare": "lerna version --no-push --no-git-tag-version --force-publish --exact",
"packages:pack": "mkdir -p ./npm-artifacts && lerna exec --no-private -- yarn pack --out \"../../npm-artifacts/%s-%v.tgz\"",
"packages:typecheck": "lerna run typecheck",
"prettier:check": "prettier --check --list-different=false --log-level=warn \"**/*.{ts,tsx,scss,md,mdx}\"",
"prettier:checkDocs": "prettier --check --list-different=false --log-level=warn \"docs/**/*.md\" \"*.md\" \"packages/**/*.{ts,tsx,scss,md,mdx}\"",
"prettier:write": "prettier --list-different \"**/*.{js,ts,tsx,scss,md,mdx}\" --write",
"prettier:check": "prettier --check --list-different=false --log-level=warn \"**/*.{ts,tsx,scss,md,mdx,json}\"",
"prettier:checkDocs": "prettier --check --list-different=false --log-level=warn \"docs/**/*.md\" \"*.md\" \"packages/**/*.{ts,tsx,scss,md,mdx,json}\"",
"prettier:write": "prettier --list-different \"**/*.{js,ts,tsx,scss,md,mdx,json}\" --write",
"start": "yarn themes:generate && yarn dev --watch",
"start:noTsCheck": "yarn start --env noTsCheck=1",
"start:noLint": "yarn start --env noTsCheck=1 --env noLint=1",
@@ -110,7 +110,7 @@
"@types/lucene": "^2",
"@types/marked": "5.0.2",
"@types/mousetrap": "1.6.15",
"@types/node": "20.11.16",
"@types/node": "20.11.17",
"@types/node-forge": "^1",
"@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.2.4",
"@types/papaparse": "5.3.14",
@@ -181,7 +181,7 @@
"jest-fail-on-console": "3.1.2",
"jest-junit": "16.0.0",
"jest-matcher-utils": "29.7.0",
"lerna": "8.1.2",
"lerna": "7.4.1",
"mini-css-extract-plugin": "2.8.0",
"msw": "1.3.2",
"mutationobserver-shim": "0.3.7",
@@ -228,10 +228,10 @@
"@floating-ui/react": "0.26.9",
"@glideapps/glide-data-grid": "^6.0.0",
"@grafana-plugins/grafana-azure-monitor-datasource": "workspace:*",
"@grafana-plugins/grafana-cloud-monitoring-datasource": "workspace:*",
"@grafana-plugins/grafana-pyroscope-datasource": "workspace:*",
"@grafana-plugins/grafana-testdata-datasource": "workspace:*",
"@grafana-plugins/parca": "workspace:*",
"@grafana-plugins/stackdriver": "workspace:*",
"@grafana-plugins/tempo": "workspace:*",
"@grafana/aws-sdk": "0.3.1",
"@grafana/data": "workspace:*",
@@ -246,7 +246,7 @@
"@grafana/o11y-ds-frontend": "workspace:*",
"@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*",
"@grafana/scenes": "2.6.6",
"@grafana/scenes": "^2.6.5",
"@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*",
+1 -1
View File
@@ -76,7 +76,7 @@
"@types/jquery": "3.5.29",
"@types/lodash": "4.14.202",
"@types/marked": "5.0.2",
"@types/node": "20.11.16",
"@types/node": "20.11.17",
"@types/papaparse": "5.3.14",
"@types/react": "18.2.55",
"@types/react-dom": "18.2.19",
@@ -146,6 +146,7 @@ export interface FeatureToggles {
annotationPermissionUpdate?: boolean;
extractFieldsNameDeduplication?: boolean;
dashboardSceneForViewers?: boolean;
dashboardSceneSolo?: boolean;
dashboardScene?: boolean;
panelFilterVariable?: boolean;
pdfTables?: boolean;
@@ -168,7 +169,6 @@ export interface FeatureToggles {
cloudRBACRoles?: boolean;
alertingQueryOptimization?: boolean;
newFolderPicker?: boolean;
jitterAlertRules?: boolean;
jitterAlertRulesWithinGroups?: boolean;
onPremToCloudMigrations?: boolean;
alertingSaveStatePeriodic?: boolean;
+1 -1
View File
@@ -41,7 +41,7 @@
"devDependencies": {
"@rollup/plugin-commonjs": "25.0.7",
"@rollup/plugin-node-resolve": "15.2.3",
"@types/node": "20.11.16",
"@types/node": "20.11.17",
"esbuild": "0.18.12",
"rimraf": "5.0.5",
"rollup": "2.79.1",
@@ -23,50 +23,16 @@
"data": {
"values": [
[
1633619595000,
1633619610000,
1633619625000,
1633619640000,
1633619655000,
1633619670000,
1633619685000,
1633619700000,
1633619715000,
1633619730000,
1633619745000,
1633619760000,
1633619775000,
1633619790000,
1633619805000,
1633619820000,
1633619835000,
1633619850000,
1633619865000,
1633619880000,
1633619895000
1633619595000, 1633619610000, 1633619625000, 1633619640000, 1633619655000, 1633619670000, 1633619685000,
1633619700000, 1633619715000, 1633619730000, 1633619745000, 1633619760000, 1633619775000, 1633619790000,
1633619805000, 1633619820000, 1633619835000, 1633619850000, 1633619865000, 1633619880000, 1633619895000
],
[
0.07245212135073513,
0.07253198890830721,
0.07247862573797707,
0.07238248338231042,
0.07221687487740913,
0.07223291298743946,
0.07225427016727755,
0.024531677091864545,
0.02317081920915543,
0.07548902139580993,
0.0777721702857508,
0.07768649905047344,
0.07782257603228229,
0.07788810213200052,
0.07791835055437593,
0.07798387201529966,
0.07790826751849372,
0.07794858648610933,
0.07778729925797964,
0.07769657495236215,
0.077550401329267
0.07245212135073513, 0.07253198890830721, 0.07247862573797707, 0.07238248338231042, 0.07221687487740913,
0.07223291298743946, 0.07225427016727755, 0.024531677091864545, 0.02317081920915543,
0.07548902139580993, 0.0777721702857508, 0.07768649905047344, 0.07782257603228229, 0.07788810213200052,
0.07791835055437593, 0.07798387201529966, 0.07790826751849372, 0.07794858648610933, 0.07778729925797964,
0.07769657495236215, 0.077550401329267
]
]
}
@@ -113,54 +79,15 @@
"data": {
"values": [
[
1633619598000,
1633619622000,
1633619625000,
1633619646000,
1633619658000,
1633619682000,
1633619695000,
1633619712000,
1633619712000,
1633619724000,
1633619717000,
1633619742000,
1633619757000,
1633619771000,
1633619784000,
1633619801000,
1633619806000,
1633619833000,
1633619833000,
1633619845000,
1633619862000,
1633619877000,
1633619889000
1633619598000, 1633619622000, 1633619625000, 1633619646000, 1633619658000, 1633619682000, 1633619695000,
1633619712000, 1633619712000, 1633619724000, 1633619717000, 1633619742000, 1633619757000, 1633619771000,
1633619784000, 1633619801000, 1633619806000, 1633619833000, 1633619833000, 1633619845000, 1633619862000,
1633619877000, 1633619889000
],
[
0.0146153,
0.0118506,
0.0473847,
0.026997,
0.0164318,
0.0113532,
0.0105197,
0.162789,
0.0556026,
0.148856,
0.0433809,
0.0117758,
0.0114496,
0.0114099,
0.0421927,
0.0134148,
0.0152827,
0.6975967,
0.0394788,
0.0137441,
0.0110939,
0.0104496,
0.0101284
0.0146153, 0.0118506, 0.0473847, 0.026997, 0.0164318, 0.0113532, 0.0105197, 0.162789, 0.0556026,
0.148856, 0.0433809, 0.0117758, 0.0114496, 0.0114099, 0.0421927, 0.0134148, 0.0152827, 0.6975967,
0.0394788, 0.0137441, 0.0110939, 0.0104496, 0.0101284
],
[
"app:80",
@@ -4,9 +4,9 @@
"declarationDir": "./compiled",
"emitDeclarationOnly": true,
"isolatedModules": true,
"rootDirs": ["."],
"rootDirs": ["."]
},
"exclude": ["dist/**/*"],
"extends": "@grafana/tsconfig",
"include": ["src/**/*.ts*", "../../public/app/types/*.d.ts", "../grafana-ui/src/types/*.d.ts"],
"include": ["src/**/*.ts*", "../../public/app/types/*.d.ts", "../grafana-ui/src/types/*.d.ts"]
}
+1 -1
View File
@@ -82,7 +82,7 @@
"@types/jquery": "3.5.29",
"@types/lodash": "4.14.202",
"@types/marked": "5.0.2",
"@types/node": "20.11.16",
"@types/node": "20.11.17",
"@types/pluralize": "^0.0.33",
"@types/prismjs": "1.26.3",
"@types/react": "18.2.55",
@@ -1,4 +1,4 @@
import { cloneDeep, defaults } from 'lodash';
import { defaults } from 'lodash';
import { lastValueFrom, Observable, throwError } from 'rxjs';
import { map, tap } from 'rxjs/operators';
import semver from 'semver/preload';
@@ -877,7 +877,7 @@ export class PrometheusDatasource
// Used when running queries through backend
applyTemplateVariables(target: PromQuery, scopedVars: ScopedVars, filters?: AdHocVariableFilter[]) {
const variables = cloneDeep(scopedVars);
const variables = { ...scopedVars };
// We want to interpolate these variables on backend.
// The pre-calculated values are replaced withe the variable strings.
@@ -208,7 +208,15 @@ export abstract class SqlDatasource extends DataSourceWithBackend<SQLQuery, SQLO
format: QueryFormat.Table,
};
const response = await this.runMetaQuery(interpolatedQuery, range);
// NOTE: we can remove this try-catch when https://github.com/grafana/grafana/issues/82250
// is fixed.
let response;
try {
response = await this.runMetaQuery(interpolatedQuery, range);
} catch (error) {
console.error(error);
throw new Error('error when executing the sql query');
}
return this.getResponseParser().transformMetricFindResponse(response);
}
+2 -2
View File
@@ -5,9 +5,9 @@
"emitDeclarationOnly": true,
"isolatedModules": true,
"strict": true,
"rootDirs": ["."],
"rootDirs": ["."]
},
"exclude": ["dist/**/*"],
"extends": "@grafana/tsconfig",
"include": ["src/**/*.ts*", "../../public/app/types/*.d.ts", "../grafana-ui/src/types/*.d.ts"],
"include": ["src/**/*.ts*", "../../public/app/types/*.d.ts", "../grafana-ui/src/types/*.d.ts"]
}
+1 -1
View File
@@ -140,7 +140,7 @@
"@types/jquery": "3.5.29",
"@types/lodash": "4.14.202",
"@types/mock-raf": "1.0.6",
"@types/node": "20.11.16",
"@types/node": "20.11.17",
"@types/prismjs": "1.26.3",
"@types/react": "18.2.55",
"@types/react-beautiful-dnd": "13.1.8",
@@ -445,6 +445,24 @@ Card can have a disabled state, effectively making it and its actions non-clicka
</Card>
</Preview>
### With overline
```jsx
<Card>
<Card.Overline>Filter option</Card.Overline>
<Card.Heading>Filter by name</Card.Heading>
<Card.Description>Filter data by query.</Card.Description>
</Card>
```
<Preview>
<Card>
<Card.Overline>Filter option</Card.Overline>
<Card.Heading>Filter by name</Card.Heading>
<Card.Description>Filter data by query.</Card.Description>
</Card>
</Preview>
### Props
<ArgTypes of={Card} />
@@ -24,9 +24,9 @@ const meta: Meta<typeof Card> = {
},
};
export const Basic: StoryFn<typeof Card> = ({ disabled }) => {
export const Basic: StoryFn<typeof Card> = (args) => {
return (
<Card disabled={disabled}>
<Card {...args}>
<Card.Heading>Filter by name</Card.Heading>
<Card.Description>
Filter data by query. This is useful if you are sharing the results from a different panel that has many queries
@@ -36,24 +36,24 @@ export const Basic: StoryFn<typeof Card> = ({ disabled }) => {
);
};
export const AsLink: StoryFn<typeof Card> = ({ disabled }) => {
export const AsLink: StoryFn<typeof Card> = (args) => {
return (
<VerticalGroup>
<Card href="https://grafana.com" disabled={disabled}>
<Card href="https://grafana.com" {...args}>
<Card.Heading>Filter by name</Card.Heading>
<Card.Description>
Filter data by query. This is useful if you are sharing the results from a different panel that has many
queries and you want to only visualize a subset of that in this panel.
</Card.Description>
</Card>
<Card href="https://grafana.com" disabled={disabled}>
<Card href="https://grafana.com" {...args}>
<Card.Heading>Filter by name2</Card.Heading>
<Card.Description>
Filter data by query. This is useful if you are sharing the results from a different panel that has many
queries and you want to only visualize a subset of that in this panel.
</Card.Description>
</Card>
<Card href="https://grafana.com" disabled={disabled}>
<Card href="https://grafana.com" {...args}>
<Card.Heading>Production system overview</Card.Heading>
<Card.Meta>Meta tags</Card.Meta>
</Card>
@@ -61,9 +61,9 @@ export const AsLink: StoryFn<typeof Card> = ({ disabled }) => {
);
};
export const WithTags: StoryFn<typeof Card> = ({ disabled }) => {
export const WithTags: StoryFn<typeof Card> = (args) => {
return (
<Card disabled={disabled}>
<Card {...args}>
<Card.Heading>Elasticsearch – Custom Templated Query</Card.Heading>
<Card.Meta>Elastic Search</Card.Meta>
<Card.Tags>
@@ -73,9 +73,9 @@ export const WithTags: StoryFn<typeof Card> = ({ disabled }) => {
);
};
export const WithMedia: StoryFn<typeof Card> = ({ disabled }) => {
export const WithMedia: StoryFn<typeof Card> = (args) => {
return (
<Card disabled={disabled}>
<Card {...args}>
<Card.Heading>1-ops-tools1-fallback</Card.Heading>
<Card.Meta>
Prometheus
@@ -89,9 +89,9 @@ export const WithMedia: StoryFn<typeof Card> = ({ disabled }) => {
</Card>
);
};
export const WithActions: StoryFn<typeof Card> = ({ disabled }) => {
export const WithActions: StoryFn<typeof Card> = (args) => {
return (
<Card disabled={disabled}>
<Card {...args}>
<Card.Heading>1-ops-tools1-fallback</Card.Heading>
<Card.Meta>
Prometheus
@@ -118,9 +118,9 @@ export const WithActions: StoryFn<typeof Card> = ({ disabled }) => {
);
};
export const Full: StoryFn<typeof Card> = ({ disabled }) => {
export const Full: StoryFn<typeof Card> = (args) => {
return (
<Card disabled={disabled}>
<Card {...args}>
<Card.Heading>Card title</Card.Heading>
<Card.Description>
Description, body text. Greetings! Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
@@ -179,4 +179,18 @@ export const NotSelected: StoryFn<typeof Card> = () => {
);
};
export const WithOverline: StoryFn<typeof Card> = (args) => {
return (
<Card {...args}>
<Card.Overline>Overline text above the title</Card.Overline>
<Card.Heading>Card title</Card.Heading>
<Card.Description>
Description, body text. Greetings! Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco
laboris nisi ut aliquip ex ea commodo consequat.
</Card.Description>
</Card>
);
};
export default meta;
@@ -5,6 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes';
import { getFocusStyles } from '../../themes/mixins';
import { Text } from '../Text/Text';
import { CardContainer, CardContainerProps, getCardContainerStyles } from './CardContainer';
@@ -23,9 +24,12 @@ export interface Props extends Omit<CardContainerProps, 'disableEvents' | 'disab
/** @deprecated Use `Card.Description` instead */
description?: string;
isSelected?: boolean;
/** If true, the padding of the Card will be smaller */
isCompact?: boolean;
}
export interface CardInterface extends FC<Props> {
Overline: typeof Overline;
Heading: typeof Heading;
Tags: typeof Tags;
Figure: typeof Figure;
@@ -47,7 +51,16 @@ const CardContext = React.createContext<{
*
* @public
*/
export const Card: CardInterface = ({ disabled, href, onClick, children, isSelected, className, ...htmlProps }) => {
export const Card: CardInterface = ({
disabled,
href,
onClick,
children,
isSelected,
isCompact,
className,
...htmlProps
}) => {
const hasHeadingComponent = useMemo(
() => React.Children.toArray(children).some((c) => React.isValidElement(c) && c.type === Heading),
[children]
@@ -55,7 +68,7 @@ export const Card: CardInterface = ({ disabled, href, onClick, children, isSelec
const disableHover = disabled || (!onClick && !href);
const onCardClick = onClick && !disabled ? onClick : undefined;
const styles = useStyles2(getCardContainerStyles, disabled, disableHover, isSelected);
const styles = useStyles2(getCardContainerStyles, disabled, disableHover, isSelected, isCompact);
return (
<CardContainer
@@ -153,6 +166,28 @@ const getHeadingStyles = (theme: GrafanaTheme2) => ({
}),
});
/** Card text to be displayed above title */
const Overline = ({ children, className }: ChildProps) => {
const styles = useStyles2(getOverlineStyles);
return (
<div className={cx(styles.overline, className)}>
{children && (
<Text color="info" weight="medium" variant="bodySmall">
{children}
</Text>
)}
</div>
);
};
Overline.displayName = 'Overline';
const getOverlineStyles = (theme: GrafanaTheme2) => ({
overline: css({
gridArea: 'Overline',
marginBottom: theme.spacing(0.5),
}),
});
const Tags = ({ children, className }: ChildProps) => {
const styles = useStyles2(getTagStyles);
return <div className={cx(styles.tagList, className)}>{children}</div>;
@@ -349,6 +384,7 @@ export const getCardStyles = (theme: GrafanaTheme2) => {
};
};
Card.Overline = Overline;
Card.Heading = Heading;
Card.Tags = Tags;
Card.Figure = Figure;
@@ -70,7 +70,8 @@ export const getCardContainerStyles = (
theme: GrafanaTheme2,
disabled = false,
disableHover = false,
isSelected?: boolean
isSelected?: boolean,
isCompact?: boolean
) => {
const isSelectable = isSelected !== undefined;
@@ -79,16 +80,17 @@ export const getCardContainerStyles = (
display: 'grid',
position: 'relative',
gridTemplateColumns: 'auto 1fr auto',
gridTemplateRows: '1fr auto auto auto',
gridTemplateRows: 'auto 1fr auto auto auto',
gridAutoColumns: '1fr',
gridAutoFlow: 'row',
gridTemplateAreas: `
"Figure Overline Tags"
"Figure Heading Tags"
"Figure Meta Tags"
"Figure Description Tags"
"Figure Actions Secondary"`,
width: '100%',
padding: theme.spacing(2),
padding: theme.spacing(isCompact ? 1 : 2),
background: theme.colors.background.secondary,
borderRadius: theme.shape.radius.default,
marginBottom: '8px',
@@ -44,6 +44,7 @@ interface RowsListProps {
onCellFilterAdded?: TableFilterActionCallback;
timeRange?: TimeRange;
footerPaginationEnabled: boolean;
initialRowIndex?: number;
}
export const RowsList = (props: RowsListProps) => {
@@ -66,9 +67,10 @@ export const RowsList = (props: RowsListProps) => {
listHeight,
listRef,
enableSharedCrosshair = false,
initialRowIndex = undefined,
} = props;
const [rowHighlightIndex, setRowHighlightIndex] = useState<number | undefined>(undefined);
const [rowHighlightIndex, setRowHighlightIndex] = useState<number | undefined>(initialRowIndex);
const theme = useTheme2();
const panelContext = usePanelContext();
@@ -203,6 +205,7 @@ export const RowsList = (props: RowsListProps) => {
({ index, style, rowHighlightIndex }: { index: number; style: CSSProperties; rowHighlightIndex?: number }) => {
const indexForPagination = rowIndexForPagination(index);
const row = rows[indexForPagination];
let additionalProps: React.HTMLAttributes<HTMLDivElement> = {};
prepareRow(row);
@@ -210,11 +213,13 @@ export const RowsList = (props: RowsListProps) => {
if (rowHighlightIndex !== undefined && row.index === rowHighlightIndex) {
style = { ...style, backgroundColor: theme.components.table.rowHoverBackground };
additionalProps = {
'aria-selected': 'true',
};
}
return (
<div
{...row.getRowProps({ style })}
{...row.getRowProps({ style, ...additionalProps })}
className={cx(tableStyles.row, expandedRowStyle)}
onMouseEnter={() => onRowHover(index, data)}
onMouseLeave={onRowLeave}
@@ -89,6 +89,7 @@ function getTestContext(propOverrides: Partial<Props> = {}) {
onSortByChange,
onCellFilterAdded,
onColumnResize,
initialRowIndex: undefined,
};
Object.assign(props, propOverrides);
@@ -657,4 +658,19 @@ describe('Table', () => {
expect(subTable.style.height).toBe('108px');
});
});
describe('when mounted with scrolled to specific row', () => {
it('the row should be visible', async () => {
getTestContext({
initialRowIndex: 2,
});
expect(getTable()).toBeInTheDocument();
const rows = within(getTable()).getAllByRole('row');
expect(rows).toHaveLength(5);
let selected = within(getTable()).getByRole('row', { selected: true });
expect(selected).toBeVisible();
});
});
});
@@ -48,6 +48,7 @@ export const Table = memo((props: Props) => {
cellHeight = TableCellHeight.Sm,
timeRange,
enableSharedCrosshair = false,
initialRowIndex = undefined,
} = props;
const listRef = useRef<VariableSizeList>(null);
@@ -307,6 +308,7 @@ export const Table = memo((props: Props) => {
tableStyles={tableStyles}
footerPaginationEnabled={Boolean(enablePagination)}
enableSharedCrosshair={enableSharedCrosshair}
initialRowIndex={initialRowIndex}
/>
</div>
) : (
@@ -97,6 +97,8 @@ export interface Props {
/** @alpha Used by SparklineCell when provided */
timeRange?: TimeRange;
enableSharedCrosshair?: boolean;
// The index of the field value that the table will initialize scrolled to
initialRowIndex?: number;
}
/**
+3 -3
View File
@@ -320,9 +320,9 @@ func Test_AdminUpdateUserPermissions(t *testing.T) {
case login.GenericOAuthModule:
socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{AllowAssignGrafanaAdmin: tc.allowAssignGrafanaAdmin, Enabled: tc.authEnabled, SkipOrgRoleSync: tc.skipOrgRoleSync}
case login.JWTModule:
cfg.JWTAuthEnabled = tc.authEnabled
cfg.JWTAuthSkipOrgRoleSync = tc.skipOrgRoleSync
cfg.JWTAuthAllowAssignGrafanaAdmin = tc.allowAssignGrafanaAdmin
cfg.JWTAuth.Enabled = tc.authEnabled
cfg.JWTAuth.SkipOrgRoleSync = tc.skipOrgRoleSync
cfg.JWTAuth.AllowAssignGrafanaAdmin = tc.allowAssignGrafanaAdmin
}
hs := &HTTPServer{
+11
View File
@@ -0,0 +1,11 @@
package api
import (
"testing"
"github.com/grafana/grafana/pkg/tests/testsuite"
)
func TestMain(m *testing.M) {
testsuite.Run(m)
}
+1 -1
View File
@@ -30,7 +30,7 @@ func (pm *fakePluginInstaller) Add(_ context.Context, pluginID, version string,
return nil
}
func (pm *fakePluginInstaller) Remove(_ context.Context, pluginID string) error {
func (pm *fakePluginInstaller) Remove(_ context.Context, pluginID, _ string) error {
delete(pm.plugins, pluginID)
return nil
}
+3 -3
View File
@@ -173,8 +173,8 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
AllowOrgCreate: (hs.Cfg.AllowUserOrgCreate && c.IsSignedIn) || c.IsGrafanaAdmin,
AuthProxyEnabled: hs.Cfg.AuthProxyEnabled,
LdapEnabled: hs.Cfg.LDAPAuthEnabled,
JwtHeaderName: hs.Cfg.JWTAuthHeaderName,
JwtUrlLogin: hs.Cfg.JWTAuthURLLogin,
JwtHeaderName: hs.Cfg.JWTAuth.HeaderName,
JwtUrlLogin: hs.Cfg.JWTAuth.URLLogin,
AlertingErrorOrTimeout: hs.Cfg.AlertingErrorOrTimeout,
AlertingNoDataOrNullValues: hs.Cfg.AlertingNoDataOrNullValues,
AlertingMinInterval: hs.Cfg.AlertingMinInterval,
@@ -321,7 +321,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
OAuthSkipOrgRoleUpdateSync: hs.Cfg.OAuthSkipOrgRoleUpdateSync,
SAMLSkipOrgRoleSync: hs.Cfg.SAMLSkipOrgRoleSync,
LDAPSkipOrgRoleSync: hs.Cfg.LDAPSkipOrgRoleSync,
JWTAuthSkipOrgRoleSync: hs.Cfg.JWTAuthSkipOrgRoleSync,
JWTAuthSkipOrgRoleSync: hs.Cfg.JWTAuth.SkipOrgRoleSync,
GoogleSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GoogleProviderName]),
GrafanaComSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GrafanaComProviderName]),
GenericOAuthSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.GenericOAuthProviderName]),
+5
View File
@@ -45,9 +45,14 @@ import (
secretsmng "github.com/grafana/grafana/pkg/services/secrets/manager"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tests/testsuite"
"github.com/grafana/grafana/pkg/web"
)
func TestMain(m *testing.M) {
testsuite.Run(m)
}
func TestDataSourceProxy_routeRule(t *testing.T) {
cfg := &setting.Cfg{}
+16 -7
View File
@@ -274,7 +274,12 @@ func (hs *HTTPServer) GetPluginMarkdown(c *contextmodel.ReqContext) response.Res
pluginID := web.Params(c.Req)[":pluginId"]
name := web.Params(c.Req)[":name"]
content, err := hs.pluginMarkdown(c.Req.Context(), pluginID, name)
p, exists := hs.pluginStore.Plugin(c.Req.Context(), pluginID)
if !exists {
return response.Error(http.StatusNotFound, "Plugin not installed", nil)
}
content, err := hs.pluginMarkdown(c.Req.Context(), pluginID, p.Info.Version, name)
if err != nil {
var notFound plugins.NotFoundError
if errors.As(err, &notFound) {
@@ -286,7 +291,7 @@ func (hs *HTTPServer) GetPluginMarkdown(c *contextmodel.ReqContext) response.Res
// fallback try readme
if len(content) == 0 {
content, err = hs.pluginMarkdown(c.Req.Context(), pluginID, "readme")
content, err = hs.pluginMarkdown(c.Req.Context(), pluginID, p.Info.Version, "readme")
if err != nil {
if errors.Is(err, plugins.ErrFileNotExist) {
return response.Error(http.StatusNotFound, plugins.ErrFileNotExist.Error(), nil)
@@ -354,7 +359,7 @@ func (hs *HTTPServer) getPluginAssets(c *contextmodel.ReqContext) {
// serveLocalPluginAsset returns the content of a plugin asset file from the local filesystem to the http client.
func (hs *HTTPServer) serveLocalPluginAsset(c *contextmodel.ReqContext, plugin pluginstore.Plugin, assetPath string) {
f, err := hs.pluginFileStore.File(c.Req.Context(), plugin.ID, assetPath)
f, err := hs.pluginFileStore.File(c.Req.Context(), plugin.ID, plugin.Info.Version, assetPath)
if err != nil {
if errors.Is(err, plugins.ErrFileNotExist) {
c.JsonApiErr(404, "Plugin file not found", nil)
@@ -476,8 +481,12 @@ func (hs *HTTPServer) InstallPlugin(c *contextmodel.ReqContext) response.Respons
func (hs *HTTPServer) UninstallPlugin(c *contextmodel.ReqContext) response.Response {
pluginID := web.Params(c.Req)[":pluginId"]
plugin, exists := hs.pluginStore.Plugin(c.Req.Context(), pluginID)
if !exists {
return response.Error(http.StatusNotFound, "Plugin not installed", nil)
}
err := hs.pluginInstaller.Remove(c.Req.Context(), pluginID)
err := hs.pluginInstaller.Remove(c.Req.Context(), pluginID, plugin.Info.Version)
if err != nil {
if errors.Is(err, plugins.ErrPluginNotInstalled) {
return response.Error(http.StatusNotFound, "Plugin not installed", err)
@@ -494,19 +503,19 @@ func translatePluginRequestErrorToAPIError(err error) response.Response {
return response.ErrOrFallback(http.StatusInternalServerError, "Plugin request failed", err)
}
func (hs *HTTPServer) pluginMarkdown(ctx context.Context, pluginID string, name string) ([]byte, error) {
func (hs *HTTPServer) pluginMarkdown(ctx context.Context, pluginID, pluginVersion, name string) ([]byte, error) {
file, err := mdFilepath(strings.ToUpper(name))
if err != nil {
return make([]byte, 0), err
}
md, err := hs.pluginFileStore.File(ctx, pluginID, file)
md, err := hs.pluginFileStore.File(ctx, pluginID, pluginVersion, file)
if err != nil {
if errors.Is(err, plugins.ErrPluginNotInstalled) {
return make([]byte, 0), plugins.NotFoundError{PluginID: pluginID}
}
md, err = hs.pluginFileStore.File(ctx, pluginID, strings.ToLower(file))
md, err = hs.pluginFileStore.File(ctx, pluginID, pluginVersion, strings.ToLower(file))
if err != nil {
return make([]byte, 0), nil
}
+19 -11
View File
@@ -49,6 +49,7 @@ func Test_PluginsInstallAndUninstall(t *testing.T) {
canInstall := []ac.Permission{{Action: pluginaccesscontrol.ActionInstall}}
cannotInstall := []ac.Permission{{Action: "plugins:cannotinstall"}}
pluginID := "grafana-test-datasource"
localOrg := int64(1)
globalOrg := int64(ac.GlobalOrgID)
@@ -88,11 +89,17 @@ func Test_PluginsInstallAndUninstall(t *testing.T) {
hs.pluginInstaller = NewFakePluginInstaller()
hs.pluginFileStore = &fakes.FakePluginFileStore{}
hs.pluginStore = pluginstore.NewFakePluginStore(pluginstore.Plugin{
JSONData: plugins.JSONData{
ID: pluginID,
},
})
})
t.Run(testName("Install", tc), func(t *testing.T) {
input := strings.NewReader(`{"version": "1.0.2"}`)
req := webtest.RequestWithSignedInUser(server.NewPostRequest("/api/plugins/test/install", input), userWithPermissions(tc.permissionOrg, tc.permissions))
endpoint := fmt.Sprintf("/api/plugins/%s/install", pluginID)
req := webtest.RequestWithSignedInUser(server.NewPostRequest(endpoint, input), userWithPermissions(tc.permissionOrg, tc.permissions))
res, err := server.SendJSON(req)
require.NoError(t, err)
require.Equal(t, tc.expectedCode, res.StatusCode)
@@ -101,7 +108,8 @@ func Test_PluginsInstallAndUninstall(t *testing.T) {
t.Run(testName("Uninstall", tc), func(t *testing.T) {
input := strings.NewReader("{ }")
req := webtest.RequestWithSignedInUser(server.NewPostRequest("/api/plugins/test/uninstall", input), userWithPermissions(tc.permissionOrg, tc.permissions))
endpoint := fmt.Sprintf("/api/plugins/%s/uninstall", pluginID)
req := webtest.RequestWithSignedInUser(server.NewPostRequest(endpoint, input), userWithPermissions(tc.permissionOrg, tc.permissions))
res, err := server.SendJSON(req)
require.NoError(t, err)
require.Equal(t, tc.expectedCode, res.StatusCode)
@@ -401,14 +409,14 @@ func TestMakePluginResourceRequestContentTypeEmpty(t *testing.T) {
func TestPluginMarkdown(t *testing.T) {
t.Run("Plugin not installed returns error", func(t *testing.T) {
pluginFileStore := &fakes.FakePluginFileStore{
FileFunc: func(ctx context.Context, pluginID, filename string) (*plugins.File, error) {
FileFunc: func(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error) {
return nil, plugins.ErrPluginNotInstalled
},
}
hs := HTTPServer{pluginFileStore: pluginFileStore}
pluginID := "test-datasource"
md, err := hs.pluginMarkdown(context.Background(), pluginID, "test")
md, err := hs.pluginMarkdown(context.Background(), pluginID, "", "test")
require.ErrorAs(t, err, &plugins.NotFoundError{PluginID: pluginID})
require.Equal(t, []byte{}, md)
})
@@ -416,7 +424,7 @@ func TestPluginMarkdown(t *testing.T) {
t.Run("File fetch will be retried using different casing if error occurs", func(t *testing.T) {
var requestedFiles []string
pluginFileStore := &fakes.FakePluginFileStore{
FileFunc: func(ctx context.Context, pluginID, filename string) (*plugins.File, error) {
FileFunc: func(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error) {
requestedFiles = append(requestedFiles, filename)
return nil, errors.New("some error")
},
@@ -424,7 +432,7 @@ func TestPluginMarkdown(t *testing.T) {
hs := HTTPServer{pluginFileStore: pluginFileStore}
md, err := hs.pluginMarkdown(context.Background(), "", "reAdMe")
md, err := hs.pluginMarkdown(context.Background(), "", "", "reAdMe")
require.NoError(t, err)
require.Equal(t, []byte{}, md)
require.Equal(t, []string{"README.md", "readme.md"}, requestedFiles)
@@ -453,7 +461,7 @@ func TestPluginMarkdown(t *testing.T) {
data := []byte{123}
var requestedFiles []string
pluginFileStore := &fakes.FakePluginFileStore{
FileFunc: func(ctx context.Context, pluginID, filename string) (*plugins.File, error) {
FileFunc: func(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error) {
requestedFiles = append(requestedFiles, filename)
return &plugins.File{Content: data}, nil
},
@@ -461,7 +469,7 @@ func TestPluginMarkdown(t *testing.T) {
hs := HTTPServer{pluginFileStore: pluginFileStore}
md, err := hs.pluginMarkdown(context.Background(), "test-datasource", tc.filePath)
md, err := hs.pluginMarkdown(context.Background(), "test-datasource", "", tc.filePath)
require.NoError(t, err)
require.Equal(t, data, md)
require.Equal(t, tc.expected, requestedFiles)
@@ -471,7 +479,7 @@ func TestPluginMarkdown(t *testing.T) {
t.Run("Non markdown file request returns an error", func(t *testing.T) {
hs := HTTPServer{pluginFileStore: &fakes.FakePluginFileStore{}}
md, err := hs.pluginMarkdown(context.Background(), "", "test.json")
md, err := hs.pluginMarkdown(context.Background(), "", "", "test.json")
require.ErrorIs(t, err, ErrUnexpectedFileExtension)
require.Equal(t, []byte{}, md)
})
@@ -480,14 +488,14 @@ func TestPluginMarkdown(t *testing.T) {
data := []byte{1, 2, 3}
pluginFileStore := &fakes.FakePluginFileStore{
FileFunc: func(ctx context.Context, pluginID, filename string) (*plugins.File, error) {
FileFunc: func(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error) {
return &plugins.File{Content: data}, nil
},
}
hs := HTTPServer{pluginFileStore: pluginFileStore}
md, err := hs.pluginMarkdown(context.Background(), "", "someFile")
md, err := hs.pluginMarkdown(context.Background(), "", "", "someFile")
require.NoError(t, err)
require.Equal(t, data, md)
})
+3 -3
View File
@@ -302,9 +302,9 @@ func Test_GetUserByID(t *testing.T) {
case login.GenericOAuthModule:
socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{AllowAssignGrafanaAdmin: tc.allowAssignGrafanaAdmin, Enabled: tc.authEnabled, SkipOrgRoleSync: tc.skipOrgRoleSync}
case login.JWTModule:
cfg.JWTAuthEnabled = tc.authEnabled
cfg.JWTAuthSkipOrgRoleSync = tc.skipOrgRoleSync
cfg.JWTAuthAllowAssignGrafanaAdmin = tc.allowAssignGrafanaAdmin
cfg.JWTAuth.Enabled = tc.authEnabled
cfg.JWTAuth.SkipOrgRoleSync = tc.skipOrgRoleSync
cfg.JWTAuth.AllowAssignGrafanaAdmin = tc.allowAssignGrafanaAdmin
}
hs := &HTTPServer{
+3
View File
@@ -98,6 +98,9 @@ type ToggleStatus struct {
// The flag description
Description string `json:"description,omitempty"`
// The feature toggle stage
Stage string `json:"stage"`
// Is the flag enabled
Enabled bool `json:"enabled"`
@@ -23,11 +23,16 @@ import (
"github.com/grafana/grafana/pkg/services/user/userimpl"
"github.com/grafana/grafana/pkg/services/user/usertest"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tests/testsuite"
)
// "Skipping conflicting users test for mysql as it does make unique constraint case insensitive by default
const ignoredDatabase = migrator.MySQL
func TestMain(m *testing.M) {
testsuite.Run(m)
}
func TestBuildConflictBlock(t *testing.T) {
type testBuildConflictBlock struct {
desc string
@@ -12,9 +12,14 @@ import (
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tests/testsuite"
"github.com/grafana/grafana/pkg/util"
)
func TestMain(m *testing.M) {
testsuite.Run(m)
}
func TestPasswordMigrationCommand(t *testing.T) {
// setup datasources with password, basic_auth and none
store := db.InitTestDB(t)
+9 -1
View File
@@ -10,6 +10,8 @@ import (
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/services/sqlstore/session"
"github.com/grafana/grafana/pkg/services/sqlstore/sqlutil"
"github.com/grafana/grafana/pkg/setting"
)
type DB interface {
@@ -51,10 +53,16 @@ type DB interface {
type Session = sqlstore.DBSession
type InitTestDBOpt = sqlstore.InitTestDBOpt
var SetupTestDB = sqlstore.SetupTestDB
var InitTestDB = sqlstore.InitTestDB
var InitTestDBwithCfg = sqlstore.InitTestDBWithCfg
var CleanupTestDB = sqlstore.CleanupTestDB
var ProvideService = sqlstore.ProvideService
func InitTestDBwithCfg(t sqlutil.ITestDB, opts ...InitTestDBOpt) (*sqlstore.SQLStore, *setting.Cfg) {
store := InitTestDB(t, opts...)
return store, store.Cfg
}
func IsTestDbSQLite() bool {
if db, present := os.LookupEnv("GRAFANA_TEST_DB"); !present || db == "sqlite" {
return true
@@ -12,12 +12,17 @@ import (
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/tests/testsuite"
)
const (
pngImageBase64 = "iVBORw0KGgoNAANSUhEUgAAAC4AAAAmCAYAAAC76qlaAAAABHNCSVQICAgIfAhkiAAAABl0RVh0U29mdHdhcmUAZ25vbWUtc2NyZWVuc2hvdO8Dvz4AAABFSURBVFiF7c5BDQAhEACx4/x7XjzwGELSKuiamfke9N8OnBKvidfEa+I18Zp4TbwmXhOvidfEa+I18Zp4TbwmXhOvidc2lcsESD1LGnUAAAAASUVORK5CYII="
)
func TestMain(m *testing.M) {
testsuite.Run(m)
}
type fsTestCase struct {
name string
skip *bool
+5
View File
@@ -10,8 +10,13 @@ import (
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/tests/testsuite"
)
func TestMain(m *testing.M) {
testsuite.Run(m)
}
func createTestableKVStore(t *testing.T) KVStore {
t.Helper()
@@ -13,8 +13,13 @@ import (
"github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/services/secrets/fakes"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tests/testsuite"
)
func TestMain(m *testing.M) {
testsuite.Run(m)
}
func createTestClient(t *testing.T, opts *setting.RemoteCacheOptions, sqlstore db.DB) CacheStorage {
t.Helper()
+5
View File
@@ -11,8 +11,13 @@ import (
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/tests/testsuite"
)
func TestMain(m *testing.M) {
testsuite.Run(m)
}
func createTestableServerLock(t *testing.T) *ServerLockService {
t.Helper()
@@ -25,8 +25,13 @@ import (
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
"github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tests/testsuite"
)
func TestMain(m *testing.M) {
testsuite.Run(m)
}
// This is to ensure that the interface contract is held by the implementation
func Test_InterfaceContractValidity(t *testing.T) {
newUsageStats := func() usagestats.Service {
@@ -14,9 +14,15 @@ import (
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/stats/statsimpl"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tests/testsuite"
"github.com/grafana/grafana/pkg/util"
)
// run tests with cleanup
func TestMain(m *testing.M) {
testsuite.Run(m)
}
func TestConcurrentUsersMetrics(t *testing.T) {
sqlStore, cfg := db.InitTestDBwithCfg(t)
statsService := statsimpl.ProvideService(&setting.Cfg{}, sqlStore)
+3 -2
View File
@@ -18,6 +18,7 @@ import (
"github.com/grafana/grafana/pkg/infra/remotecache"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/models/roletype"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/ssosettings"
@@ -185,13 +186,13 @@ func (s *SocialAzureAD) Reload(ctx context.Context, settings ssoModels.SSOSettin
return nil
}
func (s *SocialAzureAD) Validate(ctx context.Context, settings ssoModels.SSOSettings) error {
func (s *SocialAzureAD) Validate(ctx context.Context, settings ssoModels.SSOSettings, requester identity.Requester) error {
info, err := CreateOAuthInfoFromKeyValues(settings.Settings)
if err != nil {
return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err)
}
err = validateInfo(info)
err = validateInfo(info, requester)
if err != nil {
return err
}
@@ -18,10 +18,12 @@ import (
"github.com/grafana/grafana/pkg/infra/remotecache"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ssosettings"
ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models"
"github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
)
@@ -992,9 +994,10 @@ func TestSocialAzureAD_InitializeExtraFields(t *testing.T) {
func TestSocialAzureAD_Validate(t *testing.T) {
testCases := []struct {
name string
settings ssoModels.SSOSettings
wantErr error
name string
settings ssoModels.SSOSettings
requester identity.Requester
wantErr error
}{
{
name: "SSOSettings is valid",
@@ -1052,13 +1055,29 @@ func TestSocialAzureAD_Validate(t *testing.T) {
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
{
name: "fails if the user is not allowed to update allow assign grafana admin",
requester: &user.SignedInUser{
IsGrafanaAdmin: false,
},
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"allow_assign_grafana_admin": "true",
},
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewAzureADProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures(), nil)
err := s.Validate(context.Background(), tc.settings)
if tc.requester == nil {
tc.requester = &user.SignedInUser{IsGrafanaAdmin: false}
}
err := s.Validate(context.Background(), tc.settings, tc.requester)
if tc.wantErr != nil {
require.ErrorIs(t, err, tc.wantErr)
return
-60
View File
@@ -2,8 +2,6 @@ package connectors
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -12,7 +10,6 @@ import (
"strconv"
"strings"
"github.com/jmespath/go-jmespath"
"github.com/mitchellh/mapstructure"
"golang.org/x/oauth2"
@@ -96,63 +93,6 @@ func (s *SocialBase) httpGet(ctx context.Context, client *http.Client, url strin
return response, nil
}
func (s *SocialBase) searchJSONForAttr(attributePath string, data []byte) (any, error) {
if attributePath == "" {
return "", errors.New("no attribute path specified")
}
if len(data) == 0 {
return "", errors.New("empty user info JSON response provided")
}
var buf any
if err := json.Unmarshal(data, &buf); err != nil {
return "", fmt.Errorf("%v: %w", "failed to unmarshal user info JSON response", err)
}
val, err := jmespath.Search(attributePath, buf)
if err != nil {
return "", fmt.Errorf("failed to search user info JSON response with provided path: %q: %w", attributePath, err)
}
return val, nil
}
func (s *SocialBase) searchJSONForStringAttr(attributePath string, data []byte) (string, error) {
val, err := s.searchJSONForAttr(attributePath, data)
if err != nil {
return "", err
}
strVal, ok := val.(string)
if ok {
return strVal, nil
}
return "", nil
}
func (s *SocialBase) searchJSONForStringArrayAttr(attributePath string, data []byte) ([]string, error) {
val, err := s.searchJSONForAttr(attributePath, data)
if err != nil {
return []string{}, err
}
ifArr, ok := val.([]any)
if !ok {
return []string{}, nil
}
result := []string{}
for _, v := range ifArr {
if strVal, ok := v.(string); ok {
result = append(result, strVal)
}
}
return result, nil
}
func createOAuthConfig(info *social.OAuthInfo, cfg *setting.Cfg, defaultName string) *oauth2.Config {
var authStyle oauth2.AuthStyle
switch strings.ToLower(info.AuthStyle) {
+8 -7
View File
@@ -13,6 +13,7 @@ import (
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ssosettings"
ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models"
@@ -67,13 +68,13 @@ func NewGenericOAuthProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettin
return provider
}
func (s *SocialGenericOAuth) Validate(ctx context.Context, settings ssoModels.SSOSettings) error {
func (s *SocialGenericOAuth) Validate(ctx context.Context, settings ssoModels.SSOSettings, requester identity.Requester) error {
info, err := CreateOAuthInfoFromKeyValues(settings.Settings)
if err != nil {
return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err)
}
err = validateInfo(info)
err = validateInfo(info, requester)
if err != nil {
return err
}
@@ -362,7 +363,7 @@ func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson) string {
}
if s.emailAttributePath != "" {
email, err := s.searchJSONForStringAttr(s.emailAttributePath, data.rawJSON)
email, err := util.SearchJSONForStringAttr(s.emailAttributePath, data.rawJSON)
if err != nil {
s.log.Error("Failed to search JSON for attribute", "error", err)
} else if email != "" {
@@ -394,7 +395,7 @@ func (s *SocialGenericOAuth) extractLogin(data *UserInfoJson) string {
if s.loginAttributePath != "" {
s.log.Debug("Searching for login among JSON", "loginAttributePath", s.loginAttributePath)
login, err := s.searchJSONForStringAttr(s.loginAttributePath, data.rawJSON)
login, err := util.SearchJSONForStringAttr(s.loginAttributePath, data.rawJSON)
if err != nil {
s.log.Error("Failed to search JSON for login attribute", "error", err)
}
@@ -414,7 +415,7 @@ func (s *SocialGenericOAuth) extractLogin(data *UserInfoJson) string {
func (s *SocialGenericOAuth) extractUserName(data *UserInfoJson) string {
if s.nameAttributePath != "" {
name, err := s.searchJSONForStringAttr(s.nameAttributePath, data.rawJSON)
name, err := util.SearchJSONForStringAttr(s.nameAttributePath, data.rawJSON)
if err != nil {
s.log.Error("Failed to search JSON for attribute", "error", err)
} else if name != "" {
@@ -442,7 +443,7 @@ func (s *SocialGenericOAuth) extractGroups(data *UserInfoJson) ([]string, error)
return []string{}, nil
}
return s.searchJSONForStringArrayAttr(s.groupsAttributePath, data.rawJSON)
return util.SearchJSONForStringSliceAttr(s.groupsAttributePath, data.rawJSON)
}
func (s *SocialGenericOAuth) FetchPrivateEmail(ctx context.Context, client *http.Client) (string, error) {
@@ -553,7 +554,7 @@ func (s *SocialGenericOAuth) fetchTeamMembershipsFromTeamsUrl(ctx context.Contex
return nil, err
}
return s.searchJSONForStringArrayAttr(s.teamIdsAttributePath, response.Body)
return util.SearchJSONForStringSliceAttr(s.teamIdsAttributePath, response.Body)
}
func (s *SocialGenericOAuth) FetchOrganizations(ctx context.Context, client *http.Client) ([]string, bool) {
+27 -207
View File
@@ -13,216 +13,16 @@ import (
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/ssosettings"
ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models"
"github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
)
func TestSearchJSONForEmail(t *testing.T) {
t.Run("Given a generic OAuth provider", func(t *testing.T) {
provider := NewGenericOAuthProvider(social.NewOAuthInfo(), &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
tests := []struct {
Name string
UserInfoJSONResponse []byte
EmailAttributePath string
ExpectedResult string
ExpectedError string
}{
{
Name: "Given an invalid user info JSON response",
UserInfoJSONResponse: []byte("{"),
EmailAttributePath: "attributes.email",
ExpectedResult: "",
ExpectedError: "failed to unmarshal user info JSON response: unexpected end of JSON input",
},
{
Name: "Given an empty user info JSON response and empty JMES path",
UserInfoJSONResponse: []byte{},
EmailAttributePath: "",
ExpectedResult: "",
ExpectedError: "no attribute path specified",
},
{
Name: "Given an empty user info JSON response and valid JMES path",
UserInfoJSONResponse: []byte{},
EmailAttributePath: "attributes.email",
ExpectedResult: "",
ExpectedError: "empty user info JSON response provided",
},
{
Name: "Given a simple user info JSON response and valid JMES path",
UserInfoJSONResponse: []byte(`{
"attributes": {
"email": "grafana@localhost"
}
}`),
EmailAttributePath: "attributes.email",
ExpectedResult: "grafana@localhost",
},
{
Name: "Given a user info JSON response with e-mails array and valid JMES path",
UserInfoJSONResponse: []byte(`{
"attributes": {
"emails": ["grafana@localhost", "admin@localhost"]
}
}`),
EmailAttributePath: "attributes.emails[0]",
ExpectedResult: "grafana@localhost",
},
{
Name: "Given a nested user info JSON response and valid JMES path",
UserInfoJSONResponse: []byte(`{
"identities": [
{
"userId": "grafana@localhost"
},
{
"userId": "admin@localhost"
}
]
}`),
EmailAttributePath: "identities[0].userId",
ExpectedResult: "grafana@localhost",
},
}
for _, test := range tests {
provider.emailAttributePath = test.EmailAttributePath
t.Run(test.Name, func(t *testing.T) {
actualResult, err := provider.searchJSONForStringAttr(test.EmailAttributePath, test.UserInfoJSONResponse)
if test.ExpectedError == "" {
require.NoError(t, err, "Testing case %q", test.Name)
} else {
require.EqualError(t, err, test.ExpectedError, "Testing case %q", test.Name)
}
require.Equal(t, test.ExpectedResult, actualResult)
})
}
})
}
func TestSearchJSONForGroups(t *testing.T) {
t.Run("Given a generic OAuth provider", func(t *testing.T) {
provider := NewGenericOAuthProvider(social.NewOAuthInfo(), &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
tests := []struct {
Name string
UserInfoJSONResponse []byte
GroupsAttributePath string
ExpectedResult []string
ExpectedError string
}{
{
Name: "Given an invalid user info JSON response",
UserInfoJSONResponse: []byte("{"),
GroupsAttributePath: "attributes.groups",
ExpectedResult: []string{},
ExpectedError: "failed to unmarshal user info JSON response: unexpected end of JSON input",
},
{
Name: "Given an empty user info JSON response and empty JMES path",
UserInfoJSONResponse: []byte{},
GroupsAttributePath: "",
ExpectedResult: []string{},
ExpectedError: "no attribute path specified",
},
{
Name: "Given an empty user info JSON response and valid JMES path",
UserInfoJSONResponse: []byte{},
GroupsAttributePath: "attributes.groups",
ExpectedResult: []string{},
ExpectedError: "empty user info JSON response provided",
},
{
Name: "Given a simple user info JSON response and valid JMES path",
UserInfoJSONResponse: []byte(`{
"attributes": {
"groups": ["foo", "bar"]
}
}`),
GroupsAttributePath: "attributes.groups[]",
ExpectedResult: []string{"foo", "bar"},
},
}
for _, test := range tests {
provider.groupsAttributePath = test.GroupsAttributePath
t.Run(test.Name, func(t *testing.T) {
actualResult, err := provider.searchJSONForStringArrayAttr(test.GroupsAttributePath, test.UserInfoJSONResponse)
if test.ExpectedError == "" {
require.NoError(t, err, "Testing case %q", test.Name)
} else {
require.EqualError(t, err, test.ExpectedError, "Testing case %q", test.Name)
}
require.Equal(t, test.ExpectedResult, actualResult)
})
}
})
}
func TestSearchJSONForRole(t *testing.T) {
t.Run("Given a generic OAuth provider", func(t *testing.T) {
provider := NewGenericOAuthProvider(social.NewOAuthInfo(), &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
tests := []struct {
Name string
UserInfoJSONResponse []byte
RoleAttributePath string
ExpectedResult string
ExpectedError string
}{
{
Name: "Given an invalid user info JSON response",
UserInfoJSONResponse: []byte("{"),
RoleAttributePath: "attributes.role",
ExpectedResult: "",
ExpectedError: "failed to unmarshal user info JSON response: unexpected end of JSON input",
},
{
Name: "Given an empty user info JSON response and empty JMES path",
UserInfoJSONResponse: []byte{},
RoleAttributePath: "",
ExpectedResult: "",
ExpectedError: "no attribute path specified",
},
{
Name: "Given an empty user info JSON response and valid JMES path",
UserInfoJSONResponse: []byte{},
RoleAttributePath: "attributes.role",
ExpectedResult: "",
ExpectedError: "empty user info JSON response provided",
},
{
Name: "Given a simple user info JSON response and valid JMES path",
UserInfoJSONResponse: []byte(`{
"attributes": {
"role": "admin"
}
}`),
RoleAttributePath: "attributes.role",
ExpectedResult: "admin",
},
}
for _, test := range tests {
provider.info.RoleAttributePath = test.RoleAttributePath
t.Run(test.Name, func(t *testing.T) {
actualResult, err := provider.searchJSONForStringAttr(test.RoleAttributePath, test.UserInfoJSONResponse)
if test.ExpectedError == "" {
require.NoError(t, err, "Testing case %q", test.Name)
} else {
require.EqualError(t, err, test.ExpectedError, "Testing case %q", test.Name)
}
require.Equal(t, test.ExpectedResult, actualResult)
})
}
})
}
func TestUserInfoSearchesForEmailAndRole(t *testing.T) {
provider := NewGenericOAuthProvider(&social.OAuthInfo{
EmailAttributePath: "email",
@@ -920,17 +720,20 @@ func TestSocialGenericOAuth_InitializeExtraFields(t *testing.T) {
func TestSocialGenericOAuth_Validate(t *testing.T) {
testCases := []struct {
name string
settings ssoModels.SSOSettings
wantErr error
name string
settings ssoModels.SSOSettings
requester identity.Requester
wantErr error
}{
{
name: "SSOSettings is valid",
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"client_id": "client-id",
"allow_assign_grafana_admin": "true",
},
},
requester: &user.SignedInUser{IsGrafanaAdmin: true},
},
{
name: "fails if settings map contains an invalid field",
@@ -969,13 +772,30 @@ func TestSocialGenericOAuth_Validate(t *testing.T) {
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
{
name: "fails if the user is not allowed to update allow assign grafana admin",
requester: &user.SignedInUser{
IsGrafanaAdmin: false,
},
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"allow_assign_grafana_admin": "true",
"skip_org_role_sync": "true",
},
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewGenericOAuthProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
err := s.Validate(context.Background(), tc.settings)
if tc.requester == nil {
tc.requester = &user.SignedInUser{IsGrafanaAdmin: false}
}
err := s.Validate(context.Background(), tc.settings, tc.requester)
if tc.wantErr != nil {
require.ErrorIs(t, err, tc.wantErr)
return
+3 -2
View File
@@ -14,6 +14,7 @@ import (
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/models/roletype"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ssosettings"
ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models"
@@ -74,13 +75,13 @@ func NewGitHubProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings sso
return provider
}
func (s *SocialGithub) Validate(ctx context.Context, settings ssoModels.SSOSettings) error {
func (s *SocialGithub) Validate(ctx context.Context, settings ssoModels.SSOSettings, requester identity.Requester) error {
info, err := CreateOAuthInfoFromKeyValues(settings.Settings)
if err != nil {
return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err)
}
err = validateInfo(info)
err = validateInfo(info, requester)
if err != nil {
return err
}
@@ -12,10 +12,12 @@ import (
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ssosettings"
ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models"
"github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
)
@@ -346,17 +348,20 @@ func TestSocialGitHub_InitializeExtraFields(t *testing.T) {
func TestSocialGitHub_Validate(t *testing.T) {
testCases := []struct {
name string
settings ssoModels.SSOSettings
wantErr error
name string
settings ssoModels.SSOSettings
requester identity.Requester
wantErr error
}{
{
name: "SSOSettings is valid",
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"client_id": "client-id",
"allow_assign_grafana_admin": "true",
},
},
requester: &user.SignedInUser{IsGrafanaAdmin: true},
},
{
name: "fails if settings map contains an invalid field",
@@ -405,13 +410,31 @@ func TestSocialGitHub_Validate(t *testing.T) {
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
{
name: "fails if the user is not allowed to update allow assign grafana admin",
requester: &user.SignedInUser{
IsGrafanaAdmin: false,
},
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"allow_assign_grafana_admin": "true",
"skip_org_role_sync": "true",
},
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewGitHubProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
err := s.Validate(context.Background(), tc.settings)
if tc.requester == nil {
tc.requester = &user.SignedInUser{IsGrafanaAdmin: false}
}
err := s.Validate(context.Background(), tc.settings, tc.requester)
if tc.wantErr != nil {
require.ErrorIs(t, err, tc.wantErr)
return
+3 -2
View File
@@ -13,6 +13,7 @@ import (
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/models/roletype"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ssosettings"
ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models"
@@ -64,13 +65,13 @@ func NewGitLabProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings sso
return provider
}
func (s *SocialGitlab) Validate(ctx context.Context, settings ssoModels.SSOSettings) error {
func (s *SocialGitlab) Validate(ctx context.Context, settings ssoModels.SSOSettings, requester identity.Requester) error {
info, err := CreateOAuthInfoFromKeyValues(settings.Settings)
if err != nil {
return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err)
}
err = validateInfo(info)
err = validateInfo(info, requester)
if err != nil {
return err
}
@@ -16,11 +16,13 @@ import (
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/ssosettings"
ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models"
"github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
)
@@ -464,17 +466,20 @@ func TestSocialGitlab_GetGroupsNextPage(t *testing.T) {
func TestSocialGitlab_Validate(t *testing.T) {
testCases := []struct {
name string
settings ssoModels.SSOSettings
wantErr error
name string
settings ssoModels.SSOSettings
requester identity.Requester
wantErr error
}{
{
name: "SSOSettings is valid",
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"client_id": "client-id",
"allow_assign_grafana_admin": "true",
},
},
requester: &user.SignedInUser{IsGrafanaAdmin: true},
},
{
name: "fails if settings map contains an invalid field",
@@ -513,13 +518,31 @@ func TestSocialGitlab_Validate(t *testing.T) {
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
{
name: "fails if the user is not allowed to update allow assign grafana admin",
requester: &user.SignedInUser{
IsGrafanaAdmin: false,
},
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"allow_assign_grafana_admin": "true",
"skip_org_role_sync": "true",
},
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewGitLabProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
err := s.Validate(context.Background(), tc.settings)
if tc.requester == nil {
tc.requester = &user.SignedInUser{IsGrafanaAdmin: false}
}
err := s.Validate(context.Background(), tc.settings, tc.requester)
if tc.wantErr != nil {
require.ErrorIs(t, err, tc.wantErr)
return
+3 -2
View File
@@ -11,6 +11,7 @@ import (
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ssosettings"
ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models"
@@ -54,13 +55,13 @@ func NewGoogleProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings sso
return provider
}
func (s *SocialGoogle) Validate(ctx context.Context, settings ssoModels.SSOSettings) error {
func (s *SocialGoogle) Validate(ctx context.Context, settings ssoModels.SSOSettings, requester identity.Requester) error {
info, err := CreateOAuthInfoFromKeyValues(settings.Settings)
if err != nil {
return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err)
}
err = validateInfo(info)
err = validateInfo(info, requester)
if err != nil {
return err
}
@@ -16,10 +16,12 @@ import (
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/models/roletype"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ssosettings"
ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models"
"github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
)
@@ -669,17 +671,20 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
func TestSocialGoogle_Validate(t *testing.T) {
testCases := []struct {
name string
settings ssoModels.SSOSettings
wantErr error
name string
settings ssoModels.SSOSettings
requester identity.Requester
wantErr error
}{
{
name: "SSOSettings is valid",
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"client_id": "client-id",
"allow_assign_grafana_admin": "true",
},
},
requester: &user.SignedInUser{IsGrafanaAdmin: true},
},
{
name: "fails if settings map contains an invalid field",
@@ -718,13 +723,31 @@ func TestSocialGoogle_Validate(t *testing.T) {
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
{
name: "fails if the user is not allowed to update allow assign grafana admin",
requester: &user.SignedInUser{
IsGrafanaAdmin: false,
},
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"allow_assign_grafana_admin": "true",
"skip_org_role_sync": "true",
},
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewGoogleProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
err := s.Validate(context.Background(), tc.settings)
if tc.requester == nil {
tc.requester = &user.SignedInUser{IsGrafanaAdmin: false}
}
err := s.Validate(context.Background(), tc.settings, tc.requester)
if tc.wantErr != nil {
require.ErrorIs(t, err, tc.wantErr)
return
@@ -10,6 +10,7 @@ import (
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/models/roletype"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/ssosettings"
@@ -52,13 +53,13 @@ func NewGrafanaComProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings
return provider
}
func (s *SocialGrafanaCom) Validate(ctx context.Context, settings ssoModels.SSOSettings) error {
func (s *SocialGrafanaCom) Validate(ctx context.Context, settings ssoModels.SSOSettings, requester identity.Requester) error {
info, err := CreateOAuthInfoFromKeyValues(settings.Settings)
if err != nil {
return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err)
}
err = validateInfo(info)
err = validateInfo(info, requester)
if err != nil {
return err
}
@@ -10,9 +10,11 @@ import (
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/featuremgmt"
ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models"
"github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
)
@@ -139,15 +141,18 @@ func TestSocialGrafanaCom_Validate(t *testing.T) {
testCases := []struct {
name string
settings ssoModels.SSOSettings
requester identity.Requester
expectError bool
}{
{
name: "SSOSettings is valid",
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"client_id": "client-id",
"allow_assign_grafana_admin": "true",
},
},
requester: &user.SignedInUser{IsGrafanaAdmin: true},
expectError: false,
},
{
@@ -176,13 +181,31 @@ func TestSocialGrafanaCom_Validate(t *testing.T) {
},
expectError: true,
},
{
name: "fails if the user is not allowed to update allow assign grafana admin",
requester: &user.SignedInUser{
IsGrafanaAdmin: false,
},
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"allow_assign_grafana_admin": "true",
"skip_org_role_sync": "true",
},
},
expectError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewGrafanaComProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
err := s.Validate(context.Background(), tc.settings)
if tc.requester == nil {
tc.requester = &user.SignedInUser{IsGrafanaAdmin: false}
}
err := s.Validate(context.Background(), tc.settings, tc.requester)
if tc.expectError {
require.Error(t, err)
} else {
+3 -2
View File
@@ -12,6 +12,7 @@ import (
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/models/roletype"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ssosettings"
ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models"
@@ -60,13 +61,13 @@ func NewOktaProvider(info *social.OAuthInfo, cfg *setting.Cfg, ssoSettings ssose
return provider
}
func (s *SocialOkta) Validate(ctx context.Context, settings ssoModels.SSOSettings) error {
func (s *SocialOkta) Validate(ctx context.Context, settings ssoModels.SSOSettings, requester identity.Requester) error {
info, err := CreateOAuthInfoFromKeyValues(settings.Settings)
if err != nil {
return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err)
}
err = validateInfo(info)
err = validateInfo(info, requester)
if err != nil {
return err
}
+27 -5
View File
@@ -14,10 +14,12 @@ import (
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/models/roletype"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ssosettings"
ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models"
"github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
)
@@ -137,17 +139,20 @@ func TestSocialOkta_UserInfo(t *testing.T) {
func TestSocialOkta_Validate(t *testing.T) {
testCases := []struct {
name string
settings ssoModels.SSOSettings
wantErr error
name string
settings ssoModels.SSOSettings
requester identity.Requester
wantErr error
}{
{
name: "SSOSettings is valid",
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"client_id": "client-id",
"allow_assign_grafana_admin": "true",
},
},
requester: &user.SignedInUser{IsGrafanaAdmin: true},
},
{
name: "fails if settings map contains an invalid field",
@@ -186,13 +191,30 @@ func TestSocialOkta_Validate(t *testing.T) {
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
{
name: "fails if the user is not allowed to update allow assign grafana admin",
requester: &user.SignedInUser{
IsGrafanaAdmin: false,
},
settings: ssoModels.SSOSettings{
Settings: map[string]any{
"client_id": "client-id",
"allow_assign_grafana_admin": "true",
"skip_org_role_sync": "true",
},
},
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := NewOktaProvider(&social.OAuthInfo{}, &setting.Cfg{}, &ssosettingstests.MockService{}, featuremgmt.WithFeatures())
err := s.Validate(context.Background(), tc.settings)
if tc.requester == nil {
tc.requester = &user.SignedInUser{IsGrafanaAdmin: false}
}
err := s.Validate(context.Background(), tc.settings, tc.requester)
if tc.wantErr != nil {
require.ErrorIs(t, err, tc.wantErr)
return
+10 -4
View File
@@ -17,10 +17,12 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/ssosettings"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
type SocialBase struct {
@@ -111,13 +113,13 @@ func (s *SocialBase) extractRoleAndAdmin(rawJSON []byte, groups []string) (org.R
}
func (s *SocialBase) searchRole(rawJSON []byte, groups []string) (org.RoleType, bool) {
role, err := s.searchJSONForStringAttr(s.info.RoleAttributePath, rawJSON)
role, err := util.SearchJSONForStringAttr(s.info.RoleAttributePath, rawJSON)
if err == nil && role != "" {
return getRoleFromSearch(role)
}
if groupBytes, err := json.Marshal(groupStruct{groups}); err == nil {
role, err := s.searchJSONForStringAttr(s.info.RoleAttributePath, groupBytes)
role, err := util.SearchJSONForStringAttr(s.info.RoleAttributePath, groupBytes)
if err == nil && role != "" {
return getRoleFromSearch(role)
}
@@ -220,9 +222,13 @@ func getRoleFromSearch(role string) (org.RoleType, bool) {
return org.RoleType(cases.Title(language.Und).String(role)), false
}
func validateInfo(info *social.OAuthInfo) error {
func validateInfo(info *social.OAuthInfo, requester identity.Requester) error {
if info.ClientId == "" {
return ssosettings.ErrInvalidOAuthConfig("ClientId is empty")
return ssosettings.ErrInvalidOAuthConfig("Client Id is empty.")
}
if info.AllowAssignGrafanaAdmin && !requester.GetIsGrafanaAdmin() {
return ssosettings.ErrInvalidOAuthConfig("Allow assign Grafana Admin can only be updated by Grafana Server Admins.")
}
if info.AllowAssignGrafanaAdmin && info.SkipOrgRoleSync {
@@ -18,8 +18,13 @@ import (
"github.com/grafana/grafana/pkg/services/ssosettings/ssosettingsimpl"
"github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tests/testsuite"
)
func TestMain(m *testing.M) {
testsuite.Run(m)
}
func TestSocialService_ProvideService(t *testing.T) {
type testEnv struct {
features featuremgmt.FeatureToggles
+2 -2
View File
@@ -14,7 +14,7 @@ type Installer interface {
// Add adds a new plugin.
Add(ctx context.Context, pluginID, version string, opts CompatOpts) error
// Remove removes an existing plugin.
Remove(ctx context.Context, pluginID string) error
Remove(ctx context.Context, pluginID, version string) error
}
type PluginSource interface {
@@ -25,7 +25,7 @@ type PluginSource interface {
type FileStore interface {
// File retrieves a plugin file.
File(ctx context.Context, pluginID, filename string) (*File, error)
File(ctx context.Context, pluginID, pluginVersion, filename string) (*File, error)
}
type File struct {
+9 -9
View File
@@ -44,7 +44,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
return nil, errNilRequest
}
p, exists := s.plugin(ctx, req.PluginContext.PluginID)
p, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion)
if !exists {
return nil, plugins.ErrPluginNotRegistered
}
@@ -87,7 +87,7 @@ func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceReq
return errNilSender
}
p, exists := s.plugin(ctx, req.PluginContext.PluginID)
p, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion)
if !exists {
return plugins.ErrPluginNotRegistered
}
@@ -130,7 +130,7 @@ func (s *Service) CollectMetrics(ctx context.Context, req *backend.CollectMetric
return nil, errNilRequest
}
p, exists := s.plugin(ctx, req.PluginContext.PluginID)
p, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion)
if !exists {
return nil, plugins.ErrPluginNotRegistered
}
@@ -152,7 +152,7 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque
return nil, errNilRequest
}
p, exists := s.plugin(ctx, req.PluginContext.PluginID)
p, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion)
if !exists {
return nil, plugins.ErrPluginNotRegistered
}
@@ -182,7 +182,7 @@ func (s *Service) SubscribeStream(ctx context.Context, req *backend.SubscribeStr
return nil, errNilRequest
}
plugin, exists := s.plugin(ctx, req.PluginContext.PluginID)
plugin, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion)
if !exists {
return nil, plugins.ErrPluginNotRegistered
}
@@ -195,7 +195,7 @@ func (s *Service) PublishStream(ctx context.Context, req *backend.PublishStreamR
return nil, errNilRequest
}
plugin, exists := s.plugin(ctx, req.PluginContext.PluginID)
plugin, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion)
if !exists {
return nil, plugins.ErrPluginNotRegistered
}
@@ -212,7 +212,7 @@ func (s *Service) RunStream(ctx context.Context, req *backend.RunStreamRequest,
return errNilSender
}
plugin, exists := s.plugin(ctx, req.PluginContext.PluginID)
plugin, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion)
if !exists {
return plugins.ErrPluginNotRegistered
}
@@ -221,8 +221,8 @@ func (s *Service) RunStream(ctx context.Context, req *backend.RunStreamRequest,
}
// plugin finds a plugin with `pluginID` from the registry that is not decommissioned
func (s *Service) plugin(ctx context.Context, pluginID string) (*plugins.Plugin, bool) {
p, exists := s.pluginRegistry.Plugin(ctx, pluginID)
func (s *Service) plugin(ctx context.Context, pluginID, pluginVersion string) (*plugins.Plugin, bool) {
p, exists := s.pluginRegistry.Plugin(ctx, pluginID, pluginVersion)
if !exists {
return nil, false
}
+5 -5
View File
@@ -176,7 +176,7 @@ func NewFakePluginRegistry() *FakePluginRegistry {
}
}
func (f *FakePluginRegistry) Plugin(_ context.Context, id string) (*plugins.Plugin, bool) {
func (f *FakePluginRegistry) Plugin(_ context.Context, id, _ string) (*plugins.Plugin, bool) {
p, exists := f.Store[id]
return p, exists
}
@@ -195,7 +195,7 @@ func (f *FakePluginRegistry) Add(_ context.Context, p *plugins.Plugin) error {
return nil
}
func (f *FakePluginRegistry) Remove(_ context.Context, id string) error {
func (f *FakePluginRegistry) Remove(_ context.Context, id, _ string) error {
delete(f.Store, id)
return nil
}
@@ -423,12 +423,12 @@ func (s *FakePluginSource) DefaultSignature(ctx context.Context) (plugins.Signat
}
type FakePluginFileStore struct {
FileFunc func(ctx context.Context, pluginID, filename string) (*plugins.File, error)
FileFunc func(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error)
}
func (f *FakePluginFileStore) File(ctx context.Context, pluginID, filename string) (*plugins.File, error) {
func (f *FakePluginFileStore) File(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error) {
if f.FileFunc != nil {
return f.FileFunc(ctx, pluginID, filename)
return f.FileFunc(ctx, pluginID, pluginVersion, filename)
}
return nil, nil
}
+2 -2
View File
@@ -21,8 +21,8 @@ func ProvideService(pluginRegistry registry.Service) *Service {
}
}
func (s *Service) File(ctx context.Context, pluginID, filename string) (*plugins.File, error) {
if p, exists := s.pluginRegistry.Plugin(ctx, pluginID); exists {
func (s *Service) File(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error) {
if p, exists := s.pluginRegistry.Plugin(ctx, pluginID, pluginVersion); exists {
f, err := p.File(filename)
if err != nil {
return nil, err
+6 -6
View File
@@ -55,7 +55,7 @@ func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opt
}
var pluginArchive *repo.PluginArchive
if plugin, exists := m.plugin(ctx, pluginID); exists {
if plugin, exists := m.plugin(ctx, pluginID, version); exists {
if plugin.IsCorePlugin() || plugin.IsBundledPlugin() {
return plugins.ErrInstallCorePlugin
}
@@ -84,7 +84,7 @@ func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opt
}
// remove existing installation of plugin
err = m.Remove(ctx, plugin.ID)
err = m.Remove(ctx, plugin.ID, plugin.Info.Version)
if err != nil {
return err
}
@@ -139,8 +139,8 @@ func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opt
return nil
}
func (m *PluginInstaller) Remove(ctx context.Context, pluginID string) error {
plugin, exists := m.plugin(ctx, pluginID)
func (m *PluginInstaller) Remove(ctx context.Context, pluginID, version string) error {
plugin, exists := m.plugin(ctx, pluginID, version)
if !exists {
return plugins.ErrPluginNotInstalled
}
@@ -168,8 +168,8 @@ func (m *PluginInstaller) Remove(ctx context.Context, pluginID string) error {
}
// plugin finds a plugin with `pluginID` from the store
func (m *PluginInstaller) plugin(ctx context.Context, pluginID string) (*plugins.Plugin, bool) {
p, exists := m.pluginRegistry.Plugin(ctx, pluginID)
func (m *PluginInstaller) plugin(ctx context.Context, pluginID, pluginVersion string) (*plugins.Plugin, bool) {
p, exists := m.pluginRegistry.Plugin(ctx, pluginID, pluginVersion)
if !exists {
return nil, false
}
+5 -7
View File
@@ -23,6 +23,8 @@ func TestPluginManager_Add_Remove(t *testing.T) {
const (
pluginID, v1 = "test-panel", "1.0.0"
zipNameV1 = "test-panel-1.0.0.zip"
v2 = "2.0.0"
zipNameV2 = "test-panel-2.0.0.zip"
)
// mock a plugin to be returned automatically by the plugin loader
@@ -83,10 +85,6 @@ func TestPluginManager_Add_Remove(t *testing.T) {
})
t.Run("Update plugin to different version", func(t *testing.T) {
const (
v2 = "2.0.0"
zipNameV2 = "test-panel-2.0.0.zip"
)
// mock a plugin to be returned automatically by the plugin loader
pluginV2 := createPlugin(t, pluginID, plugins.ClassExternal, true, true, func(plugin *plugins.Plugin) {
plugin.Info.Version = v2
@@ -138,7 +136,7 @@ func TestPluginManager_Add_Remove(t *testing.T) {
},
}
err = inst.Remove(context.Background(), pluginID)
err = inst.Remove(context.Background(), pluginID, v2)
require.NoError(t, err)
require.Equal(t, []string{pluginID}, unloadedPlugins)
@@ -146,7 +144,7 @@ func TestPluginManager_Add_Remove(t *testing.T) {
t.Run("Won't remove if not exists", func(t *testing.T) {
inst.pluginRegistry = fakes.NewFakePluginRegistry()
err = inst.Remove(context.Background(), pluginID)
err = inst.Remove(context.Background(), pluginID, v2)
require.Equal(t, plugins.ErrPluginNotInstalled, err)
})
})
@@ -179,7 +177,7 @@ func TestPluginManager_Add_Remove(t *testing.T) {
require.Equal(t, plugins.ErrInstallCorePlugin, err)
t.Run(fmt.Sprintf("Can't uninstall %s plugin", tc.class), func(t *testing.T) {
err = pm.Remove(context.Background(), p.ID)
err = pm.Remove(context.Background(), p.ID, p.Info.Version)
require.Equal(t, plugins.ErrUninstallCorePlugin, err)
})
}
@@ -6,9 +6,7 @@ import (
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/plugins/manager/loader/finder"
"github.com/grafana/grafana/pkg/plugins/manager/registry"
)
// DefaultFindFunc is the default function used for the Find step of the Discovery stage. It will scan the local
@@ -17,44 +15,6 @@ func DefaultFindFunc(cfg *config.Cfg) FindFunc {
return finder.NewLocalFinder(cfg.DevMode, cfg.Features).Find
}
// DuplicatePluginValidation is a filter step that will filter out any plugins that are already registered with the
// registry. This includes both the primary plugin and any child plugins, which are matched using the plugin ID field.
type DuplicatePluginValidation struct {
registry registry.Service
log log.Logger
}
// NewDuplicatePluginFilterStep returns a new DuplicatePluginValidation.
func NewDuplicatePluginFilterStep(registry registry.Service) *DuplicatePluginValidation {
return &DuplicatePluginValidation{
registry: registry,
log: log.New("plugins.dedupe"),
}
}
// Filter will filter out any plugins that are already registered with the registry.
func (d *DuplicatePluginValidation) Filter(ctx context.Context, bundles []*plugins.FoundBundle) ([]*plugins.FoundBundle, error) {
res := make([]*plugins.FoundBundle, 0, len(bundles))
for _, b := range bundles {
_, exists := d.registry.Plugin(ctx, b.Primary.JSONData.ID)
if exists {
d.log.Warn("Skipping loading of plugin as it's a duplicate", "pluginId", b.Primary.JSONData.ID)
continue
}
for _, child := range b.Children {
_, exists = d.registry.Plugin(ctx, child.JSONData.ID)
if exists {
d.log.Warn("Skipping loading of child plugin as it's a duplicate", "pluginId", child.JSONData.ID)
continue
}
}
res = append(res, b)
}
return res, nil
}
// PermittedPluginTypesFilter is a filter step that will filter out any plugins that are not of a permitted type.
type PermittedPluginTypesFilter struct {
permittedTypes []plugins.Type
@@ -54,7 +54,7 @@ func newDeregister(pluginRegistry registry.Service) *Deregister {
// Deregister removes a plugin from the plugin registry.
func (d *Deregister) Deregister(ctx context.Context, p *plugins.Plugin) error {
if err := d.pluginRegistry.Remove(ctx, p.ID); err != nil {
if err := d.pluginRegistry.Remove(ctx, p.ID, p.Info.Version); err != nil {
return err
}
d.log.Debug("Plugin unregistered", "pluginId", p.ID)
+3 -3
View File
@@ -8,12 +8,12 @@ import (
// Service is responsible for the internal storing and retrieval of plugins.
type Service interface {
// Plugin finds a plugin by its ID.
Plugin(ctx context.Context, id string) (*plugins.Plugin, bool)
// Plugin finds a plugin by its ID and version.
Plugin(ctx context.Context, id, version string) (*plugins.Plugin, bool)
// Plugins returns all plugins.
Plugins(ctx context.Context) []*plugins.Plugin
// Add adds the provided plugin to the registry.
Add(ctx context.Context, plugin *plugins.Plugin) error
// Remove deletes the requested plugin from the registry.
Remove(ctx context.Context, id string) error
Remove(ctx context.Context, id, version string) error
}
+3 -2
View File
@@ -8,6 +8,7 @@ import (
"github.com/grafana/grafana/pkg/plugins"
)
// InMemory is a registry that only allows a single version of a plugin to be registered at a time.
type InMemory struct {
store map[string]*plugins.Plugin
alias map[string]*plugins.Plugin
@@ -25,7 +26,7 @@ func NewInMemory() *InMemory {
}
}
func (i *InMemory) Plugin(_ context.Context, pluginID string) (*plugins.Plugin, bool) {
func (i *InMemory) Plugin(_ context.Context, pluginID, _ string) (*plugins.Plugin, bool) {
return i.plugin(pluginID)
}
@@ -56,7 +57,7 @@ func (i *InMemory) Add(_ context.Context, p *plugins.Plugin) error {
return nil
}
func (i *InMemory) Remove(_ context.Context, pluginID string) error {
func (i *InMemory) Remove(_ context.Context, pluginID, _ string) error {
p, ok := i.plugin(pluginID)
if !ok {
return fmt.Errorf("plugin %s is not registered", pluginID)
+50 -16
View File
@@ -11,29 +11,41 @@ import (
"github.com/grafana/grafana/pkg/plugins"
)
const pluginID = "test-ds"
const (
pluginID = "test-ds"
v1 = "1.0.0"
v2 = "2.0.0"
)
func TestInMemory(t *testing.T) {
t.Run("Test mix of registry operations", func(t *testing.T) {
i := NewInMemory()
ctx := context.Background()
p, exists := i.Plugin(ctx, pluginID)
p, exists := i.Plugin(ctx, pluginID, v1)
require.False(t, exists)
require.Nil(t, p)
err := i.Remove(ctx, pluginID)
err := i.Remove(ctx, pluginID, v1)
require.EqualError(t, err, fmt.Errorf("plugin %s is not registered", pluginID).Error())
pv1 := &plugins.Plugin{JSONData: plugins.JSONData{ID: pluginID, Info: plugins.Info{Version: v1}}}
err = i.Add(ctx, pv1)
require.NoError(t, err)
pv2 := &plugins.Plugin{JSONData: plugins.JSONData{ID: pluginID, Info: plugins.Info{Version: v2}}}
err = i.Add(ctx, pv2)
require.Errorf(t, err, fmt.Sprintf("plugin %s is already registered", pluginID))
existingP, exists := i.Plugin(ctx, pluginID, v1)
require.True(t, exists)
require.Equal(t, pv1, existingP)
p = &plugins.Plugin{JSONData: plugins.JSONData{ID: pluginID}}
err = i.Add(ctx, p)
require.NoError(t, err)
require.Errorf(t, err, fmt.Sprintf("plugin %s is already registered", pluginID))
existingP, exists := i.Plugin(ctx, pluginID)
require.True(t, exists)
require.Equal(t, p, existingP)
err = i.Remove(ctx, pluginID)
err = i.Remove(ctx, pluginID, v1)
require.NoError(t, err)
existingPlugins := i.Plugins(ctx)
@@ -87,6 +99,28 @@ func TestInMemory_Add(t *testing.T) {
},
err: fmt.Errorf("plugin %s is already registered", pluginID),
},
{
name: "Cannot add a plugin to the registry even if it has a different version",
mocks: mocks{
store: map[string]*plugins.Plugin{
pluginID: {
JSONData: plugins.JSONData{
ID: pluginID,
Info: plugins.Info{Version: v1},
},
},
},
},
args: args{
p: &plugins.Plugin{
JSONData: plugins.JSONData{
ID: pluginID,
Info: plugins.Info{Version: v2},
},
},
},
err: fmt.Errorf("plugin %s is already registered", pluginID),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -151,7 +185,7 @@ func TestInMemory_Plugin(t *testing.T) {
i := &InMemory{
store: tt.mocks.store,
}
p, exists := i.Plugin(context.Background(), tt.args.pluginID)
p, exists := i.Plugin(context.Background(), tt.args.pluginID, "")
if exists != tt.exists {
t.Errorf("Plugin() got1 = %v, expected %v", exists, tt.exists)
}
@@ -265,7 +299,7 @@ func TestInMemory_Remove(t *testing.T) {
i := &InMemory{
store: tt.mocks.store,
}
err := i.Remove(context.Background(), tt.args.pluginID)
err := i.Remove(context.Background(), tt.args.pluginID, "")
require.Equal(t, tt.err, err)
})
}
@@ -280,7 +314,7 @@ func TestAliasSupport(t *testing.T) {
pluginIdOld := "plugin-old"
pluginIdOld2 := "plugin-old2"
p, exists := i.Plugin(ctx, pluginIdNew)
p, exists := i.Plugin(ctx, pluginIdNew, "")
require.False(t, exists)
require.Nil(t, p)
@@ -294,17 +328,17 @@ func TestAliasSupport(t *testing.T) {
require.NoError(t, err)
// Can lookup by the new ID
found, exists := i.Plugin(ctx, pluginIdNew)
found, exists := i.Plugin(ctx, pluginIdNew, "")
require.True(t, exists)
require.Equal(t, pluginNew, found)
// Can lookup by the old ID
found, exists = i.Plugin(ctx, pluginIdOld)
found, exists = i.Plugin(ctx, pluginIdOld, "")
require.True(t, exists)
require.Equal(t, pluginNew, found)
// Can lookup by the other old ID
found, exists = i.Plugin(ctx, pluginIdOld2)
found, exists = i.Plugin(ctx, pluginIdOld2, "")
require.True(t, exists)
require.Equal(t, pluginNew, found)
@@ -313,7 +347,7 @@ func TestAliasSupport(t *testing.T) {
ID: pluginIdOld,
}}
require.NoError(t, i.Add(ctx, pluginOld))
found, exists = i.Plugin(ctx, pluginIdOld)
found, exists = i.Plugin(ctx, pluginIdOld, "")
require.True(t, exists)
require.Equal(t, pluginOld, found)
})
+1 -1
View File
@@ -16,7 +16,7 @@ schemas: [{
// grafana.com, then the plugin `id` has to follow the naming
// conventions.
id: string & strings.MinRunes(1)
id: =~"^([0-9a-z]+\\-([0-9a-z]+\\-)?(\(strings.Join([ for t in _types {t}], "|"))))|(alertGroups|alertlist|annolist|barchart|bargauge|candlestick|canvas|dashlist|debug|datagrid|gauge|geomap|gettingstarted|graph|heatmap|histogram|icon|live|logs|news|nodeGraph|piechart|pluginlist|stat|state-timeline|status-history|table|table-old|text|timeseries|trend|traces|welcome|xychart|alertmanager|cloudwatch|dashboard|elasticsearch|grafana|grafana-azure-monitor-datasource|grafana-cloud-monitoring-datasource|graphite|influxdb|jaeger|loki|mixed|mssql|mysql|opentsdb|postgres|prometheus|stackdriver|tempo|grafana-testdata-datasource|zipkin|phlare|parca)$"
id: =~"^([0-9a-z]+\\-([0-9a-z]+\\-)?(\(strings.Join([ for t in _types {t}], "|"))))|(alertGroups|alertlist|annolist|barchart|bargauge|candlestick|canvas|dashlist|debug|datagrid|gauge|geomap|gettingstarted|graph|heatmap|histogram|icon|live|logs|news|nodeGraph|piechart|pluginlist|stat|state-timeline|status-history|table|table-old|text|timeseries|trend|traces|welcome|xychart|alertmanager|cloudwatch|dashboard|elasticsearch|grafana|grafana-azure-monitor-datasource|stackdriver|graphite|influxdb|jaeger|loki|mixed|mssql|mysql|opentsdb|postgres|prometheus|stackdriver|tempo|grafana-testdata-datasource|zipkin|phlare|parca)$"
// An alias is useful when migrating from one plugin id to another (rebranding etc)
// This should be used sparingly, and is currently only supported though a hardcoded checklist
@@ -0,0 +1,5 @@
This package supports the [Feature toggle admin page](https://grafana.com/docs/grafana/latest/administration/feature-toggles/) feature.
In order to update feature toggles through the app, the PATCH handler calls a webhook that should update Grafana's configuration and restarts the instance.
For local development, set the app mode to `development` by adding `app_mode = development` to the top level of your Grafana .ini file.
+37 -5
View File
@@ -51,6 +51,7 @@ func (b *FeatureFlagAPIBuilder) getResolvedToggleState(ctx context.Context) v0al
toggle := v0alpha1.ToggleStatus{
Name: name,
Description: f.Description, // simplify the UI changes
Stage: f.Stage.String(),
Enabled: state.Enabled[name],
Writeable: b.features.IsEditableFromAdminPage(name),
Source: startupRef,
@@ -76,6 +77,17 @@ func (b *FeatureFlagAPIBuilder) getResolvedToggleState(ctx context.Context) v0al
return state
}
func (b *FeatureFlagAPIBuilder) userCanRead(ctx context.Context, u *user.SignedInUser) bool {
if u == nil {
u, _ = appcontext.User(ctx)
if u == nil {
return false
}
}
ok, err := b.accessControl.Evaluate(ctx, u, ac.EvalPermission(ac.ActionFeatureManagementRead))
return ok && err == nil
}
func (b *FeatureFlagAPIBuilder) userCanWrite(ctx context.Context, u *user.SignedInUser) bool {
if u == nil {
u, _ = appcontext.User(ctx)
@@ -93,7 +105,24 @@ func (b *FeatureFlagAPIBuilder) handleCurrentStatus(w http.ResponseWriter, r *ht
return
}
// Check if the user can access toggle info
ctx := r.Context()
user, err := appcontext.User(ctx)
if err != nil {
errhttp.Write(ctx, err, w)
return
}
if !b.userCanRead(ctx, user) {
err = errutil.Unauthorized("featuretoggle.canNotRead",
errutil.WithPublicMessage("missing read permission")).Errorf("user %s does not have read permissions", user.Login)
errhttp.Write(ctx, err, w)
return
}
// Write the state to the response body
state := b.getResolvedToggleState(r.Context())
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(state)
}
@@ -101,7 +130,9 @@ func (b *FeatureFlagAPIBuilder) handleCurrentStatus(w http.ResponseWriter, r *ht
func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !b.features.IsFeatureEditingAllowed() {
errhttp.Write(ctx, fmt.Errorf("feature editing is not enabled"), w)
err := errutil.Forbidden("featuretoggle.disabled",
errutil.WithPublicMessage("feature toggles are read-only")).Errorf("feature toggles are not writeable due to missing configuration")
errhttp.Write(ctx, err, w)
return
}
@@ -113,7 +144,7 @@ func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *htt
if !b.userCanWrite(ctx, user) {
err = errutil.Unauthorized("featuretoggle.canNotWrite",
errutil.WithPublicMessage("missing write permission"))
errutil.WithPublicMessage("missing write permission")).Errorf("user %s does not have write permissions", user.Login)
errhttp.Write(ctx, err, w)
return
}
@@ -127,7 +158,7 @@ func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *htt
if len(request.Toggles) > 0 {
err = errutil.BadRequest("featuretoggle.badRequest",
errutil.WithPublicMessage("can only path the enabled section"))
errutil.WithPublicMessage("can only patch the enabled section")).Errorf("request payload included properties in the read-only Toggles section")
errhttp.Write(ctx, err, w)
return
}
@@ -138,7 +169,7 @@ func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *htt
if current != v {
if !b.features.IsEditableFromAdminPage(k) {
err = errutil.BadRequest("featuretoggle.badRequest",
errutil.WithPublicMessage("can not edit toggle: "+k))
errutil.WithPublicMessage("invalid toggle passed in")).Errorf("can not edit toggle %s", k)
errhttp.Write(ctx, err, w)
w.WriteHeader(http.StatusBadRequest)
return
@@ -158,7 +189,8 @@ func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *htt
}
err = sendWebhookUpdate(b.features.Settings, payload)
if err != nil {
if err != nil && b.cfg.Env != setting.Dev {
err = errutil.Internal("featuretoggle.webhookFailure", errutil.WithPublicMessage("an error occurred while updating feeature toggles")).Errorf("webhook error: %w", err)
errhttp.Write(ctx, err, w)
return
}
@@ -0,0 +1,460 @@
package featuretoggle
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1"
"github.com/grafana/grafana/pkg/infra/appcontext"
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
)
func TestGetFeatureToggles(t *testing.T) {
t.Run("fails without adequate permissions", func(t *testing.T) {
features := featuremgmt.WithFeatureManager(setting.FeatureMgmtSettings{}, []*featuremgmt.FeatureFlag{{
// Add this here to ensure the feature works as expected during tests
Name: featuremgmt.FlagFeatureToggleAdminPage,
Stage: featuremgmt.FeatureStageGeneralAvailability,
}})
b := NewFeatureFlagAPIBuilder(features, actest.FakeAccessControl{ExpectedEvaluate: false}, &setting.Cfg{})
callGetWith(t, b, http.StatusUnauthorized)
})
t.Run("should be able to get feature toggles", func(t *testing.T) {
features := []*featuremgmt.FeatureFlag{
{
Name: "toggle1",
Stage: featuremgmt.FeatureStageGeneralAvailability,
}, {
Name: "toggle2",
Stage: featuremgmt.FeatureStageGeneralAvailability,
},
}
disabled := []string{"toggle2"}
b := newTestAPIBuilder(t, features, disabled, setting.FeatureMgmtSettings{})
result := callGetWith(t, b, http.StatusOK)
assert.Len(t, result.Toggles, 2)
t1, _ := findResult(t, result, "toggle1")
assert.True(t, t1.Enabled)
t2, _ := findResult(t, result, "toggle2")
assert.False(t, t2.Enabled)
})
t.Run("toggles hidden by config are not present in the response", func(t *testing.T) {
features := []*featuremgmt.FeatureFlag{
{
Name: "toggle1",
Stage: featuremgmt.FeatureStageGeneralAvailability,
}, {
Name: "toggle2",
Stage: featuremgmt.FeatureStageGeneralAvailability,
},
}
settings := setting.FeatureMgmtSettings{
HiddenToggles: map[string]struct{}{"toggle1": {}},
}
b := newTestAPIBuilder(t, features, []string{}, settings)
result := callGetWith(t, b, http.StatusOK)
assert.Len(t, result.Toggles, 1)
assert.Equal(t, "toggle2", result.Toggles[0].Name)
})
t.Run("toggles that are read-only by config have the readOnly field set", func(t *testing.T) {
features := []*featuremgmt.FeatureFlag{
{
Name: "toggle1",
Stage: featuremgmt.FeatureStageGeneralAvailability,
}, {
Name: "toggle2",
Stage: featuremgmt.FeatureStageGeneralAvailability,
},
}
disabled := []string{"toggle2"}
settings := setting.FeatureMgmtSettings{
HiddenToggles: map[string]struct{}{"toggle1": {}},
ReadOnlyToggles: map[string]struct{}{"toggle2": {}},
AllowEditing: true,
UpdateWebhook: "bogus",
}
b := newTestAPIBuilder(t, features, disabled, settings)
result := callGetWith(t, b, http.StatusOK)
assert.Len(t, result.Toggles, 1)
assert.Equal(t, "toggle2", result.Toggles[0].Name)
assert.False(t, result.Toggles[0].Writeable)
})
t.Run("feature toggle defailts", func(t *testing.T) {
features := []*featuremgmt.FeatureFlag{
{
Name: "toggle1",
Stage: featuremgmt.FeatureStageUnknown,
}, {
Name: "toggle2",
Stage: featuremgmt.FeatureStageExperimental,
}, {
Name: "toggle3",
Stage: featuremgmt.FeatureStagePrivatePreview,
}, {
Name: "toggle4",
Stage: featuremgmt.FeatureStagePublicPreview,
AllowSelfServe: true,
}, {
Name: "toggle5",
Stage: featuremgmt.FeatureStageGeneralAvailability,
AllowSelfServe: true,
}, {
Name: "toggle6",
Stage: featuremgmt.FeatureStageDeprecated,
AllowSelfServe: true,
}, {
Name: "toggle7",
Stage: featuremgmt.FeatureStageGeneralAvailability,
AllowSelfServe: false,
},
}
t.Run("unknown, experimental, and private preview toggles are hidden by default", func(t *testing.T) {
b := newTestAPIBuilder(t, features, []string{}, setting.FeatureMgmtSettings{})
result := callGetWith(t, b, http.StatusOK)
assert.Len(t, result.Toggles, 4)
_, ok := findResult(t, result, "toggle1")
assert.False(t, ok)
_, ok = findResult(t, result, "toggle2")
assert.False(t, ok)
_, ok = findResult(t, result, "toggle3")
assert.False(t, ok)
})
t.Run("only public preview and GA with AllowSelfServe are writeable", func(t *testing.T) {
settings := setting.FeatureMgmtSettings{
AllowEditing: true,
UpdateWebhook: "bogus",
}
b := newTestAPIBuilder(t, features, []string{}, settings)
result := callGetWith(t, b, http.StatusOK)
t4, ok := findResult(t, result, "toggle4")
assert.True(t, ok)
assert.True(t, t4.Writeable)
t5, ok := findResult(t, result, "toggle5")
assert.True(t, ok)
assert.True(t, t5.Writeable)
t6, ok := findResult(t, result, "toggle6")
assert.True(t, ok)
assert.True(t, t6.Writeable)
})
t.Run("all toggles are read-only when server is misconfigured", func(t *testing.T) {
settings := setting.FeatureMgmtSettings{
AllowEditing: false,
UpdateWebhook: "",
}
b := newTestAPIBuilder(t, features, []string{}, settings)
result := callGetWith(t, b, http.StatusOK)
assert.Len(t, result.Toggles, 4)
t4, ok := findResult(t, result, "toggle4")
assert.True(t, ok)
assert.False(t, t4.Writeable)
t5, ok := findResult(t, result, "toggle5")
assert.True(t, ok)
assert.False(t, t5.Writeable)
t6, ok := findResult(t, result, "toggle6")
assert.True(t, ok)
assert.False(t, t6.Writeable)
})
})
}
func TestSetFeatureToggles(t *testing.T) {
t.Run("fails when the user doesn't have write permissions", func(t *testing.T) {
s := setting.FeatureMgmtSettings{
AllowEditing: true,
UpdateWebhook: "random",
}
features := featuremgmt.WithFeatureManager(s, []*featuremgmt.FeatureFlag{{
// Add this here to ensure the feature works as expected during tests
Name: featuremgmt.FlagFeatureToggleAdminPage,
Stage: featuremgmt.FeatureStageGeneralAvailability,
}})
b := NewFeatureFlagAPIBuilder(features, actest.FakeAccessControl{ExpectedEvaluate: false}, &setting.Cfg{})
msg := callPatchWith(t, b, v0alpha1.ResolvedToggleState{}, http.StatusUnauthorized)
assert.Equal(t, "missing write permission", msg)
})
t.Run("fails when update toggle url is not set", func(t *testing.T) {
s := setting.FeatureMgmtSettings{
AllowEditing: true,
}
b := newTestAPIBuilder(t, nil, []string{}, s)
msg := callPatchWith(t, b, v0alpha1.ResolvedToggleState{}, http.StatusForbidden)
assert.Equal(t, "feature toggles are read-only", msg)
})
t.Run("fails with non-existent toggle", func(t *testing.T) {
features := []*featuremgmt.FeatureFlag{
{
Name: "toggle1",
Stage: featuremgmt.FeatureStageGeneralAvailability,
}, {
Name: "toggle2",
Stage: featuremgmt.FeatureStageGeneralAvailability,
},
}
disabled := []string{"toggle2"}
update := v0alpha1.ResolvedToggleState{
Enabled: map[string]bool{
"toggle3": true,
},
}
s := setting.FeatureMgmtSettings{
AllowEditing: true,
UpdateWebhook: "random",
}
b := newTestAPIBuilder(t, features, disabled, s)
msg := callPatchWith(t, b, update, http.StatusBadRequest)
assert.Equal(t, "invalid toggle passed in", msg)
})
t.Run("fails with read-only toggles", func(t *testing.T) {
features := []*featuremgmt.FeatureFlag{
{
Name: featuremgmt.FlagFeatureToggleAdminPage,
Stage: featuremgmt.FeatureStageGeneralAvailability,
}, {
Name: "toggle2",
Stage: featuremgmt.FeatureStagePublicPreview,
}, {
Name: "toggle3",
Stage: featuremgmt.FeatureStageGeneralAvailability,
},
}
disabled := []string{"toggle2", "toggle3"}
s := setting.FeatureMgmtSettings{
AllowEditing: true,
UpdateWebhook: "random",
ReadOnlyToggles: map[string]struct{}{
"toggle3": {},
},
}
t.Run("because it is the feature toggle admin page toggle", func(t *testing.T) {
update := v0alpha1.ResolvedToggleState{
Enabled: map[string]bool{
featuremgmt.FlagFeatureToggleAdminPage: true,
},
}
b := newTestAPIBuilder(t, features, disabled, s)
callPatchWith(t, b, update, http.StatusNotModified)
})
t.Run("because it is not GA or Deprecated", func(t *testing.T) {
update := v0alpha1.ResolvedToggleState{
Enabled: map[string]bool{
"toggle2": true,
},
}
b := newTestAPIBuilder(t, features, disabled, s)
msg := callPatchWith(t, b, update, http.StatusBadRequest)
assert.Equal(t, "invalid toggle passed in", msg)
})
t.Run("because it is configured to be read-only", func(t *testing.T) {
update := v0alpha1.ResolvedToggleState{
Enabled: map[string]bool{
"toggle2": true,
},
}
b := newTestAPIBuilder(t, features, disabled, s)
msg := callPatchWith(t, b, update, http.StatusBadRequest)
assert.Equal(t, "invalid toggle passed in", msg)
})
})
t.Run("when all conditions met", func(t *testing.T) {
features := []*featuremgmt.FeatureFlag{
{
Name: featuremgmt.FlagFeatureToggleAdminPage,
Stage: featuremgmt.FeatureStageGeneralAvailability,
}, {
Name: "toggle2",
Stage: featuremgmt.FeatureStagePublicPreview,
}, {
Name: "toggle3",
Stage: featuremgmt.FeatureStageGeneralAvailability,
}, {
Name: "toggle4",
Stage: featuremgmt.FeatureStageGeneralAvailability,
AllowSelfServe: true,
}, {
Name: "toggle5",
Stage: featuremgmt.FeatureStageDeprecated,
AllowSelfServe: true,
},
}
disabled := []string{"toggle2", "toggle3", "toggle4"}
s := setting.FeatureMgmtSettings{
AllowEditing: true,
UpdateWebhook: "random",
UpdateWebhookToken: "token",
ReadOnlyToggles: map[string]struct{}{
"toggle3": {},
},
}
update := v0alpha1.ResolvedToggleState{
Enabled: map[string]bool{
"toggle4": true,
"toggle5": false,
},
}
t.Run("fail when webhook request is not successful", func(t *testing.T) {
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
}))
defer webhookServer.Close()
s.UpdateWebhook = webhookServer.URL
b := newTestAPIBuilder(t, features, disabled, s)
msg := callPatchWith(t, b, update, http.StatusInternalServerError)
assert.Equal(t, "an error occurred while updating feeature toggles", msg)
})
t.Run("succeed when webhook request is not successful but app is in dev mode", func(t *testing.T) {
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
}))
defer webhookServer.Close()
s.UpdateWebhook = webhookServer.URL
b := newTestAPIBuilder(t, features, disabled, s)
b.cfg.Env = setting.Dev
callPatchWith(t, b, update, http.StatusOK)
})
t.Run("succeed when webhook request is successful", func(t *testing.T) {
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "Bearer "+s.UpdateWebhookToken, r.Header.Get("Authorization"))
var req featuremgmt.FeatureToggleWebhookPayload
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
assert.Equal(t, "true", req.FeatureToggles["toggle4"])
assert.Equal(t, "false", req.FeatureToggles["toggle5"])
w.WriteHeader(http.StatusOK)
}))
defer webhookServer.Close()
s.UpdateWebhook = webhookServer.URL
b := newTestAPIBuilder(t, features, disabled, s)
msg := callPatchWith(t, b, update, http.StatusOK)
assert.Equal(t, "feature toggles updated successfully", msg)
})
})
}
func findResult(t *testing.T, result v0alpha1.ResolvedToggleState, name string) (v0alpha1.ToggleStatus, bool) {
t.Helper()
for _, t := range result.Toggles {
if t.Name == name {
return t, true
}
}
return v0alpha1.ToggleStatus{}, false
}
func callGetWith(t *testing.T, b *FeatureFlagAPIBuilder, expectedCode int) v0alpha1.ResolvedToggleState {
w := response.CreateNormalResponse(http.Header{}, []byte{}, 0)
req := &http.Request{
Method: "GET",
Header: http.Header{},
}
req.Header.Add("content-type", "application/json")
req = req.WithContext(appcontext.WithUser(req.Context(), &user.SignedInUser{}))
b.handleCurrentStatus(w, req)
rts := v0alpha1.ResolvedToggleState{}
require.NoError(t, json.Unmarshal(w.Body(), &rts))
require.Equal(t, expectedCode, w.Status())
// Tests don't expect the feature toggle admin page feature to be present, so remove them from the resolved toggle state
for i, t := range rts.Toggles {
if t.Name == "featureToggleAdminPage" {
rts.Toggles = append(rts.Toggles[0:i], rts.Toggles[i+1:]...)
}
}
return rts
}
func callPatchWith(t *testing.T, b *FeatureFlagAPIBuilder, update v0alpha1.ResolvedToggleState, expectedCode int) string {
w := response.CreateNormalResponse(http.Header{}, []byte{}, 0)
body, err := json.Marshal(update)
require.NoError(t, err)
req := &http.Request{
Method: "PATCH",
Body: io.NopCloser(bytes.NewReader(body)),
Header: http.Header{},
}
req.Header.Add("content-type", "application/json")
req = req.WithContext(appcontext.WithUser(req.Context(), &user.SignedInUser{}))
b.handleCurrentStatus(w, req)
require.NotNil(t, w.Body())
require.Equal(t, expectedCode, w.Status())
// Extract the public facing message if this is an error
if w.Status() > 399 {
res := map[string]any{}
require.NoError(t, json.Unmarshal(w.Body(), &res))
return res["message"].(string)
}
return string(w.Body())
}
func newTestAPIBuilder(
t *testing.T,
serverFeatures []*featuremgmt.FeatureFlag,
disabled []string, // the flags that are disabled
settings setting.FeatureMgmtSettings,
) *FeatureFlagAPIBuilder {
t.Helper()
features := featuremgmt.WithFeatureManager(settings, append([]*featuremgmt.FeatureFlag{{
// Add this here to ensure the feature works as expected during tests
Name: featuremgmt.FlagFeatureToggleAdminPage,
Stage: featuremgmt.FeatureStageGeneralAvailability,
}}, serverFeatures...), disabled...)
return NewFeatureFlagAPIBuilder(features, actest.FakeAccessControl{ExpectedEvaluate: true}, &setting.Cfg{})
}
+27 -38
View File
@@ -3,7 +3,7 @@ package featuretoggle
import (
"context"
"fmt"
"time"
"sync"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -27,18 +27,16 @@ var (
type featuresStorage struct {
resource *common.ResourceInfo
tableConverter rest.TableConvertor
features []featuremgmt.FeatureFlag
startup int64
features *v0alpha1.FeatureList
featuresOnce sync.Once
}
// NOTE! this does not depend on config or any system state!
// In the future, the existence of features (and their properties) can be defined dynamically
func NewFeaturesStorage(features []featuremgmt.FeatureFlag) *featuresStorage {
func NewFeaturesStorage() *featuresStorage {
resourceInfo := v0alpha1.FeatureResourceInfo
return &featuresStorage{
startup: time.Now().UnixMilli(),
resource: &resourceInfo,
features: features,
tableConverter: utils.NewTableConverter(
resourceInfo.GroupResource(),
[]metav1.TableColumnDefinition{
@@ -82,44 +80,35 @@ func (s *featuresStorage) ConvertToTable(ctx context.Context, object runtime.Obj
return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
}
func (s *featuresStorage) init() {
s.featuresOnce.Do(func() {
rv := "1"
features, _ := featuremgmt.GetEmbeddedFeatureList()
for _, feature := range features.Items {
if feature.ResourceVersion > rv {
rv = feature.ResourceVersion
}
}
features.ResourceVersion = rv
s.features = &features
})
}
func (s *featuresStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
flags := &v0alpha1.FeatureList{
ListMeta: metav1.ListMeta{
ResourceVersion: fmt.Sprintf("%d", s.startup),
},
s.init()
if s.features == nil {
return nil, fmt.Errorf("error loading embedded features")
}
for _, flag := range s.features {
flags.Items = append(flags.Items, toK8sForm(flag))
}
return flags, nil
return s.features, nil
}
func (s *featuresStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
for _, flag := range s.features {
if name == flag.Name {
obj := toK8sForm(flag)
return &obj, nil
s.init()
for idx, flag := range s.features.Items {
if flag.Name == name {
return &s.features.Items[idx], nil
}
}
return nil, fmt.Errorf("not found")
}
func toK8sForm(flag featuremgmt.FeatureFlag) v0alpha1.Feature {
return v0alpha1.Feature{
ObjectMeta: metav1.ObjectMeta{
Name: flag.Name,
CreationTimestamp: metav1.NewTime(flag.Created),
},
Spec: v0alpha1.FeatureSpec{
Description: flag.Description,
Stage: flag.Stage.String(),
Owner: string(flag.Owner),
AllowSelfServe: flag.AllowSelfServe,
HideFromAdminPage: flag.HideFromAdminPage,
HideFromDocs: flag.HideFromDocs,
FrontendOnly: flag.FrontendOnly,
RequiresDevMode: flag.RequiresDevMode,
RequiresRestart: flag.RequiresRestart,
},
}
}

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